@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.
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 +1 -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 +222 -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 +633 -62
  24. package/dist/diagram/types.d.ts +58 -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,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
- // 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
+ 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
- // ── 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
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
- // 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);
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 clusters) {
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, clusters, annotations, positionedEdges, shiftX, shiftY);
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
- // 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}`;
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
- return layoutSequence(config);
970
- if (config.layout === 'swimlane') {
971
- return layoutSwimlane(config);
972
- }
973
- return layoutLayered(config);
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
  }