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 { | |
ojan
2014/10/31 19:40:21
Is this needed? We've been talking about removing
rafaelw
2014/11/04 19:01:55
Probably not, since it doesn't do anything.
The d
| |
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 if (loadedPre >= LOAD_BUFFER_PRE && loadedPost >= LOAD_BUFFER_POST) { | |
202 if (window.startLoad) { | |
203 var loadTime = (new Date().getTime()) - window.startLoad); | |
204 console.log('Load: ' + loadTime + 'ms'); | |
205 window.startLoad = undefined; | |
206 } | |
207 return; | |
208 } | |
209 | |
210 this.loadingData = true; | |
211 | |
212 var loadIndex; | |
213 | |
214 if (!this.data) { | |
215 // Initial batch | |
216 loadIndex = 0; | |
217 } else if (loadedPost < LOAD_BUFFER_POST) { | |
218 // Load forward first | |
219 loadIndex = this.data.items.length; | |
220 } else { | |
221 // Then load backward | |
222 loadIndex = -LOAD_LENGTH; | |
223 } | |
224 | |
225 var self = this; | |
226 var externalIndex = this.externalIndex(loadIndex); | |
227 | |
228 try { | |
229 CityDataService.service.then(function(cityService) { | |
230 return cityService.get(externalIndex, LOAD_LENGTH) | |
231 .then(function(cities) { | |
232 var indexOffset = 0; | |
233 var newData = new CitySequence(cities); | |
234 if (!self.data) { | |
235 self.data = newData; | |
236 } else if (loadIndex > 0) { | |
237 self.data.append(newData); | |
238 } else { | |
239 self.zeroIndex += LOAD_LENGTH; | |
240 indexOffset = LOAD_LENGTH; | |
241 newData.append(self.data); | |
242 self.data = newData; | |
243 } | |
244 | |
245 self.loadingData = false; | |
246 dataloadedCallback(self.data, indexOffset); | |
247 }); | |
248 }).catch(function(ex) { | |
249 console.log(ex.stack); | |
ojan
2014/10/31 19:40:21
Did we fork before promises correctly logged uncau
rafaelw
2014/11/04 19:01:55
The do not (log uncaught exceptions) to the consol
| |
250 }); | |
251 } catch (ex) { | |
252 console.log(ex.stack); | |
253 } | |
254 } | |
255 | |
256 function Scroller() { | |
257 this.contentarea = null; | |
258 this.scroller = null; | |
259 this.contentTop = 0; // #contentarea's current top | |
260 this.scrollTop = 0; // #scrollarea's current top | |
261 this.scrollHeight = -1; // height of #scroller (the viewport) | |
262 this.lastScrollTop = 0; // last known scrollTop to compute deltas | |
263 } | |
264 | |
265 Scroller.prototype.setup = function(scroller, scrollarea, contentarea) { | |
266 this.contentarea = contentarea; | |
267 this.scroller = scroller; | |
268 | |
269 this.scrollHeight = scroller.offsetHeight; | |
270 scrollarea.style.height = (this.scrollHeight) * 4 + 'px'; | |
271 | |
272 this.reset(); | |
273 } | |
274 | |
275 Scroller.prototype.captureNewFrame = function(event) { | |
276 var scrollTop = event.target.scrollTop; | |
277 | |
278 // Protect from re-entry. | |
279 if (this.lastScrollTop == scrollTop) | |
280 return false; | |
281 | |
282 var scrollDown = scrollTop > this.lastScrollTop; | |
283 if (scrollDown) { | |
284 while (scrollTop > this.scrollHeight * 1.5) { | |
285 scrollTop -= this.scrollHeight; | |
286 this.contentTop -= this.scrollHeight; | |
287 } | |
288 } else { | |
289 while(scrollTop < this.scrollHeight * 1.5) { | |
290 scrollTop += this.scrollHeight; | |
291 this.contentTop += this.scrollHeight; | |
292 } | |
293 } | |
294 | |
295 this.lastScrollTop = scrollTop; | |
296 event.target.scrollTop = scrollTop; | |
297 this.contentarea.style.top = this.contentTop + 'px'; | |
298 | |
299 this.scrollTop = scrollTop - this.contentTop; | |
300 | |
301 return true; | |
302 } | |
303 | |
304 Scroller.prototype.reset = function() { | |
305 if (!this.contentarea) | |
306 return; | |
307 | |
308 this.scroller.scrollTop = this.scrollHeight; | |
309 this.lastScrollTop = this.scrollHeight; | |
310 | |
311 this.contentarea.style.top = this.scrollHeight + 'px'; | |
312 this.contentTop = this.scrollHeight; | |
313 this.scrollTop = 0; | |
314 } | |
315 | |
316 // Current position and height of the scroller, that could | |
317 // be used (by Tiler, for example) to reason about where to | |
318 // place visible things. | |
319 Scroller.prototype.getCurrentFrame = function() { | |
320 return { top: this.scrollTop, height: this.scrollHeight }; | |
321 } | |
322 | |
323 Scroller.prototype.hasFrame = function() { | |
324 return this.scrollHeight != -1; | |
325 } | |
326 | |
327 function Tile(datum, element, viewType, index) { | |
328 this.datum = datum; | |
329 this.element = element; | |
330 this.viewType = viewType; | |
331 this.index = index; | |
332 } | |
333 | |
334 function Tiler(contentArea, views, viewHeights) { | |
335 this.contentArea = contentArea; | |
336 this.drawTop = 0; | |
337 this.drawBottom = 0; | |
338 this.firstItem = -1; | |
339 this.tiles = []; | |
340 this.viewHeights = viewHeights; | |
341 this.views = views; | |
342 } | |
343 | |
344 Tiler.prototype.setupViews = function(scrollFrame) { | |
345 for (var type in this.viewHeights) { | |
346 this.initializeViewType(scrollFrame, type, this.viewHeights[type]); | |
347 } | |
348 } | |
349 | |
350 Tiler.prototype.initializeViewType = function(scrollFrame, viewType, | |
351 height) { | |
352 var count = Math.ceil(scrollFrame.height / height) * 2; | |
353 var viewCache = this.views[viewType] = { | |
354 indices: [], | |
355 elements: [] | |
356 }; | |
357 | |
358 var protoElement; | |
359 switch (viewType) { | |
360 case 'stateHeader': | |
361 protoElement = document.createElement('state-header'); | |
362 break; | |
363 case 'letterHeader': | |
364 protoElement = document.createElement('letter-header'); | |
365 break; | |
366 case 'cityItem': | |
367 protoElement = document.createElement('city-item'); | |
368 break; | |
369 default: | |
370 console.warn('Unknown viewType: ' + viewType); | |
371 } | |
372 protoElement.style.display = 'none'; | |
373 protoElement.style.height = height; | |
374 protoElement.classList.add('position'); | |
375 | |
376 for (var i = 0; i < count; i++) { | |
377 var clone = protoElement.cloneNode(false); | |
378 this.contentArea.appendChild(clone); | |
379 viewCache.elements.push(clone); | |
380 viewCache.indices.push(i); | |
381 } | |
382 } | |
383 | |
384 Tiler.prototype.checkoutTile = function(viewType, datum, top) { | |
385 var viewCache = this.views[viewType]; | |
386 var index = viewCache.indices.pop(); | |
387 var element = viewCache.elements[index]; | |
388 element.datum = datum; | |
389 element.style.display = ''; | |
390 element.style.top = top + 'px'; | |
391 | |
392 return new Tile(datum, element, viewType, index); | |
393 } | |
394 | |
395 Tiler.prototype.checkinTile = function(tile) { | |
396 if (!tile.element) | |
397 return; | |
398 | |
399 tile.element.style.display = 'none'; | |
400 this.views[tile.viewType].indices.push(tile.index); | |
401 } | |
402 | |
403 Tiler.prototype.getFirstVisibleDatum = function(scrollFrame) { | |
404 var tiles = this.tiles; | |
405 var viewHeights = this.viewHeights; | |
406 | |
407 var itemTop = this.drawTop - scrollFrame.top; | |
408 if (itemTop >= 0 && tiles.length) | |
409 return tiles[0].datum; | |
410 | |
411 var tile; | |
412 for (var i = 0; i < tiles.length && itemTop < 0; i++) { | |
413 tile = tiles[i]; | |
414 var height = viewHeights[tile.viewType]; | |
415 itemTop += height; | |
416 } | |
417 | |
418 return tile ? tile.datum : null; | |
419 } | |
420 | |
421 Tiler.prototype.viewType = function(datum) { | |
422 switch (datum.headerOrder) { | |
423 case 1: return 'stateHeader'; | |
424 case 2: return 'letterHeader'; | |
425 default: return 'cityItem'; | |
426 } | |
427 } | |
428 | |
429 Tiler.prototype.drawTiles = function(scrollFrame, data) { | |
430 var tiles = this.tiles; | |
431 var viewHeights = this.viewHeights; | |
432 | |
433 var buffer = Math.round(scrollFrame.height / 2); | |
434 var targetTop = scrollFrame.top - buffer; | |
435 var targetBottom = scrollFrame.top + scrollFrame.height + buffer; | |
436 | |
437 // Collect down to targetTop | |
438 var first = tiles[0]; | |
439 while (tiles.length && | |
440 targetTop > this.drawTop + viewHeights[first.viewType]) { | |
441 | |
442 var height = viewHeights[first.viewType]; | |
443 this.drawTop += height; | |
444 | |
445 this.firstItem++; | |
446 this.checkinTile(tiles.shift()); | |
447 | |
448 first = tiles[0]; | |
449 } | |
450 | |
451 // Collect up to targetBottom | |
452 var last = tiles[tiles.length - 1]; | |
453 while(tiles.length && | |
454 targetBottom < this.drawBottom - viewHeights[last.viewType]) { | |
455 | |
456 var height = viewHeights[last.viewType]; | |
457 this.drawBottom -= height; | |
458 | |
459 this.checkinTile(tiles.pop()); | |
460 | |
461 last = tiles[tiles.length - 1]; | |
462 } | |
463 | |
464 // Layout up to targetTop | |
465 while (this.firstItem > 0 && | |
466 targetTop < this.drawTop) { | |
467 | |
468 var datum = data[this.firstItem - 1]; | |
469 var type = this.viewType(datum); | |
470 var height = viewHeights[type]; | |
471 | |
472 this.drawTop -= height; | |
473 | |
474 var tile = targetBottom < this.drawTop ? | |
475 new Tile(datum, null, viewType, -1) : // off-screen | |
476 this.checkoutTile(type, datum, this.drawTop); | |
477 | |
478 this.firstItem--; | |
479 tiles.unshift(tile); | |
480 } | |
481 | |
482 // Layout down to targetBottom | |
483 while (this.firstItem + tiles.length < data.length - 1 && | |
484 targetBottom > this.drawBottom) { | |
485 | |
486 var datum = data[this.firstItem + tiles.length]; | |
487 var type = this.viewType(datum); | |
488 var height = viewHeights[type]; | |
489 | |
490 this.drawBottom += height; | |
491 | |
492 var tile = targetTop > this.drawBottom ? | |
493 new Tile(datum, null, viewType, -1) : // off-screen | |
494 this.checkoutTile(type, datum, this.drawBottom - height); | |
495 | |
496 tiles.push(tile); | |
497 } | |
498 | |
499 // Debug validate: | |
500 // for (var i = 0; i < tiles.length; i++) { | |
501 // if (tiles[i].datum !== data[this.firstItem + i]) | |
502 // throw Error('Invalid') | |
503 // } | |
504 } | |
505 | |
506 // FIXME: Needs better name. | |
507 Tiler.prototype.updateFirstItem = function(offset) { | |
508 var tiles = this.tiles; | |
509 | |
510 if (!tiles.length) { | |
511 this.firstItem = 0; | |
512 } else { | |
513 this.firstItem += offset; | |
514 } | |
515 } | |
516 | |
517 Tiler.prototype.checkinAllTiles = function() { | |
518 var tiles = this.tiles; | |
519 while (tiles.length) { | |
520 this.checkinTile(tiles.pop()); | |
521 } | |
522 } | |
523 | |
524 Tiler.prototype.reset = function() { | |
525 this.checkinAllTiles(); | |
526 this.drawTop = 0; | |
527 this.drawBottom = 0; | |
528 } | |
529 | |
530 SkyElement({ | |
531 name: 'city-list', | |
532 loader: null, | |
533 scroller: null, | |
534 tiler: null, | |
535 date: null, | |
536 month: null, | |
537 views: null, | |
538 | |
539 attached: function() { | |
540 this.views = {}; | |
541 this.loader = new Loader(); | |
542 this.scroller = new Scroller(); | |
543 this.tiler = new Tiler( | |
544 this.shadowRoot.getElementById('contentarea'), this.views, { | |
545 stateHeader: 27, | |
546 letterHeader: 18, | |
547 cityItem: 30 | |
548 }); | |
549 | |
550 this.dataLoaded = this.dataLoaded.bind(this); | |
551 this.shadowRoot.getElementById('scroller') | |
552 .addEventListener('scroll', this.handleScroll.bind(this)); | |
553 | |
554 var self = this; | |
555 requestAnimationFrame(function() { | |
556 self.domReady(); | |
557 self.loader.maybeLoadMoreData(self.dataLoaded); | |
558 }); | |
559 }, | |
560 | |
561 domReady: function() { | |
562 this.scroller.setup(this.shadowRoot.getElementById('scroller'), | |
563 this.shadowRoot.getElementById('scrollarea'), | |
564 this.shadowRoot.getElementById('contentarea')); | |
565 var scrollFrame = this.scroller.getCurrentFrame(); | |
566 this.tiler.setupViews(scrollFrame); | |
567 }, | |
568 | |
569 updateView: function(data, scrollChanged) { | |
570 var scrollFrame = this.scroller.getCurrentFrame(); | |
571 this.tiler.drawTiles(scrollFrame, data); | |
572 var datum = scrollChanged ? | |
573 this.tiler.getFirstVisibleDatum(scrollFrame) : null; | |
574 this.loader.maybeLoadMoreData(this.dataLoaded, datum); | |
575 }, | |
576 | |
577 dataLoaded: function(data, indexOffset) { | |
578 var scrollFrame = this.scroller.getCurrentFrame(); | |
579 this.tiler.updateFirstItem(indexOffset); | |
580 this.updateView(data.items, false); | |
581 }, | |
582 | |
583 handleScroll: function(event) { | |
584 if (!this.scroller.captureNewFrame(event)) | |
585 return; | |
586 | |
587 this.updateView(this.loader.getItems(), true); | |
588 } | |
589 }); | |
590 | |
591 })(this); | |
592 | |
593 </script> | |
594 | |
OLD | NEW |