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