@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.
- 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-BBfKN4Dw.mjs} +165 -63
- package/dist/{elkjs-ggjsBuVP.cjs → elkjs-DNfXDPup.cjs} +165 -63
- 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-5RxQhryb.cjs} +245 -39
- package/dist/{layered-C7ERzZjR.mjs → layered-DSesbctm.mjs} +240 -34
- package/dist/{layout-engine-DAw06RhN.cjs → layout-engine-CLFySz3R.cjs} +1 -1
- package/dist/{layout-engine-DC3IYkIy.mjs → layout-engine-GpBIX6ng.mjs} +1 -1
- package/package.json +1 -1
- package/src/elkjs/index.ts +112 -28
- package/src/elkjs/layout-quality.ts +81 -33
- package/src/layered/crossing-counter.ts +17 -5
- package/src/layered/hierarchy-sweepiness.ts +37 -41
- package/src/layered/index.ts +0 -11
- package/src/layered/opposing-tracks.ts +108 -8
- package/src/layered/orthogonal-hypersegments.ts +69 -7
- package/src/layered/strategies.ts +115 -18
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],
|
|
@@ -1074,6 +1113,9 @@ export default class ELK {
|
|
|
1074
1113
|
},
|
|
1075
1114
|
settings: {
|
|
1076
1115
|
...getLayeredSettings(layoutOptions),
|
|
1116
|
+
...((arguments_ as ModelArguments)[crossingModel]
|
|
1117
|
+
? { [CROSSING_MODEL]: (arguments_ as ModelArguments)[crossingModel] }
|
|
1118
|
+
: {}),
|
|
1077
1119
|
...(compoundLayout || (hasHierarchy && !separateHierarchy && !topdownLayout)
|
|
1078
1120
|
? { separateConnectedComponents: false }
|
|
1079
1121
|
: {}),
|
|
@@ -1430,18 +1472,13 @@ function coordinatePreparedScopes(
|
|
|
1430
1472
|
).length;
|
|
1431
1473
|
const childScope: HierarchyCrossingScope = {
|
|
1432
1474
|
...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
|
-
),
|
|
1475
|
+
useBottomUp: useBottomUpHierarchySweep(childPhase.input, childSession.crossingGraph(), {
|
|
1476
|
+
portOrderFixed: parentPortOrderFixed,
|
|
1477
|
+
inputPorts,
|
|
1478
|
+
outputPorts,
|
|
1479
|
+
deterministic:
|
|
1480
|
+
child.options?.settings?.["crossingMinimization.strategy"] === "MEDIAN_LAYER_SWEEP",
|
|
1481
|
+
}),
|
|
1445
1482
|
alignBoundary: (forward) => {
|
|
1446
1483
|
const childLayers = childSession.currentLayers();
|
|
1447
1484
|
const layerIndex = forward ? 0 : childLayers.length - 1;
|
|
@@ -2761,12 +2798,18 @@ function elkBoundaryEdgeOrder(
|
|
|
2761
2798
|
if (child.children?.length)
|
|
2762
2799
|
for (const edge of order(child)) if (leaves(edge)) result.add(edge);
|
|
2763
2800
|
for (const child of node.children ?? []) {
|
|
2764
|
-
for (const port of
|
|
2801
|
+
for (const port of (child.ports ?? []).map((p) => String(p.id))) {
|
|
2765
2802
|
for (const edge of edges)
|
|
2766
2803
|
if (endpoints(edge).sourceId === port && leaves(edge)) result.add(edge);
|
|
2767
2804
|
for (const edge of edges)
|
|
2768
2805
|
if (endpoints(edge).targetId === port && leaves(edge)) result.add(edge);
|
|
2769
2806
|
}
|
|
2807
|
+
// Each edge on the node itself gets its own implicit port, in import order.
|
|
2808
|
+
const id = String(child.id);
|
|
2809
|
+
for (const edge of edges) {
|
|
2810
|
+
const { sourceId, targetId } = endpoints(edge);
|
|
2811
|
+
if ((sourceId === id || targetId === id) && leaves(edge)) result.add(edge);
|
|
2812
|
+
}
|
|
2770
2813
|
}
|
|
2771
2814
|
return [...result];
|
|
2772
2815
|
};
|
|
@@ -2841,6 +2884,47 @@ function crossingVariants(
|
|
|
2841
2884
|
return [{ "elk.randomSeed": "2" }, { "elk.randomSeed": "3" }];
|
|
2842
2885
|
}
|
|
2843
2886
|
|
|
2887
|
+
/**
|
|
2888
|
+
* Whether two edges between different ends can share a port: an explicit port
|
|
2889
|
+
* carrying two or more of them, or implicit ports that merge because some
|
|
2890
|
+
* scope merges edges or a node is a hypernode.
|
|
2891
|
+
*/
|
|
2892
|
+
function mayShareTracks(graph: ElkNode, options: Readonly<Record<string, unknown>>): boolean {
|
|
2893
|
+
const merging = (node: ElkNode, inherited: Readonly<Record<string, unknown>> = {}) => {
|
|
2894
|
+
const settings = getElementLayeredSettings({
|
|
2895
|
+
...inherited,
|
|
2896
|
+
...node.properties,
|
|
2897
|
+
...node.layoutOptions,
|
|
2898
|
+
});
|
|
2899
|
+
return settings.mergeEdges === true || settings.hypernode === true;
|
|
2900
|
+
};
|
|
2901
|
+
const anyMerging = (node: ElkNode): boolean =>
|
|
2902
|
+
merging(node) || (node.children ?? []).some(anyMerging);
|
|
2903
|
+
if (merging(graph, options) || (graph.children ?? []).some(anyMerging)) return true;
|
|
2904
|
+
const ports = new Set<string>();
|
|
2905
|
+
const collectPorts = (node: ElkNode) => {
|
|
2906
|
+
for (const port of node.ports ?? []) ports.add(String(port.id));
|
|
2907
|
+
for (const child of node.children ?? []) collectPorts(child);
|
|
2908
|
+
};
|
|
2909
|
+
collectPorts(graph);
|
|
2910
|
+
if (!ports.size) return false;
|
|
2911
|
+
const seen = new Set<string>();
|
|
2912
|
+
const visit = (node: ElkNode): boolean => {
|
|
2913
|
+
for (const edge of node.edges ?? []) {
|
|
2914
|
+
const source = String(edge.sources?.[0] ?? edge.source),
|
|
2915
|
+
target = String(edge.targets?.[0] ?? edge.target);
|
|
2916
|
+
if (source === target) continue;
|
|
2917
|
+
for (const end of [source, target]) {
|
|
2918
|
+
if (!ports.has(end)) continue;
|
|
2919
|
+
if (seen.has(end)) return true;
|
|
2920
|
+
seen.add(end);
|
|
2921
|
+
}
|
|
2922
|
+
}
|
|
2923
|
+
return (node.children ?? []).some(visit);
|
|
2924
|
+
};
|
|
2925
|
+
return visit(graph);
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2844
2928
|
/** Whether the graph asks for more than ELK's default layered thoroughness (7). */
|
|
2845
2929
|
function thorough(options: Readonly<Record<string, unknown>>, graph: ElkNode): boolean {
|
|
2846
2930
|
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"));
|
|
@@ -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,7 +63,8 @@ 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
|
const nodes = new Map(graph.layers.flat().map((node) => [node.id, node]));
|
|
67
70
|
const layerByPort = new Map<string, number>();
|
|
@@ -162,7 +165,13 @@ export function countAllCrossings(
|
|
|
162
165
|
}
|
|
163
166
|
return crossings;
|
|
164
167
|
};
|
|
165
|
-
const score: CrossingScore = {
|
|
168
|
+
const score: CrossingScore = {
|
|
169
|
+
betweenLayers: 0,
|
|
170
|
+
inLayer: 0,
|
|
171
|
+
northSouth: 0,
|
|
172
|
+
total: 0,
|
|
173
|
+
sharedTracks: false,
|
|
174
|
+
};
|
|
166
175
|
if (!graph.layers.length) return score;
|
|
167
176
|
score.inLayer += countPorts(face(graph.layers[0]!, "WEST"), true);
|
|
168
177
|
score.inLayer += countPorts(face(graph.layers.at(-1)!, "EAST"), true);
|
|
@@ -174,7 +183,7 @@ export function countAllCrossings(
|
|
|
174
183
|
node.ports.some((port) => port.side === "EAST" && degree(port.id) > 1),
|
|
175
184
|
) ||
|
|
176
185
|
next.some((node) => node.ports.some((port) => port.side === "WEST" && degree(port.id) > 1));
|
|
177
|
-
if (hyper && boundaryMode
|
|
186
|
+
if (hyper && boundaryMode !== "edges") {
|
|
178
187
|
const sourcePorts = layer
|
|
179
188
|
.flatMap((node) => node.ports)
|
|
180
189
|
.filter((port) => outgoing.get(port.id)!.some((peer) => layerByPort.get(peer) !== index));
|
|
@@ -189,7 +198,7 @@ export function countAllCrossings(
|
|
|
189
198
|
});
|
|
190
199
|
const sources = new Set(sourcePorts.map((port) => port.id)),
|
|
191
200
|
targets = new Set(targetPorts.map((port) => port.id));
|
|
192
|
-
|
|
201
|
+
const hyperedges = scoreOrthogonalHypersegments(
|
|
193
202
|
[
|
|
194
203
|
...sourcePorts.map((port, position) => ({
|
|
195
204
|
id: port.id,
|
|
@@ -203,7 +212,10 @@ export function countAllCrossings(
|
|
|
203
212
|
})),
|
|
204
213
|
],
|
|
205
214
|
graph.edges.filter((edge) => sources.has(edge.source) && targets.has(edge.target)),
|
|
215
|
+
boundaryMode === "separated",
|
|
206
216
|
);
|
|
217
|
+
score.betweenLayers += hyperedges.count;
|
|
218
|
+
score.sharedTracks ||= hyperedges.shared;
|
|
207
219
|
score.inLayer +=
|
|
208
220
|
countPorts(face(layer, "EAST"), true) + countPorts(face(next, "WEST"), true);
|
|
209
221
|
} else {
|
|
@@ -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
|
@@ -1894,17 +1894,6 @@ export function* createLayeredScopePipeline<N, E, G, P>(
|
|
|
1894
1894
|
switchedSideByPort.set(`${origin.node.id}\0${origin.port.name}`, opposite);
|
|
1895
1895
|
}
|
|
1896
1896
|
}
|
|
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
1897
|
const unzippingFanIn =
|
|
1909
1898
|
graph.edges.length === graph.nodes.length - 1 &&
|
|
1910
1899
|
graph.nodes.some(
|