@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
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The explorer's viewport: a pannable, zoomable surface with a base layer
|
|
3
|
+
* and a windowed DOM overlay, driven by props.
|
|
4
|
+
*
|
|
5
|
+
* Internal. M5.6d wraps it with the root's context as the public
|
|
6
|
+
* `ExplorerViewport` part, which is why everything here arrives as a prop.
|
|
7
|
+
*
|
|
8
|
+
* **React renders only when the visible set changes, or a prop does.**
|
|
9
|
+
* `useExplorerCamera` writes the plane's transform on each frame and calls
|
|
10
|
+
* back here. The callback computes the visible set and keeps the previous
|
|
11
|
+
* one, by reference, when `sameVisibleSet` says nothing changed, so a pan
|
|
12
|
+
* inside the overscan margin is a style write and no React work. A node
|
|
13
|
+
* button is placed in world coordinates once, when it mounts, and the plane
|
|
14
|
+
* carries it.
|
|
15
|
+
*
|
|
16
|
+
* **Most frames skip the scan.** The set was computed over the view plus a
|
|
17
|
+
* margin on every side, so while the view stays inside half that margin, at
|
|
18
|
+
* the same scale, every node it can show is already in the set. A frame
|
|
19
|
+
* like that does not call `computeVisibleSet` at all.
|
|
20
|
+
*
|
|
21
|
+
* **One listener per gesture, on the viewport.** No node has a listener of
|
|
22
|
+
* its own. A click on a button resolves to that node, a click anywhere else
|
|
23
|
+
* resolves through the layout, so a base mark (which has no element the
|
|
24
|
+
* overlay owns) is clickable at every tier.
|
|
25
|
+
*
|
|
26
|
+
* The plane is a composited layer only while the camera moves, which the
|
|
27
|
+
* camera hook decides, and never through `translateZ`. A cached raster of
|
|
28
|
+
* text enlarged by the camera goes blurry, so at rest there is none.
|
|
29
|
+
*
|
|
30
|
+
* **The graph is one tab stop.** Exactly one node button has `tabIndex` 0:
|
|
31
|
+
* the selected node, else the last node focused from the keyboard, else the
|
|
32
|
+
* node nearest the viewport center as of the last scan, else (before there
|
|
33
|
+
* is a camera) the first node. It is pinned, so it is always mounted, and so
|
|
34
|
+
* is the node that has focus and the node an arrow is moving focus to. An
|
|
35
|
+
* arrow on a focused node moves focus to `nearestInDirection`: the target is
|
|
36
|
+
* pinned, mounted, focused, and only then does the old node lose its pin, so
|
|
37
|
+
* focus never falls to the page in between. A node focused from the keyboard
|
|
38
|
+
* is revealed by the least pan; one focused by a pointer moves nothing.
|
|
39
|
+
*
|
|
40
|
+
* **Nothing reads the DOM in render,** so the surface renders on a server:
|
|
41
|
+
* the base draws every mark, the plane is hidden, and no node has a button.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
45
|
+
import type { CSSProperties, MutableRefObject, ReactElement, ReactNode } from 'react';
|
|
46
|
+
import type { ExplorerBase, ExplorerCameraSource, ExplorerEmphasis } from './base.js';
|
|
47
|
+
import { visibleWorld } from './camera.js';
|
|
48
|
+
import type { ExplorerCamera, ExplorerViewportSize } from './camera.js';
|
|
49
|
+
import type { ExplorerBox, ExplorerLayout } from './layout.js';
|
|
50
|
+
import { nearestInDirection } from './navigation.js';
|
|
51
|
+
import type { ExplorerDirection } from './navigation.js';
|
|
52
|
+
import { useIsomorphicLayoutEffect } from './isomorphic-layout-effect.js';
|
|
53
|
+
import { svgBase } from './svg-base.js';
|
|
54
|
+
import type { ExplorerEdge, ExplorerGroup, ExplorerNode, ExplorerView } from './types.js';
|
|
55
|
+
import { useExplorerCamera } from './use-explorer-camera.js';
|
|
56
|
+
import type { ExplorerCameraControls } from './use-explorer-camera.js';
|
|
57
|
+
import {
|
|
58
|
+
DEFAULT_MAX_OVERLAY_NODES,
|
|
59
|
+
DEFAULT_TIERS,
|
|
60
|
+
OVERSCAN,
|
|
61
|
+
computeVisibleSet,
|
|
62
|
+
indexLayout,
|
|
63
|
+
nearestToCenter,
|
|
64
|
+
nodeAtPoint,
|
|
65
|
+
sameVisibleSet,
|
|
66
|
+
} from './visible-set.js';
|
|
67
|
+
import type {
|
|
68
|
+
ExplorerTier,
|
|
69
|
+
ExplorerTiers,
|
|
70
|
+
ExplorerVisibleSet,
|
|
71
|
+
LayoutIndex,
|
|
72
|
+
VisibleSetOptions,
|
|
73
|
+
} from './visible-set.js';
|
|
74
|
+
|
|
75
|
+
export interface ViewportSurfaceProps<N extends ExplorerNode, E extends ExplorerEdge> {
|
|
76
|
+
/** Accessible name of the region. */
|
|
77
|
+
readonly label: string;
|
|
78
|
+
readonly view: ExplorerView<N, E>;
|
|
79
|
+
readonly layout: ExplorerLayout;
|
|
80
|
+
readonly selectedId?: string | null | undefined;
|
|
81
|
+
readonly dimmed?: ReadonlySet<string> | undefined;
|
|
82
|
+
/** Always mounted. `selectedId` is pinned as well. */
|
|
83
|
+
readonly pinned?: readonly string[] | undefined;
|
|
84
|
+
readonly tiers?: ExplorerTiers | undefined;
|
|
85
|
+
readonly maxOverlayNodes?: number | undefined;
|
|
86
|
+
/** Default: the SVG base. */
|
|
87
|
+
readonly base?: ExplorerBase | undefined;
|
|
88
|
+
readonly renderNode?:
|
|
89
|
+
| ((node: N, context: { tier: ExplorerTier; selected: boolean; dimmed: boolean }) => ReactNode)
|
|
90
|
+
| undefined;
|
|
91
|
+
readonly nodeAriaLabel?:
|
|
92
|
+
| ((node: N, context: { groups: readonly ExplorerGroup[] }) => string)
|
|
93
|
+
| undefined;
|
|
94
|
+
/** One group in the default accessible name. Default: `in <group>`. */
|
|
95
|
+
readonly inGroup?: ((groupLabel: string) => string) | undefined;
|
|
96
|
+
/** Click, Enter, Space. `trigger` is the node's button, or `null` for a mark. */
|
|
97
|
+
readonly onNodeActivate?: ((id: string, trigger: HTMLElement | null) => void) | undefined;
|
|
98
|
+
/** Double click. */
|
|
99
|
+
readonly onNodeZoom?: ((id: string) => void) | undefined;
|
|
100
|
+
readonly controlsRef?: MutableRefObject<ExplorerCameraControls | null> | undefined;
|
|
101
|
+
/**
|
|
102
|
+
* The camera on screen and every drawn frame, the same source the base
|
|
103
|
+
* gets, for a part outside the viewport that follows the camera without
|
|
104
|
+
* re-rendering (the toolbar's zoom readout).
|
|
105
|
+
*/
|
|
106
|
+
readonly cameraSourceRef?: MutableRefObject<ExplorerCameraSource | null> | undefined;
|
|
107
|
+
/** The id of the element that describes the region. */
|
|
108
|
+
readonly describedBy?: string | undefined;
|
|
109
|
+
readonly className?: string | undefined;
|
|
110
|
+
/** Sizing and decoration pass through. `position` and `overflow` stay the viewport's own, because the graph needs them. */
|
|
111
|
+
readonly style?: CSSProperties | undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const NODE = '[data-dagr-explorer="node"]';
|
|
115
|
+
const NO_DIMMED: ReadonlySet<string> = new Set();
|
|
116
|
+
const NO_GROUPS: readonly ExplorerGroup[] = [];
|
|
117
|
+
const ARROWS: Readonly<Record<string, ExplorerDirection>> = {
|
|
118
|
+
ArrowUp: 'up',
|
|
119
|
+
ArrowDown: 'down',
|
|
120
|
+
ArrowLeft: 'left',
|
|
121
|
+
ArrowRight: 'right',
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const usable = (value: number | undefined): value is number =>
|
|
125
|
+
value !== undefined && Number.isFinite(value) && value >= 0;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A pair of gates with each bad or missing one replaced by its default. Per
|
|
129
|
+
* gate rather than all or nothing, so one mistyped number does not discard
|
|
130
|
+
* the other.
|
|
131
|
+
*/
|
|
132
|
+
function tiersOf(summary: number | undefined, rich: number | undefined): ExplorerTiers {
|
|
133
|
+
return {
|
|
134
|
+
summary: usable(summary) ? summary : DEFAULT_TIERS.summary,
|
|
135
|
+
rich: usable(rich) ? rich : DEFAULT_TIERS.rich,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* What the base draws before there is a camera: every node as a mark and
|
|
141
|
+
* every routed edge, and no overlay. It is what a server can render, and on
|
|
142
|
+
* the client it is replaced on the first frame. The plane is hidden until
|
|
143
|
+
* then, so it is never seen unscaled. Once there is a camera, a new layout's
|
|
144
|
+
* first render is windowed from it instead, so a swap to a large graph never
|
|
145
|
+
* commits every node.
|
|
146
|
+
*/
|
|
147
|
+
function everythingAsMarks(index: LayoutIndex): ExplorerVisibleSet {
|
|
148
|
+
return {
|
|
149
|
+
overlay: new Map(),
|
|
150
|
+
baseNodes: index.nodeIds,
|
|
151
|
+
edges: index.edgeIds.filter((_, i) => index.edgeBounds[i] !== null),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const IN_GROUP = (groupLabel: string): string => `in ${groupLabel}`;
|
|
156
|
+
|
|
157
|
+
function defaultName(
|
|
158
|
+
node: ExplorerNode,
|
|
159
|
+
groups: readonly ExplorerGroup[],
|
|
160
|
+
inGroup: (groupLabel: string) => string,
|
|
161
|
+
): string {
|
|
162
|
+
return [node.label, ...groups.map((group) => inGroup(group.label))].join(', ');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* What the last scan covered: the options and index it ran with, its scale,
|
|
167
|
+
* and the world rect a later view may move within and need no new scan.
|
|
168
|
+
*/
|
|
169
|
+
interface Scan {
|
|
170
|
+
readonly index: LayoutIndex;
|
|
171
|
+
readonly options: VisibleSetOptions;
|
|
172
|
+
readonly scale: number;
|
|
173
|
+
readonly within: ExplorerBox;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The world rect a view may move within without a scan: the view at scan
|
|
178
|
+
* time, grown by half the overscan on each side. The scan itself covered the
|
|
179
|
+
* full overscan, so a node in the actual view is a node the scan saw.
|
|
180
|
+
*/
|
|
181
|
+
function slack(world: ExplorerBox): ExplorerBox {
|
|
182
|
+
const margin = OVERSCAN / 2;
|
|
183
|
+
return {
|
|
184
|
+
x: world.x - world.width * margin,
|
|
185
|
+
y: world.y - world.height * margin,
|
|
186
|
+
width: world.width * (1 + margin * 2),
|
|
187
|
+
height: world.height * (1 + margin * 2),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const inside = (inner: ExplorerBox, outer: ExplorerBox): boolean =>
|
|
192
|
+
inner.x >= outer.x &&
|
|
193
|
+
inner.y >= outer.y &&
|
|
194
|
+
inner.x + inner.width <= outer.x + outer.width &&
|
|
195
|
+
inner.y + inner.height <= outer.y + outer.height;
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* A visible set, and the index it was computed over, so a stale one is never
|
|
199
|
+
* drawn. `center` is the node nearest the viewport center at the same scan,
|
|
200
|
+
* or `null` before there is a camera.
|
|
201
|
+
*/
|
|
202
|
+
interface Shown {
|
|
203
|
+
readonly index: LayoutIndex;
|
|
204
|
+
readonly set: ExplorerVisibleSet;
|
|
205
|
+
readonly center: string | null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The node button for `id` inside `viewport`, if it is mounted. */
|
|
209
|
+
function nodeButton(viewport: HTMLElement, id: string): HTMLElement | null {
|
|
210
|
+
for (const element of viewport.querySelectorAll(NODE)) {
|
|
211
|
+
if (element instanceof HTMLElement && element.dataset['nodeId'] === id) return element;
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function ViewportSurface<N extends ExplorerNode, E extends ExplorerEdge>(
|
|
217
|
+
props: ViewportSurfaceProps<N, E>,
|
|
218
|
+
): ReactElement {
|
|
219
|
+
const {
|
|
220
|
+
label,
|
|
221
|
+
view,
|
|
222
|
+
layout,
|
|
223
|
+
selectedId = null,
|
|
224
|
+
dimmed = NO_DIMMED,
|
|
225
|
+
pinned,
|
|
226
|
+
tiers,
|
|
227
|
+
maxOverlayNodes,
|
|
228
|
+
base = svgBase,
|
|
229
|
+
renderNode,
|
|
230
|
+
nodeAriaLabel,
|
|
231
|
+
inGroup = IN_GROUP,
|
|
232
|
+
onNodeActivate,
|
|
233
|
+
onNodeZoom,
|
|
234
|
+
controlsRef,
|
|
235
|
+
cameraSourceRef,
|
|
236
|
+
describedBy,
|
|
237
|
+
className,
|
|
238
|
+
style,
|
|
239
|
+
} = props;
|
|
240
|
+
|
|
241
|
+
const viewportRef = useRef<HTMLDivElement>(null);
|
|
242
|
+
const planeRef = useRef<HTMLDivElement>(null);
|
|
243
|
+
const index = useMemo(() => indexLayout(layout), [layout]);
|
|
244
|
+
|
|
245
|
+
const [shown, setShown] = useState<Shown>(() => ({ index, set: everythingAsMarks(index), center: null }));
|
|
246
|
+
const shownRef = useRef(shown);
|
|
247
|
+
const lastViewportRef = useRef<ExplorerViewportSize | null>(null);
|
|
248
|
+
// The last drawn camera, read where the controls do not exist yet: the
|
|
249
|
+
// pins decide the options, which `onFrame` and so the camera depend on.
|
|
250
|
+
const lastCameraRef = useRef<ExplorerCamera | null>(null);
|
|
251
|
+
const scanRef = useRef<Scan | null>(null);
|
|
252
|
+
const [listeners] = useState(() => new Set<(camera: ExplorerCamera) => void>());
|
|
253
|
+
|
|
254
|
+
// Focus. `focusedId` has focus now, by any means. `keyedId` last had focus
|
|
255
|
+
// from the keyboard, and is the tab target's fallback. `movingTo` is where
|
|
256
|
+
// an arrow is moving focus, until its button mounts and takes it.
|
|
257
|
+
const [focusedId, setFocusedId] = useState<string | null>(null);
|
|
258
|
+
const [keyedId, setKeyedId] = useState<string | null>(null);
|
|
259
|
+
const [movingTo, setMovingTo] = useState<string | null>(null);
|
|
260
|
+
const movingRef = useRef<string | null>(null);
|
|
261
|
+
|
|
262
|
+
// A layout the camera has not drawn yet. Its set comes from the camera on
|
|
263
|
+
// screen, and the camera's own effect replaces it once it has placed the
|
|
264
|
+
// new layout.
|
|
265
|
+
const pending = shown.index !== index;
|
|
266
|
+
const pendingCenter = useMemo(() => {
|
|
267
|
+
if (!pending) return null;
|
|
268
|
+
const camera = lastCameraRef.current;
|
|
269
|
+
const viewport = lastViewportRef.current;
|
|
270
|
+
return camera === null || viewport === null ? null : nearestToCenter(index, camera, viewport);
|
|
271
|
+
}, [pending, index]);
|
|
272
|
+
const center = pending ? pendingCenter : shown.center;
|
|
273
|
+
|
|
274
|
+
const has = (id: string | null): id is string => id !== null && layout.boxes.has(id);
|
|
275
|
+
const tabTarget = has(selectedId)
|
|
276
|
+
? selectedId
|
|
277
|
+
: has(keyedId)
|
|
278
|
+
? keyedId
|
|
279
|
+
: (center ?? index.nodeIds[0] ?? null);
|
|
280
|
+
|
|
281
|
+
// The options, keyed by value, so a caller that re-creates `pinned` or
|
|
282
|
+
// `tiers` on every render does not recompute the set on every render.
|
|
283
|
+
const summary = tiers?.summary;
|
|
284
|
+
const rich = tiers?.rich;
|
|
285
|
+
const cap = usable(maxOverlayNodes) ? Math.floor(maxOverlayNodes) : DEFAULT_MAX_OVERLAY_NODES;
|
|
286
|
+
const pins = [...(pinned ?? [])];
|
|
287
|
+
for (const id of [selectedId, tabTarget, focusedId, movingTo]) {
|
|
288
|
+
if (has(id) && !pins.includes(id)) pins.push(id);
|
|
289
|
+
}
|
|
290
|
+
const pinKey = JSON.stringify(pins);
|
|
291
|
+
const options = useMemo<VisibleSetOptions>(
|
|
292
|
+
() => ({
|
|
293
|
+
tiers: tiersOf(summary, rich),
|
|
294
|
+
maxOverlayNodes: cap,
|
|
295
|
+
pinned: JSON.parse(pinKey) as string[],
|
|
296
|
+
}),
|
|
297
|
+
[summary, rich, cap, pinKey],
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Brings the visible set up to date with a camera, scanning only when it
|
|
302
|
+
* can have changed. The node nearest the center is found at the same scan,
|
|
303
|
+
* so between scans it can trail the camera by up to half the overscan.
|
|
304
|
+
*/
|
|
305
|
+
const refresh = useCallback(
|
|
306
|
+
(camera: ExplorerCamera, viewport: ExplorerViewportSize) => {
|
|
307
|
+
const world = visibleWorld(camera, viewport);
|
|
308
|
+
const scan = scanRef.current;
|
|
309
|
+
if (
|
|
310
|
+
scan !== null &&
|
|
311
|
+
scan.index === index &&
|
|
312
|
+
scan.options === options &&
|
|
313
|
+
scan.scale === camera.scale &&
|
|
314
|
+
inside(world, scan.within)
|
|
315
|
+
) {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const next = computeVisibleSet(index, camera, viewport, options);
|
|
319
|
+
scanRef.current = { index, options, scale: camera.scale, within: slack(world) };
|
|
320
|
+
const center = nearestToCenter(index, camera, viewport);
|
|
321
|
+
const previous = shownRef.current;
|
|
322
|
+
if (previous.index === index && previous.center === center && sameVisibleSet(previous.set, next)) return;
|
|
323
|
+
const value = { index, set: next, center };
|
|
324
|
+
shownRef.current = value;
|
|
325
|
+
setShown(value);
|
|
326
|
+
},
|
|
327
|
+
[index, options],
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
const onFrame = useCallback(
|
|
331
|
+
(camera: ExplorerCamera, viewport: ExplorerViewportSize) => {
|
|
332
|
+
lastViewportRef.current = viewport;
|
|
333
|
+
lastCameraRef.current = camera;
|
|
334
|
+
// First, so a base that draws its own camera moves in step with the plane.
|
|
335
|
+
for (const listener of [...listeners]) listener(camera);
|
|
336
|
+
refresh(camera, viewport);
|
|
337
|
+
},
|
|
338
|
+
[refresh, listeners],
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const controls = useExplorerCamera({ viewportRef, planeRef, layout, onFrame });
|
|
342
|
+
const cameraSource = useMemo<ExplorerCameraSource>(
|
|
343
|
+
() => ({
|
|
344
|
+
get: () => controls.getCamera(),
|
|
345
|
+
subscribe(listener) {
|
|
346
|
+
// Wrapped, so one listener subscribed twice is two subscriptions,
|
|
347
|
+
// each ended by its own unsubscribe.
|
|
348
|
+
const own = (camera: ExplorerCamera): void => listener(camera);
|
|
349
|
+
listeners.add(own);
|
|
350
|
+
return () => {
|
|
351
|
+
listeners.delete(own);
|
|
352
|
+
};
|
|
353
|
+
},
|
|
354
|
+
}),
|
|
355
|
+
[controls, listeners],
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
// A prop that changes the set without moving the camera: pins, tiers, the
|
|
359
|
+
// cap. The camera's own effects have run by now, so a new layout has
|
|
360
|
+
// already been placed and drawn through the new `onFrame`, and this finds
|
|
361
|
+
// nothing to change. No frame was drawn, so the base's listeners hear
|
|
362
|
+
// nothing.
|
|
363
|
+
useEffect(() => {
|
|
364
|
+
const camera = controls.getCamera();
|
|
365
|
+
const viewport = lastViewportRef.current;
|
|
366
|
+
if (camera !== null && viewport !== null) refresh(camera, viewport);
|
|
367
|
+
}, [controls, refresh]);
|
|
368
|
+
|
|
369
|
+
useEffect(() => {
|
|
370
|
+
if (controlsRef === undefined) return undefined;
|
|
371
|
+
controlsRef.current = controls;
|
|
372
|
+
return () => {
|
|
373
|
+
if (controlsRef.current === controls) controlsRef.current = null;
|
|
374
|
+
};
|
|
375
|
+
}, [controls, controlsRef]);
|
|
376
|
+
|
|
377
|
+
useEffect(() => {
|
|
378
|
+
if (cameraSourceRef === undefined) return undefined;
|
|
379
|
+
cameraSourceRef.current = cameraSource;
|
|
380
|
+
return () => {
|
|
381
|
+
if (cameraSourceRef.current === cameraSource) cameraSourceRef.current = null;
|
|
382
|
+
};
|
|
383
|
+
}, [cameraSource, cameraSourceRef]);
|
|
384
|
+
|
|
385
|
+
// The click handlers read the latest props through a ref, so the listeners
|
|
386
|
+
// are attached once per viewport and not on every render.
|
|
387
|
+
const latest = useRef({ index, onNodeActivate, onNodeZoom });
|
|
388
|
+
useEffect(() => {
|
|
389
|
+
latest.current = { index, onNodeActivate, onNodeZoom };
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
useEffect(() => {
|
|
393
|
+
const viewport = viewportRef.current;
|
|
394
|
+
if (viewport === null) return undefined;
|
|
395
|
+
const resolve = (event: MouseEvent): { id: string; trigger: HTMLElement | null } | null => {
|
|
396
|
+
if (event.target instanceof Element) {
|
|
397
|
+
const node = event.target.closest(NODE);
|
|
398
|
+
const id = node instanceof HTMLElement ? node.dataset['nodeId'] : undefined;
|
|
399
|
+
if (node instanceof HTMLElement && id !== undefined && viewport.contains(node)) {
|
|
400
|
+
return { id, trigger: node };
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
const box = viewport.getBoundingClientRect();
|
|
404
|
+
const world = controls.screenToWorld({
|
|
405
|
+
x: event.clientX - box.left - viewport.clientLeft,
|
|
406
|
+
y: event.clientY - box.top - viewport.clientTop,
|
|
407
|
+
});
|
|
408
|
+
if (world === null) return null;
|
|
409
|
+
const id = nodeAtPoint(latest.current.index, world);
|
|
410
|
+
return id === null ? null : { id, trigger: null };
|
|
411
|
+
};
|
|
412
|
+
const onClick = (event: MouseEvent): void => {
|
|
413
|
+
const hit = resolve(event);
|
|
414
|
+
if (hit === null) {
|
|
415
|
+
if (!viewport.contains(document.activeElement)) viewport.focus({ preventScroll: true });
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
latest.current.onNodeActivate?.(hit.id, hit.trigger);
|
|
419
|
+
};
|
|
420
|
+
const onDoubleClick = (event: MouseEvent): void => {
|
|
421
|
+
const hit = resolve(event);
|
|
422
|
+
if (hit !== null) latest.current.onNodeZoom?.(hit.id);
|
|
423
|
+
};
|
|
424
|
+
viewport.addEventListener('click', onClick);
|
|
425
|
+
viewport.addEventListener('dblclick', onDoubleClick);
|
|
426
|
+
return () => {
|
|
427
|
+
viewport.removeEventListener('click', onClick);
|
|
428
|
+
viewport.removeEventListener('dblclick', onDoubleClick);
|
|
429
|
+
};
|
|
430
|
+
}, [controls]);
|
|
431
|
+
|
|
432
|
+
// Keys and focus. Whether a focus came from the keyboard is whether the
|
|
433
|
+
// last input anywhere on the page was a key, heard in the capture phase on
|
|
434
|
+
// the document, so a Tab pressed outside the graph counts.
|
|
435
|
+
useEffect(() => {
|
|
436
|
+
const viewport = viewportRef.current;
|
|
437
|
+
if (viewport === null) return undefined;
|
|
438
|
+
const doc = viewport.ownerDocument;
|
|
439
|
+
let keyed = false;
|
|
440
|
+
const onAnyKey = (): void => {
|
|
441
|
+
keyed = true;
|
|
442
|
+
};
|
|
443
|
+
const onAnyPointer = (): void => {
|
|
444
|
+
keyed = false;
|
|
445
|
+
};
|
|
446
|
+
const nodeOf = (target: EventTarget | null): { id: string; element: HTMLElement } | null => {
|
|
447
|
+
if (!(target instanceof Element)) return null;
|
|
448
|
+
const element = target.closest(NODE);
|
|
449
|
+
const id = element instanceof HTMLElement ? element.dataset['nodeId'] : undefined;
|
|
450
|
+
return element instanceof HTMLElement && id !== undefined && viewport.contains(element) ? { id, element } : null;
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
// Set only on a change, so focus moving about the graph costs no render
|
|
454
|
+
// when the pins it decides are the same.
|
|
455
|
+
let focused: string | null = null;
|
|
456
|
+
let lastKeyed: string | null = null;
|
|
457
|
+
const focus = (id: string | null): void => {
|
|
458
|
+
if (id === focused) return;
|
|
459
|
+
focused = id;
|
|
460
|
+
setFocusedId(id);
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
const onFocusIn = (event: FocusEvent): void => {
|
|
464
|
+
const hit = nodeOf(event.target);
|
|
465
|
+
focus(hit === null ? null : hit.id);
|
|
466
|
+
if (hit === null || !keyed) return;
|
|
467
|
+
if (hit.id !== lastKeyed) {
|
|
468
|
+
lastKeyed = hit.id;
|
|
469
|
+
setKeyedId(hit.id);
|
|
470
|
+
}
|
|
471
|
+
const { index: current } = latest.current;
|
|
472
|
+
const box = current.nodeBoxes[current.nodeIds.indexOf(hit.id)];
|
|
473
|
+
if (box !== undefined) controls.revealBox(box);
|
|
474
|
+
};
|
|
475
|
+
const onFocusOut = (event: FocusEvent): void => {
|
|
476
|
+
if (nodeOf(event.relatedTarget) === null) focus(null);
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
const onKeyDown = (event: KeyboardEvent): void => {
|
|
480
|
+
// Modified keys are the browser's, and a key the camera took is taken.
|
|
481
|
+
if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey) return;
|
|
482
|
+
const hit = nodeOf(event.target);
|
|
483
|
+
// Keys typed into content a host renders inside a node are the host's.
|
|
484
|
+
if (hit === null || event.target !== hit.element) return;
|
|
485
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
486
|
+
// Handled here rather than left to the button's own click, so a key
|
|
487
|
+
// inspects exactly once and a held key inspects once.
|
|
488
|
+
event.preventDefault();
|
|
489
|
+
if (!event.repeat) latest.current.onNodeActivate?.(hit.id, hit.element);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const direction = ARROWS[event.key];
|
|
493
|
+
// Shift with an arrow pans, which the camera does.
|
|
494
|
+
if (direction === undefined || event.shiftKey) return;
|
|
495
|
+
event.preventDefault();
|
|
496
|
+
const next = nearestInDirection(latest.current.index, hit.id, direction);
|
|
497
|
+
if (next === null) return;
|
|
498
|
+
movingRef.current = next;
|
|
499
|
+
setMovingTo(next);
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
// A button fires its Space click on keyup, and Firefox has not always
|
|
503
|
+
// cancelled that click when only keydown was prevented. Keydown has
|
|
504
|
+
// already activated, so the keyup is prevented too.
|
|
505
|
+
const onKeyUp = (event: KeyboardEvent): void => {
|
|
506
|
+
if (event.key !== ' ') return;
|
|
507
|
+
const hit = nodeOf(event.target);
|
|
508
|
+
if (hit !== null && event.target === hit.element) event.preventDefault();
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
// The viewport clips and never scrolls. A browser scrolls it anyway to
|
|
512
|
+
// show a node that takes focus from Tab, which would offset everything
|
|
513
|
+
// from the camera, so any scroll is put back.
|
|
514
|
+
const onScroll = (): void => {
|
|
515
|
+
if (viewport.scrollTop !== 0) viewport.scrollTop = 0;
|
|
516
|
+
if (viewport.scrollLeft !== 0) viewport.scrollLeft = 0;
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
doc.addEventListener('keydown', onAnyKey, true);
|
|
520
|
+
doc.addEventListener('pointerdown', onAnyPointer, true);
|
|
521
|
+
doc.addEventListener('mousedown', onAnyPointer, true);
|
|
522
|
+
viewport.addEventListener('focusin', onFocusIn);
|
|
523
|
+
viewport.addEventListener('focusout', onFocusOut);
|
|
524
|
+
viewport.addEventListener('keydown', onKeyDown);
|
|
525
|
+
viewport.addEventListener('keyup', onKeyUp);
|
|
526
|
+
viewport.addEventListener('scroll', onScroll);
|
|
527
|
+
return () => {
|
|
528
|
+
doc.removeEventListener('keydown', onAnyKey, true);
|
|
529
|
+
doc.removeEventListener('pointerdown', onAnyPointer, true);
|
|
530
|
+
doc.removeEventListener('mousedown', onAnyPointer, true);
|
|
531
|
+
viewport.removeEventListener('focusin', onFocusIn);
|
|
532
|
+
viewport.removeEventListener('focusout', onFocusOut);
|
|
533
|
+
viewport.removeEventListener('keydown', onKeyDown);
|
|
534
|
+
viewport.removeEventListener('keyup', onKeyUp);
|
|
535
|
+
viewport.removeEventListener('scroll', onScroll);
|
|
536
|
+
};
|
|
537
|
+
}, [controls]);
|
|
538
|
+
|
|
539
|
+
// An arrow's target takes focus in the first commit that mounts it. The
|
|
540
|
+
// node it leaves stays pinned, because it still has focus, until then.
|
|
541
|
+
useIsomorphicLayoutEffect(() => {
|
|
542
|
+
const want = movingRef.current;
|
|
543
|
+
const viewport = viewportRef.current;
|
|
544
|
+
if (want === null || viewport === null) return;
|
|
545
|
+
if (!layout.boxes.has(want) || !viewport.contains(viewport.ownerDocument.activeElement)) {
|
|
546
|
+
// Gone from the data, or focus left the graph while it mounted.
|
|
547
|
+
movingRef.current = null;
|
|
548
|
+
setMovingTo(null);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
const element = nodeButton(viewport, want);
|
|
552
|
+
if (element === null) return;
|
|
553
|
+
movingRef.current = null;
|
|
554
|
+
element.focus({ preventScroll: true });
|
|
555
|
+
setMovingTo(null);
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
const first = useMemo(() => {
|
|
559
|
+
if (!pending) return null;
|
|
560
|
+
const camera = controls.getCamera();
|
|
561
|
+
const viewport = lastViewportRef.current;
|
|
562
|
+
return camera === null || viewport === null
|
|
563
|
+
? everythingAsMarks(index)
|
|
564
|
+
: computeVisibleSet(index, camera, viewport, options);
|
|
565
|
+
}, [pending, controls, index, options]);
|
|
566
|
+
const visible = first ?? shown.set;
|
|
567
|
+
const emphasis = useMemo<ExplorerEmphasis>(() => ({ selectedId, dimmed }), [selectedId, dimmed]);
|
|
568
|
+
const nodes = useMemo(() => new Map(view.nodes.map((node) => [node.id, node])), [view.nodes]);
|
|
569
|
+
const groupsOf = useMemo(() => {
|
|
570
|
+
const map = new Map<string, ExplorerGroup[]>();
|
|
571
|
+
for (const group of view.groups ?? []) {
|
|
572
|
+
for (const id of new Set(group.nodeIds)) {
|
|
573
|
+
const list = map.get(id);
|
|
574
|
+
if (list === undefined) map.set(id, [group]);
|
|
575
|
+
else list.push(group);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return map;
|
|
579
|
+
}, [view.groups]);
|
|
580
|
+
|
|
581
|
+
const overlay: ReactElement[] = [];
|
|
582
|
+
for (const [id, tier] of visible.overlay) {
|
|
583
|
+
const node = nodes.get(id);
|
|
584
|
+
const box = layout.boxes.get(id);
|
|
585
|
+
if (node === undefined || box === undefined) continue;
|
|
586
|
+
const selected = id === selectedId;
|
|
587
|
+
const isDimmed = dimmed.has(id);
|
|
588
|
+
const groups = groupsOf.get(id) ?? NO_GROUPS;
|
|
589
|
+
overlay.push(
|
|
590
|
+
<button
|
|
591
|
+
key={id}
|
|
592
|
+
type="button"
|
|
593
|
+
tabIndex={id === tabTarget ? 0 : -1}
|
|
594
|
+
data-dagr-explorer="node"
|
|
595
|
+
data-node-id={id}
|
|
596
|
+
data-tier={tier}
|
|
597
|
+
data-selected={selected ? 'true' : undefined}
|
|
598
|
+
data-dimmed={isDimmed ? 'true' : undefined}
|
|
599
|
+
aria-pressed={selected}
|
|
600
|
+
aria-label={nodeAriaLabel === undefined ? defaultName(node, groups, inGroup) : nodeAriaLabel(node, { groups })}
|
|
601
|
+
style={{ position: 'absolute', left: box.x, top: box.y, width: box.width, height: box.height }}
|
|
602
|
+
>
|
|
603
|
+
{renderNode === undefined ? node.label : renderNode(node, { tier, selected, dimmed: isDimmed })}
|
|
604
|
+
</button>,
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const Layer = base.Layer;
|
|
609
|
+
const layer = (
|
|
610
|
+
<Layer view={view} layout={layout} visible={visible} emphasis={emphasis} camera={cameraSource} />
|
|
611
|
+
);
|
|
612
|
+
|
|
613
|
+
return (
|
|
614
|
+
<div
|
|
615
|
+
ref={viewportRef}
|
|
616
|
+
data-dagr-explorer="viewport"
|
|
617
|
+
role="region"
|
|
618
|
+
aria-label={label}
|
|
619
|
+
aria-describedby={describedBy}
|
|
620
|
+
tabIndex={-1}
|
|
621
|
+
className={className}
|
|
622
|
+
style={{
|
|
623
|
+
height: 'var(--dagr-explorer-height, 480px)',
|
|
624
|
+
...style,
|
|
625
|
+
// Last, so a caller cannot break the graph: the plane and the nodes
|
|
626
|
+
// are absolutely positioned against this element and clipped by it.
|
|
627
|
+
position: 'relative',
|
|
628
|
+
overflow: 'hidden',
|
|
629
|
+
}}
|
|
630
|
+
>
|
|
631
|
+
{base.space === 'viewport' ? layer : null}
|
|
632
|
+
<div
|
|
633
|
+
ref={planeRef}
|
|
634
|
+
data-dagr-explorer="plane"
|
|
635
|
+
style={{
|
|
636
|
+
position: 'absolute',
|
|
637
|
+
left: 0,
|
|
638
|
+
top: 0,
|
|
639
|
+
width: layout.width,
|
|
640
|
+
height: layout.height,
|
|
641
|
+
transformOrigin: '0 0',
|
|
642
|
+
visibility: 'hidden',
|
|
643
|
+
}}
|
|
644
|
+
>
|
|
645
|
+
{base.space === 'plane' ? layer : null}
|
|
646
|
+
{overlay}
|
|
647
|
+
</div>
|
|
648
|
+
</div>
|
|
649
|
+
);
|
|
650
|
+
}
|