| /** |
| * @license |
| * Copyright 2022 Google LLC |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| import {directive, AsyncDirective} from 'lit/async-directive.js'; |
| import {DirectiveParameters, ChildPart} from 'lit/directive.js'; |
| import { |
| insertPart, |
| setChildPartValue, |
| removePart, |
| } from 'lit/directive-helpers.js'; |
| |
| interface RepeatOptions<T> { |
| values: T[]; |
| mapFn?: (val: T, idx: number) => unknown; |
| initialCount: number; |
| targetFrameRate?: number; |
| startAt?: number; |
| endAt?: number; |
| } |
| |
| interface RepeatState<T> { |
| values: T[]; |
| mapFn?: (val: T, idx: number) => unknown; |
| startAt: number; |
| endAt: number; |
| incrementAmount: number; |
| lastRenderedAt: number; |
| targetFrameRate: number; |
| } |
| |
| // This directive supports incrementally rendering a list of elements. |
| // It only responds to updates to values (which forces a complete re-render) and |
| // an update to endAt (which expands the list). |
| // It currently does not support changes to mapFn, initialCount or startAt |
| // unless values are also changed. |
| class IncrementalRepeat<T> extends AsyncDirective { |
| private children: {part: ChildPart; options: RepeatOptions<T>}[] = []; |
| |
| private part!: ChildPart; |
| |
| private state!: RepeatState<T>; |
| |
| // Will render from `options.startAt` to `options.endAt`, up to |
| // `options.initialCount` elements. |
| render(options: RepeatOptions<T>) { |
| const start = options.startAt ?? 0; |
| const offset = start + options.initialCount; |
| const end = |
| options.endAt === undefined ? offset : Math.min(options.endAt, offset); |
| const values = options.values.slice(start, end); |
| if (options.mapFn) { |
| return values.map(options.mapFn); |
| } |
| return values; |
| } |
| |
| override update(part: ChildPart, [options]: DirectiveParameters<this>) { |
| if (options.values !== this.state?.values) { |
| if (this.nextScheduledFrameWork !== undefined) |
| cancelAnimationFrame(this.nextScheduledFrameWork); |
| this.part = part; |
| this.clearParts(); |
| this.state = { |
| values: options.values, |
| mapFn: options.mapFn, |
| startAt: options.initialCount, |
| endAt: options.endAt ?? options.values.length, |
| incrementAmount: options.initialCount, |
| lastRenderedAt: performance.now(), |
| targetFrameRate: options.targetFrameRate ?? 30, |
| }; |
| this.nextScheduledFrameWork = requestAnimationFrame( |
| this.animationFrameHandler |
| ); |
| } else { |
| this.updateParts(); |
| // TODO: Deal with updates to startAt by removing children and then |
| // trimming the child where the new startAt falls into. |
| if ((options.endAt ?? options.values.length) >= this.state.endAt) { |
| this.state.endAt = options.endAt ?? options.values.length; |
| if (this.nextScheduledFrameWork) { |
| cancelAnimationFrame(this.nextScheduledFrameWork); |
| } |
| this.nextScheduledFrameWork = requestAnimationFrame( |
| this.animationFrameHandler |
| ); |
| } |
| } |
| // Render the first initial count. |
| return this.render(options); |
| } |
| |
| private appendPart(options: RepeatOptions<T>) { |
| const part = insertPart(this.part); |
| this.children.push({part, options}); |
| setChildPartValue(part, this.render(options)); |
| } |
| |
| private clearParts() { |
| for (const child of this.children) { |
| removePart(child.part); |
| } |
| this.children = []; |
| } |
| |
| private updateParts() { |
| for (const child of this.children) { |
| setChildPartValue(child.part, this.render(child.options)); |
| } |
| } |
| |
| private nextScheduledFrameWork: number | undefined; |
| |
| private animationFrameHandler = () => { |
| if (this.state.startAt >= this.state.endAt) { |
| this.nextScheduledFrameWork = undefined; |
| return; |
| } |
| const now = performance.now(); |
| const frameRate = 1000 / (now - this.state.lastRenderedAt); |
| if (frameRate < this.state.targetFrameRate) { |
| // https://en.wikipedia.org/wiki/Additive_increase/multiplicative_decrease |
| this.state.incrementAmount = Math.max( |
| 1, |
| Math.round(this.state.incrementAmount / 2) |
| ); |
| } else { |
| this.state.incrementAmount++; |
| } |
| this.state.lastRenderedAt = now; |
| this.appendPart({ |
| mapFn: this.state.mapFn, |
| values: this.state.values, |
| initialCount: this.state.incrementAmount, |
| startAt: this.state.startAt, |
| endAt: this.state.endAt, |
| }); |
| |
| this.state.startAt += this.state.incrementAmount; |
| if (this.state.startAt < this.state.endAt) { |
| this.nextScheduledFrameWork = requestAnimationFrame( |
| this.animationFrameHandler |
| ); |
| } else { |
| this.nextScheduledFrameWork = undefined; |
| } |
| }; |
| } |
| |
| export const incrementalRepeat = directive(IncrementalRepeat); |