@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.
- package/dist/elkjs/index.cjs +2 -2
- package/dist/elkjs/index.mjs +2 -2
- package/dist/elkjs/main.cjs +1 -1
- package/dist/elkjs/main.mjs +1 -1
- package/dist/elkjs/worker.cjs +2 -2
- package/dist/elkjs/worker.mjs +2 -2
- package/dist/{elkjs-UciYx7lD.mjs → elkjs-CSi7oB5J.mjs} +181 -69
- package/dist/{elkjs-ggjsBuVP.cjs → elkjs-CnBvdyBu.cjs} +181 -69
- package/dist/index.cjs +2 -2
- package/dist/index.mjs +2 -2
- package/dist/layered/index.cjs +1 -1
- package/dist/layered/index.mjs +1 -1
- package/dist/{layered-CBPg6_6_.cjs → layered-BJu8A21s.cjs} +474 -158
- package/dist/{layered-C7ERzZjR.mjs → layered-BsAdm_u8.mjs} +469 -153
- package/dist/{layout-engine-DAw06RhN.cjs → layout-engine-BFP1TZbF.cjs} +1 -1
- package/dist/{layout-engine-DC3IYkIy.mjs → layout-engine-DnlmGxHL.mjs} +1 -1
- package/package.json +1 -1
- package/src/elkjs/index.ts +135 -33
- package/src/elkjs/layout-quality.ts +81 -33
- package/src/layered/bk-node-placement.ts +10 -7
- package/src/layered/crossing-counter.ts +27 -9
- package/src/layered/crossing-graph.ts +2 -2
- package/src/layered/greedy-switch-decider.ts +44 -17
- package/src/layered/hierarchy-sweepiness.ts +37 -41
- package/src/layered/index.ts +15 -21
- package/src/layered/loop-envelopes.ts +10 -4
- package/src/layered/node-lookup.ts +29 -0
- package/src/layered/opposing-tracks.ts +108 -8
- package/src/layered/orthogonal-hypersegments.ts +69 -7
- package/src/layered/port-distributor.ts +26 -14
- package/src/layered/port-edge-order.ts +2 -1
- package/src/layered/spacing.ts +4 -3
- 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
|
|
31
|
-
readonly #outgoing
|
|
32
|
-
readonly #layerByPort
|
|
33
|
-
readonly #ownerByPort
|
|
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(
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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 {
|
|
4
|
-
import type {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
58
|
-
if (
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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)
|
|
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;
|
package/src/layered/index.ts
CHANGED
|
@@ -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
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
}
|