flowsvg 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +96 -0
- package/LICENSE +21 -0
- package/PATTERNS.md +101 -0
- package/README.md +524 -2
- package/README.zh-CN.md +525 -0
- package/dist/browser.d.ts +69 -0
- package/dist/browser.js +353 -0
- package/dist/core.d.ts +306 -0
- package/dist/core.js +1149 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/node-content-paint.d.ts +10 -0
- package/dist/node-content-paint.js +54 -0
- package/dist/node-content.d.ts +184 -0
- package/dist/node-content.js +554 -0
- package/dist/player-accessibility.d.ts +16 -0
- package/dist/player-accessibility.js +36 -0
- package/dist/player-controls.d.ts +41 -0
- package/dist/player-controls.js +80 -0
- package/dist/player-lifecycle.d.ts +6 -0
- package/dist/player-lifecycle.js +31 -0
- package/dist/react.d.ts +48 -0
- package/dist/react.js +126 -0
- package/dist/styles.css +47 -0
- package/dist/svg-dom.d.ts +6 -0
- package/dist/svg-dom.js +74 -0
- package/dist/theme.d.ts +15 -0
- package/dist/theme.js +6 -0
- package/dist/timeline.d.ts +58 -0
- package/dist/timeline.js +164 -0
- package/dist/vue.d.ts +132 -0
- package/dist/vue.js +161 -0
- package/dist/web-component.d.ts +103 -0
- package/dist/web-component.js +396 -0
- package/package.json +87 -4
package/dist/browser.js
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser player: own the clock, DOM, controls and input, forwarding time and visual options to the core.
|
|
3
|
+
* Importing does not access the DOM; createPlayer allocates mount resources and destroy releases them.
|
|
4
|
+
*/
|
|
5
|
+
import { paintContentHighlight } from "./node-content-paint.js";
|
|
6
|
+
import { updateSvgDom } from "./svg-dom.js";
|
|
7
|
+
import { attachPlayerLifecycle } from "./player-lifecycle.js";
|
|
8
|
+
import { observeKeyboard, observeReducedMotion } from "./player-accessibility.js";
|
|
9
|
+
import { PlayerControls } from "./player-controls.js";
|
|
10
|
+
import { resolveThemePalette } from "./theme.js";
|
|
11
|
+
import { createDiagram } from "./core.js";
|
|
12
|
+
/**
|
|
13
|
+
* Mount a player using the host document and observe browser motion preferences unless explicitly overridden.
|
|
14
|
+
* Replaces host children; destroy an existing player before replacing it on the same host.
|
|
15
|
+
* @throws {DiagramValidationError} For an invalid initial document, before an instance exists to emit error.
|
|
16
|
+
*/
|
|
17
|
+
export function createPlayer(host, document, options = {}) {
|
|
18
|
+
const initial = createDiagram(document);
|
|
19
|
+
return new BrowserPlayer(host, initial, document, options);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Own one compiled Diagram and advance time with RAF; core results drive the canvas, caption and controls.
|
|
23
|
+
* Retain cleanup callbacks for lifecycle, media preferences and pointer listeners, releasing them on destroy.
|
|
24
|
+
*/
|
|
25
|
+
class BrowserPlayer {
|
|
26
|
+
element;
|
|
27
|
+
diagram;
|
|
28
|
+
options;
|
|
29
|
+
timeMs = 0;
|
|
30
|
+
speed = 1;
|
|
31
|
+
playing = false;
|
|
32
|
+
destroyed = false;
|
|
33
|
+
frameHandle;
|
|
34
|
+
lastTimestamp;
|
|
35
|
+
frame;
|
|
36
|
+
canvas;
|
|
37
|
+
caption;
|
|
38
|
+
controls;
|
|
39
|
+
scenes;
|
|
40
|
+
beatStarts;
|
|
41
|
+
detachLifecycle;
|
|
42
|
+
stopKeyboard;
|
|
43
|
+
stopMotion;
|
|
44
|
+
stopPointer;
|
|
45
|
+
themeOverride;
|
|
46
|
+
pointerPosition;
|
|
47
|
+
hoveredHit = null;
|
|
48
|
+
constructor(host, diagram, document, options) {
|
|
49
|
+
this.element = host;
|
|
50
|
+
this.diagram = diagram;
|
|
51
|
+
this.beatStarts = collectBeatStarts(document);
|
|
52
|
+
this.scenes = collectScenes(document);
|
|
53
|
+
const owner = host.ownerDocument;
|
|
54
|
+
const motion = observeReducedMotion(owner, options.reducedMotion, (reducedMotion) => {
|
|
55
|
+
if (this.destroyed)
|
|
56
|
+
return;
|
|
57
|
+
this.options = { ...this.options, reducedMotion };
|
|
58
|
+
this.updateFrame();
|
|
59
|
+
});
|
|
60
|
+
this.stopMotion = motion.cleanup;
|
|
61
|
+
this.themeOverride = options.theme;
|
|
62
|
+
this.options = { ...options, theme: options.theme ?? document.theme ?? "light", reducedMotion: motion.value };
|
|
63
|
+
this.frame = diagram.evaluateAt(0, this.options);
|
|
64
|
+
this.canvas = owner.createElement("div");
|
|
65
|
+
this.canvas.className = "flowsvg-canvas";
|
|
66
|
+
this.stopKeyboard = observeKeyboard(this.canvas, { toggle: this.togglePlayback, step: (direction) => this.step(direction) });
|
|
67
|
+
this.caption = owner.createElement("p");
|
|
68
|
+
this.caption.className = "flowsvg-caption";
|
|
69
|
+
this.caption.setAttribute("aria-live", "polite");
|
|
70
|
+
this.caption.setAttribute("aria-atomic", "true");
|
|
71
|
+
if (options.controls !== false) {
|
|
72
|
+
this.controls = new PlayerControls(owner, {
|
|
73
|
+
toggle: this.togglePlayback,
|
|
74
|
+
step: (direction) => this.step(direction),
|
|
75
|
+
seek: (timeMs) => this.seek(timeMs),
|
|
76
|
+
setSpeed: (speed) => this.setSpeed(speed)
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
host.replaceChildren(this.canvas, this.caption, ...(this.controls ? [this.controls.element] : []));
|
|
80
|
+
this.render();
|
|
81
|
+
this.stopPointer = observePointer(this.canvas, {
|
|
82
|
+
move: (event) => this.handlePointer("hover", event),
|
|
83
|
+
leave: () => this.clearPointer(),
|
|
84
|
+
click: (event) => this.handlePointer("click", event)
|
|
85
|
+
});
|
|
86
|
+
this.detachLifecycle = attachPlayerLifecycle(host, this.canvas, () => this.pause());
|
|
87
|
+
}
|
|
88
|
+
play() {
|
|
89
|
+
if (this.destroyed || this.playing || this.element.ownerDocument.visibilityState === "hidden" || this.diagram.totalDuration <= this.timeMs)
|
|
90
|
+
return;
|
|
91
|
+
this.playing = true;
|
|
92
|
+
this.lastTimestamp = undefined;
|
|
93
|
+
this.renderControls();
|
|
94
|
+
this.emit("playstatechange");
|
|
95
|
+
this.scheduleFrame();
|
|
96
|
+
}
|
|
97
|
+
pause() {
|
|
98
|
+
if (this.destroyed || !this.playing)
|
|
99
|
+
return;
|
|
100
|
+
this.playing = false;
|
|
101
|
+
if (this.frameHandle !== undefined)
|
|
102
|
+
cancelAnimationFrame(this.frameHandle);
|
|
103
|
+
this.frameHandle = undefined;
|
|
104
|
+
this.renderControls();
|
|
105
|
+
this.emit("playstatechange");
|
|
106
|
+
}
|
|
107
|
+
seek(timeMs) {
|
|
108
|
+
if (this.destroyed)
|
|
109
|
+
return;
|
|
110
|
+
this.timeMs = clamp(timeMs, 0, this.diagram.totalDuration);
|
|
111
|
+
this.lastTimestamp = undefined;
|
|
112
|
+
this.updateFrame();
|
|
113
|
+
if (this.timeMs >= this.diagram.totalDuration)
|
|
114
|
+
this.pause();
|
|
115
|
+
}
|
|
116
|
+
step(direction) {
|
|
117
|
+
if (this.destroyed)
|
|
118
|
+
return;
|
|
119
|
+
this.pause();
|
|
120
|
+
const starts = direction > 0 ? this.beatStarts : [...this.beatStarts].reverse();
|
|
121
|
+
const target = starts.find((start) => direction > 0 ? start > this.timeMs : start < this.timeMs);
|
|
122
|
+
this.seek(target ?? (direction > 0 ? this.diagram.totalDuration : 0));
|
|
123
|
+
}
|
|
124
|
+
setSpeed(speed) {
|
|
125
|
+
if (this.destroyed || !Number.isFinite(speed) || speed <= 0)
|
|
126
|
+
return;
|
|
127
|
+
this.speed = speed;
|
|
128
|
+
this.renderControls();
|
|
129
|
+
}
|
|
130
|
+
load(document) {
|
|
131
|
+
if (this.destroyed)
|
|
132
|
+
return;
|
|
133
|
+
try {
|
|
134
|
+
// Keep the old player intact until compilation succeeds so invalid loads do not interrupt valid playback.
|
|
135
|
+
const diagram = createDiagram(document);
|
|
136
|
+
this.pause();
|
|
137
|
+
if (this.destroyed)
|
|
138
|
+
return;
|
|
139
|
+
this.diagram = diagram;
|
|
140
|
+
this.options = { ...this.options, theme: this.themeOverride ?? document.theme ?? "light" };
|
|
141
|
+
this.beatStarts = collectBeatStarts(document);
|
|
142
|
+
this.scenes = collectScenes(document);
|
|
143
|
+
this.timeMs = 0;
|
|
144
|
+
// A replacement document owns new interaction targets, even when it reuses the same node IDs.
|
|
145
|
+
this.pointerPosition = undefined;
|
|
146
|
+
this.updateFrame();
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
this.emit("error", { ...this.state(), error: error instanceof Error ? error : new Error(String(error)) });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
getState() {
|
|
153
|
+
return this.state();
|
|
154
|
+
}
|
|
155
|
+
destroy() {
|
|
156
|
+
if (this.destroyed)
|
|
157
|
+
return;
|
|
158
|
+
// Block dispatch/rescheduling before releasing resources; do not call pause, which would emit an event.
|
|
159
|
+
this.destroyed = true;
|
|
160
|
+
this.playing = false;
|
|
161
|
+
if (this.frameHandle !== undefined)
|
|
162
|
+
cancelAnimationFrame(this.frameHandle);
|
|
163
|
+
this.frameHandle = undefined;
|
|
164
|
+
this.lastTimestamp = undefined;
|
|
165
|
+
this.detachLifecycle();
|
|
166
|
+
this.stopKeyboard();
|
|
167
|
+
this.stopMotion();
|
|
168
|
+
this.stopPointer();
|
|
169
|
+
this.pointerPosition = undefined;
|
|
170
|
+
this.hoveredHit = null;
|
|
171
|
+
this.element.replaceChildren();
|
|
172
|
+
}
|
|
173
|
+
/** The first RAF establishes a baseline; seek clears it so elapsed time from the old position is not reused. */
|
|
174
|
+
tick = (timestamp) => {
|
|
175
|
+
if (!this.playing || this.destroyed)
|
|
176
|
+
return;
|
|
177
|
+
this.frameHandle = undefined;
|
|
178
|
+
const previous = this.lastTimestamp ?? timestamp;
|
|
179
|
+
this.lastTimestamp = timestamp;
|
|
180
|
+
this.timeMs += (timestamp - previous) * this.speed;
|
|
181
|
+
if (this.timeMs >= this.diagram.totalDuration) {
|
|
182
|
+
this.timeMs = this.diagram.totalDuration;
|
|
183
|
+
this.updateFrame();
|
|
184
|
+
this.pause();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
this.updateFrame();
|
|
188
|
+
this.scheduleFrame();
|
|
189
|
+
};
|
|
190
|
+
/** Event listeners may restart playback synchronously; never queue a second frame owned by the same player. */
|
|
191
|
+
scheduleFrame() {
|
|
192
|
+
if (this.destroyed || !this.playing || this.frameHandle !== undefined)
|
|
193
|
+
return;
|
|
194
|
+
this.frameHandle = requestAnimationFrame(this.tick);
|
|
195
|
+
}
|
|
196
|
+
/** Update the frame before notifying consumers so listeners observe the rendered position. */
|
|
197
|
+
updateFrame() {
|
|
198
|
+
const previousBeat = this.frame.beatId;
|
|
199
|
+
const previousScene = this.frame.sceneId;
|
|
200
|
+
this.frame = this.diagram.evaluateAt(this.timeMs, this.options);
|
|
201
|
+
this.render();
|
|
202
|
+
if (previousBeat !== this.frame.beatId || previousScene !== this.frame.sceneId)
|
|
203
|
+
this.emit("stepchange");
|
|
204
|
+
this.emit("timeupdate");
|
|
205
|
+
}
|
|
206
|
+
togglePlayback = () => {
|
|
207
|
+
if (this.playing)
|
|
208
|
+
this.pause();
|
|
209
|
+
else
|
|
210
|
+
this.play();
|
|
211
|
+
};
|
|
212
|
+
render() {
|
|
213
|
+
updateSvgDom(this.canvas, this.diagram.renderSvg(this.timeMs, this.options));
|
|
214
|
+
this.canvas.style.aspectRatio = `${this.frame.camera.width} / ${this.frame.camera.height}`;
|
|
215
|
+
this.updateHover();
|
|
216
|
+
this.caption.textContent = this.frame.caption ?? "";
|
|
217
|
+
this.renderControls();
|
|
218
|
+
}
|
|
219
|
+
renderControls() {
|
|
220
|
+
this.controls?.render({ ...this.state(), sceneId: this.frame.sceneId, scenes: this.scenes });
|
|
221
|
+
}
|
|
222
|
+
state() {
|
|
223
|
+
return { timeMs: this.timeMs, totalDuration: this.diagram.totalDuration, playing: this.playing, speed: this.speed, frame: this.frame };
|
|
224
|
+
}
|
|
225
|
+
handlePointer(type, event) {
|
|
226
|
+
if (this.destroyed)
|
|
227
|
+
return;
|
|
228
|
+
if (type === "hover") {
|
|
229
|
+
this.pointerPosition = { clientX: event.clientX, clientY: event.clientY };
|
|
230
|
+
updateSvgDom(this.canvas, this.diagram.renderSvg(this.timeMs, this.options));
|
|
231
|
+
this.updateHover(true);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (type === "click")
|
|
235
|
+
event.stopPropagation();
|
|
236
|
+
const point = pointerToDiagramPoint(this.canvas, this.frame.camera, event);
|
|
237
|
+
const hit = this.diagram.hitTest(this.timeMs, point, this.options);
|
|
238
|
+
const detail = hit ? { ...hit, point, timeMs: this.timeMs, sceneId: this.frame.sceneId, beatId: this.frame.beatId } : null;
|
|
239
|
+
this.emit("click", detail);
|
|
240
|
+
this.emit("objectclick", detail);
|
|
241
|
+
}
|
|
242
|
+
/** Re-hit the saved screen position after camera/geometry updates; hover never changes the core hit geometry. */
|
|
243
|
+
updateHover(emitAlways = false) {
|
|
244
|
+
const point = this.pointerPosition ? pointerToDiagramPoint(this.canvas, this.frame.camera, this.pointerPosition) : undefined;
|
|
245
|
+
const hit = point ? this.diagram.hitTest(this.timeMs, point, this.options) : null;
|
|
246
|
+
if (hit?.type === "node") {
|
|
247
|
+
const shape = Array.from(this.canvas.querySelectorAll("[data-id]")).find((element) => element.getAttribute("data-id") === hit.id);
|
|
248
|
+
const color = resolveThemePalette(this.options.theme).highlight;
|
|
249
|
+
const node = this.frame.nodes.find((candidate) => candidate.id === hit.id);
|
|
250
|
+
// Border-only hover preserves all current timeline and content paint unless fill feedback is explicitly enabled.
|
|
251
|
+
if (this.options.hoverFill)
|
|
252
|
+
shape?.setAttribute("fill", color);
|
|
253
|
+
shape?.setAttribute("stroke", color);
|
|
254
|
+
const layout = node?.contentLayout;
|
|
255
|
+
const content = Array.from(this.canvas.querySelectorAll("[data-content-id]")).find((element) => element.getAttribute("data-content-id") === hit.id);
|
|
256
|
+
if (this.options.hoverFill && content && layout)
|
|
257
|
+
paintHoveredContent(content, paintContentHighlight(layout, 1, color));
|
|
258
|
+
}
|
|
259
|
+
const changed = hit?.id !== this.hoveredHit?.id || hit?.type !== this.hoveredHit?.type;
|
|
260
|
+
this.hoveredHit = hit;
|
|
261
|
+
if (!emitAlways && !changed)
|
|
262
|
+
return;
|
|
263
|
+
const detail = hit && point ? { ...hit, point, timeMs: this.timeMs, sceneId: this.frame.sceneId, beatId: this.frame.beatId } : null;
|
|
264
|
+
this.emit("hover", detail);
|
|
265
|
+
this.emit("objecthover", detail);
|
|
266
|
+
}
|
|
267
|
+
clearPointer() {
|
|
268
|
+
if (this.destroyed)
|
|
269
|
+
return;
|
|
270
|
+
this.pointerPosition = undefined;
|
|
271
|
+
updateSvgDom(this.canvas, this.diagram.renderSvg(this.timeMs, this.options));
|
|
272
|
+
this.updateHover(true);
|
|
273
|
+
}
|
|
274
|
+
emit(type, detail) {
|
|
275
|
+
if (!this.destroyed)
|
|
276
|
+
this.element.dispatchEvent(new CustomEvent(type, { detail: detail === undefined ? this.state() : detail }));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/** Apply the shared paint result to current decorative children; no child receives a separate interaction identity. */
|
|
280
|
+
function paintHoveredContent(content, layout) {
|
|
281
|
+
const tags = content.querySelectorAll("rect");
|
|
282
|
+
layout.tags.forEach((tag, index) => tags[index]?.setAttribute("fill", tag.fill));
|
|
283
|
+
const texts = content.querySelectorAll("text");
|
|
284
|
+
layout.lines.forEach((line, index) => {
|
|
285
|
+
const text = texts[index];
|
|
286
|
+
text?.setAttribute("fill", line.color);
|
|
287
|
+
const spans = text?.querySelectorAll("tspan");
|
|
288
|
+
line.spans.forEach((span, spanIndex) => spans?.[spanIndex]?.setAttribute("fill", span.color));
|
|
289
|
+
});
|
|
290
|
+
const graphs = content.querySelectorAll("[data-content-graph]");
|
|
291
|
+
layout.graphs?.forEach((graph, index) => {
|
|
292
|
+
const circles = graphs[index]?.querySelectorAll("circle");
|
|
293
|
+
const edges = graphs[index]?.querySelectorAll("line");
|
|
294
|
+
graph.nodes.forEach((node, nodeIndex) => circles?.[nodeIndex]?.setAttribute("fill", node.fill));
|
|
295
|
+
graph.edges.forEach((edge, edgeIndex) => edges?.[edgeIndex]?.setAttribute("stroke", edge.color));
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
function clamp(value, min, max) {
|
|
299
|
+
return Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : min;
|
|
300
|
+
}
|
|
301
|
+
function collectBeatStarts(document) {
|
|
302
|
+
let startMs = 0;
|
|
303
|
+
return document.scenes.flatMap((scene) => scene.beats.map((beat) => {
|
|
304
|
+
const start = startMs;
|
|
305
|
+
startMs += beat.durationMs;
|
|
306
|
+
return start;
|
|
307
|
+
}));
|
|
308
|
+
}
|
|
309
|
+
function collectScenes(document) {
|
|
310
|
+
let startMs = 0;
|
|
311
|
+
return document.scenes.map((scene) => {
|
|
312
|
+
const choice = { id: scene.id, label: scene.label, startMs };
|
|
313
|
+
startMs += scene.beats.reduce((duration, beat) => duration + beat.durationMs, 0);
|
|
314
|
+
return choice;
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
function observePointer(canvas, handlers) {
|
|
318
|
+
const onMove = (event) => handlers.move(event);
|
|
319
|
+
const onLeave = () => handlers.leave();
|
|
320
|
+
const onClick = (event) => handlers.click(event);
|
|
321
|
+
canvas.addEventListener("pointermove", onMove);
|
|
322
|
+
canvas.addEventListener("pointerleave", onLeave);
|
|
323
|
+
canvas.addEventListener("pointercancel", onLeave);
|
|
324
|
+
canvas.addEventListener("click", onClick);
|
|
325
|
+
return () => {
|
|
326
|
+
canvas.removeEventListener("pointermove", onMove);
|
|
327
|
+
canvas.removeEventListener("pointerleave", onLeave);
|
|
328
|
+
canvas.removeEventListener("pointercancel", onLeave);
|
|
329
|
+
canvas.removeEventListener("click", onClick);
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Prefer the inverse SVG screen transform for scaling/letterboxing; otherwise map the element rectangle to the camera.
|
|
334
|
+
* The rectangle fallback lacks full SVG transform information and is only an approximation.
|
|
335
|
+
*/
|
|
336
|
+
function pointerToDiagramPoint(canvas, camera, event) {
|
|
337
|
+
const svg = canvas.querySelector("svg");
|
|
338
|
+
const matrix = svg?.getScreenCTM?.();
|
|
339
|
+
if (matrix) {
|
|
340
|
+
const inverse = matrix.inverse();
|
|
341
|
+
return {
|
|
342
|
+
x: event.clientX * inverse.a + event.clientY * inverse.c + inverse.e,
|
|
343
|
+
y: event.clientX * inverse.b + event.clientY * inverse.d + inverse.f
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
const bounds = svg?.getBoundingClientRect() ?? canvas.getBoundingClientRect();
|
|
347
|
+
const width = bounds.width || 1;
|
|
348
|
+
const height = bounds.height || 1;
|
|
349
|
+
return {
|
|
350
|
+
x: camera.x + ((event.clientX - bounds.left) / width) * camera.width,
|
|
351
|
+
y: camera.y + ((event.clientY - bounds.top) / height) * camera.height
|
|
352
|
+
};
|
|
353
|
+
}
|
package/dist/core.d.ts
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform-independent core: validate and compile documents, then derive geometry, hit tests and static SVG.
|
|
3
|
+
* The browser consumes these results; this module does not read the DOM, network, system theme or browser clock.
|
|
4
|
+
*/
|
|
5
|
+
import { type NodeContent, type NodeContentLayout } from "./node-content.js";
|
|
6
|
+
export type { NodeContent, ContentBlock, ContentListItem, ContentListStyle, ContentTag, ContentTagStyle, ContentTagBackground, ContentGraphBlock, ContentGraphNode, ContentGraphEdge, ContentGraphLayout, ContentText, ContentTextSpan, ContentSpanStyle, ContentSpan, ContentTextStyle, ContentAlignment, ContentSafeArea, ContentLine, NodeContentLayout } from "./node-content.js";
|
|
7
|
+
/** Supported shapes; a group also supplies the origin and visibility constraints for its children. */
|
|
8
|
+
export type NodeKind = "rect" | "roundedRect" | "ellipse" | "cylinder" | "group";
|
|
9
|
+
/** Route families generated from node geometry; orthogonal routes avoid obstacles, and curved routes use cubic Bézier controls. */
|
|
10
|
+
export type EdgeRoute = "straight" | "orthogonal" | "curved";
|
|
11
|
+
/** Cardinal node sides used for explicit edge attachments. */
|
|
12
|
+
export type PortDirection = "top" | "right" | "bottom" | "left";
|
|
13
|
+
/** Direction used by a beat-local transfer marker; it does not change the static edge arrow. */
|
|
14
|
+
export type FlowDirection = "forward" | "reverse";
|
|
15
|
+
/** Explicit diagram palette; the core does not read the system theme. */
|
|
16
|
+
export type ThemeName = "light" | "dark";
|
|
17
|
+
/** Initial viewport in diagram units; width and height must be finite positive numbers. */
|
|
18
|
+
export interface Viewport {
|
|
19
|
+
width: number;
|
|
20
|
+
height: number;
|
|
21
|
+
}
|
|
22
|
+
/** Initial base style; colors accept hexadecimal RGB/RGBA only, and dashed is used for group borders. */
|
|
23
|
+
export interface NodeStyle {
|
|
24
|
+
fill?: string;
|
|
25
|
+
stroke?: string;
|
|
26
|
+
strokeWidth?: number;
|
|
27
|
+
textColor?: string;
|
|
28
|
+
dashed?: boolean;
|
|
29
|
+
}
|
|
30
|
+
/** Initial edge style; omitted values use the evaluation theme and default stroke width. */
|
|
31
|
+
export interface EdgeStyle {
|
|
32
|
+
stroke?: string;
|
|
33
|
+
strokeWidth?: number;
|
|
34
|
+
dashed?: boolean;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Initial node authored in parent-group coordinates, or document coordinates when parentId is absent.
|
|
38
|
+
* Dimensions are explicit; groups do not clip children, and visible defaults to true.
|
|
39
|
+
*/
|
|
40
|
+
export interface DiagramNode {
|
|
41
|
+
id: string;
|
|
42
|
+
kind: NodeKind;
|
|
43
|
+
x: number;
|
|
44
|
+
y: number;
|
|
45
|
+
width: number;
|
|
46
|
+
height: number;
|
|
47
|
+
label?: string;
|
|
48
|
+
/** Ordered content for rect, roundedRect or cylinder nodes; mutually exclusive with label and never resizes the node. */
|
|
49
|
+
content?: NodeContent;
|
|
50
|
+
/** Parent group ID; x/y are offsets from that group's current origin. */
|
|
51
|
+
parentId?: string;
|
|
52
|
+
/** Initial logical visibility, defaulting to true; ancestor visibility can still mask this node. */
|
|
53
|
+
visible?: boolean;
|
|
54
|
+
style?: NodeStyle;
|
|
55
|
+
}
|
|
56
|
+
/** Directed relation between two nodes or group boundaries; route defaults to straight, and hidden endpoints mask the edge. */
|
|
57
|
+
export interface DiagramEdge {
|
|
58
|
+
id: string;
|
|
59
|
+
from: string;
|
|
60
|
+
to: string;
|
|
61
|
+
label?: string;
|
|
62
|
+
route?: EdgeRoute;
|
|
63
|
+
/** Optional cardinal side for the source attachment; omitted values use geometry-aware automatic routing. */
|
|
64
|
+
fromPort?: PortDirection;
|
|
65
|
+
/** Optional cardinal side for the target attachment; omitted values use geometry-aware automatic routing. */
|
|
66
|
+
toPort?: PortDirection;
|
|
67
|
+
visible?: boolean;
|
|
68
|
+
style?: EdgeStyle;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Serializable version-1 document. Node and edge IDs are globally unique; an empty scenes array defines a static diagram.
|
|
72
|
+
* Nodes and edges define the initial state; each scene inherits the preceding scene's final persistent state.
|
|
73
|
+
* Flow markers and their temporary highlights belong only to their active beat.
|
|
74
|
+
*/
|
|
75
|
+
export interface DiagramDocument {
|
|
76
|
+
version: 1;
|
|
77
|
+
title?: string;
|
|
78
|
+
viewport: Viewport;
|
|
79
|
+
theme?: ThemeName;
|
|
80
|
+
nodes: DiagramNode[];
|
|
81
|
+
edges: DiagramEdge[];
|
|
82
|
+
scenes: Scene[];
|
|
83
|
+
}
|
|
84
|
+
/** Named scene on the continuous global timeline; contains at least one beat. */
|
|
85
|
+
export interface Scene {
|
|
86
|
+
id: string;
|
|
87
|
+
label: string;
|
|
88
|
+
beats: Beat[];
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Persistent actions take effect from the beat start and inherit into later beats; durationMs must be positive.
|
|
92
|
+
* transitionMs defaults to min(250, durationMs) within [0, durationMs]; omitted captions inherit the previous text.
|
|
93
|
+
* Flows on distinct edges share the full beat progress and expire together without inheritance, independently of transitionMs.
|
|
94
|
+
*/
|
|
95
|
+
export interface Beat {
|
|
96
|
+
/** Total length in milliseconds, including the transition and any remaining hold time. */
|
|
97
|
+
durationMs: number;
|
|
98
|
+
/** Opening transition in milliseconds; defaults to min(250, durationMs), and zero applies changes immediately. */
|
|
99
|
+
transitionMs?: number;
|
|
100
|
+
/** Replaces the inherited caption at the beat start; an empty string clears it. */
|
|
101
|
+
caption?: string;
|
|
102
|
+
actions: Action[];
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Base values accepted by updateNode; coordinates remain parent-relative and opacity is within [0, 1].
|
|
106
|
+
* label/content are mutually exclusive within a beat and replace the display mode at its start.
|
|
107
|
+
* Content replacement and clearing preserve authored dimensions unless geometry is explicitly updated.
|
|
108
|
+
*/
|
|
109
|
+
export interface NodeVisualState {
|
|
110
|
+
x?: number;
|
|
111
|
+
y?: number;
|
|
112
|
+
width?: number;
|
|
113
|
+
height?: number;
|
|
114
|
+
/** Switch to simple-label mode at the beat start, clearing structured content. */
|
|
115
|
+
label?: string;
|
|
116
|
+
/** Replace all content at the beat start; null clears content without restoring a previous label. */
|
|
117
|
+
content?: NodeContent | null;
|
|
118
|
+
fill?: string;
|
|
119
|
+
stroke?: string;
|
|
120
|
+
opacity?: number;
|
|
121
|
+
}
|
|
122
|
+
/** Base values accepted by updateEdge; colors and numbers interpolate, while labels switch at the beat start. */
|
|
123
|
+
export interface EdgeVisualState {
|
|
124
|
+
label?: string;
|
|
125
|
+
stroke?: string;
|
|
126
|
+
strokeWidth?: number;
|
|
127
|
+
opacity?: number;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Declarative beat action. Visibility, explicit highlighting, base updates and focus persist into later beats.
|
|
131
|
+
* Flow markers and their temporary participant highlights expire at the beat end without changing persistent layers.
|
|
132
|
+
* focus accepts a node/group ID or null for the full view; duplicate assignments within one beat are rejected.
|
|
133
|
+
* A beat permits at most one flow per edge, regardless of direction; other visual layers can coexist.
|
|
134
|
+
*/
|
|
135
|
+
export type Action = {
|
|
136
|
+
type: "show" | "hide" | "highlight" | "unhighlight";
|
|
137
|
+
target: string;
|
|
138
|
+
} | {
|
|
139
|
+
type: "updateNode";
|
|
140
|
+
target: string;
|
|
141
|
+
value: Partial<NodeVisualState>;
|
|
142
|
+
} | {
|
|
143
|
+
type: "updateEdge";
|
|
144
|
+
target: string;
|
|
145
|
+
value: Partial<EdgeVisualState>;
|
|
146
|
+
} | {
|
|
147
|
+
type: "focus";
|
|
148
|
+
target: string | null;
|
|
149
|
+
} | {
|
|
150
|
+
type: "flow";
|
|
151
|
+
target: string;
|
|
152
|
+
direction: FlowDirection;
|
|
153
|
+
/** Plain short message for this transfer; provided text uses a horizontal capsule instead of a dot. */
|
|
154
|
+
text?: string;
|
|
155
|
+
};
|
|
156
|
+
/** Validation path and reason, for example scenes[0].beats[1].actions[0].target. */
|
|
157
|
+
export interface ValidationIssue {
|
|
158
|
+
path: string;
|
|
159
|
+
message: string;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Reproducible visual options: theme overrides the document theme, with light as the final default.
|
|
163
|
+
* reducedMotion completes transitions immediately and hides flow markers while retaining flow participant highlights.
|
|
164
|
+
* Beat durations and timeline positions do not change.
|
|
165
|
+
*/
|
|
166
|
+
export interface EvaluateOptions {
|
|
167
|
+
/** Override the document theme; the final fallback is light. */
|
|
168
|
+
theme?: ThemeName;
|
|
169
|
+
/** Apply target values immediately and hide flow markers; retain participant highlights, beat durations and selection. */
|
|
170
|
+
reducedMotion?: boolean;
|
|
171
|
+
}
|
|
172
|
+
/** Deterministically laid-out line; x/y specify its centered anchor and text baseline in SVG document coordinates. */
|
|
173
|
+
export interface TextLine {
|
|
174
|
+
text: string;
|
|
175
|
+
x: number;
|
|
176
|
+
y: number;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Node after timeline and ancestor-group evaluation; x/y are absolute document coordinates.
|
|
180
|
+
* visible tracks participation in visibility/routing; it can be true at a reveal start with zero opacity.
|
|
181
|
+
* labelLines/labelBoxes retain overflowing text; drawing and hit testing still require positive opacity.
|
|
182
|
+
*/
|
|
183
|
+
export interface EvaluatedNode extends DiagramNode {
|
|
184
|
+
x: number;
|
|
185
|
+
y: number;
|
|
186
|
+
/** Logical visibility after ancestor masking; a reveal may be visible with zero opacity. */
|
|
187
|
+
visible: boolean;
|
|
188
|
+
/** Effective opacity in [0, 1], including visibility transitions and ancestor opacity. */
|
|
189
|
+
opacity: number;
|
|
190
|
+
highlighted: boolean;
|
|
191
|
+
labelLines: string[];
|
|
192
|
+
labelBoxes: TextLine[];
|
|
193
|
+
/** Structured text geometry and clip region; absent in simple-label mode. */
|
|
194
|
+
contentLayout?: NodeContentLayout;
|
|
195
|
+
}
|
|
196
|
+
/** Evaluated edge; opacity includes endpoint/ancestor masking, and path contains SVG line or cubic commands in document coordinates. */
|
|
197
|
+
export interface EvaluatedEdge extends DiagramEdge {
|
|
198
|
+
visible: boolean;
|
|
199
|
+
opacity: number;
|
|
200
|
+
highlighted: boolean;
|
|
201
|
+
path: string;
|
|
202
|
+
labelLines: string[];
|
|
203
|
+
labelBoxes: TextLine[];
|
|
204
|
+
/** Beat-local marker state, omitted outside an active beat or when reduced motion hides it. */
|
|
205
|
+
flow?: EdgeFlowMarker;
|
|
206
|
+
}
|
|
207
|
+
/** Deterministic transfer state derived from the current beat and edge path. */
|
|
208
|
+
export interface EdgeFlowMarker {
|
|
209
|
+
direction: FlowDirection;
|
|
210
|
+
/** Linear completion from the selected direction's start, in the inclusive range [0, 1). */
|
|
211
|
+
progress: number;
|
|
212
|
+
point: DiagramPoint;
|
|
213
|
+
/** Unmodified beat-local message; SVG uses deterministic code-point widths and keeps the capsule horizontal. */
|
|
214
|
+
text?: string;
|
|
215
|
+
}
|
|
216
|
+
/** Non-blocking text overflow warning; objectId identifies the object and path locates its document field. */
|
|
217
|
+
export interface LayoutWarning {
|
|
218
|
+
path: string;
|
|
219
|
+
message: string;
|
|
220
|
+
code: "text-overflow";
|
|
221
|
+
objectId: string;
|
|
222
|
+
}
|
|
223
|
+
/** SVG viewBox in document coordinates; focusing preserves the initial viewport aspect ratio. */
|
|
224
|
+
export interface CameraState {
|
|
225
|
+
x: number;
|
|
226
|
+
y: number;
|
|
227
|
+
width: number;
|
|
228
|
+
height: number;
|
|
229
|
+
}
|
|
230
|
+
/** Hit category; groups are also reported as nodes. */
|
|
231
|
+
export type HitObjectType = "node" | "edge";
|
|
232
|
+
/** Identity of a hit object, without DOM references, so it can be used outside browsers. */
|
|
233
|
+
export interface HitObject {
|
|
234
|
+
id: string;
|
|
235
|
+
type: HitObjectType;
|
|
236
|
+
}
|
|
237
|
+
/** Point in absolute document coordinates; browser screen coordinates must first be mapped through the viewBox. */
|
|
238
|
+
export interface DiagramPoint {
|
|
239
|
+
x: number;
|
|
240
|
+
y: number;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Complete frame for a time and visual options, shared by SVG export, the browser and consumers.
|
|
244
|
+
* sceneId/beatId are null for static diagrams; beatId is `${scene.id}:${beatIndex}` with a zero-based index.
|
|
245
|
+
* beatProgress spans the whole beat, unlike the eased progress used only during its opening transition.
|
|
246
|
+
*/
|
|
247
|
+
export interface DiagramFrameState {
|
|
248
|
+
/** Clamped global time in milliseconds. */
|
|
249
|
+
timeMs: number;
|
|
250
|
+
sceneId: string | null;
|
|
251
|
+
/** `${scene.id}:${beatIndex}`, with a zero-based index; null for static diagrams. */
|
|
252
|
+
beatId: string | null;
|
|
253
|
+
/** Linear progress in [0, 1] over the whole beat; zero for static diagrams. */
|
|
254
|
+
beatProgress: number;
|
|
255
|
+
nodes: EvaluatedNode[];
|
|
256
|
+
edges: EvaluatedEdge[];
|
|
257
|
+
camera: CameraState;
|
|
258
|
+
caption?: string;
|
|
259
|
+
warnings: LayoutWarning[];
|
|
260
|
+
}
|
|
261
|
+
/** Compilation error for invalid documents; issues contains all field problems found by validation. */
|
|
262
|
+
export declare class DiagramValidationError extends Error {
|
|
263
|
+
readonly issues: readonly ValidationIssue[];
|
|
264
|
+
constructor(issues: ValidationIssue[]);
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Validate document structure, values and references; an empty list means validation passed.
|
|
268
|
+
* Accumulate issues across inspectable branches without mutating input or producing a partial Diagram.
|
|
269
|
+
*/
|
|
270
|
+
export declare function validateDocument(input: unknown): ValidationIssue[];
|
|
271
|
+
/**
|
|
272
|
+
* Validate and copy a document, then compile its timeline; later input mutations cannot affect evaluation.
|
|
273
|
+
* @throws {DiagramValidationError} If document structure, values or references are invalid.
|
|
274
|
+
*/
|
|
275
|
+
export declare function createDiagram(input: DiagramDocument): Diagram;
|
|
276
|
+
/** One-shot static SVG export; use createDiagram to reuse compilation across repeated evaluations or exports. */
|
|
277
|
+
export declare function renderSvg(document: DiagramDocument, timeMs?: number, options?: EvaluateOptions): string;
|
|
278
|
+
/** Compiled diagram independent of the DOM, network and clock; each query evaluates from its time and options. */
|
|
279
|
+
export interface Diagram {
|
|
280
|
+
/** Global timeline duration in milliseconds; zero for static diagrams. */
|
|
281
|
+
readonly totalDuration: number;
|
|
282
|
+
/**
|
|
283
|
+
* Evaluate a frame; finite times clamp to [0, totalDuration], and non-finite times become zero.
|
|
284
|
+
* Beat intervals include their start and exclude their end; totalDuration returns the completed final beat.
|
|
285
|
+
*/
|
|
286
|
+
evaluateAt(timeMs: number, options?: EvaluateOptions): DiagramFrameState;
|
|
287
|
+
/** Serialize the same frame as self-contained static SVG with escaped text and resolved colors, without controls. */
|
|
288
|
+
renderSvg(timeMs?: number, options?: EvaluateOptions): string;
|
|
289
|
+
/**
|
|
290
|
+
* Hit-test document coordinates in paint order: ordinary nodes, then edges, then group backgrounds.
|
|
291
|
+
* Objects within each layer are searched in reverse document order.
|
|
292
|
+
* Node hits include their visible label area. Hidden/zero-opacity objects are excluded.
|
|
293
|
+
* Return null for no hit or non-finite coordinates.
|
|
294
|
+
*/
|
|
295
|
+
hitTest(timeMs: number, point: DiagramPoint, options?: EvaluateOptions): HitObject | null;
|
|
296
|
+
}
|
|
297
|
+
interface Typography {
|
|
298
|
+
fontSize: number;
|
|
299
|
+
lineHeight: number;
|
|
300
|
+
charWidth: number;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Estimate wrapping with fixed code-point widths, preserving explicit empty lines and preferring spaces as breaks.
|
|
304
|
+
* No font/DOM measurement is used; this handles width only, while frame evaluation reports height overflow.
|
|
305
|
+
*/
|
|
306
|
+
export declare function wrapText(label: string, maxWidth: number, typography?: Typography): string[];
|