@rokkit/graph 1.8.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 (119) hide show
  1. package/README.md +59 -0
  2. package/dist/Graph.svelte +108 -134
  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 +161 -52
  7. package/dist/GraphState.svelte.js +380 -304
  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 +11 -32
  46. package/dist/diagrams/ErDiagram.svelte +18 -6
  47. package/dist/diagrams/ErDiagram.svelte.d.ts +5 -1
  48. package/dist/diagrams/LayersDiagram.svelte +124 -0
  49. package/dist/diagrams/LayersDiagram.svelte.d.ts +30 -0
  50. package/dist/diagrams/Neighborhood.svelte +26 -6
  51. package/dist/diagrams/Neighborhood.svelte.d.ts +11 -1
  52. package/dist/diagrams/PolymetricTree.svelte +116 -0
  53. package/dist/diagrams/PolymetricTree.svelte.d.ts +28 -0
  54. package/dist/diagrams/StructureDiagram.svelte +38 -8
  55. package/dist/diagrams/StructureDiagram.svelte.d.ts +9 -1
  56. package/dist/diagrams/Sunburst.svelte +44 -8
  57. package/dist/diagrams/Sunburst.svelte.d.ts +12 -2
  58. package/dist/diagrams/Treemap.svelte +44 -8
  59. package/dist/diagrams/Treemap.svelte.d.ts +12 -2
  60. package/dist/index.d.ts +10 -3
  61. package/dist/index.js +9 -2
  62. package/dist/layout/arcs.d.ts +6 -0
  63. package/dist/layout/arcs.js +141 -0
  64. package/dist/layout/cards.js +9 -2
  65. package/dist/layout/edges.d.ts +13 -1
  66. package/dist/layout/edges.js +26 -2
  67. package/dist/layout/extent.d.ts +17 -0
  68. package/dist/layout/extent.js +27 -0
  69. package/dist/layout/flow.js +3 -1
  70. package/dist/layout/index.d.ts +4 -1
  71. package/dist/layout/index.js +17 -2
  72. package/dist/layout/layers.d.ts +6 -0
  73. package/dist/layout/layers.js +180 -0
  74. package/dist/layout/matrix.d.ts +12 -0
  75. package/dist/layout/matrix.js +20 -0
  76. package/dist/layout/neighborhood.js +16 -7
  77. package/dist/layout/options.d.ts +1 -1
  78. package/dist/layout/options.js +16 -3
  79. package/dist/layout/percentile.d.ts +8 -0
  80. package/dist/layout/percentile.js +13 -0
  81. package/dist/layout/polymetric.d.ts +3 -0
  82. package/dist/layout/polymetric.js +221 -0
  83. package/dist/layout/radial.js +3 -1
  84. package/dist/layout/structure.js +7 -1
  85. package/dist/layout/sunburst.js +11 -4
  86. package/dist/layout/types.d.ts +79 -0
  87. package/dist/layout/world.js +11 -4
  88. package/dist/messages.d.ts +14 -0
  89. package/dist/messages.js +17 -0
  90. package/dist/model/condense.d.ts +8 -0
  91. package/dist/model/condense.js +114 -0
  92. package/dist/model/entities.d.ts +12 -0
  93. package/dist/model/entities.js +12 -0
  94. package/dist/model/normalize.d.ts +2 -6
  95. package/dist/model/normalize.js +64 -2
  96. package/dist/model/relationships.d.ts +30 -0
  97. package/dist/model/relationships.js +51 -0
  98. package/dist/model/share.d.ts +9 -0
  99. package/dist/model/share.js +43 -0
  100. package/dist/model/tree.d.ts +7 -1
  101. package/dist/model/tree.js +11 -4
  102. package/dist/preset.d.ts +18 -0
  103. package/dist/preset.js +34 -3
  104. package/dist/schema/EntitiesView.svelte +8 -6
  105. package/dist/schema/EntityView.svelte +13 -10
  106. package/dist/state/GraphConfig.svelte.d.ts +124 -0
  107. package/dist/state/GraphConfig.svelte.js +253 -0
  108. package/dist/state/GraphDrill.svelte.d.ts +52 -0
  109. package/dist/state/GraphDrill.svelte.js +115 -0
  110. package/dist/state/GraphGroups.svelte.d.ts +35 -0
  111. package/dist/state/GraphGroups.svelte.js +73 -0
  112. package/dist/state/GraphSelection.svelte.d.ts +53 -0
  113. package/dist/state/GraphSelection.svelte.js +94 -0
  114. package/dist/state/intents.d.ts +33 -0
  115. package/dist/state/intents.js +30 -0
  116. package/dist/state/text.d.ts +28 -0
  117. package/dist/state/text.js +56 -0
  118. package/dist/types.d.ts +72 -1
  119. package/package.json +3 -2
package/README.md CHANGED
@@ -136,6 +136,57 @@ const er = toGraphInput(model, 'er') // tables + refs
136
136
  const deps = toGraphInput(model, 'dependencies') // tables + entities, deps as edges
137
137
  ```
138
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
+
139
190
  ## Icons need a safelist
140
191
 
141
192
  The component picks a node's icon at runtime from its kind, so the class names never appear in
@@ -162,3 +213,11 @@ from `createGraphPreset({ using: 'color' | 'pattern' })`.
162
213
  - [Package reference](../../docs/llms/packages/graph.txt) — data contract, layouts, dbd adapter
163
214
  - [Component reference](../../docs/llms/components/graph.txt) — props, interaction, attributes
164
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
- }
99
-
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
- }
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)
112
122
 
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 }
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
117
126
 
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)
127
+ const onzoom = (direction: 'in' | 'out', at: { x: number; y: number }) => {
128
+ anchor = at
129
+ zoom = nextZoom(zoom, direction)
124
130
  }
125
131
 
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)
130
- }
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,32 +179,26 @@
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 data-graph-extent style:width="{frame.extent.w}px" style:height="{frame.extent.h}px">
192
193
  <div
193
194
  data-graph-world
194
195
  data-graph-node-shape={graph.nodeShape}
195
196
  data-graph-layout={graph.layoutName}
196
197
  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
+ .h}px; transform: translate({frame.tx}px, {frame.ty}px) scale({scale}); --graph-label-counter-scale: {(
198
199
  1 / scale
199
200
  ).toFixed(3)};"
200
201
  >
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
202
  {#each graph.columns as column (column.side + column.depth)}
205
203
  <span
206
204
  data-graph-column
@@ -225,38 +223,24 @@
225
223
  >
226
224
  <title>{graph.label}</title>
227
225
  {#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}
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)}
235
230
  <path
236
231
  data-graph-wedge
237
- {...shared}
238
- style={graph.groupStyleAttr(cluster.ramp ?? cluster.name)}
232
+ {...graph.boxAttrs(cluster)}
233
+ style={graph.boxStyleAttr(cluster)}
239
234
  d={graph.wedgePath(cluster)}
240
235
  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
236
+ tabindex={0}><title>{graph.caption(cluster)}</title></path
253
237
  >
254
238
  {:else}
255
239
  <path
256
240
  data-graph-wedge
257
- {...shared}
258
- style={graph.groupStyleAttr(cluster.ramp ?? cluster.name)}
259
- d={graph.wedgePath(cluster)}><title>{caption}</title></path
241
+ {...graph.boxAttrs(cluster)}
242
+ style={graph.boxStyleAttr(cluster)}
243
+ d={graph.wedgePath(cluster)}><title>{graph.caption(cluster)}</title></path
260
244
  >
261
245
  {/if}
262
246
  {/each}
@@ -264,45 +248,38 @@
264
248
  {/if}
265
249
 
266
250
  {#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.
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.
273
253
 
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}
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)}
279
259
  <button
280
260
  type="button"
281
261
  data-graph-cluster
282
- {...shared}
262
+ {...graph.boxAttrs(cluster)}
283
263
  style:left="{cluster.x}px"
284
264
  style:top="{cluster.y}px"
285
265
  style:width="{cluster.w}px"
286
266
  style:height="{cluster.h}px"
287
- style={graph.groupStyleAttr(cluster.ramp ?? cluster.name)}
288
- onclick={(event) => {
289
- event.stopPropagation()
290
- graph.select(cluster.nodeId!)
291
- }}
267
+ style={graph.boxStyleAttr(cluster)}
268
+ style:--shade={cluster.shade}
292
269
  >
293
- <span data-graph-cluster-label>{caption}</span>
270
+ <span data-graph-cluster-label>{graph.caption(cluster)}</span>
294
271
  </button>
295
272
  {:else}
296
273
  <div
297
274
  data-graph-cluster
298
- {...shared}
275
+ {...graph.boxAttrs(cluster)}
299
276
  style:left="{cluster.x}px"
300
277
  style:top="{cluster.y}px"
301
278
  style:width="{cluster.w}px"
302
279
  style:height="{cluster.h}px"
303
- style={graph.groupStyleAttr(cluster.ramp ?? cluster.name)}
280
+ style={graph.boxStyleAttr(cluster)}
304
281
  >
305
- <span data-graph-cluster-label>{caption}</span>
282
+ <span data-graph-cluster-label>{graph.caption(cluster)}</span>
306
283
  </div>
307
284
  {/if}
308
285
  {/each}
@@ -324,6 +301,11 @@
324
301
  data-edge-to={edge.toKey}
325
302
  data-edge-state={graph.edgeState(edge)}
326
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}
327
309
  style:--edge-weight={graph.edgeWeight(edge)}
328
310
  >
329
311
  <path d={graph.edgePath(edge)} />
@@ -350,21 +332,14 @@
350
332
  <button
351
333
  type="button"
352
334
  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)}
335
+ {...graph.cardAttrs(key)}
356
336
  data-label-side={card.labelSide}
357
337
  style:--label-angle={card.labelAngle === undefined ? undefined : `${card.labelAngle}deg`}
358
- data-node-headonly={card.vis.length === 0 && card.more <= 0 ? '' : undefined}
359
338
  style:left="{card.x}px"
360
339
  style:top="{card.y}px"
361
340
  style:width="{card.w}px"
362
341
  style:height="{card.h}px"
363
342
  style={graph.groupStyleAttr(card.node.group)}
364
- onclick={(event) => {
365
- event.stopPropagation()
366
- graph.select(key)
367
- }}
368
343
  >
369
344
  <span data-graph-node-head>
370
345
  <span data-graph-node-icon class={icons[card.node.kind ?? ''] ?? icons.fallback}></span>
@@ -374,9 +349,10 @@
374
349
  materialized view is. Icon for the glance, tag for the answer. Underscores
375
350
  are the WIRE format (dbd sends `materialized_view`), not something to
376
351
  read. -->
377
- <span data-graph-node-kind>{card.node.kind.replace(/_/g, ' ')}</span>
352
+ <span data-graph-node-kind>{readable(card.node.kind)}</span>
378
353
  {/if}
379
- <span data-graph-node-count>{card.node.rows.length}</span>
354
+ <!-- A group counts what it stands for; a table counts its rows. -->
355
+ <span data-graph-node-count>{graph.cardCount(key)}</span>
380
356
  </span>
381
357
  {#each card.vis as row (row.name)}
382
358
  <span data-graph-row data-graph-row-key={row.badges.length > 0 ? '' : undefined}>
@@ -389,31 +365,29 @@
389
365
  <span data-graph-row-type>{row.type}</span>
390
366
  </span>
391
367
  {/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. -->
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. -->
395
371
  <span
396
372
  role="button"
397
373
  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
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
383
+ affordance; this expands just this card. -->
384
+ <span role="button" tabindex="0" data-graph-more {...graph.moreAttrs(key)}
385
+ >{graph.moreLabel(key)}</span
413
386
  >
414
387
  {/if}
415
388
  </button>
416
389
  {/each}
417
390
  </div>
391
+ </div>
418
392
  </div>
419
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;
@@ -14,6 +14,9 @@
14
14
  */
15
15
  import type { GraphState } from './GraphState.svelte.js'
16
16
  import { DEFAULT_ICONS } from './icons.js'
17
+ import { choices } from './actions/choices.js'
18
+ import { say } from './messages.js'
19
+ import { readable } from './state/text.js'
17
20
 
18
21
  type Section = 'kind' | 'relation' | 'group'
19
22
 
@@ -25,6 +28,21 @@
25
28
  relations?: boolean
26
29
  /** Group names present, each with its ramp colour. */
27
30
  groups?: boolean
31
+ /**
32
+ * Which measure each channel of a polymetric view carries (#168) — three encodings at
33
+ * once are unreadable without a key.
34
+ */
35
+ channels?: boolean
36
+ /**
37
+ * The two sides of an arc diagram, left then right (#169) — which relation is which is
38
+ * not something a reader can guess from the arcs. `true` names them from the data
39
+ * (`GraphState.sideNames`); a pair names them yourself.
40
+ */
41
+ sides?: boolean | [string, string]
42
+ /** A key for the hidden-coupling stroke (#169). */
43
+ hidden?: boolean
44
+ /** What the shade means in a containment view (#164): the measure, on its ramp. */
45
+ shade?: boolean
28
46
  icons?: Record<string, string>
29
47
  /**
30
48
  * Fires with the entry a reader activated. Given one, entries become real buttons;
@@ -38,15 +56,29 @@
38
56
  kinds = false,
39
57
  relations = false,
40
58
  groups = false,
59
+ channels = false,
60
+ sides = undefined,
61
+ hidden = false,
62
+ shade = false,
41
63
  icons: userIcons,
42
64
  onpick
43
65
  }: Props = $props()
44
66
 
45
67
  const icons = $derived<Record<string, string>>({ ...DEFAULT_ICONS, ...userIcons })
46
- const shown = $derived(kinds || relations || groups)
68
+ const channelRows = $derived(channels ? graph.channelRows : [])
69
+ const sideLabels = $derived(
70
+ sides === true ? [graph.sideNames.below, graph.sideNames.above] : sides || null
71
+ )
72
+ const shadeRow = $derived(shade ? graph.shadeLegend : null)
73
+ const shown = $derived(
74
+ kinds || relations || groups || channelRows.length > 0 || Boolean(sideLabels) || hidden || Boolean(shadeRow)
75
+ )
47
76
 
48
- /** Underscores are the WIRE format (dbd sends `materialized_view`), not something to read. */
49
- const readable = (value: string) => value.replace(/_/g, ' ')
77
+ /** A pick names its section and value in one choice; split at the first colon. */
78
+ function pick(choice: string) {
79
+ const at = choice.indexOf(':')
80
+ onpick?.(choice.slice(0, at) as Section, choice.slice(at + 1))
81
+ }
50
82
 
51
83
  /** `data-legend-kind` / `-relation` / `-group`, so CSS and tests can address a section. */
52
84
  const attrs = (section: Section, value: string) => ({ [`data-legend-${section}`]: value })
@@ -72,12 +104,7 @@
72
104
  a screen reader — and a click handler on a `<span>` is unreachable by keyboard. -->
73
105
  {#snippet row(section: Section, value: string)}
74
106
  {#if onpick}
75
- <button
76
- type="button"
77
- data-graph-legend-entry
78
- {...attrs(section, value)}
79
- onclick={() => onpick(section, value)}
80
- >
107
+ <button type="button" data-graph-legend-entry {...attrs(section, value)} data-graph-choice="{section}:{value}">
81
108
  {@render swatch(section, value)}
82
109
  <span data-legend-label>{readable(value)}</span>
83
110
  </button>
@@ -90,7 +117,7 @@
90
117
  {/snippet}
91
118
 
92
119
  {#if shown}
93
- <div data-graph-legend aria-label="Legend">
120
+ <div data-graph-legend aria-label={say('legend')} use:choices={{ onchoose: pick }}>
94
121
  {#if kinds}
95
122
  {#each graph.kindsPresent as kind (kind)}
96
123
  {@render row('kind', kind)}
@@ -108,5 +135,49 @@
108
135
  {@render row('group', group)}
109
136
  {/each}
110
137
  {/if}
138
+
139
+ {#if sideLabels}
140
+ <span data-graph-legend-entry data-legend-side="below">
141
+ <span data-legend-label>← {sideLabels[0]}</span>
142
+ </span>
143
+ <span data-graph-legend-entry data-legend-side="above">
144
+ <span data-legend-label>{sideLabels[1]} →</span>
145
+ </span>
146
+ {/if}
147
+
148
+ {#if hidden}
149
+ <span data-graph-legend-entry data-legend-hidden>
150
+ <svg data-legend-swatch data-edge-hidden aria-hidden="true">
151
+ <line x1="2" y1="7" x2="26" y2="7" />
152
+ </svg>
153
+ <span data-legend-label>{say('legendHidden')}</span>
154
+ </span>
155
+ {/if}
156
+
157
+ {#if shadeRow}
158
+ <!-- The swatch is the ramp itself, low to high: the colours on the canvas, not a sample. -->
159
+ <span data-graph-legend-entry data-legend-shade={shadeRow.measure}>
160
+ <span
161
+ data-legend-swatch
162
+ data-legend-shade-swatch
163
+ style:--shade-low={shadeRow.low}
164
+ style:--shade-high={shadeRow.high}
165
+ aria-hidden="true"
166
+ ></span>
167
+ <span data-legend-label>{say('shade')}</span>
168
+ <span data-legend-measure>{readable(shadeRow.measure)}</span>
169
+ <span data-legend-cap>{say('shadeScale')}</span>
170
+ </span>
171
+ {/if}
172
+
173
+ {#each channelRows as channel (channel.key)}
174
+ <!-- The cap is what the full extent of the channel stands for: past it, a box clamps. -->
175
+ <span data-graph-legend-entry data-legend-channel={channel.key}>
176
+ <span data-legend-swatch data-legend-channel-swatch={channel.key} aria-hidden="true"></span>
177
+ <span data-legend-label>{channel.label}</span>
178
+ <span data-legend-measure>{readable(channel.scale.measure)}</span>
179
+ <span data-legend-cap>{say('legendCap', { cap: channel.scale.cap })}</span>
180
+ </span>
181
+ {/each}
111
182
  </div>
112
183
  {/if}
@@ -21,6 +21,21 @@ type Props = {
21
21
  relations?: boolean;
22
22
  /** Group names present, each with its ramp colour. */
23
23
  groups?: boolean;
24
+ /**
25
+ * Which measure each channel of a polymetric view carries (#168) — three encodings at
26
+ * once are unreadable without a key.
27
+ */
28
+ channels?: boolean;
29
+ /**
30
+ * The two sides of an arc diagram, left then right (#169) — which relation is which is
31
+ * not something a reader can guess from the arcs. `true` names them from the data
32
+ * (`GraphState.sideNames`); a pair names them yourself.
33
+ */
34
+ sides?: boolean | [string, string];
35
+ /** A key for the hidden-coupling stroke (#169). */
36
+ hidden?: boolean;
37
+ /** What the shade means in a containment view (#164): the measure, on its ramp. */
38
+ shade?: boolean;
24
39
  icons?: Record<string, string>;
25
40
  /**
26
41
  * Fires with the entry a reader activated. Given one, entries become real buttons;