1// Copyright (c) 2012 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 {MetadataCache} metadataCache Metadata cache.
12 * @param {function} toggleMode Function to switch to the Slide mode.
13 * @constructor
14 */
15function MosaicMode(
16    container, dataModel, selectionModel, metadataCache, toggleMode) {
17  this.mosaic_ = new Mosaic(
18      container.ownerDocument, dataModel, selectionModel, metadataCache);
19  container.appendChild(this.mosaic_);
20
21  this.toggleMode_ = toggleMode;
22  this.mosaic_.addEventListener('dblclick', this.toggleMode_);
23}
24
25/**
26 * @return {Mosaic} The mosaic control.
27 */
28MosaicMode.prototype.getMosaic = function() { return this.mosaic_ };
29
30/**
31 * @return {string} Mode name.
32 */
33MosaicMode.prototype.getName = function() { return 'mosaic' };
34
35/**
36 * @return {string} Mode title.
37 */
38MosaicMode.prototype.getTitle = function() { return 'GALLERY_MOSAIC' };
39
40/**
41 * Execute an action (this mode has no busy state).
42 * @param {function} action Action to execute.
43 */
44MosaicMode.prototype.executeWhenReady = function(action) { action() };
45
46/**
47 * @return {boolean} Always true (no toolbar fading in this mode).
48 */
49MosaicMode.prototype.hasActiveTool = function() { return true };
50
51/**
52 * Keydown handler.
53 *
54 * @param {Event} event Event.
55 * @return {boolean} True if processed.
56 */
57MosaicMode.prototype.onKeyDown = function(event) {
58  switch (util.getKeyModifiers(event) + event.keyIdentifier) {
59    case 'Enter':
60      this.toggleMode_();
61      return true;
62  }
63  return this.mosaic_.onKeyDown(event);
64};
65
66////////////////////////////////////////////////////////////////////////////////
67
68/**
69 * Mosaic control.
70 *
71 * @param {Document} document Document.
72 * @param {cr.ui.ArrayDataModel} dataModel Data model.
73 * @param {cr.ui.ListSelectionModel} selectionModel Selection model.
74 * @param {MetadataCache} metadataCache Metadata cache.
75 * @return {Element} Mosaic element.
76 * @constructor
77 */
78function Mosaic(document, dataModel, selectionModel, metadataCache) {
79  var self = document.createElement('div');
80  Mosaic.decorate(self, dataModel, selectionModel, metadataCache);
81  return self;
82}
83
84/**
85 * Inherit from HTMLDivElement.
86 */
87Mosaic.prototype.__proto__ = HTMLDivElement.prototype;
88
89/**
90 * Default layout delay in ms.
91 * @const
92 * @type {number}
93 */
94Mosaic.LAYOUT_DELAY = 200;
95
96/**
97 * Smooth scroll animation duration when scrolling using keyboard or
98 * clicking on a partly visible tile. In ms.
99 * @const
100 * @type {number}
101 */
102Mosaic.ANIMATED_SCROLL_DURATION = 500;
103
104/**
105 * Decorate a Mosaic instance.
106 *
107 * @param {Mosaic} self Self pointer.
108 * @param {cr.ui.ArrayDataModel} dataModel Data model.
109 * @param {cr.ui.ListSelectionModel} selectionModel Selection model.
110 * @param {MetadataCache} metadataCache Metadata cache.
111 */
112Mosaic.decorate = function(self, dataModel, selectionModel, metadataCache) {
113  self.__proto__ = Mosaic.prototype;
114  self.className = 'mosaic';
115
116  self.dataModel_ = dataModel;
117  self.selectionModel_ = selectionModel;
118  self.metadataCache_ = metadataCache;
119
120  // Initialization is completed lazily on the first call to |init|.
121};
122
123/**
124 * Initialize the mosaic element.
125 */
126Mosaic.prototype.init = function() {
127  if (this.tiles_)
128    return; // Already initialized, nothing to do.
129
130  this.layoutModel_ = new Mosaic.Layout();
131  this.onResize_();
132
133  this.selectionController_ =
134      new Mosaic.SelectionController(this.selectionModel_, this.layoutModel_);
135
136  this.tiles_ = [];
137  for (var i = 0; i != this.dataModel_.length; i++)
138    this.tiles_.push(new Mosaic.Tile(this, this.dataModel_.item(i)));
139
140  this.selectionModel_.selectedIndexes.forEach(function(index) {
141    this.tiles_[index].select(true);
142  }.bind(this));
143
144  this.initTiles_(this.tiles_);
145
146  // The listeners might be called while some tiles are still loading.
147  this.initListeners_();
148};
149
150/**
151 * @return {boolean} Whether mosaic is initialized.
152 */
153Mosaic.prototype.isInitialized = function() {
154  return !!this.tiles_;
155};
156
157/**
158 * Start listening to events.
159 *
160 * We keep listening to events even when the mosaic is hidden in order to
161 * keep the layout up to date.
162 *
163 * @private
164 */
165Mosaic.prototype.initListeners_ = function() {
166  this.ownerDocument.defaultView.addEventListener(
167      'resize', this.onResize_.bind(this));
168
169  var mouseEventBound = this.onMouseEvent_.bind(this);
170  this.addEventListener('mousemove', mouseEventBound);
171  this.addEventListener('mousedown', mouseEventBound);
172  this.addEventListener('mouseup', mouseEventBound);
173  this.addEventListener('scroll', this.onScroll_.bind(this));
174
175  this.selectionModel_.addEventListener('change', this.onSelection_.bind(this));
176  this.selectionModel_.addEventListener('leadIndexChange',
177      this.onLeadChange_.bind(this));
178
179  this.dataModel_.addEventListener('splice', this.onSplice_.bind(this));
180  this.dataModel_.addEventListener('content', this.onContentChange_.bind(this));
181};
182
183/**
184 * Smoothly scrolls the container to the specified position using
185 * f(x) = sqrt(x) speed function normalized to animation duration.
186 * @param {number} targetPosition Horizontal scroll position in pixels.
187 */
188Mosaic.prototype.animatedScrollTo = function(targetPosition) {
189  if (this.scrollAnimation_) {
190    webkitCancelAnimationFrame(this.scrollAnimation_);
191    this.scrollAnimation_ = null;
192  }
193
194  // Mouse move events are fired without touching the mouse because of scrolling
195  // the container. Therefore, these events have to be suppressed.
196  this.suppressHovering_ = true;
197
198  // Calculates integral area from t1 to t2 of f(x) = sqrt(x) dx.
199  var integral = function(t1, t2) {
200    return 2.0 / 3.0 * Math.pow(t2, 3.0 / 2.0) -
201           2.0 / 3.0 * Math.pow(t1, 3.0 / 2.0);
202  };
203
204  var delta = targetPosition - this.scrollLeft;
205  var factor = delta / integral(0, Mosaic.ANIMATED_SCROLL_DURATION);
206  var startTime = Date.now();
207  var lastPosition = 0;
208  var scrollOffset = this.scrollLeft;
209
210  var animationFrame = function() {
211    var position = Date.now() - startTime;
212    var step = factor *
213        integral(Math.max(0, Mosaic.ANIMATED_SCROLL_DURATION - position),
214                 Math.max(0, Mosaic.ANIMATED_SCROLL_DURATION - lastPosition));
215    scrollOffset += step;
216
217    var oldScrollLeft = this.scrollLeft;
218    var newScrollLeft = Math.round(scrollOffset);
219
220    if (oldScrollLeft != newScrollLeft)
221      this.scrollLeft = newScrollLeft;
222
223    if (step == 0 || this.scrollLeft != newScrollLeft) {
224      this.scrollAnimation_ = null;
225      // Release the hovering lock after a safe delay to avoid hovering
226      // a tile because of altering |this.scrollLeft|.
227      setTimeout(function() {
228        if (!this.scrollAnimation_)
229          this.suppressHovering_ = false;
230      }.bind(this), 100);
231    } else {
232      // Continue the animation.
233      this.scrollAnimation_ = requestAnimationFrame(animationFrame);
234    }
235
236    lastPosition = position;
237  }.bind(this);
238
239  // Start the animation.
240  this.scrollAnimation_ = requestAnimationFrame(animationFrame);
241};
242
243/**
244 * @return {Mosaic.Tile} Selected tile or undefined if no selection.
245 */
246Mosaic.prototype.getSelectedTile = function() {
247  return this.tiles_ && this.tiles_[this.selectionModel_.selectedIndex];
248};
249
250/**
251 * @param {number} index Tile index.
252 * @return {Rect} Tile's image rectangle.
253 */
254Mosaic.prototype.getTileRect = function(index) {
255  var tile = this.tiles_[index];
256  return tile && tile.getImageRect();
257};
258
259/**
260 * @param {number} index Tile index.
261 * Scroll the given tile into the viewport.
262 */
263Mosaic.prototype.scrollIntoView = function(index) {
264  var tile = this.tiles_[index];
265  if (tile) tile.scrollIntoView();
266};
267
268/**
269 * Initializes multiple tiles.
270 *
271 * @param {Array.<Mosaic.Tile>} tiles Array of tiles.
272 * @param {function()=} opt_callback Completion callback.
273 * @private
274 */
275Mosaic.prototype.initTiles_ = function(tiles, opt_callback) {
276  // We do not want to use tile indices in asynchronous operations because they
277  // do not survive data model splices. Copy tile references instead.
278  tiles = tiles.slice();
279
280  // Throttle the metadata access so that we do not overwhelm the file system.
281  var MAX_CHUNK_SIZE = 10;
282
283  var loadChunk = function() {
284    if (!tiles.length) {
285      if (opt_callback) opt_callback();
286      return;
287    }
288    var chunkSize = Math.min(tiles.length, MAX_CHUNK_SIZE);
289    var loaded = 0;
290    for (var i = 0; i != chunkSize; i++) {
291      this.initTile_(tiles.shift(), function() {
292        if (++loaded == chunkSize) {
293          this.layout();
294          loadChunk();
295        }
296      }.bind(this));
297    }
298  }.bind(this);
299
300  loadChunk();
301};
302
303/**
304 * Initializes a single tile.
305 *
306 * @param {Mosaic.Tile} tile Tile.
307 * @param {function()} callback Completion callback.
308 * @private
309 */
310Mosaic.prototype.initTile_ = function(tile, callback) {
311  var url = tile.getItem().getUrl();
312  var onImageMeasured = callback;
313  this.metadataCache_.get(url, Gallery.METADATA_TYPE,
314      function(metadata) {
315        tile.init(metadata, onImageMeasured);
316      });
317};
318
319/**
320 * Reload all tiles.
321 */
322Mosaic.prototype.reload = function() {
323  this.layoutModel_.reset_();
324  this.tiles_.forEach(function(t) { t.markUnloaded() });
325  this.initTiles_(this.tiles_);
326};
327
328/**
329 * Layout the tiles in the order of their indices.
330 *
331 * Starts where it last stopped (at #0 the first time).
332 * Stops when all tiles are processed or when the next tile is still loading.
333 */
334Mosaic.prototype.layout = function() {
335  if (this.layoutTimer_) {
336    clearTimeout(this.layoutTimer_);
337    this.layoutTimer_ = null;
338  }
339  while (true) {
340    var index = this.layoutModel_.getTileCount();
341    if (index == this.tiles_.length)
342      break; // All tiles done.
343    var tile = this.tiles_[index];
344    if (!tile.isInitialized())
345      break;  // Next layout will try to restart from here.
346    this.layoutModel_.add(tile, index + 1 == this.tiles_.length);
347  }
348  this.loadVisibleTiles_();
349};
350
351/**
352 * Schedule the layout.
353 *
354 * @param {number=} opt_delay Delay in ms.
355 */
356Mosaic.prototype.scheduleLayout = function(opt_delay) {
357  if (!this.layoutTimer_) {
358    this.layoutTimer_ = setTimeout(function() {
359      this.layoutTimer_ = null;
360      this.layout();
361    }.bind(this), opt_delay || 0);
362  }
363};
364
365/**
366 * Resize handler.
367 *
368 * @private
369 */
370Mosaic.prototype.onResize_ = function() {
371  this.layoutModel_.setViewportSize(this.clientWidth, this.clientHeight -
372      (Mosaic.Layout.PADDING_TOP + Mosaic.Layout.PADDING_BOTTOM));
373  this.scheduleLayout();
374};
375
376/**
377 * Mouse event handler.
378 *
379 * @param {Event} event Event.
380 * @private
381 */
382Mosaic.prototype.onMouseEvent_ = function(event) {
383  // Navigating with mouse, enable hover state.
384  if (!this.suppressHovering_)
385    this.classList.add('hover-visible');
386
387  if (event.type == 'mousemove')
388    return;
389
390  var index = -1;
391  for (var target = event.target;
392       target && (target != this);
393       target = target.parentNode) {
394    if (target.classList.contains('mosaic-tile')) {
395      index = this.dataModel_.indexOf(target.getItem());
396      break;
397    }
398  }
399  this.selectionController_.handlePointerDownUp(event, index);
400};
401
402/**
403 * Scroll handler.
404 * @private
405 */
406Mosaic.prototype.onScroll_ = function() {
407  requestAnimationFrame(function() {
408    this.loadVisibleTiles_();
409  }.bind(this));
410};
411
412/**
413 * Selection change handler.
414 *
415 * @param {Event} event Event.
416 * @private
417 */
418Mosaic.prototype.onSelection_ = function(event) {
419  for (var i = 0; i != event.changes.length; i++) {
420    var change = event.changes[i];
421    var tile = this.tiles_[change.index];
422    if (tile) tile.select(change.selected);
423  }
424};
425
426/**
427 * Lead item change handler.
428 *
429 * @param {Event} event Event.
430 * @private
431 */
432Mosaic.prototype.onLeadChange_ = function(event) {
433  var index = event.newValue;
434  if (index >= 0) {
435    var tile = this.tiles_[index];
436    if (tile) tile.scrollIntoView();
437  }
438};
439
440/**
441 * Splice event handler.
442 *
443 * @param {Event} event Event.
444 * @private
445 */
446Mosaic.prototype.onSplice_ = function(event) {
447  var index = event.index;
448  this.layoutModel_.invalidateFromTile_(index);
449
450  if (event.removed.length) {
451    for (var t = 0; t != event.removed.length; t++)
452      this.removeChild(this.tiles_[index + t]);
453
454    this.tiles_.splice(index, event.removed.length);
455    this.scheduleLayout(Mosaic.LAYOUT_DELAY);
456  }
457
458  if (event.added.length) {
459    var newTiles = [];
460    for (var t = 0; t != event.added.length; t++)
461      newTiles.push(new Mosaic.Tile(this, this.dataModel_.item(index + t)));
462
463    this.tiles_.splice.apply(this.tiles_, [index, 0].concat(newTiles));
464    this.initTiles_(newTiles);
465  }
466
467  if (this.tiles_.length != this.dataModel_.length)
468    console.error('Mosaic is out of sync');
469};
470
471/**
472 * Content change handler.
473 *
474 * @param {Event} event Event.
475 * @private
476 */
477Mosaic.prototype.onContentChange_ = function(event) {
478  if (!this.tiles_)
479    return;
480
481  if (!event.metadata)
482    return; // Thumbnail unchanged, nothing to do.
483
484  var index = this.dataModel_.indexOf(event.item);
485  if (index != this.selectionModel_.selectedIndex)
486    console.error('Content changed for unselected item');
487
488  this.layoutModel_.invalidateFromTile_(index);
489  this.tiles_[index].init(event.metadata, function() {
490        this.tiles_[index].unload();
491        this.tiles_[index].load(
492            Mosaic.Tile.LoadMode.HIGH_DPI,
493            this.scheduleLayout.bind(this, Mosaic.LAYOUT_DELAY));
494      }.bind(this));
495};
496
497/**
498 * Keydown event handler.
499 *
500 * @param {Event} event Event.
501 * @return {boolean} True if the event has been consumed.
502 */
503Mosaic.prototype.onKeyDown = function(event) {
504  this.selectionController_.handleKeyDown(event);
505  if (event.defaultPrevented)  // Navigating with keyboard, hide hover state.
506    this.classList.remove('hover-visible');
507  return event.defaultPrevented;
508};
509
510/**
511 * @return {boolean} True if the mosaic zoom effect can be applied. It is
512 * too slow if there are to many images.
513 * TODO(kaznacheev): Consider unloading the images that are out of the viewport.
514 */
515Mosaic.prototype.canZoom = function() {
516  return this.tiles_.length < 100;
517};
518
519/**
520 * Show the mosaic.
521 */
522Mosaic.prototype.show = function() {
523  var duration = ImageView.MODE_TRANSITION_DURATION;
524  if (this.canZoom()) {
525    // Fade in in parallel with the zoom effect.
526    this.setAttribute('visible', 'zooming');
527  } else {
528    // Mosaic is not animating but the large image is. Fade in the mosaic
529    // shortly before the large image animation is done.
530    duration -= 100;
531  }
532  setTimeout(function() {
533    // Make the selection visible.
534    // If the mosaic is not animated it will start fading in now.
535    this.setAttribute('visible', 'normal');
536    this.loadVisibleTiles_();
537  }.bind(this), duration);
538};
539
540/**
541 * Hide the mosaic.
542 */
543Mosaic.prototype.hide = function() {
544  this.removeAttribute('visible');
545};
546
547/**
548 * Checks if the mosaic view is visible.
549 * @return {boolean} True if visible, false otherwise.
550 * @private
551 */
552Mosaic.prototype.isVisible_ = function() {
553  return this.hasAttribute('visible');
554};
555
556/**
557 * Loads visible tiles. Ignores consecutive calls. Does not reload already
558 * loaded images.
559 * @private
560 */
561Mosaic.prototype.loadVisibleTiles_ = function() {
562  if (this.loadVisibleTilesSuppressed_) {
563    this.loadVisibleTilesScheduled_ = true;
564    return;
565  }
566
567  this.loadVisibleTilesSuppressed_ = true;
568  this.loadVisibleTilesScheduled_ = false;
569  setTimeout(function() {
570    this.loadVisibleTilesSuppressed_ = false;
571    if (this.loadVisibleTilesScheduled_)
572      this.loadVisibleTiles_();
573  }.bind(this), 100);
574
575  // Tiles only in the viewport (visible).
576  var visibleRect = new Rect(0,
577                             0,
578                             this.clientWidth,
579                             this.clientHeight);
580
581  // Tiles in the viewport and also some distance on the left and right.
582  var renderableRect = new Rect(-this.clientWidth,
583                                0,
584                                3 * this.clientWidth,
585                                this.clientHeight);
586
587  // Unload tiles out of scope.
588  for (var index = 0; index < this.tiles_.length; index++) {
589    var tile = this.tiles_[index];
590    var imageRect = tile.getImageRect();
591    // Unload a thumbnail.
592    if (imageRect && !imageRect.intersects(renderableRect))
593      tile.unload();
594  }
595
596  // Load the visible tiles first.
597  var allVisibleLoaded = true;
598  // Show high-dpi only when the mosaic view is visible.
599  var loadMode = this.isVisible_() ? Mosaic.Tile.LoadMode.HIGH_DPI :
600      Mosaic.Tile.LoadMode.LOW_DPI;
601  for (var index = 0; index < this.tiles_.length; index++) {
602    var tile = this.tiles_[index];
603    var imageRect = tile.getImageRect();
604    // Load a thumbnail.
605    if (!tile.isLoading(loadMode) && !tile.isLoaded(loadMode) && imageRect &&
606        imageRect.intersects(visibleRect)) {
607      tile.load(loadMode, function() {});
608      allVisibleLoaded = false;
609    }
610  }
611
612  // Load also another, nearby, if the visible has been already loaded.
613  if (allVisibleLoaded) {
614    for (var index = 0; index < this.tiles_.length; index++) {
615      var tile = this.tiles_[index];
616      var imageRect = tile.getImageRect();
617      // Load a thumbnail.
618      if (!tile.isLoading() && !tile.isLoaded() && imageRect &&
619          imageRect.intersects(renderableRect)) {
620        tile.load(Mosaic.Tile.LoadMode.LOW_DPI, function() {});
621      }
622    }
623  }
624};
625
626/**
627 * Apply or reset the zoom transform.
628 *
629 * @param {Rect} tileRect Tile rectangle. Reset the transform if null.
630 * @param {Rect} imageRect Large image rectangle. Reset the transform if null.
631 * @param {boolean=} opt_instant True of the transition should be instant.
632 */
633Mosaic.prototype.transform = function(tileRect, imageRect, opt_instant) {
634  if (opt_instant) {
635    this.style.webkitTransitionDuration = '0';
636  } else {
637    this.style.webkitTransitionDuration =
638        ImageView.MODE_TRANSITION_DURATION + 'ms';
639  }
640
641  if (this.canZoom() && tileRect && imageRect) {
642    var scaleX = imageRect.width / tileRect.width;
643    var scaleY = imageRect.height / tileRect.height;
644    var shiftX = (imageRect.left + imageRect.width / 2) -
645        (tileRect.left + tileRect.width / 2);
646    var shiftY = (imageRect.top + imageRect.height / 2) -
647        (tileRect.top + tileRect.height / 2);
648    this.style.webkitTransform =
649        'translate(' + shiftX * scaleX + 'px, ' + shiftY * scaleY + 'px)' +
650        'scaleX(' + scaleX + ') scaleY(' + scaleY + ')';
651  } else {
652    this.style.webkitTransform = '';
653  }
654};
655
656////////////////////////////////////////////////////////////////////////////////
657
658/**
659 * Creates a selection controller that is to be used with grid.
660 * @param {cr.ui.ListSelectionModel} selectionModel The selection model to
661 *     interact with.
662 * @param {Mosaic.Layout} layoutModel The layout model to use.
663 * @constructor
664 * @extends {!cr.ui.ListSelectionController}
665 */
666Mosaic.SelectionController = function(selectionModel, layoutModel) {
667  cr.ui.ListSelectionController.call(this, selectionModel);
668  this.layoutModel_ = layoutModel;
669};
670
671/**
672 * Extends cr.ui.ListSelectionController.
673 */
674Mosaic.SelectionController.prototype.__proto__ =
675    cr.ui.ListSelectionController.prototype;
676
677/** @override */
678Mosaic.SelectionController.prototype.getLastIndex = function() {
679  return this.layoutModel_.getLaidOutTileCount() - 1;
680};
681
682/** @override */
683Mosaic.SelectionController.prototype.getIndexBefore = function(index) {
684  return this.layoutModel_.getHorizontalAdjacentIndex(index, -1);
685};
686
687/** @override */
688Mosaic.SelectionController.prototype.getIndexAfter = function(index) {
689  return this.layoutModel_.getHorizontalAdjacentIndex(index, 1);
690};
691
692/** @override */
693Mosaic.SelectionController.prototype.getIndexAbove = function(index) {
694  return this.layoutModel_.getVerticalAdjacentIndex(index, -1);
695};
696
697/** @override */
698Mosaic.SelectionController.prototype.getIndexBelow = function(index) {
699  return this.layoutModel_.getVerticalAdjacentIndex(index, 1);
700};
701
702////////////////////////////////////////////////////////////////////////////////
703
704/**
705 * Mosaic layout.
706 *
707 * @param {string=} opt_mode Layout mode.
708 * @param {Mosaic.Density=} opt_maxDensity Layout density.
709 * @constructor
710 */
711Mosaic.Layout = function(opt_mode, opt_maxDensity) {
712  this.mode_ = opt_mode || Mosaic.Layout.MODE_TENTATIVE;
713  this.maxDensity_ = opt_maxDensity || Mosaic.Density.createHighest();
714  this.reset_();
715};
716
717/**
718 * Blank space at the top of the mosaic element. We do not do that in CSS
719 * to make transition effects easier.
720 */
721Mosaic.Layout.PADDING_TOP = 50;
722
723/**
724 * Blank space at the bottom of the mosaic element.
725 */
726Mosaic.Layout.PADDING_BOTTOM = 50;
727
728/**
729 * Horizontal and vertical spacing between images. Should be kept in sync
730 * with the style of .mosaic-item in gallery.css (= 2 * ( 4 + 1))
731 */
732Mosaic.Layout.SPACING = 10;
733
734/**
735 * Margin for scrolling using keyboard. Distance between a selected tile
736 * and window border.
737 */
738Mosaic.Layout.SCROLL_MARGIN = 30;
739
740/**
741 * Layout mode: commit to DOM immediately.
742 */
743Mosaic.Layout.MODE_FINAL = 'final';
744
745/**
746 * Layout mode: do not commit layout to DOM until it is complete or the viewport
747 * overflows.
748 */
749Mosaic.Layout.MODE_TENTATIVE = 'tentative';
750
751/**
752 * Layout mode: never commit layout to DOM.
753 */
754Mosaic.Layout.MODE_DRY_RUN = 'dry_run';
755
756/**
757 * Reset the layout.
758 *
759 * @private
760 */
761Mosaic.Layout.prototype.reset_ = function() {
762  this.columns_ = [];
763  this.newColumn_ = null;
764  this.density_ = Mosaic.Density.createLowest();
765  if (this.mode_ != Mosaic.Layout.MODE_DRY_RUN)  // DRY_RUN is sticky.
766    this.mode_ = Mosaic.Layout.MODE_TENTATIVE;
767};
768
769/**
770 * @param {number} width Viewport width.
771 * @param {number} height Viewport height.
772 */
773Mosaic.Layout.prototype.setViewportSize = function(width, height) {
774  this.viewportWidth_ = width;
775  this.viewportHeight_ = height;
776  this.reset_();
777};
778
779/**
780 * @return {number} Total width of the layout.
781 */
782Mosaic.Layout.prototype.getWidth = function() {
783  var lastColumn = this.getLastColumn_();
784  return lastColumn ? lastColumn.getRight() : 0;
785};
786
787/**
788 * @return {number} Total height of the layout.
789 */
790Mosaic.Layout.prototype.getHeight = function() {
791  var firstColumn = this.columns_[0];
792  return firstColumn ? firstColumn.getHeight() : 0;
793};
794
795/**
796 * @return {Array.<Mosaic.Tile>} All tiles in the layout.
797 */
798Mosaic.Layout.prototype.getTiles = function() {
799  return Array.prototype.concat.apply([],
800      this.columns_.map(function(c) { return c.getTiles() }));
801};
802
803/**
804 * @return {number} Total number of tiles added to the layout.
805 */
806Mosaic.Layout.prototype.getTileCount = function() {
807  return this.getLaidOutTileCount() +
808      (this.newColumn_ ? this.newColumn_.getTileCount() : 0);
809};
810
811/**
812 * @return {Mosaic.Column} The last column or null for empty layout.
813 * @private
814 */
815Mosaic.Layout.prototype.getLastColumn_ = function() {
816  return this.columns_.length ? this.columns_[this.columns_.length - 1] : null;
817};
818
819/**
820 * @return {number} Total number of tiles in completed columns.
821 */
822Mosaic.Layout.prototype.getLaidOutTileCount = function() {
823  var lastColumn = this.getLastColumn_();
824  return lastColumn ? lastColumn.getNextTileIndex() : 0;
825};
826
827/**
828 * Add a tile to the layout.
829 *
830 * @param {Mosaic.Tile} tile The tile to be added.
831 * @param {boolean} isLast True if this tile is the last.
832 */
833Mosaic.Layout.prototype.add = function(tile, isLast) {
834  var layoutQueue = [tile];
835
836  // There are two levels of backtracking in the layout algorithm.
837  // |Mosaic.Layout.density_| tracks the state of the 'global' backtracking
838  // which aims to use as much of the viewport space as possible.
839  // It starts with the lowest density and increases it until the layout
840  // fits into the viewport. If it does not fit even at the highest density,
841  // the layout continues with the highest density.
842  //
843  // |Mosaic.Column.density_| tracks the state of the 'local' backtracking
844  // which aims to avoid producing unnaturally looking columns.
845  // It starts with the current global density and decreases it until the column
846  // looks nice.
847
848  while (layoutQueue.length) {
849    if (!this.newColumn_) {
850      var lastColumn = this.getLastColumn_();
851      this.newColumn_ = new Mosaic.Column(
852          this.columns_.length,
853          lastColumn ? lastColumn.getNextRowIndex() : 0,
854          lastColumn ? lastColumn.getNextTileIndex() : 0,
855          lastColumn ? lastColumn.getRight() : 0,
856          this.viewportHeight_,
857          this.density_.clone());
858    }
859
860    this.newColumn_.add(layoutQueue.shift());
861
862    var isFinalColumn = isLast && !layoutQueue.length;
863
864    if (!this.newColumn_.prepareLayout(isFinalColumn))
865      continue; // Column is incomplete.
866
867    if (this.newColumn_.isSuboptimal()) {
868      layoutQueue = this.newColumn_.getTiles().concat(layoutQueue);
869      this.newColumn_.retryWithLowerDensity();
870      continue;
871    }
872
873    this.columns_.push(this.newColumn_);
874    this.newColumn_ = null;
875
876    if (this.mode_ == Mosaic.Layout.MODE_FINAL) {
877      this.getLastColumn_().layout();
878      continue;
879    }
880
881    if (this.getWidth() > this.viewportWidth_) {
882      // Viewport completely filled.
883      if (this.density_.equals(this.maxDensity_)) {
884        // Max density reached, commit if tentative, just continue if dry run.
885        if (this.mode_ == Mosaic.Layout.MODE_TENTATIVE)
886          this.commit_();
887        continue;
888      }
889
890      // Rollback the entire layout, retry with higher density.
891      layoutQueue = this.getTiles().concat(layoutQueue);
892      this.columns_ = [];
893      this.density_.increase();
894      continue;
895    }
896
897    if (isFinalColumn && this.mode_ == Mosaic.Layout.MODE_TENTATIVE) {
898      // The complete tentative layout fits into the viewport.
899      var stretched = this.findHorizontalLayout_();
900      if (stretched)
901        this.columns_ = stretched.columns_;
902      // Center the layout in the viewport and commit.
903      this.commit_((this.viewportWidth_ - this.getWidth()) / 2,
904                   (this.viewportHeight_ - this.getHeight()) / 2);
905    }
906  }
907};
908
909/**
910 * Commit the tentative layout.
911 *
912 * @param {number=} opt_offsetX Horizontal offset.
913 * @param {number=} opt_offsetY Vertical offset.
914 * @private
915 */
916Mosaic.Layout.prototype.commit_ = function(opt_offsetX, opt_offsetY) {
917  console.assert(this.mode_ != Mosaic.Layout.MODE_FINAL,
918      'Did not expect final layout');
919  for (var i = 0; i != this.columns_.length; i++) {
920    this.columns_[i].layout(opt_offsetX, opt_offsetY);
921  }
922  this.mode_ = Mosaic.Layout.MODE_FINAL;
923};
924
925/**
926 * Find the most horizontally stretched layout built from the same tiles.
927 *
928 * The main layout algorithm fills the entire available viewport height.
929 * If there is too few tiles this results in a layout that is unnaturally
930 * stretched in the vertical direction.
931 *
932 * This method tries a number of smaller heights and returns the most
933 * horizontally stretched layout that still fits into the viewport.
934 *
935 * @return {Mosaic.Layout} A horizontally stretched layout.
936 * @private
937 */
938Mosaic.Layout.prototype.findHorizontalLayout_ = function() {
939  // If the layout aspect ratio is not dramatically different from
940  // the viewport aspect ratio then there is no need to optimize.
941  if (this.getWidth() / this.getHeight() >
942      this.viewportWidth_ / this.viewportHeight_ * 0.9)
943    return null;
944
945  var tiles = this.getTiles();
946  if (tiles.length == 1)
947    return null;  // Single tile layout is always the same.
948
949  var tileHeights = tiles.map(function(t) { return t.getMaxContentHeight() });
950  var minTileHeight = Math.min.apply(null, tileHeights);
951
952  for (var h = minTileHeight; h < this.viewportHeight_; h += minTileHeight) {
953    var layout = new Mosaic.Layout(
954        Mosaic.Layout.MODE_DRY_RUN, this.density_.clone());
955    layout.setViewportSize(this.viewportWidth_, h);
956    for (var t = 0; t != tiles.length; t++)
957      layout.add(tiles[t], t + 1 == tiles.length);
958
959    if (layout.getWidth() <= this.viewportWidth_)
960      return layout;
961  }
962
963  return null;
964};
965
966/**
967 * Invalidate the layout after the given tile was modified (added, deleted or
968 * changed dimensions).
969 *
970 * @param {number} index Tile index.
971 * @private
972 */
973Mosaic.Layout.prototype.invalidateFromTile_ = function(index) {
974  var columnIndex = this.getColumnIndexByTile_(index);
975  if (columnIndex < 0)
976    return; // Index not in the layout, probably already invalidated.
977
978  if (this.columns_[columnIndex].getLeft() >= this.viewportWidth_) {
979    // The columns to the right cover the entire viewport width, so there is no
980    // chance that the modified layout would fit into the viewport.
981    // No point in restarting the entire layout, keep the columns to the right.
982    console.assert(this.mode_ == Mosaic.Layout.MODE_FINAL,
983        'Expected FINAL layout mode');
984    this.columns_ = this.columns_.slice(0, columnIndex);
985    this.newColumn_ = null;
986  } else {
987    // There is a chance that the modified layout would fit into the viewport.
988    this.reset_();
989    this.mode_ = Mosaic.Layout.MODE_TENTATIVE;
990  }
991};
992
993/**
994 * Get the index of the tile to the left or to the right from the given tile.
995 *
996 * @param {number} index Tile index.
997 * @param {number} direction -1 for left, 1 for right.
998 * @return {number} Adjacent tile index.
999 */
1000Mosaic.Layout.prototype.getHorizontalAdjacentIndex = function(
1001    index, direction) {
1002  var column = this.getColumnIndexByTile_(index);
1003  if (column < 0) {
1004    console.error('Cannot find column for tile #' + index);
1005    return -1;
1006  }
1007
1008  var row = this.columns_[column].getRowByTileIndex(index);
1009  if (!row) {
1010    console.error('Cannot find row for tile #' + index);
1011    return -1;
1012  }
1013
1014  var sameRowNeighbourIndex = index + direction;
1015  if (row.hasTile(sameRowNeighbourIndex))
1016    return sameRowNeighbourIndex;
1017
1018  var adjacentColumn = column + direction;
1019  if (adjacentColumn < 0 || adjacentColumn == this.columns_.length)
1020    return -1;
1021
1022  return this.columns_[adjacentColumn].
1023      getEdgeTileIndex_(row.getCenterY(), -direction);
1024};
1025
1026/**
1027 * Get the index of the tile to the top or to the bottom from the given tile.
1028 *
1029 * @param {number} index Tile index.
1030 * @param {number} direction -1 for above, 1 for below.
1031 * @return {number} Adjacent tile index.
1032 */
1033Mosaic.Layout.prototype.getVerticalAdjacentIndex = function(
1034    index, direction) {
1035  var column = this.getColumnIndexByTile_(index);
1036  if (column < 0) {
1037    console.error('Cannot find column for tile #' + index);
1038    return -1;
1039  }
1040
1041  var row = this.columns_[column].getRowByTileIndex(index);
1042  if (!row) {
1043    console.error('Cannot find row for tile #' + index);
1044    return -1;
1045  }
1046
1047  // Find the first item in the next row, or the last item in the previous row.
1048  var adjacentRowNeighbourIndex =
1049      row.getEdgeTileIndex_(direction) + direction;
1050
1051  if (adjacentRowNeighbourIndex < 0 ||
1052      adjacentRowNeighbourIndex > this.getTileCount() - 1)
1053    return -1;
1054
1055  if (!this.columns_[column].hasTile(adjacentRowNeighbourIndex)) {
1056    // It is not in the current column, so return it.
1057    return adjacentRowNeighbourIndex;
1058  } else {
1059    // It is in the current column, so we have to find optically the closest
1060    // tile in the adjacent row.
1061    var adjacentRow = this.columns_[column].getRowByTileIndex(
1062        adjacentRowNeighbourIndex);
1063    var previousTileCenterX = row.getTileByIndex(index).getCenterX();
1064
1065    // Find the closest one.
1066    var closestIndex = -1;
1067    var closestDistance;
1068    var adjacentRowTiles = adjacentRow.getTiles();
1069    for (var t = 0; t != adjacentRowTiles.length; t++) {
1070      var distance =
1071          Math.abs(adjacentRowTiles[t].getCenterX() - previousTileCenterX);
1072      if (closestIndex == -1 || distance < closestDistance) {
1073        closestIndex = adjacentRow.getEdgeTileIndex_(-1) + t;
1074        closestDistance = distance;
1075      }
1076    }
1077    return closestIndex;
1078  }
1079};
1080
1081/**
1082 * @param {number} index Tile index.
1083 * @return {number} Index of the column containing the given tile.
1084 * @private
1085 */
1086Mosaic.Layout.prototype.getColumnIndexByTile_ = function(index) {
1087  for (var c = 0; c != this.columns_.length; c++) {
1088    if (this.columns_[c].hasTile(index))
1089      return c;
1090  }
1091  return -1;
1092};
1093
1094/**
1095 * Scale the given array of size values to satisfy 3 conditions:
1096 * 1. The new sizes must be integer.
1097 * 2. The new sizes must sum up to the given |total| value.
1098 * 3. The relative proportions of the sizes should be as close to the original
1099 *    as possible.
1100 *
1101 * @param {Array.<number>} sizes Array of sizes.
1102 * @param {number} newTotal New total size.
1103 */
1104Mosaic.Layout.rescaleSizesToNewTotal = function(sizes, newTotal) {
1105  var total = 0;
1106
1107  var partialTotals = [0];
1108  for (var i = 0; i != sizes.length; i++) {
1109    total += sizes[i];
1110    partialTotals.push(total);
1111  }
1112
1113  var scale = newTotal / total;
1114
1115  for (i = 0; i != sizes.length; i++) {
1116    sizes[i] = Math.round(partialTotals[i + 1] * scale) -
1117        Math.round(partialTotals[i] * scale);
1118  }
1119};
1120
1121////////////////////////////////////////////////////////////////////////////////
1122
1123/**
1124 * Representation of the layout density.
1125 *
1126 * @param {number} horizontal Horizontal density, number tiles per row.
1127 * @param {number} vertical Vertical density, frequency of rows forced to
1128 *   contain a single tile.
1129 * @constructor
1130 */
1131Mosaic.Density = function(horizontal, vertical) {
1132  this.horizontal = horizontal;
1133  this.vertical = vertical;
1134};
1135
1136/**
1137 * Minimal horizontal density (tiles per row).
1138 */
1139Mosaic.Density.MIN_HORIZONTAL = 1;
1140
1141/**
1142 * Minimal horizontal density (tiles per row).
1143 */
1144Mosaic.Density.MAX_HORIZONTAL = 3;
1145
1146/**
1147 * Minimal vertical density: force 1 out of 2 rows to containt a single tile.
1148 */
1149Mosaic.Density.MIN_VERTICAL = 2;
1150
1151/**
1152 * Maximal vertical density: force 1 out of 3 rows to containt a single tile.
1153 */
1154Mosaic.Density.MAX_VERTICAL = 3;
1155
1156/**
1157 * @return {Mosaic.Density} Lowest density.
1158 */
1159Mosaic.Density.createLowest = function() {
1160  return new Mosaic.Density(
1161      Mosaic.Density.MIN_HORIZONTAL,
1162      Mosaic.Density.MIN_VERTICAL /* ignored when horizontal is at min */);
1163};
1164
1165/**
1166 * @return {Mosaic.Density} Highest density.
1167 */
1168Mosaic.Density.createHighest = function() {
1169  return new Mosaic.Density(
1170      Mosaic.Density.MAX_HORIZONTAL,
1171      Mosaic.Density.MAX_VERTICAL);
1172};
1173
1174/**
1175 * @return {Mosaic.Density} A clone of this density object.
1176 */
1177Mosaic.Density.prototype.clone = function() {
1178  return new Mosaic.Density(this.horizontal, this.vertical);
1179};
1180
1181/**
1182 * @param {Mosaic.Density} that The other object.
1183 * @return {boolean} True if equal.
1184 */
1185Mosaic.Density.prototype.equals = function(that) {
1186  return this.horizontal == that.horizontal &&
1187         this.vertical == that.vertical;
1188};
1189
1190/**
1191 * Increase the density to the next level.
1192 */
1193Mosaic.Density.prototype.increase = function() {
1194  if (this.horizontal == Mosaic.Density.MIN_HORIZONTAL ||
1195      this.vertical == Mosaic.Density.MAX_VERTICAL) {
1196    console.assert(this.horizontal < Mosaic.Density.MAX_HORIZONTAL);
1197    this.horizontal++;
1198    this.vertical = Mosaic.Density.MIN_VERTICAL;
1199  } else {
1200    this.vertical++;
1201  }
1202};
1203
1204/**
1205 * Decrease horizontal density.
1206 */
1207Mosaic.Density.prototype.decreaseHorizontal = function() {
1208  console.assert(this.horizontal > Mosaic.Density.MIN_HORIZONTAL);
1209  this.horizontal--;
1210};
1211
1212/**
1213 * @param {number} tileCount Number of tiles in the row.
1214 * @param {number} rowIndex Global row index.
1215 * @return {boolean} True if the row is complete.
1216 */
1217Mosaic.Density.prototype.isRowComplete = function(tileCount, rowIndex) {
1218  return (tileCount == this.horizontal) || (rowIndex % this.vertical) == 0;
1219};
1220
1221////////////////////////////////////////////////////////////////////////////////
1222
1223/**
1224 * A column in a mosaic layout. Contains rows.
1225 *
1226 * @param {number} index Column index.
1227 * @param {number} firstRowIndex Global row index.
1228 * @param {number} firstTileIndex Index of the first tile in the column.
1229 * @param {number} left Left edge coordinate.
1230 * @param {number} maxHeight Maximum height.
1231 * @param {Mosaic.Density} density Layout density.
1232 * @constructor
1233 */
1234Mosaic.Column = function(index, firstRowIndex, firstTileIndex, left, maxHeight,
1235                         density) {
1236  this.index_ = index;
1237  this.firstRowIndex_ = firstRowIndex;
1238  this.firstTileIndex_ = firstTileIndex;
1239  this.left_ = left;
1240  this.maxHeight_ = maxHeight;
1241  this.density_ = density;
1242
1243  this.reset_();
1244};
1245
1246/**
1247 * Reset the layout.
1248 * @private
1249 */
1250Mosaic.Column.prototype.reset_ = function() {
1251  this.tiles_ = [];
1252  this.rows_ = [];
1253  this.newRow_ = null;
1254};
1255
1256/**
1257 * @return {number} Number of tiles in the column.
1258 */
1259Mosaic.Column.prototype.getTileCount = function() { return this.tiles_.length };
1260
1261/**
1262 * @return {number} Index of the last tile + 1.
1263 */
1264Mosaic.Column.prototype.getNextTileIndex = function() {
1265  return this.firstTileIndex_ + this.getTileCount();
1266};
1267
1268/**
1269 * @return {number} Global index of the last row + 1.
1270 */
1271Mosaic.Column.prototype.getNextRowIndex = function() {
1272  return this.firstRowIndex_ + this.rows_.length;
1273};
1274
1275/**
1276 * @return {Array.<Mosaic.Tile>} Array of tiles in the column.
1277 */
1278Mosaic.Column.prototype.getTiles = function() { return this.tiles_ };
1279
1280/**
1281 * @param {number} index Tile index.
1282 * @return {boolean} True if this column contains the tile with the given index.
1283 */
1284Mosaic.Column.prototype.hasTile = function(index) {
1285  return this.firstTileIndex_ <= index &&
1286      index < (this.firstTileIndex_ + this.getTileCount());
1287};
1288
1289/**
1290 * @param {number} y Y coordinate.
1291 * @param {number} direction -1 for left, 1 for right.
1292 * @return {number} Index of the tile lying on the edge of the column at the
1293 *    given y coordinate.
1294 * @private
1295 */
1296Mosaic.Column.prototype.getEdgeTileIndex_ = function(y, direction) {
1297  for (var r = 0; r < this.rows_.length; r++) {
1298    if (this.rows_[r].coversY(y))
1299      return this.rows_[r].getEdgeTileIndex_(direction);
1300  }
1301  return -1;
1302};
1303
1304/**
1305 * @param {number} index Tile index.
1306 * @return {Mosaic.Row} The row containing the tile with a given index.
1307 */
1308Mosaic.Column.prototype.getRowByTileIndex = function(index) {
1309  for (var r = 0; r != this.rows_.length; r++)
1310    if (this.rows_[r].hasTile(index))
1311      return this.rows_[r];
1312
1313  return null;
1314};
1315
1316/**
1317 * Add a tile to the column.
1318 *
1319 * @param {Mosaic.Tile} tile The tile to add.
1320 */
1321Mosaic.Column.prototype.add = function(tile) {
1322  var rowIndex = this.getNextRowIndex();
1323
1324  if (!this.newRow_)
1325     this.newRow_ = new Mosaic.Row(this.getNextTileIndex());
1326
1327  this.tiles_.push(tile);
1328  this.newRow_.add(tile);
1329
1330  if (this.density_.isRowComplete(this.newRow_.getTileCount(), rowIndex)) {
1331    this.rows_.push(this.newRow_);
1332    this.newRow_ = null;
1333  }
1334};
1335
1336/**
1337 * Prepare the column layout.
1338 *
1339 * @param {boolean=} opt_force True if the layout must be performed even for an
1340 *   incomplete column.
1341 * @return {boolean} True if the layout was performed.
1342 */
1343Mosaic.Column.prototype.prepareLayout = function(opt_force) {
1344  if (opt_force && this.newRow_) {
1345    this.rows_.push(this.newRow_);
1346    this.newRow_ = null;
1347  }
1348
1349  if (this.rows_.length == 0)
1350    return false;
1351
1352  this.width_ = Math.min.apply(
1353      null, this.rows_.map(function(row) { return row.getMaxWidth() }));
1354
1355  this.height_ = 0;
1356
1357  this.rowHeights_ = [];
1358  for (var r = 0; r != this.rows_.length; r++) {
1359    var rowHeight = this.rows_[r].getHeightForWidth(this.width_);
1360    this.height_ += rowHeight;
1361    this.rowHeights_.push(rowHeight);
1362  }
1363
1364  var overflow = this.height_ / this.maxHeight_;
1365  if (!opt_force && (overflow < 1))
1366    return false;
1367
1368  if (overflow > 1) {
1369    // Scale down the column width and height.
1370    this.width_ = Math.round(this.width_ / overflow);
1371    this.height_ = this.maxHeight_;
1372    Mosaic.Layout.rescaleSizesToNewTotal(this.rowHeights_, this.maxHeight_);
1373  }
1374
1375  return true;
1376};
1377
1378/**
1379 * Retry the column layout with less tiles per row.
1380 */
1381Mosaic.Column.prototype.retryWithLowerDensity = function() {
1382  this.density_.decreaseHorizontal();
1383  this.reset_();
1384};
1385
1386/**
1387 * @return {number} Column left edge coordinate.
1388 */
1389Mosaic.Column.prototype.getLeft = function() { return this.left_ };
1390
1391/**
1392 * @return {number} Column right edge coordinate after the layout.
1393 */
1394Mosaic.Column.prototype.getRight = function() {
1395  return this.left_ + this.width_;
1396};
1397
1398/**
1399 * @return {number} Column height after the layout.
1400 */
1401Mosaic.Column.prototype.getHeight = function() { return this.height_ };
1402
1403/**
1404 * Perform the column layout.
1405 * @param {number=} opt_offsetX Horizontal offset.
1406 * @param {number=} opt_offsetY Vertical offset.
1407 */
1408Mosaic.Column.prototype.layout = function(opt_offsetX, opt_offsetY) {
1409  opt_offsetX = opt_offsetX || 0;
1410  opt_offsetY = opt_offsetY || 0;
1411  var rowTop = Mosaic.Layout.PADDING_TOP;
1412  for (var r = 0; r != this.rows_.length; r++) {
1413    this.rows_[r].layout(
1414        opt_offsetX + this.left_,
1415        opt_offsetY + rowTop,
1416        this.width_,
1417        this.rowHeights_[r]);
1418    rowTop += this.rowHeights_[r];
1419  }
1420};
1421
1422/**
1423 * Check if the column layout is too ugly to be displayed.
1424 *
1425 * @return {boolean} True if the layout is suboptimal.
1426 */
1427Mosaic.Column.prototype.isSuboptimal = function() {
1428  var tileCounts =
1429      this.rows_.map(function(row) { return row.getTileCount() });
1430
1431  var maxTileCount = Math.max.apply(null, tileCounts);
1432  if (maxTileCount == 1)
1433    return false;  // Every row has exactly 1 tile, as optimal as it gets.
1434
1435  var sizes =
1436      this.tiles_.map(function(tile) { return tile.getMaxContentHeight() });
1437
1438  // Ugly layout #1: all images are small and some are one the same row.
1439  var allSmall = Math.max.apply(null, sizes) <= Mosaic.Tile.SMALL_IMAGE_SIZE;
1440  if (allSmall)
1441    return true;
1442
1443  // Ugly layout #2: all images are large and none occupies an entire row.
1444  var allLarge = Math.min.apply(null, sizes) > Mosaic.Tile.SMALL_IMAGE_SIZE;
1445  var allCombined = Math.min.apply(null, tileCounts) != 1;
1446  if (allLarge && allCombined)
1447    return true;
1448
1449  // Ugly layout #3: some rows have too many tiles for the resulting width.
1450  if (this.width_ / maxTileCount < 100)
1451    return true;
1452
1453  return false;
1454};
1455
1456////////////////////////////////////////////////////////////////////////////////
1457
1458/**
1459 * A row in a mosaic layout. Contains tiles.
1460 *
1461 * @param {number} firstTileIndex Index of the first tile in the row.
1462 * @constructor
1463 */
1464Mosaic.Row = function(firstTileIndex) {
1465  this.firstTileIndex_ = firstTileIndex;
1466  this.tiles_ = [];
1467};
1468
1469/**
1470 * @param {Mosaic.Tile} tile The tile to add.
1471 */
1472Mosaic.Row.prototype.add = function(tile) {
1473  console.assert(this.getTileCount() < Mosaic.Density.MAX_HORIZONTAL);
1474  this.tiles_.push(tile);
1475};
1476
1477/**
1478 * @return {Array.<Mosaic.Tile>} Array of tiles in the row.
1479 */
1480Mosaic.Row.prototype.getTiles = function() { return this.tiles_ };
1481
1482/**
1483 * Get a tile by index.
1484 * @param {number} index Tile index.
1485 * @return {Mosaic.Tile} Requested tile or null if not found.
1486 */
1487Mosaic.Row.prototype.getTileByIndex = function(index) {
1488  if (!this.hasTile(index))
1489    return null;
1490  return this.tiles_[index - this.firstTileIndex_];
1491};
1492
1493/**
1494 *
1495 * @return {number} Number of tiles in the row.
1496 */
1497Mosaic.Row.prototype.getTileCount = function() { return this.tiles_.length };
1498
1499/**
1500 * @param {number} index Tile index.
1501 * @return {boolean} True if this row contains the tile with the given index.
1502 */
1503Mosaic.Row.prototype.hasTile = function(index) {
1504  return this.firstTileIndex_ <= index &&
1505      index < (this.firstTileIndex_ + this.tiles_.length);
1506};
1507
1508/**
1509 * @param {number} y Y coordinate.
1510 * @return {boolean} True if this row covers the given Y coordinate.
1511 */
1512Mosaic.Row.prototype.coversY = function(y) {
1513  return this.top_ <= y && y < (this.top_ + this.height_);
1514};
1515
1516/**
1517 * @return {number} Y coordinate of the tile center.
1518 */
1519Mosaic.Row.prototype.getCenterY = function() {
1520  return this.top_ + Math.round(this.height_ / 2);
1521};
1522
1523/**
1524 * Get the first or the last tile.
1525 *
1526 * @param {number} direction -1 for the first tile, 1 for the last tile.
1527 * @return {number} Tile index.
1528 * @private
1529 */
1530Mosaic.Row.prototype.getEdgeTileIndex_ = function(direction) {
1531  if (direction < 0)
1532    return this.firstTileIndex_;
1533  else
1534    return this.firstTileIndex_ + this.getTileCount() - 1;
1535};
1536
1537/**
1538 * @return {number} Aspect ration of the combined content box of this row.
1539 * @private
1540 */
1541Mosaic.Row.prototype.getTotalContentAspectRatio_ = function() {
1542  var sum = 0;
1543  for (var t = 0; t != this.tiles_.length; t++)
1544    sum += this.tiles_[t].getAspectRatio();
1545  return sum;
1546};
1547
1548/**
1549 * @return {number} Total horizontal spacing in this row. This includes
1550 *   the spacing between the tiles and both left and right margins.
1551 *
1552 * @private
1553 */
1554Mosaic.Row.prototype.getTotalHorizontalSpacing_ = function() {
1555  return Mosaic.Layout.SPACING * this.getTileCount();
1556};
1557
1558/**
1559 * @return {number} Maximum width that this row may have without overscaling
1560 * any of the tiles.
1561 */
1562Mosaic.Row.prototype.getMaxWidth = function() {
1563  var contentHeight = Math.min.apply(null,
1564      this.tiles_.map(function(tile) { return tile.getMaxContentHeight() }));
1565
1566  var contentWidth =
1567      Math.round(contentHeight * this.getTotalContentAspectRatio_());
1568  return contentWidth + this.getTotalHorizontalSpacing_();
1569};
1570
1571/**
1572 * Compute the height that best fits the supplied row width given
1573 * aspect ratios of the tiles in this row.
1574 *
1575 * @param {number} width Row width.
1576 * @return {number} Height.
1577 */
1578Mosaic.Row.prototype.getHeightForWidth = function(width) {
1579  var contentWidth = width - this.getTotalHorizontalSpacing_();
1580  var contentHeight =
1581      Math.round(contentWidth / this.getTotalContentAspectRatio_());
1582  return contentHeight + Mosaic.Layout.SPACING;
1583};
1584
1585/**
1586 * Position the row in the mosaic.
1587 *
1588 * @param {number} left Left position.
1589 * @param {number} top Top position.
1590 * @param {number} width Width.
1591 * @param {number} height Height.
1592 */
1593Mosaic.Row.prototype.layout = function(left, top, width, height) {
1594  this.top_ = top;
1595  this.height_ = height;
1596
1597  var contentWidth = width - this.getTotalHorizontalSpacing_();
1598  var contentHeight = height - Mosaic.Layout.SPACING;
1599
1600  var tileContentWidth = this.tiles_.map(
1601      function(tile) { return tile.getAspectRatio() });
1602
1603  Mosaic.Layout.rescaleSizesToNewTotal(tileContentWidth, contentWidth);
1604
1605  var tileLeft = left;
1606  for (var t = 0; t != this.tiles_.length; t++) {
1607    var tileWidth = tileContentWidth[t] + Mosaic.Layout.SPACING;
1608    this.tiles_[t].layout(tileLeft, top, tileWidth, height);
1609    tileLeft += tileWidth;
1610  }
1611};
1612
1613////////////////////////////////////////////////////////////////////////////////
1614
1615/**
1616 * A single tile of the image mosaic.
1617 *
1618 * @param {Element} container Container element.
1619 * @param {Gallery.Item} item Gallery item associated with this tile.
1620 * @return {Element} The new tile element.
1621 * @constructor
1622 */
1623Mosaic.Tile = function(container, item) {
1624  var self = container.ownerDocument.createElement('div');
1625  Mosaic.Tile.decorate(self, container, item);
1626  return self;
1627};
1628
1629/**
1630 * @param {Element} self Self pointer.
1631 * @param {Element} container Container element.
1632 * @param {Gallery.Item} item Gallery item associated with this tile.
1633 */
1634Mosaic.Tile.decorate = function(self, container, item) {
1635  self.__proto__ = Mosaic.Tile.prototype;
1636  self.className = 'mosaic-tile';
1637
1638  self.container_ = container;
1639  self.item_ = item;
1640  self.left_ = null; // Mark as not laid out.
1641};
1642
1643/**
1644 * Load mode for the tile's image.
1645 * @enum {number}
1646 */
1647Mosaic.Tile.LoadMode = {
1648  LOW_DPI: 0,
1649  HIGH_DPI: 1
1650};
1651
1652/**
1653* Inherit from HTMLDivElement.
1654*/
1655Mosaic.Tile.prototype.__proto__ = HTMLDivElement.prototype;
1656
1657/**
1658 * Minimum tile content size.
1659 */
1660Mosaic.Tile.MIN_CONTENT_SIZE = 64;
1661
1662/**
1663 * Maximum tile content size.
1664 */
1665Mosaic.Tile.MAX_CONTENT_SIZE = 512;
1666
1667/**
1668 * Default size for a tile with no thumbnail image.
1669 */
1670Mosaic.Tile.GENERIC_ICON_SIZE = 128;
1671
1672/**
1673 * Max size of an image considered to be 'small'.
1674 * Small images are laid out slightly differently.
1675 */
1676Mosaic.Tile.SMALL_IMAGE_SIZE = 160;
1677
1678/**
1679 * @return {Gallery.Item} The Gallery item.
1680 */
1681Mosaic.Tile.prototype.getItem = function() { return this.item_ };
1682
1683/**
1684 * @return {number} Maximum content height that this tile can have.
1685 */
1686Mosaic.Tile.prototype.getMaxContentHeight = function() {
1687  return this.maxContentHeight_;
1688};
1689
1690/**
1691 * @return {number} The aspect ratio of the tile image.
1692 */
1693Mosaic.Tile.prototype.getAspectRatio = function() { return this.aspectRatio_ };
1694
1695/**
1696 * @return {boolean} True if the tile is initialized.
1697 */
1698Mosaic.Tile.prototype.isInitialized = function() {
1699  return !!this.maxContentHeight_;
1700};
1701
1702/**
1703 * Checks whether the image of specified (or better resolution) has been loaded.
1704 *
1705 * @param {Mosaic.Tile.LoadMode=} opt_loadMode Loading mode, default: LOW_DPI.
1706 * @return {boolean} True if the tile is loaded with the specified dpi or
1707 *     better.
1708 */
1709Mosaic.Tile.prototype.isLoaded = function(opt_loadMode) {
1710  var loadMode = opt_loadMode || Mosaic.Tile.LoadMode.LOW_DPI;
1711  switch (loadMode) {
1712    case Mosaic.Tile.LoadMode.LOW_DPI:
1713      if (this.imagePreloaded_ || this.imageLoaded_)
1714        return true;
1715      break;
1716    case Mosaic.Tile.LoadMode.HIGH_DPI:
1717      if (this.imageLoaded_)
1718        return true;
1719      break;
1720  }
1721  return false;
1722};
1723
1724/**
1725 * Checks whether the image of specified (or better resolution) is being loaded.
1726 *
1727 * @param {Mosaic.Tile.LoadMode=} opt_loadMode Loading mode, default: LOW_DPI.
1728 * @return {boolean} True if the tile is being loaded with the specified dpi or
1729 *     better.
1730 */
1731Mosaic.Tile.prototype.isLoading = function(opt_loadMode) {
1732  var loadMode = opt_loadMode || Mosaic.Tile.LoadMode.LOW_DPI;
1733  switch (loadMode) {
1734    case Mosaic.Tile.LoadMode.LOW_DPI:
1735      if (this.imagePreloading_ || this.imageLoading_)
1736        return true;
1737      break;
1738    case Mosaic.Tile.LoadMode.HIGH_DPI:
1739      if (this.imageLoading_)
1740        return true;
1741      break;
1742  }
1743  return false;
1744};
1745
1746/**
1747 * Mark the tile as not loaded to prevent it from participating in the layout.
1748 */
1749Mosaic.Tile.prototype.markUnloaded = function() {
1750  this.maxContentHeight_ = 0;
1751  if (this.thumbnailLoader_) {
1752    this.thumbnailLoader_.cancel();
1753    this.imagePreloaded_ = false;
1754    this.imagePreloading_ = false;
1755    this.imageLoaded_ = false;
1756    this.imageLoading_ = false;
1757  }
1758};
1759
1760/**
1761 * Initializes the thumbnail in the tile. Does not load an image, but sets
1762 * target dimensions using metadata.
1763 *
1764 * @param {Object} metadata Metadata object.
1765 * @param {function()} onImageMeasured Image measured callback.
1766 */
1767Mosaic.Tile.prototype.init = function(metadata, onImageMeasured) {
1768  this.markUnloaded();
1769  this.left_ = null;  // Mark as not laid out.
1770
1771  // Set higher priority for the selected elements to load them first.
1772  var priority = this.getAttribute('selected') ? 2 : 3;
1773
1774  // Use embedded thumbnails on Drive, since they have higher resolution.
1775  var hidpiEmbedded = FileType.isOnDrive(this.getItem().getUrl());
1776  this.thumbnailLoader_ = new ThumbnailLoader(
1777      this.getItem().getUrl(),
1778      ThumbnailLoader.LoaderType.CANVAS,
1779      metadata,
1780      undefined,  // Media type.
1781      hidpiEmbedded ? ThumbnailLoader.UseEmbedded.USE_EMBEDDED :
1782                      ThumbnailLoader.UseEmbedded.NO_EMBEDDED,
1783      priority);
1784
1785  // If no hidpi embedde thumbnail available, then use the low resolution
1786  // for preloading.
1787  if (!hidpiEmbedded) {
1788    this.thumbnailPreloader_ = new ThumbnailLoader(
1789        this.getItem().getUrl(),
1790        ThumbnailLoader.LoaderType.CANVAS,
1791        metadata,
1792        undefined,  // Media type.
1793        ThumbnailLoader.UseEmbedded.USE_EMBEDDED,
1794        2);  // Preloaders have always higher priotity, so the preload images
1795             // are loaded as soon as possible.
1796  }
1797
1798  var setDimensions = function(width, height) {
1799    if (width > height) {
1800      if (width > Mosaic.Tile.MAX_CONTENT_SIZE) {
1801        height = Math.round(height * Mosaic.Tile.MAX_CONTENT_SIZE / width);
1802        width = Mosaic.Tile.MAX_CONTENT_SIZE;
1803      }
1804    } else {
1805      if (height > Mosaic.Tile.MAX_CONTENT_SIZE) {
1806        width = Math.round(width * Mosaic.Tile.MAX_CONTENT_SIZE / height);
1807        height = Mosaic.Tile.MAX_CONTENT_SIZE;
1808      }
1809    }
1810    this.maxContentHeight_ = Math.max(Mosaic.Tile.MIN_CONTENT_SIZE, height);
1811    this.aspectRatio_ = width / height;
1812    onImageMeasured();
1813  }.bind(this);
1814
1815  // Dimensions are always acquired from the metadata. If it is not available,
1816  // then the image will not be displayed.
1817  if (metadata.media && metadata.media.width) {
1818    setDimensions(metadata.media.width, metadata.media.height);
1819  } else {
1820    // No dimensions in metadata, then display the generic icon instead.
1821    // TODO(mtomasz): Display a gneric icon instead of a black rectangle.
1822    setDimensions(Mosaic.Tile.GENERIC_ICON_SIZE,
1823                  Mosaic.Tile.GENERIC_ICON_SIZE);
1824  }
1825};
1826
1827/**
1828 * Loads an image into the tile.
1829 *
1830 * The mode argument is a hint. Use low-dpi for faster response, and high-dpi
1831 * for better output, but possibly affecting performance.
1832 *
1833 * If the mode is high-dpi, then a the high-dpi image is loaded, but also
1834 * low-dpi image is loaded for preloading (if available).
1835 * For the low-dpi mode, only low-dpi image is loaded. If not available, then
1836 * the high-dpi image is loaded as a fallback.
1837 *
1838 * @param {Mosaic.Tile.LoadMode} loadMode Loading mode.
1839 * @param {function(boolean)} onImageLoaded Callback when image is loaded.
1840 *     The argument is true for success, false for failure.
1841 */
1842Mosaic.Tile.prototype.load = function(loadMode, onImageLoaded) {
1843  // Attaches the image to the tile and finalizes loading process for the
1844  // specified loader.
1845  var finalizeLoader = function(mode, success, loader) {
1846    if (success && this.wrapper_) {
1847      // Show the fade-in animation only when previously there was no image
1848      // attached in this tile.
1849      if (!this.imageLoaded_ && !this.imagePreloaded_)
1850        this.wrapper_.classList.add('animated');
1851      else
1852        this.wrapper_.classList.remove('animated');
1853      loader.attachImage(this.wrapper_, ThumbnailLoader.FillMode.OVER_FILL);
1854    }
1855    onImageLoaded(success);
1856    switch (mode) {
1857      case Mosaic.Tile.LoadMode.LOW_DPI:
1858        this.imagePreloading_ = false;
1859        this.imagePreloaded_ = true;
1860        break;
1861      case Mosaic.Tile.LoadMode.HIGH_DPI:
1862        this.imageLoading_ = false;
1863        this.imageLoaded_ = true;
1864        break;
1865    }
1866  }.bind(this);
1867
1868  // Always load the low-dpi image first if it is available for the fastest
1869  // feedback.
1870  if (!this.imagePreloading_ && this.thumbnailPreloader_) {
1871    this.imagePreloading_ = true;
1872    this.thumbnailPreloader_.loadDetachedImage(function(success) {
1873      // Hi-dpi loaded first, ignore this call then.
1874      if (this.imageLoaded_)
1875        return;
1876      finalizeLoader(Mosaic.Tile.LoadMode.LOW_DPI,
1877                     success,
1878                     this.thumbnailPreloader_);
1879    }.bind(this));
1880  }
1881
1882  // Load the high-dpi image only when it is requested, or the low-dpi is not
1883  // available.
1884  if (!this.imageLoading_ &&
1885      (loadMode == Mosaic.Tile.LoadMode.HIGH_DPI || !this.imagePreloading_)) {
1886    this.imageLoading_ = true;
1887    this.thumbnailLoader_.loadDetachedImage(function(success) {
1888      // Cancel preloading, since the hi-dpi image is ready.
1889      if (this.thumbnailPreloader_)
1890        this.thumbnailPreloader_.cancel();
1891      finalizeLoader(Mosaic.Tile.LoadMode.HIGH_DPI,
1892                     success,
1893                     this.thumbnailLoader_);
1894    }.bind(this));
1895  }
1896};
1897
1898/**
1899 * Unloads an image from the tile.
1900 */
1901Mosaic.Tile.prototype.unload = function() {
1902  this.thumbnailLoader_.cancel();
1903  if (this.thumbnailPreloader_)
1904    this.thumbnailPreloader_.cancel();
1905  this.imagePreloaded_ = false;
1906  this.imageLoaded_ = false;
1907  this.imagePreloading_ = false;
1908  this.imageLoading_ = false;
1909  this.wrapper_.innerText = '';
1910};
1911
1912/**
1913 * Select/unselect the tile.
1914 *
1915 * @param {boolean} on True if selected.
1916 */
1917Mosaic.Tile.prototype.select = function(on) {
1918  if (on)
1919    this.setAttribute('selected', true);
1920  else
1921    this.removeAttribute('selected');
1922};
1923
1924/**
1925 * Position the tile in the mosaic.
1926 *
1927 * @param {number} left Left position.
1928 * @param {number} top Top position.
1929 * @param {number} width Width.
1930 * @param {number} height Height.
1931 */
1932Mosaic.Tile.prototype.layout = function(left, top, width, height) {
1933  this.left_ = left;
1934  this.top_ = top;
1935  this.width_ = width;
1936  this.height_ = height;
1937
1938  this.style.left = left + 'px';
1939  this.style.top = top + 'px';
1940  this.style.width = width + 'px';
1941  this.style.height = height + 'px';
1942
1943  if (!this.wrapper_) {  // First time, create DOM.
1944    this.container_.appendChild(this);
1945    var border = util.createChild(this, 'img-border');
1946    this.wrapper_ = util.createChild(border, 'img-wrapper');
1947  }
1948  if (this.hasAttribute('selected'))
1949    this.scrollIntoView(false);
1950
1951  if (this.imageLoaded_) {
1952    this.thumbnailLoader_.attachImage(this.wrapper_,
1953                                      ThumbnailLoader.FillMode.FILL);
1954  }
1955};
1956
1957/**
1958 * If the tile is not fully visible scroll the parent to make it fully visible.
1959 * @param {boolean=} opt_animated True, if scroll should be animated,
1960 *     default: true.
1961 */
1962Mosaic.Tile.prototype.scrollIntoView = function(opt_animated) {
1963  if (this.left_ == null)  // Not laid out.
1964    return;
1965
1966  var targetPosition;
1967  var tileLeft = this.left_ - Mosaic.Layout.SCROLL_MARGIN;
1968  if (tileLeft < this.container_.scrollLeft) {
1969    targetPosition = tileLeft;
1970  } else {
1971    var tileRight = this.left_ + this.width_ + Mosaic.Layout.SCROLL_MARGIN;
1972    var scrollRight = this.container_.scrollLeft + this.container_.clientWidth;
1973    if (tileRight > scrollRight)
1974      targetPosition = tileRight - this.container_.clientWidth;
1975  }
1976
1977  if (targetPosition) {
1978    if (opt_animated === false)
1979      this.container_.scrollLeft = targetPosition;
1980    else
1981      this.container_.animatedScrollTo(targetPosition);
1982  }
1983};
1984
1985/**
1986 * @return {Rect} Rectangle occupied by the tile's image,
1987 *   relative to the viewport.
1988 */
1989Mosaic.Tile.prototype.getImageRect = function() {
1990  if (this.left_ == null)  // Not laid out.
1991    return null;
1992
1993  var margin = Mosaic.Layout.SPACING / 2;
1994  return new Rect(this.left_ - this.container_.scrollLeft, this.top_,
1995      this.width_, this.height_).inflate(-margin, -margin);
1996};
1997
1998/**
1999 * @return {number} X coordinate of the tile center.
2000 */
2001Mosaic.Tile.prototype.getCenterX = function() {
2002  return this.left_ + Math.round(this.width_ / 2);
2003};
2004