@markdstage/markdstage 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.
Files changed (67) hide show
  1. package/README.md +90 -0
  2. package/bin/markdstage.mjs +12 -0
  3. package/package.json +45 -0
  4. package/shared/README.md +1014 -0
  5. package/shared/THIRD-PARTY-NOTICES.md +19 -0
  6. package/shared/deck-state.mjs +105 -0
  7. package/shared/docs/custom-theme-authoring.md +208 -0
  8. package/shared/markdown-deck.mjs +220 -0
  9. package/shared/markdstage-guide.mjs +276 -0
  10. package/shared/presenter-window.mjs +17 -0
  11. package/shared/renderer/architecture-document.mjs +596 -0
  12. package/shared/renderer/architecture-edit.mjs +298 -0
  13. package/shared/renderer/architecture-editor.mjs +449 -0
  14. package/shared/renderer/architecture.mjs +4033 -0
  15. package/shared/renderer/import-path.mjs +11 -0
  16. package/shared/renderer/index.html +106 -0
  17. package/shared/renderer/renderer.js +2082 -0
  18. package/shared/renderer/slides.css +614 -0
  19. package/shared/renderer/speaker-notes.mjs +106 -0
  20. package/shared/renderer/theme.mjs +205 -0
  21. package/shared/runtime/browser.mjs +539 -0
  22. package/shared/runtime/custom-theme.mjs +135 -0
  23. package/shared/runtime/deck-session.mjs +188 -0
  24. package/shared/runtime/errors.mjs +17 -0
  25. package/shared/runtime/output-paths.mjs +159 -0
  26. package/shared/runtime/output.mjs +385 -0
  27. package/shared/runtime/presentation-server.mjs +505 -0
  28. package/shared/runtime/static-files.mjs +70 -0
  29. package/shared/schema/README.md +228 -0
  30. package/shared/schema/architecture-v1.schema.json +664 -0
  31. package/shared/schema/examples/web-app.architecture.json +119 -0
  32. package/shared/schema/theme-metadata-v1.schema.json +75 -0
  33. package/shared/schema/theme-v1.json +84 -0
  34. package/shared/scripts/architecture-assets.mjs +226 -0
  35. package/shared/scripts/asset-paths.mjs +92 -0
  36. package/shared/scripts/atomic-markdown-replace.mjs +46 -0
  37. package/shared/scripts/markdown-blocks.mjs +182 -0
  38. package/shared/scripts/markdown-files.mjs +63 -0
  39. package/shared/scripts/markdown-save-coordinator.mjs +18 -0
  40. package/shared/scripts/markdown-watcher.mjs +80 -0
  41. package/shared/scripts/theme-paths.mjs +108 -0
  42. package/shared/scripts/vendor-assets.mjs +132 -0
  43. package/shared/scripts/workspace-root.mjs +32 -0
  44. package/shared/vendor/highlight.LICENSE +29 -0
  45. package/shared/vendor/highlight.min.js +1244 -0
  46. package/shared/vendor/marked.min.js +6 -0
  47. package/shared/vendor/mermaid.min.js.part-0001 +268 -0
  48. package/shared/vendor/mermaid.min.js.part-0002 +304 -0
  49. package/shared/vendor/mermaid.min.js.part-0003 +324 -0
  50. package/shared/vendor/mermaid.min.js.part-0004 +374 -0
  51. package/shared/vendor/mermaid.min.js.part-0005 +564 -0
  52. package/shared/vendor/mermaid.min.js.part-0006 +1308 -0
  53. package/shared/vendor/mermaid.min.js.part-0007 +269 -0
  54. package/shared/vendor/purify.min.js +3 -0
  55. package/shared/vendor/vendor-assets.lock.json +60 -0
  56. package/src/cli.mjs +347 -0
  57. package/src/commands/capture.mjs +23 -0
  58. package/src/commands/export.mjs +18 -0
  59. package/src/commands/guide.mjs +23 -0
  60. package/src/commands/inspect.mjs +35 -0
  61. package/src/commands/present.mjs +91 -0
  62. package/src/commands/skill.mjs +114 -0
  63. package/src/commands/validate.mjs +79 -0
  64. package/src/deck.mjs +63 -0
  65. package/src/exit.mjs +58 -0
  66. package/src/runtime.mjs +77 -0
  67. package/src/skills.mjs +155 -0
@@ -0,0 +1,4033 @@
1
+ const SVG_NS = "http://www.w3.org/2000/svg";
2
+ const DEFAULT_CANVAS = Object.freeze({ width: 1600, height: 900 });
3
+ const DSL_VERSION = 1;
4
+ const EMPTY_ARCHITECTURE_SOURCE = '{\n "version": 1,\n "elements": []\n}\n';
5
+ const MAX_SOURCE_LENGTH = 64 * 1024;
6
+ const MAX_ELEMENTS = 200;
7
+ const MAX_CONNECTORS = 100;
8
+ const MAX_TOTAL_TEXT = 20_000;
9
+ const MAX_DEPTH = 4;
10
+ const MAX_POINTS = 12;
11
+ const CONNECTOR_ENDPOINT_GAP = 14;
12
+ const MIN_ORTHOGONAL_ENDPOINT_SPAN = 8;
13
+ const CONNECTOR_LANE_SPACING = 52;
14
+ const MAX_CONNECTOR_LABEL_WIDTH = 560;
15
+ const CONNECTOR_LABEL_PADDING = 28;
16
+ const MIN_CONNECTOR_LABEL_WIDTH = 70;
17
+ const CONNECTOR_LABEL_STROKE_WIDTH = 2;
18
+ // Gap between a label pill and its line after moving the pill away.
19
+ const CONNECTOR_LABEL_CLEARANCE = 8;
20
+ const CONNECTOR_MARKER_VIEWBOX_SIZE = 10;
21
+ const CONNECTOR_MARKER_SIZE = 7;
22
+ const CONNECTOR_MARKER_REF_X = 9;
23
+ const CONNECTOR_MARKER_REF_Y = 5;
24
+ const CONNECTOR_MARKER_POINTS = Object.freeze([
25
+ Object.freeze({ x: 0, y: 0 }),
26
+ Object.freeze({ x: 10, y: 5 }),
27
+ Object.freeze({ x: 0, y: 10 }),
28
+ ]);
29
+ // Required visible line length used to decide whether to move a pill away.
30
+ //
31
+ // An arrowhead has an effective width of 28 from markerWidth 7 × the default
32
+ // markerUnits="strokeWidth" value 4, and refX 9 extends it about 25 units back
33
+ // from the endpoint. A centered pill divides the outer line roughly in half, so
34
+ // 50 units are needed to preserve 25 on one side. This also leaves 25 on the
35
+ // opposite side, but that excess is preferable to clipping an arrowhead on an
36
+ // asymmetric route.
37
+ const MIN_VISIBLE_ROUTE_LENGTH = 50;
38
+ const MIN_FONT_SIZE = 8;
39
+ const MAX_ROUTING_GRID_COORDINATES = 120;
40
+ const MAX_ROUTING_GRID_POINTS = 10_000;
41
+ const MAX_ROUTING_GRID_VISITS = 20_000;
42
+
43
+ // ---------------------------------------------------------------- Route cost
44
+ // Collapse route quality into one scalar. Widely separated magnitudes make the
45
+ // comparison effectively lexicographic: node penetration > label covering node >
46
+ // crossing > label collision > bend > length. Initial routing and later rerouting
47
+ // use the same scale, so routes change only when total cost decreases.
48
+ //
49
+ // Weighting prioritizes defects that hide content. Node penetration is highest
50
+ // because it breaks the diagram. A label covering a node hides text and outweighs
51
+ // a crossing. One crossing yields 100 from routeInteractionScore, for an effective
52
+ // 10,000. Label-to-label and label-to-line overlap impairs reading but preserves
53
+ // information, so it carries less weight than a crossing.
54
+ const ROUTE_COST_NODE_HIT = 1_000_000;
55
+ const ROUTE_COST_INTERACTION = 100;
56
+ const ROUTE_COST_LABEL_OVER_NODE = 24_000;
57
+ const ROUTE_COST_LABEL_OVER_LABEL = 4_000;
58
+ const ROUTE_COST_LABEL_OVER_ROUTE = 2_000;
59
+ const ROUTE_COST_BEND = 30;
60
+ // Threshold that prevents floating-point error from being mistaken for improvement.
61
+ const ROUTE_IMPROVEMENT_EPSILON = 0.5;
62
+ // Prevent routes from detouring near the diagram boundary to avoid crossings and
63
+ // looking like unrelated lines. Within this range, fewer crossings still take priority.
64
+ const MAX_ROUTE_DETOUR_RATIO = 3;
65
+ // Rip-up-and-reroute limit, providing a deterministic termination budget.
66
+ const MAX_ROUTE_REFINEMENT_PASSES = 3;
67
+ const MAX_ROUTE_REFINEMENT_REROUTES = 600;
68
+
69
+ // Routing degradation reasons, mapped one-to-one to author-facing messages.
70
+ const ROUTE_FALLBACK_REASONS = Object.freeze({
71
+ gridTooLarge: "grid-too-large",
72
+ gridVisitBudget: "grid-visit-budget",
73
+ gridUnreachable: "grid-unreachable",
74
+ endpointBlocked: "endpoint-blocked",
75
+ invalidEndpointGeometry: "invalid-endpoint-geometry",
76
+ labelPlacementImpossible: "label-placement-impossible",
77
+ noCleanCandidate: "no-clean-candidate",
78
+ });
79
+ const ROUTE_FALLBACK_REMEDIES = Object.freeze({
80
+ "grid-too-large":
81
+ "the detour grid exceeded its size budget, so the diagram is too dense to route automatically",
82
+ "grid-visit-budget":
83
+ "the detour search exceeded its work budget before reaching the target",
84
+ "grid-unreachable": "no obstacle-free orthogonal corridor exists between the ports",
85
+ "endpoint-blocked": "the connector ports are enclosed by other elements",
86
+ "invalid-endpoint-geometry":
87
+ "the endpoint ports cannot leave touching or overlapping nodes; increase the gap, choose different ports, or use an explicit polyline",
88
+ "label-placement-impossible":
89
+ "the connector label cannot expose enough of its route and terminal marker inside the canvas",
90
+ "no-clean-candidate": "every candidate route is blocked by another element",
91
+ });
92
+
93
+ const ID_PATTERN = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/;
94
+ const SHAPES = new Set(["rect", "rounded-rect", "ellipse"]);
95
+ const ROUTINGS = new Set(["straight", "orthogonal", "polyline"]);
96
+ const PORTS = new Set(["auto", "top", "right", "bottom", "left"]);
97
+ const LABEL_LAYERS = new Set(["front", "behind"]);
98
+ const LAYOUTS = new Set(["row", "column", "grid", "layered"]);
99
+ const LAYOUT_DIRECTIONS = new Set(["down", "right"]);
100
+ const IMAGE_FITS = new Set(["contain", "cover", "stretch"]);
101
+ // Recursion limit while layered layout reads the connection graph. This runs
102
+ // before MAX_DEPTH validation, so impose an independent cutoff for unvalidated input.
103
+ const MAX_GRAPH_SCAN_DEPTH = 16;
104
+ // Fixed number of barycenter sweeps for deterministic output.
105
+ const LAYERED_ORDERING_SWEEPS = 4;
106
+ // Canonical built-in icon catalog; both ICONS and rendering derive from this table.
107
+ //
108
+ // Naming: lowercase kebab-case (`^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$`) nouns for
109
+ // general concepts, not product or vendor names. Names are public DSL vocabulary,
110
+ // so renaming or deleting a published name is a breaking change (see schema/README.md).
111
+ //
112
+ // Icons are 24x24 line art rendered with the node textColor as stroke, adapting
113
+ // automatically to all four themes. Only `solid: true` icons also use textColor
114
+ // as fill for small accent dots in the line art.
115
+ const ICON_SHAPES = Object.freeze({
116
+ cloud: [
117
+ {
118
+ tag: "path",
119
+ attributes: { d: "M6 18h11.5a4.5 4.5 0 0 0 .7-8.95A6.5 6.5 0 0 0 5.7 8.2 5 5 0 0 0 6 18Z" },
120
+ },
121
+ ],
122
+ database: [
123
+ { tag: "ellipse", attributes: { cx: 12, cy: 5, rx: 8, ry: 3 } },
124
+ {
125
+ tag: "path",
126
+ attributes: { d: "M4 5v7c0 1.7 3.6 3 8 3s8-1.3 8-3V5M4 12v7c0 1.7 3.6 3 8 3s8-1.3 8-3v-7" },
127
+ },
128
+ ],
129
+ api: [{ tag: "path", attributes: { d: "m8 7-5 5 5 5M16 7l5 5-5 5M14 4l-4 16" } }],
130
+ user: [
131
+ { tag: "circle", attributes: { cx: 12, cy: 8, r: 4 } },
132
+ { tag: "path", attributes: { d: "M4 22a8 8 0 0 1 16 0" } },
133
+ ],
134
+ server: [
135
+ { tag: "rect", attributes: { x: 3, y: 3, width: 18, height: 7, rx: 1.5 } },
136
+ { tag: "rect", attributes: { x: 3, y: 14, width: 18, height: 7, rx: 1.5 } },
137
+ { tag: "circle", attributes: { cx: 7, cy: 6.5, r: 0.8 }, solid: true },
138
+ { tag: "circle", attributes: { cx: 7, cy: 17.5, r: 0.8 }, solid: true },
139
+ ],
140
+ analytics: [
141
+ { tag: "path", attributes: { d: "M4 3.5v16.5h16" } },
142
+ { tag: "path", attributes: { d: "M8 20v-5.5M12.5 20v-10.5M17 20v-3.5" } },
143
+ ],
144
+ browser: [
145
+ { tag: "rect", attributes: { x: 2.5, y: 4, width: 19, height: 16, rx: 2 } },
146
+ { tag: "path", attributes: { d: "M2.5 9h19" } },
147
+ { tag: "circle", attributes: { cx: 5.5, cy: 6.5, r: 0.8 }, solid: true },
148
+ { tag: "circle", attributes: { cx: 8, cy: 6.5, r: 0.8 }, solid: true },
149
+ ],
150
+ mobile: [
151
+ { tag: "rect", attributes: { x: 7, y: 2.5, width: 10, height: 19, rx: 2.5 } },
152
+ { tag: "path", attributes: { d: "M10.5 5.5h3" } },
153
+ { tag: "circle", attributes: { cx: 12, cy: 18.5, r: 0.9 }, solid: true },
154
+ ],
155
+ network: [
156
+ { tag: "circle", attributes: { cx: 12, cy: 4.5, r: 2.5 } },
157
+ { tag: "circle", attributes: { cx: 5, cy: 18, r: 2.5 } },
158
+ { tag: "circle", attributes: { cx: 19, cy: 18, r: 2.5 } },
159
+ { tag: "path", attributes: { d: "M10.9 6.7 6.2 15.8M13.1 6.7l4.7 9.1M7.5 18h9" } },
160
+ ],
161
+ queue: [
162
+ { tag: "path", attributes: { d: "M3 6.5h18M3 17.5h18" } },
163
+ { tag: "rect", attributes: { x: 4.5, y: 10, width: 4, height: 4, rx: 1 } },
164
+ { tag: "rect", attributes: { x: 10, y: 10, width: 4, height: 4, rx: 1 } },
165
+ { tag: "rect", attributes: { x: 15.5, y: 10, width: 4, height: 4, rx: 1 } },
166
+ ],
167
+ shield: [
168
+ { tag: "path", attributes: { d: "M12 2.5 20 5.5v6.2c0 4.6-3.2 8.1-8 9.8-4.8-1.7-8-5.2-8-9.8V5.5Z" } },
169
+ { tag: "path", attributes: { d: "m8.5 12 2.5 2.5 4.5-4.5" } },
170
+ ],
171
+ });
172
+ const ICONS = new Set(Object.keys(ICON_SHAPES));
173
+ // User-provided icons must be repository files under `assets/`. Rendering uses
174
+ // <image href="/assets/...">, served through safeJoin by the `/assets/*` route
175
+ // in extension.mjs or the test harness.
176
+ const ASSET_EXTENSIONS = Object.freeze(["svg", "png", "webp", "jpg", "jpeg"]);
177
+ // JSON Schema `pattern` has no flags, so expand both letter cases rather than
178
+ // using `/i`. This lets `.source` copy directly into the schema and guarantees
179
+ // that parser and schema handle uppercase extensions such as .PNG consistently.
180
+ const ASSET_EXTENSION_PATTERN = ASSET_EXTENSIONS.map((extension) =>
181
+ [...extension].map((character) => `[${character.toUpperCase()}${character}]`).join(""),
182
+ ).join("|");
183
+ // Path segments start with an alphanumeric character, and at least one character
184
+ // must follow '.'. This makes '..', '.', empty segments, and values containing ':'
185
+ // such as data: or http:// impossible to represent.
186
+ const ASSET_PATH_SEGMENT = "[A-Za-z0-9][A-Za-z0-9_-]*(?:\\.[A-Za-z0-9_-]+)*";
187
+ const ASSET_PATH_PATTERN = new RegExp(
188
+ `^assets/(?:${ASSET_PATH_SEGMENT}/)*${ASSET_PATH_SEGMENT}\\.(?:${ASSET_EXTENSION_PATTERN})$`,
189
+ );
190
+ const ICON_ASSET_PATTERN = ASSET_PATH_PATTERN;
191
+ const MAX_ASSET_REFERENCE = 200;
192
+ const MAX_ICON_REFERENCE = MAX_ASSET_REFERENCE;
193
+ const THEME_TOKENS = Object.freeze({
194
+ accent: "var(--accent)",
195
+ accentStrong: "var(--accent-strong)",
196
+ accentSoft: "var(--accent-soft)",
197
+ accentLine: "var(--accent-line)",
198
+ surface: "var(--surface)",
199
+ fg: "var(--fg)",
200
+ muted: "var(--muted)",
201
+ body: "var(--body)",
202
+ border: "var(--border)",
203
+ bg: "var(--bg)",
204
+ });
205
+ const LITERAL_COLORS = /^(?:#[0-9a-f]{3,8}|black|white|transparent|none)$/i;
206
+
207
+ function localAssetUrl(documentRef, path) {
208
+ const normalized = String(path || "").replace(/^\/+/, "");
209
+ try {
210
+ const base = new URL(documentRef.baseURI);
211
+ if (base.protocol === "http:" || base.protocol === "https:") {
212
+ return new URL(normalized, base).pathname;
213
+ }
214
+ } catch (_) {}
215
+ return `/${normalized}`;
216
+ }
217
+ const STYLE_KEYS = new Set([
218
+ "fill",
219
+ "stroke",
220
+ "textColor",
221
+ "strokeWidth",
222
+ "fontSize",
223
+ "opacity",
224
+ "dash",
225
+ "cornerRadius",
226
+ ]);
227
+ const LAYOUT_KEYS = new Set([
228
+ "type",
229
+ "gap",
230
+ "rowGap",
231
+ "columnGap",
232
+ "padding",
233
+ "columns",
234
+ "direction",
235
+ ]);
236
+ const ELEMENT_KEYS = Object.freeze({
237
+ node: new Set([
238
+ "type",
239
+ "id",
240
+ "shape",
241
+ "x",
242
+ "y",
243
+ "width",
244
+ "height",
245
+ "text",
246
+ "icon",
247
+ "ariaLabel",
248
+ "z",
249
+ "style",
250
+ ]),
251
+ group: new Set([
252
+ "type",
253
+ "id",
254
+ "x",
255
+ "y",
256
+ "width",
257
+ "height",
258
+ "title",
259
+ "ariaLabel",
260
+ "layout",
261
+ "z",
262
+ "style",
263
+ "children",
264
+ ]),
265
+ image: new Set([
266
+ "type",
267
+ "id",
268
+ "src",
269
+ "fit",
270
+ "x",
271
+ "y",
272
+ "width",
273
+ "height",
274
+ "ariaLabel",
275
+ "z",
276
+ "style",
277
+ ]),
278
+ connector: new Set([
279
+ "type",
280
+ "from",
281
+ "to",
282
+ "fromPort",
283
+ "toPort",
284
+ "label",
285
+ "labelLayer",
286
+ "ariaLabel",
287
+ "routing",
288
+ "points",
289
+ "arrow",
290
+ "lane",
291
+ "z",
292
+ "style",
293
+ ]),
294
+ });
295
+
296
+ let renderSequence = 0;
297
+
298
+ class ArchitectureError extends Error {
299
+ constructor(message) {
300
+ super(message);
301
+ this.name = "ArchitectureError";
302
+ }
303
+ }
304
+
305
+ function fail(path, message, remedy) {
306
+ const guidance = remedy ? `; ${remedy}` : "";
307
+ throw new ArchitectureError(`${path}: ${message}${guidance}`);
308
+ }
309
+
310
+ function describeValue(value) {
311
+ if (value === undefined) return "nothing";
312
+ if (value === null) return "null";
313
+ if (Array.isArray(value)) return "an array";
314
+ if (typeof value === "string") {
315
+ return `'${value.length > 32 ? `${value.slice(0, 32)}…` : value}'`;
316
+ }
317
+ if (typeof value === "object") return "an object";
318
+ return `'${String(value)}'`;
319
+ }
320
+
321
+ function isObject(value) {
322
+ return value !== null && typeof value === "object" && !Array.isArray(value);
323
+ }
324
+
325
+ function expectObject(value, path, remedy = "use a JSON object such as { }") {
326
+ if (!isObject(value)) fail(path, "must be an object", remedy);
327
+ return value;
328
+ }
329
+
330
+ function rejectUnknownKeys(value, allowed, path) {
331
+ for (const key of Object.keys(value)) {
332
+ if (!allowed.has(key)) {
333
+ fail(
334
+ `${path}.${key}`,
335
+ "is not supported",
336
+ `remove it or use one of: ${[...allowed].join(", ")}`,
337
+ );
338
+ }
339
+ }
340
+ }
341
+
342
+ function numberIn(value, path, min, max, fallback, remedy) {
343
+ const candidate = value === undefined ? fallback : value;
344
+ if (typeof candidate !== "number" || !Number.isFinite(candidate)) {
345
+ fail(
346
+ path,
347
+ "must be a finite number",
348
+ remedy ?? `replace ${describeValue(candidate)} with a number between ${min} and ${max}`,
349
+ );
350
+ }
351
+ if (candidate < min || candidate > max) {
352
+ fail(path, `must be between ${min} and ${max}`, remedy ?? "adjust the value into that range");
353
+ }
354
+ return candidate;
355
+ }
356
+
357
+ function textValue(value, path, fallback = "", maxLength = 500, remedy) {
358
+ const candidate = value === undefined ? fallback : value;
359
+ if (typeof candidate !== "string") {
360
+ fail(
361
+ path,
362
+ "must be a string",
363
+ remedy ?? `replace ${describeValue(candidate)} with text in double quotes`,
364
+ );
365
+ }
366
+ if (candidate.length > maxLength) {
367
+ fail(
368
+ path,
369
+ `must be at most ${maxLength} characters`,
370
+ remedy ?? `shorten it by ${candidate.length - maxLength} characters`,
371
+ );
372
+ }
373
+ return candidate;
374
+ }
375
+
376
+ function idValue(value, path) {
377
+ if (typeof value !== "string" || !ID_PATTERN.test(value)) {
378
+ fail(
379
+ path,
380
+ "must start with a letter and contain only letters, numbers, '.', '_' or '-'",
381
+ `replace ${describeValue(value)} with an id such as 'api-gateway' (64 characters or fewer)`,
382
+ );
383
+ }
384
+ return value;
385
+ }
386
+
387
+ function enumValue(value, path, values, fallback) {
388
+ const candidate = textValue(value, path, fallback, 32);
389
+ if (!values.has(candidate)) {
390
+ fail(
391
+ path,
392
+ `must be one of: ${[...values].join(", ")}`,
393
+ `replace ${describeValue(candidate)} with one of them`,
394
+ );
395
+ }
396
+ return candidate;
397
+ }
398
+
399
+ // icon is a union of built-in-name enum and path under assets/. Keep its accepted
400
+ // set synchronized with $defs.icon in schema/architecture-v1.schema.json.
401
+ function iconValue(value, path) {
402
+ if (value === undefined) return "";
403
+ const candidate = textValue(value, path, "", MAX_ASSET_REFERENCE);
404
+ if (ICONS.has(candidate)) return candidate;
405
+ if (ASSET_PATH_PATTERN.test(candidate)) return candidate;
406
+ fail(
407
+ path,
408
+ `must be a built-in icon name (${[...ICONS].join(", ")}) or a path under assets/`,
409
+ `replace ${describeValue(candidate)} with a built-in name, or with a repository asset such as 'assets/icons/logo.svg' (${ASSET_EXTENSIONS.map(
410
+ (extension) => `.${extension}`,
411
+ ).join(", ")} only; '..', 'data:' URIs and external URLs are rejected)`,
412
+ );
413
+ }
414
+
415
+ function assetPathValue(value, path) {
416
+ const candidate = textValue(value, path, "", MAX_ASSET_REFERENCE);
417
+ if (ASSET_PATH_PATTERN.test(candidate)) return candidate;
418
+ fail(
419
+ path,
420
+ "must be a path under assets/",
421
+ `replace ${describeValue(candidate)} with a repository asset such as 'assets/images/diagram.png' (${ASSET_EXTENSIONS.map(
422
+ (extension) => `.${extension}`,
423
+ ).join(", ")} only; '..', 'data:' URIs and external URLs are rejected)`,
424
+ );
425
+ }
426
+
427
+ function colorValue(value, path, fallback) {
428
+ const candidate = value === undefined ? fallback : value;
429
+ if (typeof candidate !== "string") {
430
+ fail(
431
+ path,
432
+ "must be a theme token or color string",
433
+ `replace ${describeValue(candidate)} with a theme token such as accent, or a hex color such as "#1f6feb"`,
434
+ );
435
+ }
436
+ if (Object.prototype.hasOwnProperty.call(THEME_TOKENS, candidate)) {
437
+ return THEME_TOKENS[candidate];
438
+ }
439
+ if (LITERAL_COLORS.test(candidate)) return candidate;
440
+ fail(
441
+ path,
442
+ "must be a theme token, hex color, black, white, transparent, or none",
443
+ `replace ${describeValue(candidate)} with a theme token such as ${Object.keys(THEME_TOKENS).slice(0, 3).join(", ")}, or a hex color such as #1f6feb`,
444
+ );
445
+ }
446
+
447
+ function normalizeStyle(value, path, defaults) {
448
+ const style = value === undefined ? {} : expectObject(value, path);
449
+ rejectUnknownKeys(style, STYLE_KEYS, path);
450
+ const dash = textValue(style.dash, `${path}.dash`, defaults.dash || "", 40);
451
+ if (dash && !/^\d+(?:\.\d+)?(?:[ ,]+\d+(?:\.\d+)?)*$/.test(dash)) {
452
+ fail(
453
+ `${path}.dash`,
454
+ "must contain only non-negative dash lengths",
455
+ 'use space or comma separated numbers such as "12 8"',
456
+ );
457
+ }
458
+ return {
459
+ fill: colorValue(style.fill, `${path}.fill`, defaults.fill),
460
+ stroke: colorValue(style.stroke, `${path}.stroke`, defaults.stroke),
461
+ textColor: colorValue(style.textColor, `${path}.textColor`, defaults.textColor),
462
+ strokeWidth: numberIn(style.strokeWidth, `${path}.strokeWidth`, 0.5, 20, defaults.strokeWidth),
463
+ fontSize: numberIn(style.fontSize, `${path}.fontSize`, 8, 160, defaults.fontSize),
464
+ opacity: numberIn(style.opacity, `${path}.opacity`, 0, 1, defaults.opacity ?? 1),
465
+ dash,
466
+ cornerRadius: numberIn(
467
+ style.cornerRadius,
468
+ `${path}.cornerRadius`,
469
+ 0,
470
+ 200,
471
+ defaults.cornerRadius ?? 24,
472
+ ),
473
+ };
474
+ }
475
+
476
+ function normalizePoint(value, path, origin) {
477
+ const point = expectObject(value, path);
478
+ rejectUnknownKeys(point, new Set(["x", "y"]), path);
479
+ return {
480
+ x: origin.x + numberIn(point.x, `${path}.x`, -4000, 4000),
481
+ y: origin.y + numberIn(point.y, `${path}.y`, -4000, 4000),
482
+ };
483
+ }
484
+
485
+ function parseCanvas(value) {
486
+ if (value === undefined) return { ...DEFAULT_CANVAS };
487
+ const canvas = expectObject(value, "canvas");
488
+ rejectUnknownKeys(canvas, new Set(["width", "height"]), "canvas");
489
+ return {
490
+ width: numberIn(canvas.width, "canvas.width", 320, 4000, DEFAULT_CANVAS.width),
491
+ height: numberIn(canvas.height, "canvas.height", 180, 4000, DEFAULT_CANVAS.height),
492
+ };
493
+ }
494
+
495
+ function parseLayout(value, path) {
496
+ if (value === undefined) return null;
497
+ if (typeof value === "string") {
498
+ return {
499
+ type: enumValue(value, path, LAYOUTS),
500
+ gap: 36,
501
+ rowGap: 36,
502
+ columnGap: 36,
503
+ padding: 54,
504
+ columns: 3,
505
+ direction: "down",
506
+ };
507
+ }
508
+ const layout = expectObject(value, path);
509
+ rejectUnknownKeys(layout, LAYOUT_KEYS, path);
510
+ const gap = numberIn(layout.gap, `${path}.gap`, 0, 240, 36);
511
+ const type = enumValue(layout.type, `${path}.type`, LAYOUTS);
512
+ if (layout.direction !== undefined && type !== "layered") {
513
+ fail(
514
+ `${path}.direction`,
515
+ "is only valid with layered layout",
516
+ 'set "type": "layered" or remove the direction',
517
+ );
518
+ }
519
+ return {
520
+ type,
521
+ gap,
522
+ rowGap: numberIn(layout.rowGap, `${path}.rowGap`, 0, 240, gap),
523
+ columnGap: numberIn(layout.columnGap, `${path}.columnGap`, 0, 240, gap),
524
+ padding: numberIn(layout.padding, `${path}.padding`, 0, 400, 54),
525
+ columns: Math.trunc(numberIn(layout.columns, `${path}.columns`, 1, 12, 3)),
526
+ direction: enumValue(
527
+ layout.direction,
528
+ `${path}.direction`,
529
+ LAYOUT_DIRECTIONS,
530
+ "down",
531
+ ),
532
+ };
533
+ }
534
+
535
+ /**
536
+ * Pre-scan unvalidated raw JSON for element IDs and connector edges only.
537
+ *
538
+ * Layered layout needs connections among a group's direct children, but connectors
539
+ * are often declared at the root, so inspecting only group children misses edges.
540
+ * Scan the complete tree once before flattening.
541
+ *
542
+ * This runs before validation and must never throw on malformed input.
543
+ * Later flattenElements / parseArchitecture produce the actual diagnostics.
544
+ */
545
+ function collectGraphEdges(rawElements, edges = [], depth = 0) {
546
+ if (!Array.isArray(rawElements) || depth > MAX_GRAPH_SCAN_DEPTH) return edges;
547
+ for (const raw of rawElements) {
548
+ if (!isObject(raw)) continue;
549
+ if (raw.type === "connector") {
550
+ if (typeof raw.from === "string" && typeof raw.to === "string") {
551
+ edges.push({ from: raw.from, to: raw.to });
552
+ }
553
+ continue;
554
+ }
555
+ collectGraphEdges(raw.children, edges, depth + 1);
556
+ }
557
+ return edges;
558
+ }
559
+
560
+ /** Collect all IDs contained by one raw child element, including itself and descendants. */
561
+ function collectSubtreeIds(raw, into = new Set(), depth = 0) {
562
+ if (!isObject(raw) || depth > MAX_GRAPH_SCAN_DEPTH) return into;
563
+ if (typeof raw.id === "string") into.add(raw.id);
564
+ if (Array.isArray(raw.children)) {
565
+ for (const child of raw.children) collectSubtreeIds(child, into, depth + 1);
566
+ }
567
+ return into;
568
+ }
569
+
570
+ /**
571
+ * Assign layers from the connection graph.
572
+ *
573
+ * A layer is the longest distance from an input source. Cycles would increase
574
+ * indefinitely, so limit relaxation to the element count and then clamp layer
575
+ * numbers below that count. This always terminates with cycles and produces the
576
+ * same layer structure for identical input.
577
+ */
578
+ function assignLayers(count, edges) {
579
+ const layers = new Array(count).fill(0);
580
+ for (let pass = 0; pass < count; pass += 1) {
581
+ let changed = false;
582
+ for (const [from, to] of edges) {
583
+ if (layers[to] < layers[from] + 1) {
584
+ layers[to] = layers[from] + 1;
585
+ changed = true;
586
+ }
587
+ }
588
+ if (!changed) break;
589
+ }
590
+ return layers.map((layer) => Math.min(layer, Math.max(0, count - 1)));
591
+ }
592
+
593
+ /**
594
+ * Order elements within layers using the barycenter method. Alternate a fixed
595
+ * number of downward and upward sweeps toward mean positions in adjacent layers.
596
+ * Stable sorting and a fixed iteration count make the result deterministic.
597
+ */
598
+ function orderLayers(layers, edges) {
599
+ const position = new Map();
600
+ layers.forEach((layer) => {
601
+ layer.forEach((item, index) => position.set(item, index));
602
+ });
603
+ const neighbours = new Map();
604
+ const addNeighbour = (key, value) => {
605
+ if (!neighbours.has(key)) neighbours.set(key, []);
606
+ neighbours.get(key).push(value);
607
+ };
608
+ for (const [from, to] of edges) {
609
+ addNeighbour(`down:${to}`, from);
610
+ addNeighbour(`up:${from}`, to);
611
+ }
612
+ const sweep = (layer, key) => {
613
+ const scored = layer.map((item, index) => {
614
+ const related = neighbours.get(`${key}:${item}`) || [];
615
+ const known = related
616
+ .map((other) => position.get(other))
617
+ .filter((value) => value !== undefined);
618
+ const barycenter = known.length
619
+ ? known.reduce((total, value) => total + value, 0) / known.length
620
+ : index;
621
+ return { item, index, barycenter };
622
+ });
623
+ scored.sort(
624
+ (left, right) =>
625
+ left.barycenter - right.barycenter || left.index - right.index,
626
+ );
627
+ const ordered = scored.map((entry) => entry.item);
628
+ ordered.forEach((item, index) => position.set(item, index));
629
+ return ordered;
630
+ };
631
+ let current = layers.map((layer) => layer.slice());
632
+ for (let sweepIndex = 0; sweepIndex < LAYERED_ORDERING_SWEEPS; sweepIndex += 1) {
633
+ if (sweepIndex % 2 === 0) {
634
+ for (let index = 1; index < current.length; index += 1) {
635
+ current[index] = sweep(current[index], "down");
636
+ }
637
+ } else {
638
+ for (let index = current.length - 2; index >= 0; index -= 1) {
639
+ current[index] = sweep(current[index], "up");
640
+ }
641
+ }
642
+ }
643
+ return current;
644
+ }
645
+
646
+ /** Return layered layout ranks, each containing an ordered list of flowIndex values. */
647
+ function layeredGrouping(flowItems, children, graphEdges) {
648
+ const owner = new Map();
649
+ flowItems.forEach(({ index }, flowIndex) => {
650
+ for (const id of collectSubtreeIds(children[index])) {
651
+ if (!owner.has(id)) owner.set(id, flowIndex);
652
+ }
653
+ });
654
+ const seen = new Set();
655
+ const edges = [];
656
+ for (const edge of graphEdges) {
657
+ const from = owner.get(edge.from);
658
+ const to = owner.get(edge.to);
659
+ if (from === undefined || to === undefined || from === to) continue;
660
+ const key = `${from}>${to}`;
661
+ if (seen.has(key)) continue;
662
+ seen.add(key);
663
+ edges.push([from, to]);
664
+ }
665
+ const layerOf = assignLayers(flowItems.length, edges);
666
+ const grouped = [];
667
+ layerOf.forEach((layer, flowIndex) => {
668
+ if (!grouped[layer]) grouped[layer] = [];
669
+ grouped[layer].push(flowIndex);
670
+ });
671
+ return orderLayers(
672
+ grouped.filter((layer) => layer && layer.length),
673
+ edges,
674
+ );
675
+ }
676
+
677
+ function layoutPlacements(children, group, layout, path, graphEdges = []) {
678
+ if (!layout) return new Map();
679
+ const flowItems = children
680
+ .map((child, index) => ({ child, index }))
681
+ .filter(
682
+ ({ child }) =>
683
+ child?.type === "node" || child?.type === "group" || child?.type === "image",
684
+ );
685
+ const placements = new Map();
686
+ if (!flowItems.length) return placements;
687
+ const titleReserve = group.title ? Math.max(46, group.style.fontSize * 1.7) : 0;
688
+ const inner = {
689
+ x: layout.padding,
690
+ y: layout.padding + titleReserve,
691
+ width: group.width - layout.padding * 2,
692
+ height: group.height - layout.padding * 2 - titleReserve,
693
+ };
694
+ if (inner.width <= 0 || inner.height <= 0) {
695
+ fail(
696
+ `${path}.layout`,
697
+ "padding and title leave no space for children",
698
+ "reduce layout padding or increase the group width and height",
699
+ );
700
+ }
701
+ const count = flowItems.length;
702
+ // Normalize to tracks along the main axis and contents of each track before placement.
703
+ // row / column / grid split mechanically by declaration order; only layered uses the graph.
704
+ let tracks;
705
+ let vertical;
706
+ if (layout.type === "layered") {
707
+ tracks = layeredGrouping(flowItems, children, graphEdges);
708
+ vertical = layout.direction === "down";
709
+ } else {
710
+ const columns =
711
+ layout.type === "column"
712
+ ? 1
713
+ : layout.type === "row"
714
+ ? count
715
+ : Math.min(layout.columns, count);
716
+ tracks = [];
717
+ for (let flowIndex = 0; flowIndex < count; flowIndex += 1) {
718
+ const row = Math.floor(flowIndex / columns);
719
+ if (!tracks[row]) tracks[row] = [];
720
+ tracks[row].push(flowIndex);
721
+ }
722
+ vertical = true;
723
+ }
724
+ const trackCount = tracks.length;
725
+ const widest = Math.max(...tracks.map((track) => track.length));
726
+ const rows = vertical ? trackCount : widest;
727
+ const columns = vertical ? widest : trackCount;
728
+ const cellWidth = (inner.width - layout.columnGap * (columns - 1)) / columns;
729
+ const cellHeight = (inner.height - layout.rowGap * (rows - 1)) / rows;
730
+ if (cellWidth < 24 || cellHeight < 24) {
731
+ fail(
732
+ `${path}.layout`,
733
+ "children do not fit",
734
+ "reduce layout gap/padding, enlarge the group, or move some children out",
735
+ );
736
+ }
737
+ tracks.forEach((track, trackIndex) => {
738
+ // Layered ranks vary in size, so center each complete track on the cross axis.
739
+ // Keep row / column / grid start-aligned to avoid shifting existing diagrams.
740
+ const spanCells = track.length;
741
+ const span = vertical
742
+ ? spanCells * cellWidth + layout.columnGap * (spanCells - 1)
743
+ : spanCells * cellHeight + layout.rowGap * (spanCells - 1);
744
+ const offset =
745
+ layout.type !== "layered"
746
+ ? 0
747
+ : vertical
748
+ ? (inner.width - span) / 2
749
+ : (inner.height - span) / 2;
750
+ track.forEach((flowIndex, positionInTrack) => {
751
+ const { child, index } = flowItems[flowIndex];
752
+ const defaultWidth = child.type === "group" ? cellWidth : Math.min(cellWidth, 340);
753
+ const defaultHeight =
754
+ child.type === "group"
755
+ ? cellHeight
756
+ : child.type === "image"
757
+ ? Math.min(cellHeight, 220)
758
+ : Math.min(cellHeight, 170);
759
+ const width = numberIn(
760
+ child.width,
761
+ `${path}.children[${index}].width`,
762
+ 1,
763
+ cellWidth,
764
+ defaultWidth,
765
+ "the parent layout limits each cell; reduce the value, enlarge the group, or drop width to use the automatic size",
766
+ );
767
+ const height = numberIn(
768
+ child.height,
769
+ `${path}.children[${index}].height`,
770
+ 1,
771
+ cellHeight,
772
+ defaultHeight,
773
+ "the parent layout limits each cell; reduce the value, enlarge the group, or drop height to use the automatic size",
774
+ );
775
+ const cellX = vertical
776
+ ? inner.x + offset + positionInTrack * (cellWidth + layout.columnGap)
777
+ : inner.x + trackIndex * (cellWidth + layout.columnGap);
778
+ const cellY = vertical
779
+ ? inner.y + trackIndex * (cellHeight + layout.rowGap)
780
+ : inner.y + offset + positionInTrack * (cellHeight + layout.rowGap);
781
+ placements.set(index, {
782
+ x: cellX + (cellWidth - width) / 2,
783
+ y: cellY + (cellHeight - height) / 2,
784
+ width,
785
+ height,
786
+ });
787
+ });
788
+ });
789
+ return placements;
790
+ }
791
+
792
+ function normalizeBox(element, origin, path, placement) {
793
+ return {
794
+ x:
795
+ origin.x +
796
+ (placement?.x ?? numberIn(element.x, `${path}.x`, -4000, 4000)),
797
+ y:
798
+ origin.y +
799
+ (placement?.y ?? numberIn(element.y, `${path}.y`, -4000, 4000)),
800
+ width: placement?.width ?? numberIn(element.width, `${path}.width`, 1, 4000),
801
+ height: placement?.height ?? numberIn(element.height, `${path}.height`, 1, 4000),
802
+ };
803
+ }
804
+
805
+ function flattenElements(
806
+ rawElements,
807
+ origin,
808
+ depth,
809
+ output,
810
+ ids,
811
+ path = "elements",
812
+ placements = new Map(),
813
+ graphEdges = [],
814
+ ) {
815
+ if (!Array.isArray(rawElements)) fail(path, "must be an array", "use a JSON array such as [ ]");
816
+ if (depth > MAX_DEPTH) {
817
+ fail(
818
+ path,
819
+ `nesting must not exceed ${MAX_DEPTH} levels`,
820
+ "flatten the structure or split the diagram across slides",
821
+ );
822
+ }
823
+
824
+ rawElements.forEach((raw, localIndex) => {
825
+ if (output.length >= MAX_ELEMENTS) {
826
+ fail(
827
+ "elements",
828
+ `must contain at most ${MAX_ELEMENTS} items`,
829
+ "split the diagram across multiple slides",
830
+ );
831
+ }
832
+ const elementPath = `${path}[${localIndex}]`;
833
+ const element = expectObject(raw, elementPath);
834
+ const type = textValue(element.type, `${elementPath}.type`, "", 20);
835
+ if (!Object.prototype.hasOwnProperty.call(ELEMENT_KEYS, type)) {
836
+ fail(
837
+ `${elementPath}.type`,
838
+ "must be node, group, image, or connector",
839
+ element.type === undefined
840
+ ? 'add a "type" of node, group, image, or connector'
841
+ : `replace ${describeValue(element.type)} with node, group, image, or connector`,
842
+ );
843
+ }
844
+ rejectUnknownKeys(element, ELEMENT_KEYS[type], elementPath);
845
+ const order = output.length;
846
+ const defaultZ = type === "group" ? -50 : type === "connector" ? -10 : 0;
847
+ const z = numberIn(element.z, `${elementPath}.z`, -100, 100, defaultZ);
848
+
849
+ if (type === "connector") {
850
+ const routing = enumValue(
851
+ element.routing,
852
+ `${elementPath}.routing`,
853
+ ROUTINGS,
854
+ "straight",
855
+ );
856
+ const points = element.points === undefined ? [] : element.points;
857
+ if (!Array.isArray(points) || points.length > MAX_POINTS) {
858
+ fail(
859
+ `${elementPath}.points`,
860
+ `must be an array with at most ${MAX_POINTS} points`,
861
+ "remove extra waypoints or split the connector into several connectors",
862
+ );
863
+ }
864
+ if (routing !== "polyline" && points.length) {
865
+ fail(
866
+ `${elementPath}.points`,
867
+ "is only valid with polyline routing",
868
+ 'set "routing": "polyline" or remove the points',
869
+ );
870
+ }
871
+ if (element.arrow !== undefined && typeof element.arrow !== "boolean") {
872
+ fail(
873
+ `${elementPath}.arrow`,
874
+ "must be a boolean",
875
+ `replace ${describeValue(element.arrow)} with true or false`,
876
+ );
877
+ }
878
+ output.push({
879
+ type,
880
+ from: idValue(element.from, `${elementPath}.from`),
881
+ to: idValue(element.to, `${elementPath}.to`),
882
+ fromPort: enumValue(element.fromPort, `${elementPath}.fromPort`, PORTS, "auto"),
883
+ toPort: enumValue(element.toPort, `${elementPath}.toPort`, PORTS, "auto"),
884
+ label: textValue(element.label, `${elementPath}.label`, "", 200),
885
+ labelLayer: enumValue(
886
+ element.labelLayer,
887
+ `${elementPath}.labelLayer`,
888
+ LABEL_LAYERS,
889
+ "front",
890
+ ),
891
+ ariaLabel: textValue(element.ariaLabel, `${elementPath}.ariaLabel`, "", 300),
892
+ routing,
893
+ points: points.map((point, index) =>
894
+ normalizePoint(point, `${elementPath}.points[${index}]`, origin),
895
+ ),
896
+ arrow: element.arrow !== false,
897
+ lane:
898
+ element.lane === undefined
899
+ ? null
900
+ : Math.trunc(numberIn(element.lane, `${elementPath}.lane`, -12, 12)),
901
+ z,
902
+ order,
903
+ sourcePath: elementPath,
904
+ style: normalizeStyle(element.style, `${elementPath}.style`, {
905
+ fill: "none",
906
+ stroke: "accent",
907
+ textColor: "fg",
908
+ strokeWidth: 4,
909
+ fontSize: 24,
910
+ opacity: 1,
911
+ cornerRadius: 0,
912
+ }),
913
+ });
914
+ return;
915
+ }
916
+
917
+ const id = idValue(element.id, `${elementPath}.id`);
918
+ if (ids.has(id)) {
919
+ fail(
920
+ `${elementPath}.id`,
921
+ `duplicates '${id}'`,
922
+ "give every node, group, and image a unique id across the whole diagram",
923
+ );
924
+ }
925
+ ids.add(id);
926
+ const box = normalizeBox(element, origin, elementPath, placements.get(localIndex));
927
+
928
+ if (type === "node") {
929
+ const shape = enumValue(
930
+ element.shape,
931
+ `${elementPath}.shape`,
932
+ SHAPES,
933
+ "rounded-rect",
934
+ );
935
+ const icon = iconValue(element.icon, `${elementPath}.icon`);
936
+ output.push({
937
+ type,
938
+ id,
939
+ shape,
940
+ ...box,
941
+ text: textValue(element.text, `${elementPath}.text`, "", 500),
942
+ icon,
943
+ ariaLabel: textValue(element.ariaLabel, `${elementPath}.ariaLabel`, "", 300),
944
+ z,
945
+ order,
946
+ sourcePath: elementPath,
947
+ style: normalizeStyle(element.style, `${elementPath}.style`, {
948
+ fill: "surface",
949
+ stroke: "accent",
950
+ textColor: "fg",
951
+ strokeWidth: 4,
952
+ fontSize: 32,
953
+ opacity: 1,
954
+ cornerRadius: 28,
955
+ }),
956
+ });
957
+ return;
958
+ }
959
+
960
+ if (type === "image") {
961
+ output.push({
962
+ type,
963
+ id,
964
+ ...box,
965
+ src: assetPathValue(element.src, `${elementPath}.src`),
966
+ fit: enumValue(element.fit, `${elementPath}.fit`, IMAGE_FITS, "contain"),
967
+ ariaLabel: textValue(element.ariaLabel, `${elementPath}.ariaLabel`, "", 300),
968
+ z,
969
+ order,
970
+ sourcePath: elementPath,
971
+ style: normalizeStyle(element.style, `${elementPath}.style`, {
972
+ fill: "none",
973
+ stroke: "none",
974
+ textColor: "fg",
975
+ strokeWidth: 1,
976
+ fontSize: 24,
977
+ opacity: 1,
978
+ cornerRadius: 0,
979
+ }),
980
+ });
981
+ return;
982
+ }
983
+
984
+ const groupStyle = normalizeStyle(element.style, `${elementPath}.style`, {
985
+ fill: "accentSoft",
986
+ stroke: "accentLine",
987
+ textColor: "accentStrong",
988
+ strokeWidth: 3,
989
+ fontSize: 28,
990
+ opacity: 1,
991
+ dash: "12 8",
992
+ cornerRadius: 32,
993
+ });
994
+ const group = {
995
+ type,
996
+ id,
997
+ ...box,
998
+ title: textValue(element.title, `${elementPath}.title`, "", 200),
999
+ ariaLabel: textValue(element.ariaLabel, `${elementPath}.ariaLabel`, "", 300),
1000
+ layout: parseLayout(element.layout, `${elementPath}.layout`),
1001
+ z,
1002
+ order,
1003
+ sourcePath: elementPath,
1004
+ style: groupStyle,
1005
+ };
1006
+ output.push(group);
1007
+ const children = element.children === undefined ? [] : element.children;
1008
+ const childPlacements = layoutPlacements(
1009
+ children,
1010
+ group,
1011
+ group.layout,
1012
+ elementPath,
1013
+ graphEdges,
1014
+ );
1015
+ flattenElements(
1016
+ children,
1017
+ { x: group.x, y: group.y },
1018
+ depth + 1,
1019
+ output,
1020
+ ids,
1021
+ `${elementPath}.children`,
1022
+ childPlacements,
1023
+ graphEdges,
1024
+ );
1025
+ });
1026
+ }
1027
+
1028
+ function assignConnectorLanes(elements) {
1029
+ const lookup = new Map(
1030
+ elements
1031
+ .filter((element) => element.type !== "connector")
1032
+ .map((element) => [element.id, element]),
1033
+ );
1034
+ const records = elements
1035
+ .filter((element) => element.type === "connector")
1036
+ .map((connector) => {
1037
+ const from = lookup.get(connector.from);
1038
+ const to = lookup.get(connector.to);
1039
+ const fromPort =
1040
+ connector.fromPort === "auto"
1041
+ ? autoPort(from, elementCenter(to))
1042
+ : connector.fromPort;
1043
+ const toPort =
1044
+ connector.toPort === "auto"
1045
+ ? autoPort(to, elementCenter(from))
1046
+ : connector.toPort;
1047
+ const fromKey = `${connector.from}:${fromPort}`;
1048
+ const toKey = `${connector.to}:${toPort}`;
1049
+ return {
1050
+ connector,
1051
+ automatic: connector.lane === null,
1052
+ from,
1053
+ to,
1054
+ fromPort,
1055
+ toPort,
1056
+ fromKey,
1057
+ toKey,
1058
+ endpoints: new Set([fromKey, toKey]),
1059
+ };
1060
+ });
1061
+ const conflicts = (left, right) =>
1062
+ [...left.endpoints].some((endpoint) => right.endpoints.has(endpoint));
1063
+
1064
+ records.forEach((record, index) => {
1065
+ const { connector } = record;
1066
+ if (connector.lane === null) return;
1067
+ const duplicate = records
1068
+ .slice(0, index)
1069
+ .some(
1070
+ (other) =>
1071
+ other.connector.lane === connector.lane && conflicts(record, other),
1072
+ );
1073
+ if (duplicate) {
1074
+ fail(
1075
+ `${connector.sourcePath}.lane`,
1076
+ `duplicates explicit lane ${connector.lane}`,
1077
+ "give overlapping connectors different lane values, or omit lane to assign them automatically",
1078
+ );
1079
+ }
1080
+ });
1081
+
1082
+ records
1083
+ .filter((record) => record.automatic)
1084
+ .forEach((record) => {
1085
+ const automaticPeers = records.filter(
1086
+ (other) => other.automatic && conflicts(record, other),
1087
+ );
1088
+ const preferred =
1089
+ automaticPeers.indexOf(record) - (automaticPeers.length - 1) / 2;
1090
+ const reserved = new Set(
1091
+ records
1092
+ .filter(
1093
+ (other) =>
1094
+ other !== record &&
1095
+ other.connector.lane !== null &&
1096
+ conflicts(record, other),
1097
+ )
1098
+ .map((other) => other.connector.lane),
1099
+ );
1100
+ let selected = null;
1101
+ for (let distance = 0; distance <= 12 && selected === null; distance += 0.5) {
1102
+ const candidates =
1103
+ distance === 0
1104
+ ? [preferred]
1105
+ : [preferred - distance, preferred + distance];
1106
+ selected =
1107
+ candidates.find(
1108
+ (candidate) =>
1109
+ candidate >= -12 && candidate <= 12 && !reserved.has(candidate),
1110
+ ) ?? null;
1111
+ }
1112
+ if (selected === null) {
1113
+ fail(
1114
+ record.connector.sourcePath,
1115
+ "has no available connector lane",
1116
+ "reduce the number of connectors between the same elements, or set explicit lane values",
1117
+ );
1118
+ }
1119
+ record.connector.lane = selected;
1120
+ });
1121
+
1122
+ const endpointGroups = new Map();
1123
+ for (const record of records) {
1124
+ for (const endpoint of [
1125
+ {
1126
+ key: record.fromKey,
1127
+ side: "from",
1128
+ port: record.fromPort,
1129
+ node: record.from,
1130
+ },
1131
+ {
1132
+ key: record.toKey,
1133
+ side: "to",
1134
+ port: record.toPort,
1135
+ node: record.to,
1136
+ },
1137
+ ]) {
1138
+ if (!endpointGroups.has(endpoint.key)) endpointGroups.set(endpoint.key, []);
1139
+ endpointGroups.get(endpoint.key).push({ record, ...endpoint });
1140
+ }
1141
+ }
1142
+ for (const endpoints of endpointGroups.values()) {
1143
+ endpoints.sort(
1144
+ (left, right) =>
1145
+ left.record.connector.lane - right.record.connector.lane ||
1146
+ left.record.connector.order - right.record.connector.order,
1147
+ );
1148
+ const { node, port } = endpoints[0];
1149
+ const sideLength =
1150
+ port === "top" || port === "bottom" ? node.width : node.height;
1151
+ const limit = Math.max(
1152
+ 0,
1153
+ sideLength / 2 - Math.min(12, sideLength * 0.2),
1154
+ );
1155
+ const spacing =
1156
+ endpoints.length > 1
1157
+ ? Math.min(CONNECTOR_LANE_SPACING, (limit * 2) / (endpoints.length - 1))
1158
+ : 0;
1159
+ endpoints.forEach((endpoint, index) => {
1160
+ const offset = (index - (endpoints.length - 1) / 2) * spacing;
1161
+ if (endpoint.side === "from") {
1162
+ endpoint.record.connector.fromOffset = offset;
1163
+ } else {
1164
+ endpoint.record.connector.toOffset = offset;
1165
+ }
1166
+ });
1167
+ }
1168
+ }
1169
+
1170
+ function totalTextLength(model) {
1171
+ return (
1172
+ model.title.length +
1173
+ model.description.length +
1174
+ model.elements.reduce(
1175
+ (total, element) =>
1176
+ total +
1177
+ (element.text?.length || 0) +
1178
+ (element.title?.length || 0) +
1179
+ (element.label?.length || 0) +
1180
+ (element.ariaLabel?.length || 0),
1181
+ 0,
1182
+ )
1183
+ );
1184
+ }
1185
+
1186
+ export function normalizeArchitectureSource(source) {
1187
+ return typeof source === "string" && source.trim() === "" ? EMPTY_ARCHITECTURE_SOURCE : source;
1188
+ }
1189
+
1190
+ export function parseArchitecture(source) {
1191
+ if (typeof source !== "string") {
1192
+ fail("diagram", "must be JSON text", "pass the fenced block contents as a string");
1193
+ }
1194
+ if (source.length > MAX_SOURCE_LENGTH) {
1195
+ fail(
1196
+ "diagram",
1197
+ `must be at most ${MAX_SOURCE_LENGTH} characters`,
1198
+ "split the diagram across multiple slides",
1199
+ );
1200
+ }
1201
+ const normalizedSource = normalizeArchitectureSource(source);
1202
+ let raw;
1203
+ try {
1204
+ raw = JSON.parse(normalizedSource);
1205
+ } catch (error) {
1206
+ const detail = error?.message ? ` (${error.message})` : "";
1207
+ fail(
1208
+ "diagram",
1209
+ `contains invalid JSON${detail}`,
1210
+ "check for trailing commas, unquoted keys, or missing braces",
1211
+ );
1212
+ }
1213
+ const root = expectObject(
1214
+ raw,
1215
+ "diagram",
1216
+ 'the top level must be a JSON object with an "elements" array',
1217
+ );
1218
+ rejectUnknownKeys(
1219
+ root,
1220
+ new Set(["$schema", "version", "canvas", "title", "description", "elements"]),
1221
+ "diagram",
1222
+ );
1223
+ const version = numberIn(
1224
+ root.version,
1225
+ "version",
1226
+ DSL_VERSION,
1227
+ DSL_VERSION,
1228
+ DSL_VERSION,
1229
+ `set "version": ${DSL_VERSION} or omit the field`,
1230
+ );
1231
+ if (!Number.isInteger(version)) {
1232
+ fail("version", "must be an integer", `set "version": ${DSL_VERSION} or omit the field`);
1233
+ }
1234
+ const model = {
1235
+ version,
1236
+ canvas: parseCanvas(root.canvas),
1237
+ title: textValue(root.title, "title", "Architecture diagram", 200),
1238
+ description: textValue(
1239
+ root.description,
1240
+ "description",
1241
+ "Architecture diagram rendered from a constrained JSON DSL.",
1242
+ 1000,
1243
+ ),
1244
+ elements: [],
1245
+ };
1246
+ const ids = new Set();
1247
+ flattenElements(
1248
+ root.elements,
1249
+ { x: 0, y: 0 },
1250
+ 0,
1251
+ model.elements,
1252
+ ids,
1253
+ "elements",
1254
+ new Map(),
1255
+ collectGraphEdges(root.elements),
1256
+ );
1257
+ const connectable = new Set(
1258
+ model.elements.filter((element) => element.type !== "connector").map((element) => element.id),
1259
+ );
1260
+ let connectorCount = 0;
1261
+ model.elements.forEach((element) => {
1262
+ if (element.type !== "connector") return;
1263
+ connectorCount += 1;
1264
+ if (!connectable.has(element.from)) {
1265
+ fail(
1266
+ `${element.sourcePath}.from`,
1267
+ `references unknown element '${element.from}'`,
1268
+ `add a node or group with id '${element.from}', or point the connector at an existing id`,
1269
+ );
1270
+ }
1271
+ if (!connectable.has(element.to)) {
1272
+ fail(
1273
+ `${element.sourcePath}.to`,
1274
+ `references unknown element '${element.to}'`,
1275
+ `add a node or group with id '${element.to}', or point the connector at an existing id`,
1276
+ );
1277
+ }
1278
+ if (element.from === element.to) {
1279
+ fail(
1280
+ element.sourcePath,
1281
+ "self-referencing connectors are not supported",
1282
+ "point the connector at a different element",
1283
+ );
1284
+ }
1285
+ });
1286
+ if (connectorCount > MAX_CONNECTORS) {
1287
+ fail(
1288
+ "elements",
1289
+ `must contain at most ${MAX_CONNECTORS} connectors`,
1290
+ "split the diagram across multiple slides",
1291
+ );
1292
+ }
1293
+ if (totalTextLength(model) > MAX_TOTAL_TEXT) {
1294
+ fail(
1295
+ "diagram",
1296
+ `text content must be at most ${MAX_TOTAL_TEXT} characters`,
1297
+ "shorten node text, group titles, labels, and descriptions",
1298
+ );
1299
+ }
1300
+ assignConnectorLanes(model.elements);
1301
+ model.elements = model.elements
1302
+ .slice()
1303
+ .sort((left, right) => left.z - right.z || left.order - right.order);
1304
+ return model;
1305
+ }
1306
+
1307
+ function svgElement(documentRef, tag, attributes = {}) {
1308
+ const element = documentRef.createElementNS(SVG_NS, tag);
1309
+ for (const [name, value] of Object.entries(attributes)) {
1310
+ if (value !== undefined && value !== "") element.setAttribute(name, String(value));
1311
+ }
1312
+ return element;
1313
+ }
1314
+
1315
+ function appendSvgTitle(documentRef, parent, text) {
1316
+ const title = svgElement(documentRef, "title");
1317
+ title.textContent = text;
1318
+ parent.appendChild(title);
1319
+ }
1320
+
1321
+ function textWidthUnits(text) {
1322
+ return [...text].reduce((total, character) => {
1323
+ if (character.charCodeAt(0) > 0x7f) return total + 1;
1324
+ if (/\s/.test(character)) return total + 0.35;
1325
+ if (/[mwMW@#%&]/.test(character)) return total + 0.95;
1326
+ if (/[A-Z]/.test(character)) return total + 0.72;
1327
+ if (/[ilI1.,'`|!]/.test(character)) return total + 0.35;
1328
+ return total + 0.6;
1329
+ }, 0);
1330
+ }
1331
+
1332
+ function fitTextToWidth(text, requestedFontSize, maxWidth) {
1333
+ const units = Math.max(textWidthUnits(text), 1);
1334
+ const fontSize = Math.min(requestedFontSize, Math.max(MIN_FONT_SIZE, maxWidth / units));
1335
+ if (units * fontSize <= maxWidth) {
1336
+ return { text, fontSize, width: units * fontSize };
1337
+ }
1338
+
1339
+ const ellipsis = "…";
1340
+ const maxUnits = maxWidth / fontSize;
1341
+ const ellipsisUnits = textWidthUnits(ellipsis);
1342
+ let usedUnits = 0;
1343
+ let fitted = "";
1344
+ for (const character of text) {
1345
+ const characterUnits = textWidthUnits(character);
1346
+ if (usedUnits + characterUnits + ellipsisUnits > maxUnits) break;
1347
+ fitted += character;
1348
+ usedUnits += characterUnits;
1349
+ }
1350
+ const displayText = `${fitted}${ellipsis}`;
1351
+ return {
1352
+ text: displayText,
1353
+ fontSize,
1354
+ width: textWidthUnits(displayText) * fontSize,
1355
+ };
1356
+ }
1357
+
1358
+ function appendText(documentRef, parent, element, text, options = {}) {
1359
+ const lines = text.split(/\r?\n/).slice(0, 8);
1360
+ if (!lines.some(Boolean)) return;
1361
+ const availableWidth = options.availableWidth ?? Math.max(32, element.width - 32);
1362
+ const longestLine = Math.max(...lines.map(textWidthUnits), 1);
1363
+ const fontSize = Math.min(
1364
+ element.style.fontSize,
1365
+ Math.max(8, availableWidth / longestLine),
1366
+ );
1367
+ const centerX = options.centerX ?? element.x + element.width / 2;
1368
+ const centerY = element.y + element.height / 2;
1369
+ const textElement = svgElement(documentRef, "text", {
1370
+ x: centerX,
1371
+ y: centerY,
1372
+ fill: element.style.textColor,
1373
+ "font-size": fontSize,
1374
+ "font-weight": 600,
1375
+ "text-anchor": "middle",
1376
+ "dominant-baseline": "middle",
1377
+ "pointer-events": "none",
1378
+ // Visual only. The same string is already in the parent's aria-label and <title>;
1379
+ // without hiding it, assistive technology announces each element twice, as
1380
+ // verified in Chromium's AX tree. Width may truncate the display, so aria-label
1381
+ // remains authoritative.
1382
+ "aria-hidden": "true",
1383
+ });
1384
+ lines.forEach((line, index) => {
1385
+ const tspan = svgElement(documentRef, "tspan", {
1386
+ x: centerX,
1387
+ y: centerY + (index - (lines.length - 1) / 2) * fontSize * 1.2,
1388
+ });
1389
+ tspan.textContent = line;
1390
+ textElement.appendChild(tspan);
1391
+ });
1392
+ parent.appendChild(textElement);
1393
+ }
1394
+
1395
+ function elementCenter(element) {
1396
+ return { x: element.x + element.width / 2, y: element.y + element.height / 2 };
1397
+ }
1398
+
1399
+ function autoPort(element, toward) {
1400
+ const center = elementCenter(element);
1401
+ const dx = toward.x - center.x;
1402
+ const dy = toward.y - center.y;
1403
+ if (Math.abs(dx) >= Math.abs(dy)) return dx >= 0 ? "right" : "left";
1404
+ return dy >= 0 ? "bottom" : "top";
1405
+ }
1406
+
1407
+ function portPoint(element, requestedPort, toward, tangentOffset = 0) {
1408
+ const port = requestedPort === "auto" ? autoPort(element, toward) : requestedPort;
1409
+ const center = elementCenter(element);
1410
+ const horizontalLimit = Math.max(
1411
+ 0,
1412
+ element.width / 2 - Math.min(12, element.width * 0.2),
1413
+ );
1414
+ const verticalLimit = Math.max(
1415
+ 0,
1416
+ element.height / 2 - Math.min(12, element.height * 0.2),
1417
+ );
1418
+ if (port === "top") {
1419
+ return {
1420
+ point: {
1421
+ x: center.x + Math.max(-horizontalLimit, Math.min(horizontalLimit, tangentOffset)),
1422
+ y: element.y,
1423
+ },
1424
+ direction: { x: 0, y: -1 },
1425
+ port,
1426
+ };
1427
+ }
1428
+ if (port === "bottom") {
1429
+ return {
1430
+ point: {
1431
+ x: center.x + Math.max(-horizontalLimit, Math.min(horizontalLimit, tangentOffset)),
1432
+ y: element.y + element.height,
1433
+ },
1434
+ direction: { x: 0, y: 1 },
1435
+ port,
1436
+ };
1437
+ }
1438
+ if (port === "left") {
1439
+ return {
1440
+ point: {
1441
+ x: element.x,
1442
+ y: center.y + Math.max(-verticalLimit, Math.min(verticalLimit, tangentOffset)),
1443
+ },
1444
+ direction: { x: -1, y: 0 },
1445
+ port,
1446
+ };
1447
+ }
1448
+ return {
1449
+ point: {
1450
+ x: element.x + element.width,
1451
+ y: center.y + Math.max(-verticalLimit, Math.min(verticalLimit, tangentOffset)),
1452
+ },
1453
+ direction: { x: 1, y: 0 },
1454
+ port: "right",
1455
+ };
1456
+ }
1457
+
1458
+ function offsetPoint(point, direction, distance) {
1459
+ return { x: point.x + direction.x * distance, y: point.y + direction.y * distance };
1460
+ }
1461
+
1462
+ function compressPoints(points) {
1463
+ const unique = points.filter(
1464
+ (point, index) =>
1465
+ index === 0 ||
1466
+ Math.abs(point.x - points[index - 1].x) > 0.001 ||
1467
+ Math.abs(point.y - points[index - 1].y) > 0.001,
1468
+ );
1469
+ return unique.filter((point, index) => {
1470
+ if (index === 0 || index === unique.length - 1) return true;
1471
+ const previous = unique[index - 1];
1472
+ const next = unique[index + 1];
1473
+ const vertical = previous.x === point.x && point.x === next.x;
1474
+ const horizontal = previous.y === point.y && point.y === next.y;
1475
+ if (vertical) {
1476
+ return (point.y - previous.y) * (next.y - point.y) <= 0;
1477
+ }
1478
+ if (horizontal) {
1479
+ return (point.x - previous.x) * (next.x - point.x) <= 0;
1480
+ }
1481
+ return true;
1482
+ });
1483
+ }
1484
+
1485
+ function routeIsOrthogonal(points) {
1486
+ return points
1487
+ .slice(1)
1488
+ .every(
1489
+ (point, index) =>
1490
+ point.x === points[index].x || point.y === points[index].y,
1491
+ );
1492
+ }
1493
+
1494
+ function routeHasImmediateBacktrack(points) {
1495
+ for (let index = 1; index < points.length - 1; index += 1) {
1496
+ const previous = points[index - 1];
1497
+ const current = points[index];
1498
+ const next = points[index + 1];
1499
+ const incoming = {
1500
+ x: current.x - previous.x,
1501
+ y: current.y - previous.y,
1502
+ };
1503
+ const outgoing = {
1504
+ x: next.x - current.x,
1505
+ y: next.y - current.y,
1506
+ };
1507
+ const cross = incoming.x * outgoing.y - incoming.y * outgoing.x;
1508
+ const dot = incoming.x * outgoing.x + incoming.y * outgoing.y;
1509
+ if (Math.abs(cross) < 0.001 && dot < -0.001) return true;
1510
+ }
1511
+ return false;
1512
+ }
1513
+
1514
+ function routeRespectsEndpointDirections(points, startDirection, endDirection) {
1515
+ if (points.length < 2) return false;
1516
+ const first = {
1517
+ x: points[1].x - points[0].x,
1518
+ y: points[1].y - points[0].y,
1519
+ };
1520
+ const last = {
1521
+ x: points.at(-1).x - points.at(-2).x,
1522
+ y: points.at(-1).y - points.at(-2).y,
1523
+ };
1524
+ return (
1525
+ first.x * startDirection.x + first.y * startDirection.y > 0.001 &&
1526
+ last.x * endDirection.x + last.y * endDirection.y > 0.001
1527
+ );
1528
+ }
1529
+
1530
+ /**
1531
+ * Last-resort route. Only when normal candidates and grid solutions re-enter an
1532
+ * endpoint, trace the canvas boundary clockwise and counterclockwise to guarantee
1533
+ * candidates that preserve port direction.
1534
+ */
1535
+ function perimeterRouteCandidates(
1536
+ start,
1537
+ end,
1538
+ fromDirection,
1539
+ toDirection,
1540
+ canvas,
1541
+ ) {
1542
+ const bounds = {
1543
+ left: Math.min(0, start.x, end.x),
1544
+ right: Math.max(canvas.width, start.x, end.x),
1545
+ top: Math.min(0, start.y, end.y),
1546
+ bottom: Math.max(canvas.height, start.y, end.y),
1547
+ };
1548
+ const sides = ["top", "right", "bottom", "left"];
1549
+ const sideOf = (direction) => {
1550
+ if (direction.y < 0) return "top";
1551
+ if (direction.x > 0) return "right";
1552
+ if (direction.y > 0) return "bottom";
1553
+ return "left";
1554
+ };
1555
+ const pointOnSide = (point, side) => {
1556
+ if (side === "top") return { x: point.x, y: bounds.top };
1557
+ if (side === "right") return { x: bounds.right, y: point.y };
1558
+ if (side === "bottom") return { x: point.x, y: bounds.bottom };
1559
+ return { x: bounds.left, y: point.y };
1560
+ };
1561
+ const cornerAfter = (side, step) => {
1562
+ if (step > 0) {
1563
+ if (side === "top") return { x: bounds.right, y: bounds.top };
1564
+ if (side === "right") return { x: bounds.right, y: bounds.bottom };
1565
+ if (side === "bottom") return { x: bounds.left, y: bounds.bottom };
1566
+ return { x: bounds.left, y: bounds.top };
1567
+ }
1568
+ if (side === "top") return { x: bounds.left, y: bounds.top };
1569
+ if (side === "left") return { x: bounds.left, y: bounds.bottom };
1570
+ if (side === "bottom") return { x: bounds.right, y: bounds.bottom };
1571
+ return { x: bounds.right, y: bounds.top };
1572
+ };
1573
+ const fromSide = sideOf(fromDirection);
1574
+ const toSide = sideOf(toDirection);
1575
+ const fromEdge = pointOnSide(start, fromSide);
1576
+ const toEdge = pointOnSide(end, toSide);
1577
+ const around = (step) => {
1578
+ const points = [start, fromEdge];
1579
+ let sideIndex = sides.indexOf(fromSide);
1580
+ const targetIndex = sides.indexOf(toSide);
1581
+ const transitions =
1582
+ fromSide === toSide
1583
+ ? sides.length
1584
+ : (targetIndex - sideIndex + sides.length) % sides.length;
1585
+ const count = step > 0 ? transitions : (sides.length - transitions) % sides.length;
1586
+ const transitionCount = fromSide === toSide ? sides.length : count;
1587
+ for (let index = 0; index < transitionCount; index += 1) {
1588
+ const side = sides[sideIndex];
1589
+ points.push(cornerAfter(side, step));
1590
+ sideIndex = (sideIndex + step + sides.length) % sides.length;
1591
+ }
1592
+ return compressPoints([...points, toEdge, end]);
1593
+ };
1594
+ const candidates =
1595
+ fromSide === toSide
1596
+ ? [
1597
+ compressPoints([start, fromEdge, toEdge, end]),
1598
+ around(1),
1599
+ around(-1),
1600
+ ]
1601
+ : [around(1), around(-1)];
1602
+ return candidates.filter(routeIsOrthogonal);
1603
+ }
1604
+
1605
+ function segmentIntersectsBox(start, end, box, margin = 18) {
1606
+ const left = box.x - margin;
1607
+ const right = box.x + box.width + margin;
1608
+ const top = box.y - margin;
1609
+ const bottom = box.y + box.height + margin;
1610
+ if (start.x === end.x) {
1611
+ return start.x >= left && start.x <= right && Math.max(start.y, end.y) >= top && Math.min(start.y, end.y) <= bottom;
1612
+ }
1613
+ if (start.y === end.y) {
1614
+ return start.y >= top && start.y <= bottom && Math.max(start.x, end.x) >= left && Math.min(start.x, end.x) <= right;
1615
+ }
1616
+ return false;
1617
+ }
1618
+
1619
+ function countNodeHits(points, nodes, excludedIds) {
1620
+ let hits = 0;
1621
+ for (let index = 1; index < points.length; index++) {
1622
+ for (const node of nodes) {
1623
+ if (excludedIds.has(node.id)) continue;
1624
+ if (segmentIntersectsBox(points[index - 1], points[index], node)) hits += 1;
1625
+ }
1626
+ }
1627
+ return hits;
1628
+ }
1629
+
1630
+ function routeHitsNodes(points, nodes, excludedIds) {
1631
+ return countNodeHits(points, nodes, excludedIds) > 0;
1632
+ }
1633
+
1634
+ function routeHitsEndpointNode(points, endpoints) {
1635
+ return points.slice(1).some((end, index) => {
1636
+ const start = points[index];
1637
+ const length = Math.hypot(end.x - start.x, end.y - start.y);
1638
+ return endpoints.some(
1639
+ (endpoint) =>
1640
+ segmentPortionInsideBox(start, end, endpoint) * length > 0.001,
1641
+ );
1642
+ });
1643
+ }
1644
+
1645
+ function boxesOverlap(first, second) {
1646
+ return (
1647
+ first.x < second.x + second.width &&
1648
+ second.x < first.x + first.width &&
1649
+ first.y < second.y + second.height &&
1650
+ second.y < first.y + first.height
1651
+ );
1652
+ }
1653
+
1654
+ /**
1655
+ * Dimensions of a label pill (rounded rectangle). renderConnector and route
1656
+ * calculation share this function so visible labels match the boxes routes avoid.
1657
+ */
1658
+ function connectorLabelMetrics(element, canvas = DEFAULT_CANVAS) {
1659
+ const maximumWidth = Math.max(
1660
+ 1,
1661
+ Math.min(
1662
+ MAX_CONNECTOR_LABEL_WIDTH,
1663
+ canvas.width - CONNECTOR_LABEL_CLEARANCE * 2,
1664
+ ),
1665
+ );
1666
+ const fitted = fitTextToWidth(
1667
+ element.label,
1668
+ element.style.fontSize,
1669
+ Math.max(1, maximumWidth - CONNECTOR_LABEL_PADDING),
1670
+ );
1671
+ return {
1672
+ text: fitted.text,
1673
+ fontSize: fitted.fontSize,
1674
+ width: Math.min(
1675
+ maximumWidth,
1676
+ Math.max(MIN_CONNECTOR_LABEL_WIDTH, fitted.width + CONNECTOR_LABEL_PADDING),
1677
+ ),
1678
+ height: Math.min(
1679
+ Math.max(1, canvas.height - CONNECTOR_LABEL_CLEARANCE * 2),
1680
+ fitted.fontSize * 1.55,
1681
+ ),
1682
+ };
1683
+ }
1684
+
1685
+ function connectorLabelPaintBox(position, metrics) {
1686
+ const strokeRadius = CONNECTOR_LABEL_STROKE_WIDTH / 2;
1687
+ return {
1688
+ x: position.x - metrics.width / 2 - strokeRadius,
1689
+ y: position.y - metrics.height / 2 - strokeRadius,
1690
+ width: metrics.width + CONNECTOR_LABEL_STROKE_WIDTH,
1691
+ height: metrics.height + CONNECTOR_LABEL_STROKE_WIDTH,
1692
+ };
1693
+ }
1694
+
1695
+ /**
1696
+ * Default direction for moving a pill away from a line. Choose the segment normal
1697
+ * that remains on the same side when travel direction reverses: above horizontal
1698
+ * lines and right of vertical lines. Labels therefore stay on the same side from
1699
+ * either direction rather than alternating across adjacent connectors.
1700
+ *
1701
+ * Deliberately ignore nodes and other labels when choosing direction. Keeping this
1702
+ * pure over only the connector and route preserves the invariant that renderConnector
1703
+ * and route calculation use the same box (see {@link connectorLabelMetrics}).
1704
+ * routeCost evaluates destination overlap through ROUTE_COST_LABEL_OVER_NODE.
1705
+ */
1706
+ function labelEscapeNormal(direction) {
1707
+ const normal = { x: -direction.y, y: direction.x };
1708
+ const flipped = normal.y > 0 || (normal.y === 0 && normal.x < 0);
1709
+ return flipped ? { x: -normal.x, y: -normal.y } : normal;
1710
+ }
1711
+
1712
+ function terminalMarkerPolygon(element, points) {
1713
+ if (!element.arrow || points.length < 2) return null;
1714
+ const end = points.at(-1);
1715
+ const previous = points.at(-2);
1716
+ const length = Math.hypot(end.x - previous.x, end.y - previous.y);
1717
+ if (length < 0.001) return null;
1718
+ const direction = {
1719
+ x: (end.x - previous.x) / length,
1720
+ y: (end.y - previous.y) / length,
1721
+ };
1722
+ const normal = { x: -direction.y, y: direction.x };
1723
+ const scale =
1724
+ (CONNECTOR_MARKER_SIZE * element.style.strokeWidth) /
1725
+ CONNECTOR_MARKER_VIEWBOX_SIZE;
1726
+ return CONNECTOR_MARKER_POINTS.map((point) => {
1727
+ const localX = (point.x - CONNECTOR_MARKER_REF_X) * scale;
1728
+ const localY = (point.y - CONNECTOR_MARKER_REF_Y) * scale;
1729
+ return {
1730
+ x: end.x + direction.x * localX + normal.x * localY,
1731
+ y: end.y + direction.y * localX + normal.y * localY,
1732
+ };
1733
+ });
1734
+ }
1735
+
1736
+ function polygonOverlapsBox(polygon, box) {
1737
+ const axes = [{ x: 1, y: 0 }, { x: 0, y: 1 }];
1738
+ for (let index = 0; index < polygon.length; index++) {
1739
+ const start = polygon[index];
1740
+ const end = polygon[(index + 1) % polygon.length];
1741
+ const delta = { x: end.x - start.x, y: end.y - start.y };
1742
+ const length = Math.hypot(delta.x, delta.y);
1743
+ if (length > 0.001) {
1744
+ axes.push({ x: -delta.y / length, y: delta.x / length });
1745
+ }
1746
+ }
1747
+ const center = {
1748
+ x: box.x + box.width / 2,
1749
+ y: box.y + box.height / 2,
1750
+ };
1751
+ return axes.every((axis) => {
1752
+ const projections = polygon.map((point) => point.x * axis.x + point.y * axis.y);
1753
+ const polygonMinimum = Math.min(...projections);
1754
+ const polygonMaximum = Math.max(...projections);
1755
+ const boxCenter = center.x * axis.x + center.y * axis.y;
1756
+ const boxRadius =
1757
+ Math.abs(axis.x) * box.width / 2 + Math.abs(axis.y) * box.height / 2;
1758
+ return (
1759
+ Math.min(polygonMaximum, boxCenter + boxRadius) -
1760
+ Math.max(polygonMinimum, boxCenter - boxRadius) >
1761
+ 0.001
1762
+ );
1763
+ });
1764
+ }
1765
+
1766
+ function terminalMarkerIsClear(element, points, box) {
1767
+ if (!element.arrow) return true;
1768
+ const polygon = terminalMarkerPolygon(element, points);
1769
+ return polygon ? !polygonOverlapsBox(polygon, box) : false;
1770
+ }
1771
+
1772
+ function terminalMarkerEscapeReach(
1773
+ element,
1774
+ points,
1775
+ anchor,
1776
+ normal,
1777
+ metrics,
1778
+ sign,
1779
+ baseReach,
1780
+ ) {
1781
+ const position = {
1782
+ x: anchor.x + normal.x * baseReach * sign,
1783
+ y: anchor.y + normal.y * baseReach * sign,
1784
+ };
1785
+ const box = connectorLabelPaintBox(position, metrics);
1786
+ if (terminalMarkerIsClear(element, points, box)) return baseReach;
1787
+ const polygon = terminalMarkerPolygon(element, points);
1788
+ if (!polygon) return baseReach;
1789
+ const markerProjections = polygon.map(
1790
+ (point) => point.x * normal.x + point.y * normal.y,
1791
+ );
1792
+ const markerMinimum = Math.min(...markerProjections);
1793
+ const markerMaximum = Math.max(...markerProjections);
1794
+ const anchorProjection = anchor.x * normal.x + anchor.y * normal.y;
1795
+ const labelRadius =
1796
+ Math.abs(normal.x) * box.width / 2 +
1797
+ Math.abs(normal.y) * box.height / 2;
1798
+ const required =
1799
+ sign > 0
1800
+ ? markerMaximum - anchorProjection + labelRadius + 0.001
1801
+ : anchorProjection + labelRadius - markerMinimum + 0.001;
1802
+ return Math.max(baseReach, required);
1803
+ }
1804
+
1805
+ function fullRouteEscapeReach(
1806
+ points,
1807
+ anchor,
1808
+ direction,
1809
+ metrics,
1810
+ sign,
1811
+ baseReach,
1812
+ ) {
1813
+ const projections = points.map(
1814
+ (point) => point.x * direction.x + point.y * direction.y,
1815
+ );
1816
+ const routeMinimum = Math.min(...projections);
1817
+ const routeMaximum = Math.max(...projections);
1818
+ const anchorProjection =
1819
+ anchor.x * direction.x + anchor.y * direction.y;
1820
+ const labelRadius =
1821
+ Math.abs(direction.x) *
1822
+ (metrics.width + CONNECTOR_LABEL_STROKE_WIDTH) /
1823
+ 2 +
1824
+ Math.abs(direction.y) *
1825
+ (metrics.height + CONNECTOR_LABEL_STROKE_WIDTH) /
1826
+ 2;
1827
+ const required =
1828
+ sign > 0
1829
+ ? routeMaximum -
1830
+ anchorProjection +
1831
+ labelRadius +
1832
+ CONNECTOR_LABEL_CLEARANCE
1833
+ : anchorProjection -
1834
+ routeMinimum +
1835
+ labelRadius +
1836
+ CONNECTOR_LABEL_CLEARANCE;
1837
+ return Math.max(baseReach, required);
1838
+ }
1839
+
1840
+ /**
1841
+ * Label-pill anchor point and whether it was moved away from the line.
1842
+ *
1843
+ * Defaults to the route midpoint. Because the opaque pill has minimum width
1844
+ * {@link MIN_CONNECTOR_LABEL_WIDTH}, tight node spacing can hide the complete
1845
+ * line and arrowhead; shortening text cannot reduce it below that minimum.
1846
+ * Move the pill along the midpoint segment's normal only when less than
1847
+ * {@link MIN_VISIBLE_ROUTE_LENGTH} remains. Prefer the side inside the canvas,
1848
+ * then the side with greater visible length when both are equivalent.
1849
+ *
1850
+ * Escape distance equals the axis-aligned rectangle's extent along the normal
1851
+ * plus padding. Because the pill does not rotate, this guarantees its corners do
1852
+ * not touch even a diagonal line.
1853
+ */
1854
+ function connectorLabelAnchor(
1855
+ element,
1856
+ points,
1857
+ metrics = null,
1858
+ canvas = DEFAULT_CANVAS,
1859
+ ) {
1860
+ metrics ??= connectorLabelMetrics(element, canvas);
1861
+ const measure = (position) => {
1862
+ const box = connectorLabelPaintBox(position, metrics);
1863
+ return {
1864
+ position,
1865
+ box,
1866
+ contained:
1867
+ box.x >= 0 &&
1868
+ box.y >= 0 &&
1869
+ box.x + box.width <= canvas.width &&
1870
+ box.y + box.height <= canvas.height,
1871
+ terminalClear: terminalMarkerIsClear(element, points, box),
1872
+ visible: routeLengthOutsideBox(points, box),
1873
+ };
1874
+ };
1875
+ const paintedWidth = metrics.width + CONNECTOR_LABEL_STROKE_WIDTH;
1876
+ const paintedHeight = metrics.height + CONNECTOR_LABEL_STROKE_WIDTH;
1877
+ const clampToCanvas = (position) => ({
1878
+ ...position,
1879
+ x:
1880
+ paintedWidth <= canvas.width
1881
+ ? Math.min(
1882
+ canvas.width - paintedWidth / 2,
1883
+ Math.max(paintedWidth / 2, position.x),
1884
+ )
1885
+ : canvas.width / 2,
1886
+ y:
1887
+ paintedHeight <= canvas.height
1888
+ ? Math.min(
1889
+ canvas.height - paintedHeight / 2,
1890
+ Math.max(paintedHeight / 2, position.y),
1891
+ )
1892
+ : canvas.height / 2,
1893
+ });
1894
+ const anchor = pointAtHalfLength(points);
1895
+ const centered = measure({ x: anchor.x, y: anchor.y, escaped: false });
1896
+ const minimumVisible = Math.min(MIN_VISIBLE_ROUTE_LENGTH, routeLength(points));
1897
+ if (
1898
+ centered.contained &&
1899
+ centered.terminalClear &&
1900
+ centered.visible >= minimumVisible
1901
+ ) {
1902
+ return { x: anchor.x, y: anchor.y, escaped: false };
1903
+ }
1904
+ const normals = [labelEscapeNormal(anchor.direction)];
1905
+ const segments = points
1906
+ .slice(1)
1907
+ .map((end, index) => {
1908
+ const start = points[index];
1909
+ const length = Math.hypot(end.x - start.x, end.y - start.y);
1910
+ return {
1911
+ length,
1912
+ direction: length
1913
+ ? { x: (end.x - start.x) / length, y: (end.y - start.y) / length }
1914
+ : null,
1915
+ };
1916
+ })
1917
+ .sort((first, second) => second.length - first.length);
1918
+ for (const segment of segments) {
1919
+ if (!segment.direction) continue;
1920
+ const normal = labelEscapeNormal(segment.direction);
1921
+ if (
1922
+ normals.some(
1923
+ (known) => Math.abs(known.x * normal.x + known.y * normal.y) > 0.999,
1924
+ )
1925
+ ) {
1926
+ continue;
1927
+ }
1928
+ normals.push(normal);
1929
+ }
1930
+ const axes = normals.map((direction) => ({
1931
+ direction,
1932
+ clearFullRoute: false,
1933
+ }));
1934
+ for (const segment of segments) {
1935
+ if (
1936
+ !segment.direction ||
1937
+ axes.some(
1938
+ (axis) =>
1939
+ Math.abs(
1940
+ axis.direction.x * segment.direction.x +
1941
+ axis.direction.y * segment.direction.y,
1942
+ ) > 0.999,
1943
+ )
1944
+ ) {
1945
+ continue;
1946
+ }
1947
+ axes.push({
1948
+ direction: segment.direction,
1949
+ clearFullRoute: true,
1950
+ });
1951
+ }
1952
+ const escapedByAxis = axes.map(({ direction, clearFullRoute }) => {
1953
+ const reach =
1954
+ Math.abs(direction.x) * (paintedWidth / 2) +
1955
+ Math.abs(direction.y) * (paintedHeight / 2) +
1956
+ CONNECTOR_LABEL_CLEARANCE;
1957
+ return [1, -1].map((sign) => {
1958
+ const routeAdjustedReach = clearFullRoute
1959
+ ? fullRouteEscapeReach(
1960
+ points,
1961
+ anchor,
1962
+ direction,
1963
+ metrics,
1964
+ sign,
1965
+ reach,
1966
+ )
1967
+ : reach;
1968
+ const adjustedReach = terminalMarkerEscapeReach(
1969
+ element,
1970
+ points,
1971
+ anchor,
1972
+ direction,
1973
+ metrics,
1974
+ sign,
1975
+ routeAdjustedReach,
1976
+ );
1977
+ return measure({
1978
+ x: anchor.x + direction.x * adjustedReach * sign,
1979
+ y: anchor.y + direction.y * adjustedReach * sign,
1980
+ escaped: true,
1981
+ });
1982
+ });
1983
+ });
1984
+ const best = (candidates) =>
1985
+ candidates.reduce((selected, candidate) =>
1986
+ candidate.visible > selected.visible + 0.001 ? candidate : selected,
1987
+ );
1988
+ for (const candidates of escapedByAxis) {
1989
+ const viable = candidates.filter(
1990
+ (candidate) =>
1991
+ candidate.contained &&
1992
+ candidate.terminalClear &&
1993
+ candidate.visible >= minimumVisible - 0.001,
1994
+ );
1995
+ if (viable.length) return best(viable).position;
1996
+ }
1997
+ let candidates = escapedByAxis.flat().map((candidate) =>
1998
+ candidate.contained
1999
+ ? candidate
2000
+ : measure(clampToCanvas(candidate.position)),
2001
+ );
2002
+ const viable = candidates.filter(
2003
+ (candidate) =>
2004
+ candidate.contained &&
2005
+ candidate.terminalClear &&
2006
+ candidate.visible >= minimumVisible - 0.001,
2007
+ );
2008
+ if (viable.length) return best(viable).position;
2009
+ const terminalClear = candidates.filter(
2010
+ (candidate) => candidate.terminalClear,
2011
+ );
2012
+ if (terminalClear.length) candidates = terminalClear;
2013
+ return best(candidates).position;
2014
+ }
2015
+
2016
+ /** Rectangle actually occupied by a label, or null for a connector without a label. */
2017
+ function connectorLabelBox(element, points, canvas = DEFAULT_CANVAS) {
2018
+ if (!element?.label || points.length < 2) return null;
2019
+ const metrics = connectorLabelMetrics(element, canvas);
2020
+ const position = connectorLabelAnchor(element, points, metrics, canvas);
2021
+ return connectorLabelPaintBox(position, metrics);
2022
+ }
2023
+
2024
+ /** Whether a route crosses a label rectangle, tested with margin 0. */
2025
+ function boxOverlapsRoute(box, points) {
2026
+ for (let index = 1; index < points.length; index++) {
2027
+ if (segmentIntersectsBox(points[index - 1], points[index], box, 0)) return true;
2028
+ }
2029
+ return false;
2030
+ }
2031
+
2032
+ /**
2033
+ * Fraction [0,1] of segment a→b inside box, using Liang–Barsky clipping.
2034
+ *
2035
+ * Used to measure how much line length a label hides. This needs an amount rather
2036
+ * than a Boolean intersection, so it differs from boxOverlapsRoute. The analytic,
2037
+ * nonsampled solution is deterministic for identical input.
2038
+ */
2039
+ function segmentPortionInsideBox(a, b, box) {
2040
+ const deltaX = b.x - a.x;
2041
+ const deltaY = b.y - a.y;
2042
+ let enter = 0;
2043
+ let exit = 1;
2044
+ const clip = (edge, distance) => {
2045
+ if (edge === 0) return distance >= 0;
2046
+ const ratio = distance / edge;
2047
+ if (edge < 0) {
2048
+ if (ratio > exit) return false;
2049
+ if (ratio > enter) enter = ratio;
2050
+ } else {
2051
+ if (ratio < enter) return false;
2052
+ if (ratio < exit) exit = ratio;
2053
+ }
2054
+ return true;
2055
+ };
2056
+ if (!clip(-deltaX, a.x - box.x)) return 0;
2057
+ if (!clip(deltaX, box.x + box.width - a.x)) return 0;
2058
+ if (!clip(-deltaY, a.y - box.y)) return 0;
2059
+ if (!clip(deltaY, box.y + box.height - a.y)) return 0;
2060
+ return Math.max(0, exit - enter);
2061
+ }
2062
+
2063
+ /** Route length outside box: line length that remains visible rather than hidden by a label. */
2064
+ function routeLengthOutsideBox(points, box) {
2065
+ let outside = 0;
2066
+ for (let index = 1; index < points.length; index++) {
2067
+ const start = points[index - 1];
2068
+ const end = points[index];
2069
+ const length = Math.hypot(end.x - start.x, end.y - start.y);
2070
+ outside += length * (1 - segmentPortionInsideBox(start, end, box));
2071
+ }
2072
+ return outside;
2073
+ }
2074
+
2075
+ function availableStubDistance(start, direction, desired, nodes, excludedIds) {
2076
+ for (let distance = desired; distance >= 2; distance -= 2) {
2077
+ const end = offsetPoint(start, direction, distance);
2078
+ const blocked = nodes.some(
2079
+ (node) =>
2080
+ !excludedIds.has(node.id) && segmentIntersectsBox(start, end, node),
2081
+ );
2082
+ if (!blocked) return distance;
2083
+ }
2084
+ return 0;
2085
+ }
2086
+
2087
+ function fitFacingDistances(
2088
+ start,
2089
+ end,
2090
+ fromDirection,
2091
+ toDirection,
2092
+ fromDistance,
2093
+ toDistance,
2094
+ minimumRemainingSpan = 0,
2095
+ ) {
2096
+ const directionsOppose =
2097
+ fromDirection.x === -toDirection.x &&
2098
+ fromDirection.y === -toDirection.y;
2099
+ const delta = { x: end.x - start.x, y: end.y - start.y };
2100
+ const forwardSpan = fromDirection.x * delta.x + fromDirection.y * delta.y;
2101
+ const total = fromDistance + toDistance;
2102
+ const reservedSpan = Math.min(minimumRemainingSpan, forwardSpan);
2103
+ const availableSpan = forwardSpan - reservedSpan;
2104
+ if (
2105
+ !directionsOppose ||
2106
+ forwardSpan <= 0 ||
2107
+ total <= availableSpan
2108
+ ) {
2109
+ return { fromDistance, toDistance };
2110
+ }
2111
+ const scale = availableSpan / total;
2112
+ return {
2113
+ fromDistance: fromDistance * scale,
2114
+ toDistance: toDistance * scale,
2115
+ };
2116
+ }
2117
+
2118
+ function segmentInteractionScore(firstStart, firstEnd, secondStart, secondEnd) {
2119
+ const cross = (origin, first, second) =>
2120
+ (first.x - origin.x) * (second.y - origin.y) -
2121
+ (first.y - origin.y) * (second.x - origin.x);
2122
+ const firstSideA = cross(firstStart, firstEnd, secondStart);
2123
+ const firstSideB = cross(firstStart, firstEnd, secondEnd);
2124
+ const secondSideA = cross(secondStart, secondEnd, firstStart);
2125
+ const secondSideB = cross(secondStart, secondEnd, firstEnd);
2126
+ const epsilon = 0.001;
2127
+ const collinear =
2128
+ Math.abs(firstSideA) < epsilon &&
2129
+ Math.abs(firstSideB) < epsilon &&
2130
+ Math.abs(secondSideA) < epsilon &&
2131
+ Math.abs(secondSideB) < epsilon;
2132
+ if (collinear) {
2133
+ const useX =
2134
+ Math.abs(firstEnd.x - firstStart.x) >=
2135
+ Math.abs(firstEnd.y - firstStart.y);
2136
+ const firstMin = Math.min(
2137
+ useX ? firstStart.x : firstStart.y,
2138
+ useX ? firstEnd.x : firstEnd.y,
2139
+ );
2140
+ const firstMax = Math.max(
2141
+ useX ? firstStart.x : firstStart.y,
2142
+ useX ? firstEnd.x : firstEnd.y,
2143
+ );
2144
+ const secondMin = Math.min(
2145
+ useX ? secondStart.x : secondStart.y,
2146
+ useX ? secondEnd.x : secondEnd.y,
2147
+ );
2148
+ const secondMax = Math.max(
2149
+ useX ? secondStart.x : secondStart.y,
2150
+ useX ? secondEnd.x : secondEnd.y,
2151
+ );
2152
+ const overlap = Math.min(firstMax, secondMax) - Math.max(firstMin, secondMin);
2153
+ return overlap > epsilon ? 1000 + overlap : 0;
2154
+ }
2155
+ const crosses =
2156
+ firstSideA * firstSideB < -epsilon &&
2157
+ secondSideA * secondSideB < -epsilon;
2158
+ return crosses ? 100 : 0;
2159
+ }
2160
+
2161
+ function routeInteractionScore(points, occupiedRoutes) {
2162
+ let score = 0;
2163
+ for (let index = 1; index < points.length; index++) {
2164
+ for (const occupied of occupiedRoutes) {
2165
+ for (let occupiedIndex = 1; occupiedIndex < occupied.length; occupiedIndex++) {
2166
+ score += segmentInteractionScore(
2167
+ points[index - 1],
2168
+ points[index],
2169
+ occupied[occupiedIndex - 1],
2170
+ occupied[occupiedIndex],
2171
+ );
2172
+ }
2173
+ }
2174
+ }
2175
+ return score;
2176
+ }
2177
+
2178
+ // Count only true crossings, not overlaps. Used to guarantee rerouting does not
2179
+ // increase this value. Minimizing crossings is this phase's primary goal and must
2180
+ // not be traded for label readability.
2181
+ function countRouteCrossings(points, occupiedRoutes) {
2182
+ let crossings = 0;
2183
+ for (let index = 1; index < points.length; index++) {
2184
+ for (const occupied of occupiedRoutes) {
2185
+ for (let occupiedIndex = 1; occupiedIndex < occupied.length; occupiedIndex++) {
2186
+ const score = segmentInteractionScore(
2187
+ points[index - 1],
2188
+ points[index],
2189
+ occupied[occupiedIndex - 1],
2190
+ occupied[occupiedIndex],
2191
+ );
2192
+ if (score === 100) crossings += 1;
2193
+ }
2194
+ }
2195
+ }
2196
+ return crossings;
2197
+ }
2198
+
2199
+ /**
2200
+ * Amount of otherwise readable content hidden by this route.
2201
+ *
2202
+ * Sum of nodes penetrated by the line and nodes overlapped by the label pill,
2203
+ * excluding endpoint nodes because they are connection targets. Crossings merely
2204
+ * impair reading; this hides information, so refinement guards weigh it more heavily.
2205
+ */
2206
+ function countHiddenContent(
2207
+ connector,
2208
+ points,
2209
+ nodes,
2210
+ canvas = DEFAULT_CANVAS,
2211
+ ) {
2212
+ const excludedIds = new Set([connector.from, connector.to]);
2213
+ let hidden = countNodeHits(points, nodes, excludedIds);
2214
+ const labelBox = connectorLabelBox(connector, points, canvas);
2215
+ if (!labelBox) return hidden;
2216
+ for (const node of nodes) {
2217
+ if (excludedIds.has(node.id)) continue;
2218
+ if (boxesOverlap(labelBox, node)) hidden += 1;
2219
+ }
2220
+ return hidden;
2221
+ }
2222
+
2223
+ function routeLength(points) {
2224
+ let total = 0;
2225
+ for (let index = 1; index < points.length; index++) {
2226
+ total += Math.hypot(
2227
+ points[index].x - points[index - 1].x,
2228
+ points[index].y - points[index - 1].y,
2229
+ );
2230
+ }
2231
+ return total;
2232
+ }
2233
+
2234
+ function countBends(points) {
2235
+ let bends = 0;
2236
+ for (let index = 1; index < points.length - 1; index++) {
2237
+ const previous = points[index - 1];
2238
+ const current = points[index];
2239
+ const next = points[index + 1];
2240
+ const incoming = Math.sign(current.x - previous.x) || Math.sign(current.y - previous.y) * 2;
2241
+ const outgoing = Math.sign(next.x - current.x) || Math.sign(next.y - current.y) * 2;
2242
+ const horizontalIn = Math.abs(current.x - previous.x) > 0.001;
2243
+ const horizontalOut = Math.abs(next.x - current.x) > 0.001;
2244
+ if (horizontalIn !== horizontalOut || incoming !== outgoing) bends += 1;
2245
+ }
2246
+ return bends;
2247
+ }
2248
+
2249
+ /**
2250
+ * Aggregate cost of one route. Initial placement, grid search, and rerouting all use this scale.
2251
+ *
2252
+ * Count label terms in both directions: own label vs other routes and other labels
2253
+ * vs own route. One direction alone makes the cost delta of rerouting one connector
2254
+ * differ from the whole-diagram delta and can cause oscillation. Both directions
2255
+ * produce an exact delta, guaranteeing monotonic diagram-wide improvement when
2256
+ * adopting only improvements.
2257
+ *
2258
+ * Exclude both endpoint nodes from label terms. Otherwise a short connector may
2259
+ * make an unnatural detour solely to move its label away from its own targets.
2260
+ */
2261
+ function routeCost(points, context) {
2262
+ const {
2263
+ nodes = [],
2264
+ excludedIds = new Set(),
2265
+ occupiedRoutes = [],
2266
+ occupiedLabels = [],
2267
+ connector = null,
2268
+ canvas = DEFAULT_CANVAS,
2269
+ } = context;
2270
+ let cost = countNodeHits(points, nodes, excludedIds) * ROUTE_COST_NODE_HIT;
2271
+ cost += routeInteractionScore(points, occupiedRoutes) * ROUTE_COST_INTERACTION;
2272
+ const labelBox = connectorLabelBox(connector, points, canvas);
2273
+ if (labelBox) {
2274
+ for (const node of nodes) {
2275
+ if (excludedIds.has(node.id)) continue;
2276
+ if (boxesOverlap(labelBox, node)) cost += ROUTE_COST_LABEL_OVER_NODE;
2277
+ }
2278
+ for (const other of occupiedLabels) {
2279
+ if (boxesOverlap(labelBox, other)) cost += ROUTE_COST_LABEL_OVER_LABEL;
2280
+ }
2281
+ for (const route of occupiedRoutes) {
2282
+ if (boxOverlapsRoute(labelBox, route)) cost += ROUTE_COST_LABEL_OVER_ROUTE;
2283
+ }
2284
+ }
2285
+ for (const other of occupiedLabels) {
2286
+ if (boxOverlapsRoute(other, points)) cost += ROUTE_COST_LABEL_OVER_ROUTE;
2287
+ }
2288
+ cost += countBends(points) * ROUTE_COST_BEND;
2289
+ return cost + routeLength(points);
2290
+ }
2291
+
2292
+ /**
2293
+ * Choose the lowest-cost candidate. Ties preserve the earlier candidate, making
2294
+ * generation order, which is stable relative to declaration order, deterministic.
2295
+ */
2296
+ function chooseRoute(candidates, nodes, excludedIds, occupiedRoutes, options = {}) {
2297
+ const context = {
2298
+ nodes,
2299
+ excludedIds,
2300
+ occupiedRoutes,
2301
+ occupiedLabels: options.occupiedLabels || [],
2302
+ connector: options.connector || null,
2303
+ canvas: options.canvas || DEFAULT_CANVAS,
2304
+ };
2305
+ let best = candidates[0];
2306
+ let bestCost = routeCost(best, context);
2307
+ for (let index = 1; index < candidates.length; index++) {
2308
+ const cost = routeCost(candidates[index], context);
2309
+ if (cost < bestCost) {
2310
+ best = candidates[index];
2311
+ bestCost = cost;
2312
+ }
2313
+ }
2314
+ return best;
2315
+ }
2316
+
2317
+ function filterPreferredRouteCandidates(
2318
+ candidates,
2319
+ connector,
2320
+ nodes,
2321
+ maximumRouteLength,
2322
+ canvas,
2323
+ ) {
2324
+ if (!candidates.length) return [];
2325
+ const measured = candidates.map((points) => ({
2326
+ points,
2327
+ hidden: countHiddenContent(connector, points, nodes, canvas),
2328
+ length: routeLength(points),
2329
+ }));
2330
+ const bounded = measured.filter(
2331
+ (candidate) =>
2332
+ candidate.length <= maximumRouteLength + ROUTE_IMPROVEMENT_EPSILON,
2333
+ );
2334
+ const boundedHidden = bounded.length
2335
+ ? Math.min(...bounded.map((candidate) => candidate.hidden))
2336
+ : Infinity;
2337
+ const minimumHidden = Math.min(
2338
+ ...measured.map((candidate) => candidate.hidden),
2339
+ );
2340
+ const eligible = minimumHidden < boundedHidden ? measured : bounded;
2341
+ const eligibleHidden = Math.min(
2342
+ ...eligible.map((candidate) => candidate.hidden),
2343
+ );
2344
+ return eligible
2345
+ .filter((candidate) => candidate.hidden === eligibleHidden)
2346
+ .map((candidate) => candidate.points);
2347
+ }
2348
+
2349
+ function gridRoute(
2350
+ start,
2351
+ end,
2352
+ nodes,
2353
+ excludedIds,
2354
+ canvas,
2355
+ occupiedRoutes,
2356
+ options = {},
2357
+ ) {
2358
+ const startDirection = options.startDirection || null;
2359
+ const endDirection = options.endDirection || null;
2360
+ const strictStartDirection = options.strictStartDirection === true;
2361
+ const strictEndDirection = options.strictEndDirection === true;
2362
+ // Keep endpoint nodes as physical obstacles, but test their actual bounds
2363
+ // (margin 0), not the usual 18px clearance, because endpoints have only a 14px gap.
2364
+ const obstacles = nodes.map((node) => ({
2365
+ node,
2366
+ margin: excludedIds.has(node.id) ? 0 : 18,
2367
+ }));
2368
+ const xs = new Set([0, canvas.width, start.x, end.x]);
2369
+ const ys = new Set([0, canvas.height, start.y, end.y]);
2370
+ for (const { node, margin } of obstacles) {
2371
+ const corridor = margin + 1;
2372
+ xs.add(Math.max(0, node.x - corridor));
2373
+ xs.add(Math.min(canvas.width, node.x + node.width + corridor));
2374
+ ys.add(Math.max(0, node.y - corridor));
2375
+ ys.add(Math.min(canvas.height, node.y + node.height + corridor));
2376
+ }
2377
+ for (const route of occupiedRoutes) {
2378
+ for (const point of route) {
2379
+ xs.add(Math.max(0, point.x - 8));
2380
+ xs.add(Math.min(canvas.width, point.x + 8));
2381
+ xs.add(Math.max(0, point.x - 12));
2382
+ xs.add(Math.min(canvas.width, point.x + 12));
2383
+ ys.add(Math.max(0, point.y - 8));
2384
+ ys.add(Math.min(canvas.height, point.y + 8));
2385
+ ys.add(Math.max(0, point.y - 12));
2386
+ ys.add(Math.min(canvas.height, point.y + 12));
2387
+ }
2388
+ }
2389
+ if (
2390
+ xs.size > MAX_ROUTING_GRID_COORDINATES ||
2391
+ ys.size > MAX_ROUTING_GRID_COORDINATES ||
2392
+ xs.size * ys.size > MAX_ROUTING_GRID_POINTS
2393
+ ) {
2394
+ return { points: null, reason: ROUTE_FALLBACK_REASONS.gridTooLarge };
2395
+ }
2396
+ const xValues = [...xs].sort((left, right) => left - right);
2397
+ const yValues = [...ys].sort((left, right) => left - right);
2398
+ const keyOf = (point) => `${point.x},${point.y}`;
2399
+ const insideObstacle = (point) =>
2400
+ obstacles.some(
2401
+ ({ node, margin }) =>
2402
+ point.x > node.x - margin &&
2403
+ point.x < node.x + node.width + margin &&
2404
+ point.y > node.y - margin &&
2405
+ point.y < node.y + node.height + margin,
2406
+ );
2407
+ const segmentBlocked = (startPoint, endPoint) =>
2408
+ obstacles.some(({ node, margin }) =>
2409
+ segmentIntersectsBox(startPoint, endPoint, node, margin),
2410
+ );
2411
+ const points = new Map();
2412
+ const rows = new Map();
2413
+ const columns = new Map();
2414
+ for (const y of yValues) {
2415
+ for (const x of xValues) {
2416
+ const point = { x, y };
2417
+ if (insideObstacle(point)) continue;
2418
+ const key = keyOf(point);
2419
+ points.set(key, point);
2420
+ if (!rows.has(y)) rows.set(y, []);
2421
+ if (!columns.has(x)) columns.set(x, []);
2422
+ rows.get(y).push(point);
2423
+ columns.get(x).push(point);
2424
+ }
2425
+ }
2426
+ const adjacency = new Map([...points.keys()].map((key) => [key, []]));
2427
+ const connect = (line) => {
2428
+ for (let index = 1; index < line.length; index++) {
2429
+ const from = line[index - 1];
2430
+ const to = line[index];
2431
+ if (segmentBlocked(from, to)) continue;
2432
+ adjacency.get(keyOf(from)).push(to);
2433
+ adjacency.get(keyOf(to)).push(from);
2434
+ }
2435
+ };
2436
+ for (const row of rows.values()) connect(row.sort((left, right) => left.x - right.x));
2437
+ for (const column of columns.values()) {
2438
+ connect(column.sort((left, right) => left.y - right.y));
2439
+ }
2440
+
2441
+ const startKey = keyOf(start);
2442
+ const endKey = keyOf(end);
2443
+ if (!points.has(startKey) || !points.has(endKey)) {
2444
+ return { points: null, reason: ROUTE_FALLBACK_REASONS.endpointBlocked };
2445
+ }
2446
+ const stateKey = (pointKey, direction) => `${pointKey}|${direction}`;
2447
+ const initialState = stateKey(startKey, "N");
2448
+ const distances = new Map([[initialState, 0]]);
2449
+ const previous = new Map();
2450
+ const queue = [];
2451
+ const push = (entry) => {
2452
+ queue.push(entry);
2453
+ let index = queue.length - 1;
2454
+ while (index > 0) {
2455
+ const parent = Math.floor((index - 1) / 2);
2456
+ if (queue[parent].distance <= queue[index].distance) break;
2457
+ [queue[parent], queue[index]] = [queue[index], queue[parent]];
2458
+ index = parent;
2459
+ }
2460
+ };
2461
+ const pop = () => {
2462
+ const first = queue[0];
2463
+ const last = queue.pop();
2464
+ if (queue.length) {
2465
+ queue[0] = last;
2466
+ let index = 0;
2467
+ while (true) {
2468
+ const left = index * 2 + 1;
2469
+ const right = left + 1;
2470
+ let smallest = index;
2471
+ if (left < queue.length && queue[left].distance < queue[smallest].distance) {
2472
+ smallest = left;
2473
+ }
2474
+ if (right < queue.length && queue[right].distance < queue[smallest].distance) {
2475
+ smallest = right;
2476
+ }
2477
+ if (smallest === index) break;
2478
+ [queue[index], queue[smallest]] = [queue[smallest], queue[index]];
2479
+ index = smallest;
2480
+ }
2481
+ }
2482
+ return first;
2483
+ };
2484
+ push({ state: initialState, distance: 0 });
2485
+ let finalState = null;
2486
+ let visits = 0;
2487
+ while (queue.length && visits < MAX_ROUTING_GRID_VISITS) {
2488
+ const { state: currentState, distance: currentDistance } = pop();
2489
+ if (currentDistance !== distances.get(currentState)) continue;
2490
+ visits += 1;
2491
+ const separator = currentState.lastIndexOf("|");
2492
+ const currentPointKey = currentState.slice(0, separator);
2493
+ const currentDirection = currentState.slice(separator + 1);
2494
+ if (currentPointKey === endKey) {
2495
+ finalState = currentState;
2496
+ break;
2497
+ }
2498
+ const currentPoint = points.get(currentPointKey);
2499
+ for (const neighbor of adjacency.get(currentPointKey)) {
2500
+ const movement = {
2501
+ x: neighbor.x - currentPoint.x,
2502
+ y: neighbor.y - currentPoint.y,
2503
+ };
2504
+ const startProjection = startDirection
2505
+ ? movement.x * startDirection.x + movement.y * startDirection.y
2506
+ : Infinity;
2507
+ if (
2508
+ currentPointKey === startKey &&
2509
+ startDirection &&
2510
+ (strictStartDirection
2511
+ ? startProjection <= 0.001
2512
+ : startProjection < -0.001)
2513
+ ) {
2514
+ continue;
2515
+ }
2516
+ const nextPointKey = keyOf(neighbor);
2517
+ const endProjection = endDirection
2518
+ ? movement.x * endDirection.x + movement.y * endDirection.y
2519
+ : Infinity;
2520
+ if (
2521
+ nextPointKey === endKey &&
2522
+ endDirection &&
2523
+ (strictEndDirection
2524
+ ? endProjection <= 0.001
2525
+ : endProjection < -0.001)
2526
+ ) {
2527
+ continue;
2528
+ }
2529
+ const nextDirection = neighbor.x === currentPoint.x ? "V" : "H";
2530
+ const bendCost =
2531
+ currentDirection === "N" || currentDirection === nextDirection ? 0 : 24;
2532
+ const interactionCost =
2533
+ routeInteractionScore([currentPoint, neighbor], occupiedRoutes) * 10_000;
2534
+ const nextDistance =
2535
+ currentDistance +
2536
+ Math.hypot(neighbor.x - currentPoint.x, neighbor.y - currentPoint.y) +
2537
+ bendCost +
2538
+ interactionCost;
2539
+ const nextState = stateKey(nextPointKey, nextDirection);
2540
+ if (nextDistance >= (distances.get(nextState) ?? Infinity)) continue;
2541
+ distances.set(nextState, nextDistance);
2542
+ previous.set(nextState, currentState);
2543
+ push({ state: nextState, distance: nextDistance });
2544
+ }
2545
+ }
2546
+ if (!finalState) {
2547
+ // Distinguish budget exhaustion from no possible route because author guidance differs.
2548
+ return {
2549
+ points: null,
2550
+ reason:
2551
+ visits >= MAX_ROUTING_GRID_VISITS
2552
+ ? ROUTE_FALLBACK_REASONS.gridVisitBudget
2553
+ : ROUTE_FALLBACK_REASONS.gridUnreachable,
2554
+ };
2555
+ }
2556
+ const route = [];
2557
+ for (let state = finalState; state; state = previous.get(state)) {
2558
+ route.push(points.get(state.slice(0, state.lastIndexOf("|"))));
2559
+ }
2560
+ return { points: compressPoints(route.reverse()), reason: null };
2561
+ }
2562
+
2563
+ /**
2564
+ * Determine the route for one connector.
2565
+ *
2566
+ * Fixed fallback sequence:
2567
+ * 1. Enumerate candidates and choose the minimum routeCost.
2568
+ * 2. Use grid search only if it penetrates a node or interferes with existing routes.
2569
+ * 3. Measure grid solutions with routeCost and adopt only improvements.
2570
+ * 4. If it still covers a node or label, report through options.report.
2571
+ * Do not throw, which would turn previously renderable diagrams into errors.
2572
+ *
2573
+ * options is purely additive; existing four-argument calls continue to work.
2574
+ */
2575
+ export function computeConnectorRoute(
2576
+ connector,
2577
+ lookup,
2578
+ canvas = DEFAULT_CANVAS,
2579
+ occupiedRoutes = [],
2580
+ options = {},
2581
+ ) {
2582
+ const occupiedLabels = options.occupiedLabels || [];
2583
+ const detourBaseline = options.detourBaseline ?? null;
2584
+ const report = options.report || null;
2585
+ const onReason = options.onReason || null;
2586
+ const from = lookup.get(connector.from);
2587
+ const to = lookup.get(connector.to);
2588
+ if (!from || !to) throw new ArchitectureError("connector: references an unknown element");
2589
+ const laneOffset = connector.lane * CONNECTOR_LANE_SPACING;
2590
+ const fromPort = portPoint(
2591
+ from,
2592
+ connector.fromPort,
2593
+ elementCenter(to),
2594
+ connector.fromOffset ?? laneOffset,
2595
+ );
2596
+ const toPort = portPoint(
2597
+ to,
2598
+ connector.toPort,
2599
+ elementCenter(from),
2600
+ connector.toOffset ?? laneOffset,
2601
+ );
2602
+ const endpointDistances =
2603
+ connector.routing === "orthogonal"
2604
+ ? fitFacingDistances(
2605
+ fromPort.point,
2606
+ toPort.point,
2607
+ fromPort.direction,
2608
+ toPort.direction,
2609
+ CONNECTOR_ENDPOINT_GAP,
2610
+ CONNECTOR_ENDPOINT_GAP,
2611
+ MIN_ORTHOGONAL_ENDPOINT_SPAN,
2612
+ )
2613
+ : {
2614
+ fromDistance: CONNECTOR_ENDPOINT_GAP,
2615
+ toDistance: CONNECTOR_ENDPOINT_GAP,
2616
+ };
2617
+ const start = offsetPoint(
2618
+ fromPort.point,
2619
+ fromPort.direction,
2620
+ endpointDistances.fromDistance,
2621
+ );
2622
+ const end = offsetPoint(
2623
+ toPort.point,
2624
+ toPort.direction,
2625
+ endpointDistances.toDistance,
2626
+ );
2627
+ if (connector.routing === "straight") return [start, end];
2628
+
2629
+ const nodes = [...lookup.values()].filter(
2630
+ (element) => element.type === "node" || element.type === "image",
2631
+ );
2632
+ const excluded = new Set([connector.from, connector.to]);
2633
+ const endpointNodes = [from, to].filter(
2634
+ (element) => element.type === "node" || element.type === "image",
2635
+ );
2636
+ const clearance = 42 + Math.abs(laneOffset) * 0.3;
2637
+ const fromStubExcluded =
2638
+ connector.routing === "orthogonal"
2639
+ ? new Set([connector.from])
2640
+ : excluded;
2641
+ const toStubExcluded =
2642
+ connector.routing === "orthogonal"
2643
+ ? new Set([connector.to])
2644
+ : excluded;
2645
+ const availableFromStub = availableStubDistance(
2646
+ start,
2647
+ fromPort.direction,
2648
+ clearance,
2649
+ nodes,
2650
+ fromStubExcluded,
2651
+ );
2652
+ const availableToStub = availableStubDistance(
2653
+ end,
2654
+ toPort.direction,
2655
+ clearance,
2656
+ nodes,
2657
+ toStubExcluded,
2658
+ );
2659
+ const stubDistances =
2660
+ connector.routing === "orthogonal"
2661
+ ? fitFacingDistances(
2662
+ start,
2663
+ end,
2664
+ fromPort.direction,
2665
+ toPort.direction,
2666
+ availableFromStub,
2667
+ availableToStub,
2668
+ MIN_ORTHOGONAL_ENDPOINT_SPAN,
2669
+ )
2670
+ : {
2671
+ fromDistance: availableFromStub,
2672
+ toDistance: availableToStub,
2673
+ };
2674
+ const fromStub = offsetPoint(
2675
+ start,
2676
+ fromPort.direction,
2677
+ stubDistances.fromDistance,
2678
+ );
2679
+ const toStub = offsetPoint(
2680
+ end,
2681
+ toPort.direction,
2682
+ stubDistances.toDistance,
2683
+ );
2684
+ if (connector.routing === "polyline") {
2685
+ return compressPoints([
2686
+ start,
2687
+ fromStub,
2688
+ ...connector.points,
2689
+ toStub,
2690
+ end,
2691
+ ]);
2692
+ }
2693
+ const fromHorizontal = fromPort.direction.x !== 0;
2694
+ const toHorizontal = toPort.direction.x !== 0;
2695
+ let candidates;
2696
+
2697
+ if (fromHorizontal && toHorizontal) {
2698
+ const base = (fromStub.x + toStub.x) / 2 + laneOffset;
2699
+ const outsideLeft = Math.max(0, Math.min(fromStub.x, toStub.x) - 72 - Math.abs(laneOffset));
2700
+ const outsideRight = Math.min(
2701
+ canvas.width,
2702
+ Math.max(fromStub.x, toStub.x) + 72 + Math.abs(laneOffset),
2703
+ );
2704
+ candidates = [...new Set([
2705
+ base,
2706
+ fromStub.x,
2707
+ toStub.x,
2708
+ outsideLeft,
2709
+ outsideRight,
2710
+ ])].map((corridor) =>
2711
+ compressPoints([
2712
+ start,
2713
+ fromStub,
2714
+ { x: corridor, y: fromStub.y },
2715
+ { x: corridor, y: toStub.y },
2716
+ toStub,
2717
+ end,
2718
+ ]),
2719
+ );
2720
+ const top = Math.max(
2721
+ 0,
2722
+ Math.min(fromStub.y, toStub.y, ...nodes.map((node) => node.y)) -
2723
+ 54 -
2724
+ Math.abs(laneOffset),
2725
+ );
2726
+ const bottom = Math.min(
2727
+ canvas.height,
2728
+ Math.max(
2729
+ fromStub.y,
2730
+ toStub.y,
2731
+ ...nodes.map((node) => node.y + node.height),
2732
+ ) +
2733
+ 54 +
2734
+ Math.abs(laneOffset),
2735
+ );
2736
+ candidates.push(
2737
+ compressPoints([
2738
+ start,
2739
+ fromStub,
2740
+ { x: fromStub.x, y: top },
2741
+ { x: toStub.x, y: top },
2742
+ toStub,
2743
+ end,
2744
+ ]),
2745
+ compressPoints([
2746
+ start,
2747
+ fromStub,
2748
+ { x: fromStub.x, y: bottom },
2749
+ { x: toStub.x, y: bottom },
2750
+ toStub,
2751
+ end,
2752
+ ]),
2753
+ );
2754
+ } else if (!fromHorizontal && !toHorizontal) {
2755
+ const base = (fromStub.y + toStub.y) / 2 + laneOffset;
2756
+ const outsideTop = Math.max(0, Math.min(fromStub.y, toStub.y) - 72 - Math.abs(laneOffset));
2757
+ const outsideBottom = Math.min(
2758
+ canvas.height,
2759
+ Math.max(fromStub.y, toStub.y) + 72 + Math.abs(laneOffset),
2760
+ );
2761
+ candidates = [...new Set([
2762
+ base,
2763
+ fromStub.y,
2764
+ toStub.y,
2765
+ outsideTop,
2766
+ outsideBottom,
2767
+ ])].map((corridor) =>
2768
+ compressPoints([
2769
+ start,
2770
+ fromStub,
2771
+ { x: fromStub.x, y: corridor },
2772
+ { x: toStub.x, y: corridor },
2773
+ toStub,
2774
+ end,
2775
+ ]),
2776
+ );
2777
+ const left = Math.max(
2778
+ 0,
2779
+ Math.min(fromStub.x, toStub.x, ...nodes.map((node) => node.x)) -
2780
+ 54 -
2781
+ Math.abs(laneOffset),
2782
+ );
2783
+ const right = Math.min(
2784
+ canvas.width,
2785
+ Math.max(
2786
+ fromStub.x,
2787
+ toStub.x,
2788
+ ...nodes.map((node) => node.x + node.width),
2789
+ ) +
2790
+ 54 +
2791
+ Math.abs(laneOffset),
2792
+ );
2793
+ candidates.push(
2794
+ compressPoints([
2795
+ start,
2796
+ fromStub,
2797
+ { x: left, y: fromStub.y },
2798
+ { x: left, y: toStub.y },
2799
+ toStub,
2800
+ end,
2801
+ ]),
2802
+ compressPoints([
2803
+ start,
2804
+ fromStub,
2805
+ { x: right, y: fromStub.y },
2806
+ { x: right, y: toStub.y },
2807
+ toStub,
2808
+ end,
2809
+ ]),
2810
+ );
2811
+ } else {
2812
+ const horizontalBounds = nodes.flatMap((node) => [node.x, node.x + node.width]);
2813
+ const verticalBounds = nodes.flatMap((node) => [node.y, node.y + node.height]);
2814
+ const left = Math.max(
2815
+ 0,
2816
+ Math.min(fromStub.x, toStub.x, ...horizontalBounds) -
2817
+ 54 -
2818
+ Math.abs(laneOffset),
2819
+ );
2820
+ const right = Math.min(
2821
+ canvas.width,
2822
+ Math.max(fromStub.x, toStub.x, ...horizontalBounds) +
2823
+ 54 +
2824
+ Math.abs(laneOffset),
2825
+ );
2826
+ const top = Math.max(
2827
+ 0,
2828
+ Math.min(fromStub.y, toStub.y, ...verticalBounds) -
2829
+ 54 -
2830
+ Math.abs(laneOffset),
2831
+ );
2832
+ const bottom = Math.min(
2833
+ canvas.height,
2834
+ Math.max(fromStub.y, toStub.y, ...verticalBounds) +
2835
+ 54 +
2836
+ Math.abs(laneOffset),
2837
+ );
2838
+ const fromApproach = offsetPoint(fromStub, fromPort.direction, clearance);
2839
+ const toApproach = offsetPoint(toStub, toPort.direction, clearance);
2840
+ candidates = [
2841
+ compressPoints([
2842
+ start,
2843
+ fromStub,
2844
+ { x: toStub.x, y: fromStub.y },
2845
+ toStub,
2846
+ end,
2847
+ ]),
2848
+ compressPoints([
2849
+ start,
2850
+ fromStub,
2851
+ { x: fromStub.x, y: toStub.y },
2852
+ toStub,
2853
+ end,
2854
+ ]),
2855
+ ];
2856
+ const horizontalDetours = [
2857
+ compressPoints([start, { x: left, y: start.y }, { x: left, y: end.y }, end]),
2858
+ compressPoints([start, { x: right, y: start.y }, { x: right, y: end.y }, end]),
2859
+ ];
2860
+ const verticalDetours = [
2861
+ compressPoints([start, { x: start.x, y: top }, { x: end.x, y: top }, end]),
2862
+ compressPoints([start, { x: start.x, y: bottom }, { x: end.x, y: bottom }, end]),
2863
+ ];
2864
+ const endpointHorizontalDetours = [
2865
+ compressPoints([
2866
+ start,
2867
+ { x: left, y: start.y },
2868
+ { x: left, y: toStub.y },
2869
+ toStub,
2870
+ end,
2871
+ ]),
2872
+ compressPoints([
2873
+ start,
2874
+ { x: right, y: start.y },
2875
+ { x: right, y: toStub.y },
2876
+ toStub,
2877
+ end,
2878
+ ]),
2879
+ ];
2880
+ const endpointVerticalDetours = [
2881
+ compressPoints([
2882
+ start,
2883
+ { x: start.x, y: top },
2884
+ { x: toStub.x, y: top },
2885
+ toStub,
2886
+ end,
2887
+ ]),
2888
+ compressPoints([
2889
+ start,
2890
+ { x: start.x, y: bottom },
2891
+ { x: toStub.x, y: bottom },
2892
+ toStub,
2893
+ end,
2894
+ ]),
2895
+ ];
2896
+ candidates.push(
2897
+ ...(fromHorizontal ? endpointHorizontalDetours : endpointVerticalDetours),
2898
+ ...(fromHorizontal ? endpointVerticalDetours : endpointHorizontalDetours),
2899
+ ...(fromHorizontal ? horizontalDetours : verticalDetours),
2900
+ ...(fromHorizontal ? verticalDetours : horizontalDetours),
2901
+ );
2902
+ for (const horizontal of [left, right]) {
2903
+ for (const vertical of [top, bottom]) {
2904
+ candidates.push(
2905
+ compressPoints([
2906
+ start,
2907
+ { x: horizontal, y: start.y },
2908
+ { x: horizontal, y: vertical },
2909
+ { x: end.x, y: vertical },
2910
+ end,
2911
+ ]),
2912
+ compressPoints([
2913
+ start,
2914
+ { x: start.x, y: vertical },
2915
+ { x: horizontal, y: vertical },
2916
+ { x: horizontal, y: end.y },
2917
+ end,
2918
+ ]),
2919
+ );
2920
+ }
2921
+ }
2922
+ if (fromHorizontal) {
2923
+ candidates.push(
2924
+ compressPoints([
2925
+ start,
2926
+ fromStub,
2927
+ { x: left, y: fromStub.y },
2928
+ { x: left, y: toApproach.y },
2929
+ toApproach,
2930
+ toStub,
2931
+ end,
2932
+ ]),
2933
+ compressPoints([
2934
+ start,
2935
+ fromStub,
2936
+ { x: right, y: fromStub.y },
2937
+ { x: right, y: toApproach.y },
2938
+ toApproach,
2939
+ toStub,
2940
+ end,
2941
+ ]),
2942
+ compressPoints([
2943
+ start,
2944
+ fromStub,
2945
+ fromApproach,
2946
+ { x: fromApproach.x, y: top },
2947
+ { x: toStub.x, y: top },
2948
+ toStub,
2949
+ end,
2950
+ ]),
2951
+ compressPoints([
2952
+ start,
2953
+ fromStub,
2954
+ fromApproach,
2955
+ { x: fromApproach.x, y: bottom },
2956
+ { x: toStub.x, y: bottom },
2957
+ toStub,
2958
+ end,
2959
+ ]),
2960
+ );
2961
+ } else {
2962
+ candidates.push(
2963
+ compressPoints([
2964
+ start,
2965
+ fromStub,
2966
+ { x: fromStub.x, y: top },
2967
+ { x: toApproach.x, y: top },
2968
+ toApproach,
2969
+ toStub,
2970
+ end,
2971
+ ]),
2972
+ compressPoints([
2973
+ start,
2974
+ fromStub,
2975
+ { x: fromStub.x, y: bottom },
2976
+ { x: toApproach.x, y: bottom },
2977
+ toApproach,
2978
+ toStub,
2979
+ end,
2980
+ ]),
2981
+ compressPoints([
2982
+ start,
2983
+ fromStub,
2984
+ fromApproach,
2985
+ { x: left, y: fromApproach.y },
2986
+ { x: left, y: toStub.y },
2987
+ toStub,
2988
+ end,
2989
+ ]),
2990
+ compressPoints([
2991
+ start,
2992
+ fromStub,
2993
+ fromApproach,
2994
+ { x: right, y: fromApproach.y },
2995
+ { x: right, y: toStub.y },
2996
+ toStub,
2997
+ end,
2998
+ ]),
2999
+ );
3000
+ }
3001
+ }
3002
+ candidates = candidates
3003
+ .map((points) =>
3004
+ compressPoints([
3005
+ start,
3006
+ fromStub,
3007
+ ...points.slice(1, -1),
3008
+ toStub,
3009
+ end,
3010
+ ]),
3011
+ )
3012
+ .filter(routeIsOrthogonal);
3013
+ if (!candidates.length) {
3014
+ candidates = [
3015
+ compressPoints([
3016
+ start,
3017
+ fromStub,
3018
+ { x: toStub.x, y: fromStub.y },
3019
+ toStub,
3020
+ end,
3021
+ ]),
3022
+ compressPoints([
3023
+ start,
3024
+ fromStub,
3025
+ { x: fromStub.x, y: toStub.y },
3026
+ toStub,
3027
+ end,
3028
+ ]),
3029
+ ];
3030
+ }
3031
+ const routingContext = {
3032
+ nodes,
3033
+ excludedIds: excluded,
3034
+ occupiedRoutes,
3035
+ occupiedLabels,
3036
+ connector,
3037
+ canvas,
3038
+ };
3039
+ const endDirection = {
3040
+ x: -toPort.direction.x,
3041
+ y: -toPort.direction.y,
3042
+ };
3043
+ const hasForwardAutomaticGeometry = (points) =>
3044
+ !routeHasImmediateBacktrack(points) &&
3045
+ routeRespectsEndpointDirections(
3046
+ points,
3047
+ fromPort.direction,
3048
+ endDirection,
3049
+ );
3050
+ const hasValidAutomaticGeometry = (points) =>
3051
+ hasForwardAutomaticGeometry(points) &&
3052
+ !routeHitsEndpointNode(points, endpointNodes);
3053
+ let routePool = candidates;
3054
+ let maximumRouteLength = Infinity;
3055
+ if (occupiedRoutes.length || occupiedLabels.length) {
3056
+ const standalone =
3057
+ detourBaseline ??
3058
+ computeConnectorRoute(connector, lookup, canvas, [], {
3059
+ occupiedLabels: [],
3060
+ });
3061
+ maximumRouteLength = routeLength(standalone) * MAX_ROUTE_DETOUR_RATIO;
3062
+ routePool = [...candidates, standalone];
3063
+ candidates = filterPreferredRouteCandidates(
3064
+ routePool,
3065
+ connector,
3066
+ nodes,
3067
+ maximumRouteLength,
3068
+ canvas,
3069
+ );
3070
+ }
3071
+ const selected = chooseRoute(candidates, nodes, excluded, occupiedRoutes, {
3072
+ occupiedLabels,
3073
+ connector,
3074
+ canvas,
3075
+ });
3076
+ const selectedCost = routeCost(selected, routingContext);
3077
+ const selectedHitsNodes = routeHitsNodes(selected, nodes, excluded);
3078
+ const selectedInteraction = routeInteractionScore(selected, occupiedRoutes);
3079
+ const selectedHasInvalidGeometry = !hasValidAutomaticGeometry(selected);
3080
+ let chosen = selected;
3081
+ let gridReason = null;
3082
+ // Fallback stage 2: escalate to expensive grid search only for a dirty candidate.
3083
+ if (
3084
+ selectedHasInvalidGeometry ||
3085
+ selectedHitsNodes ||
3086
+ (occupiedRoutes.length && selectedInteraction > 0)
3087
+ ) {
3088
+ const grid = gridRoute(
3089
+ fromStub,
3090
+ toStub,
3091
+ nodes,
3092
+ excluded,
3093
+ canvas,
3094
+ occupiedRoutes,
3095
+ {
3096
+ startDirection: fromPort.direction,
3097
+ endDirection,
3098
+ strictStartDirection: stubDistances.fromDistance <= 0.001,
3099
+ strictEndDirection: stubDistances.toDistance <= 0.001,
3100
+ },
3101
+ );
3102
+ gridReason = grid.reason;
3103
+ const alternate = grid.points
3104
+ ? compressPoints([start, fromStub, ...grid.points, toStub, end])
3105
+ : null;
3106
+ const selectedHidden = countHiddenContent(connector, selected, nodes, canvas);
3107
+ const alternateHidden = alternate
3108
+ ? countHiddenContent(connector, alternate, nodes, canvas)
3109
+ : Infinity;
3110
+ const alternateIsUsable =
3111
+ alternate &&
3112
+ hasValidAutomaticGeometry(alternate) &&
3113
+ alternateHidden <= selectedHidden &&
3114
+ (alternateHidden < selectedHidden ||
3115
+ routeLength(alternate) <=
3116
+ maximumRouteLength + ROUTE_IMPROVEMENT_EPSILON);
3117
+ if (selectedHasInvalidGeometry) {
3118
+ const validCandidates = routePool.filter(hasValidAutomaticGeometry);
3119
+ if (alternate && hasValidAutomaticGeometry(alternate)) {
3120
+ validCandidates.push(alternate);
3121
+ }
3122
+ let preferredCandidates = filterPreferredRouteCandidates(
3123
+ validCandidates,
3124
+ connector,
3125
+ nodes,
3126
+ maximumRouteLength,
3127
+ canvas,
3128
+ );
3129
+ if (!preferredCandidates.length) {
3130
+ // If endpoint re-entry is unavoidable, return and diagnose a route that preserves direction without doubling back.
3131
+ const perimeterCandidates = [
3132
+ ...routePool,
3133
+ ...(alternate ? [alternate] : []),
3134
+ ...perimeterRouteCandidates(
3135
+ start,
3136
+ end,
3137
+ fromPort.direction,
3138
+ toPort.direction,
3139
+ canvas,
3140
+ ),
3141
+ ].filter(hasForwardAutomaticGeometry);
3142
+ preferredCandidates = filterPreferredRouteCandidates(
3143
+ perimeterCandidates,
3144
+ connector,
3145
+ nodes,
3146
+ maximumRouteLength,
3147
+ canvas,
3148
+ );
3149
+ }
3150
+ if (preferredCandidates.length) {
3151
+ chosen = chooseRoute(
3152
+ preferredCandidates,
3153
+ nodes,
3154
+ excluded,
3155
+ occupiedRoutes,
3156
+ { occupiedLabels, connector, canvas },
3157
+ );
3158
+ }
3159
+ } else if (
3160
+ alternateIsUsable &&
3161
+ routeCost(alternate, routingContext) < selectedCost
3162
+ ) {
3163
+ // Fallback stage 3: measure grid solutions on the same scale and adopt only improvements.
3164
+ chosen = alternate;
3165
+ }
3166
+ }
3167
+ // Fallback stage 4: report any remaining invalid shape rather than silently rendering it.
3168
+ // onReason tells the caller why grid search could not be used. planConnectorRoutes
3169
+ // gathers diagnostics after finalizing routes, so retain intermediate termination
3170
+ // reasons here instead of collapsing them to no-clean-candidate.
3171
+ if (!hasValidAutomaticGeometry(chosen)) {
3172
+ gridReason = ROUTE_FALLBACK_REASONS.invalidEndpointGeometry;
3173
+ }
3174
+ if (typeof onReason === "function") onReason(gridReason);
3175
+ reportRouteDegradation(report, connector, chosen, routingContext, gridReason);
3176
+ return chosen;
3177
+ }
3178
+
3179
+ /**
3180
+ * Add diagnostics only when rendered output hides content.
3181
+ *
3182
+ * Do not report crossings alone; nonplanar graphs cannot avoid them, and reporting
3183
+ * all crossings creates noise that obscures genuinely problematic diagrams.
3184
+ * Report only a route penetrating a node, a label covering a node, a label failing
3185
+ * to expose enough of its route and arrowhead, or contact/overlap placement that
3186
+ * cannot produce a route preserving endpoint direction.
3187
+ *
3188
+ * connectorLabelAnchor tries the normal, normals of other segments, and for a
3189
+ * single segment, tangents outside the route. If the canvas remains too small,
3190
+ * emit this diagnostic rather than accepting overlap silently. Exclude straight /
3191
+ * polyline because authors explicitly define those routes, following the existing
3192
+ * planConnectorRoutes policy of diagnosing only orthogonal routes.
3193
+ */
3194
+ function reportRouteDegradation(report, connector, points, context, gridReason) {
3195
+ if (typeof report !== "function") return;
3196
+ const { nodes, excludedIds, canvas = DEFAULT_CANVAS } = context;
3197
+ const pathHits = countNodeHits(points, nodes, excludedIds);
3198
+ const labelBox = connectorLabelBox(connector, points, canvas);
3199
+ const labelHits = labelBox
3200
+ ? nodes.filter(
3201
+ (node) => !excludedIds.has(node.id) && boxesOverlap(labelBox, node),
3202
+ ).length
3203
+ : 0;
3204
+ const requiredVisible = labelBox
3205
+ ? Math.min(MIN_VISIBLE_ROUTE_LENGTH, routeLength(points))
3206
+ : 0;
3207
+ const routeVisible = labelBox
3208
+ ? routeLengthOutsideBox(points, labelBox)
3209
+ : requiredVisible;
3210
+ const markerClear = labelBox
3211
+ ? terminalMarkerIsClear(connector, points, labelBox)
3212
+ : true;
3213
+ const labelPlacementInvalid =
3214
+ Boolean(labelBox) &&
3215
+ (routeVisible < requiredVisible - 0.001 || !markerClear);
3216
+ const invalidEndpointGeometry =
3217
+ gridReason === ROUTE_FALLBACK_REASONS.invalidEndpointGeometry;
3218
+ if (
3219
+ !pathHits &&
3220
+ !labelHits &&
3221
+ !invalidEndpointGeometry &&
3222
+ !labelPlacementInvalid
3223
+ ) {
3224
+ return;
3225
+ }
3226
+ report({
3227
+ from: connector.from,
3228
+ to: connector.to,
3229
+ sourcePath: connector.sourcePath,
3230
+ kind: pathHits
3231
+ ? "path-overlaps-node"
3232
+ : labelHits
3233
+ ? "label-overlaps-node"
3234
+ : invalidEndpointGeometry
3235
+ ? "invalid-endpoint-geometry"
3236
+ : "label-overlaps-route",
3237
+ reason:
3238
+ gridReason ||
3239
+ (labelPlacementInvalid
3240
+ ? ROUTE_FALLBACK_REASONS.labelPlacementImpossible
3241
+ : ROUTE_FALLBACK_REASONS.noCleanCandidate),
3242
+ pathOverlaps: pathHits,
3243
+ labelOverlaps: labelHits,
3244
+ routeVisible,
3245
+ requiredRouteVisible: requiredVisible,
3246
+ markerClear,
3247
+ });
3248
+ }
3249
+
3250
+ /**
3251
+ * Determine routes for the complete diagram.
3252
+ *
3253
+ * Pass 0 performs the existing sequential placement, followed by a fixed number
3254
+ * of rip-up-and-reroute passes. Sequential placement chooses the first route before
3255
+ * any others exist, which can leave globally poor choices. Rerouting evaluates
3256
+ * each connector against all other routes and replaces it only when routeCost
3257
+ * definitely decreases.
3258
+ *
3259
+ * Determinism: fixed connector order, no randomness, and strict inequality for adoption.
3260
+ * Termination: cost is monotonically nonincreasing, with limits on passes and reroutes.
3261
+ */
3262
+ function planConnectorRoutes(model, lookup) {
3263
+ const connectors = model.elements
3264
+ .filter((element) => element.type === "connector")
3265
+ .slice()
3266
+ .sort((left, right) => {
3267
+ const leftFrom = elementCenter(lookup.get(left.from));
3268
+ const leftTo = elementCenter(lookup.get(left.to));
3269
+ const rightFrom = elementCenter(lookup.get(right.from));
3270
+ const rightTo = elementCenter(lookup.get(right.to));
3271
+ const leftDistance =
3272
+ Math.abs(leftFrom.x - leftTo.x) + Math.abs(leftFrom.y - leftTo.y);
3273
+ const rightDistance =
3274
+ Math.abs(rightFrom.x - rightTo.x) + Math.abs(rightFrom.y - rightTo.y);
3275
+ return leftDistance - rightDistance || left.order - right.order;
3276
+ });
3277
+ const nodes = [...lookup.values()].filter(
3278
+ (element) => element.type === "node" || element.type === "image",
3279
+ );
3280
+ const standaloneRoutes = new Map(
3281
+ connectors
3282
+ .filter((connector) => connector.routing === "orthogonal")
3283
+ .map((connector) => [
3284
+ connector,
3285
+ computeConnectorRoute(connector, lookup, model.canvas),
3286
+ ]),
3287
+ );
3288
+ const routes = new Map();
3289
+ const occupied = [];
3290
+ const labels = new Map();
3291
+ // Reason grid search was abandoned for each route, used by final diagnostics.
3292
+ const reasons = new Map();
3293
+ const noteReason = (connector) => (reason) => {
3294
+ if (reason) reasons.set(connector, reason);
3295
+ else reasons.delete(connector);
3296
+ };
3297
+ for (const connector of connectors) {
3298
+ const route = computeConnectorRoute(connector, lookup, model.canvas, occupied, {
3299
+ occupiedLabels: [...labels.values()],
3300
+ detourBaseline: standaloneRoutes.get(connector),
3301
+ onReason: noteReason(connector),
3302
+ });
3303
+ routes.set(connector, route);
3304
+ occupied.push(route);
3305
+ const box = connectorLabelBox(connector, route, model.canvas);
3306
+ if (box) labels.set(connector, box);
3307
+ }
3308
+
3309
+ const othersOf = (target) =>
3310
+ connectors.filter((c) => c !== target).map((c) => routes.get(c));
3311
+ const otherLabelsOf = (target) =>
3312
+ connectors.filter((c) => c !== target).map((c) => labels.get(c)).filter(Boolean);
3313
+ const costOf = (connector, points, others, otherLabels) =>
3314
+ routeCost(points, {
3315
+ nodes,
3316
+ excludedIds: new Set([connector.from, connector.to]),
3317
+ occupiedRoutes: others,
3318
+ occupiedLabels: otherLabels,
3319
+ connector,
3320
+ canvas: model.canvas,
3321
+ });
3322
+
3323
+ let reroutes = 0;
3324
+ for (let pass = 0; pass < MAX_ROUTE_REFINEMENT_PASSES; pass += 1) {
3325
+ let improved = false;
3326
+ for (const connector of connectors) {
3327
+ // straight / polyline are author-defined shapes; do not reroute them automatically.
3328
+ if (connector.routing !== "orthogonal") continue;
3329
+ if (reroutes >= MAX_ROUTE_REFINEMENT_REROUTES) break;
3330
+ reroutes += 1;
3331
+ const others = othersOf(connector);
3332
+ const otherLabels = otherLabelsOf(connector);
3333
+ const current = routes.get(connector);
3334
+ const currentCost = costOf(connector, current, others, otherLabels);
3335
+ const currentCrossings = countRouteCrossings(current, others);
3336
+ const currentHidden = countHiddenContent(
3337
+ connector,
3338
+ current,
3339
+ nodes,
3340
+ model.canvas,
3341
+ );
3342
+ let candidateReason = null;
3343
+ const candidate = computeConnectorRoute(
3344
+ connector,
3345
+ lookup,
3346
+ model.canvas,
3347
+ others,
3348
+ {
3349
+ occupiedLabels: otherLabels,
3350
+ detourBaseline: standaloneRoutes.get(connector),
3351
+ onReason: (reason) => {
3352
+ candidateReason = reason;
3353
+ },
3354
+ },
3355
+ );
3356
+ if (routeHasImmediateBacktrack(candidate)) continue;
3357
+ if (costOf(connector, candidate, others, otherLabels) >= currentCost - ROUTE_IMPROVEMENT_EPSILON) {
3358
+ continue;
3359
+ }
3360
+ // Normally reject lower-cost routes that add crossings. This guard prohibits
3361
+ // trading extra crossings for label readability. Because only one connector
3362
+ // changes, this delta equals the change in total graph crossings.
3363
+ //
3364
+ // Allow only the exception that reveals a hidden node; one extra crossing is
3365
+ // preferable to an unreadable diagram.
3366
+ if (countRouteCrossings(candidate, others) > currentCrossings) {
3367
+ if (
3368
+ countHiddenContent(connector, candidate, nodes, model.canvas) >=
3369
+ currentHidden
3370
+ ) {
3371
+ continue;
3372
+ }
3373
+ }
3374
+ routes.set(connector, candidate);
3375
+ // Replace the reason only when replacing the route; do not retain rejected-candidate reasons.
3376
+ if (candidateReason) reasons.set(connector, candidateReason);
3377
+ else reasons.delete(connector);
3378
+ const box = connectorLabelBox(connector, candidate, model.canvas);
3379
+ if (box) labels.set(connector, box);
3380
+ else labels.delete(connector);
3381
+ improved = true;
3382
+ }
3383
+ if (!improved) break;
3384
+ }
3385
+
3386
+ // Gather diagnostics once after finalizing the shape; reporting intermediate
3387
+ // states would include degradation resolved by later rerouting.
3388
+ const diagnostics = [];
3389
+ for (const connector of connectors) {
3390
+ if (connector.routing !== "orthogonal") continue;
3391
+ reportRouteDegradation(
3392
+ (entry) => diagnostics.push(entry),
3393
+ connector,
3394
+ routes.get(connector),
3395
+ {
3396
+ nodes,
3397
+ excludedIds: new Set([connector.from, connector.to]),
3398
+ occupiedRoutes: othersOf(connector),
3399
+ occupiedLabels: otherLabelsOf(connector),
3400
+ connector,
3401
+ canvas: model.canvas,
3402
+ },
3403
+ reasons.get(connector) || null,
3404
+ );
3405
+ }
3406
+ diagnostics.sort(
3407
+ (left, right) =>
3408
+ left.sourcePath.localeCompare(right.sourcePath) ||
3409
+ left.from.localeCompare(right.from) ||
3410
+ left.to.localeCompare(right.to),
3411
+ );
3412
+ return { routes, diagnostics };
3413
+ }
3414
+
3415
+ /**
3416
+ * Route midpoint and unit direction vector of its segment.
3417
+ * Direction determines how {@link labelEscapeNormal} moves a label away from the line.
3418
+ */
3419
+ function pointAtHalfLength(points) {
3420
+ const lengths = [];
3421
+ let total = 0;
3422
+ for (let index = 1; index < points.length; index++) {
3423
+ const length = Math.hypot(
3424
+ points[index].x - points[index - 1].x,
3425
+ points[index].y - points[index - 1].y,
3426
+ );
3427
+ lengths.push(length);
3428
+ total += length;
3429
+ }
3430
+ let remaining = total / 2;
3431
+ for (let index = 0; index < lengths.length; index++) {
3432
+ if (remaining <= lengths[index] || index === lengths.length - 1) {
3433
+ const span = lengths[index];
3434
+ const ratio = span ? remaining / span : 0;
3435
+ const deltaX = points[index + 1].x - points[index].x;
3436
+ const deltaY = points[index + 1].y - points[index].y;
3437
+ return {
3438
+ x: points[index].x + deltaX * ratio,
3439
+ y: points[index].y + deltaY * ratio,
3440
+ direction: span ? { x: deltaX / span, y: deltaY / span } : { x: 1, y: 0 },
3441
+ };
3442
+ }
3443
+ remaining -= lengths[index];
3444
+ }
3445
+ return { x: points[0].x, y: points[0].y, direction: { x: 1, y: 0 } };
3446
+ }
3447
+
3448
+ function iconShapeAttributes(shape, textColor) {
3449
+ // Apply textColor as fill only to solid icons. Preserve attribute insertion order
3450
+ // as table order + fill so existing icon output remains byte-for-byte identical.
3451
+ return shape.solid ? { ...shape.attributes, fill: textColor } : shape.attributes;
3452
+ }
3453
+
3454
+ function renderIcon(documentRef, element) {
3455
+ if (!element.icon) return null;
3456
+ const size = Math.min(58, element.height * 0.36, element.width * 0.2);
3457
+ const x = element.text
3458
+ ? element.x + Math.max(20, element.width * 0.08)
3459
+ : element.x + element.width / 2 - size / 2;
3460
+ const y = element.y + element.height / 2 - size / 2;
3461
+ const shapes = ICON_SHAPES[element.icon];
3462
+ if (!shapes) {
3463
+ // User-provided icon. Theme colors cannot modify <image> content, so omit
3464
+ // ineffective stroke / fill attributes and retain only meaningful attributes.
3465
+ const group = svgElement(documentRef, "g", {
3466
+ "data-architecture-icon": element.icon,
3467
+ "data-architecture-icon-source": "asset",
3468
+ transform: `translate(${x} ${y}) scale(${size / 24})`,
3469
+ "aria-hidden": "true",
3470
+ "pointer-events": "none",
3471
+ });
3472
+ group.appendChild(
3473
+ svgElement(documentRef, "image", {
3474
+ x: 0,
3475
+ y: 0,
3476
+ width: 24,
3477
+ height: 24,
3478
+ href: localAssetUrl(documentRef, element.icon),
3479
+ preserveAspectRatio: "xMidYMid meet",
3480
+ }),
3481
+ );
3482
+ return { group, size, x };
3483
+ }
3484
+ const group = svgElement(documentRef, "g", {
3485
+ "data-architecture-icon": element.icon,
3486
+ "data-architecture-icon-source": "builtin",
3487
+ transform: `translate(${x} ${y}) scale(${size / 24})`,
3488
+ fill: "none",
3489
+ stroke: element.style.textColor,
3490
+ "stroke-width": 1.8,
3491
+ "stroke-linecap": "round",
3492
+ "stroke-linejoin": "round",
3493
+ "aria-hidden": "true",
3494
+ "pointer-events": "none",
3495
+ });
3496
+ for (const shape of shapes) {
3497
+ group.appendChild(
3498
+ svgElement(documentRef, shape.tag, iconShapeAttributes(shape, element.style.textColor)),
3499
+ );
3500
+ }
3501
+ return { group, size, x };
3502
+ }
3503
+
3504
+ function renderGroup(documentRef, element) {
3505
+ const label = element.ariaLabel || element.title || `Group ${element.id}`;
3506
+ const group = svgElement(documentRef, "g", {
3507
+ "data-architecture-id": element.id,
3508
+ "data-architecture-type": "group",
3509
+ // Declaration order in the DSL. DOM order is rendering/z order and may differ.
3510
+ // Code requiring logical order independent of rendering must use this attribute.
3511
+ "data-architecture-order": element.order,
3512
+ opacity: element.style.opacity,
3513
+ role: "group",
3514
+ "aria-label": label,
3515
+ });
3516
+ appendSvgTitle(documentRef, group, label);
3517
+ group.appendChild(
3518
+ svgElement(documentRef, "rect", {
3519
+ x: element.x,
3520
+ y: element.y,
3521
+ width: element.width,
3522
+ height: element.height,
3523
+ rx: element.style.cornerRadius,
3524
+ fill: element.style.fill,
3525
+ stroke: element.style.stroke,
3526
+ "stroke-width": element.style.strokeWidth,
3527
+ "stroke-dasharray": element.style.dash,
3528
+ }),
3529
+ );
3530
+ if (element.title) {
3531
+ const title = svgElement(documentRef, "text", {
3532
+ x: element.x + 24,
3533
+ y: element.y + element.style.fontSize * 1.35,
3534
+ fill: element.style.textColor,
3535
+ "font-size": element.style.fontSize,
3536
+ "font-weight": 700,
3537
+ "pointer-events": "none",
3538
+ // Visual only; aria-label / <title> above provide the accessible name.
3539
+ "aria-hidden": "true",
3540
+ });
3541
+ title.textContent = element.title;
3542
+ group.appendChild(title);
3543
+ }
3544
+ return group;
3545
+ }
3546
+
3547
+ function renderNode(documentRef, element) {
3548
+ // Include meaningful built-in icon names in accessible names. User-provided
3549
+ // icons are only file paths and convey nothing when announced, so omit them;
3550
+ // authors needing meaning should provide text / ariaLabel.
3551
+ const label =
3552
+ element.ariaLabel ||
3553
+ [ICONS.has(element.icon) ? `${element.icon} icon` : "", element.text || element.id]
3554
+ .filter(Boolean)
3555
+ .join(", ");
3556
+ const group = svgElement(documentRef, "g", {
3557
+ "data-architecture-id": element.id,
3558
+ "data-architecture-type": "node",
3559
+ "data-architecture-order": element.order,
3560
+ opacity: element.style.opacity,
3561
+ role: "img",
3562
+ "aria-label": label,
3563
+ });
3564
+ appendSvgTitle(documentRef, group, label);
3565
+ const common = {
3566
+ fill: element.style.fill,
3567
+ stroke: element.style.stroke,
3568
+ "stroke-width": element.style.strokeWidth,
3569
+ "stroke-dasharray": element.style.dash,
3570
+ };
3571
+ if (element.shape === "ellipse") {
3572
+ group.appendChild(
3573
+ svgElement(documentRef, "ellipse", {
3574
+ cx: element.x + element.width / 2,
3575
+ cy: element.y + element.height / 2,
3576
+ rx: element.width / 2,
3577
+ ry: element.height / 2,
3578
+ ...common,
3579
+ }),
3580
+ );
3581
+ } else {
3582
+ group.appendChild(
3583
+ svgElement(documentRef, "rect", {
3584
+ x: element.x,
3585
+ y: element.y,
3586
+ width: element.width,
3587
+ height: element.height,
3588
+ rx: element.shape === "rounded-rect" ? element.style.cornerRadius : 0,
3589
+ ...common,
3590
+ }),
3591
+ );
3592
+ }
3593
+ const icon = renderIcon(documentRef, element);
3594
+ if (icon) group.appendChild(icon.group);
3595
+ if (icon && element.text) {
3596
+ const textLeft = icon.x + icon.size + 16;
3597
+ const textRight = element.x + element.width - 16;
3598
+ appendText(documentRef, group, element, element.text, {
3599
+ centerX: (textLeft + textRight) / 2,
3600
+ availableWidth: Math.max(32, textRight - textLeft),
3601
+ });
3602
+ } else {
3603
+ appendText(documentRef, group, element, element.text);
3604
+ }
3605
+ return group;
3606
+ }
3607
+
3608
+ function imageAccessibleName(element) {
3609
+ const filename = element.src.split("/").at(-1);
3610
+ return element.ariaLabel || filename || element.id;
3611
+ }
3612
+
3613
+ function renderImage(documentRef, element, clipId) {
3614
+ const label = imageAccessibleName(element);
3615
+ const preserveAspectRatio = {
3616
+ contain: "xMidYMid meet",
3617
+ cover: "xMidYMid slice",
3618
+ stretch: "none",
3619
+ }[element.fit];
3620
+ const group = svgElement(documentRef, "g", {
3621
+ "data-architecture-id": element.id,
3622
+ "data-architecture-type": "image",
3623
+ "data-architecture-order": element.order,
3624
+ "data-architecture-src": element.src,
3625
+ opacity: element.style.opacity,
3626
+ role: "img",
3627
+ "aria-label": label,
3628
+ });
3629
+ appendSvgTitle(documentRef, group, label);
3630
+ group.appendChild(
3631
+ svgElement(documentRef, "rect", {
3632
+ x: element.x,
3633
+ y: element.y,
3634
+ width: element.width,
3635
+ height: element.height,
3636
+ rx: element.style.cornerRadius,
3637
+ fill: element.style.fill,
3638
+ stroke: "none",
3639
+ }),
3640
+ );
3641
+ group.appendChild(
3642
+ svgElement(documentRef, "image", {
3643
+ x: element.x,
3644
+ y: element.y,
3645
+ width: element.width,
3646
+ height: element.height,
3647
+ href: localAssetUrl(documentRef, element.src),
3648
+ preserveAspectRatio,
3649
+ "clip-path": `url(#${clipId})`,
3650
+ "aria-hidden": "true",
3651
+ }),
3652
+ );
3653
+ group.appendChild(
3654
+ svgElement(documentRef, "rect", {
3655
+ x: element.x,
3656
+ y: element.y,
3657
+ width: element.width,
3658
+ height: element.height,
3659
+ rx: element.style.cornerRadius,
3660
+ fill: "none",
3661
+ stroke: element.style.stroke,
3662
+ "stroke-width": element.style.strokeWidth,
3663
+ "stroke-dasharray": element.style.dash,
3664
+ "pointer-events": "none",
3665
+ "aria-hidden": "true",
3666
+ }),
3667
+ );
3668
+ return group;
3669
+ }
3670
+
3671
+ /**
3672
+ * Build a table resolving connector endpoints to names used by assistive technology.
3673
+ *
3674
+ * Endpoints use IDs, which are often machine-oriented values such as `svc-a1` or
3675
+ * `db-primary`. Announcing them directly does not match visible node `text` or
3676
+ * group `title`, making it difficult to identify which line is being described.
3677
+ *
3678
+ * Therefore **use a visible label when available and otherwise fall back to the ID**.
3679
+ * Keep IDs in `data-architecture-connector` because editing mode and existing
3680
+ * tests depend on it; change only the accessible name and `<title>`.
3681
+ *
3682
+ * @param {Array<object>} elements Flattened element list.
3683
+ * @returns {Map<string, string>} ID-to-visible-label map, excluding IDs without visible labels.
3684
+ */
3685
+ function endpointDisplayNames(elements) {
3686
+ const names = new Map();
3687
+ for (const element of elements) {
3688
+ if (!element || element.type === "connector" || typeof element.id !== "string") continue;
3689
+ const visible =
3690
+ element.type === "group"
3691
+ ? element.title
3692
+ : element.type === "image"
3693
+ ? imageAccessibleName(element)
3694
+ : element.text;
3695
+ if (typeof visible !== "string") continue;
3696
+ const trimmed = visible.trim();
3697
+ if (trimmed) names.set(element.id, trimmed);
3698
+ }
3699
+ return names;
3700
+ }
3701
+
3702
+ /**
3703
+ * Default accessible name for a connector, used only when `ariaLabel` is absent.
3704
+ *
3705
+ * @param {object} element Connector element.
3706
+ * @param {Map<string, string>} [endpointNames] Result of {@link endpointDisplayNames}.
3707
+ * @returns {string} Text in the form `<from> to <to>[: <label>]`.
3708
+ */
3709
+ function describeConnector(element, endpointNames) {
3710
+ const from = endpointNames?.get(element.from) || element.from;
3711
+ const to = endpointNames?.get(element.to) || element.to;
3712
+ return `${from} to ${to}${element.label ? `: ${element.label}` : ""}`;
3713
+ }
3714
+
3715
+ function renderConnector(
3716
+ documentRef,
3717
+ element,
3718
+ points,
3719
+ markerId,
3720
+ endpointNames,
3721
+ canvas,
3722
+ ) {
3723
+ const label = element.ariaLabel || describeConnector(element, endpointNames);
3724
+ const group = svgElement(documentRef, "g", {
3725
+ opacity: element.style.opacity,
3726
+ "data-architecture-connector": `${element.from}-${element.to}`,
3727
+ "data-architecture-type": "connector",
3728
+ "data-architecture-order": element.order,
3729
+ "data-architecture-label-layer": element.labelLayer,
3730
+ role: "group",
3731
+ "aria-label": label,
3732
+ });
3733
+ appendSvgTitle(documentRef, group, label);
3734
+ group.appendChild(
3735
+ svgElement(documentRef, "path", {
3736
+ d: points.map((point, index) => `${index ? "L" : "M"} ${point.x} ${point.y}`).join(" "),
3737
+ fill: "none",
3738
+ stroke: element.style.stroke,
3739
+ "stroke-width": element.style.strokeWidth,
3740
+ "stroke-linejoin": "round",
3741
+ "stroke-linecap": "round",
3742
+ "stroke-dasharray": element.style.dash,
3743
+ "marker-end": element.arrow ? `url(#${markerId})` : "",
3744
+ }),
3745
+ );
3746
+ let frontLabel = null;
3747
+ if (element.label) {
3748
+ const fittedLabel = connectorLabelMetrics(element, canvas);
3749
+ const position = connectorLabelAnchor(element, points, fittedLabel, canvas);
3750
+ const { width, height } = fittedLabel;
3751
+ const labelGroup = svgElement(documentRef, "g", {
3752
+ "data-architecture-connector-label": `${element.from}-${element.to}`,
3753
+ "data-architecture-label-layer": element.labelLayer,
3754
+ "pointer-events": "none",
3755
+ "aria-hidden": "true",
3756
+ ...(element.labelLayer === "front" ? { opacity: element.style.opacity } : {}),
3757
+ });
3758
+ labelGroup.appendChild(
3759
+ svgElement(documentRef, "rect", {
3760
+ x: position.x - width / 2,
3761
+ y: position.y - height / 2,
3762
+ width,
3763
+ height,
3764
+ rx: height / 2,
3765
+ fill: "var(--surface)",
3766
+ stroke: "var(--border)",
3767
+ "stroke-width": CONNECTOR_LABEL_STROKE_WIDTH,
3768
+ }),
3769
+ );
3770
+ const text = svgElement(documentRef, "text", {
3771
+ x: position.x,
3772
+ y: position.y,
3773
+ fill: element.style.textColor,
3774
+ "font-size": fittedLabel.fontSize,
3775
+ "font-weight": 600,
3776
+ "text-anchor": "middle",
3777
+ "dominant-baseline": "middle",
3778
+ // Visual only. Width may truncate it, so the parent's aria-label is authoritative.
3779
+ "aria-hidden": "true",
3780
+ });
3781
+ text.textContent = fittedLabel.text;
3782
+ labelGroup.appendChild(text);
3783
+ if (element.labelLayer === "front") frontLabel = labelGroup;
3784
+ else group.appendChild(labelGroup);
3785
+ }
3786
+ return { group, frontLabel };
3787
+ }
3788
+
3789
+ export function architectureSemanticSnapshot(model) {
3790
+ const lookup = new Map(
3791
+ model.elements
3792
+ .filter((element) => element.type !== "connector")
3793
+ .map((element) => [element.id, element]),
3794
+ );
3795
+ const { routes: connectorRoutes, diagnostics } = planConnectorRoutes(model, lookup);
3796
+ return {
3797
+ version: model.version,
3798
+ canvas: model.canvas,
3799
+ routing: {
3800
+ degraded: diagnostics.length > 0,
3801
+ diagnostics,
3802
+ },
3803
+ elements: model.elements.map((element) => {
3804
+ if (element.type === "connector") {
3805
+ return {
3806
+ type: "connector",
3807
+ from: element.from,
3808
+ to: element.to,
3809
+ fromPort: element.fromPort,
3810
+ toPort: element.toPort,
3811
+ lane: element.lane,
3812
+ points: connectorRoutes.get(element),
3813
+ };
3814
+ }
3815
+ return {
3816
+ type: element.type,
3817
+ id: element.id,
3818
+ x: element.x,
3819
+ y: element.y,
3820
+ width: element.width,
3821
+ height: element.height,
3822
+ ...(element.type === "node" ? { icon: element.icon || undefined } : {}),
3823
+ ...(element.type === "image" ? { src: element.src, fit: element.fit } : {}),
3824
+ };
3825
+ }),
3826
+ };
3827
+ }
3828
+
3829
+ export function renderArchitectureDiagram(
3830
+ model,
3831
+ documentRef = globalThis.document,
3832
+ ) {
3833
+ if (!documentRef?.createElementNS || !documentRef?.createElement) {
3834
+ throw new ArchitectureError("diagram: a DOM document is required for SVG rendering");
3835
+ }
3836
+ const wrapper = documentRef.createElement("div");
3837
+ wrapper.className = "architecture-diagram";
3838
+ const renderId = ++renderSequence;
3839
+ const titleId = `architecture-title-${renderId}`;
3840
+ const descriptionId = `architecture-description-${renderId}`;
3841
+ const svg = svgElement(documentRef, "svg", {
3842
+ class: "architecture-svg",
3843
+ viewBox: `0 0 ${model.canvas.width} ${model.canvas.height}`,
3844
+ preserveAspectRatio: "xMidYMid meet",
3845
+ role: "group",
3846
+ "aria-labelledby": `${titleId} ${descriptionId}`,
3847
+ // Normal and presenter views have no interactive controls inside the diagram,
3848
+ // so use **one tab stop for the complete diagram** rather than one per element.
3849
+ // Keyboard users can reach it and hear <title> and <desc>, the diagram summary.
3850
+ // Editing mode (architecture-editor.mjs) handles element-level traversal.
3851
+ tabindex: "0",
3852
+ });
3853
+ const title = svgElement(documentRef, "title", { id: titleId });
3854
+ title.textContent = model.title;
3855
+ svg.appendChild(title);
3856
+ const description = svgElement(documentRef, "desc", { id: descriptionId });
3857
+ description.textContent = model.description;
3858
+ svg.appendChild(description);
3859
+
3860
+ const defs = svgElement(documentRef, "defs");
3861
+ const lookup = new Map(
3862
+ model.elements
3863
+ .filter((element) => element.type !== "connector")
3864
+ .map((element) => [element.id, element]),
3865
+ );
3866
+ const { routes: connectorRoutes, diagnostics: routingDiagnostics } =
3867
+ planConnectorRoutes(model, lookup);
3868
+ model.elements.forEach((element, index) => {
3869
+ if (element.type === "image") {
3870
+ const clipPath = svgElement(documentRef, "clipPath", {
3871
+ id: `architecture-image-clip-${renderId}-${index}`,
3872
+ });
3873
+ clipPath.appendChild(
3874
+ svgElement(documentRef, "rect", {
3875
+ x: element.x,
3876
+ y: element.y,
3877
+ width: element.width,
3878
+ height: element.height,
3879
+ rx: element.style.cornerRadius,
3880
+ }),
3881
+ );
3882
+ defs.appendChild(clipPath);
3883
+ }
3884
+ if (element.type !== "connector" || !element.arrow) return;
3885
+ const markerId = `architecture-arrow-${renderId}-${index}`;
3886
+ const marker = svgElement(documentRef, "marker", {
3887
+ id: markerId,
3888
+ viewBox: `0 0 ${CONNECTOR_MARKER_VIEWBOX_SIZE} ${CONNECTOR_MARKER_VIEWBOX_SIZE}`,
3889
+ refX: CONNECTOR_MARKER_REF_X,
3890
+ refY: CONNECTOR_MARKER_REF_Y,
3891
+ markerWidth: CONNECTOR_MARKER_SIZE,
3892
+ markerHeight: CONNECTOR_MARKER_SIZE,
3893
+ orient: "auto-start-reverse",
3894
+ markerUnits: "strokeWidth",
3895
+ });
3896
+ marker.appendChild(
3897
+ svgElement(documentRef, "path", {
3898
+ d: "M 0 0 L 10 5 L 0 10 z",
3899
+ fill: element.style.stroke,
3900
+ }),
3901
+ );
3902
+ defs.appendChild(marker);
3903
+ });
3904
+ svg.appendChild(defs);
3905
+
3906
+ // Table for announcing connector endpoints by visible label; traverse elements only once.
3907
+ const endpointNames = endpointDisplayNames(model.elements);
3908
+ const frontLabels = [];
3909
+
3910
+ model.elements.forEach((element, index) => {
3911
+ if (element.type === "group") {
3912
+ svg.appendChild(renderGroup(documentRef, element));
3913
+ } else if (element.type === "node") {
3914
+ svg.appendChild(renderNode(documentRef, element));
3915
+ } else if (element.type === "image") {
3916
+ svg.appendChild(
3917
+ renderImage(documentRef, element, `architecture-image-clip-${renderId}-${index}`),
3918
+ );
3919
+ } else {
3920
+ const rendered = renderConnector(
3921
+ documentRef,
3922
+ element,
3923
+ connectorRoutes.get(element),
3924
+ `architecture-arrow-${renderId}-${index}`,
3925
+ endpointNames,
3926
+ model.canvas,
3927
+ );
3928
+ svg.appendChild(rendered.group);
3929
+ if (rendered.frontLabel) frontLabels.push(rendered.frontLabel);
3930
+ }
3931
+ });
3932
+ frontLabels.forEach((label) => svg.appendChild(label));
3933
+ wrapper.appendChild(svg);
3934
+ appendRoutingWarning(documentRef, wrapper, routingDiagnostics);
3935
+ return wrapper;
3936
+ }
3937
+
3938
+ /**
3939
+ * Notify the author of degraded routing.
3940
+ *
3941
+ * Do not throw ArchitectureError; that would be a breaking change turning previously
3942
+ * renderable diagrams into error blocks. Report through three channels instead:
3943
+ *
3944
+ * 1. A strip below the diagram (role="status" / aria-live="polite").
3945
+ * Use "status", not "alert", because this is quality degradation rather than
3946
+ * an error and should not interrupt screen-reader output during a presentation.
3947
+ * 2. The wrapper's data-architecture-routing attribute for tests and automation.
3948
+ * 3. console.warn for authoring visibility. console.error would make visual
3949
+ * regression tests fail by treating it as a page JavaScript error.
3950
+ */
3951
+ function appendRoutingWarning(documentRef, wrapper, diagnostics) {
3952
+ if (!diagnostics?.length) return;
3953
+ wrapper.setAttribute("data-architecture-routing", "degraded");
3954
+ const summary = diagnostics
3955
+ .map(
3956
+ (entry) =>
3957
+ `${entry.sourcePath}: ${entry.from} -> ${entry.to} (${entry.kind}, ${entry.reason})`,
3958
+ )
3959
+ .join("; ");
3960
+ globalThis.console?.warn?.(
3961
+ `architecture: ${diagnostics.length} connector route(s) could not be rendered cleanly; ${summary}`,
3962
+ );
3963
+ const banner = documentRef.createElement("div");
3964
+ banner.className = "architecture-routing-warning";
3965
+ banner.setAttribute("role", "status");
3966
+ banner.setAttribute("aria-live", "polite");
3967
+ const heading = documentRef.createElement("strong");
3968
+ heading.textContent = `Connector routing degraded (${diagnostics.length})`;
3969
+ banner.appendChild(heading);
3970
+ const detail = documentRef.createElement("span");
3971
+ detail.textContent = `${summary}. Add "routing": "polyline" with explicit points, or move the elements apart.`;
3972
+ banner.appendChild(detail);
3973
+ wrapper.appendChild(banner);
3974
+ }
3975
+
3976
+ export function renderArchitectureBlock(
3977
+ source,
3978
+ documentRef = globalThis.document,
3979
+ ) {
3980
+ try {
3981
+ return renderArchitectureDiagram(parseArchitecture(source), documentRef);
3982
+ } catch (error) {
3983
+ const wrapper = documentRef.createElement("div");
3984
+ wrapper.className = "architecture-error";
3985
+ wrapper.setAttribute("role", "alert");
3986
+ const heading = documentRef.createElement("strong");
3987
+ heading.textContent = "Architecture diagram error";
3988
+ const detail = documentRef.createElement("span");
3989
+ detail.textContent = error?.message || "The diagram could not be rendered.";
3990
+ wrapper.appendChild(heading);
3991
+ wrapper.appendChild(detail);
3992
+ return wrapper;
3993
+ }
3994
+ }
3995
+
3996
+ export {
3997
+ ArchitectureError,
3998
+ CONNECTOR_LABEL_CLEARANCE,
3999
+ DSL_VERSION,
4000
+ ICONS,
4001
+ ICON_ASSET_PATTERN,
4002
+ ASSET_EXTENSIONS,
4003
+ ASSET_PATH_PATTERN,
4004
+ ID_PATTERN,
4005
+ IMAGE_FITS,
4006
+ LABEL_LAYERS,
4007
+ LAYOUT_DIRECTIONS,
4008
+ LAYOUTS,
4009
+ LITERAL_COLORS,
4010
+ MAX_CONNECTORS,
4011
+ MAX_DEPTH,
4012
+ MAX_ELEMENTS,
4013
+ MAX_ICON_REFERENCE,
4014
+ MAX_POINTS,
4015
+ MAX_ROUTE_REFINEMENT_PASSES,
4016
+ MAX_ROUTING_GRID_COORDINATES,
4017
+ MAX_ROUTING_GRID_POINTS,
4018
+ MAX_ROUTING_GRID_VISITS,
4019
+ MAX_SOURCE_LENGTH,
4020
+ MAX_TOTAL_TEXT,
4021
+ MIN_CONNECTOR_LABEL_WIDTH,
4022
+ MIN_VISIBLE_ROUTE_LENGTH,
4023
+ PORTS,
4024
+ ROUTE_FALLBACK_REASONS,
4025
+ ROUTINGS,
4026
+ SHAPES,
4027
+ THEME_TOKENS,
4028
+ connectorLabelAnchor,
4029
+ connectorLabelBox,
4030
+ connectorLabelMetrics,
4031
+ pointAtHalfLength,
4032
+ routeLengthOutsideBox,
4033
+ };