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
package/dist/core.js
DELETED
|
@@ -1,1149 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Platform-independent core: validate and compile documents, then derive geometry, hit tests and static SVG.
|
|
3
|
-
* The browser consumes these results; this module does not read the DOM, network, system theme or browser clock.
|
|
4
|
-
*/
|
|
5
|
-
import { layoutNodeContent, supportsNodeContent, validateNodeContent } from "./node-content.js";
|
|
6
|
-
import { paintContentHighlight } from "./node-content-paint.js";
|
|
7
|
-
import { CompiledTimeline, mixColor } from "./timeline.js";
|
|
8
|
-
import { resolveThemePalette } from "./theme.js";
|
|
9
|
-
/** Compilation error for invalid documents; issues contains all field problems found by validation. */
|
|
10
|
-
export class DiagramValidationError extends Error {
|
|
11
|
-
issues;
|
|
12
|
-
constructor(issues) {
|
|
13
|
-
super(`Invalid diagram document (${issues.length} issue${issues.length === 1 ? "" : "s"})`);
|
|
14
|
-
this.name = "DiagramValidationError";
|
|
15
|
-
this.issues = issues;
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
/**
|
|
19
|
-
* Validate document structure, values and references; an empty list means validation passed.
|
|
20
|
-
* Accumulate issues across inspectable branches without mutating input or producing a partial Diagram.
|
|
21
|
-
*/
|
|
22
|
-
export function validateDocument(input) {
|
|
23
|
-
const issues = [];
|
|
24
|
-
if (!isRecord(input)) {
|
|
25
|
-
return [{ path: "$", message: "document must be an object" }];
|
|
26
|
-
}
|
|
27
|
-
validateUnknownFields(input, ["version", "title", "viewport", "theme", "nodes", "edges", "scenes"], "$", issues);
|
|
28
|
-
if (input.version !== 1)
|
|
29
|
-
issues.push({ path: "version", message: "must be 1" });
|
|
30
|
-
if (input.title !== undefined && typeof input.title !== "string")
|
|
31
|
-
issues.push({ path: "title", message: "must be a string" });
|
|
32
|
-
if (input.theme !== undefined && input.theme !== "light" && input.theme !== "dark")
|
|
33
|
-
issues.push({ path: "theme", message: "must be light or dark" });
|
|
34
|
-
const viewport = input.viewport;
|
|
35
|
-
if (!isRecord(viewport)) {
|
|
36
|
-
issues.push({ path: "viewport", message: "must be an object" });
|
|
37
|
-
}
|
|
38
|
-
else {
|
|
39
|
-
validateUnknownFields(viewport, ["width", "height"], "viewport", issues);
|
|
40
|
-
validatePositiveFinite(viewport.width, "viewport.width", issues);
|
|
41
|
-
validatePositiveFinite(viewport.height, "viewport.height", issues);
|
|
42
|
-
}
|
|
43
|
-
if (!Array.isArray(input.nodes))
|
|
44
|
-
issues.push({ path: "nodes", message: "must be an array" });
|
|
45
|
-
if (!Array.isArray(input.edges))
|
|
46
|
-
issues.push({ path: "edges", message: "must be an array" });
|
|
47
|
-
if (!Array.isArray(input.scenes))
|
|
48
|
-
issues.push({ path: "scenes", message: "must be an array" });
|
|
49
|
-
const nodes = Array.isArray(input.nodes) ? input.nodes : [];
|
|
50
|
-
const edges = Array.isArray(input.edges) ? input.edges : [];
|
|
51
|
-
const scenes = Array.isArray(input.scenes) ? input.scenes : [];
|
|
52
|
-
validateDenseArray(nodes, "nodes", issues);
|
|
53
|
-
validateDenseArray(edges, "edges", issues);
|
|
54
|
-
validateDenseArray(scenes, "scenes", issues);
|
|
55
|
-
const ids = new Set();
|
|
56
|
-
nodes.forEach((node, index) => {
|
|
57
|
-
const path = `nodes[${index}]`;
|
|
58
|
-
if (!isRecord(node)) {
|
|
59
|
-
issues.push({ path, message: "must be an object" });
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
validateUnknownFields(node, ["id", "kind", "x", "y", "width", "height", "label", "content", "parentId", "visible", "style"], path, issues);
|
|
63
|
-
if (!isNonEmptyString(node.id))
|
|
64
|
-
issues.push({ path: `${path}.id`, message: "must be a non-empty string" });
|
|
65
|
-
else if (ids.has(node.id))
|
|
66
|
-
issues.push({ path: `${path}.id`, message: `duplicate id \"${node.id}\"` });
|
|
67
|
-
else
|
|
68
|
-
ids.add(node.id);
|
|
69
|
-
if (!isNodeKind(node.kind))
|
|
70
|
-
issues.push({ path: `${path}.kind`, message: "must be a supported node kind" });
|
|
71
|
-
validateFinite(node.x, `${path}.x`, issues);
|
|
72
|
-
validateFinite(node.y, `${path}.y`, issues);
|
|
73
|
-
validatePositiveFinite(node.width, `${path}.width`, issues);
|
|
74
|
-
validatePositiveFinite(node.height, `${path}.height`, issues);
|
|
75
|
-
if (node.label !== undefined && typeof node.label !== "string")
|
|
76
|
-
issues.push({ path: `${path}.label`, message: "must be a string" });
|
|
77
|
-
if (node.content !== undefined) {
|
|
78
|
-
validateNodeContent(node.content, `${path}.content`, issues);
|
|
79
|
-
if (node.label !== undefined)
|
|
80
|
-
issues.push({ path: `${path}.content`, message: "must not be declared together with label" });
|
|
81
|
-
if (!supportsNodeContent(node.kind))
|
|
82
|
-
issues.push({ path: `${path}.content`, message: "requires a rect, roundedRect or cylinder node" });
|
|
83
|
-
}
|
|
84
|
-
if (node.parentId !== undefined && !isNonEmptyString(node.parentId))
|
|
85
|
-
issues.push({ path: `${path}.parentId`, message: "must be a non-empty string" });
|
|
86
|
-
if (node.visible !== undefined && typeof node.visible !== "boolean")
|
|
87
|
-
issues.push({ path: `${path}.visible`, message: "must be a boolean" });
|
|
88
|
-
validateNodeStyle(node.style, `${path}.style`, issues);
|
|
89
|
-
});
|
|
90
|
-
const nodeById = new Map();
|
|
91
|
-
nodes.forEach((node) => {
|
|
92
|
-
if (isRecord(node) && isNonEmptyString(node.id))
|
|
93
|
-
nodeById.set(node.id, node);
|
|
94
|
-
});
|
|
95
|
-
nodes.forEach((node, index) => {
|
|
96
|
-
if (isRecord(node) && node.parentId !== undefined && isNonEmptyString(node.parentId)) {
|
|
97
|
-
const parent = nodeById.get(node.parentId);
|
|
98
|
-
if (!parent)
|
|
99
|
-
issues.push({ path: `nodes[${index}].parentId`, message: "must reference an existing node" });
|
|
100
|
-
else if (parent.kind !== "group")
|
|
101
|
-
issues.push({ path: `nodes[${index}].parentId`, message: "must reference a group node" });
|
|
102
|
-
}
|
|
103
|
-
});
|
|
104
|
-
validateParentCycles(nodes, nodeById, issues);
|
|
105
|
-
const edgeById = new Map();
|
|
106
|
-
edges.forEach((edge, index) => {
|
|
107
|
-
const path = `edges[${index}]`;
|
|
108
|
-
if (!isRecord(edge)) {
|
|
109
|
-
issues.push({ path, message: "must be an object" });
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
validateUnknownFields(edge, ["id", "from", "to", "label", "route", "fromPort", "toPort", "visible", "style"], path, issues);
|
|
113
|
-
if (!isNonEmptyString(edge.id))
|
|
114
|
-
issues.push({ path: `${path}.id`, message: "must be a non-empty string" });
|
|
115
|
-
else if (ids.has(edge.id))
|
|
116
|
-
issues.push({ path: `${path}.id`, message: `duplicate id \"${edge.id}\"` });
|
|
117
|
-
else {
|
|
118
|
-
ids.add(edge.id);
|
|
119
|
-
edgeById.set(edge.id, edge);
|
|
120
|
-
}
|
|
121
|
-
validateEdgeEndpoint(edge.from, `${path}.from`, nodeById, issues);
|
|
122
|
-
validateEdgeEndpoint(edge.to, `${path}.to`, nodeById, issues);
|
|
123
|
-
if (edge.label !== undefined && typeof edge.label !== "string")
|
|
124
|
-
issues.push({ path: `${path}.label`, message: "must be a string" });
|
|
125
|
-
if (edge.route !== undefined && edge.route !== "straight" && edge.route !== "orthogonal" && edge.route !== "curved")
|
|
126
|
-
issues.push({ path: `${path}.route`, message: "must be straight, orthogonal or curved" });
|
|
127
|
-
if (edge.fromPort !== undefined && !isPortDirection(edge.fromPort))
|
|
128
|
-
issues.push({ path: `${path}.fromPort`, message: "must be top, right, bottom or left" });
|
|
129
|
-
if (edge.toPort !== undefined && !isPortDirection(edge.toPort))
|
|
130
|
-
issues.push({ path: `${path}.toPort`, message: "must be top, right, bottom or left" });
|
|
131
|
-
if (edge.visible !== undefined && typeof edge.visible !== "boolean")
|
|
132
|
-
issues.push({ path: `${path}.visible`, message: "must be a boolean" });
|
|
133
|
-
validateEdgeStyle(edge.style, `${path}.style`, issues);
|
|
134
|
-
});
|
|
135
|
-
const allIds = new Set([...nodeById.keys(), ...edgeById.keys()]);
|
|
136
|
-
const sceneIds = new Set();
|
|
137
|
-
scenes.forEach((scene, sceneIndex) => {
|
|
138
|
-
const path = `scenes[${sceneIndex}]`;
|
|
139
|
-
if (!isRecord(scene)) {
|
|
140
|
-
issues.push({ path, message: "must be an object" });
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
validateUnknownFields(scene, ["id", "label", "beats"], path, issues);
|
|
144
|
-
if (!isNonEmptyString(scene.id))
|
|
145
|
-
issues.push({ path: `${path}.id`, message: "must be a non-empty string" });
|
|
146
|
-
else if (sceneIds.has(scene.id))
|
|
147
|
-
issues.push({ path: `${path}.id`, message: `duplicate id \"${scene.id}\"` });
|
|
148
|
-
else
|
|
149
|
-
sceneIds.add(scene.id);
|
|
150
|
-
if (!isNonEmptyString(scene.label))
|
|
151
|
-
issues.push({ path: `${path}.label`, message: "must be a non-empty string" });
|
|
152
|
-
if (!Array.isArray(scene.beats))
|
|
153
|
-
issues.push({ path: `${path}.beats`, message: "must be an array" });
|
|
154
|
-
else {
|
|
155
|
-
validateDenseArray(scene.beats, `${path}.beats`, issues);
|
|
156
|
-
if (scene.beats.length === 0)
|
|
157
|
-
issues.push({ path: `${path}.beats`, message: "must contain at least one beat" });
|
|
158
|
-
scene.beats.forEach((beat, beatIndex) => validateBeat(beat, `${path}.beats[${beatIndex}]`, allIds, nodeById, edgeById, issues));
|
|
159
|
-
}
|
|
160
|
-
});
|
|
161
|
-
return issues;
|
|
162
|
-
}
|
|
163
|
-
/**
|
|
164
|
-
* Validate and copy a document, then compile its timeline; later input mutations cannot affect evaluation.
|
|
165
|
-
* @throws {DiagramValidationError} If document structure, values or references are invalid.
|
|
166
|
-
*/
|
|
167
|
-
export function createDiagram(input) {
|
|
168
|
-
const issues = validateDocument(input);
|
|
169
|
-
if (issues.length > 0)
|
|
170
|
-
throw new DiagramValidationError(issues);
|
|
171
|
-
return new CompiledDiagram(cloneDocument(input));
|
|
172
|
-
}
|
|
173
|
-
/** One-shot static SVG export; use createDiagram to reuse compilation across repeated evaluations or exports. */
|
|
174
|
-
export function renderSvg(document, timeMs = 0, options) {
|
|
175
|
-
return createDiagram(document).renderSvg(timeMs, options);
|
|
176
|
-
}
|
|
177
|
-
/**
|
|
178
|
-
* Own the frozen document and compiled timeline; derive visual values, absolute geometry/text, routes, then camera.
|
|
179
|
-
* The ancestor index retains hierarchy; geometry is derived from current values on every frame to support group movement.
|
|
180
|
-
* See docs/adr/0001-platform-independent-core.md for the platform boundary.
|
|
181
|
-
*/
|
|
182
|
-
class CompiledDiagram {
|
|
183
|
-
totalDuration;
|
|
184
|
-
timeline;
|
|
185
|
-
document;
|
|
186
|
-
nodesById;
|
|
187
|
-
constructor(document) {
|
|
188
|
-
this.document = deepFreeze(document);
|
|
189
|
-
this.timeline = new CompiledTimeline(document);
|
|
190
|
-
this.totalDuration = this.timeline.totalDuration;
|
|
191
|
-
this.nodesById = new Map(document.nodes.map((node) => [node.id, node]));
|
|
192
|
-
}
|
|
193
|
-
evaluateAt(timeMs, options = {}) {
|
|
194
|
-
return this.evaluateFrame(timeMs, options).frame;
|
|
195
|
-
}
|
|
196
|
-
evaluateFrame(timeMs, options = {}) {
|
|
197
|
-
const palette = resolvePalette(this.document, options);
|
|
198
|
-
const timeline = this.timeline.evaluateAt(timeMs, options, palette);
|
|
199
|
-
const currentNodes = new Map(this.document.nodes.map((node) => {
|
|
200
|
-
const values = timeline.objects.get(node.id).values;
|
|
201
|
-
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 }];
|
|
202
|
-
}));
|
|
203
|
-
const warnings = [];
|
|
204
|
-
const nodes = [...currentNodes.values()].map((node, index) => {
|
|
205
|
-
const position = this.resolvePosition(node.id, currentNodes);
|
|
206
|
-
const labelLines = node.label ? wrapText(node.label, Math.max(1, node.width - TEXT_PADDING * 2), NODE_TYPOGRAPHY) : [];
|
|
207
|
-
const labelCenterY = node.kind === "group" ? position.y + 24 : position.y + node.height / 2;
|
|
208
|
-
const labelBoxes = makeLabelBoxes(labelLines, position.x + node.width / 2, labelCenterY, NODE_TYPOGRAPHY);
|
|
209
|
-
if (node.label && labelLines.length * NODE_TYPOGRAPHY.lineHeight > Math.max(0, node.height - TEXT_PADDING * 2))
|
|
210
|
-
warnings.push({ path: `nodes[${index}].label`, objectId: node.id, code: "text-overflow", message: `label for node \"${node.id}\" exceeds its height` });
|
|
211
|
-
const visual = this.resolveVisibility(node.id, timeline.objects);
|
|
212
|
-
const own = timeline.objects.get(node.id);
|
|
213
|
-
const flowHighlighted = timeline.flows.some((flow) => {
|
|
214
|
-
const edge = this.document.edges.find((candidate) => candidate.id === flow.target);
|
|
215
|
-
return edge?.from === node.id || edge?.to === node.id;
|
|
216
|
-
});
|
|
217
|
-
const style = {
|
|
218
|
-
strokeWidth: 2,
|
|
219
|
-
textColor: palette.text,
|
|
220
|
-
...node.style,
|
|
221
|
-
fill: mixColor(own.values.fill ?? palette.fill, palette.highlight, Math.max(own.highlight, Number(flowHighlighted))),
|
|
222
|
-
stroke: mixColor(own.values.stroke ?? palette.stroke, palette.highlight, Math.max(own.highlight, Number(flowHighlighted)))
|
|
223
|
-
};
|
|
224
|
-
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;
|
|
225
|
-
if (contentLayout) {
|
|
226
|
-
for (const direction of ["width", "height"]) {
|
|
227
|
-
if (contentLayout[direction === "width" ? "widthOverflow" : "heightOverflow"])
|
|
228
|
-
warnings.push({ path: `nodes[${index}].content`, objectId: node.id, code: "text-overflow", message: `content for node "${node.id}" exceeds its ${direction}` });
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
return { ...node, contentLayout, ...visual, highlighted: own.highlighted || flowHighlighted, style, x: position.x, y: position.y, labelLines, labelBoxes };
|
|
232
|
-
});
|
|
233
|
-
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
|
234
|
-
const edgeGeometries = new Map();
|
|
235
|
-
const edges = this.document.edges.map((original) => {
|
|
236
|
-
const own = timeline.objects.get(original.id);
|
|
237
|
-
const activeFlow = timeline.flows.find((flow) => flow.target === original.id);
|
|
238
|
-
const edge = { ...original, label: own.values.label };
|
|
239
|
-
const from = nodeById.get(edge.from);
|
|
240
|
-
const to = nodeById.get(edge.to);
|
|
241
|
-
const visible = own.visible && Boolean(from?.visible && to?.visible);
|
|
242
|
-
// Take the minimum so simultaneous endpoint fades do not darken an edge again through multiplication.
|
|
243
|
-
const opacity = Math.min(own.opacity, from?.opacity ?? 0, to?.opacity ?? 0);
|
|
244
|
-
const style = {
|
|
245
|
-
...edge.style,
|
|
246
|
-
strokeWidth: own.values.strokeWidth ?? edge.style?.strokeWidth ?? 2,
|
|
247
|
-
dashed: edge.style?.dashed ?? false,
|
|
248
|
-
stroke: mixColor(own.values.stroke ?? palette.edge, palette.highlight, Math.max(own.highlight, Number(activeFlow !== undefined)))
|
|
249
|
-
};
|
|
250
|
-
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);
|
|
251
|
-
// Both directions use the same lane magnitude; the perpendicular vector reverses with the edge,
|
|
252
|
-
// so the rendered relationships occupy opposite sides of their shared center line.
|
|
253
|
-
const reverseOffset = hasReverseCurve ? 1 : 0;
|
|
254
|
-
const geometry = from && to ? routeEdge(edge, from, to, nodes, reverseOffset) : emptyPathGeometry();
|
|
255
|
-
edgeGeometries.set(edge.id, geometry);
|
|
256
|
-
const labelLines = edge.label ? wrapText(edge.label, Math.max(1, geometry.length - TEXT_PADDING * 2), EDGE_TYPOGRAPHY) : [];
|
|
257
|
-
const midpoint = pathLabelAnchor(geometry);
|
|
258
|
-
const flow = activeFlow && visible && opacity > 0 && !options.reducedMotion ? {
|
|
259
|
-
direction: activeFlow.direction,
|
|
260
|
-
progress: timeline.beatProgress,
|
|
261
|
-
point: geometry.pointAt(activeFlow.direction === "forward" ? timeline.beatProgress : 1 - timeline.beatProgress),
|
|
262
|
-
...(activeFlow.text !== undefined ? { text: activeFlow.text } : {})
|
|
263
|
-
} : undefined;
|
|
264
|
-
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 } : {}) };
|
|
265
|
-
});
|
|
266
|
-
const camera = resolveCamera(this.document.viewport, timeline.focusBefore, timeline.focusAfter, timeline.transitionProgress, nodes, edges, edgeGeometries);
|
|
267
|
-
return {
|
|
268
|
-
frame: { timeMs: timeline.timeMs, sceneId: timeline.sceneId, beatId: timeline.beatId, beatProgress: timeline.beatProgress, nodes, edges, camera, warnings, ...(timeline.caption !== undefined ? { caption: timeline.caption } : {}) },
|
|
269
|
-
edgeGeometries
|
|
270
|
-
};
|
|
271
|
-
}
|
|
272
|
-
renderSvg(timeMs = 0, options = {}) {
|
|
273
|
-
const frame = this.evaluateAt(timeMs, options);
|
|
274
|
-
const palette = resolvePalette(this.document, options);
|
|
275
|
-
return serializeSvg(this.document, frame, palette);
|
|
276
|
-
}
|
|
277
|
-
hitTest(timeMs, point, options = {}) {
|
|
278
|
-
if (!Number.isFinite(point.x) || !Number.isFinite(point.y))
|
|
279
|
-
return null;
|
|
280
|
-
const evaluated = this.evaluateFrame(timeMs, options);
|
|
281
|
-
const frame = evaluated.frame;
|
|
282
|
-
const nodes = [...frame.nodes].reverse();
|
|
283
|
-
const containsPoint = (candidate) => candidate.visible && candidate.opacity > 0 && isPointInNode(point, candidate);
|
|
284
|
-
const node = nodes.find((candidate) => candidate.kind !== "group" && containsPoint(candidate));
|
|
285
|
-
if (node)
|
|
286
|
-
return { id: node.id, type: "node" };
|
|
287
|
-
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));
|
|
288
|
-
if (edge)
|
|
289
|
-
return { id: edge.id, type: "edge" };
|
|
290
|
-
const group = nodes.find((candidate) => candidate.kind === "group" && containsPoint(candidate));
|
|
291
|
-
return group ? { id: group.id, type: "node" } : null;
|
|
292
|
-
}
|
|
293
|
-
resolvePosition(id, nodes) {
|
|
294
|
-
const node = nodes.get(id);
|
|
295
|
-
if (!node)
|
|
296
|
-
return { x: 0, y: 0 };
|
|
297
|
-
const parent = node.parentId ? this.resolvePosition(node.parentId, nodes) : { x: 0, y: 0 };
|
|
298
|
-
return { x: node.x + parent.x, y: node.y + parent.y };
|
|
299
|
-
}
|
|
300
|
-
/** Intersect ancestor visibility and take minimum opacity without altering a child's stored state. */
|
|
301
|
-
resolveVisibility(id, states) {
|
|
302
|
-
const node = this.nodesById.get(id);
|
|
303
|
-
const own = states.get(id);
|
|
304
|
-
if (!node?.parentId)
|
|
305
|
-
return { visible: own.visible, opacity: own.opacity };
|
|
306
|
-
const parent = this.resolveVisibility(node.parentId, states);
|
|
307
|
-
return { visible: own.visible && parent.visible, opacity: Math.min(own.opacity, parent.opacity) };
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
const TEXT_PADDING = 12;
|
|
311
|
-
const CONTENT_TAG_CORNER_RADIUS = 3;
|
|
312
|
-
const NODE_TYPOGRAPHY = { fontSize: 16, lineHeight: 20, charWidth: 8.5 };
|
|
313
|
-
const EDGE_TYPOGRAPHY = { fontSize: 14, lineHeight: 18, charWidth: 8.4 };
|
|
314
|
-
const TEXT_DESCENT_RATIO = 0.25;
|
|
315
|
-
const FLOW_TYPOGRAPHY = { fontSize: 12, lineHeight: 16, charWidth: 7.2 };
|
|
316
|
-
const FLOW_CAPSULE_HEIGHT = 24;
|
|
317
|
-
const FLOW_TEXT_PADDING = 10;
|
|
318
|
-
const FLOW_DOT_RADIUS = 5;
|
|
319
|
-
/**
|
|
320
|
-
* Estimate wrapping with fixed code-point widths, preserving explicit empty lines and preferring spaces as breaks.
|
|
321
|
-
* No font/DOM measurement is used; this handles width only, while frame evaluation reports height overflow.
|
|
322
|
-
*/
|
|
323
|
-
export function wrapText(label, maxWidth, typography = NODE_TYPOGRAPHY) {
|
|
324
|
-
const maxChars = Math.max(1, Math.floor(maxWidth / typography.charWidth));
|
|
325
|
-
const lines = [];
|
|
326
|
-
for (const explicitLine of label.split("\n")) {
|
|
327
|
-
if (explicitLine.length === 0) {
|
|
328
|
-
lines.push("");
|
|
329
|
-
continue;
|
|
330
|
-
}
|
|
331
|
-
// Code-point slicing keeps surrogate pairs intact even when only one character fits.
|
|
332
|
-
let remaining = Array.from(explicitLine);
|
|
333
|
-
while (remaining.length > maxChars) {
|
|
334
|
-
const candidate = remaining.slice(0, maxChars + 1);
|
|
335
|
-
const breakAt = candidate.lastIndexOf(" ");
|
|
336
|
-
const cut = breakAt > 0 ? breakAt : maxChars;
|
|
337
|
-
lines.push(remaining.slice(0, cut).join(""));
|
|
338
|
-
remaining = Array.from(remaining.slice(cut).join("").replace(/^\s+/, ""));
|
|
339
|
-
}
|
|
340
|
-
lines.push(remaining.join(""));
|
|
341
|
-
}
|
|
342
|
-
return lines;
|
|
343
|
-
}
|
|
344
|
-
function makeLabelBoxes(lines, centerX, centerY, typography) {
|
|
345
|
-
const startY = centerY - ((lines.length - 1) * typography.lineHeight) / 2 + typography.fontSize * 0.35;
|
|
346
|
-
return lines.map((text, index) => ({ text, x: centerX, y: startY + index * typography.lineHeight }));
|
|
347
|
-
}
|
|
348
|
-
/** Serialize group backgrounds, edges, ordinary nodes, then labels; write the evaluated camera directly to viewBox. */
|
|
349
|
-
function serializeSvg(document, frame, palette) {
|
|
350
|
-
const title = escapeText(document.title ?? "Diagram");
|
|
351
|
-
const edgeShapes = frame.edges.filter((edge) => edge.opacity > 0).map((edge) => {
|
|
352
|
-
const stroke = edge.style?.stroke ?? palette.edge;
|
|
353
|
-
const width = edge.style?.strokeWidth ?? 2;
|
|
354
|
-
const dash = edge.style?.dashed ? ' stroke-dasharray="6 4"' : "";
|
|
355
|
-
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}"/>`;
|
|
356
|
-
}).join("");
|
|
357
|
-
const groupShapes = frame.nodes.filter((node) => node.opacity > 0 && node.kind === "group").map((node) => serializeNodeShape(node, palette)).join("");
|
|
358
|
-
const nodeShapes = frame.nodes.filter((node) => node.opacity > 0 && node.kind !== "group").map((node) => serializeNodeShape(node, palette)).join("");
|
|
359
|
-
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, palette.background)).join("");
|
|
360
|
-
const contents = frame.nodes.filter((node) => node.opacity > 0 && node.contentLayout).map((node, index) => serializeNodeContent(node, index)).join("");
|
|
361
|
-
const flows = frame.edges.filter((edge) => edge.opacity > 0 && edge.flow).map((edge) => serializeFlowMarker(edge.flow, edge.id, palette.highlight, edge.opacity)).join("");
|
|
362
|
-
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>`;
|
|
363
|
-
const ariaLabel = escapeAttribute(document.title ?? "Diagram");
|
|
364
|
-
const camera = `${formatNumber(frame.camera.x)} ${formatNumber(frame.camera.y)} ${formatNumber(frame.camera.width)} ${formatNumber(frame.camera.height)}`;
|
|
365
|
-
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${camera}" role="img" aria-label="${ariaLabel}" font-family="sans-serif"><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>`;
|
|
366
|
-
}
|
|
367
|
-
/** Serialize evaluated text and tag backgrounds inside one clip; authored content cannot introduce SVG markup or CSS. */
|
|
368
|
-
function serializeNodeContent(node, index) {
|
|
369
|
-
const layout = node.contentLayout;
|
|
370
|
-
const safe = layout.safeArea;
|
|
371
|
-
// Include exact clip geometry so independent SVGs with different node dimensions do not share a clip definition.
|
|
372
|
-
const geometryKey = [safe.x, safe.y, safe.width, safe.height].map((value) => String(value).replaceAll("-", "n").replaceAll(".", "p").replaceAll("+", "a")).join("-");
|
|
373
|
-
const clipId = `diagram-content-clip-${index}-${geometryKey}`;
|
|
374
|
-
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>`;
|
|
375
|
-
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("");
|
|
376
|
-
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("");
|
|
377
|
-
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("");
|
|
378
|
-
return `${clip}<g id="diagram-content-${index}" data-content-id="${escapeAttribute(node.id)}" clip-path="url(#${clipId})" opacity="${node.opacity}">${tags}${graphs}${lines}</g>`;
|
|
379
|
-
}
|
|
380
|
-
/** Keep markers decorative and horizontal; coordinates come only from the evaluated path point. */
|
|
381
|
-
function serializeFlowMarker(flow, edgeId, color, opacity) {
|
|
382
|
-
const attributes = `fill="${escapeAttribute(color)}" data-flow-id="${escapeAttribute(edgeId)}" opacity="${opacity}" pointer-events="none" aria-hidden="true"`;
|
|
383
|
-
if (flow.text === undefined)
|
|
384
|
-
return `<circle cx="${formatNumber(flow.point.x)}" cy="${formatNumber(flow.point.y)}" r="${FLOW_DOT_RADIUS}" ${attributes}/>`;
|
|
385
|
-
const width = Math.max(FLOW_CAPSULE_HEIGHT, estimateTextWidth(flow.text, FLOW_TYPOGRAPHY) + FLOW_TEXT_PADDING * 2);
|
|
386
|
-
const x = formatNumber(flow.point.x - width / 2);
|
|
387
|
-
const y = formatNumber(flow.point.y - FLOW_CAPSULE_HEIGHT / 2);
|
|
388
|
-
// A dark ink color preserves contrast against both themes' amber highlight fills.
|
|
389
|
-
const text = `<text x="${formatNumber(flow.point.x)}" y="${formatNumber(flow.point.y + FLOW_TYPOGRAPHY.fontSize * 0.35)}" fill="${resolveThemePalette("light").text}" font-size="${FLOW_TYPOGRAPHY.fontSize}" text-anchor="middle">${escapeText(flow.text)}</text>`;
|
|
390
|
-
return `<g ${attributes}><rect x="${x}" y="${y}" width="${formatNumber(width)}" height="${FLOW_CAPSULE_HEIGHT}" rx="${FLOW_CAPSULE_HEIGHT / 2}"/>${text}</g>`;
|
|
391
|
-
}
|
|
392
|
-
/** Reuse fixed code-point metrics without DOM measurements; reserve a full em for wide letters and Unicode. */
|
|
393
|
-
function estimateTextWidth(text, typography) {
|
|
394
|
-
return Array.from(text).reduce((width, character) => {
|
|
395
|
-
const wide = character.codePointAt(0) > 0xff || "MWmw@%".includes(character);
|
|
396
|
-
return width + (wide ? typography.fontSize : typography.charWidth);
|
|
397
|
-
}, 0);
|
|
398
|
-
}
|
|
399
|
-
/** Serialize supported shapes with escaped color/identity attributes; labels are drawn in a separate text layer. */
|
|
400
|
-
function serializeNodeShape(node, palette) {
|
|
401
|
-
const fill = node.style?.fill ?? palette.fill;
|
|
402
|
-
const stroke = node.style?.stroke ?? palette.stroke;
|
|
403
|
-
const width = node.style?.strokeWidth ?? 2;
|
|
404
|
-
const dash = node.kind === "group" && node.style?.dashed ? ' stroke-dasharray="10 7"' : "";
|
|
405
|
-
const attrs = `fill="${escapeAttribute(fill)}" stroke="${escapeAttribute(stroke)}" stroke-width="${width}"${dash} data-id="${escapeAttribute(node.id)}" opacity="${node.opacity}"`;
|
|
406
|
-
if (node.kind === "ellipse")
|
|
407
|
-
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}/>`;
|
|
408
|
-
if (node.kind === "cylinder") {
|
|
409
|
-
const ry = Math.min(14, node.height / 4);
|
|
410
|
-
const bottom = node.y + node.height - ry;
|
|
411
|
-
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`;
|
|
412
|
-
// Keep the cap as a filled sibling so the SVG fill rule cannot turn its interior into a hole.
|
|
413
|
-
// The group owns the only data-id, allowing hover and DOM synchronization to treat the cylinder as one node.
|
|
414
|
-
const cap = `<ellipse cx="${formatNumber(node.x + node.width / 2)}" cy="${formatNumber(node.y + ry)}" rx="${formatNumber(node.width / 2)}" ry="${formatNumber(ry)}"/>`;
|
|
415
|
-
return `<g ${attrs}><path d="${bodyPath}"/>${cap}</g>`;
|
|
416
|
-
}
|
|
417
|
-
const radius = node.kind === "roundedRect" ? ` rx="${Math.min(12, node.width / 4, node.height / 4)}"` : "";
|
|
418
|
-
return `<rect x="${formatNumber(node.x)}" y="${formatNumber(node.y)}" width="${formatNumber(node.width)}" height="${formatNumber(node.height)}"${radius} ${attrs}/>`;
|
|
419
|
-
}
|
|
420
|
-
function serializeLabel(lines, boxes, color, typography, opacity = 1, background = "#ffffff") {
|
|
421
|
-
if (lines.length === 0)
|
|
422
|
-
return "";
|
|
423
|
-
const x = boxes[0]?.x ?? 0;
|
|
424
|
-
const body = lines.map((line, index) => `<tspan x="${formatNumber(x)}" y="${formatNumber(boxes[index]?.y ?? 0)}">${escapeText(line)}</tspan>`).join("");
|
|
425
|
-
return `<text fill="${escapeAttribute(color)}" stroke="${escapeAttribute(background)}" stroke-width="6" stroke-linejoin="round" paint-order="stroke" font-size="${typography.fontSize}" text-anchor="middle" opacity="${opacity}">${body}</text>`;
|
|
426
|
-
}
|
|
427
|
-
function straightPoints(from, to) {
|
|
428
|
-
const fromCenter = { x: from.x + from.width / 2, y: from.y + from.height / 2 };
|
|
429
|
-
const toCenter = { x: to.x + to.width / 2, y: to.y + to.height / 2 };
|
|
430
|
-
const start = boundaryPoint(from, toCenter);
|
|
431
|
-
const end = boundaryPoint(to, fromCenter);
|
|
432
|
-
return [start, end];
|
|
433
|
-
}
|
|
434
|
-
const ROUTING_GAP = 8;
|
|
435
|
-
const FOCUS_PADDING = 24;
|
|
436
|
-
const HIT_TOLERANCE = 6;
|
|
437
|
-
function emptyPathGeometry() {
|
|
438
|
-
return createPathGeometry([]);
|
|
439
|
-
}
|
|
440
|
-
function createPathGeometry(points, serializedPath) {
|
|
441
|
-
const compact = simplifyPoints(points);
|
|
442
|
-
const path = serializedPath ?? pointsToPath(compact);
|
|
443
|
-
const segments = compact.slice(1).map((end, index) => ({ start: compact[index], end, length: Math.hypot(end.x - compact[index].x, end.y - compact[index].y) }));
|
|
444
|
-
const length = segments.reduce((total, segment) => total + segment.length, 0);
|
|
445
|
-
const xs = compact.map((point) => point.x);
|
|
446
|
-
const ys = compact.map((point) => point.y);
|
|
447
|
-
const bounds = xs.length === 0
|
|
448
|
-
? { left: 0, top: 0, right: 0, bottom: 0 }
|
|
449
|
-
: { left: Math.min(...xs), top: Math.min(...ys), right: Math.max(...xs), bottom: Math.max(...ys) };
|
|
450
|
-
return {
|
|
451
|
-
path,
|
|
452
|
-
points: compact,
|
|
453
|
-
length: length || 1,
|
|
454
|
-
bounds,
|
|
455
|
-
pointAt(progress) {
|
|
456
|
-
if (compact.length === 0)
|
|
457
|
-
return { x: 0, y: 0 };
|
|
458
|
-
if (compact.length === 1 || length === 0)
|
|
459
|
-
return compact[0];
|
|
460
|
-
const distance = Math.min(1, Math.max(0, progress)) * length;
|
|
461
|
-
let traversed = 0;
|
|
462
|
-
for (const segment of segments) {
|
|
463
|
-
if (distance <= traversed + segment.length) {
|
|
464
|
-
const ratio = segment.length === 0 ? 0 : (distance - traversed) / segment.length;
|
|
465
|
-
return {
|
|
466
|
-
x: segment.start.x + (segment.end.x - segment.start.x) * ratio,
|
|
467
|
-
y: segment.start.y + (segment.end.y - segment.start.y) * ratio
|
|
468
|
-
};
|
|
469
|
-
}
|
|
470
|
-
traversed += segment.length;
|
|
471
|
-
}
|
|
472
|
-
return compact.at(-1);
|
|
473
|
-
}
|
|
474
|
-
};
|
|
475
|
-
}
|
|
476
|
-
/** Build sampled geometry for a cubic curve while retaining the exact cubic command for SVG output. */
|
|
477
|
-
function createCurveGeometry(start, controlStart, controlEnd, end) {
|
|
478
|
-
const samples = [];
|
|
479
|
-
const sampleCount = 32;
|
|
480
|
-
for (let index = 0; index <= sampleCount; index += 1) {
|
|
481
|
-
const progress = index / sampleCount;
|
|
482
|
-
const inverse = 1 - progress;
|
|
483
|
-
samples.push({
|
|
484
|
-
x: inverse ** 3 * start.x + 3 * inverse ** 2 * progress * controlStart.x + 3 * inverse * progress ** 2 * controlEnd.x + progress ** 3 * end.x,
|
|
485
|
-
y: inverse ** 3 * start.y + 3 * inverse ** 2 * progress * controlStart.y + 3 * inverse * progress ** 2 * controlEnd.y + progress ** 3 * end.y
|
|
486
|
-
});
|
|
487
|
-
}
|
|
488
|
-
const path = `M ${formatNumber(start.x)} ${formatNumber(start.y)} C ${formatNumber(controlStart.x)} ${formatNumber(controlStart.y)} ${formatNumber(controlEnd.x)} ${formatNumber(controlEnd.y)} ${formatNumber(end.x)} ${formatNumber(end.y)}`;
|
|
489
|
-
return createPathGeometry(samples, path);
|
|
490
|
-
}
|
|
491
|
-
/** Build a two-segment cubic route used for opposite relationships; endpoint tangents remain exact port normals. */
|
|
492
|
-
function createSeparatedCurveGeometry(start, controlStart, midpoint, controlMidStart, controlMidEnd, controlEnd, end) {
|
|
493
|
-
const samples = [];
|
|
494
|
-
const sampleSegment = (first, firstControl, secondControl, last) => {
|
|
495
|
-
for (let index = 0; index <= 16; index += 1) {
|
|
496
|
-
if (samples.length > 0 && index === 0)
|
|
497
|
-
continue;
|
|
498
|
-
const progress = index / 16;
|
|
499
|
-
const inverse = 1 - progress;
|
|
500
|
-
samples.push({
|
|
501
|
-
x: inverse ** 3 * first.x + 3 * inverse ** 2 * progress * firstControl.x + 3 * inverse * progress ** 2 * secondControl.x + progress ** 3 * last.x,
|
|
502
|
-
y: inverse ** 3 * first.y + 3 * inverse ** 2 * progress * firstControl.y + 3 * inverse * progress ** 2 * secondControl.y + progress ** 3 * last.y
|
|
503
|
-
});
|
|
504
|
-
}
|
|
505
|
-
};
|
|
506
|
-
sampleSegment(start, controlStart, controlMidStart, midpoint);
|
|
507
|
-
sampleSegment(midpoint, controlMidEnd, controlEnd, end);
|
|
508
|
-
const path = `M ${formatNumber(start.x)} ${formatNumber(start.y)} C ${formatNumber(controlStart.x)} ${formatNumber(controlStart.y)} ${formatNumber(controlMidStart.x)} ${formatNumber(controlMidStart.y)} ${formatNumber(midpoint.x)} ${formatNumber(midpoint.y)} C ${formatNumber(controlMidEnd.x)} ${formatNumber(controlMidEnd.y)} ${formatNumber(controlEnd.x)} ${formatNumber(controlEnd.y)} ${formatNumber(end.x)} ${formatNumber(end.y)}`;
|
|
509
|
-
return createPathGeometry(samples, path);
|
|
510
|
-
}
|
|
511
|
-
/** Resolve both focus targets against current geometry, then interpolate cameras with the timeline's eased progress. */
|
|
512
|
-
function resolveCamera(viewport, beforeTarget, afterTarget, progress, nodes, edges, edgeGeometries) {
|
|
513
|
-
const full = { x: 0, y: 0, width: viewport.width, height: viewport.height };
|
|
514
|
-
const before = focusCamera(viewport, beforeTarget, nodes, edges, edgeGeometries) ?? full;
|
|
515
|
-
const after = focusCamera(viewport, afterTarget, nodes, edges, edgeGeometries) ?? full;
|
|
516
|
-
if (progress <= 0)
|
|
517
|
-
return before;
|
|
518
|
-
if (progress >= 1)
|
|
519
|
-
return after;
|
|
520
|
-
return {
|
|
521
|
-
x: before.x + (after.x - before.x) * progress,
|
|
522
|
-
y: before.y + (after.y - before.y) * progress,
|
|
523
|
-
width: before.width + (after.width - before.width) * progress,
|
|
524
|
-
height: before.height + (after.height - before.height) * progress
|
|
525
|
-
};
|
|
526
|
-
}
|
|
527
|
-
/** Pad bounds of the target, descendants and visible incident edges, preserving aspect ratio within the initial viewport. */
|
|
528
|
-
function focusCamera(viewport, target, nodes, edges, edgeGeometries) {
|
|
529
|
-
if (!target)
|
|
530
|
-
return undefined;
|
|
531
|
-
const targetNode = nodes.find((node) => node.id === target);
|
|
532
|
-
if (!targetNode)
|
|
533
|
-
return undefined;
|
|
534
|
-
const targetIds = new Set(nodes.filter((node) => node.id === target || hasAncestor(node, target, nodes)).map((node) => node.id));
|
|
535
|
-
const bounds = nodes.filter((node) => targetIds.has(node.id)).map(nodeBounds);
|
|
536
|
-
for (const edge of edges) {
|
|
537
|
-
if (edge.visible && edge.opacity > 0 && (targetIds.has(edge.from) || targetIds.has(edge.to)))
|
|
538
|
-
bounds.push((edgeGeometries.get(edge.id) ?? emptyPathGeometry()).bounds);
|
|
539
|
-
}
|
|
540
|
-
const combined = combineBounds(bounds);
|
|
541
|
-
if (!combined)
|
|
542
|
-
return undefined;
|
|
543
|
-
const aspect = viewport.width / viewport.height;
|
|
544
|
-
const paddedWidth = combined.right - combined.left + FOCUS_PADDING * 2;
|
|
545
|
-
const paddedHeight = combined.bottom - combined.top + FOCUS_PADDING * 2;
|
|
546
|
-
let width = Math.max(paddedWidth, paddedHeight * aspect);
|
|
547
|
-
let height = Math.max(paddedHeight, paddedWidth / aspect);
|
|
548
|
-
if (width > viewport.width) {
|
|
549
|
-
width = viewport.width;
|
|
550
|
-
height = width / aspect;
|
|
551
|
-
}
|
|
552
|
-
if (height > viewport.height) {
|
|
553
|
-
height = viewport.height;
|
|
554
|
-
width = height * aspect;
|
|
555
|
-
}
|
|
556
|
-
const centerX = (combined.left + combined.right) / 2;
|
|
557
|
-
const centerY = (combined.top + combined.bottom) / 2;
|
|
558
|
-
const x = clampCamera(centerX - width / 2, 0, viewport.width - width);
|
|
559
|
-
const y = clampCamera(centerY - height / 2, 0, viewport.height - height);
|
|
560
|
-
return { x, y, width, height };
|
|
561
|
-
}
|
|
562
|
-
function hasAncestor(node, ancestorId, nodes) {
|
|
563
|
-
const byId = new Map(nodes.map((candidate) => [candidate.id, candidate]));
|
|
564
|
-
let parentId = node.parentId;
|
|
565
|
-
while (parentId) {
|
|
566
|
-
if (parentId === ancestorId)
|
|
567
|
-
return true;
|
|
568
|
-
parentId = byId.get(parentId)?.parentId;
|
|
569
|
-
}
|
|
570
|
-
return false;
|
|
571
|
-
}
|
|
572
|
-
function nodeBounds(node) {
|
|
573
|
-
return { left: node.x, top: node.y, right: node.x + node.width, bottom: node.y + node.height };
|
|
574
|
-
}
|
|
575
|
-
function combineBounds(bounds) {
|
|
576
|
-
if (bounds.length === 0)
|
|
577
|
-
return undefined;
|
|
578
|
-
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)) };
|
|
579
|
-
}
|
|
580
|
-
function clampCamera(value, min, max) {
|
|
581
|
-
return Math.min(max, Math.max(min, value));
|
|
582
|
-
}
|
|
583
|
-
/** Include deterministic label bounds so overflowing text remains part of the node's interaction target. */
|
|
584
|
-
function isPointInNode(point, node) {
|
|
585
|
-
const labelHit = node.labelBoxes.some((line) => {
|
|
586
|
-
if (line.text.length === 0)
|
|
587
|
-
return false;
|
|
588
|
-
const halfWidth = estimateTextWidth(line.text, NODE_TYPOGRAPHY) / 2;
|
|
589
|
-
return point.x >= line.x - halfWidth && point.x <= line.x + halfWidth
|
|
590
|
-
&& point.y >= line.y - NODE_TYPOGRAPHY.fontSize && point.y <= line.y + NODE_TYPOGRAPHY.fontSize * TEXT_DESCENT_RATIO;
|
|
591
|
-
});
|
|
592
|
-
if (labelHit)
|
|
593
|
-
return true;
|
|
594
|
-
if (node.kind === "ellipse") {
|
|
595
|
-
const dx = (point.x - (node.x + node.width / 2)) / (node.width / 2);
|
|
596
|
-
const dy = (point.y - (node.y + node.height / 2)) / (node.height / 2);
|
|
597
|
-
return dx * dx + dy * dy <= 1;
|
|
598
|
-
}
|
|
599
|
-
return point.x >= node.x && point.x <= node.x + node.width && point.y >= node.y && point.y <= node.y + node.height;
|
|
600
|
-
}
|
|
601
|
-
function isPointNearPath(point, geometry, tolerance) {
|
|
602
|
-
for (let index = 1; index < geometry.points.length; index += 1)
|
|
603
|
-
if (distanceToSegment(point, geometry.points[index - 1], geometry.points[index]) <= tolerance)
|
|
604
|
-
return true;
|
|
605
|
-
return false;
|
|
606
|
-
}
|
|
607
|
-
function distanceToSegment(point, start, end) {
|
|
608
|
-
const dx = end.x - start.x;
|
|
609
|
-
const dy = end.y - start.y;
|
|
610
|
-
if (dx === 0 && dy === 0)
|
|
611
|
-
return Math.hypot(point.x - start.x, point.y - start.y);
|
|
612
|
-
const factor = Math.max(0, Math.min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / (dx * dx + dy * dy)));
|
|
613
|
-
return Math.hypot(point.x - (start.x + factor * dx), point.y - (start.y + factor * dy));
|
|
614
|
-
}
|
|
615
|
-
/**
|
|
616
|
-
* Automatic orthogonal routing excludes endpoints and group frames from obstacles.
|
|
617
|
-
* Explicit orthogonal ports reserve outward stubs and route around endpoint bodies as well.
|
|
618
|
-
* Routing uses visible separately from paint opacity: reveals block immediately, and hides block until completion.
|
|
619
|
-
*/
|
|
620
|
-
function routeEdge(edge, from, to, nodes, reverseOffset = 0) {
|
|
621
|
-
if (edge.route === "curved") {
|
|
622
|
-
const automatic = automaticPorts(from, to);
|
|
623
|
-
const fromDirection = edge.fromPort ?? automatic.from;
|
|
624
|
-
const toDirection = edge.toPort ?? automatic.to;
|
|
625
|
-
const start = portPoint(from, fromDirection);
|
|
626
|
-
const end = portPoint(to, toDirection);
|
|
627
|
-
return curvedPath(start, end, fromDirection, toDirection, reverseOffset);
|
|
628
|
-
}
|
|
629
|
-
if (edge.route !== "orthogonal") {
|
|
630
|
-
const points = edge.fromPort || edge.toPort
|
|
631
|
-
? [portPoint(from, edge.fromPort ?? automaticPorts(from, to).from), portPoint(to, edge.toPort ?? automaticPorts(from, to).to)]
|
|
632
|
-
: straightPoints(from, to);
|
|
633
|
-
return createPathGeometry(points);
|
|
634
|
-
}
|
|
635
|
-
const { start, end } = orthogonalAttachments(from, to, edge.fromPort, edge.toPort);
|
|
636
|
-
const obstacles = nodes
|
|
637
|
-
.filter((node) => node.visible && node.kind !== "group" && node.id !== from.id && node.id !== to.id)
|
|
638
|
-
.map(routingBounds);
|
|
639
|
-
if (edge.fromPort || edge.toPort)
|
|
640
|
-
return createPathGeometry(explicitOrthogonalPath(edge, from, to, obstacles));
|
|
641
|
-
return createPathGeometry(orthogonalPath(start, end, obstacles));
|
|
642
|
-
}
|
|
643
|
-
function routingBounds(node) {
|
|
644
|
-
return {
|
|
645
|
-
left: node.x - ROUTING_GAP,
|
|
646
|
-
top: node.y - ROUTING_GAP,
|
|
647
|
-
right: node.x + node.width + ROUTING_GAP,
|
|
648
|
-
bottom: node.y + node.height + ROUTING_GAP
|
|
649
|
-
};
|
|
650
|
-
}
|
|
651
|
-
/** Reserve endpoint-normal segments before grid search so a backside port cannot route through its own node. */
|
|
652
|
-
function explicitOrthogonalPath(edge, from, to, obstacles) {
|
|
653
|
-
const automatic = automaticPorts(from, to);
|
|
654
|
-
const fromDirection = edge.fromPort ?? automatic.from;
|
|
655
|
-
const toDirection = edge.toPort ?? automatic.to;
|
|
656
|
-
const start = portPoint(from, fromDirection);
|
|
657
|
-
const end = portPoint(to, toDirection);
|
|
658
|
-
const fromNormal = portNormal(fromDirection);
|
|
659
|
-
const toNormal = portNormal(toDirection);
|
|
660
|
-
const startStub = { x: start.x + fromNormal.x * ROUTING_GAP, y: start.y + fromNormal.y * ROUTING_GAP };
|
|
661
|
-
const endStub = { x: end.x + toNormal.x * ROUTING_GAP, y: end.y + toNormal.y * ROUTING_GAP };
|
|
662
|
-
const middle = orthogonalPath(startStub, endStub, [...obstacles, routingBounds(from), routingBounds(to)]);
|
|
663
|
-
return [start, ...middle, end];
|
|
664
|
-
}
|
|
665
|
-
/** Choose rectangular attachment sides along the dominant center-to-center axis for orthogonal connections. */
|
|
666
|
-
function orthogonalAttachments(from, to, fromPort, toPort) {
|
|
667
|
-
const automatic = automaticPorts(from, to);
|
|
668
|
-
return {
|
|
669
|
-
start: portPoint(from, fromPort ?? automatic.from),
|
|
670
|
-
end: portPoint(to, toPort ?? automatic.to)
|
|
671
|
-
};
|
|
672
|
-
}
|
|
673
|
-
/** Select cardinal sides from the dominant center-to-center axis for automatic routes. */
|
|
674
|
-
function automaticPorts(from, to) {
|
|
675
|
-
const fromCenter = { x: from.x + from.width / 2, y: from.y + from.height / 2 };
|
|
676
|
-
const toCenter = { x: to.x + to.width / 2, y: to.y + to.height / 2 };
|
|
677
|
-
const horizontal = Math.abs(toCenter.x - fromCenter.x) >= Math.abs(toCenter.y - fromCenter.y);
|
|
678
|
-
if (horizontal) {
|
|
679
|
-
const right = toCenter.x >= fromCenter.x;
|
|
680
|
-
return { from: right ? "right" : "left", to: right ? "left" : "right" };
|
|
681
|
-
}
|
|
682
|
-
const bottom = toCenter.y >= fromCenter.y;
|
|
683
|
-
return { from: bottom ? "bottom" : "top", to: bottom ? "top" : "bottom" };
|
|
684
|
-
}
|
|
685
|
-
/** Return the actual outline point for a cardinal side, including ellipse contours. */
|
|
686
|
-
function portPoint(node, direction) {
|
|
687
|
-
const center = { x: node.x + node.width / 2, y: node.y + node.height / 2 };
|
|
688
|
-
if (direction === "top")
|
|
689
|
-
return { x: center.x, y: node.y };
|
|
690
|
-
if (direction === "right")
|
|
691
|
-
return { x: node.x + node.width, y: center.y };
|
|
692
|
-
if (direction === "bottom")
|
|
693
|
-
return { x: center.x, y: node.y + node.height };
|
|
694
|
-
return { x: node.x, y: center.y };
|
|
695
|
-
}
|
|
696
|
-
/** Generate a smooth cubic route whose endpoint tangents follow port normals. */
|
|
697
|
-
function curvedPath(start, end, fromDirection, toDirection, separation) {
|
|
698
|
-
const distance = Math.hypot(end.x - start.x, end.y - start.y);
|
|
699
|
-
const tangentLength = Math.max(24, distance * 0.32);
|
|
700
|
-
const fromNormal = portNormal(fromDirection);
|
|
701
|
-
const toNormal = portNormal(toDirection);
|
|
702
|
-
const perpendicular = { x: -(end.y - start.y), y: end.x - start.x };
|
|
703
|
-
const perpendicularLength = Math.hypot(perpendicular.x, perpendicular.y) || 1;
|
|
704
|
-
const lane = separation * Math.min(72, Math.max(28, distance * 0.16));
|
|
705
|
-
if (separation !== 0) {
|
|
706
|
-
const midpoint = {
|
|
707
|
-
x: (start.x + end.x) / 2 + perpendicular.x / perpendicularLength * lane,
|
|
708
|
-
y: (start.y + end.y) / 2 + perpendicular.y / perpendicularLength * lane
|
|
709
|
-
};
|
|
710
|
-
const chord = { x: end.x - start.x, y: end.y - start.y };
|
|
711
|
-
const chordLength = Math.hypot(chord.x, chord.y) || 1;
|
|
712
|
-
const joinTangent = { x: chord.x / chordLength, y: chord.y / chordLength };
|
|
713
|
-
return createSeparatedCurveGeometry(start, { x: start.x + fromNormal.x * tangentLength, y: start.y + fromNormal.y * tangentLength }, midpoint, { x: midpoint.x - joinTangent.x * tangentLength * 0.5, y: midpoint.y - joinTangent.y * tangentLength * 0.5 }, { x: midpoint.x + joinTangent.x * tangentLength * 0.5, y: midpoint.y + joinTangent.y * tangentLength * 0.5 }, { x: end.x + toNormal.x * tangentLength, y: end.y + toNormal.y * tangentLength }, end);
|
|
714
|
-
}
|
|
715
|
-
const controlStart = {
|
|
716
|
-
x: start.x + fromNormal.x * tangentLength + perpendicular.x / perpendicularLength * lane,
|
|
717
|
-
y: start.y + fromNormal.y * tangentLength + perpendicular.y / perpendicularLength * lane
|
|
718
|
-
};
|
|
719
|
-
const controlEnd = {
|
|
720
|
-
x: end.x + toNormal.x * tangentLength + perpendicular.x / perpendicularLength * lane,
|
|
721
|
-
y: end.y + toNormal.y * tangentLength + perpendicular.y / perpendicularLength * lane
|
|
722
|
-
};
|
|
723
|
-
return createCurveGeometry(start, controlStart, controlEnd, end);
|
|
724
|
-
}
|
|
725
|
-
function portNormal(direction) {
|
|
726
|
-
if (direction === "top")
|
|
727
|
-
return { x: 0, y: -1 };
|
|
728
|
-
if (direction === "right")
|
|
729
|
-
return { x: 1, y: 0 };
|
|
730
|
-
if (direction === "bottom")
|
|
731
|
-
return { x: 0, y: 1 };
|
|
732
|
-
return { x: -1, y: 0 };
|
|
733
|
-
}
|
|
734
|
-
/**
|
|
735
|
-
* Breadth-first search the grid of endpoints and obstacle boundaries, using fixed neighbor order for determinism.
|
|
736
|
-
* Grid-step search does not guarantee shortest geometric length or fewest bends; fall back to a fixed elbow if blocked.
|
|
737
|
-
*/
|
|
738
|
-
function orthogonalPath(start, end, obstacles) {
|
|
739
|
-
const xs = uniqueSorted([start.x, end.x, ...obstacles.flatMap((rect) => [rect.left, rect.right])]);
|
|
740
|
-
const ys = uniqueSorted([start.y, end.y, ...obstacles.flatMap((rect) => [rect.top, rect.bottom])]);
|
|
741
|
-
const xIndex = new Map(xs.map((value, index) => [value, index]));
|
|
742
|
-
const yIndex = new Map(ys.map((value, index) => [value, index]));
|
|
743
|
-
const startKey = pointKey(start);
|
|
744
|
-
const endKey = pointKey(end);
|
|
745
|
-
const queue = [start];
|
|
746
|
-
const previous = new Map([[startKey, undefined]]);
|
|
747
|
-
const preferredHorizontal = Math.abs(end.x - start.x) >= Math.abs(end.y - start.y);
|
|
748
|
-
const directions = preferredHorizontal
|
|
749
|
-
? [[1, 0], [0, -1], [0, 1], [-1, 0]]
|
|
750
|
-
: [[0, 1], [1, 0], [-1, 0], [0, -1]];
|
|
751
|
-
while (queue.length > 0) {
|
|
752
|
-
const current = queue.shift();
|
|
753
|
-
if (pointKey(current) === endKey)
|
|
754
|
-
break;
|
|
755
|
-
const currentX = xIndex.get(current.x);
|
|
756
|
-
const currentY = yIndex.get(current.y);
|
|
757
|
-
if (currentX === undefined || currentY === undefined)
|
|
758
|
-
continue;
|
|
759
|
-
for (const [dx, dy] of directions) {
|
|
760
|
-
const nextX = xs[currentX + dx];
|
|
761
|
-
const nextY = ys[currentY + dy];
|
|
762
|
-
if (nextX === undefined || nextY === undefined)
|
|
763
|
-
continue;
|
|
764
|
-
const next = { x: nextX, y: nextY };
|
|
765
|
-
const key = pointKey(next);
|
|
766
|
-
if (previous.has(key) || isInsideObstacle(next, obstacles) || blockedSegment(current, next, obstacles))
|
|
767
|
-
continue;
|
|
768
|
-
previous.set(key, current);
|
|
769
|
-
queue.push(next);
|
|
770
|
-
}
|
|
771
|
-
}
|
|
772
|
-
const points = [];
|
|
773
|
-
if (previous.has(endKey)) {
|
|
774
|
-
let current = end;
|
|
775
|
-
while (current) {
|
|
776
|
-
points.push(current);
|
|
777
|
-
current = previous.get(pointKey(current));
|
|
778
|
-
}
|
|
779
|
-
points.reverse();
|
|
780
|
-
}
|
|
781
|
-
else {
|
|
782
|
-
const middle = preferredHorizontal ? { x: end.x, y: start.y } : { x: start.x, y: end.y };
|
|
783
|
-
points.push(start, middle, end);
|
|
784
|
-
}
|
|
785
|
-
return removeMicroTurns(points, obstacles);
|
|
786
|
-
}
|
|
787
|
-
/** Replace tiny obstacle-detour turns with a wider elbow when the replacement remains collision-free. */
|
|
788
|
-
function removeMicroTurns(points, obstacles) {
|
|
789
|
-
const result = [...points];
|
|
790
|
-
const minimum = ROUTING_GAP * 2;
|
|
791
|
-
for (let index = 1; index + 2 < result.length; index += 1) {
|
|
792
|
-
const before = result[index - 1];
|
|
793
|
-
const first = result[index];
|
|
794
|
-
const second = result[index + 1];
|
|
795
|
-
const after = result[index + 2];
|
|
796
|
-
const shortLength = Math.abs(second.x - first.x) + Math.abs(second.y - first.y);
|
|
797
|
-
if (shortLength >= minimum)
|
|
798
|
-
continue;
|
|
799
|
-
const candidate = before.x === first.x
|
|
800
|
-
? { x: first.x, y: after.y }
|
|
801
|
-
: { x: after.x, y: first.y };
|
|
802
|
-
if (isInsideObstacle(candidate, obstacles) || blockedSegment(before, candidate, obstacles) || blockedSegment(candidate, after, obstacles))
|
|
803
|
-
continue;
|
|
804
|
-
result.splice(index, 2, candidate);
|
|
805
|
-
index = Math.max(0, index - 2);
|
|
806
|
-
}
|
|
807
|
-
return result;
|
|
808
|
-
}
|
|
809
|
-
/** Remove consecutive duplicates and collinear intermediate points to keep SVG paths compact. */
|
|
810
|
-
function pointsToPath(points) {
|
|
811
|
-
return simplifyPoints(points).map((point, index) => `${index === 0 ? "M" : "L"} ${formatNumber(point.x)} ${formatNumber(point.y)}`).join(" ");
|
|
812
|
-
}
|
|
813
|
-
/** Keep collinear reversals: removing an extremum would shorten flow distance and truncate path bounds. */
|
|
814
|
-
function simplifyPoints(points) {
|
|
815
|
-
const compact = [];
|
|
816
|
-
for (const point of points) {
|
|
817
|
-
const previous = compact.at(-1);
|
|
818
|
-
if (!previous || previous.x !== point.x || previous.y !== point.y)
|
|
819
|
-
compact.push(point);
|
|
820
|
-
}
|
|
821
|
-
const simplified = [];
|
|
822
|
-
for (const point of compact) {
|
|
823
|
-
const previous = simplified.at(-1);
|
|
824
|
-
const beforePrevious = simplified.at(-2);
|
|
825
|
-
const continuesDirection = previous && beforePrevious
|
|
826
|
-
&& (previous.x - beforePrevious.x) * (point.x - previous.x) + (previous.y - beforePrevious.y) * (point.y - previous.y) >= 0;
|
|
827
|
-
if (previous && beforePrevious && continuesDirection && (beforePrevious.x === previous.x && previous.x === point.x || beforePrevious.y === previous.y && previous.y === point.y)) {
|
|
828
|
-
simplified[simplified.length - 1] = point;
|
|
829
|
-
}
|
|
830
|
-
else {
|
|
831
|
-
simplified.push(point);
|
|
832
|
-
}
|
|
833
|
-
}
|
|
834
|
-
return simplified;
|
|
835
|
-
}
|
|
836
|
-
/** Obstacles already include ROUTING_GAP; allow boundary contact and reject only segments crossing their interiors. */
|
|
837
|
-
function blockedSegment(start, end, obstacles) {
|
|
838
|
-
for (const obstacle of obstacles) {
|
|
839
|
-
if (start.x === end.x) {
|
|
840
|
-
if (start.x > obstacle.left && start.x < obstacle.right && rangesOverlap(start.y, end.y, obstacle.top, obstacle.bottom))
|
|
841
|
-
return true;
|
|
842
|
-
}
|
|
843
|
-
else if (start.y === end.y && start.y > obstacle.top && start.y < obstacle.bottom && rangesOverlap(start.x, end.x, obstacle.left, obstacle.right)) {
|
|
844
|
-
return true;
|
|
845
|
-
}
|
|
846
|
-
}
|
|
847
|
-
return false;
|
|
848
|
-
}
|
|
849
|
-
function isInsideObstacle(point, obstacles) {
|
|
850
|
-
return obstacles.some((obstacle) => point.x > obstacle.left && point.x < obstacle.right && point.y > obstacle.top && point.y < obstacle.bottom);
|
|
851
|
-
}
|
|
852
|
-
function rangesOverlap(firstStart, firstEnd, secondStart, secondEnd) {
|
|
853
|
-
return Math.max(Math.min(firstStart, firstEnd), secondStart) < Math.min(Math.max(firstStart, firstEnd), secondEnd);
|
|
854
|
-
}
|
|
855
|
-
function pointKey(point) {
|
|
856
|
-
return `${point.x},${point.y}`;
|
|
857
|
-
}
|
|
858
|
-
function uniqueSorted(values) {
|
|
859
|
-
return [...new Set(values)].sort((left, right) => left - right);
|
|
860
|
-
}
|
|
861
|
-
/** Project straight connections onto the ellipse contour, or the bounding rectangle for other shapes. */
|
|
862
|
-
function boundaryPoint(node, toward) {
|
|
863
|
-
const center = { x: node.x + node.width / 2, y: node.y + node.height / 2 };
|
|
864
|
-
const dx = toward.x - center.x;
|
|
865
|
-
const dy = toward.y - center.y;
|
|
866
|
-
if (dx === 0 && dy === 0)
|
|
867
|
-
return center;
|
|
868
|
-
const normalizedX = dx / (node.width / 2);
|
|
869
|
-
const normalizedY = dy / (node.height / 2);
|
|
870
|
-
const scale = 1 / (node.kind === "ellipse"
|
|
871
|
-
? Math.hypot(normalizedX, normalizedY)
|
|
872
|
-
: Math.max(Math.abs(normalizedX), Math.abs(normalizedY)));
|
|
873
|
-
return { x: center.x + dx * scale, y: center.y + dy * scale };
|
|
874
|
-
}
|
|
875
|
-
function pathLabelAnchor(geometry) {
|
|
876
|
-
const points = geometry.points;
|
|
877
|
-
if (points.length < 2)
|
|
878
|
-
return { x: 0, y: -12 };
|
|
879
|
-
let longest = { start: points[0], end: points[1], length: 0 };
|
|
880
|
-
for (let index = 1; index < points.length; index += 1) {
|
|
881
|
-
const start = points[index - 1];
|
|
882
|
-
const end = points[index];
|
|
883
|
-
const length = Math.hypot(end.x - start.x, end.y - start.y);
|
|
884
|
-
if (length > longest.length)
|
|
885
|
-
longest = { start, end, length };
|
|
886
|
-
}
|
|
887
|
-
const horizontal = longest.start.y === longest.end.y;
|
|
888
|
-
return {
|
|
889
|
-
x: (longest.start.x + longest.end.x) / 2 + (horizontal ? 0 : 20),
|
|
890
|
-
y: (longest.start.y + longest.end.y) / 2 + (horizontal ? -12 : 0)
|
|
891
|
-
};
|
|
892
|
-
}
|
|
893
|
-
function formatNumber(value) {
|
|
894
|
-
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(3)));
|
|
895
|
-
}
|
|
896
|
-
function escapeText(value) {
|
|
897
|
-
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
898
|
-
}
|
|
899
|
-
function escapeAttribute(value) {
|
|
900
|
-
return escapeText(value);
|
|
901
|
-
}
|
|
902
|
-
function isRecord(value) {
|
|
903
|
-
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
904
|
-
return false;
|
|
905
|
-
const prototype = Object.getPrototypeOf(value);
|
|
906
|
-
return prototype === Object.prototype || prototype === null;
|
|
907
|
-
}
|
|
908
|
-
function isNonEmptyString(value) {
|
|
909
|
-
return typeof value === "string" && value.length > 0;
|
|
910
|
-
}
|
|
911
|
-
function isNodeKind(value) {
|
|
912
|
-
return value === "rect" || value === "roundedRect" || value === "ellipse" || value === "cylinder" || value === "group";
|
|
913
|
-
}
|
|
914
|
-
function isPortDirection(value) {
|
|
915
|
-
return value === "top" || value === "right" || value === "bottom" || value === "left";
|
|
916
|
-
}
|
|
917
|
-
function validateFinite(value, path, issues) {
|
|
918
|
-
if (typeof value !== "number" || !Number.isFinite(value))
|
|
919
|
-
issues.push({ path, message: "must be a finite number" });
|
|
920
|
-
}
|
|
921
|
-
function validatePositiveFinite(value, path, issues) {
|
|
922
|
-
validateFinite(value, path, issues);
|
|
923
|
-
if (typeof value === "number" && Number.isFinite(value) && value <= 0)
|
|
924
|
-
issues.push({ path, message: "must be greater than zero" });
|
|
925
|
-
}
|
|
926
|
-
function validateNonNegativeFinite(value, path, issues) {
|
|
927
|
-
validateFinite(value, path, issues);
|
|
928
|
-
if (typeof value === "number" && Number.isFinite(value) && value < 0)
|
|
929
|
-
issues.push({ path, message: "must be zero or greater" });
|
|
930
|
-
}
|
|
931
|
-
function validateUnknownFields(record, allowed, path, issues) {
|
|
932
|
-
const allowedFields = new Set(allowed);
|
|
933
|
-
for (const key of Object.keys(record))
|
|
934
|
-
if (!allowedFields.has(key))
|
|
935
|
-
issues.push({ path: path === "$" ? key : `${path}.${key}`, message: "unknown field" });
|
|
936
|
-
}
|
|
937
|
-
function validateDenseArray(values, path, issues) {
|
|
938
|
-
for (let index = 0; index < values.length; index += 1)
|
|
939
|
-
if (!(index in values))
|
|
940
|
-
issues.push({ path: `${path}[${index}]`, message: "must be a value" });
|
|
941
|
-
}
|
|
942
|
-
/** Restrict colors to interpolable hexadecimal data, excluding CSS expressions and external resource references. */
|
|
943
|
-
function validateColor(value, path, issues) {
|
|
944
|
-
if (typeof value !== "string" || !/^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i.test(value))
|
|
945
|
-
issues.push({ path, message: "must be a hexadecimal color" });
|
|
946
|
-
}
|
|
947
|
-
function validateNodeStyle(style, path, issues) {
|
|
948
|
-
if (style === undefined)
|
|
949
|
-
return;
|
|
950
|
-
if (!isRecord(style)) {
|
|
951
|
-
issues.push({ path, message: "must be an object" });
|
|
952
|
-
return;
|
|
953
|
-
}
|
|
954
|
-
validateUnknownFields(style, ["fill", "stroke", "strokeWidth", "textColor", "dashed"], path, issues);
|
|
955
|
-
for (const key of ["fill", "stroke", "textColor"])
|
|
956
|
-
if (style[key] !== undefined)
|
|
957
|
-
validateColor(style[key], `${path}.${key}`, issues);
|
|
958
|
-
if (style.strokeWidth !== undefined)
|
|
959
|
-
validateNonNegativeFinite(style.strokeWidth, `${path}.strokeWidth`, issues);
|
|
960
|
-
if (style.dashed !== undefined && typeof style.dashed !== "boolean")
|
|
961
|
-
issues.push({ path: `${path}.dashed`, message: "must be a boolean" });
|
|
962
|
-
}
|
|
963
|
-
function validateEdgeStyle(style, path, issues) {
|
|
964
|
-
if (style === undefined)
|
|
965
|
-
return;
|
|
966
|
-
if (!isRecord(style)) {
|
|
967
|
-
issues.push({ path, message: "must be an object" });
|
|
968
|
-
return;
|
|
969
|
-
}
|
|
970
|
-
validateUnknownFields(style, ["stroke", "strokeWidth", "dashed"], path, issues);
|
|
971
|
-
if (style.stroke !== undefined)
|
|
972
|
-
validateColor(style.stroke, `${path}.stroke`, issues);
|
|
973
|
-
if (style.strokeWidth !== undefined)
|
|
974
|
-
validateNonNegativeFinite(style.strokeWidth, `${path}.strokeWidth`, issues);
|
|
975
|
-
if (style.dashed !== undefined && typeof style.dashed !== "boolean")
|
|
976
|
-
issues.push({ path: `${path}.dashed`, message: "must be a boolean" });
|
|
977
|
-
}
|
|
978
|
-
function validateEdgeEndpoint(value, path, nodeById, issues) {
|
|
979
|
-
if (!isNonEmptyString(value)) {
|
|
980
|
-
issues.push({ path, message: "must reference an existing node" });
|
|
981
|
-
return;
|
|
982
|
-
}
|
|
983
|
-
const node = nodeById.get(value);
|
|
984
|
-
if (!node)
|
|
985
|
-
issues.push({ path, message: "must reference an existing node" });
|
|
986
|
-
}
|
|
987
|
-
/** Reject parent cycles at load time so later position and visibility recursion terminates. */
|
|
988
|
-
function validateParentCycles(nodes, nodeById, issues) {
|
|
989
|
-
const reported = new Set();
|
|
990
|
-
for (const [id, node] of nodeById) {
|
|
991
|
-
const chain = new Set();
|
|
992
|
-
let current = id;
|
|
993
|
-
while (current) {
|
|
994
|
-
if (chain.has(current)) {
|
|
995
|
-
const index = nodes.findIndex((candidate) => isRecord(candidate) && candidate.id === id);
|
|
996
|
-
const path = index >= 0 ? `nodes[${index}].parentId` : "nodes.parentId";
|
|
997
|
-
if (!reported.has(path)) {
|
|
998
|
-
issues.push({ path, message: "group parent references form a cycle" });
|
|
999
|
-
reported.add(path);
|
|
1000
|
-
}
|
|
1001
|
-
break;
|
|
1002
|
-
}
|
|
1003
|
-
chain.add(current);
|
|
1004
|
-
const parentValue = nodeById.get(current)?.parentId;
|
|
1005
|
-
current = isNonEmptyString(parentValue) ? parentValue : undefined;
|
|
1006
|
-
}
|
|
1007
|
-
}
|
|
1008
|
-
}
|
|
1009
|
-
/** Track assignment keys per beat: allow different fields on one target but reject duplicate writes to a field. */
|
|
1010
|
-
function validateBeat(beat, path, allIds, nodeById, edgeById, issues) {
|
|
1011
|
-
if (!isRecord(beat)) {
|
|
1012
|
-
issues.push({ path, message: "must be an object" });
|
|
1013
|
-
return;
|
|
1014
|
-
}
|
|
1015
|
-
validateUnknownFields(beat, ["durationMs", "transitionMs", "caption", "actions"], path, issues);
|
|
1016
|
-
validatePositiveFinite(beat.durationMs, `${path}.durationMs`, issues);
|
|
1017
|
-
if (beat.transitionMs !== undefined) {
|
|
1018
|
-
validateNonNegativeFinite(beat.transitionMs, `${path}.transitionMs`, issues);
|
|
1019
|
-
if (typeof beat.durationMs === "number" && Number.isFinite(beat.durationMs) && typeof beat.transitionMs === "number" && Number.isFinite(beat.transitionMs) && beat.transitionMs > beat.durationMs)
|
|
1020
|
-
issues.push({ path: `${path}.transitionMs`, message: "must not exceed durationMs" });
|
|
1021
|
-
}
|
|
1022
|
-
if (beat.caption !== undefined && typeof beat.caption !== "string")
|
|
1023
|
-
issues.push({ path: `${path}.caption`, message: "must be a string" });
|
|
1024
|
-
if (!Array.isArray(beat.actions)) {
|
|
1025
|
-
issues.push({ path: `${path}.actions`, message: "must be an array" });
|
|
1026
|
-
return;
|
|
1027
|
-
}
|
|
1028
|
-
validateDenseArray(beat.actions, `${path}.actions`, issues);
|
|
1029
|
-
const assignments = new Set();
|
|
1030
|
-
beat.actions.forEach((action, index) => validateAction(action, `${path}.actions[${index}]`, allIds, nodeById, edgeById, assignments, issues));
|
|
1031
|
-
}
|
|
1032
|
-
/** Validate fields/targets by action kind; visibility and highlight keys are separate from ordinary style updates. */
|
|
1033
|
-
function validateAction(action, path, allIds, nodeById, edgeById, assignments, issues) {
|
|
1034
|
-
if (!isRecord(action)) {
|
|
1035
|
-
issues.push({ path, message: "must be an object" });
|
|
1036
|
-
return;
|
|
1037
|
-
}
|
|
1038
|
-
if (typeof action.type !== "string") {
|
|
1039
|
-
issues.push({ path: `${path}.type`, message: "must be a supported action" });
|
|
1040
|
-
return;
|
|
1041
|
-
}
|
|
1042
|
-
const type = action.type;
|
|
1043
|
-
const allowedByType = {
|
|
1044
|
-
show: ["type", "target"], hide: ["type", "target"], highlight: ["type", "target"], unhighlight: ["type", "target"],
|
|
1045
|
-
updateNode: ["type", "target", "value"], updateEdge: ["type", "target", "value"], focus: ["type", "target"],
|
|
1046
|
-
flow: ["type", "target", "direction", "text"]
|
|
1047
|
-
};
|
|
1048
|
-
const allowed = Object.hasOwn(allowedByType, type) ? allowedByType[type] : undefined;
|
|
1049
|
-
if (!allowed) {
|
|
1050
|
-
issues.push({ path: `${path}.type`, message: "unknown action" });
|
|
1051
|
-
return;
|
|
1052
|
-
}
|
|
1053
|
-
validateUnknownFields(action, allowed, path, issues);
|
|
1054
|
-
if (["show", "hide", "highlight", "unhighlight"].includes(type)) {
|
|
1055
|
-
if (!isNonEmptyString(action.target)) {
|
|
1056
|
-
issues.push({ path: `${path}.target`, message: "must reference an existing object" });
|
|
1057
|
-
return;
|
|
1058
|
-
}
|
|
1059
|
-
if (!allIds.has(action.target))
|
|
1060
|
-
issues.push({ path: `${path}.target`, message: "must reference an existing object" });
|
|
1061
|
-
addAssignment(`${type === "show" || type === "hide" ? "visibility" : "highlight"}:${action.target}`, path, assignments, issues);
|
|
1062
|
-
return;
|
|
1063
|
-
}
|
|
1064
|
-
if (type === "focus") {
|
|
1065
|
-
if (action.target !== null && (!isNonEmptyString(action.target) || !nodeById.has(action.target)))
|
|
1066
|
-
issues.push({ path: `${path}.target`, message: "must reference a node or group" });
|
|
1067
|
-
if (action.target === null || (isNonEmptyString(action.target) && nodeById.has(action.target)))
|
|
1068
|
-
addAssignment("focus", path, assignments, issues);
|
|
1069
|
-
return;
|
|
1070
|
-
}
|
|
1071
|
-
if (type === "flow") {
|
|
1072
|
-
if (!isNonEmptyString(action.target))
|
|
1073
|
-
issues.push({ path: `${path}.target`, message: "must reference an existing edge" });
|
|
1074
|
-
else if (!edgeById.has(action.target))
|
|
1075
|
-
issues.push({ path: `${path}.target`, message: "must reference an existing edge" });
|
|
1076
|
-
if (action.direction !== "forward" && action.direction !== "reverse")
|
|
1077
|
-
issues.push({ path: `${path}.direction`, message: "must be forward or reverse" });
|
|
1078
|
-
if (action.text !== undefined && typeof action.text !== "string")
|
|
1079
|
-
issues.push({ path: `${path}.text`, message: "must be a string" });
|
|
1080
|
-
// Reserve the edge independently of other field errors so invalid directions cannot hide duplicate transfers.
|
|
1081
|
-
if (isNonEmptyString(action.target) && edgeById.has(action.target))
|
|
1082
|
-
addAssignment(`flow:${action.target}`, `${path}.target`, assignments, issues);
|
|
1083
|
-
return;
|
|
1084
|
-
}
|
|
1085
|
-
if (!isNonEmptyString(action.target)) {
|
|
1086
|
-
issues.push({ path: `${path}.target`, message: `must reference a ${type === "updateNode" ? "node" : "edge"}` });
|
|
1087
|
-
}
|
|
1088
|
-
else if (type === "updateNode" ? !nodeById.has(action.target) : !edgeById.has(action.target))
|
|
1089
|
-
issues.push({ path: `${path}.target`, message: `must reference an existing ${type === "updateNode" ? "node" : "edge"}` });
|
|
1090
|
-
validateUpdateValue(action.value, `${path}.value`, type === "updateNode", assignments, action.target, issues);
|
|
1091
|
-
const targetNode = typeof action.target === "string" ? nodeById.get(action.target) : undefined;
|
|
1092
|
-
if (type === "updateNode" && targetNode && isRecord(action.value) && action.value.content !== undefined && action.value.content !== null && !supportsNodeContent(targetNode.kind)) {
|
|
1093
|
-
issues.push({ path: `${path}.value.content`, message: "requires a rect, roundedRect or cylinder node" });
|
|
1094
|
-
}
|
|
1095
|
-
}
|
|
1096
|
-
function validateUpdateValue(value, path, isNode, assignments, target, issues) {
|
|
1097
|
-
if (!isRecord(value)) {
|
|
1098
|
-
issues.push({ path, message: "must be an object" });
|
|
1099
|
-
return;
|
|
1100
|
-
}
|
|
1101
|
-
const allowed = isNode ? ["x", "y", "width", "height", "label", "content", "fill", "stroke", "opacity"] : ["label", "stroke", "strokeWidth", "opacity"];
|
|
1102
|
-
validateUnknownFields(value, allowed, path, issues);
|
|
1103
|
-
if (isNode && Object.hasOwn(value, "label") && Object.hasOwn(value, "content")) {
|
|
1104
|
-
issues.push({ path: `${path}.content`, message: "must not be declared together with label" });
|
|
1105
|
-
}
|
|
1106
|
-
for (const key of Object.keys(value)) {
|
|
1107
|
-
if (!allowed.includes(key))
|
|
1108
|
-
continue;
|
|
1109
|
-
// Both node text fields replace the same display mode, even across separate actions.
|
|
1110
|
-
const assignmentField = isNode && (key === "label" || key === "content") ? "text-mode" : key;
|
|
1111
|
-
addAssignment(`${isNode ? "node" : "edge"}:${String(target)}:${assignmentField}`, `${path}.${key}`, assignments, issues);
|
|
1112
|
-
const field = value[key];
|
|
1113
|
-
if (["x", "y", "width", "height", "opacity"].includes(key))
|
|
1114
|
-
validateFinite(field, `${path}.${key}`, issues);
|
|
1115
|
-
if (key === "strokeWidth")
|
|
1116
|
-
validateNonNegativeFinite(field, `${path}.${key}`, issues);
|
|
1117
|
-
if (["width", "height"].includes(key) && typeof field === "number" && field <= 0)
|
|
1118
|
-
issues.push({ path: `${path}.${key}`, message: "must be greater than zero" });
|
|
1119
|
-
if (key === "opacity" && typeof field === "number" && (field < 0 || field > 1))
|
|
1120
|
-
issues.push({ path: `${path}.${key}`, message: "must be between zero and one" });
|
|
1121
|
-
if (["fill", "stroke"].includes(key))
|
|
1122
|
-
validateColor(field, `${path}.${key}`, issues);
|
|
1123
|
-
if (key === "label" && typeof field !== "string")
|
|
1124
|
-
issues.push({ path: `${path}.${key}`, message: "must be a string" });
|
|
1125
|
-
if (key === "content" && field !== null)
|
|
1126
|
-
validateNodeContent(field, `${path}.${key}`, issues);
|
|
1127
|
-
}
|
|
1128
|
-
}
|
|
1129
|
-
function addAssignment(key, path, assignments, issues) {
|
|
1130
|
-
if (assignments.has(key))
|
|
1131
|
-
issues.push({ path, message: "duplicate beat assignment" });
|
|
1132
|
-
else
|
|
1133
|
-
assignments.add(key);
|
|
1134
|
-
}
|
|
1135
|
-
/** Validation restricts inputs to serializable documents; clone before freezing to isolate later author mutations. */
|
|
1136
|
-
function cloneDocument(document) {
|
|
1137
|
-
return JSON.parse(JSON.stringify(document));
|
|
1138
|
-
}
|
|
1139
|
-
function deepFreeze(value) {
|
|
1140
|
-
if (typeof value === "object" && value !== null) {
|
|
1141
|
-
Object.freeze(value);
|
|
1142
|
-
for (const child of Object.values(value))
|
|
1143
|
-
deepFreeze(child);
|
|
1144
|
-
}
|
|
1145
|
-
return value;
|
|
1146
|
-
}
|
|
1147
|
-
function resolvePalette(document, options) {
|
|
1148
|
-
return resolveThemePalette(options.theme ?? document.theme);
|
|
1149
|
-
}
|