flowsvg 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/LICENSE +21 -0
  3. package/PATTERNS.md +112 -0
  4. package/README.md +546 -2
  5. package/README.zh-CN.md +547 -0
  6. package/dist/adapters/react.d.ts +48 -0
  7. package/dist/adapters/react.js +126 -0
  8. package/dist/adapters/vue.d.ts +132 -0
  9. package/dist/adapters/vue.js +161 -0
  10. package/dist/adapters/web-component.d.ts +99 -0
  11. package/dist/adapters/web-component.js +396 -0
  12. package/dist/browser/accessibility.d.ts +16 -0
  13. package/dist/browser/accessibility.js +36 -0
  14. package/dist/browser/controls.d.ts +41 -0
  15. package/dist/browser/controls.js +80 -0
  16. package/dist/browser/lifecycle.d.ts +6 -0
  17. package/dist/browser/lifecycle.js +31 -0
  18. package/dist/browser/player.d.ts +69 -0
  19. package/dist/browser/player.js +353 -0
  20. package/dist/browser/svg-dom.d.ts +6 -0
  21. package/dist/browser/svg-dom.js +74 -0
  22. package/dist/browser.d.ts +4 -0
  23. package/dist/browser.js +2 -0
  24. package/dist/core/color.d.ts +3 -0
  25. package/dist/core/color.js +18 -0
  26. package/dist/core/diagram.d.ts +38 -0
  27. package/dist/core/diagram.js +391 -0
  28. package/dist/core/edge-geometry.d.ts +32 -0
  29. package/dist/core/edge-geometry.js +382 -0
  30. package/dist/core/model.d.ts +256 -0
  31. package/dist/core/model.js +1 -0
  32. package/dist/core/node-content-paint.d.ts +10 -0
  33. package/dist/core/node-content-paint.js +45 -0
  34. package/dist/core/node-content.d.ts +184 -0
  35. package/dist/core/node-content.js +554 -0
  36. package/dist/core/svg-number.d.ts +5 -0
  37. package/dist/core/svg-number.js +7 -0
  38. package/dist/core/theme.d.ts +21 -0
  39. package/dist/core/theme.js +17 -0
  40. package/dist/core/timeline.d.ts +56 -0
  41. package/dist/core/timeline.js +148 -0
  42. package/dist/core/validation.d.ts +11 -0
  43. package/dist/core/validation.js +389 -0
  44. package/dist/index.d.ts +5 -0
  45. package/dist/index.js +2 -0
  46. package/dist/react.d.ts +3 -0
  47. package/dist/react.js +2 -0
  48. package/dist/styles.css +47 -0
  49. package/dist/vue.d.ts +3 -0
  50. package/dist/vue.js +2 -0
  51. package/dist/web-component.d.ts +3 -0
  52. package/dist/web-component.js +2 -0
  53. package/docs/images/entity-graph.png +0 -0
  54. package/docs/images/facts-timeline.png +0 -0
  55. package/docs/images/facts-timeline.zh-CN.png +0 -0
  56. package/docs/images/node-content.png +0 -0
  57. package/docs/images/numbered-list.png +0 -0
  58. package/docs/images/numbered-list.zh-CN.png +0 -0
  59. package/docs/images/quick-start.png +0 -0
  60. package/docs/images/record-tags.png +0 -0
  61. package/package.json +88 -4
@@ -0,0 +1,382 @@
1
+ /**
2
+ * Platform-independent edge geometry. This module owns routing, sampled path
3
+ * distance, label anchors and line hit testing; diagram evaluation owns the
4
+ * surrounding frame state and consumes this internal interface.
5
+ */
6
+ import { formatNumber } from "./svg-number.js";
7
+ function straightPoints(from, to) {
8
+ const fromCenter = { x: from.x + from.width / 2, y: from.y + from.height / 2 };
9
+ const toCenter = { x: to.x + to.width / 2, y: to.y + to.height / 2 };
10
+ const start = boundaryPoint(from, toCenter);
11
+ const end = boundaryPoint(to, fromCenter);
12
+ return [start, end];
13
+ }
14
+ const ROUTING_GAP = 8;
15
+ const FOCUS_PADDING = 24;
16
+ const HIT_TOLERANCE = 6;
17
+ export function emptyPathGeometry() {
18
+ return createPathGeometry([]);
19
+ }
20
+ function createPathGeometry(points, serializedPath) {
21
+ const compact = simplifyPoints(points);
22
+ const path = serializedPath ?? pointsToPath(compact);
23
+ const segments = compact.slice(1).map((end, index) => ({ start: compact[index], end, length: Math.hypot(end.x - compact[index].x, end.y - compact[index].y) }));
24
+ const length = segments.reduce((total, segment) => total + segment.length, 0);
25
+ const xs = compact.map((point) => point.x);
26
+ const ys = compact.map((point) => point.y);
27
+ const bounds = xs.length === 0
28
+ ? { left: 0, top: 0, right: 0, bottom: 0 }
29
+ : { left: Math.min(...xs), top: Math.min(...ys), right: Math.max(...xs), bottom: Math.max(...ys) };
30
+ return {
31
+ path,
32
+ points: compact,
33
+ length: length || 1,
34
+ bounds,
35
+ pointAt(progress) {
36
+ if (compact.length === 0)
37
+ return { x: 0, y: 0 };
38
+ if (compact.length === 1 || length === 0)
39
+ return compact[0];
40
+ const distance = Math.min(1, Math.max(0, progress)) * length;
41
+ let traversed = 0;
42
+ for (const segment of segments) {
43
+ if (distance <= traversed + segment.length) {
44
+ const ratio = segment.length === 0 ? 0 : (distance - traversed) / segment.length;
45
+ return {
46
+ x: segment.start.x + (segment.end.x - segment.start.x) * ratio,
47
+ y: segment.start.y + (segment.end.y - segment.start.y) * ratio
48
+ };
49
+ }
50
+ traversed += segment.length;
51
+ }
52
+ return compact.at(-1);
53
+ }
54
+ };
55
+ }
56
+ /** Build sampled geometry for a cubic curve while retaining the exact cubic command for SVG output. */
57
+ function createCurveGeometry(start, controlStart, controlEnd, end) {
58
+ const samples = [];
59
+ const sampleCount = 32;
60
+ for (let index = 0; index <= sampleCount; index += 1) {
61
+ const progress = index / sampleCount;
62
+ const inverse = 1 - progress;
63
+ samples.push({
64
+ x: inverse ** 3 * start.x + 3 * inverse ** 2 * progress * controlStart.x + 3 * inverse * progress ** 2 * controlEnd.x + progress ** 3 * end.x,
65
+ y: inverse ** 3 * start.y + 3 * inverse ** 2 * progress * controlStart.y + 3 * inverse * progress ** 2 * controlEnd.y + progress ** 3 * end.y
66
+ });
67
+ }
68
+ const path = `M ${formatNumber(start.x)} ${formatNumber(start.y)} C ${formatNumber(controlStart.x)} ${formatNumber(controlStart.y)} ${formatNumber(controlEnd.x)} ${formatNumber(controlEnd.y)} ${formatNumber(end.x)} ${formatNumber(end.y)}`;
69
+ return createPathGeometry(samples, path);
70
+ }
71
+ /** Build a two-segment cubic route used for opposite relationships; endpoint tangents remain exact port normals. */
72
+ function createSeparatedCurveGeometry(start, controlStart, midpoint, controlMidStart, controlMidEnd, controlEnd, end) {
73
+ const samples = [];
74
+ const sampleSegment = (first, firstControl, secondControl, last) => {
75
+ for (let index = 0; index <= 16; index += 1) {
76
+ if (samples.length > 0 && index === 0)
77
+ continue;
78
+ const progress = index / 16;
79
+ const inverse = 1 - progress;
80
+ samples.push({
81
+ x: inverse ** 3 * first.x + 3 * inverse ** 2 * progress * firstControl.x + 3 * inverse * progress ** 2 * secondControl.x + progress ** 3 * last.x,
82
+ y: inverse ** 3 * first.y + 3 * inverse ** 2 * progress * firstControl.y + 3 * inverse * progress ** 2 * secondControl.y + progress ** 3 * last.y
83
+ });
84
+ }
85
+ };
86
+ sampleSegment(start, controlStart, controlMidStart, midpoint);
87
+ sampleSegment(midpoint, controlMidEnd, controlEnd, end);
88
+ const path = `M ${formatNumber(start.x)} ${formatNumber(start.y)} C ${formatNumber(controlStart.x)} ${formatNumber(controlStart.y)} ${formatNumber(controlMidStart.x)} ${formatNumber(controlMidStart.y)} ${formatNumber(midpoint.x)} ${formatNumber(midpoint.y)} C ${formatNumber(controlMidEnd.x)} ${formatNumber(controlMidEnd.y)} ${formatNumber(controlEnd.x)} ${formatNumber(controlEnd.y)} ${formatNumber(end.x)} ${formatNumber(end.y)}`;
89
+ return createPathGeometry(samples, path);
90
+ }
91
+ export function isPointNearPath(point, geometry, tolerance) {
92
+ for (let index = 1; index < geometry.points.length; index += 1)
93
+ if (distanceToSegment(point, geometry.points[index - 1], geometry.points[index]) <= tolerance)
94
+ return true;
95
+ return false;
96
+ }
97
+ function distanceToSegment(point, start, end) {
98
+ const dx = end.x - start.x;
99
+ const dy = end.y - start.y;
100
+ if (dx === 0 && dy === 0)
101
+ return Math.hypot(point.x - start.x, point.y - start.y);
102
+ const factor = Math.max(0, Math.min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / (dx * dx + dy * dy)));
103
+ return Math.hypot(point.x - (start.x + factor * dx), point.y - (start.y + factor * dy));
104
+ }
105
+ /**
106
+ * Automatic orthogonal routing excludes endpoints and group frames from obstacles.
107
+ * Explicit orthogonal ports reserve outward stubs and route around endpoint bodies as well.
108
+ * Routing uses visible separately from paint opacity: reveals block immediately, and hides block until completion.
109
+ */
110
+ export function routeEdge(edge, from, to, nodes, reverseOffset = 0) {
111
+ if (edge.route === "curved") {
112
+ const automatic = automaticPorts(from, to);
113
+ const fromDirection = edge.fromPort ?? automatic.from;
114
+ const toDirection = edge.toPort ?? automatic.to;
115
+ const start = portPoint(from, fromDirection);
116
+ const end = portPoint(to, toDirection);
117
+ return curvedPath(start, end, fromDirection, toDirection, reverseOffset);
118
+ }
119
+ if (edge.route !== "orthogonal") {
120
+ const points = edge.fromPort || edge.toPort
121
+ ? [portPoint(from, edge.fromPort ?? automaticPorts(from, to).from), portPoint(to, edge.toPort ?? automaticPorts(from, to).to)]
122
+ : straightPoints(from, to);
123
+ return createPathGeometry(points);
124
+ }
125
+ const { start, end } = orthogonalAttachments(from, to, edge.fromPort, edge.toPort);
126
+ const obstacles = nodes
127
+ .filter((node) => node.visible && node.kind !== "group" && node.id !== from.id && node.id !== to.id)
128
+ .map(routingBounds);
129
+ if (edge.fromPort || edge.toPort)
130
+ return createPathGeometry(explicitOrthogonalPath(edge, from, to, obstacles));
131
+ return createPathGeometry(orthogonalPath(start, end, obstacles));
132
+ }
133
+ function routingBounds(node) {
134
+ return {
135
+ left: node.x - ROUTING_GAP,
136
+ top: node.y - ROUTING_GAP,
137
+ right: node.x + node.width + ROUTING_GAP,
138
+ bottom: node.y + node.height + ROUTING_GAP
139
+ };
140
+ }
141
+ /** Reserve endpoint-normal segments before grid search so a backside port cannot route through its own node. */
142
+ function explicitOrthogonalPath(edge, from, to, obstacles) {
143
+ const automatic = automaticPorts(from, to);
144
+ const fromDirection = edge.fromPort ?? automatic.from;
145
+ const toDirection = edge.toPort ?? automatic.to;
146
+ const start = portPoint(from, fromDirection);
147
+ const end = portPoint(to, toDirection);
148
+ const fromNormal = portNormal(fromDirection);
149
+ const toNormal = portNormal(toDirection);
150
+ const startStub = { x: start.x + fromNormal.x * ROUTING_GAP, y: start.y + fromNormal.y * ROUTING_GAP };
151
+ const endStub = { x: end.x + toNormal.x * ROUTING_GAP, y: end.y + toNormal.y * ROUTING_GAP };
152
+ const middle = orthogonalPath(startStub, endStub, [...obstacles, routingBounds(from), routingBounds(to)]);
153
+ return [start, ...middle, end];
154
+ }
155
+ /** Choose rectangular attachment sides along the dominant center-to-center axis for orthogonal connections. */
156
+ function orthogonalAttachments(from, to, fromPort, toPort) {
157
+ const automatic = automaticPorts(from, to);
158
+ return {
159
+ start: portPoint(from, fromPort ?? automatic.from),
160
+ end: portPoint(to, toPort ?? automatic.to)
161
+ };
162
+ }
163
+ /** Select cardinal sides from the dominant center-to-center axis for automatic routes. */
164
+ function automaticPorts(from, to) {
165
+ const fromCenter = { x: from.x + from.width / 2, y: from.y + from.height / 2 };
166
+ const toCenter = { x: to.x + to.width / 2, y: to.y + to.height / 2 };
167
+ const horizontal = Math.abs(toCenter.x - fromCenter.x) >= Math.abs(toCenter.y - fromCenter.y);
168
+ if (horizontal) {
169
+ const right = toCenter.x >= fromCenter.x;
170
+ return { from: right ? "right" : "left", to: right ? "left" : "right" };
171
+ }
172
+ const bottom = toCenter.y >= fromCenter.y;
173
+ return { from: bottom ? "bottom" : "top", to: bottom ? "top" : "bottom" };
174
+ }
175
+ /** Return the actual outline point for a cardinal side, including ellipse contours. */
176
+ function portPoint(node, direction) {
177
+ const center = { x: node.x + node.width / 2, y: node.y + node.height / 2 };
178
+ if (direction === "top")
179
+ return { x: center.x, y: node.y };
180
+ if (direction === "right")
181
+ return { x: node.x + node.width, y: center.y };
182
+ if (direction === "bottom")
183
+ return { x: center.x, y: node.y + node.height };
184
+ return { x: node.x, y: center.y };
185
+ }
186
+ /** Generate a smooth cubic route whose endpoint tangents follow port normals. */
187
+ function curvedPath(start, end, fromDirection, toDirection, separation) {
188
+ const distance = Math.hypot(end.x - start.x, end.y - start.y);
189
+ const tangentLength = Math.max(24, distance * 0.32);
190
+ const fromNormal = portNormal(fromDirection);
191
+ const toNormal = portNormal(toDirection);
192
+ const perpendicular = { x: -(end.y - start.y), y: end.x - start.x };
193
+ const perpendicularLength = Math.hypot(perpendicular.x, perpendicular.y) || 1;
194
+ const lane = separation * Math.min(72, Math.max(28, distance * 0.16));
195
+ if (separation !== 0) {
196
+ const midpoint = {
197
+ x: (start.x + end.x) / 2 + perpendicular.x / perpendicularLength * lane,
198
+ y: (start.y + end.y) / 2 + perpendicular.y / perpendicularLength * lane
199
+ };
200
+ const chord = { x: end.x - start.x, y: end.y - start.y };
201
+ const chordLength = Math.hypot(chord.x, chord.y) || 1;
202
+ const joinTangent = { x: chord.x / chordLength, y: chord.y / chordLength };
203
+ return createSeparatedCurveGeometry(start, { x: start.x + fromNormal.x * tangentLength, y: start.y + fromNormal.y * tangentLength }, midpoint, { x: midpoint.x - joinTangent.x * tangentLength * 0.5, y: midpoint.y - joinTangent.y * tangentLength * 0.5 }, { x: midpoint.x + joinTangent.x * tangentLength * 0.5, y: midpoint.y + joinTangent.y * tangentLength * 0.5 }, { x: end.x + toNormal.x * tangentLength, y: end.y + toNormal.y * tangentLength }, end);
204
+ }
205
+ const controlStart = {
206
+ x: start.x + fromNormal.x * tangentLength + perpendicular.x / perpendicularLength * lane,
207
+ y: start.y + fromNormal.y * tangentLength + perpendicular.y / perpendicularLength * lane
208
+ };
209
+ const controlEnd = {
210
+ x: end.x + toNormal.x * tangentLength + perpendicular.x / perpendicularLength * lane,
211
+ y: end.y + toNormal.y * tangentLength + perpendicular.y / perpendicularLength * lane
212
+ };
213
+ return createCurveGeometry(start, controlStart, controlEnd, end);
214
+ }
215
+ function portNormal(direction) {
216
+ if (direction === "top")
217
+ return { x: 0, y: -1 };
218
+ if (direction === "right")
219
+ return { x: 1, y: 0 };
220
+ if (direction === "bottom")
221
+ return { x: 0, y: 1 };
222
+ return { x: -1, y: 0 };
223
+ }
224
+ /**
225
+ * Breadth-first search the grid of endpoints and obstacle boundaries, using fixed neighbor order for determinism.
226
+ * Grid-step search does not guarantee shortest geometric length or fewest bends; fall back to a fixed elbow if blocked.
227
+ */
228
+ function orthogonalPath(start, end, obstacles) {
229
+ const xs = uniqueSorted([start.x, end.x, ...obstacles.flatMap((rect) => [rect.left, rect.right])]);
230
+ const ys = uniqueSorted([start.y, end.y, ...obstacles.flatMap((rect) => [rect.top, rect.bottom])]);
231
+ const xIndex = new Map(xs.map((value, index) => [value, index]));
232
+ const yIndex = new Map(ys.map((value, index) => [value, index]));
233
+ const startKey = pointKey(start);
234
+ const endKey = pointKey(end);
235
+ const queue = [start];
236
+ const previous = new Map([[startKey, undefined]]);
237
+ const preferredHorizontal = Math.abs(end.x - start.x) >= Math.abs(end.y - start.y);
238
+ const directions = preferredHorizontal
239
+ ? [[1, 0], [0, -1], [0, 1], [-1, 0]]
240
+ : [[0, 1], [1, 0], [-1, 0], [0, -1]];
241
+ while (queue.length > 0) {
242
+ const current = queue.shift();
243
+ if (pointKey(current) === endKey)
244
+ break;
245
+ const currentX = xIndex.get(current.x);
246
+ const currentY = yIndex.get(current.y);
247
+ if (currentX === undefined || currentY === undefined)
248
+ continue;
249
+ for (const [dx, dy] of directions) {
250
+ const nextX = xs[currentX + dx];
251
+ const nextY = ys[currentY + dy];
252
+ if (nextX === undefined || nextY === undefined)
253
+ continue;
254
+ const next = { x: nextX, y: nextY };
255
+ const key = pointKey(next);
256
+ if (previous.has(key) || isInsideObstacle(next, obstacles) || blockedSegment(current, next, obstacles))
257
+ continue;
258
+ previous.set(key, current);
259
+ queue.push(next);
260
+ }
261
+ }
262
+ const points = [];
263
+ if (previous.has(endKey)) {
264
+ let current = end;
265
+ while (current) {
266
+ points.push(current);
267
+ current = previous.get(pointKey(current));
268
+ }
269
+ points.reverse();
270
+ }
271
+ else {
272
+ const middle = preferredHorizontal ? { x: end.x, y: start.y } : { x: start.x, y: end.y };
273
+ points.push(start, middle, end);
274
+ }
275
+ return removeMicroTurns(points, obstacles);
276
+ }
277
+ /** Replace tiny obstacle-detour turns with a wider elbow when the replacement remains collision-free. */
278
+ function removeMicroTurns(points, obstacles) {
279
+ const result = [...points];
280
+ const minimum = ROUTING_GAP * 2;
281
+ for (let index = 1; index + 2 < result.length; index += 1) {
282
+ const before = result[index - 1];
283
+ const first = result[index];
284
+ const second = result[index + 1];
285
+ const after = result[index + 2];
286
+ const shortLength = Math.abs(second.x - first.x) + Math.abs(second.y - first.y);
287
+ if (shortLength >= minimum)
288
+ continue;
289
+ const candidate = before.x === first.x
290
+ ? { x: first.x, y: after.y }
291
+ : { x: after.x, y: first.y };
292
+ if (isInsideObstacle(candidate, obstacles) || blockedSegment(before, candidate, obstacles) || blockedSegment(candidate, after, obstacles))
293
+ continue;
294
+ result.splice(index, 2, candidate);
295
+ index = Math.max(0, index - 2);
296
+ }
297
+ return result;
298
+ }
299
+ /** Remove consecutive duplicates and collinear intermediate points to keep SVG paths compact. */
300
+ function pointsToPath(points) {
301
+ return simplifyPoints(points).map((point, index) => `${index === 0 ? "M" : "L"} ${formatNumber(point.x)} ${formatNumber(point.y)}`).join(" ");
302
+ }
303
+ /** Keep collinear reversals: removing an extremum would shorten flow distance and truncate path bounds. */
304
+ function simplifyPoints(points) {
305
+ const compact = [];
306
+ for (const point of points) {
307
+ const previous = compact.at(-1);
308
+ if (!previous || previous.x !== point.x || previous.y !== point.y)
309
+ compact.push(point);
310
+ }
311
+ const simplified = [];
312
+ for (const point of compact) {
313
+ const previous = simplified.at(-1);
314
+ const beforePrevious = simplified.at(-2);
315
+ const continuesDirection = previous && beforePrevious
316
+ && (previous.x - beforePrevious.x) * (point.x - previous.x) + (previous.y - beforePrevious.y) * (point.y - previous.y) >= 0;
317
+ if (previous && beforePrevious && continuesDirection && (beforePrevious.x === previous.x && previous.x === point.x || beforePrevious.y === previous.y && previous.y === point.y)) {
318
+ simplified[simplified.length - 1] = point;
319
+ }
320
+ else {
321
+ simplified.push(point);
322
+ }
323
+ }
324
+ return simplified;
325
+ }
326
+ /** Obstacles already include ROUTING_GAP; allow boundary contact and reject only segments crossing their interiors. */
327
+ function blockedSegment(start, end, obstacles) {
328
+ for (const obstacle of obstacles) {
329
+ if (start.x === end.x) {
330
+ if (start.x > obstacle.left && start.x < obstacle.right && rangesOverlap(start.y, end.y, obstacle.top, obstacle.bottom))
331
+ return true;
332
+ }
333
+ else if (start.y === end.y && start.y > obstacle.top && start.y < obstacle.bottom && rangesOverlap(start.x, end.x, obstacle.left, obstacle.right)) {
334
+ return true;
335
+ }
336
+ }
337
+ return false;
338
+ }
339
+ function isInsideObstacle(point, obstacles) {
340
+ return obstacles.some((obstacle) => point.x > obstacle.left && point.x < obstacle.right && point.y > obstacle.top && point.y < obstacle.bottom);
341
+ }
342
+ function rangesOverlap(firstStart, firstEnd, secondStart, secondEnd) {
343
+ return Math.max(Math.min(firstStart, firstEnd), secondStart) < Math.min(Math.max(firstStart, firstEnd), secondEnd);
344
+ }
345
+ function pointKey(point) {
346
+ return `${point.x},${point.y}`;
347
+ }
348
+ function uniqueSorted(values) {
349
+ return [...new Set(values)].sort((left, right) => left - right);
350
+ }
351
+ /** Project straight connections onto the ellipse contour, or the bounding rectangle for other shapes. */
352
+ function boundaryPoint(node, toward) {
353
+ const center = { x: node.x + node.width / 2, y: node.y + node.height / 2 };
354
+ const dx = toward.x - center.x;
355
+ const dy = toward.y - center.y;
356
+ if (dx === 0 && dy === 0)
357
+ return center;
358
+ const normalizedX = dx / (node.width / 2);
359
+ const normalizedY = dy / (node.height / 2);
360
+ const scale = 1 / (node.kind === "ellipse"
361
+ ? Math.hypot(normalizedX, normalizedY)
362
+ : Math.max(Math.abs(normalizedX), Math.abs(normalizedY)));
363
+ return { x: center.x + dx * scale, y: center.y + dy * scale };
364
+ }
365
+ export function pathLabelAnchor(geometry) {
366
+ const points = geometry.points;
367
+ if (points.length < 2)
368
+ return { x: 0, y: -12 };
369
+ let longest = { start: points[0], end: points[1], length: 0 };
370
+ for (let index = 1; index < points.length; index += 1) {
371
+ const start = points[index - 1];
372
+ const end = points[index];
373
+ const length = Math.hypot(end.x - start.x, end.y - start.y);
374
+ if (length > longest.length)
375
+ longest = { start, end, length };
376
+ }
377
+ const horizontal = longest.start.y === longest.end.y;
378
+ return {
379
+ x: (longest.start.x + longest.end.x) / 2 + (horizontal ? 0 : 20),
380
+ y: (longest.start.y + longest.end.y) / 2 + (horizontal ? -12 : 0)
381
+ };
382
+ }
@@ -0,0 +1,256 @@
1
+ /** Shared serializable document and evaluated-frame types; no runtime or platform dependencies. */
2
+ import type { NodeContent, NodeContentLayout } from "./node-content.js";
3
+ /** Supported shapes; a group also supplies the origin and visibility constraints for its children. */
4
+ export type NodeKind = "rect" | "roundedRect" | "ellipse" | "cylinder" | "group";
5
+ /** Route families generated from node geometry; orthogonal routes avoid obstacles, and curved routes use cubic Bézier controls. */
6
+ export type EdgeRoute = "straight" | "orthogonal" | "curved";
7
+ /** Cardinal node sides used for explicit edge attachments. */
8
+ export type PortDirection = "top" | "right" | "bottom" | "left";
9
+ /** Direction used by a beat-local transfer marker; it does not change the static edge arrow. */
10
+ export type FlowDirection = "forward" | "reverse";
11
+ /** Explicit diagram palette; the core does not read the system theme. */
12
+ export type ThemeName = "light" | "dark";
13
+ /** Initial viewport in diagram units; width and height must be finite positive numbers. */
14
+ export interface Viewport {
15
+ width: number;
16
+ height: number;
17
+ }
18
+ /** Initial base style; colors accept hexadecimal RGB/RGBA only, and dashed is used for group borders. */
19
+ export interface NodeStyle {
20
+ fill?: string;
21
+ stroke?: string;
22
+ strokeWidth?: number;
23
+ textColor?: string;
24
+ dashed?: boolean;
25
+ }
26
+ /** Initial edge style; omitted values use the evaluation theme and default stroke width. */
27
+ export interface EdgeStyle {
28
+ stroke?: string;
29
+ strokeWidth?: number;
30
+ dashed?: boolean;
31
+ }
32
+ /**
33
+ * Initial node authored in parent-group coordinates, or document coordinates when parentId is absent.
34
+ * Dimensions are explicit; groups do not clip children, and visible defaults to true.
35
+ */
36
+ export interface DiagramNode {
37
+ id: string;
38
+ kind: NodeKind;
39
+ x: number;
40
+ y: number;
41
+ width: number;
42
+ height: number;
43
+ label?: string;
44
+ /** Ordered content for rect, roundedRect or cylinder nodes; mutually exclusive with label and never resizes the node. */
45
+ content?: NodeContent;
46
+ /** Parent group ID; x/y are offsets from that group's current origin. */
47
+ parentId?: string;
48
+ /** Initial logical visibility, defaulting to true; ancestor visibility can still mask this node. */
49
+ visible?: boolean;
50
+ style?: NodeStyle;
51
+ }
52
+ /** Directed relation between two nodes or group boundaries; route defaults to straight, and hidden endpoints mask the edge. */
53
+ export interface DiagramEdge {
54
+ id: string;
55
+ from: string;
56
+ to: string;
57
+ label?: string;
58
+ route?: EdgeRoute;
59
+ /** Optional cardinal side for the source attachment; omitted values use geometry-aware automatic routing. */
60
+ fromPort?: PortDirection;
61
+ /** Optional cardinal side for the target attachment; omitted values use geometry-aware automatic routing. */
62
+ toPort?: PortDirection;
63
+ visible?: boolean;
64
+ style?: EdgeStyle;
65
+ }
66
+ /**
67
+ * Serializable version-1 document. Node and edge IDs are globally unique; an empty scenes array defines a static diagram.
68
+ * Nodes and edges define the initial state; each scene inherits the preceding scene's final persistent state.
69
+ * Flow markers and their temporary highlights belong only to their active beat.
70
+ */
71
+ export interface DiagramDocument {
72
+ version: 1;
73
+ title?: string;
74
+ viewport: Viewport;
75
+ theme?: ThemeName;
76
+ nodes: DiagramNode[];
77
+ edges: DiagramEdge[];
78
+ scenes: Scene[];
79
+ }
80
+ /** Named scene on the continuous global timeline; contains at least one beat. */
81
+ export interface Scene {
82
+ id: string;
83
+ label: string;
84
+ beats: Beat[];
85
+ }
86
+ /**
87
+ * Persistent actions take effect from the beat start and inherit into later beats; durationMs must be positive.
88
+ * transitionMs defaults to min(250, durationMs) within [0, durationMs]; omitted captions inherit the previous text.
89
+ * Flows on distinct edges share the full beat progress and expire together without inheritance, independently of transitionMs.
90
+ */
91
+ export interface Beat {
92
+ /** Total length in milliseconds, including the transition and any remaining hold time. */
93
+ durationMs: number;
94
+ /** Opening transition in milliseconds; defaults to min(250, durationMs), and zero applies changes immediately. */
95
+ transitionMs?: number;
96
+ /** Replaces the inherited caption at the beat start; an empty string clears it. */
97
+ caption?: string;
98
+ actions: Action[];
99
+ }
100
+ /**
101
+ * Base values accepted by updateNode; coordinates remain parent-relative and opacity is within [0, 1].
102
+ * label/content are mutually exclusive within a beat and replace the display mode at its start.
103
+ * Content replacement and clearing preserve authored dimensions unless geometry is explicitly updated.
104
+ */
105
+ export interface NodeVisualState {
106
+ x?: number;
107
+ y?: number;
108
+ width?: number;
109
+ height?: number;
110
+ /** Switch to simple-label mode at the beat start, clearing structured content. */
111
+ label?: string;
112
+ /** Replace all content at the beat start; null clears content without restoring a previous label. */
113
+ content?: NodeContent | null;
114
+ fill?: string;
115
+ stroke?: string;
116
+ opacity?: number;
117
+ }
118
+ /** Base values accepted by updateEdge; colors and numbers interpolate, while labels switch at the beat start. */
119
+ export interface EdgeVisualState {
120
+ label?: string;
121
+ stroke?: string;
122
+ strokeWidth?: number;
123
+ opacity?: number;
124
+ }
125
+ /**
126
+ * Declarative beat action. Visibility, explicit highlighting, base updates and focus persist into later beats.
127
+ * Flow markers and their temporary participant highlights expire at the beat end without changing persistent layers.
128
+ * focus accepts a node/group ID or null for the full view; duplicate assignments within one beat are rejected.
129
+ * A beat permits at most one flow per edge, regardless of direction; other visual layers can coexist.
130
+ */
131
+ export type Action = {
132
+ type: "show" | "hide" | "highlight" | "unhighlight";
133
+ target: string;
134
+ } | {
135
+ type: "updateNode";
136
+ target: string;
137
+ value: Partial<NodeVisualState>;
138
+ } | {
139
+ type: "updateEdge";
140
+ target: string;
141
+ value: Partial<EdgeVisualState>;
142
+ } | {
143
+ type: "focus";
144
+ target: string | null;
145
+ } | {
146
+ type: "flow";
147
+ target: string;
148
+ direction: FlowDirection;
149
+ /** Plain short message for this transfer; provided text uses a horizontal capsule instead of a dot. */
150
+ text?: string;
151
+ };
152
+ /** Validation path and reason, for example scenes[0].beats[1].actions[0].target. */
153
+ export interface ValidationIssue {
154
+ path: string;
155
+ message: string;
156
+ }
157
+ /**
158
+ * Reproducible visual options: theme overrides the document theme, with light as the final default.
159
+ * reducedMotion completes transitions immediately and hides flow markers while retaining flow participant highlights.
160
+ * Beat durations and timeline positions do not change.
161
+ */
162
+ export interface EvaluateOptions {
163
+ /** Override the document theme; the final fallback is light. */
164
+ theme?: ThemeName;
165
+ /** Apply target values immediately and hide flow markers; retain participant highlights, beat durations and selection. */
166
+ reducedMotion?: boolean;
167
+ }
168
+ /** Deterministically laid-out line; x/y specify its centered anchor and text baseline in SVG document coordinates. */
169
+ export interface TextLine {
170
+ text: string;
171
+ x: number;
172
+ y: number;
173
+ }
174
+ /**
175
+ * Node after timeline and ancestor-group evaluation; x/y are absolute document coordinates.
176
+ * visible tracks participation in visibility/routing; it can be true at a reveal start with zero opacity.
177
+ * labelLines/labelBoxes retain overflowing text; drawing and hit testing still require positive opacity.
178
+ */
179
+ export interface EvaluatedNode extends DiagramNode {
180
+ x: number;
181
+ y: number;
182
+ /** Logical visibility after ancestor masking; a reveal may be visible with zero opacity. */
183
+ visible: boolean;
184
+ /** Effective opacity in [0, 1], including visibility transitions and ancestor opacity. */
185
+ opacity: number;
186
+ highlighted: boolean;
187
+ labelLines: string[];
188
+ labelBoxes: TextLine[];
189
+ /** Structured text geometry and clip region; absent in simple-label mode. */
190
+ contentLayout?: NodeContentLayout;
191
+ }
192
+ /** Evaluated edge; opacity includes endpoint/ancestor masking, and path contains SVG line or cubic commands in document coordinates. */
193
+ export interface EvaluatedEdge extends DiagramEdge {
194
+ visible: boolean;
195
+ opacity: number;
196
+ highlighted: boolean;
197
+ path: string;
198
+ labelLines: string[];
199
+ labelBoxes: TextLine[];
200
+ /** Beat-local marker state, omitted outside an active beat or when reduced motion hides it. */
201
+ flow?: EdgeFlowMarker;
202
+ }
203
+ /** Deterministic transfer state derived from the current beat and edge path. */
204
+ export interface EdgeFlowMarker {
205
+ direction: FlowDirection;
206
+ /** Linear completion from the selected direction's start, in the inclusive range [0, 1). */
207
+ progress: number;
208
+ point: DiagramPoint;
209
+ /** Unmodified beat-local message; SVG uses deterministic code-point widths and keeps the capsule horizontal. */
210
+ text?: string;
211
+ }
212
+ /** Non-blocking text overflow warning; objectId identifies the object and path locates its document field. */
213
+ export interface LayoutWarning {
214
+ path: string;
215
+ message: string;
216
+ code: "text-overflow";
217
+ objectId: string;
218
+ }
219
+ /** SVG viewBox in document coordinates; focusing preserves the initial viewport aspect ratio. */
220
+ export interface CameraState {
221
+ x: number;
222
+ y: number;
223
+ width: number;
224
+ height: number;
225
+ }
226
+ /** Hit category; groups are also reported as nodes. */
227
+ export type HitObjectType = "node" | "edge";
228
+ /** Identity of a hit object, without DOM references, so it can be used outside browsers. */
229
+ export interface HitObject {
230
+ id: string;
231
+ type: HitObjectType;
232
+ }
233
+ /** Point in absolute document coordinates; browser screen coordinates must first be mapped through the viewBox. */
234
+ export interface DiagramPoint {
235
+ x: number;
236
+ y: number;
237
+ }
238
+ /**
239
+ * Complete frame for a time and visual options, shared by SVG export, the browser and consumers.
240
+ * sceneId/beatId are null for static diagrams; beatId is `${scene.id}:${beatIndex}` with a zero-based index.
241
+ * beatProgress spans the whole beat, unlike the eased progress used only during its opening transition.
242
+ */
243
+ export interface DiagramFrameState {
244
+ /** Clamped global time in milliseconds. */
245
+ timeMs: number;
246
+ sceneId: string | null;
247
+ /** `${scene.id}:${beatIndex}`, with a zero-based index; null for static diagrams. */
248
+ beatId: string | null;
249
+ /** Linear progress in [0, 1] over the whole beat; zero for static diagrams. */
250
+ beatProgress: number;
251
+ nodes: EvaluatedNode[];
252
+ edges: EvaluatedEdge[];
253
+ camera: CameraState;
254
+ caption?: string;
255
+ warnings: LayoutWarning[];
256
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Shared node-content highlight paint for core timeline causes and browser-only hover.
3
+ * Paint consumes evaluated geometry and preserves explicit author colors; it never lays out text again.
4
+ */
5
+ import type { NodeContentLayout } from "./node-content.js";
6
+ /**
7
+ * Retain theme-specific semantic ink and tag fills; preserve geometry and explicit author colors.
8
+ * Check default ink against the evaluated fill because authored base fills can cross low-contrast transition colors.
9
+ */
10
+ export declare function paintContentHighlight(layout: NodeContentLayout, amount: number, fill: string): NodeContentLayout;