@grundyjs/algiviz 0.0.0-stage → 0.2.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 +22 -0
- package/LICENSE +21 -0
- package/README.md +185 -3
- package/dist/canvas/index.d.ts +7 -0
- package/dist/canvas/index.js +76 -0
- package/dist/core/index.d.ts +6 -0
- package/dist/core/index.js +3 -0
- package/dist/core/insertion-sort.d.ts +8 -0
- package/dist/core/insertion-sort.js +65 -0
- package/dist/core/player.d.ts +15 -0
- package/dist/core/player.js +88 -0
- package/dist/core/timeline.d.ts +17 -0
- package/dist/core/timeline.js +30 -0
- package/dist/core/types.d.ts +41 -0
- package/dist/core/types.js +1 -0
- package/package.json +56 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 — 2026-10-09
|
|
4
|
+
|
|
5
|
+
- Move the held key horizontally along the baseline without lifting it above the array;
|
|
6
|
+
remove the redundant key label while retaining its color highlight.
|
|
7
|
+
|
|
8
|
+
- Expand the demo with history/generator playback, array input, generation and speed
|
|
9
|
+
controls; hide individual bar labels on dense charts.
|
|
10
|
+
|
|
11
|
+
- Add `createSortPlayer` for forward-only playback with bounded snapshot storage,
|
|
12
|
+
interpolated canvas-compatible frames, final hold and iterator cleanup.
|
|
13
|
+
|
|
14
|
+
- Add `iterateInsertionSortSteps` for lazy immutable snapshots without retaining history.
|
|
15
|
+
- Keep `insertionSortSteps` compatible by collecting the shared generator implementation.
|
|
16
|
+
|
|
17
|
+
## 0.1.0
|
|
18
|
+
|
|
19
|
+
- Stable, immutable insertion sort traces with item identities and operation counters.
|
|
20
|
+
- A seekable timeline with interpolated frames and final hold.
|
|
21
|
+
- Stateless light and dark canvas rendering.
|
|
22
|
+
- ESM and TypeScript declarations; no runtime dependencies.
|
package/LICENSE
ADDED
|
@@ -0,0 +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.
|
package/README.md
CHANGED
|
@@ -1,4 +1,186 @@
|
|
|
1
|
-
#
|
|
1
|
+
# AlgiViz
|
|
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.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @grundyjs/algiviz
|
|
9
|
+
```
|
|
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.x is an initial API and may change in later minor releases.
|
|
12
|
+
|
|
13
|
+
## Development
|
|
14
|
+
|
|
15
|
+
Requires Node.js 22 or later and npm.
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm install
|
|
19
|
+
npm test
|
|
20
|
+
npm pack
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
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.
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
import { insertionSortSteps } from "@grundyjs/algiviz/core";
|
|
27
|
+
|
|
28
|
+
const steps = insertionSortSteps([5, 2, 4, 2, 1]);
|
|
29
|
+
console.log(steps.at(-1).state.slots.map(item => item.value));
|
|
30
|
+
// [1, 2, 2, 4, 5]
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
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.
|
|
34
|
+
|
|
35
|
+
`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.
|
|
36
|
+
|
|
37
|
+
`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.
|
|
38
|
+
|
|
39
|
+
The result, steps, events, snapshots, slot arrays and items are frozen at runtime. Items may be shared between snapshots because they are immutable.
|
|
40
|
+
|
|
41
|
+
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.
|
|
42
|
+
|
|
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
|
+
|
|
45
|
+
## Lazy steps
|
|
46
|
+
|
|
47
|
+
Use `iterateInsertionSortSteps` to consume steps on demand without retaining the
|
|
48
|
+
entire history. It yields the same immutable `SortStep` snapshots as
|
|
49
|
+
`insertionSortSteps`, which collects this iterator into a frozen array.
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
import { iterateInsertionSortSteps } from "@grundyjs/algiviz/core";
|
|
53
|
+
|
|
54
|
+
const iterator = iterateInsertionSortSteps([5, 2, 4, 2, 1]);
|
|
55
|
+
const start = iterator.next().value;
|
|
56
|
+
const selected = iterator.next().value;
|
|
57
|
+
iterator.return(); // Stop early when no more steps are needed.
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Creating the generator does not read the input or run the algorithm. The first
|
|
61
|
+
`next()` copies and validates the input; invalid values throw at that point.
|
|
62
|
+
Changes to the input before the first `next()` are observed; later changes do not
|
|
63
|
+
affect the iterator. Each subsequent `next()` runs only to the next step. An
|
|
64
|
+
iterator is single-use; create a new one to restart. A `for...of` loop can also
|
|
65
|
+
consume it, and `break` closes it early.
|
|
66
|
+
|
|
67
|
+
When the consumer retains only a fixed number of snapshots, memory is O(n).
|
|
68
|
+
Every snapshot still copies n slots, so consuming the complete worst-case trace
|
|
69
|
+
still takes O(n³) time. Collecting the iterator into an array restores the full
|
|
70
|
+
history memory cost. Iteration is synchronous; a long loop can block the UI.
|
|
71
|
+
|
|
72
|
+
The existing `createSortTimeline` still requires an array of steps for seeking.
|
|
73
|
+
It does not accept this iterator directly. Use `createSortPlayer` for forward-only
|
|
74
|
+
playback; the generator itself provides no timing or backward seeking.
|
|
75
|
+
|
|
76
|
+
## Sequential playback
|
|
77
|
+
|
|
78
|
+
`createSortPlayer` consumes an iterable of steps and retains only the two adjacent
|
|
79
|
+
snapshots needed for rendering. With the lazy generator, playback uses O(n)
|
|
80
|
+
memory as long as the caller does not retain old frames.
|
|
81
|
+
|
|
82
|
+
```js
|
|
83
|
+
import { createSortPlayer, iterateInsertionSortSteps } from "@grundyjs/algiviz/core";
|
|
84
|
+
import { createSortRenderer } from "@grundyjs/algiviz/canvas";
|
|
85
|
+
|
|
86
|
+
const player = createSortPlayer(iterateInsertionSortSteps([5, 2, 4, 1]), {
|
|
87
|
+
stepDurationMs: 650, finalHoldMs: 2000
|
|
88
|
+
});
|
|
89
|
+
const renderer = createSortRenderer({ theme: "dark" });
|
|
90
|
+
const ctx = canvas.getContext("2d");
|
|
91
|
+
let paused = false;
|
|
92
|
+
let speed = 1;
|
|
93
|
+
let last;
|
|
94
|
+
let requestId;
|
|
95
|
+
function tick(now) {
|
|
96
|
+
const delta = last === undefined ? 0 : now - last;
|
|
97
|
+
last = now;
|
|
98
|
+
if (!paused) player.advance(delta * speed);
|
|
99
|
+
renderer.render(ctx, player.frame);
|
|
100
|
+
if (!player.finished) requestId = requestAnimationFrame(tick);
|
|
101
|
+
}
|
|
102
|
+
requestId = requestAnimationFrame(tick);
|
|
103
|
+
// Set paused = true/false or speed = 2 from your controls.
|
|
104
|
+
// On teardown: cancelAnimationFrame(requestId); player.dispose();
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
- Construction reads the first step immediately, including input validation by
|
|
108
|
+
the generator. The initial `frame` is fully rendered at progress 1.
|
|
109
|
+
- `advance(deltaMs)` accepts finite, non-negative elapsed playback time and
|
|
110
|
+
returns a `SortFrame` compatible with the existing canvas renderer. Zero does
|
|
111
|
+
not consume steps. Pause by not advancing; multiply delta by a non-negative
|
|
112
|
+
speed factor to change speed.
|
|
113
|
+
- `finished` becomes true after the final transition and `finalHoldMs`. Further
|
|
114
|
+
advances return the final frame. There is no known total duration or seeking.
|
|
115
|
+
- The source must end with a `done` event. The player closes the iterator when
|
|
116
|
+
that transition completes; unexpected exhaustion or source errors throw and
|
|
117
|
+
close playback. `dispose()` closes early and is safe to repeat; afterward the
|
|
118
|
+
frame remains readable but `advance()` throws.
|
|
119
|
+
- Restart by disposing the old player and creating a new player and generator.
|
|
120
|
+
Large deltas synchronously consume all intervening steps, so limit the elapsed
|
|
121
|
+
delta in the UI if resuming from a background tab should not cause a long catch-up.
|
|
122
|
+
|
|
123
|
+
## Timeline and canvas
|
|
124
|
+
|
|
125
|
+
Import `createSortTimeline` from `@grundyjs/algiviz/core` and `createSortRenderer` from `@grundyjs/algiviz/canvas`.
|
|
126
|
+
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.
|
|
127
|
+
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.
|
|
128
|
+
|
|
129
|
+
```js
|
|
130
|
+
import { createSortTimeline } from "@grundyjs/algiviz/core";
|
|
131
|
+
import { createSortRenderer } from "@grundyjs/algiviz/canvas";
|
|
132
|
+
|
|
133
|
+
const timeline = createSortTimeline(steps, { stepDurationMs: 650, finalHoldMs: 2000 });
|
|
134
|
+
const renderer = createSortRenderer({ theme: "dark" });
|
|
135
|
+
renderer.render(canvas.getContext("2d"), timeline.sample(3250));
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
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.
|
|
139
|
+
|
|
140
|
+
The demo supports history (up to 64 items) and generator playback (up to 2,000).
|
|
141
|
+
Enter values or generate random, sorted or reversed arrays. Both modes support
|
|
142
|
+
pause, restart and speed changes; seeking is available only with full history.
|
|
143
|
+
Mode changes restart the loaded array. These limits apply only to the demo.
|
|
144
|
+
Dense charts hide bar labels. Hidden tabs pause playback, and delayed frames cap
|
|
145
|
+
catch-up work to keep controls responsive.
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
## API at a glance
|
|
149
|
+
|
|
150
|
+
- `insertionSortSteps(values)` returns immutable `SortStep[]` snapshots. Empty arrays are valid.
|
|
151
|
+
- `createSortTimeline(steps, { stepDurationMs, finalHoldMs })` returns `durationMs` and `sample(timeMs)`. Frames expose `previous`, `current`, `progress`, `stepIndex` and `event`.
|
|
152
|
+
- `createSortRenderer({ theme: "dark" | "light" }).render(ctx, frame)` draws into a browser 2D canvas context. Drawing and animation scheduling remain separate.
|
|
153
|
+
|
|
154
|
+
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.
|
|
155
|
+
|
|
156
|
+
## Release
|
|
157
|
+
|
|
158
|
+
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.
|
|
159
|
+
|
|
160
|
+
### Publishing from GitHub Releases
|
|
161
|
+
|
|
162
|
+
The `.github/workflows/npm-publish.yml` workflow publishes to npm when a stable
|
|
163
|
+
GitHub Release is published (`release: published`). Drafts, prereleases and tag
|
|
164
|
+
pushes alone do not publish a package. The release tag must be `v` followed by
|
|
165
|
+
the exact version in `package.json` and `package-lock.json` (for example `v0.2.0`).
|
|
166
|
+
The workflow uses Node.js 24 and npm 11. The publish lifecycle runs the tests and
|
|
167
|
+
build before publishing with provenance, using npm trusted publishing (OIDC).
|
|
168
|
+
|
|
169
|
+
One-time setup in the npm package settings, under **Trusted Publisher**:
|
|
170
|
+
|
|
171
|
+
- Provider: GitHub Actions.
|
|
172
|
+
- Organization or user: `urffin`.
|
|
173
|
+
- Repository: `algiviz`.
|
|
174
|
+
- Workflow filename: `npm-publish.yml` (without the directory).
|
|
175
|
+
- Environment: leave empty; this workflow does not use a GitHub environment.
|
|
176
|
+
- Allow direct publishing with `npm publish` if the settings show this option.
|
|
177
|
+
|
|
178
|
+
No `NPM_TOKEN` secret is required. Keep account 2FA enabled.
|
|
179
|
+
See [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/).
|
|
180
|
+
|
|
181
|
+
For each release, update the version and changelog, commit and push the changes
|
|
182
|
+
(including the workflow), then publish a GitHub Release for the matching tag at
|
|
183
|
+
that commit. Check the Actions run and the npm package version afterward. Do not
|
|
184
|
+
reuse an already published npm version; if setup failed before publication,
|
|
185
|
+
correct the settings and rerun the failed workflow.
|
|
2
186
|
|
|
3
|
-
This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
|
|
4
|
-
If no other versions are published within 30 days, this package and version will be deleted.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { SortFrame } from "../core/timeline.js";
|
|
2
|
+
/** Stateless full-frame renderer. Bitmap dimensions determine layout and video size. */
|
|
3
|
+
export declare function createSortRenderer(options: {
|
|
4
|
+
theme: "light" | "dark";
|
|
5
|
+
}): Readonly<{
|
|
6
|
+
render(ctx: CanvasRenderingContext2D, frame: SortFrame): void;
|
|
7
|
+
}>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
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" }
|
|
4
|
+
};
|
|
5
|
+
function positions(state) {
|
|
6
|
+
const result = new Map();
|
|
7
|
+
state.slots.forEach((item, index) => { if (item)
|
|
8
|
+
result.set(item.id, { item, index, held: false }); });
|
|
9
|
+
if (state.held)
|
|
10
|
+
result.set(state.held.id, { item: state.held,
|
|
11
|
+
index: state.slots.indexOf(null), held: true });
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
14
|
+
/** Stateless full-frame renderer. Bitmap dimensions determine layout and video size. */
|
|
15
|
+
export function createSortRenderer(options) {
|
|
16
|
+
const colors = palettes[options.theme];
|
|
17
|
+
return Object.freeze({
|
|
18
|
+
render(ctx, frame) {
|
|
19
|
+
const { width, height } = ctx.canvas;
|
|
20
|
+
if (width <= 0 || height <= 0)
|
|
21
|
+
return;
|
|
22
|
+
const before = positions(frame.previous);
|
|
23
|
+
const after = positions(frame.current);
|
|
24
|
+
const items = [...after.values()];
|
|
25
|
+
const count = frame.current.slots.length;
|
|
26
|
+
const margin = Math.min(32, width * 0.05);
|
|
27
|
+
const cell = (width - 2 * margin) / Math.max(count, 1);
|
|
28
|
+
const barWidth = cell * 0.7;
|
|
29
|
+
const available = height * 0.48;
|
|
30
|
+
const maxMagnitude = Math.max(1, ...items.map(({ item }) => Math.abs(item.value)));
|
|
31
|
+
const baseline = height * 0.69;
|
|
32
|
+
const fontSize = Math.max(9, Math.min(20, cell * 0.45, width * 0.035));
|
|
33
|
+
const progress = frame.progress;
|
|
34
|
+
const p = progress * progress * (3 - 2 * progress);
|
|
35
|
+
const event = frame.event;
|
|
36
|
+
ctx.save();
|
|
37
|
+
try {
|
|
38
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
39
|
+
ctx.globalAlpha = 1;
|
|
40
|
+
ctx.fillStyle = colors.background;
|
|
41
|
+
ctx.fillRect(0, 0, width, height);
|
|
42
|
+
ctx.font = `${Math.max(12, Math.min(24, width * 0.045))}px sans-serif`;
|
|
43
|
+
ctx.textAlign = "left";
|
|
44
|
+
ctx.textBaseline = "middle";
|
|
45
|
+
ctx.fillStyle = colors.text;
|
|
46
|
+
ctx.fillText(`AlgiViz · ${event.type} · ${frame.stepIndex}`, margin, height * 0.07);
|
|
47
|
+
if (!count)
|
|
48
|
+
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
|
+
}
|
|
66
|
+
ctx.textAlign = "left";
|
|
67
|
+
ctx.font = `${Math.max(11, Math.min(18, width * 0.03))}px sans-serif`;
|
|
68
|
+
ctx.fillStyle = colors.text;
|
|
69
|
+
ctx.fillText(`Comparisons: ${frame.current.comparisons} · Writes: ${frame.current.writes}`, margin, height * 0.9, width - margin * 2);
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
ctx.restore();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { insertionSortSteps, iterateInsertionSortSteps } from "./insertion-sort.js";
|
|
2
|
+
export type { Item, SortEvent, SortSnapshot, SortStep } from "./types.js";
|
|
3
|
+
export { createSortTimeline } from "./timeline.js";
|
|
4
|
+
export type { SortFrame, SortTimeline } from "./timeline.js";
|
|
5
|
+
export { createSortPlayer } from "./player.js";
|
|
6
|
+
export type { SortPlayer } from "./player.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
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>;
|
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
}
|
|
@@ -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,17 @@
|
|
|
1
|
+
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. */
|
|
14
|
+
export declare function createSortTimeline(steps: readonly SortStep[], options: {
|
|
15
|
+
stepDurationMs: number;
|
|
16
|
+
finalHoldMs: number;
|
|
17
|
+
}): SortTimeline;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Step i is complete at i * stepDurationMs; the final state is held afterwards. */
|
|
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
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@grundyjs/algiviz",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Deterministic algorithm steps for educational visualizations",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist",
|
|
8
|
+
"README.md",
|
|
9
|
+
"LICENSE",
|
|
10
|
+
"CHANGELOG.md"
|
|
11
|
+
],
|
|
12
|
+
"exports": {
|
|
13
|
+
"./core": {
|
|
14
|
+
"types": "./dist/core/index.d.ts",
|
|
15
|
+
"import": "./dist/core/index.js",
|
|
16
|
+
"default": "./dist/core/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./canvas": {
|
|
19
|
+
"types": "./dist/canvas/index.d.ts",
|
|
20
|
+
"import": "./dist/canvas/index.js",
|
|
21
|
+
"default": "./dist/canvas/index.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsc -p tsconfig.json",
|
|
26
|
+
"test": "npm run build && node --test test/*.test.mjs",
|
|
27
|
+
"prepack": "npm run build",
|
|
28
|
+
"prepublishOnly": "npm test"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"typescript": "6.0.3"
|
|
32
|
+
},
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"author": {
|
|
35
|
+
"name": "Grundy",
|
|
36
|
+
"url": "https://grundyjs.ru/"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://grundyjs.ru/algorithms/insertion-sort/",
|
|
39
|
+
"keywords": [
|
|
40
|
+
"algorithms",
|
|
41
|
+
"visualization",
|
|
42
|
+
"insertion-sort",
|
|
43
|
+
"canvas",
|
|
44
|
+
"typescript"
|
|
45
|
+
],
|
|
46
|
+
"sideEffects": false,
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public",
|
|
49
|
+
"registry": "https://registry.npmjs.org/"
|
|
50
|
+
},
|
|
51
|
+
"repository": {
|
|
52
|
+
"type": "git",
|
|
53
|
+
"url": "git+https://github.com/urffin/algiviz.git"
|
|
54
|
+
},
|
|
55
|
+
"bugs": {
|
|
56
|
+
"url": "https://github.com/urffin/algiviz/issues"
|
|
57
|
+
}
|
|
58
|
+
}
|