@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.
@@ -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
  }
@@ -7,7 +7,7 @@ import {
7
7
  } from "./initial-model-order";
8
8
  import { insertionSort } from "./gwt-sort";
9
9
  import { CanonicalPortDistributor } from "./port-distributor";
10
- import { countAllCrossings } from "./crossing-counter";
10
+ import { countAllCrossings, type CrossingGraph } from "./crossing-counter";
11
11
  import {
12
12
  createCrossingGraphBuilder,
13
13
  crossingGraph,
@@ -124,6 +124,24 @@ function cycleModelOrder(input: LayeredPhaseInput): Map<string, number> {
124
124
  );
125
125
  }
126
126
 
127
+ /**
128
+ * Internal setting: how crossing minimization models ports and tracks.
129
+ * `mixedPorts` (default true) counts a port carrying both directions as two
130
+ * ports and groups its neighbours by direction. `separateSharedTracks`
131
+ * (default false) counts a hyperedge joining two or more ports on each side
132
+ * of its gap edge by edge, since routing never lets edges run opposite ways
133
+ * along one track. A `probe` records whether either choice can change the
134
+ * order. Each helps on average but steers the sweeps into worse orders on
135
+ * some graphs, so the elkjs facade also lays such graphs out with the
136
+ * alternatives and keeps the best result.
137
+ */
138
+ export const CROSSING_MODEL = "internal.crossingModel";
139
+ export interface CrossingModel {
140
+ mixedPorts?: boolean;
141
+ separateSharedTracks?: boolean;
142
+ probe?: { mixedPorts: boolean; sharedTracks: boolean };
143
+ }
144
+
127
145
  export const breakCyclesWithDepthFirstSearch: CycleBreaker = (input) => {
128
146
  const outgoing = new Map<string, GraphEdge[]>();
129
147
  for (const node of input.graph.nodes) outgoing.set(node.id, []);
@@ -818,14 +836,7 @@ export function applyLayerConstraintOrder(input: LayeredPhaseInput, order: Layer
818
836
  ...order,
819
837
  layers: order.layers.map((layer) => {
820
838
  const unconstrained = layer.filter((id) => !constrained.has(id));
821
- const mergedOrder =
822
- input.settings.mergeEdges === true
823
- ? [
824
- ...unconstrained.filter((id) => id.startsWith("__layout_dummy:")),
825
- ...unconstrained.filter((id) => !id.startsWith("__layout_dummy:")),
826
- ]
827
- : unconstrained;
828
- return [...mergedOrder, ...layer.filter((id) => constrained.has(id))];
839
+ return [...unconstrained, ...layer.filter((id) => constrained.has(id))];
829
840
  }),
830
841
  };
831
842
  }
@@ -1897,6 +1908,8 @@ export interface LayerSweepSession {
1897
1908
  currentLayers(): readonly (readonly string[])[];
1898
1909
  /** Current canonical port ids of one node, as {@link snapshot} records them. */
1899
1910
  physicalPortOrder(nodeId: string): string[];
1911
+ /** Canonical crossing graph in the current order; callers must not mutate it. */
1912
+ crossingGraph(): CrossingGraph;
1900
1913
  /** Live incident edge orders of one node; callers must not mutate them. */
1901
1914
  edgeOrders(nodeId: string): { incoming: readonly string[]; outgoing: readonly string[] };
1902
1915
  /** Same as restoring a snapshot that differs only in one layer. */
@@ -1984,6 +1997,32 @@ export function createLayerSweepSession(
1984
1997
  }
1985
1998
  const nodeById = new Map(input.graph.nodes.map((node) => [node.id, node]));
1986
1999
  const edgeById = new Map(input.graph.edges.map((edge) => [edge.id, edge]));
2000
+ // ELK lists a compound's boundary ports in creation order: edges on them
2001
+ // take their slots in that order, in model order within a port. Authored
2002
+ // ports already arrive in ELK's sorted order and keep their edge order.
2003
+ const boundaryPortIndex = (nodeId: string, edgeId: string, incoming: boolean) => {
2004
+ const edge = edgeById.get(edgeId)!;
2005
+ const name =
2006
+ incoming !== orientation.reversedEdgeIds.has(edgeId) ? edge.targetPort : edge.sourcePort;
2007
+ if (!name?.startsWith("__native_hierarchy")) return -1;
2008
+ return nodeById.get(nodeId)!.ports?.findIndex((port) => port.name === name) ?? -1;
2009
+ };
2010
+ for (const [orders, incoming] of [
2011
+ [outputPortOrder, false],
2012
+ [inputPortOrder, true],
2013
+ ] as const)
2014
+ for (const [nodeId, edgeIds] of orders) {
2015
+ if (edgeIds.length < 2) continue;
2016
+ const slots = edgeIds.flatMap((id, slot) =>
2017
+ boundaryPortIndex(nodeId, id, incoming) < 0 ? [] : [slot],
2018
+ );
2019
+ const sorted = slots
2020
+ .map((slot) => edgeIds[slot]!)
2021
+ .sort(
2022
+ (a, b) => boundaryPortIndex(nodeId, a, incoming) - boundaryPortIndex(nodeId, b, incoming),
2023
+ );
2024
+ slots.forEach((slot, index) => (edgeIds[slot] = sorted[index]!));
2025
+ }
1987
2026
  const edgeModelOrder = new Map(input.graph.edges.map((edge, index) => [edge.id, index]));
1988
2027
  const hierarchicalNodes = new Set<string>();
1989
2028
  const fixedOrderNodes = new Set(
@@ -2037,6 +2076,10 @@ export function createLayerSweepSession(
2037
2076
  }
2038
2077
  }
2039
2078
  const buildCrossingGraph = createCrossingGraphBuilder(input);
2079
+ const crossingModel: CrossingModel =
2080
+ (input.settings as { [CROSSING_MODEL]?: CrossingModel })[CROSSING_MODEL] ?? {};
2081
+ const mixedPorts = crossingModel.mixedPorts !== false,
2082
+ probe = crossingModel.probe;
2040
2083
  const countCrossings = (candidateLayers: readonly (readonly string[])[]): number => {
2041
2084
  if (exactPortSweep) {
2042
2085
  const graph = buildCrossingGraph(
@@ -2045,7 +2088,26 @@ export function createLayerSweepSession(
2045
2088
  inputPortOrder,
2046
2089
  outputPortOrder,
2047
2090
  );
2048
- let total = countAllCrossings(splitMixedCrossingPorts(graph, canonicalReversed)).total;
2091
+ // Edge orders rank a side's ports per direction; a fixed-side port with
2092
+ // a reversed edge mixes both, so count the sweep's physical port order.
2093
+ for (const layer of graph.layers)
2094
+ for (const node of layer) {
2095
+ const physical = node.ports.length > 1 ? canonicalNodes.get(node.id)?.ports : undefined;
2096
+ if (physical?.length !== node.ports.length) continue;
2097
+ const slot = new Map(physical.map((port, index) => [port.id, index]));
2098
+ if (node.ports.every((port) => slot.has(port.id)))
2099
+ node.ports.sort((a, b) => slot.get(a.id)! - slot.get(b.id)!);
2100
+ }
2101
+ const split = mixedPorts ? splitMixedCrossingPorts(graph, canonicalReversed) : graph;
2102
+ const score = countAllCrossings(
2103
+ split,
2104
+ crossingModel.separateSharedTracks ? "separated" : "hyperedges",
2105
+ );
2106
+ if (probe) {
2107
+ if (split !== graph) probe.mixedPorts = true;
2108
+ if (score.sharedTracks) probe.sharedTracks = true;
2109
+ }
2110
+ let total = score.total;
2049
2111
  const nodeInfluence = Number(
2050
2112
  input.settings["considerModelOrder.crossingCounterNodeInfluence"] ?? 0,
2051
2113
  );
@@ -2151,12 +2213,13 @@ export function createLayerSweepSession(
2151
2213
  !forward,
2152
2214
  nodeRelativePortRanks,
2153
2215
  );
2154
- const fixedIds = new Set(fixedLayer);
2155
- for (let index = 0; index < canonicalEdges.length; index++) {
2216
+ // The fixed layer's edges toward the free layer, in edge order.
2217
+ const byEnd = forward ? canonicalEdgesBySource : canonicalEdgesByTarget;
2218
+ const indices = fixedLayer.flatMap((id) => byEnd.get(id) ?? []).sort((a, b) => a - b);
2219
+ for (const index of indices) {
2156
2220
  const edge = canonicalEdges[index]!;
2157
2221
  const [source, target] = canonicalOrientedEndpoints[index]!;
2158
- if (!fixedIds.has(forward ? source : target) || !freeIds.has(forward ? target : source))
2159
- continue;
2222
+ if (!freeIds.has(forward ? target : source)) continue;
2160
2223
  const endpoint = canonicalEndpoints.get(edge.id)!;
2161
2224
  const rank =
2162
2225
  canonicalDistributor.state.ranks[forward ? endpoint.source : endpoint.target]!;
@@ -2288,6 +2351,18 @@ export function createLayerSweepSession(
2288
2351
  const canonicalOrientedEndpoints = canonicalEdges.map((edge) =>
2289
2352
  getOrientedEndpoints(edge, orientation),
2290
2353
  );
2354
+ const canonicalEdgesBySource = new Map<string, number[]>(),
2355
+ canonicalEdgesByTarget = new Map<string, number[]>();
2356
+ canonicalOrientedEndpoints.forEach(([source, target], index) => {
2357
+ for (const [byEnd, id] of [
2358
+ [canonicalEdgesBySource, source],
2359
+ [canonicalEdgesByTarget, target],
2360
+ ] as const) {
2361
+ const list = byEnd.get(id);
2362
+ if (list) list.push(index);
2363
+ else byEnd.set(id, [index]);
2364
+ }
2365
+ });
2291
2366
  const canonicalEndpoints = new Map(
2292
2367
  canonicalEdges.map((edge, index) => [edge.id, canonicalGraph.edges[index]!]),
2293
2368
  );
@@ -2386,6 +2461,7 @@ export function createLayerSweepSession(
2386
2461
  fixed: readonly string[],
2387
2462
  beyond: readonly string[] = [],
2388
2463
  ) => {
2464
+ if (!mixedPorts) return;
2389
2465
  const fixedIds = new Set(fixed);
2390
2466
  const groups = new Map<string, Array<{ id: string; reversed: boolean }>>();
2391
2467
  for (const id of free) {
@@ -2408,6 +2484,7 @@ export function createLayerSweepSession(
2408
2484
  }
2409
2485
  if (![...groups.values()].some((g) => g.some((m) => m.reversed) && g.some((m) => !m.reversed)))
2410
2486
  return;
2487
+ if (probe) probe.mixedPorts = true;
2411
2488
  const index = new Map(free.map((id, i) => [id, i]));
2412
2489
  // The direction whose nodes lead further toward the start of the layer
2413
2490
  // beyond (from the previous sweep) goes first; forward edges by default.
@@ -2649,6 +2726,10 @@ export function createLayerSweepSession(
2649
2726
  restore,
2650
2727
  finish,
2651
2728
  currentLayers: () => working,
2729
+ crossingGraph: () => ({
2730
+ layers: working.map((layer) => layer.map((id) => canonicalNodes.get(id)!)),
2731
+ edges: canonicalGraph.edges,
2732
+ }),
2652
2733
  physicalPortOrder: (id) => canonicalNodes.get(id)?.ports.map((port) => port.id) ?? [],
2653
2734
  edgeOrders: (id) => ({
2654
2735
  incoming: inputPortOrder.get(id) ?? [],
@@ -3783,6 +3864,18 @@ function implicitEdgeEndpoints(
3783
3864
 
3784
3865
  const result = new Map<string, { source: Point; target: Point }>();
3785
3866
  const edgeModelOrder = new Map(input.graph.edges.map((edge, index) => [edge.id, index]));
3867
+ // First positions in swept port orders, as `indexOf` finds them.
3868
+ const orderIndexes = new Map<readonly string[], Map<string, number>>();
3869
+ const orderIndex = (list: readonly string[], id: string) => {
3870
+ let positions = orderIndexes.get(list);
3871
+ if (!positions) {
3872
+ positions = new Map();
3873
+ for (const [index, item] of list.entries())
3874
+ if (!positions.has(item)) positions.set(item, index);
3875
+ orderIndexes.set(list, positions);
3876
+ }
3877
+ return positions.get(id) ?? -1;
3878
+ };
3786
3879
  const placedPorts = new Map<GraphNode, { rect: EntityRect; ports: GraphPort[] | undefined }>();
3787
3880
  const portSideCounts = new Map<GraphNode, Map<unknown, number>>();
3788
3881
  const portSideCount = (node: GraphNode, side: unknown): number => {
@@ -3860,8 +3953,8 @@ function implicitEdgeEndpoints(
3860
3953
  const selected = (
3861
3954
  leftIncoming ? sweptOrder?.inputPortOrderByNodeId : sweptOrder?.outputPortOrderByNodeId
3862
3955
  )?.get(nodeId);
3863
- const a = selected?.indexOf(left.edge.id) ?? -1,
3864
- b = selected?.indexOf(right.edge.id) ?? -1;
3956
+ const a = selected ? orderIndex(selected, left.edge.id) : -1,
3957
+ b = selected ? orderIndex(selected, right.edge.id) : -1;
3865
3958
  if (a >= 0 && b >= 0 && a !== b) return (leftIncoming ? -1 : 1) * (a - b);
3866
3959
  }
3867
3960
  if (interactiveLeftSourceOrder) {
@@ -4931,9 +5024,13 @@ function routeEdges(style: "ORTHOGONAL" | "POLYLINE" | "SPLINES"): EdgeRouter {
4931
5024
  candidate.straight = Math.abs(segment.start - segment.end) < 1e-3;
4932
5025
  candidate.slot = increasing ? segment.slot : maximumSlot - segment.slot;
4933
5026
  if (segment.partner !== undefined) {
5027
+ // The route leaves the graph source on the half holding that end.
4934
5028
  const partner = result.segments[segment.partner]!;
4935
- candidate.secondSlot = increasing ? partner.slot : maximumSlot - segment.slot;
4936
- if (!increasing) candidate.slot = maximumSlot - partner.slot;
5029
+ const [first, second] = physicalPort(candidate, "source").graphSource
5030
+ ? [segment, partner]
5031
+ : [partner, segment];
5032
+ candidate.slot = increasing ? first.slot : maximumSlot - first.slot;
5033
+ candidate.secondSlot = increasing ? second.slot : maximumSlot - second.slot;
4937
5034
  const sourcePort = portByCandidate.get(candidate)!.source;
4938
5035
  const offset =
4939
5036
  (increasing ? candidate.sourceCross : candidate.targetCross) -