@statelyai/layout 0.4.0 → 0.4.2

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 (33) hide show
  1. package/dist/elkjs/index.cjs +2 -2
  2. package/dist/elkjs/index.mjs +2 -2
  3. package/dist/elkjs/main.cjs +1 -1
  4. package/dist/elkjs/main.mjs +1 -1
  5. package/dist/elkjs/worker.cjs +2 -2
  6. package/dist/elkjs/worker.mjs +2 -2
  7. package/dist/{elkjs-UciYx7lD.mjs → elkjs-CSi7oB5J.mjs} +181 -69
  8. package/dist/{elkjs-ggjsBuVP.cjs → elkjs-CnBvdyBu.cjs} +181 -69
  9. package/dist/index.cjs +2 -2
  10. package/dist/index.mjs +2 -2
  11. package/dist/layered/index.cjs +1 -1
  12. package/dist/layered/index.mjs +1 -1
  13. package/dist/{layered-CBPg6_6_.cjs → layered-BJu8A21s.cjs} +474 -158
  14. package/dist/{layered-C7ERzZjR.mjs → layered-BsAdm_u8.mjs} +469 -153
  15. package/dist/{layout-engine-DAw06RhN.cjs → layout-engine-BFP1TZbF.cjs} +1 -1
  16. package/dist/{layout-engine-DC3IYkIy.mjs → layout-engine-DnlmGxHL.mjs} +1 -1
  17. package/package.json +1 -1
  18. package/src/elkjs/index.ts +135 -33
  19. package/src/elkjs/layout-quality.ts +81 -33
  20. package/src/layered/bk-node-placement.ts +10 -7
  21. package/src/layered/crossing-counter.ts +27 -9
  22. package/src/layered/crossing-graph.ts +2 -2
  23. package/src/layered/greedy-switch-decider.ts +44 -17
  24. package/src/layered/hierarchy-sweepiness.ts +37 -41
  25. package/src/layered/index.ts +15 -21
  26. package/src/layered/loop-envelopes.ts +10 -4
  27. package/src/layered/node-lookup.ts +29 -0
  28. package/src/layered/opposing-tracks.ts +108 -8
  29. package/src/layered/orthogonal-hypersegments.ts +69 -7
  30. package/src/layered/port-distributor.ts +26 -14
  31. package/src/layered/port-edge-order.ts +2 -1
  32. package/src/layered/spacing.ts +4 -3
  33. package/src/layered/strategies.ts +288 -111
@@ -1,4 +1,4 @@
1
- const require_layered = require('./layered-CBPg6_6_.cjs');
1
+ const require_layered = require('./layered-BJu8A21s.cjs');
2
2
  let _statelyai_graph_layout = require("@statelyai/graph/layout");
3
3
 
4
4
  //#region src/fixed.ts
@@ -1,4 +1,4 @@
1
- import { G as placePorts, R as getPolylineMidpoint, Y as JavaRandom, i as layeredAlgorithm, n as elkjs0111LayeredAlgorithm, x as setElkjs0111ResultPolicy } from "./layered-C7ERzZjR.mjs";
1
+ import { K as placePorts, X as JavaRandom, i as layeredAlgorithm, n as elkjs0111LayeredAlgorithm, x as setElkjs0111ResultPolicy, z as getPolylineMidpoint } from "./layered-BsAdm_u8.mjs";
2
2
  import { getNodeSize } from "@statelyai/graph/layout";
3
3
 
4
4
  //#region src/fixed.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statelyai/layout",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Extensible, graph-native layout algorithms for @statelyai/graph",
5
5
  "license": "EPL-2.0 OR GPL-3.0-or-later",
6
6
  "repository": {
@@ -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],
@@ -1057,6 +1096,14 @@ export default class ELK {
1057
1096
  getOption(layoutOptions, "padding"),
1058
1097
  algorithm === "layered" ? 12 : 0,
1059
1098
  );
1099
+ // Element options are replaced, never mutated, while a scope lays out; parse each once.
1100
+ const parsedSettings = new WeakMap<object, ElkLayeredOptionValueByName>();
1101
+ const elementSettings = (elementOptions: Readonly<Record<string, unknown>> | undefined) => {
1102
+ const key = elementOptions ?? noElementOptions;
1103
+ let settings = parsedSettings.get(key);
1104
+ if (!settings) parsedSettings.set(key, (settings = getElementLayeredSettings(key)));
1105
+ return settings;
1106
+ };
1060
1107
  const options: LayeredLayoutOptions = {
1061
1108
  direction: getDirection(layoutOptions),
1062
1109
  spacing: {
@@ -1074,6 +1121,9 @@ export default class ELK {
1074
1121
  },
1075
1122
  settings: {
1076
1123
  ...getLayeredSettings(layoutOptions),
1124
+ ...((arguments_ as ModelArguments)[crossingModel]
1125
+ ? { [CROSSING_MODEL]: (arguments_ as ModelArguments)[crossingModel] }
1126
+ : {}),
1077
1127
  ...(compoundLayout || (hasHierarchy && !separateHierarchy && !topdownLayout)
1078
1128
  ? { separateConnectedComponents: false }
1079
1129
  : {}),
@@ -1090,13 +1140,13 @@ export default class ELK {
1090
1140
  },
1091
1141
  nodeSettings: (node) => {
1092
1142
  const child = findById(graph.children, node.id)?.item;
1093
- return getElementLayeredSettings(child?.layoutOptions ?? {});
1143
+ return elementSettings(child?.layoutOptions);
1094
1144
  },
1095
1145
  edgeSettings: (edge) => {
1096
1146
  const elkEdge = findById(graph.edges, edge.id)?.item;
1097
1147
  return {
1098
- ...getElementLayeredSettings(elkEdge?.layoutOptions ?? {}),
1099
- ...getElementLayeredSettings(elkEdge?.labels?.[0]?.layoutOptions ?? {}),
1148
+ ...elementSettings(elkEdge?.layoutOptions),
1149
+ ...elementSettings(elkEdge?.labels?.[0]?.layoutOptions),
1100
1150
  // ELK appends hierarchy segments to port edge lists in creation order.
1101
1151
  "edge.hierarchyRank": elkEdge && hierarchySegmentRank.get(elkEdge),
1102
1152
  };
@@ -1106,7 +1156,7 @@ export default class ELK {
1106
1156
  const found = findById(child?.ports, port.name);
1107
1157
  const elkPort = found?.item;
1108
1158
  return {
1109
- ...getElementLayeredSettings(elkPort?.layoutOptions ?? {}),
1159
+ ...elementSettings(elkPort?.layoutOptions),
1110
1160
  // ELK imports ports in authored order and sorts sides after dummy insertion.
1111
1161
  "port.authoredIndex": found?.position,
1112
1162
  "port.labelWidth": Math.max(
@@ -1430,18 +1480,13 @@ function coordinatePreparedScopes(
1430
1480
  ).length;
1431
1481
  const childScope: HierarchyCrossingScope = {
1432
1482
  ...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
- ),
1483
+ useBottomUp: useBottomUpHierarchySweep(childPhase.input, childSession.crossingGraph(), {
1484
+ portOrderFixed: parentPortOrderFixed,
1485
+ inputPorts,
1486
+ outputPorts,
1487
+ deterministic:
1488
+ child.options?.settings?.["crossingMinimization.strategy"] === "MEDIAN_LAYER_SWEEP",
1489
+ }),
1445
1490
  alignBoundary: (forward) => {
1446
1491
  const childLayers = childSession.currentLayers();
1447
1492
  const layerIndex = forward ? 0 : childLayers.length - 1;
@@ -1709,8 +1754,16 @@ function applyNodeMicroLayout(
1709
1754
  }
1710
1755
  }
1711
1756
 
1757
+ // Accepted spellings per option suffix, built once.
1758
+ const exactKeysBySuffix = new Map<string, readonly string[]>();
1759
+
1712
1760
  function getOption(options: Readonly<Record<string, unknown>>, suffix: string): unknown {
1713
- const exactKeys = [suffix, `elk.${suffix}`, `org.eclipse.elk.${suffix}`];
1761
+ let exactKeys = exactKeysBySuffix.get(suffix);
1762
+ if (!exactKeys)
1763
+ exactKeysBySuffix.set(
1764
+ suffix,
1765
+ (exactKeys = [suffix, `elk.${suffix}`, `org.eclipse.elk.${suffix}`]),
1766
+ );
1714
1767
  for (const key of exactKeys) {
1715
1768
  if (options[key] !== undefined) return options[key];
1716
1769
  }
@@ -1795,6 +1848,8 @@ function findById<T extends { id?: ElkId }>(items: readonly T[] | undefined, id:
1795
1848
  return position === undefined ? undefined : { item: items[position]!, position };
1796
1849
  }
1797
1850
 
1851
+ const noElementOptions: Readonly<Record<string, unknown>> = {};
1852
+
1798
1853
  function getElementLayeredSettings(
1799
1854
  options: Readonly<Record<string, unknown>>,
1800
1855
  ): ElkLayeredOptionValueByName {
@@ -2761,12 +2816,18 @@ function elkBoundaryEdgeOrder(
2761
2816
  if (child.children?.length)
2762
2817
  for (const edge of order(child)) if (leaves(edge)) result.add(edge);
2763
2818
  for (const child of node.children ?? []) {
2764
- for (const port of [...(child.ports ?? []).map((p) => String(p.id)), String(child.id)]) {
2819
+ for (const port of (child.ports ?? []).map((p) => String(p.id))) {
2765
2820
  for (const edge of edges)
2766
2821
  if (endpoints(edge).sourceId === port && leaves(edge)) result.add(edge);
2767
2822
  for (const edge of edges)
2768
2823
  if (endpoints(edge).targetId === port && leaves(edge)) result.add(edge);
2769
2824
  }
2825
+ // Each edge on the node itself gets its own implicit port, in import order.
2826
+ const id = String(child.id);
2827
+ for (const edge of edges) {
2828
+ const { sourceId, targetId } = endpoints(edge);
2829
+ if ((sourceId === id || targetId === id) && leaves(edge)) result.add(edge);
2830
+ }
2770
2831
  }
2771
2832
  return [...result];
2772
2833
  };
@@ -2841,6 +2902,47 @@ function crossingVariants(
2841
2902
  return [{ "elk.randomSeed": "2" }, { "elk.randomSeed": "3" }];
2842
2903
  }
2843
2904
 
2905
+ /**
2906
+ * Whether two edges between different ends can share a port: an explicit port
2907
+ * carrying two or more of them, or implicit ports that merge because some
2908
+ * scope merges edges or a node is a hypernode.
2909
+ */
2910
+ function mayShareTracks(graph: ElkNode, options: Readonly<Record<string, unknown>>): boolean {
2911
+ const merging = (node: ElkNode, inherited: Readonly<Record<string, unknown>> = {}) => {
2912
+ const settings = getElementLayeredSettings({
2913
+ ...inherited,
2914
+ ...node.properties,
2915
+ ...node.layoutOptions,
2916
+ });
2917
+ return settings.mergeEdges === true || settings.hypernode === true;
2918
+ };
2919
+ const anyMerging = (node: ElkNode): boolean =>
2920
+ merging(node) || (node.children ?? []).some(anyMerging);
2921
+ if (merging(graph, options) || (graph.children ?? []).some(anyMerging)) return true;
2922
+ const ports = new Set<string>();
2923
+ const collectPorts = (node: ElkNode) => {
2924
+ for (const port of node.ports ?? []) ports.add(String(port.id));
2925
+ for (const child of node.children ?? []) collectPorts(child);
2926
+ };
2927
+ collectPorts(graph);
2928
+ if (!ports.size) return false;
2929
+ const seen = new Set<string>();
2930
+ const visit = (node: ElkNode): boolean => {
2931
+ for (const edge of node.edges ?? []) {
2932
+ const source = String(edge.sources?.[0] ?? edge.source),
2933
+ target = String(edge.targets?.[0] ?? edge.target);
2934
+ if (source === target) continue;
2935
+ for (const end of [source, target]) {
2936
+ if (!ports.has(end)) continue;
2937
+ if (seen.has(end)) return true;
2938
+ seen.add(end);
2939
+ }
2940
+ }
2941
+ return (node.children ?? []).some(visit);
2942
+ };
2943
+ return visit(graph);
2944
+ }
2945
+
2844
2946
  /** Whether the graph asks for more than ELK's default layered thoroughness (7). */
2845
2947
  function thorough(options: Readonly<Record<string, unknown>>, graph: ElkNode): boolean {
2846
2948
  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"));
@@ -328,11 +328,17 @@ function buildNeighbors(input: LayeredPhaseInput, order: LayerOrder) {
328
328
  };
329
329
  // Routing gives fixed-side self-loop terminals on shared-slot ports a slot
330
330
  // among the side's other edges (after them here), so anchors reserve it.
331
+ const loopsByNodeId = new Map<string, GraphEdge[]>();
332
+ for (const edge of input.graph.edges)
333
+ if (edge.sourceId === edge.targetId) {
334
+ const loops = loopsByNodeId.get(edge.sourceId);
335
+ if (loops) loops.push(edge);
336
+ else loopsByNodeId.set(edge.sourceId, [edge]);
337
+ }
331
338
  const loopTerminals = (id: string, side: string): number => {
332
339
  const node = nodeById.get(id);
333
340
  if (!node) return 0;
334
- return input.graph.edges
335
- .filter((edge) => edge.sourceId === id && edge.targetId === id)
341
+ return (loopsByNodeId.get(id) ?? [])
336
342
  .flatMap((edge) => [edge.sourcePort, edge.targetPort])
337
343
  .filter((name) => {
338
344
  const port = node.ports?.find((candidate) => candidate.name === name);
@@ -515,11 +521,8 @@ function alignBlocks(
515
521
  const adjacent = (bal.hdir === "LEFT" ? neighbors.right : neighbors.left).get(id) ?? [];
516
522
  const low = Math.floor((adjacent.length + 1) / 2) - 1;
517
523
  const high = Math.ceil((adjacent.length + 1) / 2) - 1;
518
- const indices =
519
- bal.vdir === "UP"
520
- ? Array.from({ length: Math.max(0, high - low + 1) }, (_, i) => high - i)
521
- : Array.from({ length: Math.max(0, high - low + 1) }, (_, i) => low + i);
522
- for (const median of indices) {
524
+ for (let i = 0; i < high - low + 1; i++) {
525
+ const median = bal.vdir === "UP" ? high - i : low + i;
523
526
  if (bal.align.get(id) !== id) break;
524
527
  const neighbor = adjacent[median];
525
528
  if (neighbor === undefined) continue;
@@ -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,9 +63,11 @@ 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
- const nodes = new Map(graph.layers.flat().map((node) => [node.id, node]));
69
+ // Only north/south counting resolves nodes by id.
70
+ let nodes: Map<string, CrossingNode> | undefined;
67
71
  const layerByPort = new Map<string, number>();
68
72
  const incoming = new Map<string, string[]>(),
69
73
  outgoing = new Map<string, string[]>();
@@ -78,7 +82,6 @@ export function countAllCrossings(
78
82
  outgoing.get(edge.source)!.push(edge.target);
79
83
  incoming.get(edge.target)!.push(edge.source);
80
84
  }
81
- const connected = (id: string) => [...incoming.get(id)!, ...outgoing.get(id)!];
82
85
  const degree = (id: string) => incoming.get(id)!.length + outgoing.get(id)!.length;
83
86
  const face = (layer: readonly CrossingNode[], side: "EAST" | "WEST", topDown = true) =>
84
87
  (topDown ? layer : [...layer].reverse()).flatMap((node) => {
@@ -92,7 +95,11 @@ export function countAllCrossings(
92
95
  for (const [position, port] of ports.entries()) {
93
96
  tree.remove(position);
94
97
  const pending: number[] = [];
95
- for (const peer of connected(port.id)) {
98
+ // Incoming then outgoing peers, without concatenating them.
99
+ const before = incoming.get(port.id)!,
100
+ after = outgoing.get(port.id)!;
101
+ for (let index = 0; index < before.length + after.length; index++) {
102
+ const peer = index < before.length ? before[index]! : after[index - before.length]!;
96
103
  if (inLayerOnly && layerByPort.get(peer) !== layerByPort.get(port.id)) {
97
104
  crossings += tree.size;
98
105
  continue;
@@ -141,7 +148,9 @@ export function countAllCrossings(
141
148
  const node = ownerByPort.get(port.id)!;
142
149
  const targets =
143
150
  node.type === "NORMAL"
144
- ? nodes.get(port.dummy!)!.ports.map((peer) => ({ id: peer.id, degree: degree(peer.id) }))
151
+ ? (nodes ??= new Map(graph.layers.flat().map((node) => [node.id, node])))
152
+ .get(port.dummy!)!
153
+ .ports.map((peer) => ({ id: peer.id, degree: degree(peer.id) }))
145
154
  : node.type === "LONG_EDGE"
146
155
  ? node.ports
147
156
  .filter((peer) => peer.id !== port.id)
@@ -162,7 +171,13 @@ export function countAllCrossings(
162
171
  }
163
172
  return crossings;
164
173
  };
165
- const score: CrossingScore = { betweenLayers: 0, inLayer: 0, northSouth: 0, total: 0 };
174
+ const score: CrossingScore = {
175
+ betweenLayers: 0,
176
+ inLayer: 0,
177
+ northSouth: 0,
178
+ total: 0,
179
+ sharedTracks: false,
180
+ };
166
181
  if (!graph.layers.length) return score;
167
182
  score.inLayer += countPorts(face(graph.layers[0]!, "WEST"), true);
168
183
  score.inLayer += countPorts(face(graph.layers.at(-1)!, "EAST"), true);
@@ -174,7 +189,7 @@ export function countAllCrossings(
174
189
  node.ports.some((port) => port.side === "EAST" && degree(port.id) > 1),
175
190
  ) ||
176
191
  next.some((node) => node.ports.some((port) => port.side === "WEST" && degree(port.id) > 1));
177
- if (hyper && boundaryMode === "hyperedges") {
192
+ if (hyper && boundaryMode !== "edges") {
178
193
  const sourcePorts = layer
179
194
  .flatMap((node) => node.ports)
180
195
  .filter((port) => outgoing.get(port.id)!.some((peer) => layerByPort.get(peer) !== index));
@@ -189,7 +204,7 @@ export function countAllCrossings(
189
204
  });
190
205
  const sources = new Set(sourcePorts.map((port) => port.id)),
191
206
  targets = new Set(targetPorts.map((port) => port.id));
192
- score.betweenLayers += countOrthogonalHypersegmentCrossings(
207
+ const hyperedges = scoreOrthogonalHypersegments(
193
208
  [
194
209
  ...sourcePorts.map((port, position) => ({
195
210
  id: port.id,
@@ -203,7 +218,10 @@ export function countAllCrossings(
203
218
  })),
204
219
  ],
205
220
  graph.edges.filter((edge) => sources.has(edge.source) && targets.has(edge.target)),
221
+ boundaryMode === "separated",
206
222
  );
223
+ score.betweenLayers += hyperedges.count;
224
+ score.sharedTracks ||= hyperedges.shared;
207
225
  score.inLayer +=
208
226
  countPorts(face(layer, "EAST"), true) + countPorts(face(next, "WEST"), true);
209
227
  } else {
@@ -353,8 +353,8 @@ export function splitMixedCrossingPorts(
353
353
  const directions = new Map<string, number>();
354
354
  graph.edges.forEach((edge, index) => {
355
355
  const bit = reversed[index] ? 2 : 1;
356
- for (const port of [edge.source, edge.target])
357
- directions.set(port, (directions.get(port) ?? 0) | bit);
356
+ directions.set(edge.source, (directions.get(edge.source) ?? 0) | bit);
357
+ directions.set(edge.target, (directions.get(edge.target) ?? 0) | bit);
358
358
  });
359
359
  const mixed = new Set<string>();
360
360
  for (const layer of graph.layers)