@statelyai/layout 0.3.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/NOTICE.md +4 -2
- package/README.md +150 -11
- package/dist/elkjs/index.cjs +2 -2
- package/dist/elkjs/index.d.cts +2 -0
- package/dist/elkjs/index.d.mts +2 -0
- 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-BBfKN4Dw.mjs +3075 -0
- package/dist/elkjs-DNfXDPup.cjs +3104 -0
- package/dist/{index-BEA_pzeT.d.cts → index-B6cWNrMw.d.cts} +9 -1
- package/dist/{index-DNTLRsDl.d.mts → index-CJQLUKD9.d.mts} +9 -1
- package/dist/{index-DNixtE3h.d.mts → index-DT6lPMfS.d.cts} +85 -5
- package/dist/{index-D9dhxrRl.d.cts → index-F1U-fCTu.d.mts} +85 -5
- package/dist/index.cjs +6 -3
- package/dist/index.d.cts +3 -3
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +4 -4
- package/dist/layered/index.cjs +3 -1
- package/dist/layered/index.d.cts +2 -2
- package/dist/layered/index.d.mts +2 -2
- package/dist/layered/index.mjs +2 -2
- package/dist/{layered-B2buJqMf.cjs → layered-5RxQhryb.cjs} +11184 -2932
- package/dist/{layered-DGFTeQSl.mjs → layered-DSesbctm.mjs} +12059 -3903
- package/dist/{layout-engine-Dn1PiFwP.cjs → layout-engine-CLFySz3R.cjs} +5 -18
- package/dist/{layout-engine-Cs5tatA6.mjs → layout-engine-GpBIX6ng.mjs} +3 -10
- package/dist/routing/index.cjs +2 -1
- package/dist/routing/index.d.cts +2 -2
- package/dist/routing/index.d.mts +2 -2
- package/dist/routing/index.mjs +2 -2
- package/dist/{routing-A2hrK9MP.mjs → routing-CR8flv8b.mjs} +977 -593
- package/dist/{routing-ujEP7Qh6.cjs → routing-QIdGGFBe.cjs} +986 -596
- package/package.json +8 -2
- package/src/authoring/coordinates.ts +28 -8
- 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 +1476 -449
- package/src/elkjs/layout-quality.ts +267 -0
- package/src/elkjs/opposing-tracks.ts +161 -0
- package/src/elkjs/orthogonal-junctions.ts +174 -0
- package/src/elkjs/statechart.ts +9 -3
- package/src/elkjs/types.ts +2 -0
- package/src/index.ts +2 -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 +35 -7
- package/src/layered/constraint-orientation.ts +141 -0
- package/src/layered/crossing-constraints.ts +243 -0
- package/src/layered/crossing-counter.ts +233 -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 +79 -0
- package/src/layered/hints.ts +371 -0
- package/src/layered/hyperedge-dummy-merger.ts +195 -0
- package/src/layered/index.ts +875 -161
- 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 +1044 -0
- package/src/layered/orthogonal-cycle-order.ts +106 -0
- package/src/layered/orthogonal-hypersegments.ts +154 -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 +2124 -886
- package/src/layered/types.ts +13 -0
- package/src/layered/weighted-compaction.ts +84 -0
- package/src/routing/adapters.ts +32 -0
- package/src/routing/algorithms.ts +433 -21
- package/src/routing/coordination.ts +8 -3
- package/src/routing/index.ts +1 -0
- package/src/routing/search.ts +93 -11
- package/dist/elkjs-DaHMISnQ.mjs +0 -1677
- package/dist/elkjs-dMghiiWW.cjs +0 -1706
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
/*******************************************************************************
|
|
2
|
+
* Copyright (c) 2017 Kiel University and others.
|
|
3
|
+
*
|
|
4
|
+
* Adapted from ELK v0.11.0 LGraphToCGraphTransformer.java,
|
|
5
|
+
* HorizontalGraphCompactor.java and NetworkSimplexCompaction.java.
|
|
6
|
+
* Source commit: 54123e884b1ae743b453260f713b20c9bf5787f2
|
|
7
|
+
* SPDX-License-Identifier: EPL-2.0
|
|
8
|
+
*******************************************************************************/
|
|
9
|
+
import type { EntityRect, Point } from "@statelyai/graph";
|
|
10
|
+
import type { AcyclicOrientation, EdgeRoutes, LayeredPhaseInput, NodePlacement } from "./types";
|
|
11
|
+
import { loopEnvelopes } from "./loop-envelopes";
|
|
12
|
+
import { compactionBounds, recordCompactionBounds } from "./compaction-bounds";
|
|
13
|
+
import { InfeasibleCompactionError } from "./compaction-errors";
|
|
14
|
+
import { scanlineConstraints } from "./compaction-scanline";
|
|
15
|
+
import { preparePortMargins, portCrossMargins } from "./node-margins";
|
|
16
|
+
import { nodeNodeSpacing } from "./spacing";
|
|
17
|
+
import { solveWeightedCompaction, type CompactionConstraint } from "./weighted-compaction";
|
|
18
|
+
|
|
19
|
+
interface Compactable extends EntityRect {
|
|
20
|
+
id: string;
|
|
21
|
+
group: string;
|
|
22
|
+
offset: number;
|
|
23
|
+
nodeId?: string;
|
|
24
|
+
edges: Set<string>;
|
|
25
|
+
points: Point[];
|
|
26
|
+
ignoreUp?: boolean;
|
|
27
|
+
ignoreDown?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** ELK removes LONG_EDGE dummies before the compactor, while LABEL nodes remain. */
|
|
31
|
+
export function applyGroupedEdgeLengthCompaction(
|
|
32
|
+
input: LayeredPhaseInput,
|
|
33
|
+
placement: NodePlacement,
|
|
34
|
+
routes?: EdgeRoutes,
|
|
35
|
+
orientation?: AcyclicOrientation,
|
|
36
|
+
): NodePlacement {
|
|
37
|
+
if (!routes) return compactJoinedGeometry(input, placement, routes, orientation);
|
|
38
|
+
const incoming = new Map<string, (typeof input.graph.edges)[number][]>();
|
|
39
|
+
const outgoing = new Map<string, (typeof input.graph.edges)[number][]>();
|
|
40
|
+
for (const edge of input.graph.edges) {
|
|
41
|
+
incoming.set(edge.targetId, [...(incoming.get(edge.targetId) ?? []), edge]);
|
|
42
|
+
outgoing.set(edge.sourceId, [...(outgoing.get(edge.sourceId) ?? []), edge]);
|
|
43
|
+
}
|
|
44
|
+
const removed = new Set(
|
|
45
|
+
input.graph.nodes
|
|
46
|
+
.filter(
|
|
47
|
+
(node) =>
|
|
48
|
+
node.id.startsWith("__layout_dummy:") &&
|
|
49
|
+
!node.id.startsWith("__layout_dummy:label:") &&
|
|
50
|
+
((incoming.get(node.id)?.length === 1 && outgoing.get(node.id)?.length === 1) ||
|
|
51
|
+
(!node.id.startsWith("__layout_dummy:north-south:") &&
|
|
52
|
+
(input.sizes.get(node.id)?.width ?? 0) === 0 &&
|
|
53
|
+
(input.sizes.get(node.id)?.height ?? 0) === 0 &&
|
|
54
|
+
(incoming.get(node.id)?.length ?? 0) > 0 &&
|
|
55
|
+
(outgoing.get(node.id)?.length ?? 0) > 0)),
|
|
56
|
+
)
|
|
57
|
+
.map((node) => node.id),
|
|
58
|
+
);
|
|
59
|
+
if (!removed.size) return compactJoinedGeometry(input, placement, routes, orientation);
|
|
60
|
+
const joinedEdges: (typeof input.graph.edges)[number][] = [];
|
|
61
|
+
const joinedPoints = new Map<string, readonly Point[]>();
|
|
62
|
+
for (const edge of input.graph.edges) {
|
|
63
|
+
if (removed.has(edge.sourceId)) continue;
|
|
64
|
+
const chain = [edge];
|
|
65
|
+
let last = edge;
|
|
66
|
+
const visited = new Set([edge.id]);
|
|
67
|
+
while (removed.has(last.targetId)) {
|
|
68
|
+
const candidates = outgoing.get(last.targetId)!;
|
|
69
|
+
// Merged hyperedge dummies have several outgoing chains. Continue the
|
|
70
|
+
// original edge's chain, rather than choosing another branch's edge.
|
|
71
|
+
const family = last.id.replace(/(?:::(?:segment|inverted):\d+)+$/, "");
|
|
72
|
+
const next =
|
|
73
|
+
candidates.length === 1
|
|
74
|
+
? candidates[0]!
|
|
75
|
+
: candidates.find(
|
|
76
|
+
(candidate) =>
|
|
77
|
+
candidate.id.replace(/(?:::(?:segment|inverted):\d+)+$/, "") === family,
|
|
78
|
+
);
|
|
79
|
+
if (!next)
|
|
80
|
+
throw new Error("Missing continuation through a merged long-edge dummy", {
|
|
81
|
+
cause: {
|
|
82
|
+
last: last.id,
|
|
83
|
+
node: last.targetId,
|
|
84
|
+
candidates: candidates.map((edge) => edge.id),
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
if (visited.has(next.id)) throw new Error("Cyclic long-edge dummy chain");
|
|
88
|
+
visited.add(next.id);
|
|
89
|
+
chain.push(next);
|
|
90
|
+
last = next;
|
|
91
|
+
}
|
|
92
|
+
joinedEdges.push({ ...edge, targetId: last.targetId, targetPort: last.targetPort });
|
|
93
|
+
joinedPoints.set(
|
|
94
|
+
edge.id,
|
|
95
|
+
chain.flatMap((part) => [...(routes.pointsByEdgeId.get(part.id) ?? [])]),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
const before = new Map(joinedPoints);
|
|
99
|
+
const joinedRoutes = { ...routes, pointsByEdgeId: joinedPoints };
|
|
100
|
+
const rects = new Map([...placement.rectByNodeId].filter(([id]) => !removed.has(id)));
|
|
101
|
+
const joinedPlacement = { ...placement, rectByNodeId: rects };
|
|
102
|
+
compactJoinedGeometry(
|
|
103
|
+
{
|
|
104
|
+
...input,
|
|
105
|
+
graph: {
|
|
106
|
+
...input.graph,
|
|
107
|
+
nodes: input.graph.nodes.filter((n) => !removed.has(n.id)),
|
|
108
|
+
edges: joinedEdges,
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
joinedPlacement,
|
|
112
|
+
joinedRoutes,
|
|
113
|
+
orientation,
|
|
114
|
+
);
|
|
115
|
+
routes.junctionPointsByEdgeId = joinedRoutes.junctionPointsByEdgeId;
|
|
116
|
+
const bounds = compactionBounds(joinedPlacement);
|
|
117
|
+
if (bounds) recordCompactionBounds(placement, bounds);
|
|
118
|
+
for (const [id, rect] of rects) (placement.rectByNodeId as Map<string, EntityRect>).set(id, rect);
|
|
119
|
+
const moved = new Map<Point, Point>();
|
|
120
|
+
for (const [id, points] of before) {
|
|
121
|
+
const after = joinedRoutes.pointsByEdgeId.get(id)!;
|
|
122
|
+
points.forEach((point, index) => moved.set(point, after[index]!));
|
|
123
|
+
}
|
|
124
|
+
for (const [id, points] of routes.pointsByEdgeId)
|
|
125
|
+
(routes.pointsByEdgeId as Map<string, readonly Point[]>).set(
|
|
126
|
+
id,
|
|
127
|
+
points.map((point) => moved.get(point) ?? point),
|
|
128
|
+
);
|
|
129
|
+
return placement;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Compact orthogonal geometry in canonical flow coordinates, retaining rigid port leads. */
|
|
133
|
+
function compactJoinedGeometry(
|
|
134
|
+
input: LayeredPhaseInput,
|
|
135
|
+
placement: NodePlacement,
|
|
136
|
+
routes?: EdgeRoutes,
|
|
137
|
+
orientation?: AcyclicOrientation,
|
|
138
|
+
): NodePlacement {
|
|
139
|
+
try {
|
|
140
|
+
return compactJoinedGeometryUnchecked(input, placement, routes, orientation);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
// Compaction is transactional: only apply positions after a complete solve.
|
|
143
|
+
// Degenerate port leads can make the visibility relation infeasible. Keep
|
|
144
|
+
// the initial layout/routes rather than publishing partial or NaN geometry.
|
|
145
|
+
if (error instanceof InfeasibleCompactionError) return placement;
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function compactJoinedGeometryUnchecked(
|
|
151
|
+
input: LayeredPhaseInput,
|
|
152
|
+
placement: NodePlacement,
|
|
153
|
+
routes?: EdgeRoutes,
|
|
154
|
+
orientation?: AcyclicOrientation,
|
|
155
|
+
): NodePlacement {
|
|
156
|
+
const vertical = input.direction === "down" || input.direction === "up";
|
|
157
|
+
const negative = input.direction === "left" || input.direction === "up";
|
|
158
|
+
const pointToCanonical = (point: Point): Point => ({
|
|
159
|
+
x: (negative ? -1 : 1) * (vertical ? point.y : point.x),
|
|
160
|
+
y: vertical ? point.x : point.y,
|
|
161
|
+
});
|
|
162
|
+
const rectToCanonical = (rect: EntityRect): EntityRect => {
|
|
163
|
+
const width = vertical ? rect.height : rect.width;
|
|
164
|
+
const flow = vertical ? rect.y : rect.x;
|
|
165
|
+
return {
|
|
166
|
+
x: negative ? -flow - width : flow,
|
|
167
|
+
y: vertical ? rect.x : rect.y,
|
|
168
|
+
width,
|
|
169
|
+
height: vertical ? rect.width : rect.height,
|
|
170
|
+
};
|
|
171
|
+
};
|
|
172
|
+
preparePortMargins(input, placement.rectByNodeId, orientation);
|
|
173
|
+
const contentRects = new Map<string, EntityRect>();
|
|
174
|
+
const items: Compactable[] = [];
|
|
175
|
+
const nodes = new Map<string, Compactable>();
|
|
176
|
+
const groupOrigin = new Map<string, number>();
|
|
177
|
+
// Compaction must retain label clearance reserved before placement;
|
|
178
|
+
// route bends alone measure only the loop line's smaller envelope.
|
|
179
|
+
const loopMargins = loopEnvelopes(input);
|
|
180
|
+
for (const [id, rect] of placement.rectByNodeId) {
|
|
181
|
+
const content = rectToCanonical(rect);
|
|
182
|
+
contentRects.set(id, content);
|
|
183
|
+
const portMargins = portCrossMargins(input, id);
|
|
184
|
+
const loopMargin = loopMargins.get(id);
|
|
185
|
+
const margins = {
|
|
186
|
+
before: Math.max(portMargins?.before ?? 0, loopMargin?.before ?? 0),
|
|
187
|
+
after: Math.max(portMargins?.after ?? 0, loopMargin?.after ?? 0),
|
|
188
|
+
flowBefore: Math.max(portMargins?.flowBefore ?? 0, loopMargin?.flowBefore ?? 0),
|
|
189
|
+
flowAfter: Math.max(portMargins?.flowAfter ?? 0, loopMargin?.flowAfter ?? 0),
|
|
190
|
+
};
|
|
191
|
+
// Self-loop routes belong to their owner's hitbox in ELK's compaction graph.
|
|
192
|
+
// Reserve their measured envelope before clipping rigid port leads.
|
|
193
|
+
for (const edge of input.graph.edges) {
|
|
194
|
+
if (edge.sourceId !== id || edge.targetId !== id) continue;
|
|
195
|
+
const points = routes?.pointsByEdgeId.get(edge.id)?.map(pointToCanonical) ?? [];
|
|
196
|
+
for (const point of points) {
|
|
197
|
+
margins.before = Math.max(margins.before, content.y - point.y);
|
|
198
|
+
margins.after = Math.max(margins.after, point.y - content.y - content.height);
|
|
199
|
+
margins.flowBefore = Math.max(margins.flowBefore, content.x - point.x);
|
|
200
|
+
margins.flowAfter = Math.max(margins.flowAfter, point.x - content.x - content.width);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const canonical = {
|
|
204
|
+
x: content.x - (margins?.flowBefore ?? 0),
|
|
205
|
+
y: content.y - (margins?.before ?? 0),
|
|
206
|
+
width: content.width + (margins?.flowBefore ?? 0) + (margins?.flowAfter ?? 0),
|
|
207
|
+
height: content.height + (margins?.before ?? 0) + (margins?.after ?? 0),
|
|
208
|
+
};
|
|
209
|
+
const item: Compactable = {
|
|
210
|
+
...canonical,
|
|
211
|
+
id: `node:${id}`,
|
|
212
|
+
group: `node:${id}`,
|
|
213
|
+
offset: -(margins?.flowBefore ?? 0),
|
|
214
|
+
nodeId: id,
|
|
215
|
+
edges: new Set(),
|
|
216
|
+
points: [],
|
|
217
|
+
};
|
|
218
|
+
items.push(item);
|
|
219
|
+
nodes.set(id, item);
|
|
220
|
+
groupOrigin.set(item.group, content.x);
|
|
221
|
+
}
|
|
222
|
+
if (!items.length) return placement;
|
|
223
|
+
const canonicalRoutes = new Map(
|
|
224
|
+
[...(routes?.pointsByEdgeId ?? [])].map(([id, points]) => [id, points.map(pointToCanonical)]),
|
|
225
|
+
);
|
|
226
|
+
const canonicalJunctions =
|
|
227
|
+
routes?.junctionPointsByEdgeId &&
|
|
228
|
+
new Map(
|
|
229
|
+
[...routes.junctionPointsByEdgeId].map(([id, points]) => [id, points.map(pointToCanonical)]),
|
|
230
|
+
);
|
|
231
|
+
const edgeById = new Map(input.graph.edges.map((edge) => [edge.id, edge]));
|
|
232
|
+
const endpointSide = (
|
|
233
|
+
nodeId: string,
|
|
234
|
+
portName: string | undefined,
|
|
235
|
+
point: Point | undefined,
|
|
236
|
+
): string | undefined => {
|
|
237
|
+
const node = input.graph.nodes.find((node) => node.id === nodeId);
|
|
238
|
+
const port = node?.ports?.find((port) => port.name === portName);
|
|
239
|
+
const side = node && port ? input.portSettings?.(port, node)?.["port.side"] : undefined;
|
|
240
|
+
if (side && side !== "UNDEFINED") {
|
|
241
|
+
if (!vertical)
|
|
242
|
+
return negative && (side === "WEST" || side === "EAST")
|
|
243
|
+
? side === "WEST"
|
|
244
|
+
? "EAST"
|
|
245
|
+
: "WEST"
|
|
246
|
+
: side;
|
|
247
|
+
if (side === "WEST") return "NORTH";
|
|
248
|
+
if (side === "EAST") return "SOUTH";
|
|
249
|
+
return side === (negative ? "NORTH" : "SOUTH") ? "EAST" : "WEST";
|
|
250
|
+
}
|
|
251
|
+
const rect = contentRects.get(nodeId);
|
|
252
|
+
if (!point || !rect) return undefined;
|
|
253
|
+
if (point.x <= rect.x + 1e-9) return "WEST";
|
|
254
|
+
if (point.x >= rect.x + rect.width - 1e-9) return "EAST";
|
|
255
|
+
return point.y <= rect.y ? "NORTH" : "SOUTH";
|
|
256
|
+
};
|
|
257
|
+
const crossSide = (side: string | undefined) => side === "NORTH" || side === "SOUTH";
|
|
258
|
+
const segments: Compactable[] = [];
|
|
259
|
+
for (const [edgeId, points] of canonicalRoutes) {
|
|
260
|
+
const edge = edgeById.get(edgeId);
|
|
261
|
+
if (!edge) continue;
|
|
262
|
+
const edgeSegments: Compactable[] = [];
|
|
263
|
+
for (let index = 0; index + 1 < points.length; index++) {
|
|
264
|
+
const first = points[index]!;
|
|
265
|
+
const second = points[index + 1]!;
|
|
266
|
+
if (Math.abs(first.x - second.x) > 1e-9 || Math.abs(first.y - second.y) < 1e-9) continue;
|
|
267
|
+
// The ELK transformer collects endpoint leads only on cross-axis faces.
|
|
268
|
+
// Flow-face endpoints are not bends, even when a split label route turns
|
|
269
|
+
// vertically immediately beside its temporary endpoint.
|
|
270
|
+
if (index === 0 && !crossSide(endpointSide(edge.sourceId, edge.sourcePort, first))) continue;
|
|
271
|
+
if (
|
|
272
|
+
index === points.length - 2 &&
|
|
273
|
+
!crossSide(endpointSide(edge.targetId, edge.targetPort, second))
|
|
274
|
+
)
|
|
275
|
+
continue;
|
|
276
|
+
const owner =
|
|
277
|
+
index === 0
|
|
278
|
+
? nodes.get(edge.sourceId)
|
|
279
|
+
: index === points.length - 2
|
|
280
|
+
? nodes.get(edge.targetId)
|
|
281
|
+
: undefined;
|
|
282
|
+
// ELK constructs port-lead spans from the first bend to the margin
|
|
283
|
+
// border, including bends lying inside the owner's reserved envelope.
|
|
284
|
+
const ownerSide = owner
|
|
285
|
+
? endpointSide(
|
|
286
|
+
index === 0 ? edge.sourceId : edge.targetId,
|
|
287
|
+
index === 0 ? edge.sourcePort : edge.targetPort,
|
|
288
|
+
index === 0 ? first : second,
|
|
289
|
+
)
|
|
290
|
+
: undefined;
|
|
291
|
+
const bend = index === 0 ? second : first;
|
|
292
|
+
const border = ownerSide === "NORTH" ? owner?.y : owner && owner.y + owner.height;
|
|
293
|
+
const segmentStart = owner ? Math.min(bend.y, border!) : Math.min(first.y, second.y);
|
|
294
|
+
const segmentEnd = owner ? Math.max(bend.y, border!) : Math.max(first.y, second.y);
|
|
295
|
+
const id = `track:${edgeId}:${index}`;
|
|
296
|
+
const group = owner?.group ?? id;
|
|
297
|
+
if (!owner) groupOrigin.set(group, first.x);
|
|
298
|
+
const segment: Compactable = {
|
|
299
|
+
id,
|
|
300
|
+
group,
|
|
301
|
+
offset: first.x - groupOrigin.get(group)!,
|
|
302
|
+
x: first.x,
|
|
303
|
+
y: segmentStart,
|
|
304
|
+
width: 0,
|
|
305
|
+
height: Math.max(0, segmentEnd - segmentStart),
|
|
306
|
+
edges: new Set([edgeId]),
|
|
307
|
+
points: [first, second],
|
|
308
|
+
ignoreUp: owner ? ownerSide === "SOUTH" : false,
|
|
309
|
+
ignoreDown: owner ? ownerSide === "NORTH" : false,
|
|
310
|
+
};
|
|
311
|
+
segments.push(segment);
|
|
312
|
+
if (!owner) edgeSegments.push(segment);
|
|
313
|
+
}
|
|
314
|
+
const markNearNode = (
|
|
315
|
+
segment: Compactable | undefined,
|
|
316
|
+
point: Point | undefined,
|
|
317
|
+
node: Compactable | undefined,
|
|
318
|
+
) => {
|
|
319
|
+
if (!segment || !point || !node) return;
|
|
320
|
+
if (point.y < node.y) segment.ignoreDown = true;
|
|
321
|
+
else if (point.y > node.y + node.height) segment.ignoreUp = true;
|
|
322
|
+
else {
|
|
323
|
+
segment.ignoreUp = true;
|
|
324
|
+
segment.ignoreDown = true;
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
// ELK marks clearance in the physical (cycle-broken) edge direction.
|
|
328
|
+
// Authored reverse edges must not suppress the shared track's spacing.
|
|
329
|
+
const reversed = orientation?.reversedEdgeIds.has(edgeId) ?? false;
|
|
330
|
+
const firstSegment = reversed ? edgeSegments.at(-1) : edgeSegments[0];
|
|
331
|
+
const lastSegment = reversed ? edgeSegments[0] : edgeSegments.at(-1);
|
|
332
|
+
markNearNode(
|
|
333
|
+
firstSegment,
|
|
334
|
+
firstSegment?.points[reversed ? 0 : 1],
|
|
335
|
+
nodes.get(reversed ? edge.targetId : edge.sourceId),
|
|
336
|
+
);
|
|
337
|
+
// Include horizontal bends following the last vertical segment.
|
|
338
|
+
markNearNode(
|
|
339
|
+
lastSegment,
|
|
340
|
+
reversed ? points[2] : points.at(-3),
|
|
341
|
+
nodes.get(reversed ? edge.sourceId : edge.targetId),
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
// ELK joins intersecting collinear segments before calculating constraints.
|
|
345
|
+
segments.sort((a, b) => a.x - b.x || a.y - b.y);
|
|
346
|
+
const merged: Compactable[] = [];
|
|
347
|
+
for (const segment of segments) {
|
|
348
|
+
const survivor = merged.at(-1);
|
|
349
|
+
if (
|
|
350
|
+
survivor &&
|
|
351
|
+
Math.abs(survivor.x - segment.x) < 1e-9 &&
|
|
352
|
+
segment.y <= survivor.y + survivor.height + 1e-9
|
|
353
|
+
) {
|
|
354
|
+
survivor.height = Math.max(survivor.height, segment.y + segment.height - survivor.y);
|
|
355
|
+
for (const edgeId of segment.edges) survivor.edges.add(edgeId);
|
|
356
|
+
survivor.points.push(...segment.points);
|
|
357
|
+
survivor.ignoreUp ||= segment.ignoreUp;
|
|
358
|
+
survivor.ignoreDown ||= segment.ignoreDown;
|
|
359
|
+
if (!survivor.group.startsWith("node:") && segment.group.startsWith("node:")) {
|
|
360
|
+
survivor.group = segment.group;
|
|
361
|
+
survivor.offset = survivor.x - groupOrigin.get(segment.group)!;
|
|
362
|
+
}
|
|
363
|
+
} else merged.push(segment);
|
|
364
|
+
}
|
|
365
|
+
for (const points of canonicalJunctions?.values() ?? [])
|
|
366
|
+
for (const point of points) {
|
|
367
|
+
const track = merged.find(
|
|
368
|
+
(item) =>
|
|
369
|
+
Math.abs(item.x - point.x) < 1e-9 &&
|
|
370
|
+
point.y >= item.y - 1e-9 &&
|
|
371
|
+
point.y <= item.y + item.height + 1e-9,
|
|
372
|
+
);
|
|
373
|
+
if (track) track.points.push(point);
|
|
374
|
+
}
|
|
375
|
+
items.push(...merged);
|
|
376
|
+
const groups = [...new Set(items.map((item) => item.group))];
|
|
377
|
+
const constraints: CompactionConstraint[] = [];
|
|
378
|
+
const sameEdge = (a: Compactable, b: Compactable) =>
|
|
379
|
+
!a.nodeId && !b.nodeId && [...a.edges].some((id) => b.edges.has(id));
|
|
380
|
+
const spacing = (a: Compactable, b: Compactable, flow: boolean): number => {
|
|
381
|
+
if (sameEdge(a, b)) return flow ? 0 : 1;
|
|
382
|
+
if (!flow && a.nodeId && b.nodeId) return nodeNodeSpacing(input, a.nodeId, b.nodeId);
|
|
383
|
+
const kind = (item: Compactable) =>
|
|
384
|
+
!item.nodeId
|
|
385
|
+
? "long"
|
|
386
|
+
: item.nodeId.startsWith("__layout_dummy:label:")
|
|
387
|
+
? "label"
|
|
388
|
+
: item.nodeId.startsWith("__layout_dummy:") ||
|
|
389
|
+
item.nodeId.startsWith("__layout_breaking:")
|
|
390
|
+
? "long"
|
|
391
|
+
: "normal";
|
|
392
|
+
const first = kind(a),
|
|
393
|
+
second = kind(b);
|
|
394
|
+
const key =
|
|
395
|
+
first === "label" && second === "label"
|
|
396
|
+
? "spacing.edgeEdge"
|
|
397
|
+
: first !== "long" && second !== "long"
|
|
398
|
+
? flow
|
|
399
|
+
? "spacing.layer"
|
|
400
|
+
: "spacing.node"
|
|
401
|
+
: first === "long" && second === "long"
|
|
402
|
+
? flow
|
|
403
|
+
? "spacing.edgeEdgeBetweenLayers"
|
|
404
|
+
: "spacing.edgeEdge"
|
|
405
|
+
: flow
|
|
406
|
+
? "spacing.edgeNodeBetweenLayers"
|
|
407
|
+
: "spacing.edgeNode";
|
|
408
|
+
let value =
|
|
409
|
+
key === "spacing.layer"
|
|
410
|
+
? input.spacing.layer
|
|
411
|
+
: key === "spacing.node"
|
|
412
|
+
? input.spacing.node
|
|
413
|
+
: Number(input.settings[key] ?? 10);
|
|
414
|
+
for (const item of [a, b]) {
|
|
415
|
+
const node = input.graph.nodes.find((node) => node.id === item.nodeId);
|
|
416
|
+
const individual = node ? input.nodeSettings?.(node)?.["spacing.individual"] : undefined;
|
|
417
|
+
const local =
|
|
418
|
+
individual && typeof individual === "object"
|
|
419
|
+
? (individual as Record<string, number>)[key]
|
|
420
|
+
: undefined;
|
|
421
|
+
if (local !== undefined && Number.isFinite(local)) value = Math.max(value, local);
|
|
422
|
+
}
|
|
423
|
+
return value;
|
|
424
|
+
};
|
|
425
|
+
const visible = new Map<Compactable, Set<Compactable>>();
|
|
426
|
+
const sweep = (hitboxes: Compactable[]) => {
|
|
427
|
+
for (const [left, right] of scanlineConstraints(hitboxes, true)) {
|
|
428
|
+
const originalLeft = items.find((item) => item.id === left.id)!;
|
|
429
|
+
const originalRight = items.find((item) => item.id === right.id)!;
|
|
430
|
+
let targets = visible.get(originalLeft);
|
|
431
|
+
if (!targets) visible.set(originalLeft, (targets = new Set()));
|
|
432
|
+
targets.add(originalRight);
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
const edgeMargin = Math.max(0, Number(input.settings["spacing.edgeEdge"] ?? 10) / 2 - 0.5);
|
|
436
|
+
const hitboxes = new Map(items.map((item) => [item.id, { ...item }]));
|
|
437
|
+
const alter = (box: Compactable, margin: number, factor: number): void => {
|
|
438
|
+
const delta = margin * factor;
|
|
439
|
+
if (box.nodeId) {
|
|
440
|
+
box.y -= delta;
|
|
441
|
+
box.height += 2 * delta;
|
|
442
|
+
} else if (!box.ignoreUp) {
|
|
443
|
+
box.y -= delta + 0.01;
|
|
444
|
+
box.height += delta + 0.01;
|
|
445
|
+
} else if (!box.ignoreDown) box.height += delta + 0.01;
|
|
446
|
+
};
|
|
447
|
+
// ELK's restore pass negates spacing but retains its 0.01 scanline tolerance.
|
|
448
|
+
// Keep hitboxes separate from authored geometry, including that residual extent.
|
|
449
|
+
const separateSweep = (selected: Compactable[], margin: (item: Compactable) => number) => {
|
|
450
|
+
for (const item of selected) alter(hitboxes.get(item.id)!, margin(item), 1);
|
|
451
|
+
sweep(selected.map((item) => hitboxes.get(item.id)!));
|
|
452
|
+
for (const item of selected) alter(hitboxes.get(item.id)!, margin(item), -1);
|
|
453
|
+
};
|
|
454
|
+
separateSweep(merged, () => edgeMargin);
|
|
455
|
+
separateSweep([...nodes.values()], (item) => {
|
|
456
|
+
const node = input.graph.nodes.find((node) => node.id === item.nodeId);
|
|
457
|
+
const individual = node ? input.nodeSettings?.(node)?.["spacing.individual"] : undefined;
|
|
458
|
+
const edgeSpacing =
|
|
459
|
+
individual && typeof individual === "object"
|
|
460
|
+
? Number(
|
|
461
|
+
(individual as Record<string, number>)["spacing.edgeEdge"] ??
|
|
462
|
+
input.settings["spacing.edgeEdge"] ??
|
|
463
|
+
10,
|
|
464
|
+
)
|
|
465
|
+
: Number(input.settings["spacing.edgeEdge"] ?? 10);
|
|
466
|
+
return Math.max(0, edgeSpacing / 2 - 0.5);
|
|
467
|
+
});
|
|
468
|
+
const minimumMargin = Math.min(
|
|
469
|
+
merged.length ? edgeMargin : Infinity,
|
|
470
|
+
...[...nodes.values()].map((item) =>
|
|
471
|
+
Math.max(0, nodeNodeSpacing(input, item.nodeId!, item.nodeId!) / 2 - 0.5),
|
|
472
|
+
),
|
|
473
|
+
);
|
|
474
|
+
const alterGroups = (factor: number) => {
|
|
475
|
+
for (const group of groups) {
|
|
476
|
+
const members = items.filter((item) => item.group === group);
|
|
477
|
+
const master = members.find((item) => item.nodeId) ?? members[0];
|
|
478
|
+
if (!master) continue;
|
|
479
|
+
alter(hitboxes.get(master.id)!, minimumMargin, factor);
|
|
480
|
+
for (const member of members) {
|
|
481
|
+
if (member === master) continue;
|
|
482
|
+
const box = hitboxes.get(member.id)!;
|
|
483
|
+
const delta = minimumMargin * factor + 0.01;
|
|
484
|
+
if (member.ignoreUp) {
|
|
485
|
+
box.y += delta;
|
|
486
|
+
box.height -= delta;
|
|
487
|
+
} else if (member.ignoreDown) box.height -= delta;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
alterGroups(1);
|
|
492
|
+
sweep([...hitboxes.values()]);
|
|
493
|
+
alterGroups(-1);
|
|
494
|
+
for (const [left, targets] of visible) {
|
|
495
|
+
for (const right of targets) {
|
|
496
|
+
if (
|
|
497
|
+
sameEdge(left, right) &&
|
|
498
|
+
input.settings["compaction.postCompaction.strategy"] === "EDGE_LENGTH"
|
|
499
|
+
) {
|
|
500
|
+
const helper = `helper:${constraints.length}`;
|
|
501
|
+
groups.push(helper);
|
|
502
|
+
const delta = Math.ceil(right.offset - left.offset);
|
|
503
|
+
constraints.push(
|
|
504
|
+
{ source: helper, target: left.group, delta: Math.max(0, delta), weight: 1 },
|
|
505
|
+
{ source: helper, target: right.group, delta: Math.max(0, -delta), weight: 1 },
|
|
506
|
+
);
|
|
507
|
+
} else
|
|
508
|
+
constraints.push({
|
|
509
|
+
source: left.group,
|
|
510
|
+
target: right.group,
|
|
511
|
+
delta: left.offset + left.width + spacing(left, right, true) - right.offset,
|
|
512
|
+
weight: Boolean(left.nodeId) !== Boolean(right.nodeId) ? 2 : 1,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
for (const edge of input.settings["compaction.postCompaction.strategy"] === "EDGE_LENGTH"
|
|
517
|
+
? input.graph.edges
|
|
518
|
+
: []) {
|
|
519
|
+
if (edge.sourceId === edge.targetId) continue;
|
|
520
|
+
const source = nodes.get(edge.sourceId),
|
|
521
|
+
target = nodes.get(edge.targetId);
|
|
522
|
+
if (!source || !target) continue;
|
|
523
|
+
const points = canonicalRoutes.get(edge.id);
|
|
524
|
+
const sourceSide = endpointSide(edge.sourceId, edge.sourcePort, points?.[0]);
|
|
525
|
+
const targetSide = endpointSide(edge.targetId, edge.targetPort, points?.at(-1));
|
|
526
|
+
if (crossSide(sourceSide) && crossSide(targetSide)) continue;
|
|
527
|
+
const reversed = orientation?.reversedEdgeIds.has(edge.id) ?? source.x > target.x;
|
|
528
|
+
const from = reversed ? target : source,
|
|
529
|
+
to = reversed ? source : target;
|
|
530
|
+
constraints.push({ source: from.group, target: to.group, delta: 0, weight: 100 });
|
|
531
|
+
const fromSide = reversed ? targetSide : sourceSide;
|
|
532
|
+
const toSide = reversed ? sourceSide : targetSide;
|
|
533
|
+
for (const segment of merged) {
|
|
534
|
+
if (!segment.edges.has(edge.id)) continue;
|
|
535
|
+
if (fromSide === "WEST" && segment.x < from.x && segment.group !== from.group)
|
|
536
|
+
constraints.push({
|
|
537
|
+
source: segment.group,
|
|
538
|
+
target: from.group,
|
|
539
|
+
delta: 1,
|
|
540
|
+
weight: 100,
|
|
541
|
+
});
|
|
542
|
+
if (toSide === "EAST" && segment.x > to.x + to.width && segment.group !== from.group)
|
|
543
|
+
constraints.push({
|
|
544
|
+
source: from.group,
|
|
545
|
+
target: segment.group,
|
|
546
|
+
delta: 1,
|
|
547
|
+
weight: 100,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
// Synthetic LABEL terminals retain their initial flow order. A short or
|
|
552
|
+
// vertical terminal approach may have no collected CGraph bend segment,
|
|
553
|
+
// so scanline visibility alone does not retain the owner/label relation.
|
|
554
|
+
for (const [id, points] of canonicalRoutes) {
|
|
555
|
+
const edge = edgeById.get(id)!;
|
|
556
|
+
const source = nodes.get(edge.sourceId),
|
|
557
|
+
target = nodes.get(edge.targetId);
|
|
558
|
+
if (!source || !target || points.length < 3 || source.x === target.x) continue;
|
|
559
|
+
const sourceLabel = edge.sourceId.startsWith("__layout_dummy:label:");
|
|
560
|
+
const targetLabel = edge.targetId.startsWith("__layout_dummy:label:");
|
|
561
|
+
if (!sourceLabel && !targetLabel) continue;
|
|
562
|
+
const [before, after] = source.x < target.x ? [source, target] : [target, source];
|
|
563
|
+
constraints.push({
|
|
564
|
+
source: before.group,
|
|
565
|
+
target: after.group,
|
|
566
|
+
delta: before.offset + before.width - after.offset,
|
|
567
|
+
weight: 1,
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
let solved: Map<string, number>;
|
|
571
|
+
try {
|
|
572
|
+
solved =
|
|
573
|
+
input.settings["compaction.postCompaction.strategy"] === "EDGE_LENGTH"
|
|
574
|
+
? solveWeightedCompaction(groups, constraints)
|
|
575
|
+
: directionalCompaction(input, groups, constraints, items, groupOrigin, orientation);
|
|
576
|
+
} catch (error) {
|
|
577
|
+
if (error instanceof Error)
|
|
578
|
+
error.cause = {
|
|
579
|
+
groups,
|
|
580
|
+
constraints,
|
|
581
|
+
items: items.map((item) => ({ ...item, edges: [...item.edges] })),
|
|
582
|
+
};
|
|
583
|
+
throw error;
|
|
584
|
+
}
|
|
585
|
+
if ([...solved.values()].some((position) => !Number.isFinite(position)))
|
|
586
|
+
throw new InfeasibleCompactionError("Non-finite compaction solution");
|
|
587
|
+
const flowPadding = negative
|
|
588
|
+
? vertical
|
|
589
|
+
? input.padding.bottom
|
|
590
|
+
: input.padding.right
|
|
591
|
+
: vertical
|
|
592
|
+
? input.padding.top
|
|
593
|
+
: input.padding.left;
|
|
594
|
+
const offset =
|
|
595
|
+
flowPadding - Math.min(...items.map((item) => solved.get(item.group)! + item.offset));
|
|
596
|
+
const nodeDelta = new Map<string, number>();
|
|
597
|
+
for (const item of items) {
|
|
598
|
+
const x = solved.get(item.group)! + item.offset + offset;
|
|
599
|
+
if (item.nodeId) nodeDelta.set(item.nodeId, x - item.x);
|
|
600
|
+
for (const point of new Set(item.points)) point.x = x;
|
|
601
|
+
item.x = x;
|
|
602
|
+
}
|
|
603
|
+
// Endpoints move with their node. Port leads already have the same rigid-group delta.
|
|
604
|
+
for (const [edgeId, points] of canonicalRoutes) {
|
|
605
|
+
const edge = edgeById.get(edgeId)!;
|
|
606
|
+
const originals = routes!.pointsByEdgeId.get(edgeId)!;
|
|
607
|
+
if (!points.length) continue;
|
|
608
|
+
if (edge.sourceId === edge.targetId && points.every((point) => point.y === points[0]!.y)) {
|
|
609
|
+
// Zero-length self-loop spans create no compaction item. Move their
|
|
610
|
+
// unowned corners with the node; collected spans already moved above.
|
|
611
|
+
const delta = nodeDelta.get(edge.sourceId) ?? 0;
|
|
612
|
+
for (const [index, point] of points.entries()) {
|
|
613
|
+
if (
|
|
614
|
+
index > 0 &&
|
|
615
|
+
index < points.length - 1 &&
|
|
616
|
+
items.some((item) => item.points.includes(point))
|
|
617
|
+
)
|
|
618
|
+
continue;
|
|
619
|
+
const original = pointToCanonical(originals[index]!);
|
|
620
|
+
points[index] = { ...original, x: original.x + delta };
|
|
621
|
+
}
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
points[0] = {
|
|
625
|
+
...pointToCanonical(originals[0]!),
|
|
626
|
+
x: pointToCanonical(originals[0]!).x + (nodeDelta.get(edge.sourceId) ?? 0),
|
|
627
|
+
};
|
|
628
|
+
const end = pointToCanonical(originals.at(-1)!);
|
|
629
|
+
points[points.length - 1] = { ...end, x: end.x + (nodeDelta.get(edge.targetId) ?? 0) };
|
|
630
|
+
// Flow-face terminals do not create CGraph port-lead hitboxes. A split
|
|
631
|
+
// label route may nevertheless turn vertically beside that terminal;
|
|
632
|
+
// keep its adjacent corner attached when moving the endpoint's owner.
|
|
633
|
+
if (points.length > 2) {
|
|
634
|
+
const start = pointToCanonical(originals[0]!);
|
|
635
|
+
const next = pointToCanonical(originals[1]!);
|
|
636
|
+
const previous = pointToCanonical(originals.at(-2)!);
|
|
637
|
+
if (
|
|
638
|
+
!crossSide(endpointSide(edge.sourceId, edge.sourcePort, start)) &&
|
|
639
|
+
Math.abs(start.x - next.x) < 1e-9
|
|
640
|
+
)
|
|
641
|
+
points[1]!.x = points[0]!.x;
|
|
642
|
+
if (
|
|
643
|
+
!crossSide(endpointSide(edge.targetId, edge.targetPort, end)) &&
|
|
644
|
+
Math.abs(end.x - previous.x) < 1e-9
|
|
645
|
+
)
|
|
646
|
+
points[points.length - 2]!.x = points.at(-1)!.x;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
// Reflect negative directions around the new extent, preserving positive physical bounds.
|
|
650
|
+
const extent = Math.max(
|
|
651
|
+
...items.map((item) => item.x + item.width),
|
|
652
|
+
...[...canonicalRoutes.values()].flatMap((points) => points.map((point) => point.x)),
|
|
653
|
+
);
|
|
654
|
+
const physicalPadding = vertical ? input.padding.top : input.padding.left;
|
|
655
|
+
const pointFromCanonical = (point: Point): Point => {
|
|
656
|
+
const flow = negative ? extent + physicalPadding - point.x : point.x;
|
|
657
|
+
return vertical ? { x: point.y, y: flow } : { x: flow, y: point.y };
|
|
658
|
+
};
|
|
659
|
+
for (const [id, item] of nodes) {
|
|
660
|
+
const content = contentRects.get(id)!;
|
|
661
|
+
const point = pointFromCanonical({
|
|
662
|
+
x: item.x - item.offset + (negative ? content.width : 0),
|
|
663
|
+
y: content.y,
|
|
664
|
+
});
|
|
665
|
+
(placement.rectByNodeId as Map<string, EntityRect>).set(id, {
|
|
666
|
+
...point,
|
|
667
|
+
width: vertical ? content.height : content.width,
|
|
668
|
+
height: vertical ? content.width : content.height,
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
for (const [id, points] of canonicalRoutes) {
|
|
672
|
+
(routes!.pointsByEdgeId as Map<string, readonly Point[]>).set(
|
|
673
|
+
id,
|
|
674
|
+
points.map(pointFromCanonical),
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
if (canonicalJunctions)
|
|
678
|
+
routes!.junctionPointsByEdgeId = new Map(
|
|
679
|
+
[...canonicalJunctions].map(([id, points]) => [id, points.map(pointFromCanonical)]),
|
|
680
|
+
);
|
|
681
|
+
const boundPoints = items.flatMap((item) => {
|
|
682
|
+
const box = hitboxes.get(item.id)!;
|
|
683
|
+
return [
|
|
684
|
+
pointFromCanonical({ x: item.x, y: box.y }),
|
|
685
|
+
pointFromCanonical({ x: item.x + item.width, y: box.y + box.height }),
|
|
686
|
+
];
|
|
687
|
+
});
|
|
688
|
+
if (boundPoints.length)
|
|
689
|
+
recordCompactionBounds(placement, {
|
|
690
|
+
left: Math.min(...boundPoints.map((p) => p.x)),
|
|
691
|
+
top: Math.min(...boundPoints.map((p) => p.y)),
|
|
692
|
+
right: Math.max(...boundPoints.map((p) => p.x)),
|
|
693
|
+
bottom: Math.max(...boundPoints.map((p) => p.y)),
|
|
694
|
+
});
|
|
695
|
+
return placement;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** ELK LongestPathCompaction, using the same rigid groups as edge-length compaction. */
|
|
699
|
+
function directionalCompaction(
|
|
700
|
+
input: LayeredPhaseInput,
|
|
701
|
+
groups: readonly string[],
|
|
702
|
+
constraints: readonly CompactionConstraint[],
|
|
703
|
+
items: readonly Compactable[],
|
|
704
|
+
origins: ReadonlyMap<string, number>,
|
|
705
|
+
orientation?: AcyclicOrientation,
|
|
706
|
+
): Map<string, number> {
|
|
707
|
+
const strategy = input.settings["compaction.postCompaction.strategy"];
|
|
708
|
+
const pass = (
|
|
709
|
+
reverse: boolean,
|
|
710
|
+
previous: ReadonlyMap<string, number>,
|
|
711
|
+
lock: string | undefined,
|
|
712
|
+
) => {
|
|
713
|
+
const sign = reverse ? -1 : 1;
|
|
714
|
+
const offsets = new Map(
|
|
715
|
+
items.map((item) => [item.id, reverse ? -item.offset - item.width : item.offset]),
|
|
716
|
+
);
|
|
717
|
+
const minimum = Math.min(
|
|
718
|
+
...items.map((item) => sign * previous.get(item.group)! + offsets.get(item.id)!),
|
|
719
|
+
);
|
|
720
|
+
const reference = new Map(
|
|
721
|
+
groups.map((group) => [
|
|
722
|
+
group,
|
|
723
|
+
Math.min(
|
|
724
|
+
...items.filter((item) => item.group === group).map((item) => offsets.get(item.id)!),
|
|
725
|
+
),
|
|
726
|
+
]),
|
|
727
|
+
);
|
|
728
|
+
const position = new Map(groups.map((group) => [group, minimum - reference.get(group)!]));
|
|
729
|
+
const links = new Map(
|
|
730
|
+
groups.map((group) => [group, [] as { target: string; delta: number }[]]),
|
|
731
|
+
);
|
|
732
|
+
const incoming = new Map(groups.map((group) => [group, 0]));
|
|
733
|
+
for (const edge of constraints) {
|
|
734
|
+
const source = reverse ? edge.target : edge.source;
|
|
735
|
+
const target = reverse ? edge.source : edge.target;
|
|
736
|
+
if (source === target) continue;
|
|
737
|
+
links.get(source)!.push({ target, delta: edge.delta });
|
|
738
|
+
incoming.set(target, incoming.get(target)! + 1);
|
|
739
|
+
}
|
|
740
|
+
const locked = new Set<string>();
|
|
741
|
+
if (lock === "LEFT_RIGHT_CONSTRAINT_LOCKING")
|
|
742
|
+
for (const group of groups) if (incoming.get(group) === 0) locked.add(group);
|
|
743
|
+
if (lock === "LEFT_RIGHT_CONNECTION_LOCKING") {
|
|
744
|
+
// ELK locks against the physical adjacency after cycle breaking.
|
|
745
|
+
// Authored directions can turn a sink into an apparent transit node.
|
|
746
|
+
for (const item of items)
|
|
747
|
+
if (item.nodeId) {
|
|
748
|
+
const before = input.graph.edges.filter(
|
|
749
|
+
(edge) =>
|
|
750
|
+
(orientation?.reversedEdgeIds.has(edge.id) ? edge.sourceId : edge.targetId) ===
|
|
751
|
+
item.nodeId,
|
|
752
|
+
).length;
|
|
753
|
+
const after = input.graph.edges.filter(
|
|
754
|
+
(edge) =>
|
|
755
|
+
(orientation?.reversedEdgeIds.has(edge.id) ? edge.targetId : edge.sourceId) ===
|
|
756
|
+
item.nodeId,
|
|
757
|
+
).length;
|
|
758
|
+
if (before > after) locked.add(item.group);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
const queue = groups.filter((group) => incoming.get(group) === 0);
|
|
762
|
+
let processed = 0;
|
|
763
|
+
while (queue.length) {
|
|
764
|
+
const group = queue.shift()!;
|
|
765
|
+
processed++;
|
|
766
|
+
if (locked.has(group))
|
|
767
|
+
position.set(group, Math.max(position.get(group)!, sign * previous.get(group)!));
|
|
768
|
+
for (const edge of links.get(group)!) {
|
|
769
|
+
position.set(
|
|
770
|
+
edge.target,
|
|
771
|
+
Math.max(position.get(edge.target)!, position.get(group)! + edge.delta),
|
|
772
|
+
);
|
|
773
|
+
incoming.set(edge.target, incoming.get(edge.target)! - 1);
|
|
774
|
+
if (incoming.get(edge.target) === 0) queue.push(edge.target);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
if (processed !== groups.length)
|
|
778
|
+
throw new InfeasibleCompactionError("Cyclic directional compaction groups");
|
|
779
|
+
return new Map([...position].map(([group, value]) => [group, sign * value]));
|
|
780
|
+
};
|
|
781
|
+
if (strategy === "RIGHT") return pass(true, origins, undefined);
|
|
782
|
+
const left = pass(false, origins, undefined);
|
|
783
|
+
return strategy === "LEFT_RIGHT_CONSTRAINT_LOCKING" ||
|
|
784
|
+
strategy === "LEFT_RIGHT_CONNECTION_LOCKING"
|
|
785
|
+
? pass(true, left, String(strategy))
|
|
786
|
+
: left;
|
|
787
|
+
}
|