78af4d850dcf862ddb093100c611830646413fc1
[platform/framework/web/crosswalk.git] / src / ui / file_manager / gallery / js / mosaic_mode.js
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.
4
5 'use strict';
6
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
14  */
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_);
20
21   this.toggleMode_ = toggleMode;
22   this.mosaic_.addEventListener('dblclick', this.toggleMode_);
23   this.showingTimeoutID_ = null;
24 }
25
26 /**
27  * @return {Mosaic} The mosaic control.
28  */
29 MosaicMode.prototype.getMosaic = function() { return this.mosaic_; };
30
31 /**
32  * @return {string} Mode name.
33  */
34 MosaicMode.prototype.getName = function() { return 'mosaic'; };
35
36 /**
37  * @return {string} Mode title.
38  */
39 MosaicMode.prototype.getTitle = function() { return 'GALLERY_MOSAIC'; };
40
41 /**
42  * Execute an action (this mode has no busy state).
43  * @param {function} action Action to execute.
44  */
45 MosaicMode.prototype.executeWhenReady = function(action) { action(); };
46
47 /**
48  * @return {boolean} Always true (no toolbar fading in this mode).
49  */
50 MosaicMode.prototype.hasActiveTool = function() { return true; };
51
52 /**
53  * Keydown handler.
54  *
55  * @param {Event} event Event.
56  */
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();
64       }
65       return;
66   }
67   this.mosaic_.onKeyDown(event);
68 };
69
70 ////////////////////////////////////////////////////////////////////////////////
71
72 /**
73  * Mosaic control.
74  *
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
81  */
82 function Mosaic(document, dataModel, selectionModel, volumeManager) {
83   var self = document.createElement('div');
84   Mosaic.decorate(self, dataModel, selectionModel, volumeManager);
85   return self;
86 }
87
88 /**
89  * Inherits from HTMLDivElement.
90  */
91 Mosaic.prototype.__proto__ = HTMLDivElement.prototype;
92
93 /**
94  * Default layout delay in ms.
95  * @const
96  * @type {number}
97  */
98 Mosaic.LAYOUT_DELAY = 200;
99
100 /**
101  * Smooth scroll animation duration when scrolling using keyboard or
102  * clicking on a partly visible tile. In ms.
103  * @const
104  * @type {number}
105  */
106 Mosaic.ANIMATED_SCROLL_DURATION = 500;
107
108 /**
109  * Decorates a Mosaic instance.
110  *
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.
115  */
116 Mosaic.decorate = function(
117     self, dataModel, selectionModel, volumeManager) {
118   self.__proto__ = Mosaic.prototype;
119   self.className = 'mosaic';
120
121   self.dataModel_ = dataModel;
122   self.selectionModel_ = selectionModel;
123   self.volumeManager_ = volumeManager;
124
125   // Initialization is completed lazily on the first call to |init|.
126 };
127
128 /**
129  * Initializes the mosaic element.
130  */
131 Mosaic.prototype.init = function() {
132   if (this.tiles_)
133     return; // Already initialized, nothing to do.
134
135   this.layoutModel_ = new Mosaic.Layout();
136   this.onResize_();
137
138   this.selectionController_ =
139       new Mosaic.SelectionController(this.selectionModel_, this.layoutModel_);
140
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));
147   }
148
149   this.selectionModel_.selectedIndexes.forEach(function(index) {
150     this.tiles_[index].select(true);
151   }.bind(this));
152
153   this.initTiles_(this.tiles_);
154
155   // The listeners might be called while some tiles are still loading.
156   this.initListeners_();
157 };
158
159 /**
160  * @return {boolean} Whether mosaic is initialized.
161  */
162 Mosaic.prototype.isInitialized = function() {
163   return !!this.tiles_;
164 };
165
166 /**
167  * Starts listening to events.
168  *
169  * We keep listening to events even when the mosaic is hidden in order to
170  * keep the layout up to date.
171  *
172  * @private
173  */
174 Mosaic.prototype.initListeners_ = function() {
175   this.ownerDocument.defaultView.addEventListener(
176       'resize', this.onResize_.bind(this));
177
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));
183
184   this.selectionModel_.addEventListener('change', this.onSelection_.bind(this));
185   this.selectionModel_.addEventListener('leadIndexChange',
186       this.onLeadChange_.bind(this));
187
188   this.dataModel_.addEventListener('splice', this.onSplice_.bind(this));
189   this.dataModel_.addEventListener('content', this.onContentChange_.bind(this));
190 };
191
192 /**
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.
196  */
197 Mosaic.prototype.animatedScrollTo = function(targetPosition) {
198   if (this.scrollAnimation_) {
199     webkitCancelAnimationFrame(this.scrollAnimation_);
200     this.scrollAnimation_ = null;
201   }
202
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;
206
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);
211   };
212
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;
218
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;
225
226     var oldScrollLeft = this.scrollLeft;
227     var newScrollLeft = Math.round(scrollOffset);
228
229     if (oldScrollLeft !== newScrollLeft)
230       this.scrollLeft = newScrollLeft;
231
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);
243     }
244
245     lastPosition = position;
246   }.bind(this);
247
248   // Start the animation.
249   this.scrollAnimation_ = requestAnimationFrame(animationFrame);
250 };
251
252 /**
253  * @return {Mosaic.Tile} Selected tile or undefined if no selection.
254  */
255 Mosaic.prototype.getSelectedTile = function() {
256   return this.tiles_ && this.tiles_[this.selectionModel_.selectedIndex];
257 };
258
259 /**
260  * @param {number} index Tile index.
261  * @return {Rect} Tile's image rectangle.
262  */
263 Mosaic.prototype.getTileRect = function(index) {
264   var tile = this.tiles_[index];
265   return tile && tile.getImageRect();
266 };
267
268 /**
269  * @param {number} index Tile index.
270  * Scroll the given tile into the viewport.
271  */
272 Mosaic.prototype.scrollIntoView = function(index) {
273   var tile = this.tiles_[index];
274   if (tile) tile.scrollIntoView();
275 };
276
277 /**
278  * Initializes multiple tiles.
279  *
280  * @param {Array.<Mosaic.Tile>} tiles Array of tiles.
281  * @private
282  */
283 Mosaic.prototype.initTiles_ = function(tiles) {
284   for (var i = 0; i < tiles.length; i++) {
285     tiles[i].init();
286   }
287 };
288
289 /**
290  * Reloads all tiles.
291  */
292 Mosaic.prototype.reload = function() {
293   this.layoutModel_.reset_();
294   this.tiles_.forEach(function(t) { t.markUnloaded(); });
295   this.initTiles_(this.tiles_);
296 };
297
298 /**
299  * Layouts the tiles in the order of their indices.
300  *
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.
303  */
304 Mosaic.prototype.layout = function() {
305   if (this.layoutTimer_) {
306     clearTimeout(this.layoutTimer_);
307     this.layoutTimer_ = null;
308   }
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);
317   }
318   this.loadVisibleTiles_();
319 };
320
321 /**
322  * Schedules the layout.
323  *
324  * @param {number=} opt_delay Delay in ms.
325  */
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);
332   }
333 };
334
335 /**
336  * Resize handler.
337  *
338  * @private
339  */
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();
344 };
345
346 /**
347  * Mouse event handler.
348  *
349  * @param {Event} event Event.
350  * @private
351  */
352 Mosaic.prototype.onMouseEvent_ = function(event) {
353   // Navigating with mouse, enable hover state.
354   if (!this.suppressHovering_)
355     this.classList.add('hover-visible');
356
357   if (event.type === 'mousemove')
358     return;
359
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;
367     }
368   }
369   this.selectionController_.handlePointerDownUp(event, index);
370 };
371
372 /**
373  * Scroll handler.
374  * @private
375  */
376 Mosaic.prototype.onScroll_ = function() {
377   requestAnimationFrame(function() {
378     this.loadVisibleTiles_();
379   }.bind(this));
380 };
381
382 /**
383  * Selection change handler.
384  *
385  * @param {Event} event Event.
386  * @private
387  */
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);
393   }
394 };
395
396 /**
397  * Leads item change handler.
398  *
399  * @param {Event} event Event.
400  * @private
401  */
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();
407   }
408 };
409
410 /**
411  * Splice event handler.
412  *
413  * @param {Event} event Event.
414  * @private
415  */
416 Mosaic.prototype.onSplice_ = function(event) {
417   var index = event.index;
418   this.layoutModel_.invalidateFromTile_(index);
419
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]);
427     }
428
429     this.tiles_.splice(index, event.removed.length);
430     this.scheduleLayout(Mosaic.LAYOUT_DELAY);
431   }
432
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)));
437
438     this.tiles_.splice.apply(this.tiles_, [index, 0].concat(newTiles));
439     this.initTiles_(newTiles);
440     this.scheduleLayout(Mosaic.LAYOUT_DELAY);
441   }
442
443   if (this.tiles_.length !== this.dataModel_.length)
444     console.error('Mosaic is out of sync');
445 };
446
447 /**
448  * Content change handler.
449  *
450  * @param {Event} event Event.
451  * @private
452  */
453 Mosaic.prototype.onContentChange_ = function(event) {
454   if (!this.tiles_)
455     return;
456
457   if (!event.metadata)
458     return; // Thumbnail unchanged, nothing to do.
459
460   var index = this.dataModel_.indexOf(event.item);
461   if (index !== this.selectionModel_.selectedIndex)
462     console.error('Content changed for unselected item');
463
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));
470 };
471
472 /**
473  * Keydown event handler.
474  *
475  * @param {Event} event Event.
476  * @return {boolean} True if the event has been consumed.
477  */
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;
483 };
484
485 /**
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.
489  */
490 Mosaic.prototype.canZoom = function() {
491   return this.tiles_.length < 100;
492 };
493
494 /**
495  * Shows the mosaic.
496  */
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;
506   }
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);
514 };
515
516 /**
517  * Hides the mosaic.
518  */
519 Mosaic.prototype.hide = function() {
520   if (this.showingTimeoutID_ !== null) {
521     clearTimeout(this.showingTimeoutID_);
522     this.showingTimeoutID_ = null;
523   }
524   this.removeAttribute('visible');
525 };
526
527 /**
528  * Checks if the mosaic view is visible.
529  * @return {boolean} True if visible, false otherwise.
530  * @private
531  */
532 Mosaic.prototype.isVisible_ = function() {
533   return this.hasAttribute('visible');
534 };
535
536 /**
537  * Loads visible tiles. Ignores consecutive calls. Does not reload already
538  * loaded images.
539  * @private
540  */
541 Mosaic.prototype.loadVisibleTiles_ = function() {
542   if (this.loadVisibleTilesSuppressed_) {
543     this.loadVisibleTilesScheduled_ = true;
544     return;
545   }
546
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);
554
555   // Tiles only in the viewport (visible).
556   var visibleRect = new Rect(0,
557                              0,
558                              this.clientWidth,
559                              this.clientHeight);
560
561   // Tiles in the viewport and also some distance on the left and right.
562   var renderableRect = new Rect(-this.clientWidth,
563                                 0,
564                                 3 * this.clientWidth,
565                                 this.clientHeight);
566
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();
574   }
575
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;
589     }
590   }
591
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() {});
601       }
602     }
603   }
604 };
605
606 /**
607  * Applies reset the zoom transform.
608  *
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.
612  */
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';
619   }
620
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 = '';
633   }
634 };
635
636 ////////////////////////////////////////////////////////////////////////////////
637
638 /**
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}
645  */
646 Mosaic.SelectionController = function(selectionModel, layoutModel) {
647   cr.ui.ListSelectionController.call(this, selectionModel);
648   this.layoutModel_ = layoutModel;
649 };
650
651 /**
652  * Extends cr.ui.ListSelectionController.
653  */
654 Mosaic.SelectionController.prototype.__proto__ =
655     cr.ui.ListSelectionController.prototype;
656
657 /** @override */
658 Mosaic.SelectionController.prototype.getLastIndex = function() {
659   return this.layoutModel_.getLaidOutTileCount() - 1;
660 };
661
662 /** @override */
663 Mosaic.SelectionController.prototype.getIndexBefore = function(index) {
664   return this.layoutModel_.getHorizontalAdjacentIndex(index, -1);
665 };
666
667 /** @override */
668 Mosaic.SelectionController.prototype.getIndexAfter = function(index) {
669   return this.layoutModel_.getHorizontalAdjacentIndex(index, 1);
670 };
671
672 /** @override */
673 Mosaic.SelectionController.prototype.getIndexAbove = function(index) {
674   return this.layoutModel_.getVerticalAdjacentIndex(index, -1);
675 };
676
677 /** @override */
678 Mosaic.SelectionController.prototype.getIndexBelow = function(index) {
679   return this.layoutModel_.getVerticalAdjacentIndex(index, 1);
680 };
681
682 ////////////////////////////////////////////////////////////////////////////////
683
684 /**
685  * Mosaic layout.
686  *
687  * @param {string=} opt_mode Layout mode.
688  * @param {Mosaic.Density=} opt_maxDensity Layout density.
689  * @constructor
690  */
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_();
695 };
696
697 /**
698  * Blank space at the top of the mosaic element. We do not do that in CSS
699  * to make transition effects easier.
700  */
701 Mosaic.Layout.PADDING_TOP = 50;
702
703 /**
704  * Blank space at the bottom of the mosaic element.
705  */
706 Mosaic.Layout.PADDING_BOTTOM = 50;
707
708 /**
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))
711  */
712 Mosaic.Layout.SPACING = 10;
713
714 /**
715  * Margin for scrolling using keyboard. Distance between a selected tile
716  * and window border.
717  */
718 Mosaic.Layout.SCROLL_MARGIN = 30;
719
720 /**
721  * Layout mode: commit to DOM immediately.
722  */
723 Mosaic.Layout.MODE_FINAL = 'final';
724
725 /**
726  * Layout mode: do not commit layout to DOM until it is complete or the viewport
727  * overflows.
728  */
729 Mosaic.Layout.MODE_TENTATIVE = 'tentative';
730
731 /**
732  * Layout mode: never commit layout to DOM.
733  */
734 Mosaic.Layout.MODE_DRY_RUN = 'dry_run';
735
736 /**
737  * Resets the layout.
738  *
739  * @private
740  */
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;
747 };
748
749 /**
750  * @param {number} width Viewport width.
751  * @param {number} height Viewport height.
752  */
753 Mosaic.Layout.prototype.setViewportSize = function(width, height) {
754   this.viewportWidth_ = width;
755   this.viewportHeight_ = height;
756   this.reset_();
757 };
758
759 /**
760  * @return {number} Total width of the layout.
761  */
762 Mosaic.Layout.prototype.getWidth = function() {
763   var lastColumn = this.getLastColumn_();
764   return lastColumn ? lastColumn.getRight() : 0;
765 };
766
767 /**
768  * @return {number} Total height of the layout.
769  */
770 Mosaic.Layout.prototype.getHeight = function() {
771   var firstColumn = this.columns_[0];
772   return firstColumn ? firstColumn.getHeight() : 0;
773 };
774
775 /**
776  * @return {Array.<Mosaic.Tile>} All tiles in the layout.
777  */
778 Mosaic.Layout.prototype.getTiles = function() {
779   return Array.prototype.concat.apply([],
780       this.columns_.map(function(c) { return c.getTiles(); }));
781 };
782
783 /**
784  * @return {number} Total number of tiles added to the layout.
785  */
786 Mosaic.Layout.prototype.getTileCount = function() {
787   return this.getLaidOutTileCount() +
788       (this.newColumn_ ? this.newColumn_.getTileCount() : 0);
789 };
790
791 /**
792  * @return {Mosaic.Column} The last column or null for empty layout.
793  * @private
794  */
795 Mosaic.Layout.prototype.getLastColumn_ = function() {
796   return this.columns_.length ? this.columns_[this.columns_.length - 1] : null;
797 };
798
799 /**
800  * @return {number} Total number of tiles in completed columns.
801  */
802 Mosaic.Layout.prototype.getLaidOutTileCount = function() {
803   var lastColumn = this.getLastColumn_();
804   return lastColumn ? lastColumn.getNextTileIndex() : 0;
805 };
806
807 /**
808  * Adds a tile to the layout.
809  *
810  * @param {Mosaic.Tile} tile The tile to be added.
811  * @param {boolean} isLast True if this tile is the last.
812  */
813 Mosaic.Layout.prototype.add = function(tile, isLast) {
814   var layoutQueue = [tile];
815
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.
822   //
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.
827
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());
838     }
839
840     this.newColumn_.add(layoutQueue.shift());
841
842     var isFinalColumn = isLast && !layoutQueue.length;
843
844     if (!this.newColumn_.prepareLayout(isFinalColumn))
845       continue; // Column is incomplete.
846
847     if (this.newColumn_.isSuboptimal()) {
848       layoutQueue = this.newColumn_.getTiles().concat(layoutQueue);
849       this.newColumn_.retryWithLowerDensity();
850       continue;
851     }
852
853     this.columns_.push(this.newColumn_);
854     this.newColumn_ = null;
855
856     if (this.mode_ === Mosaic.Layout.MODE_FINAL && isFinalColumn) {
857       this.commit_();
858       continue;
859     }
860
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;
868       }
869
870       // Rollback the entire layout, retry with higher density.
871       layoutQueue = this.getTiles().concat(layoutQueue);
872       this.columns_ = [];
873       this.density_.increase();
874       continue;
875     }
876
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);
885     }
886   }
887 };
888
889 /**
890  * Commits the tentative layout.
891  *
892  * @param {number=} opt_offsetX Horizontal offset.
893  * @param {number=} opt_offsetY Vertical offset.
894  * @private
895  */
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);
899   }
900   this.mode_ = Mosaic.Layout.MODE_FINAL;
901 };
902
903 /**
904  * Finds the most horizontally stretched layout built from the same tiles.
905  *
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.
909  *
910  * This method tries a number of smaller heights and returns the most
911  * horizontally stretched layout that still fits into the viewport.
912  *
913  * @return {Mosaic.Layout} A horizontally stretched layout.
914  * @private
915  */
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;
922
923   var tiles = this.getTiles();
924   if (tiles.length === 1)
925     return null;  // Single tile layout is always the same.
926
927   var tileHeights = tiles.map(function(t) { return t.getMaxContentHeight(); });
928   var minTileHeight = Math.min.apply(null, tileHeights);
929
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);
936
937     if (layout.getWidth() <= this.viewportWidth_)
938       return layout;
939   }
940
941   return null;
942 };
943
944 /**
945  * Invalidates the layout after the given tile was modified (added, deleted or
946  * changed dimensions).
947  *
948  * @param {number} index Tile index.
949  * @private
950  */
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.
955
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;
968   }
969 };
970
971 /**
972  * Gets the index of the tile to the left or to the right from the given tile.
973  *
974  * @param {number} index Tile index.
975  * @param {number} direction -1 for left, 1 for right.
976  * @return {number} Adjacent tile index.
977  */
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;
984   }
985
986   var row = this.columns_[column].getRowByTileIndex(index);
987   if (!row) {
988     console.error('Cannot find row for tile #' + index);
989     return -1;
990   }
991
992   var sameRowNeighbourIndex = index + direction;
993   if (row.hasTile(sameRowNeighbourIndex))
994     return sameRowNeighbourIndex;
995
996   var adjacentColumn = column + direction;
997   if (adjacentColumn < 0 || adjacentColumn === this.columns_.length)
998     return -1;
999
1000   return this.columns_[adjacentColumn].
1001       getEdgeTileIndex_(row.getCenterY(), -direction);
1002 };
1003
1004 /**
1005  * Gets the index of the tile to the top or to the bottom from the given tile.
1006  *
1007  * @param {number} index Tile index.
1008  * @param {number} direction -1 for above, 1 for below.
1009  * @return {number} Adjacent tile index.
1010  */
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;
1017   }
1018
1019   var row = this.columns_[column].getRowByTileIndex(index);
1020   if (!row) {
1021     console.error('Cannot find row for tile #' + index);
1022     return -1;
1023   }
1024
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;
1028
1029   if (adjacentRowNeighbourIndex < 0 ||
1030       adjacentRowNeighbourIndex > this.getTileCount() - 1)
1031     return -1;
1032
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();
1042
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;
1053       }
1054     }
1055     return closestIndex;
1056   }
1057 };
1058
1059 /**
1060  * @param {number} index Tile index.
1061  * @return {number} Index of the column containing the given tile.
1062  * @private
1063  */
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;
1068   }
1069   return -1;
1070 };
1071
1072 /**
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.
1078  *
1079  * @param {Array.<number>} sizes Array of sizes.
1080  * @param {number} newTotal New total size.
1081  */
1082 Mosaic.Layout.rescaleSizesToNewTotal = function(sizes, newTotal) {
1083   var total = 0;
1084
1085   var partialTotals = [0];
1086   for (var i = 0; i !== sizes.length; i++) {
1087     total += sizes[i];
1088     partialTotals.push(total);
1089   }
1090
1091   var scale = newTotal / total;
1092
1093   for (i = 0; i !== sizes.length; i++) {
1094     sizes[i] = Math.round(partialTotals[i + 1] * scale) -
1095         Math.round(partialTotals[i] * scale);
1096   }
1097 };
1098
1099 ////////////////////////////////////////////////////////////////////////////////
1100
1101 /**
1102  * Representation of the layout density.
1103  *
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
1108  */
1109 Mosaic.Density = function(horizontal, vertical) {
1110   this.horizontal = horizontal;
1111   this.vertical = vertical;
1112 };
1113
1114 /**
1115  * Minimal horizontal density (tiles per row).
1116  */
1117 Mosaic.Density.MIN_HORIZONTAL = 1;
1118
1119 /**
1120  * Minimal horizontal density (tiles per row).
1121  */
1122 Mosaic.Density.MAX_HORIZONTAL = 3;
1123
1124 /**
1125  * Minimal vertical density: force 1 out of 2 rows to containt a single tile.
1126  */
1127 Mosaic.Density.MIN_VERTICAL = 2;
1128
1129 /**
1130  * Maximal vertical density: force 1 out of 3 rows to containt a single tile.
1131  */
1132 Mosaic.Density.MAX_VERTICAL = 3;
1133
1134 /**
1135  * @return {Mosaic.Density} Lowest density.
1136  */
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 */);
1141 };
1142
1143 /**
1144  * @return {Mosaic.Density} Highest density.
1145  */
1146 Mosaic.Density.createHighest = function() {
1147   return new Mosaic.Density(
1148       Mosaic.Density.MAX_HORIZONTAL,
1149       Mosaic.Density.MAX_VERTICAL);
1150 };
1151
1152 /**
1153  * @return {Mosaic.Density} A clone of this density object.
1154  */
1155 Mosaic.Density.prototype.clone = function() {
1156   return new Mosaic.Density(this.horizontal, this.vertical);
1157 };
1158
1159 /**
1160  * @param {Mosaic.Density} that The other object.
1161  * @return {boolean} True if equal.
1162  */
1163 Mosaic.Density.prototype.equals = function(that) {
1164   return this.horizontal === that.horizontal &&
1165          this.vertical === that.vertical;
1166 };
1167
1168 /**
1169  * Increases the density to the next level.
1170  */
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++;
1179   }
1180 };
1181
1182 /**
1183  * Decreases horizontal density.
1184  */
1185 Mosaic.Density.prototype.decreaseHorizontal = function() {
1186   console.assert(this.horizontal > Mosaic.Density.MIN_HORIZONTAL);
1187   this.horizontal--;
1188 };
1189
1190 /**
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.
1194  */
1195 Mosaic.Density.prototype.isRowComplete = function(tileCount, rowIndex) {
1196   return (tileCount === this.horizontal) || (rowIndex % this.vertical) === 0;
1197 };
1198
1199 ////////////////////////////////////////////////////////////////////////////////
1200
1201 /**
1202  * A column in a mosaic layout. Contains rows.
1203  *
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
1211  */
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;
1220
1221   this.reset_();
1222 };
1223
1224 /**
1225  * Resets the layout.
1226  * @private
1227  */
1228 Mosaic.Column.prototype.reset_ = function() {
1229   this.tiles_ = [];
1230   this.rows_ = [];
1231   this.newRow_ = null;
1232 };
1233
1234 /**
1235  * @return {number} Number of tiles in the column.
1236  */
1237 Mosaic.Column.prototype.getTileCount = function() { return this.tiles_.length };
1238
1239 /**
1240  * @return {number} Index of the last tile + 1.
1241  */
1242 Mosaic.Column.prototype.getNextTileIndex = function() {
1243   return this.firstTileIndex_ + this.getTileCount();
1244 };
1245
1246 /**
1247  * @return {number} Global index of the last row + 1.
1248  */
1249 Mosaic.Column.prototype.getNextRowIndex = function() {
1250   return this.firstRowIndex_ + this.rows_.length;
1251 };
1252
1253 /**
1254  * @return {Array.<Mosaic.Tile>} Array of tiles in the column.
1255  */
1256 Mosaic.Column.prototype.getTiles = function() { return this.tiles_ };
1257
1258 /**
1259  * @param {number} index Tile index.
1260  * @return {boolean} True if this column contains the tile with the given index.
1261  */
1262 Mosaic.Column.prototype.hasTile = function(index) {
1263   return this.firstTileIndex_ <= index &&
1264       index < (this.firstTileIndex_ + this.getTileCount());
1265 };
1266
1267 /**
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
1273  */
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);
1278   }
1279   return -1;
1280 };
1281
1282 /**
1283  * @param {number} index Tile index.
1284  * @return {Mosaic.Row} The row containing the tile with a given index.
1285  */
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];
1290
1291   return null;
1292 };
1293
1294 /**
1295  * Adds a tile to the column.
1296  *
1297  * @param {Mosaic.Tile} tile The tile to add.
1298  */
1299 Mosaic.Column.prototype.add = function(tile) {
1300   var rowIndex = this.getNextRowIndex();
1301
1302   if (!this.newRow_)
1303      this.newRow_ = new Mosaic.Row(this.getNextTileIndex());
1304
1305   this.tiles_.push(tile);
1306   this.newRow_.add(tile);
1307
1308   if (this.density_.isRowComplete(this.newRow_.getTileCount(), rowIndex)) {
1309     this.rows_.push(this.newRow_);
1310     this.newRow_ = null;
1311   }
1312 };
1313
1314 /**
1315  * Prepares the column layout.
1316  *
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.
1320  */
1321 Mosaic.Column.prototype.prepareLayout = function(opt_force) {
1322   if (opt_force && this.newRow_) {
1323     this.rows_.push(this.newRow_);
1324     this.newRow_ = null;
1325   }
1326
1327   if (this.rows_.length === 0)
1328     return false;
1329
1330   this.width_ = Math.min.apply(
1331       null, this.rows_.map(function(row) { return row.getMaxWidth() }));
1332
1333   this.height_ = 0;
1334
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);
1340   }
1341
1342   var overflow = this.height_ / this.maxHeight_;
1343   if (!opt_force && (overflow < 1))
1344     return false;
1345
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_);
1351   }
1352
1353   return true;
1354 };
1355
1356 /**
1357  * Retries the column layout with less tiles per row.
1358  */
1359 Mosaic.Column.prototype.retryWithLowerDensity = function() {
1360   this.density_.decreaseHorizontal();
1361   this.reset_();
1362 };
1363
1364 /**
1365  * @return {number} Column left edge coordinate.
1366  */
1367 Mosaic.Column.prototype.getLeft = function() { return this.left_ };
1368
1369 /**
1370  * @return {number} Column right edge coordinate after the layout.
1371  */
1372 Mosaic.Column.prototype.getRight = function() {
1373   return this.left_ + this.width_;
1374 };
1375
1376 /**
1377  * @return {number} Column height after the layout.
1378  */
1379 Mosaic.Column.prototype.getHeight = function() { return this.height_ };
1380
1381 /**
1382  * Performs the column layout.
1383  * @param {number=} opt_offsetX Horizontal offset.
1384  * @param {number=} opt_offsetY Vertical offset.
1385  */
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];
1397   }
1398 };
1399
1400 /**
1401  * Checks if the column layout is too ugly to be displayed.
1402  *
1403  * @return {boolean} True if the layout is suboptimal.
1404  */
1405 Mosaic.Column.prototype.isSuboptimal = function() {
1406   var tileCounts =
1407       this.rows_.map(function(row) { return row.getTileCount() });
1408
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.
1412
1413   var sizes =
1414       this.tiles_.map(function(tile) { return tile.getMaxContentHeight() });
1415
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;
1420
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;
1426
1427   // Ugly layout #3: some rows have too many tiles for the resulting width.
1428   if (this.width_ / maxTileCount < 100)
1429     return true;
1430
1431   return false;
1432 };
1433
1434 ////////////////////////////////////////////////////////////////////////////////
1435
1436 /**
1437  * A row in a mosaic layout. Contains tiles.
1438  *
1439  * @param {number} firstTileIndex Index of the first tile in the row.
1440  * @constructor
1441  */
1442 Mosaic.Row = function(firstTileIndex) {
1443   this.firstTileIndex_ = firstTileIndex;
1444   this.tiles_ = [];
1445 };
1446
1447 /**
1448  * @param {Mosaic.Tile} tile The tile to add.
1449  */
1450 Mosaic.Row.prototype.add = function(tile) {
1451   console.assert(this.getTileCount() < Mosaic.Density.MAX_HORIZONTAL);
1452   this.tiles_.push(tile);
1453 };
1454
1455 /**
1456  * @return {Array.<Mosaic.Tile>} Array of tiles in the row.
1457  */
1458 Mosaic.Row.prototype.getTiles = function() { return this.tiles_ };
1459
1460 /**
1461  * Gets a tile by index.
1462  * @param {number} index Tile index.
1463  * @return {Mosaic.Tile} Requested tile or null if not found.
1464  */
1465 Mosaic.Row.prototype.getTileByIndex = function(index) {
1466   if (!this.hasTile(index))
1467     return null;
1468   return this.tiles_[index - this.firstTileIndex_];
1469 };
1470
1471 /**
1472  *
1473  * @return {number} Number of tiles in the row.
1474  */
1475 Mosaic.Row.prototype.getTileCount = function() { return this.tiles_.length };
1476
1477 /**
1478  * @param {number} index Tile index.
1479  * @return {boolean} True if this row contains the tile with the given index.
1480  */
1481 Mosaic.Row.prototype.hasTile = function(index) {
1482   return this.firstTileIndex_ <= index &&
1483       index < (this.firstTileIndex_ + this.tiles_.length);
1484 };
1485
1486 /**
1487  * @param {number} y Y coordinate.
1488  * @return {boolean} True if this row covers the given Y coordinate.
1489  */
1490 Mosaic.Row.prototype.coversY = function(y) {
1491   return this.top_ <= y && y < (this.top_ + this.height_);
1492 };
1493
1494 /**
1495  * @return {number} Y coordinate of the tile center.
1496  */
1497 Mosaic.Row.prototype.getCenterY = function() {
1498   return this.top_ + Math.round(this.height_ / 2);
1499 };
1500
1501 /**
1502  * Gets the first or the last tile.
1503  *
1504  * @param {number} direction -1 for the first tile, 1 for the last tile.
1505  * @return {number} Tile index.
1506  * @private
1507  */
1508 Mosaic.Row.prototype.getEdgeTileIndex_ = function(direction) {
1509   if (direction < 0)
1510     return this.firstTileIndex_;
1511   else
1512     return this.firstTileIndex_ + this.getTileCount() - 1;
1513 };
1514
1515 /**
1516  * @return {number} Aspect ration of the combined content box of this row.
1517  * @private
1518  */
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;
1524 };
1525
1526 /**
1527  * @return {number} Total horizontal spacing in this row. This includes
1528  *   the spacing between the tiles and both left and right margins.
1529  *
1530  * @private
1531  */
1532 Mosaic.Row.prototype.getTotalHorizontalSpacing_ = function() {
1533   return Mosaic.Layout.SPACING * this.getTileCount();
1534 };
1535
1536 /**
1537  * @return {number} Maximum width that this row may have without overscaling
1538  * any of the tiles.
1539  */
1540 Mosaic.Row.prototype.getMaxWidth = function() {
1541   var contentHeight = Math.min.apply(null,
1542       this.tiles_.map(function(tile) { return tile.getMaxContentHeight() }));
1543
1544   var contentWidth =
1545       Math.round(contentHeight * this.getTotalContentAspectRatio_());
1546   return contentWidth + this.getTotalHorizontalSpacing_();
1547 };
1548
1549 /**
1550  * Computes the height that best fits the supplied row width given
1551  * aspect ratios of the tiles in this row.
1552  *
1553  * @param {number} width Row width.
1554  * @return {number} Height.
1555  */
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;
1561 };
1562
1563 /**
1564  * Positions the row in the mosaic.
1565  *
1566  * @param {number} left Left position.
1567  * @param {number} top Top position.
1568  * @param {number} width Width.
1569  * @param {number} height Height.
1570  */
1571 Mosaic.Row.prototype.layout = function(left, top, width, height) {
1572   this.top_ = top;
1573   this.height_ = height;
1574
1575   var contentWidth = width - this.getTotalHorizontalSpacing_();
1576   var contentHeight = height - Mosaic.Layout.SPACING;
1577
1578   var tileContentWidth = this.tiles_.map(
1579       function(tile) { return tile.getAspectRatio() });
1580
1581   Mosaic.Layout.rescaleSizesToNewTotal(tileContentWidth, contentWidth);
1582
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;
1588   }
1589 };
1590
1591 ////////////////////////////////////////////////////////////////////////////////
1592
1593 /**
1594  * A single tile of the image mosaic.
1595  *
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
1601  */
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;
1606 };
1607
1608 /**
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.
1613  */
1614 Mosaic.Tile.decorate = function(self, container, item, locationInfo) {
1615   self.__proto__ = Mosaic.Tile.prototype;
1616   self.className = 'mosaic-tile';
1617
1618   self.container_ = container;
1619   self.item_ = item;
1620   self.left_ = null; // Mark as not laid out.
1621   self.hidpiEmbedded_ = locationInfo && locationInfo.isDriveBased;
1622 };
1623
1624 /**
1625  * Load mode for the tile's image.
1626  * @enum {number}
1627  */
1628 Mosaic.Tile.LoadMode = {
1629   LOW_DPI: 0,
1630   HIGH_DPI: 1
1631 };
1632
1633 /**
1634 * Inherit from HTMLDivElement.
1635 */
1636 Mosaic.Tile.prototype.__proto__ = HTMLDivElement.prototype;
1637
1638 /**
1639  * Minimum tile content size.
1640  */
1641 Mosaic.Tile.MIN_CONTENT_SIZE = 64;
1642
1643 /**
1644  * Maximum tile content size.
1645  */
1646 Mosaic.Tile.MAX_CONTENT_SIZE = 512;
1647
1648 /**
1649  * Default size for a tile with no thumbnail image.
1650  */
1651 Mosaic.Tile.GENERIC_ICON_SIZE = 128;
1652
1653 /**
1654  * Max size of an image considered to be 'small'.
1655  * Small images are laid out slightly differently.
1656  */
1657 Mosaic.Tile.SMALL_IMAGE_SIZE = 160;
1658
1659 /**
1660  * @return {Gallery.Item} The Gallery item.
1661  */
1662 Mosaic.Tile.prototype.getItem = function() { return this.item_; };
1663
1664 /**
1665  * @return {number} Maximum content height that this tile can have.
1666  */
1667 Mosaic.Tile.prototype.getMaxContentHeight = function() {
1668   return this.maxContentHeight_;
1669 };
1670
1671 /**
1672  * @return {number} The aspect ratio of the tile image.
1673  */
1674 Mosaic.Tile.prototype.getAspectRatio = function() { return this.aspectRatio_; };
1675
1676 /**
1677  * @return {boolean} True if the tile is initialized.
1678  */
1679 Mosaic.Tile.prototype.isInitialized = function() {
1680   return !!this.maxContentHeight_;
1681 };
1682
1683 /**
1684  * Checks whether the image of specified (or better resolution) has been loaded.
1685  *
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.
1689  */
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;
1701   }
1702   return false;
1703 };
1704
1705 /**
1706  * Checks whether the image of specified (or better resolution) is being loaded.
1707  *
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.
1711  */
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;
1723   }
1724   return false;
1725 };
1726
1727 /**
1728  * Marks the tile as not loaded to prevent it from participating in the layout.
1729  */
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;
1738   }
1739 };
1740
1741 /**
1742  * Initializes the thumbnail in the tile. Does not load an image, but sets
1743  * target dimensions using metadata.
1744  */
1745 Mosaic.Tile.prototype.init = function() {
1746   var metadata = this.getItem().getMetadata();
1747   this.markUnloaded();
1748   this.left_ = null;  // Mark as not laid out.
1749
1750   // Set higher priority for the selected elements to load them first.
1751   var priority = this.getAttribute('selected') ? 2 : 3;
1752
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);
1763
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.
1775   }
1776
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;
1794   }
1795
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;
1800     }
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;
1805     }
1806   }
1807   this.maxContentHeight_ = Math.max(Mosaic.Tile.MIN_CONTENT_SIZE, height);
1808   this.aspectRatio_ = width / height;
1809 };
1810
1811 /**
1812  * Loads an image into the tile.
1813  *
1814  * The mode argument is a hint. Use low-dpi for faster response, and high-dpi
1815  * for better output, but possibly affecting performance.
1816  *
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.
1821  *
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.
1825  */
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');
1837     }
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;
1849     }
1850   }.bind(this);
1851
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));
1864   }
1865
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));
1879   }
1880 };
1881
1882 /**
1883  * Unloads an image from the tile.
1884  */
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 = '';
1894 };
1895
1896 /**
1897  * Selects/unselects the tile.
1898  *
1899  * @param {boolean} on True if selected.
1900  */
1901 Mosaic.Tile.prototype.select = function(on) {
1902   if (on)
1903     this.setAttribute('selected', true);
1904   else
1905     this.removeAttribute('selected');
1906 };
1907
1908 /**
1909  * Positions the tile in the mosaic.
1910  *
1911  * @param {number} left Left position.
1912  * @param {number} top Top position.
1913  * @param {number} width Width.
1914  * @param {number} height Height.
1915  */
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;
1921
1922   this.style.left = left + 'px';
1923   this.style.top = top + 'px';
1924   this.style.width = width + 'px';
1925   this.style.height = height + 'px';
1926
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');
1931   }
1932   if (this.hasAttribute('selected'))
1933     this.scrollIntoView(false);
1934
1935   if (this.imageLoaded_) {
1936     this.thumbnailLoader_.attachImage(this.wrapper_,
1937                                       ThumbnailLoader.FillMode.OVER_FILL);
1938   }
1939 };
1940
1941 /**
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.
1945  */
1946 Mosaic.Tile.prototype.scrollIntoView = function(opt_animated) {
1947   if (this.left_ === null)  // Not laid out.
1948     return;
1949
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;
1959   }
1960
1961   if (targetPosition) {
1962     if (opt_animated === false)
1963       this.container_.scrollLeft = targetPosition;
1964     else
1965       this.container_.animatedScrollTo(targetPosition);
1966   }
1967 };
1968
1969 /**
1970  * @return {Rect} Rectangle occupied by the tile's image,
1971  *   relative to the viewport.
1972  */
1973 Mosaic.Tile.prototype.getImageRect = function() {
1974   if (this.left_ === null)  // Not laid out.
1975     return null;
1976
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);
1980 };
1981
1982 /**
1983  * @return {number} X coordinate of the tile center.
1984  */
1985 Mosaic.Tile.prototype.getCenterX = function() {
1986   return this.left_ + Math.round(this.width_ / 2);
1987 };