@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
@@ -15,6 +15,36 @@ function sidePorts(node: CrossingNode, side: Side): CrossingPort[] {
15
15
  return side === "WEST" || side === "SOUTH" ? ports.reverse() : ports;
16
16
  }
17
17
 
18
+ /** Port connectivity and layers; switches within layers leave it unchanged. */
19
+ export interface GreedySwitchTopology {
20
+ incoming: Map<string, string[]>;
21
+ outgoing: Map<string, string[]>;
22
+ layerByPort: Map<string, number>;
23
+ ownerByPort: Map<string, CrossingNode>;
24
+ }
25
+
26
+ export function greedySwitchTopology(graph: CrossingGraph): GreedySwitchTopology {
27
+ const topology: GreedySwitchTopology = {
28
+ incoming: new Map(),
29
+ outgoing: new Map(),
30
+ layerByPort: new Map(),
31
+ ownerByPort: new Map(),
32
+ };
33
+ for (const [layer, nodes] of graph.layers.entries())
34
+ for (const node of nodes)
35
+ for (const port of node.ports) {
36
+ topology.layerByPort.set(port.id, layer);
37
+ topology.ownerByPort.set(port.id, node);
38
+ topology.incoming.set(port.id, []);
39
+ topology.outgoing.set(port.id, []);
40
+ }
41
+ for (const edge of graph.edges) {
42
+ topology.outgoing.get(edge.source)?.push(edge.target);
43
+ topology.incoming.get(edge.target)?.push(edge.source);
44
+ }
45
+ return topology;
46
+ }
47
+
18
48
  interface Adjacency {
19
49
  position: number;
20
50
  cardinality: number;
@@ -27,10 +57,10 @@ interface Adjacency {
27
57
  */
28
58
  export class GreedySwitchDecider {
29
59
  readonly #free: readonly CrossingNode[];
30
- readonly #incoming = new Map<string, string[]>();
31
- readonly #outgoing = new Map<string, string[]>();
32
- readonly #layerByPort = new Map<string, number>();
33
- readonly #ownerByPort = new Map<string, CrossingNode>();
60
+ readonly #incoming: ReadonlyMap<string, readonly string[]>;
61
+ readonly #outgoing: ReadonlyMap<string, readonly string[]>;
62
+ readonly #layerByPort: ReadonlyMap<string, number>;
63
+ readonly #ownerByPort: ReadonlyMap<string, CrossingNode>;
34
64
  readonly #neighbourPositions = new Map<string, number>();
35
65
  readonly #adjacencies = new Map<string, { sides: Map<Side, Adjacency[]> }>();
36
66
  readonly #matrix = new Map<string, [number, number]>();
@@ -39,21 +69,18 @@ export class GreedySwitchDecider {
39
69
  readonly #inLayerSize = new Map<Side, number>();
40
70
  readonly #oneSided?: "WEST" | "EAST";
41
71
 
42
- constructor(graph: CrossingGraph, freeLayerIndex: number, oneSided?: "WEST" | "EAST") {
72
+ constructor(
73
+ graph: CrossingGraph,
74
+ freeLayerIndex: number,
75
+ oneSided?: "WEST" | "EAST",
76
+ topology = greedySwitchTopology(graph),
77
+ ) {
43
78
  this.#free = graph.layers[freeLayerIndex] ?? [];
44
79
  this.#oneSided = oneSided;
45
- for (const [layer, nodes] of graph.layers.entries())
46
- for (const node of nodes)
47
- for (const port of node.ports) {
48
- this.#layerByPort.set(port.id, layer);
49
- this.#ownerByPort.set(port.id, node);
50
- this.#incoming.set(port.id, []);
51
- this.#outgoing.set(port.id, []);
52
- }
53
- for (const edge of graph.edges) {
54
- this.#outgoing.get(edge.source)?.push(edge.target);
55
- this.#incoming.get(edge.target)?.push(edge.source);
56
- }
80
+ this.#incoming = topology.incoming;
81
+ this.#outgoing = topology.outgoing;
82
+ this.#layerByPort = topology.layerByPort;
83
+ this.#ownerByPort = topology.ownerByPort;
57
84
  for (const [layerIndex, side] of [
58
85
  [freeLayerIndex - 1, "EAST"],
59
86
  [freeLayerIndex + 1, "WEST"],
@@ -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;
@@ -1436,15 +1436,16 @@ export function* createLayeredScopePipeline<N, E, G, P>(
1436
1436
  settings: options.settings ?? {},
1437
1437
  ...(options.nodeSettings === undefined ? {} : { nodeSettings: options.nodeSettings }),
1438
1438
  ...(options.edgeSettings === undefined ? {} : { edgeSettings: options.edgeSettings }),
1439
- portSettings: (port, node) => ({
1440
- ...options.portSettings?.(port, node),
1441
- ...(parallelPortIndexByKey.has(`${node.id}\0${port.name}`)
1442
- ? { "port.index": parallelPortIndexByKey.get(`${node.id}\0${port.name}`) }
1443
- : {}),
1444
- ...(switchedSideByPort.has(`${node.id}\0${port.name}`)
1445
- ? { "port.side": switchedSideByPort.get(`${node.id}\0${port.name}`) }
1446
- : {}),
1447
- }),
1439
+ portSettings: (port, node) => {
1440
+ const key = `${node.id}\0${port.name}`;
1441
+ return {
1442
+ ...options.portSettings?.(port, node),
1443
+ ...(parallelPortIndexByKey.has(key)
1444
+ ? { "port.index": parallelPortIndexByKey.get(key) }
1445
+ : {}),
1446
+ ...(switchedSideByPort.has(key) ? { "port.side": switchedSideByPort.get(key) } : {}),
1447
+ };
1448
+ },
1448
1449
  };
1449
1450
  const measure = <T>(id: string, run: () => T): T => {
1450
1451
  context?.throwIfAborted();
@@ -1697,12 +1698,16 @@ export function* createLayeredScopePipeline<N, E, G, P>(
1697
1698
  southEdges = new Set<string>();
1698
1699
  const upper = direction === "right" || direction === "left" ? "NORTH" : "WEST";
1699
1700
  const lower = direction === "right" || direction === "left" ? "SOUTH" : "EAST";
1701
+ // First node per id, as `find` returns.
1702
+ const endpointNodes = new Map<string, GraphNode>();
1703
+ for (const node of expanded.input.graph.nodes)
1704
+ if (!endpointNodes.has(node.id)) endpointNodes.set(node.id, node);
1700
1705
  for (const edge of expanded.input.graph.edges)
1701
1706
  for (const [id, name] of [
1702
1707
  [edge.sourceId, edge.sourcePort],
1703
1708
  [edge.targetId, edge.targetPort],
1704
1709
  ]) {
1705
- const node = expanded.input.graph.nodes.find((n) => n.id === id),
1710
+ const node = endpointNodes.get(id!),
1706
1711
  port = node?.ports?.find((p) => p.name === name);
1707
1712
  const side =
1708
1713
  node && port ? expanded.input.portSettings?.(port, node)?.["port.side"] : undefined;
@@ -1894,17 +1899,6 @@ export function* createLayeredScopePipeline<N, E, G, P>(
1894
1899
  switchedSideByPort.set(`${origin.node.id}\0${origin.port.name}`, opposite);
1895
1900
  }
1896
1901
  }
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
1902
  const unzippingFanIn =
1909
1903
  graph.edges.length === graph.nodes.length - 1 &&
1910
1904
  graph.nodes.some(
@@ -7,6 +7,7 @@ import {
7
7
  routeFixedSelfLoop,
8
8
  selfLoopTracks,
9
9
  } from "./fixed-self-loop";
10
+ import { findNode } from "./node-lookup";
10
11
 
11
12
  export interface LoopEnvelope {
12
13
  before: number;
@@ -23,7 +24,7 @@ export function hasMovableLoopPorts(input: LayeredPhaseInput, edge: GraphEdge):
23
24
  edge.targetPort !== undefined
24
25
  )
25
26
  return false;
26
- const node = input.graph.nodes.find((candidate) => candidate.id === edge.sourceId);
27
+ const node = findNode(input.graph.nodes, edge.sourceId);
27
28
  const constraints = node && input.nodeSettings?.(node)?.portConstraints;
28
29
  return constraints === undefined || constraints === "FREE" || constraints === "UNDEFINED";
29
30
  }
@@ -31,10 +32,15 @@ export function hasMovableLoopPorts(input: LayeredPhaseInput, edge: GraphEdge):
31
32
  export function loopEnvelopes(input: LayeredPhaseInput): ReadonlyMap<string, LoopEnvelope> {
32
33
  const envelopes = new Map<string, LoopEnvelope>();
33
34
  if ((input.settings.edgeRouting ?? "ORTHOGONAL") !== "ORTHOGONAL") return envelopes;
35
+ const loopsByNodeId = new Map<string, GraphEdge[]>();
36
+ for (const edge of input.graph.edges)
37
+ if (edge.sourceId === edge.targetId) {
38
+ const loops = loopsByNodeId.get(edge.sourceId);
39
+ if (loops) loops.push(edge);
40
+ else loopsByNodeId.set(edge.sourceId, [edge]);
41
+ }
34
42
  for (const node of input.graph.nodes) {
35
- const allLoops = input.graph.edges.filter(
36
- (edge) => edge.sourceId === node.id && edge.targetId === node.id,
37
- );
43
+ const allLoops = loopsByNodeId.get(node.id)?.slice() ?? [];
38
44
  const constraints = input.nodeSettings?.(node)?.portConstraints;
39
45
  if (
40
46
  allLoops.length &&
@@ -0,0 +1,29 @@
1
+ import type { GraphNode } from "@statelyai/graph";
2
+
3
+ // First position of each id per node list, rebuilt when the list's length
4
+ // changes. Layout code only appends to node lists, never replaces entries in
5
+ // place; positions are still checked on use and a miss scans like `find`.
6
+ const indexByNodes = new WeakMap<
7
+ readonly GraphNode[],
8
+ { length: number; positions: Map<string, number> }
9
+ >();
10
+
11
+ function indexNodes(nodes: readonly GraphNode[]): Map<string, number> {
12
+ const positions = new Map<string, number>();
13
+ for (const [index, node] of nodes.entries())
14
+ if (!positions.has(node.id)) positions.set(node.id, index);
15
+ indexByNodes.set(nodes, { length: nodes.length, positions });
16
+ return positions;
17
+ }
18
+
19
+ /** Same as `nodes.find((node) => node.id === id)`, indexed per list. */
20
+ export function findNode(nodes: readonly GraphNode[], id: string): GraphNode | undefined {
21
+ const cached = indexByNodes.get(nodes);
22
+ let position = (
23
+ cached && cached.length === nodes.length ? cached.positions : indexNodes(nodes)
24
+ ).get(id);
25
+ if (position !== undefined && nodes[position]?.id !== id) position = indexNodes(nodes).get(id);
26
+ return position !== undefined && nodes[position]?.id === id
27
+ ? nodes[position]
28
+ : nodes.find((node) => node.id === id);
29
+ }
@@ -120,6 +120,14 @@ function crossing(s: Segment, t: Segment): boolean {
120
120
  h.a.y < Math.max(v.a.y, v.b.y) - EPS
121
121
  );
122
122
  }
123
+ const onBorder = (p: TrackPoint, r: TrackRect) => {
124
+ const inX = p.x >= r.x - EPS && p.x <= r.x + r.width + EPS,
125
+ inY = p.y >= r.y - EPS && p.y <= r.y + r.height + EPS;
126
+ return (
127
+ (inY && (Math.abs(p.x - r.x) <= EPS || Math.abs(p.x - r.x - r.width) <= EPS)) ||
128
+ (inX && (Math.abs(p.y - r.y) <= EPS || Math.abs(p.y - r.y - r.height) <= EPS))
129
+ );
130
+ };
123
131
  const segmentsOf = (points: readonly TrackPoint[]): Segment[] =>
124
132
  points.slice(1).flatMap((b, i) => {
125
133
  const s = { a: points[i]!, b };
@@ -159,8 +167,28 @@ export function separateOpposingTracks(scene: TrackScene): Map<string, TrackPoin
159
167
  if (!rects) terminalCache.set(key, (rects = terminals(a.route, b.route)));
160
168
  return rects;
161
169
  };
170
+ const boxes = new WeakMap<Segment[], [number, number, number, number]>();
171
+ const boxOf = (segments: Segment[]) => {
172
+ let box = boxes.get(segments);
173
+ if (!box) {
174
+ box = [Infinity, Infinity, -Infinity, -Infinity];
175
+ for (const { a, b } of segments) {
176
+ box[0] = Math.min(box[0], a.x, b.x);
177
+ box[1] = Math.min(box[1], a.y, b.y);
178
+ box[2] = Math.max(box[2], a.x, b.x);
179
+ box[3] = Math.max(box[3], a.y, b.y);
180
+ }
181
+ boxes.set(segments, box);
182
+ }
183
+ return box;
184
+ };
185
+ const none = { opposing: 0, overlap: 0, crossings: 0 };
162
186
  // The metric's pair measures: opposite-direction and same-direction shared length, crossings.
163
187
  const pair = (mine: Segment[], self: State, other: State) => {
188
+ // Routes with disjoint bounds neither share a track nor cross.
189
+ const [ax0, ay0, ax1, ay1] = boxOf(mine),
190
+ [bx0, by0, bx1, by1] = boxOf(other.measured);
191
+ if (ax1 < bx0 - EPS || bx1 < ax0 - EPS || ay1 < by0 - EPS || by1 < ay0 - EPS) return none;
164
192
  const rects = terminalsOf(self, other);
165
193
  const as = rects.length ? clip(mine, rects, true) : mine,
166
194
  bs = rects.length ? clip(other.measured, rects, true) : other.measured;
@@ -449,6 +477,41 @@ export function separateOpposingTracks(scene: TrackScene): Map<string, TrackPoin
449
477
  0,
450
478
  )
451
479
  : along;
480
+ // A portless end may instead slide along its node side; its
481
+ // perpendicular neighbour stretches.
482
+ const after = { a: corner, b: beyond };
483
+ const against = (s: Segment) =>
484
+ states.reduce(
485
+ (sum, other) =>
486
+ other === state
487
+ ? sum
488
+ : other.measured.reduce((n, t) => n + (opposite(s, t) ? shared(s, t) : 0), sum),
489
+ 0,
490
+ );
491
+ if (
492
+ state.route.ends.length === 2 &&
493
+ state.route.ports?.[reversed ? 1 : 0] === undefined &&
494
+ rect &&
495
+ onBorder(port, rect) &&
496
+ (h ? vertical(after) : horizontal(after))
497
+ ) {
498
+ const lo = h ? rect.y : rect.x,
499
+ hi = lo + (h ? rect.height : rect.width);
500
+ for (const delta of offsets) {
501
+ const moved = shift(port, delta);
502
+ if ((h ? moved.y : moved.x) < lo - EPS || (h ? moved.y : moved.x) > hi + EPS)
503
+ continue;
504
+ const next = [
505
+ { a: moved, b: shift(corner, delta) },
506
+ { a: shift(corner, delta), b: beyond },
507
+ ];
508
+ // The stretched neighbour may not run against another route more than before.
509
+ if (!keeps(after, next[1]!) || against(next[1]!) > against(after) + EPS) continue;
510
+ const replaced = [moved, shift(corner, delta), ...path.slice(2)];
511
+ const points = reversed ? replaced.reverse() : replaced;
512
+ consider(points, [segment, after], next, next[0]!, 0, delta);
513
+ }
514
+ }
452
515
  for (const stub of new Set([along - clearance, spacing]))
453
516
  for (const delta of offsets) {
454
517
  if (stub < 1 || along - stub < spacing) continue;
@@ -491,17 +554,30 @@ export function separateOpposingTracks(scene: TrackScene): Map<string, TrackPoin
491
554
  * half a spacing either side of the port's centre.
492
555
  */
493
556
  function splitSharedPorts(tolerant: boolean) {
494
- const ports = scene.ports;
495
- if (!ports?.size) return;
496
557
  type End = { state: State; end: 0 | 1 };
497
558
  const groups = new Map<string, { port: TrackPort; normal: TrackPoint; ends: End[] }>();
559
+ // Implicit attachment points per node; points within EPS are one point.
560
+ const implicit = new Map<string, TrackPoint[]>();
498
561
  for (const state of states)
499
562
  for (const end of [0, 1] as const) {
500
- const id = state.route.ports?.[end];
501
- const port = id === undefined ? undefined : ports.get(id);
502
- if (!port || state.points.length < 2) continue;
563
+ if (state.points.length < 2) continue;
503
564
  const path = end ? [...state.points].reverse() : state.points;
504
565
  const [p, q] = path as [TrackPoint, TrackPoint];
566
+ let id = state.route.ports?.[end];
567
+ let port = id === undefined ? undefined : scene.ports?.get(id);
568
+ // Portless ends meeting at one point of a node border (merged edges)
569
+ // share an implicit port there.
570
+ const node = state.route.ends.length === 2 ? state.route.ends[end]! : undefined;
571
+ const rect = node === undefined ? undefined : nodes.get(node);
572
+ if (id === undefined && rect && onBorder(p, rect)) {
573
+ const points = implicit.get(node!) ?? [];
574
+ implicit.set(node!, points);
575
+ let at = points.find((o) => Math.abs(o.x - p.x) <= EPS && Math.abs(o.y - p.y) <= EPS);
576
+ if (!at) points.push((at = { x: p.x, y: p.y }));
577
+ id = `${node}\0${points.indexOf(at)}`;
578
+ port = { node: node!, rect: { ...at, width: 0, height: 0 } };
579
+ }
580
+ if (!port) continue;
505
581
  const s = { a: p, b: q };
506
582
  if (size(s) <= EPS || (!horizontal(s) && !vertical(s))) continue;
507
583
  const normal = { x: Math.sign(q.x - p.x), y: Math.sign(q.y - p.y) };
@@ -510,7 +586,14 @@ export function separateOpposingTracks(scene: TrackScene): Map<string, TrackPoin
510
586
  if (!group) groups.set(key, (group = { port, normal, ends: [] }));
511
587
  group.ends.push({ state, end });
512
588
  }
513
- for (const { port, normal, ends } of groups.values()) {
589
+ for (const group of groups.values()) {
590
+ const { port, normal } = group;
591
+ // An earlier split may have slid a straight route off an implicit port.
592
+ const ends = group.ends.filter(({ state, end }) => {
593
+ if (state.route.ports?.[end] !== undefined) return true;
594
+ const p = end ? state.points.at(-1)! : state.points[0]!;
595
+ return Math.abs(p.x - port.rect.x) <= EPS && Math.abs(p.y - port.rect.y) <= EPS;
596
+ });
514
597
  const incoming = ends.filter((e) => e.end === 1),
515
598
  outgoing = ends.filter((e) => e.end === 0);
516
599
  if (!incoming.length || !outgoing.length) continue;
@@ -530,10 +613,27 @@ export function separateOpposingTracks(scene: TrackScene): Map<string, TrackPoin
530
613
  sideHi = node[lateral] + (lateral === "x" ? node.width : node.height);
531
614
  const wide = extent >= spacing - EPS;
532
615
  const half = spacing / 2;
616
+ // A straight portless two-point route slides sideways whole, both ends
617
+ // staying on their node sides.
618
+ const slid = (state: State, points: TrackPoint[], end: 0 | 1, offset: number) => {
619
+ if (state.route.ports?.some((id) => id !== undefined)) return undefined;
620
+ if (Math.abs(points[0]![across] - points[1]![across]) <= EPS) return undefined;
621
+ const far = nodes.get(state.route.ends[1 - end] ?? "");
622
+ const moved = points.map((p) => ({ ...p, [lateral]: p[lateral] + offset }));
623
+ const within = (value: number, rect: TrackRect) => {
624
+ const lo = rect[lateral];
625
+ return (
626
+ value >= lo - EPS && value <= lo + (lateral === "x" ? rect.width : rect.height) + EPS
627
+ );
628
+ };
629
+ const value = moved[0]![lateral];
630
+ return within(value, node) && far && within(value, far) ? moved : undefined;
631
+ };
533
632
  // Move one end of a route sideways by `offset`, keeping it orthogonal.
534
- const shifted = (points: TrackPoint[], end: 0 | 1, offset: number) => {
633
+ const shifted = (state: State, points: TrackPoint[], end: 0 | 1, offset: number) => {
535
634
  if (Math.abs(offset) <= EPS) return points;
536
635
  const path = end ? [...points].reverse() : [...points];
636
+ if (path.length === 2) return slid(state, points, end, offset);
537
637
  if (path.length < 3) return undefined;
538
638
  const [p, q, r] = path as [TrackPoint, TrackPoint, TrackPoint];
539
639
  const along = r[lateral] - q[lateral];
@@ -594,7 +694,7 @@ export function separateOpposingTracks(scene: TrackScene): Map<string, TrackPoin
594
694
  const next = new Map<State, TrackPoint[]>();
595
695
  let feasible = true;
596
696
  for (const e of ends) {
597
- const points = shifted(next.get(e.state) ?? e.state.points, e.end, offsetOf(e));
697
+ const points = shifted(e.state, next.get(e.state) ?? e.state.points, e.end, offsetOf(e));
598
698
  if (!points) {
599
699
  feasible = false;
600
700
  break;
@@ -72,13 +72,34 @@ export function countOrthogonalHypersegmentCrossings(
72
72
  ports: readonly OrthogonalPort[],
73
73
  connections: readonly OrthogonalConnection[],
74
74
  ): number {
75
- const { segments } = createOrthogonalHypersegments(ports, connections);
76
- const spans = segments.map((s) => ({
77
- leftStart: Math.min(...s.incoming),
78
- leftEnd: Math.max(...s.incoming),
79
- rightStart: Math.min(...s.outgoing),
80
- rightEnd: Math.max(...s.outgoing),
81
- }));
75
+ return scoreOrthogonalHypersegments(ports, connections).count;
76
+ }
77
+
78
+ /**
79
+ * {@link countOrthogonalHypersegmentCrossings}, optionally separating shared
80
+ * tracks. ELK routes a hyperedge on one shared track. When it joins two or
81
+ * more ports on each side, its edges would run opposite ways along that
82
+ * track, which routing never allows; `separateShared` counts such a
83
+ * hyperedge's edges one by one instead. `shared` reports whether one exists.
84
+ */
85
+ export function scoreOrthogonalHypersegments(
86
+ ports: readonly OrthogonalPort[],
87
+ connections: readonly OrthogonalConnection[],
88
+ separateShared = false,
89
+ ): { count: number; shared: boolean } {
90
+ const { segments: merged, segmentByPort } = createOrthogonalHypersegments(ports, connections);
91
+ const sharedSegments = new Set(
92
+ merged.flatMap((s, i) => (s.incoming.length > 1 && s.outgoing.length > 1 ? [i] : [])),
93
+ );
94
+ const separating = separateShared && sharedSegments.size > 0;
95
+ const spans = merged
96
+ .filter((_, i) => !separating || !sharedSegments.has(i))
97
+ .map((s) => ({
98
+ leftStart: Math.min(...s.incoming),
99
+ leftEnd: Math.max(...s.incoming),
100
+ rightStart: Math.min(...s.outgoing),
101
+ rightEnd: Math.max(...s.outgoing),
102
+ }));
82
103
  let count = 0;
83
104
  for (let i = 0; i < spans.length; i++)
84
105
  for (let j = i + 1; j < spans.length; j++) {
@@ -88,5 +109,46 @@ export function countOrthogonalHypersegmentCrossings(
88
109
  if (a.leftStart < b.leftEnd && b.leftStart < a.leftEnd) count++;
89
110
  if (a.rightStart < b.rightEnd && b.rightStart < a.rightEnd) count++;
90
111
  }
112
+ if (separating) {
113
+ // A separated edge is a one-point span on each side.
114
+ const position = new Map(ports.map((p) => [p.id, p.position]));
115
+ const edges = connections
116
+ .filter((c) => sharedSegments.has(segmentByPort.get(c.source)!))
117
+ .map((c) => [position.get(c.source)!, position.get(c.target)!] as const);
118
+ for (const [left, right] of edges)
119
+ for (const b of spans) {
120
+ if ((left - b.leftStart) * (right - b.rightStart) < 0) count++;
121
+ if (left < b.leftEnd && b.leftStart < left) count++;
122
+ if (right < b.rightEnd && b.rightStart < right) count++;
123
+ }
124
+ count += strictInversions(edges);
125
+ }
126
+ return { count, shared: sharedSegments.size > 0 };
127
+ }
128
+
129
+ /** Pairs whose ends are strictly inverted: two separated edges that cross, in O(n log n). */
130
+ function strictInversions(pairs: ReadonlyArray<readonly [number, number]>): number {
131
+ const ranks = [...new Set(pairs.map(([, right]) => right))].sort((a, b) => a - b);
132
+ const rankOf = new Map(ranks.map((value, index) => [value, index + 1]));
133
+ const tree = new Array<number>(ranks.length + 1).fill(0);
134
+ const atMost = (rank: number) => {
135
+ let sum = 0;
136
+ for (let i = rank; i > 0; i -= i & -i) sum += tree[i]!;
137
+ return sum;
138
+ };
139
+ const sorted = [...pairs].sort((a, b) => a[0] - b[0]);
140
+ let inserted = 0,
141
+ count = 0;
142
+ for (let start = 0; start < sorted.length;) {
143
+ let end = start;
144
+ while (end < sorted.length && sorted[end]![0] === sorted[start]![0]) end++;
145
+ // Edges with an equal left end never cross; compare against earlier ones only.
146
+ for (let i = start; i < end; i++) count += inserted - atMost(rankOf.get(sorted[i]![1])!);
147
+ for (let i = start; i < end; i++) {
148
+ for (let r = rankOf.get(sorted[i]![1])!; r <= ranks.length; r += r & -r) tree[r]!++;
149
+ inserted++;
150
+ }
151
+ start = end;
152
+ }
91
153
  return count;
92
154
  }