@slxu/graphsx 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,987 @@
1
+ import { renderPlot } from "./plot-renderer.js";
2
+
3
+ const SVG_NS = "http://www.w3.org/2000/svg";
4
+
5
+ export function renderGraph(svg, graph, options = {}) {
6
+ const nodes = flattenNodes(graph.nodes);
7
+ const edges = flattenEdges(graph);
8
+ const paths = flattenPaths(graph);
9
+ const legs = indexLegs(nodes);
10
+ const bounds = getBounds(nodes, edges, legs, paths);
11
+ const viewportPadding = Number(options.viewportPadding ?? 80);
12
+ const width = Math.max(options.minWidth ?? 720, bounds.maxX - bounds.minX + viewportPadding * 2);
13
+ const height = Math.max(options.minHeight ?? 520, bounds.maxY - bounds.minY + viewportPadding * 2);
14
+ const offsetX = viewportPadding - bounds.minX;
15
+ const offsetY = viewportPadding - bounds.minY;
16
+ const context = {
17
+ document: options.document ?? svg.ownerDocument ?? document,
18
+ katex: options.katex ?? null,
19
+ graph,
20
+ nodes,
21
+ routing: routingDefaults(graph.attrs),
22
+ arrowMarkers: collectArrowMarkerKeys(edges, paths)
23
+ };
24
+
25
+ svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
26
+ svg.replaceChildren();
27
+ svg.append(defs(context));
28
+
29
+ const edgeLayer = el(context, "g");
30
+ const pathLayer = el(context, "g");
31
+ const nodeLayer = el(context, "g");
32
+ svg.append(edgeLayer, pathLayer, nodeLayer);
33
+
34
+ for (const edge of edges) {
35
+ const from = legs.get(edge.from);
36
+ const to = legs.get(edge.to);
37
+ if (!from || !to) continue;
38
+ edgeLayer.append(drawEdge(context, resolveEdgeRouting(edge, context.routing), from, to, offsetX, offsetY));
39
+ }
40
+
41
+ for (const path of paths) {
42
+ pathLayer.append(drawPath(context, path, offsetX, offsetY));
43
+ }
44
+
45
+ for (const node of graph.nodes) {
46
+ nodeLayer.append(drawNodeTree(context, node, offsetX, offsetY));
47
+ }
48
+
49
+ return { width, height, bounds };
50
+ }
51
+
52
+ export function graphSummary(graph) {
53
+ const nodeCount = flattenNodes(graph.nodes).length;
54
+ const edgeCount = flattenEdges(graph).length;
55
+ const pathCount = flattenPaths(graph).length;
56
+ const text = pathCount === 0
57
+ ? `${nodeCount} ${plural(nodeCount, "node")}, ${edgeCount} ${plural(edgeCount, "link")}`
58
+ : `${nodeCount} ${plural(nodeCount, "node")}, ${edgeCount} ${plural(edgeCount, "link")}, ${pathCount} ${plural(pathCount, "path")}`;
59
+ return {
60
+ nodeCount,
61
+ edgeCount,
62
+ pathCount,
63
+ text
64
+ };
65
+ }
66
+
67
+ export function flattenNodes(nodes) {
68
+ return nodes.flatMap((node) => [node, ...flattenNodes(node.children)]);
69
+ }
70
+
71
+ export function flattenEdges(graph) {
72
+ return [
73
+ ...graph.edges,
74
+ ...graph.nodes.flatMap((node) => collectNodeEdges(node))
75
+ ];
76
+ }
77
+
78
+ export function flattenPaths(graph) {
79
+ return [
80
+ ...(graph.paths ?? []),
81
+ ...(graph.nodes ?? []).flatMap((node) => collectNodePaths(node))
82
+ ];
83
+ }
84
+
85
+ export function edgePathData(edge, from, to, offsetX = 0, offsetY = 0, routingContext = null) {
86
+ const route = edge.attrs.route ?? "curve";
87
+ if (route === "straight") {
88
+ return pathData([
89
+ { x: from.x + offsetX, y: from.y + offsetY },
90
+ { x: to.x + offsetX, y: to.y + offsetY }
91
+ ]);
92
+ }
93
+ if (route === "orthogonal") {
94
+ return routedPathData(edge, orthogonalPoints(edge, from, to, offsetX, offsetY));
95
+ }
96
+ if (route === "auto") {
97
+ return routedPathData(edge, autoRoutePoints(edge, from, to, offsetX, offsetY, routingContext));
98
+ }
99
+
100
+ const distance = Math.hypot(to.x - from.x, to.y - from.y);
101
+ const handle = Math.max(48, distance * 0.35);
102
+ const fromDir = angleVector(from.angle ?? 0);
103
+ const toDir = angleVector(to.angle ?? 180);
104
+ return `M ${from.x + offsetX} ${from.y + offsetY} C ${from.x + offsetX + fromDir.x * handle} ${from.y + offsetY + fromDir.y * handle}, ${to.x + offsetX + toDir.x * handle} ${to.y + offsetY + toDir.y * handle}, ${to.x + offsetX} ${to.y + offsetY}`;
105
+ }
106
+
107
+ function drawNodeTree(context, node, offsetX, offsetY) {
108
+ const group = el(context, "g");
109
+ if (node.children.length > 0 || (node.paths?.length ?? 0) > 0) {
110
+ if (showsGroupBox(node)) {
111
+ group.append(drawGroupBox(context, node, offsetX, offsetY));
112
+ } else {
113
+ appendMaybe(group, drawNodeLabel(context, node, offsetX, offsetY));
114
+ }
115
+ for (const child of node.children) {
116
+ group.append(drawNodeTree(context, child, offsetX, offsetY));
117
+ }
118
+ for (const leg of Object.values(node.legs)) {
119
+ appendMaybe(group, drawLeg(context, leg, offsetX, offsetY));
120
+ }
121
+ return group;
122
+ }
123
+
124
+ appendMaybe(group, drawShape(context, node, offsetX, offsetY));
125
+ appendMaybe(group, drawNodeLabel(context, node, offsetX, offsetY));
126
+ for (const leg of Object.values(node.legs)) {
127
+ appendMaybe(group, drawLeg(context, leg, offsetX, offsetY));
128
+ }
129
+ return group;
130
+ }
131
+
132
+ function showsGroupBox(node) {
133
+ return booleanAttr(node.attrs.groupBox ?? node.attrs.groupbox, true);
134
+ }
135
+
136
+ function drawShape(context, node, offsetX, offsetY) {
137
+ if (node.shape === "point") {
138
+ return null;
139
+ }
140
+
141
+ const transform = node.transform ? viewportMatrixAttr(node.transform, offsetX, offsetY) : null;
142
+ if (node.shape === "plot") {
143
+ const plotSvg = styledEl(context, "svg", node.attrs.style, {
144
+ class: "plot-node",
145
+ x: node.transform ? node.x : node.x + offsetX,
146
+ y: node.transform ? node.y : node.y + offsetY,
147
+ width: Number(node.attrs.width ?? node.attrs.w ?? 720),
148
+ height: Number(node.attrs.height ?? node.attrs.h ?? 420),
149
+ overflow: "visible",
150
+ ...(transform ? { transform } : {})
151
+ });
152
+ renderPlot(plotSvg, node.plot, {
153
+ document: context.document,
154
+ katex: context.katex
155
+ });
156
+ return plotSvg;
157
+ }
158
+
159
+ if (node.shape === "circle") {
160
+ const r = Number(node.attrs.r ?? 28);
161
+ return styledEl(context, "circle", node.attrs.style, {
162
+ class: "shape",
163
+ cx: node.transform ? node.x : node.x + offsetX,
164
+ cy: node.transform ? node.y : node.y + offsetY,
165
+ r,
166
+ ...(transform ? { transform } : {})
167
+ });
168
+ }
169
+
170
+ return styledEl(context, "rect", node.attrs.style, {
171
+ class: "shape",
172
+ x: node.transform ? node.x : node.x + offsetX,
173
+ y: node.transform ? node.y : node.y + offsetY,
174
+ width: Number(node.attrs.w ?? 100),
175
+ height: Number(node.attrs.h ?? 60),
176
+ rx: Number(node.attrs.corner ?? node.attrs.rx ?? 6),
177
+ ...(transform ? { transform } : {})
178
+ });
179
+ }
180
+
181
+ function drawGroupBox(context, node, offsetX, offsetY) {
182
+ const nestedNodes = flattenNodes([node]);
183
+ const bounds = getBounds(nestedNodes, [], indexLegs(nestedNodes));
184
+ const padding = 22;
185
+ const box = el(context, "g");
186
+ box.append(el(context, "rect", {
187
+ class: "group-box",
188
+ x: bounds.minX + offsetX - padding,
189
+ y: bounds.minY + offsetY - padding,
190
+ width: Math.max(80, bounds.maxX - bounds.minX + padding * 2),
191
+ height: Math.max(54, bounds.maxY - bounds.minY + padding * 2),
192
+ rx: 8
193
+ }));
194
+ appendMaybe(box, drawNodeLabel(context, node, offsetX, offsetY, {
195
+ x: node.x,
196
+ y: bounds.minY - 30
197
+ }));
198
+ return box;
199
+ }
200
+
201
+ function drawNodeLabel(context, node, offsetX, offsetY, position = null) {
202
+ if (node.attrs.label == null) {
203
+ return null;
204
+ }
205
+ const box = nodeBox(node);
206
+ const x = position?.x ?? box.cx;
207
+ const y = position?.y ?? box.cy;
208
+ return drawLabel(context, node.attrs.label, x + offsetX, y + offsetY, "node-label");
209
+ }
210
+
211
+ function drawLeg(context, leg, offsetX, offsetY) {
212
+ if (leg.auto && leg.attrs.label == null && leg.attrs.style == null) {
213
+ return null;
214
+ }
215
+ const group = el(context, "g");
216
+ group.append(styledEl(context, "circle", leg.attrs.style, {
217
+ class: "leg-dot",
218
+ cx: leg.x + offsetX,
219
+ cy: leg.y + offsetY,
220
+ r: Number(leg.attrs.r ?? 5)
221
+ }));
222
+ if (leg.attrs.label != null) {
223
+ group.append(drawLabel(context, leg.attrs.label, leg.x + offsetX + 10, leg.y + offsetY - 10, "leg-label", "start"));
224
+ }
225
+ return group;
226
+ }
227
+
228
+ function drawEdge(context, edge, from, to, offsetX, offsetY) {
229
+ return styledEl(context, "path", edge.attrs.style, {
230
+ class: "edge",
231
+ ...arrowMarkerAttrs(context, edge.attrs),
232
+ d: edgePathData(edge, from, to, offsetX, offsetY, context)
233
+ });
234
+ }
235
+
236
+ function drawPath(context, path, offsetX, offsetY) {
237
+ const attrs = {
238
+ class: "path",
239
+ fill: "none",
240
+ stroke: "#111111",
241
+ strokeWidth: 2,
242
+ ...arrowMarkerAttrs(context, path.attrs),
243
+ d: explicitPathData(path, offsetX, offsetY)
244
+ };
245
+ if (!Array.isArray(path.points)) {
246
+ if (path.transform) {
247
+ attrs.transform = `${viewportMatrixAttr(path.transform, offsetX, offsetY)} translate(${path.x ?? 0} ${path.y ?? 0})`;
248
+ } else if (path.x || path.y) {
249
+ attrs.transform = `translate(${path.x + offsetX} ${path.y + offsetY})`;
250
+ }
251
+ }
252
+ return styledEl(context, "path", path.attrs.style, attrs);
253
+ }
254
+
255
+ function explicitPathData(path, offsetX, offsetY) {
256
+ if (Array.isArray(path.points)) {
257
+ const points = path.points.map((point) => ({
258
+ x: point.x + offsetX,
259
+ y: point.y + offsetY
260
+ }));
261
+ const data = routedPathData(path, compactPoints(points));
262
+ return booleanAttr(path.attrs.closed, false) ? `${data} Z` : data;
263
+ }
264
+ return path.attrs.d ?? "";
265
+ }
266
+
267
+ function arrowMarkerAttrs(context, attrs) {
268
+ const size = arrowSize(attrs);
269
+ const markerKey = arrowMarkerKey(size);
270
+ return {
271
+ ...(booleanAttr(attrs.tailArrow ?? attrs.tailarrow, false) ? { "marker-start": `url(#${arrowMarkerId("tail", markerKey)})` } : {}),
272
+ ...(booleanAttr(attrs.headArrow ?? attrs.headarrow, false) ? { "marker-end": `url(#${arrowMarkerId("head", markerKey)})` } : {})
273
+ };
274
+ }
275
+
276
+ function arrowSize(attrs) {
277
+ const size = Number(attrs.arrowSize ?? attrs.arrowsize ?? 12);
278
+ return Number.isFinite(size) && size > 0 ? size : 12;
279
+ }
280
+
281
+ function arrowMarkerKey(size) {
282
+ return String(Number(size.toFixed(3))).replace(/[^0-9A-Za-z_-]/g, "_");
283
+ }
284
+
285
+ function arrowMarkerId(kind, key) {
286
+ return key === "12" ? `graphsx-arrow-${kind}` : `graphsx-arrow-${kind}-${key}`;
287
+ }
288
+
289
+ function collectArrowMarkerKeys(edges, paths) {
290
+ const keys = new Set();
291
+ for (const item of [...edges, ...paths]) {
292
+ const attrs = item.attrs ?? {};
293
+ if (booleanAttr(attrs.headArrow ?? attrs.headarrow, false) || booleanAttr(attrs.tailArrow ?? attrs.tailarrow, false)) {
294
+ keys.add(arrowMarkerKey(arrowSize(attrs)));
295
+ }
296
+ }
297
+ return keys;
298
+ }
299
+
300
+ function routingDefaults(attrs) {
301
+ const routing = attrs.routing && typeof attrs.routing === "object" ? attrs.routing : {};
302
+ return {
303
+ route: attrs.route,
304
+ grid: attrs.grid,
305
+ padding: attrs.padding,
306
+ stub: attrs.stub,
307
+ corner: attrs.corner,
308
+ ...routing
309
+ };
310
+ }
311
+
312
+ function resolveEdgeRouting(edge, defaults) {
313
+ return {
314
+ ...edge,
315
+ attrs: {
316
+ ...defaults,
317
+ ...edge.attrs,
318
+ style: edge.attrs.style
319
+ }
320
+ };
321
+ }
322
+
323
+ function orthogonalPoints(edge, from, to, offsetX, offsetY) {
324
+ const stub = Number(edge.attrs.stub ?? 32);
325
+ const fromDir = cardinalVector(from.angle ?? 0);
326
+ const toDir = cardinalVector(to.angle ?? 180);
327
+ const start = { x: from.x + offsetX, y: from.y + offsetY };
328
+ const end = { x: to.x + offsetX, y: to.y + offsetY };
329
+ const startStub = {
330
+ x: start.x + fromDir.x * stub,
331
+ y: start.y + fromDir.y * stub
332
+ };
333
+ const endStub = {
334
+ x: end.x + toDir.x * stub,
335
+ y: end.y + toDir.y * stub
336
+ };
337
+
338
+ if (fromDir.x !== 0) {
339
+ const midX = (startStub.x + endStub.x) / 2;
340
+ return compactPoints([
341
+ start,
342
+ startStub,
343
+ { x: midX, y: startStub.y },
344
+ { x: midX, y: endStub.y },
345
+ endStub,
346
+ end
347
+ ]);
348
+ }
349
+
350
+ const midY = (startStub.y + endStub.y) / 2;
351
+ return compactPoints([
352
+ start,
353
+ startStub,
354
+ { x: startStub.x, y: midY },
355
+ { x: endStub.x, y: midY },
356
+ endStub,
357
+ end
358
+ ]);
359
+ }
360
+
361
+ function autoRoutePoints(edge, from, to, offsetX, offsetY, context) {
362
+ if (!context?.nodes) {
363
+ return orthogonalPoints(edge, from, to, offsetX, offsetY);
364
+ }
365
+
366
+ const grid = Math.max(4, Number(edge.attrs.grid ?? 20));
367
+ const padding = Number(edge.attrs.padding ?? 16);
368
+ const stub = Number(edge.attrs.stub ?? 32);
369
+ const fromDir = cardinalVector(from.angle ?? 0);
370
+ const toDir = cardinalVector(to.angle ?? 180);
371
+ const start = { x: from.x + offsetX, y: from.y + offsetY };
372
+ const end = { x: to.x + offsetX, y: to.y + offsetY };
373
+ const startStub = { x: start.x + fromDir.x * stub, y: start.y + fromDir.y * stub };
374
+ const endStub = { x: end.x + toDir.x * stub, y: end.y + toDir.y * stub };
375
+ const obstacles = obstacleBoxes(context.nodes, edge, offsetX, offsetY, padding);
376
+ const routeBounds = routeSearchBounds(context.nodes, obstacles, [start, end, startStub, endStub], offsetX, offsetY, padding, grid);
377
+ const middle = findGridPath(startStub, endStub, obstacles, routeBounds, grid);
378
+
379
+ if (!middle) {
380
+ return orthogonalPoints(edge, from, to, offsetX, offsetY);
381
+ }
382
+
383
+ const startBridge = orthogonalBridge(startStub, middle[0], fromDir.x !== 0 ? "horizontal" : "vertical");
384
+ const endBridge = orthogonalBridge(middle[middle.length - 1], endStub, toDir.x !== 0 ? "vertical" : "horizontal");
385
+ return compactCollinearPoints(compactPoints([
386
+ start,
387
+ startStub,
388
+ ...startBridge,
389
+ ...middle.slice(1, -1),
390
+ middle[middle.length - 1],
391
+ ...endBridge,
392
+ endStub,
393
+ end
394
+ ]));
395
+ }
396
+
397
+ function orthogonalBridge(from, to, firstDirection) {
398
+ if (from.x === to.x || from.y === to.y) {
399
+ return [to];
400
+ }
401
+ const corner = firstDirection === "horizontal"
402
+ ? { x: to.x, y: from.y }
403
+ : { x: from.x, y: to.y };
404
+ return [corner, to];
405
+ }
406
+
407
+ function obstacleBoxes(nodes, edge, offsetX, offsetY, padding) {
408
+ const sourceNode = nodeAddress(edge.from);
409
+ return nodes
410
+ .filter((node) => node.children.length === 0)
411
+ .filter((node) => node.shape !== "point")
412
+ .filter((node) => !isEndpointNode(node.id, sourceNode))
413
+ .map((node) => {
414
+ const box = nodeBox(node);
415
+ return {
416
+ minX: box.minX + offsetX - padding,
417
+ minY: box.minY + offsetY - padding,
418
+ maxX: box.maxX + offsetX + padding,
419
+ maxY: box.maxY + offsetY + padding
420
+ };
421
+ });
422
+ }
423
+
424
+ function routeSearchBounds(nodes, obstacles, points, offsetX, offsetY, padding, grid) {
425
+ const nodeBounds = getBounds(nodes, [], new Map());
426
+ const xs = [
427
+ nodeBounds.minX + offsetX,
428
+ nodeBounds.maxX + offsetX,
429
+ ...points.map((point) => point.x),
430
+ ...obstacles.flatMap((box) => [box.minX, box.maxX])
431
+ ];
432
+ const ys = [
433
+ nodeBounds.minY + offsetY,
434
+ nodeBounds.maxY + offsetY,
435
+ ...points.map((point) => point.y),
436
+ ...obstacles.flatMap((box) => [box.minY, box.maxY])
437
+ ];
438
+ const margin = padding + grid * 3;
439
+ return {
440
+ minX: Math.floor((Math.min(...xs) - margin) / grid) * grid,
441
+ minY: Math.floor((Math.min(...ys) - margin) / grid) * grid,
442
+ maxX: Math.ceil((Math.max(...xs) + margin) / grid) * grid,
443
+ maxY: Math.ceil((Math.max(...ys) + margin) / grid) * grid
444
+ };
445
+ }
446
+
447
+ function findGridPath(start, end, obstacles, bounds, grid) {
448
+ const tracks = buildTracks(bounds, grid, [start.x, end.x], [start.y, end.y]);
449
+ const startCell = snapCell(start, tracks);
450
+ const endCell = snapCell(end, tracks);
451
+ const startKey = cellKey(startCell);
452
+ const endKey = cellKey(endCell);
453
+ const open = new Map([[startKey, { cell: startCell, g: 0, f: manhattan(startCell, endCell), parent: null, direction: null }]]);
454
+ const closed = new Set();
455
+
456
+ while (open.size > 0) {
457
+ const current = lowestScore(open);
458
+ const currentKey = cellKey(current.cell);
459
+ open.delete(currentKey);
460
+ if (currentKey === endKey) {
461
+ return cellsToPoints(reconstructCells(current), tracks);
462
+ }
463
+ closed.add(currentKey);
464
+
465
+ for (const next of neighborCells(current.cell)) {
466
+ if (!cellInBounds(next, tracks)) continue;
467
+ const nextKey = cellKey(next);
468
+ if (closed.has(nextKey)) continue;
469
+ const point = cellPoint(next, tracks);
470
+ if (pointInBoxes(point, obstacles)) continue;
471
+
472
+ const direction = {
473
+ x: next.x - current.cell.x,
474
+ y: next.y - current.cell.y
475
+ };
476
+ const turnPenalty = current.direction && (current.direction.x !== direction.x || current.direction.y !== direction.y) ? 0.35 : 0;
477
+ const g = current.g + cellDistance(current.cell, next, tracks) / grid + turnPenalty;
478
+ const known = open.get(nextKey);
479
+ if (known && known.g <= g) continue;
480
+ open.set(nextKey, {
481
+ cell: next,
482
+ g,
483
+ f: g + manhattan(next, endCell),
484
+ parent: current,
485
+ direction
486
+ });
487
+ }
488
+ }
489
+
490
+ return null;
491
+ }
492
+
493
+ function lowestScore(open) {
494
+ let best = null;
495
+ for (const item of open.values()) {
496
+ if (!best || item.f < best.f) {
497
+ best = item;
498
+ }
499
+ }
500
+ return best;
501
+ }
502
+
503
+ function reconstructCells(node) {
504
+ const cells = [];
505
+ for (let current = node; current; current = current.parent) {
506
+ cells.push(current.cell);
507
+ }
508
+ return cells.reverse();
509
+ }
510
+
511
+ function cellsToPoints(cells, tracks) {
512
+ return compactCollinearPoints(cells.map((cell) => cellPoint(cell, tracks)));
513
+ }
514
+
515
+ function buildTracks(bounds, grid, exactXs, exactYs) {
516
+ return {
517
+ xs: buildTrack(bounds.minX, bounds.maxX, grid, exactXs),
518
+ ys: buildTrack(bounds.minY, bounds.maxY, grid, exactYs)
519
+ };
520
+ }
521
+
522
+ function buildTrack(min, max, grid, exactValues) {
523
+ const values = [];
524
+ for (let value = min; value <= max; value += grid) {
525
+ values.push(value);
526
+ }
527
+ values.push(...exactValues);
528
+ return [...new Set(values.map((value) => Number(value.toFixed(6))))].sort((a, b) => a - b);
529
+ }
530
+
531
+ function snapCell(point, tracks) {
532
+ return {
533
+ x: nearestTrackIndex(tracks.xs, point.x),
534
+ y: nearestTrackIndex(tracks.ys, point.y)
535
+ };
536
+ }
537
+
538
+ function nearestTrackIndex(track, value) {
539
+ let bestIndex = 0;
540
+ let bestDelta = Infinity;
541
+ for (let index = 0; index < track.length; index += 1) {
542
+ const delta = Math.abs(track[index] - value);
543
+ if (delta < bestDelta) {
544
+ bestDelta = delta;
545
+ bestIndex = index;
546
+ }
547
+ }
548
+ return bestIndex;
549
+ }
550
+
551
+ function cellPoint(cell, tracks) {
552
+ return {
553
+ x: tracks.xs[cell.x],
554
+ y: tracks.ys[cell.y]
555
+ };
556
+ }
557
+
558
+ function cellKey(cell) {
559
+ return `${cell.x},${cell.y}`;
560
+ }
561
+
562
+ function cellInBounds(cell, tracks) {
563
+ return (
564
+ cell.x >= 0 &&
565
+ cell.y >= 0 &&
566
+ cell.x < tracks.xs.length &&
567
+ cell.y < tracks.ys.length
568
+ );
569
+ }
570
+
571
+ function cellDistance(a, b, tracks) {
572
+ return Math.abs(tracks.xs[a.x] - tracks.xs[b.x]) + Math.abs(tracks.ys[a.y] - tracks.ys[b.y]);
573
+ }
574
+
575
+ function neighborCells(cell) {
576
+ return [
577
+ { x: cell.x + 1, y: cell.y },
578
+ { x: cell.x - 1, y: cell.y },
579
+ { x: cell.x, y: cell.y + 1 },
580
+ { x: cell.x, y: cell.y - 1 }
581
+ ];
582
+ }
583
+
584
+ function manhattan(a, b) {
585
+ return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
586
+ }
587
+
588
+ function pointInBoxes(point, boxes) {
589
+ return boxes.some((box) => (
590
+ point.x >= box.minX &&
591
+ point.x <= box.maxX &&
592
+ point.y >= box.minY &&
593
+ point.y <= box.maxY
594
+ ));
595
+ }
596
+
597
+ function nodeAddress(portAddress) {
598
+ return String(portAddress).split(".").slice(0, -1).join(".");
599
+ }
600
+
601
+ function isEndpointNode(nodeId, endpointId) {
602
+ return nodeId === endpointId || nodeId.startsWith(`${endpointId}.`);
603
+ }
604
+
605
+ function pathData(points) {
606
+ return points.map((point, index) => {
607
+ return `${index === 0 ? "M" : "L"} ${point.x} ${point.y}`;
608
+ }).join(" ");
609
+ }
610
+
611
+ function routedPathData(edge, points) {
612
+ const corner = Number(edge.attrs.corner ?? 0);
613
+ if (corner <= 0) {
614
+ return pathData(points);
615
+ }
616
+ return roundedPathData(points, corner);
617
+ }
618
+
619
+ function roundedPathData(points, radius) {
620
+ if (points.length <= 2) {
621
+ return pathData(points);
622
+ }
623
+
624
+ const commands = [`M ${points[0].x} ${points[0].y}`];
625
+ for (let index = 1; index < points.length - 1; index += 1) {
626
+ const previous = points[index - 1];
627
+ const point = points[index];
628
+ const next = points[index + 1];
629
+ const inLength = distance(previous, point);
630
+ const outLength = distance(point, next);
631
+ const amount = Math.min(radius, inLength / 2, outLength / 2);
632
+
633
+ if (amount <= 0 || isCollinear(previous, point, next)) {
634
+ commands.push(`L ${point.x} ${point.y}`);
635
+ continue;
636
+ }
637
+
638
+ const before = moveToward(point, previous, amount);
639
+ const after = moveToward(point, next, amount);
640
+ commands.push(`L ${before.x} ${before.y}`);
641
+ commands.push(`Q ${point.x} ${point.y} ${after.x} ${after.y}`);
642
+ }
643
+ const last = points[points.length - 1];
644
+ commands.push(`L ${last.x} ${last.y}`);
645
+ return commands.join(" ");
646
+ }
647
+
648
+ function distance(a, b) {
649
+ return Math.hypot(b.x - a.x, b.y - a.y);
650
+ }
651
+
652
+ function moveToward(from, to, amount) {
653
+ const length = distance(from, to);
654
+ if (length === 0) return from;
655
+ return {
656
+ x: from.x + (to.x - from.x) / length * amount,
657
+ y: from.y + (to.y - from.y) / length * amount
658
+ };
659
+ }
660
+
661
+ function isCollinear(a, b, c) {
662
+ return (a.x === b.x && b.x === c.x) || (a.y === b.y && b.y === c.y);
663
+ }
664
+
665
+ function compactPoints(points) {
666
+ return points.filter((point, index) => {
667
+ if (index === 0) return true;
668
+ const previous = points[index - 1];
669
+ return point.x !== previous.x || point.y !== previous.y;
670
+ });
671
+ }
672
+
673
+ function compactCollinearPoints(points) {
674
+ if (points.length <= 2) return points;
675
+ const compacted = [points[0]];
676
+ for (let index = 1; index < points.length - 1; index += 1) {
677
+ const previous = compacted[compacted.length - 1];
678
+ const point = points[index];
679
+ const next = points[index + 1];
680
+ if ((previous.x === point.x && point.x === next.x) || (previous.y === point.y && point.y === next.y)) {
681
+ continue;
682
+ }
683
+ compacted.push(point);
684
+ }
685
+ compacted.push(points[points.length - 1]);
686
+ return compacted;
687
+ }
688
+
689
+ function angleVector(angle) {
690
+ const radians = Number(angle) * Math.PI / 180;
691
+ return {
692
+ x: Math.cos(radians),
693
+ y: Math.sin(radians)
694
+ };
695
+ }
696
+
697
+ function cardinalVector(angle) {
698
+ const normalized = ((Number(angle) % 360) + 360) % 360;
699
+ const directions = [
700
+ { angle: 0, x: 1, y: 0 },
701
+ { angle: 90, x: 0, y: 1 },
702
+ { angle: 180, x: -1, y: 0 },
703
+ { angle: 270, x: 0, y: -1 }
704
+ ];
705
+ return directions.reduce((best, direction) => {
706
+ const delta = Math.abs(((normalized - direction.angle + 540) % 360) - 180);
707
+ return delta < best.delta ? { ...direction, delta } : best;
708
+ }, { ...directions[0], delta: Infinity });
709
+ }
710
+
711
+ function defs(context) {
712
+ const defs = el(context, "defs");
713
+ for (const key of context.arrowMarkers) {
714
+ const size = Number(key.replace(/_/g, "."));
715
+ defs.append(arrowMarker(context, "head", key, size), arrowMarker(context, "tail", key, size));
716
+ }
717
+ return defs;
718
+ }
719
+
720
+ function arrowMarker(context, kind, key, size) {
721
+ const marker = el(context, "marker", {
722
+ id: arrowMarkerId(kind, key),
723
+ markerWidth: size,
724
+ markerHeight: size,
725
+ refX: kind === "head" ? size * 5 / 6 : size / 6,
726
+ refY: size / 2,
727
+ orient: "auto",
728
+ markerUnits: "strokeWidth"
729
+ });
730
+ marker.append(el(context, "path", {
731
+ d: kind === "head"
732
+ ? `M ${size / 6} ${size / 6} L ${size * 5 / 6} ${size / 2} L ${size / 6} ${size * 5 / 6} z`
733
+ : `M ${size * 5 / 6} ${size / 6} L ${size / 6} ${size / 2} L ${size * 5 / 6} ${size * 5 / 6} z`,
734
+ fill: "context-stroke"
735
+ }));
736
+ return marker;
737
+ }
738
+
739
+ function collectNodeEdges(node) {
740
+ return [
741
+ ...node.edges,
742
+ ...node.children.flatMap((child) => collectNodeEdges(child))
743
+ ];
744
+ }
745
+
746
+ function collectNodePaths(node) {
747
+ return [
748
+ ...(node.paths ?? []),
749
+ ...node.children.flatMap((child) => collectNodePaths(child))
750
+ ];
751
+ }
752
+
753
+ function indexLegs(nodes) {
754
+ const legs = new Map();
755
+ for (const node of nodes) {
756
+ for (const [id, leg] of Object.entries(node.legs)) {
757
+ legs.set(`${node.id}.${id}`, leg);
758
+ }
759
+ }
760
+ return legs;
761
+ }
762
+
763
+ function getBounds(nodes, edges, legs, paths = []) {
764
+ const points = [];
765
+ for (const node of nodes) {
766
+ const box = nodeBox(node);
767
+ points.push({ x: box.minX, y: box.minY }, { x: box.maxX, y: box.maxY });
768
+ }
769
+ for (const edge of edges) {
770
+ const from = legs.get(edge.from);
771
+ const to = legs.get(edge.to);
772
+ if (from) points.push(from);
773
+ if (to) points.push(to);
774
+ }
775
+ for (const path of paths) {
776
+ points.push(...pathBoundsPoints(path));
777
+ }
778
+ if (points.length === 0) {
779
+ return { minX: 0, minY: 0, maxX: 640, maxY: 360 };
780
+ }
781
+ return {
782
+ minX: Math.min(...points.map((point) => point.x)),
783
+ minY: Math.min(...points.map((point) => point.y)),
784
+ maxX: Math.max(...points.map((point) => point.x)),
785
+ maxY: Math.max(...points.map((point) => point.y))
786
+ };
787
+ }
788
+
789
+ function nodeBox(node) {
790
+ if (node.children.length > 0) {
791
+ const nodes = flattenNodes(node.children);
792
+ return getBounds(nodes, node.edges, indexLegs(nodes), node.paths);
793
+ }
794
+ if (node.shape === "point") {
795
+ return transformedBox(node.transform, [{
796
+ x: node.x,
797
+ y: node.y
798
+ }]) ?? {
799
+ minX: node.x,
800
+ minY: node.y,
801
+ maxX: node.x,
802
+ maxY: node.y,
803
+ cx: node.x,
804
+ cy: node.y
805
+ };
806
+ }
807
+ if (node.shape === "circle") {
808
+ const r = Number(node.attrs.r ?? 28);
809
+ const center = node.transform ? transformPoint(node.transform, node) : node;
810
+ return {
811
+ minX: center.x - r,
812
+ minY: center.y - r,
813
+ maxX: center.x + r,
814
+ maxY: center.y + r,
815
+ cx: center.x,
816
+ cy: center.y
817
+ };
818
+ }
819
+ const w = Number(node.attrs.w ?? 100);
820
+ const h = Number(node.attrs.h ?? 60);
821
+ const transformed = transformedBox(node.transform, [
822
+ { x: node.x, y: node.y },
823
+ { x: node.x + w, y: node.y },
824
+ { x: node.x + w, y: node.y + h },
825
+ { x: node.x, y: node.y + h }
826
+ ]);
827
+ if (transformed) return transformed;
828
+ return {
829
+ minX: node.x,
830
+ minY: node.y,
831
+ maxX: node.x + w,
832
+ maxY: node.y + h,
833
+ cx: node.x + w / 2,
834
+ cy: node.y + h / 2
835
+ };
836
+ }
837
+
838
+ function pathBoundsPoints(path) {
839
+ if (Array.isArray(path.points)) {
840
+ return path.points;
841
+ }
842
+ if (typeof path.attrs.d !== "string") {
843
+ return [];
844
+ }
845
+ const numbers = [...path.attrs.d.matchAll(/-?\d+(?:\.\d+)?/g)].map((match) => Number(match[0]));
846
+ const points = [];
847
+ for (let index = 0; index + 1 < numbers.length; index += 2) {
848
+ const point = {
849
+ x: numbers[index] + (path.x ?? 0),
850
+ y: numbers[index + 1] + (path.y ?? 0)
851
+ };
852
+ points.push(path.transform ? transformPoint(path.transform, point) : point);
853
+ }
854
+ return points;
855
+ }
856
+
857
+ function viewportMatrixAttr(matrix, offsetX, offsetY) {
858
+ const adjusted = {
859
+ ...matrix,
860
+ e: matrix.e + offsetX,
861
+ f: matrix.f + offsetY
862
+ };
863
+ return matrixAttr(adjusted);
864
+ }
865
+
866
+ function matrixAttr(matrix) {
867
+ return `matrix(${formatNumber(matrix.a)} ${formatNumber(matrix.b)} ${formatNumber(matrix.c)} ${formatNumber(matrix.d)} ${formatNumber(matrix.e)} ${formatNumber(matrix.f)})`;
868
+ }
869
+
870
+ function transformPoint(matrix, point) {
871
+ return {
872
+ x: matrix.a * point.x + matrix.c * point.y + matrix.e,
873
+ y: matrix.b * point.x + matrix.d * point.y + matrix.f
874
+ };
875
+ }
876
+
877
+ function transformedBox(matrix, points) {
878
+ if (!matrix) return null;
879
+ const transformed = points.map((point) => transformPoint(matrix, point));
880
+ const xs = transformed.map((point) => point.x);
881
+ const ys = transformed.map((point) => point.y);
882
+ return {
883
+ minX: Math.min(...xs),
884
+ minY: Math.min(...ys),
885
+ maxX: Math.max(...xs),
886
+ maxY: Math.max(...ys),
887
+ cx: (Math.min(...xs) + Math.max(...xs)) / 2,
888
+ cy: (Math.min(...ys) + Math.max(...ys)) / 2
889
+ };
890
+ }
891
+
892
+ function formatNumber(value) {
893
+ return Number(value.toFixed(6));
894
+ }
895
+
896
+ function el(context, name, attrs = {}, text = null) {
897
+ const node = context.document.createElementNS(SVG_NS, name);
898
+ for (const [key, value] of Object.entries(attrs)) {
899
+ node.setAttribute(key, value);
900
+ }
901
+ if (text != null) {
902
+ node.textContent = text;
903
+ }
904
+ return node;
905
+ }
906
+
907
+ function styledEl(context, name, style, attrs = {}, text = null) {
908
+ return el(context, name, { ...attrs, ...svgStyleAttrs(style) }, text);
909
+ }
910
+
911
+ function svgStyleAttrs(style) {
912
+ if (!style || typeof style !== "object") {
913
+ return {};
914
+ }
915
+ const declarations = Object.entries(style).map(([key, value]) => {
916
+ const name = key.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`);
917
+ return `${name}: ${value}`;
918
+ });
919
+ return { style: declarations.join("; ") };
920
+ }
921
+
922
+ function drawLabel(context, value, x, y, className, anchor = "middle") {
923
+ const label = String(value);
924
+ const math = parseMathLabel(label);
925
+ if (math && context.katex) {
926
+ return drawMathLabel(context, math, x, y, className, anchor);
927
+ }
928
+
929
+ return el(context, "text", {
930
+ class: className,
931
+ x,
932
+ y: y + 4,
933
+ "text-anchor": anchor
934
+ }, math ?? label);
935
+ }
936
+
937
+ function drawMathLabel(context, source, x, y, className, anchor) {
938
+ const width = estimateMathWidth(source);
939
+ const height = 34;
940
+ const left = anchor === "middle" ? x - width / 2 : x;
941
+ const foreignObject = el(context, "foreignObject", {
942
+ class: className,
943
+ x: left,
944
+ y: y - height / 2,
945
+ width,
946
+ height
947
+ });
948
+ const host = context.document.createElement("div");
949
+ host.style.width = `${width}px`;
950
+ host.style.height = `${height}px`;
951
+ host.style.display = "flex";
952
+ host.style.alignItems = "center";
953
+ host.style.justifyContent = anchor === "middle" ? "center" : "flex-start";
954
+ host.style.color = "#1e2724";
955
+ context.katex.render(source, host, { throwOnError: false });
956
+ foreignObject.append(host);
957
+ return foreignObject;
958
+ }
959
+
960
+ function parseMathLabel(label) {
961
+ const trimmed = label.trim();
962
+ if (trimmed.length >= 2 && trimmed.startsWith("$") && trimmed.endsWith("$")) {
963
+ return trimmed.slice(1, -1);
964
+ }
965
+ return null;
966
+ }
967
+
968
+ function estimateMathWidth(source) {
969
+ return Math.max(34, Math.min(220, source.length * 12 + 28));
970
+ }
971
+
972
+ function appendMaybe(parent, child) {
973
+ if (child) {
974
+ parent.append(child);
975
+ }
976
+ }
977
+
978
+ function booleanAttr(value, fallback) {
979
+ if (value == null) return fallback;
980
+ if (value === false || value === "false") return false;
981
+ if (value === true || value === "true") return true;
982
+ return Boolean(value);
983
+ }
984
+
985
+ function plural(count, label) {
986
+ return count === 1 ? label : `${label}s`;
987
+ }