@statelyai/layout 0.2.0 → 0.4.0
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/NOTICE.md +4 -2
- package/README.md +221 -13
- package/dist/elkjs/index.cjs +3 -2
- package/dist/elkjs/index.d.cts +13 -2
- package/dist/elkjs/index.d.mts +13 -2
- package/dist/elkjs/index.mjs +3 -3
- package/dist/elkjs/main.cjs +2 -2
- package/dist/elkjs/main.d.cts +2 -2
- package/dist/elkjs/main.d.mts +2 -2
- package/dist/elkjs/main.mjs +2 -2
- package/dist/elkjs/worker-api.cjs +1 -1
- package/dist/elkjs/worker-api.d.cts +2 -2
- package/dist/elkjs/worker-api.d.mts +2 -2
- package/dist/elkjs/worker-api.mjs +1 -1
- package/dist/elkjs/worker.cjs +2 -2
- package/dist/elkjs/worker.mjs +2 -2
- package/dist/elkjs-UciYx7lD.mjs +2973 -0
- package/dist/elkjs-ggjsBuVP.cjs +3002 -0
- package/dist/index-B6cWNrMw.d.cts +80 -0
- package/dist/index-CJQLUKD9.d.mts +80 -0
- package/dist/{index-BSH2bloA.d.mts → index-DT6lPMfS.d.cts} +117 -5
- package/dist/{index-CG712jzV.d.cts → index-F1U-fCTu.d.mts} +117 -5
- package/dist/index.cjs +39 -9
- package/dist/index.d.cts +4 -3
- package/dist/index.d.mts +4 -3
- package/dist/index.mjs +4 -3
- package/dist/layered/index.cjs +3 -1
- package/dist/layered/index.d.cts +3 -2
- package/dist/layered/index.d.mts +3 -2
- package/dist/layered/index.mjs +2 -2
- package/dist/{layered-Bz2nWmVp.mjs → layered-C7ERzZjR.mjs} +12592 -4682
- package/dist/{layered-Cf9Rn8l7.cjs → layered-CBPg6_6_.cjs} +12711 -4717
- package/dist/{layout-engine-BNex9jxg.cjs → layout-engine-DAw06RhN.cjs} +5 -18
- package/dist/{layout-engine-WbC0YjjT.mjs → layout-engine-DC3IYkIy.mjs} +3 -10
- package/dist/routing/index.cjs +29 -0
- package/dist/routing/index.d.cts +3 -0
- package/dist/routing/index.d.mts +3 -0
- package/dist/routing/index.mjs +3 -0
- package/dist/routing-CR8flv8b.mjs +2780 -0
- package/dist/routing-QIdGGFBe.cjs +2996 -0
- package/dist/types-CT2akdK0.d.mts +133 -0
- package/dist/types-uxPlGRof.d.cts +133 -0
- package/dist/{worker-client-DAT_z2OR.d.mts → worker-client-BwGGRD5y.d.mts} +1 -1
- package/dist/{worker-client-DVhklF6A.d.cts → worker-client-D4zNyPEI.d.cts} +1 -1
- package/package.json +14 -3
- package/src/authoring/coordinates.ts +38 -10
- package/src/authoring/partial.ts +7 -2
- package/src/authoring/routing.ts +4 -1
- package/src/constraints.ts +174 -6
- package/src/elkjs/index.ts +1397 -452
- package/src/elkjs/layout-quality.ts +219 -0
- package/src/elkjs/opposing-tracks.ts +161 -0
- package/src/elkjs/orthogonal-junctions.ts +174 -0
- package/src/elkjs/routes.ts +147 -0
- package/src/elkjs/statechart.ts +9 -3
- package/src/elkjs/types.ts +2 -0
- package/src/index.ts +8 -0
- package/src/internal/elkjs-compatibility.ts +4 -1
- package/src/internal/layered-trace.ts +48 -0
- package/src/internal/layout-engine.ts +2 -1
- package/src/java-random.ts +6 -0
- package/src/layered/bk-node-placement.ts +403 -92
- package/src/layered/center-labels.ts +398 -0
- package/src/layered/compaction-bounds.ts +26 -0
- package/src/layered/compaction-errors.ts +2 -0
- package/src/layered/compaction-scanline.ts +63 -0
- package/src/layered/compound-boundaries.ts +99 -0
- package/src/layered/compound.ts +445 -0
- package/src/layered/constraint-orientation.ts +141 -0
- package/src/layered/crossing-constraints.ts +243 -0
- package/src/layered/crossing-counter.ts +221 -0
- package/src/layered/crossing-graph.ts +401 -0
- package/src/layered/cycle-random.ts +22 -0
- package/src/layered/elk-port-lists.ts +243 -0
- package/src/layered/end-labels.ts +124 -0
- package/src/layered/external-port-dummy.ts +137 -0
- package/src/layered/feedback-chains.ts +114 -0
- package/src/layered/fixed-self-loop.ts +150 -0
- package/src/layered/greedy-switch-decider.ts +356 -0
- package/src/layered/grouped-compaction.ts +787 -0
- package/src/layered/gwt-sort.ts +53 -0
- package/src/layered/hierarchical-port-phases.ts +242 -0
- package/src/layered/hierarchical-port-restoration.ts +233 -0
- package/src/layered/hierarchical-port-routing.ts +179 -0
- package/src/layered/hierarchy-crossing.ts +121 -0
- package/src/layered/hierarchy-sweepiness.ts +83 -0
- package/src/layered/hints.ts +371 -0
- package/src/layered/hyperedge-dummy-merger.ts +195 -0
- package/src/layered/index.ts +911 -261
- package/src/layered/initial-model-order.ts +404 -0
- package/src/layered/inverted-ports.ts +207 -0
- package/src/layered/label-dummy-switcher.ts +200 -0
- package/src/layered/long-edges.ts +135 -44
- package/src/layered/loop-envelopes.ts +184 -0
- package/src/layered/native-routing.ts +275 -0
- package/src/layered/network-simplex.ts +11 -15
- package/src/layered/node-margins.ts +59 -0
- package/src/layered/north-south-ports.ts +271 -0
- package/src/layered/opposing-tracks.ts +944 -0
- package/src/layered/orthogonal-cycle-order.ts +106 -0
- package/src/layered/orthogonal-hypersegments.ts +92 -0
- package/src/layered/orthogonal-junctions.ts +202 -0
- package/src/layered/orthogonal-segments.ts +243 -0
- package/src/layered/placement-orientation.ts +22 -0
- package/src/layered/port-distributor.ts +192 -0
- package/src/layered/port-edge-order.ts +20 -0
- package/src/layered/replace-routing.ts +45 -0
- package/src/layered/routing-coordinates.ts +32 -0
- package/src/layered/separate-exterior-labels.ts +24 -6
- package/src/layered/spacing.ts +34 -12
- package/src/layered/splitter-order.ts +106 -0
- package/src/layered/strategies.ts +2024 -883
- package/src/layered/types.ts +34 -0
- package/src/layered/weighted-compaction.ts +84 -0
- package/src/routing/adapters.ts +143 -0
- package/src/routing/algorithms.ts +1088 -0
- package/src/routing/coordination.ts +91 -0
- package/src/routing/engine.ts +473 -0
- package/src/routing/index.ts +69 -0
- package/src/routing/layout-cache.ts +65 -0
- package/src/routing/model.ts +218 -0
- package/src/routing/path.ts +324 -0
- package/src/routing/persistent.ts +117 -0
- package/src/routing/search.ts +358 -0
- package/src/routing/spatial.ts +78 -0
- package/src/routing/types.ts +146 -0
- package/dist/elkjs-BVnDwnBQ.cjs +0 -1551
- package/dist/elkjs-CFlfkEBz.mjs +0 -1528
- /package/dist/{public-types-B8rAALJq.d.mts → public-types-9NCPoqbp.d.mts} +0 -0
- /package/dist/{public-types-CVB0hXGk.d.cts → public-types-BcRZoJE3.d.cts} +0 -0
- /package/dist/{worker-client-DkwS4sHx.mjs → worker-client-BNjwT1AJ.mjs} +0 -0
- /package/dist/{worker-client-D3HMK33r.cjs → worker-client-D4oEvhAl.cjs} +0 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import type { GraphNode, VisualGraph } from "@statelyai/graph";
|
|
2
|
+
import { worldGeometry } from "../authoring/coordinates";
|
|
3
|
+
import type { LayoutDiagnostic } from "../types";
|
|
4
|
+
import type { LayerOrder, LayeredPhaseInput, NodePlacement } from "./types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Hints steer the layered phases toward a reader's expectations. They are
|
|
8
|
+
* preferences unless marked `require`, and never at the cost of a defect: a
|
|
9
|
+
* hint that would make nodes overlap is relaxed and reported instead.
|
|
10
|
+
*/
|
|
11
|
+
export type HintStrength = "prefer" | "require";
|
|
12
|
+
interface HintBase {
|
|
13
|
+
id: string;
|
|
14
|
+
strength?: HintStrength;
|
|
15
|
+
}
|
|
16
|
+
/** Place a node at the start (top-left) or end of its container. */
|
|
17
|
+
export interface AnchorHint extends HintBase {
|
|
18
|
+
kind: "anchor";
|
|
19
|
+
nodeId: string;
|
|
20
|
+
corner: "start" | "end";
|
|
21
|
+
}
|
|
22
|
+
/** Align the centers of nodes that follow each other, such as sequential states. */
|
|
23
|
+
export interface ChainHint extends HintBase {
|
|
24
|
+
kind: "chain";
|
|
25
|
+
nodeIds: readonly string[];
|
|
26
|
+
}
|
|
27
|
+
export type LayoutHint = AnchorHint | ChainHint;
|
|
28
|
+
|
|
29
|
+
/** Serializable hint constructors, alongside the `c` geometry constraint constructors. */
|
|
30
|
+
export const hint = {
|
|
31
|
+
anchor: (value: Omit<AnchorHint, "kind">): AnchorHint => ({ ...value, kind: "anchor" }),
|
|
32
|
+
chain: (value: Omit<ChainHint, "kind">): ChainHint => ({ ...value, kind: "chain" }),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const relaxed = (
|
|
36
|
+
diagnostics: LayoutDiagnostic[] | undefined,
|
|
37
|
+
item: LayoutHint,
|
|
38
|
+
message: string,
|
|
39
|
+
): void => {
|
|
40
|
+
diagnostics?.push({
|
|
41
|
+
severity: "info",
|
|
42
|
+
code: "HINT_RELAXED",
|
|
43
|
+
message: `Hint ${item.id}: ${message}`,
|
|
44
|
+
entityIds: item.kind === "anchor" ? [item.nodeId] : [...item.nodeIds],
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Layering: an anchored node takes the first or last layer of its scope. */
|
|
49
|
+
export function hintLayerConstraint(
|
|
50
|
+
hints: readonly LayoutHint[] | undefined,
|
|
51
|
+
node: GraphNode,
|
|
52
|
+
): "FIRST" | "LAST" | undefined {
|
|
53
|
+
const anchor = hints?.find(
|
|
54
|
+
(item): item is AnchorHint => item.kind === "anchor" && item.nodeId === node.id,
|
|
55
|
+
);
|
|
56
|
+
return anchor ? (anchor.corner === "start" ? "FIRST" : "LAST") : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Ordering: an anchored node, with any helper nodes kept beside it, moves to
|
|
61
|
+
* the start or end of its layer. A preferred anchor yields when the move
|
|
62
|
+
* would add crossings.
|
|
63
|
+
*/
|
|
64
|
+
export function applyOrderHints(
|
|
65
|
+
hints: readonly LayoutHint[] | undefined,
|
|
66
|
+
order: LayerOrder,
|
|
67
|
+
unitOf: (id: string) => readonly string[],
|
|
68
|
+
crossings: (layers: LayerOrder["layers"]) => number,
|
|
69
|
+
diagnostics?: LayoutDiagnostic[],
|
|
70
|
+
): LayerOrder {
|
|
71
|
+
let layers = order.layers;
|
|
72
|
+
for (const item of hints ?? []) {
|
|
73
|
+
if (item.kind !== "anchor") continue;
|
|
74
|
+
const index = layers.findIndex((layer) => layer.includes(item.nodeId));
|
|
75
|
+
if (index < 0) continue;
|
|
76
|
+
const unit = new Set([item.nodeId, ...unitOf(item.nodeId)]);
|
|
77
|
+
const layer = layers[index]!;
|
|
78
|
+
const members = layer.filter((id) => unit.has(id)),
|
|
79
|
+
rest = layer.filter((id) => !unit.has(id));
|
|
80
|
+
const moved = item.corner === "start" ? [...members, ...rest] : [...rest, ...members];
|
|
81
|
+
if (moved.every((id, position) => id === layer[position])) continue;
|
|
82
|
+
const candidate = layers.map((current, position) => (position === index ? moved : current));
|
|
83
|
+
if ((item.strength ?? "prefer") === "prefer" && crossings(candidate) > crossings(layers)) {
|
|
84
|
+
relaxed(diagnostics, item, "kept its layer position to avoid adding crossings");
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
layers = candidate;
|
|
88
|
+
}
|
|
89
|
+
return layers === order.layers ? order : { ...order, layers };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Placement: an anchored node moves to the start (or end) of its scope on the
|
|
94
|
+
* cross axis, and chained nodes move onto the first member's center line,
|
|
95
|
+
* each only as far as its layer neighbors allow.
|
|
96
|
+
*/
|
|
97
|
+
export function applyPlacementHints(
|
|
98
|
+
hints: readonly LayoutHint[] | undefined,
|
|
99
|
+
input: LayeredPhaseInput,
|
|
100
|
+
order: LayerOrder,
|
|
101
|
+
placement: NodePlacement,
|
|
102
|
+
diagnostics?: LayoutDiagnostic[],
|
|
103
|
+
): void {
|
|
104
|
+
if (!hints?.length) return;
|
|
105
|
+
const rects = placement.rectByNodeId as Map<
|
|
106
|
+
string,
|
|
107
|
+
{ x: number; y: number; width: number; height: number }
|
|
108
|
+
>;
|
|
109
|
+
const horizontal = input.direction === "right" || input.direction === "left";
|
|
110
|
+
const cross = (id: string) => {
|
|
111
|
+
const rect = rects.get(id)!;
|
|
112
|
+
return horizontal ? { start: rect.y, size: rect.height } : { start: rect.x, size: rect.width };
|
|
113
|
+
};
|
|
114
|
+
const setCross = (id: string, start: number) => {
|
|
115
|
+
const rect = rects.get(id)!;
|
|
116
|
+
rects.set(id, horizontal ? { ...rect, y: start } : { ...rect, x: start });
|
|
117
|
+
};
|
|
118
|
+
const position = new Map<string, { layer: readonly string[]; index: number }>();
|
|
119
|
+
for (const layer of order.layers)
|
|
120
|
+
for (const [index, id] of layer.entries()) position.set(id, { layer, index });
|
|
121
|
+
const gap = (id: string) => (id.startsWith("__") ? 10 : input.spacing.node);
|
|
122
|
+
// The free interval for a node's cross start between its layer neighbors.
|
|
123
|
+
const room = (id: string) => {
|
|
124
|
+
const at = position.get(id);
|
|
125
|
+
const own = cross(id);
|
|
126
|
+
if (!at) return { low: own.start, high: own.start };
|
|
127
|
+
const before = at.layer[at.index - 1],
|
|
128
|
+
after = at.layer[at.index + 1];
|
|
129
|
+
const low = before
|
|
130
|
+
? cross(before).start + cross(before).size + Math.max(gap(before), gap(id))
|
|
131
|
+
: Number.NEGATIVE_INFINITY;
|
|
132
|
+
const high = after
|
|
133
|
+
? cross(after).start - own.size - Math.max(gap(after), gap(id))
|
|
134
|
+
: Number.POSITIVE_INFINITY;
|
|
135
|
+
// Never grow the scope: stay within its current cross extent.
|
|
136
|
+
const bounded = {
|
|
137
|
+
low: Math.max(low, extent.start),
|
|
138
|
+
high: Math.min(high, extent.end - own.size),
|
|
139
|
+
};
|
|
140
|
+
return {
|
|
141
|
+
low: Math.min(bounded.low, own.start),
|
|
142
|
+
high: Math.max(bounded.high, own.start),
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
const real = [...rects.keys()].filter((id) => !id.startsWith("__"));
|
|
146
|
+
const extent = {
|
|
147
|
+
start: Math.min(...[...rects.keys()].map((id) => cross(id).start)),
|
|
148
|
+
end: Math.max(...[...rects.keys()].map((id) => cross(id).start + cross(id).size)),
|
|
149
|
+
};
|
|
150
|
+
for (const item of hints) {
|
|
151
|
+
if (item.kind === "anchor") {
|
|
152
|
+
if (!rects.has(item.nodeId)) continue;
|
|
153
|
+
const own = cross(item.nodeId);
|
|
154
|
+
const target =
|
|
155
|
+
item.corner === "start"
|
|
156
|
+
? Math.min(...real.map((id) => cross(id).start))
|
|
157
|
+
: Math.max(...real.map((id) => cross(id).start + cross(id).size)) - own.size;
|
|
158
|
+
const { low, high } = room(item.nodeId);
|
|
159
|
+
const placed = Math.min(high, Math.max(low, target));
|
|
160
|
+
setCross(item.nodeId, placed);
|
|
161
|
+
if (Math.abs(placed - target) > 1e-6)
|
|
162
|
+
relaxed(diagnostics, item, "a neighbor in its layer kept it from the corner");
|
|
163
|
+
} else {
|
|
164
|
+
const members = item.nodeIds.filter((id) => rects.has(id));
|
|
165
|
+
if (members.length < 2) continue;
|
|
166
|
+
const anchored = members.find((id) =>
|
|
167
|
+
hints.some((other) => other.kind === "anchor" && other.nodeId === id),
|
|
168
|
+
);
|
|
169
|
+
// Find the center line every member can reach, nearest the first member
|
|
170
|
+
// (or the anchored one, which stays put).
|
|
171
|
+
const reach = members.map((id) => {
|
|
172
|
+
const own = cross(id);
|
|
173
|
+
if (id === anchored) {
|
|
174
|
+
const center = own.start + own.size / 2;
|
|
175
|
+
return { low: center, high: center };
|
|
176
|
+
}
|
|
177
|
+
const { low, high } = room(id);
|
|
178
|
+
return { low: low + own.size / 2, high: high + own.size / 2 };
|
|
179
|
+
});
|
|
180
|
+
const line = cross(anchored ?? members[0]!);
|
|
181
|
+
const preferred = line.start + line.size / 2;
|
|
182
|
+
const low = Math.max(...reach.map((range) => range.low)),
|
|
183
|
+
high = Math.min(...reach.map((range) => range.high));
|
|
184
|
+
const center = low <= high ? Math.min(high, Math.max(low, preferred)) : preferred;
|
|
185
|
+
let blocked = low > high;
|
|
186
|
+
for (const [index, id] of members.entries()) {
|
|
187
|
+
if (id === anchored) continue;
|
|
188
|
+
const own = cross(id);
|
|
189
|
+
const range = reach[index]!;
|
|
190
|
+
const placed = Math.min(range.high, Math.max(range.low, center)) - own.size / 2;
|
|
191
|
+
setCross(id, placed);
|
|
192
|
+
if (Math.abs(placed + own.size / 2 - center) > 1e-6) blocked = true;
|
|
193
|
+
}
|
|
194
|
+
if (blocked) relaxed(diagnostics, item, "layer neighbors kept some members off the line");
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Hints for statecharts: each container's initial state (and the graph's)
|
|
201
|
+
* is required at the start, and each run of states joined one-to-one within
|
|
202
|
+
* the same parent forms a preferred chain. Callers can filter or extend the
|
|
203
|
+
* result.
|
|
204
|
+
*/
|
|
205
|
+
export function statechartHints(graph: {
|
|
206
|
+
initialNodeId?: string | null;
|
|
207
|
+
nodes: readonly { id: string; parentId?: string | null; initialNodeId?: string | null }[];
|
|
208
|
+
edges: readonly { sourceId: string; targetId: string }[];
|
|
209
|
+
}): LayoutHint[] {
|
|
210
|
+
const nodes = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
211
|
+
const hints: LayoutHint[] = [];
|
|
212
|
+
const initials = [
|
|
213
|
+
...(graph.initialNodeId ? [{ scope: "root", id: graph.initialNodeId }] : []),
|
|
214
|
+
...graph.nodes.flatMap((node) =>
|
|
215
|
+
node.initialNodeId ? [{ scope: node.id, id: node.initialNodeId }] : [],
|
|
216
|
+
),
|
|
217
|
+
];
|
|
218
|
+
for (const { scope, id } of initials)
|
|
219
|
+
if (nodes.has(id))
|
|
220
|
+
hints.push(
|
|
221
|
+
hint.anchor({ id: `initial:${scope}`, nodeId: id, corner: "start", strength: "require" }),
|
|
222
|
+
);
|
|
223
|
+
// One-to-one transitions between siblings, ignoring self loops.
|
|
224
|
+
const outgoing = new Map<string, Set<string>>(),
|
|
225
|
+
incoming = new Map<string, Set<string>>();
|
|
226
|
+
for (const edge of graph.edges) {
|
|
227
|
+
if (edge.sourceId === edge.targetId) continue;
|
|
228
|
+
outgoing.set(edge.sourceId, (outgoing.get(edge.sourceId) ?? new Set()).add(edge.targetId));
|
|
229
|
+
incoming.set(edge.targetId, (incoming.get(edge.targetId) ?? new Set()).add(edge.sourceId));
|
|
230
|
+
}
|
|
231
|
+
const next = (id: string) => {
|
|
232
|
+
const targets = outgoing.get(id);
|
|
233
|
+
if (targets?.size !== 1) return undefined;
|
|
234
|
+
const [target] = targets;
|
|
235
|
+
const node = nodes.get(id),
|
|
236
|
+
other = nodes.get(target!);
|
|
237
|
+
return other &&
|
|
238
|
+
(other.parentId ?? null) === (node?.parentId ?? null) &&
|
|
239
|
+
incoming.get(target!)?.size === 1
|
|
240
|
+
? target
|
|
241
|
+
: undefined;
|
|
242
|
+
};
|
|
243
|
+
const previous = new Set(
|
|
244
|
+
graph.nodes.flatMap((node) => {
|
|
245
|
+
const target = next(node.id);
|
|
246
|
+
return target ? [target] : [];
|
|
247
|
+
}),
|
|
248
|
+
);
|
|
249
|
+
for (const node of graph.nodes) {
|
|
250
|
+
if (previous.has(node.id)) continue;
|
|
251
|
+
const chain = [node.id];
|
|
252
|
+
for (let id = next(node.id); id && !chain.includes(id); id = next(id)) chain.push(id);
|
|
253
|
+
if (chain.length >= 2) hints.push(hint.chain({ id: `chain:${node.id}`, nodeIds: chain }));
|
|
254
|
+
}
|
|
255
|
+
return hints;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
interface SoftQuality {
|
|
259
|
+
crossings: number;
|
|
260
|
+
bends: number;
|
|
261
|
+
length: number;
|
|
262
|
+
area: number;
|
|
263
|
+
}
|
|
264
|
+
/** Soft measures of a finished layout, in world coordinates. */
|
|
265
|
+
export function measureSoftQuality(graph: VisualGraph): SoftQuality {
|
|
266
|
+
const world = worldGeometry(graph);
|
|
267
|
+
const rects = new Map(world.nodes.map((node) => [node.id, node]));
|
|
268
|
+
const routes = world.edges.map((edge) => {
|
|
269
|
+
const points = edge.points ?? [];
|
|
270
|
+
return {
|
|
271
|
+
ends: [edge.sourceId, edge.targetId],
|
|
272
|
+
segments: points.slice(1).map((b, index) => ({ a: points[index]!, b })),
|
|
273
|
+
};
|
|
274
|
+
});
|
|
275
|
+
let bends = 0,
|
|
276
|
+
length = 0;
|
|
277
|
+
for (const { segments } of routes)
|
|
278
|
+
for (const [index, { a, b }] of segments.entries()) {
|
|
279
|
+
length += Math.abs(b.x - a.x) + Math.abs(b.y - a.y);
|
|
280
|
+
const previous = segments[index - 1];
|
|
281
|
+
if (previous && Math.abs(previous.a.x - previous.b.x) < 1e-9 !== Math.abs(a.x - b.x) < 1e-9)
|
|
282
|
+
bends++;
|
|
283
|
+
}
|
|
284
|
+
let crossings = 0;
|
|
285
|
+
for (const [index, first] of routes.entries())
|
|
286
|
+
for (const second of routes.slice(index + 1)) {
|
|
287
|
+
const shared = first.ends
|
|
288
|
+
.filter((id) => second.ends.includes(id))
|
|
289
|
+
.flatMap((id) => {
|
|
290
|
+
const rect = rects.get(id);
|
|
291
|
+
return rect ? [rect] : [];
|
|
292
|
+
});
|
|
293
|
+
for (const s of first.segments)
|
|
294
|
+
for (const t of second.segments) {
|
|
295
|
+
const sHorizontal = Math.abs(s.a.y - s.b.y) < 1e-9,
|
|
296
|
+
tHorizontal = Math.abs(t.a.y - t.b.y) < 1e-9;
|
|
297
|
+
if (sHorizontal === tHorizontal) continue;
|
|
298
|
+
const h = sHorizontal ? s : t,
|
|
299
|
+
v = sHorizontal ? t : s;
|
|
300
|
+
const x = v.a.x,
|
|
301
|
+
y = h.a.y;
|
|
302
|
+
if (
|
|
303
|
+
x > Math.min(h.a.x, h.b.x) + 1e-6 &&
|
|
304
|
+
x < Math.max(h.a.x, h.b.x) - 1e-6 &&
|
|
305
|
+
y > Math.min(v.a.y, v.b.y) + 1e-6 &&
|
|
306
|
+
y < Math.max(v.a.y, v.b.y) - 1e-6 &&
|
|
307
|
+
shared.every(
|
|
308
|
+
(rect) =>
|
|
309
|
+
x < rect.x - 12 ||
|
|
310
|
+
x > rect.x + rect.width + 12 ||
|
|
311
|
+
y < rect.y - 12 ||
|
|
312
|
+
y > rect.y + rect.height + 12,
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
crossings++;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const xs = world.nodes.flatMap((node) => [node.x, node.x + node.width]),
|
|
319
|
+
ys = world.nodes.flatMap((node) => [node.y, node.y + node.height]);
|
|
320
|
+
const area = xs.length
|
|
321
|
+
? (Math.max(...xs) - Math.min(...xs)) * (Math.max(...ys) - Math.min(...ys))
|
|
322
|
+
: 0;
|
|
323
|
+
return { crossings, bends, length, area };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** True when `candidate` is worse at the first soft measure where the two differ. */
|
|
327
|
+
export function isSoftRegression(candidate: SoftQuality, baseline: SoftQuality): boolean {
|
|
328
|
+
for (const key of ["crossings", "bends", "length", "area"] as const) {
|
|
329
|
+
const limit = key === "length" || key === "area" ? 0.02 * Math.abs(baseline[key]) : 0;
|
|
330
|
+
if (Math.abs(candidate[key] - baseline[key]) > limit) return candidate[key] > baseline[key];
|
|
331
|
+
}
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Lay out with every hint, keeping preferred hints only while they do not
|
|
337
|
+
* make the layout worse than one without them. Usually two layouts; when the
|
|
338
|
+
* full set regresses, preferred hints are added back one at a time.
|
|
339
|
+
*/
|
|
340
|
+
export function layoutWithHints<T extends VisualGraph>(
|
|
341
|
+
hints: readonly LayoutHint[] | undefined,
|
|
342
|
+
run: (hints: readonly LayoutHint[] | undefined, diagnostics: LayoutDiagnostic[]) => T,
|
|
343
|
+
diagnostics?: LayoutDiagnostic[],
|
|
344
|
+
): T {
|
|
345
|
+
const preferred = hints?.filter((item) => (item.strength ?? "prefer") === "prefer") ?? [];
|
|
346
|
+
if (!preferred.length) return run(hints, diagnostics ?? []);
|
|
347
|
+
const required = hints!.filter((item) => item.strength === "require");
|
|
348
|
+
const attempt = (selected: readonly LayoutHint[]) => {
|
|
349
|
+
const own: LayoutDiagnostic[] = [];
|
|
350
|
+
const graph = run(selected, own);
|
|
351
|
+
return { graph, own, quality: measureSoftQuality(graph) };
|
|
352
|
+
};
|
|
353
|
+
let best = attempt(required);
|
|
354
|
+
const all = attempt(hints!);
|
|
355
|
+
let kept: LayoutHint[] = [...required];
|
|
356
|
+
if (!isSoftRegression(all.quality, best.quality)) {
|
|
357
|
+
best = all;
|
|
358
|
+
kept = [...hints!];
|
|
359
|
+
} else
|
|
360
|
+
for (const item of preferred) {
|
|
361
|
+
const candidate = attempt([...kept, item]);
|
|
362
|
+
if (isSoftRegression(candidate.quality, best.quality)) continue;
|
|
363
|
+
best = candidate;
|
|
364
|
+
kept.push(item);
|
|
365
|
+
}
|
|
366
|
+
diagnostics?.push(...best.own);
|
|
367
|
+
for (const item of preferred)
|
|
368
|
+
if (!kept.includes(item))
|
|
369
|
+
relaxed(diagnostics, item, "dropped because it made the layout worse");
|
|
370
|
+
return best.graph;
|
|
371
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2015, 2020 Kiel University and others.
|
|
3
|
+
* Native adaptation of ELK HyperedgeDummyMerger (v0.11.0).
|
|
4
|
+
* SPDX-License-Identifier: EPL-2.0
|
|
5
|
+
*/
|
|
6
|
+
import type { LayeredPhaseInput, LayerOrder } from "./types";
|
|
7
|
+
import type { LongEdgeExpansion } from "./long-edges";
|
|
8
|
+
|
|
9
|
+
const mergedNodes = new WeakMap<LayeredPhaseInput, Set<string>>();
|
|
10
|
+
export const isMergedHyperedgeDummy = (input: LayeredPhaseInput, id: string): boolean =>
|
|
11
|
+
mergedNodes.get(input)?.has(id) ?? false;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* ELK schedules HyperedgeDummyMerger only when import finds a port with more
|
|
15
|
+
* than one incoming or outgoing edge (or a hypernode). Hierarchy scopes keep
|
|
16
|
+
* the merger: their imported edges are not visible here.
|
|
17
|
+
*/
|
|
18
|
+
export function importsHyperedges(input: LayeredPhaseInput): boolean {
|
|
19
|
+
const { graph } = input;
|
|
20
|
+
if (
|
|
21
|
+
graph.nodes.some(
|
|
22
|
+
(node) =>
|
|
23
|
+
node.id.startsWith("__native_hierarchy") || input.nodeSettings?.(node)?.hypernode === true,
|
|
24
|
+
) ||
|
|
25
|
+
graph.edges.some((edge) => edge.id.startsWith("__native_hierarchy"))
|
|
26
|
+
)
|
|
27
|
+
return true;
|
|
28
|
+
const counts = new Map<string, number>();
|
|
29
|
+
const exceeds = (nodeId: string, port: string | undefined, kind: string, edgeId: string) => {
|
|
30
|
+
const key =
|
|
31
|
+
port !== undefined
|
|
32
|
+
? `${nodeId}\0${port}\0${kind}`
|
|
33
|
+
: nodeId.startsWith("__") || input.settings.mergeEdges === true
|
|
34
|
+
? `${nodeId}\0\0${kind}`
|
|
35
|
+
: `\0${edgeId}\0${kind}`;
|
|
36
|
+
const count = (counts.get(key) ?? 0) + 1;
|
|
37
|
+
counts.set(key, count);
|
|
38
|
+
return count > 1;
|
|
39
|
+
};
|
|
40
|
+
let found = false;
|
|
41
|
+
for (const edge of graph.edges) {
|
|
42
|
+
const source = exceeds(edge.sourceId, edge.sourcePort, "out", edge.id);
|
|
43
|
+
const target = exceeds(edge.targetId, edge.targetPort, "in", edge.id);
|
|
44
|
+
found ||= source || target;
|
|
45
|
+
}
|
|
46
|
+
return found;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Merge adjacent long-edge dummies connected through the same physical hyperedge. */
|
|
50
|
+
export function mergeHyperedgeDummies(expansion: LongEdgeExpansion, order: LayerOrder): LayerOrder {
|
|
51
|
+
const { input } = expansion;
|
|
52
|
+
const labelNodes = new Set(expansion.labelDummyIdByEdgeId.values());
|
|
53
|
+
const longDummy = (id: string) =>
|
|
54
|
+
id.startsWith("__layout_dummy:") &&
|
|
55
|
+
!id.startsWith("__layout_dummy:north-south:") &&
|
|
56
|
+
!id.startsWith("__layout_dummy:label:") &&
|
|
57
|
+
!labelNodes.has(id);
|
|
58
|
+
const parent = new Map<string, string>();
|
|
59
|
+
const root = (id: string): string => {
|
|
60
|
+
const next = parent.get(id);
|
|
61
|
+
if (next === undefined) {
|
|
62
|
+
parent.set(id, id);
|
|
63
|
+
return id;
|
|
64
|
+
}
|
|
65
|
+
if (next === id) return id;
|
|
66
|
+
const result = root(next);
|
|
67
|
+
parent.set(id, result);
|
|
68
|
+
return result;
|
|
69
|
+
};
|
|
70
|
+
const port = (id: string, name: string | undefined, edge: string, endpoint: string) =>
|
|
71
|
+
longDummy(id)
|
|
72
|
+
? JSON.stringify(["dummy", id])
|
|
73
|
+
: name !== undefined
|
|
74
|
+
? JSON.stringify(["port", id, name])
|
|
75
|
+
: input.settings.mergeEdges === true
|
|
76
|
+
? JSON.stringify(["implicit", id, endpoint])
|
|
77
|
+
: JSON.stringify(["edge", edge, endpoint]);
|
|
78
|
+
for (const edge of input.graph.edges) {
|
|
79
|
+
const source = port(edge.sourceId, edge.sourcePort, edge.id, "source");
|
|
80
|
+
const target = port(edge.targetId, edge.targetPort, edge.id, "target");
|
|
81
|
+
parent.set(root(source), root(target));
|
|
82
|
+
}
|
|
83
|
+
// Label-bearing chains merge only before a shared source or after a shared target.
|
|
84
|
+
type Metadata = {
|
|
85
|
+
source?: string;
|
|
86
|
+
target?: string;
|
|
87
|
+
label?: boolean;
|
|
88
|
+
before?: boolean;
|
|
89
|
+
reversed?: boolean;
|
|
90
|
+
};
|
|
91
|
+
const metadata = new Map<string, Metadata>();
|
|
92
|
+
const edges = new Map(input.graph.edges.map((edge) => [edge.id, edge]));
|
|
93
|
+
for (const [edgeId, ids] of expansion.segmentIdsByEdgeId) {
|
|
94
|
+
const chain = ids.map((id) => edges.get(id)).filter((edge) => edge !== undefined);
|
|
95
|
+
if (!chain.length) continue;
|
|
96
|
+
const first = chain[0]!,
|
|
97
|
+
last = chain.at(-1)!;
|
|
98
|
+
const reversed =
|
|
99
|
+
(expansion.assignment.layerByNodeId.get(first.sourceId) ?? 0) >
|
|
100
|
+
(expansion.assignment.layerByNodeId.get(last.targetId) ?? 0);
|
|
101
|
+
const source = port(
|
|
102
|
+
reversed ? last.targetId : first.sourceId,
|
|
103
|
+
reversed ? last.targetPort : first.sourcePort,
|
|
104
|
+
edgeId,
|
|
105
|
+
"source",
|
|
106
|
+
);
|
|
107
|
+
const target = port(
|
|
108
|
+
reversed ? first.sourceId : last.targetId,
|
|
109
|
+
reversed ? first.sourcePort : last.targetPort,
|
|
110
|
+
edgeId,
|
|
111
|
+
"target",
|
|
112
|
+
);
|
|
113
|
+
const labelId = expansion.labelDummyIdByEdgeId.get(edgeId);
|
|
114
|
+
const labelLayer =
|
|
115
|
+
labelId === undefined ? undefined : expansion.assignment.layerByNodeId.get(labelId);
|
|
116
|
+
for (const edge of chain)
|
|
117
|
+
for (const id of [edge.sourceId, edge.targetId]) {
|
|
118
|
+
if (!longDummy(id)) continue;
|
|
119
|
+
metadata.set(id, {
|
|
120
|
+
source,
|
|
121
|
+
target,
|
|
122
|
+
label: labelId !== undefined,
|
|
123
|
+
reversed,
|
|
124
|
+
before:
|
|
125
|
+
labelLayer !== undefined &&
|
|
126
|
+
(expansion.assignment.layerByNodeId.get(id) ?? 0) < labelLayer,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const aliases = new Map<string, string>(),
|
|
131
|
+
merged = new Set<string>();
|
|
132
|
+
const layers = order.layers.map((layer) => {
|
|
133
|
+
const result: string[] = [];
|
|
134
|
+
for (const id of layer) {
|
|
135
|
+
const previous = result.at(-1);
|
|
136
|
+
if (previous !== undefined && longDummy(id) && longDummy(previous)) {
|
|
137
|
+
const currentInfo = metadata.get(id) ?? {},
|
|
138
|
+
priorInfo = metadata.get(previous) ?? {};
|
|
139
|
+
const sameSource =
|
|
140
|
+
currentInfo.source !== undefined && currentInfo.source === priorInfo.source;
|
|
141
|
+
const sameTarget =
|
|
142
|
+
currentInfo.target !== undefined && currentInfo.target === priorInfo.target;
|
|
143
|
+
// Opposite-direction chains never share a track.
|
|
144
|
+
const allowed =
|
|
145
|
+
currentInfo.reversed === priorInfo.reversed &&
|
|
146
|
+
(!currentInfo.label && !priorInfo.label
|
|
147
|
+
? root(JSON.stringify(["dummy", id])) === root(JSON.stringify(["dummy", previous]))
|
|
148
|
+
: (sameSource &&
|
|
149
|
+
(!currentInfo.label || currentInfo.before) &&
|
|
150
|
+
(!priorInfo.label || priorInfo.before)) ||
|
|
151
|
+
(sameTarget &&
|
|
152
|
+
(!currentInfo.label || !currentInfo.before) &&
|
|
153
|
+
(!priorInfo.label || !priorInfo.before)));
|
|
154
|
+
if (allowed) {
|
|
155
|
+
aliases.set(id, previous);
|
|
156
|
+
merged.add(previous);
|
|
157
|
+
metadata.set(previous, {
|
|
158
|
+
...priorInfo,
|
|
159
|
+
source: sameSource ? priorInfo.source : undefined,
|
|
160
|
+
target: sameTarget ? priorInfo.target : undefined,
|
|
161
|
+
});
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
result.push(id);
|
|
166
|
+
}
|
|
167
|
+
return result;
|
|
168
|
+
});
|
|
169
|
+
if (!aliases.size) return order;
|
|
170
|
+
input.graph = {
|
|
171
|
+
...input.graph,
|
|
172
|
+
nodes: input.graph.nodes.filter((node) => !aliases.has(node.id)),
|
|
173
|
+
edges: input.graph.edges.map((edge) => ({
|
|
174
|
+
...edge,
|
|
175
|
+
sourceId: aliases.get(edge.sourceId) ?? edge.sourceId,
|
|
176
|
+
targetId: aliases.get(edge.targetId) ?? edge.targetId,
|
|
177
|
+
})),
|
|
178
|
+
} as LayeredPhaseInput["graph"];
|
|
179
|
+
mergedNodes.set(input, merged);
|
|
180
|
+
const portOrders = (original: ReadonlyMap<string, readonly string[]> | undefined) => {
|
|
181
|
+
if (!original) return undefined;
|
|
182
|
+
const result = new Map(original);
|
|
183
|
+
for (const [removed, kept] of aliases) {
|
|
184
|
+
result.set(kept, [...(result.get(kept) ?? []), ...(result.get(removed) ?? [])]);
|
|
185
|
+
result.delete(removed);
|
|
186
|
+
}
|
|
187
|
+
return result;
|
|
188
|
+
};
|
|
189
|
+
return {
|
|
190
|
+
...order,
|
|
191
|
+
layers,
|
|
192
|
+
inputPortOrderByNodeId: portOrders(order.inputPortOrderByNodeId),
|
|
193
|
+
outputPortOrderByNodeId: portOrders(order.outputPortOrderByNodeId),
|
|
194
|
+
};
|
|
195
|
+
}
|