@weasel-js/diagram 1.9.1 → 1.9.3

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
@@ -95,19 +95,65 @@ relaxation seeded from the current positions, so re-running it keeps relaxing.
95
95
  ### From data
96
96
 
97
97
  `diagramScene` turns plain `{ nodes, edges }` into a laid-out scene, and
98
- `DiagramView` draws one read-only, with pan, zoom and picking:
98
+ `DiagramView` draws it, with pan, zoom and picking:
99
99
 
100
100
  ```tsx
101
101
  const specs = useMemo(() => diagramScene(data, { nodeStyle }), [data]);
102
102
  return <DiagramView specs={specs} width={360} height={600} selected={id} onSelect={setId} />;
103
103
  ```
104
104
 
105
- Node sizes are estimated from line lengths, so layout runs without a canvas.
106
- It defaults to `layered` with `order: 'barycenter'`. Labels paint only in a
107
- registered font family, and the default is sans-serif, so call
108
- `registerCanvasFont('sans-serif')` once or they render blank. `minScale` stops
109
- the initial fit from shrinking a wide diagram past readable; the rest is a pan
110
- away.
105
+ Every box is a `buildBody` body, so a data node takes the same knobs a body
106
+ does — `outline`, `rows`, `padding`, `gap`, `verticalAlign`, `ports` — plus `pinned`, a
107
+ starting `at`, and a minimum `width`/`height`; `lines` is shorthand for one
108
+ label row each. An edge can name `fromPort`/`toPort`, its own `router`,
109
+ `waypoints` and `labelPlacement`.
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
+
127
+ Text is sized from character counts unless `measure` is given, so layout runs
128
+ without a canvas. The layout defaults to `layered`, picked by name or passed as
129
+ any `LayoutFn`; `layoutOptions` merge over `order: 'barycenter'`, and
130
+ `'none'` leaves every node at its `at`.
131
+
132
+ `DiagramView` is the front door for the whole package. The data owns the
133
+ diagram: a new `specs` array is reconciled into the scene the view already
134
+ holds (`reconcileSpecs` in core), so the view, the pick and anything moved
135
+ since survive it. Editing reports rather than writes —
136
+
137
+ ```tsx
138
+ <DiagramView
139
+ ref={view}
140
+ specs={specs}
141
+ onMove={(moves) => setNodes((ns) => withPositions(ns, moves))}
142
+ onConnect={(edge) => setEdges((es) => [...es, edge])}
143
+ />
144
+ ```
145
+
146
+ — `onMove` turns on dragging and hears every node a drag or a layout run
147
+ moved; `onConnect` makes ports grabbable and hears the edge a connect
148
+ describes. Store a move as the node's `at`, with `pinned: true` unless the
149
+ scene uses `layout: 'none'`, or the next `diagramScene` lays it out again.
150
+ `view.current.layout('layered')` re-runs a layout animated from where the
151
+ nodes stand; `'force'` relaxes live.
152
+
153
+ Labels paint only in a registered font family, and the default is
154
+ sans-serif, so call `registerCanvasFont('sans-serif')` once or they render
155
+ blank. `minScale` stops the initial fit from shrinking a wide diagram past
156
+ readable; the rest is a pan away.
111
157
 
112
158
  ## Design notes
113
159
 
@@ -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/core';
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,20 +274,21 @@ 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
  }
287
287
  function measureRow(row, measure) {
288
- const text = (s, style) => measure === void 0 ? { width: s.length * ((style?.fontSize ?? FALLBACK_LINE) * 0.6), height: FALLBACK_LINE } : measure(s, style ?? {});
288
+ const text = (s, style) => measure === void 0 ? {
289
+ width: s.length * (style?.fontSize ?? FALLBACK_LINE) * 0.6,
290
+ height: style?.fontSize === void 0 ? FALLBACK_LINE : Math.ceil(style.fontSize * 1.2)
291
+ } : measure(s, style ?? {});
289
292
  switch (row.kind) {
290
293
  case "label":
291
294
  return text(row.text, row.style);
@@ -333,9 +336,13 @@ function layoutBody(spec, bounds, measure) {
333
336
  const gap = spec.gap ?? DEFAULT_GAP;
334
337
  const inner = contentBox(spec.outline, bounds);
335
338
  const width = Math.max(inner.width - padding * 2, 0);
336
- let y = inner.y + padding;
339
+ const heights = spec.rows.map((row) => measureRow(row, measure).height);
340
+ const used = heights.reduce((sum, h) => sum + h, 0) + gap * Math.max(heights.length - 1, 0);
341
+ const slack = Math.max(inner.height - padding * 2 - used, 0);
342
+ const share = { top: 0, center: 0.5, bottom: 1 }[spec.verticalAlign ?? "center"];
343
+ let y = inner.y + padding + slack * share;
337
344
  return spec.rows.map((row, index) => {
338
- const h = measureRow(row, measure).height;
345
+ const h = heights[index];
339
346
  const box = { row, index, x: inner.x + padding, y, width, height: h };
340
347
  y += h + gap;
341
348
  return box;
@@ -366,13 +373,13 @@ function layoutRowPorts(row, box, measure) {
366
373
  }
367
374
  function bodyTrait(spec, bounds, measure) {
368
375
  const outline = spec.outline;
369
- const compass = [
376
+ const compass = spec.ports ?? [
370
377
  { id: "n", at: COMPASS.n },
371
378
  { id: "e", at: COMPASS.e },
372
379
  { id: "s", at: COMPASS.s },
373
380
  { id: "w", at: COMPASS.w }
374
381
  ];
375
- if (bounds.height <= 0) return { outline, ports: compass };
382
+ if (bounds.height <= 0) return { outline, ports: [...compass] };
376
383
  const rowPorts = [];
377
384
  let hasLeft = false;
378
385
  let hasRight = false;
@@ -388,7 +395,7 @@ function bodyTrait(spec, bounds, measure) {
388
395
  ));
389
396
  }
390
397
  }
391
- const kept = compass.filter((p) => !(p.id === "w" && hasLeft) && !(p.id === "e" && hasRight));
398
+ const kept = spec.ports !== void 0 ? compass : compass.filter((p) => !(p.id === "w" && hasLeft) && !(p.id === "e" && hasRight));
392
399
  return { outline, ports: [...kept, ...rowPorts] };
393
400
  }
394
401
  function withType(spec, type) {
@@ -537,112 +544,164 @@ function graphOrder(graph) {
537
544
  return (id) => index.get(id) ?? Number.MAX_SAFE_INTEGER;
538
545
  }
539
546
 
540
- // src/barycenter.ts
541
- var SWEEPS = 4;
542
- function barycenterOrder(graph, rows, back) {
543
- const out = rows.map((row) => [...row]);
544
- const index = /* @__PURE__ */ new Map();
545
- const reindex = (row) => row.forEach((n, i) => index.set(n.id, i));
546
- out.forEach(reindex);
547
- const reorder = (row, neighbors) => {
548
- const key = /* @__PURE__ */ new Map();
549
- for (const [i, n] of row.entries()) {
550
- const at = neighbors(n.id).map((id) => index.get(id)).filter((v) => v !== void 0);
551
- key.set(n.id, at.length === 0 ? i : at.reduce((s, v) => s + v, 0) / at.length);
552
- }
553
- row.sort((a, b) => key.get(a.id) - key.get(b.id) || index.get(a.id) - index.get(b.id));
554
- reindex(row);
547
+ // src/cluster.ts
548
+ function insetOf(inset) {
549
+ if (typeof inset === "number") return { top: inset, right: inset, bottom: inset, left: inset };
550
+ return {
551
+ top: inset?.top ?? DEFAULT_GROUP_INSET,
552
+ right: inset?.right ?? DEFAULT_GROUP_INSET,
553
+ bottom: inset?.bottom ?? DEFAULT_GROUP_INSET,
554
+ left: inset?.left ?? DEFAULT_GROUP_INSET
555
555
  };
556
- const ups = (id) => graph.incoming(id).filter((e) => !back.has(e.id)).map((e) => e.from);
557
- const downs = (id) => graph.outgoing(id).filter((e) => !back.has(e.id)).map((e) => e.to);
558
- for (let s = 0; s < SWEEPS; s++) {
559
- for (let r = 1; r < out.length; r++) reorder(out[r], ups);
560
- for (let r = out.length - 2; r >= 0; r--) reorder(out[r], downs);
561
- }
562
- return out;
563
556
  }
564
-
565
- // src/layered.ts
566
- function backEdges(graph) {
567
- const back = /* @__PURE__ */ new Set();
568
- const done = /* @__PURE__ */ new Set();
569
- const onStack = /* @__PURE__ */ new Set();
570
- const walk = (id) => {
571
- if (done.has(id)) return;
572
- onStack.add(id);
573
- for (const edge of graph.outgoing(id)) {
574
- if (onStack.has(edge.to)) back.add(edge.id);
575
- else walk(edge.to);
576
- }
577
- onStack.delete(id);
578
- done.add(id);
579
- };
580
- for (const node of graph.nodes) walk(node.id);
581
- return back;
557
+ var DEFAULT_GROUP_INSET = 12;
558
+ function canonical(inset, axes) {
559
+ if (axes.rank === "y") {
560
+ 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 };
561
+ }
562
+ 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 };
582
563
  }
583
- function ranksOf(graph, back) {
584
- const ranks = /* @__PURE__ */ new Map();
585
- const resolving = /* @__PURE__ */ new Set();
586
- const rankOf = (id) => {
587
- const known = ranks.get(id);
588
- if (known !== void 0) return known;
589
- if (resolving.has(id)) return 0;
590
- resolving.add(id);
591
- let rank = 0;
592
- for (const edge of graph.incoming(id)) {
593
- if (back.has(edge.id)) continue;
594
- rank = Math.max(rank, rankOf(edge.from) + 1);
595
- }
596
- resolving.delete(id);
597
- ranks.set(id, rank);
598
- return rank;
599
- };
600
- for (const node of graph.nodes) rankOf(node.id);
601
- return ranks;
564
+ function rankGaps(graph, rows, axes, rankGap) {
565
+ const gaps = rows.slice(1).map(() => rankGap);
566
+ const ends = /* @__PURE__ */ new Map();
567
+ const starts = /* @__PURE__ */ new Map();
568
+ for (const span of groupSpans(graph, rows)) {
569
+ const inset = canonical(span.group.inset, axes);
570
+ ends.set(span.hi, Math.max(ends.get(span.hi) ?? 0, inset.rankHi));
571
+ starts.set(span.lo, Math.max(starts.get(span.lo) ?? 0, inset.rankLo));
572
+ }
573
+ for (let i = 0; i < gaps.length; i++) {
574
+ gaps[i] = Math.max(gaps[i], rankGap + (ends.get(i) ?? 0) + (starts.get(i + 1) ?? 0));
575
+ }
576
+ return gaps;
602
577
  }
603
- var layered = (graph, opts = {}) => {
604
- const axes = axesFor(opts.direction);
605
- const nodeGap = opts.nodeGap ?? DEFAULT_NODE_GAP;
606
- const rankGap = opts.rankGap ?? DEFAULT_RANK_GAP;
607
- const order = graphOrder(graph);
608
- const back = backEdges(graph);
609
- const ranks = ranksOf(graph, back);
610
- const byRank = /* @__PURE__ */ new Map();
611
- for (const node of graph.nodes) {
612
- const rank = ranks.get(node.id) ?? 0;
613
- const list = byRank.get(rank);
614
- if (list === void 0) byRank.set(rank, [node]);
615
- else list.push(node);
578
+ function groupSpans(graph, rows) {
579
+ const rankOf = /* @__PURE__ */ new Map();
580
+ rows.forEach((row, i) => row.forEach((n) => rankOf.set(n.id, i)));
581
+ const spans = [];
582
+ for (const group of graph.groups ?? []) {
583
+ const ranks = group.members.map((id) => rankOf.get(id)).filter((r) => r !== void 0);
584
+ if (ranks.length === 0) continue;
585
+ spans.push({ group, lo: Math.min(...ranks), hi: Math.max(...ranks) });
616
586
  }
617
- const rows = [...byRank.keys()].sort((a, b) => a - b);
618
- let ordered = rows.map((rank) => seededOrder(byRank.get(rank), axes.cross, order));
619
- if (opts.order === "barycenter") ordered = barycenterOrder(graph, ordered, back);
620
- const packed = /* @__PURE__ */ new Map();
621
- let widest = 0;
622
- for (const [i, rank] of rows.entries()) {
623
- const p = packAcross(ordered[i], axes.cross, nodeGap);
624
- packed.set(rank, p);
625
- widest = Math.max(widest, p.span);
587
+ return spans;
588
+ }
589
+ function packClusters(graph, rows, preferred, axes, nodeGap) {
590
+ const spans = groupSpans(graph, rows);
591
+ const spanOf = /* @__PURE__ */ new Map();
592
+ for (const span of spans) for (const id of span.group.members) spanOf.set(id, span);
593
+ const width = (n) => extent(n.bounds, axes.cross);
594
+ const pref = (n) => preferred.get(n.id) ?? 0;
595
+ const packedWidth = (ns) => ns.reduce((s, n) => s + width(n), 0) + Math.max(0, ns.length - 1) * nodeGap;
596
+ const insets = new Map(spans.map((s) => [s, canonical(s.group.inset, axes)]));
597
+ const inner = /* @__PURE__ */ new Map();
598
+ const centerOf = /* @__PURE__ */ new Map();
599
+ for (const span of spans) {
600
+ let widest = 0;
601
+ let sum = 0;
602
+ let count = 0;
603
+ for (const row of rows) {
604
+ const members = row.filter((n) => spanOf.get(n.id) === span);
605
+ widest = Math.max(widest, packedWidth(members));
606
+ for (const n of members) {
607
+ sum += pref(n) + width(n) / 2;
608
+ count++;
609
+ }
610
+ }
611
+ inner.set(span, widest);
612
+ centerOf.set(span, count === 0 ? 0 : sum / count);
626
613
  }
627
- const slots = /* @__PURE__ */ new Map();
628
- let along = 0;
629
- for (const rank of rows) {
630
- const row = byRank.get(rank);
631
- const p = packed.get(rank);
632
- const indent = (widest - p.span) / 2;
633
- const depth = Math.max(0, ...row.map((n) => extent(n.bounds, axes.rank)));
614
+ const outer = (span) => inner.get(span) + insets.get(span).crossLo + insets.get(span).crossHi;
615
+ const itemsByRow = rows.map((row, r) => {
616
+ const items = [];
634
617
  for (const node of row) {
635
- slots.set(node.id, {
636
- cross: indent + p.at.get(node.id),
637
- // Centered in its rank's band, so a tall node does not push its
638
- // neighbors' edges off the line they read along.
639
- rank: along + (depth - extent(node.bounds, axes.rank)) / 2
618
+ if (!spanOf.has(node.id)) items.push({ kind: "node", node, center: pref(node) + width(node) / 2 });
619
+ }
620
+ for (const span of spans) {
621
+ if (r < span.lo || r > span.hi) continue;
622
+ items.push({
623
+ kind: "group",
624
+ span,
625
+ members: row.filter((n) => spanOf.get(n.id) === span),
626
+ center: centerOf.get(span)
640
627
  });
641
628
  }
642
- along += depth + rankGap;
629
+ return items.map((item, i) => ({ item, i })).sort((a, b) => a.item.center - b.item.center || a.i - b.i).map(({ item }) => item);
630
+ });
631
+ const start = new Map(spans.map((s) => [s, centerOf.get(s) - outer(s) / 2]));
632
+ const at = /* @__PURE__ */ new Map();
633
+ for (let pass = 0; pass <= spans.length; pass++) {
634
+ let raised = false;
635
+ for (const items of itemsByRow) {
636
+ let cursor = -Infinity;
637
+ for (const item of items) {
638
+ if (item.kind === "node") {
639
+ const x2 = Math.max(pref(item.node), cursor);
640
+ at.set(item.node.id, x2);
641
+ cursor = x2 + width(item.node) + nodeGap;
642
+ continue;
643
+ }
644
+ const s = start.get(item.span);
645
+ const x = Math.max(s, cursor);
646
+ if (x > s) {
647
+ start.set(item.span, x);
648
+ raised = true;
649
+ }
650
+ const inset = insets.get(item.span);
651
+ let m = x + inset.crossLo + (inner.get(item.span) - packedWidth(item.members)) / 2;
652
+ for (const node of item.members) {
653
+ at.set(node.id, m);
654
+ m += width(node) + nodeGap;
655
+ }
656
+ cursor = x + outer(item.span) + nodeGap;
657
+ }
658
+ }
659
+ if (!raised) break;
643
660
  }
644
- return settle(graph, slots, opts);
645
- };
661
+ return at;
662
+ }
663
+ function expandGroupEdges(edges, groups) {
664
+ const byId = new Map(groups.map((g) => [g.id, g]));
665
+ if (byId.size === 0) return [...edges];
666
+ const groupOf = /* @__PURE__ */ new Map();
667
+ for (const g of groups) for (const m of g.members) groupOf.set(m, g.id);
668
+ const entries = /* @__PURE__ */ new Map();
669
+ const exits = /* @__PURE__ */ new Map();
670
+ for (const g of groups) {
671
+ const inside = new Set(g.members);
672
+ const pointedAt = /* @__PURE__ */ new Set();
673
+ const pointing = /* @__PURE__ */ new Set();
674
+ for (const e of edges) {
675
+ if (inside.has(e.from) && inside.has(e.to) && e.from !== e.to) {
676
+ pointedAt.add(e.to);
677
+ pointing.add(e.from);
678
+ }
679
+ }
680
+ const entry = g.members.filter((m) => !pointedAt.has(m));
681
+ const exit = g.members.filter((m) => !pointing.has(m));
682
+ entries.set(g.id, entry.length > 0 ? entry : g.members);
683
+ exits.set(g.id, exit.length > 0 ? exit : g.members);
684
+ }
685
+ const out = [];
686
+ for (const e of edges) {
687
+ const fromGroup = byId.has(e.from) ? e.from : void 0;
688
+ const toGroup = byId.has(e.to) ? e.to : void 0;
689
+ if (fromGroup === void 0 && toGroup === void 0) {
690
+ out.push(e);
691
+ continue;
692
+ }
693
+ const fromOwner = fromGroup ?? groupOf.get(e.from);
694
+ const toOwner = toGroup ?? groupOf.get(e.to);
695
+ if (fromOwner !== void 0 && fromOwner === toOwner) continue;
696
+ const froms = fromGroup !== void 0 ? exits.get(fromGroup) : [e.from];
697
+ const tos = toGroup !== void 0 ? entries.get(toGroup) : [e.to];
698
+ let k = 0;
699
+ for (const from of froms) {
700
+ for (const to of tos) out.push({ id: `${e.id}#${k++}`, from, to });
701
+ }
702
+ }
703
+ return out;
704
+ }
646
705
  var DEFAULTS = {
647
706
  iterations: 300,
648
707
  linkDistance: 160,
@@ -798,6 +857,113 @@ function separate(bodies) {
798
857
  });
799
858
  }
800
859
 
860
+ // src/barycenter.ts
861
+ var SWEEPS = 4;
862
+ function barycenterOrder(graph, rows, back) {
863
+ const out = rows.map((row) => [...row]);
864
+ const index = /* @__PURE__ */ new Map();
865
+ const reindex = (row) => row.forEach((n, i) => index.set(n.id, i));
866
+ out.forEach(reindex);
867
+ const reorder = (row, neighbors) => {
868
+ const key = /* @__PURE__ */ new Map();
869
+ for (const [i, n] of row.entries()) {
870
+ const at = neighbors(n.id).map((id) => index.get(id)).filter((v) => v !== void 0);
871
+ key.set(n.id, at.length === 0 ? i : at.reduce((s, v) => s + v, 0) / at.length);
872
+ }
873
+ row.sort((a, b) => key.get(a.id) - key.get(b.id) || index.get(a.id) - index.get(b.id));
874
+ reindex(row);
875
+ };
876
+ const ups = (id) => graph.incoming(id).filter((e) => !back.has(e.id)).map((e) => e.from);
877
+ const downs = (id) => graph.outgoing(id).filter((e) => !back.has(e.id)).map((e) => e.to);
878
+ for (let s = 0; s < SWEEPS; s++) {
879
+ for (let r = 1; r < out.length; r++) reorder(out[r], ups);
880
+ for (let r = out.length - 2; r >= 0; r--) reorder(out[r], downs);
881
+ }
882
+ return out;
883
+ }
884
+
885
+ // src/layered.ts
886
+ function backEdges(graph) {
887
+ const back = /* @__PURE__ */ new Set();
888
+ const done = /* @__PURE__ */ new Set();
889
+ const onStack = /* @__PURE__ */ new Set();
890
+ const walk = (id) => {
891
+ if (done.has(id)) return;
892
+ onStack.add(id);
893
+ for (const edge of graph.outgoing(id)) {
894
+ if (onStack.has(edge.to)) back.add(edge.id);
895
+ else walk(edge.to);
896
+ }
897
+ onStack.delete(id);
898
+ done.add(id);
899
+ };
900
+ for (const node of graph.nodes) walk(node.id);
901
+ return back;
902
+ }
903
+ function ranksOf(graph, back) {
904
+ const ranks = /* @__PURE__ */ new Map();
905
+ const resolving = /* @__PURE__ */ new Set();
906
+ const rankOf = (id) => {
907
+ const known = ranks.get(id);
908
+ if (known !== void 0) return known;
909
+ if (resolving.has(id)) return 0;
910
+ resolving.add(id);
911
+ let rank = 0;
912
+ for (const edge of graph.incoming(id)) {
913
+ if (back.has(edge.id)) continue;
914
+ rank = Math.max(rank, rankOf(edge.from) + 1);
915
+ }
916
+ resolving.delete(id);
917
+ ranks.set(id, rank);
918
+ return rank;
919
+ };
920
+ for (const node of graph.nodes) rankOf(node.id);
921
+ return ranks;
922
+ }
923
+ var layered = (graph, opts = {}) => {
924
+ const axes = axesFor(opts.direction);
925
+ const nodeGap = opts.nodeGap ?? DEFAULT_NODE_GAP;
926
+ const rankGap = opts.rankGap ?? DEFAULT_RANK_GAP;
927
+ const order = graphOrder(graph);
928
+ const back = backEdges(graph);
929
+ const ranks = ranksOf(graph, back);
930
+ const byRank = /* @__PURE__ */ new Map();
931
+ for (const node of graph.nodes) {
932
+ const rank = ranks.get(node.id) ?? 0;
933
+ const list = byRank.get(rank);
934
+ if (list === void 0) byRank.set(rank, [node]);
935
+ else list.push(node);
936
+ }
937
+ const rows = [...byRank.keys()].sort((a, b) => a - b);
938
+ let ordered = rows.map((rank) => seededOrder(byRank.get(rank), axes.cross, order));
939
+ if (opts.order === "barycenter") ordered = barycenterOrder(graph, ordered, back);
940
+ const packed = ordered.map((row) => packAcross(row, axes.cross, nodeGap));
941
+ const widest = Math.max(0, ...packed.map((p) => p.span));
942
+ const preferred = /* @__PURE__ */ new Map();
943
+ for (const [i, row] of ordered.entries()) {
944
+ const indent = (widest - packed[i].span) / 2;
945
+ for (const node of row) preferred.set(node.id, indent + packed[i].at.get(node.id));
946
+ }
947
+ const grouped = (graph.groups?.length ?? 0) > 0;
948
+ const crossAt = grouped ? packClusters(graph, ordered, preferred, axes, nodeGap) : preferred;
949
+ const gaps = grouped ? rankGaps(graph, ordered, axes, rankGap) : ordered.slice(1).map(() => rankGap);
950
+ const slots = /* @__PURE__ */ new Map();
951
+ let along = 0;
952
+ for (const [i, row] of ordered.entries()) {
953
+ const depth = Math.max(0, ...row.map((n) => extent(n.bounds, axes.rank)));
954
+ for (const node of row) {
955
+ slots.set(node.id, {
956
+ cross: crossAt.get(node.id),
957
+ // Centered in its rank's band, so a tall node does not push its
958
+ // neighbors' edges off the line they read along.
959
+ rank: along + (depth - extent(node.bounds, axes.rank)) / 2
960
+ });
961
+ }
962
+ along += depth + (gaps[i] ?? 0);
963
+ }
964
+ return settle(graph, slots, opts);
965
+ };
966
+
801
967
  // src/tree.ts
802
968
  function forestOf(graph, crossAxis) {
803
969
  const order = graphOrder(graph);
@@ -841,11 +1007,15 @@ var tree = (graph, opts = {}) => {
841
1007
  const at = depth.get(node.id) ?? 0;
842
1008
  bands.set(at, Math.max(bands.get(at) ?? 0, extent(node.bounds, axes.rank)));
843
1009
  }
1010
+ const depths = [...bands.keys()].sort((a, b) => a - b);
1011
+ const rowsAt = depths.map((at) => graph.nodes.filter((n) => (depth.get(n.id) ?? 0) === at));
1012
+ const grouped = (graph.groups?.length ?? 0) > 0;
1013
+ const gaps = grouped ? rankGaps(graph, rowsAt, axes, rankGap) : depths.slice(1).map(() => rankGap);
844
1014
  const bandStart = /* @__PURE__ */ new Map();
845
1015
  let along = 0;
846
- for (const at of [...bands.keys()].sort((a, b) => a - b)) {
1016
+ for (const [i, at] of depths.entries()) {
847
1017
  bandStart.set(at, along);
848
- along += bands.get(at) + rankGap;
1018
+ along += bands.get(at) + (gaps[i] ?? 0);
849
1019
  }
850
1020
  const slots = /* @__PURE__ */ new Map();
851
1021
  const measured = /* @__PURE__ */ new Map();
@@ -881,9 +1051,15 @@ var tree = (graph, opts = {}) => {
881
1051
  place(root, cursor);
882
1052
  cursor += measure(root) + nodeGap;
883
1053
  }
1054
+ if (grouped) {
1055
+ const preferred = new Map([...slots].map(([id, slot]) => [id, slot.cross]));
1056
+ const ordered = rowsAt.map((row) => [...row].sort((a, b) => preferred.get(a.id) - preferred.get(b.id)));
1057
+ const crossAt = packClusters(graph, ordered, preferred, axes, nodeGap);
1058
+ for (const [id, slot] of slots) slots.set(id, { ...slot, cross: crossAt.get(id) ?? slot.cross });
1059
+ }
884
1060
  return settle(graph, slots, opts);
885
1061
  };
886
1062
 
887
- 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 };
888
- //# sourceMappingURL=chunk-6VYT4WCM.js.map
889
- //# sourceMappingURL=chunk-6VYT4WCM.js.map
1063
+ 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 };
1064
+ //# sourceMappingURL=chunk-ERT3XWBS.js.map
1065
+ //# sourceMappingURL=chunk-ERT3XWBS.js.map