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.
- package/CHANGELOG.md +28 -0
- package/PATTERNS.md +61 -5
- package/README.md +149 -461
- package/README.zh-CN.md +149 -461
- package/dist/adapters/react.d.ts +7 -0
- package/dist/adapters/react.js +14 -2
- package/dist/adapters/vue.d.ts +29 -3
- package/dist/adapters/vue.js +20 -4
- package/dist/adapters/web-component.d.ts +14 -2
- package/dist/adapters/web-component.js +32 -3
- package/dist/browser/controls.d.ts +7 -3
- package/dist/browser/controls.js +23 -31
- package/dist/browser/export-controls.d.ts +32 -0
- package/dist/browser/export-controls.js +151 -0
- package/dist/browser/player.d.ts +9 -1
- package/dist/browser/player.js +83 -24
- package/dist/browser/select-control.d.ts +33 -0
- package/dist/browser/select-control.js +171 -0
- package/dist/browser.d.ts +2 -1
- package/dist/browser.js +2 -1
- package/dist/core/diagram.d.ts +4 -2
- package/dist/core/diagram.js +111 -21
- package/dist/core/edge-geometry.d.ts +16 -3
- package/dist/core/edge-geometry.js +264 -37
- package/dist/core/model.d.ts +35 -9
- package/dist/core/node-content-measurement.d.ts +34 -0
- package/dist/core/node-content-measurement.js +66 -0
- package/dist/core/node-content.d.ts +17 -0
- package/dist/core/node-content.js +30 -6
- package/dist/core/timeline.d.ts +11 -3
- package/dist/core/timeline.js +29 -5
- package/dist/core/validation.d.ts +1 -1
- package/dist/core/validation.js +13 -3
- package/dist/export/browser.d.ts +9 -0
- package/dist/export/browser.js +172 -0
- package/dist/export/captions.d.ts +18 -0
- package/dist/export/captions.js +44 -0
- package/dist/export/plan.d.ts +25 -0
- package/dist/export/plan.js +94 -0
- package/dist/export/types.d.ts +64 -0
- package/dist/export/types.js +9 -0
- package/dist/export/worker-source.d.ts +1 -0
- package/dist/export/worker-source.js +1 -0
- package/dist/export.d.ts +3 -0
- package/dist/export.js +2 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/node/cli.d.ts +2 -0
- package/dist/node/cli.js +72 -0
- package/dist/node/gif.d.ts +16 -0
- package/dist/node/gif.js +184 -0
- package/dist/node/html.d.ts +19 -0
- package/dist/node/html.js +61 -0
- package/dist/node/output.d.ts +2 -0
- package/dist/node/output.js +22 -0
- package/dist/node.d.ts +6 -0
- package/dist/node.js +3 -0
- package/dist/portable/LICENSE.esbuild.txt +21 -0
- package/dist/portable/LICENSE.flowsvg.txt +21 -0
- package/dist/portable/LICENSE.gifenc.txt +21 -0
- package/dist/portable/export.js +5 -0
- package/dist/portable/manifest.json +36 -0
- package/dist/portable/player.js +2 -0
- package/dist/portable/styles.css +227 -0
- package/dist/styles.css +194 -14
- package/docs/images/flowsvg-wordmark.gif +0 -0
- package/package.json +37 -6
package/dist/core/diagram.js
CHANGED
|
@@ -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 =
|
|
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
|
|
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,
|
|
72
|
-
stroke: mixColor(own.values.stroke ?? palette.stroke, palette.highlight,
|
|
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),
|
|
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 ||
|
|
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.
|
|
111
|
-
point: geometry.pointAt(activeFlow.direction === "forward" ? timeline.
|
|
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="
|
|
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
|
|
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
|
-
*
|
|
24
|
-
*
|
|
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[],
|
|
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;
|