@rokkit/graph 1.6.0 → 1.8.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 (82) hide show
  1. package/README.md +66 -2
  2. package/dist/Graph.svelte +219 -169
  3. package/dist/GraphLegend.svelte +112 -0
  4. package/dist/GraphLegend.svelte.d.ts +33 -0
  5. package/dist/GraphState.svelte.d.ts +115 -15
  6. package/dist/GraphState.svelte.js +213 -5
  7. package/dist/controls/BundleControl.svelte +30 -0
  8. package/dist/controls/BundleControl.svelte.d.ts +16 -0
  9. package/dist/controls/DensityControl.svelte +39 -0
  10. package/dist/controls/DensityControl.svelte.d.ts +16 -0
  11. package/dist/controls/DepthControl.svelte +34 -0
  12. package/dist/controls/DepthControl.svelte.d.ts +17 -0
  13. package/dist/controls/EdgeStyleControl.svelte +31 -0
  14. package/dist/controls/EdgeStyleControl.svelte.d.ts +14 -0
  15. package/dist/controls/ZoomControl.svelte +47 -0
  16. package/dist/controls/ZoomControl.svelte.d.ts +7 -0
  17. package/dist/controls/zoom.d.ts +6 -0
  18. package/dist/controls/zoom.js +12 -0
  19. package/dist/diagrams/CallTree.svelte +135 -0
  20. package/dist/diagrams/CallTree.svelte.d.ts +37 -0
  21. package/dist/diagrams/DependencyDiagram.svelte +124 -0
  22. package/dist/diagrams/DependencyDiagram.svelte.d.ts +30 -0
  23. package/dist/diagrams/DependencyMatrix.svelte +177 -0
  24. package/dist/diagrams/DependencyMatrix.svelte.d.ts +34 -0
  25. package/dist/diagrams/DiagramFrame.svelte +36 -0
  26. package/dist/diagrams/DiagramFrame.svelte.d.ts +20 -0
  27. package/dist/diagrams/ErDiagram.svelte +128 -0
  28. package/dist/diagrams/ErDiagram.svelte.d.ts +33 -0
  29. package/dist/diagrams/Neighborhood.svelte +127 -0
  30. package/dist/diagrams/Neighborhood.svelte.d.ts +33 -0
  31. package/dist/diagrams/StructureDiagram.svelte +131 -0
  32. package/dist/diagrams/StructureDiagram.svelte.d.ts +31 -0
  33. package/dist/diagrams/Sunburst.svelte +124 -0
  34. package/dist/diagrams/Sunburst.svelte.d.ts +32 -0
  35. package/dist/diagrams/Treemap.svelte +124 -0
  36. package/dist/diagrams/Treemap.svelte.d.ts +32 -0
  37. package/dist/icons.d.ts +5 -0
  38. package/dist/icons.js +5 -0
  39. package/dist/index.d.ts +21 -1
  40. package/dist/index.js +25 -1
  41. package/dist/layout/arc.d.ts +13 -0
  42. package/dist/layout/arc.js +58 -0
  43. package/dist/layout/bundle.d.ts +30 -0
  44. package/dist/layout/bundle.js +80 -0
  45. package/dist/layout/cluster.js +2 -0
  46. package/dist/layout/edges.js +11 -0
  47. package/dist/layout/flow.d.ts +8 -0
  48. package/dist/layout/flow.js +141 -0
  49. package/dist/layout/hierarchy.d.ts +42 -0
  50. package/dist/layout/hierarchy.js +175 -0
  51. package/dist/layout/index.d.ts +7 -1
  52. package/dist/layout/index.js +8 -2
  53. package/dist/layout/matrix.d.ts +38 -0
  54. package/dist/layout/matrix.js +90 -0
  55. package/dist/layout/neighborhood.js +164 -28
  56. package/dist/layout/options.d.ts +44 -0
  57. package/dist/layout/options.js +106 -0
  58. package/dist/layout/order.d.ts +11 -0
  59. package/dist/layout/order.js +153 -0
  60. package/dist/layout/points.js +27 -35
  61. package/dist/layout/radial.d.ts +16 -0
  62. package/dist/layout/radial.js +214 -0
  63. package/dist/layout/rank.d.ts +15 -0
  64. package/dist/layout/rank.js +105 -0
  65. package/dist/layout/rings.d.ts +20 -0
  66. package/dist/layout/rings.js +92 -0
  67. package/dist/layout/sizing.d.ts +37 -0
  68. package/dist/layout/sizing.js +79 -0
  69. package/dist/layout/squarify.d.ts +14 -0
  70. package/dist/layout/squarify.js +114 -0
  71. package/dist/layout/structure.d.ts +9 -0
  72. package/dist/layout/structure.js +367 -0
  73. package/dist/layout/sunburst.d.ts +8 -0
  74. package/dist/layout/sunburst.js +126 -0
  75. package/dist/layout/types.d.ts +145 -0
  76. package/dist/layout/world.d.ts +9 -0
  77. package/dist/layout/world.js +144 -0
  78. package/dist/model/normalize.js +66 -4
  79. package/dist/model/tree.d.ts +27 -0
  80. package/dist/model/tree.js +182 -0
  81. package/dist/types.d.ts +75 -3
  82. package/package.json +9 -4
package/README.md CHANGED
@@ -47,17 +47,50 @@ vocabulary lives entirely in the optional `./schema` entry point.
47
47
 
48
48
  | Import | Contains |
49
49
  |--------|----------|
50
- | `@rokkit/graph` | `Graph`, `GraphState`, layouts, presets, `normalizeGraph`, `DEFAULT_ICONS`, types |
50
+ | `@rokkit/graph` | `Graph`, `GraphState`, layouts, presets, `normalizeGraph`, `DEFAULT_ICONS`, `appliesTo`, types |
51
51
  | `@rokkit/graph/schema` | `EntityView`, `EntitiesView`, `NoteBlocks`, `fromSchemaModel`, `toGraphInput`, `SCHEMA_FIELDS`, `DEPS_FIELDS` |
52
+ | `@rokkit/graph/icons` | `DEFAULT_ICONS` alone, with no Svelte imports — for your UnoCSS safelist |
53
+
54
+ ## Named diagrams
55
+
56
+ `Graph` is the canvas and ships no chrome. A named diagram is the composition already made —
57
+ one layout, the controls that mean something for it, an opt-in legend. The same shape
58
+ `@rokkit/chart` uses, where `BarChart` and `LineChart` sit over one `Plot`.
59
+
60
+ ```svelte
61
+ <script>
62
+ import { ErDiagram, CallTree, Treemap, Sunburst } from '@rokkit/graph'
63
+ </script>
64
+
65
+ <ErDiagram {nodes} {edges} {fields} controls legend />
66
+ ```
67
+
68
+ `ErDiagram` · `DependencyDiagram` · `CallTree` · `Treemap` · `Sunburst` · `StructureDiagram` ·
69
+ `Neighborhood` · `DependencyMatrix` (a dependency structure matrix — cells above the diagonal are
70
+ dependencies against the grain).
71
+ Both `controls` and `legend` default to off: the default is the bare picture.
72
+
73
+ Composing your own is the same parts in a different box — `DensityControl`, `EdgeStyleControl`,
74
+ `DepthControl`, `ZoomControl` and `GraphLegend` are all exported, and none of them stores a
75
+ value. They report what was chosen and you own it.
52
76
 
53
77
  ## Layouts
54
78
 
55
79
  A layout is a pure `(model, options) => LayoutResult` — DOM-free, synchronous, deterministic.
56
80
  Your own slots in the same way.
57
81
 
58
- - **`cluster`** — cards in group boxes. The default; entity diagrams.
82
+ - **`flow`** — cards in columns by reference direction. **The default.** Every edge leaves
83
+ right and enters left, so direction reads off the geometry and links stay traceable. Pair it
84
+ with `groupTint` to keep schema visible without cluster boxes.
85
+ - **`cluster`** — cards in group boxes. Entity diagrams grouped by schema.
59
86
  - **`neighborhood`** — one focus node and its 1-hop neighbours.
87
+ - **`radial`** — a tidy tree or dendrogram around a circle. Call graphs: angle separates the
88
+ subtrees, radius carries the depth.
60
89
  - **`points`** — degree-sized rects, shelf-packed. Dense graphs, 1000+ nodes.
90
+ - **`world`** — nested rectangles, area proportional to a measure. Where the mass is.
91
+ - **`sunburst`** — the same containment as nested wedges, with depth on the radius.
92
+ - **`structure`** — a codebase as a radial dendrogram with its calls bundled through the
93
+ hierarchy. What `radial` cannot be: containment is a real tree where a call graph is not.
61
94
 
62
95
  Two-level clustering shows both grouping axes at once, which matters for a dependency graph
63
96
  where one schema holds a table, a trigger and a procedure:
@@ -66,6 +99,25 @@ where one schema holds a table, a trigger and a procedure:
66
99
  <Graph {nodes} {edges} {fields} groupBy="group" nestBy="kind" />
67
100
  ```
68
101
 
102
+ `world` returns no cards: a treemap nests one shape, so a leaf is a `Cluster` too, carrying
103
+ `nodeId` and `kind` so it stays selectable. Its caption is the measure driving its area, not a
104
+ child count.
105
+
106
+ Not every option reaches every layout, and a control wired to one that does not is worse than a
107
+ missing one — it moves and nothing happens:
108
+
109
+ ```js
110
+ import { appliesTo } from '@rokkit/graph'
111
+
112
+ appliesTo('world', 'density') // false — a treemap box has no row list to thin
113
+ ```
114
+
115
+ ## Overlay edges
116
+
117
+ An edge with `overlay: true` (or `fields.overlay`) is drawn over the picture but never shapes it —
118
+ co-change pairs, a broken layering rule. It never reaches the layout, neighbours or relationships,
119
+ renders with `data-edge-overlay`, and its `weight` thickens the stroke via `--edge-weight`.
120
+
69
121
  ## Two things worth knowing
70
122
 
71
123
  **An unplaced edge is dimmed, never dropped.** An endpoint the map cannot resolve is a normal
@@ -84,6 +136,18 @@ const er = toGraphInput(model, 'er') // tables + refs
84
136
  const deps = toGraphInput(model, 'dependencies') // tables + entities, deps as edges
85
137
  ```
86
138
 
139
+ ## Icons need a safelist
140
+
141
+ The component picks a node's icon at runtime from its kind, so the class names never appear in
142
+ your source and UnoCSS purges them — cards render a blank box. Safelist them from the
143
+ Node-safe subpath (a `uno.config.ts` runs in Node and cannot parse `.svelte`):
144
+
145
+ ```js
146
+ import { DEFAULT_ICONS } from '@rokkit/graph/icons'
147
+
148
+ export default defineConfig({ safelist: Object.values(DEFAULT_ICONS) })
149
+ ```
150
+
87
151
  ## Theming
88
152
 
89
153
  Unstyled by default; `@rokkit/themes` ships `graph.css` for all six styles. Every visual hook
package/dist/Graph.svelte CHANGED
@@ -4,6 +4,7 @@
4
4
  import type { GraphStateConfig } from './GraphState.svelte.js'
5
5
  import type { GraphProps } from './types.js'
6
6
  import { DEFAULT_ICONS } from './icons.js'
7
+ import { ZOOM_STEP, clampZoom } from './controls/zoom.js'
7
8
 
8
9
  // Aliased: a local binding literally named `state` makes the compiler read the `$state`
9
10
  // rune below as a store subscription on it. The PUBLIC prop name is still `state`.
@@ -12,11 +13,14 @@
12
13
  nodes = [],
13
14
  edges = [],
14
15
  fields = {},
15
- layout = 'cluster',
16
+ layout = 'flow',
16
17
  density = $bindable('keys'),
17
18
  arrange = 'untangle',
18
19
  groupBy = 'group',
20
+ groupTint = false,
19
21
  nestBy = undefined,
22
+ sizeBy = 'degree',
23
+ sizeScale = 'linear',
20
24
  edgeStyle = 'curved',
21
25
  focus = null,
22
26
  value = undefined,
@@ -24,7 +28,6 @@
24
28
  mode = 'light',
25
29
  zoom = $bindable(1),
26
30
  zoomable = true,
27
- densityToggle = true,
28
31
  arrows = true,
29
32
  label = undefined,
30
33
  onselect = undefined,
@@ -42,7 +45,10 @@
42
45
  density,
43
46
  arrange,
44
47
  groupBy,
48
+ groupTint,
45
49
  nestBy,
50
+ sizeBy,
51
+ sizeScale,
46
52
  edgeStyle,
47
53
  focus,
48
54
  value,
@@ -82,27 +88,11 @@
82
88
  // `fit` alone shrinks a real schema until its labels are unreadable, which is the whole
83
89
  // reason zoom exists.
84
90
  const PAD = 28
85
- const ZOOM_MIN = 0.25
86
- const ZOOM_MAX = 4
87
- const ZOOM_STEP = 1.25
88
91
 
89
92
  let vw = $state(0)
90
93
  let vh = $state(0)
91
94
  let paper = $state<HTMLElement | null>(null)
92
95
 
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
96
  function zoomBy(factor: number) {
107
97
  zoom = clampZoom(zoom * factor)
108
98
  }
@@ -154,7 +144,6 @@
154
144
  // padding offset so scrolling reaches the far edge instead of clipping it.
155
145
  const tx = $derived(Math.max(PAD, (vw - graph.contentSize.w * scale) / 2))
156
146
  const ty = $derived(Math.max(PAD, (vh - graph.contentSize.h * scale) / 2))
157
- const zoomPercent = $derived(Math.round(zoom * 100))
158
147
 
159
148
  /**
160
149
  * Level of detail, from the EFFECTIVE scale rather than from `zoom` — a diagram fitted to
@@ -185,6 +174,7 @@
185
174
  <div
186
175
  data-graph-paper
187
176
  data-graph-detail={detail}
177
+ data-graph-group-tint={graph.groupTint ? '' : undefined}
188
178
  data-graph-panning={panning ? '' : undefined}
189
179
  role="presentation"
190
180
  bind:this={paper}
@@ -199,171 +189,231 @@
199
189
  onpointercancel={endPan}
200
190
  tabindex="-1"
201
191
  >
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;"
192
+ <div
193
+ data-graph-world
194
+ data-graph-node-shape={graph.nodeShape}
195
+ data-graph-layout={graph.layoutName}
196
+ style="width: {graph.size.w}px; height: {graph.size
197
+ .h}px; transform: translate({tx}px, {ty}px) scale({scale}); --graph-label-counter-scale: {(
198
+ 1 / scale
199
+ ).toFixed(3)};"
228
200
  >
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)}
201
+ <!-- Column headings. Three columns get away without them — the focus is visibly
202
+ central — but a depth-2 portrait is five columns and ambiguous without one.
203
+ Position, width and wording all come from the layout; this only prints them. -->
204
+ {#each graph.columns as column (column.side + column.depth)}
205
+ <span
206
+ data-graph-column
207
+ data-column-side={column.side}
208
+ data-column-depth={column.depth}
209
+ style:left="{column.x}px"
210
+ style:width="{column.w}px">{column.label}</span
238
211
  >
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
212
  {/each}
257
- </svg>
258
213
 
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>
214
+ <!-- A wedge cannot be a positioned <div> — an annulus sector has no box. It is drawn in
215
+ its own SVG UNDER the boxes, so a sunburst renders through the same cluster list as
216
+ every other containment layout and nothing downstream learns a second vocabulary. -->
217
+ {#if graph.hasWedges}
218
+ <svg
219
+ data-graph-wedges
220
+ width={graph.size.w}
221
+ height={graph.size.h}
222
+ role="img"
223
+ aria-label={graph.label}
224
+ style="position: absolute; top: 0; left: 0;"
225
+ >
226
+ <title>{graph.label}</title>
227
+ {#each graph.clusters as cluster (graph.clusterKey(cluster))}
228
+ {@const shared = graph.boxAttrs(cluster)}
229
+ {@const caption = `${cluster.name} · ${cluster.caption ?? cluster.count}`}
230
+ <!-- Two branches so `role` is STATIC. A wedge that is a node is a button and says
231
+ so; a region is scenery. Written as one element with a conditional role, the
232
+ compiler cannot tell which it is and neither can a screen reader. The shared
233
+ attributes come from state, so the branches cannot drift apart. -->
234
+ {#if cluster.nodeId}
235
+ <path
236
+ data-graph-wedge
237
+ {...shared}
238
+ style={graph.groupStyleAttr(cluster.ramp ?? cluster.name)}
239
+ d={graph.wedgePath(cluster)}
240
+ role="button"
241
+ tabindex={0}
242
+ aria-label={caption}
243
+ onclick={(event: Event) => {
244
+ event.stopPropagation()
245
+ graph.select(cluster.nodeId!)
246
+ }}
247
+ onkeydown={(event: KeyboardEvent) => {
248
+ if (event.key !== 'Enter' && event.key !== ' ') return
249
+ event.preventDefault()
250
+ event.stopPropagation()
251
+ graph.select(cluster.nodeId!)
252
+ }}><title>{caption}</title></path
253
+ >
292
254
  {:else}
293
- <span data-row-badge-empty></span>
255
+ <path
256
+ data-graph-wedge
257
+ {...shared}
258
+ style={graph.groupStyleAttr(cluster.ramp ?? cluster.name)}
259
+ d={graph.wedgePath(cluster)}><title>{caption}</title></path
260
+ >
294
261
  {/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}
262
+ {/each}
263
+ </svg>
264
+ {/if}
265
+
266
+ {#each graph.boxes as cluster (graph.clusterKey(cluster))}
267
+ {@const shared = graph.boxAttrs(cluster)}
268
+ {@const caption = `${cluster.name} · ${cluster.caption ?? cluster.count}`}
269
+ <!-- One box shape at every depth. A containment layout nests boxes inside boxes, so a
270
+ childless one is still a box — rendering it as a node CARD instead put two
271
+ structures in one hierarchy and brought the card's furniture with it, down to a
272
+ row count that is `0` for anything without rows.
273
+
274
+ Two branches, not one element with a conditional tag: a leaf is clickable and
275
+ focusable, so it is a real <button> and gets Enter/Space, focus order and the right
276
+ announcement for free. A region is scenery and is a <div> that says so. The shared
277
+ attributes come from state, so the two cannot drift. -->
278
+ {#if cluster.nodeId}
279
+ <button
280
+ type="button"
281
+ data-graph-cluster
282
+ {...shared}
283
+ style:left="{cluster.x}px"
284
+ style:top="{cluster.y}px"
285
+ style:width="{cluster.w}px"
286
+ style:height="{cluster.h}px"
287
+ style={graph.groupStyleAttr(cluster.ramp ?? cluster.name)}
310
288
  onclick={(event) => {
311
289
  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)
290
+ graph.select(cluster.nodeId!)
319
291
  }}
320
- >{graph.moreLabel(key)}</span
321
292
  >
293
+ <span data-graph-cluster-label>{caption}</span>
294
+ </button>
295
+ {:else}
296
+ <div
297
+ data-graph-cluster
298
+ {...shared}
299
+ style:left="{cluster.x}px"
300
+ style:top="{cluster.y}px"
301
+ style:width="{cluster.w}px"
302
+ style:height="{cluster.h}px"
303
+ style={graph.groupStyleAttr(cluster.ramp ?? cluster.name)}
304
+ >
305
+ <span data-graph-cluster-label>{caption}</span>
306
+ </div>
322
307
  {/if}
323
- </button>
324
- {/each}
325
- </div>
326
- </div>
308
+ {/each}
309
+
310
+ <svg
311
+ width={graph.size.w}
312
+ height={graph.size.h}
313
+ role="img"
314
+ aria-label={graph.label}
315
+ style="position: absolute; top: 0; left: 0; pointer-events: none;"
316
+ >
317
+ <title>{graph.label}</title>
318
+ {#each graph.routedEdges as edge (edge.id)}
319
+ <g
320
+ data-graph-edge
321
+ data-edge-kind={edge.kind}
322
+ data-edge-relation={edge.relation}
323
+ data-edge-from={edge.fromKey}
324
+ data-edge-to={edge.toKey}
325
+ data-edge-state={graph.edgeState(edge)}
326
+ data-edge-overlay={edge.overlay ? '' : undefined}
327
+ style:--edge-weight={graph.edgeWeight(edge)}
328
+ >
329
+ <path d={graph.edgePath(edge)} />
330
+ <circle data-graph-edge-dot="from" cx={edge.x1} cy={edge.y1} r="3.2" />
331
+ {#if arrows}
332
+ <!-- An edge HAS a direction — source to target — and two identical dots threw
333
+ that away. The tangent of both path shapes is horizontal at the endpoint,
334
+ so the heading is just the side the curve arrives from: -s2. No angle
335
+ maths, and it stays correct for a self-loop. -->
336
+ {@const d = -edge.s2}
337
+ <polygon
338
+ data-graph-edge-arrow
339
+ points="{edge.x2},{edge.y2} {edge.x2 - d * 9},{edge.y2 - 4.5} {edge.x2 -
340
+ d * 9},{edge.y2 + 4.5}"
341
+ />
342
+ {:else}
343
+ <circle data-graph-edge-dot="to" cx={edge.x2} cy={edge.y2} r="3.2" />
344
+ {/if}
345
+ </g>
346
+ {/each}
347
+ </svg>
327
348
 
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)}
349
+ {#each Object.entries(graph.cards) as [key, card] (key)}
333
350
  <button
334
351
  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
352
+ data-graph-node={key}
353
+ data-node-kind={card.node.kind}
354
+ data-node-group={card.node.group}
355
+ data-node-state={graph.nodeState(key)}
356
+ data-label-side={card.labelSide}
357
+ style:--label-angle={card.labelAngle === undefined ? undefined : `${card.labelAngle}deg`}
358
+ data-node-headonly={card.vis.length === 0 && card.more <= 0 ? '' : undefined}
359
+ style:left="{card.x}px"
360
+ style:top="{card.y}px"
361
+ style:width="{card.w}px"
362
+ style:height="{card.h}px"
363
+ style={graph.groupStyleAttr(card.node.group)}
364
+ onclick={(event) => {
365
+ event.stopPropagation()
366
+ graph.select(key)
367
+ }}
340
368
  >
369
+ <span data-graph-node-head>
370
+ <span data-graph-node-icon class={icons[card.node.kind ?? ''] ?? icons.fallback}></span>
371
+ <span data-graph-node-title>{card.node.label}</span>
372
+ {#if card.node.kind}
373
+ <!-- The glyph alone is a guess: layers vs eye vs bolt does not tell anyone what a
374
+ materialized view is. Icon for the glance, tag for the answer. Underscores
375
+ are the WIRE format (dbd sends `materialized_view`), not something to
376
+ read. -->
377
+ <span data-graph-node-kind>{card.node.kind.replace(/_/g, ' ')}</span>
378
+ {/if}
379
+ <span data-graph-node-count>{card.node.rows.length}</span>
380
+ </span>
381
+ {#each card.vis as row (row.name)}
382
+ <span data-graph-row data-graph-row-key={row.badges.length > 0 ? '' : undefined}>
383
+ {#if row.badges.length > 0}
384
+ <span data-row-badge={row.badges[0]} class={icons[row.badges[0]]}></span>
385
+ {:else}
386
+ <span data-row-badge-empty></span>
387
+ {/if}
388
+ <span data-graph-row-name>{row.name}</span>
389
+ <span data-graph-row-type>{row.type}</span>
390
+ </span>
391
+ {/each}
392
+ {#if graph.moreLabel(key)}
393
+ <!-- A real control. "+3 more" that does nothing is a statement dressed as an
394
+ affordance; this expands just this card. -->
395
+ <span
396
+ role="button"
397
+ tabindex="0"
398
+ data-graph-more
399
+ data-graph-more-empty={card.vis.length === 0 && !graph.isExpanded(key)
400
+ ? ''
401
+ : undefined}
402
+ data-expanded={graph.isExpanded(key) ? '' : undefined}
403
+ onclick={(event) => {
404
+ event.stopPropagation()
405
+ graph.toggleExpanded(key)
406
+ }}
407
+ onkeydown={(event) => {
408
+ if (event.key !== 'Enter' && event.key !== ' ') return
409
+ event.preventDefault()
410
+ event.stopPropagation()
411
+ graph.toggleExpanded(key)
412
+ }}>{graph.moreLabel(key)}</span
413
+ >
414
+ {/if}
415
+ </button>
341
416
  {/each}
342
417
  </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}
418
+ </div>
369
419
  </div>
@@ -0,0 +1,112 @@
1
+ <script lang="ts">
2
+ /**
3
+ * What the colours and strokes on the canvas mean.
4
+ *
5
+ * Graph-native rather than `@rokkit/chart`'s legend: that package carries eight d3 modules
6
+ * plus ramda, and `@rokkit/graph` has exactly one dependency — so reusing it would cost a
7
+ * consumer ten transitive packages for one component, and there is no package both already
8
+ * share (chart does not depend on `@rokkit/ui` either). The vocabularies differ anyway: a
9
+ * chart swatch is a symbol path, a graph swatch is a kind ICON or a LINE STYLE. If the
10
+ * generic legend is ever promoted somewhere both can reach, this is what it replaces.
11
+ *
12
+ * Every section is opt-in. A diagram whose nodes are all one kind wants the relations key
13
+ * and nothing else, and a legend that lists a constant is noise.
14
+ */
15
+ import type { GraphState } from './GraphState.svelte.js'
16
+ import { DEFAULT_ICONS } from './icons.js'
17
+
18
+ type Section = 'kind' | 'relation' | 'group'
19
+
20
+ type Props = {
21
+ state: GraphState
22
+ /** Node kinds present, each with its glyph. */
23
+ kinds?: boolean
24
+ /** Edge relations present, each drawn as the stroke it uses. */
25
+ relations?: boolean
26
+ /** Group names present, each with its ramp colour. */
27
+ groups?: boolean
28
+ icons?: Record<string, string>
29
+ /**
30
+ * Fires with the entry a reader activated. Given one, entries become real buttons;
31
+ * without one they stay inert text, because a static key is not a control.
32
+ */
33
+ onpick?: (section: Section, value: string) => void
34
+ }
35
+
36
+ let {
37
+ state: graph,
38
+ kinds = false,
39
+ relations = false,
40
+ groups = false,
41
+ icons: userIcons,
42
+ onpick
43
+ }: Props = $props()
44
+
45
+ const icons = $derived<Record<string, string>>({ ...DEFAULT_ICONS, ...userIcons })
46
+ const shown = $derived(kinds || relations || groups)
47
+
48
+ /** Underscores are the WIRE format (dbd sends `materialized_view`), not something to read. */
49
+ const readable = (value: string) => value.replace(/_/g, ' ')
50
+
51
+ /** `data-legend-kind` / `-relation` / `-group`, so CSS and tests can address a section. */
52
+ const attrs = (section: Section, value: string) => ({ [`data-legend-${section}`]: value })
53
+ </script>
54
+
55
+ <!-- The swatch IS the mark used on the canvas: a kind's own icon, the group's ramp chip, or
56
+ an SVG line inheriting the stroke the edge is drawn with. A key that redraws its marks a
57
+ second way is a key that can disagree with the picture. -->
58
+ {#snippet swatch(section: Section, value: string)}
59
+ {#if section === 'kind'}
60
+ <span data-legend-swatch data-node-kind={value} class={icons[value] ?? icons.fallback}></span>
61
+ {:else if section === 'relation'}
62
+ <svg data-legend-swatch data-edge-relation={value} aria-hidden="true">
63
+ <line x1="1" y1="4" x2="21" y2="4" />
64
+ </svg>
65
+ {:else}
66
+ <span data-legend-swatch data-node-group={value} style={graph.groupStyleAttr(value)}></span>
67
+ {/if}
68
+ {/snippet}
69
+
70
+ <!-- Two branches so the element and its role are STATIC. Written as one `<svelte:element>`
71
+ with a conditional tag, the compiler cannot tell whether it is interactive and neither can
72
+ a screen reader — and a click handler on a `<span>` is unreachable by keyboard. -->
73
+ {#snippet row(section: Section, value: string)}
74
+ {#if onpick}
75
+ <button
76
+ type="button"
77
+ data-graph-legend-entry
78
+ {...attrs(section, value)}
79
+ onclick={() => onpick(section, value)}
80
+ >
81
+ {@render swatch(section, value)}
82
+ <span data-legend-label>{readable(value)}</span>
83
+ </button>
84
+ {:else}
85
+ <span data-graph-legend-entry {...attrs(section, value)}>
86
+ {@render swatch(section, value)}
87
+ <span data-legend-label>{readable(value)}</span>
88
+ </span>
89
+ {/if}
90
+ {/snippet}
91
+
92
+ {#if shown}
93
+ <div data-graph-legend aria-label="Legend">
94
+ {#if kinds}
95
+ {#each graph.kindsPresent as kind (kind)}
96
+ {@render row('kind', kind)}
97
+ {/each}
98
+ {/if}
99
+
100
+ {#if relations}
101
+ {#each graph.relationsPresent as relation (relation)}
102
+ {@render row('relation', relation)}
103
+ {/each}
104
+ {/if}
105
+
106
+ {#if groups}
107
+ {#each graph.groupsPresent as group (group)}
108
+ {@render row('group', group)}
109
+ {/each}
110
+ {/if}
111
+ </div>
112
+ {/if}