@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.
Files changed (135) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/LICENSE +21 -0
  3. package/README.md +441 -0
  4. package/dist/base.d.ts +63 -0
  5. package/dist/base.d.ts.map +1 -0
  6. package/dist/base.js +20 -0
  7. package/dist/base.js.map +1 -0
  8. package/dist/camera.d.ts +61 -0
  9. package/dist/camera.d.ts.map +1 -0
  10. package/dist/camera.js +142 -0
  11. package/dist/camera.js.map +1 -0
  12. package/dist/context.d.ts +110 -0
  13. package/dist/context.d.ts.map +1 -0
  14. package/dist/context.js +71 -0
  15. package/dist/context.js.map +1 -0
  16. package/dist/dagr-explorer.d.ts +41 -0
  17. package/dist/dagr-explorer.d.ts.map +1 -0
  18. package/dist/dagr-explorer.js +13 -0
  19. package/dist/dagr-explorer.js.map +1 -0
  20. package/dist/errors.d.ts +55 -0
  21. package/dist/errors.d.ts.map +1 -0
  22. package/dist/errors.js +57 -0
  23. package/dist/errors.js.map +1 -0
  24. package/dist/explorer-details.d.ts +44 -0
  25. package/dist/explorer-details.d.ts.map +1 -0
  26. package/dist/explorer-details.js +76 -0
  27. package/dist/explorer-details.js.map +1 -0
  28. package/dist/explorer-search.d.ts +31 -0
  29. package/dist/explorer-search.d.ts.map +1 -0
  30. package/dist/explorer-search.js +86 -0
  31. package/dist/explorer-search.js.map +1 -0
  32. package/dist/explorer-toolbar.d.ts +17 -0
  33. package/dist/explorer-toolbar.d.ts.map +1 -0
  34. package/dist/explorer-toolbar.js +44 -0
  35. package/dist/explorer-toolbar.js.map +1 -0
  36. package/dist/explorer-trace-toggle.d.ts +16 -0
  37. package/dist/explorer-trace-toggle.d.ts.map +1 -0
  38. package/dist/explorer-trace-toggle.js +8 -0
  39. package/dist/explorer-trace-toggle.js.map +1 -0
  40. package/dist/explorer-viewport.d.ts +69 -0
  41. package/dist/explorer-viewport.d.ts.map +1 -0
  42. package/dist/explorer-viewport.js +69 -0
  43. package/dist/explorer-viewport.js.map +1 -0
  44. package/dist/explorer-views.d.ts +24 -0
  45. package/dist/explorer-views.d.ts.map +1 -0
  46. package/dist/explorer-views.js +16 -0
  47. package/dist/explorer-views.js.map +1 -0
  48. package/dist/index.d.ts +48 -0
  49. package/dist/index.d.ts.map +1 -0
  50. package/dist/index.js +33 -0
  51. package/dist/index.js.map +1 -0
  52. package/dist/isomorphic-layout-effect.d.ts +7 -0
  53. package/dist/isomorphic-layout-effect.d.ts.map +1 -0
  54. package/dist/isomorphic-layout-effect.js +7 -0
  55. package/dist/isomorphic-layout-effect.js.map +1 -0
  56. package/dist/labels.d.ts +67 -0
  57. package/dist/labels.d.ts.map +1 -0
  58. package/dist/labels.js +50 -0
  59. package/dist/labels.js.map +1 -0
  60. package/dist/layout.d.ts +82 -0
  61. package/dist/layout.d.ts.map +1 -0
  62. package/dist/layout.js +244 -0
  63. package/dist/layout.js.map +1 -0
  64. package/dist/navigation.d.ts +20 -0
  65. package/dist/navigation.d.ts.map +1 -0
  66. package/dist/navigation.js +44 -0
  67. package/dist/navigation.js.map +1 -0
  68. package/dist/root.d.ts +89 -0
  69. package/dist/root.d.ts.map +1 -0
  70. package/dist/root.js +401 -0
  71. package/dist/root.js.map +1 -0
  72. package/dist/search.d.ts +19 -0
  73. package/dist/search.d.ts.map +1 -0
  74. package/dist/search.js +31 -0
  75. package/dist/search.js.map +1 -0
  76. package/dist/size.d.ts +12 -0
  77. package/dist/size.d.ts.map +1 -0
  78. package/dist/size.js +20 -0
  79. package/dist/size.js.map +1 -0
  80. package/dist/svg-base.d.ts +33 -0
  81. package/dist/svg-base.d.ts.map +1 -0
  82. package/dist/svg-base.js +89 -0
  83. package/dist/svg-base.js.map +1 -0
  84. package/dist/types.d.ts +61 -0
  85. package/dist/types.d.ts.map +1 -0
  86. package/dist/types.js +2 -0
  87. package/dist/types.js.map +1 -0
  88. package/dist/use-explorer-camera.d.ts +56 -0
  89. package/dist/use-explorer-camera.d.ts.map +1 -0
  90. package/dist/use-explorer-camera.js +641 -0
  91. package/dist/use-explorer-camera.js.map +1 -0
  92. package/dist/use-explorer.d.ts +23 -0
  93. package/dist/use-explorer.d.ts.map +1 -0
  94. package/dist/use-explorer.js +26 -0
  95. package/dist/use-explorer.js.map +1 -0
  96. package/dist/validate.d.ts +20 -0
  97. package/dist/validate.d.ts.map +1 -0
  98. package/dist/validate.js +99 -0
  99. package/dist/validate.js.map +1 -0
  100. package/dist/viewport-surface.d.ts +90 -0
  101. package/dist/viewport-surface.d.ts.map +1 -0
  102. package/dist/viewport-surface.js +485 -0
  103. package/dist/viewport-surface.js.map +1 -0
  104. package/dist/visible-set.d.ts +65 -0
  105. package/dist/visible-set.d.ts.map +1 -0
  106. package/dist/visible-set.js +168 -0
  107. package/dist/visible-set.js.map +1 -0
  108. package/package.json +62 -0
  109. package/src/base.ts +67 -0
  110. package/src/camera.ts +206 -0
  111. package/src/context.ts +168 -0
  112. package/src/dagr-explorer.tsx +85 -0
  113. package/src/errors.ts +85 -0
  114. package/src/explorer-details.tsx +156 -0
  115. package/src/explorer-search.tsx +140 -0
  116. package/src/explorer-toolbar.tsx +77 -0
  117. package/src/explorer-trace-toggle.tsx +34 -0
  118. package/src/explorer-viewport.tsx +181 -0
  119. package/src/explorer-views.tsx +61 -0
  120. package/src/index.ts +76 -0
  121. package/src/isomorphic-layout-effect.ts +7 -0
  122. package/src/labels.ts +109 -0
  123. package/src/layout.ts +298 -0
  124. package/src/navigation.ts +51 -0
  125. package/src/root.tsx +544 -0
  126. package/src/search.ts +36 -0
  127. package/src/size.ts +23 -0
  128. package/src/svg-base.tsx +173 -0
  129. package/src/types.ts +69 -0
  130. package/src/use-explorer-camera.ts +675 -0
  131. package/src/use-explorer.ts +32 -0
  132. package/src/validate.ts +173 -0
  133. package/src/viewport-surface.tsx +650 -0
  134. package/src/visible-set.ts +225 -0
  135. 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
+ }