@grundyjs/algiviz 0.4.0 → 0.5.1

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,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.1 — 2026-10-14
4
+
5
+ - Update the README around the current public API and examples; remove migration
6
+ instructions and detailed legacy API documentation.
7
+ - Correct demo input limits and clarify playback, snapshot memory and rendering.
8
+ - No runtime API or behavior changes.
9
+
10
+ ## 0.5.0 — 2026-10-14
11
+
12
+ - Cache immutable array and quick-sort scene projections and avoid repeated scale/layout
13
+ calculations and hidden text styling on dense charts; add a reproducible CPU/heap benchmark.
14
+ - Add application-owned Lomuto and three-way quick-sort demos with shared playback
15
+ controls and limits (64 history values or 2,000 generator values).
16
+ - Move the tree cursor along edges through shared ancestors in Canvas and SVG.
17
+
18
+ - Add a retained SVG tree renderer alongside Canvas, with layer ordering and seek-safe element cleanup.
19
+
20
+ - Add createSceneProjector to adapt immutable state frames with a two-endpoint cache; use it in the merge example.
21
+
22
+ - Run tests and isolated package-consumer checks on pushes and pull requests,
23
+ using Node.js 22/24 on Linux and Windows.
24
+
25
+ - Share array generation controls and validation across insertion, bubble and merge demos.
26
+
27
+ - Support forward-only generator playback in the merge sort example without retaining history.
28
+
29
+ - Add an application-owned merge sort example using scene/playback, with a main
30
+ array and buffer, stable identity labels, seeking and accessible playback controls.
31
+
3
32
  ## 0.4.0 — 2026-10-09
4
33
 
5
34
  - Verify the packed npm archive in an isolated TypeScript consumer before publishing.
package/README.md CHANGED
@@ -1,10 +1,28 @@
1
1
  # AlgiViz
2
2
 
3
- A TypeScript toolkit for user-defined algorithm visualizations. You write the algorithm; AlgiViz provides array operations, immutable steps, playback and canvas rendering. No runtime dependencies. Playback works without DOM, React or Next.js. ESM only, with TypeScript declarations.
3
+ A TypeScript toolkit for user-defined algorithm visualizations. You write the
4
+ algorithm and define your objects; AlgiViz provides array operations, playback,
5
+ typed scene dispatch and a ready-made Canvas array renderer. No runtime dependencies.
6
+ Playback works without DOM, React or Next.js. ESM only, with TypeScript declarations.
7
+
8
+ This README describes version 0.5.1.
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ npm install @grundyjs/algiviz
14
+ ```
15
+
16
+ There is no root entry point or CommonJS build. Import the modules you need:
17
+ `/array`, `/playback`, `/scene` and `/canvas`.
18
+
19
+ The deprecated `/core` sorting API remains available for compatibility.
20
+ For new integrations, use the APIs below. Version 0.x may introduce breaking changes
21
+ in minor releases; consult [CHANGELOG.md](CHANGELOG.md) when updating.
4
22
 
5
23
  ## User-defined algorithms
6
24
 
7
- The following API is introduced in 0.4.0.
25
+
8
26
  New applications use these entry points, which do not import bundled algorithms:
9
27
 
10
28
  - `@grundyjs/algiviz/array`: array operations and generator definitions.
@@ -75,12 +93,8 @@ order throw before mutating the workspace. Read snapshots rather than modifying
75
93
  items yourself. `createArrayTrace(values)` exposes the same operations directly,
76
94
  with immediate input validation, for authors writing their own generator wrapper.
77
95
 
78
- Full insertion/bubble implementations live in [examples/algorithms.mjs](examples/algorithms.mjs).
79
- The browser demo imports those external algorithms. Deprecated `/core` exports
80
- remain available through isolated compatibility implementations using the same
81
- operations; they are retained in the package until a breaking release removes them.
82
- Existing insertion/bubble traces remain unchanged. `ArrayEvent` also includes the
83
- new `highlight` event, so exhaustive event switches must handle it.
96
+ Insertion and bubble sort implementations live in [examples/algorithms.mjs](examples/algorithms.mjs).
97
+ The algorithms belong to the application; they require no changes to the library.
84
98
 
85
99
  ### Custom structures and renderers
86
100
 
@@ -111,7 +125,7 @@ graph/tree algorithm or operation set.
111
125
 
112
126
  ### User-defined objects and visualizations
113
127
 
114
- The `/scene` API is introduced in 0.4.0. Define a type map with your own data, then
128
+ Define a type map with your own data, then
115
129
  provide a visualization for every type. AlgiViz matches identities and dispatches
116
130
  objects; your handlers decide how data becomes geometry and how it interpolates.
117
131
 
@@ -180,271 +194,214 @@ with `bar` objects whose data contains `item`, `index` and `held`. The built-in
180
194
  array renderer uses this same dispatcher. You can provide a different `bar`
181
195
  visualization using `createSceneRenderer<ArrayObjects, YourContext>`.
182
196
 
183
- See [examples/tree-scene.mjs](examples/tree-scene.mjs) and open
184
- `examples/tree.html` after building and serving the repository. The application
185
- defines its own tree traversal, `node`, `edge` and `pointer` types, appearance,
186
- pointer movement and removal. No tree-specific code is added to AlgiViz.
187
-
188
- ## Migrating from 0.3.x
189
-
190
- Existing `/core` imports continue to work in 0.4.0. Built-in insertion and bubble
191
- sort exports are deprecated, but their traces remain unchanged. Migrate each
192
- algorithm into your application using `defineArrayAlgorithm`; complete examples
193
- are in [examples/algorithms.mjs](https://github.com/urffin/algiviz/blob/v0.4.0/examples/algorithms.mjs).
194
-
195
- | Previous API | Application-owned API |
196
- | --- | --- |
197
- | `insertionSortSteps(values)` | `insertion.steps(values)` on your algorithm definition |
198
- | `iterateInsertionSortSteps(values)` | `insertion.iterate(values)` |
199
- | `bubbleSortSteps(values)` / `iterateBubbleSortSteps(values)` | `bubble.steps(values)` / `bubble.iterate(values)` |
200
- | `/core` `createSortTimeline` / `createSortPlayer` | `/array` `createArrayTimeline` / `createArrayPlayer` |
201
- | `/canvas` `createSortRenderer` | `/canvas` `createArrayRenderer` |
202
- | `/core` `SortEvent`, `SortSnapshot`, `SortStep`, `SortFrame` | `/array` `ArrayEvent`, `ArraySnapshot`, `ArrayStep`, `ArrayFrame` |
203
-
204
- Timing options and array frames remain compatible. Exhaustive event switches
205
- must handle the new `highlight` event, even though the legacy algorithms do not
206
- emit it. An incomplete player source now reports a missing terminal step; avoid
207
- depending on the old error text. Generic `/playback` requires an explicit
208
- `isTerminal` predicate; `/array` players still recognize the `done` event.
209
-
210
- Use `/scene` when you need your own object types and visualization handlers.
211
- This is optional for array users: `createArrayRenderer` already uses scenes.
212
- Generic states and custom scene data must remain immutable; unlike array
213
- operations, the generic engine does not clone or deep-freeze them.
214
-
215
- ## Legacy sorting API
216
-
217
- The following `/core` examples remain supported for compatibility. Prefer the
218
- user-defined algorithm API above for new integrations.
219
-
220
- ## Install
221
-
222
- ```sh
223
- npm install @grundyjs/algiviz
224
- ```
225
-
226
- 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.
227
-
228
- ## Development
229
-
230
- Requires Node.js 22 or later and npm.
231
-
232
- ```sh
233
- npm install
234
- npm test
235
- npm pack
236
- ```
237
-
238
- Build output includes ESM and TypeScript declarations. `npm pack` rebuilds automatically; `npm publish` runs the tests before packing. The archive includes only dist, README, LICENSE, CHANGELOG and package metadata. Licensed under MIT.
239
-
240
- ```js
241
- import { insertionSortSteps } from "@grundyjs/algiviz/core";
242
-
243
- const steps = insertionSortSteps([5, 2, 4, 2, 1]);
244
- console.log(steps.at(-1).state.slots.map(item => item.value));
245
- // [1, 2, 2, 4, 5]
246
- ```
247
-
248
- Each step contains a typed event and the complete state **after** it. `start` preserves the input; `done` has no held item and a fully sorted array. Original-index ids (`item-0`, etc.) distinguish equal values and are deterministic within one trace. The input is never modified. Invalid values, including NaN, Infinity and sparse holes, throw TypeError.
249
-
250
- `select` moves the key into `held` and leaves one null slot. `compare` compares the neighbor with the key. `shift` moves the neighbor into the hole. `insert` puts the key back. All original items occur exactly once across `slots` and `held` in every snapshot. Equal values are never shifted past each other.
251
-
252
- `sortedPrefixLength` counts the leading occupied slots known to be sorted: initially min(1, n), during insertion it ends at the hole, after insertion it extends through the processed region. `comparisons` counts compare events. `writes` counts shift and insert events, including reinserting an unchanged key; extracting a key and clearing the hole are visualization bookkeeping, not counted writes.
253
-
254
- The result, steps, events, snapshots, slot arrays and items are frozen at runtime. Items may be shared between snapshots because they are immutable.
255
-
256
- The underlying sort uses O(n²) comparisons in the worst case and O(n) on already sorted input. This API stores full history, so its worst-case time and memory are O(n³), unlike ordinary in-place insertion sort. Intended for small educational inputs; the demo site limits inputs to 24 items. This core imposes no UI limit.
197
+ ### State-to-scene projection
257
198
 
258
- 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.
259
-
260
- ## Bubble sort
261
-
262
- `bubbleSortSteps(values)` collects a frozen history; `iterateBubbleSortSteps(values)`
263
- yields the same snapshots on demand. Both work with the existing timeline, player
264
- and canvas renderer. Available since version 0.3.0.
265
-
266
- ```js
267
- import { bubbleSortSteps, iterateBubbleSortSteps, createSortPlayer } from "@grundyjs/algiviz/core";
268
-
269
- const steps = bubbleSortSteps([5, 2, 4, 2, 1]);
270
- const player = createSortPlayer(iterateBubbleSortSteps([5, 2, 4, 2, 1]), {
271
- stepDurationMs: 350, finalHoldMs: 1000
272
- });
273
- ```
274
-
275
- Each left-to-right pass compares adjacent items. `compare` identifies both items;
276
- `swap` contains their pre-swap `leftId`, `rightId`, `left` and `right` positions,
277
- and its snapshot is after the exchange. Each swap counts as two array writes.
278
- Equal items are never swapped, so the sort is stable. `held` remains null and
279
- `sortedPrefixLength` is zero.
280
-
281
- `pass` marks a completed pass, with `end` identifying its last compared position.
282
- The optional `sortedSuffixLength` snapshot field counts trailing items in their
283
- final positions; renderers treat an omitted value as zero for older traces.
284
- A pass with no swaps ends the sort early and marks the entire array sorted.
285
- `done` always has `sortedSuffixLength` equal to the array length.
286
-
287
- Validation, lazy input copying, freezing and memory costs match insertion sort.
288
- The underlying algorithm performs O(n²) comparisons in the worst case and O(n)
289
- on sorted input; full snapshots still make worst-case trace generation O(n³).
290
- Consumers switching on `SortEvent.type` should handle the new `swap` and `pass` events.
291
-
292
- ## Lazy steps
293
-
294
- Use `iterateInsertionSortSteps` to consume steps on demand without retaining the
295
- entire history. It yields the same immutable `SortStep` snapshots as
296
- `insertionSortSteps`, which collects this iterator into a frozen array.
199
+ `createSceneProjector` adapts
200
+ playback frames from your state model to scenes without changing the algorithm:
297
201
 
298
202
  ```js
299
- import { iterateInsertionSortSteps } from "@grundyjs/algiviz/core";
300
-
301
- const iterator = iterateInsertionSortSteps([5, 2, 4, 2, 1]);
302
- const start = iterator.next().value;
303
- const selected = iterator.next().value;
304
- iterator.return(); // Stop early when no more steps are needed.
203
+ import { createSceneProjector } from '@grundyjs/algiviz/scene';
204
+ const projectFrame = createSceneProjector(state => ({
205
+ objects: state.items.map(item => ({ id: item.id, type: 'node', data: item }))
206
+ }));
207
+ // Reuse the projector across renders, with either history or generator playback.
208
+ renderer.render(ctx, projectFrame(frame));
305
209
  ```
306
210
 
307
- Creating the generator does not read the input or run the algorithm. The first
308
- `next()` copies and validates the input; invalid values throw at that point.
309
- Changes to the input before the first `next()` are observed; later changes do not
310
- affect the iterator. Each subsequent `next()` runs only to the next step. An
311
- iterator is single-use; create a new one to restart. A `for...of` loop can also
312
- consume it, and `break` closes it early.
313
-
314
- When the consumer retains only a fixed number of snapshots, memory is O(n).
315
- Every snapshot still copies n slots, so consuming the complete worst-case trace
316
- still takes O(n³) time. Collecting the iterator into an array restores the full
317
- history memory cost. Iteration is synchronous; a long loop can block the UI.
318
-
319
- The existing `createSortTimeline` still requires an array of steps for seeking.
320
- It does not accept this iterator directly. Use `createSortPlayer` for forward-only
321
- playback; the generator itself provides no timing or backward seeking.
322
-
323
- ## Sequential playback
324
-
325
- `createSortPlayer` consumes an iterable of steps and retains only the two adjacent
326
- snapshots needed for rendering. With the lazy generator, playback uses O(n)
327
- memory as long as the caller does not retain old frames.
211
+ The projection receives only a state, not an event or progress. Use a pure
212
+ function and immutable states/scenes. Endpoints are compared with `Object.is`;
213
+ mutating a retained state or changing captured projection settings will not
214
+ invalidate the cache. Create a new projector when those settings change.
215
+ At most two endpoint projections are retained; seeking to older states may
216
+ recompute them. Events, step indices and progress are preserved. Viewport layout
217
+ and animation belong in render handlers, not in this cached projection.
218
+ The returned frame is frozen; user states and projected scenes are not cloned
219
+ or deep-frozen. Scene validation still happens in the scene renderer.
220
+ The merge example uses this adapter to avoid rebuilding two cell scenes on every
221
+ animation frame. The algorithm, object types and visual behavior remain external.
222
+
223
+ ## Playback and rendering
224
+
225
+ Array players and timelines use the same frame shape as generic playback:
226
+ `previous`, `current`, `progress`, `stepIndex` and `event`.
227
+ A frame describes the transition into the current step; that step's state is the
228
+ state after its operation.
229
+
230
+ | API | Behavior |
231
+ | --- | --- |
232
+ | `createArrayPlayer(iterable, options)` | Array playback; recognizes the `done` event |
233
+ | `createPlayer(iterable, options)` | Generic playback; requires `isTerminal(step)` |
234
+ | `createArrayTimeline(steps, timing)` / `createTimeline(steps, timing)` | Retained history with `durationMs` and `sample(timeMs)` |
235
+ | `createArrayRenderer({ theme: "dark" \| "light" })` | Full-frame Canvas array rendering |
236
+ | `createSceneRenderer(handlers)` | Dispatches custom objects to your visualization handlers |
237
+ | `createSceneProjector(project)` | Adapts immutable state endpoints to scenes with a bounded cache |
238
+
239
+ Players read the first step at construction and expose `frame`, `advance(deltaMs)`,
240
+ `finished` and `dispose()`. The initial frame has progress 1.
241
+ `advance` accepts finite, non-negative elapsed playback time; zero consumes no steps.
242
+ Pause by not advancing. Apply speed by multiplying elapsed time.
243
+ The optional render callback runs initially and on every advance.
244
+
245
+ The source must reach a terminal step. The player closes the iterator after its
246
+ final transition and becomes finished after `finalHoldMs`. Premature exhaustion,
247
+ source errors and render errors propagate and close the iterator.
248
+ `dispose()` closes early and can be repeated; the frame remains readable, but
249
+ advancing a disposed player throws. Restart with a new iterator and player.
250
+
251
+ Timelines require an array of steps. Step i completes at `i * stepDurationMs`;
252
+ `finalHoldMs` extends the last frame. Sampling clamps finite times to the timeline
253
+ range and rejects non-finite times. There is no seeking or known total duration
254
+ in generator playback.
255
+
256
+ Animation scheduling belongs to your application. For example:
328
257
 
329
258
  ```js
330
- import { createSortPlayer, iterateInsertionSortSteps } from "@grundyjs/algiviz/core";
331
- import { createSortRenderer } from "@grundyjs/algiviz/canvas";
332
-
333
- const player = createSortPlayer(iterateInsertionSortSteps([5, 2, 4, 1]), {
334
- stepDurationMs: 650, finalHoldMs: 2000
335
- });
336
- const renderer = createSortRenderer({ theme: "dark" });
337
- const ctx = canvas.getContext("2d");
338
- let paused = false;
339
- let speed = 1;
259
+ const player = createArrayPlayer(reverse.iterate([1, 2, 3, 4]), options);
340
260
  let last;
341
261
  let requestId;
262
+ let paused = false;
263
+ let speed = 1;
264
+
342
265
  function tick(now) {
343
- const delta = last === undefined ? 0 : now - last;
266
+ const delta = last === undefined ? 0 : Math.min(100, now - last);
344
267
  last = now;
345
268
  if (!paused) player.advance(delta * speed);
346
269
  renderer.render(ctx, player.frame);
347
270
  if (!player.finished) requestId = requestAnimationFrame(tick);
348
271
  }
349
272
  requestId = requestAnimationFrame(tick);
350
- // Set paused = true/false or speed = 2 from your controls.
351
273
  // On teardown: cancelAnimationFrame(requestId); player.dispose();
274
+ // To replay after finishing, create a new player and schedule a new loop.
352
275
  ```
353
276
 
354
- - Construction reads the first step immediately, including input validation by
355
- the generator. The initial `frame` is fully rendered at progress 1.
356
- - `advance(deltaMs)` accepts finite, non-negative elapsed playback time and
357
- returns a `SortFrame` compatible with the existing canvas renderer. Zero does
358
- not consume steps. Pause by not advancing; multiply delta by a non-negative
359
- speed factor to change speed.
360
- - `finished` becomes true after the final transition and `finalHoldMs`. Further
361
- advances return the final frame. There is no known total duration or seeking.
362
- - The source must end with a `done` event. The player closes the iterator when
363
- that transition completes; unexpected exhaustion or source errors throw and
364
- close playback. `dispose()` closes early and is safe to repeat; afterward the
365
- frame remains readable but `advance()` throws.
366
- - Restart by disposing the old player and creating a new player and generator.
367
- Large deltas synchronously consume all intervening steps, so limit the elapsed
368
- delta in the UI if resuming from a background tab should not cause a long catch-up.
369
-
370
- ## Timeline and canvas
371
-
372
- Import `createSortTimeline` from `@grundyjs/algiviz/core` and `createSortRenderer` from `@grundyjs/algiviz/canvas`.
373
- Step i completes at i * stepDurationMs; finalHoldMs extends the final frame. sample clamps finite times to the timeline range and rejects non-finite times. Frames include the current event for explanations and highlights.
374
- The renderer draws a full frame using the canvas bitmap dimensions and restores context state. Signed values use magnitude for bar height and retain their sign in labels. Use small inputs for readable labels.
277
+ This is an alternative to the player setup in the earlier array example, reusing
278
+ its `reverse`, `options`, `ctx` and `renderer`. Large deltas consume intervening steps
279
+ synchronously, so cap elapsed time or pause on hidden tabs to avoid a long catch-up.
375
280
 
376
- ```js
377
- import { createSortTimeline } from "@grundyjs/algiviz/core";
378
- import { createSortRenderer } from "@grundyjs/algiviz/canvas";
281
+ The array renderer expects immutable snapshots. It uses Canvas bitmap dimensions
282
+ for layout, draws a full frame and restores context state. Negative values use
283
+ magnitude for bar height and retain their sign in labels; this is not a signed-axis
284
+ plot. Dense charts hide labels. For recording, use a canvas with fixed export
285
+ dimensions. Rendering does not start an animation loop or record video.
379
286
 
380
- const timeline = createSortTimeline(steps, { stepDurationMs: 650, finalHoldMs: 2000 });
381
- const renderer = createSortRenderer({ theme: "dark" });
382
- renderer.render(canvas.getContext("2d"), timeline.sample(3250));
383
- ```
287
+ ### Memory and generation cost
384
288
 
385
- 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.
289
+ Array operations copy the input, validate finite numbers and reject sparse inputs.
290
+ `defineArrayAlgorithm` performs this work on the first iterator `next()`;
291
+ `createArrayTrace` validates immediately. Each iterator owns its workspace.
292
+ Array steps, events, snapshots and items are frozen; immutable items can be shared
293
+ between snapshots.
386
294
 
387
- The demo supports history (up to 64 items) and generator playback (up to 2,000).
388
- Enter values or generate random, sorted or reversed arrays. Both modes support
389
- pause, restart and speed changes; seeking is available only with full history.
390
- Algorithm and mode changes restart the loaded array. Choose insertion or bubble
391
- sort; both support both playback modes. These limits apply only to the demo.
392
- Dense charts hide bar labels. Hidden tabs pause playback, and delayed frames cap
393
- catch-up work to keep controls responsive.
295
+ Generator playback retains adjacent snapshots, not the entire history. Array
296
+ playback therefore uses O(n) snapshot memory if the caller does not retain old
297
+ frames. Generic states and algorithm-specific storage may have different costs.
298
+ Each full array snapshot still copies n slots: laziness reduces retained history,
299
+ not the cost of producing every step. Collecting steps restores the history cost.
300
+ For example, a quadratic sorting trace with O(n)-sized snapshots can take O(n³)
301
+ time and history space. Iteration is synchronous and can block the UI.
394
302
 
303
+ ## Browser examples
395
304
 
396
- ## API at a glance
305
+ Build the project, serve the repository root with a static HTTP server and open
306
+ one of these pages. Examples are application code in the repository and are not
307
+ included in the npm archive.
397
308
 
398
- - `insertionSortSteps(values)` returns immutable `SortStep[]` snapshots. Empty arrays are valid.
399
- - `bubbleSortSteps(values)` returns the same snapshot format for stable bubble sort.
400
- - `iterateInsertionSortSteps(values)` and `iterateBubbleSortSteps(values)` yield snapshots lazily.
401
- - `createSortPlayer(iterable, { stepDurationMs, finalHoldMs })` provides forward-only playback with `frame`, `advance(deltaMs)`, `finished` and `dispose()`.
402
- - `createSortTimeline(steps, { stepDurationMs, finalHoldMs })` returns `durationMs` and `sample(timeMs)`. Frames expose `previous`, `current`, `progress`, `stepIndex` and `event`.
403
- - `createSortRenderer({ theme: "dark" | "light" }).render(ctx, frame)` draws into a browser 2D canvas context. Drawing and animation scheduling remain separate.
309
+ | Example | What it demonstrates |
310
+ | --- | --- |
311
+ | [Sorting lab](examples/index.html) | Insertion and bubble sort using the array operations and renderer |
312
+ | [Merge sort](examples/merge.html) | Custom state and a two-row scene for the main array and buffer |
313
+ | [Quick sort partitions](examples/quick.html) | Lomuto and three-way partitioning with custom state and scene |
314
+ | [Tree traversal](examples/tree.html) | Custom nodes, edges and pointer with Canvas and retained SVG backends |
315
+
316
+ All examples reuse [demo-player.mjs](examples/demo-player.mjs) and
317
+ [player-settings.mjs](examples/player-settings.mjs): 650 ms per step, 1,000 ms final
318
+ hold, speeds 0.25× through 32×, play/pause, restart and step controls.
319
+ History enables backward stepping and seeking; generator mode disables both.
320
+ Reduced motion disables interpolation, and hidden tabs pause playback.
321
+
322
+ All sorting examples accept up to **64 finite values with history** or **2,000 in
323
+ generator mode**. Enter values or generate an array by count with Random, Sorted or
324
+ Reverse order. These are demo limits, not library restrictions. Switching a large
325
+ array to history requires first loading an array within the history limit.
326
+ Dense charts hide labels; custom sorting examples preview the first 24 cells in
327
+ their text status.
328
+
329
+ ### Merge sort
330
+
331
+ The [algorithm](examples/merge-algorithm.mjs) and
332
+ [renderer](examples/merge-scene.mjs) use `/playback` and `/scene`.
333
+ Equal values have letter suffixes to demonstrate stability. Cells use row/position
334
+ IDs because copy-back temporarily duplicates item identities. Left wins ties.
335
+ The algorithm uses O(n) auxiliary memory; full snapshot history has an
336
+ O(n² log n) space upper bound.
337
+
338
+ ### Quick sort
339
+
340
+ Choose Lomuto or three-way partitioning while preserving loaded values.
341
+ Both algorithms and their scene belong to the example. Three-way recursion skips
342
+ the equal band: n ≥ 2 equal items take 2n scalar checks and zero swaps, versus
343
+ n(n−1)/2 checks with this Lomuto variant. Neither variant guarantees stability.
344
+ Worst-case algorithm time is quadratic; recursion can use O(n) stack space.
345
+ Full snapshot history has a separate O(n³) space upper bound.
346
+
347
+ ### Tree traversal
348
+
349
+ The example defines its own `node`, `edge` and `pointer` types. The pointer follows
350
+ tree edges through shared ancestors between visited nodes. The Renderer selector
351
+ switches between Canvas and SVG without restarting playback.
352
+ [tree-svg.mjs](examples/tree-svg.mjs) retains elements by ID, restores layer order
353
+ and removes stale elements even after seeking skips exit transitions.
354
+ No tree-specific algorithm or renderer is added to the package.
404
355
 
405
- 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.
356
+ ## Development
406
357
 
407
- ## Adding an algorithm
358
+ Requires Node.js 22 or later and npm.
408
359
 
409
- Create an application-owned generator with `defineArrayAlgorithm`, as shown
410
- above. Add it to your application's controls or the demo registry. AlgiViz needs
411
- no change when the existing operations are sufficient. For a new kind of state
412
- or event, implement the generic playback contract and its renderer.
360
+ ```sh
361
+ npm ci
362
+ npm test
363
+ npm run test:package
364
+ ```
365
+
366
+ `npm test` builds the library, checks TypeScript fixtures and runs the tests.
367
+ `npm run test:package` builds a tarball, installs it offline in an isolated temporary
368
+ consumer, compiles against the installed declarations and exercises public APIs,
369
+ including compatibility exports. The temporary consumer is removed afterward.
370
+
371
+ GitHub Actions runs both checks on pushes to `master` and pull requests, using
372
+ Node.js 22 and 24 on Linux and Windows. CI checks do not publish packages.
373
+
374
+ `npm pack` rebuilds automatically. The archive includes dist, README, LICENSE,
375
+ CHANGELOG and package metadata. Source and issues:
376
+ [urffin/algiviz](https://github.com/urffin/algiviz). Licensed under MIT.
377
+
378
+ ### Performance checks
379
+
380
+ Run `npm run benchmark` for 2,000-element insertion, bubble, merge and both quick-sort
381
+ variants. It reports generation of 1,000 steps, median rendering CPU time over five
382
+ 100-frame samples and retained Node heap after each 5,000 playback steps, up to
383
+ 25,000. Drawing uses a mock Canvas: these are CPU comparisons, not browser FPS or
384
+ GPU measurements. Heap samples use explicit GC and include the Node process;
385
+ they are diagnostics, not portable pass/fail thresholds.
413
386
 
414
387
  ## Release
415
388
 
416
- Run `npm test` and `npm run test:package`. The package check builds a real tarball,
417
- installs it offline in a separate temporary consumer, compiles TypeScript against
418
- the installed declarations and runs an external algorithm and custom scene with
419
- both playback modes. It also checks the array adapter and legacy compatibility.
420
- The temporary consumer is removed afterward. The release workflow runs this check
421
- before publishing. Source and issues: [urffin/algiviz](https://github.com/urffin/algiviz).
422
- Publishing is a separate maintainer action.
423
-
424
- ### Publishing from GitHub Releases
425
-
426
- The `.github/workflows/npm-publish.yml` workflow publishes to npm when a stable
427
- GitHub Release is published (`release: published`). Drafts, prereleases and tag
428
- pushes alone do not publish a package. The release tag must be `v` followed by
429
- the exact version in `package.json` and `package-lock.json` (for example `v0.4.0`).
430
- The workflow uses Node.js 24 and npm 11. The publish lifecycle runs the tests and
431
- build before publishing with provenance, using npm trusted publishing (OIDC).
432
-
433
- One-time setup in the npm package settings, under **Trusted Publisher**:
434
-
435
- - Provider: GitHub Actions.
436
- - Organization or user: `urffin`.
437
- - Repository: `algiviz`.
438
- - Workflow filename: `npm-publish.yml` (without the directory).
439
- - Environment: leave empty; this workflow does not use a GitHub environment.
440
- - Allow direct publishing with `npm publish` if the settings show this option.
441
-
442
- No `NPM_TOKEN` secret is required. Keep account 2FA enabled.
443
- See [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/).
389
+ Publishing is a maintainer action. Update the package version, lockfile and changelog;
390
+ run `npm test` and `npm run test:package`, commit and push, and check CI on the
391
+ candidate commit.
392
+
393
+ The [publish workflow](.github/workflows/npm-publish.yml) runs when a stable GitHub
394
+ Release is published. Drafts, prereleases and tag pushes alone do not publish.
395
+ Use `v` followed by the exact package version, such as `v0.5.1`, at the tested commit.
444
396
 
445
- For each release, update the version and changelog, commit and push the changes
446
- (including the workflow), then publish a GitHub Release for the matching tag at
447
- that commit. Check the Actions run and the npm package version afterward. Do not
448
- reuse an already published npm version; if setup failed before publication,
449
- correct the settings and rerun the failed workflow.
397
+ The workflow uses Node.js 24 and npm 11, checks the version and packed consumer,
398
+ then publishes with provenance through npm trusted publishing (OIDC).
399
+ The publish lifecycle also runs tests and rebuilds. No `NPM_TOKEN` secret is required.
400
+
401
+ The npm trusted publisher must match GitHub owner `urffin`, repository `algiviz`
402
+ and workflow `npm-publish.yml`. The workflow does not use a GitHub environment.
403
+ See [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/).
450
404
 
405
+ After publication, verify the Actions run and the exact version in npm.
406
+ Never reuse an already published npm version. If a workflow fails, check whether
407
+ publication occurred before deciding whether to rerun it.
@@ -1,4 +1,4 @@
1
- import { createSceneRenderer } from "../scene/index.js";
1
+ import { createSceneRenderer, createSceneProjector } from "../scene/index.js";
2
2
  /** Adapts array identities to the same public scene contract used by custom objects. */
3
3
  export function arrayScene(state) {
4
4
  return Object.freeze({
@@ -37,19 +37,20 @@ function positions(state) {
37
37
  /** Stateless full-frame renderer. Bitmap dimensions determine layout and video size. */
38
38
  export function createSortRenderer(options) {
39
39
  const colors = palettes[options.theme];
40
+ const projectFrame = createSceneProjector(arrayScene);
40
41
  return Object.freeze({
41
42
  render(ctx, frame) {
42
43
  const { width, height } = ctx.canvas;
43
44
  if (width <= 0 || height <= 0)
44
45
  return;
45
- const after = positions(frame.current);
46
- const items = [...after.values()];
46
+ const projected = projectFrame(frame);
47
+ const items = projected.current.objects;
47
48
  const count = frame.current.slots.length;
48
49
  const margin = Math.min(32, width * 0.05);
49
50
  const cell = (width - 2 * margin) / Math.max(count, 1);
50
51
  const barWidth = cell * 0.7;
51
52
  const available = height * 0.48;
52
- const maxMagnitude = Math.max(1, ...items.map(({ item }) => Math.abs(item.value)));
53
+ const maxMagnitude = Math.max(1, ...items.map(({ data }) => Math.abs(data.item.value)));
53
54
  const baseline = height * 0.69;
54
55
  const fontSize = Math.max(9, Math.min(20, cell * 0.45, width * 0.035));
55
56
  const progress = frame.progress;
@@ -99,11 +100,7 @@ export function createSortRenderer(options) {
99
100
  ctx.fillText(String(next.index), margin + (next.index + 0.5) * cell, baseline + height * 0.06);
100
101
  }
101
102
  });
102
- sceneRenderer.render(ctx, {
103
- ...frame,
104
- previous: arrayScene(frame.previous),
105
- current: arrayScene(frame.current)
106
- });
103
+ sceneRenderer.render(ctx, projected);
107
104
  ctx.textAlign = "left";
108
105
  ctx.font = `${Math.max(11, Math.min(18, width * 0.03))}px sans-serif`;
109
106
  ctx.fillStyle = colors.text;
@@ -40,3 +40,5 @@ export declare function createSceneFrame<Types extends object, Event>(frame: Fra
40
40
  export declare function createSceneRenderer<Types extends object, Context, Event = unknown>(visualizations: SceneVisualizations<Types, Context, Event>): Readonly<{
41
41
  render(context: Context, frame: Frame<Scene<Types>, Event>): void;
42
42
  }>;
43
+ /** Projects immutable state endpoints, retaining at most two scenes. */
44
+ export declare function createSceneProjector<State, Types extends object>(project: (state: State) => Scene<Types>): <Event>(frame: Frame<State, Event>) => Frame<Scene<Types>, Event>;
@@ -63,3 +63,22 @@ export function createSceneRenderer(visualizations) {
63
63
  }
64
64
  });
65
65
  }
66
+ /** Projects immutable state endpoints, retaining at most two scenes. */
67
+ export function createSceneProjector(project) {
68
+ let cache = [];
69
+ return (frame) => {
70
+ const next = [];
71
+ function endpoint(state) {
72
+ const found = [...next, ...cache].find(entry => Object.is(entry.state, state));
73
+ const entry = found ?? { state, scene: project(state) };
74
+ if (!next.includes(entry))
75
+ next.push(entry);
76
+ return entry.scene;
77
+ }
78
+ const previous = endpoint(frame.previous);
79
+ const current = endpoint(frame.current);
80
+ // Failed projections do not replace the previously successful cache.
81
+ cache = next;
82
+ return Object.freeze({ previous, current, progress: frame.progress, stepIndex: frame.stepIndex, event: frame.event });
83
+ };
84
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grundyjs/algiviz",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Deterministic algorithm steps for educational visualizations",
5
5
  "type": "module",
6
6
  "files": [
@@ -38,6 +38,7 @@
38
38
  },
39
39
  "scripts": {
40
40
  "build": "tsc -p tsconfig.json",
41
+ "benchmark": "npm run build && node --expose-gc scripts/benchmark.mjs",
41
42
  "test": "npm run build && tsc -p test/tsconfig.json && node --test test/*.test.mjs",
42
43
  "test:package": "node scripts/test-package.mjs",
43
44
  "prepack": "npm run build",