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