blob: bcfb6827e2fdc4ffbd2d491a31baa0629ccfef2e [file] [log] [blame]
/**
* @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);