@weasel-js/diagram 1.9.2 → 1.9.4

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/README.md CHANGED
@@ -108,6 +108,22 @@ starting `at`, and a minimum `width`/`height`; `lines` is shorthand for one
108
108
  label row each. An edge can name `fromPort`/`toPort`, its own `router`,
109
109
  `waypoints` and `labelPlacement`.
110
110
 
111
+ `groups` draws a box around some nodes, with an optional `label` inside it:
112
+
113
+ ```tsx
114
+ diagramScene({ nodes, edges, groups: [{ id: 'ingest', members: ['fetch', 'decode'], label: 'ingest' }] });
115
+ ```
116
+
117
+ `layered` and `tree` keep a group's members together. Each group gets a column
118
+ on the cross axis that nothing else enters, in every rank from its first
119
+ member's to its last, and rank gaps widen where a box needs the room. The box
120
+ is an ordinary scene node that depends on its members and derives its pose from
121
+ theirs (`groupDerivePose`), so dragging a member carries the box along. An edge
122
+ whose `from` or `to` names a group meets the box, and ranks against the
123
+ group's first stage. Groups are flat, and `force` ignores them. A scene built
124
+ any other way joins in with a node whose trait carries `group` and whose
125
+ `dependsOn` lists its members.
126
+
111
127
  Text is sized from character counts unless `measure` is given, so layout runs
112
128
  without a canvas. The layout defaults to `layered`, picked by name or passed as
113
129
  any `LayoutFn`; `layoutOptions` merge over `order: 'barycenter'`, and
@@ -1,4 +1,5 @@
1
1
  import { rectPath, polygonFromPoints, PathBuilder, PATH_M, PATH_L, PATH_C, flattenCubic, PATH_Q, PATH_Z, AUTO_POSE_DESCRIPTOR, rotatePoint, createSimulation } from '@weasel-js/core/math';
2
+ import { cssFamilyName } from '@weasel-js/font';
2
3
 
3
4
  // src/outline.ts
4
5
  var LEAN = 0.2;
@@ -188,6 +189,7 @@ var dataKeyReader = (node) => {
188
189
  const trait = data[DIAGRAM_TRAIT_KEY];
189
190
  if (trait === null || typeof trait !== "object") return null;
190
191
  if ("from" in trait && "to" in trait) return null;
192
+ if ("label" in trait || "anchor" in trait) return null;
191
193
  return trait;
192
194
  };
193
195
  function createDiagramNodes(entries) {
@@ -272,15 +274,13 @@ function portOf(node, pose, portId, opts = {}) {
272
274
  function typeOf(spec) {
273
275
  return spec.type === void 0 ? {} : { type: spec.type };
274
276
  }
275
-
276
- // src/body.ts
277
277
  var DEFAULT_PADDING = 8;
278
278
  var DEFAULT_GAP = 4;
279
279
  var FALLBACK_LINE = 16;
280
280
  function canvasMeasure(ctx) {
281
281
  return (text, style) => {
282
282
  const size = style.fontSize ?? FALLBACK_LINE;
283
- ctx.font = `${style.bold === true ? "bold " : ""}${size}px ${style.fontFamily ?? "sans-serif"}`;
283
+ ctx.font = `${style.bold === true ? "bold " : ""}${size}px ${cssFamilyName(style.fontFamily ?? "sans-serif")}`;
284
284
  return { width: ctx.measureText(text).width, height: Math.ceil(size * 1.2) };
285
285
  };
286
286
  }
@@ -487,6 +487,30 @@ function settle(graph, slots, opts = {}) {
487
487
  }
488
488
  return translated(graph, placed, pinned, tolerance);
489
489
  }
490
+ function untilSettled(once, graph, opts = {}, passes = 8) {
491
+ const first = once(graph, opts);
492
+ if (first.size === 0) return first;
493
+ const at = new Map(first);
494
+ for (let pass = 1; pass < passes; pass++) {
495
+ const nodes = graph.nodes.map((node) => {
496
+ const to = at.get(node.id);
497
+ return to === void 0 ? node : { ...node, bounds: { ...node.bounds, x: to.x, y: to.y } };
498
+ });
499
+ const byId = new Map(nodes.map((n) => [n.id, n]));
500
+ const next = once({ ...graph, nodes, node: (id) => byId.get(id) }, opts);
501
+ if (next.size === 0) break;
502
+ for (const [id, to] of next) at.set(id, to);
503
+ }
504
+ const tolerance = opts.tolerance ?? DEFAULT_TOLERANCE;
505
+ const out = /* @__PURE__ */ new Map();
506
+ for (const node of graph.nodes) {
507
+ const to = at.get(node.id);
508
+ if (to === void 0) continue;
509
+ if (Math.abs(to.x - node.bounds.x) <= tolerance && Math.abs(to.y - node.bounds.y) <= tolerance) continue;
510
+ out.set(node.id, to);
511
+ }
512
+ return out;
513
+ }
490
514
  function translated(graph, placed, pinned, tolerance = DEFAULT_TOLERANCE) {
491
515
  const anchor = anchorDelta(graph, placed, pinned);
492
516
  const out = /* @__PURE__ */ new Map();
@@ -543,13 +567,313 @@ function graphOrder(graph) {
543
567
  graph.nodes.forEach((node, i) => index.set(node.id, i));
544
568
  return (id) => index.get(id) ?? Number.MAX_SAFE_INTEGER;
545
569
  }
570
+
571
+ // src/cluster.ts
572
+ function insetOf(inset) {
573
+ if (typeof inset === "number") return { top: inset, right: inset, bottom: inset, left: inset };
574
+ return {
575
+ top: inset?.top ?? DEFAULT_GROUP_INSET,
576
+ right: inset?.right ?? DEFAULT_GROUP_INSET,
577
+ bottom: inset?.bottom ?? DEFAULT_GROUP_INSET,
578
+ left: inset?.left ?? DEFAULT_GROUP_INSET
579
+ };
580
+ }
581
+ var DEFAULT_GROUP_INSET = 12;
582
+ function canonical(inset, axes) {
583
+ if (axes.rank === "y") {
584
+ return axes.sign === 1 ? { crossLo: inset.left, crossHi: inset.right, rankLo: inset.top, rankHi: inset.bottom } : { crossLo: inset.left, crossHi: inset.right, rankLo: inset.bottom, rankHi: inset.top };
585
+ }
586
+ return axes.sign === 1 ? { crossLo: inset.top, crossHi: inset.bottom, rankLo: inset.left, rankHi: inset.right } : { crossLo: inset.top, crossHi: inset.bottom, rankLo: inset.right, rankHi: inset.left };
587
+ }
588
+ function rankGaps(graph, rows, axes, rankGap) {
589
+ const gaps = rows.slice(1).map(() => rankGap);
590
+ const ends = /* @__PURE__ */ new Map();
591
+ const starts = /* @__PURE__ */ new Map();
592
+ const reach = (span, side) => {
593
+ const own = canonical(span.group.inset, axes)[side === "lo" ? "rankLo" : "rankHi"];
594
+ const flush = span.children.filter((c) => c[side] === span[side]);
595
+ return own + Math.max(0, ...flush.map((c) => reach(c, side)));
596
+ };
597
+ for (const span of groupSpans(graph, rows)) {
598
+ ends.set(span.hi, Math.max(ends.get(span.hi) ?? 0, reach(span, "hi")));
599
+ starts.set(span.lo, Math.max(starts.get(span.lo) ?? 0, reach(span, "lo")));
600
+ }
601
+ for (let i = 0; i < gaps.length; i++) {
602
+ gaps[i] = Math.max(gaps[i], rankGap + (ends.get(i) ?? 0) + (starts.get(i + 1) ?? 0));
603
+ }
604
+ return gaps;
605
+ }
606
+ function groupSpans(graph, rows) {
607
+ const rankOf = /* @__PURE__ */ new Map();
608
+ rows.forEach((row, i) => row.forEach((n) => rankOf.set(n.id, i)));
609
+ const spans = [];
610
+ const byId = /* @__PURE__ */ new Map();
611
+ for (const group of graph.groups ?? []) {
612
+ const ranks = group.members.map((id) => rankOf.get(id)).filter((r) => r !== void 0);
613
+ if (ranks.length === 0) continue;
614
+ const span = { group, lo: Math.min(...ranks), hi: Math.max(...ranks), children: [] };
615
+ spans.push(span);
616
+ byId.set(group.id, span);
617
+ }
618
+ for (const span of spans) {
619
+ const parent = span.group.parent === void 0 ? void 0 : byId.get(span.group.parent);
620
+ if (parent === void 0) continue;
621
+ span.parent = parent;
622
+ parent.children.push(span);
623
+ }
624
+ return spans;
625
+ }
626
+ function packClusters(graph, rows, preferred, axes, nodeGap) {
627
+ const spans = groupSpans(graph, rows);
628
+ const spanById = new Map(spans.map((s) => [s.group.id, s]));
629
+ const spanOf = (n) => n.group === void 0 ? void 0 : spanById.get(n.group);
630
+ const width = (n) => extent(n.bounds, axes.cross);
631
+ const pref = (n) => preferred.get(n.id) ?? 0;
632
+ const insets = new Map(spans.map((s) => [s, canonical(s.group.inset, axes)]));
633
+ const centerOf = /* @__PURE__ */ new Map();
634
+ for (const span of spans) {
635
+ const inside = new Set(span.group.members);
636
+ let sum = 0;
637
+ let count = 0;
638
+ for (const row of rows) {
639
+ for (const n of row) {
640
+ if (!inside.has(n.id)) continue;
641
+ sum += pref(n) + width(n) / 2;
642
+ count++;
643
+ }
644
+ }
645
+ centerOf.set(span, count === 0 ? 0 : sum / count);
646
+ }
647
+ const itemsByRow = (owner, among) => rows.map((row, r) => {
648
+ const items = [];
649
+ for (const node of row) {
650
+ if (spanOf(node) === owner) items.push({ kind: "node", node, center: pref(node) + width(node) / 2 });
651
+ }
652
+ for (const span of among) {
653
+ if (r >= span.lo && r <= span.hi) items.push({ kind: "group", span, center: centerOf.get(span) });
654
+ }
655
+ return items.map((item, i) => ({ item, i })).sort((a, b) => a.item.center - b.item.center || a.i - b.i).map(({ item }) => item);
656
+ });
657
+ const settle2 = (byRow, start2, nodeFloor, origin) => {
658
+ const nodeAt = /* @__PURE__ */ new Map();
659
+ let ends = [];
660
+ for (let pass = 0; pass <= start2.size; pass++) {
661
+ let raised = false;
662
+ ends = byRow.map((items) => {
663
+ let cursor = origin;
664
+ for (const item of items) {
665
+ if (item.kind === "node") {
666
+ const x2 = Math.max(nodeFloor(item.node), cursor);
667
+ nodeAt.set(item.node.id, x2);
668
+ cursor = x2 + width(item.node) + nodeGap;
669
+ continue;
670
+ }
671
+ const s = start2.get(item.span);
672
+ const x = Math.max(s, cursor);
673
+ if (x > s) {
674
+ start2.set(item.span, x);
675
+ raised = true;
676
+ }
677
+ cursor = x + outer(item.span) + nodeGap;
678
+ }
679
+ return items.length === 0 ? origin : cursor - nodeGap;
680
+ });
681
+ if (!raised) break;
682
+ }
683
+ return { nodeAt, ends };
684
+ };
685
+ const blocks = /* @__PURE__ */ new Map();
686
+ const blockOf = (span) => {
687
+ const known = blocks.get(span);
688
+ if (known !== void 0) return known;
689
+ const byRow = itemsByRow(span, span.children);
690
+ const childAt = new Map(span.children.map((c) => [c, 0]));
691
+ const { nodeAt, ends } = settle2(byRow, childAt, () => 0, 0);
692
+ const block = { width: Math.max(0, ...ends), nodeAt, childAt };
693
+ byRow.forEach((items, r) => {
694
+ if (items.some((item) => item.kind === "group")) return;
695
+ const shift = (block.width - ends[r]) / 2;
696
+ for (const item of items) {
697
+ if (item.kind === "node") nodeAt.set(item.node.id, nodeAt.get(item.node.id) + shift);
698
+ }
699
+ });
700
+ blocks.set(span, block);
701
+ return block;
702
+ };
703
+ function outer(span) {
704
+ const inset = insets.get(span);
705
+ return blockOf(span).width + inset.crossLo + inset.crossHi;
706
+ }
707
+ const roots = spans.filter((s) => s.parent === void 0);
708
+ const start = new Map(roots.map((s) => [s, centerOf.get(s) - outer(s) / 2]));
709
+ const { nodeAt: at } = settle2(itemsByRow(void 0, roots), start, pref, -Infinity);
710
+ const place = (span, from) => {
711
+ const origin = from + insets.get(span).crossLo;
712
+ const block = blockOf(span);
713
+ for (const [id, local] of block.nodeAt) at.set(id, origin + local);
714
+ for (const [child, local] of block.childAt) place(child, origin + local);
715
+ };
716
+ for (const root of roots) place(root, start.get(root));
717
+ return at;
718
+ }
719
+ function expandGroupEdges(edges, groups) {
720
+ const byId = new Map(groups.map((g) => [g.id, g]));
721
+ if (byId.size === 0) return [...edges];
722
+ const within = (id, group) => {
723
+ if (!byId.has(id)) return group.members.includes(id);
724
+ for (let at = byId.get(id); at !== void 0; at = at.parent === void 0 ? void 0 : byId.get(at.parent)) {
725
+ if (at === group) return true;
726
+ }
727
+ return false;
728
+ };
729
+ const entries = /* @__PURE__ */ new Map();
730
+ const exits = /* @__PURE__ */ new Map();
731
+ for (const g of groups) {
732
+ const inside = new Set(g.members);
733
+ const pointedAt = /* @__PURE__ */ new Set();
734
+ const pointing = /* @__PURE__ */ new Set();
735
+ for (const e of edges) {
736
+ if (inside.has(e.from) && inside.has(e.to) && e.from !== e.to) {
737
+ pointedAt.add(e.to);
738
+ pointing.add(e.from);
739
+ }
740
+ }
741
+ const entry = g.members.filter((m) => !pointedAt.has(m));
742
+ const exit = g.members.filter((m) => !pointing.has(m));
743
+ entries.set(g.id, entry.length > 0 ? entry : g.members);
744
+ exits.set(g.id, exit.length > 0 ? exit : g.members);
745
+ }
746
+ const out = [];
747
+ for (const e of edges) {
748
+ const fromGroup = byId.has(e.from) ? e.from : void 0;
749
+ const toGroup = byId.has(e.to) ? e.to : void 0;
750
+ if (fromGroup === void 0 && toGroup === void 0) {
751
+ out.push(e);
752
+ continue;
753
+ }
754
+ if (fromGroup !== void 0 && within(e.to, byId.get(fromGroup))) continue;
755
+ if (toGroup !== void 0 && within(e.from, byId.get(toGroup))) continue;
756
+ const froms = fromGroup !== void 0 ? exits.get(fromGroup) : [e.from];
757
+ const tos = toGroup !== void 0 ? entries.get(toGroup) : [e.to];
758
+ let k = 0;
759
+ for (const from of froms) {
760
+ for (const to of tos) out.push({ id: `${e.id}#${k++}`, from, to });
761
+ }
762
+ }
763
+ return out;
764
+ }
765
+
766
+ // src/forceGroups.ts
767
+ function resolve(groups, byId) {
768
+ const out = [];
769
+ for (const group of groups) {
770
+ const members = group.members.map((id) => byId.get(id)).filter((b) => b !== void 0);
771
+ if (members.length > 0) out.push({ members, group, children: [] });
772
+ }
773
+ const byGroup = new Map(out.map((g) => [g.group.id, g]));
774
+ for (const g of out) {
775
+ const parent = g.group.parent === void 0 ? void 0 : byGroup.get(g.group.parent);
776
+ if (parent === void 0) continue;
777
+ g.parent = parent;
778
+ parent.children.push(g);
779
+ }
780
+ return out;
781
+ }
782
+ function groupCohesion(groups, byId, strength) {
783
+ const resolved = resolve(groups, byId);
784
+ return (alpha) => {
785
+ for (const { members } of resolved) {
786
+ let cx = 0;
787
+ let cy = 0;
788
+ for (const m of members) {
789
+ cx += m.x;
790
+ cy += m.y;
791
+ }
792
+ cx /= members.length;
793
+ cy /= members.length;
794
+ for (const m of members) {
795
+ m.vx += (cx - m.x) * strength * alpha;
796
+ m.vy += (cy - m.y) * strength * alpha;
797
+ }
798
+ }
799
+ };
800
+ }
801
+ function boxOf(g) {
802
+ let x0 = Infinity;
803
+ let y0 = Infinity;
804
+ let x1 = -Infinity;
805
+ let y1 = -Infinity;
806
+ for (const m of g.members) {
807
+ x0 = Math.min(x0, m.x - m.hw);
808
+ y0 = Math.min(y0, m.y - m.hh);
809
+ x1 = Math.max(x1, m.x + m.hw);
810
+ y1 = Math.max(y1, m.y + m.hh);
811
+ }
812
+ for (const child of g.children) {
813
+ const b = boxOf(child);
814
+ x0 = Math.min(x0, b.x0);
815
+ y0 = Math.min(y0, b.y0);
816
+ x1 = Math.max(x1, b.x1);
817
+ y1 = Math.max(y1, b.y1);
818
+ }
819
+ const { inset } = g.group;
820
+ return { x0: x0 - inset.left, y0: y0 - inset.top, x1: x1 + inset.right, y1: y1 + inset.bottom };
821
+ }
822
+ function clearance(a, b, padding) {
823
+ const gapX = Math.min(a.x1, b.x1) - Math.max(a.x0, b.x0) + padding;
824
+ const gapY = Math.min(a.y1, b.y1) - Math.max(a.y0, b.y0) + padding;
825
+ if (gapX <= 0 || gapY <= 0) return null;
826
+ const dx = b.x0 + b.x1 - (a.x0 + a.x1);
827
+ const dy = b.y0 + b.y1 - (a.y0 + a.y1);
828
+ return gapX < gapY ? { x: (Math.sign(dx) || 1) * gapX, y: 0 } : { x: 0, y: (Math.sign(dy) || 1) * gapY };
829
+ }
830
+ function holds(outer, inner) {
831
+ for (let at = inner.parent; at !== void 0; at = at.parent) {
832
+ if (at === outer) return true;
833
+ }
834
+ return false;
835
+ }
836
+ function shove(bodies, x, y) {
837
+ for (const body of bodies) {
838
+ body.vx += x;
839
+ body.vy += y;
840
+ }
841
+ }
842
+ function separateGroups(groups, bodies, byId, padding) {
843
+ const resolved = resolve(groups, byId);
844
+ const memberSets = resolved.map((g) => new Set(g.members));
845
+ return () => {
846
+ const boxes = resolved.map(boxOf);
847
+ for (let i = 0; i < resolved.length; i++) {
848
+ for (let j = i + 1; j < resolved.length; j++) {
849
+ if (holds(resolved[i], resolved[j]) || holds(resolved[j], resolved[i])) continue;
850
+ const push = clearance(boxes[i], boxes[j], padding);
851
+ if (push === null) continue;
852
+ shove(resolved[i].members, -push.x / 2, -push.y / 2);
853
+ shove(resolved[j].members, push.x / 2, push.y / 2);
854
+ }
855
+ for (const body of bodies) {
856
+ if (memberSets[i].has(body)) continue;
857
+ const box = { x0: body.x - body.hw, y0: body.y - body.hh, x1: body.x + body.hw, y1: body.y + body.hh };
858
+ const push = clearance(boxes[i], box, padding);
859
+ if (push === null) continue;
860
+ shove(resolved[i].members, -push.x / 2, -push.y / 2);
861
+ body.vx += push.x / 2;
862
+ body.vy += push.y / 2;
863
+ }
864
+ }
865
+ };
866
+ }
867
+
868
+ // src/force.ts
546
869
  var DEFAULTS = {
547
870
  iterations: 300,
548
871
  linkDistance: 160,
549
872
  linkStrength: 0.35,
550
873
  charge: -3e3,
551
874
  gravity: 0.02,
552
- padding: 24
875
+ padding: 24,
876
+ groupStrength: 0.2
553
877
  };
554
878
  var GOLDEN = Math.PI * (3 - Math.sqrt(5));
555
879
  var NUDGE = 0.5;
@@ -628,7 +952,8 @@ function forceRelaxation(graph, opts = {}) {
628
952
  linkStrength = DEFAULTS.linkStrength,
629
953
  charge: chargeStrength = DEFAULTS.charge,
630
954
  gravity: gravityStrength = DEFAULTS.gravity,
631
- padding = DEFAULTS.padding
955
+ padding = DEFAULTS.padding,
956
+ groupStrength = DEFAULTS.groupStrength
632
957
  } = opts;
633
958
  const pinned = pinnedSet(graph, opts);
634
959
  const bodies = graph.nodes.map((node) => {
@@ -651,13 +976,15 @@ function forceRelaxation(graph, opts = {}) {
651
976
  x: bodies.reduce((sum, b) => sum + b.x, 0) / bodies.length,
652
977
  y: bodies.reduce((sum, b) => sum + b.y, 0) / bodies.length
653
978
  };
979
+ const groups = graph.groups ?? [];
654
980
  const sim = createSimulation({
655
981
  nodes: bodies,
656
982
  forces: [
657
983
  charge(bodies, chargeStrength),
658
984
  links(byId, graph, linkDistance, linkStrength),
659
985
  gravity(bodies, centroid, gravityStrength),
660
- separateBoxes(bodies, padding)
986
+ separateBoxes(bodies, padding),
987
+ ...groups.length > 0 ? [groupCohesion(groups, byId, groupStrength), separateGroups(groups, bodies, byId, padding)] : []
661
988
  ]
662
989
  });
663
990
  return {
@@ -761,7 +1088,12 @@ function ranksOf(graph, back) {
761
1088
  for (const node of graph.nodes) rankOf(node.id);
762
1089
  return ranks;
763
1090
  }
764
- var layered = (graph, opts = {}) => {
1091
+ var layered = (graph, opts = {}) => (
1092
+ // Barycenter ordering reorders on every run by design, so it has no
1093
+ // arrangement to settle on.
1094
+ (graph.groups?.length ?? 0) > 0 && opts.order !== "barycenter" ? untilSettled(layeredOnce, graph, opts) : layeredOnce(graph, opts)
1095
+ );
1096
+ var layeredOnce = (graph, opts = {}) => {
765
1097
  const axes = axesFor(opts.direction);
766
1098
  const nodeGap = opts.nodeGap ?? DEFAULT_NODE_GAP;
767
1099
  const rankGap = opts.rankGap ?? DEFAULT_RANK_GAP;
@@ -778,29 +1110,29 @@ var layered = (graph, opts = {}) => {
778
1110
  const rows = [...byRank.keys()].sort((a, b) => a - b);
779
1111
  let ordered = rows.map((rank) => seededOrder(byRank.get(rank), axes.cross, order));
780
1112
  if (opts.order === "barycenter") ordered = barycenterOrder(graph, ordered, back);
781
- const packed = /* @__PURE__ */ new Map();
782
- let widest = 0;
783
- for (const [i, rank] of rows.entries()) {
784
- const p = packAcross(ordered[i], axes.cross, nodeGap);
785
- packed.set(rank, p);
786
- widest = Math.max(widest, p.span);
1113
+ const packed = ordered.map((row) => packAcross(row, axes.cross, nodeGap));
1114
+ const widest = Math.max(0, ...packed.map((p) => p.span));
1115
+ const preferred = /* @__PURE__ */ new Map();
1116
+ for (const [i, row] of ordered.entries()) {
1117
+ const indent = (widest - packed[i].span) / 2;
1118
+ for (const node of row) preferred.set(node.id, indent + packed[i].at.get(node.id));
787
1119
  }
1120
+ const grouped = (graph.groups?.length ?? 0) > 0;
1121
+ const crossAt = grouped ? packClusters(graph, ordered, preferred, axes, nodeGap) : preferred;
1122
+ const gaps = grouped ? rankGaps(graph, ordered, axes, rankGap) : ordered.slice(1).map(() => rankGap);
788
1123
  const slots = /* @__PURE__ */ new Map();
789
1124
  let along = 0;
790
- for (const rank of rows) {
791
- const row = byRank.get(rank);
792
- const p = packed.get(rank);
793
- const indent = (widest - p.span) / 2;
1125
+ for (const [i, row] of ordered.entries()) {
794
1126
  const depth = Math.max(0, ...row.map((n) => extent(n.bounds, axes.rank)));
795
1127
  for (const node of row) {
796
1128
  slots.set(node.id, {
797
- cross: indent + p.at.get(node.id),
1129
+ cross: crossAt.get(node.id),
798
1130
  // Centered in its rank's band, so a tall node does not push its
799
1131
  // neighbors' edges off the line they read along.
800
1132
  rank: along + (depth - extent(node.bounds, axes.rank)) / 2
801
1133
  });
802
1134
  }
803
- along += depth + rankGap;
1135
+ along += depth + (gaps[i] ?? 0);
804
1136
  }
805
1137
  return settle(graph, slots, opts);
806
1138
  };
@@ -838,7 +1170,8 @@ function forestOf(graph, crossAxis) {
838
1170
  }
839
1171
  return { roots, children, depth };
840
1172
  }
841
- var tree = (graph, opts = {}) => {
1173
+ var tree = (graph, opts = {}) => (graph.groups?.length ?? 0) > 0 ? untilSettled(treeOnce, graph, opts) : treeOnce(graph, opts);
1174
+ var treeOnce = (graph, opts = {}) => {
842
1175
  const axes = axesFor(opts.direction);
843
1176
  const nodeGap = opts.nodeGap ?? DEFAULT_NODE_GAP;
844
1177
  const rankGap = opts.rankGap ?? DEFAULT_RANK_GAP;
@@ -848,11 +1181,15 @@ var tree = (graph, opts = {}) => {
848
1181
  const at = depth.get(node.id) ?? 0;
849
1182
  bands.set(at, Math.max(bands.get(at) ?? 0, extent(node.bounds, axes.rank)));
850
1183
  }
1184
+ const depths = [...bands.keys()].sort((a, b) => a - b);
1185
+ const rowsAt = depths.map((at) => graph.nodes.filter((n) => (depth.get(n.id) ?? 0) === at));
1186
+ const grouped = (graph.groups?.length ?? 0) > 0;
1187
+ const gaps = grouped ? rankGaps(graph, rowsAt, axes, rankGap) : depths.slice(1).map(() => rankGap);
851
1188
  const bandStart = /* @__PURE__ */ new Map();
852
1189
  let along = 0;
853
- for (const at of [...bands.keys()].sort((a, b) => a - b)) {
1190
+ for (const [i, at] of depths.entries()) {
854
1191
  bandStart.set(at, along);
855
- along += bands.get(at) + rankGap;
1192
+ along += bands.get(at) + (gaps[i] ?? 0);
856
1193
  }
857
1194
  const slots = /* @__PURE__ */ new Map();
858
1195
  const measured = /* @__PURE__ */ new Map();
@@ -888,9 +1225,15 @@ var tree = (graph, opts = {}) => {
888
1225
  place(root, cursor);
889
1226
  cursor += measure(root) + nodeGap;
890
1227
  }
1228
+ if (grouped) {
1229
+ const preferred = new Map([...slots].map(([id, slot]) => [id, slot.cross]));
1230
+ const ordered = rowsAt.map((row) => [...row].sort((a, b) => preferred.get(a.id) - preferred.get(b.id)));
1231
+ const crossAt = packClusters(graph, ordered, preferred, axes, nodeGap);
1232
+ for (const [id, slot] of slots) slots.set(id, { ...slot, cross: crossAt.get(id) ?? slot.cross });
1233
+ }
891
1234
  return settle(graph, slots, opts);
892
1235
  };
893
1236
 
894
- export { COMPASS, DEFAULT_NODE_GAP, DEFAULT_PORTS, DEFAULT_RANK_GAP, DIAGRAM_TRAIT_KEY, axesFor, backEdges, bodyOutline, bodyTrait, boxForContent, buildBody, canvasMeasure, center, contentBox, createDiagramNodes, dataKeyReader, diagramNodeOf, extent, force, forceRelaxation, forestOf, graphOrder, isPinned, layered, layoutBody, layoutRowPorts, measureBody, outlinePath, outlinePolyline, packAcross, pinnedSet, portOf, portsOf, portsOnOutline, ranksOf, rayHit, seededOrder, settle, sizeToBody, translated, tree };
895
- //# sourceMappingURL=chunk-UBM2J3HJ.js.map
896
- //# sourceMappingURL=chunk-UBM2J3HJ.js.map
1237
+ export { COMPASS, DEFAULT_GROUP_INSET, DEFAULT_NODE_GAP, DEFAULT_PORTS, DEFAULT_RANK_GAP, DIAGRAM_TRAIT_KEY, axesFor, backEdges, bodyOutline, bodyTrait, boxForContent, buildBody, canvasMeasure, center, contentBox, createDiagramNodes, dataKeyReader, diagramNodeOf, expandGroupEdges, extent, force, forceRelaxation, forestOf, graphOrder, insetOf, isPinned, layered, layoutBody, layoutRowPorts, measureBody, outlinePath, outlinePolyline, packAcross, packClusters, pinnedSet, portOf, portsOf, portsOnOutline, rankGaps, ranksOf, rayHit, seededOrder, settle, sizeToBody, translated, tree, untilSettled };
1238
+ //# sourceMappingURL=chunk-EDSBULQM.js.map
1239
+ //# sourceMappingURL=chunk-EDSBULQM.js.map