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
@@ -8,11 +8,11 @@ import { CompiledTimeline } from "./timeline.js";
8
8
  import { mixColor } from "./color.js";
9
9
  import { DEFAULT_NODE_STROKE_WIDTH, DIAGRAM_FONT_FAMILY, NODE_CORNER_RADIUS, resolveThemePalette } from "./theme.js";
10
10
  import { DiagramValidationError, validateDocument } from "./validation.js";
11
- import { emptyPathGeometry, isPointNearPath, pathLabelAnchor, routeEdge } from "./edge-geometry.js";
11
+ import { ARROW_MARKER_SIZE, emptyPathGeometry, isPointNearPath, routedTargetOffsets, pathLabelAnchor, routeEdge } from "./edge-geometry.js";
12
12
  import { formatNumber } from "./svg-number.js";
13
13
  /**
14
14
  * Validate and copy a document, then compile its timeline; later input mutations cannot affect evaluation.
15
- * @throws {DiagramValidationError} If document structure, values or references are invalid.
15
+ * @throws {DiagramValidationError} If document structure, values or references are invalid, or derived flow timing is non-finite.
16
16
  */
17
17
  export function createDiagram(input) {
18
18
  const issues = validateDocument(input);
@@ -31,25 +31,47 @@ export function renderSvg(document, timeMs = 0, options) {
31
31
  */
32
32
  class CompiledDiagram {
33
33
  totalDuration;
34
+ beats;
34
35
  timeline;
35
36
  document;
36
37
  nodesById;
37
38
  constructor(document) {
38
39
  this.document = deepFreeze(document);
39
- this.timeline = new CompiledTimeline(document);
40
- this.totalDuration = this.timeline.totalDuration;
41
40
  this.nodesById = new Map(document.nodes.map((node) => [node.id, node]));
41
+ // Resolve reference lengths from target snapshots using the same routing as ordinary frame evaluation.
42
+ // The second compilation changes timing only; authored transitions are capped at each effective beat duration.
43
+ const authoredTimeline = new CompiledTimeline(document);
44
+ const movementDurations = new Map();
45
+ let startMs = 0;
46
+ for (const [sceneIndex, scene] of document.scenes.entries()) {
47
+ scene.beats.forEach((beat, index) => {
48
+ const flows = beat.actions.filter((action) => action.type === "flow");
49
+ if (flows.length === 1) {
50
+ const geometry = this.evaluateFrame(startMs, { reducedMotion: true }, authoredTimeline).edgeGeometries.get(flows[0].target);
51
+ // Invalid finite-input arithmetic can produce NaN points even when geometry's zero-length fallback is finite.
52
+ if (!Number.isFinite(geometry.length) || geometry.points.some((point) => !Number.isFinite(point.x) || !Number.isFinite(point.y))) {
53
+ throw new DiagramValidationError([{ path: `scenes[${sceneIndex}].beats[${index}].durationMs`, message: "effective beat timing must be finite" }]);
54
+ }
55
+ movementDurations.set(`${scene.id}:${index}`, Math.max(MIN_FLOW_MOVEMENT_MS, geometry.length / FLOW_UNITS_PER_SECOND * MILLISECONDS_PER_SECOND));
56
+ }
57
+ startMs += beat.durationMs;
58
+ });
59
+ }
60
+ this.timeline = new CompiledTimeline(document, movementDurations);
61
+ this.totalDuration = this.timeline.totalDuration;
62
+ this.beats = this.timeline.schedule;
42
63
  }
43
64
  evaluateAt(timeMs, options = {}) {
44
65
  return this.evaluateFrame(timeMs, options).frame;
45
66
  }
46
- evaluateFrame(timeMs, options = {}) {
67
+ evaluateFrame(timeMs, options = {}, compiledTimeline = this.timeline) {
47
68
  const palette = resolvePalette(this.document, options);
48
- const timeline = this.timeline.evaluateAt(timeMs, options, palette);
69
+ const timeline = compiledTimeline.evaluateAt(timeMs, options, palette);
49
70
  const currentNodes = new Map(this.document.nodes.map((node) => {
50
71
  const values = timeline.objects.get(node.id).values;
51
72
  return [node.id, { ...node, x: values.x ?? node.x, y: values.y ?? node.y, width: values.width ?? node.width, height: values.height ?? node.height, label: values.label, content: values.content ?? undefined }];
52
73
  }));
74
+ const flowHighlights = resolveFlowHighlights(timeline.flows, this.document.edges, timeline.flowArrivalProgress, timeline.flowDepartureProgress);
53
75
  const warnings = [];
54
76
  const nodes = [...currentNodes.values()].map((node, index) => {
55
77
  const position = this.resolvePosition(node.id, currentNodes);
@@ -60,27 +82,25 @@ class CompiledDiagram {
60
82
  warnings.push({ path: `nodes[${index}].label`, objectId: node.id, code: "text-overflow", message: `label for node \"${node.id}\" exceeds its height` });
61
83
  const visual = this.resolveVisibility(node.id, timeline.objects);
62
84
  const own = timeline.objects.get(node.id);
63
- const flowHighlighted = timeline.flows.some((flow) => {
64
- const edge = this.document.edges.find((candidate) => candidate.id === flow.target);
65
- return edge?.from === node.id || edge?.to === node.id;
66
- });
85
+ const highlight = Math.max(own.highlight, flowHighlights.get(node.id) ?? 0);
67
86
  const style = {
68
87
  strokeWidth: DEFAULT_NODE_STROKE_WIDTH,
69
88
  textColor: palette.text,
70
89
  ...node.style,
71
- fill: mixColor(own.values.fill ?? palette.fill, palette.highlightFill, Math.max(own.highlight, Number(flowHighlighted))),
72
- stroke: mixColor(own.values.stroke ?? palette.stroke, palette.highlight, Math.max(own.highlight, Number(flowHighlighted)))
90
+ fill: mixColor(own.values.fill ?? palette.fill, palette.highlightFill, highlight),
91
+ stroke: mixColor(own.values.stroke ?? palette.stroke, palette.highlight, highlight)
73
92
  };
74
- const contentLayout = node.content ? paintContentHighlight(layoutNodeContent(node.content, { ...position, width: node.width, height: node.height }, palette, node.kind), Math.max(own.highlight, Number(flowHighlighted)), style.fill) : undefined;
93
+ const contentLayout = node.content ? paintContentHighlight(layoutNodeContent(node.content, { ...position, width: node.width, height: node.height }, palette, node.kind), highlight, style.fill) : undefined;
75
94
  if (contentLayout) {
76
95
  for (const direction of ["width", "height"]) {
77
96
  if (contentLayout[direction === "width" ? "widthOverflow" : "heightOverflow"])
78
97
  warnings.push({ path: `nodes[${index}].content`, objectId: node.id, code: "text-overflow", message: `content for node "${node.id}" exceeds its ${direction}` });
79
98
  }
80
99
  }
81
- return { ...node, contentLayout, ...visual, highlighted: own.highlighted || flowHighlighted, style, x: position.x, y: position.y, labelLines, labelBoxes };
100
+ return { ...node, contentLayout, ...visual, highlighted: own.highlighted || flowHighlights.has(node.id), style, x: position.x, y: position.y, labelLines, labelBoxes };
82
101
  });
83
102
  const nodeById = new Map(nodes.map((node) => [node.id, node]));
103
+ const targetOffsets = routedTargetOffsets(this.document.edges, nodeById);
84
104
  const edgeGeometries = new Map();
85
105
  const edges = this.document.edges.map((original) => {
86
106
  const own = timeline.objects.get(original.id);
@@ -95,20 +115,20 @@ class CompiledDiagram {
95
115
  ...edge.style,
96
116
  strokeWidth: own.values.strokeWidth ?? edge.style?.strokeWidth ?? 2,
97
117
  dashed: edge.style?.dashed ?? false,
98
- stroke: mixColor(own.values.stroke ?? palette.edge, palette.highlight, Math.max(own.highlight, Number(activeFlow !== undefined)))
118
+ stroke: mixColor(own.values.stroke ?? palette.edge, palette.highlight, Math.max(own.highlight, Number(activeFlow !== undefined) * (1 - timeline.flowDepartureProgress)))
99
119
  };
100
120
  const hasReverseCurve = edge.route === "curved" && this.document.edges.some((candidate) => candidate.id !== edge.id && candidate.route === "curved" && candidate.from === edge.to && candidate.to === edge.from);
101
121
  // Both directions use the same lane magnitude; the perpendicular vector reverses with the edge,
102
122
  // so the rendered relationships occupy opposite sides of their shared center line.
103
123
  const reverseOffset = hasReverseCurve ? 1 : 0;
104
- const geometry = from && to ? routeEdge(edge, from, to, nodes, reverseOffset) : emptyPathGeometry();
124
+ const geometry = from && to ? routeEdge({ ...edge, style }, from, to, nodes, { reverseOffset, targetOffset: targetOffsets.get(edge.id) ?? 0 }) : emptyPathGeometry();
105
125
  edgeGeometries.set(edge.id, geometry);
106
126
  const labelLines = edge.label ? wrapText(edge.label, Math.max(1, geometry.length - TEXT_PADDING * 2), EDGE_TYPOGRAPHY) : [];
107
127
  const midpoint = pathLabelAnchor(geometry);
108
128
  const flow = activeFlow && visible && opacity > 0 && !options.reducedMotion ? {
109
129
  direction: activeFlow.direction,
110
- progress: timeline.beatProgress,
111
- point: geometry.pointAt(activeFlow.direction === "forward" ? timeline.beatProgress : 1 - timeline.beatProgress),
130
+ progress: timeline.flowProgress,
131
+ point: geometry.pointAt(activeFlow.direction === "forward" ? timeline.flowProgress : 1 - timeline.flowProgress),
112
132
  ...(activeFlow.text !== undefined ? { text: activeFlow.text } : {})
113
133
  } : undefined;
114
134
  return { ...edge, visible, opacity, highlighted: own.highlighted || activeFlow !== undefined, style, path: geometry.path, labelLines, labelBoxes: makeLabelBoxes(labelLines, midpoint.x, midpoint.y, EDGE_TYPOGRAPHY), ...(flow ? { flow } : {}) };
@@ -166,6 +186,9 @@ const FLOW_TYPOGRAPHY = { fontSize: 12, lineHeight: 16, charWidth: 7.2 };
166
186
  const FLOW_CAPSULE_HEIGHT = 24;
167
187
  const FLOW_TEXT_PADDING = 10;
168
188
  const FLOW_DOT_RADIUS = 5;
189
+ const FLOW_UNITS_PER_SECOND = 180;
190
+ const MIN_FLOW_MOVEMENT_MS = 400;
191
+ const MILLISECONDS_PER_SECOND = 1000;
169
192
  /**
170
193
  * Estimate wrapping with fixed code-point widths, preserving explicit empty lines and preferring spaces as breaks.
171
194
  * No font/DOM measurement is used; this handles width only, while frame evaluation reports height overflow.
@@ -191,6 +214,24 @@ export function wrapText(label, maxWidth, typography = NODE_TYPOGRAPHY) {
191
214
  }
192
215
  return lines;
193
216
  }
217
+ /** Paint senders immediately and receivers after arrival, then remove single-flow paint before beat cleanup. */
218
+ function resolveFlowHighlights(flows, edges, arrivalProgress, departureProgress) {
219
+ const highlights = new Map();
220
+ const amount = 1 - departureProgress;
221
+ for (const flow of flows) {
222
+ if (!flow.highlightParticipants)
223
+ continue;
224
+ const edge = edges.find((candidate) => candidate.id === flow.target);
225
+ if (!edge)
226
+ continue;
227
+ const sender = flow.direction === "forward" ? edge.from : edge.to;
228
+ const receiver = flow.direction === "forward" ? edge.to : edge.from;
229
+ highlights.set(sender, amount);
230
+ // Keep membership at zero paint so arrival does not change logical participant state.
231
+ highlights.set(receiver, Math.max(highlights.get(receiver) ?? 0, arrivalProgress * amount));
232
+ }
233
+ return highlights;
234
+ }
194
235
  function makeLabelBoxes(lines, centerX, centerY, typography) {
195
236
  const startY = centerY - ((lines.length - 1) * typography.lineHeight) / 2 + typography.fontSize * 0.35;
196
237
  return lines.map((text, index) => ({ text, x: centerX, y: startY + index * typography.lineHeight }));
@@ -209,7 +250,7 @@ function serializeSvg(document, frame, palette) {
209
250
  const labels = frame.edges.filter((edge) => edge.opacity > 0).map((edge) => serializeLabel(edge.labelLines, edge.labelBoxes, edge.style?.stroke ?? palette.text, EDGE_TYPOGRAPHY, edge.opacity, palette.background)).join("") + frame.nodes.filter((node) => node.opacity > 0).map((node) => serializeLabel(node.labelLines, node.labelBoxes, node.style?.textColor ?? palette.text, NODE_TYPOGRAPHY, node.opacity)).join("");
210
251
  const contents = frame.nodes.filter((node) => node.opacity > 0 && node.contentLayout).map((node, index) => serializeNodeContent(node, index)).join("");
211
252
  const flows = frame.edges.filter((edge) => edge.opacity > 0 && edge.flow).map((edge) => serializeFlowMarker(edge.flow, edge.id, palette, edge.opacity)).join("");
212
- const marker = `<defs><marker id="diagram-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="context-stroke"/></marker></defs>`;
253
+ const marker = `<defs><marker id="diagram-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="${ARROW_MARKER_SIZE}" markerHeight="${ARROW_MARKER_SIZE}" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="context-stroke"/></marker></defs>`;
213
254
  const ariaLabel = escapeAttribute(document.title ?? "Diagram");
214
255
  const camera = `${formatNumber(frame.camera.x)} ${formatNumber(frame.camera.y)} ${formatNumber(frame.camera.width)} ${formatNumber(frame.camera.height)}`;
215
256
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${camera}" role="img" aria-label="${ariaLabel}" font-family="${escapeAttribute(DIAGRAM_FONT_FAMILY)}"><title>${title}</title>${marker}<rect x="${formatNumber(frame.camera.x)}" y="${formatNumber(frame.camera.y)}" width="${formatNumber(frame.camera.width)}" height="${formatNumber(frame.camera.height)}" fill="${palette.background}"/>${groupShapes}${edgeShapes}${nodeShapes}${labels}${contents}${flows}</svg>`;
@@ -222,7 +263,7 @@ function serializeNodeContent(node, index) {
222
263
  const geometryKey = [safe.x, safe.y, safe.width, safe.height].map((value) => String(value).replaceAll("-", "n").replaceAll(".", "p").replaceAll("+", "a")).join("-");
223
264
  const clipId = `diagram-content-clip-${index}-${geometryKey}`;
224
265
  const clip = `<defs><clipPath id="${clipId}" clipPathUnits="userSpaceOnUse"><rect x="${formatNumber(safe.x)}" y="${formatNumber(safe.y)}" width="${formatNumber(safe.width)}" height="${formatNumber(safe.height)}"/></clipPath></defs>`;
225
- const tags = layout.tags.map((tag) => `<rect x="${formatNumber(tag.x)}" y="${formatNumber(tag.y)}" width="${formatNumber(tag.width)}" height="${formatNumber(tag.height)}" rx="${CONTENT_TAG_CORNER_RADIUS}" fill="${escapeAttribute(tag.fill)}"/>`).join("");
266
+ const tags = layout.tags.map((tag) => `<rect x="${formatNumber(tag.x)}" y="${formatNumber(tag.y)}" width="${formatNumber(tag.width)}" height="${formatNumber(tag.height)}" rx="${formatNumber(tag.shape === "pill" ? tag.height / 2 : CONTENT_TAG_CORNER_RADIUS)}" fill="${escapeAttribute(tag.fill)}"/>`).join("");
226
267
  const lines = layout.lines.map((line) => `<text x="${formatNumber(line.x)}" y="${formatNumber(line.y)}" fill="${escapeAttribute(line.color)}" font-size="${line.fontSize}" font-weight="${line.fontWeight}" text-anchor="${line.align === "left" ? "start" : line.align === "right" ? "end" : "middle"}">${line.spans.map((span) => `<tspan x="${formatNumber(span.x)}" y="${formatNumber(span.y)}" fill="${escapeAttribute(span.color)}" font-size="${span.fontSize}" font-weight="${span.fontWeight}" text-anchor="start">${escapeText(span.text)}</tspan>`).join("")}</text>`).join("");
227
268
  const graphs = (layout.graphs ?? []).map((graph) => `<g data-content-graph="${graph.blockIndex}" aria-hidden="true">${graph.edges.map((edge) => `<line x1="${formatNumber(edge.x1)}" y1="${formatNumber(edge.y1)}" x2="${formatNumber(edge.x2)}" y2="${formatNumber(edge.y2)}" stroke="${edge.color}"/>`).join("")}${graph.nodes.map((point) => `<circle cx="${formatNumber(point.x)}" cy="${formatNumber(point.y)}" r="${point.radius}" fill="${point.fill}"/>`).join("")}</g>`).join("");
228
269
  return `${clip}<g id="diagram-content-${index}" data-content-id="${escapeAttribute(node.id)}" clip-path="url(#${clipId})" opacity="${node.opacity}">${tags}${graphs}${lines}</g>`;
@@ -277,10 +318,12 @@ function serializeLabel(lines, boxes, color, typography, opacity = 1, background
277
318
  return `<text fill="${escapeAttribute(color)}"${halo} font-size="${typography.fontSize}" text-anchor="middle" opacity="${opacity}">${body}</text>`;
278
319
  }
279
320
  const FOCUS_PADDING = 24;
321
+ const MIN_FITTED_HEIGHT = 1;
322
+ const EDGE_LABEL_HALO_RADIUS = 3;
280
323
  const HIT_TOLERANCE = 6;
281
324
  /** Resolve both focus targets against current geometry, then interpolate cameras with the timeline's eased progress. */
282
325
  function resolveCamera(viewport, beforeTarget, afterTarget, progress, nodes, edges, edgeGeometries) {
283
- const full = { x: 0, y: 0, width: viewport.width, height: viewport.height };
326
+ const full = { x: 0, y: 0, width: viewport.width, height: fittedHeight(viewport, nodes, edges, edgeGeometries) };
284
327
  const before = focusCamera(viewport, beforeTarget, nodes, edges, edgeGeometries) ?? full;
285
328
  const after = focusCamera(viewport, afterTarget, nodes, edges, edgeGeometries) ?? full;
286
329
  if (progress <= 0)
@@ -294,6 +337,53 @@ function resolveCamera(viewport, beforeTarget, afterTarget, progress, nodes, edg
294
337
  height: before.height + (after.height - before.height) * progress
295
338
  };
296
339
  }
340
+ /** Full-view fitting keeps authored width and origin; include paint without changing node geometry. */
341
+ function fittedHeight(viewport, nodes, edges, geometries) {
342
+ if (!viewport.fitHeight)
343
+ return viewport.height;
344
+ const bottoms = [];
345
+ for (const node of nodes) {
346
+ if (node.opacity <= 0)
347
+ continue;
348
+ bottoms.push(node.y + node.height + (node.style?.strokeWidth ?? DEFAULT_NODE_STROKE_WIDTH) / 2);
349
+ for (const bottom of labelBottoms(node.labelBoxes, NODE_TYPOGRAPHY))
350
+ bottoms.push(bottom);
351
+ }
352
+ for (const edge of edges) {
353
+ if (edge.opacity <= 0)
354
+ continue;
355
+ const geometry = geometries.get(edge.id);
356
+ if (geometry) {
357
+ const strokeWidth = edge.style?.strokeWidth ?? 2;
358
+ bottoms.push(geometry.paintedBottom + strokeWidth / 2, arrowBottom(geometry, strokeWidth));
359
+ }
360
+ for (const bottom of labelBottoms(edge.labelBoxes, EDGE_TYPOGRAPHY, EDGE_LABEL_HALO_RADIUS))
361
+ bottoms.push(bottom);
362
+ if (edge.flow)
363
+ bottoms.push(edge.flow.point.y + (edge.flow.text === undefined ? FLOW_DOT_RADIUS : FLOW_CAPSULE_HEIGHT / 2));
364
+ }
365
+ if (bottoms.length === 0)
366
+ return viewport.height;
367
+ const bottom = bottoms.reduce((maximum, value) => Math.max(maximum, value), 0);
368
+ const height = Math.max(MIN_FITTED_HEIGHT, Math.ceil(bottom + viewport.fitHeight.padding));
369
+ if (!Number.isFinite(height))
370
+ throw new DiagramValidationError([{ path: "viewport.fitHeight", message: "current painted geometry and padding must produce a finite height" }]);
371
+ return height;
372
+ }
373
+ /** Match the three SVG marker vertices around refX=9, refY=5 in its ten-unit viewBox. */
374
+ function arrowBottom(geometry, strokeWidth) {
375
+ const end = geometry.points.at(-1);
376
+ if (!end)
377
+ return 0;
378
+ const direction = geometry.terminalDirection;
379
+ const markerScale = ARROW_MARKER_SIZE * strokeWidth;
380
+ const vertexOffset = Math.max(0.1 * direction.y, -0.9 * direction.y + 0.5 * Math.abs(direction.x));
381
+ return end.y + markerScale * vertexOffset;
382
+ }
383
+ /** Use the same deterministic text descent as node hit bounds; edge labels also paint a three-unit halo. */
384
+ function labelBottoms(boxes, typography, halo = 0) {
385
+ return boxes.filter((line) => line.text.length > 0).map((line) => line.y + typography.fontSize * TEXT_DESCENT_RATIO + halo);
386
+ }
297
387
  /** Pad bounds of the target, descendants and visible incident edges, preserving aspect ratio within the initial viewport. */
298
388
  function focusCamera(viewport, target, nodes, edges, edgeGeometries) {
299
389
  if (!target)
@@ -9,22 +9,35 @@ interface Rect {
9
9
  right: number;
10
10
  bottom: number;
11
11
  }
12
+ /** SVG marker size in stroke-width units; routing uses the same footprint as serialization. */
13
+ export declare const ARROW_MARKER_SIZE = 7;
12
14
  /** Internal path representation shared by SVG output, labels, bounds, hit testing and distance-based flow placement. */
13
15
  export interface PathGeometry {
14
16
  readonly path: string;
15
17
  readonly points: readonly Point[];
16
18
  readonly length: number;
19
+ /** Exact final tangent for SVG marker bounds; sampled chords can differ on curves. */
20
+ readonly terminalDirection: Point;
21
+ /** Exact vertical paint extent for full-view fitting; independent of distance-sampling resolution. */
22
+ readonly paintedBottom: number;
17
23
  readonly bounds: Rect;
18
24
  pointAt(progress: number): Point;
19
25
  }
20
26
  export declare function emptyPathGeometry(): PathGeometry;
21
27
  export declare function isPointNearPath(point: DiagramPoint, geometry: PathGeometry, tolerance: number): boolean;
22
28
  /**
23
- * Automatic orthogonal routing excludes endpoints and group frames from obstacles.
24
- * Explicit orthogonal ports reserve outward stubs and route around endpoint bodies as well.
29
+ * Orthogonal routes prefer centered bridges and single outside channels before obstacle search.
30
+ * They reserve outward endpoint segments and the final bend before the straight marker approach.
31
+ * Separate ancestor frames set outside clearance but are not obstacles.
32
+ * Curved and orthogonal input slots follow source position with document-order ties, independently of timeline visibility.
25
33
  * Routing uses visible separately from paint opacity: reveals block immediately, and hides block until completion.
26
34
  */
27
- export declare function routeEdge(edge: DiagramEdge, from: EvaluatedNode, to: EvaluatedNode, nodes: readonly EvaluatedNode[], reverseOffset?: number): PathGeometry;
35
+ export declare function routeEdge(edge: DiagramEdge, from: EvaluatedNode, to: EvaluatedNode, nodes: readonly EvaluatedNode[], routing: {
36
+ reverseOffset: number;
37
+ targetOffset: number;
38
+ }): PathGeometry;
39
+ /** Order inputs across each side by source position; retain hidden edges so reveals cannot move other arrow tips. */
40
+ export declare function routedTargetOffsets(edges: readonly DiagramEdge[], nodes: ReadonlyMap<string, EvaluatedNode>): ReadonlyMap<string, number>;
28
41
  export declare function pathLabelAnchor(geometry: PathGeometry): {
29
42
  x: number;
30
43
  y: number;