@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,91 @@
|
|
|
1
|
+
import { flattenPath } from "./path";
|
|
2
|
+
import { segmentBounds } from "./search";
|
|
3
|
+
import { inflate } from "./spatial";
|
|
4
|
+
import type { Settings } from "./model";
|
|
5
|
+
import type { Route, RouteBounds, RoutePoint, RoutePath } from "./types";
|
|
6
|
+
|
|
7
|
+
export interface Reservation {
|
|
8
|
+
readonly a: RoutePoint;
|
|
9
|
+
readonly b: RoutePoint;
|
|
10
|
+
readonly sharedId?: string;
|
|
11
|
+
}
|
|
12
|
+
export function reservations(route: Route): Reservation[] {
|
|
13
|
+
return route.sections.flatMap((section) =>
|
|
14
|
+
pathReservations(section.path).map((segment) => ({ ...segment, sharedId: section.sharedId })),
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
export function pathReservations(path: RoutePath): Reservation[] {
|
|
18
|
+
const points = flattenPath(path, { tolerance: 0.5 });
|
|
19
|
+
return points.slice(1).map((b, i) => ({ a: points[i]!, b }));
|
|
20
|
+
}
|
|
21
|
+
export function reservationBounds(segment: Reservation, spacing: number): RouteBounds {
|
|
22
|
+
return inflate(segmentBounds(segment.a, segment.b), Math.max(1, spacing));
|
|
23
|
+
}
|
|
24
|
+
/** Finite penalties: a crossing is preferable to an arbitrarily long detour. */
|
|
25
|
+
export function conflictCost(
|
|
26
|
+
a: RoutePoint,
|
|
27
|
+
b: RoutePoint,
|
|
28
|
+
other: Reservation,
|
|
29
|
+
config: Settings,
|
|
30
|
+
): number {
|
|
31
|
+
const dx = b.x - a.x,
|
|
32
|
+
dy = b.y - a.y;
|
|
33
|
+
const ex = other.b.x - other.a.x,
|
|
34
|
+
ey = other.b.y - other.a.y;
|
|
35
|
+
const length = Math.hypot(dx, dy),
|
|
36
|
+
otherLength = Math.hypot(ex, ey);
|
|
37
|
+
if (length < 1e-8 || otherLength < 1e-8) return 0;
|
|
38
|
+
const cross = dx * ey - dy * ex;
|
|
39
|
+
const ox = other.a.x - a.x,
|
|
40
|
+
oy = other.a.y - a.y;
|
|
41
|
+
if (Math.abs(cross) > 1e-8 * length * otherLength) {
|
|
42
|
+
const t = (ox * ey - oy * ex) / cross;
|
|
43
|
+
const u = (ox * dy - oy * dx) / cross;
|
|
44
|
+
if (t < -1e-8 || t > 1 + 1e-8 || u < -1e-8 || u > 1 + 1e-8) return 0;
|
|
45
|
+
// Half-cost at vertices keeps subdivision from making crossings free.
|
|
46
|
+
return (
|
|
47
|
+
config.crossingPenalty *
|
|
48
|
+
(t < 1e-8 || t > 1 - 1e-8 ? 0.5 : 1) *
|
|
49
|
+
(u < 1e-8 || u > 1 - 1e-8 ? 0.5 : 1)
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
const separation = Math.abs(ox * dy - oy * dx) / length;
|
|
53
|
+
const spacing = Math.max(1e-8, config.edgeSpacing);
|
|
54
|
+
if (separation >= spacing) return 0;
|
|
55
|
+
const lo = (ox * dx + oy * dy) / length;
|
|
56
|
+
const hi = lo + (ex * dx + ey * dy) / length;
|
|
57
|
+
const overlap = Math.max(0, Math.min(length, Math.max(lo, hi)) - Math.max(0, Math.min(lo, hi)));
|
|
58
|
+
return config.overlapPenalty * overlap * (1 - separation / spacing);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Remove only the common terminal's attachment region from soft reservations. */
|
|
62
|
+
export function outsideTerminal(
|
|
63
|
+
segment: Reservation,
|
|
64
|
+
rect: RouteBounds,
|
|
65
|
+
closed = true,
|
|
66
|
+
): Reservation[] {
|
|
67
|
+
let lo = 0,
|
|
68
|
+
hi = 1;
|
|
69
|
+
const dx = segment.b.x - segment.a.x,
|
|
70
|
+
dy = segment.b.y - segment.a.y;
|
|
71
|
+
for (const [start, delta, min, max] of [
|
|
72
|
+
[segment.a.x, dx, rect.x, rect.x + rect.width],
|
|
73
|
+
[segment.a.y, dy, rect.y, rect.y + rect.height],
|
|
74
|
+
] as const) {
|
|
75
|
+
if (Math.abs(delta) < 1e-8) {
|
|
76
|
+
if (closed ? start < min || start > max : start <= min + 1e-8 || start >= max - 1e-8)
|
|
77
|
+
return [segment];
|
|
78
|
+
} else {
|
|
79
|
+
const a = (min - start) / delta,
|
|
80
|
+
b = (max - start) / delta;
|
|
81
|
+
lo = Math.max(lo, Math.min(a, b));
|
|
82
|
+
hi = Math.min(hi, Math.max(a, b));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (closed ? lo > hi : lo >= hi) return [segment];
|
|
86
|
+
const at = (t: number) => ({ x: segment.a.x + dx * t, y: segment.a.y + dy * t });
|
|
87
|
+
return [
|
|
88
|
+
...(lo > 1e-8 ? [{ ...segment, b: at(lo) }] : []),
|
|
89
|
+
...(hi < 1 - 1e-8 ? [{ ...segment, a: at(hi) }] : []),
|
|
90
|
+
];
|
|
91
|
+
}
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import type { GraphDiff } from "@statelyai/graph";
|
|
2
|
+
import { conflictCost, reservations, reservationBounds, outsideTerminal } from "./coordination";
|
|
3
|
+
import { segmentBounds } from "./search";
|
|
4
|
+
import { routeBounds, routeEdge, type Metrics, type Batch } from "./algorithms";
|
|
5
|
+
import {
|
|
6
|
+
edgeGeometry,
|
|
7
|
+
freeze,
|
|
8
|
+
groupKey,
|
|
9
|
+
labelRect,
|
|
10
|
+
members,
|
|
11
|
+
nodeGeometry,
|
|
12
|
+
settings,
|
|
13
|
+
worldRect,
|
|
14
|
+
type EdgeGeometry,
|
|
15
|
+
type NodeGeometry,
|
|
16
|
+
type State,
|
|
17
|
+
} from "./model";
|
|
18
|
+
import { PersistentMap } from "./persistent";
|
|
19
|
+
import { inflate, intersects, SpatialIndex, union } from "./spatial";
|
|
20
|
+
import type {
|
|
21
|
+
NativeRoutingStrategy,
|
|
22
|
+
Route,
|
|
23
|
+
RoutePatch,
|
|
24
|
+
RouteStyle,
|
|
25
|
+
RoutingGraph,
|
|
26
|
+
RoutingSettings,
|
|
27
|
+
RoutingSnapshot,
|
|
28
|
+
RoutingUpdate,
|
|
29
|
+
} from "./types";
|
|
30
|
+
|
|
31
|
+
const STATE = Symbol("routingState");
|
|
32
|
+
type Snapshot = RoutingSnapshot & { readonly [STATE]: State };
|
|
33
|
+
const metrics = (): Metrics => ({
|
|
34
|
+
routedEdges: 0,
|
|
35
|
+
reusedEdges: 0,
|
|
36
|
+
affectedEdges: 0,
|
|
37
|
+
searchNodes: 0,
|
|
38
|
+
obstacleCandidates: 0,
|
|
39
|
+
});
|
|
40
|
+
function snapshot(state: State, style: RouteStyle, revision: number, counts: Metrics): Snapshot {
|
|
41
|
+
return Object.freeze({
|
|
42
|
+
strategy: style,
|
|
43
|
+
revision,
|
|
44
|
+
routes: state.routes,
|
|
45
|
+
metrics: Object.freeze(counts),
|
|
46
|
+
[STATE]: Object.freeze(state),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
function edgeMembership(state: State, edge: EdgeGeometry, style: RouteStyle, add: boolean): State {
|
|
50
|
+
let incident = state.incident,
|
|
51
|
+
groups = state.groups;
|
|
52
|
+
for (const id of new Set([edge.sourceId, edge.targetId]))
|
|
53
|
+
incident = members(incident, id, edge.id, add);
|
|
54
|
+
const key = groupKey(edge, style, state.settings);
|
|
55
|
+
if (key !== undefined) groups = members(groups, key, edge.id, add);
|
|
56
|
+
return { ...state, incident, groups };
|
|
57
|
+
}
|
|
58
|
+
function initialState(graph: RoutingGraph, config: RoutingSettings, style: RouteStyle): State {
|
|
59
|
+
let nodes = new PersistentMap<NodeGeometry>(),
|
|
60
|
+
edges = new PersistentMap<EdgeGeometry>(),
|
|
61
|
+
children = new PersistentMap<PersistentMap<true>>();
|
|
62
|
+
for (const n of graph.nodes) {
|
|
63
|
+
if (nodes.has(n.id)) throw new Error(`Duplicate routing node ${n.id}`);
|
|
64
|
+
nodes = nodes.set(n.id, nodeGeometry(n));
|
|
65
|
+
if (n.parentId != null) children = members(children, n.parentId, n.id, true);
|
|
66
|
+
}
|
|
67
|
+
for (const e of graph.edges) {
|
|
68
|
+
if (edges.has(e.id)) throw new Error(`Duplicate routing edge ${e.id}`);
|
|
69
|
+
edges = edges.set(e.id, edgeGeometry(e));
|
|
70
|
+
}
|
|
71
|
+
let state: State = {
|
|
72
|
+
graphId: graph.id,
|
|
73
|
+
direction: graph.direction,
|
|
74
|
+
settings: settings(config),
|
|
75
|
+
nodes,
|
|
76
|
+
edges,
|
|
77
|
+
children,
|
|
78
|
+
incident: new PersistentMap(),
|
|
79
|
+
groups: new PersistentMap(),
|
|
80
|
+
obstacles: new SpatialIndex(),
|
|
81
|
+
routeIndex: new SpatialIndex(),
|
|
82
|
+
routes: new PersistentMap(),
|
|
83
|
+
};
|
|
84
|
+
for (const [id, n] of nodes)
|
|
85
|
+
state = { ...state, obstacles: state.obstacles.set(`n:${id}`, worldRect(n, state)) };
|
|
86
|
+
for (const [id, e] of edges) {
|
|
87
|
+
state = edgeMembership(state, e, style, true);
|
|
88
|
+
state = { ...state, obstacles: state.obstacles.set(`e:${id}`, labelRect(e, state)) };
|
|
89
|
+
}
|
|
90
|
+
return state;
|
|
91
|
+
}
|
|
92
|
+
function calculate(
|
|
93
|
+
state: State,
|
|
94
|
+
style: RouteStyle,
|
|
95
|
+
affected: ReadonlySet<string>,
|
|
96
|
+
counts: Metrics,
|
|
97
|
+
previous?: State,
|
|
98
|
+
): { state: State; patches: RoutePatch[] } {
|
|
99
|
+
let routes = state.routes,
|
|
100
|
+
routeIndex = state.routeIndex;
|
|
101
|
+
const input = {
|
|
102
|
+
nodes: state.nodes,
|
|
103
|
+
incident: state.incident,
|
|
104
|
+
edges: state.edges,
|
|
105
|
+
groups: state.groups,
|
|
106
|
+
obstacles: state.obstacles,
|
|
107
|
+
settings: state.settings,
|
|
108
|
+
};
|
|
109
|
+
const patches: RoutePatch[] = [],
|
|
110
|
+
batch: Batch = {
|
|
111
|
+
plans: new Map(),
|
|
112
|
+
};
|
|
113
|
+
const pending = new Set(affected);
|
|
114
|
+
const processed = new Set<string>();
|
|
115
|
+
const priorities = new Map<string, string>();
|
|
116
|
+
const priority = (id: string) => {
|
|
117
|
+
let result = priorities.get(id);
|
|
118
|
+
if (result !== undefined) return result;
|
|
119
|
+
const edge = state.edges.get(id) ?? previous?.edges.get(id);
|
|
120
|
+
const key = edge && groupKey(edge, style, state.settings);
|
|
121
|
+
result = key
|
|
122
|
+
? ((state.edges.has(id) ? state.groups : previous?.groups)?.get(key)?.keys().next().value ??
|
|
123
|
+
id)
|
|
124
|
+
: id;
|
|
125
|
+
priorities.set(id, result);
|
|
126
|
+
return result;
|
|
127
|
+
};
|
|
128
|
+
const later = (candidate: string, id: string) =>
|
|
129
|
+
priority(candidate) > priority(id) || (priority(candidate) === priority(id) && candidate > id);
|
|
130
|
+
const invalidate = (id: string, bounds: ReturnType<typeof routeBounds>) => {
|
|
131
|
+
for (const candidate of routeIndex.query(inflate(bounds, state.settings.edgeSpacing))) {
|
|
132
|
+
if (!later(candidate, id) || processed.has(candidate)) continue;
|
|
133
|
+
pending.add(candidate);
|
|
134
|
+
const edge = state.edges.get(candidate);
|
|
135
|
+
const key = edge && groupKey(edge, style, state.settings);
|
|
136
|
+
if (key)
|
|
137
|
+
for (const peer of state.groups.get(key)?.keys() ?? [])
|
|
138
|
+
if (!processed.has(peer)) pending.add(peer);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
// Changing a group's first ID changes its priority relative to nearby groups.
|
|
142
|
+
// Seed these dependencies before processing, including readers now ordered earlier.
|
|
143
|
+
if (previous)
|
|
144
|
+
for (const id of affected) {
|
|
145
|
+
const edge = previous.edges.get(id);
|
|
146
|
+
if (!edge) continue;
|
|
147
|
+
const key = groupKey(edge, style, previous.settings);
|
|
148
|
+
const oldPriority = key ? (previous.groups.get(key)?.keys().next().value ?? id) : id;
|
|
149
|
+
if (oldPriority === priority(id)) continue;
|
|
150
|
+
const oldRoute = previous.routes.get(id);
|
|
151
|
+
if (oldRoute)
|
|
152
|
+
for (const candidate of previous.routeIndex.query(
|
|
153
|
+
inflate(routeBounds(oldRoute), state.settings.edgeSpacing),
|
|
154
|
+
)) {
|
|
155
|
+
pending.add(candidate);
|
|
156
|
+
const current = state.edges.get(candidate);
|
|
157
|
+
const group = current && groupKey(current, style, state.settings);
|
|
158
|
+
if (group) for (const peer of state.groups.get(group)?.keys() ?? []) pending.add(peer);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const segmentCache = new Map<string, ReturnType<typeof reservations>>();
|
|
162
|
+
while (pending.size) {
|
|
163
|
+
const id = [...pending].sort(
|
|
164
|
+
(a, b) =>
|
|
165
|
+
(priority(a) < priority(b) ? -1 : priority(a) > priority(b) ? 1 : 0) ||
|
|
166
|
+
(a < b ? -1 : a > b ? 1 : 0),
|
|
167
|
+
)[0]!;
|
|
168
|
+
pending.delete(id);
|
|
169
|
+
processed.add(id);
|
|
170
|
+
const edge = state.edges.get(id),
|
|
171
|
+
oldRoute = previous?.routes.get(id);
|
|
172
|
+
if (!edge) {
|
|
173
|
+
if (routes.has(id)) {
|
|
174
|
+
invalidate(id, routeBounds(routes.get(id)!));
|
|
175
|
+
routes = routes.delete(id);
|
|
176
|
+
routeIndex = routeIndex.set(id);
|
|
177
|
+
patches.push({ op: "delete", edgeId: id });
|
|
178
|
+
}
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
batch.dependencyBounds = undefined;
|
|
182
|
+
const query = (bounds: ReturnType<typeof routeBounds>) => {
|
|
183
|
+
const area = inflate(bounds, state.settings.edgeSpacing);
|
|
184
|
+
batch.dependencyBounds = batch.dependencyBounds ? union(batch.dependencyBounds, area) : area;
|
|
185
|
+
const result: ReturnType<typeof reservations> = [];
|
|
186
|
+
const shared = new Set<string>();
|
|
187
|
+
for (const otherId of routeIndex.query(area)) {
|
|
188
|
+
if (!later(id, otherId)) continue;
|
|
189
|
+
const other = state.edges.get(otherId),
|
|
190
|
+
route = routes.get(otherId);
|
|
191
|
+
if (!other || !route) continue;
|
|
192
|
+
if (
|
|
193
|
+
["bus", "fan", "bundle"].includes(style) &&
|
|
194
|
+
groupKey(other, style, state.settings) === groupKey(edge, style, state.settings)
|
|
195
|
+
)
|
|
196
|
+
continue;
|
|
197
|
+
const terminalRegions = [edge.sourceId, edge.targetId]
|
|
198
|
+
.filter((node) => node === other.sourceId || node === other.targetId)
|
|
199
|
+
.flatMap((id) => {
|
|
200
|
+
const node = state.nodes.get(id),
|
|
201
|
+
bounds = node && worldRect(node, state);
|
|
202
|
+
return bounds
|
|
203
|
+
? [inflate(bounds, state.settings.clearance + state.settings.edgeSpacing * 2 + 1)]
|
|
204
|
+
: [];
|
|
205
|
+
});
|
|
206
|
+
let segments = segmentCache.get(otherId);
|
|
207
|
+
if (!segments) {
|
|
208
|
+
segments = reservations(route);
|
|
209
|
+
segmentCache.set(otherId, segments);
|
|
210
|
+
}
|
|
211
|
+
const outside = terminalRegions.reduce(
|
|
212
|
+
(segments, rect) => segments.flatMap((segment) => outsideTerminal(segment, rect)),
|
|
213
|
+
segments,
|
|
214
|
+
);
|
|
215
|
+
for (const segment of outside) {
|
|
216
|
+
if (!intersects(segmentBounds(segment.a, segment.b), area)) continue;
|
|
217
|
+
if (segment.sharedId !== undefined) {
|
|
218
|
+
const key = JSON.stringify([segment.sharedId, segment.a, segment.b]);
|
|
219
|
+
if (shared.has(key)) continue;
|
|
220
|
+
shared.add(key);
|
|
221
|
+
}
|
|
222
|
+
result.push(segment);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return result;
|
|
226
|
+
};
|
|
227
|
+
batch.edgeCost = (a, b) =>
|
|
228
|
+
query(segmentBounds(a, b)).reduce(
|
|
229
|
+
(cost, other) => cost + conflictCost(a, b, other, state.settings),
|
|
230
|
+
0,
|
|
231
|
+
);
|
|
232
|
+
batch.conflicts = (a, b) =>
|
|
233
|
+
query(segmentBounds(a, b)).some(
|
|
234
|
+
(other) =>
|
|
235
|
+
conflictCost(a, b, other, { ...state.settings, crossingPenalty: 1, overlapPenalty: 1 }) >
|
|
236
|
+
1e-8,
|
|
237
|
+
);
|
|
238
|
+
batch.guides = (bounds) =>
|
|
239
|
+
query(bounds).map((segment) => reservationBounds(segment, state.settings.edgeSpacing));
|
|
240
|
+
let route = routeEdge(edge, input, style, counts, batch);
|
|
241
|
+
if (oldRoute && JSON.stringify(oldRoute) === JSON.stringify(route)) route = oldRoute;
|
|
242
|
+
const drawnBounds = routeBounds(route);
|
|
243
|
+
if (route !== oldRoute) {
|
|
244
|
+
if (oldRoute) invalidate(id, routeBounds(oldRoute));
|
|
245
|
+
invalidate(id, drawnBounds);
|
|
246
|
+
}
|
|
247
|
+
routeIndex = routeIndex.set(
|
|
248
|
+
id,
|
|
249
|
+
batch.dependencyBounds ? union(batch.dependencyBounds, drawnBounds) : drawnBounds,
|
|
250
|
+
);
|
|
251
|
+
if (route === oldRoute) continue;
|
|
252
|
+
route = freeze(route);
|
|
253
|
+
routes = routes.set(id, route);
|
|
254
|
+
segmentCache.delete(id);
|
|
255
|
+
patches.push({ op: "set", edgeId: id, route });
|
|
256
|
+
}
|
|
257
|
+
counts.affectedEdges = processed.size;
|
|
258
|
+
counts.reusedEdges = Math.max(
|
|
259
|
+
0,
|
|
260
|
+
state.routes.size - [...processed].filter((id) => state.routes.has(id)).length,
|
|
261
|
+
);
|
|
262
|
+
patches.sort((a, b) => (a.edgeId < b.edgeId ? -1 : a.edgeId > b.edgeId ? 1 : 0));
|
|
263
|
+
return { state: { ...state, routes, routeIndex }, patches };
|
|
264
|
+
}
|
|
265
|
+
function descendants(state: State, ids: Set<string>) {
|
|
266
|
+
const queue = [...ids];
|
|
267
|
+
for (let i = 0; i < queue.length; i++)
|
|
268
|
+
for (const child of state.children.get(queue[i]!)?.keys() ?? [])
|
|
269
|
+
if (!ids.has(child)) {
|
|
270
|
+
ids.add(child);
|
|
271
|
+
queue.push(child);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
function verifyOld(record: NodeGeometry | EdgeGeometry | undefined, old: object, id: string) {
|
|
275
|
+
if (!record) throw new Error(`Routing diff updates missing entity ${id}`);
|
|
276
|
+
for (const [key, value] of Object.entries(old))
|
|
277
|
+
if (
|
|
278
|
+
key in record &&
|
|
279
|
+
JSON.stringify(record[key as keyof typeof record]) !==
|
|
280
|
+
JSON.stringify(
|
|
281
|
+
key === "ports"
|
|
282
|
+
? nodeGeometry({ id, ports: value as NodeGeometry["ports"] }).ports
|
|
283
|
+
: value,
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
throw new Error(`Stale routing diff for ${id}.${key}`);
|
|
287
|
+
}
|
|
288
|
+
function updateState(
|
|
289
|
+
graph: RoutingGraph,
|
|
290
|
+
prior: State,
|
|
291
|
+
diff: GraphDiff<unknown, unknown>,
|
|
292
|
+
style: RouteStyle,
|
|
293
|
+
config?: RoutingSettings,
|
|
294
|
+
): { state: State; affected: Set<string> } {
|
|
295
|
+
if (graph.id !== prior.graphId)
|
|
296
|
+
throw new Error("A routing snapshot belongs to a different graph");
|
|
297
|
+
const candidateSettings = config === undefined ? prior.settings : settings(config);
|
|
298
|
+
const nextSettings =
|
|
299
|
+
JSON.stringify(candidateSettings) === JSON.stringify(prior.settings)
|
|
300
|
+
? prior.settings
|
|
301
|
+
: candidateSettings;
|
|
302
|
+
const settingsChanged =
|
|
303
|
+
JSON.stringify(nextSettings) !== JSON.stringify(prior.settings) ||
|
|
304
|
+
prior.direction !== graph.direction;
|
|
305
|
+
let state: State = { ...prior, settings: nextSettings, direction: graph.direction };
|
|
306
|
+
const changedNodes = new Set<string>(),
|
|
307
|
+
changedEdges = new Set<string>(),
|
|
308
|
+
affected = new Set<string>();
|
|
309
|
+
for (const n of diff.nodes.removed) {
|
|
310
|
+
if (!state.nodes.has(n.id)) throw new Error(`Routing diff removes missing node ${n.id}`);
|
|
311
|
+
const old = state.nodes.get(n.id)!;
|
|
312
|
+
if (old.parentId != null)
|
|
313
|
+
state = { ...state, children: members(state.children, old.parentId, n.id, false) };
|
|
314
|
+
state = { ...state, nodes: state.nodes.delete(n.id) };
|
|
315
|
+
changedNodes.add(n.id);
|
|
316
|
+
}
|
|
317
|
+
for (const n of diff.nodes.added) {
|
|
318
|
+
if (state.nodes.has(n.id)) throw new Error(`Routing diff adds existing node ${n.id}`);
|
|
319
|
+
state = { ...state, nodes: state.nodes.set(n.id, nodeGeometry(n)) };
|
|
320
|
+
if (n.parentId != null)
|
|
321
|
+
state = { ...state, children: members(state.children, n.parentId, n.id, true) };
|
|
322
|
+
changedNodes.add(n.id);
|
|
323
|
+
}
|
|
324
|
+
for (const change of diff.nodes.updated) {
|
|
325
|
+
const old = state.nodes.get(change.id);
|
|
326
|
+
verifyOld(old, change.old, change.id);
|
|
327
|
+
const next = nodeGeometry({ ...old, ...change.new, id: change.id } as Parameters<
|
|
328
|
+
typeof nodeGeometry
|
|
329
|
+
>[0]);
|
|
330
|
+
if (JSON.stringify(old) === JSON.stringify(next)) continue;
|
|
331
|
+
if (old!.parentId != null)
|
|
332
|
+
state = { ...state, children: members(state.children, old!.parentId!, change.id, false) };
|
|
333
|
+
if (next.parentId != null)
|
|
334
|
+
state = { ...state, children: members(state.children, next.parentId, change.id, true) };
|
|
335
|
+
state = { ...state, nodes: state.nodes.set(change.id, next) };
|
|
336
|
+
changedNodes.add(change.id);
|
|
337
|
+
}
|
|
338
|
+
for (const e of diff.edges.removed) {
|
|
339
|
+
const old = state.edges.get(e.id);
|
|
340
|
+
if (!old) throw new Error(`Routing diff removes missing edge ${e.id}`);
|
|
341
|
+
state = edgeMembership(state, old, style, false);
|
|
342
|
+
state = { ...state, edges: state.edges.delete(e.id) };
|
|
343
|
+
changedEdges.add(e.id);
|
|
344
|
+
}
|
|
345
|
+
for (const e of diff.edges.added) {
|
|
346
|
+
if (state.edges.has(e.id)) throw new Error(`Routing diff adds existing edge ${e.id}`);
|
|
347
|
+
const next = edgeGeometry(e);
|
|
348
|
+
state = { ...state, edges: state.edges.set(e.id, next) };
|
|
349
|
+
state = edgeMembership(state, next, style, true);
|
|
350
|
+
changedEdges.add(e.id);
|
|
351
|
+
}
|
|
352
|
+
for (const change of diff.edges.updated) {
|
|
353
|
+
const old = state.edges.get(change.id);
|
|
354
|
+
verifyOld(old, change.old, change.id);
|
|
355
|
+
const next = edgeGeometry({ ...old, ...change.new, id: change.id });
|
|
356
|
+
if (JSON.stringify(old) === JSON.stringify(next)) continue;
|
|
357
|
+
state = edgeMembership(state, old!, style, false);
|
|
358
|
+
state = { ...state, edges: state.edges.set(change.id, next) };
|
|
359
|
+
state = edgeMembership(state, next, style, true);
|
|
360
|
+
changedEdges.add(change.id);
|
|
361
|
+
}
|
|
362
|
+
if (settingsChanged) {
|
|
363
|
+
// Configuration changes can alter every dependency and clearance envelope.
|
|
364
|
+
state = { ...state, groups: new PersistentMap() };
|
|
365
|
+
for (const [id, e] of state.edges) {
|
|
366
|
+
const key = groupKey(e, style, nextSettings);
|
|
367
|
+
if (key !== undefined) state = { ...state, groups: members(state.groups, key, id, true) };
|
|
368
|
+
changedEdges.add(id);
|
|
369
|
+
}
|
|
370
|
+
for (const id of state.nodes.keys()) changedNodes.add(id);
|
|
371
|
+
for (const id of prior.routes.keys()) affected.add(id);
|
|
372
|
+
}
|
|
373
|
+
descendants(prior, changedNodes);
|
|
374
|
+
descendants(state, changedNodes);
|
|
375
|
+
function invalidateObstacle(id: string, bounds: ReturnType<typeof worldRect>) {
|
|
376
|
+
const old = prior.obstacles.bounds.get(id);
|
|
377
|
+
for (const rect of [old, bounds])
|
|
378
|
+
if (rect)
|
|
379
|
+
for (const edgeId of prior.routeIndex.query(
|
|
380
|
+
inflate(rect, Math.max(prior.settings.clearance, nextSettings.clearance)),
|
|
381
|
+
))
|
|
382
|
+
affected.add(edgeId);
|
|
383
|
+
state = { ...state, obstacles: state.obstacles.set(id, bounds) };
|
|
384
|
+
}
|
|
385
|
+
for (const id of changedNodes) {
|
|
386
|
+
const node = state.nodes.get(id);
|
|
387
|
+
invalidateObstacle(`n:${id}`, node && worldRect(node, state));
|
|
388
|
+
for (const index of [prior.incident, state.incident])
|
|
389
|
+
for (const edgeId of index.get(id)?.keys() ?? []) changedEdges.add(edgeId);
|
|
390
|
+
}
|
|
391
|
+
for (const id of changedEdges) {
|
|
392
|
+
const edge = state.edges.get(id);
|
|
393
|
+
invalidateObstacle(`e:${id}`, edge && labelRect(edge, state));
|
|
394
|
+
affected.add(id);
|
|
395
|
+
}
|
|
396
|
+
// Terminal placement depends on current incident edges, including neighbors
|
|
397
|
+
// whose other endpoint changes side or whose connection is added/deleted.
|
|
398
|
+
if (!["bus", "fan", "bundle"].includes(style))
|
|
399
|
+
for (const id of changedEdges) {
|
|
400
|
+
const before = prior.edges.get(id),
|
|
401
|
+
after = state.edges.get(id);
|
|
402
|
+
const changesAttachment =
|
|
403
|
+
!before ||
|
|
404
|
+
!after ||
|
|
405
|
+
before.sourceId !== after.sourceId ||
|
|
406
|
+
before.targetId !== after.targetId ||
|
|
407
|
+
before.sourcePort !== after.sourcePort ||
|
|
408
|
+
before.targetPort !== after.targetPort ||
|
|
409
|
+
[before.sourceId, before.targetId, after.sourceId, after.targetId].some((nodeId) =>
|
|
410
|
+
changedNodes.has(nodeId),
|
|
411
|
+
);
|
|
412
|
+
if (!changesAttachment) continue;
|
|
413
|
+
for (const version of [prior, state]) {
|
|
414
|
+
const edge = version.edges.get(id);
|
|
415
|
+
if (!edge) continue;
|
|
416
|
+
for (const nodeId of [edge.sourceId, edge.targetId])
|
|
417
|
+
for (const peer of version.incident.get(nodeId)?.keys() ?? []) affected.add(peer);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
// A changed branch/trunk may affect every member, but never unrelated groups.
|
|
421
|
+
for (const id of [...affected])
|
|
422
|
+
for (const version of [prior, state]) {
|
|
423
|
+
const edge = version.edges.get(id);
|
|
424
|
+
if (!edge) continue;
|
|
425
|
+
const key = groupKey(edge, style, version.settings);
|
|
426
|
+
if (key !== undefined)
|
|
427
|
+
for (const peer of version.groups.get(key)?.keys() ?? []) affected.add(peer);
|
|
428
|
+
}
|
|
429
|
+
return { state, affected };
|
|
430
|
+
}
|
|
431
|
+
export function createRoutingStrategy(id: RouteStyle): NativeRoutingStrategy {
|
|
432
|
+
return Object.freeze({
|
|
433
|
+
id,
|
|
434
|
+
route(graph: RoutingGraph, config: RoutingSettings = {}): RoutingSnapshot {
|
|
435
|
+
const initial = initialState(graph, config, id),
|
|
436
|
+
counts = metrics();
|
|
437
|
+
const result = calculate(initial, id, new Set(initial.edges.keys()), counts);
|
|
438
|
+
return snapshot(result.state, id, 0, counts);
|
|
439
|
+
},
|
|
440
|
+
update(
|
|
441
|
+
graph: RoutingGraph,
|
|
442
|
+
previous: RoutingSnapshot,
|
|
443
|
+
diff: GraphDiff<unknown, unknown>,
|
|
444
|
+
config?: RoutingSettings,
|
|
445
|
+
): RoutingUpdate {
|
|
446
|
+
const prior = (previous as Snapshot)[STATE];
|
|
447
|
+
if (!prior || previous.strategy !== id)
|
|
448
|
+
throw new Error(
|
|
449
|
+
"Update requires a snapshot from the same routing strategy; route again after serialization or strategy changes",
|
|
450
|
+
);
|
|
451
|
+
const { state, affected } = updateState(graph, prior, diff, id, config),
|
|
452
|
+
counts = metrics();
|
|
453
|
+
const result = calculate(state, id, affected, counts, prior);
|
|
454
|
+
return Object.freeze({
|
|
455
|
+
base: previous,
|
|
456
|
+
snapshot: snapshot(result.state, id, previous.revision + 1, counts),
|
|
457
|
+
patches: Object.freeze(result.patches.map((patch) => Object.freeze(patch))),
|
|
458
|
+
});
|
|
459
|
+
},
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
export function applyRoutePatches(
|
|
463
|
+
routes: ReadonlyMap<string, Route>,
|
|
464
|
+
patches: readonly RoutePatch[],
|
|
465
|
+
): ReadonlyMap<string, Route> {
|
|
466
|
+
let result = routes instanceof PersistentMap ? routes : new PersistentMap<Route>();
|
|
467
|
+
if (!(routes instanceof PersistentMap))
|
|
468
|
+
for (const [id, route] of routes) result = result.set(id, route);
|
|
469
|
+
for (const patch of patches)
|
|
470
|
+
result =
|
|
471
|
+
patch.op === "set" ? result.set(patch.edgeId, patch.route) : result.delete(patch.edgeId);
|
|
472
|
+
return result;
|
|
473
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createRoutingStrategy } from "./engine";
|
|
2
|
+
export const straightRouting = createRoutingStrategy("straight");
|
|
3
|
+
export const bezierRouting = createRoutingStrategy("bezier");
|
|
4
|
+
export const orthogonalRouting = createRoutingStrategy("orthogonal");
|
|
5
|
+
export const polylineRouting = createRoutingStrategy("polyline");
|
|
6
|
+
export const octilinearRouting = createRoutingStrategy("octilinear");
|
|
7
|
+
export const curvedRouting = createRoutingStrategy("curved");
|
|
8
|
+
export const organicRouting = createRoutingStrategy("organic");
|
|
9
|
+
export const parallelRouting = createRoutingStrategy("parallel");
|
|
10
|
+
export const selfLoopRouting = createRoutingStrategy("self-loop");
|
|
11
|
+
export const fanRouting = createRoutingStrategy("fan");
|
|
12
|
+
export const busRouting = createRoutingStrategy("bus");
|
|
13
|
+
export const bundleRouting = createRoutingStrategy("bundle");
|
|
14
|
+
export const routingStrategies = Object.freeze({
|
|
15
|
+
straight: straightRouting,
|
|
16
|
+
bezier: bezierRouting,
|
|
17
|
+
orthogonal: orthogonalRouting,
|
|
18
|
+
polyline: polylineRouting,
|
|
19
|
+
octilinear: octilinearRouting,
|
|
20
|
+
curved: curvedRouting,
|
|
21
|
+
organic: organicRouting,
|
|
22
|
+
parallel: parallelRouting,
|
|
23
|
+
"self-loop": selfLoopRouting,
|
|
24
|
+
fan: fanRouting,
|
|
25
|
+
bus: busRouting,
|
|
26
|
+
bundle: bundleRouting,
|
|
27
|
+
});
|
|
28
|
+
export { applyRoutePatches } from "./engine";
|
|
29
|
+
export {
|
|
30
|
+
flattenPath,
|
|
31
|
+
getPathBounds,
|
|
32
|
+
getPathLength,
|
|
33
|
+
getPointAtLength,
|
|
34
|
+
getTangentAtLength,
|
|
35
|
+
pathFromPoints,
|
|
36
|
+
roundCorners,
|
|
37
|
+
toSvgPath,
|
|
38
|
+
} from "./path";
|
|
39
|
+
export type {
|
|
40
|
+
RouteAttachment,
|
|
41
|
+
EdgeRoutingSettings,
|
|
42
|
+
NativeRoutingStrategy,
|
|
43
|
+
Route,
|
|
44
|
+
RouteBounds,
|
|
45
|
+
RouteEndpoint,
|
|
46
|
+
RoutePatch,
|
|
47
|
+
RoutePath,
|
|
48
|
+
RoutePoint,
|
|
49
|
+
RouteSection,
|
|
50
|
+
RouteSegment,
|
|
51
|
+
RouteSide,
|
|
52
|
+
RouteStyle,
|
|
53
|
+
RoutingDiagnostic,
|
|
54
|
+
RoutingDiagnosticCode,
|
|
55
|
+
RoutingGraph,
|
|
56
|
+
RoutingMetrics,
|
|
57
|
+
RoutingSettings,
|
|
58
|
+
RoutingSnapshot,
|
|
59
|
+
RoutingStrategy,
|
|
60
|
+
RoutingUpdate,
|
|
61
|
+
} from "./types";
|
|
62
|
+
export type { FlattenPathOptions } from "./path";
|
|
63
|
+
export {
|
|
64
|
+
getLayoutRoutes,
|
|
65
|
+
pathFromSplinePoints,
|
|
66
|
+
routeToGraphPatch,
|
|
67
|
+
routeToPolylines,
|
|
68
|
+
routeToPoints,
|
|
69
|
+
} from "./adapters";
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { VisualGraph } from "@statelyai/graph";
|
|
2
|
+
import type { Route } from "./types";
|
|
3
|
+
|
|
4
|
+
const geometryByRoutes = new WeakMap<ReadonlyMap<string, Route>, string>();
|
|
5
|
+
|
|
6
|
+
/** Compare only geometry and topology; application data may contain cycles. */
|
|
7
|
+
function geometryKey(graph: VisualGraph): string {
|
|
8
|
+
return JSON.stringify({
|
|
9
|
+
nodes: graph.nodes.map(({ id, parentId, x, y, width, height, ports }) => ({
|
|
10
|
+
id,
|
|
11
|
+
parentId,
|
|
12
|
+
x,
|
|
13
|
+
y,
|
|
14
|
+
width,
|
|
15
|
+
height,
|
|
16
|
+
ports: ports?.map(({ name, x, y, width, height }) => ({
|
|
17
|
+
name,
|
|
18
|
+
x,
|
|
19
|
+
y,
|
|
20
|
+
width,
|
|
21
|
+
height,
|
|
22
|
+
})),
|
|
23
|
+
})),
|
|
24
|
+
edges: graph.edges.map(
|
|
25
|
+
({
|
|
26
|
+
id,
|
|
27
|
+
sourceId,
|
|
28
|
+
targetId,
|
|
29
|
+
sourcePort,
|
|
30
|
+
targetPort,
|
|
31
|
+
x,
|
|
32
|
+
y,
|
|
33
|
+
width,
|
|
34
|
+
height,
|
|
35
|
+
points,
|
|
36
|
+
routing,
|
|
37
|
+
}) => ({
|
|
38
|
+
id,
|
|
39
|
+
sourceId,
|
|
40
|
+
targetId,
|
|
41
|
+
sourcePort,
|
|
42
|
+
targetPort,
|
|
43
|
+
x,
|
|
44
|
+
y,
|
|
45
|
+
width,
|
|
46
|
+
height,
|
|
47
|
+
points,
|
|
48
|
+
routing,
|
|
49
|
+
}),
|
|
50
|
+
),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Associate structured sections with the geometry that produced them. */
|
|
55
|
+
export function recordRouteGeometry(graph: VisualGraph, routes: ReadonlyMap<string, Route>): void {
|
|
56
|
+
geometryByRoutes.set(routes, geometryKey(graph));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Graph edits invalidate cached sections, including edits made by external consumers. */
|
|
60
|
+
export function hasCurrentRouteGeometry(
|
|
61
|
+
graph: VisualGraph,
|
|
62
|
+
routes: ReadonlyMap<string, Route>,
|
|
63
|
+
): boolean {
|
|
64
|
+
return geometryByRoutes.get(routes) === geometryKey(graph);
|
|
65
|
+
}
|