Updating trunk VERSION from 2139.0 to 2140.0
[chromium-blink-merge.git] / ui / file_manager / gallery / js / mosaic_mode.js
blob78af4d850dcf862ddb093100c611830646413fc1
1 // Copyright 2014 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
5 'use strict';
7 /**
8 * @param {Element} container Content container.
9 * @param {cr.ui.ArrayDataModel} dataModel Data model.
10 * @param {cr.ui.ListSelectionModel} selectionModel Selection model.
11 * @param {VolumeManagerWrapper} volumeManager Volume manager.
12 * @param {function} toggleMode Function to switch to the Slide mode.
13 * @constructor
15 function MosaicMode(
16 container, dataModel, selectionModel, volumeManager, toggleMode) {
17 this.mosaic_ = new Mosaic(
18 container.ownerDocument, dataModel, selectionModel, volumeManager);
19 container.appendChild(this.mosaic_);
21 this.toggleMode_ = toggleMode;
22 this.mosaic_.addEventListener('dblclick', this.toggleMode_);
23 this.showingTimeoutID_ = null;
26 /**
27 * @return {Mosaic} The mosaic control.
29 MosaicMode.prototype.getMosaic = function() { return this.mosaic_; };
31 /**
32 * @return {string} Mode name.
34 MosaicMode.prototype.getName = function() { return 'mosaic'; };
36 /**
37 * @return {string} Mode title.
39 MosaicMode.prototype.getTitle = function() { return 'GALLERY_MOSAIC'; };
41 /**
42 * Execute an action (this mode has no busy state).
43 * @param {function} action Action to execute.
45 MosaicMode.prototype.executeWhenReady = function(action) { action(); };
47 /**
48 * @return {boolean} Always true (no toolbar fading in this mode).
50 MosaicMode.prototype.hasActiveTool = function() { return true; };
52 /**
53 * Keydown handler.
55 * @param {Event} event Event.
57 MosaicMode.prototype.onKeyDown = function(event) {
58 switch (util.getKeyModifiers(event) + event.keyIdentifier) {
59 case 'Enter':
60 if (!document.activeElement ||
61 document.activeElement.localName !== 'button') {
62 this.toggleMode_();
63 event.preventDefault();
65 return;
67 this.mosaic_.onKeyDown(event);
70 ////////////////////////////////////////////////////////////////////////////////
72 /**
73 * Mosaic control.
75 * @param {Document} document Document.
76 * @param {cr.ui.ArrayDataModel} dataModel Data model.
77 * @param {cr.ui.ListSelectionModel} selectionModel Selection model.
78 * @param {VolumeManagerWrapper} volumeManager Volume manager.
79 * @return {Element} Mosaic element.
80 * @constructor
82 function Mosaic(document, dataModel, selectionModel, volumeManager) {
83 var self = document.createElement('div');
84 Mosaic.decorate(self, dataModel, selectionModel, volumeManager);
85 return self;
88 /**
89 * Inherits from HTMLDivElement.
91 Mosaic.prototype.__proto__ = HTMLDivElement.prototype;
93 /**
94 * Default layout delay in ms.
95 * @const
96 * @type {number}
98 Mosaic.LAYOUT_DELAY = 200;
101 * Smooth scroll animation duration when scrolling using keyboard or
102 * clicking on a partly visible tile. In ms.
103 * @const
104 * @type {number}
106 Mosaic.ANIMATED_SCROLL_DURATION = 500;
109 * Decorates a Mosaic instance.
111 * @param {Mosaic} self Self pointer.
112 * @param {cr.ui.ArrayDataModel} dataModel Data model.
113 * @param {cr.ui.ListSelectionModel} selectionModel Selection model.
114 * @param {VolumeManagerWrapper} volumeManager Volume manager.
116 Mosaic.decorate = function(
117 self, dataModel, selectionModel, volumeManager) {
118 self.__proto__ = Mosaic.prototype;
119 self.className = 'mosaic';
121 self.dataModel_ = dataModel;
122 self.selectionModel_ = selectionModel;
123 self.volumeManager_ = volumeManager;
125 // Initialization is completed lazily on the first call to |init|.
129 * Initializes the mosaic element.
131 Mosaic.prototype.init = function() {
132 if (this.tiles_)
133 return; // Already initialized, nothing to do.
135 this.layoutModel_ = new Mosaic.Layout();
136 this.onResize_();
138 this.selectionController_ =
139 new Mosaic.SelectionController(this.selectionModel_, this.layoutModel_);
141 this.tiles_ = [];
142 for (var i = 0; i !== this.dataModel_.length; i++) {
143 var locationInfo =
144 this.volumeManager_.getLocationInfo(this.dataModel_.item(i).getEntry());
145 this.tiles_.push(
146 new Mosaic.Tile(this, this.dataModel_.item(i), locationInfo));
149 this.selectionModel_.selectedIndexes.forEach(function(index) {
150 this.tiles_[index].select(true);
151 }.bind(this));
153 this.initTiles_(this.tiles_);
155 // The listeners might be called while some tiles are still loading.
156 this.initListeners_();
160 * @return {boolean} Whether mosaic is initialized.
162 Mosaic.prototype.isInitialized = function() {
163 return !!this.tiles_;
167 * Starts listening to events.
169 * We keep listening to events even when the mosaic is hidden in order to
170 * keep the layout up to date.
172 * @private
174 Mosaic.prototype.initListeners_ = function() {
175 this.ownerDocument.defaultView.addEventListener(
176 'resize', this.onResize_.bind(this));
178 var mouseEventBound = this.onMouseEvent_.bind(this);
179 this.addEventListener('mousemove', mouseEventBound);
180 this.addEventListener('mousedown', mouseEventBound);
181 this.addEventListener('mouseup', mouseEventBound);
182 this.addEventListener('scroll', this.onScroll_.bind(this));
184 this.selectionModel_.addEventListener('change', this.onSelection_.bind(this));
185 this.selectionModel_.addEventListener('leadIndexChange',
186 this.onLeadChange_.bind(this));
188 this.dataModel_.addEventListener('splice', this.onSplice_.bind(this));
189 this.dataModel_.addEventListener('content', this.onContentChange_.bind(this));
193 * Smoothly scrolls the container to the specified position using
194 * f(x) = sqrt(x) speed function normalized to animation duration.
195 * @param {number} targetPosition Horizontal scroll position in pixels.
197 Mosaic.prototype.animatedScrollTo = function(targetPosition) {
198 if (this.scrollAnimation_) {
199 webkitCancelAnimationFrame(this.scrollAnimation_);
200 this.scrollAnimation_ = null;
203 // Mouse move events are fired without touching the mouse because of scrolling
204 // the container. Therefore, these events have to be suppressed.
205 this.suppressHovering_ = true;
207 // Calculates integral area from t1 to t2 of f(x) = sqrt(x) dx.
208 var integral = function(t1, t2) {
209 return 2.0 / 3.0 * Math.pow(t2, 3.0 / 2.0) -
210 2.0 / 3.0 * Math.pow(t1, 3.0 / 2.0);
213 var delta = targetPosition - this.scrollLeft;
214 var factor = delta / integral(0, Mosaic.ANIMATED_SCROLL_DURATION);
215 var startTime = Date.now();
216 var lastPosition = 0;
217 var scrollOffset = this.scrollLeft;
219 var animationFrame = function() {
220 var position = Date.now() - startTime;
221 var step = factor *
222 integral(Math.max(0, Mosaic.ANIMATED_SCROLL_DURATION - position),
223 Math.max(0, Mosaic.ANIMATED_SCROLL_DURATION - lastPosition));
224 scrollOffset += step;
226 var oldScrollLeft = this.scrollLeft;
227 var newScrollLeft = Math.round(scrollOffset);
229 if (oldScrollLeft !== newScrollLeft)
230 this.scrollLeft = newScrollLeft;
232 if (step === 0 || this.scrollLeft !== newScrollLeft) {
233 this.scrollAnimation_ = null;
234 // Release the hovering lock after a safe delay to avoid hovering
235 // a tile because of altering |this.scrollLeft|.
236 setTimeout(function() {
237 if (!this.scrollAnimation_)
238 this.suppressHovering_ = false;
239 }.bind(this), 100);
240 } else {
241 // Continue the animation.
242 this.scrollAnimation_ = requestAnimationFrame(animationFrame);
245 lastPosition = position;
246 }.bind(this);
248 // Start the animation.
249 this.scrollAnimation_ = requestAnimationFrame(animationFrame);
253 * @return {Mosaic.Tile} Selected tile or undefined if no selection.
255 Mosaic.prototype.getSelectedTile = function() {
256 return this.tiles_ && this.tiles_[this.selectionModel_.selectedIndex];
260 * @param {number} index Tile index.
261 * @return {Rect} Tile's image rectangle.
263 Mosaic.prototype.getTileRect = function(index) {
264 var tile = this.tiles_[index];
265 return tile && tile.getImageRect();
269 * @param {number} index Tile index.
270 * Scroll the given tile into the viewport.
272 Mosaic.prototype.scrollIntoView = function(index) {
273 var tile = this.tiles_[index];
274 if (tile) tile.scrollIntoView();
278 * Initializes multiple tiles.
280 * @param {Array.<Mosaic.Tile>} tiles Array of tiles.
281 * @private
283 Mosaic.prototype.initTiles_ = function(tiles) {
284 for (var i = 0; i < tiles.length; i++) {
285 tiles[i].init();
290 * Reloads all tiles.
292 Mosaic.prototype.reload = function() {
293 this.layoutModel_.reset_();
294 this.tiles_.forEach(function(t) { t.markUnloaded(); });
295 this.initTiles_(this.tiles_);
299 * Layouts the tiles in the order of their indices.
301 * Starts where it last stopped (at #0 the first time).
302 * Stops when all tiles are processed or when the next tile is still loading.
304 Mosaic.prototype.layout = function() {
305 if (this.layoutTimer_) {
306 clearTimeout(this.layoutTimer_);
307 this.layoutTimer_ = null;
309 while (true) {
310 var index = this.layoutModel_.getTileCount();
311 if (index === this.tiles_.length)
312 break; // All tiles done.
313 var tile = this.tiles_[index];
314 if (!tile.isInitialized())
315 break; // Next layout will try to restart from here.
316 this.layoutModel_.add(tile, index + 1 === this.tiles_.length);
318 this.loadVisibleTiles_();
322 * Schedules the layout.
324 * @param {number=} opt_delay Delay in ms.
326 Mosaic.prototype.scheduleLayout = function(opt_delay) {
327 if (!this.layoutTimer_) {
328 this.layoutTimer_ = setTimeout(function() {
329 this.layoutTimer_ = null;
330 this.layout();
331 }.bind(this), opt_delay || 0);
336 * Resize handler.
338 * @private
340 Mosaic.prototype.onResize_ = function() {
341 this.layoutModel_.setViewportSize(this.clientWidth, this.clientHeight -
342 (Mosaic.Layout.PADDING_TOP + Mosaic.Layout.PADDING_BOTTOM));
343 this.scheduleLayout();
347 * Mouse event handler.
349 * @param {Event} event Event.
350 * @private
352 Mosaic.prototype.onMouseEvent_ = function(event) {
353 // Navigating with mouse, enable hover state.
354 if (!this.suppressHovering_)
355 this.classList.add('hover-visible');
357 if (event.type === 'mousemove')
358 return;
360 var index = -1;
361 for (var target = event.target;
362 target && (target !== this);
363 target = target.parentNode) {
364 if (target.classList.contains('mosaic-tile')) {
365 index = this.dataModel_.indexOf(target.getItem());
366 break;
369 this.selectionController_.handlePointerDownUp(event, index);
373 * Scroll handler.
374 * @private
376 Mosaic.prototype.onScroll_ = function() {
377 requestAnimationFrame(function() {
378 this.loadVisibleTiles_();
379 }.bind(this));
383 * Selection change handler.
385 * @param {Event} event Event.
386 * @private
388 Mosaic.prototype.onSelection_ = function(event) {
389 for (var i = 0; i !== event.changes.length; i++) {
390 var change = event.changes[i];
391 var tile = this.tiles_[change.index];
392 if (tile) tile.select(change.selected);
397 * Leads item change handler.
399 * @param {Event} event Event.
400 * @private
402 Mosaic.prototype.onLeadChange_ = function(event) {
403 var index = event.newValue;
404 if (index >= 0) {
405 var tile = this.tiles_[index];
406 if (tile) tile.scrollIntoView();
411 * Splice event handler.
413 * @param {Event} event Event.
414 * @private
416 Mosaic.prototype.onSplice_ = function(event) {
417 var index = event.index;
418 this.layoutModel_.invalidateFromTile_(index);
420 if (event.removed.length) {
421 for (var t = 0; t !== event.removed.length; t++) {
422 // If the layout for the tile has not done yet, the parent is null.
423 // And the layout will not be done after onSplice_ because it is removed
424 // from this.tiles_.
425 if (this.tiles_[index + t].parentNode)
426 this.removeChild(this.tiles_[index + t]);
429 this.tiles_.splice(index, event.removed.length);
430 this.scheduleLayout(Mosaic.LAYOUT_DELAY);
433 if (event.added.length) {
434 var newTiles = [];
435 for (var t = 0; t !== event.added.length; t++)
436 newTiles.push(new Mosaic.Tile(this, this.dataModel_.item(index + t)));
438 this.tiles_.splice.apply(this.tiles_, [index, 0].concat(newTiles));
439 this.initTiles_(newTiles);
440 this.scheduleLayout(Mosaic.LAYOUT_DELAY);
443 if (this.tiles_.length !== this.dataModel_.length)
444 console.error('Mosaic is out of sync');
448 * Content change handler.
450 * @param {Event} event Event.
451 * @private
453 Mosaic.prototype.onContentChange_ = function(event) {
454 if (!this.tiles_)
455 return;
457 if (!event.metadata)
458 return; // Thumbnail unchanged, nothing to do.
460 var index = this.dataModel_.indexOf(event.item);
461 if (index !== this.selectionModel_.selectedIndex)
462 console.error('Content changed for unselected item');
464 this.layoutModel_.invalidateFromTile_(index);
465 this.tiles_[index].init();
466 this.tiles_[index].unload();
467 this.tiles_[index].load(
468 Mosaic.Tile.LoadMode.HIGH_DPI,
469 this.scheduleLayout.bind(this, Mosaic.LAYOUT_DELAY));
473 * Keydown event handler.
475 * @param {Event} event Event.
476 * @return {boolean} True if the event has been consumed.
478 Mosaic.prototype.onKeyDown = function(event) {
479 this.selectionController_.handleKeyDown(event);
480 if (event.defaultPrevented) // Navigating with keyboard, hide hover state.
481 this.classList.remove('hover-visible');
482 return event.defaultPrevented;
486 * @return {boolean} True if the mosaic zoom effect can be applied. It is
487 * too slow if there are to many images.
488 * TODO(kaznacheev): Consider unloading the images that are out of the viewport.
490 Mosaic.prototype.canZoom = function() {
491 return this.tiles_.length < 100;
495 * Shows the mosaic.
497 Mosaic.prototype.show = function() {
498 var duration = ImageView.MODE_TRANSITION_DURATION;
499 if (this.canZoom()) {
500 // Fade in in parallel with the zoom effect.
501 this.setAttribute('visible', 'zooming');
502 } else {
503 // Mosaic is not animating but the large image is. Fade in the mosaic
504 // shortly before the large image animation is done.
505 duration -= 100;
507 this.showingTimeoutID_ = setTimeout(function() {
508 this.showingTimeoutID_ = null;
509 // Make the selection visible.
510 // If the mosaic is not animated it will start fading in now.
511 this.setAttribute('visible', 'normal');
512 this.loadVisibleTiles_();
513 }.bind(this), duration);
517 * Hides the mosaic.
519 Mosaic.prototype.hide = function() {
520 if (this.showingTimeoutID_ !== null) {
521 clearTimeout(this.showingTimeoutID_);
522 this.showingTimeoutID_ = null;
524 this.removeAttribute('visible');
528 * Checks if the mosaic view is visible.
529 * @return {boolean} True if visible, false otherwise.
530 * @private
532 Mosaic.prototype.isVisible_ = function() {
533 return this.hasAttribute('visible');
537 * Loads visible tiles. Ignores consecutive calls. Does not reload already
538 * loaded images.
539 * @private
541 Mosaic.prototype.loadVisibleTiles_ = function() {
542 if (this.loadVisibleTilesSuppressed_) {
543 this.loadVisibleTilesScheduled_ = true;
544 return;
547 this.loadVisibleTilesSuppressed_ = true;
548 this.loadVisibleTilesScheduled_ = false;
549 setTimeout(function() {
550 this.loadVisibleTilesSuppressed_ = false;
551 if (this.loadVisibleTilesScheduled_)
552 this.loadVisibleTiles_();
553 }.bind(this), 100);
555 // Tiles only in the viewport (visible).
556 var visibleRect = new Rect(0,
558 this.clientWidth,
559 this.clientHeight);
561 // Tiles in the viewport and also some distance on the left and right.
562 var renderableRect = new Rect(-this.clientWidth,
564 3 * this.clientWidth,
565 this.clientHeight);
567 // Unload tiles out of scope.
568 for (var index = 0; index < this.tiles_.length; index++) {
569 var tile = this.tiles_[index];
570 var imageRect = tile.getImageRect();
571 // Unload a thumbnail.
572 if (imageRect && !imageRect.intersects(renderableRect))
573 tile.unload();
576 // Load the visible tiles first.
577 var allVisibleLoaded = true;
578 // Show high-dpi only when the mosaic view is visible.
579 var loadMode = this.isVisible_() ? Mosaic.Tile.LoadMode.HIGH_DPI :
580 Mosaic.Tile.LoadMode.LOW_DPI;
581 for (var index = 0; index < this.tiles_.length; index++) {
582 var tile = this.tiles_[index];
583 var imageRect = tile.getImageRect();
584 // Load a thumbnail.
585 if (!tile.isLoading(loadMode) && !tile.isLoaded(loadMode) && imageRect &&
586 imageRect.intersects(visibleRect)) {
587 tile.load(loadMode, function() {});
588 allVisibleLoaded = false;
592 // Load also another, nearby, if the visible has been already loaded.
593 if (allVisibleLoaded) {
594 for (var index = 0; index < this.tiles_.length; index++) {
595 var tile = this.tiles_[index];
596 var imageRect = tile.getImageRect();
597 // Load a thumbnail.
598 if (!tile.isLoading() && !tile.isLoaded() && imageRect &&
599 imageRect.intersects(renderableRect)) {
600 tile.load(Mosaic.Tile.LoadMode.LOW_DPI, function() {});
607 * Applies reset the zoom transform.
609 * @param {Rect} tileRect Tile rectangle. Reset the transform if null.
610 * @param {Rect} imageRect Large image rectangle. Reset the transform if null.
611 * @param {boolean=} opt_instant True of the transition should be instant.
613 Mosaic.prototype.transform = function(tileRect, imageRect, opt_instant) {
614 if (opt_instant) {
615 this.style.webkitTransitionDuration = '0';
616 } else {
617 this.style.webkitTransitionDuration =
618 ImageView.MODE_TRANSITION_DURATION + 'ms';
621 if (this.canZoom() && tileRect && imageRect) {
622 var scaleX = imageRect.width / tileRect.width;
623 var scaleY = imageRect.height / tileRect.height;
624 var shiftX = (imageRect.left + imageRect.width / 2) -
625 (tileRect.left + tileRect.width / 2);
626 var shiftY = (imageRect.top + imageRect.height / 2) -
627 (tileRect.top + tileRect.height / 2);
628 this.style.webkitTransform =
629 'translate(' + shiftX * scaleX + 'px, ' + shiftY * scaleY + 'px)' +
630 'scaleX(' + scaleX + ') scaleY(' + scaleY + ')';
631 } else {
632 this.style.webkitTransform = '';
636 ////////////////////////////////////////////////////////////////////////////////
639 * Creates a selection controller that is to be used with grid.
640 * @param {cr.ui.ListSelectionModel} selectionModel The selection model to
641 * interact with.
642 * @param {Mosaic.Layout} layoutModel The layout model to use.
643 * @constructor
644 * @extends {!cr.ui.ListSelectionController}
646 Mosaic.SelectionController = function(selectionModel, layoutModel) {
647 cr.ui.ListSelectionController.call(this, selectionModel);
648 this.layoutModel_ = layoutModel;
652 * Extends cr.ui.ListSelectionController.
654 Mosaic.SelectionController.prototype.__proto__ =
655 cr.ui.ListSelectionController.prototype;
657 /** @override */
658 Mosaic.SelectionController.prototype.getLastIndex = function() {
659 return this.layoutModel_.getLaidOutTileCount() - 1;
662 /** @override */
663 Mosaic.SelectionController.prototype.getIndexBefore = function(index) {
664 return this.layoutModel_.getHorizontalAdjacentIndex(index, -1);
667 /** @override */
668 Mosaic.SelectionController.prototype.getIndexAfter = function(index) {
669 return this.layoutModel_.getHorizontalAdjacentIndex(index, 1);
672 /** @override */
673 Mosaic.SelectionController.prototype.getIndexAbove = function(index) {
674 return this.layoutModel_.getVerticalAdjacentIndex(index, -1);
677 /** @override */
678 Mosaic.SelectionController.prototype.getIndexBelow = function(index) {
679 return this.layoutModel_.getVerticalAdjacentIndex(index, 1);
682 ////////////////////////////////////////////////////////////////////////////////
685 * Mosaic layout.
687 * @param {string=} opt_mode Layout mode.
688 * @param {Mosaic.Density=} opt_maxDensity Layout density.
689 * @constructor
691 Mosaic.Layout = function(opt_mode, opt_maxDensity) {
692 this.mode_ = opt_mode || Mosaic.Layout.MODE_TENTATIVE;
693 this.maxDensity_ = opt_maxDensity || Mosaic.Density.createHighest();
694 this.reset_();
698 * Blank space at the top of the mosaic element. We do not do that in CSS
699 * to make transition effects easier.
701 Mosaic.Layout.PADDING_TOP = 50;
704 * Blank space at the bottom of the mosaic element.
706 Mosaic.Layout.PADDING_BOTTOM = 50;
709 * Horizontal and vertical spacing between images. Should be kept in sync
710 * with the style of .mosaic-item in gallery.css (= 2 * ( 4 + 1))
712 Mosaic.Layout.SPACING = 10;
715 * Margin for scrolling using keyboard. Distance between a selected tile
716 * and window border.
718 Mosaic.Layout.SCROLL_MARGIN = 30;
721 * Layout mode: commit to DOM immediately.
723 Mosaic.Layout.MODE_FINAL = 'final';
726 * Layout mode: do not commit layout to DOM until it is complete or the viewport
727 * overflows.
729 Mosaic.Layout.MODE_TENTATIVE = 'tentative';
732 * Layout mode: never commit layout to DOM.
734 Mosaic.Layout.MODE_DRY_RUN = 'dry_run';
737 * Resets the layout.
739 * @private
741 Mosaic.Layout.prototype.reset_ = function() {
742 this.columns_ = [];
743 this.newColumn_ = null;
744 this.density_ = Mosaic.Density.createLowest();
745 if (this.mode_ !== Mosaic.Layout.MODE_DRY_RUN) // DRY_RUN is sticky.
746 this.mode_ = Mosaic.Layout.MODE_TENTATIVE;
750 * @param {number} width Viewport width.
751 * @param {number} height Viewport height.
753 Mosaic.Layout.prototype.setViewportSize = function(width, height) {
754 this.viewportWidth_ = width;
755 this.viewportHeight_ = height;
756 this.reset_();
760 * @return {number} Total width of the layout.
762 Mosaic.Layout.prototype.getWidth = function() {
763 var lastColumn = this.getLastColumn_();
764 return lastColumn ? lastColumn.getRight() : 0;
768 * @return {number} Total height of the layout.
770 Mosaic.Layout.prototype.getHeight = function() {
771 var firstColumn = this.columns_[0];
772 return firstColumn ? firstColumn.getHeight() : 0;
776 * @return {Array.<Mosaic.Tile>} All tiles in the layout.
778 Mosaic.Layout.prototype.getTiles = function() {
779 return Array.prototype.concat.apply([],
780 this.columns_.map(function(c) { return c.getTiles(); }));
784 * @return {number} Total number of tiles added to the layout.
786 Mosaic.Layout.prototype.getTileCount = function() {
787 return this.getLaidOutTileCount() +
788 (this.newColumn_ ? this.newColumn_.getTileCount() : 0);
792 * @return {Mosaic.Column} The last column or null for empty layout.
793 * @private
795 Mosaic.Layout.prototype.getLastColumn_ = function() {
796 return this.columns_.length ? this.columns_[this.columns_.length - 1] : null;
800 * @return {number} Total number of tiles in completed columns.
802 Mosaic.Layout.prototype.getLaidOutTileCount = function() {
803 var lastColumn = this.getLastColumn_();
804 return lastColumn ? lastColumn.getNextTileIndex() : 0;
808 * Adds a tile to the layout.
810 * @param {Mosaic.Tile} tile The tile to be added.
811 * @param {boolean} isLast True if this tile is the last.
813 Mosaic.Layout.prototype.add = function(tile, isLast) {
814 var layoutQueue = [tile];
816 // There are two levels of backtracking in the layout algorithm.
817 // |Mosaic.Layout.density_| tracks the state of the 'global' backtracking
818 // which aims to use as much of the viewport space as possible.
819 // It starts with the lowest density and increases it until the layout
820 // fits into the viewport. If it does not fit even at the highest density,
821 // the layout continues with the highest density.
823 // |Mosaic.Column.density_| tracks the state of the 'local' backtracking
824 // which aims to avoid producing unnaturally looking columns.
825 // It starts with the current global density and decreases it until the column
826 // looks nice.
828 while (layoutQueue.length) {
829 if (!this.newColumn_) {
830 var lastColumn = this.getLastColumn_();
831 this.newColumn_ = new Mosaic.Column(
832 this.columns_.length,
833 lastColumn ? lastColumn.getNextRowIndex() : 0,
834 lastColumn ? lastColumn.getNextTileIndex() : 0,
835 lastColumn ? lastColumn.getRight() : 0,
836 this.viewportHeight_,
837 this.density_.clone());
840 this.newColumn_.add(layoutQueue.shift());
842 var isFinalColumn = isLast && !layoutQueue.length;
844 if (!this.newColumn_.prepareLayout(isFinalColumn))
845 continue; // Column is incomplete.
847 if (this.newColumn_.isSuboptimal()) {
848 layoutQueue = this.newColumn_.getTiles().concat(layoutQueue);
849 this.newColumn_.retryWithLowerDensity();
850 continue;
853 this.columns_.push(this.newColumn_);
854 this.newColumn_ = null;
856 if (this.mode_ === Mosaic.Layout.MODE_FINAL && isFinalColumn) {
857 this.commit_();
858 continue;
861 if (this.getWidth() > this.viewportWidth_) {
862 // Viewport completely filled.
863 if (this.density_.equals(this.maxDensity_)) {
864 // Max density reached, commit if tentative, just continue if dry run.
865 if (this.mode_ === Mosaic.Layout.MODE_TENTATIVE)
866 this.commit_();
867 continue;
870 // Rollback the entire layout, retry with higher density.
871 layoutQueue = this.getTiles().concat(layoutQueue);
872 this.columns_ = [];
873 this.density_.increase();
874 continue;
877 if (isFinalColumn && this.mode_ === Mosaic.Layout.MODE_TENTATIVE) {
878 // The complete tentative layout fits into the viewport.
879 var stretched = this.findHorizontalLayout_();
880 if (stretched)
881 this.columns_ = stretched.columns_;
882 // Center the layout in the viewport and commit.
883 this.commit_((this.viewportWidth_ - this.getWidth()) / 2,
884 (this.viewportHeight_ - this.getHeight()) / 2);
890 * Commits the tentative layout.
892 * @param {number=} opt_offsetX Horizontal offset.
893 * @param {number=} opt_offsetY Vertical offset.
894 * @private
896 Mosaic.Layout.prototype.commit_ = function(opt_offsetX, opt_offsetY) {
897 for (var i = 0; i !== this.columns_.length; i++) {
898 this.columns_[i].layout(opt_offsetX, opt_offsetY);
900 this.mode_ = Mosaic.Layout.MODE_FINAL;
904 * Finds the most horizontally stretched layout built from the same tiles.
906 * The main layout algorithm fills the entire available viewport height.
907 * If there is too few tiles this results in a layout that is unnaturally
908 * stretched in the vertical direction.
910 * This method tries a number of smaller heights and returns the most
911 * horizontally stretched layout that still fits into the viewport.
913 * @return {Mosaic.Layout} A horizontally stretched layout.
914 * @private
916 Mosaic.Layout.prototype.findHorizontalLayout_ = function() {
917 // If the layout aspect ratio is not dramatically different from
918 // the viewport aspect ratio then there is no need to optimize.
919 if (this.getWidth() / this.getHeight() >
920 this.viewportWidth_ / this.viewportHeight_ * 0.9)
921 return null;
923 var tiles = this.getTiles();
924 if (tiles.length === 1)
925 return null; // Single tile layout is always the same.
927 var tileHeights = tiles.map(function(t) { return t.getMaxContentHeight(); });
928 var minTileHeight = Math.min.apply(null, tileHeights);
930 for (var h = minTileHeight; h < this.viewportHeight_; h += minTileHeight) {
931 var layout = new Mosaic.Layout(
932 Mosaic.Layout.MODE_DRY_RUN, this.density_.clone());
933 layout.setViewportSize(this.viewportWidth_, h);
934 for (var t = 0; t !== tiles.length; t++)
935 layout.add(tiles[t], t + 1 === tiles.length);
937 if (layout.getWidth() <= this.viewportWidth_)
938 return layout;
941 return null;
945 * Invalidates the layout after the given tile was modified (added, deleted or
946 * changed dimensions).
948 * @param {number} index Tile index.
949 * @private
951 Mosaic.Layout.prototype.invalidateFromTile_ = function(index) {
952 var columnIndex = this.getColumnIndexByTile_(index);
953 if (columnIndex < 0)
954 return; // Index not in the layout, probably already invalidated.
956 if (this.columns_[columnIndex].getLeft() >= this.viewportWidth_) {
957 // The columns to the right cover the entire viewport width, so there is no
958 // chance that the modified layout would fit into the viewport.
959 // No point in restarting the entire layout, keep the columns to the right.
960 console.assert(this.mode_ === Mosaic.Layout.MODE_FINAL,
961 'Expected FINAL layout mode');
962 this.columns_ = this.columns_.slice(0, columnIndex);
963 this.newColumn_ = null;
964 } else {
965 // There is a chance that the modified layout would fit into the viewport.
966 this.reset_();
967 this.mode_ = Mosaic.Layout.MODE_TENTATIVE;
972 * Gets the index of the tile to the left or to the right from the given tile.
974 * @param {number} index Tile index.
975 * @param {number} direction -1 for left, 1 for right.
976 * @return {number} Adjacent tile index.
978 Mosaic.Layout.prototype.getHorizontalAdjacentIndex = function(
979 index, direction) {
980 var column = this.getColumnIndexByTile_(index);
981 if (column < 0) {
982 console.error('Cannot find column for tile #' + index);
983 return -1;
986 var row = this.columns_[column].getRowByTileIndex(index);
987 if (!row) {
988 console.error('Cannot find row for tile #' + index);
989 return -1;
992 var sameRowNeighbourIndex = index + direction;
993 if (row.hasTile(sameRowNeighbourIndex))
994 return sameRowNeighbourIndex;
996 var adjacentColumn = column + direction;
997 if (adjacentColumn < 0 || adjacentColumn === this.columns_.length)
998 return -1;
1000 return this.columns_[adjacentColumn].
1001 getEdgeTileIndex_(row.getCenterY(), -direction);
1005 * Gets the index of the tile to the top or to the bottom from the given tile.
1007 * @param {number} index Tile index.
1008 * @param {number} direction -1 for above, 1 for below.
1009 * @return {number} Adjacent tile index.
1011 Mosaic.Layout.prototype.getVerticalAdjacentIndex = function(
1012 index, direction) {
1013 var column = this.getColumnIndexByTile_(index);
1014 if (column < 0) {
1015 console.error('Cannot find column for tile #' + index);
1016 return -1;
1019 var row = this.columns_[column].getRowByTileIndex(index);
1020 if (!row) {
1021 console.error('Cannot find row for tile #' + index);
1022 return -1;
1025 // Find the first item in the next row, or the last item in the previous row.
1026 var adjacentRowNeighbourIndex =
1027 row.getEdgeTileIndex_(direction) + direction;
1029 if (adjacentRowNeighbourIndex < 0 ||
1030 adjacentRowNeighbourIndex > this.getTileCount() - 1)
1031 return -1;
1033 if (!this.columns_[column].hasTile(adjacentRowNeighbourIndex)) {
1034 // It is not in the current column, so return it.
1035 return adjacentRowNeighbourIndex;
1036 } else {
1037 // It is in the current column, so we have to find optically the closest
1038 // tile in the adjacent row.
1039 var adjacentRow = this.columns_[column].getRowByTileIndex(
1040 adjacentRowNeighbourIndex);
1041 var previousTileCenterX = row.getTileByIndex(index).getCenterX();
1043 // Find the closest one.
1044 var closestIndex = -1;
1045 var closestDistance;
1046 var adjacentRowTiles = adjacentRow.getTiles();
1047 for (var t = 0; t !== adjacentRowTiles.length; t++) {
1048 var distance =
1049 Math.abs(adjacentRowTiles[t].getCenterX() - previousTileCenterX);
1050 if (closestIndex === -1 || distance < closestDistance) {
1051 closestIndex = adjacentRow.getEdgeTileIndex_(-1) + t;
1052 closestDistance = distance;
1055 return closestIndex;
1060 * @param {number} index Tile index.
1061 * @return {number} Index of the column containing the given tile.
1062 * @private
1064 Mosaic.Layout.prototype.getColumnIndexByTile_ = function(index) {
1065 for (var c = 0; c !== this.columns_.length; c++) {
1066 if (this.columns_[c].hasTile(index))
1067 return c;
1069 return -1;
1073 * Scales the given array of size values to satisfy 3 conditions:
1074 * 1. The new sizes must be integer.
1075 * 2. The new sizes must sum up to the given |total| value.
1076 * 3. The relative proportions of the sizes should be as close to the original
1077 * as possible.
1079 * @param {Array.<number>} sizes Array of sizes.
1080 * @param {number} newTotal New total size.
1082 Mosaic.Layout.rescaleSizesToNewTotal = function(sizes, newTotal) {
1083 var total = 0;
1085 var partialTotals = [0];
1086 for (var i = 0; i !== sizes.length; i++) {
1087 total += sizes[i];
1088 partialTotals.push(total);
1091 var scale = newTotal / total;
1093 for (i = 0; i !== sizes.length; i++) {
1094 sizes[i] = Math.round(partialTotals[i + 1] * scale) -
1095 Math.round(partialTotals[i] * scale);
1099 ////////////////////////////////////////////////////////////////////////////////
1102 * Representation of the layout density.
1104 * @param {number} horizontal Horizontal density, number tiles per row.
1105 * @param {number} vertical Vertical density, frequency of rows forced to
1106 * contain a single tile.
1107 * @constructor
1109 Mosaic.Density = function(horizontal, vertical) {
1110 this.horizontal = horizontal;
1111 this.vertical = vertical;
1115 * Minimal horizontal density (tiles per row).
1117 Mosaic.Density.MIN_HORIZONTAL = 1;
1120 * Minimal horizontal density (tiles per row).
1122 Mosaic.Density.MAX_HORIZONTAL = 3;
1125 * Minimal vertical density: force 1 out of 2 rows to containt a single tile.
1127 Mosaic.Density.MIN_VERTICAL = 2;
1130 * Maximal vertical density: force 1 out of 3 rows to containt a single tile.
1132 Mosaic.Density.MAX_VERTICAL = 3;
1135 * @return {Mosaic.Density} Lowest density.
1137 Mosaic.Density.createLowest = function() {
1138 return new Mosaic.Density(
1139 Mosaic.Density.MIN_HORIZONTAL,
1140 Mosaic.Density.MIN_VERTICAL /* ignored when horizontal is at min */);
1144 * @return {Mosaic.Density} Highest density.
1146 Mosaic.Density.createHighest = function() {
1147 return new Mosaic.Density(
1148 Mosaic.Density.MAX_HORIZONTAL,
1149 Mosaic.Density.MAX_VERTICAL);
1153 * @return {Mosaic.Density} A clone of this density object.
1155 Mosaic.Density.prototype.clone = function() {
1156 return new Mosaic.Density(this.horizontal, this.vertical);
1160 * @param {Mosaic.Density} that The other object.
1161 * @return {boolean} True if equal.
1163 Mosaic.Density.prototype.equals = function(that) {
1164 return this.horizontal === that.horizontal &&
1165 this.vertical === that.vertical;
1169 * Increases the density to the next level.
1171 Mosaic.Density.prototype.increase = function() {
1172 if (this.horizontal === Mosaic.Density.MIN_HORIZONTAL ||
1173 this.vertical === Mosaic.Density.MAX_VERTICAL) {
1174 console.assert(this.horizontal < Mosaic.Density.MAX_HORIZONTAL);
1175 this.horizontal++;
1176 this.vertical = Mosaic.Density.MIN_VERTICAL;
1177 } else {
1178 this.vertical++;
1183 * Decreases horizontal density.
1185 Mosaic.Density.prototype.decreaseHorizontal = function() {
1186 console.assert(this.horizontal > Mosaic.Density.MIN_HORIZONTAL);
1187 this.horizontal--;
1191 * @param {number} tileCount Number of tiles in the row.
1192 * @param {number} rowIndex Global row index.
1193 * @return {boolean} True if the row is complete.
1195 Mosaic.Density.prototype.isRowComplete = function(tileCount, rowIndex) {
1196 return (tileCount === this.horizontal) || (rowIndex % this.vertical) === 0;
1199 ////////////////////////////////////////////////////////////////////////////////
1202 * A column in a mosaic layout. Contains rows.
1204 * @param {number} index Column index.
1205 * @param {number} firstRowIndex Global row index.
1206 * @param {number} firstTileIndex Index of the first tile in the column.
1207 * @param {number} left Left edge coordinate.
1208 * @param {number} maxHeight Maximum height.
1209 * @param {Mosaic.Density} density Layout density.
1210 * @constructor
1212 Mosaic.Column = function(index, firstRowIndex, firstTileIndex, left, maxHeight,
1213 density) {
1214 this.index_ = index;
1215 this.firstRowIndex_ = firstRowIndex;
1216 this.firstTileIndex_ = firstTileIndex;
1217 this.left_ = left;
1218 this.maxHeight_ = maxHeight;
1219 this.density_ = density;
1221 this.reset_();
1225 * Resets the layout.
1226 * @private
1228 Mosaic.Column.prototype.reset_ = function() {
1229 this.tiles_ = [];
1230 this.rows_ = [];
1231 this.newRow_ = null;
1235 * @return {number} Number of tiles in the column.
1237 Mosaic.Column.prototype.getTileCount = function() { return this.tiles_.length };
1240 * @return {number} Index of the last tile + 1.
1242 Mosaic.Column.prototype.getNextTileIndex = function() {
1243 return this.firstTileIndex_ + this.getTileCount();
1247 * @return {number} Global index of the last row + 1.
1249 Mosaic.Column.prototype.getNextRowIndex = function() {
1250 return this.firstRowIndex_ + this.rows_.length;
1254 * @return {Array.<Mosaic.Tile>} Array of tiles in the column.
1256 Mosaic.Column.prototype.getTiles = function() { return this.tiles_ };
1259 * @param {number} index Tile index.
1260 * @return {boolean} True if this column contains the tile with the given index.
1262 Mosaic.Column.prototype.hasTile = function(index) {
1263 return this.firstTileIndex_ <= index &&
1264 index < (this.firstTileIndex_ + this.getTileCount());
1268 * @param {number} y Y coordinate.
1269 * @param {number} direction -1 for left, 1 for right.
1270 * @return {number} Index of the tile lying on the edge of the column at the
1271 * given y coordinate.
1272 * @private
1274 Mosaic.Column.prototype.getEdgeTileIndex_ = function(y, direction) {
1275 for (var r = 0; r < this.rows_.length; r++) {
1276 if (this.rows_[r].coversY(y))
1277 return this.rows_[r].getEdgeTileIndex_(direction);
1279 return -1;
1283 * @param {number} index Tile index.
1284 * @return {Mosaic.Row} The row containing the tile with a given index.
1286 Mosaic.Column.prototype.getRowByTileIndex = function(index) {
1287 for (var r = 0; r !== this.rows_.length; r++)
1288 if (this.rows_[r].hasTile(index))
1289 return this.rows_[r];
1291 return null;
1295 * Adds a tile to the column.
1297 * @param {Mosaic.Tile} tile The tile to add.
1299 Mosaic.Column.prototype.add = function(tile) {
1300 var rowIndex = this.getNextRowIndex();
1302 if (!this.newRow_)
1303 this.newRow_ = new Mosaic.Row(this.getNextTileIndex());
1305 this.tiles_.push(tile);
1306 this.newRow_.add(tile);
1308 if (this.density_.isRowComplete(this.newRow_.getTileCount(), rowIndex)) {
1309 this.rows_.push(this.newRow_);
1310 this.newRow_ = null;
1315 * Prepares the column layout.
1317 * @param {boolean=} opt_force True if the layout must be performed even for an
1318 * incomplete column.
1319 * @return {boolean} True if the layout was performed.
1321 Mosaic.Column.prototype.prepareLayout = function(opt_force) {
1322 if (opt_force && this.newRow_) {
1323 this.rows_.push(this.newRow_);
1324 this.newRow_ = null;
1327 if (this.rows_.length === 0)
1328 return false;
1330 this.width_ = Math.min.apply(
1331 null, this.rows_.map(function(row) { return row.getMaxWidth() }));
1333 this.height_ = 0;
1335 this.rowHeights_ = [];
1336 for (var r = 0; r !== this.rows_.length; r++) {
1337 var rowHeight = this.rows_[r].getHeightForWidth(this.width_);
1338 this.height_ += rowHeight;
1339 this.rowHeights_.push(rowHeight);
1342 var overflow = this.height_ / this.maxHeight_;
1343 if (!opt_force && (overflow < 1))
1344 return false;
1346 if (overflow > 1) {
1347 // Scale down the column width and height.
1348 this.width_ = Math.round(this.width_ / overflow);
1349 this.height_ = this.maxHeight_;
1350 Mosaic.Layout.rescaleSizesToNewTotal(this.rowHeights_, this.maxHeight_);
1353 return true;
1357 * Retries the column layout with less tiles per row.
1359 Mosaic.Column.prototype.retryWithLowerDensity = function() {
1360 this.density_.decreaseHorizontal();
1361 this.reset_();
1365 * @return {number} Column left edge coordinate.
1367 Mosaic.Column.prototype.getLeft = function() { return this.left_ };
1370 * @return {number} Column right edge coordinate after the layout.
1372 Mosaic.Column.prototype.getRight = function() {
1373 return this.left_ + this.width_;
1377 * @return {number} Column height after the layout.
1379 Mosaic.Column.prototype.getHeight = function() { return this.height_ };
1382 * Performs the column layout.
1383 * @param {number=} opt_offsetX Horizontal offset.
1384 * @param {number=} opt_offsetY Vertical offset.
1386 Mosaic.Column.prototype.layout = function(opt_offsetX, opt_offsetY) {
1387 opt_offsetX = opt_offsetX || 0;
1388 opt_offsetY = opt_offsetY || 0;
1389 var rowTop = Mosaic.Layout.PADDING_TOP;
1390 for (var r = 0; r !== this.rows_.length; r++) {
1391 this.rows_[r].layout(
1392 opt_offsetX + this.left_,
1393 opt_offsetY + rowTop,
1394 this.width_,
1395 this.rowHeights_[r]);
1396 rowTop += this.rowHeights_[r];
1401 * Checks if the column layout is too ugly to be displayed.
1403 * @return {boolean} True if the layout is suboptimal.
1405 Mosaic.Column.prototype.isSuboptimal = function() {
1406 var tileCounts =
1407 this.rows_.map(function(row) { return row.getTileCount() });
1409 var maxTileCount = Math.max.apply(null, tileCounts);
1410 if (maxTileCount === 1)
1411 return false; // Every row has exactly 1 tile, as optimal as it gets.
1413 var sizes =
1414 this.tiles_.map(function(tile) { return tile.getMaxContentHeight() });
1416 // Ugly layout #1: all images are small and some are one the same row.
1417 var allSmall = Math.max.apply(null, sizes) <= Mosaic.Tile.SMALL_IMAGE_SIZE;
1418 if (allSmall)
1419 return true;
1421 // Ugly layout #2: all images are large and none occupies an entire row.
1422 var allLarge = Math.min.apply(null, sizes) > Mosaic.Tile.SMALL_IMAGE_SIZE;
1423 var allCombined = Math.min.apply(null, tileCounts) !== 1;
1424 if (allLarge && allCombined)
1425 return true;
1427 // Ugly layout #3: some rows have too many tiles for the resulting width.
1428 if (this.width_ / maxTileCount < 100)
1429 return true;
1431 return false;
1434 ////////////////////////////////////////////////////////////////////////////////
1437 * A row in a mosaic layout. Contains tiles.
1439 * @param {number} firstTileIndex Index of the first tile in the row.
1440 * @constructor
1442 Mosaic.Row = function(firstTileIndex) {
1443 this.firstTileIndex_ = firstTileIndex;
1444 this.tiles_ = [];
1448 * @param {Mosaic.Tile} tile The tile to add.
1450 Mosaic.Row.prototype.add = function(tile) {
1451 console.assert(this.getTileCount() < Mosaic.Density.MAX_HORIZONTAL);
1452 this.tiles_.push(tile);
1456 * @return {Array.<Mosaic.Tile>} Array of tiles in the row.
1458 Mosaic.Row.prototype.getTiles = function() { return this.tiles_ };
1461 * Gets a tile by index.
1462 * @param {number} index Tile index.
1463 * @return {Mosaic.Tile} Requested tile or null if not found.
1465 Mosaic.Row.prototype.getTileByIndex = function(index) {
1466 if (!this.hasTile(index))
1467 return null;
1468 return this.tiles_[index - this.firstTileIndex_];
1473 * @return {number} Number of tiles in the row.
1475 Mosaic.Row.prototype.getTileCount = function() { return this.tiles_.length };
1478 * @param {number} index Tile index.
1479 * @return {boolean} True if this row contains the tile with the given index.
1481 Mosaic.Row.prototype.hasTile = function(index) {
1482 return this.firstTileIndex_ <= index &&
1483 index < (this.firstTileIndex_ + this.tiles_.length);
1487 * @param {number} y Y coordinate.
1488 * @return {boolean} True if this row covers the given Y coordinate.
1490 Mosaic.Row.prototype.coversY = function(y) {
1491 return this.top_ <= y && y < (this.top_ + this.height_);
1495 * @return {number} Y coordinate of the tile center.
1497 Mosaic.Row.prototype.getCenterY = function() {
1498 return this.top_ + Math.round(this.height_ / 2);
1502 * Gets the first or the last tile.
1504 * @param {number} direction -1 for the first tile, 1 for the last tile.
1505 * @return {number} Tile index.
1506 * @private
1508 Mosaic.Row.prototype.getEdgeTileIndex_ = function(direction) {
1509 if (direction < 0)
1510 return this.firstTileIndex_;
1511 else
1512 return this.firstTileIndex_ + this.getTileCount() - 1;
1516 * @return {number} Aspect ration of the combined content box of this row.
1517 * @private
1519 Mosaic.Row.prototype.getTotalContentAspectRatio_ = function() {
1520 var sum = 0;
1521 for (var t = 0; t !== this.tiles_.length; t++)
1522 sum += this.tiles_[t].getAspectRatio();
1523 return sum;
1527 * @return {number} Total horizontal spacing in this row. This includes
1528 * the spacing between the tiles and both left and right margins.
1530 * @private
1532 Mosaic.Row.prototype.getTotalHorizontalSpacing_ = function() {
1533 return Mosaic.Layout.SPACING * this.getTileCount();
1537 * @return {number} Maximum width that this row may have without overscaling
1538 * any of the tiles.
1540 Mosaic.Row.prototype.getMaxWidth = function() {
1541 var contentHeight = Math.min.apply(null,
1542 this.tiles_.map(function(tile) { return tile.getMaxContentHeight() }));
1544 var contentWidth =
1545 Math.round(contentHeight * this.getTotalContentAspectRatio_());
1546 return contentWidth + this.getTotalHorizontalSpacing_();
1550 * Computes the height that best fits the supplied row width given
1551 * aspect ratios of the tiles in this row.
1553 * @param {number} width Row width.
1554 * @return {number} Height.
1556 Mosaic.Row.prototype.getHeightForWidth = function(width) {
1557 var contentWidth = width - this.getTotalHorizontalSpacing_();
1558 var contentHeight =
1559 Math.round(contentWidth / this.getTotalContentAspectRatio_());
1560 return contentHeight + Mosaic.Layout.SPACING;
1564 * Positions the row in the mosaic.
1566 * @param {number} left Left position.
1567 * @param {number} top Top position.
1568 * @param {number} width Width.
1569 * @param {number} height Height.
1571 Mosaic.Row.prototype.layout = function(left, top, width, height) {
1572 this.top_ = top;
1573 this.height_ = height;
1575 var contentWidth = width - this.getTotalHorizontalSpacing_();
1576 var contentHeight = height - Mosaic.Layout.SPACING;
1578 var tileContentWidth = this.tiles_.map(
1579 function(tile) { return tile.getAspectRatio() });
1581 Mosaic.Layout.rescaleSizesToNewTotal(tileContentWidth, contentWidth);
1583 var tileLeft = left;
1584 for (var t = 0; t !== this.tiles_.length; t++) {
1585 var tileWidth = tileContentWidth[t] + Mosaic.Layout.SPACING;
1586 this.tiles_[t].layout(tileLeft, top, tileWidth, height);
1587 tileLeft += tileWidth;
1591 ////////////////////////////////////////////////////////////////////////////////
1594 * A single tile of the image mosaic.
1596 * @param {Element} container Container element.
1597 * @param {Gallery.Item} item Gallery item associated with this tile.
1598 * @param {EntryLocation} locationInfo Location information for the tile.
1599 * @return {Element} The new tile element.
1600 * @constructor
1602 Mosaic.Tile = function(container, item, locationInfo) {
1603 var self = container.ownerDocument.createElement('div');
1604 Mosaic.Tile.decorate(self, container, item, locationInfo);
1605 return self;
1609 * @param {Element} self Self pointer.
1610 * @param {Element} container Container element.
1611 * @param {Gallery.Item} item Gallery item associated with this tile.
1612 * @param {EntryLocation} locationInfo Location info for the tile image.
1614 Mosaic.Tile.decorate = function(self, container, item, locationInfo) {
1615 self.__proto__ = Mosaic.Tile.prototype;
1616 self.className = 'mosaic-tile';
1618 self.container_ = container;
1619 self.item_ = item;
1620 self.left_ = null; // Mark as not laid out.
1621 self.hidpiEmbedded_ = locationInfo && locationInfo.isDriveBased;
1625 * Load mode for the tile's image.
1626 * @enum {number}
1628 Mosaic.Tile.LoadMode = {
1629 LOW_DPI: 0,
1630 HIGH_DPI: 1
1634 * Inherit from HTMLDivElement.
1636 Mosaic.Tile.prototype.__proto__ = HTMLDivElement.prototype;
1639 * Minimum tile content size.
1641 Mosaic.Tile.MIN_CONTENT_SIZE = 64;
1644 * Maximum tile content size.
1646 Mosaic.Tile.MAX_CONTENT_SIZE = 512;
1649 * Default size for a tile with no thumbnail image.
1651 Mosaic.Tile.GENERIC_ICON_SIZE = 128;
1654 * Max size of an image considered to be 'small'.
1655 * Small images are laid out slightly differently.
1657 Mosaic.Tile.SMALL_IMAGE_SIZE = 160;
1660 * @return {Gallery.Item} The Gallery item.
1662 Mosaic.Tile.prototype.getItem = function() { return this.item_; };
1665 * @return {number} Maximum content height that this tile can have.
1667 Mosaic.Tile.prototype.getMaxContentHeight = function() {
1668 return this.maxContentHeight_;
1672 * @return {number} The aspect ratio of the tile image.
1674 Mosaic.Tile.prototype.getAspectRatio = function() { return this.aspectRatio_; };
1677 * @return {boolean} True if the tile is initialized.
1679 Mosaic.Tile.prototype.isInitialized = function() {
1680 return !!this.maxContentHeight_;
1684 * Checks whether the image of specified (or better resolution) has been loaded.
1686 * @param {Mosaic.Tile.LoadMode=} opt_loadMode Loading mode, default: LOW_DPI.
1687 * @return {boolean} True if the tile is loaded with the specified dpi or
1688 * better.
1690 Mosaic.Tile.prototype.isLoaded = function(opt_loadMode) {
1691 var loadMode = opt_loadMode || Mosaic.Tile.LoadMode.LOW_DPI;
1692 switch (loadMode) {
1693 case Mosaic.Tile.LoadMode.LOW_DPI:
1694 if (this.imagePreloaded_ || this.imageLoaded_)
1695 return true;
1696 break;
1697 case Mosaic.Tile.LoadMode.HIGH_DPI:
1698 if (this.imageLoaded_)
1699 return true;
1700 break;
1702 return false;
1706 * Checks whether the image of specified (or better resolution) is being loaded.
1708 * @param {Mosaic.Tile.LoadMode=} opt_loadMode Loading mode, default: LOW_DPI.
1709 * @return {boolean} True if the tile is being loaded with the specified dpi or
1710 * better.
1712 Mosaic.Tile.prototype.isLoading = function(opt_loadMode) {
1713 var loadMode = opt_loadMode || Mosaic.Tile.LoadMode.LOW_DPI;
1714 switch (loadMode) {
1715 case Mosaic.Tile.LoadMode.LOW_DPI:
1716 if (this.imagePreloading_ || this.imageLoading_)
1717 return true;
1718 break;
1719 case Mosaic.Tile.LoadMode.HIGH_DPI:
1720 if (this.imageLoading_)
1721 return true;
1722 break;
1724 return false;
1728 * Marks the tile as not loaded to prevent it from participating in the layout.
1730 Mosaic.Tile.prototype.markUnloaded = function() {
1731 this.maxContentHeight_ = 0;
1732 if (this.thumbnailLoader_) {
1733 this.thumbnailLoader_.cancel();
1734 this.imagePreloaded_ = false;
1735 this.imagePreloading_ = false;
1736 this.imageLoaded_ = false;
1737 this.imageLoading_ = false;
1742 * Initializes the thumbnail in the tile. Does not load an image, but sets
1743 * target dimensions using metadata.
1745 Mosaic.Tile.prototype.init = function() {
1746 var metadata = this.getItem().getMetadata();
1747 this.markUnloaded();
1748 this.left_ = null; // Mark as not laid out.
1750 // Set higher priority for the selected elements to load them first.
1751 var priority = this.getAttribute('selected') ? 2 : 3;
1753 // Use embedded thumbnails on Drive, since they have higher resolution.
1754 this.thumbnailLoader_ = new ThumbnailLoader(
1755 this.getItem().getEntry(),
1756 ThumbnailLoader.LoaderType.CANVAS,
1757 metadata,
1758 undefined, // Media type.
1759 this.hidpiEmbedded_ ?
1760 ThumbnailLoader.UseEmbedded.USE_EMBEDDED :
1761 ThumbnailLoader.UseEmbedded.NO_EMBEDDED,
1762 priority);
1764 // If no hidpi embedded thumbnail available, then use the low resolution
1765 // for preloading.
1766 if (!this.hidpiEmbedded_) {
1767 this.thumbnailPreloader_ = new ThumbnailLoader(
1768 this.getItem().getEntry(),
1769 ThumbnailLoader.LoaderType.CANVAS,
1770 metadata,
1771 undefined, // Media type.
1772 ThumbnailLoader.UseEmbedded.USE_EMBEDDED,
1773 2); // Preloaders have always higher priotity, so the preload images
1774 // are loaded as soon as possible.
1777 // Dimensions are always acquired from the metadata. For local files, it is
1778 // extracted from headers. For Drive files, it is received via the Drive API.
1779 // If the dimensions are not available, then the fallback dimensions will be
1780 // used (same as for the generic icon).
1781 var width;
1782 var height;
1783 if (metadata.media && metadata.media.width) {
1784 width = metadata.media.width;
1785 height = metadata.media.height;
1786 } else if (metadata.drive && metadata.drive.imageWidth &&
1787 metadata.drive.imageHeight) {
1788 width = metadata.drive.imageWidth;
1789 height = metadata.drive.imageHeight;
1790 } else {
1791 // No dimensions in metadata, then use the generic dimensions.
1792 width = Mosaic.Tile.GENERIC_ICON_SIZE;
1793 height = Mosaic.Tile.GENERIC_ICON_SIZE;
1796 if (width > height) {
1797 if (width > Mosaic.Tile.MAX_CONTENT_SIZE) {
1798 height = Math.round(height * Mosaic.Tile.MAX_CONTENT_SIZE / width);
1799 width = Mosaic.Tile.MAX_CONTENT_SIZE;
1801 } else {
1802 if (height > Mosaic.Tile.MAX_CONTENT_SIZE) {
1803 width = Math.round(width * Mosaic.Tile.MAX_CONTENT_SIZE / height);
1804 height = Mosaic.Tile.MAX_CONTENT_SIZE;
1807 this.maxContentHeight_ = Math.max(Mosaic.Tile.MIN_CONTENT_SIZE, height);
1808 this.aspectRatio_ = width / height;
1812 * Loads an image into the tile.
1814 * The mode argument is a hint. Use low-dpi for faster response, and high-dpi
1815 * for better output, but possibly affecting performance.
1817 * If the mode is high-dpi, then a the high-dpi image is loaded, but also
1818 * low-dpi image is loaded for preloading (if available).
1819 * For the low-dpi mode, only low-dpi image is loaded. If not available, then
1820 * the high-dpi image is loaded as a fallback.
1822 * @param {Mosaic.Tile.LoadMode} loadMode Loading mode.
1823 * @param {function(boolean)} onImageLoaded Callback when image is loaded.
1824 * The argument is true for success, false for failure.
1826 Mosaic.Tile.prototype.load = function(loadMode, onImageLoaded) {
1827 // Attaches the image to the tile and finalizes loading process for the
1828 // specified loader.
1829 var finalizeLoader = function(mode, success, loader) {
1830 if (success && this.wrapper_) {
1831 // Show the fade-in animation only when previously there was no image
1832 // attached in this tile.
1833 if (!this.imageLoaded_ && !this.imagePreloaded_)
1834 this.wrapper_.classList.add('animated');
1835 else
1836 this.wrapper_.classList.remove('animated');
1838 loader.attachImage(this.wrapper_, ThumbnailLoader.FillMode.OVER_FILL);
1839 onImageLoaded(success);
1840 switch (mode) {
1841 case Mosaic.Tile.LoadMode.LOW_DPI:
1842 this.imagePreloading_ = false;
1843 this.imagePreloaded_ = true;
1844 break;
1845 case Mosaic.Tile.LoadMode.HIGH_DPI:
1846 this.imageLoading_ = false;
1847 this.imageLoaded_ = true;
1848 break;
1850 }.bind(this);
1852 // Always load the low-dpi image first if it is available for the fastest
1853 // feedback.
1854 if (!this.imagePreloading_ && this.thumbnailPreloader_) {
1855 this.imagePreloading_ = true;
1856 this.thumbnailPreloader_.loadDetachedImage(function(success) {
1857 // Hi-dpi loaded first, ignore this call then.
1858 if (this.imageLoaded_)
1859 return;
1860 finalizeLoader(Mosaic.Tile.LoadMode.LOW_DPI,
1861 success,
1862 this.thumbnailPreloader_);
1863 }.bind(this));
1866 // Load the high-dpi image only when it is requested, or the low-dpi is not
1867 // available.
1868 if (!this.imageLoading_ &&
1869 (loadMode === Mosaic.Tile.LoadMode.HIGH_DPI || !this.imagePreloading_)) {
1870 this.imageLoading_ = true;
1871 this.thumbnailLoader_.loadDetachedImage(function(success) {
1872 // Cancel preloading, since the hi-dpi image is ready.
1873 if (this.thumbnailPreloader_)
1874 this.thumbnailPreloader_.cancel();
1875 finalizeLoader(Mosaic.Tile.LoadMode.HIGH_DPI,
1876 success,
1877 this.thumbnailLoader_);
1878 }.bind(this));
1883 * Unloads an image from the tile.
1885 Mosaic.Tile.prototype.unload = function() {
1886 this.thumbnailLoader_.cancel();
1887 if (this.thumbnailPreloader_)
1888 this.thumbnailPreloader_.cancel();
1889 this.imagePreloaded_ = false;
1890 this.imageLoaded_ = false;
1891 this.imagePreloading_ = false;
1892 this.imageLoading_ = false;
1893 this.wrapper_.innerText = '';
1897 * Selects/unselects the tile.
1899 * @param {boolean} on True if selected.
1901 Mosaic.Tile.prototype.select = function(on) {
1902 if (on)
1903 this.setAttribute('selected', true);
1904 else
1905 this.removeAttribute('selected');
1909 * Positions the tile in the mosaic.
1911 * @param {number} left Left position.
1912 * @param {number} top Top position.
1913 * @param {number} width Width.
1914 * @param {number} height Height.
1916 Mosaic.Tile.prototype.layout = function(left, top, width, height) {
1917 this.left_ = left;
1918 this.top_ = top;
1919 this.width_ = width;
1920 this.height_ = height;
1922 this.style.left = left + 'px';
1923 this.style.top = top + 'px';
1924 this.style.width = width + 'px';
1925 this.style.height = height + 'px';
1927 if (!this.wrapper_) { // First time, create DOM.
1928 this.container_.appendChild(this);
1929 var border = util.createChild(this, 'img-border');
1930 this.wrapper_ = util.createChild(border, 'img-wrapper');
1932 if (this.hasAttribute('selected'))
1933 this.scrollIntoView(false);
1935 if (this.imageLoaded_) {
1936 this.thumbnailLoader_.attachImage(this.wrapper_,
1937 ThumbnailLoader.FillMode.OVER_FILL);
1942 * If the tile is not fully visible scroll the parent to make it fully visible.
1943 * @param {boolean=} opt_animated True, if scroll should be animated,
1944 * default: true.
1946 Mosaic.Tile.prototype.scrollIntoView = function(opt_animated) {
1947 if (this.left_ === null) // Not laid out.
1948 return;
1950 var targetPosition;
1951 var tileLeft = this.left_ - Mosaic.Layout.SCROLL_MARGIN;
1952 if (tileLeft < this.container_.scrollLeft) {
1953 targetPosition = tileLeft;
1954 } else {
1955 var tileRight = this.left_ + this.width_ + Mosaic.Layout.SCROLL_MARGIN;
1956 var scrollRight = this.container_.scrollLeft + this.container_.clientWidth;
1957 if (tileRight > scrollRight)
1958 targetPosition = tileRight - this.container_.clientWidth;
1961 if (targetPosition) {
1962 if (opt_animated === false)
1963 this.container_.scrollLeft = targetPosition;
1964 else
1965 this.container_.animatedScrollTo(targetPosition);
1970 * @return {Rect} Rectangle occupied by the tile's image,
1971 * relative to the viewport.
1973 Mosaic.Tile.prototype.getImageRect = function() {
1974 if (this.left_ === null) // Not laid out.
1975 return null;
1977 var margin = Mosaic.Layout.SPACING / 2;
1978 return new Rect(this.left_ - this.container_.scrollLeft, this.top_,
1979 this.width_, this.height_).inflate(-margin, -margin);
1983 * @return {number} X coordinate of the tile center.
1985 Mosaic.Tile.prototype.getCenterX = function() {
1986 return this.left_ + Math.round(this.width_ / 2);