@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
package/src/elkjs/index.ts
CHANGED
|
@@ -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
|
|
172
|
-
const
|
|
173
|
-
|
|
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
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
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_:
|
|
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
|
|
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
|
-
...
|
|
1099
|
-
...
|
|
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
|
-
...
|
|
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
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
40
|
-
point.x
|
|
41
|
-
point.y
|
|
42
|
-
point.y
|
|
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
|
-
/**
|
|
45
|
-
|
|
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({
|
|
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
|
-
|
|
168
|
-
for (const
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
66
|
+
/** "separated" counts hyperedges like ELK unless they would share a track (see scoreOrthogonalHypersegments). */
|
|
67
|
+
boundaryMode: "hyperedges" | "separated" | "edges" = "hyperedges",
|
|
65
68
|
): CrossingScore {
|
|
66
|
-
|
|
69
|
+
// 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
|
-
|
|
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.
|
|
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 = {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
357
|
-
|
|
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)
|