flowsvg 0.1.0 → 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 +54 -1
- package/PATTERNS.md +71 -4
- package/README.md +148 -438
- package/README.zh-CN.md +148 -438
- package/dist/adapters/react.d.ts +55 -0
- package/dist/adapters/react.js +138 -0
- package/dist/adapters/vue.d.ts +158 -0
- package/dist/adapters/vue.js +177 -0
- package/dist/adapters/web-component.d.ts +111 -0
- package/dist/adapters/web-component.js +425 -0
- package/dist/{player-controls.d.ts → browser/controls.d.ts} +7 -3
- package/dist/{player-controls.js → 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 +77 -0
- package/dist/browser/player.js +412 -0
- package/dist/browser/select-control.d.ts +33 -0
- package/dist/browser/select-control.js +171 -0
- package/dist/browser.d.ts +5 -69
- package/dist/browser.js +3 -353
- package/dist/core/color.d.ts +3 -0
- package/dist/core/color.js +18 -0
- package/dist/core/diagram.d.ts +40 -0
- package/dist/core/diagram.js +481 -0
- package/dist/core/edge-geometry.d.ts +45 -0
- package/dist/core/edge-geometry.js +609 -0
- package/dist/{core.d.ts → core/model.d.ts} +37 -61
- package/dist/core/model.js +1 -0
- package/dist/core/node-content-measurement.d.ts +34 -0
- package/dist/core/node-content-measurement.js +66 -0
- package/dist/{node-content-paint.d.ts → core/node-content-paint.d.ts} +2 -2
- package/dist/{node-content-paint.js → core/node-content-paint.js} +11 -20
- package/dist/{node-content.d.ts → core/node-content.d.ts} +18 -1
- package/dist/{node-content.js → core/node-content.js} +30 -6
- package/dist/core/svg-number.d.ts +5 -0
- package/dist/core/svg-number.js +7 -0
- package/dist/{theme.d.ts → core/theme.d.ts} +6 -0
- package/dist/core/theme.js +17 -0
- package/dist/{timeline.d.ts → core/timeline.d.ts} +11 -5
- package/dist/{timeline.js → core/timeline.js} +30 -22
- package/dist/core/validation.d.ts +11 -0
- package/dist/core/validation.js +399 -0
- 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 +7 -1
- package/dist/index.js +3 -1
- 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/react.d.ts +3 -48
- package/dist/react.js +2 -126
- package/dist/styles.css +194 -14
- package/dist/vue.d.ts +3 -132
- package/dist/vue.js +2 -161
- package/dist/web-component.d.ts +3 -103
- package/dist/web-component.js +2 -396
- package/docs/images/entity-graph.png +0 -0
- package/docs/images/facts-timeline.png +0 -0
- package/docs/images/facts-timeline.zh-CN.png +0 -0
- package/docs/images/flowsvg-wordmark.gif +0 -0
- package/docs/images/node-content.png +0 -0
- package/docs/images/numbered-list.png +0 -0
- package/docs/images/numbered-list.zh-CN.png +0 -0
- package/docs/images/quick-start.png +0 -0
- package/docs/images/record-tags.png +0 -0
- package/package.json +38 -6
- package/dist/core.js +0 -1149
- package/dist/theme.js +0 -6
- /package/dist/{player-accessibility.d.ts → browser/accessibility.d.ts} +0 -0
- /package/dist/{player-accessibility.js → browser/accessibility.js} +0 -0
- /package/dist/{player-lifecycle.d.ts → browser/lifecycle.d.ts} +0 -0
- /package/dist/{player-lifecycle.js → browser/lifecycle.js} +0 -0
- /package/dist/{svg-dom.d.ts → browser/svg-dom.d.ts} +0 -0
- /package/dist/{svg-dom.js → browser/svg-dom.js} +0 -0
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform-independent diagram orchestration: compile documents and derive frames, geometry, hit tests and SVG.
|
|
3
|
+
* Validation owns document rules; this module owns cloning and freezing and does not read DOM, clocks or network.
|
|
4
|
+
*/
|
|
5
|
+
import { layoutNodeContent } from "./node-content.js";
|
|
6
|
+
import { paintContentHighlight } from "./node-content-paint.js";
|
|
7
|
+
import { CompiledTimeline } from "./timeline.js";
|
|
8
|
+
import { mixColor } from "./color.js";
|
|
9
|
+
import { DEFAULT_NODE_STROKE_WIDTH, DIAGRAM_FONT_FAMILY, NODE_CORNER_RADIUS, resolveThemePalette } from "./theme.js";
|
|
10
|
+
import { DiagramValidationError, validateDocument } from "./validation.js";
|
|
11
|
+
import { ARROW_MARKER_SIZE, emptyPathGeometry, isPointNearPath, routedTargetOffsets, pathLabelAnchor, routeEdge } from "./edge-geometry.js";
|
|
12
|
+
import { formatNumber } from "./svg-number.js";
|
|
13
|
+
/**
|
|
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, or derived flow timing is non-finite.
|
|
16
|
+
*/
|
|
17
|
+
export function createDiagram(input) {
|
|
18
|
+
const issues = validateDocument(input);
|
|
19
|
+
if (issues.length > 0)
|
|
20
|
+
throw new DiagramValidationError(issues);
|
|
21
|
+
return new CompiledDiagram(cloneDocument(input));
|
|
22
|
+
}
|
|
23
|
+
/** One-shot static SVG export; use createDiagram to reuse compilation across repeated evaluations or exports. */
|
|
24
|
+
export function renderSvg(document, timeMs = 0, options) {
|
|
25
|
+
return createDiagram(document).renderSvg(timeMs, options);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Own the frozen document and compiled timeline; derive visual values, absolute geometry/text, routes, then camera.
|
|
29
|
+
* The ancestor index retains hierarchy; geometry is derived from current values on every frame to support group movement.
|
|
30
|
+
* See docs/adr/0001-platform-independent-core.md for the platform boundary.
|
|
31
|
+
*/
|
|
32
|
+
class CompiledDiagram {
|
|
33
|
+
totalDuration;
|
|
34
|
+
beats;
|
|
35
|
+
timeline;
|
|
36
|
+
document;
|
|
37
|
+
nodesById;
|
|
38
|
+
constructor(document) {
|
|
39
|
+
this.document = deepFreeze(document);
|
|
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;
|
|
63
|
+
}
|
|
64
|
+
evaluateAt(timeMs, options = {}) {
|
|
65
|
+
return this.evaluateFrame(timeMs, options).frame;
|
|
66
|
+
}
|
|
67
|
+
evaluateFrame(timeMs, options = {}, compiledTimeline = this.timeline) {
|
|
68
|
+
const palette = resolvePalette(this.document, options);
|
|
69
|
+
const timeline = compiledTimeline.evaluateAt(timeMs, options, palette);
|
|
70
|
+
const currentNodes = new Map(this.document.nodes.map((node) => {
|
|
71
|
+
const values = timeline.objects.get(node.id).values;
|
|
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 }];
|
|
73
|
+
}));
|
|
74
|
+
const flowHighlights = resolveFlowHighlights(timeline.flows, this.document.edges, timeline.flowArrivalProgress, timeline.flowDepartureProgress);
|
|
75
|
+
const warnings = [];
|
|
76
|
+
const nodes = [...currentNodes.values()].map((node, index) => {
|
|
77
|
+
const position = this.resolvePosition(node.id, currentNodes);
|
|
78
|
+
const labelLines = node.label ? wrapText(node.label, Math.max(1, node.width - TEXT_PADDING * 2), NODE_TYPOGRAPHY) : [];
|
|
79
|
+
const labelCenterY = node.kind === "group" ? position.y + 24 : position.y + node.height / 2;
|
|
80
|
+
const labelBoxes = makeLabelBoxes(labelLines, position.x + node.width / 2, labelCenterY, NODE_TYPOGRAPHY);
|
|
81
|
+
if (node.label && labelLines.length * NODE_TYPOGRAPHY.lineHeight > Math.max(0, node.height - TEXT_PADDING * 2))
|
|
82
|
+
warnings.push({ path: `nodes[${index}].label`, objectId: node.id, code: "text-overflow", message: `label for node \"${node.id}\" exceeds its height` });
|
|
83
|
+
const visual = this.resolveVisibility(node.id, timeline.objects);
|
|
84
|
+
const own = timeline.objects.get(node.id);
|
|
85
|
+
const highlight = Math.max(own.highlight, flowHighlights.get(node.id) ?? 0);
|
|
86
|
+
const style = {
|
|
87
|
+
strokeWidth: DEFAULT_NODE_STROKE_WIDTH,
|
|
88
|
+
textColor: palette.text,
|
|
89
|
+
...node.style,
|
|
90
|
+
fill: mixColor(own.values.fill ?? palette.fill, palette.highlightFill, highlight),
|
|
91
|
+
stroke: mixColor(own.values.stroke ?? palette.stroke, palette.highlight, highlight)
|
|
92
|
+
};
|
|
93
|
+
const contentLayout = node.content ? paintContentHighlight(layoutNodeContent(node.content, { ...position, width: node.width, height: node.height }, palette, node.kind), highlight, style.fill) : undefined;
|
|
94
|
+
if (contentLayout) {
|
|
95
|
+
for (const direction of ["width", "height"]) {
|
|
96
|
+
if (contentLayout[direction === "width" ? "widthOverflow" : "heightOverflow"])
|
|
97
|
+
warnings.push({ path: `nodes[${index}].content`, objectId: node.id, code: "text-overflow", message: `content for node "${node.id}" exceeds its ${direction}` });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { ...node, contentLayout, ...visual, highlighted: own.highlighted || flowHighlights.has(node.id), style, x: position.x, y: position.y, labelLines, labelBoxes };
|
|
101
|
+
});
|
|
102
|
+
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
|
103
|
+
const targetOffsets = routedTargetOffsets(this.document.edges, nodeById);
|
|
104
|
+
const edgeGeometries = new Map();
|
|
105
|
+
const edges = this.document.edges.map((original) => {
|
|
106
|
+
const own = timeline.objects.get(original.id);
|
|
107
|
+
const activeFlow = timeline.flows.find((flow) => flow.target === original.id);
|
|
108
|
+
const edge = { ...original, label: own.values.label };
|
|
109
|
+
const from = nodeById.get(edge.from);
|
|
110
|
+
const to = nodeById.get(edge.to);
|
|
111
|
+
const visible = own.visible && Boolean(from?.visible && to?.visible);
|
|
112
|
+
// Take the minimum so simultaneous endpoint fades do not darken an edge again through multiplication.
|
|
113
|
+
const opacity = Math.min(own.opacity, from?.opacity ?? 0, to?.opacity ?? 0);
|
|
114
|
+
const style = {
|
|
115
|
+
...edge.style,
|
|
116
|
+
strokeWidth: own.values.strokeWidth ?? edge.style?.strokeWidth ?? 2,
|
|
117
|
+
dashed: edge.style?.dashed ?? false,
|
|
118
|
+
stroke: mixColor(own.values.stroke ?? palette.edge, palette.highlight, Math.max(own.highlight, Number(activeFlow !== undefined) * (1 - timeline.flowDepartureProgress)))
|
|
119
|
+
};
|
|
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);
|
|
121
|
+
// Both directions use the same lane magnitude; the perpendicular vector reverses with the edge,
|
|
122
|
+
// so the rendered relationships occupy opposite sides of their shared center line.
|
|
123
|
+
const reverseOffset = hasReverseCurve ? 1 : 0;
|
|
124
|
+
const geometry = from && to ? routeEdge({ ...edge, style }, from, to, nodes, { reverseOffset, targetOffset: targetOffsets.get(edge.id) ?? 0 }) : emptyPathGeometry();
|
|
125
|
+
edgeGeometries.set(edge.id, geometry);
|
|
126
|
+
const labelLines = edge.label ? wrapText(edge.label, Math.max(1, geometry.length - TEXT_PADDING * 2), EDGE_TYPOGRAPHY) : [];
|
|
127
|
+
const midpoint = pathLabelAnchor(geometry);
|
|
128
|
+
const flow = activeFlow && visible && opacity > 0 && !options.reducedMotion ? {
|
|
129
|
+
direction: activeFlow.direction,
|
|
130
|
+
progress: timeline.flowProgress,
|
|
131
|
+
point: geometry.pointAt(activeFlow.direction === "forward" ? timeline.flowProgress : 1 - timeline.flowProgress),
|
|
132
|
+
...(activeFlow.text !== undefined ? { text: activeFlow.text } : {})
|
|
133
|
+
} : undefined;
|
|
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 } : {}) };
|
|
135
|
+
});
|
|
136
|
+
const camera = resolveCamera(this.document.viewport, timeline.focusBefore, timeline.focusAfter, timeline.transitionProgress, nodes, edges, edgeGeometries);
|
|
137
|
+
return {
|
|
138
|
+
frame: { timeMs: timeline.timeMs, sceneId: timeline.sceneId, beatId: timeline.beatId, beatProgress: timeline.beatProgress, nodes, edges, camera, warnings, ...(timeline.caption !== undefined ? { caption: timeline.caption } : {}) },
|
|
139
|
+
edgeGeometries
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
renderSvg(timeMs = 0, options = {}) {
|
|
143
|
+
const frame = this.evaluateAt(timeMs, options);
|
|
144
|
+
const palette = resolvePalette(this.document, options);
|
|
145
|
+
return serializeSvg(this.document, frame, palette);
|
|
146
|
+
}
|
|
147
|
+
hitTest(timeMs, point, options = {}) {
|
|
148
|
+
if (!Number.isFinite(point.x) || !Number.isFinite(point.y))
|
|
149
|
+
return null;
|
|
150
|
+
const evaluated = this.evaluateFrame(timeMs, options);
|
|
151
|
+
const frame = evaluated.frame;
|
|
152
|
+
const nodes = [...frame.nodes].reverse();
|
|
153
|
+
const containsPoint = (candidate) => candidate.visible && candidate.opacity > 0 && isPointInNode(point, candidate);
|
|
154
|
+
const node = nodes.find((candidate) => candidate.kind !== "group" && containsPoint(candidate));
|
|
155
|
+
if (node)
|
|
156
|
+
return { id: node.id, type: "node" };
|
|
157
|
+
const edge = [...frame.edges].reverse().find((candidate) => candidate.visible && candidate.opacity > 0 && isPointNearPath(point, evaluated.edgeGeometries.get(candidate.id) ?? emptyPathGeometry(), (candidate.style?.strokeWidth ?? 2) + HIT_TOLERANCE));
|
|
158
|
+
if (edge)
|
|
159
|
+
return { id: edge.id, type: "edge" };
|
|
160
|
+
const group = nodes.find((candidate) => candidate.kind === "group" && containsPoint(candidate));
|
|
161
|
+
return group ? { id: group.id, type: "node" } : null;
|
|
162
|
+
}
|
|
163
|
+
resolvePosition(id, nodes) {
|
|
164
|
+
const node = nodes.get(id);
|
|
165
|
+
if (!node)
|
|
166
|
+
return { x: 0, y: 0 };
|
|
167
|
+
const parent = node.parentId ? this.resolvePosition(node.parentId, nodes) : { x: 0, y: 0 };
|
|
168
|
+
return { x: node.x + parent.x, y: node.y + parent.y };
|
|
169
|
+
}
|
|
170
|
+
/** Intersect ancestor visibility and take minimum opacity without altering a child's stored state. */
|
|
171
|
+
resolveVisibility(id, states) {
|
|
172
|
+
const node = this.nodesById.get(id);
|
|
173
|
+
const own = states.get(id);
|
|
174
|
+
if (!node?.parentId)
|
|
175
|
+
return { visible: own.visible, opacity: own.opacity };
|
|
176
|
+
const parent = this.resolveVisibility(node.parentId, states);
|
|
177
|
+
return { visible: own.visible && parent.visible, opacity: Math.min(own.opacity, parent.opacity) };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const TEXT_PADDING = 12;
|
|
181
|
+
const CONTENT_TAG_CORNER_RADIUS = 3;
|
|
182
|
+
const NODE_TYPOGRAPHY = { fontSize: 16, lineHeight: 20, charWidth: 8.5 };
|
|
183
|
+
const EDGE_TYPOGRAPHY = { fontSize: 14, lineHeight: 18, charWidth: 8.4 };
|
|
184
|
+
const TEXT_DESCENT_RATIO = 0.25;
|
|
185
|
+
const FLOW_TYPOGRAPHY = { fontSize: 12, lineHeight: 16, charWidth: 7.2 };
|
|
186
|
+
const FLOW_CAPSULE_HEIGHT = 24;
|
|
187
|
+
const FLOW_TEXT_PADDING = 10;
|
|
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;
|
|
192
|
+
/**
|
|
193
|
+
* Estimate wrapping with fixed code-point widths, preserving explicit empty lines and preferring spaces as breaks.
|
|
194
|
+
* No font/DOM measurement is used; this handles width only, while frame evaluation reports height overflow.
|
|
195
|
+
*/
|
|
196
|
+
export function wrapText(label, maxWidth, typography = NODE_TYPOGRAPHY) {
|
|
197
|
+
const maxChars = Math.max(1, Math.floor(maxWidth / typography.charWidth));
|
|
198
|
+
const lines = [];
|
|
199
|
+
for (const explicitLine of label.split("\n")) {
|
|
200
|
+
if (explicitLine.length === 0) {
|
|
201
|
+
lines.push("");
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
// Code-point slicing keeps surrogate pairs intact even when only one character fits.
|
|
205
|
+
let remaining = Array.from(explicitLine);
|
|
206
|
+
while (remaining.length > maxChars) {
|
|
207
|
+
const candidate = remaining.slice(0, maxChars + 1);
|
|
208
|
+
const breakAt = candidate.lastIndexOf(" ");
|
|
209
|
+
const cut = breakAt > 0 ? breakAt : maxChars;
|
|
210
|
+
lines.push(remaining.slice(0, cut).join(""));
|
|
211
|
+
remaining = Array.from(remaining.slice(cut).join("").replace(/^\s+/, ""));
|
|
212
|
+
}
|
|
213
|
+
lines.push(remaining.join(""));
|
|
214
|
+
}
|
|
215
|
+
return lines;
|
|
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
|
+
}
|
|
235
|
+
function makeLabelBoxes(lines, centerX, centerY, typography) {
|
|
236
|
+
const startY = centerY - ((lines.length - 1) * typography.lineHeight) / 2 + typography.fontSize * 0.35;
|
|
237
|
+
return lines.map((text, index) => ({ text, x: centerX, y: startY + index * typography.lineHeight }));
|
|
238
|
+
}
|
|
239
|
+
/** Serialize group backgrounds, edges, ordinary nodes, then labels; write the evaluated camera directly to viewBox. */
|
|
240
|
+
function serializeSvg(document, frame, palette) {
|
|
241
|
+
const title = escapeText(document.title ?? "Diagram");
|
|
242
|
+
const edgeShapes = frame.edges.filter((edge) => edge.opacity > 0).map((edge) => {
|
|
243
|
+
const stroke = edge.style?.stroke ?? palette.edge;
|
|
244
|
+
const width = edge.style?.strokeWidth ?? 2;
|
|
245
|
+
const dash = edge.style?.dashed ? ' stroke-dasharray="6 4"' : "";
|
|
246
|
+
return `<path d="${escapeAttribute(edge.path)}" fill="none" stroke="${escapeAttribute(stroke)}" color="${escapeAttribute(stroke)}" stroke-width="${width}"${dash} marker-end="url(#diagram-arrow)" data-id="${escapeAttribute(edge.id)}" opacity="${edge.opacity}"/>`;
|
|
247
|
+
}).join("");
|
|
248
|
+
const groupShapes = frame.nodes.filter((node) => node.opacity > 0 && node.kind === "group").map((node) => serializeNodeShape(node, palette)).join("");
|
|
249
|
+
const nodeShapes = frame.nodes.filter((node) => node.opacity > 0 && node.kind !== "group").map((node) => serializeNodeShape(node, palette)).join("");
|
|
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("");
|
|
251
|
+
const contents = frame.nodes.filter((node) => node.opacity > 0 && node.contentLayout).map((node, index) => serializeNodeContent(node, index)).join("");
|
|
252
|
+
const flows = frame.edges.filter((edge) => edge.opacity > 0 && edge.flow).map((edge) => serializeFlowMarker(edge.flow, edge.id, palette, edge.opacity)).join("");
|
|
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>`;
|
|
254
|
+
const ariaLabel = escapeAttribute(document.title ?? "Diagram");
|
|
255
|
+
const camera = `${formatNumber(frame.camera.x)} ${formatNumber(frame.camera.y)} ${formatNumber(frame.camera.width)} ${formatNumber(frame.camera.height)}`;
|
|
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>`;
|
|
257
|
+
}
|
|
258
|
+
/** Serialize evaluated text and tag backgrounds inside one clip; authored content cannot introduce SVG markup or CSS. */
|
|
259
|
+
function serializeNodeContent(node, index) {
|
|
260
|
+
const layout = node.contentLayout;
|
|
261
|
+
const safe = layout.safeArea;
|
|
262
|
+
// Include exact clip geometry so independent SVGs with different node dimensions do not share a clip definition.
|
|
263
|
+
const geometryKey = [safe.x, safe.y, safe.width, safe.height].map((value) => String(value).replaceAll("-", "n").replaceAll(".", "p").replaceAll("+", "a")).join("-");
|
|
264
|
+
const clipId = `diagram-content-clip-${index}-${geometryKey}`;
|
|
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>`;
|
|
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("");
|
|
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("");
|
|
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("");
|
|
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>`;
|
|
270
|
+
}
|
|
271
|
+
/** Keep markers decorative and horizontal; coordinates come only from the evaluated path point. */
|
|
272
|
+
function serializeFlowMarker(flow, edgeId, palette, opacity) {
|
|
273
|
+
const attributes = `fill="${palette.highlight}" data-flow-id="${escapeAttribute(edgeId)}" opacity="${opacity}" pointer-events="none" aria-hidden="true"`;
|
|
274
|
+
if (flow.text === undefined)
|
|
275
|
+
return `<circle cx="${formatNumber(flow.point.x)}" cy="${formatNumber(flow.point.y)}" r="${FLOW_DOT_RADIUS}" ${attributes}/>`;
|
|
276
|
+
const width = Math.max(FLOW_CAPSULE_HEIGHT, estimateTextWidth(flow.text, FLOW_TYPOGRAPHY) + FLOW_TEXT_PADDING * 2);
|
|
277
|
+
const x = formatNumber(flow.point.x - width / 2);
|
|
278
|
+
const y = formatNumber(flow.point.y - FLOW_CAPSULE_HEIGHT / 2);
|
|
279
|
+
// Marker ink follows its saturated fill, independently of node text and interior highlight paint.
|
|
280
|
+
const text = `<text x="${formatNumber(flow.point.x)}" y="${formatNumber(flow.point.y + FLOW_TYPOGRAPHY.fontSize * 0.35)}" fill="${palette.highlightText}" font-size="${FLOW_TYPOGRAPHY.fontSize}" text-anchor="middle">${escapeText(flow.text)}</text>`;
|
|
281
|
+
return `<g ${attributes}><rect x="${x}" y="${y}" width="${formatNumber(width)}" height="${FLOW_CAPSULE_HEIGHT}" rx="${FLOW_CAPSULE_HEIGHT / 2}"/>${text}</g>`;
|
|
282
|
+
}
|
|
283
|
+
/** Reuse fixed code-point metrics without DOM measurements; reserve a full em for wide letters and Unicode. */
|
|
284
|
+
function estimateTextWidth(text, typography) {
|
|
285
|
+
return Array.from(text).reduce((width, character) => {
|
|
286
|
+
const wide = character.codePointAt(0) > 0xff || "MWmw@%".includes(character);
|
|
287
|
+
return width + (wide ? typography.fontSize : typography.charWidth);
|
|
288
|
+
}, 0);
|
|
289
|
+
}
|
|
290
|
+
/** Serialize supported shapes with escaped color/identity attributes; labels are drawn in a separate text layer. */
|
|
291
|
+
function serializeNodeShape(node, palette) {
|
|
292
|
+
const fill = node.style?.fill ?? palette.fill;
|
|
293
|
+
const stroke = node.style?.stroke ?? palette.stroke;
|
|
294
|
+
const width = node.style?.strokeWidth ?? DEFAULT_NODE_STROKE_WIDTH;
|
|
295
|
+
const dash = node.kind === "group" && node.style?.dashed ? ' stroke-dasharray="10 7"' : "";
|
|
296
|
+
const attrs = `fill="${escapeAttribute(fill)}" stroke="${escapeAttribute(stroke)}" stroke-width="${width}"${dash} data-id="${escapeAttribute(node.id)}" opacity="${node.opacity}"`;
|
|
297
|
+
if (node.kind === "ellipse")
|
|
298
|
+
return `<ellipse cx="${formatNumber(node.x + node.width / 2)}" cy="${formatNumber(node.y + node.height / 2)}" rx="${formatNumber(node.width / 2)}" ry="${formatNumber(node.height / 2)}" ${attrs}/>`;
|
|
299
|
+
if (node.kind === "cylinder") {
|
|
300
|
+
const ry = Math.min(14, node.height / 4);
|
|
301
|
+
const bottom = node.y + node.height - ry;
|
|
302
|
+
const bodyPath = `M ${formatNumber(node.x)} ${formatNumber(node.y + ry)} L ${formatNumber(node.x + node.width)} ${formatNumber(node.y + ry)} L ${formatNumber(node.x + node.width)} ${formatNumber(bottom)} A ${formatNumber(node.width / 2)} ${formatNumber(ry)} 0 0 1 ${formatNumber(node.x)} ${formatNumber(bottom)} Z`;
|
|
303
|
+
// Keep the cap as a filled sibling so the SVG fill rule cannot turn its interior into a hole.
|
|
304
|
+
// The group owns the only data-id, allowing hover and DOM synchronization to treat the cylinder as one node.
|
|
305
|
+
const cap = `<ellipse cx="${formatNumber(node.x + node.width / 2)}" cy="${formatNumber(node.y + ry)}" rx="${formatNumber(node.width / 2)}" ry="${formatNumber(ry)}"/>`;
|
|
306
|
+
return `<g ${attrs}><path d="${bodyPath}"/>${cap}</g>`;
|
|
307
|
+
}
|
|
308
|
+
const radius = node.kind === "roundedRect" ? ` rx="${Math.min(NODE_CORNER_RADIUS, node.width / 4, node.height / 4)}"` : "";
|
|
309
|
+
return `<rect x="${formatNumber(node.x)}" y="${formatNumber(node.y)}" width="${formatNumber(node.width)}" height="${formatNumber(node.height)}"${radius} ${attrs}/>`;
|
|
310
|
+
}
|
|
311
|
+
function serializeLabel(lines, boxes, color, typography, opacity = 1, background) {
|
|
312
|
+
if (lines.length === 0)
|
|
313
|
+
return "";
|
|
314
|
+
const x = boxes[0]?.x ?? 0;
|
|
315
|
+
const body = lines.map((line, index) => `<tspan x="${formatNumber(x)}" y="${formatNumber(boxes[index]?.y ?? 0)}">${escapeText(line)}</tspan>`).join("");
|
|
316
|
+
// Only edge labels need background isolation from the path beneath them.
|
|
317
|
+
const halo = background ? ` stroke="${escapeAttribute(background)}" stroke-width="6" stroke-linejoin="round" paint-order="stroke"` : "";
|
|
318
|
+
return `<text fill="${escapeAttribute(color)}"${halo} font-size="${typography.fontSize}" text-anchor="middle" opacity="${opacity}">${body}</text>`;
|
|
319
|
+
}
|
|
320
|
+
const FOCUS_PADDING = 24;
|
|
321
|
+
const MIN_FITTED_HEIGHT = 1;
|
|
322
|
+
const EDGE_LABEL_HALO_RADIUS = 3;
|
|
323
|
+
const HIT_TOLERANCE = 6;
|
|
324
|
+
/** Resolve both focus targets against current geometry, then interpolate cameras with the timeline's eased progress. */
|
|
325
|
+
function resolveCamera(viewport, beforeTarget, afterTarget, progress, nodes, edges, edgeGeometries) {
|
|
326
|
+
const full = { x: 0, y: 0, width: viewport.width, height: fittedHeight(viewport, nodes, edges, edgeGeometries) };
|
|
327
|
+
const before = focusCamera(viewport, beforeTarget, nodes, edges, edgeGeometries) ?? full;
|
|
328
|
+
const after = focusCamera(viewport, afterTarget, nodes, edges, edgeGeometries) ?? full;
|
|
329
|
+
if (progress <= 0)
|
|
330
|
+
return before;
|
|
331
|
+
if (progress >= 1)
|
|
332
|
+
return after;
|
|
333
|
+
return {
|
|
334
|
+
x: before.x + (after.x - before.x) * progress,
|
|
335
|
+
y: before.y + (after.y - before.y) * progress,
|
|
336
|
+
width: before.width + (after.width - before.width) * progress,
|
|
337
|
+
height: before.height + (after.height - before.height) * progress
|
|
338
|
+
};
|
|
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
|
+
}
|
|
387
|
+
/** Pad bounds of the target, descendants and visible incident edges, preserving aspect ratio within the initial viewport. */
|
|
388
|
+
function focusCamera(viewport, target, nodes, edges, edgeGeometries) {
|
|
389
|
+
if (!target)
|
|
390
|
+
return undefined;
|
|
391
|
+
const targetNode = nodes.find((node) => node.id === target);
|
|
392
|
+
if (!targetNode)
|
|
393
|
+
return undefined;
|
|
394
|
+
const targetIds = new Set(nodes.filter((node) => node.id === target || hasAncestor(node, target, nodes)).map((node) => node.id));
|
|
395
|
+
const bounds = nodes.filter((node) => targetIds.has(node.id)).map(nodeBounds);
|
|
396
|
+
for (const edge of edges) {
|
|
397
|
+
if (edge.visible && edge.opacity > 0 && (targetIds.has(edge.from) || targetIds.has(edge.to)))
|
|
398
|
+
bounds.push((edgeGeometries.get(edge.id) ?? emptyPathGeometry()).bounds);
|
|
399
|
+
}
|
|
400
|
+
const combined = combineBounds(bounds);
|
|
401
|
+
if (!combined)
|
|
402
|
+
return undefined;
|
|
403
|
+
const aspect = viewport.width / viewport.height;
|
|
404
|
+
const paddedWidth = combined.right - combined.left + FOCUS_PADDING * 2;
|
|
405
|
+
const paddedHeight = combined.bottom - combined.top + FOCUS_PADDING * 2;
|
|
406
|
+
let width = Math.max(paddedWidth, paddedHeight * aspect);
|
|
407
|
+
let height = Math.max(paddedHeight, paddedWidth / aspect);
|
|
408
|
+
if (width > viewport.width) {
|
|
409
|
+
width = viewport.width;
|
|
410
|
+
height = width / aspect;
|
|
411
|
+
}
|
|
412
|
+
if (height > viewport.height) {
|
|
413
|
+
height = viewport.height;
|
|
414
|
+
width = height * aspect;
|
|
415
|
+
}
|
|
416
|
+
const centerX = (combined.left + combined.right) / 2;
|
|
417
|
+
const centerY = (combined.top + combined.bottom) / 2;
|
|
418
|
+
const x = clampCamera(centerX - width / 2, 0, viewport.width - width);
|
|
419
|
+
const y = clampCamera(centerY - height / 2, 0, viewport.height - height);
|
|
420
|
+
return { x, y, width, height };
|
|
421
|
+
}
|
|
422
|
+
function hasAncestor(node, ancestorId, nodes) {
|
|
423
|
+
const byId = new Map(nodes.map((candidate) => [candidate.id, candidate]));
|
|
424
|
+
let parentId = node.parentId;
|
|
425
|
+
while (parentId) {
|
|
426
|
+
if (parentId === ancestorId)
|
|
427
|
+
return true;
|
|
428
|
+
parentId = byId.get(parentId)?.parentId;
|
|
429
|
+
}
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
function nodeBounds(node) {
|
|
433
|
+
return { left: node.x, top: node.y, right: node.x + node.width, bottom: node.y + node.height };
|
|
434
|
+
}
|
|
435
|
+
function combineBounds(bounds) {
|
|
436
|
+
if (bounds.length === 0)
|
|
437
|
+
return undefined;
|
|
438
|
+
return { left: Math.min(...bounds.map((bound) => bound.left)), top: Math.min(...bounds.map((bound) => bound.top)), right: Math.max(...bounds.map((bound) => bound.right)), bottom: Math.max(...bounds.map((bound) => bound.bottom)) };
|
|
439
|
+
}
|
|
440
|
+
function clampCamera(value, min, max) {
|
|
441
|
+
return Math.min(max, Math.max(min, value));
|
|
442
|
+
}
|
|
443
|
+
/** Include deterministic label bounds so overflowing text remains part of the node's interaction target. */
|
|
444
|
+
function isPointInNode(point, node) {
|
|
445
|
+
const labelHit = node.labelBoxes.some((line) => {
|
|
446
|
+
if (line.text.length === 0)
|
|
447
|
+
return false;
|
|
448
|
+
const halfWidth = estimateTextWidth(line.text, NODE_TYPOGRAPHY) / 2;
|
|
449
|
+
return point.x >= line.x - halfWidth && point.x <= line.x + halfWidth
|
|
450
|
+
&& point.y >= line.y - NODE_TYPOGRAPHY.fontSize && point.y <= line.y + NODE_TYPOGRAPHY.fontSize * TEXT_DESCENT_RATIO;
|
|
451
|
+
});
|
|
452
|
+
if (labelHit)
|
|
453
|
+
return true;
|
|
454
|
+
if (node.kind === "ellipse") {
|
|
455
|
+
const dx = (point.x - (node.x + node.width / 2)) / (node.width / 2);
|
|
456
|
+
const dy = (point.y - (node.y + node.height / 2)) / (node.height / 2);
|
|
457
|
+
return dx * dx + dy * dy <= 1;
|
|
458
|
+
}
|
|
459
|
+
return point.x >= node.x && point.x <= node.x + node.width && point.y >= node.y && point.y <= node.y + node.height;
|
|
460
|
+
}
|
|
461
|
+
function escapeText(value) {
|
|
462
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
463
|
+
}
|
|
464
|
+
function escapeAttribute(value) {
|
|
465
|
+
return escapeText(value);
|
|
466
|
+
}
|
|
467
|
+
/** Validation restricts inputs to serializable documents; clone before freezing to isolate later author mutations. */
|
|
468
|
+
function cloneDocument(document) {
|
|
469
|
+
return JSON.parse(JSON.stringify(document));
|
|
470
|
+
}
|
|
471
|
+
function deepFreeze(value) {
|
|
472
|
+
if (typeof value === "object" && value !== null) {
|
|
473
|
+
Object.freeze(value);
|
|
474
|
+
for (const child of Object.values(value))
|
|
475
|
+
deepFreeze(child);
|
|
476
|
+
}
|
|
477
|
+
return value;
|
|
478
|
+
}
|
|
479
|
+
function resolvePalette(document, options) {
|
|
480
|
+
return resolveThemePalette(options.theme ?? document.theme);
|
|
481
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { DiagramEdge, DiagramPoint, EvaluatedNode } from "./model.js";
|
|
2
|
+
interface Point {
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
}
|
|
6
|
+
interface Rect {
|
|
7
|
+
left: number;
|
|
8
|
+
top: number;
|
|
9
|
+
right: number;
|
|
10
|
+
bottom: number;
|
|
11
|
+
}
|
|
12
|
+
/** SVG marker size in stroke-width units; routing uses the same footprint as serialization. */
|
|
13
|
+
export declare const ARROW_MARKER_SIZE = 7;
|
|
14
|
+
/** Internal path representation shared by SVG output, labels, bounds, hit testing and distance-based flow placement. */
|
|
15
|
+
export interface PathGeometry {
|
|
16
|
+
readonly path: string;
|
|
17
|
+
readonly points: readonly Point[];
|
|
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;
|
|
23
|
+
readonly bounds: Rect;
|
|
24
|
+
pointAt(progress: number): Point;
|
|
25
|
+
}
|
|
26
|
+
export declare function emptyPathGeometry(): PathGeometry;
|
|
27
|
+
export declare function isPointNearPath(point: DiagramPoint, geometry: PathGeometry, tolerance: number): boolean;
|
|
28
|
+
/**
|
|
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.
|
|
33
|
+
* Routing uses visible separately from paint opacity: reveals block immediately, and hides block until completion.
|
|
34
|
+
*/
|
|
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>;
|
|
41
|
+
export declare function pathLabelAnchor(geometry: PathGeometry): {
|
|
42
|
+
x: number;
|
|
43
|
+
y: number;
|
|
44
|
+
};
|
|
45
|
+
export {};
|