@dryui/ui 0.5.2 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/alert/{alert-root.svelte → alert.svelte} +78 -20
- package/dist/alert/alert.svelte.d.ts +15 -0
- package/dist/alert/index.d.ts +15 -14
- package/dist/alert/index.js +3 -12
- package/dist/breadcrumb/breadcrumb-link.svelte +1 -1
- package/dist/button/button.svelte +1 -1
- package/dist/card/card-root.svelte +2 -2
- package/dist/chromatic-shift/chromatic-shift.svelte +2 -2
- package/dist/code-block/code-block-button.svelte +1 -1
- package/dist/color-picker/color-picker-area.svelte +2 -2
- package/dist/color-picker/color-picker-channel-input.svelte +2 -2
- package/dist/color-picker/color-picker-input-alpha-slider.svelte +2 -2
- package/dist/color-picker/color-picker-input-hue-slider.svelte +2 -2
- package/dist/color-picker/color-picker-input.svelte +9 -9
- package/dist/color-picker/color-picker-swatch.svelte +2 -2
- package/dist/combobox/combobox-input.svelte +9 -9
- package/dist/command-palette/command-palette-item.svelte +1 -1
- package/dist/data-grid/data-grid-button-input-column.svelte +1 -1
- package/dist/diagram/diagram.svelte +222 -32
- package/dist/diagram/diagram.svelte.d.ts +1 -0
- package/dist/diagram/edge-routing.d.ts +63 -1
- package/dist/diagram/edge-routing.js +316 -26
- package/dist/diagram/layout.js +633 -62
- package/dist/diagram/types.d.ts +58 -0
- package/dist/drag-and-drop/drag-and-drop-handle.svelte +1 -1
- package/dist/drag-and-drop/drag-and-drop-item.svelte +1 -1
- package/dist/file-select/file-select-root.svelte +2 -2
- package/dist/file-upload/file-upload-dropzone.svelte +2 -2
- package/dist/gauge/gauge.svelte +1 -1
- package/dist/image-comparison/image-comparison.svelte +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/input/input.svelte +10 -11
- package/dist/label/label.svelte +1 -1
- package/dist/link/link.svelte +1 -1
- package/dist/list/list-item.svelte +2 -2
- package/dist/multi-select-combobox/multi-select-combobox-selection-item.svelte +9 -3
- package/dist/multi-select-combobox/multi-select-combobox-selection-remove-button.svelte +2 -0
- package/dist/navigation-menu/navigation-menu-link.svelte +1 -1
- package/dist/notification-center/notification-center-item.svelte +1 -1
- package/dist/number-input/number-input-button.svelte +3 -3
- package/dist/option-picker/context.svelte.d.ts +9 -0
- package/dist/option-picker/context.svelte.js +2 -0
- package/dist/option-picker/option-picker-item.svelte +31 -4
- package/dist/option-picker/option-picker-preview.svelte +2 -2
- package/dist/option-picker/option-picker-root.svelte +2 -2
- package/dist/phone-input/phone-input-select.svelte +2 -2
- package/dist/pin-input/pin-input-cell.svelte +1 -1
- package/dist/pin-input/pin-input-root.svelte +1 -1
- package/dist/progress/progress.svelte +1 -1
- package/dist/scroll-area/scroll-area.svelte +1 -1
- package/dist/shimmer/index.d.ts +8 -0
- package/dist/shimmer/index.js +1 -0
- package/dist/shimmer/shimmer.svelte +87 -0
- package/dist/shimmer/shimmer.svelte.d.ts +10 -0
- package/dist/sidebar/sidebar-item.svelte +1 -1
- package/dist/slider/slider-input.svelte +2 -2
- package/dist/splitter/splitter-handle.svelte +1 -1
- package/dist/table-of-contents/table-of-contents-item.svelte +1 -1
- package/dist/table-of-contents/table-of-contents-list.svelte +1 -1
- package/dist/tags-input/tags-input-root.svelte +1 -1
- package/dist/tags-input/tags-input-tag-delete-button.svelte +2 -0
- package/dist/tags-input/tags-input-tag.svelte +9 -3
- package/dist/textarea/textarea.svelte +11 -11
- package/dist/themes/default.css +31 -0
- package/dist/toast/toast-root.svelte +1 -1
- package/dist/tour/tour-root.css +3 -3
- package/dist/tree/tree-item-children.svelte +1 -1
- package/dist/tree/tree-item-label.svelte +1 -1
- package/dist/video-embed/video-embed-button.svelte +1 -1
- package/package.json +11 -750
- package/skills/dryui/SKILL.md +26 -21
- package/skills/dryui/rules/compound-components.md +3 -3
- package/skills/dryui/rules/theming.md +1 -1
- package/dist/alert/alert-button-close.svelte +0 -29
- package/dist/alert/alert-button-close.svelte.d.ts +0 -8
- package/dist/alert/alert-description.svelte +0 -28
- package/dist/alert/alert-description.svelte.d.ts +0 -8
- package/dist/alert/alert-icon.svelte +0 -26
- package/dist/alert/alert-icon.svelte.d.ts +0 -8
- package/dist/alert/alert-root.svelte.d.ts +0 -12
- package/dist/alert/alert-title.svelte +0 -29
- package/dist/alert/alert-title.svelte.d.ts +0 -8
- package/dist/alert/context.svelte.d.ts +0 -9
- package/dist/alert/context.svelte.js +0 -10
- package/dist/option-swatch-group/context.svelte.d.ts +0 -9
- package/dist/option-swatch-group/context.svelte.js +0 -2
- package/dist/option-swatch-group/index.d.ts +0 -29
- package/dist/option-swatch-group/index.js +0 -12
- package/dist/option-swatch-group/option-swatch-group-item-button.svelte +0 -214
- package/dist/option-swatch-group/option-swatch-group-item-button.svelte.d.ts +0 -12
- package/dist/option-swatch-group/option-swatch-group-label.svelte +0 -24
- package/dist/option-swatch-group/option-swatch-group-label.svelte.d.ts +0 -8
- package/dist/option-swatch-group/option-swatch-group-meta.svelte +0 -24
- package/dist/option-swatch-group/option-swatch-group-meta.svelte.d.ts +0 -8
- package/dist/option-swatch-group/option-swatch-group-root.svelte +0 -81
- package/dist/option-swatch-group/option-swatch-group-root.svelte.d.ts +0 -12
- package/dist/option-swatch-group/option-swatch-group-swatch.svelte +0 -52
- package/dist/option-swatch-group/option-swatch-group-swatch.svelte.d.ts +0 -10
package/dist/diagram/layout.js
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
|
-
import { computeEdgePaths, emptyEdge } from './edge-routing.js';
|
|
2
|
-
const DEFAULT_NODE_GAP =
|
|
3
|
-
const DEFAULT_LAYER_GAP =
|
|
4
|
-
const DEFAULT_CLUSTER_PADDING =
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
const
|
|
1
|
+
import { buildPathFromCollapsed, computeEdgePaths, emptyEdge, getPointAtFraction, splitCollapsedAtBox } from './edge-routing.js';
|
|
2
|
+
const DEFAULT_NODE_GAP = 32;
|
|
3
|
+
const DEFAULT_LAYER_GAP = 64;
|
|
4
|
+
const DEFAULT_CLUSTER_PADDING = 40;
|
|
5
|
+
const DEFAULT_CORNER_RADIUS = 8;
|
|
6
|
+
const DEFAULT_BACK_EDGE_LANE_GAP = 32;
|
|
7
|
+
const DEFAULT_NODE_HEIGHT = 68;
|
|
8
|
+
const DESC_NODE_HEIGHT = 116;
|
|
9
|
+
const MIN_NODE_WIDTH = 176;
|
|
10
|
+
const CHAR_WIDTH = 9;
|
|
11
|
+
const NODE_PADDING_X = 64;
|
|
10
12
|
const MARGIN = 40;
|
|
11
13
|
// ── Helpers ────────────────────────────────────────────────
|
|
12
14
|
function estimateNodeWidth(label, description) {
|
|
13
15
|
const labelWidth = label.length * CHAR_WIDTH + NODE_PADDING_X;
|
|
14
|
-
const descWidth = description ? description.length *
|
|
16
|
+
const descWidth = description ? description.length * 7 + NODE_PADDING_X : 0;
|
|
15
17
|
return Math.max(MIN_NODE_WIDTH, labelWidth, descWidth);
|
|
16
18
|
}
|
|
17
19
|
function isHorizontal(dir) {
|
|
@@ -287,10 +289,8 @@ function countClusterBoundaries(layer, clusterMap) {
|
|
|
287
289
|
return count;
|
|
288
290
|
}
|
|
289
291
|
// ── Cluster Bounds ─────────────────────────────────────────
|
|
290
|
-
function computeClusterBounds(
|
|
291
|
-
|
|
292
|
-
return [];
|
|
293
|
-
return config.clusters.map((cluster) => {
|
|
292
|
+
function computeClusterBounds(clusters, positions, nodeDims, padding) {
|
|
293
|
+
return clusters.map((cluster) => {
|
|
294
294
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
295
295
|
for (const nodeId of cluster.nodes) {
|
|
296
296
|
const pos = positions.get(nodeId);
|
|
@@ -310,24 +310,128 @@ function computeClusterBounds(config, positions, nodeDims, padding) {
|
|
|
310
310
|
width: 0,
|
|
311
311
|
height: 0,
|
|
312
312
|
label: cluster.label,
|
|
313
|
+
labelPosition: cluster.labelPosition,
|
|
314
|
+
iconComponent: cluster.iconComponent,
|
|
313
315
|
color: cluster.color || 'neutral',
|
|
314
316
|
dashed: cluster.dashed ?? true
|
|
315
317
|
};
|
|
316
318
|
}
|
|
317
|
-
|
|
319
|
+
const labelOnLeft = cluster.labelPosition === 'left';
|
|
318
320
|
const labelPad = cluster.label ? 24 : 0;
|
|
321
|
+
const padTop = labelOnLeft ? 0 : labelPad;
|
|
322
|
+
const padLeft = labelOnLeft ? labelPad : 0;
|
|
319
323
|
return {
|
|
320
324
|
id: cluster.id,
|
|
321
|
-
x: minX - padding,
|
|
322
|
-
y: minY - padding -
|
|
323
|
-
width: maxX - minX + padding * 2,
|
|
324
|
-
height: maxY - minY + padding * 2 +
|
|
325
|
+
x: minX - padding - padLeft,
|
|
326
|
+
y: minY - padding - padTop,
|
|
327
|
+
width: maxX - minX + padding * 2 + padLeft,
|
|
328
|
+
height: maxY - minY + padding * 2 + padTop,
|
|
325
329
|
label: cluster.label,
|
|
330
|
+
labelPosition: cluster.labelPosition,
|
|
331
|
+
iconComponent: cluster.iconComponent,
|
|
326
332
|
color: cluster.color || 'neutral',
|
|
327
333
|
dashed: cluster.dashed ?? true
|
|
328
334
|
};
|
|
329
335
|
});
|
|
330
336
|
}
|
|
337
|
+
function computeNodeAndClusterBounds(nodes, clusters) {
|
|
338
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
339
|
+
for (const n of nodes) {
|
|
340
|
+
minX = Math.min(minX, n.x);
|
|
341
|
+
minY = Math.min(minY, n.y);
|
|
342
|
+
maxX = Math.max(maxX, n.x + n.width);
|
|
343
|
+
maxY = Math.max(maxY, n.y + n.height);
|
|
344
|
+
}
|
|
345
|
+
for (const c of clusters) {
|
|
346
|
+
minX = Math.min(minX, c.x);
|
|
347
|
+
minY = Math.min(minY, c.y);
|
|
348
|
+
maxX = Math.max(maxX, c.x + c.width);
|
|
349
|
+
maxY = Math.max(maxY, c.y + c.height);
|
|
350
|
+
}
|
|
351
|
+
if (minX === Infinity) {
|
|
352
|
+
return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
|
353
|
+
}
|
|
354
|
+
return { minX, minY, maxX, maxY };
|
|
355
|
+
}
|
|
356
|
+
// ── Waypoints ──────────────────────────────────────────────
|
|
357
|
+
const WAYPOINT_DEFAULT_WIDTH = 240;
|
|
358
|
+
const WAYPOINT_DEFAULT_HEIGHT_DESC = 132;
|
|
359
|
+
const WAYPOINT_DEFAULT_HEIGHT = 84;
|
|
360
|
+
function estimateWaypointDims(waypoint) {
|
|
361
|
+
const w = waypoint.width ??
|
|
362
|
+
Math.max(MIN_NODE_WIDTH, waypoint.label.length * CHAR_WIDTH + NODE_PADDING_X, (waypoint.description?.length ?? 0) * 7 + NODE_PADDING_X);
|
|
363
|
+
const h = waypoint.height ??
|
|
364
|
+
(waypoint.description ? WAYPOINT_DEFAULT_HEIGHT_DESC : WAYPOINT_DEFAULT_HEIGHT);
|
|
365
|
+
return { w: Math.min(w, WAYPOINT_DEFAULT_WIDTH * 1.4), h };
|
|
366
|
+
}
|
|
367
|
+
function placeWaypoints(configEdges, positionedEdges, collapsedByIndex, cornerRadius) {
|
|
368
|
+
const newEdges = [];
|
|
369
|
+
const waypoints = [];
|
|
370
|
+
configEdges.forEach((edge, i) => {
|
|
371
|
+
const positioned = positionedEdges[i];
|
|
372
|
+
const collapsed = collapsedByIndex[i];
|
|
373
|
+
if (!edge.waypoint || !positioned || !collapsed || collapsed.length < 2) {
|
|
374
|
+
if (positioned)
|
|
375
|
+
newEdges.push(positioned);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
const wp = edge.waypoint;
|
|
379
|
+
const t = wp.position ?? 0.5;
|
|
380
|
+
const at = getPointAtFraction(collapsed, t);
|
|
381
|
+
const dims = estimateWaypointDims(wp);
|
|
382
|
+
// Snap box center to the segment axis so the polyline enters/exits cleanly
|
|
383
|
+
const boxCenter = at.axis === 'h'
|
|
384
|
+
? { x: at.point.x, y: at.point.y } // horizontal segment — box vertical center on the line
|
|
385
|
+
: { x: at.point.x, y: at.point.y };
|
|
386
|
+
const box = {
|
|
387
|
+
x: boxCenter.x - dims.w / 2,
|
|
388
|
+
y: boxCenter.y - dims.h / 2,
|
|
389
|
+
width: dims.w,
|
|
390
|
+
height: dims.h
|
|
391
|
+
};
|
|
392
|
+
const split = splitCollapsedAtBox(collapsed, at.segmentIndex, box, at.axis);
|
|
393
|
+
if (!split) {
|
|
394
|
+
newEdges.push(positioned);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const entryPath = buildPathFromCollapsed(split.entry, cornerRadius);
|
|
398
|
+
const exitPath = buildPathFromCollapsed(split.exit, cornerRadius);
|
|
399
|
+
// Entry segment: no arrow marker (arrow is on the exit)
|
|
400
|
+
newEdges.push({
|
|
401
|
+
...positioned,
|
|
402
|
+
path: entryPath,
|
|
403
|
+
label: undefined,
|
|
404
|
+
labelX: 0,
|
|
405
|
+
labelY: 0,
|
|
406
|
+
arrow: 'none',
|
|
407
|
+
kind: 'entry'
|
|
408
|
+
});
|
|
409
|
+
// Exit segment: keeps original arrow + label
|
|
410
|
+
const exitMid = getPointAtFraction(split.exit, 0.5).point;
|
|
411
|
+
newEdges.push({
|
|
412
|
+
...positioned,
|
|
413
|
+
path: exitPath,
|
|
414
|
+
label: positioned.label,
|
|
415
|
+
labelX: exitMid.x,
|
|
416
|
+
labelY: exitMid.y - 12,
|
|
417
|
+
kind: 'exit'
|
|
418
|
+
});
|
|
419
|
+
waypoints.push({
|
|
420
|
+
id: wp.id ?? `${edge.from}->${edge.to}`,
|
|
421
|
+
x: box.x,
|
|
422
|
+
y: box.y,
|
|
423
|
+
width: dims.w,
|
|
424
|
+
height: dims.h,
|
|
425
|
+
label: wp.label,
|
|
426
|
+
description: wp.description,
|
|
427
|
+
icon: wp.icon,
|
|
428
|
+
iconComponent: wp.iconComponent,
|
|
429
|
+
variant: wp.variant ?? 'default',
|
|
430
|
+
color: wp.color ?? 'neutral'
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
return { edges: newEdges, waypoints };
|
|
434
|
+
}
|
|
331
435
|
// ── Annotations ────────────────────────────────────────────
|
|
332
436
|
const ANNOTATION_CHAR_WIDTH = 6;
|
|
333
437
|
const ANNOTATION_HEIGHT = 14;
|
|
@@ -401,38 +505,435 @@ function resolveAnnotations(config, positions, nodeDims) {
|
|
|
401
505
|
}
|
|
402
506
|
return resolved;
|
|
403
507
|
}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
const nodeIds =
|
|
411
|
-
const nodeDims = buildNodeDims(
|
|
412
|
-
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
508
|
+
/** Pipeline steps 1-7: build the graph, assign layers, order within layers,
|
|
509
|
+
* assign coordinates, and pre-compute cluster bounds. Returned state is fed
|
|
510
|
+
* to `finishLayeredPass` to compute edges and waypoints. The split lets the
|
|
511
|
+
* recursive orchestrator inject inner-node positions between these phases
|
|
512
|
+
* for cross-boundary back-edge re-anchoring. */
|
|
513
|
+
function computeLayeredPositions(nodes, edges, clusters, direction, spacing) {
|
|
514
|
+
const nodeIds = nodes.map((n) => n.id);
|
|
515
|
+
const nodeDims = buildNodeDims(nodes);
|
|
516
|
+
const clusterMap = new Map();
|
|
517
|
+
for (const cluster of clusters) {
|
|
518
|
+
for (const nodeId of cluster.nodes) {
|
|
519
|
+
if (nodeIds.includes(nodeId))
|
|
520
|
+
clusterMap.set(nodeId, cluster.id);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
const graph = buildGraph(nodeIds, edges);
|
|
417
524
|
const layerMap = assignLayers(graph.order, graph.adjacencyOut);
|
|
418
|
-
// Group by layer
|
|
419
525
|
const maxLayer = Math.max(0, ...layerMap.values());
|
|
420
526
|
const layers = Array.from({ length: maxLayer + 1 }, () => []);
|
|
421
527
|
for (const id of graph.order) {
|
|
422
528
|
layers[layerMap.get(id)].push(id);
|
|
423
529
|
}
|
|
424
|
-
// Order within layers (cluster-aware)
|
|
425
530
|
const orderedLayers = orderWithinLayers(layers, graph.adjacencyOut, graph.adjacencyIn, clusterMap);
|
|
426
|
-
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
531
|
+
const positions = assignCoordinates(orderedLayers, nodeDims, direction, spacing.nodeGap, spacing.layerGap, clusterMap);
|
|
532
|
+
const positionedNodes = buildPositionedNodes(nodes, positions, nodeDims);
|
|
533
|
+
const positionedClusters = computeClusterBounds(clusters, positions, nodeDims, spacing.clusterPadding);
|
|
534
|
+
const preBounds = computeNodeAndClusterBounds(positionedNodes, positionedClusters);
|
|
535
|
+
return {
|
|
536
|
+
positions,
|
|
537
|
+
nodeDims,
|
|
538
|
+
positionedNodes,
|
|
539
|
+
positionedClusters,
|
|
540
|
+
preBounds,
|
|
541
|
+
reversedEdges: graph.reversedEdges
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
/** Pipeline steps 8-10: route edges, place waypoints, compute bbox. Takes
|
|
545
|
+
* optional position/dim extras and back-edge anchor overrides so the
|
|
546
|
+
* orchestrator can re-anchor cross-boundary back edges to inner nodes. */
|
|
547
|
+
function finishLayeredPass(edges, pos, direction, spacing, superNodeIds, extras) {
|
|
548
|
+
let edgePositions = pos.positions;
|
|
549
|
+
let edgeNodeDims = pos.nodeDims;
|
|
550
|
+
if (extras?.extraPositions || extras?.extraDims) {
|
|
551
|
+
edgePositions = new Map(pos.positions);
|
|
552
|
+
edgeNodeDims = new Map(pos.nodeDims);
|
|
553
|
+
if (extras.extraPositions) {
|
|
554
|
+
for (const [k, v] of extras.extraPositions)
|
|
555
|
+
edgePositions.set(k, v);
|
|
556
|
+
}
|
|
557
|
+
if (extras.extraDims) {
|
|
558
|
+
for (const [k, v] of extras.extraDims)
|
|
559
|
+
edgeNodeDims.set(k, v);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
const computed = computeEdgePaths(edges, edgePositions, edgeNodeDims, direction, {
|
|
563
|
+
cornerRadius: spacing.cornerRadius,
|
|
564
|
+
reversedEdges: pos.reversedEdges,
|
|
565
|
+
bounds: pos.preBounds,
|
|
566
|
+
backEdgeLaneGap: spacing.backEdgeLaneGap,
|
|
567
|
+
superNodeIds,
|
|
568
|
+
backEdgeAnchorOverrides: extras?.backEdgeAnchorOverrides
|
|
569
|
+
});
|
|
570
|
+
let positionedEdges = computed.edges;
|
|
571
|
+
const waypointResult = placeWaypoints(edges, positionedEdges, computed.collapsed, spacing.cornerRadius);
|
|
572
|
+
positionedEdges = waypointResult.edges;
|
|
573
|
+
const waypoints = waypointResult.waypoints;
|
|
574
|
+
const bbox = computeFullPassBounds(pos.positionedNodes, pos.positionedClusters, positionedEdges, waypoints);
|
|
575
|
+
return {
|
|
576
|
+
positionedNodes: pos.positionedNodes,
|
|
577
|
+
positionedClusters: pos.positionedClusters,
|
|
578
|
+
positionedEdges,
|
|
579
|
+
waypoints,
|
|
580
|
+
bbox
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
/** Layered layout for a flat node/edge/cluster slice in local coordinates.
|
|
584
|
+
* Annotations and the global non-negative shift stay in layoutLayered. */
|
|
585
|
+
function layoutLayeredPass(nodes, edges, clusters, direction, spacing, superNodeIds) {
|
|
586
|
+
const pos = computeLayeredPositions(nodes, edges, clusters, direction, spacing);
|
|
587
|
+
return finishLayeredPass(edges, pos, direction, spacing, superNodeIds);
|
|
588
|
+
}
|
|
589
|
+
function computeFullPassBounds(nodes, clusters, edges, waypoints) {
|
|
590
|
+
const base = computeNodeAndClusterBounds(nodes, clusters);
|
|
591
|
+
let { minX, minY, maxX, maxY } = base;
|
|
592
|
+
for (const w of waypoints) {
|
|
593
|
+
minX = Math.min(minX, w.x);
|
|
594
|
+
minY = Math.min(minY, w.y);
|
|
595
|
+
maxX = Math.max(maxX, w.x + w.width);
|
|
596
|
+
maxY = Math.max(maxY, w.y + w.height);
|
|
597
|
+
}
|
|
598
|
+
for (const e of edges) {
|
|
599
|
+
const pb = e.bounds ?? extractPathBounds(e.path);
|
|
600
|
+
if (pb) {
|
|
601
|
+
minX = Math.min(minX, pb.minX);
|
|
602
|
+
minY = Math.min(minY, pb.minY);
|
|
603
|
+
maxX = Math.max(maxX, pb.maxX);
|
|
604
|
+
maxY = Math.max(maxY, pb.maxY);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return { minX, minY, maxX, maxY };
|
|
608
|
+
}
|
|
609
|
+
const SUPER_NODE_PREFIX = '__dry_super_';
|
|
610
|
+
function isSuperNodeId(id) {
|
|
611
|
+
return id.startsWith(SUPER_NODE_PREFIX);
|
|
612
|
+
}
|
|
613
|
+
// ── Layout caches ──────────────────────────────────────────
|
|
614
|
+
//
|
|
615
|
+
// Two-tier caching:
|
|
616
|
+
//
|
|
617
|
+
// 1. `fullLayoutCache` (WeakMap by config identity): when a caller invokes
|
|
618
|
+
// computeLayout repeatedly with the *same* config object, return the
|
|
619
|
+
// already-computed result. The Svelte `<Diagram>` component re-runs its
|
|
620
|
+
// `$derived(computeLayout(config))` on every dependency change, so a stable
|
|
621
|
+
// `const config = { ... }` benefits enormously here.
|
|
622
|
+
//
|
|
623
|
+
// 2. `subLayoutCache` (LRU by content hash): each directed cluster sub-layout
|
|
624
|
+
// runs the full layered pipeline. When a subsequent computeLayout call
|
|
625
|
+
// contains a leaf directed cluster whose nodes/edges/direction/spacing
|
|
626
|
+
// haven't changed, we can reuse the cached LayeredPassResult instead of
|
|
627
|
+
// re-running the pipeline. Only LEAF sub-layouts (no further nested
|
|
628
|
+
// clusters) are cached — keeps the key derivation simple and the cached
|
|
629
|
+
// object stable. The merge code in `layoutNested` always spreads sub
|
|
630
|
+
// results into fresh objects, so cached entries are never mutated.
|
|
631
|
+
const fullLayoutCache = new WeakMap();
|
|
632
|
+
const SUB_LAYOUT_CACHE_MAX = 64;
|
|
633
|
+
const subLayoutCache = new Map();
|
|
634
|
+
function buildSubLayoutKey(subNodes, subEdges, direction, spacing) {
|
|
635
|
+
const parts = [
|
|
636
|
+
direction,
|
|
637
|
+
`s${spacing.nodeGap}|${spacing.layerGap}|${spacing.clusterPadding}|${spacing.cornerRadius}|${spacing.backEdgeLaneGap}`
|
|
638
|
+
];
|
|
639
|
+
for (const n of subNodes) {
|
|
640
|
+
parts.push(`N|${n.id}|${n.label}|${n.description ?? ''}|${n.width ?? ''}|${n.height ?? ''}|${n.variant ?? ''}|${n.color ?? ''}|${n.state ?? ''}`);
|
|
641
|
+
}
|
|
642
|
+
for (const e of subEdges) {
|
|
643
|
+
const wp = e.waypoint;
|
|
644
|
+
parts.push(`E|${e.from}|${e.to}|${e.label ?? ''}|${e.loop ?? ''}|${e.arrow ?? ''}|${e.dashed ?? ''}|${e.color ?? ''}|${wp
|
|
645
|
+
? `W|${wp.id ?? ''}|${wp.label}|${wp.description ?? ''}|${wp.position ?? ''}|${wp.width ?? ''}|${wp.height ?? ''}|${wp.variant ?? ''}|${wp.color ?? ''}`
|
|
646
|
+
: ''}`);
|
|
647
|
+
}
|
|
648
|
+
return parts.join('||');
|
|
649
|
+
}
|
|
650
|
+
function getSubLayoutFromCache(key) {
|
|
651
|
+
const cached = subLayoutCache.get(key);
|
|
652
|
+
if (!cached)
|
|
653
|
+
return undefined;
|
|
654
|
+
subLayoutCache.delete(key);
|
|
655
|
+
subLayoutCache.set(key, cached);
|
|
656
|
+
return cached;
|
|
657
|
+
}
|
|
658
|
+
function setSubLayoutInCache(key, result) {
|
|
659
|
+
if (subLayoutCache.size >= SUB_LAYOUT_CACHE_MAX) {
|
|
660
|
+
const oldest = subLayoutCache.keys().next().value;
|
|
661
|
+
if (oldest !== undefined)
|
|
662
|
+
subLayoutCache.delete(oldest);
|
|
663
|
+
}
|
|
664
|
+
subLayoutCache.set(key, result);
|
|
665
|
+
}
|
|
666
|
+
/** True iff `inner.nodes` is a strict subset (different set, all nodes
|
|
667
|
+
* present) of `outer.nodes`. Used to detect cluster nesting. */
|
|
668
|
+
function isContainedIn(inner, outer) {
|
|
669
|
+
if (inner === outer)
|
|
670
|
+
return false;
|
|
671
|
+
const outerSet = new Set(outer.nodes);
|
|
672
|
+
if (!inner.nodes.every((n) => outerSet.has(n)))
|
|
673
|
+
return false;
|
|
674
|
+
if (inner.nodes.length === outer.nodes.length)
|
|
675
|
+
return false;
|
|
676
|
+
return true;
|
|
677
|
+
}
|
|
678
|
+
/** Recursive layered layout. Handles arbitrary nesting of directed clusters
|
|
679
|
+
* (and flat clusters inside directed clusters). At each level:
|
|
680
|
+
* 1. Sub-layout each top-level directed cluster recursively, passing any
|
|
681
|
+
* cluster whose nodes live entirely inside it as nested context.
|
|
682
|
+
* 2. Run an outer pass with super-nodes for those top-level directed
|
|
683
|
+
* clusters and any flat clusters that live at this level.
|
|
684
|
+
* 3. Merge inner sub-layouts back into outer coordinates. */
|
|
685
|
+
function layoutNested(nodes, edges, allClusters, direction, spacing) {
|
|
686
|
+
const nodeIdSet = new Set(nodes.map((n) => n.id));
|
|
687
|
+
const localClusters = allClusters.filter((c) => c.nodes.every((n) => nodeIdSet.has(n)));
|
|
688
|
+
const directedLocal = localClusters.filter((c) => c.direction);
|
|
689
|
+
if (directedLocal.length === 0) {
|
|
690
|
+
const flatLocal = localClusters.filter((c) => !c.direction);
|
|
691
|
+
return layoutLayeredPass(nodes, edges, flatLocal, direction, spacing);
|
|
692
|
+
}
|
|
693
|
+
// Top-level directed clusters at this scope: not strictly contained in any
|
|
694
|
+
// other directed cluster from the same scope.
|
|
695
|
+
const topLevelDirected = directedLocal.filter((inner) => !directedLocal.some((outer) => isContainedIn(inner, outer)));
|
|
696
|
+
const directedClusterById = new Map();
|
|
697
|
+
const memberToDirectedCluster = new Map();
|
|
698
|
+
for (const cluster of topLevelDirected) {
|
|
699
|
+
directedClusterById.set(cluster.id, cluster);
|
|
700
|
+
for (const nodeId of cluster.nodes) {
|
|
701
|
+
memberToDirectedCluster.set(nodeId, cluster);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
const subLayouts = new Map();
|
|
705
|
+
const subSpacings = new Map();
|
|
706
|
+
for (const cluster of topLevelDirected) {
|
|
707
|
+
const memberSet = new Set(cluster.nodes);
|
|
708
|
+
const subNodes = nodes.filter((n) => memberSet.has(n.id));
|
|
709
|
+
const subEdges = edges.filter((e) => memberSet.has(e.from) && memberSet.has(e.to));
|
|
710
|
+
// Any cluster (flat or directed) whose nodes all live inside this
|
|
711
|
+
// cluster is nested context for the recursive call.
|
|
712
|
+
const nestedClusters = allClusters.filter((c) => c !== cluster && c.nodes.every((n) => memberSet.has(n)));
|
|
713
|
+
const subSpacing = {
|
|
714
|
+
nodeGap: cluster.spacing?.nodeGap ?? spacing.nodeGap,
|
|
715
|
+
layerGap: cluster.spacing?.layerGap ?? spacing.layerGap,
|
|
716
|
+
clusterPadding: cluster.spacing?.clusterPadding ?? spacing.clusterPadding,
|
|
717
|
+
cornerRadius: cluster.spacing?.cornerRadius ?? spacing.cornerRadius,
|
|
718
|
+
backEdgeLaneGap: cluster.spacing?.backEdgeLaneGap ?? spacing.backEdgeLaneGap
|
|
719
|
+
};
|
|
720
|
+
subSpacings.set(cluster.id, subSpacing);
|
|
721
|
+
// Cache leaf sub-layouts (no further nested clusters) by content. The
|
|
722
|
+
// merge code below always spreads the cached result into fresh objects,
|
|
723
|
+
// so the cached entry stays immutable across calls.
|
|
724
|
+
let subResult;
|
|
725
|
+
if (nestedClusters.length === 0) {
|
|
726
|
+
const key = buildSubLayoutKey(subNodes, subEdges, cluster.direction, subSpacing);
|
|
727
|
+
const cached = getSubLayoutFromCache(key);
|
|
728
|
+
if (cached) {
|
|
729
|
+
subResult = cached;
|
|
730
|
+
}
|
|
731
|
+
else {
|
|
732
|
+
subResult = layoutNested(subNodes, subEdges, [], cluster.direction, subSpacing);
|
|
733
|
+
setSubLayoutInCache(key, subResult);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
else {
|
|
737
|
+
subResult = layoutNested(subNodes, subEdges, nestedClusters, cluster.direction, subSpacing);
|
|
738
|
+
}
|
|
739
|
+
subLayouts.set(cluster.id, subResult);
|
|
740
|
+
}
|
|
741
|
+
const outerNodes = [];
|
|
742
|
+
const seenSuperClusters = new Set();
|
|
743
|
+
for (const node of nodes) {
|
|
744
|
+
const owner = memberToDirectedCluster.get(node.id);
|
|
745
|
+
if (owner) {
|
|
746
|
+
if (!seenSuperClusters.has(owner.id)) {
|
|
747
|
+
const sub = subLayouts.get(owner.id);
|
|
748
|
+
const subSpacing = subSpacings.get(owner.id);
|
|
749
|
+
const subW = sub.bbox.maxX - sub.bbox.minX;
|
|
750
|
+
const subH = sub.bbox.maxY - sub.bbox.minY;
|
|
751
|
+
const labelOnLeft = owner.labelPosition === 'left';
|
|
752
|
+
const labelPad = owner.label ? 32 : 0;
|
|
753
|
+
const padTop = labelOnLeft ? 0 : labelPad;
|
|
754
|
+
const padLeft = labelOnLeft ? labelPad : 0;
|
|
755
|
+
outerNodes.push({
|
|
756
|
+
id: SUPER_NODE_PREFIX + owner.id,
|
|
757
|
+
label: '',
|
|
758
|
+
width: subW + subSpacing.clusterPadding * 2 + padLeft,
|
|
759
|
+
height: subH + subSpacing.clusterPadding * 2 + padTop
|
|
760
|
+
});
|
|
761
|
+
seenSuperClusters.add(owner.id);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
else {
|
|
765
|
+
outerNodes.push(node);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
// Edges internal to a directed cluster are handled by its sub-layout.
|
|
769
|
+
const outerEdges = [];
|
|
770
|
+
for (const edge of edges) {
|
|
771
|
+
const fromCluster = memberToDirectedCluster.get(edge.from);
|
|
772
|
+
const toCluster = memberToDirectedCluster.get(edge.to);
|
|
773
|
+
if (fromCluster && toCluster && fromCluster.id === toCluster.id)
|
|
774
|
+
continue;
|
|
775
|
+
outerEdges.push({
|
|
776
|
+
...edge,
|
|
777
|
+
from: fromCluster ? SUPER_NODE_PREFIX + fromCluster.id : edge.from,
|
|
778
|
+
to: toCluster ? SUPER_NODE_PREFIX + toCluster.id : edge.to
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
// Flat clusters that live at THIS level — i.e. not inside any top-level
|
|
782
|
+
// directed cluster, where they would have been handled by the recursive
|
|
783
|
+
// sub-layout instead.
|
|
784
|
+
const flatLocal = localClusters.filter((c) => !c.direction && !topLevelDirected.some((td) => isContainedIn(c, td)));
|
|
785
|
+
const superNodeIds = new Set();
|
|
786
|
+
for (const cluster of topLevelDirected) {
|
|
787
|
+
superNodeIds.add(SUPER_NODE_PREFIX + cluster.id);
|
|
788
|
+
}
|
|
789
|
+
// Compute outer positions first (steps 1-7 of the layered pipeline) so we
|
|
790
|
+
// can derive global inner-node positions from the super-node placements,
|
|
791
|
+
// THEN run edge routing with overrides that re-anchor cross-boundary back
|
|
792
|
+
// edges to the actual inner nodes instead of the cluster super-node.
|
|
793
|
+
const outerPositions = computeLayeredPositions(outerNodes, outerEdges, flatLocal, direction, spacing);
|
|
794
|
+
const innerExtraPositions = new Map();
|
|
795
|
+
const innerExtraDims = new Map();
|
|
796
|
+
for (const outerNode of outerPositions.positionedNodes) {
|
|
797
|
+
if (!isSuperNodeId(outerNode.id))
|
|
798
|
+
continue;
|
|
799
|
+
const clusterId = outerNode.id.slice(SUPER_NODE_PREFIX.length);
|
|
800
|
+
const sub = subLayouts.get(clusterId);
|
|
801
|
+
const cluster = directedClusterById.get(clusterId);
|
|
802
|
+
const subSpacing = subSpacings.get(clusterId);
|
|
803
|
+
const labelOnLeft = cluster.labelPosition === 'left';
|
|
804
|
+
const labelPad = cluster.label ? 32 : 0;
|
|
805
|
+
const padTop = labelOnLeft ? 0 : labelPad;
|
|
806
|
+
const padLeft = labelOnLeft ? labelPad : 0;
|
|
807
|
+
const offsetX = outerNode.x + subSpacing.clusterPadding + padLeft - sub.bbox.minX;
|
|
808
|
+
const offsetY = outerNode.y + subSpacing.clusterPadding + padTop - sub.bbox.minY;
|
|
809
|
+
for (const inner of sub.positionedNodes) {
|
|
810
|
+
innerExtraPositions.set(inner.id, { x: inner.x + offsetX, y: inner.y + offsetY });
|
|
811
|
+
innerExtraDims.set(inner.id, { w: inner.width, h: inner.height });
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
// Build back-edge anchor overrides: each cross-boundary edge is keyed by
|
|
815
|
+
// its outer-pass form (super-node IDs), with the override pointing back at
|
|
816
|
+
// the original inner node ID. The router applies these only to back edges,
|
|
817
|
+
// so forward cross-boundary edges keep the super-node anchoring.
|
|
818
|
+
const backEdgeAnchorOverrides = new Map();
|
|
819
|
+
for (const edge of edges) {
|
|
820
|
+
const fromCluster = memberToDirectedCluster.get(edge.from);
|
|
821
|
+
const toCluster = memberToDirectedCluster.get(edge.to);
|
|
822
|
+
if (fromCluster && toCluster && fromCluster.id === toCluster.id)
|
|
823
|
+
continue;
|
|
824
|
+
if (!fromCluster && !toCluster)
|
|
825
|
+
continue;
|
|
826
|
+
const outerKey = `${fromCluster ? SUPER_NODE_PREFIX + fromCluster.id : edge.from}->${toCluster ? SUPER_NODE_PREFIX + toCluster.id : edge.to}`;
|
|
827
|
+
backEdgeAnchorOverrides.set(outerKey, {
|
|
828
|
+
source: fromCluster ? edge.from : undefined,
|
|
829
|
+
target: toCluster ? edge.to : undefined
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
const outerPass = finishLayeredPass(outerEdges, outerPositions, direction, spacing, superNodeIds, {
|
|
833
|
+
extraPositions: innerExtraPositions,
|
|
834
|
+
extraDims: innerExtraDims,
|
|
835
|
+
backEdgeAnchorOverrides
|
|
836
|
+
});
|
|
837
|
+
const positionedNodes = [];
|
|
838
|
+
const positionedClusters = [...outerPass.positionedClusters];
|
|
839
|
+
const positionedEdges = [];
|
|
840
|
+
const waypoints = [...outerPass.waypoints];
|
|
841
|
+
for (const outerNode of outerPass.positionedNodes) {
|
|
842
|
+
if (isSuperNodeId(outerNode.id)) {
|
|
843
|
+
const clusterId = outerNode.id.slice(SUPER_NODE_PREFIX.length);
|
|
844
|
+
const cluster = directedClusterById.get(clusterId);
|
|
845
|
+
const sub = subLayouts.get(clusterId);
|
|
846
|
+
const subSpacing = subSpacings.get(clusterId);
|
|
847
|
+
const labelOnLeft = cluster.labelPosition === 'left';
|
|
848
|
+
const labelPad = cluster.label ? 32 : 0;
|
|
849
|
+
const padTop = labelOnLeft ? 0 : labelPad;
|
|
850
|
+
const padLeft = labelOnLeft ? labelPad : 0;
|
|
851
|
+
const offsetX = outerNode.x + subSpacing.clusterPadding + padLeft - sub.bbox.minX;
|
|
852
|
+
const offsetY = outerNode.y + subSpacing.clusterPadding + padTop - sub.bbox.minY;
|
|
853
|
+
for (const inner of sub.positionedNodes) {
|
|
854
|
+
positionedNodes.push({
|
|
855
|
+
...inner,
|
|
856
|
+
x: inner.x + offsetX,
|
|
857
|
+
y: inner.y + offsetY
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
for (const innerCluster of sub.positionedClusters) {
|
|
861
|
+
positionedClusters.push({
|
|
862
|
+
...innerCluster,
|
|
863
|
+
x: innerCluster.x + offsetX,
|
|
864
|
+
y: innerCluster.y + offsetY
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
for (const innerEdge of sub.positionedEdges) {
|
|
868
|
+
positionedEdges.push({
|
|
869
|
+
...innerEdge,
|
|
870
|
+
path: shiftSvgPath(innerEdge.path, offsetX, offsetY),
|
|
871
|
+
labelX: innerEdge.labelX + offsetX,
|
|
872
|
+
labelY: innerEdge.labelY + offsetY,
|
|
873
|
+
bounds: innerEdge.bounds
|
|
874
|
+
? {
|
|
875
|
+
minX: innerEdge.bounds.minX + offsetX,
|
|
876
|
+
minY: innerEdge.bounds.minY + offsetY,
|
|
877
|
+
maxX: innerEdge.bounds.maxX + offsetX,
|
|
878
|
+
maxY: innerEdge.bounds.maxY + offsetY
|
|
879
|
+
}
|
|
880
|
+
: undefined
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
for (const innerWp of sub.waypoints) {
|
|
884
|
+
waypoints.push({
|
|
885
|
+
...innerWp,
|
|
886
|
+
x: innerWp.x + offsetX,
|
|
887
|
+
y: innerWp.y + offsetY
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
// Visible cluster chrome over the super-node slot.
|
|
891
|
+
positionedClusters.push({
|
|
892
|
+
id: cluster.id,
|
|
893
|
+
x: outerNode.x,
|
|
894
|
+
y: outerNode.y,
|
|
895
|
+
width: outerNode.width,
|
|
896
|
+
height: outerNode.height,
|
|
897
|
+
label: cluster.label,
|
|
898
|
+
labelPosition: cluster.labelPosition,
|
|
899
|
+
iconComponent: cluster.iconComponent,
|
|
900
|
+
color: cluster.color || 'neutral',
|
|
901
|
+
dashed: cluster.dashed ?? true
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
else {
|
|
905
|
+
positionedNodes.push(outerNode);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
// Cross-boundary edges keep their super-node anchoring: pointing the arrow
|
|
909
|
+
// at the cluster as a whole reads more naturally than re-anchoring to a
|
|
910
|
+
// specific inner node, which would force a Z-shape that conflicts with the
|
|
911
|
+
// inner flow.
|
|
912
|
+
positionedEdges.push(...outerPass.positionedEdges);
|
|
913
|
+
const bbox = computeFullPassBounds(positionedNodes, positionedClusters, positionedEdges, waypoints);
|
|
914
|
+
return { positionedNodes, positionedClusters, positionedEdges, waypoints, bbox };
|
|
915
|
+
}
|
|
916
|
+
function layoutLayered(config) {
|
|
917
|
+
const direction = config.direction || 'TB';
|
|
918
|
+
const spacing = {
|
|
919
|
+
nodeGap: config.spacing?.nodeGap ?? DEFAULT_NODE_GAP,
|
|
920
|
+
layerGap: config.spacing?.layerGap ?? DEFAULT_LAYER_GAP,
|
|
921
|
+
clusterPadding: config.spacing?.clusterPadding ?? DEFAULT_CLUSTER_PADDING,
|
|
922
|
+
cornerRadius: config.spacing?.cornerRadius ?? DEFAULT_CORNER_RADIUS,
|
|
923
|
+
backEdgeLaneGap: config.spacing?.backEdgeLaneGap ?? DEFAULT_BACK_EDGE_LANE_GAP
|
|
924
|
+
};
|
|
925
|
+
const nested = layoutNested(config.nodes, config.edges, config.clusters ?? [], direction, spacing);
|
|
926
|
+
const positionedNodes = nested.positionedNodes;
|
|
927
|
+
const positionedClusters = nested.positionedClusters;
|
|
928
|
+
const positionedEdges = nested.positionedEdges;
|
|
929
|
+
const waypoints = nested.waypoints;
|
|
930
|
+
const globalPositions = new Map();
|
|
931
|
+
const globalDims = new Map();
|
|
932
|
+
for (const n of positionedNodes) {
|
|
933
|
+
globalPositions.set(n.id, { x: n.x, y: n.y });
|
|
934
|
+
globalDims.set(n.id, { w: n.width, h: n.height });
|
|
935
|
+
}
|
|
936
|
+
const annotations = resolveAnnotations(config, globalPositions, globalDims);
|
|
436
937
|
// Compute viewBox with full bounds coverage
|
|
437
938
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
438
939
|
// Include all node bounds
|
|
@@ -443,7 +944,7 @@ function layoutLayered(config) {
|
|
|
443
944
|
maxY = Math.max(maxY, n.y + n.height);
|
|
444
945
|
}
|
|
445
946
|
// Include all cluster bounds
|
|
446
|
-
for (const c of
|
|
947
|
+
for (const c of positionedClusters) {
|
|
447
948
|
minX = Math.min(minX, c.x);
|
|
448
949
|
minY = Math.min(minY, c.y);
|
|
449
950
|
maxX = Math.max(maxX, c.x + c.width);
|
|
@@ -466,6 +967,14 @@ function layoutLayered(config) {
|
|
|
466
967
|
maxX = Math.max(maxX, e.labelX + labelW / 2);
|
|
467
968
|
maxY = Math.max(maxY, e.labelY + 7);
|
|
468
969
|
}
|
|
970
|
+
// Back-edge paths can extend outside the node bbox; expand viewBox to fit
|
|
971
|
+
const pathBounds = e.bounds ?? extractPathBounds(e.path);
|
|
972
|
+
if (pathBounds) {
|
|
973
|
+
minX = Math.min(minX, pathBounds.minX);
|
|
974
|
+
minY = Math.min(minY, pathBounds.minY);
|
|
975
|
+
maxX = Math.max(maxX, pathBounds.maxX);
|
|
976
|
+
maxY = Math.max(maxY, pathBounds.maxY);
|
|
977
|
+
}
|
|
469
978
|
}
|
|
470
979
|
// Handle empty diagram
|
|
471
980
|
if (minX === Infinity) {
|
|
@@ -474,24 +983,36 @@ function layoutLayered(config) {
|
|
|
474
983
|
maxX = MARGIN * 2;
|
|
475
984
|
maxY = MARGIN * 2;
|
|
476
985
|
}
|
|
986
|
+
// Include waypoint bounds
|
|
987
|
+
for (const w of waypoints) {
|
|
988
|
+
minX = Math.min(minX, w.x);
|
|
989
|
+
minY = Math.min(minY, w.y);
|
|
990
|
+
maxX = Math.max(maxX, w.x + w.width);
|
|
991
|
+
maxY = Math.max(maxY, w.y + w.height);
|
|
992
|
+
}
|
|
477
993
|
// Shift everything so all coordinates are non-negative
|
|
478
994
|
const shiftX = minX < 0 ? -minX + MARGIN : 0;
|
|
479
995
|
const shiftY = minY < 0 ? -minY + MARGIN : 0;
|
|
480
996
|
if (shiftX > 0 || shiftY > 0) {
|
|
481
|
-
shiftAllPositions(positionedNodes,
|
|
997
|
+
shiftAllPositions(positionedNodes, positionedClusters, annotations, positionedEdges, shiftX, shiftY);
|
|
998
|
+
for (const w of waypoints) {
|
|
999
|
+
w.x += shiftX;
|
|
1000
|
+
w.y += shiftY;
|
|
1001
|
+
}
|
|
482
1002
|
maxX += shiftX;
|
|
483
1003
|
maxY += shiftY;
|
|
484
1004
|
}
|
|
485
1005
|
return {
|
|
486
1006
|
nodes: positionedNodes,
|
|
487
1007
|
edges: positionedEdges,
|
|
488
|
-
clusters,
|
|
1008
|
+
clusters: positionedClusters,
|
|
489
1009
|
swimlanes: [],
|
|
490
1010
|
regions: [],
|
|
491
1011
|
annotations,
|
|
492
1012
|
messages: [],
|
|
493
1013
|
lifelines: [],
|
|
494
1014
|
positionedFragments: [],
|
|
1015
|
+
waypoints,
|
|
495
1016
|
viewBox: { width: maxX + MARGIN, height: maxY + MARGIN }
|
|
496
1017
|
};
|
|
497
1018
|
}
|
|
@@ -680,6 +1201,7 @@ function layoutSwimlane(config) {
|
|
|
680
1201
|
messages: [],
|
|
681
1202
|
lifelines: [],
|
|
682
1203
|
positionedFragments: [],
|
|
1204
|
+
waypoints: [],
|
|
683
1205
|
viewBox: {
|
|
684
1206
|
width: Math.max(totalWidth, maxViewX + MARGIN),
|
|
685
1207
|
height: Math.max(totalHeight, maxViewY + MARGIN)
|
|
@@ -718,19 +1240,60 @@ function shiftAllPositions(nodes, clusters, annotations, edges, dx, dy) {
|
|
|
718
1240
|
e.labelY += dy;
|
|
719
1241
|
// Shift SVG path coordinates
|
|
720
1242
|
e.path = shiftSvgPath(e.path, dx, dy);
|
|
1243
|
+
if (e.bounds) {
|
|
1244
|
+
e.bounds = {
|
|
1245
|
+
minX: e.bounds.minX + dx,
|
|
1246
|
+
minY: e.bounds.minY + dy,
|
|
1247
|
+
maxX: e.bounds.maxX + dx,
|
|
1248
|
+
maxY: e.bounds.maxY + dy
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
const PATH_CMD_RE = /([MLQTSC])([^MLQTSCAHVZmlqtscahvz]*)/g;
|
|
1254
|
+
const NUM_RE = /-?\d+(?:\.\d+)?/g;
|
|
1255
|
+
/** Extract min/max x/y from an SVG path string (absolute commands only).
|
|
1256
|
+
* Used to expand viewBox to include back-edge paths that extend beyond node bounds.
|
|
1257
|
+
*/
|
|
1258
|
+
function extractPathBounds(path) {
|
|
1259
|
+
if (!path)
|
|
1260
|
+
return null;
|
|
1261
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
1262
|
+
for (const match of path.matchAll(PATH_CMD_RE)) {
|
|
1263
|
+
const args = match[2] ?? '';
|
|
1264
|
+
const nums = args.match(NUM_RE);
|
|
1265
|
+
if (!nums)
|
|
1266
|
+
continue;
|
|
1267
|
+
for (let i = 0; i + 1 < nums.length; i += 2) {
|
|
1268
|
+
const x = parseFloat(nums[i]);
|
|
1269
|
+
const y = parseFloat(nums[i + 1]);
|
|
1270
|
+
if (x < minX)
|
|
1271
|
+
minX = x;
|
|
1272
|
+
if (x > maxX)
|
|
1273
|
+
maxX = x;
|
|
1274
|
+
if (y < minY)
|
|
1275
|
+
minY = y;
|
|
1276
|
+
if (y > maxY)
|
|
1277
|
+
maxY = y;
|
|
1278
|
+
}
|
|
721
1279
|
}
|
|
1280
|
+
if (minX === Infinity)
|
|
1281
|
+
return null;
|
|
1282
|
+
return { minX, minY, maxX, maxY };
|
|
722
1283
|
}
|
|
723
|
-
/** Shift all absolute coordinates in an SVG path string by (dx, dy)
|
|
1284
|
+
/** Shift all absolute coordinates in an SVG path string by (dx, dy).
|
|
1285
|
+
* Handles multi-pair commands (Q, C, S, T) where the args contain more than one (x, y).
|
|
1286
|
+
*/
|
|
724
1287
|
function shiftSvgPath(path, dx, dy) {
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
return
|
|
732
|
-
}
|
|
733
|
-
return `${cmd} ${
|
|
1288
|
+
return path.replace(PATH_CMD_RE, (_match, cmd, args) => {
|
|
1289
|
+
const nums = args.match(NUM_RE);
|
|
1290
|
+
if (!nums || nums.length === 0)
|
|
1291
|
+
return cmd;
|
|
1292
|
+
const shifted = nums.map((n, i) => {
|
|
1293
|
+
const v = parseFloat(n);
|
|
1294
|
+
return (i % 2 === 0 ? v + dx : v + dy).toString();
|
|
1295
|
+
});
|
|
1296
|
+
return `${cmd} ${shifted.join(' ')}`;
|
|
734
1297
|
});
|
|
735
1298
|
}
|
|
736
1299
|
function buildNodeDims(nodes) {
|
|
@@ -755,6 +1318,7 @@ function buildPositionedNodes(nodes, positions, nodeDims) {
|
|
|
755
1318
|
label: node.label,
|
|
756
1319
|
description: node.description,
|
|
757
1320
|
icon: node.icon,
|
|
1321
|
+
iconComponent: node.iconComponent,
|
|
758
1322
|
variant: node.variant || 'default',
|
|
759
1323
|
color: node.color || 'neutral',
|
|
760
1324
|
state: node.state || 'default'
|
|
@@ -960,15 +1524,22 @@ function layoutSequence(config) {
|
|
|
960
1524
|
messages: positionedMessages,
|
|
961
1525
|
lifelines,
|
|
962
1526
|
positionedFragments,
|
|
1527
|
+
waypoints: [],
|
|
963
1528
|
viewBox: { width: totalWidth, height: totalHeight }
|
|
964
1529
|
};
|
|
965
1530
|
}
|
|
966
1531
|
// ── Public API ─────────────────────────────────────────────
|
|
967
1532
|
export function computeLayout(config) {
|
|
1533
|
+
const cached = fullLayoutCache.get(config);
|
|
1534
|
+
if (cached)
|
|
1535
|
+
return cached;
|
|
1536
|
+
let result;
|
|
968
1537
|
if (config.layout === 'sequence')
|
|
969
|
-
|
|
970
|
-
if (config.layout === 'swimlane')
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1538
|
+
result = layoutSequence(config);
|
|
1539
|
+
else if (config.layout === 'swimlane')
|
|
1540
|
+
result = layoutSwimlane(config);
|
|
1541
|
+
else
|
|
1542
|
+
result = layoutLayered(config);
|
|
1543
|
+
fullLayoutCache.set(config, result);
|
|
1544
|
+
return result;
|
|
974
1545
|
}
|