@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.
- package/README.md +59 -2
- package/dist/Graph.svelte +121 -72
- package/dist/GraphLegend.svelte +112 -0
- package/dist/GraphLegend.svelte.d.ts +33 -0
- package/dist/GraphState.svelte.d.ts +107 -15
- package/dist/GraphState.svelte.js +186 -3
- package/dist/controls/BundleControl.svelte +30 -0
- package/dist/controls/BundleControl.svelte.d.ts +16 -0
- package/dist/controls/DensityControl.svelte +39 -0
- package/dist/controls/DensityControl.svelte.d.ts +16 -0
- package/dist/controls/DepthControl.svelte +34 -0
- package/dist/controls/DepthControl.svelte.d.ts +17 -0
- package/dist/controls/EdgeStyleControl.svelte +31 -0
- package/dist/controls/EdgeStyleControl.svelte.d.ts +14 -0
- package/dist/controls/ZoomControl.svelte +47 -0
- package/dist/controls/ZoomControl.svelte.d.ts +7 -0
- package/dist/controls/zoom.d.ts +6 -0
- package/dist/controls/zoom.js +12 -0
- package/dist/diagrams/CallTree.svelte +135 -0
- package/dist/diagrams/CallTree.svelte.d.ts +37 -0
- package/dist/diagrams/DependencyDiagram.svelte +124 -0
- package/dist/diagrams/DependencyDiagram.svelte.d.ts +30 -0
- package/dist/diagrams/DiagramFrame.svelte +36 -0
- package/dist/diagrams/DiagramFrame.svelte.d.ts +20 -0
- package/dist/diagrams/ErDiagram.svelte +128 -0
- package/dist/diagrams/ErDiagram.svelte.d.ts +33 -0
- package/dist/diagrams/Neighborhood.svelte +127 -0
- package/dist/diagrams/Neighborhood.svelte.d.ts +33 -0
- package/dist/diagrams/StructureDiagram.svelte +131 -0
- package/dist/diagrams/StructureDiagram.svelte.d.ts +31 -0
- package/dist/diagrams/Sunburst.svelte +124 -0
- package/dist/diagrams/Sunburst.svelte.d.ts +32 -0
- package/dist/diagrams/Treemap.svelte +124 -0
- package/dist/diagrams/Treemap.svelte.d.ts +32 -0
- package/dist/icons.d.ts +5 -0
- package/dist/icons.js +5 -0
- package/dist/index.d.ts +18 -1
- package/dist/index.js +23 -1
- package/dist/layout/arc.d.ts +13 -0
- package/dist/layout/arc.js +58 -0
- package/dist/layout/bundle.d.ts +30 -0
- package/dist/layout/bundle.js +80 -0
- package/dist/layout/cluster.js +2 -0
- package/dist/layout/flow.d.ts +8 -0
- package/dist/layout/flow.js +141 -0
- package/dist/layout/hierarchy.d.ts +42 -0
- package/dist/layout/hierarchy.js +175 -0
- package/dist/layout/index.d.ts +7 -1
- package/dist/layout/index.js +8 -2
- package/dist/layout/neighborhood.js +164 -28
- package/dist/layout/options.d.ts +44 -0
- package/dist/layout/options.js +106 -0
- package/dist/layout/order.d.ts +11 -0
- package/dist/layout/order.js +153 -0
- package/dist/layout/points.js +27 -35
- package/dist/layout/radial.d.ts +16 -0
- package/dist/layout/radial.js +214 -0
- package/dist/layout/rank.d.ts +15 -0
- package/dist/layout/rank.js +105 -0
- package/dist/layout/rings.d.ts +20 -0
- package/dist/layout/rings.js +92 -0
- package/dist/layout/sizing.d.ts +37 -0
- package/dist/layout/sizing.js +79 -0
- package/dist/layout/squarify.d.ts +14 -0
- package/dist/layout/squarify.js +114 -0
- package/dist/layout/structure.d.ts +9 -0
- package/dist/layout/structure.js +367 -0
- package/dist/layout/sunburst.d.ts +8 -0
- package/dist/layout/sunburst.js +126 -0
- package/dist/layout/types.d.ts +141 -0
- package/dist/layout/world.d.ts +9 -0
- package/dist/layout/world.js +144 -0
- package/dist/model/normalize.js +47 -1
- package/dist/model/tree.d.ts +27 -0
- package/dist/model/tree.js +182 -0
- package/dist/types.d.ts +55 -3
- 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
|
-
- **`
|
|
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 = '
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
style:
|
|
216
|
-
style={
|
|
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
|
-
|
|
219
|
-
|
|
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
|
*
|