autodiff-dev / frontends /react /src /Timeline.ts
nhamid289's picture
Deploy autodiff ff35e9c
b9499ab verified
Raw History Blame Contribute Delete
7.77 kB
import { flowDuration } from "./flowTiming";
import type { RunSettings, State, Step } from "./types";
// A moment to read a result once its animation has played
const READING_MS = 300;
// How long Play stays on each step when steps aren't animated
const UNANIMATED_STEP_MS = 800;
// The most a frame can move the playhead, so a stall doesn't skip ahead
const MAX_FRAME_MS = 100;
// A step being animated, from the numbers it starts with
export interface Flow {
// Tells one animation from the next
id: number;
step: Step;
before: State;
// When it starts on the timeline
start: number;
}
export interface TimelineSnapshot {
// The settings of the run on screen
run: RunSettings | null;
// What the graph and table show: during an animation, the step without its results yet
state: State | undefined;
flow: Flow | null;
// The last step completed
iteration: number;
lastIteration: number | null;
atStart: boolean;
playing: boolean;
// Where each step ends, starting with 0; the last is the timeline's length
ticks: number[];
// Where the backward pass starts, if the run has one
backwardAt: number | null;
}
// A run's steps laid end to end, with a playhead moving along them in milliseconds.
// Steps take their animation's length and then a pause. A step's results show once its animation ends.
export class Timeline {
private states: State[] = [];
// ends[k] is when step k finishes, including its pause, and is where stepping stops; ends[0] is 0
private ends: number[] = [0];
private animationMs: number[] = [0];
private time = 0;
// Where to stop when stepping; null to run on to the end
private target: number | null = null;
private frame: number | null = null;
private lastFrameAt: number | null = null;
private playing = false;
private settings: RunSettings | null = null;
private snapshot: TimelineSnapshot = { run: null, state: undefined, flow: null, iteration: 0, lastIteration: null, atStart: true, playing: false, ticks: [0], backwardAt: null };
private snapshotListeners = new Set<() => void>();
private timeListeners = new Set<() => void>();
private animated: boolean;
constructor(animated: boolean) {
this.animated = animated;
}
getSnapshot = (): TimelineSnapshot => this.snapshot;
getTime = (): number => this.time;
subscribe = (listener: () => void): (() => void) => {
this.snapshotListeners.add(listener);
return () => this.snapshotListeners.delete(listener);
};
// Called every frame, so only what animates should use this
subscribeTime = (listener: () => void): (() => void) => {
this.timeListeners.add(listener);
return () => this.timeListeners.delete(listener);
};
// A new run, from its state at every iteration; no states if it couldn't run
load = (run: RunSettings | null, states: State[]): void => {
this.stop();
this.settings = run;
this.states = states;
this.measure();
this.moveTo(0, true);
};
setAnimated = (animated: boolean): void => {
const { iteration } = this.snapshot;
this.animated = animated;
this.target = null;
this.measure();
this.moveTo(this.ends[iteration] ?? 0, true);
};
play = (): void => {
this.target = null;
this.playing = true;
this.run();
this.publish();
};
pause = (): void => {
this.stop();
this.publish();
};
// Play to the end of the step under way, or through the next one
stepForward = (): void => {
// A step already playing finishes straight away
if (this.target !== null) {
this.moveTo(this.target);
}
const { flow, iteration } = this.snapshot;
const next = flow ? flow.id : iteration + 1;
if (next >= this.ends.length) {
return;
}
if (!this.animated) {
this.stop();
this.moveTo(this.ends[next]);
return;
}
// Skip any pause left after the step just finished, as nothing moves during it
if (!flow) {
this.moveTo(this.ends[iteration]);
}
this.target = this.ends[next];
this.playing = false;
this.run();
};
// Back to the start of the step under way, or the one before
stepBack = (): void => {
this.stop();
const before = this.ends.filter((end) => end < this.time).length - 1;
this.moveTo(this.ends[Math.max(before, 0)]);
};
// Move the playhead by hand, stopping anything playing. Without animations, only steps have anything to show.
seek = (time: number): void => {
this.stop();
const clamped = Math.min(Math.max(time, 0), this.ends[this.ends.length - 1]);
this.moveTo(this.animated ? clamped : this.nearestTick(clamped));
};
reset = (): void => {
this.stop();
this.moveTo(0);
};
private nearestTick(time: number): number {
return this.ends.reduce((best, end) => (Math.abs(end - time) < Math.abs(best - time) ? end : best));
}
private stop() {
this.playing = false;
this.target = null;
this.lastFrameAt = null;
if (this.frame !== null) {
cancelAnimationFrame(this.frame);
this.frame = null;
}
}
private run() {
if (this.frame === null) {
this.frame = requestAnimationFrame(this.tick);
}
}
private tick = (now: number) => {
const moved = Math.min(now - (this.lastFrameAt ?? now), MAX_FRAME_MS);
this.lastFrameAt = now;
const stopAt = this.target ?? this.ends[this.ends.length - 1];
const time = Math.min(this.time + moved, stopAt);
this.frame = null;
if (time >= stopAt) {
this.stop();
} else {
this.run();
}
this.moveTo(time);
};
private measure() {
this.animationMs = [0];
this.ends = [0];
for (const { step } of this.states.slice(1)) {
const animation = this.animated ? flowDuration(step!) : 0;
this.animationMs.push(animation);
this.ends.push(this.ends[this.ends.length - 1] + animation + (this.animated ? READING_MS : UNANIMATED_STEP_MS));
}
}
private backwardStart(): number | null {
const first = this.states.findIndex((state) => state.step?.phase === "backward");
return first < 0 ? null : this.ends[first - 1];
}
private moveTo(time: number, force = false) {
this.time = time;
this.timeListeners.forEach((listener) => listener());
if (force) {
this.snapshot = { ...this.snapshot, flow: null, iteration: -1 };
}
this.publish();
}
// Tell listeners only when what's shown has changed, not on every frame
private publish() {
// The step finished most recently, and how far into the next this is
const reached = this.ends.filter((end) => end <= this.time).length - 1;
const into = this.time - this.ends[reached];
const animating = into > 0 && reached + 1 < this.ends.length && into < this.animationMs[reached + 1];
const iteration = into > 0 && !animating && reached + 1 < this.ends.length ? reached + 1 : reached;
const before = this.states[reached];
const playing = this.playing;
const atStart = this.time === 0;
const previous = this.snapshot;
const flowId = animating ? reached + 1 : null;
if (previous.iteration === iteration && (previous.flow?.id ?? null) === flowId && previous.playing === playing && previous.atStart === atStart) {
return;
}
const step = animating ? this.states[reached + 1].step : undefined;
this.snapshot = {
run: this.settings,
state: step ? { ...before, step } : this.states[iteration],
flow: step ? { id: reached + 1, step, before, start: this.ends[reached] } : null,
iteration,
lastIteration: this.states.length > 0 ? this.states.length - 1 : null,
atStart,
playing,
ticks: this.ends,
backwardAt: this.backwardStart(),
};
this.snapshotListeners.forEach((listener) => listener());
}
}