@prnt/dagr-explorer 0.1.3
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/CHANGELOG.md +93 -0
- package/LICENSE +21 -0
- package/README.md +441 -0
- package/dist/base.d.ts +63 -0
- package/dist/base.d.ts.map +1 -0
- package/dist/base.js +20 -0
- package/dist/base.js.map +1 -0
- package/dist/camera.d.ts +61 -0
- package/dist/camera.d.ts.map +1 -0
- package/dist/camera.js +142 -0
- package/dist/camera.js.map +1 -0
- package/dist/context.d.ts +110 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +71 -0
- package/dist/context.js.map +1 -0
- package/dist/dagr-explorer.d.ts +41 -0
- package/dist/dagr-explorer.d.ts.map +1 -0
- package/dist/dagr-explorer.js +13 -0
- package/dist/dagr-explorer.js.map +1 -0
- package/dist/errors.d.ts +55 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +57 -0
- package/dist/errors.js.map +1 -0
- package/dist/explorer-details.d.ts +44 -0
- package/dist/explorer-details.d.ts.map +1 -0
- package/dist/explorer-details.js +76 -0
- package/dist/explorer-details.js.map +1 -0
- package/dist/explorer-search.d.ts +31 -0
- package/dist/explorer-search.d.ts.map +1 -0
- package/dist/explorer-search.js +86 -0
- package/dist/explorer-search.js.map +1 -0
- package/dist/explorer-toolbar.d.ts +17 -0
- package/dist/explorer-toolbar.d.ts.map +1 -0
- package/dist/explorer-toolbar.js +44 -0
- package/dist/explorer-toolbar.js.map +1 -0
- package/dist/explorer-trace-toggle.d.ts +16 -0
- package/dist/explorer-trace-toggle.d.ts.map +1 -0
- package/dist/explorer-trace-toggle.js +8 -0
- package/dist/explorer-trace-toggle.js.map +1 -0
- package/dist/explorer-viewport.d.ts +69 -0
- package/dist/explorer-viewport.d.ts.map +1 -0
- package/dist/explorer-viewport.js +69 -0
- package/dist/explorer-viewport.js.map +1 -0
- package/dist/explorer-views.d.ts +24 -0
- package/dist/explorer-views.d.ts.map +1 -0
- package/dist/explorer-views.js +16 -0
- package/dist/explorer-views.js.map +1 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/isomorphic-layout-effect.d.ts +7 -0
- package/dist/isomorphic-layout-effect.d.ts.map +1 -0
- package/dist/isomorphic-layout-effect.js +7 -0
- package/dist/isomorphic-layout-effect.js.map +1 -0
- package/dist/labels.d.ts +67 -0
- package/dist/labels.d.ts.map +1 -0
- package/dist/labels.js +50 -0
- package/dist/labels.js.map +1 -0
- package/dist/layout.d.ts +82 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +244 -0
- package/dist/layout.js.map +1 -0
- package/dist/navigation.d.ts +20 -0
- package/dist/navigation.d.ts.map +1 -0
- package/dist/navigation.js +44 -0
- package/dist/navigation.js.map +1 -0
- package/dist/root.d.ts +89 -0
- package/dist/root.d.ts.map +1 -0
- package/dist/root.js +401 -0
- package/dist/root.js.map +1 -0
- package/dist/search.d.ts +19 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +31 -0
- package/dist/search.js.map +1 -0
- package/dist/size.d.ts +12 -0
- package/dist/size.d.ts.map +1 -0
- package/dist/size.js +20 -0
- package/dist/size.js.map +1 -0
- package/dist/svg-base.d.ts +33 -0
- package/dist/svg-base.d.ts.map +1 -0
- package/dist/svg-base.js +89 -0
- package/dist/svg-base.js.map +1 -0
- package/dist/types.d.ts +61 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/use-explorer-camera.d.ts +56 -0
- package/dist/use-explorer-camera.d.ts.map +1 -0
- package/dist/use-explorer-camera.js +641 -0
- package/dist/use-explorer-camera.js.map +1 -0
- package/dist/use-explorer.d.ts +23 -0
- package/dist/use-explorer.d.ts.map +1 -0
- package/dist/use-explorer.js +26 -0
- package/dist/use-explorer.js.map +1 -0
- package/dist/validate.d.ts +20 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +99 -0
- package/dist/validate.js.map +1 -0
- package/dist/viewport-surface.d.ts +90 -0
- package/dist/viewport-surface.d.ts.map +1 -0
- package/dist/viewport-surface.js +485 -0
- package/dist/viewport-surface.js.map +1 -0
- package/dist/visible-set.d.ts +65 -0
- package/dist/visible-set.d.ts.map +1 -0
- package/dist/visible-set.js +168 -0
- package/dist/visible-set.js.map +1 -0
- package/package.json +62 -0
- package/src/base.ts +67 -0
- package/src/camera.ts +206 -0
- package/src/context.ts +168 -0
- package/src/dagr-explorer.tsx +85 -0
- package/src/errors.ts +85 -0
- package/src/explorer-details.tsx +156 -0
- package/src/explorer-search.tsx +140 -0
- package/src/explorer-toolbar.tsx +77 -0
- package/src/explorer-trace-toggle.tsx +34 -0
- package/src/explorer-viewport.tsx +181 -0
- package/src/explorer-views.tsx +61 -0
- package/src/index.ts +76 -0
- package/src/isomorphic-layout-effect.ts +7 -0
- package/src/labels.ts +109 -0
- package/src/layout.ts +298 -0
- package/src/navigation.ts +51 -0
- package/src/root.tsx +544 -0
- package/src/search.ts +36 -0
- package/src/size.ts +23 -0
- package/src/svg-base.tsx +173 -0
- package/src/types.ts +69 -0
- package/src/use-explorer-camera.ts +675 -0
- package/src/use-explorer.ts +32 -0
- package/src/validate.ts +173 -0
- package/src/viewport-surface.tsx +650 -0
- package/src/visible-set.ts +225 -0
- package/styles.css +253 -0
package/src/layout.ts
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { Graph } from '@prnt/dagr-graph';
|
|
2
|
+
import { layout } from '@prnt/dagr-layout';
|
|
3
|
+
import { shapeEdgePath } from '@prnt/dagr-render/core';
|
|
4
|
+
import type { Vec2 } from '@prnt/dagr-render/core';
|
|
5
|
+
|
|
6
|
+
import { ExplorerDataError } from './errors.js';
|
|
7
|
+
import { resolveNodeSize } from './size.js';
|
|
8
|
+
import type { ExplorerEdge, ExplorerNode, ExplorerView, Size } from './types.js';
|
|
9
|
+
import { validateView } from './validate.js';
|
|
10
|
+
|
|
11
|
+
/** Gap between neighbors across the flow, when the view does not say. */
|
|
12
|
+
export const DEFAULT_NODE_SEP = 40;
|
|
13
|
+
/** Gap between ranks along the flow, when the view does not say. */
|
|
14
|
+
export const DEFAULT_RANK_SEP = 120;
|
|
15
|
+
// The four constants below are exported for this package's tests and are NOT
|
|
16
|
+
// re-exported from the entry. They are fixed values today. Publishing them
|
|
17
|
+
// would make a change to any one a silent behavior break for whoever read it.
|
|
18
|
+
|
|
19
|
+
/** How far the content sits from the world origin, and from the far edges. */
|
|
20
|
+
export const WORLD_PADDING = 40;
|
|
21
|
+
/** Space between a group's outline and its members. `NodeGroup`'s default. */
|
|
22
|
+
export const GROUP_PADDING = 24;
|
|
23
|
+
/** Extra space above a group's members, for its label. */
|
|
24
|
+
export const GROUP_LABEL_BAND = 24;
|
|
25
|
+
/** Distance between neighboring edges that join the same pair of nodes. */
|
|
26
|
+
export const PARALLEL_EDGE_GAP = 16;
|
|
27
|
+
|
|
28
|
+
/** An axis-aligned rectangle in world space. `x`, `y` is its top-left corner. */
|
|
29
|
+
export interface ExplorerBox {
|
|
30
|
+
readonly x: number;
|
|
31
|
+
readonly y: number;
|
|
32
|
+
readonly width: number;
|
|
33
|
+
readonly height: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A view, placed. World space is y-down CSS pixels at zoom 1, and the content
|
|
38
|
+
* is padded 40 off the origin, so `width` and `height`
|
|
39
|
+
* describe a plane that starts at (0, 0).
|
|
40
|
+
*/
|
|
41
|
+
export interface ExplorerLayout {
|
|
42
|
+
/** One box per node, in the order the view lists its nodes. */
|
|
43
|
+
readonly boxes: ReadonlyMap<string, ExplorerBox>;
|
|
44
|
+
/**
|
|
45
|
+
* One route per edge, in the order the view lists its edges, running from
|
|
46
|
+
* the edge's source to its target. A self loop has an empty route.
|
|
47
|
+
*/
|
|
48
|
+
readonly routes: ReadonlyMap<string, readonly Vec2[]>;
|
|
49
|
+
/** One rectangle per group, in the order the view lists its groups. */
|
|
50
|
+
readonly groups: ReadonlyMap<string, ExplorerBox>;
|
|
51
|
+
readonly width: number;
|
|
52
|
+
readonly height: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface LayoutViewOptions {
|
|
56
|
+
/** Throw when a group's outline would enclose a node that is not a member. */
|
|
57
|
+
readonly strictGroups?: boolean | undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The key two edges share when they join the same two nodes, whichever way
|
|
62
|
+
* each one points. JSON rather than a joined string, because ids are the
|
|
63
|
+
* caller's and may contain any separator: joined with a comma, 'a,b' with 'c'
|
|
64
|
+
* and 'a' with 'b,c' are one pair.
|
|
65
|
+
*/
|
|
66
|
+
const pairKey = (a: string, b: string): string => JSON.stringify(a < b ? [a, b] : [b, a]);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Gives a two-point route a middle point, moved sideways across the flow, so
|
|
70
|
+
* two edges the router drew on one line can be told apart. Both ends stay
|
|
71
|
+
* where they are.
|
|
72
|
+
*/
|
|
73
|
+
function bow(points: readonly Vec2[], offset: number, right: boolean): Vec2[] {
|
|
74
|
+
const first = points[0];
|
|
75
|
+
const last = points[1];
|
|
76
|
+
if (first === undefined || last === undefined) return [...points];
|
|
77
|
+
const mid = { x: (first.x + last.x) / 2, y: (first.y + last.y) / 2 };
|
|
78
|
+
return [first, right ? { x: mid.x, y: mid.y + offset } : { x: mid.x + offset, y: mid.y }, last];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Places a view: a box per node, a route per edge, a rectangle per group.
|
|
83
|
+
*
|
|
84
|
+
* `@prnt/dagr-layout` lays out top-down and has no direction option, so
|
|
85
|
+
* flowing right is a transpose done here, once: sizes go in with width and
|
|
86
|
+
* height swapped, and every coordinate comes out with x and y swapped.
|
|
87
|
+
*
|
|
88
|
+
* Three things the layout engine does not do are done here:
|
|
89
|
+
*
|
|
90
|
+
* - **Parallel edges.** The router draws edges between one pair of nodes on
|
|
91
|
+
* the same line only when they span one rank. Those are given a middle point
|
|
92
|
+
* and moved apart, symmetrically about that line, so each can be seen. Both
|
|
93
|
+
* ends stay on their nodes. A pair that spans more ranks is left alone: the
|
|
94
|
+
* engine already routes each through its own dummy nodes.
|
|
95
|
+
* - **Self loops.** The router gives an edge from a node to itself a
|
|
96
|
+
* zero-length line. Such an edge is left out of layout and has an empty
|
|
97
|
+
* route. It stays in the data, and drawing a loop is a later slice.
|
|
98
|
+
* - **Groups.** A group is an annotation over the finished layout: the hull
|
|
99
|
+
* of its members, padded, with a band above for its label. It moves no node.
|
|
100
|
+
*
|
|
101
|
+
* @throws {ExplorerDataError} for a malformed view, and under `strictGroups`
|
|
102
|
+
* when a group's outline would enclose a node that is not a member.
|
|
103
|
+
*/
|
|
104
|
+
export function layoutView<N extends ExplorerNode, E extends ExplorerEdge>(
|
|
105
|
+
view: ExplorerView<N, E>,
|
|
106
|
+
options: LayoutViewOptions = {},
|
|
107
|
+
): ExplorerLayout {
|
|
108
|
+
validateView(view);
|
|
109
|
+
const right = (view.layout?.direction ?? 'right') === 'right';
|
|
110
|
+
|
|
111
|
+
const sizes = new Map<string, Size>();
|
|
112
|
+
const graph = new Graph();
|
|
113
|
+
for (const node of view.nodes) {
|
|
114
|
+
sizes.set(node.id, resolveNodeSize(view.layout, node));
|
|
115
|
+
graph.addNode({ id: node.id });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const siblings = new Map<string, string[]>();
|
|
119
|
+
for (const edge of view.edges) {
|
|
120
|
+
if (edge.source === edge.target) continue;
|
|
121
|
+
graph.addEdge({ id: edge.id, source: edge.source, target: edge.target });
|
|
122
|
+
const key = pairKey(edge.source, edge.target);
|
|
123
|
+
const list = siblings.get(key);
|
|
124
|
+
if (list === undefined) siblings.set(key, [edge.id]);
|
|
125
|
+
else list.push(edge.id);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Each edge's place among the edges that join its pair, looked up once here
|
|
129
|
+
// so the route loop below does not search a list per edge.
|
|
130
|
+
const siblingOf = new Map<string, { readonly index: number; readonly count: number }>();
|
|
131
|
+
for (const list of siblings.values()) {
|
|
132
|
+
list.forEach((id, index) => siblingOf.set(id, { index, count: list.length }));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const sizeOf = (id: string): Size => {
|
|
136
|
+
const size = sizes.get(id);
|
|
137
|
+
if (size === undefined) throw new Error(`layoutView lost the size of node "${id}"`);
|
|
138
|
+
return size;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const result = layout({
|
|
142
|
+
graph,
|
|
143
|
+
config: {
|
|
144
|
+
nodeSep: view.layout?.nodeSep ?? DEFAULT_NODE_SEP,
|
|
145
|
+
rankSep: view.layout?.rankSep ?? DEFAULT_RANK_SEP,
|
|
146
|
+
nodeSize: (node) => {
|
|
147
|
+
const size = sizeOf(node.id);
|
|
148
|
+
return right ? { width: size.height, height: size.width } : size;
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const toWorld = (p: Vec2): Vec2 => (right ? { x: p.y, y: p.x } : { x: p.x, y: p.y });
|
|
154
|
+
|
|
155
|
+
const boxes = new Map<string, ExplorerBox>();
|
|
156
|
+
for (const node of view.nodes) {
|
|
157
|
+
const placed = result.nodes.get(node.id);
|
|
158
|
+
if (placed === undefined) throw new Error(`layout returned no position for node "${node.id}"`);
|
|
159
|
+
const size = sizeOf(node.id);
|
|
160
|
+
const center = toWorld(placed);
|
|
161
|
+
boxes.set(node.id, {
|
|
162
|
+
x: center.x - size.width / 2,
|
|
163
|
+
y: center.y - size.height / 2,
|
|
164
|
+
width: size.width,
|
|
165
|
+
height: size.height,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const style = view.layout?.edgeStyle ?? 'smooth';
|
|
170
|
+
const routes = new Map<string, Vec2[]>();
|
|
171
|
+
for (const edge of view.edges) {
|
|
172
|
+
if (edge.source === edge.target) {
|
|
173
|
+
routes.set(edge.id, []);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const routed = result.edges.get(edge.id);
|
|
177
|
+
if (routed === undefined) throw new Error(`layout returned no route for edge "${edge.id}"`);
|
|
178
|
+
let points = routed.points.map(toWorld);
|
|
179
|
+
// Only a route of two points needs separating. That is a pair spanning one
|
|
180
|
+
// rank, which the router draws on one line. A longer route already has its
|
|
181
|
+
// own dummy nodes, a nodeSep from its siblings', and moving those would
|
|
182
|
+
// undo an ordering the engine chose. See `route.ts` in `@prnt/dagr-layout`.
|
|
183
|
+
const place = siblingOf.get(edge.id);
|
|
184
|
+
if (place !== undefined && points.length === 2) {
|
|
185
|
+
const offset = (place.index - (place.count - 1) / 2) * PARALLEL_EDGE_GAP;
|
|
186
|
+
if (offset !== 0) points = bow(points, offset, right);
|
|
187
|
+
}
|
|
188
|
+
routes.set(
|
|
189
|
+
edge.id,
|
|
190
|
+
shapeEdgePath(points, { style, direction: right ? 'horizontal' : 'vertical' }),
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const groups = new Map<string, ExplorerBox>();
|
|
195
|
+
for (const group of view.groups ?? []) {
|
|
196
|
+
const members = new Set(group.nodeIds);
|
|
197
|
+
let minX = Infinity;
|
|
198
|
+
let minY = Infinity;
|
|
199
|
+
let maxX = -Infinity;
|
|
200
|
+
let maxY = -Infinity;
|
|
201
|
+
for (const id of members) {
|
|
202
|
+
const member = boxes.get(id);
|
|
203
|
+
if (member === undefined) continue;
|
|
204
|
+
minX = Math.min(minX, member.x);
|
|
205
|
+
minY = Math.min(minY, member.y);
|
|
206
|
+
maxX = Math.max(maxX, member.x + member.width);
|
|
207
|
+
maxY = Math.max(maxY, member.y + member.height);
|
|
208
|
+
}
|
|
209
|
+
const rect: ExplorerBox = {
|
|
210
|
+
x: minX - GROUP_PADDING,
|
|
211
|
+
y: minY - GROUP_PADDING - GROUP_LABEL_BAND,
|
|
212
|
+
width: maxX - minX + GROUP_PADDING * 2,
|
|
213
|
+
height: maxY - minY + GROUP_PADDING * 2 + GROUP_LABEL_BAND,
|
|
214
|
+
};
|
|
215
|
+
if (options.strictGroups === true) {
|
|
216
|
+
for (const [id, other] of boxes) {
|
|
217
|
+
if (members.has(id)) continue;
|
|
218
|
+
const overlaps =
|
|
219
|
+
other.x < rect.x + rect.width &&
|
|
220
|
+
other.x + other.width > rect.x &&
|
|
221
|
+
other.y < rect.y + rect.height &&
|
|
222
|
+
other.y + other.height > rect.y;
|
|
223
|
+
if (overlaps) {
|
|
224
|
+
throw new ExplorerDataError(
|
|
225
|
+
'GROUP_ENCLOSES_NON_MEMBER',
|
|
226
|
+
`Group "${group.id}" in view "${view.id}" would enclose non-member node "${id}"`,
|
|
227
|
+
group.id,
|
|
228
|
+
view.id,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
groups.set(group.id, rect);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Everything above is in the layout engine's frame, which is centered on
|
|
237
|
+
// nothing in particular. Move it so the hull of every box, group and route
|
|
238
|
+
// point starts WORLD_PADDING in from the origin.
|
|
239
|
+
let minX = Infinity;
|
|
240
|
+
let minY = Infinity;
|
|
241
|
+
let maxX = -Infinity;
|
|
242
|
+
let maxY = -Infinity;
|
|
243
|
+
const grow = (x0: number, y0: number, x1: number, y1: number): void => {
|
|
244
|
+
minX = Math.min(minX, x0);
|
|
245
|
+
minY = Math.min(minY, y0);
|
|
246
|
+
maxX = Math.max(maxX, x1);
|
|
247
|
+
maxY = Math.max(maxY, y1);
|
|
248
|
+
};
|
|
249
|
+
for (const b of boxes.values()) grow(b.x, b.y, b.x + b.width, b.y + b.height);
|
|
250
|
+
for (const g of groups.values()) grow(g.x, g.y, g.x + g.width, g.y + g.height);
|
|
251
|
+
for (const points of routes.values()) for (const p of points) grow(p.x, p.y, p.x, p.y);
|
|
252
|
+
|
|
253
|
+
if (minX === Infinity) {
|
|
254
|
+
return { boxes: new Map(), routes: new Map(), groups: new Map(), width: 0, height: 0 };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const dx = WORLD_PADDING - minX;
|
|
258
|
+
const dy = WORLD_PADDING - minY;
|
|
259
|
+
return {
|
|
260
|
+
boxes: new Map([...boxes].map(([id, b]) => [id, { ...b, x: b.x + dx, y: b.y + dy }])),
|
|
261
|
+
routes: new Map(
|
|
262
|
+
[...routes].map(([id, points]) => [id, points.map((p) => ({ x: p.x + dx, y: p.y + dy }))]),
|
|
263
|
+
),
|
|
264
|
+
groups: new Map([...groups].map(([id, g]) => [id, { ...g, x: g.x + dx, y: g.y + dy }])),
|
|
265
|
+
width: maxX - minX + WORLD_PADDING * 2,
|
|
266
|
+
height: maxY - minY + WORLD_PADDING * 2,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* A string that is equal for two views exactly when they lay out the same.
|
|
272
|
+
*
|
|
273
|
+
* It holds what moves the layout and nothing else: direction, edge style and
|
|
274
|
+
* spacing, each node's id and resolved size, each edge's id and endpoints,
|
|
275
|
+
* each group's id and members. Labels, colors and descriptions are absent, so
|
|
276
|
+
* changing one never relayouts, and data re-created on every render with the
|
|
277
|
+
* same shape keeps its layout.
|
|
278
|
+
*
|
|
279
|
+
* JSON, for the reason `pairKey` gives: ids are the caller's.
|
|
280
|
+
*
|
|
281
|
+
* Internal: the root uses it to memoize, and the entry does not export it.
|
|
282
|
+
*/
|
|
283
|
+
export function layoutKey<N extends ExplorerNode, E extends ExplorerEdge>(
|
|
284
|
+
view: ExplorerView<N, E>,
|
|
285
|
+
): string {
|
|
286
|
+
return JSON.stringify([
|
|
287
|
+
view.layout?.direction ?? 'right',
|
|
288
|
+
view.layout?.edgeStyle ?? 'smooth',
|
|
289
|
+
view.layout?.nodeSep ?? DEFAULT_NODE_SEP,
|
|
290
|
+
view.layout?.rankSep ?? DEFAULT_RANK_SEP,
|
|
291
|
+
view.nodes.map((node) => {
|
|
292
|
+
const size = resolveNodeSize(view.layout, node);
|
|
293
|
+
return [node.id, size.width, size.height];
|
|
294
|
+
}),
|
|
295
|
+
view.edges.map((edge) => [edge.id, edge.source, edge.target]),
|
|
296
|
+
(view.groups ?? []).map((group) => [group.id, group.nodeIds]),
|
|
297
|
+
]);
|
|
298
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which node an arrow key moves focus to, as one pure function.
|
|
3
|
+
*
|
|
4
|
+
* Candidates are the nodes whose center lies strictly in the arrow's
|
|
5
|
+
* half-plane from the focused node's center. The score is the distance along
|
|
6
|
+
* the arrow's axis plus twice the distance across it, lowest wins, and a tie
|
|
7
|
+
* goes to the lower id. Weighing the cross axis keeps a press on a row or a
|
|
8
|
+
* column rather than letting it drift onto a nearer diagonal.
|
|
9
|
+
*
|
|
10
|
+
* It scans every node in the layout, mounted or not: an unmounted node is a
|
|
11
|
+
* mark with no element, and focus has to be able to reach it. Linear, like
|
|
12
|
+
* the visible set's scan, and for the same reason.
|
|
13
|
+
*
|
|
14
|
+
* Internal to the package.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { LayoutIndex } from './visible-set.js';
|
|
18
|
+
|
|
19
|
+
/** In screen terms, which are world terms: the world is y-down. */
|
|
20
|
+
export type ExplorerDirection = 'up' | 'down' | 'left' | 'right';
|
|
21
|
+
|
|
22
|
+
export function nearestInDirection(
|
|
23
|
+
index: LayoutIndex,
|
|
24
|
+
fromId: string,
|
|
25
|
+
direction: ExplorerDirection,
|
|
26
|
+
): string | null {
|
|
27
|
+
const from = index.nodeIds.indexOf(fromId);
|
|
28
|
+
const origin = index.nodeBoxes[from];
|
|
29
|
+
if (from < 0 || origin === undefined) return null;
|
|
30
|
+
const fromX = origin.x + origin.width / 2;
|
|
31
|
+
const fromY = origin.y + origin.height / 2;
|
|
32
|
+
const horizontal = direction === 'left' || direction === 'right';
|
|
33
|
+
const sign = direction === 'right' || direction === 'down' ? 1 : -1;
|
|
34
|
+
|
|
35
|
+
let best: string | null = null;
|
|
36
|
+
let bestScore = Infinity;
|
|
37
|
+
index.nodeBoxes.forEach((box, i) => {
|
|
38
|
+
if (i === from) return;
|
|
39
|
+
const dx = box.x + box.width / 2 - fromX;
|
|
40
|
+
const dy = box.y + box.height / 2 - fromY;
|
|
41
|
+
const along = (horizontal ? dx : dy) * sign;
|
|
42
|
+
if (!(along > 0)) return;
|
|
43
|
+
const score = along + 2 * Math.abs(horizontal ? dy : dx);
|
|
44
|
+
const id = index.nodeIds[i] as string;
|
|
45
|
+
if (score < bestScore || (score === bestScore && best !== null && id < best)) {
|
|
46
|
+
best = id;
|
|
47
|
+
bestScore = score;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
return best;
|
|
51
|
+
}
|