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