@grundyjs/algiviz 0.2.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 +14 -0
- package/README.md +76 -3
- package/dist/canvas/index.js +3 -2
- package/dist/core/bubble-sort.d.ts +5 -0
- package/dist/core/bubble-sort.js +33 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/insertion-sort.js +10 -35
- package/dist/core/sort-trace.d.ts +11 -0
- package/dist/core/sort-trace.js +34 -0
- package/dist/core/types.d.ts +11 -0
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
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
|
+
|
|
3
17
|
## 0.2.0 — 2026-10-09
|
|
4
18
|
|
|
5
19
|
- Move the held key horizontally along the baseline without lifting it above the array;
|
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
|
|
|
@@ -42,6 +42,38 @@ 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
|
+
|
|
45
77
|
## Lazy steps
|
|
46
78
|
|
|
47
79
|
Use `iterateInsertionSortSteps` to consume steps on demand without retaining the
|
|
@@ -140,7 +172,8 @@ Build, serve this project root using any static HTTP server, and open examples/i
|
|
|
140
172
|
The demo supports history (up to 64 items) and generator playback (up to 2,000).
|
|
141
173
|
Enter values or generate random, sorted or reversed arrays. Both modes support
|
|
142
174
|
pause, restart and speed changes; seeking is available only with full history.
|
|
143
|
-
|
|
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.
|
|
144
177
|
Dense charts hide bar labels. Hidden tabs pause playback, and delayed frames cap
|
|
145
178
|
catch-up work to keep controls responsive.
|
|
146
179
|
|
|
@@ -148,11 +181,51 @@ catch-up work to keep controls responsive.
|
|
|
148
181
|
## API at a glance
|
|
149
182
|
|
|
150
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()`.
|
|
151
187
|
- `createSortTimeline(steps, { stepDurationMs, finalHoldMs })` returns `durationMs` and `sample(timeMs)`. Frames expose `previous`, `current`, `progress`, `stepIndex` and `event`.
|
|
152
188
|
- `createSortRenderer({ theme: "dark" | "light" }).render(ctx, frame)` draws into a browser 2D canvas context. Drawing and animation scheduling remain separate.
|
|
153
189
|
|
|
154
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.
|
|
155
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
|
+
|
|
156
229
|
## Release
|
|
157
230
|
|
|
158
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.
|
|
@@ -162,7 +235,7 @@ Run `npm test`, `npm pack --dry-run` and test the resulting tarball in a separat
|
|
|
162
235
|
The `.github/workflows/npm-publish.yml` workflow publishes to npm when a stable
|
|
163
236
|
GitHub Release is published (`release: published`). Drafts, prereleases and tag
|
|
164
237
|
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.
|
|
238
|
+
the exact version in `package.json` and `package-lock.json` (for example `v0.3.0`).
|
|
166
239
|
The workflow uses Node.js 24 and npm 11. The publish lifecycle runs the tests and
|
|
167
240
|
build before publishing with provenance, using npm trusted publishing (OIDC).
|
|
168
241
|
|
package/dist/canvas/index.js
CHANGED
|
@@ -50,10 +50,11 @@ export function createSortRenderer(options) {
|
|
|
50
50
|
const old = before.get(id) ?? next;
|
|
51
51
|
const x = margin + ((old.index + (next.index - old.index) * p) + 0.5) * cell;
|
|
52
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 :
|
|
53
|
+
const active = event.type === "compare" || event.type === "swap" ? id === event.leftId || id === event.rightId :
|
|
54
54
|
"itemId" in event && event.itemId === id;
|
|
55
55
|
ctx.fillStyle = next.held ? colors.held : active ? colors.active :
|
|
56
|
-
next.index < frame.current.sortedPrefixLength
|
|
56
|
+
next.index < frame.current.sortedPrefixLength ||
|
|
57
|
+
next.index >= count - (frame.current.sortedSuffixLength ?? 0) ? colors.sorted : colors.bar;
|
|
57
58
|
ctx.fillRect(x - barWidth / 2, baseline - size, barWidth, size);
|
|
58
59
|
if (cell < 24)
|
|
59
60
|
continue;
|
|
@@ -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
|
+
}
|
package/dist/core/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { insertionSortSteps, iterateInsertionSortSteps } from "./insertion-sort.js";
|
|
2
|
+
export { bubbleSortSteps, iterateBubbleSortSteps } from "./bubble-sort.js";
|
|
2
3
|
export type { Item, SortEvent, SortSnapshot, SortStep } from "./types.js";
|
|
3
4
|
export { createSortTimeline } from "./timeline.js";
|
|
4
5
|
export type { SortFrame, SortTimeline } from "./timeline.js";
|
package/dist/core/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
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
4
|
return Object.freeze([...iterateInsertionSortSteps(values)]);
|
|
@@ -7,59 +8,33 @@ export function insertionSortSteps(values) {
|
|
|
7
8
|
* Input is copied and validated on the first next(), not when creating the iterator.
|
|
8
9
|
*/
|
|
9
10
|
export function* iterateInsertionSortSteps(values) {
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
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
|
-
};
|
|
11
|
+
const { state, emit } = createSortTrace(values);
|
|
12
|
+
const { slots } = state;
|
|
13
|
+
state.sortedPrefixLength = Math.min(1, slots.length);
|
|
36
14
|
yield emit({ type: "start" });
|
|
37
15
|
for (let i = 1; i < slots.length; i++) {
|
|
38
16
|
const key = slots[i];
|
|
39
|
-
held = key;
|
|
17
|
+
state.held = key;
|
|
40
18
|
slots[i] = null;
|
|
41
|
-
sortedPrefixLength = i;
|
|
19
|
+
state.sortedPrefixLength = i;
|
|
42
20
|
yield emit({ type: "select", itemId: key.id, from: i });
|
|
43
21
|
let hole = i;
|
|
44
22
|
while (hole > 0) {
|
|
45
23
|
const left = slots[hole - 1];
|
|
46
|
-
comparisons++;
|
|
47
24
|
yield emit({ type: "compare", leftId: left.id, rightId: key.id });
|
|
48
25
|
if (left.value <= key.value)
|
|
49
26
|
break;
|
|
50
27
|
slots[hole] = left;
|
|
51
28
|
slots[hole - 1] = null;
|
|
52
|
-
|
|
53
|
-
sortedPrefixLength = hole - 1;
|
|
29
|
+
state.sortedPrefixLength = hole - 1;
|
|
54
30
|
yield emit({ type: "shift", itemId: left.id, from: hole - 1, to: hole });
|
|
55
31
|
hole--;
|
|
56
32
|
}
|
|
57
33
|
slots[hole] = key;
|
|
58
|
-
held = null;
|
|
59
|
-
|
|
60
|
-
sortedPrefixLength = i + 1;
|
|
34
|
+
state.held = null;
|
|
35
|
+
state.sortedPrefixLength = i + 1;
|
|
61
36
|
yield emit({ type: "insert", itemId: key.id, to: hole });
|
|
62
37
|
}
|
|
63
|
-
sortedPrefixLength = slots.length;
|
|
38
|
+
state.sortedPrefixLength = slots.length;
|
|
64
39
|
yield emit({ type: "done" });
|
|
65
40
|
}
|
|
@@ -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
|
+
}
|
package/dist/core/types.d.ts
CHANGED
|
@@ -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.
|
|
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/
|
|
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
|
],
|