@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 +8 -1
- package/dist/Graph.svelte +196 -195
- package/dist/GraphState.svelte.d.ts +8 -0
- package/dist/GraphState.svelte.js +27 -2
- package/dist/diagrams/DependencyMatrix.svelte +177 -0
- package/dist/diagrams/DependencyMatrix.svelte.d.ts +34 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/layout/edges.js +11 -0
- package/dist/layout/matrix.d.ts +38 -0
- package/dist/layout/matrix.js +90 -0
- package/dist/layout/types.d.ts +4 -0
- package/dist/model/normalize.js +19 -3
- package/dist/types.d.ts +20 -0
- package/package.json +4 -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
|
package/dist/Graph.svelte
CHANGED
|
@@ -189,81 +189,84 @@
|
|
|
189
189
|
onpointercancel={endPan}
|
|
190
190
|
tabindex="-1"
|
|
191
191
|
>
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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-
|
|
279
|
-
{
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
style
|
|
284
|
-
|
|
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(
|
|
366
|
+
graph.select(key)
|
|
288
367
|
}}
|
|
289
368
|
>
|
|
290
|
-
<span data-graph-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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-
|
|
386
|
-
<span data-graph-row-type>{row.type}</span>
|
|
379
|
+
<span data-graph-node-count>{card.node.rows.length}</span>
|
|
387
380
|
</span>
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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.#
|
|
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';
|
package/dist/layout/edges.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/layout/types.d.ts
CHANGED
|
@@ -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;
|
package/dist/model/normalize.js
CHANGED
|
@@ -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
|
-
|
|
260
|
-
|
|
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.
|
|
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.
|
|
49
|
+
"@rokkit/core": "1.8.0"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
|
-
"@rokkit/ui": "1.
|
|
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.
|
|
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",
|