@0xbuff/throughline 0.0.0-stage → 0.0.2

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.
@@ -0,0 +1,810 @@
1
+ import { a as getAt, i as format, n as visibleScene, o as overlaps, r as effectiveView, s as parseTarget, t as describeFrame } from "../describe-ZC7v_r7z.js";
2
+ import { cn } from "cn";
3
+ import { useEffect, useId, useMemo, useReducer, useRef, useState } from "react";
4
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
5
+ // src/layout/edges.ts
6
+ function bypass(a, b, blockers, horizontal) {
7
+ if (horizontal) {
8
+ const y = Math.max(a.y + a.h, b.y + b.h, ...blockers.map((box) => box.y + box.h)) + 16;
9
+ const from = [a.x + a.w / 2, a.y + a.h];
10
+ const to = [b.x + b.w / 2, b.y + b.h];
11
+ return {
12
+ from,
13
+ to,
14
+ d: `M${from[0]} ${from[1]}V${y}H${to[0]}V${to[1]}`,
15
+ label: [(from[0] + to[0]) / 2, y],
16
+ angle: -Math.PI / 2
17
+ };
18
+ }
19
+ const x = Math.min(a.x, b.x, ...blockers.map((box) => box.x)) - 10;
20
+ const from = [a.x, a.y + a.h / 2];
21
+ const to = [b.x, b.y + b.h / 2];
22
+ return {
23
+ from,
24
+ to,
25
+ d: `M${from[0]} ${from[1]}H${x}V${to[1]}H${to[0]}`,
26
+ label: [x, (from[1] + to[1]) / 2],
27
+ angle: 0
28
+ };
29
+ }
30
+ /** Attach to the facing sides, preserving direction in horizontal and stacked scenes. */
31
+ function routeEdges(relations, nodes) {
32
+ const edges = {};
33
+ for (const relation of relations) {
34
+ const a = nodes[relation.from];
35
+ const b = nodes[relation.to];
36
+ if (!a || !b) continue;
37
+ const dx = b.x + b.w / 2 - a.x - a.w / 2;
38
+ const dy = b.y + b.h / 2 - a.y - a.h / 2;
39
+ const horizontal = Math.abs(dx) > Math.abs(dy);
40
+ const blockers = Object.values(nodes).filter((box) => box !== a && box !== b && (horizontal ? box.x > Math.min(a.x, b.x) && box.x < Math.max(a.x, b.x) && box.y < Math.max(a.y + a.h, b.y + b.h) && box.y + box.h > Math.min(a.y, b.y) : box.y > Math.min(a.y, b.y) && box.y < Math.max(a.y, b.y) && box.x < Math.max(a.x + a.w, b.x + b.w) && box.x + box.w > Math.min(a.x, b.x)));
41
+ if (blockers.length) {
42
+ edges[relation.id] = bypass(a, b, blockers, horizontal);
43
+ continue;
44
+ }
45
+ let from, to, c1, c2;
46
+ if (relation.from === relation.to) {
47
+ from = [a.x + a.w * .65, a.y + a.h];
48
+ to = [a.x + a.w * .35, a.y + a.h];
49
+ c1 = [from[0] + 12, from[1] + 24];
50
+ c2 = [to[0] - 12, to[1] + 24];
51
+ } else if (Math.abs(dx) > Math.abs(dy)) {
52
+ const direction = Math.sign(dx);
53
+ from = [direction > 0 ? a.x + a.w : a.x, a.y + a.h / 2];
54
+ to = [direction > 0 ? b.x : b.x + b.w, b.y + b.h / 2];
55
+ const bend = Math.max(14, Math.abs(to[0] - from[0]) / 2);
56
+ c1 = [from[0] + direction * bend, from[1]];
57
+ c2 = [to[0] - direction * bend, to[1]];
58
+ } else {
59
+ const direction = Math.sign(dy) || 1;
60
+ from = [a.x + a.w / 2, direction > 0 ? a.y + a.h : a.y];
61
+ to = [b.x + b.w / 2, direction > 0 ? b.y : b.y + b.h];
62
+ const bend = Math.max(14, Math.abs(to[1] - from[1]) / 2);
63
+ c1 = [from[0], from[1] + direction * bend];
64
+ c2 = [to[0], to[1] - direction * bend];
65
+ }
66
+ edges[relation.id] = {
67
+ d: `M${from[0]} ${from[1]}C${c1[0]} ${c1[1]} ${c2[0]} ${c2[1]} ${to[0]} ${to[1]}`,
68
+ from,
69
+ to,
70
+ label: [(from[0] + 3 * c1[0] + 3 * c2[0] + to[0]) / 8, (from[1] + 3 * c1[1] + 3 * c2[1] + to[1]) / 8],
71
+ angle: Math.atan2(to[1] - c2[1], to[0] - c2[0])
72
+ };
73
+ }
74
+ return edges;
75
+ }
76
+ // src/layout/ranks.ts
77
+ function assignRanks(entities, relations) {
78
+ const out = new Map(entities.map((entity) => [entity.id, []]));
79
+ const indegree = new Map(entities.map((entity) => [entity.id, 0]));
80
+ const connected = /* @__PURE__ */ new Set();
81
+ for (const relation of relations) {
82
+ out.get(relation.from)?.push(relation.to);
83
+ indegree.set(relation.to, indegree.get(relation.to) + 1);
84
+ connected.add(relation.from);
85
+ connected.add(relation.to);
86
+ }
87
+ const colour = /* @__PURE__ */ new Map();
88
+ const back = /* @__PURE__ */ new Set();
89
+ const visit = (id) => {
90
+ colour.set(id, 1);
91
+ for (const to of out.get(id)) {
92
+ const state = colour.get(to);
93
+ if (state === 1) back.add(`${id}>${to}`);
94
+ else if (!state) visit(to);
95
+ }
96
+ colour.set(id, 2);
97
+ };
98
+ const order = [...entities.filter((entity) => indegree.get(entity.id) === 0), ...entities];
99
+ for (const entity of order) if (!colour.has(entity.id)) visit(entity.id);
100
+ const preds = new Map(entities.map((entity) => [entity.id, []]));
101
+ for (const relation of relations) if (relation.from !== relation.to && !back.has(`${relation.from}>${relation.to}`)) preds.get(relation.to)?.push(relation.from);
102
+ const ranks = /* @__PURE__ */ new Map();
103
+ const rank = (id) => {
104
+ const known = ranks.get(id);
105
+ if (known !== void 0) return known;
106
+ ranks.set(id, 0);
107
+ const value = Math.max(-1, ...(preds.get(id) ?? []).map(rank)) + 1;
108
+ ranks.set(id, value);
109
+ return value;
110
+ };
111
+ for (const entity of entities) if (connected.has(entity.id)) rank(entity.id);
112
+ const perLane = /* @__PURE__ */ new Map();
113
+ for (const entity of entities) {
114
+ if (connected.has(entity.id)) continue;
115
+ const lane = entity.lane ?? "State";
116
+ const next = perLane.get(lane) ?? 0;
117
+ perLane.set(lane, next + 1);
118
+ ranks.set(entity.id, next);
119
+ }
120
+ return ranks;
121
+ }
122
+ // src/layout/value.ts
123
+ const PAD$1 = 10;
124
+ const TOP = 32;
125
+ const gap = 4;
126
+ const limit = (n, min, max) => Math.max(min, Math.min(max, n));
127
+ /** Keep the focused position in a bounded window without hiding truncation. */
128
+ function windowStart(length, count, focus) {
129
+ return limit(focus - count + 1, 0, Math.max(0, length - count));
130
+ }
131
+ function focusIndex(key, fallback = 0) {
132
+ return typeof key === "number" && Number.isFinite(key) ? key : fallback;
133
+ }
134
+ function grid(items, columns, cellW, note) {
135
+ const stride = items.some((item) => item.label !== void 0) ? 46 : 32;
136
+ return {
137
+ w: columns * (cellW + gap) - gap + 20,
138
+ h: TOP + Math.max(1, Math.ceil(items.length / columns)) * stride + (note ? 24 : 6),
139
+ note,
140
+ cells: items.map((item, i) => ({
141
+ ...item,
142
+ x: PAD$1 + i % columns * (cellW + gap),
143
+ y: TOP + Math.floor(i / columns) * stride,
144
+ w: cellW,
145
+ h: 28
146
+ }))
147
+ };
148
+ }
149
+ /** Shared by measurement and SVG rendering: the two cannot disagree on clipping. */
150
+ function presentValue(entity, maxWidth, focus = []) {
151
+ const { value } = entity;
152
+ const view = effectiveView(entity.view, value);
153
+ const width = Math.max(80, maxWidth);
154
+ let result;
155
+ if (view === "matrix") {
156
+ const rows = value;
157
+ const colCount = rows.reduce((maximum, row) => Math.max(maximum, row.length), 0);
158
+ const cols = Math.max(1, Math.min(6, colCount, Math.floor((width - 16) / 38)));
159
+ const firstRow = windowStart(rows.length, 6, focusIndex(focus[0]?.[0]));
160
+ const firstCol = windowStart(colCount, cols, focusIndex(focus[0]?.[1]));
161
+ const visibleRows = rows.slice(firstRow, firstRow + 6);
162
+ const items = visibleRows.flatMap((row, r) => Array.from({ length: cols }, (_, c) => ({
163
+ text: row[firstCol + c] === void 0 ? "·" : format(row[firstCol + c]),
164
+ path: [firstRow + r, firstCol + c]
165
+ })));
166
+ const note = rows.length > 6 || colCount > cols ? `Rows ${firstRow + 1}–${firstRow + visibleRows.length}/${rows.length} · cols ${firstCol + 1}–${firstCol + cols}/${colCount}` : void 0;
167
+ result = grid(items, cols, Math.min(56, (width - 20 - (cols - 1) * gap) / cols), note);
168
+ } else if (view === "array" || view === "stack" || view === "bits") {
169
+ const bits = view === "bits" ? value.toString(2).padStart(Math.max(8, Math.ceil(value.toString(2).length / 4) * 4), "0") : "";
170
+ const items = bits ? [...bits] : value;
171
+ const count = view === "stack" ? 6 : view === "bits" ? items.length : 12;
172
+ const focused = focusIndex(focus[0]?.[0], view === "stack" ? items.length - 1 : 0);
173
+ const start = windowStart(items.length, count, focused);
174
+ const shown = items.slice(start, start + count);
175
+ const cellW = view === "bits" ? 28 : Math.min(86, Math.max(34, ...shown.map((item) => format(item).length * 7.5 + 14)));
176
+ const cols = view === "stack" ? 1 : Math.max(1, Math.min(shown.length || 1, Math.floor((width - 16) / (cellW + gap))));
177
+ const note = items.length > count ? `Items ${start + 1}–${start + shown.length} of ${items.length}` : void 0;
178
+ result = grid(shown.map((item, i) => ({
179
+ text: format(item),
180
+ path: [bits ? bits.length - 1 - i : start + i],
181
+ label: String(bits ? bits.length - 1 - i : start + i)
182
+ })), cols, Math.min(cellW, width - 20), note);
183
+ } else if (view === "record") {
184
+ const entries = Object.entries(value);
185
+ const focused = entries.findIndex(([key]) => key === focus[0]?.[0]);
186
+ const start = windowStart(entries.length, 6, focused);
187
+ const shown = entries.slice(start, start + 6);
188
+ const note = entries.length > 6 ? `Fields ${start + 1}–${start + shown.length} of ${entries.length}` : void 0;
189
+ const cellW = Math.min(width - 20, Math.max(100, ...shown.map(([key, item]) => (key.length + format(item).length + 2) * 7.5 + 16)));
190
+ result = grid(shown.map(([key, item]) => ({
191
+ text: `${key}: ${format(item)}`,
192
+ path: [key]
193
+ })), 1, cellW, note);
194
+ } else {
195
+ const text = format(value);
196
+ result = grid([{
197
+ text,
198
+ path: []
199
+ }], 1, Math.min(width - 20, Math.max(60, text.length * 8 + 16)));
200
+ }
201
+ result.w = Math.min(width, Math.max(result.w, Math.min(240, entity.label.length * 7 + 20)));
202
+ if (!result.cells.length) result.cells.push({
203
+ path: [],
204
+ text: "empty",
205
+ x: PAD$1,
206
+ y: TOP,
207
+ w: result.w - 20,
208
+ h: 28
209
+ });
210
+ return result;
211
+ }
212
+ // src/layout.ts
213
+ const PAD = 12;
214
+ const GAP = 28;
215
+ const laneOf = (entity) => entity.lane ?? "State";
216
+ function arrangeGroup(name, members, relations, sizes, arrangement, maxWidth) {
217
+ const ids = new Set(members.map((entity) => entity.id));
218
+ const ranks = assignRanks(members, relations.filter((relation) => ids.has(relation.from) && ids.has(relation.to)));
219
+ const rows = [];
220
+ for (const member of members) {
221
+ const rank = arrangement === "tree" ? ranks.get(member.id) ?? 0 : arrangement === "column" ? rows.length : 0;
222
+ rows[rank] ??= [];
223
+ rows[rank].push(member);
224
+ }
225
+ const nodes = {};
226
+ let y = 32, w = Math.min(maxWidth, Math.max(100, name.length * 7 + 24));
227
+ for (const row of rows) {
228
+ if (!row) continue;
229
+ let x = PAD, rowHeight = 0;
230
+ for (const entity of row) {
231
+ const size = sizes.get(entity.id);
232
+ if (x > PAD && x + size.w + PAD > maxWidth) {
233
+ y += rowHeight + GAP;
234
+ x = PAD;
235
+ rowHeight = 0;
236
+ }
237
+ nodes[entity.id] = {
238
+ ...size,
239
+ x,
240
+ y
241
+ };
242
+ rowHeight = Math.max(rowHeight, size.h);
243
+ w = Math.max(w, x + size.w + PAD);
244
+ x += size.w + GAP;
245
+ }
246
+ y += rowHeight + GAP;
247
+ }
248
+ if (arrangement === "tree") {
249
+ const levels = new Set(Object.values(nodes).map((box) => box.y));
250
+ for (const level of levels) {
251
+ const boxes = Object.values(nodes).filter((box) => box.y === level);
252
+ const right = Math.max(...boxes.map((box) => box.x + box.w));
253
+ const offset = (w - PAD - right) / 2;
254
+ for (const box of boxes) box.x += offset;
255
+ }
256
+ }
257
+ return {
258
+ name,
259
+ x: 0,
260
+ y: 0,
261
+ w,
262
+ h: Math.max(64, y),
263
+ nodes
264
+ };
265
+ }
266
+ /** History-sized boxes and responsive group packing; no per-step layout or shrinking text. */
267
+ function composeTrace(trace, availableWidth = 720) {
268
+ const width = Math.max(160, Number.isFinite(availableWidth) ? Math.floor(availableWidth) : 720);
269
+ const entities = trace.frames[0]?.state.entities ?? [];
270
+ const relations = trace.frames[0]?.state.relations ?? [];
271
+ const maxWidth = width - 24;
272
+ const nodeWidth = Math.min(260, maxWidth - 24);
273
+ const sizes = /* @__PURE__ */ new Map();
274
+ for (const frame of trace.frames) {
275
+ const targets = frame.state.active.map(parseTarget);
276
+ for (const entity of frame.state.entities) {
277
+ const value = presentValue(entity, nodeWidth, targets.filter((target) => target?.id === entity.id).map((target) => target.path));
278
+ const old = sizes.get(entity.id);
279
+ sizes.set(entity.id, {
280
+ x: 0,
281
+ y: 0,
282
+ w: Math.max(old?.w ?? 0, value.w),
283
+ h: Math.max(old?.h ?? 0, value.h)
284
+ });
285
+ }
286
+ }
287
+ const groups = [...new Set(entities.map(laneOf))].map((name) => arrangeGroup(name, entities.filter((entity) => laneOf(entity) === name), relations, sizes, trace.groups?.find((group) => group.name === name)?.layout ?? "row", maxWidth));
288
+ const nodes = {};
289
+ let x = PAD, y = PAD, rowHeight = 0;
290
+ for (const group of groups) {
291
+ if (x > PAD && x + group.w + PAD > width) {
292
+ x = PAD;
293
+ y += rowHeight + GAP;
294
+ rowHeight = 0;
295
+ }
296
+ group.x = x;
297
+ group.y = y;
298
+ for (const [id, box] of Object.entries(group.nodes)) nodes[id] = {
299
+ ...box,
300
+ x: box.x + x,
301
+ y: box.y + y
302
+ };
303
+ x += group.w + GAP;
304
+ rowHeight = Math.max(rowHeight, group.h);
305
+ }
306
+ for (const rowY of new Set(groups.map((group) => group.y))) {
307
+ const row = groups.filter((group) => group.y === rowY);
308
+ const right = Math.max(...row.map((group) => group.x + group.w));
309
+ const offset = Math.max(0, (width - PAD - right) / 2);
310
+ for (const group of row) {
311
+ group.x += offset;
312
+ for (const id of Object.keys(group.nodes)) nodes[id].x += offset;
313
+ }
314
+ }
315
+ const height = Math.max(100, y + rowHeight + PAD);
316
+ return {
317
+ width,
318
+ height,
319
+ viewBox: `0 0 ${width} ${height}`,
320
+ groups: groups.map(({ nodes: _, ...group }) => group),
321
+ nodes,
322
+ edges: routeEdges(relations, nodes)
323
+ };
324
+ }
325
+ // src/react/caption.tsx
326
+ function PlayerCaption({ frame, id }) {
327
+ return /* @__PURE__ */ jsxs("div", {
328
+ className: "thl-caption",
329
+ id,
330
+ children: [/* @__PURE__ */ jsx("span", {
331
+ className: "thl-step",
332
+ children: String(frame.index + 1).padStart(2, "0")
333
+ }), /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsxs("p", { children: [/* @__PURE__ */ jsx("strong", { children: frame.label }), frame.state.notes.length > 0 && /* @__PURE__ */ jsx("span", { children: frame.state.notes.join(" ") })] }), frame.state.transfers.length > 0 && /* @__PURE__ */ jsx("ul", {
334
+ className: "thl-transfers",
335
+ "aria-label": "Transfers",
336
+ children: frame.state.transfers.map((transfer, index) => {
337
+ const link = frame.state.relations.find((relation) => relation.id === transfer.relation);
338
+ return /* @__PURE__ */ jsxs("li", { children: [link ? `${link.from} → ${link.to}` : transfer.relation, transfer.value !== void 0 && /* @__PURE__ */ jsx("code", { children: format(transfer.value) })] }, `${transfer.relation}:${index}`);
339
+ })
340
+ })] })]
341
+ });
342
+ }
343
+ // src/seek.ts
344
+ /**
345
+ * Player seek model: pure state transitions, no DOM, no timers. The React
346
+ * binding (usePlayerInTimeline) drives these from an interval; tests drive
347
+ * them directly.
348
+ */
349
+ const SPEEDS = [
350
+ .5,
351
+ 1,
352
+ 2
353
+ ];
354
+ const initialSeek = (max) => ({
355
+ step: 0,
356
+ max,
357
+ playing: false,
358
+ speed: 1
359
+ });
360
+ const clamp = (n, max) => Math.max(0, Math.min(max, n));
361
+ function seekReducer(state, action) {
362
+ switch (action.type) {
363
+ case "reset": return initialSeek(action.max);
364
+ case "tick":
365
+ if (!state.playing) return state;
366
+ if (state.step >= state.max) return {
367
+ ...state,
368
+ playing: false
369
+ };
370
+ return {
371
+ ...state,
372
+ step: state.step + 1,
373
+ playing: state.step + 1 < state.max
374
+ };
375
+ case "toggle":
376
+ if (state.playing) return {
377
+ ...state,
378
+ playing: false
379
+ };
380
+ if (state.step >= state.max) return {
381
+ ...state,
382
+ step: 0,
383
+ playing: true
384
+ };
385
+ return {
386
+ ...state,
387
+ playing: true
388
+ };
389
+ case "pause": return {
390
+ ...state,
391
+ playing: false
392
+ };
393
+ case "step": return {
394
+ ...state,
395
+ step: clamp(action.to, state.max),
396
+ playing: false
397
+ };
398
+ case "speed": return {
399
+ ...state,
400
+ speed: action.to
401
+ };
402
+ }
403
+ }
404
+ /** Milliseconds per frame at the given speed; `base` is the 1x cadence. */
405
+ const stepDelayMs = (base, speed) => Math.round(base / speed);
406
+ // src/react/controls.tsx
407
+ function PlayerControls({ state, dispatch, sound }) {
408
+ return /* @__PURE__ */ jsxs("div", {
409
+ className: "thl-controls",
410
+ children: [
411
+ /* @__PURE__ */ jsx("button", {
412
+ type: "button",
413
+ "aria-label": "Step back",
414
+ disabled: state.step === 0,
415
+ onClick: () => dispatch({
416
+ type: "step",
417
+ to: state.step - 1
418
+ }),
419
+ children: "←"
420
+ }),
421
+ /* @__PURE__ */ jsx("button", {
422
+ type: "button",
423
+ className: "thl-play",
424
+ "aria-label": state.playing ? "Pause" : "Play",
425
+ disabled: state.max < 1,
426
+ onClick: () => dispatch({ type: "toggle" }),
427
+ children: state.playing ? "Pause" : state.step === state.max && state.max > 0 ? "Replay" : "Play"
428
+ }),
429
+ /* @__PURE__ */ jsx("button", {
430
+ type: "button",
431
+ "aria-label": "Step forward",
432
+ disabled: state.step === state.max,
433
+ onClick: () => dispatch({
434
+ type: "step",
435
+ to: state.step + 1
436
+ }),
437
+ children: "→"
438
+ }),
439
+ /* @__PURE__ */ jsx("input", {
440
+ type: "range",
441
+ className: "thl-timeline",
442
+ "aria-label": "Timeline",
443
+ "aria-valuetext": `Step ${state.step + 1} of ${state.max + 1}`,
444
+ min: 0,
445
+ max: state.max,
446
+ value: state.step,
447
+ disabled: state.max < 1,
448
+ onChange: (event) => dispatch({
449
+ type: "step",
450
+ to: Number(event.currentTarget.value)
451
+ })
452
+ }),
453
+ /* @__PURE__ */ jsxs("span", {
454
+ className: "thl-count",
455
+ children: [
456
+ state.step + 1,
457
+ " / ",
458
+ state.max + 1
459
+ ]
460
+ }),
461
+ /* @__PURE__ */ jsx("select", {
462
+ "aria-label": "Playback speed",
463
+ value: state.speed,
464
+ onChange: (event) => dispatch({
465
+ type: "speed",
466
+ to: Number(event.currentTarget.value)
467
+ }),
468
+ children: SPEEDS.map((speed) => /* @__PURE__ */ jsxs("option", {
469
+ value: speed,
470
+ children: [speed, "×"]
471
+ }, speed))
472
+ }),
473
+ sound && /* @__PURE__ */ jsx("button", {
474
+ type: "button",
475
+ "aria-label": "Sonify trace",
476
+ "aria-pressed": sound.enabled,
477
+ onClick: sound.toggle,
478
+ children: "Sound"
479
+ })
480
+ ]
481
+ });
482
+ }
483
+ // src/react/inspector.tsx
484
+ function StateInspector({ frame }) {
485
+ return /* @__PURE__ */ jsxs("details", {
486
+ className: "thl-inspector",
487
+ children: [/* @__PURE__ */ jsx("summary", { children: "Inspect state" }), /* @__PURE__ */ jsx("dl", { children: visibleScene(frame.state).entities.map((entity) => /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsxs("dt", { children: [
488
+ entity.label,
489
+ " ",
490
+ /* @__PURE__ */ jsxs("span", { children: [
491
+ "(",
492
+ entity.id,
493
+ ")"
494
+ ] })
495
+ ] }), /* @__PURE__ */ jsx("dd", { children: /* @__PURE__ */ jsx("pre", { children: JSON.stringify(entity.value) }) })] }, entity.id)) })]
496
+ });
497
+ }
498
+ // src/react/node-card.tsx
499
+ const truncate = (text, length) => text.length > length ? `${text.slice(0, Math.max(1, length - 1))}…` : text;
500
+ function NodeCard({ entity, previous, box, focus, changed }) {
501
+ const value = presentValue(entity, box.w, focus);
502
+ return /* @__PURE__ */ jsxs("g", {
503
+ transform: `translate(${box.x},${box.y})`,
504
+ "data-node": entity.id,
505
+ children: [
506
+ /* @__PURE__ */ jsx("title", { children: `${entity.label}: ${format(entity.value)}${focus.length ? " (focus)" : ""}` }),
507
+ /* @__PURE__ */ jsx("rect", {
508
+ width: box.w,
509
+ height: box.h,
510
+ rx: 8,
511
+ className: cn("thl-node", { "thl-focused": focus.length > 0 })
512
+ }),
513
+ /* @__PURE__ */ jsx("text", {
514
+ x: 10,
515
+ y: 19,
516
+ className: "thl-node-label",
517
+ children: truncate(entity.label, Math.floor((box.w - 20) / 7))
518
+ }),
519
+ value.cells.map((cell) => {
520
+ const isChanged = changed.some((path) => overlaps(path, cell.path)) && (effectiveView(entity.view, entity.value) === "bits" ? typeof previous !== "number" || String(Math.floor(previous / 2 ** Number(cell.path[0])) % 2) !== cell.text : JSON.stringify(getAt(previous, cell.path)) !== JSON.stringify(getAt(entity.value, cell.path)));
521
+ const isFocused = focus.some((path) => overlaps(path, cell.path));
522
+ return /* @__PURE__ */ jsxs("g", {
523
+ transform: `translate(${cell.x},${cell.y})`,
524
+ className: cn("thl-cell", {
525
+ "thl-changed": isChanged,
526
+ "thl-focused": isFocused
527
+ }),
528
+ children: [
529
+ /* @__PURE__ */ jsx("title", { children: cell.text }),
530
+ /* @__PURE__ */ jsx("rect", {
531
+ width: cell.w,
532
+ height: cell.h,
533
+ rx: 4
534
+ }),
535
+ /* @__PURE__ */ jsx("text", {
536
+ x: cell.w / 2,
537
+ y: cell.h / 2,
538
+ dominantBaseline: "central",
539
+ textAnchor: "middle",
540
+ children: truncate(cell.text, Math.floor((cell.w - 10) / 7.8))
541
+ }),
542
+ cell.label !== void 0 && /* @__PURE__ */ jsx("text", {
543
+ x: cell.w / 2,
544
+ y: cell.h + 13,
545
+ textAnchor: "middle",
546
+ className: "thl-index",
547
+ children: cell.label
548
+ })
549
+ ]
550
+ }, JSON.stringify(cell.path));
551
+ }),
552
+ value.note && /* @__PURE__ */ jsxs("text", {
553
+ x: 10,
554
+ y: box.h - 10,
555
+ className: "thl-window",
556
+ children: [truncate(value.note, Math.floor((box.w - 20) / 6.2)), /* @__PURE__ */ jsx("title", { children: value.note })]
557
+ })
558
+ ]
559
+ });
560
+ }
561
+ // src/react/scene.tsx
562
+ function SceneLink({ relation, edge, frame }) {
563
+ const transfer = frame.state.transfers.find((item) => item.relation === relation.id);
564
+ const label = relation.label;
565
+ return /* @__PURE__ */ jsxs("g", {
566
+ className: cn("thl-link", { "thl-transferred": !!transfer }),
567
+ children: [
568
+ /* @__PURE__ */ jsx("title", { children: `${relation.from} → ${relation.to}${relation.label ? `: ${relation.label}` : ""}` }),
569
+ /* @__PURE__ */ jsx("path", {
570
+ d: edge.d,
571
+ className: "thl-edge"
572
+ }),
573
+ transfer && /* @__PURE__ */ jsx("path", {
574
+ d: edge.d,
575
+ pathLength: 1,
576
+ className: "thl-flow"
577
+ }, frame.index),
578
+ /* @__PURE__ */ jsx("polygon", {
579
+ points: "0,0 -7,-3.5 -7,3.5",
580
+ transform: `translate(${edge.to[0]},${edge.to[1]}) rotate(${edge.angle * 180 / Math.PI})`
581
+ }),
582
+ label && /* @__PURE__ */ jsxs("text", {
583
+ x: edge.label[0],
584
+ y: edge.label[1] - 8,
585
+ textAnchor: "middle",
586
+ className: "thl-edge-label",
587
+ children: [truncate(label, 20), /* @__PURE__ */ jsx("title", { children: label })]
588
+ })
589
+ ]
590
+ });
591
+ }
592
+ function TraceScene({ frame, previous, layout }) {
593
+ const scene = visibleScene(frame.state);
594
+ const before = new Map(previous?.state.entities.map((entity) => [entity.id, entity.value]));
595
+ const targets = frame.state.active.map(parseTarget);
596
+ const changes = frame.state.changed.map(parseTarget);
597
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
598
+ layout.groups.filter((group) => scene.entities.some((entity) => (entity.lane ?? "State") === group.name)).map((group) => /* @__PURE__ */ jsxs("g", { children: [/* @__PURE__ */ jsx("rect", {
599
+ x: group.x,
600
+ y: group.y,
601
+ width: group.w,
602
+ height: group.h,
603
+ rx: 12,
604
+ className: "thl-group"
605
+ }), /* @__PURE__ */ jsx("text", {
606
+ x: group.x + 12,
607
+ y: group.y + 20,
608
+ className: "thl-group-label",
609
+ children: truncate(group.name, Math.floor((group.w - 24) / 7))
610
+ })] }, group.name)),
611
+ scene.relations.map((relation) => /* @__PURE__ */ jsx(SceneLink, {
612
+ relation,
613
+ edge: layout.edges[relation.id],
614
+ frame
615
+ }, relation.id)),
616
+ scene.entities.map((entity) => /* @__PURE__ */ jsx(NodeCard, {
617
+ entity,
618
+ previous: before.get(entity.id),
619
+ box: layout.nodes[entity.id],
620
+ focus: targets.flatMap((target) => target?.id === entity.id ? [target.path] : []),
621
+ changed: changes.flatMap((target) => target?.id === entity.id ? [target.path] : [])
622
+ }, entity.id)),
623
+ !scene.entities.length && /* @__PURE__ */ jsx("text", {
624
+ x: layout.width / 2,
625
+ y: 50,
626
+ textAnchor: "middle",
627
+ className: "thl-empty",
628
+ children: "No visible state at this step."
629
+ })
630
+ ] });
631
+ }
632
+ // src/react/use-player.ts
633
+ function usePlayer(trace) {
634
+ const [identity, setIdentity] = useState(trace);
635
+ const [state, dispatch] = useReducer(seekReducer, Math.max(0, trace.frames.length - 1), initialSeek);
636
+ if (identity !== trace) {
637
+ setIdentity(trace);
638
+ dispatch({
639
+ type: "reset",
640
+ max: Math.max(0, trace.frames.length - 1)
641
+ });
642
+ }
643
+ useEffect(() => {
644
+ if (!state.playing) return;
645
+ const timer = setInterval(() => dispatch({ type: "tick" }), stepDelayMs(1500, state.speed));
646
+ return () => clearInterval(timer);
647
+ }, [state.playing, state.speed]);
648
+ const onKeyDown = (event) => {
649
+ if (event.target !== event.currentTarget) return;
650
+ if ([
651
+ "ArrowRight",
652
+ "ArrowLeft",
653
+ "Home",
654
+ "End"
655
+ ].includes(event.key)) {
656
+ event.preventDefault();
657
+ const to = event.key === "Home" ? 0 : event.key === "End" ? state.max : state.step + (event.key === "ArrowRight" ? 1 : -1);
658
+ dispatch({
659
+ type: "step",
660
+ to
661
+ });
662
+ } else if (event.key === " " && state.max > 0) {
663
+ event.preventDefault();
664
+ dispatch({ type: "toggle" });
665
+ }
666
+ };
667
+ return {
668
+ state,
669
+ dispatch,
670
+ onKeyDown
671
+ };
672
+ }
673
+ function useSceneWidth(ref) {
674
+ const [width, setWidth] = useState(720);
675
+ useEffect(() => {
676
+ const element = ref.current;
677
+ if (!element) return;
678
+ const observer = new ResizeObserver(([entry]) => {
679
+ if (entry.contentRect.width > 0) setWidth(Math.floor(entry.contentRect.width));
680
+ });
681
+ observer.observe(element);
682
+ return () => observer.disconnect();
683
+ }, [ref]);
684
+ return width;
685
+ }
686
+ // src/sonify.ts
687
+ /** Optional stage feedback. Visual focus is not evidence of cost or contention. */
688
+ const SONIFY_MIN_HZ = 261.63;
689
+ const SONIFY_MAX_HZ = 523.25;
690
+ function frequencyForStep(step, total) {
691
+ if (!Number.isFinite(step) || !Number.isFinite(total) || total <= 1 || step <= 0) return SONIFY_MIN_HZ;
692
+ if (step >= total - 1) return SONIFY_MAX_HZ;
693
+ return Math.round(SONIFY_MIN_HZ * (SONIFY_MAX_HZ / SONIFY_MIN_HZ) ** (step / (total - 1)) * 100) / 100;
694
+ }
695
+ function sonificationEvent(step, total) {
696
+ return {
697
+ step,
698
+ total,
699
+ frequency: frequencyForStep(step, total),
700
+ durationMs: 180
701
+ };
702
+ }
703
+ // src/react/use-sound.ts
704
+ /** One short voice at a time; seeking or disabling sound stops the previous voice. */
705
+ function useSound(step, total, enabled, options) {
706
+ const context = useRef(null);
707
+ const handler = options?.onTone;
708
+ useEffect(() => () => {
709
+ const audio = context.current;
710
+ context.current = null;
711
+ audio?.close().catch(() => {});
712
+ }, []);
713
+ useEffect(() => {
714
+ if (!enabled || total === 0) return;
715
+ const event = sonificationEvent(step, total);
716
+ try {
717
+ if (handler) {
718
+ handler(event);
719
+ return;
720
+ }
721
+ if (typeof AudioContext === "undefined") return;
722
+ context.current ??= new AudioContext();
723
+ const audio = context.current;
724
+ audio.resume().catch(() => {});
725
+ const oscillator = audio.createOscillator();
726
+ const gain = audio.createGain();
727
+ const now = audio.currentTime;
728
+ oscillator.frequency.value = event.frequency;
729
+ gain.gain.setValueAtTime(1e-4, now);
730
+ gain.gain.exponentialRampToValueAtTime(.1, now + .02);
731
+ gain.gain.exponentialRampToValueAtTime(1e-4, now + event.durationMs / 1e3);
732
+ oscillator.connect(gain);
733
+ gain.connect(audio.destination);
734
+ oscillator.onended = () => {
735
+ oscillator.disconnect();
736
+ gain.disconnect();
737
+ };
738
+ oscillator.start();
739
+ oscillator.stop(now + event.durationMs / 1e3 + .02);
740
+ return () => {
741
+ oscillator.stop();
742
+ };
743
+ } catch {}
744
+ }, [
745
+ step,
746
+ total,
747
+ enabled,
748
+ handler
749
+ ]);
750
+ }
751
+ // src/react/player.tsx
752
+ function ThroughlinePlayer({ trace, className, theme = "auto", sonification }) {
753
+ const ref = useRef(null);
754
+ const width = useSceneWidth(ref);
755
+ const { state, dispatch, onKeyDown } = usePlayer(trace);
756
+ const [sound, setSound] = useState(sonification?.enabled ?? false);
757
+ const layout = useMemo(() => composeTrace(trace, width), [trace, width]);
758
+ const captionId = useId();
759
+ const frame = trace.frames[state.step] ?? trace.frames[0];
760
+ useSound(state.step, trace.frames.length, !!sonification && sound, sonification);
761
+ const summary = frame ? describeFrame(frame, trace.frames.length) : "No steps to display.";
762
+ return /* @__PURE__ */ jsxs("div", {
763
+ ref,
764
+ className: cn("thl", className),
765
+ "data-theme": theme,
766
+ role: "group",
767
+ "aria-label": "Visualization player",
768
+ tabIndex: 0,
769
+ onKeyDown,
770
+ children: [/* @__PURE__ */ jsx("p", {
771
+ role: "status",
772
+ className: "thl-sr-only",
773
+ children: summary
774
+ }), frame ? /* @__PURE__ */ jsxs(Fragment, { children: [
775
+ /* @__PURE__ */ jsx("svg", {
776
+ width: "100%",
777
+ height: layout.height,
778
+ viewBox: layout.viewBox,
779
+ role: "img",
780
+ "aria-label": summary,
781
+ "aria-describedby": captionId,
782
+ className: "thl-scene",
783
+ children: /* @__PURE__ */ jsx(TraceScene, {
784
+ frame,
785
+ previous: trace.frames[state.step - 1],
786
+ layout
787
+ })
788
+ }),
789
+ /* @__PURE__ */ jsx(PlayerCaption, {
790
+ frame,
791
+ id: captionId
792
+ }),
793
+ /* @__PURE__ */ jsx(PlayerControls, {
794
+ state,
795
+ dispatch,
796
+ sound: sonification ? {
797
+ enabled: sound,
798
+ toggle: () => setSound(!sound)
799
+ } : void 0
800
+ }),
801
+ /* @__PURE__ */ jsx(StateInspector, { frame })
802
+ ] }) : /* @__PURE__ */ jsx("p", {
803
+ className: "thl-empty",
804
+ children: "No steps to display."
805
+ })]
806
+ });
807
+ }
808
+ export { ThroughlinePlayer };
809
+
810
+ //# sourceMappingURL=index.js.map