flowsvg 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -1
- package/PATTERNS.md +12 -1
- package/README.md +139 -117
- package/README.zh-CN.md +139 -117
- package/dist/adapters/react.d.ts +48 -0
- package/dist/adapters/react.js +126 -0
- package/dist/adapters/vue.d.ts +132 -0
- package/dist/adapters/vue.js +161 -0
- package/dist/adapters/web-component.d.ts +99 -0
- package/dist/adapters/web-component.js +396 -0
- package/dist/browser/player.d.ts +69 -0
- package/dist/browser/player.js +353 -0
- package/dist/browser.d.ts +4 -69
- package/dist/browser.js +2 -353
- package/dist/core/color.d.ts +3 -0
- package/dist/core/color.js +18 -0
- package/dist/core/diagram.d.ts +38 -0
- package/dist/core/diagram.js +391 -0
- package/dist/core/edge-geometry.d.ts +32 -0
- package/dist/core/edge-geometry.js +382 -0
- package/dist/{core.d.ts → core/model.d.ts} +2 -52
- package/dist/core/model.js +1 -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} +1 -1
- 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} +1 -3
- package/dist/{timeline.js → core/timeline.js} +1 -17
- package/dist/core/validation.d.ts +11 -0
- package/dist/core/validation.js +389 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +2 -1
- package/dist/react.d.ts +3 -48
- package/dist/react.js +2 -126
- 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/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 +2 -1
- 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-controls.d.ts → browser/controls.d.ts} +0 -0
- /package/dist/{player-controls.js → browser/controls.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
- /package/dist/{node-content.js → core/node-content.js} +0 -0
|
@@ -0,0 +1,391 @@
|
|
|
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 { emptyPathGeometry, isPointNearPath, 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.
|
|
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
|
+
timeline;
|
|
35
|
+
document;
|
|
36
|
+
nodesById;
|
|
37
|
+
constructor(document) {
|
|
38
|
+
this.document = deepFreeze(document);
|
|
39
|
+
this.timeline = new CompiledTimeline(document);
|
|
40
|
+
this.totalDuration = this.timeline.totalDuration;
|
|
41
|
+
this.nodesById = new Map(document.nodes.map((node) => [node.id, node]));
|
|
42
|
+
}
|
|
43
|
+
evaluateAt(timeMs, options = {}) {
|
|
44
|
+
return this.evaluateFrame(timeMs, options).frame;
|
|
45
|
+
}
|
|
46
|
+
evaluateFrame(timeMs, options = {}) {
|
|
47
|
+
const palette = resolvePalette(this.document, options);
|
|
48
|
+
const timeline = this.timeline.evaluateAt(timeMs, options, palette);
|
|
49
|
+
const currentNodes = new Map(this.document.nodes.map((node) => {
|
|
50
|
+
const values = timeline.objects.get(node.id).values;
|
|
51
|
+
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
|
+
}));
|
|
53
|
+
const warnings = [];
|
|
54
|
+
const nodes = [...currentNodes.values()].map((node, index) => {
|
|
55
|
+
const position = this.resolvePosition(node.id, currentNodes);
|
|
56
|
+
const labelLines = node.label ? wrapText(node.label, Math.max(1, node.width - TEXT_PADDING * 2), NODE_TYPOGRAPHY) : [];
|
|
57
|
+
const labelCenterY = node.kind === "group" ? position.y + 24 : position.y + node.height / 2;
|
|
58
|
+
const labelBoxes = makeLabelBoxes(labelLines, position.x + node.width / 2, labelCenterY, NODE_TYPOGRAPHY);
|
|
59
|
+
if (node.label && labelLines.length * NODE_TYPOGRAPHY.lineHeight > Math.max(0, node.height - TEXT_PADDING * 2))
|
|
60
|
+
warnings.push({ path: `nodes[${index}].label`, objectId: node.id, code: "text-overflow", message: `label for node \"${node.id}\" exceeds its height` });
|
|
61
|
+
const visual = this.resolveVisibility(node.id, timeline.objects);
|
|
62
|
+
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
|
+
});
|
|
67
|
+
const style = {
|
|
68
|
+
strokeWidth: DEFAULT_NODE_STROKE_WIDTH,
|
|
69
|
+
textColor: palette.text,
|
|
70
|
+
...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)))
|
|
73
|
+
};
|
|
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;
|
|
75
|
+
if (contentLayout) {
|
|
76
|
+
for (const direction of ["width", "height"]) {
|
|
77
|
+
if (contentLayout[direction === "width" ? "widthOverflow" : "heightOverflow"])
|
|
78
|
+
warnings.push({ path: `nodes[${index}].content`, objectId: node.id, code: "text-overflow", message: `content for node "${node.id}" exceeds its ${direction}` });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { ...node, contentLayout, ...visual, highlighted: own.highlighted || flowHighlighted, style, x: position.x, y: position.y, labelLines, labelBoxes };
|
|
82
|
+
});
|
|
83
|
+
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
|
84
|
+
const edgeGeometries = new Map();
|
|
85
|
+
const edges = this.document.edges.map((original) => {
|
|
86
|
+
const own = timeline.objects.get(original.id);
|
|
87
|
+
const activeFlow = timeline.flows.find((flow) => flow.target === original.id);
|
|
88
|
+
const edge = { ...original, label: own.values.label };
|
|
89
|
+
const from = nodeById.get(edge.from);
|
|
90
|
+
const to = nodeById.get(edge.to);
|
|
91
|
+
const visible = own.visible && Boolean(from?.visible && to?.visible);
|
|
92
|
+
// Take the minimum so simultaneous endpoint fades do not darken an edge again through multiplication.
|
|
93
|
+
const opacity = Math.min(own.opacity, from?.opacity ?? 0, to?.opacity ?? 0);
|
|
94
|
+
const style = {
|
|
95
|
+
...edge.style,
|
|
96
|
+
strokeWidth: own.values.strokeWidth ?? edge.style?.strokeWidth ?? 2,
|
|
97
|
+
dashed: edge.style?.dashed ?? false,
|
|
98
|
+
stroke: mixColor(own.values.stroke ?? palette.edge, palette.highlight, Math.max(own.highlight, Number(activeFlow !== undefined)))
|
|
99
|
+
};
|
|
100
|
+
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
|
+
// Both directions use the same lane magnitude; the perpendicular vector reverses with the edge,
|
|
102
|
+
// so the rendered relationships occupy opposite sides of their shared center line.
|
|
103
|
+
const reverseOffset = hasReverseCurve ? 1 : 0;
|
|
104
|
+
const geometry = from && to ? routeEdge(edge, from, to, nodes, reverseOffset) : emptyPathGeometry();
|
|
105
|
+
edgeGeometries.set(edge.id, geometry);
|
|
106
|
+
const labelLines = edge.label ? wrapText(edge.label, Math.max(1, geometry.length - TEXT_PADDING * 2), EDGE_TYPOGRAPHY) : [];
|
|
107
|
+
const midpoint = pathLabelAnchor(geometry);
|
|
108
|
+
const flow = activeFlow && visible && opacity > 0 && !options.reducedMotion ? {
|
|
109
|
+
direction: activeFlow.direction,
|
|
110
|
+
progress: timeline.beatProgress,
|
|
111
|
+
point: geometry.pointAt(activeFlow.direction === "forward" ? timeline.beatProgress : 1 - timeline.beatProgress),
|
|
112
|
+
...(activeFlow.text !== undefined ? { text: activeFlow.text } : {})
|
|
113
|
+
} : undefined;
|
|
114
|
+
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 } : {}) };
|
|
115
|
+
});
|
|
116
|
+
const camera = resolveCamera(this.document.viewport, timeline.focusBefore, timeline.focusAfter, timeline.transitionProgress, nodes, edges, edgeGeometries);
|
|
117
|
+
return {
|
|
118
|
+
frame: { timeMs: timeline.timeMs, sceneId: timeline.sceneId, beatId: timeline.beatId, beatProgress: timeline.beatProgress, nodes, edges, camera, warnings, ...(timeline.caption !== undefined ? { caption: timeline.caption } : {}) },
|
|
119
|
+
edgeGeometries
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
renderSvg(timeMs = 0, options = {}) {
|
|
123
|
+
const frame = this.evaluateAt(timeMs, options);
|
|
124
|
+
const palette = resolvePalette(this.document, options);
|
|
125
|
+
return serializeSvg(this.document, frame, palette);
|
|
126
|
+
}
|
|
127
|
+
hitTest(timeMs, point, options = {}) {
|
|
128
|
+
if (!Number.isFinite(point.x) || !Number.isFinite(point.y))
|
|
129
|
+
return null;
|
|
130
|
+
const evaluated = this.evaluateFrame(timeMs, options);
|
|
131
|
+
const frame = evaluated.frame;
|
|
132
|
+
const nodes = [...frame.nodes].reverse();
|
|
133
|
+
const containsPoint = (candidate) => candidate.visible && candidate.opacity > 0 && isPointInNode(point, candidate);
|
|
134
|
+
const node = nodes.find((candidate) => candidate.kind !== "group" && containsPoint(candidate));
|
|
135
|
+
if (node)
|
|
136
|
+
return { id: node.id, type: "node" };
|
|
137
|
+
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));
|
|
138
|
+
if (edge)
|
|
139
|
+
return { id: edge.id, type: "edge" };
|
|
140
|
+
const group = nodes.find((candidate) => candidate.kind === "group" && containsPoint(candidate));
|
|
141
|
+
return group ? { id: group.id, type: "node" } : null;
|
|
142
|
+
}
|
|
143
|
+
resolvePosition(id, nodes) {
|
|
144
|
+
const node = nodes.get(id);
|
|
145
|
+
if (!node)
|
|
146
|
+
return { x: 0, y: 0 };
|
|
147
|
+
const parent = node.parentId ? this.resolvePosition(node.parentId, nodes) : { x: 0, y: 0 };
|
|
148
|
+
return { x: node.x + parent.x, y: node.y + parent.y };
|
|
149
|
+
}
|
|
150
|
+
/** Intersect ancestor visibility and take minimum opacity without altering a child's stored state. */
|
|
151
|
+
resolveVisibility(id, states) {
|
|
152
|
+
const node = this.nodesById.get(id);
|
|
153
|
+
const own = states.get(id);
|
|
154
|
+
if (!node?.parentId)
|
|
155
|
+
return { visible: own.visible, opacity: own.opacity };
|
|
156
|
+
const parent = this.resolveVisibility(node.parentId, states);
|
|
157
|
+
return { visible: own.visible && parent.visible, opacity: Math.min(own.opacity, parent.opacity) };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const TEXT_PADDING = 12;
|
|
161
|
+
const CONTENT_TAG_CORNER_RADIUS = 3;
|
|
162
|
+
const NODE_TYPOGRAPHY = { fontSize: 16, lineHeight: 20, charWidth: 8.5 };
|
|
163
|
+
const EDGE_TYPOGRAPHY = { fontSize: 14, lineHeight: 18, charWidth: 8.4 };
|
|
164
|
+
const TEXT_DESCENT_RATIO = 0.25;
|
|
165
|
+
const FLOW_TYPOGRAPHY = { fontSize: 12, lineHeight: 16, charWidth: 7.2 };
|
|
166
|
+
const FLOW_CAPSULE_HEIGHT = 24;
|
|
167
|
+
const FLOW_TEXT_PADDING = 10;
|
|
168
|
+
const FLOW_DOT_RADIUS = 5;
|
|
169
|
+
/**
|
|
170
|
+
* Estimate wrapping with fixed code-point widths, preserving explicit empty lines and preferring spaces as breaks.
|
|
171
|
+
* No font/DOM measurement is used; this handles width only, while frame evaluation reports height overflow.
|
|
172
|
+
*/
|
|
173
|
+
export function wrapText(label, maxWidth, typography = NODE_TYPOGRAPHY) {
|
|
174
|
+
const maxChars = Math.max(1, Math.floor(maxWidth / typography.charWidth));
|
|
175
|
+
const lines = [];
|
|
176
|
+
for (const explicitLine of label.split("\n")) {
|
|
177
|
+
if (explicitLine.length === 0) {
|
|
178
|
+
lines.push("");
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
// Code-point slicing keeps surrogate pairs intact even when only one character fits.
|
|
182
|
+
let remaining = Array.from(explicitLine);
|
|
183
|
+
while (remaining.length > maxChars) {
|
|
184
|
+
const candidate = remaining.slice(0, maxChars + 1);
|
|
185
|
+
const breakAt = candidate.lastIndexOf(" ");
|
|
186
|
+
const cut = breakAt > 0 ? breakAt : maxChars;
|
|
187
|
+
lines.push(remaining.slice(0, cut).join(""));
|
|
188
|
+
remaining = Array.from(remaining.slice(cut).join("").replace(/^\s+/, ""));
|
|
189
|
+
}
|
|
190
|
+
lines.push(remaining.join(""));
|
|
191
|
+
}
|
|
192
|
+
return lines;
|
|
193
|
+
}
|
|
194
|
+
function makeLabelBoxes(lines, centerX, centerY, typography) {
|
|
195
|
+
const startY = centerY - ((lines.length - 1) * typography.lineHeight) / 2 + typography.fontSize * 0.35;
|
|
196
|
+
return lines.map((text, index) => ({ text, x: centerX, y: startY + index * typography.lineHeight }));
|
|
197
|
+
}
|
|
198
|
+
/** Serialize group backgrounds, edges, ordinary nodes, then labels; write the evaluated camera directly to viewBox. */
|
|
199
|
+
function serializeSvg(document, frame, palette) {
|
|
200
|
+
const title = escapeText(document.title ?? "Diagram");
|
|
201
|
+
const edgeShapes = frame.edges.filter((edge) => edge.opacity > 0).map((edge) => {
|
|
202
|
+
const stroke = edge.style?.stroke ?? palette.edge;
|
|
203
|
+
const width = edge.style?.strokeWidth ?? 2;
|
|
204
|
+
const dash = edge.style?.dashed ? ' stroke-dasharray="6 4"' : "";
|
|
205
|
+
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}"/>`;
|
|
206
|
+
}).join("");
|
|
207
|
+
const groupShapes = frame.nodes.filter((node) => node.opacity > 0 && node.kind === "group").map((node) => serializeNodeShape(node, palette)).join("");
|
|
208
|
+
const nodeShapes = frame.nodes.filter((node) => node.opacity > 0 && node.kind !== "group").map((node) => serializeNodeShape(node, palette)).join("");
|
|
209
|
+
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
|
+
const contents = frame.nodes.filter((node) => node.opacity > 0 && node.contentLayout).map((node, index) => serializeNodeContent(node, index)).join("");
|
|
211
|
+
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>`;
|
|
213
|
+
const ariaLabel = escapeAttribute(document.title ?? "Diagram");
|
|
214
|
+
const camera = `${formatNumber(frame.camera.x)} ${formatNumber(frame.camera.y)} ${formatNumber(frame.camera.width)} ${formatNumber(frame.camera.height)}`;
|
|
215
|
+
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>`;
|
|
216
|
+
}
|
|
217
|
+
/** Serialize evaluated text and tag backgrounds inside one clip; authored content cannot introduce SVG markup or CSS. */
|
|
218
|
+
function serializeNodeContent(node, index) {
|
|
219
|
+
const layout = node.contentLayout;
|
|
220
|
+
const safe = layout.safeArea;
|
|
221
|
+
// Include exact clip geometry so independent SVGs with different node dimensions do not share a clip definition.
|
|
222
|
+
const geometryKey = [safe.x, safe.y, safe.width, safe.height].map((value) => String(value).replaceAll("-", "n").replaceAll(".", "p").replaceAll("+", "a")).join("-");
|
|
223
|
+
const clipId = `diagram-content-clip-${index}-${geometryKey}`;
|
|
224
|
+
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("");
|
|
226
|
+
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
|
+
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
|
+
return `${clip}<g id="diagram-content-${index}" data-content-id="${escapeAttribute(node.id)}" clip-path="url(#${clipId})" opacity="${node.opacity}">${tags}${graphs}${lines}</g>`;
|
|
229
|
+
}
|
|
230
|
+
/** Keep markers decorative and horizontal; coordinates come only from the evaluated path point. */
|
|
231
|
+
function serializeFlowMarker(flow, edgeId, palette, opacity) {
|
|
232
|
+
const attributes = `fill="${palette.highlight}" data-flow-id="${escapeAttribute(edgeId)}" opacity="${opacity}" pointer-events="none" aria-hidden="true"`;
|
|
233
|
+
if (flow.text === undefined)
|
|
234
|
+
return `<circle cx="${formatNumber(flow.point.x)}" cy="${formatNumber(flow.point.y)}" r="${FLOW_DOT_RADIUS}" ${attributes}/>`;
|
|
235
|
+
const width = Math.max(FLOW_CAPSULE_HEIGHT, estimateTextWidth(flow.text, FLOW_TYPOGRAPHY) + FLOW_TEXT_PADDING * 2);
|
|
236
|
+
const x = formatNumber(flow.point.x - width / 2);
|
|
237
|
+
const y = formatNumber(flow.point.y - FLOW_CAPSULE_HEIGHT / 2);
|
|
238
|
+
// Marker ink follows its saturated fill, independently of node text and interior highlight paint.
|
|
239
|
+
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>`;
|
|
240
|
+
return `<g ${attributes}><rect x="${x}" y="${y}" width="${formatNumber(width)}" height="${FLOW_CAPSULE_HEIGHT}" rx="${FLOW_CAPSULE_HEIGHT / 2}"/>${text}</g>`;
|
|
241
|
+
}
|
|
242
|
+
/** Reuse fixed code-point metrics without DOM measurements; reserve a full em for wide letters and Unicode. */
|
|
243
|
+
function estimateTextWidth(text, typography) {
|
|
244
|
+
return Array.from(text).reduce((width, character) => {
|
|
245
|
+
const wide = character.codePointAt(0) > 0xff || "MWmw@%".includes(character);
|
|
246
|
+
return width + (wide ? typography.fontSize : typography.charWidth);
|
|
247
|
+
}, 0);
|
|
248
|
+
}
|
|
249
|
+
/** Serialize supported shapes with escaped color/identity attributes; labels are drawn in a separate text layer. */
|
|
250
|
+
function serializeNodeShape(node, palette) {
|
|
251
|
+
const fill = node.style?.fill ?? palette.fill;
|
|
252
|
+
const stroke = node.style?.stroke ?? palette.stroke;
|
|
253
|
+
const width = node.style?.strokeWidth ?? DEFAULT_NODE_STROKE_WIDTH;
|
|
254
|
+
const dash = node.kind === "group" && node.style?.dashed ? ' stroke-dasharray="10 7"' : "";
|
|
255
|
+
const attrs = `fill="${escapeAttribute(fill)}" stroke="${escapeAttribute(stroke)}" stroke-width="${width}"${dash} data-id="${escapeAttribute(node.id)}" opacity="${node.opacity}"`;
|
|
256
|
+
if (node.kind === "ellipse")
|
|
257
|
+
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}/>`;
|
|
258
|
+
if (node.kind === "cylinder") {
|
|
259
|
+
const ry = Math.min(14, node.height / 4);
|
|
260
|
+
const bottom = node.y + node.height - ry;
|
|
261
|
+
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`;
|
|
262
|
+
// Keep the cap as a filled sibling so the SVG fill rule cannot turn its interior into a hole.
|
|
263
|
+
// The group owns the only data-id, allowing hover and DOM synchronization to treat the cylinder as one node.
|
|
264
|
+
const cap = `<ellipse cx="${formatNumber(node.x + node.width / 2)}" cy="${formatNumber(node.y + ry)}" rx="${formatNumber(node.width / 2)}" ry="${formatNumber(ry)}"/>`;
|
|
265
|
+
return `<g ${attrs}><path d="${bodyPath}"/>${cap}</g>`;
|
|
266
|
+
}
|
|
267
|
+
const radius = node.kind === "roundedRect" ? ` rx="${Math.min(NODE_CORNER_RADIUS, node.width / 4, node.height / 4)}"` : "";
|
|
268
|
+
return `<rect x="${formatNumber(node.x)}" y="${formatNumber(node.y)}" width="${formatNumber(node.width)}" height="${formatNumber(node.height)}"${radius} ${attrs}/>`;
|
|
269
|
+
}
|
|
270
|
+
function serializeLabel(lines, boxes, color, typography, opacity = 1, background) {
|
|
271
|
+
if (lines.length === 0)
|
|
272
|
+
return "";
|
|
273
|
+
const x = boxes[0]?.x ?? 0;
|
|
274
|
+
const body = lines.map((line, index) => `<tspan x="${formatNumber(x)}" y="${formatNumber(boxes[index]?.y ?? 0)}">${escapeText(line)}</tspan>`).join("");
|
|
275
|
+
// Only edge labels need background isolation from the path beneath them.
|
|
276
|
+
const halo = background ? ` stroke="${escapeAttribute(background)}" stroke-width="6" stroke-linejoin="round" paint-order="stroke"` : "";
|
|
277
|
+
return `<text fill="${escapeAttribute(color)}"${halo} font-size="${typography.fontSize}" text-anchor="middle" opacity="${opacity}">${body}</text>`;
|
|
278
|
+
}
|
|
279
|
+
const FOCUS_PADDING = 24;
|
|
280
|
+
const HIT_TOLERANCE = 6;
|
|
281
|
+
/** Resolve both focus targets against current geometry, then interpolate cameras with the timeline's eased progress. */
|
|
282
|
+
function resolveCamera(viewport, beforeTarget, afterTarget, progress, nodes, edges, edgeGeometries) {
|
|
283
|
+
const full = { x: 0, y: 0, width: viewport.width, height: viewport.height };
|
|
284
|
+
const before = focusCamera(viewport, beforeTarget, nodes, edges, edgeGeometries) ?? full;
|
|
285
|
+
const after = focusCamera(viewport, afterTarget, nodes, edges, edgeGeometries) ?? full;
|
|
286
|
+
if (progress <= 0)
|
|
287
|
+
return before;
|
|
288
|
+
if (progress >= 1)
|
|
289
|
+
return after;
|
|
290
|
+
return {
|
|
291
|
+
x: before.x + (after.x - before.x) * progress,
|
|
292
|
+
y: before.y + (after.y - before.y) * progress,
|
|
293
|
+
width: before.width + (after.width - before.width) * progress,
|
|
294
|
+
height: before.height + (after.height - before.height) * progress
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
/** Pad bounds of the target, descendants and visible incident edges, preserving aspect ratio within the initial viewport. */
|
|
298
|
+
function focusCamera(viewport, target, nodes, edges, edgeGeometries) {
|
|
299
|
+
if (!target)
|
|
300
|
+
return undefined;
|
|
301
|
+
const targetNode = nodes.find((node) => node.id === target);
|
|
302
|
+
if (!targetNode)
|
|
303
|
+
return undefined;
|
|
304
|
+
const targetIds = new Set(nodes.filter((node) => node.id === target || hasAncestor(node, target, nodes)).map((node) => node.id));
|
|
305
|
+
const bounds = nodes.filter((node) => targetIds.has(node.id)).map(nodeBounds);
|
|
306
|
+
for (const edge of edges) {
|
|
307
|
+
if (edge.visible && edge.opacity > 0 && (targetIds.has(edge.from) || targetIds.has(edge.to)))
|
|
308
|
+
bounds.push((edgeGeometries.get(edge.id) ?? emptyPathGeometry()).bounds);
|
|
309
|
+
}
|
|
310
|
+
const combined = combineBounds(bounds);
|
|
311
|
+
if (!combined)
|
|
312
|
+
return undefined;
|
|
313
|
+
const aspect = viewport.width / viewport.height;
|
|
314
|
+
const paddedWidth = combined.right - combined.left + FOCUS_PADDING * 2;
|
|
315
|
+
const paddedHeight = combined.bottom - combined.top + FOCUS_PADDING * 2;
|
|
316
|
+
let width = Math.max(paddedWidth, paddedHeight * aspect);
|
|
317
|
+
let height = Math.max(paddedHeight, paddedWidth / aspect);
|
|
318
|
+
if (width > viewport.width) {
|
|
319
|
+
width = viewport.width;
|
|
320
|
+
height = width / aspect;
|
|
321
|
+
}
|
|
322
|
+
if (height > viewport.height) {
|
|
323
|
+
height = viewport.height;
|
|
324
|
+
width = height * aspect;
|
|
325
|
+
}
|
|
326
|
+
const centerX = (combined.left + combined.right) / 2;
|
|
327
|
+
const centerY = (combined.top + combined.bottom) / 2;
|
|
328
|
+
const x = clampCamera(centerX - width / 2, 0, viewport.width - width);
|
|
329
|
+
const y = clampCamera(centerY - height / 2, 0, viewport.height - height);
|
|
330
|
+
return { x, y, width, height };
|
|
331
|
+
}
|
|
332
|
+
function hasAncestor(node, ancestorId, nodes) {
|
|
333
|
+
const byId = new Map(nodes.map((candidate) => [candidate.id, candidate]));
|
|
334
|
+
let parentId = node.parentId;
|
|
335
|
+
while (parentId) {
|
|
336
|
+
if (parentId === ancestorId)
|
|
337
|
+
return true;
|
|
338
|
+
parentId = byId.get(parentId)?.parentId;
|
|
339
|
+
}
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
function nodeBounds(node) {
|
|
343
|
+
return { left: node.x, top: node.y, right: node.x + node.width, bottom: node.y + node.height };
|
|
344
|
+
}
|
|
345
|
+
function combineBounds(bounds) {
|
|
346
|
+
if (bounds.length === 0)
|
|
347
|
+
return undefined;
|
|
348
|
+
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)) };
|
|
349
|
+
}
|
|
350
|
+
function clampCamera(value, min, max) {
|
|
351
|
+
return Math.min(max, Math.max(min, value));
|
|
352
|
+
}
|
|
353
|
+
/** Include deterministic label bounds so overflowing text remains part of the node's interaction target. */
|
|
354
|
+
function isPointInNode(point, node) {
|
|
355
|
+
const labelHit = node.labelBoxes.some((line) => {
|
|
356
|
+
if (line.text.length === 0)
|
|
357
|
+
return false;
|
|
358
|
+
const halfWidth = estimateTextWidth(line.text, NODE_TYPOGRAPHY) / 2;
|
|
359
|
+
return point.x >= line.x - halfWidth && point.x <= line.x + halfWidth
|
|
360
|
+
&& point.y >= line.y - NODE_TYPOGRAPHY.fontSize && point.y <= line.y + NODE_TYPOGRAPHY.fontSize * TEXT_DESCENT_RATIO;
|
|
361
|
+
});
|
|
362
|
+
if (labelHit)
|
|
363
|
+
return true;
|
|
364
|
+
if (node.kind === "ellipse") {
|
|
365
|
+
const dx = (point.x - (node.x + node.width / 2)) / (node.width / 2);
|
|
366
|
+
const dy = (point.y - (node.y + node.height / 2)) / (node.height / 2);
|
|
367
|
+
return dx * dx + dy * dy <= 1;
|
|
368
|
+
}
|
|
369
|
+
return point.x >= node.x && point.x <= node.x + node.width && point.y >= node.y && point.y <= node.y + node.height;
|
|
370
|
+
}
|
|
371
|
+
function escapeText(value) {
|
|
372
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
373
|
+
}
|
|
374
|
+
function escapeAttribute(value) {
|
|
375
|
+
return escapeText(value);
|
|
376
|
+
}
|
|
377
|
+
/** Validation restricts inputs to serializable documents; clone before freezing to isolate later author mutations. */
|
|
378
|
+
function cloneDocument(document) {
|
|
379
|
+
return JSON.parse(JSON.stringify(document));
|
|
380
|
+
}
|
|
381
|
+
function deepFreeze(value) {
|
|
382
|
+
if (typeof value === "object" && value !== null) {
|
|
383
|
+
Object.freeze(value);
|
|
384
|
+
for (const child of Object.values(value))
|
|
385
|
+
deepFreeze(child);
|
|
386
|
+
}
|
|
387
|
+
return value;
|
|
388
|
+
}
|
|
389
|
+
function resolvePalette(document, options) {
|
|
390
|
+
return resolveThemePalette(options.theme ?? document.theme);
|
|
391
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
/** Internal path representation shared by SVG output, labels, bounds, hit testing and distance-based flow placement. */
|
|
13
|
+
export interface PathGeometry {
|
|
14
|
+
readonly path: string;
|
|
15
|
+
readonly points: readonly Point[];
|
|
16
|
+
readonly length: number;
|
|
17
|
+
readonly bounds: Rect;
|
|
18
|
+
pointAt(progress: number): Point;
|
|
19
|
+
}
|
|
20
|
+
export declare function emptyPathGeometry(): PathGeometry;
|
|
21
|
+
export declare function isPointNearPath(point: DiagramPoint, geometry: PathGeometry, tolerance: number): boolean;
|
|
22
|
+
/**
|
|
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.
|
|
25
|
+
* Routing uses visible separately from paint opacity: reveals block immediately, and hides block until completion.
|
|
26
|
+
*/
|
|
27
|
+
export declare function routeEdge(edge: DiagramEdge, from: EvaluatedNode, to: EvaluatedNode, nodes: readonly EvaluatedNode[], reverseOffset?: number): PathGeometry;
|
|
28
|
+
export declare function pathLabelAnchor(geometry: PathGeometry): {
|
|
29
|
+
x: number;
|
|
30
|
+
y: number;
|
|
31
|
+
};
|
|
32
|
+
export {};
|