File size: 7,769 Bytes
b9499ab
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
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());
  }
}