@rokkit/graph 1.6.0 → 1.7.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 (77) hide show
  1. package/README.md +59 -2
  2. package/dist/Graph.svelte +121 -72
  3. package/dist/GraphLegend.svelte +112 -0
  4. package/dist/GraphLegend.svelte.d.ts +33 -0
  5. package/dist/GraphState.svelte.d.ts +107 -15
  6. package/dist/GraphState.svelte.js +186 -3
  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/DiagramFrame.svelte +36 -0
  24. package/dist/diagrams/DiagramFrame.svelte.d.ts +20 -0
  25. package/dist/diagrams/ErDiagram.svelte +128 -0
  26. package/dist/diagrams/ErDiagram.svelte.d.ts +33 -0
  27. package/dist/diagrams/Neighborhood.svelte +127 -0
  28. package/dist/diagrams/Neighborhood.svelte.d.ts +33 -0
  29. package/dist/diagrams/StructureDiagram.svelte +131 -0
  30. package/dist/diagrams/StructureDiagram.svelte.d.ts +31 -0
  31. package/dist/diagrams/Sunburst.svelte +124 -0
  32. package/dist/diagrams/Sunburst.svelte.d.ts +32 -0
  33. package/dist/diagrams/Treemap.svelte +124 -0
  34. package/dist/diagrams/Treemap.svelte.d.ts +32 -0
  35. package/dist/icons.d.ts +5 -0
  36. package/dist/icons.js +5 -0
  37. package/dist/index.d.ts +18 -1
  38. package/dist/index.js +23 -1
  39. package/dist/layout/arc.d.ts +13 -0
  40. package/dist/layout/arc.js +58 -0
  41. package/dist/layout/bundle.d.ts +30 -0
  42. package/dist/layout/bundle.js +80 -0
  43. package/dist/layout/cluster.js +2 -0
  44. package/dist/layout/flow.d.ts +8 -0
  45. package/dist/layout/flow.js +141 -0
  46. package/dist/layout/hierarchy.d.ts +42 -0
  47. package/dist/layout/hierarchy.js +175 -0
  48. package/dist/layout/index.d.ts +7 -1
  49. package/dist/layout/index.js +8 -2
  50. package/dist/layout/neighborhood.js +164 -28
  51. package/dist/layout/options.d.ts +44 -0
  52. package/dist/layout/options.js +106 -0
  53. package/dist/layout/order.d.ts +11 -0
  54. package/dist/layout/order.js +153 -0
  55. package/dist/layout/points.js +27 -35
  56. package/dist/layout/radial.d.ts +16 -0
  57. package/dist/layout/radial.js +214 -0
  58. package/dist/layout/rank.d.ts +15 -0
  59. package/dist/layout/rank.js +105 -0
  60. package/dist/layout/rings.d.ts +20 -0
  61. package/dist/layout/rings.js +92 -0
  62. package/dist/layout/sizing.d.ts +37 -0
  63. package/dist/layout/sizing.js +79 -0
  64. package/dist/layout/squarify.d.ts +14 -0
  65. package/dist/layout/squarify.js +114 -0
  66. package/dist/layout/structure.d.ts +9 -0
  67. package/dist/layout/structure.js +367 -0
  68. package/dist/layout/sunburst.d.ts +8 -0
  69. package/dist/layout/sunburst.js +126 -0
  70. package/dist/layout/types.d.ts +141 -0
  71. package/dist/layout/world.d.ts +9 -0
  72. package/dist/layout/world.js +144 -0
  73. package/dist/model/normalize.js +47 -1
  74. package/dist/model/tree.d.ts +27 -0
  75. package/dist/model/tree.js +182 -0
  76. package/dist/types.d.ts +55 -3
  77. package/package.json +9 -4
package/README.md CHANGED
@@ -47,17 +47,49 @@ 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`.
70
+ Both `controls` and `legend` default to off: the default is the bare picture.
71
+
72
+ Composing your own is the same parts in a different box — `DensityControl`, `EdgeStyleControl`,
73
+ `DepthControl`, `ZoomControl` and `GraphLegend` are all exported, and none of them stores a
74
+ value. They report what was chosen and you own it.
52
75
 
53
76
  ## Layouts
54
77
 
55
78
  A layout is a pure `(model, options) => LayoutResult` — DOM-free, synchronous, deterministic.
56
79
  Your own slots in the same way.
57
80
 
58
- - **`cluster`** — cards in group boxes. The default; entity diagrams.
81
+ - **`flow`** — cards in columns by reference direction. **The default.** Every edge leaves
82
+ right and enters left, so direction reads off the geometry and links stay traceable. Pair it
83
+ with `groupTint` to keep schema visible without cluster boxes.
84
+ - **`cluster`** — cards in group boxes. Entity diagrams grouped by schema.
59
85
  - **`neighborhood`** — one focus node and its 1-hop neighbours.
86
+ - **`radial`** — a tidy tree or dendrogram around a circle. Call graphs: angle separates the
87
+ subtrees, radius carries the depth.
60
88
  - **`points`** — degree-sized rects, shelf-packed. Dense graphs, 1000+ nodes.
89
+ - **`world`** — nested rectangles, area proportional to a measure. Where the mass is.
90
+ - **`sunburst`** — the same containment as nested wedges, with depth on the radius.
91
+ - **`structure`** — a codebase as a radial dendrogram with its calls bundled through the
92
+ hierarchy. What `radial` cannot be: containment is a real tree where a call graph is not.
61
93
 
62
94
  Two-level clustering shows both grouping axes at once, which matters for a dependency graph
63
95
  where one schema holds a table, a trigger and a procedure:
@@ -66,6 +98,19 @@ where one schema holds a table, a trigger and a procedure:
66
98
  <Graph {nodes} {edges} {fields} groupBy="group" nestBy="kind" />
67
99
  ```
68
100
 
101
+ `world` returns no cards: a treemap nests one shape, so a leaf is a `Cluster` too, carrying
102
+ `nodeId` and `kind` so it stays selectable. Its caption is the measure driving its area, not a
103
+ child count.
104
+
105
+ Not every option reaches every layout, and a control wired to one that does not is worse than a
106
+ missing one — it moves and nothing happens:
107
+
108
+ ```js
109
+ import { appliesTo } from '@rokkit/graph'
110
+
111
+ appliesTo('world', 'density') // false — a treemap box has no row list to thin
112
+ ```
113
+
69
114
  ## Two things worth knowing
70
115
 
71
116
  **An unplaced edge is dimmed, never dropped.** An endpoint the map cannot resolve is a normal
@@ -84,6 +129,18 @@ const er = toGraphInput(model, 'er') // tables + refs
84
129
  const deps = toGraphInput(model, 'dependencies') // tables + entities, deps as edges
85
130
  ```
86
131
 
132
+ ## Icons need a safelist
133
+
134
+ The component picks a node's icon at runtime from its kind, so the class names never appear in
135
+ your source and UnoCSS purges them — cards render a blank box. Safelist them from the
136
+ Node-safe subpath (a `uno.config.ts` runs in Node and cannot parse `.svelte`):
137
+
138
+ ```js
139
+ import { DEFAULT_ICONS } from '@rokkit/graph/icons'
140
+
141
+ export default defineConfig({ safelist: Object.values(DEFAULT_ICONS) })
142
+ ```
143
+
87
144
  ## Theming
88
145
 
89
146
  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}
@@ -201,22 +191,117 @@
201
191
  >
202
192
  <div
203
193
  data-graph-world
194
+ data-graph-node-shape={graph.nodeShape}
204
195
  data-graph-layout={graph.layoutName}
205
196
  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
197
  >
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)}
198
+ <!-- Column headings. Three columns get away without them — the focus is visibly
199
+ central — but a depth-2 portrait is five columns and ambiguous without one.
200
+ Position, width and wording all come from the layout; this only prints them. -->
201
+ {#each graph.columns as column (column.side + column.depth)}
202
+ <span
203
+ data-graph-column
204
+ data-column-side={column.side}
205
+ data-column-depth={column.depth}
206
+ style:left="{column.x}px"
207
+ style:width="{column.w}px">{column.label}</span
217
208
  >
218
- <span data-graph-cluster-label>{cluster.name} · {cluster.count}</span>
219
- </div>
209
+ {/each}
210
+
211
+ <!-- A wedge cannot be a positioned <div> — an annulus sector has no box. It is drawn in
212
+ its own SVG UNDER the boxes, so a sunburst renders through the same cluster list as
213
+ every other containment layout and nothing downstream learns a second vocabulary. -->
214
+ {#if graph.hasWedges}
215
+ <svg
216
+ data-graph-wedges
217
+ width={graph.size.w}
218
+ height={graph.size.h}
219
+ role="img"
220
+ aria-label={graph.label}
221
+ style="position: absolute; top: 0; left: 0;"
222
+ >
223
+ <title>{graph.label}</title>
224
+ {#each graph.clusters as cluster (graph.clusterKey(cluster))}
225
+ {@const shared = graph.boxAttrs(cluster)}
226
+ {@const caption = `${cluster.name} · ${cluster.caption ?? cluster.count}`}
227
+ <!-- Two branches so `role` is STATIC. A wedge that is a node is a button and says
228
+ so; a region is scenery. Written as one element with a conditional role, the
229
+ compiler cannot tell which it is and neither can a screen reader. The shared
230
+ attributes come from state, so the branches cannot drift apart. -->
231
+ {#if cluster.nodeId}
232
+ <path
233
+ data-graph-wedge
234
+ {...shared}
235
+ style={graph.groupStyleAttr(cluster.ramp ?? cluster.name)}
236
+ d={graph.wedgePath(cluster)}
237
+ role="button"
238
+ tabindex={0}
239
+ aria-label={caption}
240
+ onclick={(event: Event) => {
241
+ event.stopPropagation()
242
+ graph.select(cluster.nodeId!)
243
+ }}
244
+ onkeydown={(event: KeyboardEvent) => {
245
+ if (event.key !== 'Enter' && event.key !== ' ') return
246
+ event.preventDefault()
247
+ event.stopPropagation()
248
+ graph.select(cluster.nodeId!)
249
+ }}><title>{caption}</title></path
250
+ >
251
+ {:else}
252
+ <path
253
+ data-graph-wedge
254
+ {...shared}
255
+ style={graph.groupStyleAttr(cluster.ramp ?? cluster.name)}
256
+ d={graph.wedgePath(cluster)}><title>{caption}</title></path
257
+ >
258
+ {/if}
259
+ {/each}
260
+ </svg>
261
+ {/if}
262
+
263
+ {#each graph.boxes as cluster (graph.clusterKey(cluster))}
264
+ {@const shared = graph.boxAttrs(cluster)}
265
+ {@const caption = `${cluster.name} · ${cluster.caption ?? cluster.count}`}
266
+ <!-- One box shape at every depth. A containment layout nests boxes inside boxes, so a
267
+ childless one is still a box — rendering it as a node CARD instead put two
268
+ structures in one hierarchy and brought the card's furniture with it, down to a
269
+ row count that is `0` for anything without rows.
270
+
271
+ Two branches, not one element with a conditional tag: a leaf is clickable and
272
+ focusable, so it is a real <button> and gets Enter/Space, focus order and the right
273
+ announcement for free. A region is scenery and is a <div> that says so. The shared
274
+ attributes come from state, so the two cannot drift. -->
275
+ {#if cluster.nodeId}
276
+ <button
277
+ type="button"
278
+ data-graph-cluster
279
+ {...shared}
280
+ style:left="{cluster.x}px"
281
+ style:top="{cluster.y}px"
282
+ style:width="{cluster.w}px"
283
+ style:height="{cluster.h}px"
284
+ style={graph.groupStyleAttr(cluster.ramp ?? cluster.name)}
285
+ onclick={(event) => {
286
+ event.stopPropagation()
287
+ graph.select(cluster.nodeId!)
288
+ }}
289
+ >
290
+ <span data-graph-cluster-label>{caption}</span>
291
+ </button>
292
+ {:else}
293
+ <div
294
+ data-graph-cluster
295
+ {...shared}
296
+ style:left="{cluster.x}px"
297
+ style:top="{cluster.y}px"
298
+ style:width="{cluster.w}px"
299
+ style:height="{cluster.h}px"
300
+ style={graph.groupStyleAttr(cluster.ramp ?? cluster.name)}
301
+ >
302
+ <span data-graph-cluster-label>{caption}</span>
303
+ </div>
304
+ {/if}
220
305
  {/each}
221
306
 
222
307
  <svg
@@ -263,10 +348,15 @@
263
348
  data-node-kind={card.node.kind}
264
349
  data-node-group={card.node.group}
265
350
  data-node-state={graph.nodeState(key)}
351
+ data-label-side={card.labelSide}
352
+ style:--label-angle={card.labelAngle === undefined
353
+ ? undefined
354
+ : `${card.labelAngle}deg`}
266
355
  data-node-headonly={card.vis.length === 0 && card.more <= 0 ? '' : undefined}
267
356
  style:left="{card.x}px"
268
357
  style:top="{card.y}px"
269
358
  style:width="{card.w}px"
359
+ style:height="{card.h}px"
270
360
  style={graph.groupStyleAttr(card.node.group)}
271
361
  onclick={(event) => {
272
362
  event.stopPropagation()
@@ -325,45 +415,4 @@
325
415
  </div>
326
416
  </div>
327
417
 
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
418
  </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}
@@ -0,0 +1,33 @@
1
+ /**
2
+ * What the colours and strokes on the canvas mean.
3
+ *
4
+ * Graph-native rather than `@rokkit/chart`'s legend: that package carries eight d3 modules
5
+ * plus ramda, and `@rokkit/graph` has exactly one dependency — so reusing it would cost a
6
+ * consumer ten transitive packages for one component, and there is no package both already
7
+ * share (chart does not depend on `@rokkit/ui` either). The vocabularies differ anyway: a
8
+ * chart swatch is a symbol path, a graph swatch is a kind ICON or a LINE STYLE. If the
9
+ * generic legend is ever promoted somewhere both can reach, this is what it replaces.
10
+ *
11
+ * Every section is opt-in. A diagram whose nodes are all one kind wants the relations key
12
+ * and nothing else, and a legend that lists a constant is noise.
13
+ */
14
+ import type { GraphState } from './GraphState.svelte.js';
15
+ type Section = 'kind' | 'relation' | 'group';
16
+ type Props = {
17
+ state: GraphState;
18
+ /** Node kinds present, each with its glyph. */
19
+ kinds?: boolean;
20
+ /** Edge relations present, each drawn as the stroke it uses. */
21
+ relations?: boolean;
22
+ /** Group names present, each with its ramp colour. */
23
+ groups?: boolean;
24
+ icons?: Record<string, string>;
25
+ /**
26
+ * Fires with the entry a reader activated. Given one, entries become real buttons;
27
+ * without one they stay inert text, because a static key is not a control.
28
+ */
29
+ onpick?: (section: Section, value: string) => void;
30
+ };
31
+ declare const GraphLegend: import("svelte").Component<Props, {}, "">;
32
+ type GraphLegend = ReturnType<typeof GraphLegend>;
33
+ export default GraphLegend;
@@ -1,6 +1,6 @@
1
1
  import { SvelteSet } from 'svelte/reactivity';
2
2
  import type { GraphPreset } from './preset.js';
3
- import type { Arrange, Cards, Cluster, Density, EdgeStyle, LayoutFn, NodeAxis, RoutedEdge, Size } from './layout/types.js';
3
+ import type { Arrange, Cards, Cluster, Column, Density, EdgeStyle, LayoutFn, NodeAxis, RoutedEdge, Size } from './layout/types.js';
4
4
  import type { GraphEdge, GraphFields, GraphModel, GraphNode } from './types.js';
5
5
  export type EntityRow = {
6
6
  id: string;
@@ -32,27 +32,41 @@ export type GraphStateConfig = {
32
32
  groupBy?: NodeAxis;
33
33
  /** Second axis subdividing each outer cluster. Unset means one level. */
34
34
  nestBy?: NodeAxis | null;
35
+ /**
36
+ * `points` only — what a node's size encodes. `degree` (default), `weight`, or any key in
37
+ * `GraphNode.measures`.
38
+ */
39
+ sizeBy?: string;
40
+ /** How the measure maps onto area. Defaults to `linear`. */
41
+ sizeScale?: 'linear' | 'log';
42
+ /** `neighborhood` only — hops out from the focus. Defaults to 1. */
43
+ depth?: number;
44
+ /** `world` only — the subtree rendered as the whole canvas. `[]` is the root. */
45
+ focusPath?: string[];
46
+ /** `world` only — levels below the focus to materialise. Defaults to 2. */
47
+ levels?: number;
48
+ /** `radial` only — `tree` ranks by depth, `dendrogram` pins leaves to the rim. */
49
+ radialMode?: 'tree' | 'dendrogram';
50
+ /** `radial` only — the node to centre on. Drilling; NOT the selection. */
51
+ root?: string | null;
52
+ /** `structure` only — how hard edges are pulled onto the tree, 0..1. */
53
+ bundleTension?: number;
54
+ /**
55
+ * Paint each CARD with its group's ramp, not just the cluster box around it.
56
+ *
57
+ * A clusterless layout — `flow`, `neighborhood` — has no box for the ramp to land on,
58
+ * so schema membership becomes invisible the moment you leave `cluster`. The cards
59
+ * already carry the custom properties; this is what tells CSS to use them.
60
+ */
61
+ groupTint?: boolean;
35
62
  edgeStyle?: EdgeStyle;
36
63
  focus?: string | null;
37
64
  value?: string | null;
38
65
  preset?: GraphPreset;
39
66
  mode?: 'light' | 'dark';
40
67
  label?: string;
41
- onselect?: (id: string) => void;
68
+ onselect?: (id: string | null) => void;
42
69
  };
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
70
  export declare class GraphState {
57
71
  #private;
58
72
  constructor(config?: GraphStateConfig);
@@ -64,7 +78,27 @@ export declare class GraphState {
64
78
  * field by field.
65
79
  */
66
80
  update(config?: GraphStateConfig): void;
81
+ /**
82
+ * Merge a partial config, leaving every key it does not name alone.
83
+ *
84
+ * `update()` fully RE-APPLIES — an omitted key reverts to its default, which is what makes
85
+ * it safe to call from a render. That is the wrong verb for a component writing the few
86
+ * options its own controls drive into a state someone else owns: it would reset the
87
+ * owner's data on every keystroke. This merges over the last full config instead.
88
+ */
89
+ apply(config: Partial<GraphStateConfig>): void;
67
90
  select(id: string): void;
91
+ /**
92
+ * Drop the selection, and SAY SO.
93
+ *
94
+ * Found by dbd consuming the package: it owns `selected` in its route and branches on it
95
+ * to show the entity panel, so a silent clear left that panel open over nothing. A
96
+ * controlled consumer cannot observe an internal `#value` — the callback is the only
97
+ * channel, which is why it carries `null` rather than being skipped.
98
+ *
99
+ * Guarded on an actual change, or a background click on an already-empty canvas
100
+ * round-trips through the consumer's setter on every stray click.
101
+ */
68
102
  clear(): void;
69
103
  /**
70
104
  * Show one node's rows in full, whatever the density says.
@@ -109,6 +143,15 @@ export declare class GraphState {
109
143
  */
110
144
  groupStyleAttr(group: string | undefined): string;
111
145
  get model(): GraphModel;
146
+ /**
147
+ * Clusters drawn as rectangles — everything the box renderer handles.
148
+ *
149
+ * A wedge has no box, so it is drawn as an SVG arc instead. Split here rather than in the
150
+ * template so the component keeps computing nothing.
151
+ */
152
+ get boxes(): Cluster[];
153
+ /** Whether any cluster is a wedge, which is what puts the arc layer on the canvas. */
154
+ get hasWedges(): boolean;
112
155
  get clusters(): Cluster[];
113
156
  get cards(): Cards;
114
157
  get routedEdges(): RoutedEdge[];
@@ -147,9 +190,58 @@ export declare class GraphState {
147
190
  * render aborts, and no inner box appears at all, which reads as nesting silently not
148
191
  * working rather than as an error.
149
192
  */
193
+ /**
194
+ * The SVG path for a wedge-shaped box, centred on the canvas.
195
+ *
196
+ * Here rather than in the component for the same reason `edgePath` is: it is arithmetic,
197
+ * and the view's job is to render what state derives, not to compute geometry.
198
+ */
199
+ wedgePath(cluster: Cluster): string;
200
+ /**
201
+ * The data attributes every containment box carries, wedge or rectangle.
202
+ *
203
+ * Built here so the two render branches — interactive and scenery — cannot drift: they
204
+ * differ only in the element and its role, which is exactly the difference that matters.
205
+ */
206
+ boxAttrs(cluster: Cluster): Record<string, string | null | undefined>;
150
207
  clusterKey(cluster: Cluster): string;
151
208
  get groupBy(): NodeAxis;
209
+ /**
210
+ * The node kinds actually present, sorted.
211
+ *
212
+ * What a LEGEND needs: naming the whole vocabulary over a diagram that uses three of it is
213
+ * a key to a different picture, and the reader hunts for a `trigger` that is not there.
214
+ */
215
+ get kindsPresent(): string[];
216
+ /**
217
+ * The edge relations present, sorted — the producer's verb where it gave one, else the
218
+ * coarse kind.
219
+ *
220
+ * A plain foreign key carries no verb, and listing nothing for it would leave the one
221
+ * stroke actually on the canvas unexplained.
222
+ */
223
+ get relationsPresent(): string[];
224
+ /** The group names present, sorted. Same reasoning as `kindsPresent`. */
225
+ get groupsPresent(): string[];
226
+ /** Whether cards carry the group ramp themselves. See `GraphStateConfig.groupTint`. */
227
+ /** `dot` or `card` — what the active layout draws a node as. */
228
+ get nodeShape(): 'dot' | 'card';
229
+ get groupTint(): boolean;
152
230
  get nestBy(): NodeAxis | null;
231
+ get sizeBy(): string;
232
+ get sizeScale(): 'linear' | 'log';
233
+ get depth(): number;
234
+ get focusPath(): string[];
235
+ /** The configured cap, or the package default of 2 when none was set. */
236
+ get levels(): number;
237
+ /**
238
+ * Column headings for a multi-column layout, empty for the grid ones.
239
+ *
240
+ * Exposed so the view can render headings without knowing which layout is active or what
241
+ * it means — the layout decides both the position and the wording, and the component
242
+ * prints them.
243
+ */
244
+ get columns(): Column[];
153
245
  /**
154
246
  * Change the grouping axes from inside the component.
155
247
  *