@turing-machine-js/visuals 7.0.0-alpha.6.1 → 7.0.0-alpha.7.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
@@ -4,6 +4,23 @@ All notable changes to this package will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [7.0.0-alpha.7.1] - 2026-05-30
8
+
9
+ ### Added
10
+
11
+ - **`tapeViewport(snapshot, width, blank): { cells, headIndex }`** — fixed-width window of tape cells centered on the head. The engine's `Tape` class exposes a `viewport` getter for the live tape; this is the equivalent for `TapeSnapshot` (the wire-data shape carried in `Frame.tape`). Cells outside the snapshot's `symbols` array are padded with `blank`, so the result always has exactly `width` entries. `headIndex` is deterministic at `Math.floor(width / 2)` and is exposed for caller convenience (and to leave room for future non-centered policies without a signature break). Throws `RangeError` on non-positive or non-integer width.
12
+
13
+ - **`SnippetPlayer`** — pure-state playback driver for a `Snippet`. `new SnippetPlayer(snippet)` returns an instance exposing `currentFrame` / `frameIndex` / `done` getters plus `forward()` / `back()` / `reset()` / `goTo(idx)`. Mirrors the engine's `DebugSession` shape (a stateful playback driver for live runs); this is the analogous driver for prerecorded runs. Stateless w.r.t. wall-clock — consumers wire their own ticking (`setInterval`, `requestAnimationFrame`, `IntersectionObserver`); renderer-agnostic — consumers read `currentFrame` and apply it however they like (typically `applyHighlight(snippet.graph, frame.highlight, ops)` for the state graph plus app-specific tape rendering). Two players over the same `Snippet` are independent (frame storage is shared and read-only). `forward()` / `back()` return a `boolean` (true if moved, false at end/start — no-op in that case); `goTo(idx)` throws `RangeError` on out-of-bounds.
14
+
15
+ ### Compatibility
16
+
17
+ - Engine + builder + library-binary-numbers + library-binary-numbers-bare stay at `7.0.0-alpha.7` — no changes there. Visuals-only follow-up patch, mirroring the alpha.6.1 precedent for additive consumer-package enhancements.
18
+ - Peer dep `@turing-machine-js/machine: ^7.0.0-alpha.7` unchanged (semver-prerelease caret already accepts `alpha.7.1`).
19
+
20
+ ## [7.0.0-alpha.7] - 2026-05-30
21
+
22
+ Lockstep re-alignment with the engine 7.0.0-alpha.7 bump (engine [#213](https://github.com/mellonis/turing-machine-js/issues/213) `CallFrame` extraction + [#223](https://github.com/mellonis/turing-machine-js/issues/223) `toMermaid` framed-wrapper emit fix). No source or behavior changes in this package since alpha.6.1. Peer dep `@turing-machine-js/machine` widened `^7.0.0-alpha.6` → `^7.0.0-alpha.7`.
23
+
7
24
  ## [7.0.0-alpha.6.1] - 2026-05-30
8
25
 
9
26
  ### Added
package/README.md CHANGED
@@ -1,21 +1,309 @@
1
1
  # @turing-machine-js/visuals
2
2
 
3
- Pure highlight + graph-indexing logic for [`@turing-machine-js/machine`](../machine). No DOM, no Svelte, no Mermaid — consumers bring their own renderer and DOM applier.
3
+ Pure highlight + graph-indexing logic for [`@turing-machine-js/machine`](../machine) — plus a renderer-agnostic edge-label formatter and a `recordSnippet` artifact recorder for prerecorded playback (article embeds, landing-page panels, terminal tools). No DOM, no Svelte, no Mermaid — consumers bring their own renderer.
4
4
 
5
- ## Scope
5
+ ## When to reach for this
6
6
 
7
- Types and pure functions for:
8
- - Indexing an engine `Graph` for wrapper/bare lookup (`indexGraph`, `bareIdOf`, `highlightExpand`).
9
- - Applying highlight + indicator operations against a renderer-agnostic `HighlightOps` interface (`applyHighlight`, `applyIndicator`).
10
-
11
- See [`docs/graph-highlight-and-breakpoints.md`](./docs/graph-highlight-and-breakpoints.md) for the full set of rules these functions satisfy.
12
-
13
- ## Versioning
14
-
15
- Lockstep with `@turing-machine-js/machine`.
7
+ - You have an engine `Graph` (from `State.toGraph(initialState, tapeBlock)`) and want to render it with **runtime highlight cues** — `from → edge → to` triples that animate as the machine steps, breakpoint dots, frame-active subgraph marks, pulse-on-revisit. Implement a small `HighlightOps` object that wraps your renderer's DOM/canvas/ANSI primitives; `applyHighlight` decides *what* to highlight, your `HighlightOps` decides *how*.
8
+ - You want a logged step's notation to **line up byte-for-byte** with the rendered state graph's edge labels. `formatStepNotation` mirrors the engine's `toMermaid` edge-label vocabulary; same `[reads] → [writes]/[moves]`, same `'X'` / `B` / `*='X'` / `K='X'` / `E` shortcuts.
9
+ - You want to **record a machine run** as a self-contained playback artifact — for an article embed, a landing-page panel, a snapshot test, anything that needs to replay a run without booting an engine. `recordSnippet` produces a `Snippet` with one `Frame` per iter (tape state + per-tape `{ movement, read, write }` command + highlight + optional log line); a player walks frames forward AND backward without recomputing deltas.
16
10
 
17
11
  ## Install
18
12
 
19
13
  ```sh
20
14
  npm install @turing-machine-js/visuals @turing-machine-js/machine
21
15
  ```
16
+
17
+ Both prereleases on npm `next`:
18
+
19
+ ```sh
20
+ npm install @turing-machine-js/visuals@next @turing-machine-js/machine@next
21
+ ```
22
+
23
+ Peer-deps on `@turing-machine-js/machine@^7.0.0-alpha.6` — visuals follows engine v7 alphas with occasional visuals-only patches (`7.0.0-alpha.6.1` added the formatter primitives + token surface on top of the lockstep alpha.6 engine release).
24
+
25
+ ## Public API
26
+
27
+ ### Types
28
+
29
+ ```ts
30
+ // Highlight contract
31
+ type NodeKey = number | 'idle';
32
+ type HighlightClass = 'mg-highlight-from' | 'mg-highlight-to' | 'mg-highlight-strong';
33
+ interface HighlightOps {
34
+ addNodeClass(id: NodeKey, cls: HighlightClass): void;
35
+ highlightEdge(fromKey: string, toKey: string): void;
36
+ markFrameActive(frameId: number): void;
37
+ pulse(id: NodeKey): void;
38
+ scrollIntoView(id: NodeKey): void;
39
+ }
40
+ interface IndicatorOps {
41
+ setBreakpoint(id: NodeKey, on: boolean): void;
42
+ }
43
+ type GraphHighlight = {
44
+ fromId: number | 'idle';
45
+ toId: number | null;
46
+ strong: 'from' | 'to';
47
+ paused: boolean;
48
+ };
49
+ type GraphIndexes = { /* node→frame, frame→wrappers, frame→label, etc. */ };
50
+
51
+ // Recording artifact
52
+ type TapeSnapshot = { symbols: string[]; position: number };
53
+ type StepCommand = { movement: 'L' | 'R' | 'S'; symbol: string | null };
54
+ type Frame = {
55
+ step: number;
56
+ tape: TapeSnapshot[];
57
+ commands?: { movement: 'L' | 'R' | 'S'; read: string; write: string }[];
58
+ highlight: GraphHighlight | null;
59
+ log?: string;
60
+ };
61
+ type Snippet = {
62
+ version: 1;
63
+ name?: string;
64
+ graph: Graph;
65
+ alphabets: string[][];
66
+ frames: Frame[];
67
+ };
68
+
69
+ // Token surface (renderer-agnostic alternative to formatStepNotation strings)
70
+ type ReadToken =
71
+ | { kind: 'literal'; symbol: string }
72
+ | { kind: 'blank' }
73
+ | { kind: 'wildcard'; symbol: string };
74
+ type WriteToken =
75
+ | { kind: 'literal'; symbol: string }
76
+ | { kind: 'erase' }
77
+ | { kind: 'keep'; readContext?: { symbol: string; isBlank: boolean } };
78
+ type StepTokens = {
79
+ reads: readonly ReadToken[] | null; // null = manual-Apply path (no transition fired)
80
+ writes: readonly WriteToken[];
81
+ moves: readonly ('L' | 'R' | 'S')[];
82
+ };
83
+ ```
84
+
85
+ ### Functions
86
+
87
+ ```ts
88
+ indexGraph(graph): GraphIndexes
89
+ applyHighlight(highlight, graph, indexes, prev, ops): { nextPrev }
90
+ applyIndicator(breakpoints, graph, ops): void
91
+ bareIdOf(id, graph): number
92
+ highlightExpand(id, graph): number[]
93
+ equivalentIds(id, graph): number[]
94
+ recordingOps(): { highlight: HighlightOps; indicator: IndicatorOps; record: RecordedOp[] }
95
+
96
+ // Formatters
97
+ formatStepNotation(reads, commands, blanks, matchKinds?): string
98
+ tokenizeStep(reads, commands, blanks, matchKinds?): StepTokens
99
+ formatTape(tape): string
100
+ formatCommand(tapeCommand): string // alpha.6 single-command formatter; kept for back-compat
101
+ formatStep(machineState): string // alpha.6 MachineState-based formatter; kept for back-compat
102
+
103
+ // Recording
104
+ recordSnippet({ machine, initialState, graph, alphabets, name?, maxSteps?, log? }): Snippet
105
+
106
+ // Playback
107
+ new SnippetPlayer(snippet)
108
+ tapeViewport(snapshot, width, blank): { cells, headIndex }
109
+ ```
110
+
111
+ The 16-rule contract `applyHighlight` satisfies is documented at [`docs/graph-highlight-and-breakpoints.md`](./docs/graph-highlight-and-breakpoints.md).
112
+
113
+ ## Example: applying highlight in a DOM renderer
114
+
115
+ ```ts
116
+ import {
117
+ applyHighlight,
118
+ indexGraph,
119
+ type HighlightOps,
120
+ type GraphHighlight,
121
+ } from '@turing-machine-js/visuals';
122
+ import { State } from '@turing-machine-js/machine';
123
+
124
+ // 1. Build your machine + Graph elsewhere (typical Svelte / React / vanilla code).
125
+ const graph = State.toGraph(initialState, tapeBlock);
126
+ const indexes = indexGraph(graph);
127
+
128
+ // 2. Tiny DOM applier (illustrative — your renderer probably has a richer one).
129
+ function domOps(svgRoot: SVGSVGElement): HighlightOps {
130
+ const node = (id: number | 'idle') =>
131
+ svgRoot.querySelector(`g.node[data-id="${id === 'idle' ? 'idle' : `s${id}`}"]`);
132
+ return {
133
+ addNodeClass: (id, cls) => node(id)?.classList.add(cls),
134
+ highlightEdge: (from, to) =>
135
+ svgRoot
136
+ .querySelector(`path[data-id^="L_${from}_${to}_"]`)
137
+ ?.classList.add('mg-highlight-edge'),
138
+ markFrameActive: (frameId) =>
139
+ svgRoot.querySelector(`g.cluster[data-id="w_${frameId}"]`)?.classList.add('mg-frame-active'),
140
+ pulse: (id) => node(id)?.classList.add('mg-pulse'),
141
+ scrollIntoView: (id) => node(id)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }),
142
+ };
143
+ }
144
+
145
+ // 3. On each engine pause / step: wipe previous highlight from the DOM, then apply the new one.
146
+ // HighlightOps is purely additive — the consumer is responsible for clearing
147
+ // `mg-highlight-*` classes before calling applyHighlight.
148
+ let prev = null;
149
+ function onMachineStep(highlight: GraphHighlight | null, svgRoot: SVGSVGElement) {
150
+ // Wipe.
151
+ for (const el of svgRoot.querySelectorAll(
152
+ '.mg-highlight-from, .mg-highlight-to, .mg-highlight-strong, .mg-highlight-edge, .mg-frame-active',
153
+ )) {
154
+ el.classList.remove(
155
+ 'mg-highlight-from',
156
+ 'mg-highlight-to',
157
+ 'mg-highlight-strong',
158
+ 'mg-highlight-edge',
159
+ 'mg-frame-active',
160
+ );
161
+ }
162
+ if (!highlight) return;
163
+ const result = applyHighlight(highlight, graph, indexes, prev, domOps(svgRoot));
164
+ prev = result.nextPrev;
165
+ }
166
+ ```
167
+
168
+ ## Example: rendering a step's edge-label notation
169
+
170
+ ```ts
171
+ import { formatStepNotation, type StepCommand } from '@turing-machine-js/visuals';
172
+
173
+ const commands: StepCommand[] = [
174
+ { movement: 'R', symbol: 'b' }, // write 'b', move right
175
+ { movement: 'L', symbol: null }, // keep current symbol, move left
176
+ ];
177
+ const reads = ['a', 'x'];
178
+ const blanks = [' ', ' '];
179
+ const matchKinds = ['literal', 'wildcard'] as const;
180
+
181
+ formatStepNotation(reads, commands, blanks, matchKinds);
182
+ // => "['a',*='x'] → ['b',K='x']/[R,L]"
183
+ ```
184
+
185
+ For manual-Apply rendering (no transition fired), pass `reads: null`:
186
+
187
+ ```ts
188
+ formatStepNotation(null, [{ movement: 'R', symbol: 'b' }], [' '], null);
189
+ // => "['b']/[R]"
190
+ ```
191
+
192
+ ## Example: tokenize for custom (non-string) rendering
193
+
194
+ ```ts
195
+ import { tokenizeStep } from '@turing-machine-js/visuals';
196
+
197
+ const tokens = tokenizeStep(['a'], [{ movement: 'R', symbol: 'b' }], [' '], ['literal']);
198
+ // tokens.reads: [{ kind: 'literal', symbol: 'a' }]
199
+ // tokens.writes: [{ kind: 'literal', symbol: 'b' }]
200
+ // tokens.moves: ['R']
201
+
202
+ // Now render however you want — HTML spans with CSS classes, ANSI escapes, JSON, etc.
203
+ function readToHtml(t: typeof tokens.reads[0]) {
204
+ if (t.kind === 'wildcard') return `<span class="cell-wildcard">${t.symbol}</span>`;
205
+ if (t.kind === 'blank') return `<span class="cell-blank">␣</span>`;
206
+ return `<span class="cell-literal">${t.symbol}</span>`;
207
+ }
208
+ ```
209
+
210
+ ## Example: recording a snippet
211
+
212
+ ```ts
213
+ import { recordSnippet, formatStep } from '@turing-machine-js/visuals';
214
+ import {
215
+ Alphabet, Tape, TapeBlock, TuringMachine, State, haltState, movements,
216
+ } from '@turing-machine-js/machine';
217
+
218
+ const alphabet = new Alphabet([' ', 'a', 'b']);
219
+ const tape = new Tape({ alphabet, symbols: ['a', 'a', 'a'] });
220
+ const tapeBlock = TapeBlock.fromTapes([tape]);
221
+ const machine = new TuringMachine({ tapeBlock });
222
+ const initialState = new State({
223
+ [tapeBlock.symbol(['a'])]: { command: [{ symbol: 'b', movement: movements.right }] },
224
+ [tapeBlock.symbol([' '])]: { nextState: haltState },
225
+ });
226
+
227
+ const snippet = recordSnippet({
228
+ machine,
229
+ initialState,
230
+ graph: State.toGraph(initialState, tapeBlock),
231
+ alphabets: [[' ', 'a', 'b']],
232
+ name: 'replace a with b',
233
+ log: (m) => formatStep(m),
234
+ });
235
+
236
+ // snippet.frames[0] — initial state (no commands, highlight null)
237
+ // snippet.frames[N] — post-iter snapshot with per-tape commands { movement, read, write }
238
+ // and a graph highlight ready to feed applyHighlight()
239
+ // snippet.graph — the same Graph the player should render
240
+ // snippet.alphabets — per-tape alphabet symbols (single-tape: length 1)
241
+ ```
242
+
243
+ `Frame.commands` carries both `read` and `write` per tape so a player can step forward (write `write`, move per `movement`, flash if `write !== read`) AND backward (move opposite of `movement`, restore `read`) without diffing neighbouring frames.
244
+
245
+ ## Example: playing a snippet
246
+
247
+ ```ts
248
+ import {
249
+ applyHighlight, indexGraph, SnippetPlayer,
250
+ type Frame, type HighlightOps,
251
+ } from '@turing-machine-js/visuals';
252
+
253
+ // Build a HighlightOps over your SVG / renderer — see the
254
+ // "Example: applying highlight in a DOM renderer" above for the
255
+ // concrete `domOps(svgRoot)` factory.
256
+ declare const ops: HighlightOps;
257
+
258
+ // Render a frame's tape state into your UI — app-specific (Svelte,
259
+ // React, vanilla, ANSI, …). For a fixed-width centered window padded
260
+ // with the alphabet's blank, see `tapeViewport(snap, width, blank)` below.
261
+ declare function renderTape(tape: Frame['tape']): void;
262
+
263
+ // Buttons your UI exposes — strictly illustrative.
264
+ declare const prevBtn: HTMLButtonElement;
265
+ declare const nextBtn: HTMLButtonElement;
266
+ declare const replayBtn: HTMLButtonElement;
267
+
268
+ const player = new SnippetPlayer(snippet);
269
+ const indexes = indexGraph(snippet.graph);
270
+ let prev: Parameters<typeof applyHighlight>[3] = null;
271
+
272
+ function applyFrame(): void {
273
+ const frame = player.currentFrame;
274
+ // (consumer is responsible for wiping previous highlight classes from
275
+ // the DOM before each applyHighlight call — see the highlight example above.)
276
+ if (frame.highlight) {
277
+ prev = applyHighlight(frame.highlight, snippet.graph, indexes, prev, ops).nextPrev;
278
+ }
279
+ renderTape(frame.tape);
280
+ }
281
+
282
+ // One AbortController scopes both the auto-play timer and the click
283
+ // listeners — call controller.abort() in your component teardown
284
+ // (Svelte onDestroy / React useEffect cleanup / etc.).
285
+ const controller = new AbortController();
286
+ const { signal } = controller;
287
+
288
+ // Auto-play forward at a fixed cadence:
289
+ const id = setInterval(() => {
290
+ if (!player.forward()) { clearInterval(id); return; }
291
+ applyFrame();
292
+ }, 800);
293
+ signal.addEventListener('abort', () => clearInterval(id), { once: true });
294
+
295
+ // Bi-directional scrub:
296
+ prevBtn.addEventListener('click', () => { if (player.back()) applyFrame(); }, { signal });
297
+ nextBtn.addEventListener('click', () => { if (player.forward()) applyFrame(); }, { signal });
298
+ replayBtn.addEventListener('click', () => { player.reset(); applyFrame(); }, { signal });
299
+ ```
300
+
301
+ `SnippetPlayer` is pure state — no timers, no events. Consumers wire `setInterval` / `requestAnimationFrame` / `IntersectionObserver` and call `forward()` / `back()` / `goTo(idx)`. Two players over the same `Snippet` are independent (frame storage is shared and read-only). Mirrors the engine's `DebugSession` shape — stateful playback driver for live runs vs prerecorded runs.
302
+
303
+ ## Versioning
304
+
305
+ Engine + builder + libraries bump in **lockstep** via `lerna version`. **Visuals breaks lockstep for additive consumer-package patches** (e.g., `7.0.0-alpha.6.1` added formatter primitives + the token surface without bumping the engine — there were no engine changes to justify ghost releases). Semver-prerelease caret semantics make this safe: peer `^7.0.0-alpha.6` accepts `7.0.0-alpha.6.1+` without consumers having to widen anything. When the next engine alpha needs to ship, all 5 packages bump back to lockstep.
306
+
307
+ ## License
308
+
309
+ [GPL-3.0-or-later](../../LICENSE)
package/dist/index.cjs CHANGED
@@ -599,6 +599,130 @@ function recordSnippet(opts) {
599
599
  };
600
600
  }
601
601
 
602
+ var __classPrivateFieldSet = (undefined && undefined.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
603
+ if (kind === "m") throw new TypeError("Private method is not writable");
604
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
605
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
606
+ return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
607
+ };
608
+ var __classPrivateFieldGet = (undefined && undefined.__classPrivateFieldGet) || function (receiver, state, kind, f) {
609
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
610
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
611
+ return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
612
+ };
613
+ var _SnippetPlayer_snippet, _SnippetPlayer_lastIndex, _SnippetPlayer_index;
614
+ /**
615
+ * Pure-state playback driver for a `Snippet` produced by `recordSnippet`.
616
+ *
617
+ * Holds the current frame index and exposes `forward` / `back` / `reset` /
618
+ * `goTo`. Stateless w.r.t. wall-clock — consumers wire their own ticking
619
+ * (`setInterval`, `requestAnimationFrame`, `IntersectionObserver`, etc.).
620
+ * Renderer-agnostic — consumers read `currentFrame` and apply it however
621
+ * they like (e.g. `applyHighlight(snippet.graph, frame.highlight, ops)`
622
+ * for state-graph highlight, plus app-specific tape rendering).
623
+ *
624
+ * Two players over the same `Snippet` are independent — frame storage is
625
+ * shared and read-only.
626
+ *
627
+ * Mirrors the engine's `DebugSession` shape (a stateful playback driver
628
+ * for live runs); this is the analogous driver for prerecorded runs.
629
+ */
630
+ class SnippetPlayer {
631
+ constructor(snippet) {
632
+ _SnippetPlayer_snippet.set(this, void 0);
633
+ _SnippetPlayer_lastIndex.set(this, void 0);
634
+ _SnippetPlayer_index.set(this, 0);
635
+ if (snippet.frames.length === 0) {
636
+ throw new Error('SnippetPlayer: snippet has no frames');
637
+ }
638
+ __classPrivateFieldSet(this, _SnippetPlayer_snippet, snippet, "f");
639
+ __classPrivateFieldSet(this, _SnippetPlayer_lastIndex, snippet.frames.length - 1, "f");
640
+ }
641
+ /** The frame at the current index. Live getter — re-reads on every access. */
642
+ get currentFrame() {
643
+ return __classPrivateFieldGet(this, _SnippetPlayer_snippet, "f").frames[__classPrivateFieldGet(this, _SnippetPlayer_index, "f")];
644
+ }
645
+ /** Current frame index (0 = initial state, `snippet.frames.length - 1` = final). */
646
+ get frameIndex() {
647
+ return __classPrivateFieldGet(this, _SnippetPlayer_index, "f");
648
+ }
649
+ /** True when `frameIndex === snippet.frames.length - 1`. */
650
+ get done() {
651
+ return __classPrivateFieldGet(this, _SnippetPlayer_index, "f") === __classPrivateFieldGet(this, _SnippetPlayer_lastIndex, "f");
652
+ }
653
+ /**
654
+ * Advance one frame. Returns `true` if advanced, `false` if already at the
655
+ * last frame (no-op in that case).
656
+ */
657
+ forward() {
658
+ if (__classPrivateFieldGet(this, _SnippetPlayer_index, "f") >= __classPrivateFieldGet(this, _SnippetPlayer_lastIndex, "f"))
659
+ return false;
660
+ __classPrivateFieldSet(this, _SnippetPlayer_index, __classPrivateFieldGet(this, _SnippetPlayer_index, "f") + 1, "f");
661
+ return true;
662
+ }
663
+ /**
664
+ * Retreat one frame. Returns `true` if retreated, `false` if already at
665
+ * the first frame (no-op in that case).
666
+ */
667
+ back() {
668
+ if (__classPrivateFieldGet(this, _SnippetPlayer_index, "f") <= 0)
669
+ return false;
670
+ __classPrivateFieldSet(this, _SnippetPlayer_index, __classPrivateFieldGet(this, _SnippetPlayer_index, "f") - 1, "f");
671
+ return true;
672
+ }
673
+ /** Jump to frame 0. */
674
+ reset() {
675
+ __classPrivateFieldSet(this, _SnippetPlayer_index, 0, "f");
676
+ }
677
+ /**
678
+ * Jump to a specific frame index. Throws `RangeError` if out of bounds
679
+ * (negative, beyond the last frame, or not an integer).
680
+ */
681
+ goTo(frameIndex) {
682
+ if (!Number.isInteger(frameIndex) || frameIndex < 0 || frameIndex > __classPrivateFieldGet(this, _SnippetPlayer_lastIndex, "f")) {
683
+ throw new RangeError(`SnippetPlayer.goTo: frame index ${frameIndex} out of bounds [0, ${__classPrivateFieldGet(this, _SnippetPlayer_lastIndex, "f")}]`);
684
+ }
685
+ __classPrivateFieldSet(this, _SnippetPlayer_index, frameIndex, "f");
686
+ }
687
+ }
688
+ _SnippetPlayer_snippet = new WeakMap(), _SnippetPlayer_lastIndex = new WeakMap(), _SnippetPlayer_index = new WeakMap();
689
+
690
+ /**
691
+ * Compute a fixed-width window of tape cells centered on the head.
692
+ *
693
+ * The engine's `Tape` class exposes a `viewport` getter that does this for
694
+ * the live tape; `tapeViewport` is the equivalent for the wire-data
695
+ * `TapeSnapshot` carried in `Frame.tape`. Cells outside the snapshot's
696
+ * `symbols` array are padded with `blank`, so the result always has
697
+ * exactly `width` entries.
698
+ *
699
+ * The returned `headIndex` is the head's index within `cells` —
700
+ * deterministic at `Math.floor(width / 2)`, but exposed for callers that
701
+ * want to avoid recomputing it (and to leave room for future non-centered
702
+ * policies without a signature break).
703
+ *
704
+ * Pass the tape's blank symbol from `Snippet.alphabets[i]` (by convention
705
+ * the first entry per tape in the visuals/engine pipeline — verify against
706
+ * how the snippet was recorded).
707
+ */
708
+ function tapeViewport(snapshot, width, blank) {
709
+ if (!Number.isInteger(width) || width <= 0) {
710
+ throw new RangeError(`tapeViewport: width must be a positive integer (got ${width})`);
711
+ }
712
+ const half = Math.floor(width / 2);
713
+ const startTapeIdx = snapshot.position - half;
714
+ const cells = new Array(width);
715
+ for (let i = 0; i < width; i += 1) {
716
+ const tapeIdx = startTapeIdx + i;
717
+ cells[i] =
718
+ tapeIdx >= 0 && tapeIdx < snapshot.symbols.length
719
+ ? snapshot.symbols[tapeIdx]
720
+ : blank;
721
+ }
722
+ return { cells, headIndex: half };
723
+ }
724
+
725
+ exports.SnippetPlayer = SnippetPlayer;
602
726
  exports.applyHighlight = applyHighlight;
603
727
  exports.applyIndicator = applyIndicator;
604
728
  exports.bareIdOf = bareIdOf;
@@ -611,4 +735,5 @@ exports.highlightExpand = highlightExpand;
611
735
  exports.indexGraph = indexGraph;
612
736
  exports.recordSnippet = recordSnippet;
613
737
  exports.recordingOps = recordingOps;
738
+ exports.tapeViewport = tapeViewport;
614
739
  exports.tokenizeStep = tokenizeStep;
package/dist/index.d.ts CHANGED
@@ -7,3 +7,5 @@ export type { GraphHighlight, TapeSnapshot, Frame, Snippet } from './types';
7
7
  export { applyHighlight, applyIndicator } from './applyHighlight';
8
8
  export { formatCommand, formatStep, formatStepNotation, formatTape, tokenizeStep, type StepCommand, type ReadToken, type WriteToken, type StepTokens, } from './format';
9
9
  export { recordSnippet, type RecordSnippetOptions } from './recordSnippet';
10
+ export { SnippetPlayer } from './snippetPlayer';
11
+ export { tapeViewport } from './tapeViewport';
package/dist/index.mjs CHANGED
@@ -597,4 +597,127 @@ function recordSnippet(opts) {
597
597
  };
598
598
  }
599
599
 
600
- export { applyHighlight, applyIndicator, bareIdOf, equivalentIds, formatCommand, formatStep, formatStepNotation, formatTape, highlightExpand, indexGraph, recordSnippet, recordingOps, tokenizeStep };
600
+ var __classPrivateFieldSet = (undefined && undefined.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
601
+ if (kind === "m") throw new TypeError("Private method is not writable");
602
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
603
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
604
+ return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
605
+ };
606
+ var __classPrivateFieldGet = (undefined && undefined.__classPrivateFieldGet) || function (receiver, state, kind, f) {
607
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
608
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
609
+ return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
610
+ };
611
+ var _SnippetPlayer_snippet, _SnippetPlayer_lastIndex, _SnippetPlayer_index;
612
+ /**
613
+ * Pure-state playback driver for a `Snippet` produced by `recordSnippet`.
614
+ *
615
+ * Holds the current frame index and exposes `forward` / `back` / `reset` /
616
+ * `goTo`. Stateless w.r.t. wall-clock — consumers wire their own ticking
617
+ * (`setInterval`, `requestAnimationFrame`, `IntersectionObserver`, etc.).
618
+ * Renderer-agnostic — consumers read `currentFrame` and apply it however
619
+ * they like (e.g. `applyHighlight(snippet.graph, frame.highlight, ops)`
620
+ * for state-graph highlight, plus app-specific tape rendering).
621
+ *
622
+ * Two players over the same `Snippet` are independent — frame storage is
623
+ * shared and read-only.
624
+ *
625
+ * Mirrors the engine's `DebugSession` shape (a stateful playback driver
626
+ * for live runs); this is the analogous driver for prerecorded runs.
627
+ */
628
+ class SnippetPlayer {
629
+ constructor(snippet) {
630
+ _SnippetPlayer_snippet.set(this, void 0);
631
+ _SnippetPlayer_lastIndex.set(this, void 0);
632
+ _SnippetPlayer_index.set(this, 0);
633
+ if (snippet.frames.length === 0) {
634
+ throw new Error('SnippetPlayer: snippet has no frames');
635
+ }
636
+ __classPrivateFieldSet(this, _SnippetPlayer_snippet, snippet, "f");
637
+ __classPrivateFieldSet(this, _SnippetPlayer_lastIndex, snippet.frames.length - 1, "f");
638
+ }
639
+ /** The frame at the current index. Live getter — re-reads on every access. */
640
+ get currentFrame() {
641
+ return __classPrivateFieldGet(this, _SnippetPlayer_snippet, "f").frames[__classPrivateFieldGet(this, _SnippetPlayer_index, "f")];
642
+ }
643
+ /** Current frame index (0 = initial state, `snippet.frames.length - 1` = final). */
644
+ get frameIndex() {
645
+ return __classPrivateFieldGet(this, _SnippetPlayer_index, "f");
646
+ }
647
+ /** True when `frameIndex === snippet.frames.length - 1`. */
648
+ get done() {
649
+ return __classPrivateFieldGet(this, _SnippetPlayer_index, "f") === __classPrivateFieldGet(this, _SnippetPlayer_lastIndex, "f");
650
+ }
651
+ /**
652
+ * Advance one frame. Returns `true` if advanced, `false` if already at the
653
+ * last frame (no-op in that case).
654
+ */
655
+ forward() {
656
+ if (__classPrivateFieldGet(this, _SnippetPlayer_index, "f") >= __classPrivateFieldGet(this, _SnippetPlayer_lastIndex, "f"))
657
+ return false;
658
+ __classPrivateFieldSet(this, _SnippetPlayer_index, __classPrivateFieldGet(this, _SnippetPlayer_index, "f") + 1, "f");
659
+ return true;
660
+ }
661
+ /**
662
+ * Retreat one frame. Returns `true` if retreated, `false` if already at
663
+ * the first frame (no-op in that case).
664
+ */
665
+ back() {
666
+ if (__classPrivateFieldGet(this, _SnippetPlayer_index, "f") <= 0)
667
+ return false;
668
+ __classPrivateFieldSet(this, _SnippetPlayer_index, __classPrivateFieldGet(this, _SnippetPlayer_index, "f") - 1, "f");
669
+ return true;
670
+ }
671
+ /** Jump to frame 0. */
672
+ reset() {
673
+ __classPrivateFieldSet(this, _SnippetPlayer_index, 0, "f");
674
+ }
675
+ /**
676
+ * Jump to a specific frame index. Throws `RangeError` if out of bounds
677
+ * (negative, beyond the last frame, or not an integer).
678
+ */
679
+ goTo(frameIndex) {
680
+ if (!Number.isInteger(frameIndex) || frameIndex < 0 || frameIndex > __classPrivateFieldGet(this, _SnippetPlayer_lastIndex, "f")) {
681
+ throw new RangeError(`SnippetPlayer.goTo: frame index ${frameIndex} out of bounds [0, ${__classPrivateFieldGet(this, _SnippetPlayer_lastIndex, "f")}]`);
682
+ }
683
+ __classPrivateFieldSet(this, _SnippetPlayer_index, frameIndex, "f");
684
+ }
685
+ }
686
+ _SnippetPlayer_snippet = new WeakMap(), _SnippetPlayer_lastIndex = new WeakMap(), _SnippetPlayer_index = new WeakMap();
687
+
688
+ /**
689
+ * Compute a fixed-width window of tape cells centered on the head.
690
+ *
691
+ * The engine's `Tape` class exposes a `viewport` getter that does this for
692
+ * the live tape; `tapeViewport` is the equivalent for the wire-data
693
+ * `TapeSnapshot` carried in `Frame.tape`. Cells outside the snapshot's
694
+ * `symbols` array are padded with `blank`, so the result always has
695
+ * exactly `width` entries.
696
+ *
697
+ * The returned `headIndex` is the head's index within `cells` —
698
+ * deterministic at `Math.floor(width / 2)`, but exposed for callers that
699
+ * want to avoid recomputing it (and to leave room for future non-centered
700
+ * policies without a signature break).
701
+ *
702
+ * Pass the tape's blank symbol from `Snippet.alphabets[i]` (by convention
703
+ * the first entry per tape in the visuals/engine pipeline — verify against
704
+ * how the snippet was recorded).
705
+ */
706
+ function tapeViewport(snapshot, width, blank) {
707
+ if (!Number.isInteger(width) || width <= 0) {
708
+ throw new RangeError(`tapeViewport: width must be a positive integer (got ${width})`);
709
+ }
710
+ const half = Math.floor(width / 2);
711
+ const startTapeIdx = snapshot.position - half;
712
+ const cells = new Array(width);
713
+ for (let i = 0; i < width; i += 1) {
714
+ const tapeIdx = startTapeIdx + i;
715
+ cells[i] =
716
+ tapeIdx >= 0 && tapeIdx < snapshot.symbols.length
717
+ ? snapshot.symbols[tapeIdx]
718
+ : blank;
719
+ }
720
+ return { cells, headIndex: half };
721
+ }
722
+
723
+ export { SnippetPlayer, applyHighlight, applyIndicator, bareIdOf, equivalentIds, formatCommand, formatStep, formatStepNotation, formatTape, highlightExpand, indexGraph, recordSnippet, recordingOps, tapeViewport, tokenizeStep };
@@ -0,0 +1,44 @@
1
+ import type { Frame, Snippet } from './types';
2
+ /**
3
+ * Pure-state playback driver for a `Snippet` produced by `recordSnippet`.
4
+ *
5
+ * Holds the current frame index and exposes `forward` / `back` / `reset` /
6
+ * `goTo`. Stateless w.r.t. wall-clock — consumers wire their own ticking
7
+ * (`setInterval`, `requestAnimationFrame`, `IntersectionObserver`, etc.).
8
+ * Renderer-agnostic — consumers read `currentFrame` and apply it however
9
+ * they like (e.g. `applyHighlight(snippet.graph, frame.highlight, ops)`
10
+ * for state-graph highlight, plus app-specific tape rendering).
11
+ *
12
+ * Two players over the same `Snippet` are independent — frame storage is
13
+ * shared and read-only.
14
+ *
15
+ * Mirrors the engine's `DebugSession` shape (a stateful playback driver
16
+ * for live runs); this is the analogous driver for prerecorded runs.
17
+ */
18
+ export declare class SnippetPlayer {
19
+ #private;
20
+ constructor(snippet: Snippet);
21
+ /** The frame at the current index. Live getter — re-reads on every access. */
22
+ get currentFrame(): Frame;
23
+ /** Current frame index (0 = initial state, `snippet.frames.length - 1` = final). */
24
+ get frameIndex(): number;
25
+ /** True when `frameIndex === snippet.frames.length - 1`. */
26
+ get done(): boolean;
27
+ /**
28
+ * Advance one frame. Returns `true` if advanced, `false` if already at the
29
+ * last frame (no-op in that case).
30
+ */
31
+ forward(): boolean;
32
+ /**
33
+ * Retreat one frame. Returns `true` if retreated, `false` if already at
34
+ * the first frame (no-op in that case).
35
+ */
36
+ back(): boolean;
37
+ /** Jump to frame 0. */
38
+ reset(): void;
39
+ /**
40
+ * Jump to a specific frame index. Throws `RangeError` if out of bounds
41
+ * (negative, beyond the last frame, or not an integer).
42
+ */
43
+ goTo(frameIndex: number): void;
44
+ }
@@ -0,0 +1,23 @@
1
+ import type { TapeSnapshot } from './types';
2
+ /**
3
+ * Compute a fixed-width window of tape cells centered on the head.
4
+ *
5
+ * The engine's `Tape` class exposes a `viewport` getter that does this for
6
+ * the live tape; `tapeViewport` is the equivalent for the wire-data
7
+ * `TapeSnapshot` carried in `Frame.tape`. Cells outside the snapshot's
8
+ * `symbols` array are padded with `blank`, so the result always has
9
+ * exactly `width` entries.
10
+ *
11
+ * The returned `headIndex` is the head's index within `cells` —
12
+ * deterministic at `Math.floor(width / 2)`, but exposed for callers that
13
+ * want to avoid recomputing it (and to leave room for future non-centered
14
+ * policies without a signature break).
15
+ *
16
+ * Pass the tape's blank symbol from `Snippet.alphabets[i]` (by convention
17
+ * the first entry per tape in the visuals/engine pipeline — verify against
18
+ * how the snippet was recorded).
19
+ */
20
+ export declare function tapeViewport(snapshot: TapeSnapshot, width: number, blank: string): {
21
+ cells: string[];
22
+ headIndex: number;
23
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turing-machine-js/visuals",
3
- "version": "7.0.0-alpha.6.1",
3
+ "version": "7.0.0-alpha.7.1",
4
4
  "description": "Pure highlight + graph-indexing logic for @turing-machine-js/machine — no DOM, no renderer.",
5
5
  "engines": {
6
6
  "npm": ">=7.0.0"
@@ -25,7 +25,7 @@
25
25
  "visualization"
26
26
  ],
27
27
  "peerDependencies": {
28
- "@turing-machine-js/machine": "^7.0.0-alpha.6"
28
+ "@turing-machine-js/machine": "^7.0.0-alpha.7"
29
29
  },
30
30
  "scripts": {
31
31
  "build": "tsc --build --verbose tsconfig.build.json && node ../../scripts/build-node-entries.mjs --package=@turing-machine-js/visuals",
@@ -42,5 +42,5 @@
42
42
  "default": "./dist/index.mjs"
43
43
  }
44
44
  },
45
- "gitHead": "4f5364293038aabea742235ea0cec2b135f0d6b7"
45
+ "gitHead": "2adf5ab059319427a9a449370f91b2ea8e926059"
46
46
  }