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()); } }