blob: 1b419b6cc6c80cce049d9cf1f137c08c51a8a32b [file] [log] [blame]
// Copyright 2014 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @param {!Element} container Content container.
* @param {!ErrorBanner} errorBanner Error banner.
* @param {!cr.ui.ArrayDataModel} dataModel Data model.
* @param {!cr.ui.ListSelectionModel} selectionModel Selection model.
* @param {!VolumeManager} volumeManager Volume manager.
* @param {function(Event=)} toggleMode Function to switch to the Slide mode.
* @constructor
* @struct
*/
function MosaicMode(
container, errorBanner, dataModel, selectionModel, volumeManager,
toggleMode) {
this.mosaic_ = new Mosaic(assert(container.ownerDocument), errorBanner,
dataModel, selectionModel, volumeManager);
container.appendChild(this.mosaic_);
this.toggleMode_ = toggleMode;
this.mosaic_.addEventListener('dblclick', this.toggleMode_);
this.showingTimeoutID_ = null;
}
/**
* @return {!Mosaic} The mosaic control.
*/
MosaicMode.prototype.getMosaic = function() { return this.mosaic_; };
/**
* @return {string} Mode name.
*/
MosaicMode.prototype.getName = function() { return 'mosaic'; };
/**
* @return {string} Mode title.
*/
MosaicMode.prototype.getTitle = function() { return 'GALLERY_MOSAIC'; };
/**
* Execute an action (this mode has no busy state).
* @param {function()} action Action to execute.
*/
MosaicMode.prototype.executeWhenReady = function(action) { action(); };
/**
* @return {boolean} Always true (no toolbar fading in this mode).
*/
MosaicMode.prototype.hasActiveTool = function() { return true; };
/**
* Keydown handler.
*
* @param {!Event} event Event.
*/
MosaicMode.prototype.onKeyDown = function(event) {
switch (util.getKeyModifiers(event) + event.keyIdentifier) {
case 'Enter':
if (!document.activeElement ||
document.activeElement.localName !== 'button') {
this.toggleMode_();
event.preventDefault();
}
return;
}
this.mosaic_.onKeyDown(event);
};
/**
* Enters the debug mode.
*/
MosaicMode.prototype.debugMe = function() {
this.mosaic_.debugMe();
};
////////////////////////////////////////////////////////////////////////////////
/**
* Mosaic control.
*
* @param {!Document} document Document.
* @param {!ErrorBanner} errorBanner Error banner.
* @param {!cr.ui.ArrayDataModel} dataModel Data model.
* @param {!cr.ui.ListSelectionModel} selectionModel Selection model.
* @param {!VolumeManager} volumeManager Volume manager.
* @return {!Element} Mosaic element.
* @constructor
* @struct
* @extends {HTMLDivElement}
* @suppress {checkStructDictInheritance}
*/
function Mosaic(document, errorBanner, dataModel, selectionModel,
volumeManager) {
// This is a hack to make closure compiler recognize definitions of fields
// with this decorate pattern. When this constructor is called as "new
// Mosaic(...)", "this" should be Mosaic. In that case, this calls this
// constructor again with setting this as HTMLDivElement. When this condition
// is false, this method decorates the "this" object, and returns it.
if (this instanceof Mosaic) {
return Mosaic.call(/** @type {Mosaic} */ (document.createElement('div')),
document, errorBanner, dataModel, selectionModel, volumeManager);
}
this.__proto__ = Mosaic.prototype;
this.className = 'mosaic';
/**
* @type {!cr.ui.ArrayDataModel}
* @private
*/
this.dataModel_ = dataModel;
/**
* @type {!cr.ui.ListSelectionModel}
* @private
*/
this.selectionModel_ = selectionModel;
/**
* @type {!VolumeManager}
* @private
*/
this.volumeManager_ = volumeManager;
/**
* @type {!ErrorBanner}
* @private
*/
this.errorBanner_ = errorBanner;
/**
* @type {Array.<!Mosaic.Tile>}
* @private
*/
this.tiles_ = null;
/**
* @type {boolean}
* @private
*/
this.loadVisibleTilesSuppressed_ = false;
/**
* @type {boolean}
* @private
*/
this.loadVisibleTilesScheduled_ = false;
/**
* @type {number}
* @private
*/
this.showingTimeoutID_ = 0;
/**
* @type {Mosaic.SelectionController}
* @private
*/
this.selectionController_ = null;
/**
* @type {Mosaic.Layout}
* @private
*/
this.layoutModel_ = null;
/**
* @type {boolean}
* @private
*/
this.suppressHovering_ = false;
/**
* @type {number}
* @private
*/
this.layoutTimer_ = 0;
/**
* @type {number}
* @private
*/
this.scrollAnimation_ = 0;
// Initialization is completed lazily on the first call to |init|.
return this;
}
/**
* Inherits from HTMLDivElement.
*/
Mosaic.prototype.__proto__ = HTMLDivElement.prototype;
/**
* Default layout delay in ms.
* @const
* @type {number}
*/
Mosaic.LAYOUT_DELAY = 200;
/**
* Smooth scroll animation duration when scrolling using keyboard or
* clicking on a partly visible tile. In ms.
* @const
* @type {number}
*/
Mosaic.ANIMATED_SCROLL_DURATION = 500;
/**
* Initializes the mosaic element.
*/
Mosaic.prototype.init = function() {
if (this.tiles_)
return; // Already initialized, nothing to do.
this.layoutModel_ = new Mosaic.Layout();
this.onResize_();
this.selectionController_ =
new Mosaic.SelectionController(this.selectionModel_, this.layoutModel_);
this.tiles_ = [];
for (var i = 0; i !== this.dataModel_.length; i++) {
var locationInfo =
this.volumeManager_.getLocationInfo(this.dataModel_.item(i).getEntry());
this.tiles_.push(
new Mosaic.Tile(
this,
assertInstanceof(this.dataModel_.item(i), Gallery.Item),
locationInfo));
}
this.selectionModel_.selectedIndexes.forEach(function(index) {
this.tiles_[index].select(true);
}.bind(this));
this.initTiles_(this.tiles_);
// The listeners might be called while some tiles are still loading.
this.initListeners_();
};
/**
* @return {boolean} Whether mosaic is initialized.
*/
Mosaic.prototype.isInitialized = function() {
return !!this.tiles_;
};
/**
* Starts listening to events.
*
* We keep listening to events even when the mosaic is hidden in order to
* keep the layout up to date.
*
* @private
*/
Mosaic.prototype.initListeners_ = function() {
this.ownerDocument.defaultView.addEventListener(
'resize', this.onResize_.bind(this));
var mouseEventBound = this.onMouseEvent_.bind(this);
this.addEventListener('mousemove', mouseEventBound);
this.addEventListener('mousedown', mouseEventBound);
this.addEventListener('mouseup', mouseEventBound);
this.addEventListener('scroll', this.onScroll_.bind(this));
this.selectionModel_.addEventListener('change', this.onSelection_.bind(this));
this.selectionModel_.addEventListener('leadIndexChange',
this.onLeadChange_.bind(this));
this.dataModel_.addEventListener('splice', this.onSplice_.bind(this));
this.dataModel_.addEventListener('content', this.onContentChange_.bind(this));
};
/**
* Smoothly scrolls the container to the specified position using
* f(x) = sqrt(x) speed function normalized to animation duration.
* @param {number} targetPosition Horizontal scroll position in pixels.
*/
Mosaic.prototype.animatedScrollTo = function(targetPosition) {
if (this.scrollAnimation_) {
webkitCancelAnimationFrame(this.scrollAnimation_);
this.scrollAnimation_ = 0;
}
// Mouse move events are fired without touching the mouse because of scrolling
// the container. Therefore, these events have to be suppressed.
this.suppressHovering_ = true;
// Calculates integral area from t1 to t2 of f(x) = sqrt(x) dx.
var integral = function(t1, t2) {
return 2.0 / 3.0 * Math.pow(t2, 3.0 / 2.0) -
2.0 / 3.0 * Math.pow(t1, 3.0 / 2.0);
};
var delta = targetPosition - this.scrollLeft;
var factor = delta / integral(0, Mosaic.ANIMATED_SCROLL_DURATION);
var startTime = Date.now();
var lastPosition = 0;
var scrollOffset = this.scrollLeft;
var animationFrame = function() {
var position = Date.now() - startTime;
var step = factor *
integral(Math.max(0, Mosaic.ANIMATED_SCROLL_DURATION - position),
Math.max(0, Mosaic.ANIMATED_SCROLL_DURATION - lastPosition));
scrollOffset += step;
var oldScrollLeft = this.scrollLeft;
var newScrollLeft = Math.round(scrollOffset);
if (oldScrollLeft !== newScrollLeft)
this.scrollLeft = newScrollLeft;
if (step === 0 || this.scrollLeft !== newScrollLeft) {
this.scrollAnimation_ = null;
// Release the hovering lock after a safe delay to avoid hovering
// a tile because of altering |this.scrollLeft|.
setTimeout(function() {
if (!this.scrollAnimation_)
this.suppressHovering_ = false;
}.bind(this), 100);
} else {
// Continue the animation.
this.scrollAnimation_ = requestAnimationFrame(animationFrame);
}
lastPosition = position;
}.bind(this);
// Start the animation.
this.scrollAnimation_ = requestAnimationFrame(animationFrame);
};
/**
* @return {Mosaic.Tile} Selected tile or undefined if no selection.
*/
Mosaic.prototype.getSelectedTile = function() {
return this.tiles_ && this.tiles_[this.selectionModel_.selectedIndex];
};
/**
* @param {number} index Tile index.
* @return {ImageRect} Tile's image rectangle.
*/
Mosaic.prototype.getTileRect = function(index) {
var tile = this.tiles_[index];
return tile && tile.getImageRect();
};
/**
* Scroll the given tile into the viewport.
* @param {number} index Tile index.
*/
Mosaic.prototype.scrollIntoViewByIndex = function(index) {
var tile = this.tiles_[index];
if (tile) tile.scrollIntoView();
};
/**
* Initializes multiple tiles.
*
* @param {!Array.<!Mosaic.Tile>} tiles Array of tiles.
* @private
*/
Mosaic.prototype.initTiles_ = function(tiles) {
for (var i = 0; i < tiles.length; i++) {
tiles[i].init();
}
};
/**
* Reloads all tiles.
*/
Mosaic.prototype.reload = function() {
this.layoutModel_.reset_();
this.tiles_.forEach(function(t) { t.markUnloaded(); });
this.initTiles_(this.tiles_);
};
/**
* Layouts the tiles in the order of their indices.
*
* Starts where it last stopped (at #0 the first time).
* Stops when all tiles are processed or when the next tile is still loading.
*/
Mosaic.prototype.layout = function() {
if (this.layoutTimer_) {
clearTimeout(this.layoutTimer_);
this.layoutTimer_ = 0;
}
while (true) {
var index = this.layoutModel_.getTileCount();
if (index === this.tiles_.length)
break; // All tiles done.
var tile = this.tiles_[index];
if (!tile.isInitialized())
break; // Next layout will try to restart from here.
this.layoutModel_.add(tile, index + 1 === this.tiles_.length);
}
this.loadVisibleTiles_();
};
/**
* Schedules the layout.
*
* @param {number=} opt_delay Delay in ms.
*/
Mosaic.prototype.scheduleLayout = function(opt_delay) {
if (!this.layoutTimer_) {
this.layoutTimer_ = setTimeout(function() {
this.layoutTimer_ = 0;
this.layout();
}.bind(this), opt_delay || 0);
}
};
/**
* Resize handler.
*
* @private
*/
Mosaic.prototype.onResize_ = function() {
this.layoutModel_.setViewportSize(this.clientWidth, this.clientHeight -
(Mosaic.Layout.PADDING_TOP + Mosaic.Layout.PADDING_BOTTOM));
this.scheduleLayout();
};
/**
* Mouse event handler.
*
* @param {!Event} event Event.
* @private
*/
Mosaic.prototype.onMouseEvent_ = function(event) {
// Navigating with mouse, enable hover state.
if (!this.suppressHovering_)
this.classList.add('hover-visible');
if (event.type === 'mousemove')
return;
var index = -1;
for (var target = event.target;
target && (target !== this);
target = target.parentNode) {
if (target.classList.contains('mosaic-tile')) {
index = this.dataModel_.indexOf(target.getItem());
break;
}
}
this.selectionController_.handlePointerDownUp(event, index);
};
/**
* Scroll handler.
* @private
*/
Mosaic.prototype.onScroll_ = function() {
requestAnimationFrame(function() {
this.loadVisibleTiles_();
}.bind(this));
};
/**
* Selection change handler.
*
* @param {!Event} event Event.
* @private
*/
Mosaic.prototype.onSelection_ = function(event) {
for (var i = 0; i !== event.changes.length; i++) {
var change = event.changes[i];
var tile = this.tiles_[change.index];
if (tile) tile.select(change.selected);
}
};
/**
* Leads item change handler.
*
* @param {!Event} event Event.
* @private
*/
Mosaic.prototype.onLeadChange_ = function(event) {
var index = event.newValue;
if (index >= 0) {
var tile = this.tiles_[index];
if (tile) tile.scrollIntoView();
}
};
/**
* Splice event handler.
*
* @param {!Event} event Event.
* @private
*/
Mosaic.prototype.onSplice_ = function(event) {
var index = event.index;
this.layoutModel_.invalidateFromTile_(index);
if (event.removed.length) {
for (var t = 0; t !== event.removed.length; t++) {
// If the layout for the tile has not done yet, the parent is null.
// And the layout will not be done after onSplice_ because it is removed
// from this.tiles_.
if (this.tiles_[index + t].parentNode)
this.removeChild(this.tiles_[index + t]);
}
this.tiles_.splice(index, event.removed.length);
// No items left, show the banner.
if (this.getItemCount_() === 0)
this.errorBanner_.show('GALLERY_NO_IMAGES');
this.scheduleLayout(Mosaic.LAYOUT_DELAY);
}
if (event.added.length) {
var newTiles = [];
for (var t = 0; t !== event.added.length; t++)
newTiles.push(new Mosaic.Tile(this,
assertInstanceof(this.dataModel_.item(index + t), Gallery.Item)));
this.tiles_.splice.apply(this.tiles_, [index, 0].concat(newTiles));
this.initTiles_(newTiles);
this.scheduleLayout(Mosaic.LAYOUT_DELAY);
}
if (this.tiles_.length !== this.dataModel_.length)
console.error('Mosaic is out of sync');
};
/**
* Content change handler.
*
* @param {!Event} event Event.
* @private
*/
Mosaic.prototype.onContentChange_ = function(event) {
if (!this.tiles_)
return;
if (!event.metadata)
return; // Thumbnail unchanged, nothing to do.
var index = this.dataModel_.indexOf(event.item);
if (index !== this.selectionModel_.selectedIndex)
console.error('Content changed for unselected item');
this.layoutModel_.invalidateFromTile_(index);
this.tiles_[index].init();
this.tiles_[index].unload();
this.tiles_[index].load(
Mosaic.Tile.LoadMode.HIGH_DPI,
this.scheduleLayout.bind(this, Mosaic.LAYOUT_DELAY));
};
/**
* Keydown event handler.
*
* @param {!Event} event Event.
* @return {boolean} True if the event has been consumed.
*/
Mosaic.prototype.onKeyDown = function(event) {
this.selectionController_.handleKeyDown(event);
if (event.defaultPrevented) // Navigating with keyboard, hide hover state.
this.classList.remove('hover-visible');
return event.defaultPrevented;
};
/**
* @return {boolean} True if the mosaic zoom effect can be applied. It is
* too slow if there are to many images.
* TODO(kaznacheev): Consider unloading the images that are out of the viewport.
*/
Mosaic.prototype.canZoom = function() {
return this.tiles_.length < 100;
};
/**
* Shows the mosaic.
*/
Mosaic.prototype.show = function() {
// If the items are empty, just show the error message.
if (this.getItemCount_() === 0)
this.errorBanner_.show('GALLERY_NO_IMAGES');
var duration = ImageView.MODE_TRANSITION_DURATION;
if (this.canZoom()) {
// Fade in in parallel with the zoom effect.
this.setAttribute('visible', 'zooming');
} else {
// Mosaic is not animating but the large image is. Fade in the mosaic
// shortly before the large image animation is done.
duration -= 100;
}
this.showingTimeoutID_ = setTimeout(function() {
this.showingTimeoutID_ = 0;
// Make the selection visible.
// If the mosaic is not animated it will start fading in now.
this.setAttribute('visible', 'normal');
this.loadVisibleTiles_();
}.bind(this), duration);
};
/**
* Hides the mosaic.
*/
Mosaic.prototype.hide = function() {
this.errorBanner_.clear();
if (this.showingTimeoutID_ !== 0) {
clearTimeout(this.showingTimeoutID_);
this.showingTimeoutID_ = 0;
}
this.removeAttribute('visible');
};
/**
* Checks if the mosaic view is visible.
* @return {boolean} True if visible, false otherwise.
* @private
*/
Mosaic.prototype.isVisible_ = function() {
return this.hasAttribute('visible');
};
/**
* Loads visible tiles. Ignores consecutive calls. Does not reload already
* loaded images.
* @private
*/
Mosaic.prototype.loadVisibleTiles_ = function() {
if (this.loadVisibleTilesSuppressed_) {
this.loadVisibleTilesScheduled_ = true;
return;
}
this.loadVisibleTilesSuppressed_ = true;
this.loadVisibleTilesScheduled_ = false;
setTimeout(function() {
this.loadVisibleTilesSuppressed_ = false;
if (this.loadVisibleTilesScheduled_)
this.loadVisibleTiles_();
}.bind(this), 100);
// Tiles only in the viewport (visible).
var visibleRect = new ImageRect(
0, 0, this.clientWidth, this.clientHeight);
// Tiles in the viewport and also some distance on the left and right.
var renderableRect = new ImageRect(
-this.clientWidth,
0,
3 * this.clientWidth,
this.clientHeight);
// Unload tiles out of scope.
for (var index = 0; index < this.tiles_.length; index++) {
var tile = this.tiles_[index];
var imageRect = tile.getImageRect();
// Unload a thumbnail.
if (imageRect && !imageRect.intersects(renderableRect))
tile.unload();
}
// Load the visible tiles first.
var allVisibleLoaded = true;
// Show high-dpi only when the mosaic view is visible.
var loadMode = this.isVisible_() ? Mosaic.Tile.LoadMode.HIGH_DPI :
Mosaic.Tile.LoadMode.LOW_DPI;
for (var index = 0; index < this.tiles_.length; index++) {
var tile = this.tiles_[index];
var imageRect = tile.getImageRect();
// Load a thumbnail.
if (!tile.isLoading(loadMode) && !tile.isLoaded(loadMode) && imageRect &&
imageRect.intersects(visibleRect)) {
tile.load(loadMode, function() {});
allVisibleLoaded = false;
}
}
// Load also another, nearby, if the visible has been already loaded.
if (allVisibleLoaded) {
for (var index = 0; index < this.tiles_.length; index++) {
var tile = this.tiles_[index];
var imageRect = tile.getImageRect();
// Load a thumbnail.
if (!tile.isLoading() && !tile.isLoaded() && imageRect &&
imageRect.intersects(renderableRect)) {
tile.load(Mosaic.Tile.LoadMode.LOW_DPI, function() {});
}
}
}
};
/**
* Applies reset the zoom transform.
*
* @param {ImageRect} tileRect Tile rectangle. Reset the transform if null.
* @param {ImageRect} imageRect Large image rectangle. Reset the transform if
* null.
* @param {boolean=} opt_instant True of the transition should be instant.
*/
Mosaic.prototype.transform = function(tileRect, imageRect, opt_instant) {
if (opt_instant) {
this.style.webkitTransitionDuration = '0';
} else {
this.style.webkitTransitionDuration =
ImageView.MODE_TRANSITION_DURATION + 'ms';
}
if (this.canZoom() && tileRect && imageRect) {
var scaleX = imageRect.width / tileRect.width;
var scaleY = imageRect.height / tileRect.height;
var shiftX = (imageRect.left + imageRect.width / 2) -
(tileRect.left + tileRect.width / 2);
var shiftY = (imageRect.top + imageRect.height / 2) -
(tileRect.top + tileRect.height / 2);
this.style.webkitTransform =
'translate(' + shiftX * scaleX + 'px, ' + shiftY * scaleY + 'px)' +
'scaleX(' + scaleX + ') scaleY(' + scaleY + ')';
} else {
this.style.webkitTransform = '';
}
};
/**
* @return {number} Item count
* @private
*/
Mosaic.prototype.getItemCount_ = function() {
return this.dataModel_.length;
};
/**
* Enters the debug me.
*/
Mosaic.prototype.debugMe = function() {
this.classList.add('debug-me');
};
////////////////////////////////////////////////////////////////////////////////
/**
* Creates a selection controller that is to be used with grid.
* @param {!cr.ui.ListSelectionModel} selectionModel The selection model to
* interact with.
* @param {!Mosaic.Layout} layoutModel The layout model to use.
* @constructor
* @struct
* @extends {cr.ui.ListSelectionController}
* @suppress {checkStructDictInheritance}
*/
Mosaic.SelectionController = function(selectionModel, layoutModel) {
cr.ui.ListSelectionController.call(this, selectionModel);
this.layoutModel_ = layoutModel;
};
/**
* Extends cr.ui.ListSelectionController.
*/
Mosaic.SelectionController.prototype.__proto__ =
cr.ui.ListSelectionController.prototype;
/** @override */
Mosaic.SelectionController.prototype.getLastIndex = function() {
return this.layoutModel_.getLaidOutTileCount() - 1;
};
/** @override */
Mosaic.SelectionController.prototype.getIndexBefore = function(index) {
return this.layoutModel_.getHorizontalAdjacentIndex(index, -1);
};
/** @override */
Mosaic.SelectionController.prototype.getIndexAfter = function(index) {
return this.layoutModel_.getHorizontalAdjacentIndex(index, 1);
};
/** @override */
Mosaic.SelectionController.prototype.getIndexAbove = function(index) {
return this.layoutModel_.getVerticalAdjacentIndex(index, -1);
};
/** @override */
Mosaic.SelectionController.prototype.getIndexBelow = function(index) {
return this.layoutModel_.getVerticalAdjacentIndex(index, 1);
};
////////////////////////////////////////////////////////////////////////////////
/**
* Mosaic layout.
*
* @param {string=} opt_mode Layout mode.
* @param {Mosaic.Density=} opt_maxDensity Layout density.
* @constructor
* @struct
*/
Mosaic.Layout = function(opt_mode, opt_maxDensity) {
this.mode_ = opt_mode || Mosaic.Layout.Mode.TENTATIVE;
this.maxDensity_ = opt_maxDensity || Mosaic.Density.createHighest();
/**
* @type {!Array.<!Mosaic.Column>}
* @private
*/
this.columns_ = [];
/**
* @type {Mosaic.Column}
* @private
*/
this.newColumn_ = null;
/**
* @type {number}
* @private
*/
this.viewportWidth_ = 0;
/**
* @type {number}
* @private
*/
this.viewportHeight_ = 0;
/**
* @type {Mosaic.Density}
* @private
*/
this.density_ = null;
this.reset_();
};
/**
* Blank space at the top of the mosaic element. We do not do that in CSS
* to make transition effects easier.
* @type {number}
* @const
*/
Mosaic.Layout.PADDING_TOP = 50;
/**
* Blank space at the bottom of the mosaic element.
* @type {number}
* @const
*/
Mosaic.Layout.PADDING_BOTTOM = 50;
/**
* Horizontal and vertical spacing between images. Should be kept in sync
* with the style of .mosaic-item in gallery.css (= 2 * ( 4 + 1))
* @type {number}
* @const
*/
Mosaic.Layout.SPACING = 10;
/**
* Margin for scrolling using keyboard. Distance between a selected tile
* and window border.
* @type {number}
* @const
*/
Mosaic.Layout.SCROLL_MARGIN = 30;
/**
* Layout mode.
* @enum {string}
*/
Mosaic.Layout.Mode = {
// Commit to DOM immediately.
FINAL: 'final',
// Do not commit layout to DOM until it is complete or the viewport
// overflows.
TENTATIVE: 'tentative',
// Never commit layout to DOM.
DRY_RUN: 'dry_run',
};
/**
* Resets the layout.
*
* @private
*/
Mosaic.Layout.prototype.reset_ = function() {
this.columns_ = [];
this.newColumn_ = null;
this.density_ = Mosaic.Density.createLowest();
if (this.mode_ !== Mosaic.Layout.Mode.DRY_RUN) // DRY_RUN is sticky.
this.mode_ = Mosaic.Layout.Mode.TENTATIVE;
};
/**
* @param {number} width Viewport width.
* @param {number} height Viewport height.
*/
Mosaic.Layout.prototype.setViewportSize = function(width, height) {
this.viewportWidth_ = width;
this.viewportHeight_ = height;
this.reset_();
};
/**
* @return {number} Total width of the layout.
*/
Mosaic.Layout.prototype.getWidth = function() {
var lastColumn = this.getLastColumn_();
return lastColumn ? lastColumn.getRight() : 0;
};
/**
* @return {number} Total height of the layout.
*/
Mosaic.Layout.prototype.getHeight = function() {
var firstColumn = this.columns_[0];
return firstColumn ? firstColumn.getHeight() : 0;
};
/**
* @return {!Array.<!Mosaic.Tile>} All tiles in the layout.
*/
Mosaic.Layout.prototype.getTiles = function() {
return Array.prototype.concat.apply([],
this.columns_.map(function(c) { return c.getTiles(); }));
};
/**
* @return {number} Total number of tiles added to the layout.
*/
Mosaic.Layout.prototype.getTileCount = function() {
return this.getLaidOutTileCount() +
(this.newColumn_ ? this.newColumn_.getTileCount() : 0);
};
/**
* @return {Mosaic.Column} The last column or null for empty layout.
* @private
*/
Mosaic.Layout.prototype.getLastColumn_ = function() {
return this.columns_.length ? this.columns_[this.columns_.length - 1] : null;
};
/**
* @return {number} Total number of tiles in completed columns.
*/
Mosaic.Layout.prototype.getLaidOutTileCount = function() {
var lastColumn = this.getLastColumn_();
return lastColumn ? lastColumn.getNextTileIndex() : 0;
};
/**
* Adds a tile to the layout.
*
* @param {!Mosaic.Tile} tile The tile to be added.
* @param {boolean} isLast True if this tile is the last.
*/
Mosaic.Layout.prototype.add = function(tile, isLast) {
var layoutQueue = [tile];
// There are two levels of backtracking in the layout algorithm.
// |Mosaic.Layout.density_| tracks the state of the 'global' backtracking
// which aims to use as much of the viewport space as possible.
// It starts with the lowest density and increases it until the layout
// fits into the viewport. If it does not fit even at the highest density,
// the layout continues with the highest density.
//
// |Mosaic.Column.density_| tracks the state of the 'local' backtracking
// which aims to avoid producing unnaturally looking columns.
// It starts with the current global density and decreases it until the column
// looks nice.
while (layoutQueue.length) {
if (!this.newColumn_) {
var lastColumn = this.getLastColumn_();
this.newColumn_ = new Mosaic.Column(
this.columns_.length,
lastColumn ? lastColumn.getNextRowIndex() : 0,
lastColumn ? lastColumn.getNextTileIndex() : 0,
lastColumn ? lastColumn.getRight() : 0,
this.viewportHeight_,
this.density_.clone());
}
this.newColumn_.add(layoutQueue.shift());
var isFinalColumn = isLast && !layoutQueue.length;
if (!this.newColumn_.prepareLayout(isFinalColumn))
continue; // Column is incomplete.
if (this.newColumn_.isSuboptimal()) {
layoutQueue = this.newColumn_.getTiles().concat(layoutQueue);
this.newColumn_.retryWithLowerDensity();
continue;
}
this.columns_.push(this.newColumn_);
this.newColumn_ = null;
if (this.mode_ === Mosaic.Layout.Mode.FINAL && isFinalColumn) {
this.commit_();
continue;
}
if (this.getWidth() > this.viewportWidth_) {
// Viewport completely filled.
if (this.density_.equals(this.maxDensity_)) {
// Max density reached, commit if tentative, just continue if dry run.
if (this.mode_ === Mosaic.Layout.Mode.TENTATIVE)
this.commit_();
continue;
}
// Rollback the entire layout, retry with higher density.
layoutQueue = this.getTiles().concat(layoutQueue);
this.columns_ = [];
this.density_.increase();
continue;
}
if (isFinalColumn && this.mode_ === Mosaic.Layout.Mode.TENTATIVE) {
// The complete tentative layout fits into the viewport.
var stretched = this.findHorizontalLayout_();
if (stretched)
this.columns_ = stretched.columns_;
// Center the layout in the viewport and commit.
this.commit_((this.viewportWidth_ - this.getWidth()) / 2,
(this.viewportHeight_ - this.getHeight()) / 2);
}
}
};
/**
* Commits the tentative layout.
*
* @param {number=} opt_offsetX Horizontal offset.
* @param {number=} opt_offsetY Vertical offset.
* @private
*/
Mosaic.Layout.prototype.commit_ = function(opt_offsetX, opt_offsetY) {
for (var i = 0; i !== this.columns_.length; i++) {
this.columns_[i].layout(opt_offsetX, opt_offsetY);
}
this.mode_ = Mosaic.Layout.Mode.FINAL;
};
/**
* Finds the most horizontally stretched layout built from the same tiles.
*
* The main layout algorithm fills the entire available viewport height.
* If there is too few tiles this results in a layout that is unnaturally
* stretched in the vertical direction.
*
* This method tries a number of smaller heights and returns the most
* horizontally stretched layout that still fits into the viewport.
*
* @return {Mosaic.Layout} A horizontally stretched layout.
* @private
*/
Mosaic.Layout.prototype.findHorizontalLayout_ = function() {
// If the layout aspect ratio is not dramatically different from
// the viewport aspect ratio then there is no need to optimize.
if (this.getWidth() / this.getHeight() >
this.viewportWidth_ / this.viewportHeight_ * 0.9)
return null;
var tiles = this.getTiles();
if (tiles.length === 1)
return null; // Single tile layout is always the same.
var tileHeights = tiles.map(function(t) { return t.getMaxContentHeight(); });
var minTileHeight = Math.min.apply(null, tileHeights);
for (var h = minTileHeight; h < this.viewportHeight_; h += minTileHeight) {
var layout = new Mosaic.Layout(
Mosaic.Layout.Mode.DRY_RUN, this.density_.clone());
layout.setViewportSize(this.viewportWidth_, h);
for (var t = 0; t !== tiles.length; t++)
layout.add(tiles[t], t + 1 === tiles.length);
if (layout.getWidth() <= this.viewportWidth_)
return layout;
}
return null;
};
/**
* Invalidates the layout after the given tile was modified (added, deleted or
* changed dimensions).
*
* @param {number} index Tile index.
* @private
*/
Mosaic.Layout.prototype.invalidateFromTile_ = function(index) {
var columnIndex = this.getColumnIndexByTile_(index);
if (columnIndex < 0)
return; // Index not in the layout, probably already invalidated.
if (this.columns_[columnIndex].getLeft() >= this.viewportWidth_) {
// The columns to the right cover the entire viewport width, so there is no
// chance that the modified layout would fit into the viewport.
// No point in restarting the entire layout, keep the columns to the right.
console.assert(this.mode_ === Mosaic.Layout.Mode.FINAL,
'Expected FINAL layout mode');
this.columns_ = this.columns_.slice(0, columnIndex);
this.newColumn_ = null;
} else {
// There is a chance that the modified layout would fit into the viewport.
this.reset_();
this.mode_ = Mosaic.Layout.Mode.TENTATIVE;
}
};
/**
* Gets the index of the tile to the left or to the right from the given tile.
*
* @param {number} index Tile index.
* @param {number} direction -1 for left, 1 for right.
* @return {number} Adjacent tile index.
*/
Mosaic.Layout.prototype.getHorizontalAdjacentIndex = function(
index, direction) {
var column = this.getColumnIndexByTile_(index);
if (column < 0) {
console.error('Cannot find column for tile #' + index);
return -1;
}
var row = this.columns_[column].getRowByTileIndex(index);
if (!row) {
console.error('Cannot find row for tile #' + index);
return -1;
}
var sameRowNeighbourIndex = index + direction;
if (row.hasTile(sameRowNeighbourIndex))
return sameRowNeighbourIndex;
var adjacentColumn = column + direction;
if (adjacentColumn < 0 || adjacentColumn === this.columns_.length)
return -1;
return this.columns_[adjacentColumn].
getEdgeTileIndex_(row.getCenterY(), -direction);
};
/**
* Gets the index of the tile to the top or to the bottom from the given tile.
*
* @param {number} index Tile index.
* @param {number} direction -1 for above, 1 for below.
* @return {number} Adjacent tile index.
*/
Mosaic.Layout.prototype.getVerticalAdjacentIndex = function(
index, direction) {
var column = this.getColumnIndexByTile_(index);
if (column < 0) {
console.error('Cannot find column for tile #' + index);
return -1;
}
var row = this.columns_[column].getRowByTileIndex(index);
if (!row) {
console.error('Cannot find row for tile #' + index);
return -1;
}
// Find the first item in the next row, or the last item in the previous row.
var adjacentRowNeighbourIndex =
row.getEdgeTileIndex_(direction) + direction;
if (adjacentRowNeighbourIndex < 0 ||
adjacentRowNeighbourIndex > this.getTileCount() - 1)
return -1;
if (!this.columns_[column].hasTile(adjacentRowNeighbourIndex)) {
// It is not in the current column, so return it.
return adjacentRowNeighbourIndex;
} else {
// It is in the current column, so we have to find optically the closest
// tile in the adjacent row.
var adjacentRow = this.columns_[column].getRowByTileIndex(
adjacentRowNeighbourIndex);
var previousTileCenterX = row.getTileByIndex(index).getCenterX();
// Find the closest one.
var closestIndex = -1;
var closestDistance;
var adjacentRowTiles = adjacentRow.getTiles();
for (var t = 0; t !== adjacentRowTiles.length; t++) {
var distance =
Math.abs(adjacentRowTiles[t].getCenterX() - previousTileCenterX);
if (closestIndex === -1 || distance < closestDistance) {
closestIndex = adjacentRow.getEdgeTileIndex_(-1) + t;
closestDistance = distance;
}
}
return closestIndex;
}
};
/**
* @param {number} index Tile index.
* @return {number} Index of the column containing the given tile.
* @private
*/
Mosaic.Layout.prototype.getColumnIndexByTile_ = function(index) {
for (var c = 0; c !== this.columns_.length; c++) {
if (this.columns_[c].hasTile(index))
return c;
}
return -1;
};
/**
* Scales the given array of size values to satisfy 3 conditions:
* 1. The new sizes must be integer.
* 2. The new sizes must sum up to the given |total| value.
* 3. The relative proportions of the sizes should be as close to the original
* as possible.
*
* @param {!Array.<number>} sizes Array of sizes.
* @param {number} newTotal New total size.
*/
Mosaic.Layout.rescaleSizesToNewTotal = function(sizes, newTotal) {
var total = 0;
var partialTotals = [0];
for (var i = 0; i !== sizes.length; i++) {
total += sizes[i];
partialTotals.push(total);
}
var scale = newTotal / total;
for (i = 0; i !== sizes.length; i++) {
sizes[i] = Math.round(partialTotals[i + 1] * scale) -
Math.round(partialTotals[i] * scale);
}
};
////////////////////////////////////////////////////////////////////////////////
/**
* Representation of the layout density.
*
* @param {number} horizontal Horizontal density, number tiles per row.
* @param {number} vertical Vertical density, frequency of rows forced to
* contain a single tile.
* @constructor
* @struct
*/
Mosaic.Density = function(horizontal, vertical) {
this.horizontal = horizontal;
this.vertical = vertical;
};
/**
* Minimal horizontal density (tiles per row).
* @type {number}
* @const
*/
Mosaic.Density.MIN_HORIZONTAL = 1;
/**
* Minimal horizontal density (tiles per row).
* @type {number}
* @const
*/
Mosaic.Density.MAX_HORIZONTAL = 3;
/**
* Minimal vertical density: force 1 out of 2 rows to containt a single tile.
* @type {number}
* @const
*/
Mosaic.Density.MIN_VERTICAL = 2;
/**
* Maximal vertical density: force 1 out of 3 rows to containt a single tile.
* @type {number}
* @const
*/
Mosaic.Density.MAX_VERTICAL = 3;
/**
* @return {!Mosaic.Density} Lowest density.
*/
Mosaic.Density.createLowest = function() {
return new Mosaic.Density(
Mosaic.Density.MIN_HORIZONTAL,
Mosaic.Density.MIN_VERTICAL /* ignored when horizontal is at min */);
};
/**
* @return {!Mosaic.Density} Highest density.
*/
Mosaic.Density.createHighest = function() {
return new Mosaic.Density(
Mosaic.Density.MAX_HORIZONTAL,
Mosaic.Density.MAX_VERTICAL);
};
/**
* @return {!Mosaic.Density} A clone of this density object.
*/
Mosaic.Density.prototype.clone = function() {
return new Mosaic.Density(this.horizontal, this.vertical);
};
/**
* @param {!Mosaic.Density} that The other object.
* @return {boolean} True if equal.
*/
Mosaic.Density.prototype.equals = function(that) {
return this.horizontal === that.horizontal &&
this.vertical === that.vertical;
};
/**
* Increases the density to the next level.
*/
Mosaic.Density.prototype.increase = function() {
if (this.horizontal === Mosaic.Density.MIN_HORIZONTAL ||
this.vertical === Mosaic.Density.MAX_VERTICAL) {
console.assert(this.horizontal < Mosaic.Density.MAX_HORIZONTAL);
this.horizontal++;
this.vertical = Mosaic.Density.MIN_VERTICAL;
} else {
this.vertical++;
}
};
/**
* Decreases horizontal density.
*/
Mosaic.Density.prototype.decreaseHorizontal = function() {
console.assert(this.horizontal > Mosaic.Density.MIN_HORIZONTAL);
this.horizontal--;
};
/**
* @param {number} tileCount Number of tiles in the row.
* @param {number} rowIndex Global row index.
* @return {boolean} True if the row is complete.
*/
Mosaic.Density.prototype.isRowComplete = function(tileCount, rowIndex) {
return (tileCount === this.horizontal) || (rowIndex % this.vertical) === 0;
};
////////////////////////////////////////////////////////////////////////////////
/**
* A column in a mosaic layout. Contains rows.
*
* @param {number} index Column index.
* @param {number} firstRowIndex Global row index.
* @param {number} firstTileIndex Index of the first tile in the column.
* @param {number} left Left edge coordinate.
* @param {number} maxHeight Maximum height.
* @param {!Mosaic.Density} density Layout density.
* @constructor
* @struct
*/
Mosaic.Column = function(index, firstRowIndex, firstTileIndex, left, maxHeight,
density) {
this.index_ = index;
this.firstRowIndex_ = firstRowIndex;
this.firstTileIndex_ = firstTileIndex;
this.left_ = left;
this.maxHeight_ = maxHeight;
this.density_ = density;
/**
* @type {number}
* @private
*/
this.width_ = 0;
/**
* @type {!Array.<!Mosaic.Tile>}
* @private
*/
this.tiles_ = [];
/**
* @type {!Array.<!Mosaic.Row>}
* @private
*/
this.rows_ = [];
/**
* @type {Mosaic.Row}
* @private
*/
this.newRow_ = null;
/**
* @type {!Array.<number>}
* @private
*/
this.rowHeights_ = [];
/**
* @type {number}
* @private
*/
this.height_ = 0;
this.reset_();
};
/**
* Resets the layout.
* @private
*/
Mosaic.Column.prototype.reset_ = function() {
this.tiles_ = [];
this.rows_ = [];
this.newRow_ = null;
};
/**
* @return {number} Number of tiles in the column.
*/
Mosaic.Column.prototype.getTileCount = function() { return this.tiles_.length };
/**
* @return {number} Index of the last tile + 1.
*/
Mosaic.Column.prototype.getNextTileIndex = function() {
return this.firstTileIndex_ + this.getTileCount();
};
/**
* @return {number} Global index of the last row + 1.
*/
Mosaic.Column.prototype.getNextRowIndex = function() {
return this.firstRowIndex_ + this.rows_.length;
};
/**
* @return {!Array.<!Mosaic.Tile>} Array of tiles in the column.
*/
Mosaic.Column.prototype.getTiles = function() { return this.tiles_ };
/**
* @param {number} index Tile index.
* @return {boolean} True if this column contains the tile with the given index.
*/
Mosaic.Column.prototype.hasTile = function(index) {
return this.firstTileIndex_ <= index &&
index < (this.firstTileIndex_ + this.getTileCount());
};
/**
* @param {number} y Y coordinate.
* @param {number} direction -1 for left, 1 for right.
* @return {number} Index of the tile lying on the edge of the column at the
* given y coordinate.
* @private
*/
Mosaic.Column.prototype.getEdgeTileIndex_ = function(y, direction) {
for (var r = 0; r < this.rows_.length; r++) {
if (this.rows_[r].coversY(y))
return this.rows_[r].getEdgeTileIndex_(direction);
}
return -1;
};
/**
* @param {number} index Tile index.
* @return {Mosaic.Row} The row containing the tile with a given index.
*/
Mosaic.Column.prototype.getRowByTileIndex = function(index) {
for (var r = 0; r !== this.rows_.length; r++) {
if (this.rows_[r].hasTile(index))
return this.rows_[r];
}
return null;
};
/**
* Adds a tile to the column.
*
* @param {!Mosaic.Tile} tile The tile to add.
*/
Mosaic.Column.prototype.add = function(tile) {
var rowIndex = this.getNextRowIndex();
if (!this.newRow_)
this.newRow_ = new Mosaic.Row(this.getNextTileIndex());
this.tiles_.push(tile);
this.newRow_.add(tile);
if (this.density_.isRowComplete(this.newRow_.getTileCount(), rowIndex)) {
this.rows_.push(this.newRow_);
this.newRow_ = null;
}
};
/**
* Prepares the column layout.
*
* @param {boolean=} opt_force True if the layout must be performed even for an
* incomplete column.
* @return {boolean} True if the layout was performed.
*/
Mosaic.Column.prototype.prepareLayout = function(opt_force) {
if (opt_force && this.newRow_) {
this.rows_.push(this.newRow_);
this.newRow_ = null;
}
if (this.rows_.length === 0)
return false;
this.width_ = Math.min.apply(
null, this.rows_.map(function(row) { return row.getMaxWidth() }));
this.height_ = 0;
this.rowHeights_ = [];
for (var r = 0; r !== this.rows_.length; r++) {
var rowHeight = this.rows_[r].getHeightForWidth(this.width_);
this.height_ += rowHeight;
this.rowHeights_.push(rowHeight);
}
var overflow = this.height_ / this.maxHeight_;
if (!opt_force && (overflow < 1))
return false;
if (overflow > 1) {
// Scale down the column width and height.
this.width_ = Math.round(this.width_ / overflow);
this.height_ = this.maxHeight_;
Mosaic.Layout.rescaleSizesToNewTotal(this.rowHeights_, this.maxHeight_);
}
return true;
};
/**
* Retries the column layout with less tiles per row.
*/
Mosaic.Column.prototype.retryWithLowerDensity = function() {
this.density_.decreaseHorizontal();
this.reset_();
};
/**
* @return {number} Column left edge coordinate.
*/
Mosaic.Column.prototype.getLeft = function() { return this.left_ };
/**
* @return {number} Column right edge coordinate after the layout.
*/
Mosaic.Column.prototype.getRight = function() {
return this.left_ + this.width_;
};
/**
* @return {number} Column height after the layout.
*/
Mosaic.Column.prototype.getHeight = function() { return this.height_ };
/**
* Performs the column layout.
* @param {number=} opt_offsetX Horizontal offset.
* @param {number=} opt_offsetY Vertical offset.
*/
Mosaic.Column.prototype.layout = function(opt_offsetX, opt_offsetY) {
opt_offsetX = opt_offsetX || 0;
opt_offsetY = opt_offsetY || 0;
var rowTop = Mosaic.Layout.PADDING_TOP;
for (var r = 0; r !== this.rows_.length; r++) {
this.rows_[r].layout(
opt_offsetX + this.left_,
opt_offsetY + rowTop,
this.width_,
this.rowHeights_[r]);
rowTop += this.rowHeights_[r];
}
};
/**
* Checks if the column layout is too ugly to be displayed.
*
* @return {boolean} True if the layout is suboptimal.
*/
Mosaic.Column.prototype.isSuboptimal = function() {
var tileCounts =
this.rows_.map(function(row) { return row.getTileCount() });
var maxTileCount = Math.max.apply(null, tileCounts);
if (maxTileCount === 1)
return false; // Every row has exactly 1 tile, as optimal as it gets.
var sizes =
this.tiles_.map(function(tile) { return tile.getMaxContentHeight() });
// Ugly layout #1: all images are small and some are one the same row.
var allSmall = Math.max.apply(null, sizes) <= Mosaic.Tile.SMALL_IMAGE_SIZE;
if (allSmall)
return true;
// Ugly layout #2: all images are large and none occupies an entire row.
var allLarge = Math.min.apply(null, sizes) > Mosaic.Tile.SMALL_IMAGE_SIZE;
var allCombined = Math.min.apply(null, tileCounts) !== 1;
if (allLarge && allCombined)
return true;
// Ugly layout #3: some rows have too many tiles for the resulting width.
if (this.width_ / maxTileCount < 100)
return true;
return false;
};
////////////////////////////////////////////////////////////////////////////////
/**
* A row in a mosaic layout. Contains tiles.
*
* @param {number} firstTileIndex Index of the first tile in the row.
* @constructor
* @struct
*/
Mosaic.Row = function(firstTileIndex) {
this.firstTileIndex_ = firstTileIndex;
this.tiles_ = [];
/**
* @type {number}
* @private
*/
this.top_ = 0;
/**
* @type {number}
* @private
*/
this.height_ = 0;
};
/**
* @param {!Mosaic.Tile} tile The tile to add.
*/
Mosaic.Row.prototype.add = function(tile) {
console.assert(this.getTileCount() < Mosaic.Density.MAX_HORIZONTAL);
this.tiles_.push(tile);
};
/**
* @return {!Array.<!Mosaic.Tile>} Array of tiles in the row.
*/
Mosaic.Row.prototype.getTiles = function() { return this.tiles_ };
/**
* Gets a tile by index.
* @param {number} index Tile index.
* @return {Mosaic.Tile} Requested tile or null if not found.
*/
Mosaic.Row.prototype.getTileByIndex = function(index) {
if (!this.hasTile(index))
return null;
return this.tiles_[index - this.firstTileIndex_];
};
/**
*
* @return {number} Number of tiles in the row.
*/
Mosaic.Row.prototype.getTileCount = function() { return this.tiles_.length };
/**
* @param {number} index Tile index.
* @return {boolean} True if this row contains the tile with the given index.
*/
Mosaic.Row.prototype.hasTile = function(index) {
return this.firstTileIndex_ <= index &&
index < (this.firstTileIndex_ + this.tiles_.length);
};
/**
* @param {number} y Y coordinate.
* @return {boolean} True if this row covers the given Y coordinate.
*/
Mosaic.Row.prototype.coversY = function(y) {
return this.top_ <= y && y < (this.top_ + this.height_);
};
/**
* @return {number} Y coordinate of the tile center.
*/
Mosaic.Row.prototype.getCenterY = function() {
return this.top_ + Math.round(this.height_ / 2);
};
/**
* Gets the first or the last tile.
*
* @param {number} direction -1 for the first tile, 1 for the last tile.
* @return {number} Tile index.
* @private
*/
Mosaic.Row.prototype.getEdgeTileIndex_ = function(direction) {
if (direction < 0)
return this.firstTileIndex_;
else
return this.firstTileIndex_ + this.getTileCount() - 1;
};
/**
* @return {number} Aspect ration of the combined content box of this row.
* @private
*/
Mosaic.Row.prototype.getTotalContentAspectRatio_ = function() {
var sum = 0;
for (var t = 0; t !== this.tiles_.length; t++)
sum += this.tiles_[t].getAspectRatio();
return sum;
};
/**
* @return {number} Total horizontal spacing in this row. This includes
* the spacing between the tiles and both left and right margins.
*
* @private
*/
Mosaic.Row.prototype.getTotalHorizontalSpacing_ = function() {
return Mosaic.Layout.SPACING * this.getTileCount();
};
/**
* @return {number} Maximum width that this row may have without overscaling
* any of the tiles.
*/
Mosaic.Row.prototype.getMaxWidth = function() {
var contentHeight = Math.min.apply(null,
this.tiles_.map(function(tile) { return tile.getMaxContentHeight() }));
var contentWidth =
Math.round(contentHeight * this.getTotalContentAspectRatio_());
return contentWidth + this.getTotalHorizontalSpacing_();
};
/**
* Computes the height that best fits the supplied row width given
* aspect ratios of the tiles in this row.
*
* @param {number} width Row width.
* @return {number} Height.
*/
Mosaic.Row.prototype.getHeightForWidth = function(width) {
var contentWidth = width - this.getTotalHorizontalSpacing_();
var contentHeight =
Math.round(contentWidth / this.getTotalContentAspectRatio_());
return contentHeight + Mosaic.Layout.SPACING;
};
/**
* Positions the row in the mosaic.
*
* @param {number} left Left position.
* @param {number} top Top position.
* @param {number} width Width.
* @param {number} height Height.
*/
Mosaic.Row.prototype.layout = function(left, top, width, height) {
this.top_ = top;
this.height_ = height;
var contentWidth = width - this.getTotalHorizontalSpacing_();
var contentHeight = height - Mosaic.Layout.SPACING;
var tileContentWidth = this.tiles_.map(
function(tile) { return tile.getAspectRatio() });
Mosaic.Layout.rescaleSizesToNewTotal(tileContentWidth, contentWidth);
var tileLeft = left;
for (var t = 0; t !== this.tiles_.length; t++) {
var tileWidth = tileContentWidth[t] + Mosaic.Layout.SPACING;
this.tiles_[t].layout(tileLeft, top, tileWidth, height);
tileLeft += tileWidth;
}
};
////////////////////////////////////////////////////////////////////////////////
/**
* A single tile of the image mosaic.
*
* @param {!Element} container Container element.
* @param {!Gallery.Item} item Gallery item associated with this tile.
* @param {EntryLocation=} opt_locationInfo Location information for the tile.
* @return {!Element} The new tile element.
* @constructor
* @extends {HTMLDivElement}
* @struct
* @suppress {checkStructDictInheritance}
*/
Mosaic.Tile = function(container, item, opt_locationInfo) {
// This is a hack to make closure compiler recognize definitions of fields
// with this decorate pattern. When this constructor is called as "new
// Mosaic.Tile(...)", "this" should be Mosaic.Tile. In that case, this calls
// this constructor again with setting this as HTMLDivElement. When this
// condition is false, this method decorates the "this" object, and returns
// it.
if (this instanceof Mosaic.Tile) {
return Mosaic.Tile.call(
/** @type {Mosaic.Tile} */ (document.createElement('div')),
container, item, opt_locationInfo);
}
this.__proto__ = Mosaic.Tile.prototype;
this.className = 'mosaic-tile';
/**
* @type {!Element}
* @private
*/
this.container_ = container;
/**
* @type {!Gallery.Item}
* @private
*/
this.item_ = item;
/**
* @type {?number}
* @private
*/
this.left_ = null; // Mark as not laid out.
/**
* @type {number}
* @private
*/
this.top_ = 0;
/**
* @type {number}
* @private
*/
this.width_ = 0;
/**
* @type {number}
* @private
*/
this.height_ = 0;
/**
* @type {number}
* @private
*/
this.maxContentHeight_ = 0;
/**
* @type {number}
* @private
*/
this.aspectRatio_ = 0;
/**
* @type {ThumbnailLoader}
* @private
*/
this.thumbnailPreloader_ = null;
/**
* @type {ThumbnailLoader}
* @private
*/
this.thumbnailLoader_ = null;
/**
* @type {boolean}
* @private
*/
this.imagePreloaded_ = false;
/**
* @type {boolean}
* @private
*/
this.imageLoaded_ = false;
/**
* @type {boolean}
* @private
*/
this.imagePreloading_ = false;
/**
* @type {boolean}
* @private
*/
this.imageLoading_ = false;
/**
* @type {HTMLDivElement}
* @private
*/
this.wrapper_ = null;
return this;
};
/**
* Load mode for the tile's image.
* @enum {number}
*/
Mosaic.Tile.LoadMode = {
LOW_DPI: 0,
HIGH_DPI: 1
};
/**
* Inherit from HTMLDivElement.
*/
Mosaic.Tile.prototype.__proto__ = HTMLDivElement.prototype;
/**
* Minimum tile content size.
* @type {number}
* @const
*/
Mosaic.Tile.MIN_CONTENT_SIZE = 64;
/**
* Maximum tile content size.
* @type {number}
* @const
*/
Mosaic.Tile.MAX_CONTENT_SIZE = 512;
/**
* Default size for a tile with no thumbnail image.
* @type {number}
* @const
*/
Mosaic.Tile.GENERIC_ICON_SIZE = 128;
/**
* Max size of an image considered to be 'small'.
* Small images are laid out slightly differently.
* @type {number}
* @const
*/
Mosaic.Tile.SMALL_IMAGE_SIZE = 160;
/**
* @return {!Gallery.Item} The Gallery item.
*/
Mosaic.Tile.prototype.getItem = function() { return this.item_; };
/**
* @return {number} Maximum content height that this tile can have.
*/
Mosaic.Tile.prototype.getMaxContentHeight = function() {
return this.maxContentHeight_;
};
/**
* @return {number} The aspect ratio of the tile image.
*/
Mosaic.Tile.prototype.getAspectRatio = function() { return this.aspectRatio_; };
/**
* @return {boolean} True if the tile is initialized.
*/
Mosaic.Tile.prototype.isInitialized = function() {
return !!this.maxContentHeight_;
};
/**
* Checks whether the image of specified (or better resolution) has been loaded.
*
* @param {Mosaic.Tile.LoadMode=} opt_loadMode Loading mode, default: LOW_DPI.
* @return {boolean} True if the tile is loaded with the specified dpi or
* better.
*/
Mosaic.Tile.prototype.isLoaded = function(opt_loadMode) {
var loadMode = opt_loadMode || Mosaic.Tile.LoadMode.LOW_DPI;
switch (loadMode) {
case Mosaic.Tile.LoadMode.LOW_DPI:
if (this.imagePreloaded_ || this.imageLoaded_)
return true;
break;
case Mosaic.Tile.LoadMode.HIGH_DPI:
if (this.imageLoaded_)
return true;
break;
}
return false;
};
/**
* Checks whether the image of specified (or better resolution) is being loaded.
*
* @param {Mosaic.Tile.LoadMode=} opt_loadMode Loading mode, default: LOW_DPI.
* @return {boolean} True if the tile is being loaded with the specified dpi or
* better.
*/
Mosaic.Tile.prototype.isLoading = function(opt_loadMode) {
var loadMode = opt_loadMode || Mosaic.Tile.LoadMode.LOW_DPI;
switch (loadMode) {
case Mosaic.Tile.LoadMode.LOW_DPI:
if (this.imagePreloading_ || this.imageLoading_)
return true;
break;
case Mosaic.Tile.LoadMode.HIGH_DPI:
if (this.imageLoading_)
return true;
break;
}
return false;
};
/**
* Marks the tile as not loaded to prevent it from participating in the layout.
*/
Mosaic.Tile.prototype.markUnloaded = function() {
this.maxContentHeight_ = 0;
if (this.thumbnailLoader_) {
this.thumbnailLoader_.cancel();
this.imagePreloaded_ = false;
this.imagePreloading_ = false;
this.imageLoaded_ = false;
this.imageLoading_ = false;
}
};
/**
* Initializes the thumbnail in the tile. Does not load an image, but sets
* target dimensions using metadata.
*/
Mosaic.Tile.prototype.init = function() {
var metadata = this.getItem().getMetadata();
this.markUnloaded();
this.left_ = null; // Mark as not laid out.
// Set higher priority for the selected elements to load them first.
var priority = this.getAttribute('selected') ? 2 : 3;
// Use embedded thumbnails on Drive, since they have higher resolution.
this.thumbnailLoader_ = new ThumbnailLoader(
this.getItem().getEntry(),
ThumbnailLoader.LoaderType.CANVAS,
metadata,
undefined, // Media type.
[
ThumbnailLoader.LoadTarget.EXTERNAL_METADATA,
ThumbnailLoader.LoadTarget.FILE_ENTRY
]);
// If no hidpi embedded thumbnail available, then use the low resolution
// for preloading.
if (this.thumbnailLoader_.getLoadTarget() ===
ThumbnailLoader.LoadTarget.FILE_ENTRY) {
this.thumbnailPreloader_ = new ThumbnailLoader(
this.getItem().getEntry(),
ThumbnailLoader.LoaderType.CANVAS,
metadata,
undefined, // Media type.
[
ThumbnailLoader.LoadTarget.CONTENT_METADATA
],
// Preloaders have always higher priotity, so the preload images
// are loaded as soon as possible.
2);
if (!this.thumbnailPreloader_.getLoadTarget())
this.thumbnailPreloader_ = null;
}
// Dimensions are always acquired from the metadata. For local files, it is
// extracted from headers. For Drive files, it is received via the Drive API.
// If the dimensions are not available, then the fallback dimensions will be
// used (same as for the generic icon).
var width;
var height;
if (metadata.media && metadata.media.width) {
width = metadata.media.width;
height = metadata.media.height;
} else if (metadata.external && metadata.external.imageWidth &&
metadata.external.imageHeight) {
width = metadata.external.imageWidth;
height = metadata.external.imageHeight;
} else {
// No dimensions in metadata, then use the generic dimensions.
width = Mosaic.Tile.GENERIC_ICON_SIZE;
height = Mosaic.Tile.GENERIC_ICON_SIZE;
}
if (width > height) {
if (width > Mosaic.Tile.MAX_CONTENT_SIZE) {
height = Math.round(height * Mosaic.Tile.MAX_CONTENT_SIZE / width);
width = Mosaic.Tile.MAX_CONTENT_SIZE;
}
} else {
if (height > Mosaic.Tile.MAX_CONTENT_SIZE) {
width = Math.round(width * Mosaic.Tile.MAX_CONTENT_SIZE / height);
height = Mosaic.Tile.MAX_CONTENT_SIZE;
}
}
this.maxContentHeight_ = Math.max(Mosaic.Tile.MIN_CONTENT_SIZE, height);
this.aspectRatio_ = width / height;
};
/**
* Loads an image into the tile.
*
* The mode argument is a hint. Use low-dpi for faster response, and high-dpi
* for better output, but possibly affecting performance.
*
* If the mode is high-dpi, then a the high-dpi image is loaded, but also
* low-dpi image is loaded for preloading (if available).
* For the low-dpi mode, only low-dpi image is loaded. If not available, then
* the high-dpi image is loaded as a fallback.
*
* @param {!Mosaic.Tile.LoadMode} loadMode Loading mode.
* @param {function(boolean)} onImageLoaded Callback when image is loaded.
* The argument is true for success, false for failure.
*/
Mosaic.Tile.prototype.load = function(loadMode, onImageLoaded) {
// Attaches the image to the tile and finalizes loading process for the
// specified loader.
var finalizeLoader = function(mode, success, loader) {
if (success && this.wrapper_) {
// Show the fade-in animation only when previously there was no image
// attached in this tile.
if (!this.imageLoaded_ && !this.imagePreloaded_)
this.wrapper_.classList.add('animated');
else
this.wrapper_.classList.remove('animated');
}
// Add debug mode classes.
this.wrapper_.classList.remove('load-target-content-metadata');
this.wrapper_.classList.remove('load-target-external-metadata');
this.wrapper_.classList.remove('load-target-file-entry');
switch (loader.getLoadTarget()) {
case ThumbnailLoader.LoadTarget.CONTENT_METADATA:
this.wrapper_.classList.add('load-target-content-metadata');
break;
case ThumbnailLoader.LoadTarget.EXTERNAL_METADATA:
this.wrapper_.classList.add('load-target-external-metadata');
break;
case ThumbnailLoader.LoadTarget.FILE_ENTRY:
this.wrapper_.classList.add('load-target-file-entry');
break;
}
loader.attachImage(this.wrapper_, ThumbnailLoader.FillMode.OVER_FILL);
onImageLoaded(success);
switch (mode) {
case Mosaic.Tile.LoadMode.LOW_DPI:
this.imagePreloading_ = false;
this.imagePreloaded_ = true;
break;
case Mosaic.Tile.LoadMode.HIGH_DPI:
this.imageLoading_ = false;
this.imageLoaded_ = true;
break;
}
}.bind(this);
// Always load the low-dpi image first if it is available for the fastest
// feedback.
if (!this.imagePreloading_ && this.thumbnailPreloader_) {
this.imagePreloading_ = true;
this.thumbnailPreloader_.loadDetachedImage(function(success) {
// Hi-dpi loaded first, ignore this call then.
if (this.imageLoaded_)
return;
finalizeLoader(Mosaic.Tile.LoadMode.LOW_DPI,
success,
this.thumbnailPreloader_);
}.bind(this));
}
// Load the high-dpi image only when it is requested, or the low-dpi is not
// available.
if (!this.imageLoading_ &&
(loadMode === Mosaic.Tile.LoadMode.HIGH_DPI || !this.imagePreloading_)) {
this.imageLoading_ = true;
this.thumbnailLoader_.loadDetachedImage(function(success) {
// Cancel preloading, since the hi-dpi image is ready.
if (this.thumbnailPreloader_)
this.thumbnailPreloader_.cancel();
finalizeLoader(Mosaic.Tile.LoadMode.HIGH_DPI,
success,
this.thumbnailLoader_);
}.bind(this));
}
};
/**
* Unloads an image from the tile.
*/
Mosaic.Tile.prototype.unload = function() {
this.thumbnailLoader_.cancel();
if (this.thumbnailPreloader_)
this.thumbnailPreloader_.cancel();
this.imagePreloaded_ = false;
this.imageLoaded_ = false;
this.imagePreloading_ = false;
this.imageLoading_ = false;
this.wrapper_.innerText = '';
};
/**
* Selects/unselects the tile.
*
* @param {boolean} on True if selected.
*/
Mosaic.Tile.prototype.select = function(on) {
if (on)
this.setAttribute('selected', true);
else
this.removeAttribute('selected');
};
/**
* Positions the tile in the mosaic.
*
* @param {number} left Left position.
* @param {number} top Top position.
* @param {number} width Width.
* @param {number} height Height.
*/
Mosaic.Tile.prototype.layout = function(left, top, width, height) {
this.left_ = left;
this.top_ = top;
this.width_ = width;
this.height_ = height;
this.style.left = left + 'px';
this.style.top = top + 'px';
this.style.width = width + 'px';
this.style.height = height + 'px';
if (!this.wrapper_) { // First time, create DOM.
this.container_.appendChild(this);
var border = util.createChild(this, 'img-border');
this.wrapper_ = assertInstanceof(util.createChild(border, 'img-wrapper'),
HTMLDivElement);
}
if (this.hasAttribute('selected'))
this.scrollIntoView(false);
if (this.imageLoaded_) {
this.thumbnailLoader_.attachImage(this.wrapper_,
ThumbnailLoader.FillMode.OVER_FILL);
}
};
/**
* If the tile is not fully visible scroll the parent to make it fully visible.
* @param {boolean=} opt_animated True, if scroll should be animated,
* default: true.
*/
Mosaic.Tile.prototype.scrollIntoView = function(opt_animated) {
if (this.left_ === null) // Not laid out.
return;
var targetPosition;
var tileLeft = this.left_ - Mosaic.Layout.SCROLL_MARGIN;
if (tileLeft < this.container_.scrollLeft) {
targetPosition = tileLeft;
} else {
var tileRight = this.left_ + this.width_ + Mosaic.Layout.SCROLL_MARGIN;
var scrollRight = this.container_.scrollLeft + this.container_.clientWidth;
if (tileRight > scrollRight)
targetPosition = tileRight - this.container_.clientWidth;
}
if (targetPosition) {
if (opt_animated === false)
this.container_.scrollLeft = targetPosition;
else
this.container_.animatedScrollTo(targetPosition);
}
};
/**
* @return {ImageRect} Rectangle occupied by the tile's image,
* relative to the viewport.
*/
Mosaic.Tile.prototype.getImageRect = function() {
if (this.left_ === null) // Not laid out.
return null;
var margin = Mosaic.Layout.SPACING / 2;
return new ImageRect(this.left_ - this.container_.scrollLeft, this.top_,
this.width_, this.height_).inflate(-margin, -margin);
};
/**
* @return {number} X coordinate of the tile center.
*/
Mosaic.Tile.prototype.getCenterX = function() {
return this.left_ + Math.round(this.width_ / 2);
};