@grundyjs/algiviz 0.1.0 → 0.3.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.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,34 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.0 — unreleased
3
+ ## 0.3.0 — 2026-10-09
4
+
5
+ - Extend `SortEvent` with `swap` and `pass`, and `SortSnapshot` with optional
6
+ `sortedSuffixLength`. Consumers with exhaustive event switches must handle
7
+ the new event types. Existing insertion-sort traces remain unchanged.
8
+
9
+ - Share input validation, item identities, immutable snapshots and operation
10
+ counters between sorting algorithms; document adding an algorithm internally.
11
+
12
+ - Add stable bubble sort with `bubbleSortSteps` and `iterateBubbleSortSteps`,
13
+ adjacent swaps, sorted suffix tracking and early exit after a pass without swaps.
14
+ - Render both swapped items and the sorted suffix; select either algorithm in
15
+ the demo with history or generator playback.
16
+
17
+ ## 0.2.0 — 2026-10-09
18
+
19
+ - Move the held key horizontally along the baseline without lifting it above the array;
20
+ remove the redundant key label while retaining its color highlight.
21
+
22
+ - Expand the demo with history/generator playback, array input, generation and speed
23
+ controls; hide individual bar labels on dense charts.
24
+
25
+ - Add `createSortPlayer` for forward-only playback with bounded snapshot storage,
26
+ interpolated canvas-compatible frames, final hold and iterator cleanup.
27
+
28
+ - Add `iterateInsertionSortSteps` for lazy immutable snapshots without retaining history.
29
+ - Keep `insertionSortSteps` compatible by collecting the shared generator implementation.
30
+
31
+ ## 0.1.0
4
32
 
5
33
  - Stable, immutable insertion sort traces with item identities and operation counters.
6
34
  - A seekable timeline with interpolated frames and final hold.
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Grundy
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Grundy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # AlgiViz
2
2
 
3
- A TypeScript library for educational algorithm visualization: stable insertion sort traces, a seekable timeline and a canvas renderer. No runtime dependencies. The core works without DOM, React or Next.js. ESM only, with TypeScript declarations.
3
+ A TypeScript library for educational algorithm visualization: stable insertion and bubble sort traces, a seekable timeline and a canvas renderer. No runtime dependencies. The core works without DOM, React or Next.js. ESM only, with TypeScript declarations.
4
4
 
5
5
  ## Install
6
6
 
@@ -8,7 +8,7 @@ A TypeScript library for educational algorithm visualization: stable insertion s
8
8
  npm install @grundyjs/algiviz
9
9
  ```
10
10
 
11
- Try the [interactive insertion sort demo](https://grundyjs.ru/algorithms/insertion-sort/). Import the core from `@grundyjs/algiviz/core` and the browser renderer from `@grundyjs/algiviz/canvas`; there is no root entry point or CommonJS build. Version 0.1 is an initial API and may change in later minor releases.
11
+ Try the [interactive insertion sort demo](https://grundyjs.ru/algorithms/insertion-sort/). Import the core from `@grundyjs/algiviz/core` and the browser renderer from `@grundyjs/algiviz/canvas`; there is no root entry point or CommonJS build. Version 0.x is an initial API and may change in later minor releases.
12
12
 
13
13
  ## Development
14
14
 
@@ -42,6 +42,116 @@ The underlying sort uses O(n²) comparisons in the worst case and O(n) on alread
42
42
 
43
43
  Tests replay every event independently, check item conservation, stability, prefix ordering, counters, frozen snapshots and 1,093 exhaustive inputs with values -1, 0 and 1 of lengths 0–6.
44
44
 
45
+ ## Bubble sort
46
+
47
+ `bubbleSortSteps(values)` collects a frozen history; `iterateBubbleSortSteps(values)`
48
+ yields the same snapshots on demand. Both work with the existing timeline, player
49
+ and canvas renderer. Available since version 0.3.0.
50
+
51
+ ```js
52
+ import { bubbleSortSteps, iterateBubbleSortSteps, createSortPlayer } from "@grundyjs/algiviz/core";
53
+
54
+ const steps = bubbleSortSteps([5, 2, 4, 2, 1]);
55
+ const player = createSortPlayer(iterateBubbleSortSteps([5, 2, 4, 2, 1]), {
56
+ stepDurationMs: 350, finalHoldMs: 1000
57
+ });
58
+ ```
59
+
60
+ Each left-to-right pass compares adjacent items. `compare` identifies both items;
61
+ `swap` contains their pre-swap `leftId`, `rightId`, `left` and `right` positions,
62
+ and its snapshot is after the exchange. Each swap counts as two array writes.
63
+ Equal items are never swapped, so the sort is stable. `held` remains null and
64
+ `sortedPrefixLength` is zero.
65
+
66
+ `pass` marks a completed pass, with `end` identifying its last compared position.
67
+ The optional `sortedSuffixLength` snapshot field counts trailing items in their
68
+ final positions; renderers treat an omitted value as zero for older traces.
69
+ A pass with no swaps ends the sort early and marks the entire array sorted.
70
+ `done` always has `sortedSuffixLength` equal to the array length.
71
+
72
+ Validation, lazy input copying, freezing and memory costs match insertion sort.
73
+ The underlying algorithm performs O(n²) comparisons in the worst case and O(n)
74
+ on sorted input; full snapshots still make worst-case trace generation O(n³).
75
+ Consumers switching on `SortEvent.type` should handle the new `swap` and `pass` events.
76
+
77
+ ## Lazy steps
78
+
79
+ Use `iterateInsertionSortSteps` to consume steps on demand without retaining the
80
+ entire history. It yields the same immutable `SortStep` snapshots as
81
+ `insertionSortSteps`, which collects this iterator into a frozen array.
82
+
83
+ ```js
84
+ import { iterateInsertionSortSteps } from "@grundyjs/algiviz/core";
85
+
86
+ const iterator = iterateInsertionSortSteps([5, 2, 4, 2, 1]);
87
+ const start = iterator.next().value;
88
+ const selected = iterator.next().value;
89
+ iterator.return(); // Stop early when no more steps are needed.
90
+ ```
91
+
92
+ Creating the generator does not read the input or run the algorithm. The first
93
+ `next()` copies and validates the input; invalid values throw at that point.
94
+ Changes to the input before the first `next()` are observed; later changes do not
95
+ affect the iterator. Each subsequent `next()` runs only to the next step. An
96
+ iterator is single-use; create a new one to restart. A `for...of` loop can also
97
+ consume it, and `break` closes it early.
98
+
99
+ When the consumer retains only a fixed number of snapshots, memory is O(n).
100
+ Every snapshot still copies n slots, so consuming the complete worst-case trace
101
+ still takes O(n³) time. Collecting the iterator into an array restores the full
102
+ history memory cost. Iteration is synchronous; a long loop can block the UI.
103
+
104
+ The existing `createSortTimeline` still requires an array of steps for seeking.
105
+ It does not accept this iterator directly. Use `createSortPlayer` for forward-only
106
+ playback; the generator itself provides no timing or backward seeking.
107
+
108
+ ## Sequential playback
109
+
110
+ `createSortPlayer` consumes an iterable of steps and retains only the two adjacent
111
+ snapshots needed for rendering. With the lazy generator, playback uses O(n)
112
+ memory as long as the caller does not retain old frames.
113
+
114
+ ```js
115
+ import { createSortPlayer, iterateInsertionSortSteps } from "@grundyjs/algiviz/core";
116
+ import { createSortRenderer } from "@grundyjs/algiviz/canvas";
117
+
118
+ const player = createSortPlayer(iterateInsertionSortSteps([5, 2, 4, 1]), {
119
+ stepDurationMs: 650, finalHoldMs: 2000
120
+ });
121
+ const renderer = createSortRenderer({ theme: "dark" });
122
+ const ctx = canvas.getContext("2d");
123
+ let paused = false;
124
+ let speed = 1;
125
+ let last;
126
+ let requestId;
127
+ function tick(now) {
128
+ const delta = last === undefined ? 0 : now - last;
129
+ last = now;
130
+ if (!paused) player.advance(delta * speed);
131
+ renderer.render(ctx, player.frame);
132
+ if (!player.finished) requestId = requestAnimationFrame(tick);
133
+ }
134
+ requestId = requestAnimationFrame(tick);
135
+ // Set paused = true/false or speed = 2 from your controls.
136
+ // On teardown: cancelAnimationFrame(requestId); player.dispose();
137
+ ```
138
+
139
+ - Construction reads the first step immediately, including input validation by
140
+ the generator. The initial `frame` is fully rendered at progress 1.
141
+ - `advance(deltaMs)` accepts finite, non-negative elapsed playback time and
142
+ returns a `SortFrame` compatible with the existing canvas renderer. Zero does
143
+ not consume steps. Pause by not advancing; multiply delta by a non-negative
144
+ speed factor to change speed.
145
+ - `finished` becomes true after the final transition and `finalHoldMs`. Further
146
+ advances return the final frame. There is no known total duration or seeking.
147
+ - The source must end with a `done` event. The player closes the iterator when
148
+ that transition completes; unexpected exhaustion or source errors throw and
149
+ close playback. `dispose()` closes early and is safe to repeat; afterward the
150
+ frame remains readable but `advance()` throws.
151
+ - Restart by disposing the old player and creating a new player and generator.
152
+ Large deltas synchronously consume all intervening steps, so limit the elapsed
153
+ delta in the UI if resuming from a background tab should not cause a long catch-up.
154
+
45
155
  ## Timeline and canvas
46
156
 
47
157
  Import `createSortTimeline` from `@grundyjs/algiviz/core` and `createSortRenderer` from `@grundyjs/algiviz/canvas`.
@@ -57,17 +167,93 @@ const renderer = createSortRenderer({ theme: "dark" });
57
167
  renderer.render(canvas.getContext("2d"), timeline.sample(3250));
58
168
  ```
59
169
 
60
- Build, serve this project root using any static HTTP server, and open examples/index.html for play, pause, restart and seeking. The example changes bitmap size to fit its container; for video use a separate canvas with fixed export dimensions. Canvas rendering does not start any animation loop. There are no video recording dependencies.
170
+ Build, serve this project root using any static HTTP server, and open examples/index.html for play, pause, restart and seeking. The example changes bitmap size to fit its container; for video use a separate canvas with fixed export dimensions. Canvas rendering does not start any animation loop. There are no video recording dependencies.
171
+
172
+ The demo supports history (up to 64 items) and generator playback (up to 2,000).
173
+ Enter values or generate random, sorted or reversed arrays. Both modes support
174
+ pause, restart and speed changes; seeking is available only with full history.
175
+ Algorithm and mode changes restart the loaded array. Choose insertion or bubble
176
+ sort; both support both playback modes. These limits apply only to the demo.
177
+ Dense charts hide bar labels. Hidden tabs pause playback, and delayed frames cap
178
+ catch-up work to keep controls responsive.
179
+
61
180
 
62
181
  ## API at a glance
63
182
 
64
183
  - `insertionSortSteps(values)` returns immutable `SortStep[]` snapshots. Empty arrays are valid.
184
+ - `bubbleSortSteps(values)` returns the same snapshot format for stable bubble sort.
185
+ - `iterateInsertionSortSteps(values)` and `iterateBubbleSortSteps(values)` yield snapshots lazily.
186
+ - `createSortPlayer(iterable, { stepDurationMs, finalHoldMs })` provides forward-only playback with `frame`, `advance(deltaMs)`, `finished` and `dispose()`.
65
187
  - `createSortTimeline(steps, { stepDurationMs, finalHoldMs })` returns `durationMs` and `sample(timeMs)`. Frames expose `previous`, `current`, `progress`, `stepIndex` and `event`.
66
188
  - `createSortRenderer({ theme: "dark" | "light" }).render(ctx, frame)` draws into a browser 2D canvas context. Drawing and animation scheduling remain separate.
67
189
 
68
190
  For playback, call `timeline.sample(elapsedMs)` in your own requestAnimationFrame loop. For recording, use a fixed-size export canvas. Negative values are shown by magnitude with signed labels; the chart is not a signed-axis plot.
69
191
 
192
+ ## Adding an algorithm to this repository
193
+
194
+ Implement a generator in `src/core/` using the internal `createSortTrace` helper
195
+ from `./sort-trace.js`. Call it **inside** the generator body to preserve lazy
196
+ validation. It provides a mutable `state` workspace and `emit(event)`; emitting
197
+ copies and freezes the snapshot, assigns its step index, and updates counters.
198
+ It retains no history. The helper is internal, not a public package export.
199
+
200
+ The algorithm owns its loop, array changes and sorted-region markers. Always
201
+ change the workspace before yielding the event that describes that change:
202
+
203
+ ```ts
204
+ // Inside a generator, after checking that adjacent items need exchanging:
205
+ const a = state.slots[left]!;
206
+ const b = state.slots[right]!;
207
+ state.slots[left] = b;
208
+ state.slots[right] = a;
209
+ yield emit({ type: "swap", leftId: a.id, rightId: b.id, left, right });
210
+ ```
211
+
212
+ Yield `start` before sorting and `done` after updating the final sorted region.
213
+ Do not increment counters manually: `compare` adds one comparison, `shift` and
214
+ `insert` add one write, and `swap` adds two writes. Reuse the frozen items created
215
+ by the helper and preserve each item's identity across moves.
216
+
217
+ Add a history wrapper with `Object.freeze([...iterateYourSortSteps(values)])`,
218
+ export both functions from `src/core/index.ts`, and register the algorithm in
219
+ `examples/demo.mjs` plus its select option in `examples/index.html`. The timeline
220
+ and sequential player need no algorithm-specific changes. A new operation also
221
+ requires updating `SortEvent`, its counter semantics and canvas highlighting or
222
+ movement; existing operations can reuse the renderer.
223
+
224
+ Use the bubble-sort tests as a guide: independently replay events, verify stable
225
+ ordering and item conservation, check frozen snapshots and lazy validation, and
226
+ compare sequential frames with the timeline. This helper reduces snapshot
227
+ boilerplate; it does not validate that an algorithm's events match its mutations.
228
+
70
229
  ## Release
71
230
 
72
231
  Run `npm test`, `npm pack --dry-run` and test the resulting tarball in a separate consumer project. Source and issues: [urffin/algiviz](https://github.com/urffin/algiviz). Publishing is a separate maintainer action.
73
-
232
+
233
+ ### Publishing from GitHub Releases
234
+
235
+ The `.github/workflows/npm-publish.yml` workflow publishes to npm when a stable
236
+ GitHub Release is published (`release: published`). Drafts, prereleases and tag
237
+ pushes alone do not publish a package. The release tag must be `v` followed by
238
+ the exact version in `package.json` and `package-lock.json` (for example `v0.3.0`).
239
+ The workflow uses Node.js 24 and npm 11. The publish lifecycle runs the tests and
240
+ build before publishing with provenance, using npm trusted publishing (OIDC).
241
+
242
+ One-time setup in the npm package settings, under **Trusted Publisher**:
243
+
244
+ - Provider: GitHub Actions.
245
+ - Organization or user: `urffin`.
246
+ - Repository: `algiviz`.
247
+ - Workflow filename: `npm-publish.yml` (without the directory).
248
+ - Environment: leave empty; this workflow does not use a GitHub environment.
249
+ - Allow direct publishing with `npm publish` if the settings show this option.
250
+
251
+ No `NPM_TOKEN` secret is required. Keep account 2FA enabled.
252
+ See [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/).
253
+
254
+ For each release, update the version and changelog, commit and push the changes
255
+ (including the workflow), then publish a GitHub Release for the matching tag at
256
+ that commit. Check the Actions run and the npm package version afterward. Do not
257
+ reuse an already published npm version; if setup failed before publication,
258
+ correct the settings and rerun the failed workflow.
259
+
@@ -49,20 +49,20 @@ export function createSortRenderer(options) {
49
49
  for (const [id, next] of after) {
50
50
  const old = before.get(id) ?? next;
51
51
  const x = margin + ((old.index + (next.index - old.index) * p) + 0.5) * cell;
52
- const lift = ((old.held ? 1 : 0) + ((next.held ? 1 : 0) - (old.held ? 1 : 0)) * p) * height * 0.13;
53
52
  const size = Math.max(4, Math.abs(next.item.value) / maxMagnitude * available);
54
- const active = event.type === "compare" ? id === event.leftId || id === event.rightId :
53
+ const active = event.type === "compare" || event.type === "swap" ? id === event.leftId || id === event.rightId :
55
54
  "itemId" in event && event.itemId === id;
56
55
  ctx.fillStyle = next.held ? colors.held : active ? colors.active :
57
- next.index < frame.current.sortedPrefixLength ? colors.sorted : colors.bar;
58
- ctx.fillRect(x - barWidth / 2, baseline - size - lift, barWidth, size);
56
+ next.index < frame.current.sortedPrefixLength ||
57
+ next.index >= count - (frame.current.sortedSuffixLength ?? 0) ? colors.sorted : colors.bar;
58
+ ctx.fillRect(x - barWidth / 2, baseline - size, barWidth, size);
59
+ if (cell < 24)
60
+ continue;
59
61
  ctx.fillStyle = colors.text;
60
62
  ctx.font = `${fontSize}px sans-serif`;
61
63
  ctx.textAlign = "center";
62
- ctx.fillText(String(next.item.value), x, baseline - size - lift - fontSize, cell * 0.95);
64
+ ctx.fillText(String(next.item.value), x, baseline - size - fontSize, cell * 0.95);
63
65
  ctx.fillText(String(next.index), margin + (next.index + 0.5) * cell, baseline + height * 0.06);
64
- if (next.held)
65
- ctx.fillText("key", x, baseline - lift + fontSize, cell * 0.95);
66
66
  }
67
67
  ctx.textAlign = "left";
68
68
  ctx.font = `${Math.max(11, Math.min(18, width * 0.03))}px sans-serif`;
@@ -0,0 +1,5 @@
1
+ import type { SortStep } from "./types.js";
2
+ /** Creates a stable ascending bubble-sort trace without modifying the input. */
3
+ export declare function bubbleSortSteps(values: readonly number[]): readonly SortStep[];
4
+ /** Yields immutable snapshots lazily, retaining no history. */
5
+ export declare function iterateBubbleSortSteps(values: readonly number[]): Generator<SortStep, void, unknown>;
@@ -0,0 +1,33 @@
1
+ import { createSortTrace } from "./sort-trace.js";
2
+ /** Creates a stable ascending bubble-sort trace without modifying the input. */
3
+ export function bubbleSortSteps(values) {
4
+ return Object.freeze([...iterateBubbleSortSteps(values)]);
5
+ }
6
+ /** Yields immutable snapshots lazily, retaining no history. */
7
+ export function* iterateBubbleSortSteps(values) {
8
+ const { state, emit } = createSortTrace(values);
9
+ const { slots } = state;
10
+ state.sortedSuffixLength = slots.length < 2 ? slots.length : 0;
11
+ yield emit({ type: "start" });
12
+ for (let end = slots.length - 1; end > 0; end--) {
13
+ let swapped = false;
14
+ for (let left = 0; left < end; left++) {
15
+ const right = left + 1;
16
+ const a = slots[left];
17
+ const b = slots[right];
18
+ yield emit({ type: "compare", leftId: a.id, rightId: b.id });
19
+ if (a.value <= b.value)
20
+ continue;
21
+ slots[left] = b;
22
+ slots[right] = a;
23
+ swapped = true;
24
+ yield emit({ type: "swap", leftId: a.id, rightId: b.id, left, right });
25
+ }
26
+ state.sortedSuffixLength = swapped ? slots.length - end : slots.length;
27
+ yield emit({ type: "pass", end });
28
+ if (!swapped)
29
+ break;
30
+ }
31
+ state.sortedSuffixLength = slots.length;
32
+ yield emit({ type: "done" });
33
+ }
@@ -1,4 +1,7 @@
1
- export { insertionSortSteps } from "./insertion-sort.js";
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";
6
+ export { createSortPlayer } from "./player.js";
7
+ export type { SortPlayer } from "./player.js";
@@ -1,2 +1,4 @@
1
- export { insertionSortSteps } from "./insertion-sort.js";
1
+ export { insertionSortSteps, iterateInsertionSortSteps } from "./insertion-sort.js";
2
+ export { bubbleSortSteps, iterateBubbleSortSteps } from "./bubble-sort.js";
2
3
  export { createSortTimeline } from "./timeline.js";
4
+ export { createSortPlayer } from "./player.js";
@@ -1,3 +1,8 @@
1
1
  import type { SortStep } from "./types.js";
2
2
  /** Creates a stable ascending insertion-sort trace without modifying the input. */
3
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,59 +1,40 @@
1
+ import { createSortTrace } from "./sort-trace.js";
1
2
  /** Creates a stable ascending insertion-sort trace without modifying the input. */
2
3
  export function insertionSortSteps(values) {
3
- // Array.from also exposes sparse holes as undefined for validation.
4
- const input = Array.from(values);
5
- for (let i = 0; i < input.length; i++) {
6
- if (typeof input[i] !== "number" || !Number.isFinite(input[i])) {
7
- throw new TypeError(`Expected a finite number at index ${i}`);
8
- }
9
- }
10
- const slots = input.map((value, index) => Object.freeze({ id: `item-${index}`, value }));
11
- const steps = [];
12
- let held = null;
13
- let sortedPrefixLength = Math.min(1, slots.length);
14
- let comparisons = 0;
15
- let writes = 0;
16
- const emit = (event) => {
17
- steps.push(Object.freeze({
18
- index: steps.length,
19
- event: Object.freeze(event),
20
- state: Object.freeze({
21
- slots: Object.freeze(slots.slice()),
22
- held,
23
- sortedPrefixLength,
24
- comparisons,
25
- writes
26
- })
27
- }));
28
- };
29
- emit({ type: "start" });
4
+ return Object.freeze([...iterateInsertionSortSteps(values)]);
5
+ }
6
+ /**
7
+ * Yields immutable snapshots without retaining history.
8
+ * Input is copied and validated on the first next(), not when creating the iterator.
9
+ */
10
+ export function* iterateInsertionSortSteps(values) {
11
+ const { state, emit } = createSortTrace(values);
12
+ const { slots } = state;
13
+ state.sortedPrefixLength = Math.min(1, slots.length);
14
+ yield emit({ type: "start" });
30
15
  for (let i = 1; i < slots.length; i++) {
31
16
  const key = slots[i];
32
- held = key;
17
+ state.held = key;
33
18
  slots[i] = null;
34
- sortedPrefixLength = i;
35
- emit({ type: "select", itemId: key.id, from: i });
19
+ state.sortedPrefixLength = i;
20
+ yield emit({ type: "select", itemId: key.id, from: i });
36
21
  let hole = i;
37
22
  while (hole > 0) {
38
23
  const left = slots[hole - 1];
39
- comparisons++;
40
- emit({ type: "compare", leftId: left.id, rightId: key.id });
24
+ yield emit({ type: "compare", leftId: left.id, rightId: key.id });
41
25
  if (left.value <= key.value)
42
26
  break;
43
27
  slots[hole] = left;
44
28
  slots[hole - 1] = null;
45
- writes++;
46
- sortedPrefixLength = hole - 1;
47
- emit({ type: "shift", itemId: left.id, from: hole - 1, to: hole });
29
+ state.sortedPrefixLength = hole - 1;
30
+ yield emit({ type: "shift", itemId: left.id, from: hole - 1, to: hole });
48
31
  hole--;
49
32
  }
50
33
  slots[hole] = key;
51
- held = null;
52
- writes++;
53
- sortedPrefixLength = i + 1;
54
- emit({ type: "insert", itemId: key.id, to: hole });
34
+ state.held = null;
35
+ state.sortedPrefixLength = i + 1;
36
+ yield emit({ type: "insert", itemId: key.id, to: hole });
55
37
  }
56
- sortedPrefixLength = slots.length;
57
- emit({ type: "done" });
58
- return Object.freeze(steps);
38
+ state.sortedPrefixLength = slots.length;
39
+ yield emit({ type: "done" });
59
40
  }
@@ -0,0 +1,15 @@
1
+ import type { SortStep } from "./types.js";
2
+ 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. */
12
+ export declare function createSortPlayer(steps: Iterable<SortStep>, options: {
13
+ stepDurationMs: number;
14
+ finalHoldMs: number;
15
+ }): SortPlayer;
@@ -0,0 +1,88 @@
1
+ /** Forward-only playback. The source must end with a `done` event. */
2
+ 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
+ });
88
+ }
@@ -0,0 +1,11 @@
1
+ import type { Item, SortEvent, SortStep } from "./types.js";
2
+ /** Internal mutable workspace; only emit() exposes immutable snapshots. */
3
+ export declare function createSortTrace(values: readonly number[]): {
4
+ state: {
5
+ slots: (Item | null)[];
6
+ held: Item | null;
7
+ sortedPrefixLength: number;
8
+ sortedSuffixLength?: number;
9
+ };
10
+ emit: (event: SortEvent) => SortStep;
11
+ };
@@ -0,0 +1,34 @@
1
+ /** Internal mutable workspace; only emit() exposes immutable snapshots. */
2
+ export function createSortTrace(values) {
3
+ // Array.from exposes sparse holes as undefined so they fail validation too.
4
+ const input = Array.from(values);
5
+ const slots = input.map((value, index) => {
6
+ if (typeof value !== "number" || !Number.isFinite(value)) {
7
+ throw new TypeError(`Expected a finite number at index ${index}`);
8
+ }
9
+ return Object.freeze({ id: `item-${index}`, value });
10
+ });
11
+ const state = { slots, held: null, sortedPrefixLength: 0 };
12
+ let index = 0;
13
+ let comparisons = 0;
14
+ let writes = 0;
15
+ function emit(event) {
16
+ if (event.type === "compare")
17
+ comparisons++;
18
+ if (event.type === "shift" || event.type === "insert")
19
+ writes++;
20
+ if (event.type === "swap")
21
+ writes += 2;
22
+ return Object.freeze({
23
+ index: index++,
24
+ event: Object.freeze(event),
25
+ state: Object.freeze({
26
+ ...state,
27
+ slots: Object.freeze(state.slots.slice()),
28
+ comparisons,
29
+ writes
30
+ })
31
+ });
32
+ }
33
+ return { state, emit };
34
+ }
@@ -21,6 +21,15 @@ export type SortEvent = Readonly<{
21
21
  type: "insert";
22
22
  itemId: string;
23
23
  to: number;
24
+ } | {
25
+ type: "swap";
26
+ leftId: string;
27
+ rightId: string;
28
+ left: number;
29
+ right: number;
30
+ } | {
31
+ type: "pass";
32
+ end: number;
24
33
  } | {
25
34
  type: "done";
26
35
  }>;
@@ -29,6 +38,8 @@ export interface SortSnapshot {
29
38
  readonly held: Item | null;
30
39
  /** Sorted leading occupied slots; during insertion it ends at the hole. */
31
40
  readonly sortedPrefixLength: number;
41
+ /** Final sorted trailing slots. Omitted by older traces and insertion sort. */
42
+ readonly sortedSuffixLength?: number;
32
43
  readonly comparisons: number;
33
44
  /** Writes into array slots; selecting a key does not count as a write. */
34
45
  readonly writes: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grundyjs/algiviz",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Deterministic algorithm steps for educational visualizations",
5
5
  "type": "module",
6
6
  "files": [
@@ -35,11 +35,12 @@
35
35
  "name": "Grundy",
36
36
  "url": "https://grundyjs.ru/"
37
37
  },
38
- "homepage": "https://grundyjs.ru/algorithms/insertion-sort/",
38
+ "homepage": "https://grundyjs.ru/algiviz/",
39
39
  "keywords": [
40
40
  "algorithms",
41
41
  "visualization",
42
42
  "insertion-sort",
43
+ "bubble-sort",
43
44
  "canvas",
44
45
  "typescript"
45
46
  ],