@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/CHANGELOG.md ADDED
@@ -0,0 +1,93 @@
1
+ # @prnt/dagr-explorer
2
+
3
+ ## 0.1.3
4
+
5
+ The first release. Built in slices M5.6a to M5.6f; the entries below are in
6
+ the order they landed.
7
+
8
+ - Add the headless core (M5.6b): the `ExplorerView` data model, `validateView`
9
+ and `validateViews` with `ExplorerDataError`, `layoutView`, and
10
+ `searchNodes`.
11
+ - Layout flows `'right'` by default or `'down'`, in y-down world pixels padded
12
+ 40 off the origin. Parallel edges that span one rank bow 16 apart. A self
13
+ loop has an empty route. A group is the padded hull of its members and
14
+ moves no node.
15
+ - Add the viewport's pure core (M5.6c-1), internal for now: camera arithmetic
16
+ with limits from `Camera2D`, and the visible set that decides which nodes
17
+ get a DOM element at which tier, capped, with pinned nodes always mounted.
18
+ - Add the viewport (M5.6c-2), internal for now: a pannable, zoomable surface
19
+ with an SVG base layer and a windowed DOM overlay. Pan and zoom by wheel,
20
+ keys, drag, trackpad pinch and two-finger touch. A drag tracks the pointer
21
+ exactly, and a resize or a relayout keeps the user's place. The base layer
22
+ is a seam that is told of every drawn camera, for a native base later.
23
+ - Add the React parts (M5.6d), the first public exports beyond the core:
24
+ `DagrExplorer`, preassembled, and the parts it is built from,
25
+ `ExplorerRoot`, `ExplorerViews`, `ExplorerSearch`, `ExplorerTraceToggle`,
26
+ `ExplorerViewport`, `ExplorerDetails` and `ExplorerToolbar`, with
27
+ `useExplorer` and an `apiRef` for the same methods, and `useExplorerApi`
28
+ for the methods alone, which never re-renders its caller. The methods see
29
+ each other's writes, so two calls in one tick end where the last asked.
30
+ - `ExplorerRoot` validates every view and lays out the active one, memoized
31
+ by shape. The view and the selection are controllable, and a controlled
32
+ value is never rendered past: the explorer calls back and waits for the
33
+ prop. The query, trace, drawer and camera are internal, and a view switch
34
+ resets them. Data that arrives after mount is not a switch, so a
35
+ deep-linked selection survives data that starts empty.
36
+ - `ExplorerViewport` renders its children, such as `ExplorerDetails`, in a
37
+ positioned stage with the graph, and the graph's hint after it. Its
38
+ surface renders only when what it draws changes. Every part's `style`
39
+ wins over its own, so `style={{ height: 600 }}` sizes the graph.
40
+ - `ExplorerSearch` lists at most `maxResults` matches (default 50), then a
41
+ line saying how many more. The count and Enter cover every match.
42
+ - The drawer returns focus to what opened it, or to the search field, or to
43
+ the root, never to the page. Escape closes it from anywhere in the root.
44
+ In the search field it closes the drawer before it clears the query, and
45
+ on a node or the graph's surface it closes the drawer before it leaves
46
+ the graph (see the M5.6f-2 entry below).
47
+ - Every string comes from `labels`, with neutral English defaults in
48
+ `DEFAULT_EXPLORER_LABELS`, including the `moreMatches` and `inGroup`
49
+ formatters. An inline `labels` object is kept by value. A part outside a
50
+ root, or a second viewport in one, throws `ExplorerContextError`.
51
+ - Add `@prnt/dagr-explorer/styles.css`, the optional default look, themed by
52
+ eight `--dagr-explorer-*` variables. The parts work without it.
53
+ - Export the base-layer seam types (`ExplorerBase`, `ExplorerBaseProps`,
54
+ `ExplorerCameraSource`, `ExplorerEmphasis`, `ExplorerVisibleSet`) as
55
+ experimental, until a native base confirms them, and `ExplorerCamera` as a
56
+ type.
57
+ - The test suite runs under React 18 and React 19. The React 18 run needs
58
+ Node 22.15 or later, for `module.registerHooks`.
59
+ - Add keyboard navigation (M5.6e). The graph is one tab stop: the selected
60
+ node, else the last node focused from the keyboard, else the node nearest
61
+ the center, always mounted. Arrow keys on a focused node move focus to the
62
+ nearest node in that direction, mounted or not; Shift with an arrow pans,
63
+ and Enter and Space inspect. A node focused from the keyboard is revealed
64
+ by the least pan at the current zoom; a click moves nothing. The default
65
+ `hint` now names the arrow keys. The tab target and the focused node are
66
+ pinned outside `maxOverlayNodes`, so the page can hold a few more node
67
+ elements than the cap.
68
+ - Every part renders on a server (M5.6e): the shell and the base layer, with
69
+ every node as a mark, the plane hidden and no node elements until the
70
+ client measures the graph. It hydrates without a mismatch. No part needed
71
+ a change for it.
72
+ - Add the docs page, `/docs/explorer`, with two live demos, an
73
+ architecture graph and a 2,000 node synthetic graph, and Node benches
74
+ for the visible set at 1,000 and 10,000 nodes and for layout at 1,000
75
+ (M5.6f-1). No package code changed.
76
+ - Validate the explorer in real browsers (M5.6f-2): the docs demos in
77
+ Chromium 153 and WebKit 26.6, at 1440 by 900 and at 390 by 844 with
78
+ touch, with `bench/browser/explorer-check.mjs`. Every check passes in
79
+ both. The README's "Known browser differences" records what differs.
80
+ - Fix: `Escape` on a node or the graph's surface with the drawer open
81
+ closes the drawer and keeps focus where it is, and a second `Escape`
82
+ leaves the graph. One `Escape` used to do both, which left focus on the
83
+ page body. An `Escape` that content inside a node handles is left to it.
84
+ - Fix: the graph holds its wheel listener only while it has focus. WebKit
85
+ does not scroll a page whose root sets `overscroll-behavior: none` while
86
+ the pointer is over any non-passive wheel listener, so the page would not
87
+ scroll past an unfocused graph there.
88
+ - Measure the SVG base's ceiling: smooth (a 95th percentile frame within
89
+ one 16.7 ms frame) to about 4,000 nodes and 5,400 edges, panned at one and
90
+ a half times the fit zoom in Chromium on an Apple M4. The table is in the
91
+ README.
92
+ - This is the first published version. The umbrella package re-exports it
93
+ as `@prnt/dagr/explorer`.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 prnt.design
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,441 @@
1
+ # @prnt/dagr-explorer
2
+
3
+ An interactive graph explorer for [Dagr](https://dagr.prnt.design): views,
4
+ search, connection tracing, groups and a details drawer, with node content
5
+ virtualized by on-screen size.
6
+
7
+ ## Install and requirements
8
+
9
+ ```bash
10
+ npm install @prnt/dagr-explorer
11
+ ```
12
+
13
+ The package is browser-validated in Chromium and WebKit (see "Known browser
14
+ differences" below).
15
+
16
+ The explorer never loads three.js at runtime: from `@prnt/dagr-render` it
17
+ imports only the three-free `core` entry, and `@prnt/dagr-render` declares
18
+ `three` an optional peer, so it is not installed either.
19
+
20
+ It runs on React 18 and React 19 (`react` and `react-dom` `>=18.2.0 <20.0.0`).
21
+ The umbrella package re-exports it as `@prnt/dagr/explorer`, but the umbrella
22
+ requires React 19, so a React 18 site installs this package directly.
23
+
24
+ ## The explorer
25
+
26
+ ```tsx
27
+ import { DagrExplorer } from '@prnt/dagr-explorer';
28
+ import '@prnt/dagr-explorer/styles.css'; // optional
29
+
30
+ <DagrExplorer
31
+ label="Architecture"
32
+ views={[
33
+ {
34
+ id: 'overview',
35
+ label: 'Overview',
36
+ nodes: [
37
+ { id: 'app', label: 'Application', team: 'web' },
38
+ { id: 'store', label: 'Store', team: 'data' },
39
+ ],
40
+ edges: [{ id: 'read', source: 'app', target: 'store' }],
41
+ },
42
+ ]}
43
+ renderNode={(node) => `${node.label} (${node.team})`}
44
+ />;
45
+ ```
46
+
47
+ `label` is required: it is the accessible name of the graph, and the parts
48
+ derive theirs from it. For one graph, pass `nodes`, `edges` and optionally
49
+ `groups` and `layout` instead of `views`. That is one view with the id
50
+ `'default'` and `label` as its label. Passing both shapes is a type error.
51
+
52
+ The node type is inferred from your data, so `renderNode` above sees `team`.
53
+ `DagrExplorer` takes the root's props (below) plus `renderNode`,
54
+ `renderDetails`, `renderConnection`, `renderViews`, `tiers`,
55
+ `maxOverlayNodes`, `base` and `nodeAriaLabel`, which it forwards to the parts.
56
+
57
+ ### The parts
58
+
59
+ `DagrExplorer` is built only from these, with no private access, so a host
60
+ that owns its layout can rebuild it, or arrange them differently:
61
+
62
+ ```tsx
63
+ import {
64
+ ExplorerDetails,
65
+ ExplorerRoot,
66
+ ExplorerSearch,
67
+ ExplorerToolbar,
68
+ ExplorerViewport,
69
+ type ExplorerNode,
70
+ type ExplorerView,
71
+ } from '@prnt/dagr-explorer';
72
+
73
+ interface MyNode extends ExplorerNode {
74
+ readonly title: string;
75
+ }
76
+
77
+ const views: ExplorerView<MyNode>[] = [
78
+ {
79
+ id: 'overview',
80
+ label: 'Overview',
81
+ nodes: [
82
+ { id: 'app', label: 'Application', title: 'App' },
83
+ { id: 'store', label: 'Store', title: 'Store' },
84
+ ],
85
+ edges: [{ id: 'read', source: 'app', target: 'store' }],
86
+ },
87
+ ];
88
+
89
+ export function Architecture() {
90
+ return (
91
+ <ExplorerRoot label="Architecture" views={views}>
92
+ <ExplorerSearch />
93
+ <ExplorerViewport<MyNode> renderNode={(node) => node.title}>
94
+ <ExplorerDetails />
95
+ </ExplorerViewport>
96
+ <ExplorerToolbar />
97
+ </ExplorerRoot>
98
+ );
99
+ }
100
+ ```
101
+
102
+ | Part | Owns |
103
+ | --- | --- |
104
+ | `ExplorerRoot` | the data, its validation and layout, and the state below. Renders one element around its children |
105
+ | `ExplorerViews` | the view switcher. Renders nothing for a single view. Children `({ views, activeView, selectView })` replace it |
106
+ | `ExplorerSearch` | the search field, a live match count, and the matches as buttons, at most `maxResults` (default 50) |
107
+ | `ExplorerTraceToggle` | trace on and off |
108
+ | `ExplorerViewport` | the graph: pan and zoom, the SVG base, node elements for nodes large enough to read. Takes `renderNode`, `nodeAriaLabel`, `tiers`, `maxOverlayNodes`, `base`. Its children, such as `ExplorerDetails`, share a positioned stage with the graph, and the graph's hint comes after the stage, where an overlay cannot cover it |
109
+ | `ExplorerDetails` | the drawer: an overlay with a close button and a scrolling body. Children `({ node, connections, inspect })` replace the body, and `renderConnection(edge, otherNode)` draws one connection in the default body |
110
+ | `ExplorerToolbar` | zoom out, the zoom readout, zoom in, fit, and zoom to the selected node |
111
+
112
+ Every part takes `className` and `style`, and your `style` wins over the
113
+ part's own: `<ExplorerViewport style={{ height: 600 }} />` sets the graph's
114
+ height, which is otherwise `--dagr-explorer-height`. The one exception is
115
+ the viewport's `position` and `overflow`: they stay the viewport's own
116
+ (`relative` and `hidden`), because the graph's nodes are positioned against
117
+ it and clipped by it.
118
+
119
+ One `ExplorerViewport` per root: a second throws `ExplorerContextError` with
120
+ the code `SECOND_VIEWPORT`. A part outside a root throws it with
121
+ `OUTSIDE_EXPLORER`, naming the part.
122
+
123
+ **A part's type parameters are a claim, not a check.** The parts talk through
124
+ a context, which erases them, so `ExplorerViewport<MyNode>` asserts the node
125
+ type and nothing verifies it against the root's data. Unannotated, a part
126
+ sees only `id` and `label`. `DagrExplorer` has no such gap: it infers the
127
+ types from `views`.
128
+
129
+ ### The root's props and state
130
+
131
+ `viewId` and `selectedId` are controllable (with `defaultViewId`,
132
+ `defaultSelectedId`, `onViewChange` and `onSelectedChange`), because they are
133
+ what a host syncs to a URL. Under a controlled value, every change the
134
+ explorer starts calls the callback and changes nothing on screen until the
135
+ prop does. A controlled id the data lacks renders as no selection, or as the
136
+ first view, with no corrective callback.
137
+
138
+ The query, trace, the drawer and the camera are internal. Changing view
139
+ resets all four, and sets the selection to what `selectOnViewChange(view)`
140
+ returns, or clears it. If the selected node leaves the data, the selection
141
+ clears and the drawer closes.
142
+
143
+ The other props: `searchText` (what search reads from a node, default the id
144
+ and label), `strictGroups`, `labels`, `apiRef`, `className` and `style`.
145
+
146
+ ### `useExplorer`, `useExplorerApi` and `apiRef`
147
+
148
+ `useExplorer()` returns the explorer's state (`views`, `activeView`,
149
+ `layout`, `selectedId`, `selectedNode`, `query`, `matches`, `trace`,
150
+ `detailsOpen`, `dimmed`, `labels`, and `camera` for a readout of your own) and
151
+ the same methods `apiRef` hands out. It re-renders its caller on every change
152
+ of state.
153
+
154
+ `useExplorerApi()` returns the methods alone, the same stable functions, and
155
+ never re-renders its caller: use it in a component that only calls them, such
156
+ as a button of your own. Called twice in one tick, the methods see each other,
157
+ so `select('a'); select(null)` ends with nothing selected.
158
+
159
+ | Method | Does |
160
+ | --- | --- |
161
+ | `fit()`, `zoomBy(factor)` | the camera. No-ops before the viewport has a size |
162
+ | `focusNode(id)` | flies the camera to fit the node |
163
+ | `reveal(id)` | pans the least distance that brings the node into view |
164
+ | `select(id \| null)` | sets the current node, without opening the drawer |
165
+ | `inspect(id, trigger?)` | selects and opens the drawer. Focus returns to `trigger` when it closes |
166
+ | `closeDetails()` | closes the drawer |
167
+ | `selectView(id)` | switches view |
168
+ | `setQuery(query)`, `setTrace(on)` | search and trace |
169
+
170
+ ### Search, drawer and Escape
171
+
172
+ Search matches nodes whose text contains every whitespace-separated token,
173
+ case ignored. `Enter` inspects the first match and flies to it, and choosing a
174
+ result does the same. The result list stays mounted while a node is
175
+ inspected. Search is the complete way to every node: a node too small on
176
+ screen to read has no element, and the graph's accessible description says
177
+ so. The list shows the first `maxResults` matches in data order, then a line
178
+ saying how many more there are. The count and `Enter` cover every match, and
179
+ a longer query narrows the list.
180
+
181
+ When the drawer closes with focus inside it, focus returns to what opened
182
+ it, or to the search field if that element is gone, or to the root element
183
+ if there is no search field. A control of yours that closes the drawer keeps
184
+ its focus. A connection button in the drawer inspects its neighbor and keeps
185
+ the original opener. `Escape` closes the drawer from anywhere in the root,
186
+ with two places that keep their own order. In the search field it closes
187
+ the drawer first and clears the query second. On a node or the graph's
188
+ surface it closes the drawer first, keeping focus where it is, and releases
189
+ graph focus second. A control of yours that handles `Escape` and calls
190
+ `preventDefault()` keeps it.
191
+
192
+ ### Keyboard
193
+
194
+ The graph is one tab stop. Exactly one node is in the tab order: the
195
+ selected node, else the last node you focused from the keyboard, else the
196
+ node nearest the center of the view. That node always has an element, at
197
+ any zoom, so once the graph has been measured Tab always lands on a node.
198
+ Tab again leaves the graph. The graph's surface takes focus when you click
199
+ it, and is not in the tab order.
200
+
201
+ With a node focused:
202
+
203
+ | Key | Does |
204
+ | --- | --- |
205
+ | Arrow keys | move focus to the nearest node in that direction, on screen or not |
206
+ | `Shift` with an arrow | pans |
207
+ | `Enter`, `Space` | inspect the node |
208
+
209
+ With the surface focused, the arrow keys pan. In both cases `+` and `=` zoom
210
+ in, `-` zooms out and `0` fits. `Escape` closes the drawer if it is open,
211
+ and otherwise leaves the graph. A key with `Ctrl`, `Command` or `Alt` is left to the browser.
212
+
213
+ A node that takes focus from the keyboard is brought into view by the
214
+ least pan, at the current zoom. A node you click is not moved to.
215
+ "Keyboard focus" means focus that followed a key press: a pointer press ends
216
+ it, and only keyboard focus moves the camera.
217
+
218
+ Keys typed into content you render inside a node are left to that content.
219
+
220
+ Only the nodes on screen and large enough to read have elements, so a
221
+ screen reader finds only those in the graph. Search is the way to every
222
+ node: it reaches all of them whatever is on screen, and the graph's
223
+ accessible description says so and gives the node and edge counts.
224
+
225
+ The node in the tab order, the focused node and the selected node are
226
+ mounted outside `maxOverlayNodes`, so the page can hold a few more node
227
+ elements than the cap.
228
+
229
+ ### Server rendering
230
+
231
+ Every part renders on a server, with no DOM: layout is pure, so the HTML
232
+ carries the shell and the base layer, every node as a mark and every routed
233
+ edge. The camera and the node elements start on the client, once the
234
+ viewport is measured. Until then the plane is hidden, so no unscaled frame
235
+ is painted, and the graph has no tab stop. The viewport's height is fixed by
236
+ `--dagr-explorer-height` (or your `style`), so nothing shifts when it fits.
237
+ A data error throws `ExplorerDataError` on the server as it does on the
238
+ client.
239
+
240
+ ### Labels
241
+
242
+ Every string the parts show comes from `labels`, an `ExplorerLabels` object
243
+ whose neutral English defaults are `DEFAULT_EXPLORER_LABELS`. Pass any subset
244
+ to `ExplorerRoot` or `DagrExplorer`. Counts and names are formatters, such as
245
+ `matches(count)`, `moreMatches(count)` (the matches the capped list does not
246
+ show), `stats({ nodes, edges })`, `zoomLevel(percent)`, `zoomTo(label)` and
247
+ `inGroup(groupLabel)` (one group in a node's default accessible name, as in
248
+ "Store, in Data tier").
249
+
250
+ An inline object is fine: `labels={{ search: 'Find' }}` is kept by value,
251
+ so re-creating it on every render with the same contents changes nothing.
252
+ An inline formatter is a new function each time, and so a change; define it
253
+ outside the component to keep it stable.
254
+
255
+ ### Styling
256
+
257
+ The parts work with no stylesheet: what they need to function is inline. Each
258
+ carries a `data-dagr-explorer` hook (`root`, `views`, `search`, `trace`,
259
+ `viewport`, `node`, `details`, `toolbar` and others), and state hooks
260
+ `data-tier`, `data-selected`, `data-dimmed`, `data-dragging` and
261
+ `data-active`. Nothing names a host framework.
262
+
263
+ `@prnt/dagr-explorer/styles.css` is the optional default look, for a light
264
+ page. No module imports it. It reads these variables, which you set anywhere
265
+ above the explorer:
266
+
267
+ | Variable | For |
268
+ | --- | --- |
269
+ | `--dagr-explorer-accent` | selection and pressed controls |
270
+ | `--dagr-explorer-fg` | text |
271
+ | `--dagr-explorer-fg-muted` | secondary text, marks and edges |
272
+ | `--dagr-explorer-border` | borders |
273
+ | `--dagr-explorer-bg` | nodes, controls and the drawer |
274
+ | `--dagr-explorer-bg-subtle` | the graph's background, hover |
275
+ | `--dagr-explorer-focus` | focus rings |
276
+ | `--dagr-explorer-font-mono` | the zoom readout |
277
+
278
+ Its selectors are wrapped in `:where()`, so they have no specificity and any
279
+ rule of yours wins. The exceptions are the `:hover`, `:focus` and
280
+ `:focus-visible` rules, whose pseudo-class sits outside the `:where()`: each
281
+ weighs as one class, so override it with a class that comes later or with a
282
+ more specific selector.
283
+ `--dagr-explorer-height` sets the graph's height, 480px by default, with or
284
+ without the stylesheet.
285
+
286
+ ### The base layer is experimental
287
+
288
+ Nodes too small to read, edges and group outlines are drawn by a base layer,
289
+ SVG by default. `ExplorerViewport`'s `base` swaps it, through the
290
+ `ExplorerBase`, `ExplorerBaseProps`, `ExplorerCameraSource`,
291
+ `ExplorerEmphasis` and `ExplorerVisibleSet` types. They are exported and
292
+ experimental: a seam with one implementation is a guess, and they may change
293
+ when a native base over `DagrCanvas` lands and confirms or corrects them.
294
+ `ExplorerCamera` (`{ x, y, scale }`), what `camera.get()` returns, is
295
+ exported as a type too.
296
+
297
+ **The SVG base is smooth to about 4,000 nodes.** Panned at one and a half
298
+ times the fit zoom, where every node and edge is in the base, in Chromium
299
+ 153 on an Apple M4 (macOS, 16 GB, device pixel ratio 1), on 2026-10-04:
300
+
301
+ | Nodes | Edges | Median frame | 95th percentile | Frames dropped |
302
+ | --- | --- | --- | --- | --- |
303
+ | 500 | 643 | 16.7 ms | 18.3 ms (1 frame) | 0% |
304
+ | 1,000 | 1,314 | 16.7 ms | 18.3 ms (1 frame) | 0% |
305
+ | 2,000 | 2,661 | 16.7 ms | 18.1 ms (1 frame) | 0% |
306
+ | 4,000 | 5,407 | 16.7 ms | 18.4 ms (1 frame) | 1% |
307
+ | 8,000 | 10,826 | 16.7 ms | 33.4 ms (2 frames) | 11% |
308
+
309
+ A size is smooth when its 95th percentile frame stays within one 16.7 ms
310
+ frame, counted in frames because timestamps jitter by a millisecond or two.
311
+ The method and the harness are in the repository's `bench/browser`. Above
312
+ the ceiling the answer is a native base, which is not built yet.
313
+
314
+ ### Known browser differences
315
+
316
+ Checked on 2026-10-04 in Chromium 153 and WebKit 26.6 (Playwright 1.63), at
317
+ 1440 by 900 and at 390 by 844 with touch, against the docs demos. Every
318
+ check passed in both. What differs is the browsers, not the explorer:
319
+
320
+ - **WebKit, with macOS's default keyboard setting, leaves buttons out of the
321
+ Tab order,** as it does on every page: the view switcher, the trace toggle,
322
+ the search results and the toolbar. The graph's tab stop is still reached,
323
+ because the explorer gives that node `tabIndex` 0 explicitly.
324
+ - **WebKit stops a page from scrolling under a non-passive wheel listener
325
+ when the page's root sets `overscroll-behavior: none`.** The explorer holds
326
+ its wheel listener only while the graph has focus, so the page scrolls past
327
+ an unfocused graph, and a host's own non-passive wheel listener on an
328
+ ancestor would bring the problem back.
329
+ - **A phone has no wheel, so neither does the check.** The wheel and
330
+ `Ctrl` wheel checks were run on the 390 profile in Chromium and not in
331
+ WebKit, where Playwright has no wheel for a mobile page. The touch pinch
332
+ was checked in both, synthesized as pointer events, and in Chromium also
333
+ as real touch input.
334
+
335
+ ## The core
336
+
337
+ ```ts
338
+ import { layoutView, searchNodes } from '@prnt/dagr-explorer';
339
+ import type { ExplorerView } from '@prnt/dagr-explorer';
340
+
341
+ const view: ExplorerView = {
342
+ id: 'overview',
343
+ label: 'Overview',
344
+ nodes: [
345
+ { id: 'app', label: 'Application' },
346
+ { id: 'store', label: 'Store' },
347
+ ],
348
+ edges: [{ id: 'read', source: 'app', target: 'store' }],
349
+ };
350
+
351
+ const layout = layoutView(view);
352
+ layout.boxes.get('app'); // { x: 40, y: 40, width: 240, height: 120 }
353
+ layout.routes.get('read'); // [{ x: 280, y: 100 }, { x: 400, y: 100 }]
354
+
355
+ searchNodes(view.nodes, 'sto'); // [{ id: 'store', label: 'Store' }]
356
+ ```
357
+
358
+ These functions touch no DOM and render nothing, so they run on a server.
359
+
360
+ ## A node is an id and a label
361
+
362
+ Those two fields are all the explorer reads. Everything else about a node is
363
+ your own fields on a type that extends `ExplorerNode`, and that type flows
364
+ through to every function and every slot.
365
+
366
+ ## Sizes are declared, never measured
367
+
368
+ A node's size is its own `size`, else the view's `layout.nodeSize` (a value or
369
+ a function of the node), else 240 by 120. The explorer virtualizes node
370
+ content, and a node with no element cannot be measured.
371
+
372
+ A width or height that is not finite and greater than zero throws
373
+ `INVALID_NODE_SIZE`, naming the node.
374
+
375
+ ## Layout
376
+
377
+ `layoutView(view, options)` returns boxes, routes and group rectangles in world
378
+ space: y-down CSS pixels at zoom 1, padded 40 off the origin.
379
+
380
+ | `view.layout` | Default | |
381
+ | --- | --- | --- |
382
+ | `direction` | `'right'` | or `'down'` |
383
+ | `nodeSize` | 240 by 120 | a size, or a function of the node |
384
+ | `nodeSep` | 40 | gap between neighbors across the flow |
385
+ | `rankSep` | 120 | gap between ranks along the flow |
386
+ | `edgeStyle` | `'smooth'` | or `'orthogonal'` |
387
+
388
+ Three behaviors are the explorer's, not the layout engine's:
389
+
390
+ - **Parallel edges on one line bow apart.** Edges joining the same two nodes,
391
+ in either direction, are drawn on one line when they span a single rank.
392
+ Those are separated by 16, with both ends left on their nodes. A pair that
393
+ spans more ranks is left as the layout engine routed it, which is already
394
+ apart.
395
+ - **A self loop is not drawn.** An edge from a node to itself stays in your
396
+ data and has an empty route. It moves nothing.
397
+ - **A group moves no node.** It is the padded hull of its members with a band
398
+ above for its label. Pass `{ strictGroups: true }` to throw
399
+ `GROUP_ENCLOSES_NON_MEMBER` when an outline would overlap a node that is not
400
+ a member, even partly, for a diagram where that would be a false statement.
401
+ An outline that only touches a neighbor is not an overlap.
402
+
403
+ ## Search
404
+
405
+ `searchNodes(nodes, query, searchText)` returns the nodes whose text contains
406
+ every whitespace-separated token of the query, case ignored, in data order. The
407
+ text is the id and label unless you pass `searchText`, an accessor over your
408
+ own node type. An empty query matches nothing. The query is literal text, not a
409
+ pattern.
410
+
411
+ ## Errors
412
+
413
+ A malformed view throws `ExplorerDataError`. `ExplorerRoot` validates every
414
+ view and lays out the active one during render, so an error boundary
415
+ catches it. Switch on its `code`. Its `id` is
416
+ the view, node, edge or group the error is about, and its `viewId` is the view
417
+ that was found in (`undefined` when the error is about a view itself), so a
418
+ host can point at the offender without parsing the message.
419
+
420
+ `layoutView` validates its view first, so it throws these too. `validateView`
421
+ and `validateViews` run the same checks without laying out. Only
422
+ `validateViews` can raise `DUPLICATE_VIEW_ID`, since it is the only one that
423
+ sees more than one view.
424
+
425
+ | `code` | When |
426
+ | --- | --- |
427
+ | `INVALID_ID` | a view, node, edge or group has an empty id |
428
+ | `DUPLICATE_VIEW_ID` | two views share an id |
429
+ | `DUPLICATE_NODE_ID` | two nodes in one view share an id |
430
+ | `DUPLICATE_EDGE_ID` | two edges in one view share an id |
431
+ | `DUPLICATE_GROUP_ID` | two groups in one view share an id |
432
+ | `INVALID_NODE_SIZE` | a node's width or height is not finite and greater than zero |
433
+ | `INVALID_LAYOUT_OPTION` | a view's `nodeSep` or `rankSep` is not finite and zero or greater, or its `direction` or `edgeStyle` is not one of the allowed values |
434
+ | `MISSING_EDGE_ENDPOINT` | an edge names a node its view lacks |
435
+ | `MISSING_GROUP_MEMBER` | a group names a node its view lacks |
436
+ | `EMPTY_GROUP` | a group has no members |
437
+ | `GROUP_ENCLOSES_NON_MEMBER` | `strictGroups` only |
438
+
439
+ Ids may repeat across views.
440
+
441
+ MIT © prnt.design
package/dist/base.d.ts ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The base-layer seam: what draws every node the overlay does not mount.
3
+ *
4
+ * The viewport renders the overlay, capped, as DOM. Everything else in view
5
+ * (nodes as marks, edges, group outlines) is the base layer's, and the base
6
+ * is swappable: the SVG base is the default, and a native renderer can take
7
+ * its place for graphs past the SVG ceiling.
8
+ *
9
+ * `space` is the one fact the viewport needs about a base. A `'plane'` base
10
+ * renders inside the transformed plane in world coordinates, so a camera
11
+ * frame costs it nothing. A `'viewport'` base sits outside the plane and
12
+ * drives its own camera from `camera`, which tells it of every frame in the
13
+ * same frame the overlay moves, so the two never drift apart.
14
+ *
15
+ * **Experimental.** These types are exported, and may change until the
16
+ * native base lands and confirms or corrects them: a seam with one
17
+ * implementation is a guess.
18
+ */
19
+ import type { ComponentType } from 'react';
20
+ import type { ExplorerCamera } from './camera.js';
21
+ import type { ExplorerLayout } from './layout.js';
22
+ import type { ExplorerEdge, ExplorerNode, ExplorerView } from './types.js';
23
+ import type { ExplorerVisibleSet } from './visible-set.js';
24
+ /** What the base draws emphasized. Experimental, like the seam. */
25
+ export interface ExplorerEmphasis {
26
+ readonly selectedId: string | null;
27
+ /** Node ids drawn dimmed. Edges with a dimmed end are dimmed. */
28
+ readonly dimmed: ReadonlySet<string>;
29
+ }
30
+ /** The camera on screen, and every frame of it. Experimental, like the seam. */
31
+ export interface ExplorerCameraSource {
32
+ /** The camera on screen, or `null` before the first fit. */
33
+ get(): ExplorerCamera | null;
34
+ /**
35
+ * Calls `listener` on each drawn frame, right after the plane's transform
36
+ * is written. Returns the unsubscribe.
37
+ */
38
+ subscribe(listener: (camera: ExplorerCamera) => void): () => void;
39
+ }
40
+ /** What a base layer is given. Experimental: may change until a native base confirms it. */
41
+ export interface ExplorerBaseProps<N extends ExplorerNode, E extends ExplorerEdge> {
42
+ /**
43
+ * The view as given, less its layout options: `nodeSize` there is a
44
+ * function of `N`, which a base typed over the base node cannot accept,
45
+ * and the layout is already done.
46
+ */
47
+ readonly view: Omit<ExplorerView<N, E>, 'layout'>;
48
+ readonly layout: ExplorerLayout;
49
+ readonly visible: ExplorerVisibleSet;
50
+ readonly emphasis: ExplorerEmphasis;
51
+ readonly camera: ExplorerCameraSource;
52
+ }
53
+ /**
54
+ * A base layer: what draws every node the overlay does not mount, every
55
+ * edge and every group outline. Experimental: may change until a native
56
+ * base confirms it.
57
+ */
58
+ export interface ExplorerBase {
59
+ readonly Layer: ComponentType<ExplorerBaseProps<ExplorerNode, ExplorerEdge>>;
60
+ /** 'plane': rendered inside the transformed plane. 'viewport': handles the camera itself. */
61
+ readonly space: 'plane' | 'viewport';
62
+ }
63
+ //# sourceMappingURL=base.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"base.d.ts","sourceRoot":"","sources":["../src/base.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,OAAO,CAAC;AAC3C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAClD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAClD,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC3E,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAE3D,mEAAmE;AACnE,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IACnC,iEAAiE;IACjE,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;CACtC;AAED,gFAAgF;AAChF,MAAM,WAAW,oBAAoB;IACnC,4DAA4D;IAC5D,GAAG,IAAI,cAAc,GAAG,IAAI,CAAC;IAC7B;;;OAGG;IACH,SAAS,CAAC,QAAQ,EAAE,CAAC,MAAM,EAAE,cAAc,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;CACnE;AAED,4FAA4F;AAC5F,MAAM,WAAW,iBAAiB,CAAC,CAAC,SAAS,YAAY,EAAE,CAAC,SAAS,YAAY;IAC/E;;;;OAIG;IACH,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC,YAAY,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAC;IAClD,QAAQ,CAAC,MAAM,EAAE,cAAc,CAAC;IAChC,QAAQ,CAAC,OAAO,EAAE,kBAAkB,CAAC;IACrC,QAAQ,CAAC,QAAQ,EAAE,gBAAgB,CAAC;IACpC,QAAQ,CAAC,MAAM,EAAE,oBAAoB,CAAC;CACvC;AAED;;;;GAIG;AACH,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAC,iBAAiB,CAAC,YAAY,EAAE,YAAY,CAAC,CAAC,CAAC;IAC7E,6FAA6F;IAC7F,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,UAAU,CAAC;CACtC"}
package/dist/base.js ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The base-layer seam: what draws every node the overlay does not mount.
3
+ *
4
+ * The viewport renders the overlay, capped, as DOM. Everything else in view
5
+ * (nodes as marks, edges, group outlines) is the base layer's, and the base
6
+ * is swappable: the SVG base is the default, and a native renderer can take
7
+ * its place for graphs past the SVG ceiling.
8
+ *
9
+ * `space` is the one fact the viewport needs about a base. A `'plane'` base
10
+ * renders inside the transformed plane in world coordinates, so a camera
11
+ * frame costs it nothing. A `'viewport'` base sits outside the plane and
12
+ * drives its own camera from `camera`, which tells it of every frame in the
13
+ * same frame the overlay moves, so the two never drift apart.
14
+ *
15
+ * **Experimental.** These types are exported, and may change until the
16
+ * native base lands and confirms or corrects them: a seam with one
17
+ * implementation is a guess.
18
+ */
19
+ export {};
20
+ //# sourceMappingURL=base.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"base.js","sourceRoot":"","sources":["../src/base.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG"}