@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.
- package/CHANGELOG.md +26 -0
- package/COPYRIGHT.md +8 -0
- package/LICENSE +201 -0
- package/README.md +189 -2
- package/dist/describe-ZC7v_r7z.js +138 -0
- package/dist/describe-ZC7v_r7z.js.map +1 -0
- package/dist/examples.d.ts +17 -0
- package/dist/examples.js +70 -0
- package/dist/examples.js.map +1 -0
- package/dist/index.d.ts +68 -0
- package/dist/index.js +1134 -0
- package/dist/index.js.map +1 -0
- package/dist/model-Dwy20ibD.d.ts +97 -0
- package/dist/react/index.d.ts +22 -0
- package/dist/react/index.js +810 -0
- package/dist/react/index.js.map +1 -0
- package/package.json +93 -3
- package/src/styles/throughline.css +77 -0
|
@@ -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
|