@grundyjs/algiviz 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,37 @@
1
+ import { createSceneRenderer } from "../scene/index.js";
2
+ /** Adapts array identities to the same public scene contract used by custom objects. */
3
+ export function arrayScene(state) {
4
+ return Object.freeze({
5
+ objects: Object.freeze([...positions(state)].map(([id, data]) => Object.freeze({ id, type: "bar", data: Object.freeze(data) })))
6
+ });
7
+ }
1
8
  const palettes = {
2
- dark: { background: "#101827", text: "#f1f5f9", bar: "#94a3b8", sorted: "#34d399", active: "#fbbf24", held: "#c4b5fd" },
3
- light: { background: "#f8fafc", text: "#0f172a", bar: "#475569", sorted: "#047857", active: "#b45309", held: "#7c3aed" }
9
+ dark: {
10
+ background: "#101827",
11
+ text: "#f1f5f9",
12
+ bar: "#94a3b8",
13
+ sorted: "#34d399",
14
+ active: "#fbbf24",
15
+ held: "#c4b5fd"
16
+ },
17
+ light: {
18
+ background: "#f8fafc",
19
+ text: "#0f172a",
20
+ bar: "#475569",
21
+ sorted: "#047857",
22
+ active: "#b45309",
23
+ held: "#7c3aed"
24
+ }
4
25
  };
26
+ export { createSortRenderer as createArrayRenderer };
5
27
  function positions(state) {
6
28
  const result = new Map();
7
- state.slots.forEach((item, index) => { if (item)
8
- result.set(item.id, { item, index, held: false }); });
29
+ state.slots.forEach((item, index) => {
30
+ if (item)
31
+ result.set(item.id, { item, index, held: false });
32
+ });
9
33
  if (state.held)
10
- result.set(state.held.id, { item: state.held,
11
- index: state.slots.indexOf(null), held: true });
34
+ result.set(state.held.id, { item: state.held, index: state.slots.indexOf(null), held: true });
12
35
  return result;
13
36
  }
14
37
  /** Stateless full-frame renderer. Bitmap dimensions determine layout and video size. */
@@ -19,7 +42,6 @@ export function createSortRenderer(options) {
19
42
  const { width, height } = ctx.canvas;
20
43
  if (width <= 0 || height <= 0)
21
44
  return;
22
- const before = positions(frame.previous);
23
45
  const after = positions(frame.current);
24
46
  const items = [...after.values()];
25
47
  const count = frame.current.slots.length;
@@ -46,23 +68,42 @@ export function createSortRenderer(options) {
46
68
  ctx.fillText(`AlgiViz · ${event.type} · ${frame.stepIndex}`, margin, height * 0.07);
47
69
  if (!count)
48
70
  ctx.fillText("Empty array", margin, baseline);
49
- for (const [id, next] of after) {
50
- const old = before.get(id) ?? next;
51
- const x = margin + ((old.index + (next.index - old.index) * p) + 0.5) * cell;
52
- const size = Math.max(4, Math.abs(next.item.value) / maxMagnitude * available);
53
- const active = event.type === "compare" ? id === event.leftId || id === event.rightId :
54
- "itemId" in event && event.itemId === id;
55
- ctx.fillStyle = next.held ? colors.held : active ? colors.active :
56
- next.index < frame.current.sortedPrefixLength ? colors.sorted : colors.bar;
57
- ctx.fillRect(x - barWidth / 2, baseline - size, barWidth, size);
58
- if (cell < 24)
59
- continue;
60
- ctx.fillStyle = colors.text;
61
- ctx.font = `${fontSize}px sans-serif`;
62
- ctx.textAlign = "center";
63
- ctx.fillText(String(next.item.value), x, baseline - size - fontSize, cell * 0.95);
64
- ctx.fillText(String(next.index), margin + (next.index + 0.5) * cell, baseline + height * 0.06);
65
- }
71
+ const sceneRenderer = createSceneRenderer({
72
+ bar(ctx, transition) {
73
+ const object = transition.current ?? transition.previous;
74
+ const id = object.id;
75
+ const next = object.data;
76
+ const old = transition.previous?.data ?? next;
77
+ const x = margin + (old.index + (next.index - old.index) * p + 0.5) * cell;
78
+ const size = Math.max(4, (Math.abs(next.item.value) / maxMagnitude) * available);
79
+ const active = event.type === "highlight"
80
+ ? event.itemIds.includes(id)
81
+ : event.type === "compare" || event.type === "swap"
82
+ ? id === event.leftId || id === event.rightId
83
+ : "itemId" in event && event.itemId === id;
84
+ ctx.fillStyle = next.held
85
+ ? colors.held
86
+ : active
87
+ ? colors.active
88
+ : next.index < frame.current.sortedPrefixLength ||
89
+ next.index >= count - (frame.current.sortedSuffixLength ?? 0)
90
+ ? colors.sorted
91
+ : colors.bar;
92
+ ctx.fillRect(x - barWidth / 2, baseline - size, barWidth, size);
93
+ if (cell < 24)
94
+ return;
95
+ ctx.fillStyle = colors.text;
96
+ ctx.font = `${fontSize}px sans-serif`;
97
+ ctx.textAlign = "center";
98
+ ctx.fillText(String(next.item.value), x, baseline - size - fontSize, cell * 0.95);
99
+ ctx.fillText(String(next.index), margin + (next.index + 0.5) * cell, baseline + height * 0.06);
100
+ }
101
+ });
102
+ sceneRenderer.render(ctx, {
103
+ ...frame,
104
+ previous: arrayScene(frame.previous),
105
+ current: arrayScene(frame.current)
106
+ });
66
107
  ctx.textAlign = "left";
67
108
  ctx.font = `${Math.max(11, Math.min(18, width * 0.03))}px sans-serif`;
68
109
  ctx.fillStyle = colors.text;
@@ -0,0 +1,2 @@
1
+ export declare const insertion: import("../array/trace.js").ArrayAlgorithm;
2
+ export declare const bubble: import("../array/trace.js").ArrayAlgorithm;
@@ -0,0 +1,36 @@
1
+ import { defineArrayAlgorithm } from "../array/trace.js";
2
+ // Legacy core exports only. New consumers provide algorithms like examples/algorithms.mjs.
3
+ export const insertion = defineArrayAlgorithm(function* (array) {
4
+ yield array.start({ sortedPrefixLength: Math.min(1, array.length) });
5
+ for (let i = 1; i < array.length; i++) {
6
+ const key = array.at(i);
7
+ yield array.select(i, { sortedPrefixLength: i });
8
+ let hole = i;
9
+ while (hole > 0) {
10
+ yield array.compare(hole - 1, 'held');
11
+ if (array.at(hole - 1).value <= key.value)
12
+ break;
13
+ yield array.shift(hole - 1, hole, { sortedPrefixLength: hole - 1 });
14
+ hole--;
15
+ }
16
+ yield array.insert(hole, { sortedPrefixLength: i + 1 });
17
+ }
18
+ yield array.done({ sortedPrefixLength: array.length });
19
+ });
20
+ export const bubble = defineArrayAlgorithm(function* (array) {
21
+ yield array.start({ sortedSuffixLength: array.length < 2 ? array.length : 0 });
22
+ for (let end = array.length - 1; end > 0; end--) {
23
+ let swapped = false;
24
+ for (let left = 0; left < end; left++) {
25
+ yield array.compare(left, left + 1);
26
+ if (array.at(left).value <= array.at(left + 1).value)
27
+ continue;
28
+ yield array.swap(left, left + 1);
29
+ swapped = true;
30
+ }
31
+ yield array.pass(end, { sortedSuffixLength: swapped ? array.length - end : array.length });
32
+ if (!swapped)
33
+ break;
34
+ }
35
+ yield array.done({ sortedSuffixLength: array.length });
36
+ });
@@ -0,0 +1,4 @@
1
+ /** @deprecated Define your algorithm with @grundyjs/algiviz/array. */
2
+ export declare const bubbleSortSteps: (values: readonly number[]) => readonly import("./types.js").SortStep[];
3
+ /** @deprecated Define your algorithm with @grundyjs/algiviz/array. */
4
+ export declare const iterateBubbleSortSteps: (values: readonly number[]) => Generator<import("./types.js").SortStep, void, unknown>;
@@ -0,0 +1,5 @@
1
+ import { bubble } from "../compat/algorithms.js";
2
+ /** @deprecated Define your algorithm with @grundyjs/algiviz/array. */
3
+ export const bubbleSortSteps = bubble.steps;
4
+ /** @deprecated Define your algorithm with @grundyjs/algiviz/array. */
5
+ export const iterateBubbleSortSteps = bubble.iterate;
@@ -1,4 +1,5 @@
1
1
  export { insertionSortSteps, iterateInsertionSortSteps } from "./insertion-sort.js";
2
+ export { bubbleSortSteps, iterateBubbleSortSteps } from "./bubble-sort.js";
2
3
  export type { Item, SortEvent, SortSnapshot, SortStep } from "./types.js";
3
4
  export { createSortTimeline } from "./timeline.js";
4
5
  export type { SortFrame, SortTimeline } from "./timeline.js";
@@ -1,3 +1,4 @@
1
1
  export { insertionSortSteps, iterateInsertionSortSteps } from "./insertion-sort.js";
2
+ export { bubbleSortSteps, iterateBubbleSortSteps } from "./bubble-sort.js";
2
3
  export { createSortTimeline } from "./timeline.js";
3
4
  export { createSortPlayer } from "./player.js";
@@ -1,8 +1,4 @@
1
- import type { SortStep } from "./types.js";
2
- /** Creates a stable ascending insertion-sort trace without modifying the input. */
3
- export declare function insertionSortSteps(values: readonly number[]): readonly SortStep[];
4
- /**
5
- * Yields immutable snapshots without retaining history.
6
- * Input is copied and validated on the first next(), not when creating the iterator.
7
- */
8
- export declare function iterateInsertionSortSteps(values: readonly number[]): Generator<SortStep, void, unknown>;
1
+ /** @deprecated Define your algorithm with @grundyjs/algiviz/array. */
2
+ export declare const insertionSortSteps: (values: readonly number[]) => readonly import("./types.js").SortStep[];
3
+ /** @deprecated Define your algorithm with @grundyjs/algiviz/array. */
4
+ export declare const iterateInsertionSortSteps: (values: readonly number[]) => Generator<import("./types.js").SortStep, void, unknown>;
@@ -1,65 +1,5 @@
1
- /** Creates a stable ascending insertion-sort trace without modifying the input. */
2
- export function insertionSortSteps(values) {
3
- return Object.freeze([...iterateInsertionSortSteps(values)]);
4
- }
5
- /**
6
- * Yields immutable snapshots without retaining history.
7
- * Input is copied and validated on the first next(), not when creating the iterator.
8
- */
9
- export function* iterateInsertionSortSteps(values) {
10
- // Array.from also exposes sparse holes as undefined for validation.
11
- const input = Array.from(values);
12
- for (let i = 0; i < input.length; i++) {
13
- if (typeof input[i] !== "number" || !Number.isFinite(input[i])) {
14
- throw new TypeError(`Expected a finite number at index ${i}`);
15
- }
16
- }
17
- const slots = input.map((value, index) => Object.freeze({ id: `item-${index}`, value }));
18
- let index = 0;
19
- let held = null;
20
- let sortedPrefixLength = Math.min(1, slots.length);
21
- let comparisons = 0;
22
- let writes = 0;
23
- const emit = (event) => {
24
- return Object.freeze({
25
- index: index++,
26
- event: Object.freeze(event),
27
- state: Object.freeze({
28
- slots: Object.freeze(slots.slice()),
29
- held,
30
- sortedPrefixLength,
31
- comparisons,
32
- writes
33
- })
34
- });
35
- };
36
- yield emit({ type: "start" });
37
- for (let i = 1; i < slots.length; i++) {
38
- const key = slots[i];
39
- held = key;
40
- slots[i] = null;
41
- sortedPrefixLength = i;
42
- yield emit({ type: "select", itemId: key.id, from: i });
43
- let hole = i;
44
- while (hole > 0) {
45
- const left = slots[hole - 1];
46
- comparisons++;
47
- yield emit({ type: "compare", leftId: left.id, rightId: key.id });
48
- if (left.value <= key.value)
49
- break;
50
- slots[hole] = left;
51
- slots[hole - 1] = null;
52
- writes++;
53
- sortedPrefixLength = hole - 1;
54
- yield emit({ type: "shift", itemId: left.id, from: hole - 1, to: hole });
55
- hole--;
56
- }
57
- slots[hole] = key;
58
- held = null;
59
- writes++;
60
- sortedPrefixLength = i + 1;
61
- yield emit({ type: "insert", itemId: key.id, to: hole });
62
- }
63
- sortedPrefixLength = slots.length;
64
- yield emit({ type: "done" });
65
- }
1
+ import { insertion } from "../compat/algorithms.js";
2
+ /** @deprecated Define your algorithm with @grundyjs/algiviz/array. */
3
+ export const insertionSortSteps = insertion.steps;
4
+ /** @deprecated Define your algorithm with @grundyjs/algiviz/array. */
5
+ export const iterateInsertionSortSteps = insertion.iterate;
@@ -1,15 +1,10 @@
1
- import type { SortStep } from "./types.js";
1
+ import { type Player } from "../playback/index.js";
2
+ import type { SortStep, SortSnapshot, SortEvent } from "./types.js";
2
3
  import type { SortFrame } from "./timeline.js";
3
- export interface SortPlayer {
4
- readonly frame: SortFrame;
5
- readonly finished: boolean;
6
- /** Advance by playback time; pass zero to redraw without consuming steps. */
7
- advance(deltaMs: number): SortFrame;
8
- /** Close the source early. The last frame remains readable. */
9
- dispose(): void;
10
- }
11
- /** Forward-only playback. The source must end with a `done` event. */
4
+ export type SortPlayer = Player<SortSnapshot, SortEvent>;
5
+ /** Compatibility adapter for array steps ending in done. */
12
6
  export declare function createSortPlayer(steps: Iterable<SortStep>, options: {
13
7
  stepDurationMs: number;
14
8
  finalHoldMs: number;
9
+ render?: (frame: SortFrame) => void;
15
10
  }): SortPlayer;
@@ -1,88 +1,5 @@
1
- /** Forward-only playback. The source must end with a `done` event. */
1
+ import { createPlayer } from "../playback/index.js";
2
+ /** Compatibility adapter for array steps ending in done. */
2
3
  export function createSortPlayer(steps, options) {
3
- const { stepDurationMs, finalHoldMs } = options;
4
- if (!Number.isFinite(stepDurationMs) || stepDurationMs <= 0 ||
5
- !Number.isFinite(finalHoldMs) || finalHoldMs < 0) {
6
- throw new RangeError("Expected positive step duration and non-negative final hold");
7
- }
8
- const iterator = steps[Symbol.iterator]();
9
- let closed = false;
10
- const close = () => {
11
- if (!closed) {
12
- closed = true;
13
- iterator.return?.();
14
- }
15
- };
16
- let current;
17
- try {
18
- const first = iterator.next();
19
- if (first.done)
20
- throw new RangeError("Player requires at least one step");
21
- current = first.value;
22
- }
23
- catch (error) {
24
- close();
25
- throw error;
26
- }
27
- let previous = current;
28
- let elapsed = stepDurationMs;
29
- let hold = 0;
30
- let disposed = false;
31
- let finished = false;
32
- const complete = () => {
33
- if (current.event.type === "done" && elapsed === stepDurationMs) {
34
- previous = current;
35
- close();
36
- finished = hold >= finalHoldMs;
37
- }
38
- };
39
- complete();
40
- const frame = () => Object.freeze({
41
- previous: previous.state, current: current.state,
42
- progress: elapsed / stepDurationMs,
43
- stepIndex: current.index, event: current.event
44
- });
45
- return Object.freeze({
46
- get frame() { return frame(); },
47
- get finished() { return finished; },
48
- advance(deltaMs) {
49
- if (disposed)
50
- throw new Error("Player has been disposed");
51
- if (!Number.isFinite(deltaMs) || deltaMs < 0) {
52
- throw new RangeError("Expected finite non-negative playback time");
53
- }
54
- try {
55
- let remaining = deltaMs;
56
- while (remaining > 0 && !finished) {
57
- if (elapsed === stepDurationMs) {
58
- if (current.event.type === "done") {
59
- hold += Math.min(remaining, finalHoldMs - hold);
60
- complete();
61
- break;
62
- }
63
- const next = iterator.next();
64
- if (next.done)
65
- throw new RangeError("Step source ended without a done event");
66
- previous = current;
67
- current = next.value;
68
- elapsed = 0;
69
- }
70
- const amount = Math.min(remaining, stepDurationMs - elapsed);
71
- elapsed += amount;
72
- remaining -= amount;
73
- complete();
74
- }
75
- return frame();
76
- }
77
- catch (error) {
78
- disposed = true;
79
- close();
80
- throw error;
81
- }
82
- },
83
- dispose() {
84
- disposed = true;
85
- close();
86
- }
87
- });
4
+ return createPlayer(steps, { ...options, isTerminal: step => step.event.type === "done" });
88
5
  }
@@ -1,16 +1,7 @@
1
+ import { type Frame, type Timeline } from "../playback/index.js";
1
2
  import type { SortEvent, SortSnapshot, SortStep } from "./types.js";
2
- export interface SortFrame {
3
- readonly previous: SortSnapshot;
4
- readonly current: SortSnapshot;
5
- readonly progress: number;
6
- readonly stepIndex: number;
7
- readonly event: SortEvent;
8
- }
9
- export interface SortTimeline {
10
- readonly durationMs: number;
11
- sample(timeMs: number): SortFrame;
12
- }
13
- /** Step i is complete at i * stepDurationMs; the final state is held afterwards. */
3
+ export type SortFrame = Frame<SortSnapshot, SortEvent>;
4
+ export type SortTimeline = Timeline<SortSnapshot, SortEvent>;
14
5
  export declare function createSortTimeline(steps: readonly SortStep[], options: {
15
6
  stepDurationMs: number;
16
7
  finalHoldMs: number;
@@ -1,30 +1,4 @@
1
- /** Step i is complete at i * stepDurationMs; the final state is held afterwards. */
1
+ import { createTimeline } from "../playback/index.js";
2
2
  export function createSortTimeline(steps, options) {
3
- if (!steps.length)
4
- throw new RangeError("Timeline requires at least one step");
5
- const { stepDurationMs, finalHoldMs } = options;
6
- if (!Number.isFinite(stepDurationMs) || stepDurationMs <= 0 ||
7
- !Number.isFinite(finalHoldMs) || finalHoldMs < 0) {
8
- throw new RangeError("Expected positive step duration and non-negative final hold");
9
- }
10
- const history = steps.slice();
11
- const end = (history.length - 1) * stepDurationMs;
12
- const durationMs = end + finalHoldMs;
13
- if (!Number.isFinite(durationMs))
14
- throw new RangeError("Timeline duration is too large");
15
- return Object.freeze({
16
- durationMs,
17
- sample(timeMs) {
18
- if (!Number.isFinite(timeMs))
19
- throw new TypeError("Time must be finite");
20
- const time = Math.max(0, Math.min(timeMs, durationMs));
21
- const index = Math.min(history.length - 1, Math.ceil(time / stepDurationMs));
22
- const step = history[index];
23
- const previous = time >= end || index === 0 ? step : history[index - 1];
24
- const progress = previous === step ? 1 :
25
- Math.min(1, (time - (index - 1) * stepDurationMs) / stepDurationMs);
26
- return Object.freeze({ previous: previous.state, current: step.state,
27
- progress, stepIndex: index, event: step.event });
28
- }
29
- });
3
+ return createTimeline(steps, options);
30
4
  }
@@ -1,41 +1 @@
1
- export type Item = Readonly<{
2
- id: string;
3
- value: number;
4
- }>;
5
- export type SortEvent = Readonly<{
6
- type: "start";
7
- } | {
8
- type: "select";
9
- itemId: string;
10
- from: number;
11
- } | {
12
- type: "compare";
13
- leftId: string;
14
- rightId: string;
15
- } | {
16
- type: "shift";
17
- itemId: string;
18
- from: number;
19
- to: number;
20
- } | {
21
- type: "insert";
22
- itemId: string;
23
- to: number;
24
- } | {
25
- type: "done";
26
- }>;
27
- export interface SortSnapshot {
28
- readonly slots: readonly (Item | null)[];
29
- readonly held: Item | null;
30
- /** Sorted leading occupied slots; during insertion it ends at the hole. */
31
- readonly sortedPrefixLength: number;
32
- readonly comparisons: number;
33
- /** Writes into array slots; selecting a key does not count as a write. */
34
- readonly writes: number;
35
- }
36
- export interface SortStep {
37
- readonly index: number;
38
- readonly event: SortEvent;
39
- /** Complete immutable state immediately after the event. */
40
- readonly state: SortSnapshot;
41
- }
1
+ export type { Item, SortEvent, SortSnapshot, SortStep } from "../array/types.js";
@@ -0,0 +1,4 @@
1
+ export { createPlayer } from "./player.js";
2
+ export type { Player } from "./player.js";
3
+ export { createTimeline } from "./timeline.js";
4
+ export type { Step, Frame, Timeline } from "./types.js";
@@ -0,0 +1,2 @@
1
+ export { createPlayer } from "./player.js";
2
+ export { createTimeline } from "./timeline.js";
@@ -0,0 +1,18 @@
1
+ import type { Step } from "./types.js";
2
+ import type { Frame } from "./types.js";
3
+ export interface Player<S, E> {
4
+ readonly frame: Frame<S, E>;
5
+ readonly finished: boolean;
6
+ /** Advance by playback time; pass zero to redraw without consuming steps. */
7
+ advance(deltaMs: number): Frame<S, E>;
8
+ /** Close the source early. The last frame remains readable. */
9
+ dispose(): void;
10
+ }
11
+ /** Forward-only playback. The source must reach a step accepted by isTerminal. */
12
+ export declare function createPlayer<S, E>(steps: Iterable<Step<S, E>>, options: {
13
+ stepDurationMs: number;
14
+ finalHoldMs: number;
15
+ isTerminal: (step: Step<S, E>) => boolean;
16
+ /** Called initially and on each advance, including advance(0). */
17
+ render?: (frame: Frame<S, E>) => void;
18
+ }): Player<S, E>;
@@ -0,0 +1,97 @@
1
+ /** Forward-only playback. The source must reach a step accepted by isTerminal. */
2
+ export function createPlayer(steps, options) {
3
+ const { stepDurationMs, finalHoldMs } = options;
4
+ if (!Number.isFinite(stepDurationMs) || stepDurationMs <= 0 ||
5
+ !Number.isFinite(finalHoldMs) || finalHoldMs < 0) {
6
+ throw new RangeError("Expected positive step duration and non-negative final hold");
7
+ }
8
+ const iterator = steps[Symbol.iterator]();
9
+ let closed = false;
10
+ const close = () => {
11
+ if (!closed) {
12
+ closed = true;
13
+ iterator.return?.();
14
+ }
15
+ };
16
+ let current;
17
+ try {
18
+ const first = iterator.next();
19
+ if (first.done)
20
+ throw new RangeError("Player requires at least one step");
21
+ current = first.value;
22
+ }
23
+ catch (error) {
24
+ close();
25
+ throw error;
26
+ }
27
+ let previous = current;
28
+ let elapsed = stepDurationMs;
29
+ let hold = 0;
30
+ let disposed = false;
31
+ let finished = false;
32
+ const complete = () => {
33
+ if (options.isTerminal(current) && elapsed === stepDurationMs) {
34
+ previous = current;
35
+ close();
36
+ finished = hold >= finalHoldMs;
37
+ }
38
+ };
39
+ const frame = () => Object.freeze({
40
+ previous: previous.state, current: current.state,
41
+ progress: elapsed / stepDurationMs,
42
+ stepIndex: current.index, event: current.event
43
+ });
44
+ try {
45
+ complete();
46
+ options.render?.(frame());
47
+ }
48
+ catch (error) {
49
+ close();
50
+ throw error;
51
+ }
52
+ return Object.freeze({
53
+ get frame() { return frame(); },
54
+ get finished() { return finished; },
55
+ advance(deltaMs) {
56
+ if (disposed)
57
+ throw new Error("Player has been disposed");
58
+ if (!Number.isFinite(deltaMs) || deltaMs < 0) {
59
+ throw new RangeError("Expected finite non-negative playback time");
60
+ }
61
+ try {
62
+ let remaining = deltaMs;
63
+ while (remaining > 0 && !finished) {
64
+ if (elapsed === stepDurationMs) {
65
+ if (options.isTerminal(current)) {
66
+ hold += Math.min(remaining, finalHoldMs - hold);
67
+ complete();
68
+ break;
69
+ }
70
+ const next = iterator.next();
71
+ if (next.done)
72
+ throw new RangeError("Step source ended without a terminal step");
73
+ previous = current;
74
+ current = next.value;
75
+ elapsed = 0;
76
+ }
77
+ const amount = Math.min(remaining, stepDurationMs - elapsed);
78
+ elapsed += amount;
79
+ remaining -= amount;
80
+ complete();
81
+ }
82
+ const result = frame();
83
+ options.render?.(result);
84
+ return result;
85
+ }
86
+ catch (error) {
87
+ disposed = true;
88
+ close();
89
+ throw error;
90
+ }
91
+ },
92
+ dispose() {
93
+ disposed = true;
94
+ close();
95
+ }
96
+ });
97
+ }
@@ -0,0 +1,6 @@
1
+ import type { Step, Timeline } from "./types.js";
2
+ /** Step i is complete at i * stepDurationMs; the final state is held afterwards. */
3
+ export declare function createTimeline<S, E>(steps: readonly Step<S, E>[], options: {
4
+ stepDurationMs: number;
5
+ finalHoldMs: number;
6
+ }): Timeline<S, E>;
@@ -0,0 +1,30 @@
1
+ /** Step i is complete at i * stepDurationMs; the final state is held afterwards. */
2
+ export function createTimeline(steps, options) {
3
+ if (!steps.length)
4
+ throw new RangeError("Timeline requires at least one step");
5
+ const { stepDurationMs, finalHoldMs } = options;
6
+ if (!Number.isFinite(stepDurationMs) || stepDurationMs <= 0 ||
7
+ !Number.isFinite(finalHoldMs) || finalHoldMs < 0) {
8
+ throw new RangeError("Expected positive step duration and non-negative final hold");
9
+ }
10
+ const history = steps.slice();
11
+ const end = (history.length - 1) * stepDurationMs;
12
+ const durationMs = end + finalHoldMs;
13
+ if (!Number.isFinite(durationMs))
14
+ throw new RangeError("Timeline duration is too large");
15
+ return Object.freeze({
16
+ durationMs,
17
+ sample(timeMs) {
18
+ if (!Number.isFinite(timeMs))
19
+ throw new TypeError("Time must be finite");
20
+ const time = Math.max(0, Math.min(timeMs, durationMs));
21
+ const index = Math.min(history.length - 1, Math.ceil(time / stepDurationMs));
22
+ const step = history[index];
23
+ const previous = time >= end || index === 0 ? step : history[index - 1];
24
+ const progress = previous === step ? 1 :
25
+ Math.min(1, (time - (index - 1) * stepDurationMs) / stepDurationMs);
26
+ return Object.freeze({ previous: previous.state, current: step.state,
27
+ progress, stepIndex: index, event: step.event });
28
+ }
29
+ });
30
+ }