@rokkit/graph 1.7.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +67 -1
  2. package/dist/Graph.svelte +234 -259
  3. package/dist/Graph.svelte.d.ts +1 -1
  4. package/dist/GraphLegend.svelte +81 -10
  5. package/dist/GraphLegend.svelte.d.ts +15 -0
  6. package/dist/GraphState.svelte.d.ts +168 -51
  7. package/dist/GraphState.svelte.js +406 -305
  8. package/dist/actions/canvas.d.ts +18 -0
  9. package/dist/actions/canvas.js +59 -0
  10. package/dist/actions/choices.d.ts +12 -0
  11. package/dist/actions/choices.js +23 -0
  12. package/dist/actions/interactions.d.ts +20 -0
  13. package/dist/actions/interactions.js +57 -0
  14. package/dist/actions/listen.d.ts +2 -0
  15. package/dist/actions/listen.js +10 -0
  16. package/dist/canvas/frame.d.ts +41 -0
  17. package/dist/canvas/frame.js +34 -0
  18. package/dist/controls/BundleControl.svelte +11 -10
  19. package/dist/controls/BundleControl.svelte.d.ts +0 -7
  20. package/dist/controls/DensityControl.svelte +17 -9
  21. package/dist/controls/DensityControl.svelte.d.ts +0 -8
  22. package/dist/controls/DepthControl.svelte +11 -4
  23. package/dist/controls/DepthControl.svelte.d.ts +0 -9
  24. package/dist/controls/DrillBar.svelte +63 -0
  25. package/dist/controls/DrillBar.svelte.d.ts +9 -0
  26. package/dist/controls/EdgeStyleControl.svelte +10 -10
  27. package/dist/controls/EdgeStyleControl.svelte.d.ts +0 -6
  28. package/dist/controls/HiddenControl.svelte +27 -0
  29. package/dist/controls/HiddenControl.svelte.d.ts +7 -0
  30. package/dist/controls/MeasureControl.svelte +39 -0
  31. package/dist/controls/MeasureControl.svelte.d.ts +12 -0
  32. package/dist/controls/ToggleControl.svelte +33 -0
  33. package/dist/controls/ToggleControl.svelte.d.ts +13 -0
  34. package/dist/controls/ViolationsControl.svelte +27 -0
  35. package/dist/controls/ViolationsControl.svelte.d.ts +7 -0
  36. package/dist/controls/ZoomControl.svelte +21 -18
  37. package/dist/controls/zoom.d.ts +3 -0
  38. package/dist/controls/zoom.js +6 -0
  39. package/dist/diagrams/ArcDiagram.svelte +116 -0
  40. package/dist/diagrams/ArcDiagram.svelte.d.ts +31 -0
  41. package/dist/diagrams/CallTree.svelte +17 -5
  42. package/dist/diagrams/CallTree.svelte.d.ts +5 -1
  43. package/dist/diagrams/DependencyDiagram.svelte +18 -6
  44. package/dist/diagrams/DependencyDiagram.svelte.d.ts +5 -1
  45. package/dist/diagrams/DependencyMatrix.svelte +156 -0
  46. package/dist/diagrams/DependencyMatrix.svelte.d.ts +34 -0
  47. package/dist/diagrams/ErDiagram.svelte +18 -6
  48. package/dist/diagrams/ErDiagram.svelte.d.ts +5 -1
  49. package/dist/diagrams/LayersDiagram.svelte +124 -0
  50. package/dist/diagrams/LayersDiagram.svelte.d.ts +30 -0
  51. package/dist/diagrams/Neighborhood.svelte +26 -6
  52. package/dist/diagrams/Neighborhood.svelte.d.ts +11 -1
  53. package/dist/diagrams/PolymetricTree.svelte +116 -0
  54. package/dist/diagrams/PolymetricTree.svelte.d.ts +28 -0
  55. package/dist/diagrams/StructureDiagram.svelte +38 -8
  56. package/dist/diagrams/StructureDiagram.svelte.d.ts +9 -1
  57. package/dist/diagrams/Sunburst.svelte +44 -8
  58. package/dist/diagrams/Sunburst.svelte.d.ts +12 -2
  59. package/dist/diagrams/Treemap.svelte +44 -8
  60. package/dist/diagrams/Treemap.svelte.d.ts +12 -2
  61. package/dist/index.d.ts +13 -3
  62. package/dist/index.js +11 -2
  63. package/dist/layout/arcs.d.ts +6 -0
  64. package/dist/layout/arcs.js +141 -0
  65. package/dist/layout/cards.js +9 -2
  66. package/dist/layout/edges.d.ts +13 -1
  67. package/dist/layout/edges.js +35 -0
  68. package/dist/layout/extent.d.ts +17 -0
  69. package/dist/layout/extent.js +27 -0
  70. package/dist/layout/flow.js +3 -1
  71. package/dist/layout/index.d.ts +4 -1
  72. package/dist/layout/index.js +17 -2
  73. package/dist/layout/layers.d.ts +6 -0
  74. package/dist/layout/layers.js +180 -0
  75. package/dist/layout/matrix.d.ts +50 -0
  76. package/dist/layout/matrix.js +110 -0
  77. package/dist/layout/neighborhood.js +16 -7
  78. package/dist/layout/options.d.ts +1 -1
  79. package/dist/layout/options.js +16 -3
  80. package/dist/layout/percentile.d.ts +8 -0
  81. package/dist/layout/percentile.js +13 -0
  82. package/dist/layout/polymetric.d.ts +3 -0
  83. package/dist/layout/polymetric.js +221 -0
  84. package/dist/layout/radial.js +3 -1
  85. package/dist/layout/structure.js +7 -1
  86. package/dist/layout/sunburst.js +11 -4
  87. package/dist/layout/types.d.ts +83 -0
  88. package/dist/layout/world.js +11 -4
  89. package/dist/messages.d.ts +14 -0
  90. package/dist/messages.js +17 -0
  91. package/dist/model/condense.d.ts +8 -0
  92. package/dist/model/condense.js +114 -0
  93. package/dist/model/entities.d.ts +12 -0
  94. package/dist/model/entities.js +12 -0
  95. package/dist/model/normalize.d.ts +2 -6
  96. package/dist/model/normalize.js +83 -5
  97. package/dist/model/relationships.d.ts +30 -0
  98. package/dist/model/relationships.js +51 -0
  99. package/dist/model/share.d.ts +9 -0
  100. package/dist/model/share.js +43 -0
  101. package/dist/model/tree.d.ts +7 -1
  102. package/dist/model/tree.js +11 -4
  103. package/dist/preset.d.ts +18 -0
  104. package/dist/preset.js +34 -3
  105. package/dist/schema/EntitiesView.svelte +8 -6
  106. package/dist/schema/EntityView.svelte +13 -10
  107. package/dist/state/GraphConfig.svelte.d.ts +124 -0
  108. package/dist/state/GraphConfig.svelte.js +253 -0
  109. package/dist/state/GraphDrill.svelte.d.ts +52 -0
  110. package/dist/state/GraphDrill.svelte.js +115 -0
  111. package/dist/state/GraphGroups.svelte.d.ts +35 -0
  112. package/dist/state/GraphGroups.svelte.js +73 -0
  113. package/dist/state/GraphSelection.svelte.d.ts +53 -0
  114. package/dist/state/GraphSelection.svelte.js +94 -0
  115. package/dist/state/intents.d.ts +33 -0
  116. package/dist/state/intents.js +30 -0
  117. package/dist/state/text.d.ts +28 -0
  118. package/dist/state/text.js +56 -0
  119. package/dist/types.d.ts +92 -1
  120. package/package.json +5 -4
package/README.md CHANGED
@@ -66,7 +66,8 @@ one layout, the controls that mean something for it, an opt-in legend. The same
66
66
  ```
67
67
 
68
68
  `ErDiagram` · `DependencyDiagram` · `CallTree` · `Treemap` · `Sunburst` · `StructureDiagram` ·
69
- `Neighborhood`.
69
+ `Neighborhood` · `DependencyMatrix` (a dependency structure matrix — cells above the diagonal are
70
+ dependencies against the grain).
70
71
  Both `controls` and `legend` default to off: the default is the bare picture.
71
72
 
72
73
  Composing your own is the same parts in a different box — `DensityControl`, `EdgeStyleControl`,
@@ -111,6 +112,12 @@ import { appliesTo } from '@rokkit/graph'
111
112
  appliesTo('world', 'density') // false — a treemap box has no row list to thin
112
113
  ```
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
+
114
121
  ## Two things worth knowing
115
122
 
116
123
  **An unplaced edge is dimmed, never dropped.** An endpoint the map cannot resolve is a normal
@@ -129,6 +136,57 @@ const er = toGraphInput(model, 'er') // tables + refs
129
136
  const deps = toGraphInput(model, 'dependencies') // tables + entities, deps as edges
130
137
  ```
131
138
 
139
+ ## Imports against shared commits
140
+
141
+ `ArcDiagram` puts items on one axis, with imports arcing left and shared commits right. Flag a
142
+ co-change pair with no import `hidden` (rokkit computes no history) and it draws in red; *Hidden
143
+ only* leaves just those. Arcs are as thick as their weight, and each side is scaled on its own.
144
+ The legend names each side from the relations drawn there; `sideLabels` names them yourself.
145
+
146
+ ## Shading by a share
147
+
148
+ `Treemap` and `Sunburst` take `shadeBy`, a 0..1 share from each node's measures, and paint it
149
+ on a ramp beside the size. A container takes the size-weighted mean of what it holds, and
150
+ unmeasured boxes are hatched, not zeroed. The label colour is measured for every step.
151
+
152
+ ## Three measures per box
153
+
154
+ `PolymetricTree` draws containment top down, each file a box whose width, height and shade are
155
+ three measures you name: `widthBy="fns" heightBy="loc" colorBy="churn"`. Send containment as
156
+ `parent` ids or as `path`. Each channel caps at its 95th percentile, so one huge file does not
157
+ flatten the rest; a capped box and a missing measure are both marked, and a missing value is
158
+ never drawn as a zero. The legend names each channel.
159
+
160
+ ## Checking a layered architecture
161
+
162
+ `LayersDiagram` draws the layers you intend (`layer` on each node, 0 at the top) and marks
163
+ every edge `down`, `skip` (dashed), `up` (red, the violation) or `level`. *Violations only*
164
+ leaves just the edges that climb. rokkit computes no depth; it compares the layers you gave.
165
+
166
+ ## Condensing cycles into group nodes
167
+
168
+ Send each strongly-connected component as a node with `members` (rokkit computes none). Its
169
+ members collapse into it, their edges reroute and aggregate onto it, and it expands back to
170
+ show the cycle. Flag the cheapest edge to cut `weakest: true` and it is drawn dashed in the
171
+ danger colour. `onexpand` / `oncollapse` tell you when the reader opens or folds one.
172
+
173
+ ## Drilling a tree too large to load
174
+
175
+ `Treemap`, `Sunburst` and `StructureDiagram` draw the subtree at `focusPath`. The reader
176
+ opens a box (a click, or Enter on it), and `ondrill(path, node)` tells you, so you can load that
177
+ level on demand:
178
+
179
+ ```svelte
180
+ <Treemap {nodes} {fields} bind:focusPath ondrill={(path) => load(path)} ondrillup={(path) => load(path)} />
181
+ ```
182
+
183
+ - Return a promise while you fetch. The old level dims and the drill bar says *Loading…*. A
184
+ rejection returns to the previous level and says why.
185
+ - `path` is the path in YOUR data. Send one level at a time: the containers on the focus path
186
+ are kept even when nothing declares them.
187
+ - `bind:focusPath` (or omit it). An unbound prop is yours, and a re-render puts it back.
188
+ - Drilling never selects. `onselect` is not called.
189
+
132
190
  ## Icons need a safelist
133
191
 
134
192
  The component picks a node's icon at runtime from its kind, so the class names never appear in
@@ -155,3 +213,11 @@ from `createGraphPreset({ using: 'color' | 'pattern' })`.
155
213
  - [Package reference](../../docs/llms/packages/graph.txt) — data contract, layouts, dbd adapter
156
214
  - [Component reference](../../docs/llms/components/graph.txt) — props, interaction, attributes
157
215
  - [Design](../../docs/design/23-graph.md) — why it is its own package, and what is locked
216
+
217
+ ## Words and interactions
218
+
219
+ Every string graph shows is a key in `messages.graph` (`@rokkit/states`), so a locale replaces
220
+ it: `messages.register('de', { graph: { zoomIn: 'Vergrößern' } })`. No element carries an event
221
+ handler: the state writes each element's intent (`data-graph-press`, `data-graph-open`) and
222
+ the `interactions`, `choices` and `canvasNavigation` actions perform it through
223
+ `GraphState.act`.
package/dist/Graph.svelte CHANGED
@@ -4,7 +4,13 @@
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
+ import { nextZoom } from './controls/zoom.js'
8
+ import { anchoredScroll, frameOf } from './canvas/frame.js'
9
+ import type { Frame } from './canvas/frame.js'
10
+ import { interactions } from './actions/interactions.js'
11
+ import { canvasNavigation } from './actions/canvas.js'
12
+ import { say } from './messages.js'
13
+ import { readable } from './state/text.js'
8
14
 
9
15
  // Aliased: a local binding literally named `state` makes the compiler read the `$state`
10
16
  // rune below as a store subscription on it. The PUBLIC prop name is still `state`.
@@ -31,6 +37,11 @@
31
37
  arrows = true,
32
38
  label = undefined,
33
39
  onselect = undefined,
40
+ focusPath = $bindable([]),
41
+ ondrill = undefined,
42
+ ondrillup = undefined,
43
+ onexpand = undefined,
44
+ oncollapse = undefined,
34
45
  icons: userIcons = undefined,
35
46
  class: className = ''
36
47
  }: GraphProps = $props()
@@ -55,7 +66,15 @@
55
66
  preset,
56
67
  mode,
57
68
  label,
58
- onselect
69
+ onselect,
70
+ focusPath,
71
+ ondrill,
72
+ ondrillup,
73
+ onexpand,
74
+ oncollapse,
75
+ // Drilling moves focusPath inside the state; keep the bindable prop in step, or the next
76
+ // update() would put the old value back.
77
+ onfocuspath: (path) => (focusPath = path)
59
78
  })
60
79
 
61
80
  // untrack: the constructor's initial read must not register as a dependency of whatever
@@ -93,57 +112,42 @@
93
112
  let vh = $state(0)
94
113
  let paper = $state<HTMLElement | null>(null)
95
114
 
96
- function zoomBy(factor: number) {
97
- zoom = clampZoom(zoom * factor)
98
- }
115
+ // The frame — scale, placement and scroll extent — is pure arithmetic over the measured
116
+ // viewport (`canvas/frame.ts`, #171). The world sits inside an EXTENT box sized to the
117
+ // scaled drawing plus PAD all round: a CSS transform does not change layout size, so left
118
+ // to the transformed world the paper's scroll area was the unscaled box — unreachable past
119
+ // fit, phantom scroll at it.
120
+ const frame = $derived(frameOf({ vw, vh, content: graph.contentSize, zoom, pad: PAD }))
121
+ const scale = $derived(frame.scale)
99
122
 
100
- /**
101
- * Ctrl/meta + wheel is what a trackpad pinch reports as. Without preventDefault the
102
- * browser zooms the whole PAGE instead of the diagram, which is the behaviour a reader
103
- * hits first and reads as the component ignoring them.
104
- *
105
- * A plain wheel is left alone: the canvas scrolls, which is how panning works here.
106
- */
107
- function onWheel(event: WheelEvent) {
108
- if (!zoomable || !(event.ctrlKey || event.metaKey)) return
109
- event.preventDefault()
110
- zoomBy(event.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP)
111
- }
123
+ /** Where the next zoom should hold still: the pointer for a pinch; the centre otherwise. */
124
+ let anchor: { x: number; y: number } | null = null
125
+ let previous: Frame | null = null
112
126
 
113
- // Drag-to-pan on the background. Scrolling already reaches the overflow, so this is the
114
- // direct-manipulation path on top of it, not the only way across.
115
- let panning = $state(false)
116
- let panFrom = { x: 0, y: 0, left: 0, top: 0 }
117
-
118
- function onPointerDown(event: PointerEvent) {
119
- // Only the background drags — a press that starts on a card is a selection.
120
- if (!paper || (event.target as HTMLElement).closest('[data-graph-node]')) return
121
- panning = true
122
- panFrom = { x: event.clientX, y: event.clientY, left: paper.scrollLeft, top: paper.scrollTop }
123
- paper.setPointerCapture(event.pointerId)
124
- }
125
-
126
- function onPointerMove(event: PointerEvent) {
127
- if (!panning || !paper) return
128
- paper.scrollLeft = panFrom.left - (event.clientX - panFrom.x)
129
- paper.scrollTop = panFrom.top - (event.clientY - panFrom.y)
127
+ const onzoom = (direction: 'in' | 'out', at: { x: number; y: number }) => {
128
+ anchor = at
129
+ zoom = nextZoom(zoom, direction)
130
130
  }
131
131
 
132
- function endPan() {
133
- panning = false
134
- }
135
-
136
- const fit = $derived(
137
- Math.max(
138
- 0.08,
139
- Math.min((vw - PAD * 2) / graph.contentSize.w, (vh - PAD * 2) / graph.contentSize.h, 1)
140
- ) || 0.5
141
- )
142
- const scale = $derived(fit * zoom)
143
- // Centre while the content is smaller than the viewport; once it is larger, pin to a
144
- // padding offset so scrolling reaches the far edge instead of clipping it.
145
- const tx = $derived(Math.max(PAD, (vw - graph.contentSize.w * scale) / 2))
146
- const ty = $derived(Math.max(PAD, (vh - graph.contentSize.h * scale) / 2))
132
+ // After a change of scale, scroll so the anchored content point is where it was. Runs after
133
+ // the DOM update, so the extent has already grown or shrunk to the new size.
134
+ $effect(() => {
135
+ const next = frame
136
+ untrack(() => {
137
+ if (previous && paper && next.scale !== previous.scale) {
138
+ const scroll = anchoredScroll({
139
+ before: previous,
140
+ after: next,
141
+ scroll: { left: paper.scrollLeft, top: paper.scrollTop },
142
+ anchor: anchor ?? { x: vw / 2, y: vh / 2 }
143
+ })
144
+ paper.scrollLeft = scroll.left
145
+ paper.scrollTop = scroll.top
146
+ }
147
+ previous = next
148
+ anchor = null
149
+ })
150
+ })
147
151
 
148
152
  /**
149
153
  * Level of detail, from the EFFECTIVE scale rather than from `zoom` — a diagram fitted to
@@ -175,244 +179,215 @@
175
179
  data-graph-paper
176
180
  data-graph-detail={detail}
177
181
  data-graph-group-tint={graph.groupTint ? '' : undefined}
178
- data-graph-panning={panning ? '' : undefined}
182
+ data-graph-pending={graph.pending ? '' : undefined}
183
+ aria-busy={graph.pending}
179
184
  role="presentation"
180
185
  bind:this={paper}
181
186
  bind:clientWidth={vw}
182
187
  bind:clientHeight={vh}
183
- onclick={() => graph.clear()}
184
- onkeydown={(e) => e.key === 'Escape' && graph.clear()}
185
- onwheel={onWheel}
186
- onpointerdown={onPointerDown}
187
- onpointermove={onPointerMove}
188
- onpointerup={endPan}
189
- onpointercancel={endPan}
188
+ use:interactions={{ state: graph, clearOnBackground: true }}
189
+ use:canvasNavigation={{ zoomable, onzoom }}
190
190
  tabindex="-1"
191
191
  >
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.h}px; transform: translate({tx}px, {ty}px) scale({scale}); --graph-label-counter-scale: {(1 / scale).toFixed(3)};"
197
- >
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
208
- >
209
- {/each}
192
+ <div data-graph-extent style:width="{frame.extent.w}px" style:height="{frame.extent.h}px">
193
+ <div
194
+ data-graph-world
195
+ data-graph-node-shape={graph.nodeShape}
196
+ data-graph-layout={graph.layoutName}
197
+ style="width: {graph.size.w}px; height: {graph.size
198
+ .h}px; transform: translate({frame.tx}px, {frame.ty}px) scale({scale}); --graph-label-counter-scale: {(
199
+ 1 / scale
200
+ ).toFixed(3)};"
201
+ >
202
+ {#each graph.columns as column (column.side + column.depth)}
203
+ <span
204
+ data-graph-column
205
+ data-column-side={column.side}
206
+ data-column-depth={column.depth}
207
+ style:left="{column.x}px"
208
+ style:width="{column.w}px">{column.label}</span
209
+ >
210
+ {/each}
210
211
 
211
- <!-- A wedge cannot be a positioned <div> — an annulus sector has no box. It is drawn in
212
+ <!-- A wedge cannot be a positioned <div> — an annulus sector has no box. It is drawn in
212
213
  its own SVG UNDER the boxes, so a sunburst renders through the same cluster list as
213
214
  every other containment layout and nothing downstream learns a second vocabulary. -->
214
- {#if graph.hasWedges}
215
+ {#if graph.hasWedges}
216
+ <svg
217
+ data-graph-wedges
218
+ width={graph.size.w}
219
+ height={graph.size.h}
220
+ role="img"
221
+ aria-label={graph.label}
222
+ style="position: absolute; top: 0; left: 0;"
223
+ >
224
+ <title>{graph.label}</title>
225
+ {#each graph.clusters as cluster (graph.clusterKey(cluster))}
226
+ <!-- Two branches so `role` is STATIC: an interactive wedge is a button and says so;
227
+ a region is scenery. What a press on it means comes from state, in the
228
+ shared attributes, and the canvas's one `interactions` action acts on it. -->
229
+ {#if graph.interactive(cluster)}
230
+ <path
231
+ data-graph-wedge
232
+ {...graph.boxAttrs(cluster)}
233
+ style={graph.boxStyleAttr(cluster)}
234
+ d={graph.wedgePath(cluster)}
235
+ role="button"
236
+ tabindex={0}><title>{graph.caption(cluster)}</title></path
237
+ >
238
+ {:else}
239
+ <path
240
+ data-graph-wedge
241
+ {...graph.boxAttrs(cluster)}
242
+ style={graph.boxStyleAttr(cluster)}
243
+ d={graph.wedgePath(cluster)}><title>{graph.caption(cluster)}</title></path
244
+ >
245
+ {/if}
246
+ {/each}
247
+ </svg>
248
+ {/if}
249
+
250
+ {#each graph.boxes as cluster (graph.clusterKey(cluster))}
251
+ <!-- One box shape at every depth: a containment layout nests boxes inside boxes, so a
252
+ childless one is still a box, not a node card.
253
+
254
+ Two branches, not one element with a conditional tag: an interactive box is a real
255
+ <button> and gets Enter/Space, focus order and the right announcement for free; a
256
+ region is scenery and is a <div> that says so. What its press means — select a leaf,
257
+ open a container — comes from state in the shared attributes. -->
258
+ {#if graph.interactive(cluster)}
259
+ <button
260
+ type="button"
261
+ data-graph-cluster
262
+ {...graph.boxAttrs(cluster)}
263
+ style:left="{cluster.x}px"
264
+ style:top="{cluster.y}px"
265
+ style:width="{cluster.w}px"
266
+ style:height="{cluster.h}px"
267
+ style={graph.boxStyleAttr(cluster)}
268
+ style:--shade={cluster.shade}
269
+ >
270
+ <span data-graph-cluster-label>{graph.caption(cluster)}</span>
271
+ </button>
272
+ {:else}
273
+ <div
274
+ data-graph-cluster
275
+ {...graph.boxAttrs(cluster)}
276
+ style:left="{cluster.x}px"
277
+ style:top="{cluster.y}px"
278
+ style:width="{cluster.w}px"
279
+ style:height="{cluster.h}px"
280
+ style={graph.boxStyleAttr(cluster)}
281
+ >
282
+ <span data-graph-cluster-label>{graph.caption(cluster)}</span>
283
+ </div>
284
+ {/if}
285
+ {/each}
286
+
215
287
  <svg
216
- data-graph-wedges
217
288
  width={graph.size.w}
218
289
  height={graph.size.h}
219
290
  role="img"
220
291
  aria-label={graph.label}
221
- style="position: absolute; top: 0; left: 0;"
292
+ style="position: absolute; top: 0; left: 0; pointer-events: none;"
222
293
  >
223
294
  <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}
295
+ {#each graph.routedEdges as edge (edge.id)}
296
+ <g
297
+ data-graph-edge
298
+ data-edge-kind={edge.kind}
299
+ data-edge-relation={edge.relation}
300
+ data-edge-from={edge.fromKey}
301
+ data-edge-to={edge.toKey}
302
+ data-edge-state={graph.edgeState(edge)}
303
+ data-edge-overlay={edge.overlay ? '' : undefined}
304
+ data-edge-weakest={edge.weakest ? '' : undefined}
305
+ data-edge-hidden={edge.hidden ? '' : undefined}
306
+ data-edge-side={edge.side}
307
+ data-edge-conformance={edge.conformance}
308
+ data-edge-count={edge.count}
309
+ style:--edge-weight={graph.edgeWeight(edge)}
310
+ >
311
+ <path d={graph.edgePath(edge)} />
312
+ <circle data-graph-edge-dot="from" cx={edge.x1} cy={edge.y1} r="3.2" />
313
+ {#if arrows}
314
+ <!-- An edge HAS a direction — source to target — and two identical dots threw
315
+ that away. The tangent of both path shapes is horizontal at the endpoint,
316
+ so the heading is just the side the curve arrives from: -s2. No angle
317
+ maths, and it stays correct for a self-loop. -->
318
+ {@const d = -edge.s2}
319
+ <polygon
320
+ data-graph-edge-arrow
321
+ points="{edge.x2},{edge.y2} {edge.x2 - d * 9},{edge.y2 - 4.5} {edge.x2 -
322
+ d * 9},{edge.y2 + 4.5}"
323
+ />
324
+ {:else}
325
+ <circle data-graph-edge-dot="to" cx={edge.x2} cy={edge.y2} r="3.2" />
326
+ {/if}
327
+ </g>
259
328
  {/each}
260
329
  </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
330
 
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}
331
+ {#each Object.entries(graph.cards) as [key, card] (key)}
276
332
  <button
277
333
  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)}
334
+ data-graph-node={key}
335
+ {...graph.cardAttrs(key)}
336
+ data-label-side={card.labelSide}
337
+ style:--label-angle={card.labelAngle === undefined ? undefined : `${card.labelAngle}deg`}
338
+ style:left="{card.x}px"
339
+ style:top="{card.y}px"
340
+ style:width="{card.w}px"
341
+ style:height="{card.h}px"
342
+ style={graph.groupStyleAttr(card.node.group)}
301
343
  >
302
- <span data-graph-cluster-label>{caption}</span>
303
- </div>
304
- {/if}
305
- {/each}
306
-
307
- <svg
308
- width={graph.size.w}
309
- height={graph.size.h}
310
- role="img"
311
- aria-label={graph.label}
312
- style="position: absolute; top: 0; left: 0; pointer-events: none;"
313
- >
314
- <title>{graph.label}</title>
315
- {#each graph.routedEdges as edge (edge.id)}
316
- <g
317
- data-graph-edge
318
- data-edge-kind={edge.kind}
319
- data-edge-relation={edge.relation}
320
- data-edge-from={edge.fromKey}
321
- data-edge-to={edge.toKey}
322
- data-edge-state={graph.edgeState(edge)}
323
- >
324
- <path d={graph.edgePath(edge)} />
325
- <circle data-graph-edge-dot="from" cx={edge.x1} cy={edge.y1} r="3.2" />
326
- {#if arrows}
327
- <!-- An edge HAS a direction — source to target — and two identical dots threw
328
- that away. The tangent of both path shapes is horizontal at the endpoint,
329
- so the heading is just the side the curve arrives from: -s2. No angle
330
- maths, and it stays correct for a self-loop. -->
331
- {@const d = -edge.s2}
332
- <polygon
333
- data-graph-edge-arrow
334
- points="{edge.x2},{edge.y2} {edge.x2 - d * 9},{edge.y2 - 4.5} {edge.x2 -
335
- d * 9},{edge.y2 + 4.5}"
336
- />
337
- {:else}
338
- <circle data-graph-edge-dot="to" cx={edge.x2} cy={edge.y2} r="3.2" />
339
- {/if}
340
- </g>
341
- {/each}
342
- </svg>
343
-
344
- {#each Object.entries(graph.cards) as [key, card] (key)}
345
- <button
346
- type="button"
347
- data-graph-node={key}
348
- data-node-kind={card.node.kind}
349
- data-node-group={card.node.group}
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`}
355
- data-node-headonly={card.vis.length === 0 && card.more <= 0 ? '' : undefined}
356
- style:left="{card.x}px"
357
- style:top="{card.y}px"
358
- style:width="{card.w}px"
359
- style:height="{card.h}px"
360
- style={graph.groupStyleAttr(card.node.group)}
361
- onclick={(event) => {
362
- event.stopPropagation()
363
- graph.select(key)
364
- }}
365
- >
366
- <span data-graph-node-head>
367
- <span data-graph-node-icon class={icons[card.node.kind ?? ''] ?? icons.fallback}></span>
368
- <span data-graph-node-title>{card.node.label}</span>
369
- {#if card.node.kind}
370
- <!-- The glyph alone is a guess: layers vs eye vs bolt does not tell anyone what a
344
+ <span data-graph-node-head>
345
+ <span data-graph-node-icon class={icons[card.node.kind ?? ''] ?? icons.fallback}></span>
346
+ <span data-graph-node-title>{card.node.label}</span>
347
+ {#if card.node.kind}
348
+ <!-- The glyph alone is a guess: layers vs eye vs bolt does not tell anyone what a
371
349
  materialized view is. Icon for the glance, tag for the answer. Underscores
372
350
  are the WIRE format (dbd sends `materialized_view`), not something to
373
351
  read. -->
374
- <span data-graph-node-kind>{card.node.kind.replace(/_/g, ' ')}</span>
375
- {/if}
376
- <span data-graph-node-count>{card.node.rows.length}</span>
377
- </span>
378
- {#each card.vis as row (row.name)}
379
- <span data-graph-row data-graph-row-key={row.badges.length > 0 ? '' : undefined}>
380
- {#if row.badges.length > 0}
381
- <span data-row-badge={row.badges[0]} class={icons[row.badges[0]]}></span>
382
- {:else}
383
- <span data-row-badge-empty></span>
352
+ <span data-graph-node-kind>{readable(card.node.kind)}</span>
384
353
  {/if}
385
- <span data-graph-row-name>{row.name}</span>
386
- <span data-graph-row-type>{row.type}</span>
354
+ <!-- A group counts what it stands for; a table counts its rows. -->
355
+ <span data-graph-node-count>{graph.cardCount(key)}</span>
387
356
  </span>
388
- {/each}
389
- {#if graph.moreLabel(key)}
390
- <!-- A real control. "+3 more" that does nothing is a statement dressed as an
357
+ {#each card.vis as row (row.name)}
358
+ <span data-graph-row data-graph-row-key={row.badges.length > 0 ? '' : undefined}>
359
+ {#if row.badges.length > 0}
360
+ <span data-row-badge={row.badges[0]} class={icons[row.badges[0]]}></span>
361
+ {:else}
362
+ <span data-row-badge-empty></span>
363
+ {/if}
364
+ <span data-graph-row-name>{row.name}</span>
365
+ <span data-graph-row-type>{row.type}</span>
366
+ </span>
367
+ {/each}
368
+ {#if graph.isCollapsed(key)}
369
+ <!-- The way in, as a real control inside the card — the same pattern as "+3 more":
370
+ the card's own press still selects, this one expands. -->
371
+ <span
372
+ role="button"
373
+ tabindex="0"
374
+ data-graph-group-toggle
375
+ data-graph-press="group"
376
+ data-graph-key={key}
377
+ aria-label={say('expand', { name: card.node.label })}
378
+ >{say('expandCount', { n: graph.memberCount(key) })}</span
379
+ >
380
+ {/if}
381
+ {#if graph.moreLabel(key)}
382
+ <!-- A real control. "+3 more" that does nothing is a statement dressed as an
391
383
  affordance; this expands just this card. -->
392
- <span
393
- role="button"
394
- tabindex="0"
395
- data-graph-more
396
- data-graph-more-empty={card.vis.length === 0 && !graph.isExpanded(key)
397
- ? ''
398
- : undefined}
399
- data-expanded={graph.isExpanded(key) ? '' : undefined}
400
- onclick={(event) => {
401
- event.stopPropagation()
402
- graph.toggleExpanded(key)
403
- }}
404
- onkeydown={(event) => {
405
- if (event.key !== 'Enter' && event.key !== ' ') return
406
- event.preventDefault()
407
- event.stopPropagation()
408
- graph.toggleExpanded(key)
409
- }}
410
- >{graph.moreLabel(key)}</span
411
- >
412
- {/if}
413
- </button>
414
- {/each}
384
+ <span role="button" tabindex="0" data-graph-more {...graph.moreAttrs(key)}
385
+ >{graph.moreLabel(key)}</span
386
+ >
387
+ {/if}
388
+ </button>
389
+ {/each}
390
+ </div>
415
391
  </div>
416
392
  </div>
417
-
418
393
  </div>
@@ -1,4 +1,4 @@
1
1
  import type { GraphProps } from './types.js';
2
- declare const Graph: import("svelte").Component<GraphProps, {}, "density" | "zoom">;
2
+ declare const Graph: import("svelte").Component<GraphProps, {}, "density" | "focusPath" | "zoom">;
3
3
  type Graph = ReturnType<typeof Graph>;
4
4
  export default Graph;