@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
package/src/root.tsx ADDED
@@ -0,0 +1,544 @@
1
+ /**
2
+ * `ExplorerRoot`: the data, its validation and layout, and the state every
3
+ * part reads.
4
+ *
5
+ * **Controllable, and never past the owner.** `viewId` and `selectedId` are
6
+ * controllable because they are what a host syncs to a URL. Under a
7
+ * controlled value, every change that starts inside the explorer (a click, a
8
+ * search pick, an `ExplorerApi` call, a view switch reselecting, the selected
9
+ * node leaving the data, the active view being removed) calls the callback
10
+ * and changes nothing on screen until the prop does. A controlled value the
11
+ * data lacks renders as its fallback, with no corrective callback: a
12
+ * callback fired to fix the owner's own prop is how update loops start.
13
+ *
14
+ * **Changes the explorer makes on its own are found after commit,** in a
15
+ * layout effect, not in render: they call the owner's callbacks, which must
16
+ * not run during render. A layout effect re-renders before paint, so the
17
+ * stale frame is never seen.
18
+ *
19
+ * **Closing the drawer restores focus here,** whichever way it closed: to
20
+ * the element that opened it, else to the search field, else to the root
21
+ * element itself, never to the page. Only when focus was lost with the
22
+ * drawer (it was inside it, or on a node that left the data), so a host
23
+ * control that closes the drawer keeps its focus. `Escape` anywhere in the
24
+ * root closes the drawer, except where a part has its own precedence (the
25
+ * search field, the drawer) or a host control handled the key. On a node or
26
+ * the graph's surface it closes the drawer and nothing else, so focus stays
27
+ * where it is and a second `Escape` leaves the graph. It is taken in the
28
+ * capture phase for that, before the camera's own `Escape` would blur the
29
+ * graph and drop focus to the page.
30
+ *
31
+ * **Validation and layout run in render,** so a data error reaches an error
32
+ * boundary. Every view is validated; only the active one is laid out, and
33
+ * its layout is memoized by shape, so data re-created on every render keeps
34
+ * its layout and its camera.
35
+ */
36
+
37
+ import {
38
+ useEffect,
39
+ useImperativeHandle,
40
+ useMemo,
41
+ useReducer,
42
+ useRef,
43
+ useState,
44
+ } from 'react';
45
+ import type { CSSProperties, KeyboardEvent, ReactElement, ReactNode, Ref } from 'react';
46
+ import { ExplorerApiContext, ExplorerContext, createCameraHub } from './context.js';
47
+ import type { ExplorerApi, ExplorerContextValue, ExplorerInternals, ExplorerState } from './context.js';
48
+ import { ExplorerContextError } from './errors.js';
49
+ import { useIsomorphicLayoutEffect } from './isomorphic-layout-effect.js';
50
+ import { resolveLabels, sameLabels } from './labels.js';
51
+ import type { ExplorerLabels } from './labels.js';
52
+ import { layoutKey, layoutView } from './layout.js';
53
+ import type { ExplorerLayout } from './layout.js';
54
+ import { defaultSearchText, searchNodes } from './search.js';
55
+ import type {
56
+ ExplorerEdge,
57
+ ExplorerGroup,
58
+ ExplorerLayoutOptions,
59
+ ExplorerNode,
60
+ ExplorerView,
61
+ } from './types.js';
62
+ import type { ExplorerCameraControls } from './use-explorer-camera.js';
63
+ import { validateViews } from './validate.js';
64
+
65
+ /** The id of the one view the `nodes` and `edges` shorthand makes. */
66
+ const DEFAULT_VIEW_ID = 'default';
67
+ const NOTHING: ReadonlySet<string> = new Set();
68
+
69
+ /** Hidden from sight and not from a screen reader. */
70
+ const VISUALLY_HIDDEN: CSSProperties = {
71
+ position: 'absolute',
72
+ width: 1,
73
+ height: 1,
74
+ margin: -1,
75
+ padding: 0,
76
+ overflow: 'hidden',
77
+ clip: 'rect(0 0 0 0)',
78
+ whiteSpace: 'nowrap',
79
+ border: 0,
80
+ };
81
+
82
+ /**
83
+ * `value`, or the one this hook returned last render if `same` says they are
84
+ * equal, so a value re-created with the same contents keeps its identity.
85
+ */
86
+ function useSame<T>(value: T, same: (a: T, b: T) => boolean): T {
87
+ const kept = useRef(value);
88
+ if (!same(kept.current, value)) kept.current = value;
89
+ return kept.current;
90
+ }
91
+
92
+ const sameItems = <T,>(a: readonly T[], b: readonly T[]): boolean =>
93
+ a === b || (a.length === b.length && a.every((item, i) => item === b[i]));
94
+
95
+ const sameSet = (a: ReadonlySet<string>, b: ReadonlySet<string>): boolean =>
96
+ a === b || (a.size === b.size && [...a].every((id) => b.has(id)));
97
+
98
+ interface ExplorerRootCommonProps<N extends ExplorerNode, E extends ExplorerEdge> {
99
+ /** Required. The accessible name the parts derive theirs from. */
100
+ readonly label: string;
101
+ /** Controlled active view. An id no view has renders the first view. */
102
+ readonly viewId?: string | undefined;
103
+ /** The view to start on, read once at mount. Default: the first. */
104
+ readonly defaultViewId?: string | undefined;
105
+ readonly onViewChange?: ((viewId: string) => void) | undefined;
106
+ /** Controlled selection. `null` is controlled and empty. An unknown id renders as none. */
107
+ readonly selectedId?: string | null | undefined;
108
+ /** The node to start with, read once at mount. */
109
+ readonly defaultSelectedId?: string | null | undefined;
110
+ readonly onSelectedChange?: ((id: string | null) => void) | undefined;
111
+ /** The node to select when a view becomes active. Without it, a switch clears the selection. */
112
+ readonly selectOnViewChange?: ((view: ExplorerView<N, E>) => string | null) | undefined;
113
+ /** What search reads from a node. Default: the id and the label. */
114
+ readonly searchText?: ((node: N) => string) | undefined;
115
+ /** Throw `GROUP_ENCLOSES_NON_MEMBER` when a group outline would enclose a non-member. */
116
+ readonly strictGroups?: boolean | undefined;
117
+ readonly labels?: Partial<ExplorerLabels> | undefined;
118
+ readonly apiRef?: Ref<ExplorerApi> | undefined;
119
+ readonly className?: string | undefined;
120
+ readonly style?: CSSProperties | undefined;
121
+ readonly children?: ReactNode;
122
+ }
123
+
124
+ interface ExplorerRootViews<N extends ExplorerNode, E extends ExplorerEdge> {
125
+ readonly views: readonly ExplorerView<N, E>[];
126
+ readonly nodes?: never;
127
+ readonly edges?: never;
128
+ readonly groups?: never;
129
+ readonly layout?: never;
130
+ }
131
+
132
+ interface ExplorerRootGraph<N extends ExplorerNode, E extends ExplorerEdge> {
133
+ readonly views?: never;
134
+ readonly nodes: readonly N[];
135
+ readonly edges: readonly E[];
136
+ readonly groups?: readonly ExplorerGroup[] | undefined;
137
+ readonly layout?: ExplorerLayoutOptions<N> | undefined;
138
+ }
139
+
140
+ /**
141
+ * The root's props. The data is exactly one of two shapes, `views` or the
142
+ * single-graph shorthand (`nodes`, `edges`, `groups`, `layout`), and each
143
+ * types the other's props as `never`, so passing both is a compile error and
144
+ * not a silent precedence rule. The shorthand is one view whose id is
145
+ * `'default'` and whose label is `label`.
146
+ */
147
+ export type ExplorerRootProps<
148
+ N extends ExplorerNode = ExplorerNode,
149
+ E extends ExplorerEdge = ExplorerEdge,
150
+ > = ExplorerRootCommonProps<N, E> & (ExplorerRootViews<N, E> | ExplorerRootGraph<N, E>);
151
+
152
+ /**
153
+ * What the stable methods read: the latest committed render, with each
154
+ * method's own writes on top until the next commit, so two calls in one tick
155
+ * see each other.
156
+ */
157
+ interface Latest<N extends ExplorerNode, E extends ExplorerEdge> {
158
+ readonly views: readonly ExplorerView<N, E>[];
159
+ /** The active view, or the one a call asked for since the last commit. */
160
+ readonly activeId: string | null;
161
+ readonly layout: ExplorerLayout | null;
162
+ readonly nodeById: ReadonlyMap<string, N>;
163
+ /** The selection, or the one a call asked for since the last commit. */
164
+ readonly rawSelected: string | null;
165
+ /** The selection as committed: under control, the owner's prop. */
166
+ readonly committedSelected: string | null;
167
+ readonly selectionControlled: boolean;
168
+ readonly viewControlled: boolean;
169
+ readonly detailsOpen: boolean;
170
+ readonly onSelectedChange: ((id: string | null) => void) | undefined;
171
+ readonly onViewChange: ((viewId: string) => void) | undefined;
172
+ }
173
+
174
+ export function ExplorerRoot<N extends ExplorerNode = ExplorerNode, E extends ExplorerEdge = ExplorerEdge>(
175
+ props: ExplorerRootProps<N, E>,
176
+ ): ReactElement {
177
+ const {
178
+ label,
179
+ viewId,
180
+ defaultViewId,
181
+ onViewChange,
182
+ selectedId: selectedProp,
183
+ defaultSelectedId,
184
+ onSelectedChange,
185
+ selectOnViewChange,
186
+ searchText = defaultSearchText,
187
+ strictGroups = false,
188
+ labels: labelOverrides,
189
+ apiRef,
190
+ className,
191
+ style,
192
+ children,
193
+ } = props;
194
+ const { views: viewsProp, nodes, edges, groups, layout: layoutOptions } = props;
195
+
196
+ const views = useMemo<readonly ExplorerView<N, E>[]>(() => {
197
+ if (viewsProp !== undefined) return viewsProp;
198
+ if (nodes === undefined) return [];
199
+ return [{ id: DEFAULT_VIEW_ID, label, nodes, edges: edges ?? [], groups, layout: layoutOptions }];
200
+ }, [viewsProp, nodes, edges, groups, layoutOptions, label]);
201
+
202
+ // Every view, not only the active one, so a broken view fails on the page
203
+ // that ships it and not on the day somebody switches to it.
204
+ useMemo(() => validateViews(views), [views]);
205
+
206
+ // Kept by value, so an inline `labels={{ search: 'Find' }}` re-created on
207
+ // every parent render changes nothing downstream while it says the same.
208
+ const labels = useSame(resolveLabels(labelOverrides), sameLabels);
209
+
210
+ // View.
211
+ const [viewState, setViewState] = useState<string | null>(() => defaultViewId ?? null);
212
+ const viewControlled = viewId !== undefined;
213
+ const requestedView = viewControlled ? viewId : viewState;
214
+ const activeView = views.find((view) => view.id === requestedView) ?? views[0] ?? null;
215
+ const activeId = activeView?.id ?? null;
216
+
217
+ // Layout, by shape. The key is a string, so the memo holds across data
218
+ // re-created with the same shape and a label or color change. The key
219
+ // itself is linear in the view and calls the host's `nodeSize`, so it is
220
+ // computed once per view object, not on every render.
221
+ const key = useMemo(() => (activeView === null ? null : layoutKey(activeView)), [activeView]);
222
+ const layout = useMemo(
223
+ () => (activeView === null ? null : layoutView(activeView, { strictGroups })),
224
+ // Keyed by shape, on purpose: `activeView` is read for the key's sake.
225
+ [key, strictGroups],
226
+ );
227
+
228
+ const nodeById = useMemo(
229
+ () => new Map((activeView?.nodes ?? []).map((node) => [node.id, node])),
230
+ [activeView],
231
+ );
232
+
233
+ // Selection.
234
+ const [selectedState, setSelectedState] = useState<string | null>(() => defaultSelectedId ?? null);
235
+ const selectionControlled = selectedProp !== undefined;
236
+ const rawSelected = selectionControlled ? selectedProp : selectedState;
237
+ const selectedNode = rawSelected === null ? null : (nodeById.get(rawSelected) ?? null);
238
+ const selectedId = selectedNode === null ? null : selectedNode.id;
239
+
240
+ // Query, trace, drawer.
241
+ const [query, setQueryState] = useState('');
242
+ const [trace, setTraceState] = useState(false);
243
+ const [drawerOpen, setDrawerOpen] = useState(false);
244
+ // A node `inspect` asked for under a controlled selection: the drawer opens
245
+ // when the owner's prop arrives at it, and not before.
246
+ const [pendingOpen, setPendingOpen] = useState<string | null>(null);
247
+ const [lastRaw, setLastRaw] = useState(rawSelected);
248
+ if (lastRaw !== rawSelected) {
249
+ setLastRaw(rawSelected);
250
+ if (pendingOpen !== null) {
251
+ setPendingOpen(null);
252
+ if (pendingOpen === rawSelected) setDrawerOpen(true);
253
+ }
254
+ }
255
+ // No selection, no drawer, and none that reopens on the next selection.
256
+ if (selectedId === null && drawerOpen) setDrawerOpen(false);
257
+ const detailsOpen = drawerOpen && selectedId !== null;
258
+
259
+ // Kept while the same nodes match, so a keystroke that changes nothing
260
+ // found re-renders no list and re-dims nothing.
261
+ const matches = useSame(
262
+ useMemo(
263
+ () => (activeView === null ? [] : searchNodes(activeView.nodes, query, searchText)),
264
+ [activeView, query, searchText],
265
+ ),
266
+ sameItems,
267
+ );
268
+ const hasQuery = query.trim() !== '';
269
+ // Kept by value too: the graph renders again only when the dimming does.
270
+ const dimmedNow = useMemo<ReadonlySet<string>>(() => {
271
+ if (activeView === null || (!hasQuery && !(trace && selectedId !== null))) return NOTHING;
272
+ const out = new Set<string>();
273
+ if (hasQuery) {
274
+ const hit = new Set(matches.map((node) => node.id));
275
+ for (const node of activeView.nodes) if (!hit.has(node.id)) out.add(node.id);
276
+ }
277
+ if (trace && selectedId !== null) {
278
+ const keep = new Set([selectedId]);
279
+ for (const edge of activeView.edges) {
280
+ if (edge.source === selectedId) keep.add(edge.target);
281
+ if (edge.target === selectedId) keep.add(edge.source);
282
+ }
283
+ for (const node of activeView.nodes) if (!keep.has(node.id)) out.add(node.id);
284
+ }
285
+ return out.size === 0 ? NOTHING : out;
286
+ }, [activeView, hasQuery, matches, trace, selectedId]);
287
+ const dimmed = useSame(dimmedNow, sameSet);
288
+
289
+ const snapshot: Latest<N, E> = {
290
+ views,
291
+ activeId,
292
+ layout,
293
+ nodeById,
294
+ rawSelected,
295
+ committedSelected: rawSelected,
296
+ selectionControlled,
297
+ viewControlled,
298
+ detailsOpen,
299
+ onSelectedChange,
300
+ onViewChange,
301
+ };
302
+ const latest = useRef(snapshot);
303
+ // A render after a call under control, so the commit puts the owner's
304
+ // value back in `latest` even when the owner does not move.
305
+ const [, recommit] = useReducer((n: number) => n + 1, 0);
306
+ useIsomorphicLayoutEffect(() => {
307
+ latest.current = snapshot;
308
+ });
309
+
310
+ const [internals] = useState<ExplorerInternals>(() => {
311
+ let viewports = 0;
312
+ return {
313
+ registerViewport() {
314
+ if (viewports > 0) {
315
+ throw new ExplorerContextError(
316
+ 'SECOND_VIEWPORT',
317
+ 'An ExplorerRoot has a second ExplorerViewport. Render exactly one per root',
318
+ );
319
+ }
320
+ viewports += 1;
321
+ return () => {
322
+ viewports -= 1;
323
+ };
324
+ },
325
+ controlsRef: { current: null as ExplorerCameraControls | null },
326
+ camera: createCameraHub(),
327
+ searchInputRef: { current: null as HTMLInputElement | null },
328
+ };
329
+ });
330
+ const openerRef = useRef<HTMLElement | null>(null);
331
+ const rootRef = useRef<HTMLDivElement>(null);
332
+ // Set by Escape inside the graph, whose close restores nothing.
333
+ const skipRestore = useRef(false);
334
+
335
+ const [{ api, changeSelection }] = useState(() => {
336
+ const changeSelection = (next: string | null): void => {
337
+ const s = latest.current;
338
+ if (next === s.rawSelected) return;
339
+ latest.current = { ...s, rawSelected: next };
340
+ if (s.selectionControlled) recommit();
341
+ else setSelectedState(next);
342
+ s.onSelectedChange?.(next);
343
+ };
344
+ const box = (id: string) => latest.current.layout?.boxes.get(id);
345
+ const methods: ExplorerApi = {
346
+ fit() {
347
+ internals.controlsRef.current?.fit();
348
+ },
349
+ zoomBy(factor) {
350
+ internals.controlsRef.current?.zoomBy(factor);
351
+ },
352
+ focusNode(id) {
353
+ const found = box(id);
354
+ if (found !== undefined) internals.controlsRef.current?.focusBox(found);
355
+ },
356
+ reveal(id) {
357
+ const found = box(id);
358
+ if (found !== undefined) internals.controlsRef.current?.revealBox(found);
359
+ },
360
+ select(id) {
361
+ if (id !== null && !latest.current.nodeById.has(id)) return;
362
+ setPendingOpen(null);
363
+ changeSelection(id);
364
+ },
365
+ inspect(id, trigger) {
366
+ const s = latest.current;
367
+ if (!s.nodeById.has(id)) return;
368
+ if (trigger !== undefined) openerRef.current = trigger;
369
+ else if (!s.detailsOpen) {
370
+ const active = document.activeElement;
371
+ openerRef.current = active instanceof HTMLElement && active !== document.body ? active : null;
372
+ }
373
+ if (!s.selectionControlled || s.committedSelected === id) {
374
+ setPendingOpen(null);
375
+ setDrawerOpen(true);
376
+ latest.current = { ...s, detailsOpen: true };
377
+ } else {
378
+ setPendingOpen(id);
379
+ }
380
+ changeSelection(id);
381
+ },
382
+ closeDetails() {
383
+ setPendingOpen(null);
384
+ setDrawerOpen(false);
385
+ latest.current = { ...latest.current, detailsOpen: false };
386
+ },
387
+ selectView(id) {
388
+ const s = latest.current;
389
+ if (id === s.activeId || !s.views.some((view) => view.id === id)) return;
390
+ latest.current = { ...s, activeId: id };
391
+ if (s.viewControlled) recommit();
392
+ else setViewState(id);
393
+ s.onViewChange?.(id);
394
+ },
395
+ setQuery(next) {
396
+ setQueryState(next);
397
+ },
398
+ setTrace(on) {
399
+ setTraceState(on);
400
+ },
401
+ };
402
+ return { api: methods, changeSelection };
403
+ });
404
+
405
+ useImperativeHandle(apiRef, () => api, [api]);
406
+
407
+ // The changes the explorer makes on its own, found after commit. See the
408
+ // file comment for why here and not in render.
409
+ const seenView = useRef(activeId);
410
+ const present = useRef<{ readonly view: string | null; readonly id: string } | null>(null);
411
+ useIsomorphicLayoutEffect(() => {
412
+ // A removed view, or an unknown default, is forgotten, so it is not
413
+ // reselected if it appears later.
414
+ if (!viewControlled && activeId !== null && viewState !== activeId) setViewState(activeId);
415
+
416
+ // Data arriving after mount, or after every view was gone, is not a
417
+ // switch: there was nothing to switch from. Recorded, and nothing reset,
418
+ // so a deep-linked selection survives data that starts empty.
419
+ if (seenView.current === null && activeId !== null) seenView.current = activeId;
420
+
421
+ if (seenView.current !== activeId) {
422
+ const previous = seenView.current;
423
+ seenView.current = activeId;
424
+ present.current = null;
425
+ setQueryState('');
426
+ setTraceState(false);
427
+ setDrawerOpen(false);
428
+ setPendingOpen(null);
429
+ // The camera resets because the viewport remounts per view.
430
+ changeSelection(
431
+ activeView !== null && selectOnViewChange !== undefined ? selectOnViewChange(activeView) : null,
432
+ );
433
+ const removed = previous !== null && !views.some((view) => view.id === previous);
434
+ if (removed && activeId !== null) onViewChange?.(activeId);
435
+ return;
436
+ }
437
+
438
+ // The selected node leaving the data is a transition: reported once,
439
+ // when an id that resolved last commit no longer does. An unknown id the
440
+ // owner passes never resolved, so it is never reported.
441
+ if (selectedId !== null) {
442
+ present.current = { view: activeId, id: selectedId };
443
+ return;
444
+ }
445
+ const was = present.current;
446
+ present.current = null;
447
+ if (was !== null && was.view === activeId && rawSelected === was.id) changeSelection(null);
448
+ });
449
+
450
+ const wasOpen = useRef(detailsOpen);
451
+ useEffect(() => {
452
+ const was = wasOpen.current;
453
+ wasOpen.current = detailsOpen;
454
+ if (detailsOpen || !was) return;
455
+ const skip = skipRestore.current;
456
+ skipRestore.current = false;
457
+ if (skip) return;
458
+ const active = document.activeElement;
459
+ if (active !== null && active !== document.body) return;
460
+ const opener = openerRef.current;
461
+ // Never the page: the root takes focus when nothing better is left.
462
+ const target =
463
+ opener !== null && opener.isConnected ? opener : (internals.searchInputRef.current ?? rootRef.current);
464
+ target?.focus();
465
+ }, [detailsOpen, internals]);
466
+
467
+ // The capture phase, so it runs before the camera's listener on the
468
+ // viewport, which blurs the graph on an Escape nobody has handled. Only a
469
+ // node's own button or the surface: content a host renders inside a node
470
+ // gets the key first, as everywhere else.
471
+ const onKeyDownCapture = (event: KeyboardEvent<HTMLDivElement>): void => {
472
+ const target = event.target;
473
+ if (event.key !== 'Escape' || !detailsOpen || !(target instanceof HTMLElement)) return;
474
+ const viewport = target.closest('[data-dagr-explorer="viewport"]');
475
+ if (viewport === null || viewport.closest('[data-dagr-explorer="root"]') !== rootRef.current) return;
476
+ if (target !== viewport && target.getAttribute('data-dagr-explorer') !== 'node') return;
477
+ event.preventDefault();
478
+ api.closeDetails();
479
+ };
480
+
481
+ const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
482
+ const target = event.target;
483
+ if (event.key !== 'Escape' || !detailsOpen || !(target instanceof Element)) return;
484
+ // A root nested in this one handles its own.
485
+ if (target.closest('[data-dagr-explorer="root"]') !== rootRef.current) return;
486
+ if (target.closest('[data-dagr-explorer="viewport"]') !== null) {
487
+ // Closed already in the capture phase, or handled by content in a node.
488
+ if (event.defaultPrevented) return;
489
+ // The camera has already released graph focus by now: its listener is
490
+ // on the viewport, below this one.
491
+ skipRestore.current = true;
492
+ api.closeDetails();
493
+ return;
494
+ }
495
+ // The search field and the drawer keep their own precedence, and so
496
+ // does a host control that handled the key.
497
+ if (event.defaultPrevented || target === internals.searchInputRef.current) return;
498
+ api.closeDetails();
499
+ };
500
+
501
+ const state = useMemo<ExplorerState>(
502
+ () =>
503
+ ({
504
+ ...api,
505
+ label,
506
+ labels,
507
+ views,
508
+ activeView,
509
+ layout,
510
+ selectedId,
511
+ selectedNode,
512
+ query,
513
+ matches,
514
+ trace,
515
+ detailsOpen,
516
+ dimmed,
517
+ camera: internals.camera,
518
+ }) as unknown as ExplorerState,
519
+ [api, label, labels, views, activeView, layout, selectedId, selectedNode, query, matches, trace, detailsOpen, dimmed, internals],
520
+ );
521
+ const value = useMemo<ExplorerContextValue>(() => ({ state, internals }), [state, internals]);
522
+
523
+ return (
524
+ <ExplorerApiContext.Provider value={api}>
525
+ <ExplorerContext.Provider value={value}>
526
+ <div
527
+ ref={rootRef}
528
+ data-dagr-explorer="root"
529
+ // Where focus goes when the drawer closes with nothing else to take it.
530
+ tabIndex={-1}
531
+ className={className}
532
+ style={{ position: 'relative', ...style }}
533
+ onKeyDownCapture={onKeyDownCapture}
534
+ onKeyDown={onKeyDown}
535
+ >
536
+ {children}
537
+ <div data-dagr-explorer="announcer" aria-live="polite" style={VISUALLY_HIDDEN}>
538
+ {detailsOpen && selectedNode !== null ? selectedNode.label : ''}
539
+ </div>
540
+ </div>
541
+ </ExplorerContext.Provider>
542
+ </ExplorerApiContext.Provider>
543
+ );
544
+ }
package/src/search.ts ADDED
@@ -0,0 +1,36 @@
1
+ import type { ExplorerNode } from './types.js';
2
+
3
+ /** What search reads when the caller gives no accessor: the id, then the label. */
4
+ export function defaultSearchText(node: ExplorerNode): string {
5
+ return `${node.id} ${node.label}`;
6
+ }
7
+
8
+ /**
9
+ * The nodes whose text contains every token of the query, in data order.
10
+ *
11
+ * Tokens are the query split on whitespace and lowercased. They are matched as
12
+ * text with `includes`, never compiled into a pattern, so a query of `(v2.*)`
13
+ * finds exactly those characters and a query of `[` is not an error.
14
+ *
15
+ * An empty or blank query matches nothing, which is what lets the explorer
16
+ * treat "no query" and "no dimming" as the same state.
17
+ *
18
+ * `searchText` is the caller's accessor over the caller's node type. Whatever
19
+ * it returns is coerced to a string, so an accessor with a missing branch
20
+ * costs a miss, not a crash in the middle of typing.
21
+ */
22
+ export function searchNodes<N extends ExplorerNode>(
23
+ nodes: readonly N[],
24
+ query: string,
25
+ searchText: (node: N) => string = defaultSearchText,
26
+ ): N[] {
27
+ const tokens = query
28
+ .toLowerCase()
29
+ .split(/\s+/)
30
+ .filter((token) => token !== '');
31
+ if (tokens.length === 0) return [];
32
+ return nodes.filter((node) => {
33
+ const text = String(searchText(node) ?? '').toLowerCase();
34
+ return tokens.every((token) => text.includes(token));
35
+ });
36
+ }
package/src/size.ts ADDED
@@ -0,0 +1,23 @@
1
+ import type { ExplorerLayoutOptions, ExplorerNode, Size } from './types.js';
2
+
3
+ /** The size of a node that declares none, in world pixels. */
4
+ export const DEFAULT_NODE_SIZE: Size = Object.freeze({ width: 240, height: 120 });
5
+
6
+ /**
7
+ * A node's size: its own `size`, else the view's `nodeSize`, else the default.
8
+ *
9
+ * Sizes are declared and never measured, because a virtualized node has no
10
+ * element to measure. This is the one place that order is written down, so
11
+ * validation, layout and the shape key cannot disagree about it.
12
+ */
13
+ export function resolveNodeSize<N extends ExplorerNode>(
14
+ layout: ExplorerLayoutOptions<N> | undefined,
15
+ node: N,
16
+ ): Size {
17
+ if (node.size !== undefined && node.size !== null) return node.size;
18
+ const configured = layout?.nodeSize;
19
+ if (configured === undefined) return DEFAULT_NODE_SIZE;
20
+ // The `??` is for a function that returns nothing: a JavaScript caller, or a
21
+ // branch the type checker was told not to look at.
22
+ return (typeof configured === 'function' ? configured(node) : configured) ?? DEFAULT_NODE_SIZE;
23
+ }