@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.
- package/README.md +66 -2
- package/dist/Graph.svelte +219 -169
- package/dist/GraphLegend.svelte +112 -0
- package/dist/GraphLegend.svelte.d.ts +33 -0
- package/dist/GraphState.svelte.d.ts +115 -15
- package/dist/GraphState.svelte.js +213 -5
- 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/DependencyMatrix.svelte +177 -0
- package/dist/diagrams/DependencyMatrix.svelte.d.ts +34 -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 +21 -1
- package/dist/index.js +25 -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/edges.js +11 -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/matrix.d.ts +38 -0
- package/dist/layout/matrix.js +90 -0
- 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 +145 -0
- package/dist/layout/world.d.ts +9 -0
- package/dist/layout/world.js +144 -0
- package/dist/model/normalize.js +66 -4
- package/dist/model/tree.d.ts +27 -0
- package/dist/model/tree.js +182 -0
- package/dist/types.d.ts +75 -3
- 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
|
-
- **`
|
|
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 = '
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
data-
|
|
235
|
-
data-
|
|
236
|
-
data-
|
|
237
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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.
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
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-
|
|
336
|
-
data-
|
|
337
|
-
|
|
338
|
-
{
|
|
339
|
-
|
|
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
|
-
|
|
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}
|