@statelyai/layout 0.1.3 → 0.3.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 +43 -0
- package/README.md +153 -6
- package/dist/elkjs/index.cjs +9 -0
- package/dist/elkjs/index.d.cts +155 -0
- package/dist/elkjs/index.d.mts +51 -39
- package/dist/elkjs/index.mjs +3 -1484
- package/dist/elkjs/main.cjs +17 -0
- package/dist/elkjs/main.d.cts +10 -0
- package/dist/elkjs/main.d.mts +11 -0
- package/dist/elkjs/main.mjs +17 -0
- package/dist/elkjs/worker-api.cjs +3 -0
- package/dist/elkjs/worker-api.d.cts +3 -0
- package/dist/elkjs/worker-api.d.mts +3 -0
- package/dist/elkjs/worker-api.mjs +3 -0
- package/dist/elkjs/worker.cjs +86 -0
- package/dist/elkjs/worker.d.cts +17 -0
- package/dist/elkjs/worker.d.mts +17 -0
- package/dist/elkjs/worker.mjs +86 -0
- package/dist/elkjs-DaHMISnQ.mjs +1677 -0
- package/dist/elkjs-dMghiiWW.cjs +1706 -0
- package/dist/index-BEA_pzeT.d.cts +72 -0
- package/dist/{index-8xYkohbz.d.mts → index-D9dhxrRl.d.cts} +111 -5
- package/dist/index-DNTLRsDl.d.mts +72 -0
- package/dist/index-DNixtE3h.d.mts +1318 -0
- package/dist/index.cjs +215 -0
- package/dist/index.d.cts +78 -0
- package/dist/index.d.mts +4 -3
- package/dist/index.mjs +25 -25
- package/dist/layered/index.cjs +38 -0
- package/dist/layered/index.d.cts +3 -0
- package/dist/layered/index.d.mts +3 -2
- package/dist/layered/index.mjs +1 -1
- package/dist/layered-B2buJqMf.cjs +12009 -0
- package/dist/{layered-CoeH9LRO.mjs → layered-DGFTeQSl.mjs} +2323 -149
- package/dist/{spore-BtTSZbb6.mjs → layout-engine-Cs5tatA6.mjs} +159 -5
- package/dist/layout-engine-Dn1PiFwP.cjs +712 -0
- package/dist/public-types-9NCPoqbp.d.mts +109 -0
- package/dist/public-types-BcRZoJE3.d.cts +109 -0
- package/dist/routing/index.cjs +28 -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-A2hrK9MP.mjs +2396 -0
- package/dist/routing-ujEP7Qh6.cjs +2606 -0
- package/dist/types-CT2akdK0.d.mts +133 -0
- package/dist/types-uxPlGRof.d.cts +133 -0
- package/dist/worker-client-BNjwT1AJ.mjs +92 -0
- package/dist/worker-client-BwGGRD5y.d.mts +19 -0
- package/dist/worker-client-D4oEvhAl.cjs +98 -0
- package/dist/worker-client-D4zNyPEI.d.cts +19 -0
- package/package.json +45 -10
- package/src/authoring/coordinates.ts +114 -0
- package/src/authoring/partial.ts +584 -0
- package/src/authoring/routing.ts +211 -0
- package/src/box.ts +22 -0
- package/src/constraints.ts +222 -0
- package/src/elkjs/index.ts +104 -39
- package/src/elkjs/main.ts +19 -0
- package/src/elkjs/public-types.ts +125 -0
- package/src/elkjs/routes.ts +147 -0
- package/src/elkjs/worker-api.ts +29 -0
- package/src/elkjs/worker-client.ts +138 -0
- package/src/elkjs/worker.ts +117 -0
- package/src/index.ts +19 -0
- package/src/internal/elkjs-compatibility.ts +17 -0
- package/src/internal/layout-engine.ts +126 -0
- package/src/layered/compound.ts +417 -0
- package/src/layered/index.ts +58 -105
- package/src/layered/types.ts +21 -0
- package/src/layout.ts +35 -28
- package/src/packing.ts +22 -0
- package/src/random.ts +75 -3
- package/src/routing/adapters.ts +111 -0
- package/src/routing/algorithms.ts +676 -0
- package/src/routing/coordination.ts +86 -0
- package/src/routing/engine.ts +473 -0
- package/src/routing/index.ts +68 -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 +276 -0
- package/src/routing/spatial.ts +78 -0
- package/src/routing/types.ts +146 -0
- package/src/types.ts +17 -2
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { LayoutError } from "../errors";
|
|
2
|
+
import type { VisualGraph, Point } from "@statelyai/graph";
|
|
3
|
+
|
|
4
|
+
/** Legacy sibling edges are parent-relative; native compound output marks world-space edges. */
|
|
5
|
+
function frames<N, E, G, P>(graph: VisualGraph<N, E, G, P>) {
|
|
6
|
+
const nodes = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
7
|
+
const offsets = new Map<string, Point>();
|
|
8
|
+
const visiting = new Set<string>();
|
|
9
|
+
function offset(id: string): Point {
|
|
10
|
+
const cached = offsets.get(id);
|
|
11
|
+
if (cached) return cached;
|
|
12
|
+
if (visiting.has(id))
|
|
13
|
+
throw new LayoutError(`Cyclic parent hierarchy at ${id}`, "INVALID_GRAPH");
|
|
14
|
+
visiting.add(id);
|
|
15
|
+
const parentId = nodes.get(id)?.parentId;
|
|
16
|
+
if (parentId == null) {
|
|
17
|
+
const origin = { x: 0, y: 0 };
|
|
18
|
+
visiting.delete(id);
|
|
19
|
+
offsets.set(id, origin);
|
|
20
|
+
return origin;
|
|
21
|
+
}
|
|
22
|
+
const parent = nodes.get(parentId)!;
|
|
23
|
+
const ancestor = offset(parentId);
|
|
24
|
+
const result = { x: ancestor.x + parent.x, y: ancestor.y + parent.y };
|
|
25
|
+
visiting.delete(id);
|
|
26
|
+
offsets.set(id, result);
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
for (const node of graph.nodes) offsets.set(node.id, offset(node.id));
|
|
30
|
+
const edgeOffsets = new Map(
|
|
31
|
+
graph.edges.map((edge) => {
|
|
32
|
+
const source = nodes.get(edge.sourceId)!;
|
|
33
|
+
const target = nodes.get(edge.targetId)!;
|
|
34
|
+
return [
|
|
35
|
+
edge.id,
|
|
36
|
+
(graph as VisualGraph & { edgeCoordinateSpace?: string }).edgeCoordinateSpace !== "world" &&
|
|
37
|
+
(source.parentId ?? null) === (target.parentId ?? null)
|
|
38
|
+
? offset(source.id)
|
|
39
|
+
: { x: 0, y: 0 },
|
|
40
|
+
] as const;
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
return { nodes: offsets, edges: edgeOffsets };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function worldGeometry<N, E, G, P>(graph: VisualGraph<N, E, G, P>): VisualGraph<N, E, G, P> {
|
|
47
|
+
const offsets = frames(graph);
|
|
48
|
+
const base = { ...graph } as VisualGraph<N, E, G, P> & {
|
|
49
|
+
compoundRoutes?: unknown;
|
|
50
|
+
compoundGeometry?: unknown;
|
|
51
|
+
};
|
|
52
|
+
// Authoring may change nodes, labels, or paths. Never carry derived full-layout caches.
|
|
53
|
+
delete base.compoundRoutes;
|
|
54
|
+
delete base.compoundGeometry;
|
|
55
|
+
return {
|
|
56
|
+
...base,
|
|
57
|
+
nodes: graph.nodes.map((n) => {
|
|
58
|
+
const p = offsets.nodes.get(n.id)!;
|
|
59
|
+
return p.x === 0 && p.y === 0 ? n : { ...n, x: n.x + p.x, y: n.y + p.y };
|
|
60
|
+
}),
|
|
61
|
+
edges: graph.edges.map((e) => {
|
|
62
|
+
const p = offsets.edges.get(e.id)!;
|
|
63
|
+
return p.x === 0 && p.y === 0
|
|
64
|
+
? e
|
|
65
|
+
: {
|
|
66
|
+
...e,
|
|
67
|
+
x: e.x + p.x,
|
|
68
|
+
y: e.y + p.y,
|
|
69
|
+
...(e.points ? { points: e.points.map((v) => ({ x: v.x + p.x, y: v.y + p.y })) } : {}),
|
|
70
|
+
};
|
|
71
|
+
}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Ancestor frames stay fixed during authoring. Preserve untouched coordinates exactly. */
|
|
76
|
+
export function nativeGeometry<N, E, G, P>(
|
|
77
|
+
graph: VisualGraph<N, E, G, P>,
|
|
78
|
+
original: VisualGraph<N, E, G, P>,
|
|
79
|
+
): VisualGraph<N, E, G, P> {
|
|
80
|
+
const offsets = frames(original);
|
|
81
|
+
const world = worldGeometry(original);
|
|
82
|
+
const originalNodes = new Map(original.nodes.map((n) => [n.id, n]));
|
|
83
|
+
const originalEdges = new Map(original.edges.map((e) => [e.id, e]));
|
|
84
|
+
const worldNodes = new Map(world.nodes.map((n) => [n.id, n]));
|
|
85
|
+
const worldEdges = new Map(world.edges.map((e) => [e.id, e]));
|
|
86
|
+
return {
|
|
87
|
+
...graph,
|
|
88
|
+
nodes: graph.nodes.map((n) => {
|
|
89
|
+
const p = offsets.nodes.get(n.id)!,
|
|
90
|
+
before = originalNodes.get(n.id)!,
|
|
91
|
+
prior = worldNodes.get(n.id)!;
|
|
92
|
+
return {
|
|
93
|
+
...n,
|
|
94
|
+
x: n.x === prior.x ? before.x : n.x - p.x,
|
|
95
|
+
y: n.y === prior.y ? before.y : n.y - p.y,
|
|
96
|
+
};
|
|
97
|
+
}),
|
|
98
|
+
edges: graph.edges.map((e) => {
|
|
99
|
+
const p = offsets.edges.get(e.id)!,
|
|
100
|
+
before = originalEdges.get(e.id)!,
|
|
101
|
+
prior = worldEdges.get(e.id)!;
|
|
102
|
+
const points =
|
|
103
|
+
JSON.stringify(e.points) === JSON.stringify(prior.points)
|
|
104
|
+
? before.points
|
|
105
|
+
: e.points?.map((v) => ({ x: v.x - p.x, y: v.y - p.y }));
|
|
106
|
+
return {
|
|
107
|
+
...e,
|
|
108
|
+
x: e.x === prior.x ? before.x : e.x - p.x,
|
|
109
|
+
y: e.y === prior.y ? before.y : e.y - p.y,
|
|
110
|
+
...(points === undefined ? {} : { points }),
|
|
111
|
+
};
|
|
112
|
+
}),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
import { nativeGeometry, worldGeometry } from "./coordinates";
|
|
2
|
+
import type {
|
|
3
|
+
Graph,
|
|
4
|
+
VisualGraph,
|
|
5
|
+
VisualNode,
|
|
6
|
+
VisualEdge,
|
|
7
|
+
EntityRect,
|
|
8
|
+
Point,
|
|
9
|
+
} from "@statelyai/graph";
|
|
10
|
+
import { getNodeSize } from "@statelyai/graph/layout";
|
|
11
|
+
import type { LayeredLayoutOptions } from "../layered/types";
|
|
12
|
+
import type { LayoutExecutionContext } from "../types";
|
|
13
|
+
import { LayoutError, UnsupportedLayoutError } from "../errors";
|
|
14
|
+
import {
|
|
15
|
+
constraintFailure,
|
|
16
|
+
solveGeometryConstraints,
|
|
17
|
+
type GeometryReference,
|
|
18
|
+
type LayoutConstraint,
|
|
19
|
+
} from "../constraints";
|
|
20
|
+
import { getPolylineMidpoint } from "../layered/strategies";
|
|
21
|
+
import { overlaps, routeCrosses, routeOrthogonal } from "./routing";
|
|
22
|
+
|
|
23
|
+
const geometryKeys = ["x", "y", "width", "height"] as const;
|
|
24
|
+
function finite(value: unknown): value is number {
|
|
25
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
26
|
+
}
|
|
27
|
+
function samePosition(a: EntityRect, b: EntityRect) {
|
|
28
|
+
return a.x === b.x && a.y === b.y;
|
|
29
|
+
}
|
|
30
|
+
function onRoute(point: Point, points: readonly Point[] | undefined): boolean {
|
|
31
|
+
return (
|
|
32
|
+
points?.some((b, i) => {
|
|
33
|
+
if (!i) return false;
|
|
34
|
+
const a = points[i - 1]!;
|
|
35
|
+
const cross = (point.x - a.x) * (b.y - a.y) - (point.y - a.y) * (b.x - a.x);
|
|
36
|
+
return (
|
|
37
|
+
Math.abs(cross) < 1e-6 &&
|
|
38
|
+
point.x >= Math.min(a.x, b.x) - 1e-6 &&
|
|
39
|
+
point.x <= Math.max(a.x, b.x) + 1e-6 &&
|
|
40
|
+
point.y >= Math.min(a.y, b.y) - 1e-6 &&
|
|
41
|
+
point.y <= Math.max(a.y, b.y) + 1e-6
|
|
42
|
+
);
|
|
43
|
+
}) ?? false
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
export function labelReferences(constraints: readonly LayoutConstraint[]): Set<string> {
|
|
47
|
+
const ids = new Set<string>();
|
|
48
|
+
const add = (ref: GeometryReference) => {
|
|
49
|
+
if ("edgeId" in ref) ids.add(ref.edgeId);
|
|
50
|
+
};
|
|
51
|
+
for (const c of constraints) {
|
|
52
|
+
if (c.kind === "align" || c.kind === "distribute") c.entities.forEach(add);
|
|
53
|
+
else if (c.kind === "pin") add(c.entity);
|
|
54
|
+
else if (c.kind === "linear") c.terms.forEach((term) => add(term.entity));
|
|
55
|
+
}
|
|
56
|
+
return ids;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type Arrange = <N, E, G, P>(
|
|
60
|
+
graph: Graph<N, E, G, P>,
|
|
61
|
+
options: LayeredLayoutOptions,
|
|
62
|
+
) => VisualGraph<N, E, G, P>;
|
|
63
|
+
|
|
64
|
+
/** Authoring path: plan only selected nodes; route against the complete fixed scene. */
|
|
65
|
+
export function runPartialLayout<N, E, G, P>(
|
|
66
|
+
input: Graph<N, E, G, P>,
|
|
67
|
+
options: LayeredLayoutOptions,
|
|
68
|
+
context: LayoutExecutionContext,
|
|
69
|
+
arrange: Arrange,
|
|
70
|
+
phase: "selection" | "constraints" = "selection",
|
|
71
|
+
): VisualGraph<N, E, G, P> {
|
|
72
|
+
const scope = context.scope;
|
|
73
|
+
if (scope.mode !== "partial" && scope.mode !== "route-only")
|
|
74
|
+
throw new Error("Expected authoring scope");
|
|
75
|
+
const selectedNodes = new Set(scope.mode === "partial" ? (scope.nodeIds ?? []) : []);
|
|
76
|
+
const selectedEdges = new Set(
|
|
77
|
+
scope.mode === "route-only"
|
|
78
|
+
? (scope.edgeIds ?? input.edges.map((e) => e.id))
|
|
79
|
+
: (scope.edgeIds ?? []),
|
|
80
|
+
);
|
|
81
|
+
const geometry = scope.mode === "partial" ? (scope.edgeGeometry ?? "both") : "routes";
|
|
82
|
+
const routing = scope.mode === "partial" ? (scope.routing ?? "affected") : "selected";
|
|
83
|
+
const constraints = context.constraints ?? [];
|
|
84
|
+
const constrainedLabels = labelReferences(constraints);
|
|
85
|
+
for (const [ids, entities] of [
|
|
86
|
+
[selectedNodes, input.nodes],
|
|
87
|
+
[selectedEdges, input.edges],
|
|
88
|
+
] as const) {
|
|
89
|
+
const known = new Set(entities.map((e) => e.id));
|
|
90
|
+
for (const id of ids)
|
|
91
|
+
if (!known.has(id))
|
|
92
|
+
throw new LayoutError(`Unknown selected entity: ${id}`, "INVALID_SELECTION");
|
|
93
|
+
}
|
|
94
|
+
const containers = new Set(input.nodes.flatMap((n) => (n.parentId == null ? [] : [n.parentId])));
|
|
95
|
+
if ([...selectedNodes].some((id) => containers.has(id)))
|
|
96
|
+
throw new UnsupportedLayoutError(
|
|
97
|
+
"Authoring layout cannot move containers; select leaf nodes or use full layout without geometry constraints",
|
|
98
|
+
);
|
|
99
|
+
const previousNodes = new Map(scope.previous?.nodes.map((n) => [n.id, n]));
|
|
100
|
+
const previousEdges = new Map(scope.previous?.edges.map((e) => [e.id, e]));
|
|
101
|
+
const missing = (id: string): never => {
|
|
102
|
+
throw new LayoutError(
|
|
103
|
+
`Fixed entity ${id} requires finite geometry in graph or previous`,
|
|
104
|
+
"MISSING_GEOMETRY",
|
|
105
|
+
);
|
|
106
|
+
};
|
|
107
|
+
let nodes = input.nodes.map((node): VisualNode<N, P> => {
|
|
108
|
+
const previous = previousNodes.get(node.id);
|
|
109
|
+
const size = getNodeSize(node, options);
|
|
110
|
+
const rect = { ...node };
|
|
111
|
+
for (const key of geometryKeys) {
|
|
112
|
+
const value = node[key] ?? previous?.[key];
|
|
113
|
+
if (
|
|
114
|
+
value !== undefined &&
|
|
115
|
+
(!finite(value) || ((key === "width" || key === "height") && value < 0))
|
|
116
|
+
)
|
|
117
|
+
missing(node.id);
|
|
118
|
+
rect[key] =
|
|
119
|
+
value ??
|
|
120
|
+
(selectedNodes.has(node.id)
|
|
121
|
+
? key === "width" || key === "height"
|
|
122
|
+
? size[key]
|
|
123
|
+
: 0
|
|
124
|
+
: missing(node.id));
|
|
125
|
+
if (!finite(rect[key]) || ((key === "width" || key === "height") && rect[key]! < 0))
|
|
126
|
+
missing(node.id);
|
|
127
|
+
}
|
|
128
|
+
return rect as VisualNode<N, P>;
|
|
129
|
+
});
|
|
130
|
+
let edges = input.edges.map((edge): VisualEdge<E> => {
|
|
131
|
+
const previous = previousEdges.get(edge.id);
|
|
132
|
+
const absentLabel =
|
|
133
|
+
!edge.label &&
|
|
134
|
+
(edge.width ?? previous?.width ?? 0) === 0 &&
|
|
135
|
+
(edge.height ?? previous?.height ?? 0) === 0;
|
|
136
|
+
const rect = {
|
|
137
|
+
...edge,
|
|
138
|
+
...(edge.points === undefined && previous?.points !== undefined
|
|
139
|
+
? { points: previous.points }
|
|
140
|
+
: {}),
|
|
141
|
+
...(edge.routing === undefined && previous?.routing !== undefined
|
|
142
|
+
? { routing: previous.routing }
|
|
143
|
+
: {}),
|
|
144
|
+
};
|
|
145
|
+
for (const key of geometryKeys) {
|
|
146
|
+
const value = edge[key] ?? previous?.[key];
|
|
147
|
+
if (
|
|
148
|
+
value !== undefined &&
|
|
149
|
+
(!finite(value) || ((key === "width" || key === "height") && value < 0))
|
|
150
|
+
)
|
|
151
|
+
missing(edge.id);
|
|
152
|
+
// An absent label has a canonical zero-sized rectangle. Measured labels must be supplied.
|
|
153
|
+
rect[key] =
|
|
154
|
+
value ??
|
|
155
|
+
((selectedEdges.has(edge.id) && geometry !== "routes" && (key === "x" || key === "y")) ||
|
|
156
|
+
absentLabel
|
|
157
|
+
? 0
|
|
158
|
+
: missing(edge.id));
|
|
159
|
+
}
|
|
160
|
+
return rect as VisualEdge<E>;
|
|
161
|
+
});
|
|
162
|
+
const authored: VisualGraph<N, E, G, P> = {
|
|
163
|
+
...input,
|
|
164
|
+
direction: options.direction ?? input.direction ?? scope.previous?.direction ?? "down",
|
|
165
|
+
nodes,
|
|
166
|
+
edges,
|
|
167
|
+
};
|
|
168
|
+
const baseline = worldGeometry(authored);
|
|
169
|
+
nodes = baseline.nodes;
|
|
170
|
+
edges = baseline.edges;
|
|
171
|
+
let graph = baseline;
|
|
172
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
173
|
+
const ancestorSets = new Map<string, Set<string>>();
|
|
174
|
+
const ancestors = (id: string): Set<string> => {
|
|
175
|
+
const cached = ancestorSets.get(id);
|
|
176
|
+
if (cached) return cached;
|
|
177
|
+
const result = new Set<string>();
|
|
178
|
+
let parent = byId.get(id)?.parentId;
|
|
179
|
+
while (parent != null && !result.has(parent)) {
|
|
180
|
+
result.add(parent);
|
|
181
|
+
parent = byId.get(parent)?.parentId;
|
|
182
|
+
}
|
|
183
|
+
ancestorSets.set(id, result);
|
|
184
|
+
return result;
|
|
185
|
+
};
|
|
186
|
+
for (const node of nodes) ancestors(node.id);
|
|
187
|
+
const direction = options.direction ?? input.direction ?? scope.previous?.direction ?? "down";
|
|
188
|
+
const gap = options.spacing?.node ?? 20;
|
|
189
|
+
if (!finite(gap) || gap < 0)
|
|
190
|
+
throw new LayoutError("Spacing must be finite and non-negative", "INVALID_OPTIONS");
|
|
191
|
+
context.throwIfAborted();
|
|
192
|
+
if (selectedNodes.size && phase === "selection") {
|
|
193
|
+
// The existing layered phases plan the selection, never the surrounding graph.
|
|
194
|
+
// Keep parents separate so selection does not reparent or resize anything.
|
|
195
|
+
const groups = new Map<string | null, VisualNode<N, P>[]>();
|
|
196
|
+
for (const node of nodes)
|
|
197
|
+
if (selectedNodes.has(node.id)) {
|
|
198
|
+
const group = groups.get(node.parentId ?? null) ?? [];
|
|
199
|
+
group.push(node);
|
|
200
|
+
groups.set(node.parentId ?? null, group);
|
|
201
|
+
}
|
|
202
|
+
const placed = new Map(nodes.map((n) => [n.id, n]));
|
|
203
|
+
for (const group of groups.values()) {
|
|
204
|
+
const ids = new Set(group.map((n) => n.id));
|
|
205
|
+
const local = arrange(
|
|
206
|
+
{
|
|
207
|
+
...input,
|
|
208
|
+
initialNodeId: undefined,
|
|
209
|
+
nodes: group.map((n) => ({ ...n, parentId: undefined, initialNodeId: undefined })),
|
|
210
|
+
edges: edges.filter((e) => ids.has(e.sourceId) && ids.has(e.targetId)),
|
|
211
|
+
},
|
|
212
|
+
{ ...options, padding: 0 },
|
|
213
|
+
);
|
|
214
|
+
const anchorX = Math.min(...group.map((n) => n.x));
|
|
215
|
+
const anchorY = Math.min(...group.map((n) => n.y));
|
|
216
|
+
const localX = Math.min(...local.nodes.map((n) => n.x));
|
|
217
|
+
const localY = Math.min(...local.nodes.map((n) => n.y));
|
|
218
|
+
const proposed = group.map((n) => {
|
|
219
|
+
const p = local.nodes.find((item) => item.id === n.id)!;
|
|
220
|
+
return { ...n, x: p.x - localX + anchorX, y: p.y - localY + anchorY };
|
|
221
|
+
});
|
|
222
|
+
const obstacles = [...placed.values()].filter((n) => !ids.has(n.id));
|
|
223
|
+
const fixedLabels = edges.filter((e) => geometry === "routes" || !selectedEdges.has(e.id));
|
|
224
|
+
const candidates: Point[] = [{ x: 0, y: 0 }];
|
|
225
|
+
// Bound both allocation and collision work in densely blocked scenes.
|
|
226
|
+
candidateGeneration: for (const obstacle of [...obstacles, ...fixedLabels])
|
|
227
|
+
for (const node of proposed) {
|
|
228
|
+
context.throwIfAborted();
|
|
229
|
+
if (candidates.length + 4 > 2000) break candidateGeneration;
|
|
230
|
+
candidates.push(
|
|
231
|
+
{ x: obstacle.x + obstacle.width + gap - node.x, y: 0 },
|
|
232
|
+
{ x: obstacle.x - gap - node.x - node.width, y: 0 },
|
|
233
|
+
{ x: 0, y: obstacle.y + obstacle.height + gap - node.y },
|
|
234
|
+
{ x: 0, y: obstacle.y - gap - node.y - node.height },
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
candidates.sort((a, b) => Math.abs(a.x) + Math.abs(a.y) - Math.abs(b.x) - Math.abs(b.y));
|
|
238
|
+
let comparisons = 0;
|
|
239
|
+
const budget = () => {
|
|
240
|
+
if (++comparisons % 128 === 0) context.throwIfAborted();
|
|
241
|
+
return comparisons <= 100000;
|
|
242
|
+
};
|
|
243
|
+
const fits = (offset: Point) => {
|
|
244
|
+
context.throwIfAborted();
|
|
245
|
+
return proposed.every((n) => {
|
|
246
|
+
if (!budget()) return false;
|
|
247
|
+
const translated = { ...n, x: n.x + offset.x, y: n.y + offset.y };
|
|
248
|
+
const parent = n.parentId == null ? undefined : placed.get(n.parentId);
|
|
249
|
+
if (
|
|
250
|
+
parent &&
|
|
251
|
+
(translated.x < parent.x ||
|
|
252
|
+
translated.y < parent.y ||
|
|
253
|
+
translated.x + translated.width > parent.x + parent.width ||
|
|
254
|
+
translated.y + translated.height > parent.y + parent.height)
|
|
255
|
+
)
|
|
256
|
+
return false;
|
|
257
|
+
return (
|
|
258
|
+
fixedLabels.every((label) => budget() && !overlaps(translated, label, gap)) &&
|
|
259
|
+
obstacles.every(
|
|
260
|
+
(o) =>
|
|
261
|
+
budget() &&
|
|
262
|
+
(ancestors(n.id).has(o.id) ||
|
|
263
|
+
ancestors(o.id).has(n.id) ||
|
|
264
|
+
!overlaps(translated, o, gap)),
|
|
265
|
+
)
|
|
266
|
+
);
|
|
267
|
+
});
|
|
268
|
+
};
|
|
269
|
+
const offset = candidates.find((candidate) => comparisons <= 100000 && fits(candidate));
|
|
270
|
+
if (offset)
|
|
271
|
+
for (const n of proposed) placed.set(n.id, { ...n, x: n.x + offset.x, y: n.y + offset.y });
|
|
272
|
+
else
|
|
273
|
+
context.diagnostics.push({
|
|
274
|
+
severity: "warning",
|
|
275
|
+
code: "PLACEMENT_BLOCKED",
|
|
276
|
+
message:
|
|
277
|
+
"No placement found within the search budget without moving fixed geometry; preserved selected positions",
|
|
278
|
+
entityIds: [...ids],
|
|
279
|
+
geometry: "node",
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
graph = { ...graph, nodes: nodes.map((n) => placed.get(n.id)!) };
|
|
283
|
+
}
|
|
284
|
+
graph = solveGeometryConstraints(
|
|
285
|
+
graph,
|
|
286
|
+
constraints,
|
|
287
|
+
selectedNodes,
|
|
288
|
+
geometry === "routes" ? new Set() : selectedEdges,
|
|
289
|
+
context.diagnostics,
|
|
290
|
+
);
|
|
291
|
+
const moved = graph.nodes.filter((n) => !samePosition(n, byId.get(n.id)!));
|
|
292
|
+
const movedIds = new Set(moved.map((n) => n.id));
|
|
293
|
+
const resultNodes = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
294
|
+
const automatic = new Set<string>();
|
|
295
|
+
const affected = (edge: VisualEdge<E>) =>
|
|
296
|
+
movedIds.has(edge.sourceId) ||
|
|
297
|
+
movedIds.has(edge.targetId) ||
|
|
298
|
+
moved.some(
|
|
299
|
+
(n) =>
|
|
300
|
+
!ancestors(edge.sourceId).has(n.id) &&
|
|
301
|
+
!ancestors(edge.targetId).has(n.id) &&
|
|
302
|
+
(routeCrosses(edge.points, n) ||
|
|
303
|
+
overlaps(edge, n) ||
|
|
304
|
+
(edge.routing === "splines" &&
|
|
305
|
+
edge.points?.length &&
|
|
306
|
+
overlaps(n, {
|
|
307
|
+
x: Math.min(...edge.points.map((p) => p.x)),
|
|
308
|
+
y: Math.min(...edge.points.map((p) => p.y)),
|
|
309
|
+
width:
|
|
310
|
+
Math.max(...edge.points.map((p) => p.x)) - Math.min(...edge.points.map((p) => p.x)),
|
|
311
|
+
height:
|
|
312
|
+
Math.max(...edge.points.map((p) => p.y)) - Math.min(...edge.points.map((p) => p.y)),
|
|
313
|
+
}))),
|
|
314
|
+
);
|
|
315
|
+
const edgeById = new Map(graph.edges.map((e) => [e.id, e]));
|
|
316
|
+
for (const constraint of constraints)
|
|
317
|
+
if (constraint.kind === "waypoint") {
|
|
318
|
+
if (
|
|
319
|
+
!finite(constraint.point.x) ||
|
|
320
|
+
!finite(constraint.point.y) ||
|
|
321
|
+
!edgeById.has(constraint.edgeId)
|
|
322
|
+
)
|
|
323
|
+
throw new LayoutError(`${constraint.id}: invalid waypoint`, "INVALID_CONSTRAINT");
|
|
324
|
+
}
|
|
325
|
+
graph = {
|
|
326
|
+
...graph,
|
|
327
|
+
edges: graph.edges.map((edge) => {
|
|
328
|
+
context.throwIfAborted();
|
|
329
|
+
const needsRepair = affected(edge);
|
|
330
|
+
const allowed = selectedEdges.has(edge.id) || (routing === "affected" && needsRepair);
|
|
331
|
+
const waypoints = constraints.filter((c) => c.kind === "waypoint" && c.edgeId === edge.id);
|
|
332
|
+
// Full constraints grant label permissions independently of route repair.
|
|
333
|
+
const routeAllowed =
|
|
334
|
+
phase === "constraints"
|
|
335
|
+
? needsRepair ||
|
|
336
|
+
waypoints.some(
|
|
337
|
+
(c) =>
|
|
338
|
+
c.kind === "waypoint" &&
|
|
339
|
+
(edge.routing === "splines" || !onRoute(c.point, edge.points)),
|
|
340
|
+
)
|
|
341
|
+
: allowed && geometry !== "labels";
|
|
342
|
+
const labelAllowed = allowed && geometry !== "routes" && !constrainedLabels.has(edge.id);
|
|
343
|
+
const source = resultNodes.get(edge.sourceId)!;
|
|
344
|
+
const target = resultNodes.get(edge.targetId)!;
|
|
345
|
+
let next = edge;
|
|
346
|
+
let routeFailed = false;
|
|
347
|
+
if (needsRepair && !routeAllowed)
|
|
348
|
+
context.diagnostics.push({
|
|
349
|
+
severity: "warning",
|
|
350
|
+
code: "ROUTE_REPAIR_REQUIRED",
|
|
351
|
+
message: `Edge ${edge.id} needs routing outside the permitted geometry`,
|
|
352
|
+
entityIds: [edge.id],
|
|
353
|
+
geometry: "routes",
|
|
354
|
+
});
|
|
355
|
+
if (routeAllowed) {
|
|
356
|
+
if (options.settings?.edgeRouting === "SPLINES" || options.strategies?.routeEdges)
|
|
357
|
+
throw new UnsupportedLayoutError(
|
|
358
|
+
"Partial layout supports its built-in orthogonal router only",
|
|
359
|
+
);
|
|
360
|
+
const ancestorIds = new Set([...ancestors(source.id), ...ancestors(target.id)]);
|
|
361
|
+
const nodeObstacles = graph.nodes
|
|
362
|
+
.filter((n) => !ancestorIds.has(n.id))
|
|
363
|
+
.map((n) =>
|
|
364
|
+
n.id === source.id || n.id === target.id
|
|
365
|
+
? n
|
|
366
|
+
: { ...n, x: n.x - 1, y: n.y - 1, width: n.width + 2, height: n.height + 2 },
|
|
367
|
+
);
|
|
368
|
+
const labelObstacles = [...edgeById.values()].filter(
|
|
369
|
+
(e) => e.id !== edge.id && e.width > 0 && e.height > 0,
|
|
370
|
+
);
|
|
371
|
+
const required = waypoints.filter((c) => (c.strength ?? "required") === "required");
|
|
372
|
+
const accepted = new Set(required.map((c) => c.id));
|
|
373
|
+
const route = () =>
|
|
374
|
+
routeOrthogonal(
|
|
375
|
+
edge,
|
|
376
|
+
source,
|
|
377
|
+
target,
|
|
378
|
+
direction,
|
|
379
|
+
[...nodeObstacles, ...labelObstacles],
|
|
380
|
+
waypoints
|
|
381
|
+
.filter((c) => c.kind === "waypoint" && accepted.has(c.id))
|
|
382
|
+
.map((c) => (c.kind === "waypoint" ? c.point : { x: 0, y: 0 })),
|
|
383
|
+
() => context.throwIfAborted(),
|
|
384
|
+
);
|
|
385
|
+
let points = route();
|
|
386
|
+
const priorities = { strong: 3, medium: 2, weak: 1, required: 4 };
|
|
387
|
+
for (const preference of waypoints
|
|
388
|
+
.filter((c) => !accepted.has(c.id))
|
|
389
|
+
.sort(
|
|
390
|
+
(a, b) => priorities[b.strength ?? "required"] - priorities[a.strength ?? "required"],
|
|
391
|
+
)) {
|
|
392
|
+
accepted.add(preference.id);
|
|
393
|
+
const candidate = route();
|
|
394
|
+
if (candidate) points = candidate;
|
|
395
|
+
else accepted.delete(preference.id);
|
|
396
|
+
}
|
|
397
|
+
if (points) next = { ...next, points, routing: "orthogonal" };
|
|
398
|
+
else {
|
|
399
|
+
routeFailed = true;
|
|
400
|
+
context.diagnostics.push({
|
|
401
|
+
severity: "warning",
|
|
402
|
+
code: "ROUTE_BLOCKED",
|
|
403
|
+
message: `No route found for ${edge.id} within the search budget; preserved the route`,
|
|
404
|
+
entityIds: [edge.id],
|
|
405
|
+
geometry: "routes",
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
for (const constraint of waypoints)
|
|
410
|
+
if (
|
|
411
|
+
constraint.kind === "waypoint" &&
|
|
412
|
+
(routeFailed || next.routing === "splines" || !onRoute(constraint.point, next.points))
|
|
413
|
+
) {
|
|
414
|
+
if ((constraint.strength ?? "required") === "required")
|
|
415
|
+
constraintFailure(
|
|
416
|
+
constraint,
|
|
417
|
+
"route cannot pass through the waypoint within the permitted geometry",
|
|
418
|
+
);
|
|
419
|
+
context.diagnostics.push({
|
|
420
|
+
severity: "warning",
|
|
421
|
+
code: "CONSTRAINT_VIOLATION",
|
|
422
|
+
message: `Waypoint ${constraint.id} could not be satisfied`,
|
|
423
|
+
entityIds: [edge.id],
|
|
424
|
+
constraintIds: [constraint.id],
|
|
425
|
+
geometry: "routes",
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
if (labelAllowed && (next.points?.length ?? 0) >= 2) {
|
|
429
|
+
const midpoint = getPolylineMidpoint(next.points ?? []);
|
|
430
|
+
const candidates = [
|
|
431
|
+
{ x: midpoint.x - next.width / 2, y: midpoint.y - next.height / 2 },
|
|
432
|
+
{ x: midpoint.x - next.width / 2, y: midpoint.y - next.height - gap },
|
|
433
|
+
{ x: midpoint.x - next.width / 2, y: midpoint.y + gap },
|
|
434
|
+
{ x: midpoint.x + gap, y: midpoint.y - next.height / 2 },
|
|
435
|
+
{ x: midpoint.x - next.width - gap, y: midpoint.y - next.height / 2 },
|
|
436
|
+
];
|
|
437
|
+
const parentIds = new Set([...ancestors(source.id), ...ancestors(target.id)]);
|
|
438
|
+
for (const obstacle of [...graph.nodes, ...edgeById.values()]) {
|
|
439
|
+
candidates.push(
|
|
440
|
+
{ x: midpoint.x - next.width / 2, y: obstacle.y - next.height - gap },
|
|
441
|
+
{ x: midpoint.x - next.width / 2, y: obstacle.y + obstacle.height + gap },
|
|
442
|
+
{ x: obstacle.x - next.width - gap, y: midpoint.y - next.height / 2 },
|
|
443
|
+
{ x: obstacle.x + obstacle.width + gap, y: midpoint.y - next.height / 2 },
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
candidates.sort(
|
|
447
|
+
(a, b) =>
|
|
448
|
+
Math.hypot(a.x + next.width / 2 - midpoint.x, a.y + next.height / 2 - midpoint.y) -
|
|
449
|
+
Math.hypot(b.x + next.width / 2 - midpoint.x, b.y + next.height / 2 - midpoint.y),
|
|
450
|
+
);
|
|
451
|
+
const chosen = candidates.find((p) => {
|
|
452
|
+
const rect = { ...next, ...p };
|
|
453
|
+
return (
|
|
454
|
+
!graph.nodes.some((n) => !parentIds.has(n.id) && overlaps(rect, n)) &&
|
|
455
|
+
![...edgeById.values()].some((e) => e.id !== edge.id && overlaps(rect, e))
|
|
456
|
+
);
|
|
457
|
+
});
|
|
458
|
+
if (chosen) next = { ...next, ...chosen };
|
|
459
|
+
else
|
|
460
|
+
context.diagnostics.push({
|
|
461
|
+
severity: "warning",
|
|
462
|
+
code: "LABEL_PLACEMENT_BLOCKED",
|
|
463
|
+
message: `No clear label position found for ${edge.id}; preserved label position`,
|
|
464
|
+
entityIds: [edge.id],
|
|
465
|
+
geometry: "labels",
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
if (
|
|
469
|
+
geometry === "labels" &&
|
|
470
|
+
allowed &&
|
|
471
|
+
!samePosition(
|
|
472
|
+
next,
|
|
473
|
+
baseline.edges.find((e) => e.id === edge.id)!,
|
|
474
|
+
)
|
|
475
|
+
) {
|
|
476
|
+
context.diagnostics.push({
|
|
477
|
+
severity: "info",
|
|
478
|
+
code: "ROUTE_PRESERVED",
|
|
479
|
+
message: `Route for ${edge.id} was preserved; label attachment may need repair`,
|
|
480
|
+
entityIds: [edge.id],
|
|
481
|
+
geometry: "routes",
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
edgeById.set(edge.id, next);
|
|
485
|
+
if (
|
|
486
|
+
!selectedEdges.has(edge.id) &&
|
|
487
|
+
(next.x !== edge.x ||
|
|
488
|
+
next.y !== edge.y ||
|
|
489
|
+
next.routing !== edge.routing ||
|
|
490
|
+
JSON.stringify(next.points) !== JSON.stringify(edge.points))
|
|
491
|
+
)
|
|
492
|
+
automatic.add(edge.id);
|
|
493
|
+
return next;
|
|
494
|
+
}),
|
|
495
|
+
};
|
|
496
|
+
if (automatic.size)
|
|
497
|
+
context.diagnostics.push({
|
|
498
|
+
severity: "info",
|
|
499
|
+
code: "AFFECTED_EDGES_UPDATED",
|
|
500
|
+
message: "Repaired geometry affected by moved nodes",
|
|
501
|
+
entityIds: [...automatic],
|
|
502
|
+
});
|
|
503
|
+
// Constraint solving must not silently imply containment or collision guarantees.
|
|
504
|
+
for (const node of graph.nodes)
|
|
505
|
+
if (selectedNodes.has(node.id) || (node.parentId != null && movedIds.has(node.parentId))) {
|
|
506
|
+
const parent = node.parentId == null ? undefined : resultNodes.get(node.parentId);
|
|
507
|
+
if (
|
|
508
|
+
parent &&
|
|
509
|
+
(node.x < parent.x ||
|
|
510
|
+
node.y < parent.y ||
|
|
511
|
+
node.x + node.width > parent.x + parent.width ||
|
|
512
|
+
node.y + node.height > parent.y + parent.height)
|
|
513
|
+
)
|
|
514
|
+
context.diagnostics.push({
|
|
515
|
+
severity: "warning",
|
|
516
|
+
code: "CONTAINMENT_VIOLATION",
|
|
517
|
+
message: `Node ${node.id} extends outside its fixed parent`,
|
|
518
|
+
entityIds: [node.id, parent.id],
|
|
519
|
+
geometry: "node",
|
|
520
|
+
});
|
|
521
|
+
if (
|
|
522
|
+
graph.nodes.some(
|
|
523
|
+
(other) =>
|
|
524
|
+
other.id !== node.id &&
|
|
525
|
+
!ancestors(node.id).has(other.id) &&
|
|
526
|
+
!ancestors(other.id).has(node.id) &&
|
|
527
|
+
overlaps(node, other),
|
|
528
|
+
)
|
|
529
|
+
)
|
|
530
|
+
context.diagnostics.push({
|
|
531
|
+
severity: "warning",
|
|
532
|
+
code: "NODE_OVERLAP",
|
|
533
|
+
message: `Node ${node.id} overlaps another node`,
|
|
534
|
+
entityIds: [node.id],
|
|
535
|
+
geometry: "node",
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
for (const edge of graph.edges) {
|
|
539
|
+
const before = baseline.edges.find((e) => e.id === edge.id)!;
|
|
540
|
+
if (!samePosition(edge, before)) {
|
|
541
|
+
const containerIds = new Set([...ancestors(edge.sourceId), ...ancestors(edge.targetId)]);
|
|
542
|
+
const collisions = [
|
|
543
|
+
...graph.nodes.filter((n) => !containerIds.has(n.id) && overlaps(edge, n)),
|
|
544
|
+
...graph.edges.filter((other) => other.id !== edge.id && overlaps(edge, other)),
|
|
545
|
+
];
|
|
546
|
+
if (collisions.length)
|
|
547
|
+
context.diagnostics.push({
|
|
548
|
+
severity: "warning",
|
|
549
|
+
code: "LABEL_OVERLAP",
|
|
550
|
+
message: `Label ${edge.id} overlaps fixed or constrained geometry`,
|
|
551
|
+
entityIds: [edge.id, ...collisions.map((e) => e.id)],
|
|
552
|
+
geometry: "labels",
|
|
553
|
+
});
|
|
554
|
+
for (const other of graph.edges)
|
|
555
|
+
if (
|
|
556
|
+
other.id !== edge.id &&
|
|
557
|
+
routeCrosses(other.points, edge) &&
|
|
558
|
+
!routeCrosses(other.points, before)
|
|
559
|
+
) {
|
|
560
|
+
context.diagnostics.push({
|
|
561
|
+
severity: "warning",
|
|
562
|
+
code: "ROUTE_REPAIR_REQUIRED",
|
|
563
|
+
message: `Route ${other.id} crosses the moved label ${edge.id}`,
|
|
564
|
+
entityIds: [other.id, edge.id],
|
|
565
|
+
geometry: "routes",
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
if (
|
|
570
|
+
geometry === "routes" &&
|
|
571
|
+
routeCrosses(before.points, before) &&
|
|
572
|
+
!routeCrosses(edge.points, edge)
|
|
573
|
+
) {
|
|
574
|
+
context.diagnostics.push({
|
|
575
|
+
severity: "warning",
|
|
576
|
+
code: "LABEL_REPAIR_REQUIRED",
|
|
577
|
+
message: `Label ${edge.id} is no longer attached to its route`,
|
|
578
|
+
entityIds: [edge.id],
|
|
579
|
+
geometry: "labels",
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return nativeGeometry(graph, authored);
|
|
584
|
+
}
|