OLD | NEW |
(Empty) | |
| 1 <!-- |
| 2 // Copyright 2014 The Chromium Authors. All rights reserved. |
| 3 // Use of this source code is governed by a BSD-style license that can be |
| 4 // found in the LICENSE file. |
| 5 --> |
| 6 <link rel="import" |
| 7 href="../../framework/sky-element/sky-element.sky" |
| 8 as="SkyElement" /> |
| 9 <link rel="import" href="city-data-service.sky" as="CityDataService" /> |
| 10 <link rel="import" href="city-sequence.sky" as="CitySequence" /> |
| 11 |
| 12 <template> |
| 13 <style> |
| 14 div { |
| 15 font-size: 16px; |
| 16 color: #FFF; |
| 17 background-color: #333; |
| 18 padding: 4px 4px 4px 12px; |
| 19 } |
| 20 </style> |
| 21 <div>{{ state }}</div> |
| 22 </template> |
| 23 <script> |
| 24 SkyElement({ |
| 25 name: 'state-header', |
| 26 |
| 27 set datum(datum) { |
| 28 this.state = datum.state; |
| 29 } |
| 30 }); |
| 31 </script> |
| 32 |
| 33 <template> |
| 34 <style> |
| 35 div { |
| 36 font-size: 12px; |
| 37 font-weight: bold; |
| 38 padding: 2px 4px 4px 12px; |
| 39 background-color: #DDD; |
| 40 } |
| 41 </style> |
| 42 <div>{{ letter }}</div> |
| 43 </template> |
| 44 <script> |
| 45 SkyElement({ |
| 46 name: 'letter-header', |
| 47 |
| 48 set datum(datum) { |
| 49 this.letter = datum.letter; |
| 50 } |
| 51 }); |
| 52 </script> |
| 53 |
| 54 <template> |
| 55 <style> |
| 56 :host { |
| 57 display: flex; |
| 58 font-size: 13px; |
| 59 padding: 8px 4px 4px 12px; |
| 60 border-bottom: 1px solid #EEE; |
| 61 line-height: 15px; |
| 62 overflow: hidden; |
| 63 } |
| 64 |
| 65 span { |
| 66 display: inline; |
| 67 } |
| 68 |
| 69 #name { |
| 70 font-weight: bold |
| 71 } |
| 72 |
| 73 #population { |
| 74 color: #AAA; |
| 75 } |
| 76 |
| 77 </style> |
| 78 <div> |
| 79 <span id="name">{{ name }}</span>, |
| 80 <span id="population">population {{ population }}</span> |
| 81 </div> |
| 82 </template> |
| 83 <script> |
| 84 SkyElement({ |
| 85 name: 'city-item', |
| 86 |
| 87 set datum(datum) { |
| 88 this.name = datum.name; |
| 89 this.population = datum.population; |
| 90 } |
| 91 }); |
| 92 </script> |
| 93 |
| 94 <template> |
| 95 <style> |
| 96 |
| 97 :host { |
| 98 overflow: hidden; |
| 99 position: absolute; |
| 100 top: 0; |
| 101 right: 0; |
| 102 bottom: 0; |
| 103 left: 0; |
| 104 display: block; |
| 105 background-color: #fff; |
| 106 } |
| 107 |
| 108 #scroller { |
| 109 overflow-x: hidden; |
| 110 overflow-y: auto; |
| 111 perspective: 5px; |
| 112 position: absolute; |
| 113 left: 0; |
| 114 right: 0; |
| 115 top: 0; |
| 116 bottom: 0; |
| 117 } |
| 118 |
| 119 #scroller::-webkit-scrollbar { |
| 120 display:none; |
| 121 } |
| 122 |
| 123 #scrollarea { |
| 124 will-change: transform; |
| 125 transform-style: preserve-3d; |
| 126 } |
| 127 |
| 128 #contentarea { |
| 129 position: absolute; |
| 130 will-change: contents; |
| 131 width: 100%; |
| 132 } |
| 133 |
| 134 .void { |
| 135 display: none; |
| 136 } |
| 137 |
| 138 .position { |
| 139 position: absolute; |
| 140 left: 0; |
| 141 right: 0; |
| 142 } |
| 143 |
| 144 </style> |
| 145 |
| 146 <div id="scroller" fit> |
| 147 <div id="scrollarea"> |
| 148 <div id="contentarea"> |
| 149 </div> |
| 150 </div> |
| 151 </div> |
| 152 |
| 153 </template> |
| 154 <script> |
| 155 |
| 156 (function(global) { |
| 157 "use strict"; |
| 158 |
| 159 var LOAD_LENGTH = 20; |
| 160 var LOAD_BUFFER_PRE = LOAD_LENGTH * 4; |
| 161 var LOAD_BUFFER_POST = LOAD_LENGTH * 4; |
| 162 |
| 163 function Loader() { |
| 164 this.loadingData = false; |
| 165 this.data = null; |
| 166 this.zeroIndex = 0; |
| 167 this.loadIndex = 0; |
| 168 } |
| 169 |
| 170 Loader.prototype.localIndex = function(externalIndex) { |
| 171 return externalIndex + this.zeroIndex; |
| 172 } |
| 173 |
| 174 Loader.prototype.externalIndex = function(localIndex) { |
| 175 return localIndex - this.zeroIndex; |
| 176 } |
| 177 |
| 178 Loader.prototype.getItems = function() { |
| 179 return this.data ? this.data.items : []; |
| 180 } |
| 181 |
| 182 Loader.prototype.maybeLoadMoreData = function(dataloadedCallback, |
| 183 firstVisible) { |
| 184 if (this.loadingData) |
| 185 return; |
| 186 |
| 187 if (firstVisible) { |
| 188 this.loadIndex = this.externalIndex( |
| 189 this.data.items.indexOf(firstVisible)); |
| 190 } |
| 191 |
| 192 var localIndex = this.localIndex(this.loadIndex); |
| 193 var loadedPre = 0; |
| 194 var loadedPost = 0; |
| 195 |
| 196 if (this.data) { |
| 197 loadedPre = localIndex; |
| 198 loadedPost = this.data.items.length - loadedPre; |
| 199 } |
| 200 |
| 201 var loadTime; |
| 202 if (loadedPre >= LOAD_BUFFER_PRE && loadedPost >= LOAD_BUFFER_POST) { |
| 203 if (window.startLoad) { |
| 204 loadTime = new Date().getTime() - window.startLoad; |
| 205 console.log('Load: ' + loadTime + 'ms'); |
| 206 window.startLoad = undefined; |
| 207 } |
| 208 return; |
| 209 } |
| 210 |
| 211 this.loadingData = true; |
| 212 |
| 213 var loadIndex; |
| 214 |
| 215 if (!this.data) { |
| 216 // Initial batch |
| 217 loadIndex = 0; |
| 218 } else if (loadedPost < LOAD_BUFFER_POST) { |
| 219 // Load forward first |
| 220 loadIndex = this.data.items.length; |
| 221 } else { |
| 222 // Then load backward |
| 223 loadIndex = -LOAD_LENGTH; |
| 224 } |
| 225 |
| 226 var self = this; |
| 227 var externalIndex = this.externalIndex(loadIndex); |
| 228 |
| 229 try { |
| 230 CityDataService.service.then(function(cityService) { |
| 231 return cityService.get(externalIndex, LOAD_LENGTH) |
| 232 .then(function(cities) { |
| 233 var indexOffset = 0; |
| 234 var newData = new CitySequence(cities); |
| 235 if (!self.data) { |
| 236 self.data = newData; |
| 237 } else if (loadIndex > 0) { |
| 238 self.data.append(newData); |
| 239 } else { |
| 240 self.zeroIndex += LOAD_LENGTH; |
| 241 indexOffset = LOAD_LENGTH; |
| 242 newData.append(self.data); |
| 243 self.data = newData; |
| 244 } |
| 245 |
| 246 self.loadingData = false; |
| 247 dataloadedCallback(self.data, indexOffset); |
| 248 }); |
| 249 }).catch(function(ex) { |
| 250 console.log(ex.stack); |
| 251 }); |
| 252 } catch (ex) { |
| 253 console.log(ex.stack); |
| 254 } |
| 255 } |
| 256 |
| 257 function Scroller() { |
| 258 this.contentarea = null; |
| 259 this.scroller = null; |
| 260 this.contentTop = 0; // #contentarea's current top |
| 261 this.scrollTop = 0; // #scrollarea's current top |
| 262 this.scrollHeight = -1; // height of #scroller (the viewport) |
| 263 this.lastScrollTop = 0; // last known scrollTop to compute deltas |
| 264 } |
| 265 |
| 266 Scroller.prototype.setup = function(scroller, scrollarea, contentarea) { |
| 267 this.contentarea = contentarea; |
| 268 this.scroller = scroller; |
| 269 |
| 270 this.scrollHeight = scroller.offsetHeight; |
| 271 scrollarea.style.height = (this.scrollHeight) * 4 + 'px'; |
| 272 |
| 273 this.reset(); |
| 274 } |
| 275 |
| 276 Scroller.prototype.captureNewFrame = function(event) { |
| 277 var scrollTop = event.target.scrollTop; |
| 278 |
| 279 // Protect from re-entry. |
| 280 if (this.lastScrollTop == scrollTop) |
| 281 return false; |
| 282 |
| 283 var scrollDown = scrollTop > this.lastScrollTop; |
| 284 if (scrollDown) { |
| 285 while (scrollTop > this.scrollHeight * 1.5) { |
| 286 scrollTop -= this.scrollHeight; |
| 287 this.contentTop -= this.scrollHeight; |
| 288 } |
| 289 } else { |
| 290 while(scrollTop < this.scrollHeight * 1.5) { |
| 291 scrollTop += this.scrollHeight; |
| 292 this.contentTop += this.scrollHeight; |
| 293 } |
| 294 } |
| 295 |
| 296 this.lastScrollTop = scrollTop; |
| 297 event.target.scrollTop = scrollTop; |
| 298 this.contentarea.style.top = this.contentTop + 'px'; |
| 299 |
| 300 this.scrollTop = scrollTop - this.contentTop; |
| 301 |
| 302 return true; |
| 303 } |
| 304 |
| 305 Scroller.prototype.reset = function() { |
| 306 if (!this.contentarea) |
| 307 return; |
| 308 |
| 309 this.scroller.scrollTop = this.scrollHeight; |
| 310 this.lastScrollTop = this.scrollHeight; |
| 311 |
| 312 this.contentarea.style.top = this.scrollHeight + 'px'; |
| 313 this.contentTop = this.scrollHeight; |
| 314 this.scrollTop = 0; |
| 315 } |
| 316 |
| 317 // Current position and height of the scroller, that could |
| 318 // be used (by Tiler, for example) to reason about where to |
| 319 // place visible things. |
| 320 Scroller.prototype.getCurrentFrame = function() { |
| 321 return { top: this.scrollTop, height: this.scrollHeight }; |
| 322 } |
| 323 |
| 324 Scroller.prototype.hasFrame = function() { |
| 325 return this.scrollHeight != -1; |
| 326 } |
| 327 |
| 328 function Tile(datum, element, viewType, index) { |
| 329 this.datum = datum; |
| 330 this.element = element; |
| 331 this.viewType = viewType; |
| 332 this.index = index; |
| 333 } |
| 334 |
| 335 function Tiler(contentArea, views, viewHeights) { |
| 336 this.contentArea = contentArea; |
| 337 this.drawTop = 0; |
| 338 this.drawBottom = 0; |
| 339 this.firstItem = -1; |
| 340 this.tiles = []; |
| 341 this.viewHeights = viewHeights; |
| 342 this.views = views; |
| 343 } |
| 344 |
| 345 Tiler.prototype.setupViews = function(scrollFrame) { |
| 346 for (var type in this.viewHeights) { |
| 347 this.initializeViewType(scrollFrame, type, this.viewHeights[type]); |
| 348 } |
| 349 } |
| 350 |
| 351 Tiler.prototype.initializeViewType = function(scrollFrame, viewType, |
| 352 height) { |
| 353 var count = Math.ceil(scrollFrame.height / height) * 2; |
| 354 var viewCache = this.views[viewType] = { |
| 355 indices: [], |
| 356 elements: [] |
| 357 }; |
| 358 |
| 359 var protoElement; |
| 360 switch (viewType) { |
| 361 case 'stateHeader': |
| 362 protoElement = document.createElement('state-header'); |
| 363 break; |
| 364 case 'letterHeader': |
| 365 protoElement = document.createElement('letter-header'); |
| 366 break; |
| 367 case 'cityItem': |
| 368 protoElement = document.createElement('city-item'); |
| 369 break; |
| 370 default: |
| 371 console.warn('Unknown viewType: ' + viewType); |
| 372 } |
| 373 protoElement.style.display = 'none'; |
| 374 protoElement.style.height = height; |
| 375 protoElement.classList.add('position'); |
| 376 |
| 377 for (var i = 0; i < count; i++) { |
| 378 var clone = protoElement.cloneNode(false); |
| 379 this.contentArea.appendChild(clone); |
| 380 viewCache.elements.push(clone); |
| 381 viewCache.indices.push(i); |
| 382 } |
| 383 } |
| 384 |
| 385 Tiler.prototype.checkoutTile = function(viewType, datum, top) { |
| 386 var viewCache = this.views[viewType]; |
| 387 var index = viewCache.indices.pop(); |
| 388 var element = viewCache.elements[index]; |
| 389 element.datum = datum; |
| 390 element.style.display = ''; |
| 391 element.style.top = top + 'px'; |
| 392 |
| 393 return new Tile(datum, element, viewType, index); |
| 394 } |
| 395 |
| 396 Tiler.prototype.checkinTile = function(tile) { |
| 397 if (!tile.element) |
| 398 return; |
| 399 |
| 400 tile.element.style.display = 'none'; |
| 401 this.views[tile.viewType].indices.push(tile.index); |
| 402 } |
| 403 |
| 404 Tiler.prototype.getFirstVisibleDatum = function(scrollFrame) { |
| 405 var tiles = this.tiles; |
| 406 var viewHeights = this.viewHeights; |
| 407 |
| 408 var itemTop = this.drawTop - scrollFrame.top; |
| 409 if (itemTop >= 0 && tiles.length) |
| 410 return tiles[0].datum; |
| 411 |
| 412 var tile; |
| 413 for (var i = 0; i < tiles.length && itemTop < 0; i++) { |
| 414 tile = tiles[i]; |
| 415 var height = viewHeights[tile.viewType]; |
| 416 itemTop += height; |
| 417 } |
| 418 |
| 419 return tile ? tile.datum : null; |
| 420 } |
| 421 |
| 422 Tiler.prototype.viewType = function(datum) { |
| 423 switch (datum.headerOrder) { |
| 424 case 1: return 'stateHeader'; |
| 425 case 2: return 'letterHeader'; |
| 426 default: return 'cityItem'; |
| 427 } |
| 428 } |
| 429 |
| 430 Tiler.prototype.drawTiles = function(scrollFrame, data) { |
| 431 var tiles = this.tiles; |
| 432 var viewHeights = this.viewHeights; |
| 433 |
| 434 var buffer = Math.round(scrollFrame.height / 2); |
| 435 var targetTop = scrollFrame.top - buffer; |
| 436 var targetBottom = scrollFrame.top + scrollFrame.height + buffer; |
| 437 |
| 438 // Collect down to targetTop |
| 439 var first = tiles[0]; |
| 440 while (tiles.length && |
| 441 targetTop > this.drawTop + viewHeights[first.viewType]) { |
| 442 |
| 443 var height = viewHeights[first.viewType]; |
| 444 this.drawTop += height; |
| 445 |
| 446 this.firstItem++; |
| 447 this.checkinTile(tiles.shift()); |
| 448 |
| 449 first = tiles[0]; |
| 450 } |
| 451 |
| 452 // Collect up to targetBottom |
| 453 var last = tiles[tiles.length - 1]; |
| 454 while(tiles.length && |
| 455 targetBottom < this.drawBottom - viewHeights[last.viewType]) { |
| 456 |
| 457 var height = viewHeights[last.viewType]; |
| 458 this.drawBottom -= height; |
| 459 |
| 460 this.checkinTile(tiles.pop()); |
| 461 |
| 462 last = tiles[tiles.length - 1]; |
| 463 } |
| 464 |
| 465 // Layout up to targetTop |
| 466 while (this.firstItem > 0 && |
| 467 targetTop < this.drawTop) { |
| 468 |
| 469 var datum = data[this.firstItem - 1]; |
| 470 var type = this.viewType(datum); |
| 471 var height = viewHeights[type]; |
| 472 |
| 473 this.drawTop -= height; |
| 474 |
| 475 var tile = targetBottom < this.drawTop ? |
| 476 new Tile(datum, null, viewType, -1) : // off-screen |
| 477 this.checkoutTile(type, datum, this.drawTop); |
| 478 |
| 479 this.firstItem--; |
| 480 tiles.unshift(tile); |
| 481 } |
| 482 |
| 483 // Layout down to targetBottom |
| 484 while (this.firstItem + tiles.length < data.length - 1 && |
| 485 targetBottom > this.drawBottom) { |
| 486 |
| 487 var datum = data[this.firstItem + tiles.length]; |
| 488 var type = this.viewType(datum); |
| 489 var height = viewHeights[type]; |
| 490 |
| 491 this.drawBottom += height; |
| 492 |
| 493 var tile = targetTop > this.drawBottom ? |
| 494 new Tile(datum, null, viewType, -1) : // off-screen |
| 495 this.checkoutTile(type, datum, this.drawBottom - height); |
| 496 |
| 497 tiles.push(tile); |
| 498 } |
| 499 |
| 500 // Debug validate: |
| 501 // for (var i = 0; i < tiles.length; i++) { |
| 502 // if (tiles[i].datum !== data[this.firstItem + i]) |
| 503 // throw Error('Invalid') |
| 504 // } |
| 505 } |
| 506 |
| 507 // FIXME: Needs better name. |
| 508 Tiler.prototype.updateFirstItem = function(offset) { |
| 509 var tiles = this.tiles; |
| 510 |
| 511 if (!tiles.length) { |
| 512 this.firstItem = 0; |
| 513 } else { |
| 514 this.firstItem += offset; |
| 515 } |
| 516 } |
| 517 |
| 518 Tiler.prototype.checkinAllTiles = function() { |
| 519 var tiles = this.tiles; |
| 520 while (tiles.length) { |
| 521 this.checkinTile(tiles.pop()); |
| 522 } |
| 523 } |
| 524 |
| 525 Tiler.prototype.reset = function() { |
| 526 this.checkinAllTiles(); |
| 527 this.drawTop = 0; |
| 528 this.drawBottom = 0; |
| 529 } |
| 530 |
| 531 SkyElement({ |
| 532 name: 'city-list', |
| 533 loader: null, |
| 534 scroller: null, |
| 535 tiler: null, |
| 536 date: null, |
| 537 month: null, |
| 538 views: null, |
| 539 |
| 540 attached: function() { |
| 541 this.views = {}; |
| 542 this.loader = new Loader(); |
| 543 this.scroller = new Scroller(); |
| 544 this.tiler = new Tiler( |
| 545 this.shadowRoot.getElementById('contentarea'), this.views, { |
| 546 stateHeader: 27, |
| 547 letterHeader: 18, |
| 548 cityItem: 30 |
| 549 }); |
| 550 |
| 551 this.dataLoaded = this.dataLoaded.bind(this); |
| 552 this.shadowRoot.getElementById('scroller') |
| 553 .addEventListener('scroll', this.handleScroll.bind(this)); |
| 554 |
| 555 var self = this; |
| 556 requestAnimationFrame(function() { |
| 557 self.domReady(); |
| 558 self.loader.maybeLoadMoreData(self.dataLoaded); |
| 559 }); |
| 560 }, |
| 561 |
| 562 domReady: function() { |
| 563 this.scroller.setup(this.shadowRoot.getElementById('scroller'), |
| 564 this.shadowRoot.getElementById('scrollarea'), |
| 565 this.shadowRoot.getElementById('contentarea')); |
| 566 var scrollFrame = this.scroller.getCurrentFrame(); |
| 567 this.tiler.setupViews(scrollFrame); |
| 568 }, |
| 569 |
| 570 updateView: function(data, scrollChanged) { |
| 571 var scrollFrame = this.scroller.getCurrentFrame(); |
| 572 this.tiler.drawTiles(scrollFrame, data); |
| 573 var datum = scrollChanged ? |
| 574 this.tiler.getFirstVisibleDatum(scrollFrame) : null; |
| 575 this.loader.maybeLoadMoreData(this.dataLoaded, datum); |
| 576 }, |
| 577 |
| 578 dataLoaded: function(data, indexOffset) { |
| 579 var scrollFrame = this.scroller.getCurrentFrame(); |
| 580 this.tiler.updateFirstItem(indexOffset); |
| 581 this.updateView(data.items, false); |
| 582 }, |
| 583 |
| 584 handleScroll: function(event) { |
| 585 if (!this.scroller.captureNewFrame(event)) |
| 586 return; |
| 587 |
| 588 this.updateView(this.loader.getItems(), true); |
| 589 } |
| 590 }); |
| 591 |
| 592 })(this); |
| 593 |
| 594 </script> |
| 595 |
OLD | NEW |