@rokkit/graph 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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
package/dist/Graph.svelte CHANGED
@@ -189,81 +189,84 @@
189
189
  onpointercancel={endPan}
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
192
+ <div
193
+ data-graph-world
194
+ data-graph-node-shape={graph.nodeShape}
195
+ data-graph-layout={graph.layoutName}
196
+ style="width: {graph.size.w}px; height: {graph.size
197
+ .h}px; transform: translate({tx}px, {ty}px) scale({scale}); --graph-label-counter-scale: {(
198
+ 1 / scale
199
+ ).toFixed(3)};"
200
+ >
201
+ <!-- Column headings. Three columns get away without them — the focus is visibly
199
202
  central — but a depth-2 portrait is five columns and ambiguous without one.
200
203
  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}
204
+ {#each graph.columns as column (column.side + column.depth)}
205
+ <span
206
+ data-graph-column
207
+ data-column-side={column.side}
208
+ data-column-depth={column.depth}
209
+ style:left="{column.x}px"
210
+ style:width="{column.w}px">{column.label}</span
211
+ >
212
+ {/each}
210
213
 
211
- <!-- A wedge cannot be a positioned <div> — an annulus sector has no box. It is drawn in
214
+ <!-- A wedge cannot be a positioned <div> — an annulus sector has no box. It is drawn in
212
215
  its own SVG UNDER the boxes, so a sunburst renders through the same cluster list as
213
216
  every other containment layout and nothing downstream learns a second vocabulary. -->
214
- {#if graph.hasWedges}
215
- <svg
216
- data-graph-wedges
217
- width={graph.size.w}
218
- height={graph.size.h}
219
- role="img"
220
- aria-label={graph.label}
221
- style="position: absolute; top: 0; left: 0;"
222
- >
223
- <title>{graph.label}</title>
224
- {#each graph.clusters as cluster (graph.clusterKey(cluster))}
225
- {@const shared = graph.boxAttrs(cluster)}
226
- {@const caption = `${cluster.name} · ${cluster.caption ?? cluster.count}`}
227
- <!-- Two branches so `role` is STATIC. A wedge that is a node is a button and says
217
+ {#if graph.hasWedges}
218
+ <svg
219
+ data-graph-wedges
220
+ width={graph.size.w}
221
+ height={graph.size.h}
222
+ role="img"
223
+ aria-label={graph.label}
224
+ style="position: absolute; top: 0; left: 0;"
225
+ >
226
+ <title>{graph.label}</title>
227
+ {#each graph.clusters as cluster (graph.clusterKey(cluster))}
228
+ {@const shared = graph.boxAttrs(cluster)}
229
+ {@const caption = `${cluster.name} · ${cluster.caption ?? cluster.count}`}
230
+ <!-- Two branches so `role` is STATIC. A wedge that is a node is a button and says
228
231
  so; a region is scenery. Written as one element with a conditional role, the
229
232
  compiler cannot tell which it is and neither can a screen reader. The shared
230
233
  attributes come from state, so the branches cannot drift apart. -->
231
- {#if cluster.nodeId}
232
- <path
233
- data-graph-wedge
234
- {...shared}
235
- style={graph.groupStyleAttr(cluster.ramp ?? cluster.name)}
236
- d={graph.wedgePath(cluster)}
237
- role="button"
238
- tabindex={0}
239
- aria-label={caption}
240
- onclick={(event: Event) => {
241
- event.stopPropagation()
242
- graph.select(cluster.nodeId!)
243
- }}
244
- onkeydown={(event: KeyboardEvent) => {
245
- if (event.key !== 'Enter' && event.key !== ' ') return
246
- event.preventDefault()
247
- event.stopPropagation()
248
- graph.select(cluster.nodeId!)
249
- }}><title>{caption}</title></path
250
- >
251
- {:else}
252
- <path
253
- data-graph-wedge
254
- {...shared}
255
- style={graph.groupStyleAttr(cluster.ramp ?? cluster.name)}
256
- d={graph.wedgePath(cluster)}><title>{caption}</title></path
257
- >
258
- {/if}
259
- {/each}
260
- </svg>
261
- {/if}
234
+ {#if cluster.nodeId}
235
+ <path
236
+ data-graph-wedge
237
+ {...shared}
238
+ style={graph.groupStyleAttr(cluster.ramp ?? cluster.name)}
239
+ d={graph.wedgePath(cluster)}
240
+ role="button"
241
+ tabindex={0}
242
+ aria-label={caption}
243
+ onclick={(event: Event) => {
244
+ event.stopPropagation()
245
+ graph.select(cluster.nodeId!)
246
+ }}
247
+ onkeydown={(event: KeyboardEvent) => {
248
+ if (event.key !== 'Enter' && event.key !== ' ') return
249
+ event.preventDefault()
250
+ event.stopPropagation()
251
+ graph.select(cluster.nodeId!)
252
+ }}><title>{caption}</title></path
253
+ >
254
+ {:else}
255
+ <path
256
+ data-graph-wedge
257
+ {...shared}
258
+ style={graph.groupStyleAttr(cluster.ramp ?? cluster.name)}
259
+ d={graph.wedgePath(cluster)}><title>{caption}</title></path
260
+ >
261
+ {/if}
262
+ {/each}
263
+ </svg>
264
+ {/if}
262
265
 
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
266
+ {#each graph.boxes as cluster (graph.clusterKey(cluster))}
267
+ {@const shared = graph.boxAttrs(cluster)}
268
+ {@const caption = `${cluster.name} · ${cluster.caption ?? cluster.count}`}
269
+ <!-- One box shape at every depth. A containment layout nests boxes inside boxes, so a
267
270
  childless one is still a box — rendering it as a node CARD instead put two
268
271
  structures in one hierarchy and brought the card's furniture with it, down to a
269
272
  row count that is `0` for anything without rows.
@@ -272,147 +275,145 @@
272
275
  focusable, so it is a real <button> and gets Enter/Space, focus order and the right
273
276
  announcement for free. A region is scenery and is a <div> that says so. The shared
274
277
  attributes come from state, so the two cannot drift. -->
275
- {#if cluster.nodeId}
278
+ {#if cluster.nodeId}
279
+ <button
280
+ type="button"
281
+ data-graph-cluster
282
+ {...shared}
283
+ style:left="{cluster.x}px"
284
+ style:top="{cluster.y}px"
285
+ style:width="{cluster.w}px"
286
+ style:height="{cluster.h}px"
287
+ style={graph.groupStyleAttr(cluster.ramp ?? cluster.name)}
288
+ onclick={(event) => {
289
+ event.stopPropagation()
290
+ graph.select(cluster.nodeId!)
291
+ }}
292
+ >
293
+ <span data-graph-cluster-label>{caption}</span>
294
+ </button>
295
+ {:else}
296
+ <div
297
+ data-graph-cluster
298
+ {...shared}
299
+ style:left="{cluster.x}px"
300
+ style:top="{cluster.y}px"
301
+ style:width="{cluster.w}px"
302
+ style:height="{cluster.h}px"
303
+ style={graph.groupStyleAttr(cluster.ramp ?? cluster.name)}
304
+ >
305
+ <span data-graph-cluster-label>{caption}</span>
306
+ </div>
307
+ {/if}
308
+ {/each}
309
+
310
+ <svg
311
+ width={graph.size.w}
312
+ height={graph.size.h}
313
+ role="img"
314
+ aria-label={graph.label}
315
+ style="position: absolute; top: 0; left: 0; pointer-events: none;"
316
+ >
317
+ <title>{graph.label}</title>
318
+ {#each graph.routedEdges as edge (edge.id)}
319
+ <g
320
+ data-graph-edge
321
+ data-edge-kind={edge.kind}
322
+ data-edge-relation={edge.relation}
323
+ data-edge-from={edge.fromKey}
324
+ data-edge-to={edge.toKey}
325
+ data-edge-state={graph.edgeState(edge)}
326
+ data-edge-overlay={edge.overlay ? '' : undefined}
327
+ style:--edge-weight={graph.edgeWeight(edge)}
328
+ >
329
+ <path d={graph.edgePath(edge)} />
330
+ <circle data-graph-edge-dot="from" cx={edge.x1} cy={edge.y1} r="3.2" />
331
+ {#if arrows}
332
+ <!-- An edge HAS a direction — source to target — and two identical dots threw
333
+ that away. The tangent of both path shapes is horizontal at the endpoint,
334
+ so the heading is just the side the curve arrives from: -s2. No angle
335
+ maths, and it stays correct for a self-loop. -->
336
+ {@const d = -edge.s2}
337
+ <polygon
338
+ data-graph-edge-arrow
339
+ points="{edge.x2},{edge.y2} {edge.x2 - d * 9},{edge.y2 - 4.5} {edge.x2 -
340
+ d * 9},{edge.y2 + 4.5}"
341
+ />
342
+ {:else}
343
+ <circle data-graph-edge-dot="to" cx={edge.x2} cy={edge.y2} r="3.2" />
344
+ {/if}
345
+ </g>
346
+ {/each}
347
+ </svg>
348
+
349
+ {#each Object.entries(graph.cards) as [key, card] (key)}
276
350
  <button
277
351
  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)}
352
+ data-graph-node={key}
353
+ data-node-kind={card.node.kind}
354
+ data-node-group={card.node.group}
355
+ data-node-state={graph.nodeState(key)}
356
+ data-label-side={card.labelSide}
357
+ style:--label-angle={card.labelAngle === undefined ? undefined : `${card.labelAngle}deg`}
358
+ data-node-headonly={card.vis.length === 0 && card.more <= 0 ? '' : undefined}
359
+ style:left="{card.x}px"
360
+ style:top="{card.y}px"
361
+ style:width="{card.w}px"
362
+ style:height="{card.h}px"
363
+ style={graph.groupStyleAttr(card.node.group)}
285
364
  onclick={(event) => {
286
365
  event.stopPropagation()
287
- graph.select(cluster.nodeId!)
366
+ graph.select(key)
288
367
  }}
289
368
  >
290
- <span data-graph-cluster-label>{caption}</span>
291
- </button>
292
- {:else}
293
- <div
294
- data-graph-cluster
295
- {...shared}
296
- style:left="{cluster.x}px"
297
- style:top="{cluster.y}px"
298
- style:width="{cluster.w}px"
299
- style:height="{cluster.h}px"
300
- style={graph.groupStyleAttr(cluster.ramp ?? cluster.name)}
301
- >
302
- <span data-graph-cluster-label>{caption}</span>
303
- </div>
304
- {/if}
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
369
+ <span data-graph-node-head>
370
+ <span data-graph-node-icon class={icons[card.node.kind ?? ''] ?? icons.fallback}></span>
371
+ <span data-graph-node-title>{card.node.label}</span>
372
+ {#if card.node.kind}
373
+ <!-- The glyph alone is a guess: layers vs eye vs bolt does not tell anyone what a
371
374
  materialized view is. Icon for the glance, tag for the answer. Underscores
372
375
  are the WIRE format (dbd sends `materialized_view`), not something to
373
376
  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>
377
+ <span data-graph-node-kind>{card.node.kind.replace(/_/g, ' ')}</span>
384
378
  {/if}
385
- <span data-graph-row-name>{row.name}</span>
386
- <span data-graph-row-type>{row.type}</span>
379
+ <span data-graph-node-count>{card.node.rows.length}</span>
387
380
  </span>
388
- {/each}
389
- {#if graph.moreLabel(key)}
390
- <!-- A real control. "+3 more" that does nothing is a statement dressed as an
381
+ {#each card.vis as row (row.name)}
382
+ <span data-graph-row data-graph-row-key={row.badges.length > 0 ? '' : undefined}>
383
+ {#if row.badges.length > 0}
384
+ <span data-row-badge={row.badges[0]} class={icons[row.badges[0]]}></span>
385
+ {:else}
386
+ <span data-row-badge-empty></span>
387
+ {/if}
388
+ <span data-graph-row-name>{row.name}</span>
389
+ <span data-graph-row-type>{row.type}</span>
390
+ </span>
391
+ {/each}
392
+ {#if graph.moreLabel(key)}
393
+ <!-- A real control. "+3 more" that does nothing is a statement dressed as an
391
394
  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}
395
+ <span
396
+ role="button"
397
+ tabindex="0"
398
+ data-graph-more
399
+ data-graph-more-empty={card.vis.length === 0 && !graph.isExpanded(key)
400
+ ? ''
401
+ : undefined}
402
+ data-expanded={graph.isExpanded(key) ? '' : undefined}
403
+ onclick={(event) => {
404
+ event.stopPropagation()
405
+ graph.toggleExpanded(key)
406
+ }}
407
+ onkeydown={(event) => {
408
+ if (event.key !== 'Enter' && event.key !== ' ') return
409
+ event.preventDefault()
410
+ event.stopPropagation()
411
+ graph.toggleExpanded(key)
412
+ }}>{graph.moreLabel(key)}</span
413
+ >
414
+ {/if}
415
+ </button>
416
+ {/each}
415
417
  </div>
416
418
  </div>
417
-
418
419
  </div>
@@ -154,7 +154,15 @@ export declare class GraphState {
154
154
  get hasWedges(): boolean;
155
155
  get clusters(): Cluster[];
156
156
  get cards(): Cards;
157
+ /** Structural edges as the layout routed them, then any overlay edges on top. */
157
158
  get routedEdges(): RoutedEdge[];
159
+ /** Every overlay edge in the model, placed or not. */
160
+ get overlayEdges(): GraphEdge[];
161
+ /**
162
+ * An edge's weight as 0..1 of the heaviest weighted edge, for stroke width — or undefined
163
+ * for an edge with no weight, which draws at the theme's normal width.
164
+ */
165
+ edgeWeight(edge: RoutedEdge): number | undefined;
158
166
  get size(): Size;
159
167
  /**
160
168
  * The true content extent, as distinct from `size`.
@@ -1,7 +1,7 @@
1
1
  import { SvelteSet } from 'svelte/reactivity';
2
2
  import { normalizeGraph } from './model/normalize.js';
3
3
  import { layouts } from './layout/index.js';
4
- import { edgePath } from './layout/edges.js';
4
+ import { buildEdges, edgePath } from './layout/edges.js';
5
5
  import { nodeShapeOf } from './layout/options.js';
6
6
  import { arcPath } from './layout/arc.js';
7
7
  import { defaultGraphPreset, resolveGroupStyles } from './preset.js';
@@ -132,6 +132,17 @@ export class GraphState {
132
132
  focus: this.#focus ?? this.#value,
133
133
  expanded: this.#expanded
134
134
  }));
135
+ /**
136
+ * Overlay edges routed over the finished layout. The layout never saw them — that is the
137
+ * point — so they are routed here, against whatever cards it placed. A layout with no cards
138
+ * (the treemap, the sunburst) simply has nothing to route them between.
139
+ */
140
+ #overlayRouted = $derived(buildEdges(this.#model.overlays, this.#result.cards));
141
+ #routed = $derived(this.#overlayRouted.length > 0
142
+ ? [...this.#result.edges, ...this.#overlayRouted]
143
+ : this.#result.edges);
144
+ /** Heaviest weight across every edge that carries one — the 1 that `edgeWeight` scales to. */
145
+ #maxEdgeWeight = $derived(Math.max(0, ...[...this.#model.edges, ...this.#model.overlays].map((e) => e.weight ?? 0)));
135
146
  // Duplicates are left in on purpose: `resolveGroupStyles` de-duplicates and sorts, because
136
147
  // that is where ramp assignment lives. A Set here would be a second, redundant de-dup.
137
148
  #groups = $derived(this.#model.nodes.map((n) => n.group).filter((g) => Boolean(g)));
@@ -385,8 +396,22 @@ export class GraphState {
385
396
  get cards() {
386
397
  return this.#result.cards;
387
398
  }
399
+ /** Structural edges as the layout routed them, then any overlay edges on top. */
388
400
  get routedEdges() {
389
- return this.#result.edges;
401
+ return this.#routed;
402
+ }
403
+ /** Every overlay edge in the model, placed or not. */
404
+ get overlayEdges() {
405
+ return this.#model.overlays;
406
+ }
407
+ /**
408
+ * An edge's weight as 0..1 of the heaviest weighted edge, for stroke width — or undefined
409
+ * for an edge with no weight, which draws at the theme's normal width.
410
+ */
411
+ edgeWeight(edge) {
412
+ if (edge.weight === undefined || this.#maxEdgeWeight <= 0)
413
+ return undefined;
414
+ return edge.weight / this.#maxEdgeWeight;
390
415
  }
391
416
  get size() {
392
417
  return this.#result.size;
@@ -0,0 +1,177 @@
1
+ <script lang="ts">
2
+ /**
3
+ * The dependency structure matrix: every node a row and a column, a cell where the row
4
+ * depends on the column.
5
+ *
6
+ * Providers come first, so a layered codebase is lower-triangular and a mark ABOVE the
7
+ * diagonal is a dependency against the grain — a cycle, or a layer reaching up. Groups sit
8
+ * on the diagonal as outlined blocks, so a cross-module violation is a mark outside its
9
+ * block. It stays readable at sizes where a node-link drawing is a hairball, which is the
10
+ * reason to reach for it.
11
+ *
12
+ * Share a `state` with a `DependencyDiagram` beside it and a row click selects the same
13
+ * node in both.
14
+ */
15
+ import { GraphState } from '../GraphState.svelte.js'
16
+ import { buildMatrix } from '../layout/matrix.js'
17
+ import type { NodeAxis } from '../layout/types.js'
18
+ import type { GraphFields } from '../types.js'
19
+
20
+ type Props = {
21
+ /** Share one state with another view. The caller owns it; this component never updates it. */
22
+ state?: GraphState
23
+ nodes?: unknown[]
24
+ edges?: unknown[]
25
+ fields?: GraphFields
26
+ /** Keep each group contiguous and outline it on the diagonal. `null` for one flat order. */
27
+ groupBy?: NodeAxis | null
28
+ /** Cell size in pixels. */
29
+ cell?: number
30
+ value?: string | null
31
+ label?: string
32
+ onselect?: (id: string | null) => void
33
+ class?: string
34
+ }
35
+
36
+ let {
37
+ state: provided,
38
+ nodes = [],
39
+ edges = [],
40
+ fields = {},
41
+ groupBy = 'group',
42
+ cell = 16,
43
+ value = $bindable(undefined),
44
+ label = undefined,
45
+ onselect = undefined,
46
+ class: className = ''
47
+ }: Props = $props()
48
+
49
+ const config = () => ({
50
+ nodes,
51
+ edges,
52
+ fields,
53
+ value,
54
+ onselect: (id: string | null) => {
55
+ value = id
56
+ onselect?.(id)
57
+ }
58
+ })
59
+
60
+ // svelte-ignore state_referenced_locally
61
+ const graph = provided ?? new GraphState(config())
62
+
63
+ $effect(() => {
64
+ if (!provided) graph.update(config())
65
+ })
66
+
67
+ const matrix = $derived(buildMatrix(graph.model, { groupBy: groupBy ?? undefined }))
68
+
69
+ // Labels are measured by length, not by the DOM — deterministic, like every layout here.
70
+ const labelW = $derived(
71
+ Math.min(220, Math.max(48, Math.max(0, ...matrix.labels.map((l) => l.length)) * 6.5 + 14))
72
+ )
73
+ const n = $derived(matrix.order.length)
74
+ const width = $derived(labelW + n * cell + 8)
75
+ const height = $derived(labelW + n * cell + 8)
76
+ const selected = $derived(graph.value)
77
+
78
+ const name = $derived(
79
+ label ??
80
+ `Dependency matrix: ${n} nodes, ${matrix.cells.length} dependencies, ${matrix.above} above the diagonal`
81
+ )
82
+
83
+ function toggle(id: string) {
84
+ if (selected === id) graph.clear()
85
+ else graph.select(id)
86
+ }
87
+
88
+ function onKey(event: KeyboardEvent, id: string) {
89
+ if (event.key !== 'Enter' && event.key !== ' ') return
90
+ event.preventDefault()
91
+ toggle(id)
92
+ }
93
+
94
+ const cellState = (source: string, target: string) =>
95
+ selected && (source === selected || target === selected) ? 'highlight' : undefined
96
+ </script>
97
+
98
+ <div data-graph-matrix class={className} role="group" aria-label={name}>
99
+ <svg {width} {height} viewBox="0 0 {width} {height}">
100
+ <!-- Column headings, rotated so a long name costs height rather than width. -->
101
+ {#each matrix.labels as text, i (matrix.order[i])}
102
+ <text
103
+ data-matrix-col={matrix.order[i]}
104
+ data-matrix-state={selected === matrix.order[i] ? 'selected' : undefined}
105
+ transform="translate({labelW + i * cell + cell / 2 + 4}, {labelW - 6}) rotate(-90)"
106
+ >{text}</text
107
+ >
108
+ {/each}
109
+
110
+ {#each matrix.labels as text, i (matrix.order[i])}
111
+ {@const id = matrix.order[i]}
112
+ <g
113
+ data-matrix-row={id}
114
+ data-matrix-state={selected === id ? 'selected' : undefined}
115
+ role="button"
116
+ tabindex="0"
117
+ aria-pressed={selected === id}
118
+ aria-label={text}
119
+ onclick={() => toggle(id)}
120
+ onkeydown={(event) => onKey(event, id)}
121
+ >
122
+ <rect
123
+ x="0"
124
+ y={labelW + i * cell}
125
+ width={labelW + n * cell}
126
+ height={cell}
127
+ data-matrix-band
128
+ />
129
+ <text
130
+ x={labelW - 6}
131
+ y={labelW + i * cell + cell / 2}
132
+ text-anchor="end"
133
+ dominant-baseline="middle">{text}</text
134
+ >
135
+ </g>
136
+ {/each}
137
+
138
+ {#each matrix.order as id, i (id)}
139
+ <rect
140
+ data-matrix-diagonal
141
+ x={labelW + i * cell}
142
+ y={labelW + i * cell}
143
+ width={cell}
144
+ height={cell}
145
+ />
146
+ {/each}
147
+
148
+ {#each matrix.cells as c (`${c.row}:${c.col}`)}
149
+ <g
150
+ data-matrix-cell
151
+ data-matrix-from={c.source}
152
+ data-matrix-to={c.target}
153
+ data-matrix-above={c.above ? '' : undefined}
154
+ data-matrix-state={cellState(c.source, c.target)}
155
+ style:--cell-weight={c.count / matrix.maxCount}
156
+ >
157
+ <rect
158
+ x={labelW + c.col * cell + 1}
159
+ y={labelW + c.row * cell + 1}
160
+ width={cell}
161
+ height={cell}
162
+ />
163
+ <title>{c.source} → {c.target} ({c.count})</title>
164
+ </g>
165
+ {/each}
166
+
167
+ {#each matrix.blocks as b (b.name + b.start)}
168
+ <rect
169
+ data-matrix-block={b.name}
170
+ x={labelW + b.start * cell}
171
+ y={labelW + b.start * cell}
172
+ width={(b.end - b.start + 1) * cell}
173
+ height={(b.end - b.start + 1) * cell}
174
+ />
175
+ {/each}
176
+ </svg>
177
+ </div>
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The dependency structure matrix: every node a row and a column, a cell where the row
3
+ * depends on the column.
4
+ *
5
+ * Providers come first, so a layered codebase is lower-triangular and a mark ABOVE the
6
+ * diagonal is a dependency against the grain — a cycle, or a layer reaching up. Groups sit
7
+ * on the diagonal as outlined blocks, so a cross-module violation is a mark outside its
8
+ * block. It stays readable at sizes where a node-link drawing is a hairball, which is the
9
+ * reason to reach for it.
10
+ *
11
+ * Share a `state` with a `DependencyDiagram` beside it and a row click selects the same
12
+ * node in both.
13
+ */
14
+ import { GraphState } from '../GraphState.svelte.js';
15
+ import type { NodeAxis } from '../layout/types.js';
16
+ import type { GraphFields } from '../types.js';
17
+ type Props = {
18
+ /** Share one state with another view. The caller owns it; this component never updates it. */
19
+ state?: GraphState;
20
+ nodes?: unknown[];
21
+ edges?: unknown[];
22
+ fields?: GraphFields;
23
+ /** Keep each group contiguous and outline it on the diagonal. `null` for one flat order. */
24
+ groupBy?: NodeAxis | null;
25
+ /** Cell size in pixels. */
26
+ cell?: number;
27
+ value?: string | null;
28
+ label?: string;
29
+ onselect?: (id: string | null) => void;
30
+ class?: string;
31
+ };
32
+ declare const DependencyMatrix: import("svelte").Component<Props, {}, "value">;
33
+ type DependencyMatrix = ReturnType<typeof DependencyMatrix>;
34
+ export default DependencyMatrix;
package/dist/index.d.ts CHANGED
@@ -7,6 +7,7 @@ export { default as Treemap } from './diagrams/Treemap.svelte';
7
7
  export { default as Sunburst } from './diagrams/Sunburst.svelte';
8
8
  export { default as StructureDiagram } from './diagrams/StructureDiagram.svelte';
9
9
  export { default as Neighborhood } from './diagrams/Neighborhood.svelte';
10
+ export { default as DependencyMatrix } from './diagrams/DependencyMatrix.svelte';
10
11
  export { default as DensityControl } from './controls/DensityControl.svelte';
11
12
  export { default as EdgeStyleControl } from './controls/EdgeStyleControl.svelte';
12
13
  export { default as ZoomControl } from './controls/ZoomControl.svelte';
@@ -22,6 +23,8 @@ export { createGraphPreset, defaultGraphPreset, resolveGroupStyles } from './pre
22
23
  export { DEFAULT_ICONS } from './icons.js';
23
24
  export { cluster, flow, neighborhood, points, radial, structure, sunburst, world, layouts } from './layout/index.js';
24
25
  export { LAYOUT_OPTIONS, appliesTo } from './layout/options.js';
26
+ export { buildMatrix } from './layout/matrix.js';
27
+ export type { Matrix, MatrixCell, MatrixBlock, MatrixOptions } from './layout/matrix.js';
25
28
  export type { EntityRow, GraphStateConfig, Relationship } from './GraphState.svelte.js';
26
29
  export type { GraphChannel, GraphPreset, GraphShades } from './preset.js';
27
30
  export type * from './types.js';
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ export { default as Treemap } from './diagrams/Treemap.svelte';
13
13
  export { default as Sunburst } from './diagrams/Sunburst.svelte';
14
14
  export { default as StructureDiagram } from './diagrams/StructureDiagram.svelte';
15
15
  export { default as Neighborhood } from './diagrams/Neighborhood.svelte';
16
+ export { default as DependencyMatrix } from './diagrams/DependencyMatrix.svelte';
16
17
  // Controls, for composing your own arrangement over the bare canvas.
17
18
  export { default as DensityControl } from './controls/DensityControl.svelte';
18
19
  export { default as EdgeStyleControl } from './controls/EdgeStyleControl.svelte';
@@ -28,3 +29,4 @@ export { createGraphPreset, defaultGraphPreset, resolveGroupStyles } from './pre
28
29
  export { DEFAULT_ICONS } from './icons.js';
29
30
  export { cluster, flow, neighborhood, points, radial, structure, sunburst, world, layouts } from './layout/index.js';
30
31
  export { LAYOUT_OPTIONS, appliesTo } from './layout/options.js';
32
+ export { buildMatrix } from './layout/matrix.js';
@@ -26,6 +26,7 @@ function selfLoop(edge, i, card, { y1, y2 }) {
26
26
  toKey: edge.target,
27
27
  kind: edge.kind,
28
28
  relation: edge.relation,
29
+ ...carried(edge),
29
30
  self: true,
30
31
  x1: card.x + card.w,
31
32
  y1,
@@ -35,6 +36,15 @@ function selfLoop(edge, i, card, { y1, y2 }) {
35
36
  s2: 1
36
37
  };
37
38
  }
39
+ /** Overlay flag and weight, copied only when set so existing routed edges stay key-identical. */
40
+ function carried(edge) {
41
+ const out = {};
42
+ if (edge.overlay)
43
+ out.overlay = true;
44
+ if (edge.weight !== undefined)
45
+ out.weight = edge.weight;
46
+ return out;
47
+ }
38
48
  /** Which side of each card the connector leaves from. 1 = right, -1 = left. */
39
49
  function sides(a, b) {
40
50
  if (a.x + a.w + 50 <= b.x)
@@ -61,6 +71,7 @@ function buildEdge(edge, i, cards) {
61
71
  toKey: edge.target,
62
72
  kind: edge.kind,
63
73
  relation: edge.relation,
74
+ ...carried(edge),
64
75
  self: false,
65
76
  x1: s1 === 1 ? a.x + a.w : a.x,
66
77
  y1,
@@ -0,0 +1,38 @@
1
+ import type { NodeAxis } from './types.js';
2
+ import type { GraphModel } from '../types.js';
3
+ export type MatrixCell = {
4
+ /** Index into `order` of the dependent (the edge's source). */
5
+ row: number;
6
+ /** Index into `order` of the dependency (the edge's target). */
7
+ col: number;
8
+ source: string;
9
+ target: string;
10
+ /** Edges folded into this cell — two FKs, a read and a write — each counted by its weight. */
11
+ count: number;
12
+ /** Against the grain: a cycle or a layer reaching up. */
13
+ above: boolean;
14
+ };
15
+ export type MatrixBlock = {
16
+ name: string;
17
+ start: number;
18
+ end: number;
19
+ };
20
+ export type Matrix = {
21
+ /** Node ids, providers first. Row i and column i are the same node. */
22
+ order: string[];
23
+ labels: string[];
24
+ /** Each node's group key on the grouping axis, parallel to `order` ('' when ungrouped). */
25
+ groups: string[];
26
+ cells: MatrixCell[];
27
+ /** Contiguous group runs along the diagonal. Empty when not grouping. */
28
+ blocks: MatrixBlock[];
29
+ /** Cells above the diagonal. */
30
+ above: number;
31
+ /** The largest `count`, for scaling cell intensity. */
32
+ maxCount: number;
33
+ };
34
+ export type MatrixOptions = {
35
+ /** Keep each group contiguous and outline it on the diagonal. Omit for one flat order. */
36
+ groupBy?: NodeAxis;
37
+ };
38
+ export declare function buildMatrix(model: GraphModel, options?: MatrixOptions): Matrix;
@@ -0,0 +1,90 @@
1
+ /* The dependency structure matrix (DSM): every node is both a row and a column, and a cell at
2
+ * (row, col) says "row depends on col".
3
+ *
4
+ * Its strength is the ORDER. Providers come first, so in a layered architecture every
5
+ * dependency points up-and-left and the whole matrix is lower-triangular. A mark ABOVE the
6
+ * diagonal is then a dependency against the grain — a cycle, or a layer that reaches up —
7
+ * and it is visible at a glance at sizes where a node-link drawing is a hairball.
8
+ *
9
+ * The order reuses `flow`'s ranking (longest path, DFS cycle breaking), reversed: `rank` puts
10
+ * a target in a later column than its source, and a DSM wants the target FIRST. Grouping keeps
11
+ * each group contiguous, ordered by where its first member ranks, so a module's block sits on
12
+ * the diagonal and a cross-module dependency against the grain lands above it.
13
+ *
14
+ * Pure and DOM-free, like every layout.
15
+ */
16
+ import { rank } from './rank.js';
17
+ const axisOf = (node, axis) => (axis === 'kind' ? node.kind : node.group) ?? '';
18
+ /** Node ids, providers first; with `groupBy`, each group contiguous in first-ranked order. */
19
+ function orderNodes(model, groupBy) {
20
+ const { ranks } = rank(model);
21
+ // `rank` places every node, so each lookup hits; carrying the model index alongside the
22
+ // rank keeps ties in model order without a second map.
23
+ const byRank = model.nodes
24
+ .map((node, i) => ({ node, i, r: ranks.get(node.id) }))
25
+ .sort((a, b) => b.r - a.r || a.i - b.i)
26
+ .map((entry) => entry.node);
27
+ if (!groupBy)
28
+ return byRank;
29
+ const runs = new Map();
30
+ for (const node of byRank) {
31
+ const key = axisOf(node, groupBy);
32
+ const run = runs.get(key) ?? [];
33
+ run.push(node);
34
+ runs.set(key, run);
35
+ }
36
+ return [...runs.values()].flat();
37
+ }
38
+ function blocksOf(groups) {
39
+ const blocks = [];
40
+ groups.forEach((name, i) => {
41
+ const last = blocks[blocks.length - 1];
42
+ if (last && last.name === name)
43
+ last.end = i;
44
+ else
45
+ blocks.push({ name, start: i, end: i });
46
+ });
47
+ return blocks;
48
+ }
49
+ /** One cell per (dependent, dependency) pair, parallel edges folded into its count. */
50
+ function collectCells(model, index) {
51
+ const cells = new Map();
52
+ for (const edge of model.edges) {
53
+ const row = index.get(edge.source);
54
+ const col = index.get(edge.target);
55
+ if (row === undefined || col === undefined || row === col)
56
+ continue;
57
+ const key = `${row}:${col}`;
58
+ // A weighted edge already IS a count — an import list aggregated to components says
59
+ // "7 imports" once rather than repeating the edge seven times.
60
+ const n = edge.weight ?? 1;
61
+ const cell = cells.get(key);
62
+ if (cell)
63
+ cell.count += n;
64
+ else
65
+ cells.set(key, {
66
+ row,
67
+ col,
68
+ source: edge.source,
69
+ target: edge.target,
70
+ count: n,
71
+ above: col > row
72
+ });
73
+ }
74
+ return [...cells.values()].sort((a, b) => a.row - b.row || a.col - b.col);
75
+ }
76
+ export function buildMatrix(model, options = {}) {
77
+ const nodes = orderNodes(model, options.groupBy);
78
+ const index = new Map(nodes.map((n, i) => [n.id, i]));
79
+ const cells = collectCells(model, index);
80
+ const groups = nodes.map((n) => (options.groupBy ? axisOf(n, options.groupBy) : ''));
81
+ return {
82
+ order: nodes.map((n) => n.id),
83
+ labels: nodes.map((n) => n.label),
84
+ groups,
85
+ cells,
86
+ blocks: options.groupBy ? blocksOf(groups) : [],
87
+ above: cells.filter((c) => c.above).length,
88
+ maxCount: Math.max(0, ...cells.map((c) => c.count))
89
+ };
90
+ }
@@ -126,6 +126,10 @@ export type RoutedEdge = {
126
126
  * than the reader wondering why one link behaves differently.
127
127
  */
128
128
  back?: boolean;
129
+ /** GraphEdge.overlay — routed after the layout ran, over a picture it did not shape. */
130
+ overlay?: boolean;
131
+ /** GraphEdge.weight, raw. `GraphState.edgeWeight` normalises it for drawing. */
132
+ weight?: number;
129
133
  x1: number;
130
134
  y1: number;
131
135
  x2: number;
@@ -202,9 +202,23 @@ function buildEdge(source, fields, byId, seen) {
202
202
  relation,
203
203
  cardinality: str(pick(source, fields.cardinality, 'cardinality')),
204
204
  action: str(pick(source, fields.action, 'action')),
205
- unplaced
205
+ unplaced,
206
+ ...edgeExtras(source, fields)
206
207
  };
207
208
  }
209
+ /**
210
+ * `overlay` and `weight`, added only when present so every edge a v1 consumer already has
211
+ * stays key-for-key identical — `toEqual` on an edge is a contract downstream specs rely on.
212
+ */
213
+ function edgeExtras(source, fields) {
214
+ const extras = {};
215
+ if (pick(source, fields.overlay, 'overlay'))
216
+ extras.overlay = true;
217
+ const weight = num(pick(source, fields.edgeWeight, 'weight'));
218
+ if (weight !== undefined)
219
+ extras.weight = weight;
220
+ return extras;
221
+ }
208
222
  /**
209
223
  * The source row an edge makes a foreign key, if any.
210
224
  *
@@ -256,6 +270,8 @@ export function normalizeGraph(nodes, edges, fields = {}) {
256
270
  const resolved = edges
257
271
  .map((source) => buildEdge(source, fields, byId, seen))
258
272
  .filter((edge) => edge !== null);
259
- markForeignKeys(byId, resolved);
260
- return { nodes: built, edges: resolved, byId, neighbors: buildNeighbors(resolved) };
273
+ const structural = resolved.filter((edge) => !edge.overlay);
274
+ const overlays = resolved.filter((edge) => edge.overlay);
275
+ markForeignKeys(byId, structural);
276
+ return { nodes: built, edges: structural, overlays, byId, neighbors: buildNeighbors(structural) };
261
277
  }
package/dist/types.d.ts CHANGED
@@ -94,10 +94,26 @@ export type GraphEdge = {
94
94
  * edge is kept and marked, and it is the renderer's job to dim it.
95
95
  */
96
96
  unplaced?: 'source' | 'target' | 'both';
97
+ /**
98
+ * Drawn over the picture but never allowed to shape it — a co-change pair, a layering
99
+ * rule broken, a suggested dependency.
100
+ *
101
+ * Such an edge is exactly what the reader wants to see and exactly what must not re-rank a
102
+ * layered layout or pull a cluster ordering: files that change together with no import
103
+ * between them would otherwise be drawn next to each other, hiding the very coupling the
104
+ * overlay exists to expose. So they never enter `GraphModel.edges`; they live in
105
+ * `GraphModel.overlays` and are routed after the layout has run.
106
+ */
107
+ overlay?: boolean;
108
+ /** A strength this edge carries — co-change count, call frequency. Drives stroke width. */
109
+ weight?: number;
97
110
  };
98
111
  export type GraphModel = {
99
112
  nodes: GraphNode[];
113
+ /** Structural edges — the ones layouts, neighbours and relationships see. */
100
114
  edges: GraphEdge[];
115
+ /** Overlay edges (`GraphEdge.overlay`) — routed after layout, never shaping it. */
116
+ overlays: GraphEdge[];
101
117
  byId: Map<string, GraphNode>;
102
118
  /** Undirected adjacency, self-edges excluded. */
103
119
  neighbors: Map<string, Set<string>>;
@@ -207,6 +223,10 @@ export type GraphFields = {
207
223
  defaultEdgeKind?: EdgeKind;
208
224
  /** Path to the consumer's own relationship word — see `GraphEdge.relation`. */
209
225
  relation?: string;
226
+ /** Path to a truthy flag marking an overlay edge — see `GraphEdge.overlay`. */
227
+ overlay?: string;
228
+ /** Path to an edge's strength — see `GraphEdge.weight`. */
229
+ edgeWeight?: string;
210
230
  cardinality?: string;
211
231
  action?: string;
212
232
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokkit/graph",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "Node-link diagram components — pluggable layouts, ER/schema views",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,10 +46,10 @@
46
46
  "check:types": "tsc --noEmit"
47
47
  },
48
48
  "dependencies": {
49
- "@rokkit/core": "1.7.0"
49
+ "@rokkit/core": "1.8.0"
50
50
  },
51
51
  "peerDependencies": {
52
- "@rokkit/ui": "1.7.0",
52
+ "@rokkit/ui": "1.8.0",
53
53
  "svelte": "^5.0.0"
54
54
  },
55
55
  "peerDependenciesMeta": {
@@ -58,7 +58,7 @@
58
58
  }
59
59
  },
60
60
  "devDependencies": {
61
- "@rokkit/ui": "1.7.0",
61
+ "@rokkit/ui": "1.8.0",
62
62
  "@sveltejs/package": "^2.5.8",
63
63
  "@sveltejs/vite-plugin-svelte": "^6.2.4",
64
64
  "svelte": "^5.55.7",