@rokkit/graph 1.6.0

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 (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -0
  3. package/dist/Graph.svelte +369 -0
  4. package/dist/Graph.svelte.d.ts +4 -0
  5. package/dist/GraphState.svelte.d.ts +171 -0
  6. package/dist/GraphState.svelte.js +398 -0
  7. package/dist/icons.d.ts +13 -0
  8. package/dist/icons.js +38 -0
  9. package/dist/index.d.ts +11 -0
  10. package/dist/index.js +8 -0
  11. package/dist/layout/cards.d.ts +19 -0
  12. package/dist/layout/cards.js +31 -0
  13. package/dist/layout/cluster.d.ts +11 -0
  14. package/dist/layout/cluster.js +39 -0
  15. package/dist/layout/clusters.d.ts +39 -0
  16. package/dist/layout/clusters.js +217 -0
  17. package/dist/layout/constants.d.ts +12 -0
  18. package/dist/layout/constants.js +15 -0
  19. package/dist/layout/edges.d.ts +5 -0
  20. package/dist/layout/edges.js +110 -0
  21. package/dist/layout/index.d.ts +9 -0
  22. package/dist/layout/index.js +7 -0
  23. package/dist/layout/neighborhood.d.ts +9 -0
  24. package/dist/layout/neighborhood.js +123 -0
  25. package/dist/layout/nested.d.ts +13 -0
  26. package/dist/layout/nested.js +156 -0
  27. package/dist/layout/points.d.ts +6 -0
  28. package/dist/layout/points.js +204 -0
  29. package/dist/layout/types.d.ts +115 -0
  30. package/dist/layout/types.js +1 -0
  31. package/dist/model/normalize.d.ts +7 -0
  32. package/dist/model/normalize.js +215 -0
  33. package/dist/model/path.d.ts +13 -0
  34. package/dist/model/path.js +23 -0
  35. package/dist/preset.d.ts +47 -0
  36. package/dist/preset.js +61 -0
  37. package/dist/schema/EntitiesView.svelte +83 -0
  38. package/dist/schema/EntitiesView.svelte.d.ts +13 -0
  39. package/dist/schema/EntityView.svelte +116 -0
  40. package/dist/schema/EntityView.svelte.d.ts +13 -0
  41. package/dist/schema/NoteBlocks.svelte +31 -0
  42. package/dist/schema/NoteBlocks.svelte.d.ts +6 -0
  43. package/dist/schema/column-type.d.ts +4 -0
  44. package/dist/schema/column-type.js +19 -0
  45. package/dist/schema/fromSchemaModel.d.ts +66 -0
  46. package/dist/schema/fromSchemaModel.js +92 -0
  47. package/dist/schema/index.d.ts +8 -0
  48. package/dist/schema/index.js +7 -0
  49. package/dist/schema/notes.d.ts +12 -0
  50. package/dist/schema/notes.js +65 -0
  51. package/dist/types.d.ts +160 -0
  52. package/dist/types.js +1 -0
  53. package/package.json +62 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Jerry Thomas
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,100 @@
1
+ # @rokkit/graph
2
+
3
+ Node-link diagram components for Svelte 5. ER diagrams, schema entity tables and dependency /
4
+ call graphs from one component, driven by field-mapped data rather than a fixed schema type.
5
+
6
+ ```bash
7
+ npm install @rokkit/graph
8
+ ```
9
+
10
+ `@rokkit/ui` and `svelte` are peer dependencies. `@rokkit/ui` is optional — only the
11
+ `./schema` views use it.
12
+
13
+ ## Usage
14
+
15
+ ```svelte
16
+ <script>
17
+ import { Graph } from '@rokkit/graph'
18
+
19
+ const services = [
20
+ { key: 'checkout', team: 'commerce', type: 'procedure' },
21
+ { key: 'ledger', team: 'finance', type: 'table' }
22
+ ]
23
+ const calls = [{ caller: 'checkout', callee: 'ledger' }]
24
+
25
+ // Your shape, mapped once. Nothing downstream sees your field names.
26
+ const fields = {
27
+ id: 'key',
28
+ label: 'key',
29
+ group: 'team',
30
+ kind: 'type',
31
+ source: 'caller',
32
+ target: 'callee'
33
+ }
34
+ </script>
35
+
36
+ <div class="relative h-[600px]">
37
+ <Graph nodes={services} edges={calls} {fields} />
38
+ </div>
39
+ ```
40
+
41
+ The canvas fills its nearest positioned ancestor, so give it a `relative` box with a height.
42
+
43
+ ## What it is
44
+
45
+ The package knows nodes, edges, groups and kinds — it has no opinion about databases. The dbd
46
+ vocabulary lives entirely in the optional `./schema` entry point.
47
+
48
+ | Import | Contains |
49
+ |--------|----------|
50
+ | `@rokkit/graph` | `Graph`, `GraphState`, layouts, presets, `normalizeGraph`, `DEFAULT_ICONS`, types |
51
+ | `@rokkit/graph/schema` | `EntityView`, `EntitiesView`, `NoteBlocks`, `fromSchemaModel`, `toGraphInput`, `SCHEMA_FIELDS`, `DEPS_FIELDS` |
52
+
53
+ ## Layouts
54
+
55
+ A layout is a pure `(model, options) => LayoutResult` — DOM-free, synchronous, deterministic.
56
+ Your own slots in the same way.
57
+
58
+ - **`cluster`** — cards in group boxes. The default; entity diagrams.
59
+ - **`neighborhood`** — one focus node and its 1-hop neighbours.
60
+ - **`points`** — degree-sized rects, shelf-packed. Dense graphs, 1000+ nodes.
61
+
62
+ Two-level clustering shows both grouping axes at once, which matters for a dependency graph
63
+ where one schema holds a table, a trigger and a procedure:
64
+
65
+ ```svelte
66
+ <Graph {nodes} {edges} {fields} groupBy="group" nestBy="kind" />
67
+ ```
68
+
69
+ ## Two things worth knowing
70
+
71
+ **An unplaced edge is dimmed, never dropped.** An endpoint the map cannot resolve is a normal
72
+ state at scale, not a defect — Sensei's code graph reports 59.6% of 4.08M edges with a null
73
+ target: the call is real, the callee is simply not indexed. Dropping them would make the graph
74
+ look far more complete than it is.
75
+
76
+ **A schema is two graphs.** dbd's v2 `SchemaModel` separates `tables`/`refs` from
77
+ `entities`/`deps` because an ER diagram is table entities and their foreign keys — a view is a
78
+ derived projection and a routine is behaviour, so neither belongs on that canvas:
79
+
80
+ ```js
81
+ import { toGraphInput } from '@rokkit/graph/schema'
82
+
83
+ const er = toGraphInput(model, 'er') // tables + refs
84
+ const deps = toGraphInput(model, 'dependencies') // tables + entities, deps as edges
85
+ ```
86
+
87
+ ## Theming
88
+
89
+ Unstyled by default; `@rokkit/themes` ships `graph.css` for all six styles. Every visual hook
90
+ is a data-attribute — `data-node-kind`, `data-edge-kind`, `data-edge-relation`,
91
+ `data-cluster-depth`, `data-graph-detail` and more.
92
+
93
+ Node kinds are a closed set, so they are plain CSS. Groups are open-ended, so they get a ramp
94
+ from `createGraphPreset({ using: 'color' | 'pattern' })`.
95
+
96
+ ## Docs
97
+
98
+ - [Package reference](../../docs/llms/packages/graph.txt) — data contract, layouts, dbd adapter
99
+ - [Component reference](../../docs/llms/components/graph.txt) — props, interaction, attributes
100
+ - [Design](../../docs/design/23-graph.md) — why it is its own package, and what is locked
@@ -0,0 +1,369 @@
1
+ <script lang="ts">
2
+ import { setContext, untrack } from 'svelte'
3
+ import { GraphState } from './GraphState.svelte.js'
4
+ import type { GraphStateConfig } from './GraphState.svelte.js'
5
+ import type { GraphProps } from './types.js'
6
+ import { DEFAULT_ICONS } from './icons.js'
7
+
8
+ // Aliased: a local binding literally named `state` makes the compiler read the `$state`
9
+ // rune below as a store subscription on it. The PUBLIC prop name is still `state`.
10
+ let {
11
+ state: provided,
12
+ nodes = [],
13
+ edges = [],
14
+ fields = {},
15
+ layout = 'cluster',
16
+ density = $bindable('keys'),
17
+ arrange = 'untangle',
18
+ groupBy = 'group',
19
+ nestBy = undefined,
20
+ edgeStyle = 'curved',
21
+ focus = null,
22
+ value = undefined,
23
+ preset = undefined,
24
+ mode = 'light',
25
+ zoom = $bindable(1),
26
+ zoomable = true,
27
+ densityToggle = true,
28
+ arrows = true,
29
+ label = undefined,
30
+ onselect = undefined,
31
+ icons: userIcons = undefined,
32
+ class: className = ''
33
+ }: GraphProps = $props()
34
+
35
+ // A getter thunk (not a plain object) so every reactive prop it closes over is re-read on
36
+ // each call — used both by the untracked initial construction and the live $effect below.
37
+ const config = (): GraphStateConfig => ({
38
+ nodes,
39
+ edges,
40
+ fields,
41
+ layout,
42
+ density,
43
+ arrange,
44
+ groupBy,
45
+ nestBy,
46
+ edgeStyle,
47
+ focus,
48
+ value,
49
+ preset,
50
+ mode,
51
+ label,
52
+ onselect
53
+ })
54
+
55
+ // untrack: the constructor's initial read must not register as a dependency of whatever
56
+ // scope creates <Graph> — mirrors Spark's `untrack(() => new SparkState(config()))`.
57
+ const own = untrack(() => new GraphState(config()))
58
+
59
+ // Resolved ONCE, not $derived. setContext runs a single time at init and captures the value
60
+ // it is handed, so publishing a $derived would put whichever object won the branch at that
61
+ // instant into context forever — a caller later swapping `state` (or going from none to one)
62
+ // would leave nested EntityView/EntitiesView reading the stale object, silently.
63
+ //
64
+ // PlotState and SparkState hold the same discipline: reactivity flows through mutating ONE
65
+ // object's $state fields, never through replacing which object is in context.
66
+ // svelte-ignore state_referenced_locally
67
+ const graph = provided ?? own
68
+
69
+ setContext('graph-state', graph)
70
+
71
+ // Only sync the state WE own. A caller-supplied state is theirs to drive.
72
+ $effect(() => {
73
+ if (!provided) own.update(config())
74
+ })
75
+
76
+ const icons = $derived<Record<string, string>>({ ...DEFAULT_ICONS, ...userIcons })
77
+
78
+ // Fit-to-container, multiplied by `zoom`. These stay in the component because they depend
79
+ // on the rendered viewport, which state cannot know. The content extent they divide by is
80
+ // `graph.contentSize` — a pure derivation, so it lives in state.
81
+ //
82
+ // `fit` alone shrinks a real schema until its labels are unreadable, which is the whole
83
+ // reason zoom exists.
84
+ const PAD = 28
85
+ const ZOOM_MIN = 0.25
86
+ const ZOOM_MAX = 4
87
+ const ZOOM_STEP = 1.25
88
+
89
+ let vw = $state(0)
90
+ let vh = $state(0)
91
+ let paper = $state<HTMLElement | null>(null)
92
+
93
+ const clampZoom = (value: number) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, value))
94
+
95
+ /**
96
+ * Writes the STATE, then mirrors into the bindable prop. Both halves matter: the state is
97
+ * what renders (and may be caller-supplied, where the prop never reaches it), and the
98
+ * prop is what a `bind:density` consumer observes — without the mirror its own effect
99
+ * would re-apply the stale value and undo the click.
100
+ */
101
+ function setDensity(next: typeof density) {
102
+ graph.setDensity(next)
103
+ density = next
104
+ }
105
+
106
+ function zoomBy(factor: number) {
107
+ zoom = clampZoom(zoom * factor)
108
+ }
109
+
110
+ /**
111
+ * Ctrl/meta + wheel is what a trackpad pinch reports as. Without preventDefault the
112
+ * browser zooms the whole PAGE instead of the diagram, which is the behaviour a reader
113
+ * hits first and reads as the component ignoring them.
114
+ *
115
+ * A plain wheel is left alone: the canvas scrolls, which is how panning works here.
116
+ */
117
+ function onWheel(event: WheelEvent) {
118
+ if (!zoomable || !(event.ctrlKey || event.metaKey)) return
119
+ event.preventDefault()
120
+ zoomBy(event.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP)
121
+ }
122
+
123
+ // Drag-to-pan on the background. Scrolling already reaches the overflow, so this is the
124
+ // direct-manipulation path on top of it, not the only way across.
125
+ let panning = $state(false)
126
+ let panFrom = { x: 0, y: 0, left: 0, top: 0 }
127
+
128
+ function onPointerDown(event: PointerEvent) {
129
+ // Only the background drags — a press that starts on a card is a selection.
130
+ if (!paper || (event.target as HTMLElement).closest('[data-graph-node]')) return
131
+ panning = true
132
+ panFrom = { x: event.clientX, y: event.clientY, left: paper.scrollLeft, top: paper.scrollTop }
133
+ paper.setPointerCapture(event.pointerId)
134
+ }
135
+
136
+ function onPointerMove(event: PointerEvent) {
137
+ if (!panning || !paper) return
138
+ paper.scrollLeft = panFrom.left - (event.clientX - panFrom.x)
139
+ paper.scrollTop = panFrom.top - (event.clientY - panFrom.y)
140
+ }
141
+
142
+ function endPan() {
143
+ panning = false
144
+ }
145
+
146
+ const fit = $derived(
147
+ Math.max(
148
+ 0.08,
149
+ Math.min((vw - PAD * 2) / graph.contentSize.w, (vh - PAD * 2) / graph.contentSize.h, 1)
150
+ ) || 0.5
151
+ )
152
+ const scale = $derived(fit * zoom)
153
+ // Centre while the content is smaller than the viewport; once it is larger, pin to a
154
+ // padding offset so scrolling reaches the far edge instead of clipping it.
155
+ const tx = $derived(Math.max(PAD, (vw - graph.contentSize.w * scale) / 2))
156
+ const ty = $derived(Math.max(PAD, (vh - graph.contentSize.h * scale) / 2))
157
+ const zoomPercent = $derived(Math.round(zoom * 100))
158
+
159
+ /**
160
+ * Level of detail, from the EFFECTIVE scale rather than from `zoom` — a diagram fitted to
161
+ * 0.05 is unreadable whether or not the reader touched the zoom control.
162
+ *
163
+ * Measured on a 1000-node call graph fitted into 1440x900: scale 0.047, so a 248px card
164
+ * renders 11.6px wide and a 12px label lands at 0.56px. Drawing that text costs layout
165
+ * and paint for glyphs nobody can see, so each tier drops what has stopped being legible
166
+ * and the card keeps its colour, shape and position — which is what still reads at that
167
+ * size. Hovering a node brings its label back at any tier.
168
+ */
169
+ const detail = $derived(
170
+ scale >= 0.6 ? 'full' : scale >= 0.32 ? 'compact' : scale >= 0.14 ? 'minimal' : 'dot'
171
+ )
172
+ </script>
173
+
174
+ <!-- The viewport fills its nearest positioned ancestor. Place it inside a `relative` box
175
+ with a height.
176
+
177
+ The accessible name lives on the <svg> below, NOT here: role="img" makes its whole
178
+ subtree presentational, so putting it on this root would hide every node button from
179
+ assistive tech. The svg holds only edge geometry, which is genuinely one graphic.
180
+
181
+ role="presentation" is what this is — a positioned surface. Click-the-background-to-
182
+ dismiss is not the only way out: Escape does the same, and every node is a real
183
+ <button>, so nothing here is keyboard-inaccessible. -->
184
+ <div data-graph-viewport class={className}>
185
+ <div
186
+ data-graph-paper
187
+ data-graph-detail={detail}
188
+ data-graph-panning={panning ? '' : undefined}
189
+ role="presentation"
190
+ bind:this={paper}
191
+ bind:clientWidth={vw}
192
+ bind:clientHeight={vh}
193
+ onclick={() => graph.clear()}
194
+ onkeydown={(e) => e.key === 'Escape' && graph.clear()}
195
+ onwheel={onWheel}
196
+ onpointerdown={onPointerDown}
197
+ onpointermove={onPointerMove}
198
+ onpointerup={endPan}
199
+ onpointercancel={endPan}
200
+ tabindex="-1"
201
+ >
202
+ <div
203
+ data-graph-world
204
+ data-graph-layout={graph.layoutName}
205
+ style="width: {graph.size.w}px; height: {graph.size.h}px; transform: translate({tx}px, {ty}px) scale({scale}); --graph-label-counter-scale: {(1 / scale).toFixed(3)};"
206
+ >
207
+ {#each graph.clusters as cluster (graph.clusterKey(cluster))}
208
+ <div
209
+ data-graph-cluster
210
+ data-cluster-depth={cluster.depth ?? 0}
211
+ data-node-group={cluster.name}
212
+ style:left="{cluster.x}px"
213
+ style:top="{cluster.y}px"
214
+ style:width="{cluster.w}px"
215
+ style:height="{cluster.h}px"
216
+ style={graph.groupStyleAttr(cluster.name)}
217
+ >
218
+ <span data-graph-cluster-label>{cluster.name} · {cluster.count}</span>
219
+ </div>
220
+ {/each}
221
+
222
+ <svg
223
+ width={graph.size.w}
224
+ height={graph.size.h}
225
+ role="img"
226
+ aria-label={graph.label}
227
+ style="position: absolute; top: 0; left: 0; pointer-events: none;"
228
+ >
229
+ <title>{graph.label}</title>
230
+ {#each graph.routedEdges as edge (edge.id)}
231
+ <g
232
+ data-graph-edge
233
+ data-edge-kind={edge.kind}
234
+ data-edge-relation={edge.relation}
235
+ data-edge-from={edge.fromKey}
236
+ data-edge-to={edge.toKey}
237
+ data-edge-state={graph.edgeState(edge)}
238
+ >
239
+ <path d={graph.edgePath(edge)} />
240
+ <circle data-graph-edge-dot="from" cx={edge.x1} cy={edge.y1} r="3.2" />
241
+ {#if arrows}
242
+ <!-- An edge HAS a direction — source to target — and two identical dots threw
243
+ that away. The tangent of both path shapes is horizontal at the endpoint,
244
+ so the heading is just the side the curve arrives from: -s2. No angle
245
+ maths, and it stays correct for a self-loop. -->
246
+ {@const d = -edge.s2}
247
+ <polygon
248
+ data-graph-edge-arrow
249
+ points="{edge.x2},{edge.y2} {edge.x2 - d * 9},{edge.y2 - 4.5} {edge.x2 -
250
+ d * 9},{edge.y2 + 4.5}"
251
+ />
252
+ {:else}
253
+ <circle data-graph-edge-dot="to" cx={edge.x2} cy={edge.y2} r="3.2" />
254
+ {/if}
255
+ </g>
256
+ {/each}
257
+ </svg>
258
+
259
+ {#each Object.entries(graph.cards) as [key, card] (key)}
260
+ <button
261
+ type="button"
262
+ data-graph-node={key}
263
+ data-node-kind={card.node.kind}
264
+ data-node-group={card.node.group}
265
+ data-node-state={graph.nodeState(key)}
266
+ data-node-headonly={card.vis.length === 0 && card.more <= 0 ? '' : undefined}
267
+ style:left="{card.x}px"
268
+ style:top="{card.y}px"
269
+ style:width="{card.w}px"
270
+ style={graph.groupStyleAttr(card.node.group)}
271
+ onclick={(event) => {
272
+ event.stopPropagation()
273
+ graph.select(key)
274
+ }}
275
+ >
276
+ <span data-graph-node-head>
277
+ <span data-graph-node-icon class={icons[card.node.kind ?? ''] ?? icons.fallback}></span>
278
+ <span data-graph-node-title>{card.node.label}</span>
279
+ {#if card.node.kind}
280
+ <!-- The glyph alone is a guess: layers vs eye vs bolt does not tell anyone what a
281
+ materialized view is. Icon for the glance, tag for the answer. Underscores
282
+ are the WIRE format (dbd sends `materialized_view`), not something to
283
+ read. -->
284
+ <span data-graph-node-kind>{card.node.kind.replace(/_/g, ' ')}</span>
285
+ {/if}
286
+ <span data-graph-node-count>{card.node.rows.length}</span>
287
+ </span>
288
+ {#each card.vis as row (row.name)}
289
+ <span data-graph-row data-graph-row-key={row.badges.length > 0 ? '' : undefined}>
290
+ {#if row.badges.length > 0}
291
+ <span data-row-badge={row.badges[0]} class={icons[row.badges[0]]}></span>
292
+ {:else}
293
+ <span data-row-badge-empty></span>
294
+ {/if}
295
+ <span data-graph-row-name>{row.name}</span>
296
+ <span data-graph-row-type>{row.type}</span>
297
+ </span>
298
+ {/each}
299
+ {#if graph.moreLabel(key)}
300
+ <!-- A real control. "+3 more" that does nothing is a statement dressed as an
301
+ affordance; this expands just this card. -->
302
+ <span
303
+ role="button"
304
+ tabindex="0"
305
+ data-graph-more
306
+ data-graph-more-empty={card.vis.length === 0 && !graph.isExpanded(key)
307
+ ? ''
308
+ : undefined}
309
+ data-expanded={graph.isExpanded(key) ? '' : undefined}
310
+ onclick={(event) => {
311
+ event.stopPropagation()
312
+ graph.toggleExpanded(key)
313
+ }}
314
+ onkeydown={(event) => {
315
+ if (event.key !== 'Enter' && event.key !== ' ') return
316
+ event.preventDefault()
317
+ event.stopPropagation()
318
+ graph.toggleExpanded(key)
319
+ }}
320
+ >{graph.moreLabel(key)}</span
321
+ >
322
+ {/if}
323
+ </button>
324
+ {/each}
325
+ </div>
326
+ </div>
327
+
328
+ {#if densityToggle}
329
+ <!-- On the canvas for the same reason zoom is: "+3 more" tells a reader something is
330
+ hidden; this is the control that does something about it for every card at once. -->
331
+ <div data-graph-density-controls role="group" aria-label="Detail level">
332
+ {#each [['names', 'Names', 'Titles only'], ['keys', 'Keys', 'Key rows only'], ['full', 'All', 'All rows']] as [value, short, title] (value)}
333
+ <button
334
+ type="button"
335
+ data-graph-density={value}
336
+ data-selected={graph.density === value ? '' : undefined}
337
+ aria-pressed={graph.density === value}
338
+ {title}
339
+ onclick={() => setDensity(value as typeof density)}>{short}</button
340
+ >
341
+ {/each}
342
+ </div>
343
+ {/if}
344
+
345
+ {#if zoomable}
346
+ <!-- On-canvas, because a zoom control that lives in someone else's settings drawer is
347
+ a control a reader never finds. Buttons rather than a slider: each is one tab stop
348
+ and one keypress, so this works without a trackpad. -->
349
+ <div data-graph-zoom-controls>
350
+ <button
351
+ type="button"
352
+ data-graph-zoom="out"
353
+ aria-label="Zoom out"
354
+ disabled={zoom <= ZOOM_MIN}
355
+ onclick={() => zoomBy(1 / ZOOM_STEP)}>−</button
356
+ >
357
+ <button type="button" data-graph-zoom="reset" aria-label="Reset zoom to fit" onclick={() => (zoom = 1)}
358
+ >{zoomPercent}%</button
359
+ >
360
+ <button
361
+ type="button"
362
+ data-graph-zoom="in"
363
+ aria-label="Zoom in"
364
+ disabled={zoom >= ZOOM_MAX}
365
+ onclick={() => zoomBy(ZOOM_STEP)}>+</button
366
+ >
367
+ </div>
368
+ {/if}
369
+ </div>
@@ -0,0 +1,4 @@
1
+ import type { GraphProps } from './types.js';
2
+ declare const Graph: import("svelte").Component<GraphProps, {}, "density" | "zoom">;
3
+ type Graph = ReturnType<typeof Graph>;
4
+ export default Graph;
@@ -0,0 +1,171 @@
1
+ import { SvelteSet } from 'svelte/reactivity';
2
+ import type { GraphPreset } from './preset.js';
3
+ import type { Arrange, Cards, Cluster, Density, EdgeStyle, LayoutFn, NodeAxis, RoutedEdge, Size } from './layout/types.js';
4
+ import type { GraphEdge, GraphFields, GraphModel, GraphNode } from './types.js';
5
+ export type EntityRow = {
6
+ id: string;
7
+ label: string;
8
+ group?: string;
9
+ kind?: string;
10
+ rowCount: number;
11
+ refCount: number;
12
+ note?: string;
13
+ };
14
+ export type Relationship = {
15
+ direction: 'in' | 'out';
16
+ id: string;
17
+ label: string;
18
+ group?: string;
19
+ /** The canonical edge — always present. A relationship is a fact about the model. */
20
+ edge: GraphEdge;
21
+ /** Routed geometry, present only when the ACTIVE layout placed this edge. */
22
+ routed?: RoutedEdge;
23
+ };
24
+ export type GraphStateConfig = {
25
+ nodes?: unknown[];
26
+ edges?: unknown[];
27
+ fields?: GraphFields;
28
+ layout?: string | LayoutFn;
29
+ density?: Density;
30
+ arrange?: Arrange;
31
+ /** Outer grouping axis. Defaults to `group` (the schema, for dbd data). */
32
+ groupBy?: NodeAxis;
33
+ /** Second axis subdividing each outer cluster. Unset means one level. */
34
+ nestBy?: NodeAxis | null;
35
+ edgeStyle?: EdgeStyle;
36
+ focus?: string | null;
37
+ value?: string | null;
38
+ preset?: GraphPreset;
39
+ mode?: 'light' | 'dark';
40
+ label?: string;
41
+ onselect?: (id: string) => void;
42
+ };
43
+ /**
44
+ * The store. Turns raw `nodes`/`edges`/`fields` into the reactive shape the visuals
45
+ * render, and owns every transition.
46
+ *
47
+ * Components read from this and call its methods — they never compute. That is what
48
+ * lets the geometry, badge derivation and selection logic be covered exhaustively
49
+ * with no DOM, and lets the component specs assert only attributes.
50
+ *
51
+ * `update()` is re-callable and FULLY re-applies config rather than merging deltas,
52
+ * matching `SparkState.update` — so a prop reverting to undefined actually reverts.
53
+ * Deliberately NOT `PlotState.update`, which guards each field with
54
+ * `if (config.X !== undefined)` and therefore merges.
55
+ */
56
+ export declare class GraphState {
57
+ #private;
58
+ constructor(config?: GraphStateConfig);
59
+ /**
60
+ * Fully re-applies config. Safe to call on every prop change.
61
+ *
62
+ * Split across two helpers only to stay under the complexity bar — every field is still
63
+ * assigned unconditionally on every call, which is the contract the specs enforce
64
+ * field by field.
65
+ */
66
+ update(config?: GraphStateConfig): void;
67
+ select(id: string): void;
68
+ clear(): void;
69
+ /**
70
+ * Show one node's rows in full, whatever the density says.
71
+ *
72
+ * Per NODE rather than a global density change: a reader who clicks "+3 more" on one
73
+ * card is asking about that card, and expanding all of them answers a question they did
74
+ * not ask — on a large diagram it also relayouts everything under them.
75
+ */
76
+ toggleExpanded(id: string): void;
77
+ isExpanded(id: string): boolean;
78
+ /**
79
+ * The more-row's text, or null when the card is hiding nothing and needs no control.
80
+ *
81
+ * The "no keys" case is the whole reason this is a method rather than a template
82
+ * expression. A view, a procedure and an enum have no pk and no fk — correctly, they are
83
+ * not tables — so at 'keys' their cards collapse to a title and "+3 more", which is
84
+ * indistinguishable from a card the reader collapsed and reads as a rendering failure.
85
+ * The count alone cannot say WHY the rows are hidden; naming the empty filter can.
86
+ *
87
+ * Scoped to 'keys' because 'names' hides every row BY DESIGN — an empty card there is the
88
+ * answer to what was asked, not a filter that matched nothing.
89
+ */
90
+ moreLabel(id: string): string | null;
91
+ /**
92
+ * Change the detail level from inside the component.
93
+ *
94
+ * Needed because `Graph`'s `density` PROP only ever reaches the state it owns. With a
95
+ * caller-supplied state — which is how all three views share one — a control that set the
96
+ * prop would render, click, and change nothing.
97
+ */
98
+ setDensity(density: Density): void;
99
+ nodeState(id: string): 'selected' | 'related' | 'dim' | null;
100
+ edgeState(edge: RoutedEdge): 'highlight' | 'dim' | null;
101
+ edgePath(edge: RoutedEdge): string;
102
+ groupStyle(group: string | undefined): Record<string, string>;
103
+ /**
104
+ * The same style as a `style=` attribute string.
105
+ *
106
+ * A template cannot spread an object into `style`, so something has to serialise it.
107
+ * Doing that inline put the same map/join in two places in `Graph.svelte` — a derivation
108
+ * living in a component, which is the one thing this layer exists to prevent.
109
+ */
110
+ groupStyleAttr(group: string | undefined): string;
111
+ get model(): GraphModel;
112
+ get clusters(): Cluster[];
113
+ get cards(): Cards;
114
+ get routedEdges(): RoutedEdge[];
115
+ get size(): Size;
116
+ /**
117
+ * The true content extent, as distinct from `size`.
118
+ *
119
+ * `LayoutResult.size` adds a +60 margin on the right and bottom only, so fitting to it hugs
120
+ * the left/top edge and floats away from the right/bottom. Centring needs the real bounds.
121
+ *
122
+ * This lives in state, not in `Graph.svelte`, because it is a pure max over `clusters` — no
123
+ * viewport involved. Only the `scale`/`tx`/`ty` that consume it need `clientWidth`, and those
124
+ * stay in the component. Keeping this here is what lets it be tested without a renderer.
125
+ */
126
+ get contentSize(): Size;
127
+ get related(): SvelteSet<string>;
128
+ get entities(): EntityRow[];
129
+ get entity(): GraphNode | null;
130
+ get relationships(): Relationship[];
131
+ /**
132
+ * Edges the field map could not place at one or both ends.
133
+ *
134
+ * Exposed rather than hidden because at scale this is the MAJORITY: Sensei's code graph
135
+ * reports 59.6% of 4.08M edges with a null target. A view that silently omitted them
136
+ * would claim a completeness the data does not have. They are absent from `routedEdges`
137
+ * because there is no card to draw them between — that is geometry, not a judgement.
138
+ */
139
+ get unplacedEdges(): GraphEdge[];
140
+ get density(): Density;
141
+ get arrange(): Arrange;
142
+ /**
143
+ * A cluster's unique render key.
144
+ *
145
+ * NOT the name: nesting puts a `table` box under `public` AND under `billing`, and keying
146
+ * an `{#each}` by name alone is a duplicate key — Svelte throws `each_key_duplicate`, the
147
+ * render aborts, and no inner box appears at all, which reads as nesting silently not
148
+ * working rather than as an error.
149
+ */
150
+ clusterKey(cluster: Cluster): string;
151
+ get groupBy(): NodeAxis;
152
+ get nestBy(): NodeAxis | null;
153
+ /**
154
+ * Change the grouping axes from inside the component.
155
+ *
156
+ * Same reason `setDensity` exists: a `groupBy` PROP only ever reaches the state `Graph`
157
+ * owns, so with a caller-supplied state — which is how all three views share one — a
158
+ * control that set the prop would render, click, and change nothing.
159
+ *
160
+ * Passing the same axis for both is rejected rather than silently rendered: a box
161
+ * subdivided by itself produces exactly one child containing everything, which looks like
162
+ * a rendering fault rather than a no-op.
163
+ */
164
+ setGrouping(outer: NodeAxis, inner?: NodeAxis | null): void;
165
+ get edgeStyle(): EdgeStyle;
166
+ get mode(): 'light' | 'dark';
167
+ /** The layout's registry key, or 'custom' when a LayoutFn was passed directly. */
168
+ get layoutName(): string;
169
+ get value(): string | null;
170
+ get label(): string;
171
+ }