@statelyai/layout 0.4.0 → 0.4.1

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.
@@ -7,7 +7,7 @@ import {
7
7
  type LayeredLayoutOptions,
8
8
  type LayerOrder,
9
9
  } from "../layered";
10
- import { createLayerSweepSession } from "../layered/strategies";
10
+ import { createLayerSweepSession, CROSSING_MODEL, type CrossingModel } from "../layered/strategies";
11
11
  import {
12
12
  minimizeHierarchyCrossings,
13
13
  type HierarchyCrossingScope,
@@ -83,6 +83,10 @@ export type {
83
83
  } from "./public-types";
84
84
  export type { ElkId, ElkLogging } from "./types";
85
85
 
86
+ /** The crossing-model setting a layout passes to crossing minimization. */
87
+ const crossingModel = Symbol("crossingModel");
88
+ type ModelArguments = ElkLayoutArguments & { [crossingModel]?: CrossingModel };
89
+
86
90
  interface PreparedElkScope {
87
91
  graph: ElkNode;
88
92
  native: Graph;
@@ -168,21 +172,45 @@ export default class ELK {
168
172
  arguments_: ElkLayoutArguments = {},
169
173
  ): Promise<LaidOutElkNode<T>> {
170
174
  const options = { ...this.#options.defaultLayoutOptions, ...arguments_.layoutOptions };
171
- const variants = crossingVariants(options, graph);
172
- const pristine = variants.length ? structuredClone(graph) : undefined;
173
- let best = separated(await this.#layoutWithoutDefectiveCompaction(graph, arguments_), options);
175
+ const seeds = crossingVariants(options, graph);
176
+ const probe = mayShareTracks(graph, options)
177
+ ? { mixedPorts: false, sharedTracks: false }
178
+ : undefined;
179
+ const pristine = seeds.length || probe ? structuredClone(graph) : undefined;
180
+ let best = separated(
181
+ await this.#layoutWithoutDefectiveCompaction(
182
+ graph,
183
+ probe ? { ...arguments_, [crossingModel]: { probe } } : arguments_,
184
+ ),
185
+ options,
186
+ );
174
187
  if (!pristine) return best;
175
- // A compound layout with defects tries other random seeds and keeps the
176
- // best measured result. Above ELK's default thoroughness, every compound
177
- // layout does, trading time for fewer crossings and bends.
188
+ // Crossing minimization models a port carrying both directions as two
189
+ // ports with grouped neighbours, and counts hyperedges as ELK routes
190
+ // them. Each choice steers the sweeps into worse orders on some graphs,
191
+ // so a graph where the mixed-port model applied is also laid out without
192
+ // it, and one with a hyperedge that routing must split is also laid out
193
+ // counting that hyperedge edge by edge. The best result wins.
194
+ // A compound layout with defects tries other random seeds too. Above
195
+ // ELK's default thoroughness, every compound layout does, trading time
196
+ // for fewer crossings and bends.
197
+ const reseed =
198
+ seeds.length > 0 && (thorough(options, graph) || measureLayout(best, true).defects > 0);
199
+ const variants: ModelArguments[] = [
200
+ ...(probe?.mixedPorts ? [{ ...arguments_, [crossingModel]: { mixedPorts: false } }] : []),
201
+ ...(probe?.sharedTracks
202
+ ? [{ ...arguments_, [crossingModel]: { mixedPorts: false, separateSharedTracks: true } }]
203
+ : []),
204
+ ...(reseed ? seeds : []).map((variant) => ({
205
+ ...arguments_,
206
+ layoutOptions: { ...arguments_.layoutOptions, ...variant },
207
+ })),
208
+ ];
209
+ if (!variants.length) return best;
178
210
  let quality = measureLayout(best);
179
- if (quality.defects === 0 && !thorough(options, graph)) return best;
180
211
  for (const variant of variants) {
181
212
  const candidate = separated(
182
- await this.#layoutWithoutDefectiveCompaction(structuredClone(pristine), {
183
- ...arguments_,
184
- layoutOptions: { ...arguments_.layoutOptions, ...variant },
185
- }),
213
+ await this.#layoutWithoutDefectiveCompaction(structuredClone(pristine), variant),
186
214
  options,
187
215
  );
188
216
  const candidateQuality = measureLayout(candidate);
@@ -196,7 +224,7 @@ export default class ELK {
196
224
 
197
225
  async #layoutWithoutDefectiveCompaction<T extends ElkNode>(
198
226
  graph: T,
199
- arguments_: ElkLayoutArguments,
227
+ arguments_: ModelArguments,
200
228
  ): Promise<LaidOutElkNode<T>> {
201
229
  const compacting = usesPostCompaction(
202
230
  { ...this.#options.defaultLayoutOptions, ...arguments_.layoutOptions },
@@ -204,7 +232,7 @@ export default class ELK {
204
232
  );
205
233
  const pristine = compacting ? structuredClone(graph) : undefined;
206
234
  const result = await this.#layout(graph, arguments_);
207
- const defects = pristine ? measureLayout(result).defects : 0;
235
+ const defects = pristine ? measureLayout(result, true).defects : 0;
208
236
  if (!pristine || defects === 0) return result;
209
237
  // Post-compaction is an optimization; it must not route edges through nodes
210
238
  // or fold a route back onto itself.
@@ -213,7 +241,7 @@ export default class ELK {
213
241
  ...arguments_,
214
242
  layoutOptions: { ...arguments_.layoutOptions, ...strategy },
215
243
  });
216
- return measureLayout(uncompacted).defects < defects ? uncompacted : result;
244
+ return measureLayout(uncompacted, true).defects < defects ? uncompacted : result;
217
245
  }
218
246
 
219
247
  async #layout<T extends ElkNode>(
@@ -592,8 +620,19 @@ export default class ELK {
592
620
  const { sourceId, targetId } = originalHierarchyEndpoints.get(edge)!;
593
621
  const sourceInside = descendantOwnerByEndpointId.has(sourceId);
594
622
  const proxy = proxyFor(sourceInside ? "output" : "input", edge);
623
+ // ELK keeps center labels on the shallowest segment: the parent's,
624
+ // unless the edge enters through the child's own port.
625
+ const labels =
626
+ (graph.edges ?? []).includes(edge) && !ownPortFor(edge)
627
+ ? edge.labels?.filter(
628
+ (label) =>
629
+ (getOption(label.layoutOptions ?? {}, "edgeLabels.placement") ?? "CENTER") !==
630
+ "CENTER",
631
+ )
632
+ : edge.labels;
595
633
  const segment: ElkEdge = {
596
634
  ...edge,
635
+ labels,
597
636
  id: `__native_hierarchy_edge_${String(child.id)}_${String(edge.id)}`,
598
637
  sources: [sourceInside ? sourceId : proxy.ports![0]!.id!],
599
638
  targets: [sourceInside ? proxy.ports![0]!.id! : targetId],
@@ -1074,6 +1113,9 @@ export default class ELK {
1074
1113
  },
1075
1114
  settings: {
1076
1115
  ...getLayeredSettings(layoutOptions),
1116
+ ...((arguments_ as ModelArguments)[crossingModel]
1117
+ ? { [CROSSING_MODEL]: (arguments_ as ModelArguments)[crossingModel] }
1118
+ : {}),
1077
1119
  ...(compoundLayout || (hasHierarchy && !separateHierarchy && !topdownLayout)
1078
1120
  ? { separateConnectedComponents: false }
1079
1121
  : {}),
@@ -1430,18 +1472,13 @@ function coordinatePreparedScopes(
1430
1472
  ).length;
1431
1473
  const childScope: HierarchyCrossingScope = {
1432
1474
  ...scopes.get(child)!,
1433
- useBottomUp: useBottomUpHierarchySweep(
1434
- childPhase.input,
1435
- childPhase.orientation,
1436
- childSession.snapshot().layers,
1437
- {
1438
- portOrderFixed: parentPortOrderFixed,
1439
- inputPorts,
1440
- outputPorts,
1441
- deterministic:
1442
- child.options?.settings?.["crossingMinimization.strategy"] === "MEDIAN_LAYER_SWEEP",
1443
- },
1444
- ),
1475
+ useBottomUp: useBottomUpHierarchySweep(childPhase.input, childSession.crossingGraph(), {
1476
+ portOrderFixed: parentPortOrderFixed,
1477
+ inputPorts,
1478
+ outputPorts,
1479
+ deterministic:
1480
+ child.options?.settings?.["crossingMinimization.strategy"] === "MEDIAN_LAYER_SWEEP",
1481
+ }),
1445
1482
  alignBoundary: (forward) => {
1446
1483
  const childLayers = childSession.currentLayers();
1447
1484
  const layerIndex = forward ? 0 : childLayers.length - 1;
@@ -2761,12 +2798,18 @@ function elkBoundaryEdgeOrder(
2761
2798
  if (child.children?.length)
2762
2799
  for (const edge of order(child)) if (leaves(edge)) result.add(edge);
2763
2800
  for (const child of node.children ?? []) {
2764
- for (const port of [...(child.ports ?? []).map((p) => String(p.id)), String(child.id)]) {
2801
+ for (const port of (child.ports ?? []).map((p) => String(p.id))) {
2765
2802
  for (const edge of edges)
2766
2803
  if (endpoints(edge).sourceId === port && leaves(edge)) result.add(edge);
2767
2804
  for (const edge of edges)
2768
2805
  if (endpoints(edge).targetId === port && leaves(edge)) result.add(edge);
2769
2806
  }
2807
+ // Each edge on the node itself gets its own implicit port, in import order.
2808
+ const id = String(child.id);
2809
+ for (const edge of edges) {
2810
+ const { sourceId, targetId } = endpoints(edge);
2811
+ if ((sourceId === id || targetId === id) && leaves(edge)) result.add(edge);
2812
+ }
2770
2813
  }
2771
2814
  return [...result];
2772
2815
  };
@@ -2841,6 +2884,47 @@ function crossingVariants(
2841
2884
  return [{ "elk.randomSeed": "2" }, { "elk.randomSeed": "3" }];
2842
2885
  }
2843
2886
 
2887
+ /**
2888
+ * Whether two edges between different ends can share a port: an explicit port
2889
+ * carrying two or more of them, or implicit ports that merge because some
2890
+ * scope merges edges or a node is a hypernode.
2891
+ */
2892
+ function mayShareTracks(graph: ElkNode, options: Readonly<Record<string, unknown>>): boolean {
2893
+ const merging = (node: ElkNode, inherited: Readonly<Record<string, unknown>> = {}) => {
2894
+ const settings = getElementLayeredSettings({
2895
+ ...inherited,
2896
+ ...node.properties,
2897
+ ...node.layoutOptions,
2898
+ });
2899
+ return settings.mergeEdges === true || settings.hypernode === true;
2900
+ };
2901
+ const anyMerging = (node: ElkNode): boolean =>
2902
+ merging(node) || (node.children ?? []).some(anyMerging);
2903
+ if (merging(graph, options) || (graph.children ?? []).some(anyMerging)) return true;
2904
+ const ports = new Set<string>();
2905
+ const collectPorts = (node: ElkNode) => {
2906
+ for (const port of node.ports ?? []) ports.add(String(port.id));
2907
+ for (const child of node.children ?? []) collectPorts(child);
2908
+ };
2909
+ collectPorts(graph);
2910
+ if (!ports.size) return false;
2911
+ const seen = new Set<string>();
2912
+ const visit = (node: ElkNode): boolean => {
2913
+ for (const edge of node.edges ?? []) {
2914
+ const source = String(edge.sources?.[0] ?? edge.source),
2915
+ target = String(edge.targets?.[0] ?? edge.target);
2916
+ if (source === target) continue;
2917
+ for (const end of [source, target]) {
2918
+ if (!ports.has(end)) continue;
2919
+ if (seen.has(end)) return true;
2920
+ seen.add(end);
2921
+ }
2922
+ }
2923
+ return (node.children ?? []).some(visit);
2924
+ };
2925
+ return visit(graph);
2926
+ }
2927
+
2844
2928
  /** Whether the graph asks for more than ELK's default layered thoroughness (7). */
2845
2929
  function thorough(options: Readonly<Record<string, unknown>>, graph: ElkNode): boolean {
2846
2930
  const own = { ...options, ...graph.properties, ...graph.layoutOptions };
@@ -35,14 +35,18 @@ const strictlyInside = (value: number, low: number, high: number) =>
35
35
  value > low + EPS && value < high - EPS;
36
36
  const overlapLength = (a: readonly [number, number], b: readonly [number, number]) =>
37
37
  Math.max(0, Math.min(a[1], b[1]) - Math.max(a[0], b[0]));
38
+ /** Within `margin` of the rectangle, its border included. */
38
39
  const inRect = (point: Point, rect: Rect, margin: number) =>
39
- point.x > rect.x - margin &&
40
- point.x < rect.x + rect.width + margin &&
41
- point.y > rect.y - margin &&
42
- point.y < rect.y + rect.height + margin;
40
+ point.x >= rect.x - margin - EPS &&
41
+ point.x <= rect.x + rect.width + margin + EPS &&
42
+ point.y >= rect.y - margin - EPS &&
43
+ point.y <= rect.y + rect.height + margin + EPS;
43
44
 
44
- /** Measure an ELK JSON result in absolute coordinates. */
45
- export function measureLayout(root: ElkNode): LayoutQuality {
45
+ /**
46
+ * Measure an ELK JSON result in absolute coordinates. `defectsOnly` skips the
47
+ * pairwise route measures (crossings and overlap stay 0).
48
+ */
49
+ export function measureLayout(root: ElkNode, defectsOnly = false): LayoutQuality {
46
50
  const frames = new Map<string, Point>([[String(root.id), { x: 0, y: 0 }]]);
47
51
  const rects = new Map<string, Rect>();
48
52
  const owner = new Map<string, string>();
@@ -64,7 +68,8 @@ export function measureLayout(root: ElkNode): LayoutQuality {
64
68
  };
65
69
  place(root, { x: 0, y: 0 });
66
70
 
67
- const routes: Array<{ ends: string[]; segments: Segment[]; labels: Rect[] }> = [];
71
+ const routes: Array<{ ends: string[]; segments: Segment[]; visible: Segment[]; labels: Rect[] }> =
72
+ [];
68
73
  let defects = 0,
69
74
  bends = 0,
70
75
  length = 0;
@@ -127,7 +132,13 @@ export function measureLayout(root: ElkNode): LayoutQuality {
127
132
  for (const [index, first] of segments.entries())
128
133
  for (const second of segments.slice(index + 1))
129
134
  if (sharedLength(first, second) > EPS) defects++;
130
- routes.push({ ends, segments, labels });
135
+ routes.push({
136
+ ends,
137
+ segments,
138
+ visible:
139
+ labels.length && !defectsOnly ? segments.flatMap((s) => outsideAll(s, labels)) : segments,
140
+ labels,
141
+ });
131
142
  }
132
143
  for (const child of parent.children ?? []) collect(child);
133
144
  };
@@ -164,33 +175,42 @@ export function measureLayout(root: ElkNode): LayoutQuality {
164
175
 
165
176
  let crossings = 0,
166
177
  overlap = 0;
167
- for (const [index, first] of routes.entries())
168
- for (const second of routes.slice(index + 1)) {
169
- const shared = first.ends
170
- .filter((id) => second.ends.includes(id))
171
- .flatMap((id) => {
172
- const rect = rects.get(id);
173
- return rect ? [rect] : [];
174
- });
175
- const clear = (point: Point) => shared.every((rect) => !inRect(point, rect, TERMINAL_MARGIN));
176
- for (const s of first.segments)
177
- for (const t of second.segments) {
178
+ if (!defectsOnly)
179
+ for (const [index, first] of routes.entries())
180
+ for (const second of routes.slice(index + 1)) {
181
+ const shared = first.ends
182
+ .filter((id) => second.ends.includes(id))
183
+ .flatMap((id) => {
184
+ const rect = rects.get(id);
185
+ return rect ? [rect] : [];
186
+ });
187
+ const clear = (point: Point) =>
188
+ shared.every((rect) => !inRect(point, rect, TERMINAL_MARGIN));
189
+ // A crossing under an edge's own label is not seen; two routes crossing
190
+ // at one point cross once.
191
+ let points: Set<string> | undefined;
192
+ const cross = (s: Segment, t: Segment) => {
178
193
  const h = horizontal(s) && vertical(t) ? s : horizontal(t) && vertical(s) ? t : undefined;
179
- if (h) {
180
- const v = h === s ? t : s;
181
- const point = { x: v.a.x, y: h.a.y };
182
- if (
183
- strictlyInside(point.x, ...span(h, "x")) &&
184
- strictlyInside(point.y, ...span(v, "y")) &&
185
- clear(point)
186
- )
187
- crossings++;
188
- continue;
194
+ if (!h) return;
195
+ const v = h === s ? t : s;
196
+ const point = { x: v.a.x, y: h.a.y };
197
+ if (
198
+ strictlyInside(point.x, ...span(h, "x")) &&
199
+ strictlyInside(point.y, ...span(v, "y")) &&
200
+ clear(point)
201
+ )
202
+ (points ??= new Set()).add(`${Math.round(point.x / EPS)}:${Math.round(point.y / EPS)}`);
203
+ };
204
+ const unlabeled = first.visible === first.segments && second.visible === second.segments;
205
+ for (const s of first.segments)
206
+ for (const t of second.segments) {
207
+ if (unlabeled) cross(s, t);
208
+ const shared = sharedLength(s, t);
209
+ if (shared > EPS && clear(s.a) && clear(s.b)) overlap += shared;
189
210
  }
190
- const shared = sharedLength(s, t);
191
- if (shared > EPS && clear(s.a) && clear(s.b)) overlap += shared;
192
- }
193
- }
211
+ if (!unlabeled) for (const s of first.visible) for (const t of second.visible) cross(s, t);
212
+ crossings += points?.size ?? 0;
213
+ }
194
214
  const xs = bounds.map((point) => point.x).filter(Number.isFinite),
195
215
  ys = bounds.map((point) => point.y).filter(Number.isFinite);
196
216
  const area = xs.length
@@ -199,6 +219,34 @@ export function measureLayout(root: ElkNode): LayoutQuality {
199
219
  return { defects, crossings, overlap, bends, length, area };
200
220
  }
201
221
 
222
+ /** The parts of an axis-parallel segment outside the open interiors of `rects`. */
223
+ function outsideAll(segment: Segment, rects: readonly Rect[]): Segment[] {
224
+ let parts = [segment];
225
+ for (const rect of rects)
226
+ parts = parts.flatMap((part) => {
227
+ const axis = horizontal(part) ? "x" : vertical(part) ? "y" : undefined;
228
+ if (!axis) return [part];
229
+ const cross = axis === "x" ? "y" : "x";
230
+ const [low, high] =
231
+ axis === "x" ? [rect.x, rect.x + rect.width] : [rect.y, rect.y + rect.height];
232
+ const [crossLow, crossHigh] =
233
+ cross === "x" ? [rect.x, rect.x + rect.width] : [rect.y, rect.y + rect.height];
234
+ if (!strictlyInside(part.a[cross], crossLow, crossHigh)) return [part];
235
+ const [from, to] = span(part, axis);
236
+ if (overlapLength([from, to], [low, high]) <= EPS) return [part];
237
+ const at = (value: number) => ({ ...part.a, [axis]: value }) as Point;
238
+ return (
239
+ [
240
+ [from, Math.max(from, low)],
241
+ [Math.min(to, high), to],
242
+ ] as const
243
+ )
244
+ .filter(([a, b]) => b - a > EPS)
245
+ .map(([a, b]) => ({ a: at(a), b: at(b) }));
246
+ });
247
+ return parts;
248
+ }
249
+
202
250
  function sharedLength(s: Segment, t: Segment): number {
203
251
  if (horizontal(s) && horizontal(t) && Math.abs(s.a.y - t.a.y) < EPS)
204
252
  return overlapLength(span(s, "x"), span(t, "x"));
@@ -3,7 +3,7 @@
3
3
  * Native adaptation of ELK AllCrossingsCounter and CrossingsCounter.
4
4
  * SPDX-License-Identifier: EPL-2.0
5
5
  */
6
- import { countOrthogonalHypersegmentCrossings } from "./orthogonal-hypersegments";
6
+ import { scoreOrthogonalHypersegments } from "./orthogonal-hypersegments";
7
7
 
8
8
  export interface CrossingPort {
9
9
  id: string;
@@ -29,6 +29,8 @@ export interface CrossingScore {
29
29
  inLayer: number;
30
30
  northSouth: number;
31
31
  total: number;
32
+ /** Some hyperedge joins two or more ports on each side of its gap. */
33
+ sharedTracks: boolean;
32
34
  }
33
35
 
34
36
  class IndexTree {
@@ -61,7 +63,8 @@ class IndexTree {
61
63
  /** Score a canonical candidate without changing its node or port orders. */
62
64
  export function countAllCrossings(
63
65
  graph: CrossingGraph,
64
- boundaryMode: "hyperedges" | "edges" = "hyperedges",
66
+ /** "separated" counts hyperedges like ELK unless they would share a track (see scoreOrthogonalHypersegments). */
67
+ boundaryMode: "hyperedges" | "separated" | "edges" = "hyperedges",
65
68
  ): CrossingScore {
66
69
  const nodes = new Map(graph.layers.flat().map((node) => [node.id, node]));
67
70
  const layerByPort = new Map<string, number>();
@@ -162,7 +165,13 @@ export function countAllCrossings(
162
165
  }
163
166
  return crossings;
164
167
  };
165
- const score: CrossingScore = { betweenLayers: 0, inLayer: 0, northSouth: 0, total: 0 };
168
+ const score: CrossingScore = {
169
+ betweenLayers: 0,
170
+ inLayer: 0,
171
+ northSouth: 0,
172
+ total: 0,
173
+ sharedTracks: false,
174
+ };
166
175
  if (!graph.layers.length) return score;
167
176
  score.inLayer += countPorts(face(graph.layers[0]!, "WEST"), true);
168
177
  score.inLayer += countPorts(face(graph.layers.at(-1)!, "EAST"), true);
@@ -174,7 +183,7 @@ export function countAllCrossings(
174
183
  node.ports.some((port) => port.side === "EAST" && degree(port.id) > 1),
175
184
  ) ||
176
185
  next.some((node) => node.ports.some((port) => port.side === "WEST" && degree(port.id) > 1));
177
- if (hyper && boundaryMode === "hyperedges") {
186
+ if (hyper && boundaryMode !== "edges") {
178
187
  const sourcePorts = layer
179
188
  .flatMap((node) => node.ports)
180
189
  .filter((port) => outgoing.get(port.id)!.some((peer) => layerByPort.get(peer) !== index));
@@ -189,7 +198,7 @@ export function countAllCrossings(
189
198
  });
190
199
  const sources = new Set(sourcePorts.map((port) => port.id)),
191
200
  targets = new Set(targetPorts.map((port) => port.id));
192
- score.betweenLayers += countOrthogonalHypersegmentCrossings(
201
+ const hyperedges = scoreOrthogonalHypersegments(
193
202
  [
194
203
  ...sourcePorts.map((port, position) => ({
195
204
  id: port.id,
@@ -203,7 +212,10 @@ export function countAllCrossings(
203
212
  })),
204
213
  ],
205
214
  graph.edges.filter((edge) => sources.has(edge.source) && targets.has(edge.target)),
215
+ boundaryMode === "separated",
206
216
  );
217
+ score.betweenLayers += hyperedges.count;
218
+ score.sharedTracks ||= hyperedges.shared;
207
219
  score.inLayer +=
208
220
  countPorts(face(layer, "EAST"), true) + countPorts(face(next, "WEST"), true);
209
221
  } else {
@@ -1,12 +1,16 @@
1
1
  /* Native adaptation of ELK 0.11.1 LayerSweepTypeDecider. SPDX-License-Identifier: EPL-2.0 */
2
2
  import { externalPortDummyOf } from "./external-port-dummy";
3
- import { getCrossingUnits } from "./crossing-constraints";
4
- import type { AcyclicOrientation, LayeredPhaseInput } from "./types";
3
+ import type { CrossingGraph } from "./crossing-counter";
4
+ import type { LayeredPhaseInput } from "./types";
5
5
 
6
+ /**
7
+ * Whether a child graph is swept on its own before its parent. Like ELK, a
8
+ * node starts a random path when no western port carries an edge and ends one
9
+ * when no eastern port does, on the canonical crossing graph's port sides.
10
+ */
6
11
  export function useBottomUpHierarchySweep(
7
12
  input: LayeredPhaseInput,
8
- orientation: AcyclicOrientation,
9
- layers: readonly (readonly string[])[],
13
+ graph: CrossingGraph,
10
14
  parent: {
11
15
  portOrderFixed: boolean;
12
16
  inputPorts: number;
@@ -19,24 +23,27 @@ export function useBottomUpHierarchySweep(
19
23
  return true;
20
24
  if (parent.deterministic) return false;
21
25
  const nodes = new Map(input.graph.nodes.map((node) => [node.id, node]));
26
+ const owner = new Map<string, string>();
27
+ for (const node of graph.layers.flat())
28
+ for (const port of node.ports) owner.set(port.id, node.id);
29
+ const degree = new Map<string, number>(),
30
+ targets = new Map<string, string[]>();
31
+ for (const { source, target } of graph.edges) {
32
+ degree.set(source, (degree.get(source) ?? 0) + 1);
33
+ degree.set(target, (degree.get(target) ?? 0) + 1);
34
+ const from = owner.get(source)!;
35
+ targets.set(from, [...(targets.get(from) ?? []), owner.get(target)!]);
36
+ }
22
37
  const infos = new Map(
23
- input.graph.nodes.map((node) => [node.id, { random: 0, hierarchy: 0, edges: 0 }]),
38
+ graph.layers.flat().map((node) => [node.id, { random: 0, hierarchy: 0, edges: 0 }]),
24
39
  );
25
- const outgoing = new Map<string, string[]>(),
26
- incoming = new Map<string, string[]>();
27
- for (const edge of input.graph.edges) {
28
- const reversed = orientation.reversedEdgeIds.has(edge.id);
29
- const source = reversed ? edge.targetId : edge.sourceId,
30
- target = reversed ? edge.sourceId : edge.targetId;
31
- outgoing.set(source, [...(outgoing.get(source) ?? []), target]);
32
- incoming.set(target, [...(incoming.get(target) ?? []), source]);
33
- }
34
- const units = getCrossingUnits(input);
35
40
  let randomPaths = 0,
36
41
  hierarchyPaths = 0;
37
42
  const transfer = (source: string, target: string) => {
38
43
  const a = infos.get(source)!,
39
44
  b = infos.get(target)!;
45
+ randomPaths += a.random;
46
+ hierarchyPaths += a.hierarchy;
40
47
  b.random += a.random;
41
48
  b.hierarchy += a.hierarchy;
42
49
  b.edges += a.edges + 1;
@@ -44,39 +51,28 @@ export function useBottomUpHierarchySweep(
44
51
  const outputSide = ({ right: "EAST", left: "WEST", down: "SOUTH", up: "NORTH" } as const)[
45
52
  input.direction
46
53
  ];
47
- const passEdges = (id: string) => {
48
- const state = infos.get(id)!;
49
- for (const target of outgoing.get(id) ?? []) {
50
- randomPaths += state.random;
51
- hierarchyPaths += state.hierarchy;
52
- transfer(id, target);
53
- }
54
- };
55
- for (const layer of layers) {
54
+ for (const layer of graph.layers) {
56
55
  const northSouth: string[] = [];
57
- for (const id of layer) {
58
- if (id.startsWith("__layout_dummy:north-south:")) {
59
- northSouth.push(id);
56
+ for (const node of layer) {
57
+ if (node.type === "NORTH_SOUTH_PORT") {
58
+ northSouth.push(node.id);
60
59
  continue;
61
60
  }
62
- const node = nodes.get(id)!,
63
- state = infos.get(id)!;
64
- const external = externalPortDummyOf(node);
61
+ const state = infos.get(node.id)!;
62
+ const connected = (side: string) =>
63
+ node.ports.some((port) => port.side === side && degree.has(port.id));
64
+ const external = externalPortDummyOf(nodes.get(node.id)!);
65
65
  if (external) {
66
66
  state.hierarchy = 1;
67
67
  if (external.side === outputSide) hierarchyPaths += state.edges;
68
- } else {
69
- if (!incoming.get(id)?.length) state.random = 1;
70
- else if (!outgoing.get(id)?.length) randomPaths += state.edges;
71
- }
72
- passEdges(id);
73
- for (const dummy of units?.associates.get(id) ?? []) {
74
- randomPaths += state.random;
75
- hierarchyPaths += state.hierarchy;
76
- transfer(id, dummy);
77
- }
68
+ } else if (!connected("WEST")) state.random = 1;
69
+ else if (!connected("EAST")) randomPaths += state.edges;
70
+ for (const target of targets.get(node.id) ?? []) transfer(node.id, target);
71
+ for (const port of node.ports)
72
+ if ((port.side === "NORTH" || port.side === "SOUTH") && port.dummy)
73
+ transfer(node.id, port.dummy);
78
74
  }
79
- for (const id of northSouth) passEdges(id);
75
+ for (const id of northSouth) for (const target of targets.get(id) ?? []) transfer(id, target);
80
76
  }
81
77
  const all = randomPaths + hierarchyPaths;
82
78
  return (all === 0 ? Number.POSITIVE_INFINITY : (randomPaths - hierarchyPaths) / all) >= boundary;
@@ -1894,17 +1894,6 @@ export function* createLayeredScopePipeline<N, E, G, P>(
1894
1894
  switchedSideByPort.set(`${origin.node.id}\0${origin.port.name}`, opposite);
1895
1895
  }
1896
1896
  }
1897
- // Preserve the existing merged-edge dummy policy without moving FIRST/LAST
1898
- // nodes after crossing minimization.
1899
- if (expanded.input.settings.mergeEdges === true) {
1900
- order = {
1901
- ...order,
1902
- layers: order.layers.map((layer) => [
1903
- ...layer.filter((id) => id.startsWith("__layout_dummy:")),
1904
- ...layer.filter((id) => !id.startsWith("__layout_dummy:")),
1905
- ]),
1906
- };
1907
- }
1908
1897
  const unzippingFanIn =
1909
1898
  graph.edges.length === graph.nodes.length - 1 &&
1910
1899
  graph.nodes.some(