flowsvg 0.1.1 → 0.2.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.
Files changed (67) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/PATTERNS.md +61 -5
  3. package/README.md +149 -461
  4. package/README.zh-CN.md +149 -461
  5. package/dist/adapters/react.d.ts +7 -0
  6. package/dist/adapters/react.js +14 -2
  7. package/dist/adapters/vue.d.ts +29 -3
  8. package/dist/adapters/vue.js +20 -4
  9. package/dist/adapters/web-component.d.ts +14 -2
  10. package/dist/adapters/web-component.js +32 -3
  11. package/dist/browser/controls.d.ts +7 -3
  12. package/dist/browser/controls.js +23 -31
  13. package/dist/browser/export-controls.d.ts +32 -0
  14. package/dist/browser/export-controls.js +151 -0
  15. package/dist/browser/player.d.ts +9 -1
  16. package/dist/browser/player.js +83 -24
  17. package/dist/browser/select-control.d.ts +33 -0
  18. package/dist/browser/select-control.js +171 -0
  19. package/dist/browser.d.ts +2 -1
  20. package/dist/browser.js +2 -1
  21. package/dist/core/diagram.d.ts +4 -2
  22. package/dist/core/diagram.js +111 -21
  23. package/dist/core/edge-geometry.d.ts +16 -3
  24. package/dist/core/edge-geometry.js +264 -37
  25. package/dist/core/model.d.ts +35 -9
  26. package/dist/core/node-content-measurement.d.ts +34 -0
  27. package/dist/core/node-content-measurement.js +66 -0
  28. package/dist/core/node-content.d.ts +17 -0
  29. package/dist/core/node-content.js +30 -6
  30. package/dist/core/timeline.d.ts +11 -3
  31. package/dist/core/timeline.js +29 -5
  32. package/dist/core/validation.d.ts +1 -1
  33. package/dist/core/validation.js +13 -3
  34. package/dist/export/browser.d.ts +9 -0
  35. package/dist/export/browser.js +172 -0
  36. package/dist/export/captions.d.ts +18 -0
  37. package/dist/export/captions.js +44 -0
  38. package/dist/export/plan.d.ts +25 -0
  39. package/dist/export/plan.js +94 -0
  40. package/dist/export/types.d.ts +64 -0
  41. package/dist/export/types.js +9 -0
  42. package/dist/export/worker-source.d.ts +1 -0
  43. package/dist/export/worker-source.js +1 -0
  44. package/dist/export.d.ts +3 -0
  45. package/dist/export.js +2 -0
  46. package/dist/index.d.ts +3 -1
  47. package/dist/index.js +1 -0
  48. package/dist/node/cli.d.ts +2 -0
  49. package/dist/node/cli.js +72 -0
  50. package/dist/node/gif.d.ts +16 -0
  51. package/dist/node/gif.js +184 -0
  52. package/dist/node/html.d.ts +19 -0
  53. package/dist/node/html.js +61 -0
  54. package/dist/node/output.d.ts +2 -0
  55. package/dist/node/output.js +22 -0
  56. package/dist/node.d.ts +6 -0
  57. package/dist/node.js +3 -0
  58. package/dist/portable/LICENSE.esbuild.txt +21 -0
  59. package/dist/portable/LICENSE.flowsvg.txt +21 -0
  60. package/dist/portable/LICENSE.gifenc.txt +21 -0
  61. package/dist/portable/export.js +5 -0
  62. package/dist/portable/manifest.json +36 -0
  63. package/dist/portable/player.js +2 -0
  64. package/dist/portable/styles.css +227 -0
  65. package/dist/styles.css +194 -14
  66. package/docs/images/flowsvg-wordmark.gif +0 -0
  67. package/package.json +37 -6
@@ -12,12 +12,22 @@ function straightPoints(from, to) {
12
12
  return [start, end];
13
13
  }
14
14
  const ROUTING_GAP = 8;
15
+ const PORT_SPACING = 24;
16
+ const MIN_ARROW_APPROACH = 24;
17
+ const ORTHOGONAL_BEND_RADIUS = 12;
18
+ const BEND_SAMPLE_COUNT = 16;
19
+ const BEND_CLEARANCE_EPSILON = 0.002;
20
+ const BEND_RADIUS_SEARCH_STEPS = 16;
21
+ const BEND_RADIUS_PRECISION = 1000;
22
+ const CYLINDER_CAP_RADIUS = 14;
23
+ /** SVG marker size in stroke-width units; routing uses the same footprint as serialization. */
24
+ export const ARROW_MARKER_SIZE = 7;
15
25
  const FOCUS_PADDING = 24;
16
26
  const HIT_TOLERANCE = 6;
17
27
  export function emptyPathGeometry() {
18
28
  return createPathGeometry([]);
19
29
  }
20
- function createPathGeometry(points, serializedPath) {
30
+ function createPathGeometry(points, serializedPath, terminalControl, paintedBottom) {
21
31
  const compact = simplifyPoints(points);
22
32
  const path = serializedPath ?? pointsToPath(compact);
23
33
  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) }));
@@ -31,6 +41,8 @@ function createPathGeometry(points, serializedPath) {
31
41
  path,
32
42
  points: compact,
33
43
  length: length || 1,
44
+ terminalDirection: terminalDirection(compact, terminalControl),
45
+ paintedBottom: paintedBottom ?? bounds.bottom,
34
46
  bounds,
35
47
  pointAt(progress) {
36
48
  if (compact.length === 0)
@@ -53,6 +65,88 @@ function createPathGeometry(points, serializedPath) {
53
65
  }
54
66
  };
55
67
  }
68
+ /** Use the exact final cubic control, or the final straight run, for the SVG marker orientation. */
69
+ function terminalDirection(points, control) {
70
+ const end = points.at(-1);
71
+ const previous = control ?? points.at(-2);
72
+ if (!end || !previous)
73
+ return { x: 1, y: 0 };
74
+ const dx = end.x - previous.x;
75
+ const dy = end.y - previous.y;
76
+ const length = Math.hypot(dx, dy);
77
+ return length > 0 ? { x: dx / length, y: dy / length } : { x: 1, y: 0 };
78
+ }
79
+ /**
80
+ * Trim orthogonal corners into quadratic bends. Adjacent trims cannot overlap,
81
+ * and the final run retains the marker approach. SVG and distance consumers
82
+ * share the same controls; conservative hull checks protect stroked node bounds.
83
+ */
84
+ function createRoundedGeometry(points, obstacles, strokeWidth) {
85
+ const route = simplifyPoints(points);
86
+ if (route.length < 3)
87
+ return createPathGeometry(route);
88
+ const samples = [route[0]];
89
+ const commands = [`M ${formatNumber(route[0].x)} ${formatNumber(route[0].y)}`];
90
+ const approach = Math.max(MIN_ARROW_APPROACH, strokeWidth * ARROW_MARKER_SIZE + ROUTING_GAP);
91
+ for (let index = 1; index < route.length - 1; index += 1) {
92
+ const previous = route[index - 1];
93
+ const corner = route[index];
94
+ const next = route[index + 1];
95
+ const incoming = Math.hypot(corner.x - previous.x, corner.y - previous.y);
96
+ const outgoing = Math.hypot(next.x - corner.x, next.y - corner.y);
97
+ const turns = (corner.x - previous.x) * (next.x - corner.x) + (corner.y - previous.y) * (next.y - corner.y) === 0;
98
+ const maximum = turns ? Math.max(0, Math.min(ORTHOGONAL_BEND_RADIUS, incoming / 2, outgoing / 2, index === route.length - 2 ? outgoing - Math.min(approach, outgoing) : ORTHOGONAL_BEND_RADIUS)) : 0;
99
+ const radius = clearBendRadius(corner, previous, next, maximum, obstacles, strokeWidth / 2);
100
+ const entry = moveToward(corner, previous, radius);
101
+ const exit = moveToward(corner, next, radius);
102
+ commands.push(`L ${formatNumber(entry.x)} ${formatNumber(entry.y)}`);
103
+ samples.push(entry);
104
+ if (radius === 0)
105
+ continue;
106
+ commands.push(`Q ${formatNumber(corner.x)} ${formatNumber(corner.y)} ${formatNumber(exit.x)} ${formatNumber(exit.y)}`);
107
+ for (let sample = 1; sample <= BEND_SAMPLE_COUNT; sample += 1) {
108
+ const progress = sample / BEND_SAMPLE_COUNT;
109
+ const inverse = 1 - progress;
110
+ samples.push({ x: inverse ** 2 * entry.x + 2 * inverse * progress * corner.x + progress ** 2 * exit.x,
111
+ y: inverse ** 2 * entry.y + 2 * inverse * progress * corner.y + progress ** 2 * exit.y });
112
+ }
113
+ }
114
+ const end = route.at(-1);
115
+ commands.push(`L ${formatNumber(end.x)} ${formatNumber(end.y)}`);
116
+ samples.push(end);
117
+ return createPathGeometry(samples, commands.join(" "));
118
+ }
119
+ function moveToward(origin, target, distance) {
120
+ const length = Math.hypot(target.x - origin.x, target.y - origin.y);
121
+ return { x: origin.x + (target.x - origin.x) / length * distance,
122
+ y: origin.y + (target.y - origin.y) / length * distance };
123
+ }
124
+ /** Shrinking the control hull cannot create a new collision. Retain a rounding margin for SVG serialization. */
125
+ function clearBendRadius(corner, previous, next, maximum, obstacles, strokeRadius) {
126
+ const clear = (radius) => {
127
+ const entry = moveToward(corner, previous, radius);
128
+ const exit = moveToward(corner, next, radius);
129
+ const margin = strokeRadius + BEND_CLEARANCE_EPSILON;
130
+ return !obstacles.some((bounds) => Math.min(entry.x, corner.x, exit.x) < bounds.right + margin
131
+ && Math.max(entry.x, corner.x, exit.x) > bounds.left - margin
132
+ && Math.min(entry.y, corner.y, exit.y) < bounds.bottom + margin
133
+ && Math.max(entry.y, corner.y, exit.y) > bounds.top - margin);
134
+ };
135
+ if (maximum === 0 || !clear(0))
136
+ return 0;
137
+ if (clear(maximum))
138
+ return maximum;
139
+ let lower = 0;
140
+ let upper = maximum;
141
+ for (let iteration = 0; iteration < BEND_RADIUS_SEARCH_STEPS; iteration += 1) {
142
+ const candidate = (lower + upper) / 2;
143
+ if (clear(candidate))
144
+ lower = candidate;
145
+ else
146
+ upper = candidate;
147
+ }
148
+ return Math.floor(lower * BEND_RADIUS_PRECISION) / BEND_RADIUS_PRECISION;
149
+ }
56
150
  /** Build sampled geometry for a cubic curve while retaining the exact cubic command for SVG output. */
57
151
  function createCurveGeometry(start, controlStart, controlEnd, end) {
58
152
  const samples = [];
@@ -66,7 +160,30 @@ function createCurveGeometry(start, controlStart, controlEnd, end) {
66
160
  });
67
161
  }
68
162
  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);
163
+ return createPathGeometry(samples, path, controlEnd, cubicBottom(start, controlStart, controlEnd, end));
164
+ }
165
+ /** Solve the cubic's vertical derivative; sampled flow points can miss the painted lower extremum. */
166
+ function cubicBottom(start, first, second, end) {
167
+ const scale = Math.max(Math.abs(start.y), Math.abs(first.y), Math.abs(second.y), Math.abs(end.y), 1);
168
+ const a = start.y / scale;
169
+ const b = first.y / scale;
170
+ const c = second.y / scale;
171
+ const d = end.y / scale;
172
+ const quadratic = -a + 3 * b - 3 * c + d;
173
+ const linear = 2 * (a - 2 * b + c);
174
+ const constant = b - a;
175
+ const discriminant = linear * linear - 4 * quadratic * constant;
176
+ const roots = quadratic === 0 ? linear === 0 ? [] : [-constant / linear]
177
+ : discriminant < 0 ? [] : [(-linear - Math.sqrt(discriminant)) / (2 * quadratic), (-linear + Math.sqrt(discriminant)) / (2 * quadratic)];
178
+ let bottom = Math.max(start.y, end.y);
179
+ for (const progress of roots) {
180
+ if (progress <= 0 || progress >= 1)
181
+ continue;
182
+ const inverse = 1 - progress;
183
+ const value = inverse ** 3 * start.y + 3 * inverse ** 2 * progress * first.y + 3 * inverse * progress ** 2 * second.y + progress ** 3 * end.y;
184
+ bottom = Math.max(bottom, value);
185
+ }
186
+ return bottom;
70
187
  }
71
188
  /** Build a two-segment cubic route used for opposite relationships; endpoint tangents remain exact port normals. */
72
189
  function createSeparatedCurveGeometry(start, controlStart, midpoint, controlMidStart, controlMidEnd, controlEnd, end) {
@@ -86,7 +203,7 @@ function createSeparatedCurveGeometry(start, controlStart, midpoint, controlMidS
86
203
  sampleSegment(start, controlStart, controlMidStart, midpoint);
87
204
  sampleSegment(midpoint, controlMidEnd, controlEnd, end);
88
205
  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);
206
+ return createPathGeometry(samples, path, controlEnd, Math.max(cubicBottom(start, controlStart, controlMidStart, midpoint), cubicBottom(midpoint, controlMidEnd, controlEnd, end)));
90
207
  }
91
208
  export function isPointNearPath(point, geometry, tolerance) {
92
209
  for (let index = 1; index < geometry.points.length; index += 1)
@@ -103,18 +220,20 @@ function distanceToSegment(point, start, end) {
103
220
  return Math.hypot(point.x - (start.x + factor * dx), point.y - (start.y + factor * dy));
104
221
  }
105
222
  /**
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.
223
+ * Orthogonal routes prefer centered bridges and single outside channels before obstacle search.
224
+ * They reserve outward endpoint segments and the final bend before the straight marker approach.
225
+ * Separate ancestor frames set outside clearance but are not obstacles.
226
+ * Curved and orthogonal input slots follow source position with document-order ties, independently of timeline visibility.
108
227
  * Routing uses visible separately from paint opacity: reveals block immediately, and hides block until completion.
109
228
  */
110
- export function routeEdge(edge, from, to, nodes, reverseOffset = 0) {
229
+ export function routeEdge(edge, from, to, nodes, routing) {
111
230
  if (edge.route === "curved") {
112
231
  const automatic = automaticPorts(from, to);
113
232
  const fromDirection = edge.fromPort ?? automatic.from;
114
233
  const toDirection = edge.toPort ?? automatic.to;
115
234
  const start = portPoint(from, fromDirection);
116
- const end = portPoint(to, toDirection);
117
- return curvedPath(start, end, fromDirection, toDirection, reverseOffset);
235
+ const end = portPoint(to, toDirection, routing.targetOffset);
236
+ return curvedPath(start, end, fromDirection, toDirection, routing.reverseOffset);
118
237
  }
119
238
  if (edge.route !== "orthogonal") {
120
239
  const points = edge.fromPort || edge.toPort
@@ -122,43 +241,146 @@ export function routeEdge(edge, from, to, nodes, reverseOffset = 0) {
122
241
  : straightPoints(from, to);
123
242
  return createPathGeometry(points);
124
243
  }
125
- const { start, end } = orthogonalAttachments(from, to, edge.fromPort, edge.toPort);
126
244
  const obstacles = nodes
127
245
  .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));
246
+ .map(nodeBounds);
247
+ return createRoundedGeometry(orthogonalPortPath(edge, from, to, obstacles, routing.targetOffset, nodes), [...obstacles, nodeBounds(from), nodeBounds(to)], edge.style?.strokeWidth ?? 2);
248
+ }
249
+ /** Order inputs across each side by source position; retain hidden edges so reveals cannot move other arrow tips. */
250
+ export function routedTargetOffsets(edges, nodes) {
251
+ const groups = new Map();
252
+ for (const edge of edges) {
253
+ if (edge.route !== "orthogonal" && edge.route !== "curved")
254
+ continue;
255
+ const from = nodes.get(edge.from);
256
+ const to = nodes.get(edge.to);
257
+ if (!from || !to)
258
+ continue;
259
+ const side = edge.toPort ?? automaticPorts(from, to).to;
260
+ const key = JSON.stringify([to.id, side]);
261
+ const group = groups.get(key);
262
+ const position = side === "top" || side === "bottom" ? from.x + from.width / 2 : from.y + from.height / 2;
263
+ groups.set(key, { node: to, side, inputs: [...(group?.inputs ?? []), { id: edge.id, position }] });
264
+ }
265
+ const offsets = new Map();
266
+ for (const { node, side, inputs } of groups.values()) {
267
+ const span = side === "top" || side === "bottom" ? node.width : node.height;
268
+ const spacing = Math.min(PORT_SPACING, span / (2 * inputs.length));
269
+ [...inputs].sort((left, right) => left.position - right.position)
270
+ .forEach((edge, index) => offsets.set(edge.id, (index - (inputs.length - 1) / 2) * spacing));
271
+ }
272
+ return offsets;
273
+ }
274
+ function nodeBounds(node) {
275
+ return { left: node.x, top: node.y, right: node.x + node.width, bottom: node.y + node.height };
132
276
  }
133
- function routingBounds(node) {
277
+ function routingBounds(bounds, side, approach = ROUTING_GAP) {
134
278
  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
279
+ left: bounds.left - (side === "left" ? approach : ROUTING_GAP),
280
+ top: bounds.top - (side === "top" ? approach : ROUTING_GAP),
281
+ right: bounds.right + (side === "right" ? approach : ROUTING_GAP),
282
+ bottom: bounds.bottom + (side === "bottom" ? approach : ROUTING_GAP)
139
283
  };
140
284
  }
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) {
285
+ /** Extend the target obstacle through the final bend and arrow approach so grid search cannot fold back into either reservation. */
286
+ function orthogonalPortPath(edge, from, to, obstacles, targetOffset, nodes) {
143
287
  const automatic = automaticPorts(from, to);
144
288
  const fromDirection = edge.fromPort ?? automatic.from;
145
289
  const toDirection = edge.toPort ?? automatic.to;
146
290
  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)]);
291
+ const end = portPoint(to, toDirection, targetOffset);
292
+ const approach = Math.max(MIN_ARROW_APPROACH, (edge.style?.strokeWidth ?? 2) * ARROW_MARKER_SIZE + ROUTING_GAP);
293
+ const fromOutline = nodeBounds(from);
294
+ const toOutline = nodeBounds(to);
295
+ const fromRun = availablePortRun(portStub(start, fromDirection, fromOutline), fromDirection, ROUTING_GAP, [...obstacles, toOutline]);
296
+ // Tight gaps clamp the preferred bridge lane to these stubs before the bend radius is reduced.
297
+ const toRun = availablePortRun(portStub(end, toDirection, toOutline), toDirection, approach + ORTHOGONAL_BEND_RADIUS, [...obstacles, fromOutline]);
298
+ const fromBounds = routingBounds(fromOutline, fromDirection, fromRun);
299
+ const toBounds = routingBounds(toOutline, toDirection, toRun);
300
+ const startStub = portStub(start, fromDirection, fromBounds);
301
+ const endStub = portStub(end, toDirection, toBounds);
302
+ const routingObstacles = [...obstacles.map((bounds) => routingBounds(bounds)), fromBounds, toBounds];
303
+ const preferred = preferredPortPath(startStub, endStub, fromDirection, toDirection, [fromOutline, toOutline, ...separateAncestorBounds(from, to, nodes)], approach);
304
+ const clear = preferred && preferred.every((point, index) => index === 0
305
+ || !blockedSegment(preferred[index - 1], point, routingObstacles));
306
+ const middle = clear ? preferred : orthogonalPath(startStub, endStub, routingObstacles);
153
307
  return [start, ...middle, end];
154
308
  }
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)
309
+ /** Prefer a centered bridge or a single outside channel; obstacle search handles unsafe candidates. */
310
+ function preferredPortPath(start, end, from, to, outlines, clearance) {
311
+ const vertical = from === "top" || from === "bottom";
312
+ if (vertical !== (to === "top" || to === "bottom"))
313
+ return undefined;
314
+ const axis = vertical ? "y" : "x";
315
+ const normal = portNormal(from);
316
+ const sign = vertical ? normal.y : normal.x;
317
+ let lane;
318
+ if (from === to) {
319
+ const boundary = vertical ? (sign < 0 ? "top" : "bottom") : (sign < 0 ? "left" : "right");
320
+ const extents = outlines.map((bounds) => bounds[boundary]);
321
+ const naturalLane = (sign < 0 ? Math.min(...extents) : Math.max(...extents)) + sign * clearance;
322
+ // Ancestor clearance and endpoint reservations have separate responsibilities:
323
+ // move outward only when a reserved endpoint stub extends beyond the natural lane.
324
+ lane = sign < 0 ? Math.min(naturalLane, start[axis], end[axis]) : Math.max(naturalLane, start[axis], end[axis]);
325
+ }
326
+ else {
327
+ // Stubs bound the legal turning interval, including the target's arrow footprint.
328
+ if ((end[axis] - start[axis]) * sign < 0)
329
+ return undefined;
330
+ const first = outlines[0];
331
+ const last = outlines[1];
332
+ const sourceSide = vertical ? (sign < 0 ? first.top : first.bottom) : (sign < 0 ? first.left : first.right);
333
+ const targetSide = vertical ? (sign < 0 ? last.bottom : last.top) : (sign < 0 ? last.right : last.left);
334
+ const midpoint = (sourceSide + targetSide) / 2;
335
+ lane = Math.max(Math.min(start[axis], end[axis]), Math.min(Math.max(start[axis], end[axis]), midpoint));
336
+ }
337
+ return simplifyPoints([start, { ...start, [axis]: lane }, { ...end, [axis]: lane }, end]);
338
+ }
339
+ /** Only separate ancestor frames affect outside lanes; a shared container must not push an internal edge outside. */
340
+ function separateAncestorBounds(from, to, nodes) {
341
+ const byId = new Map(nodes.map((node) => [node.id, node]));
342
+ const ancestors = (node) => {
343
+ const result = [];
344
+ let parent = node.parentId ? byId.get(node.parentId) : undefined;
345
+ while (parent) {
346
+ result.push(parent);
347
+ parent = parent.parentId ? byId.get(parent.parentId) : undefined;
348
+ }
349
+ return result;
161
350
  };
351
+ const sourceAncestors = ancestors(from);
352
+ const targetAncestors = ancestors(to);
353
+ const sourceIds = new Set([from.id, ...sourceAncestors.map(({ id }) => id)]);
354
+ const targetIds = new Set([to.id, ...targetAncestors.map(({ id }) => id)]);
355
+ return [...sourceAncestors.filter(({ id }) => !targetIds.has(id)),
356
+ ...targetAncestors.filter(({ id }) => !sourceIds.has(id))].map(nodeBounds);
357
+ }
358
+ /** Share narrow endpoint gaps instead of putting a reserved stub inside the opposite node. */
359
+ function availablePortRun(point, side, desired, obstacles) {
360
+ const vertical = side === "top" || side === "bottom";
361
+ const normal = portNormal(side);
362
+ return obstacles.reduce((run, bounds) => {
363
+ const transverse = vertical ? point.x : point.y;
364
+ const minimum = vertical ? bounds.left : bounds.top;
365
+ const maximum = vertical ? bounds.right : bounds.bottom;
366
+ if (transverse < minimum - ROUTING_GAP || transverse > maximum + ROUTING_GAP)
367
+ return run;
368
+ const distance = vertical
369
+ ? (side === "top" ? bounds.bottom - point.y : bounds.top - point.y) * normal.y
370
+ : (side === "left" ? bounds.right - point.x : bounds.left - point.x) * normal.x;
371
+ if (distance < 0)
372
+ return run;
373
+ return Math.min(run, Math.max(distance - ROUTING_GAP, distance / 2));
374
+ }, desired);
375
+ }
376
+ function portStub(point, direction, bounds) {
377
+ if (direction === "top")
378
+ return { x: point.x, y: bounds.top };
379
+ if (direction === "bottom")
380
+ return { x: point.x, y: bounds.bottom };
381
+ if (direction === "left")
382
+ return { x: bounds.left, y: point.y };
383
+ return { x: bounds.right, y: point.y };
162
384
  }
163
385
  /** Select cardinal sides from the dominant center-to-center axis for automatic routes. */
164
386
  function automaticPorts(from, to) {
@@ -173,15 +395,20 @@ function automaticPorts(from, to) {
173
395
  return { from: bottom ? "bottom" : "top", to: bottom ? "top" : "bottom" };
174
396
  }
175
397
  /** Return the actual outline point for a cardinal side, including ellipse contours. */
176
- function portPoint(node, direction) {
398
+ function portPoint(node, direction, offset = 0) {
177
399
  const center = { x: node.x + node.width / 2, y: node.y + node.height / 2 };
400
+ const vertical = direction === "top" || direction === "bottom";
401
+ const radius = (vertical ? node.width : node.height) / 2;
402
+ const contour = Math.sqrt(Math.max(0, 1 - (offset / radius) ** 2));
403
+ const inset = node.kind === "ellipse" ? (vertical ? node.height : node.width) / 2 * (1 - contour)
404
+ : node.kind === "cylinder" && vertical ? Math.min(CYLINDER_CAP_RADIUS, node.height / 4) * (1 - contour) : 0;
178
405
  if (direction === "top")
179
- return { x: center.x, y: node.y };
406
+ return { x: center.x + offset, y: node.y + inset };
180
407
  if (direction === "right")
181
- return { x: node.x + node.width, y: center.y };
408
+ return { x: node.x + node.width - inset, y: center.y + offset };
182
409
  if (direction === "bottom")
183
- return { x: center.x, y: node.y + node.height };
184
- return { x: node.x, y: center.y };
410
+ return { x: center.x + offset, y: node.y + node.height - inset };
411
+ return { x: node.x + inset, y: center.y + offset };
185
412
  }
186
413
  /** Generate a smooth cubic route whose endpoint tangents follow port normals. */
187
414
  function curvedPath(start, end, fromDirection, toDirection, separation) {
@@ -2,7 +2,7 @@
2
2
  import type { NodeContent, NodeContentLayout } from "./node-content.js";
3
3
  /** Supported shapes; a group also supplies the origin and visibility constraints for its children. */
4
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. */
5
+ /** Route families generated from node geometry; orthogonal routes avoid obstacles with rounded bends, and curved routes use cubic Bézier controls. */
6
6
  export type EdgeRoute = "straight" | "orthogonal" | "curved";
7
7
  /** Cardinal node sides used for explicit edge attachments. */
8
8
  export type PortDirection = "top" | "right" | "bottom" | "left";
@@ -14,6 +14,14 @@ export type ThemeName = "light" | "dark";
14
14
  export interface Viewport {
15
15
  width: number;
16
16
  height: number;
17
+ /**
18
+ * Fit full-view height to current painted geometry plus finite nonnegative bottom padding, in diagram units.
19
+ * Round height outward to whole units. Keep origin and authored width; empty frames use authored height. Explicit focus keeps authored viewport semantics.
20
+ * GIF export uses authored fixed dimensions for every frame to preserve diagram scale.
21
+ */
22
+ fitHeight?: {
23
+ padding: number;
24
+ };
17
25
  }
18
26
  /** Initial base style; colors accept hexadecimal RGB/RGBA only, and dashed is used for group borders. */
19
27
  export interface NodeStyle {
@@ -58,7 +66,7 @@ export interface DiagramEdge {
58
66
  route?: EdgeRoute;
59
67
  /** Optional cardinal side for the source attachment; omitted values use geometry-aware automatic routing. */
60
68
  fromPort?: PortDirection;
61
- /** Optional cardinal side for the target attachment; omitted values use geometry-aware automatic routing. */
69
+ /** Optional target side, otherwise selected automatically. Same-side curved and orthogonal inputs receive separate positions, including hidden inputs. */
62
70
  toPort?: PortDirection;
63
71
  visible?: boolean;
64
72
  style?: EdgeStyle;
@@ -83,15 +91,26 @@ export interface Scene {
83
91
  label: string;
84
92
  beats: Beat[];
85
93
  }
94
+ /** Immutable effective beat interval in global milliseconds; caption includes inherited text. */
95
+ export interface DiagramBeat {
96
+ readonly sceneId: string;
97
+ readonly beatId: string;
98
+ /** Zero-based index within the scene. */
99
+ readonly beatIndex: number;
100
+ readonly startMs: number;
101
+ readonly endMs: number;
102
+ readonly caption?: string;
103
+ }
86
104
  /**
87
105
  * Persistent actions take effect from the beat start and inherit into later beats; durationMs must be positive.
88
106
  * 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.
107
+ * One flow moves at a reference 180 diagram units/second with a 400 ms minimum, then uses a 500 ms post-arrival interval.
108
+ * Single-flow duration replaces authored timing; multiple flows share authored full-beat progress. All flows expire without inheritance.
90
109
  */
91
110
  export interface Beat {
92
- /** Total length in milliseconds, including the transition and any remaining hold time. */
111
+ /** Authored beat length in milliseconds for no-flow/multi-flow beats; one flow uses movement plus 500 ms. */
93
112
  durationMs: number;
94
- /** Opening transition in milliseconds; defaults to min(250, durationMs), and zero applies changes immediately. */
113
+ /** Opening transition in milliseconds; defaults to min(250, durationMs), clamps to effective beat length, and zero applies changes immediately. */
95
114
  transitionMs?: number;
96
115
  /** Replaces the inherited caption at the beat start; an empty string clears it. */
97
116
  caption?: string;
@@ -124,7 +143,11 @@ export interface EdgeVisualState {
124
143
  }
125
144
  /**
126
145
  * 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.
146
+ * Flow markers and their temporary highlights expire at the beat end without changing persistent layers.
147
+ * Flows highlight their edge and, by default, both endpoints; highlightParticipants: false excludes the endpoints.
148
+ * Single-flow receiver paint fades in for 200 ms after arrival, holds for 100 ms, then automatic node/edge paint fades out for 200 ms.
149
+ * Logical participant flags and markers last for the whole beat; explicit highlights retain authored timing.
150
+ * Reduced motion skips paint interpolation. Multiple flows retain synchronized endpoint paint.
128
151
  * focus accepts a node/group ID or null for the full view; duplicate assignments within one beat are rejected.
129
152
  * A beat permits at most one flow per edge, regardless of direction; other visual layers can coexist.
130
153
  */
@@ -148,6 +171,8 @@ export type Action = {
148
171
  direction: FlowDirection;
149
172
  /** Plain short message for this transfer; provided text uses a horizontal capsule instead of a dot. */
150
173
  text?: string;
174
+ /** Defaults to true. False disables temporary endpoint highlights; explicit highlights and the flow edge are unchanged. */
175
+ highlightParticipants?: boolean;
151
176
  };
152
177
  /** Validation path and reason, for example scenes[0].beats[1].actions[0].target. */
153
178
  export interface ValidationIssue {
@@ -156,13 +181,13 @@ export interface ValidationIssue {
156
181
  }
157
182
  /**
158
183
  * 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.
184
+ * reducedMotion completes transitions immediately and hides flow markers while retaining the flow's configured highlights.
160
185
  * Beat durations and timeline positions do not change.
161
186
  */
162
187
  export interface EvaluateOptions {
163
188
  /** Override the document theme; the final fallback is light. */
164
189
  theme?: ThemeName;
165
- /** Apply target values immediately and hide flow markers; retain participant highlights, beat durations and selection. */
190
+ /** Apply target values immediately and hide flow markers; retain configured flow highlights, beat durations and selection. */
166
191
  reducedMotion?: boolean;
167
192
  }
168
193
  /** Deterministically laid-out line; x/y specify its centered anchor and text baseline in SVG document coordinates. */
@@ -183,6 +208,7 @@ export interface EvaluatedNode extends DiagramNode {
183
208
  visible: boolean;
184
209
  /** Effective opacity in [0, 1], including visibility transitions and ancestor opacity. */
185
210
  opacity: number;
211
+ /** Logical highlight state; automatic single-flow paint can still be pending or interpolating. */
186
212
  highlighted: boolean;
187
213
  labelLines: string[];
188
214
  labelBoxes: TextLine[];
@@ -203,7 +229,7 @@ export interface EvaluatedEdge extends DiagramEdge {
203
229
  /** Deterministic transfer state derived from the current beat and edge path. */
204
230
  export interface EdgeFlowMarker {
205
231
  direction: FlowDirection;
206
- /** Linear completion from the selected direction's start, in the inclusive range [0, 1). */
232
+ /** Linear movement completion in [0, 1]; a single flow holds at 1 until its beat ends. */
207
233
  progress: number;
208
234
  point: DiagramPoint;
209
235
  /** Unmodified beat-local message; SVG uses deterministic code-point widths and keeps the capsule horizontal. */
@@ -0,0 +1,34 @@
1
+ /** Author-time content measurement shares deterministic core layout and never reads browser font metrics. */
2
+ import { type NodeContent } from "./node-content.js";
3
+ /** Inputs use diagram units and the same content styles as fixed-size nodes. */
4
+ export interface NodeContentMeasurementInput {
5
+ kind: "rect" | "roundedRect" | "cylinder";
6
+ /** Positive finite node width in diagram units. */
7
+ width: number;
8
+ /** One content version or a nonempty collection; caller-owned objects and arrays are never mutated. */
9
+ content: NodeContent | readonly NodeContent[];
10
+ }
11
+ /** Unresolved overflow for a zero-based content version and its source path. */
12
+ export interface NodeContentMeasurementDiagnostic {
13
+ /** A single content object uses index zero. */
14
+ versionIndex: number;
15
+ /** Normalized source path, such as content[1].blocks[0]. */
16
+ path: string;
17
+ /** Height refers to overflow inside a fixed-height graph block. */
18
+ dimension: "width" | "height";
19
+ message: string;
20
+ }
21
+ /** A fixed node height in diagram units; diagnostics can still require content or width changes. */
22
+ export interface NodeContentMeasurement {
23
+ /** Positive finite recommended node height, including complete line boxes and shape clearance. */
24
+ height: number;
25
+ diagnostics: NodeContentMeasurementDiagnostic[];
26
+ }
27
+ /**
28
+ * Measure content before creating a document, using deterministic SDK text estimates rather than browser fonts.
29
+ * Multiple versions share their largest required height. Diagnostics retain every unresolved block overflow.
30
+ * The caller writes the result into explicit node dimensions; input objects and arrays remain unchanged.
31
+ * Finite source values can overflow height arithmetic; validation issues identify every affected content version.
32
+ * @throws {DiagramValidationError} If input is invalid or any required node height is non-finite.
33
+ */
34
+ export declare function measureNodeContent(input: NodeContentMeasurementInput): NodeContentMeasurement;
@@ -0,0 +1,66 @@
1
+ /** Author-time content measurement shares deterministic core layout and never reads browser font metrics. */
2
+ import { layoutNodeContentWithMetrics, supportsNodeContent, validateNodeContent } from "./node-content.js";
3
+ import { DiagramValidationError } from "./validation.js";
4
+ import { resolveThemePalette } from "./theme.js";
5
+ /**
6
+ * Measure content before creating a document, using deterministic SDK text estimates rather than browser fonts.
7
+ * Multiple versions share their largest required height. Diagnostics retain every unresolved block overflow.
8
+ * The caller writes the result into explicit node dimensions; input objects and arrays remain unchanged.
9
+ * Finite source values can overflow height arithmetic; validation issues identify every affected content version.
10
+ * @throws {DiagramValidationError} If input is invalid or any required node height is non-finite.
11
+ */
12
+ export function measureNodeContent(input) {
13
+ const issues = validateMeasurementInput(input);
14
+ if (issues.length > 0)
15
+ throw new DiagramValidationError(issues);
16
+ const versions = isContentVersions(input.content) ? input.content : [input.content];
17
+ const diagnostics = [];
18
+ const heights = versions.map((content, versionIndex) => {
19
+ // Saturate shape clearance once. Any nonempty cylinder needs more height than its capped end reserves.
20
+ const { contentHeight, layout, overflows } = layoutNodeContentWithMetrics(content, { x: 0, y: 0, width: input.width, height: Number.MAX_VALUE }, resolveThemePalette(undefined), input.kind);
21
+ // Append separately so overflowing block counts cannot exceed the engine's function-argument limit.
22
+ for (const { blockIndex, dimension } of overflows) {
23
+ diagnostics.push({ versionIndex, path: `content[${versionIndex}].blocks[${blockIndex}]`, dimension,
24
+ message: dimension === "width" ? "content exceeds the available width; increasing node height cannot fix this overflow"
25
+ : "graph content exceeds its fixed block height; increasing node height cannot fix this overflow"
26
+ });
27
+ }
28
+ const height = contentHeight + layout.safeArea.y * 2;
29
+ // Individually finite source values can still overflow when line heights, gaps and insets are combined.
30
+ if (!Number.isFinite(height)) {
31
+ issues.push({ path: `content[${versionIndex}]`, message: "required node height must be finite" });
32
+ }
33
+ return height;
34
+ });
35
+ if (issues.length > 0)
36
+ throw new DiagramValidationError(issues);
37
+ return { height: heights.reduce((largest, height) => Math.max(largest, height), 1), diagnostics };
38
+ }
39
+ function isContentVersions(value) {
40
+ return Array.isArray(value);
41
+ }
42
+ /** Reuse document content validation before layout so malformed JavaScript inputs receive field errors. */
43
+ function validateMeasurementInput(input) {
44
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
45
+ return [{ path: "$", message: "measurement input must be an object" }];
46
+ }
47
+ const issues = [];
48
+ for (const field of Object.keys(input)) {
49
+ if (!["kind", "width", "content"].includes(field))
50
+ issues.push({ path: field, message: "unknown field" });
51
+ }
52
+ if (!("kind" in input) || !supportsNodeContent(input.kind)) {
53
+ issues.push({ path: "kind", message: "must be rect, roundedRect or cylinder" });
54
+ }
55
+ if (!("width" in input) || typeof input.width !== "number" || !Number.isFinite(input.width) || input.width <= 0) {
56
+ issues.push({ path: "width", message: "must be a finite number greater than zero" });
57
+ }
58
+ const content = "content" in input ? input.content : undefined;
59
+ const versions = Array.isArray(content) ? content : [content];
60
+ if (versions.length === 0)
61
+ issues.push({ path: "content", message: "must contain at least one content version" });
62
+ for (let index = 0; index < versions.length; index += 1) {
63
+ validateNodeContent(versions[index], `content[${index}]`, issues);
64
+ }
65
+ return issues;
66
+ }
@@ -27,6 +27,8 @@ export type ContentText = string | ContentTextSpan[];
27
27
  /** Controlled tag typography and optional background; tags remain left-aligned within the item. */
28
28
  export interface ContentTagStyle extends ContentSpanStyle {
29
29
  fill?: string;
30
+ /** Rounded corners by default; pill uses half the final tag height without changing text layout. */
31
+ shape?: "rounded" | "pill";
30
32
  }
31
33
  /** A decorative text tag, without an independent identity or interaction. */
32
34
  export interface ContentTag {
@@ -159,6 +161,8 @@ export interface ContentLine {
159
161
  export interface ContentTagBackground extends ContentSafeArea {
160
162
  fill: string;
161
163
  explicitFill: boolean;
164
+ /** Omitted shape uses the default rounded corners; pill follows the final aligned height. */
165
+ shape?: ContentTagStyle["shape"];
162
166
  blockIndex: number;
163
167
  itemIndex: number;
164
168
  tagIndex: number;
@@ -176,6 +180,19 @@ export interface NodeContentLayout {
176
180
  export declare function supportsNodeContent(kind: unknown): boolean;
177
181
  /** Lay out current content from the top left, without DOM font measurement or authored-size changes. */
178
182
  export declare function layoutNodeContent(content: NodeContent, bounds: ContentSafeArea, palette: ThemePalette, kind: NodeKind): NodeContentLayout;
183
+ /**
184
+ * Internal layout shared by fixed-size evaluation and author-time measurement.
185
+ * contentHeight includes complete line boxes, padded tags, graph block heights and all authored gaps.
186
+ * overflows retain only width limits and graph-internal height limits; outer node height remains adjustable.
187
+ */
188
+ export declare function layoutNodeContentWithMetrics(content: NodeContent, bounds: ContentSafeArea, palette: ThemePalette, kind: NodeKind): {
189
+ layout: NodeContentLayout;
190
+ contentHeight: number;
191
+ overflows: {
192
+ blockIndex: number;
193
+ dimension: "width" | "height";
194
+ }[];
195
+ };
179
196
  /** Validate nested content separately from shape and label-mode checks at the document boundary. */
180
197
  export declare function validateNodeContent(value: unknown, path: string, issues: ValidationIssue[]): void;
181
198
  /** Validate inline source values before evaluation; unknown fields keep their exact source position. */