@rokkit/graph 1.6.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.
Files changed (82) hide show
  1. package/README.md +66 -2
  2. package/dist/Graph.svelte +219 -169
  3. package/dist/GraphLegend.svelte +112 -0
  4. package/dist/GraphLegend.svelte.d.ts +33 -0
  5. package/dist/GraphState.svelte.d.ts +115 -15
  6. package/dist/GraphState.svelte.js +213 -5
  7. package/dist/controls/BundleControl.svelte +30 -0
  8. package/dist/controls/BundleControl.svelte.d.ts +16 -0
  9. package/dist/controls/DensityControl.svelte +39 -0
  10. package/dist/controls/DensityControl.svelte.d.ts +16 -0
  11. package/dist/controls/DepthControl.svelte +34 -0
  12. package/dist/controls/DepthControl.svelte.d.ts +17 -0
  13. package/dist/controls/EdgeStyleControl.svelte +31 -0
  14. package/dist/controls/EdgeStyleControl.svelte.d.ts +14 -0
  15. package/dist/controls/ZoomControl.svelte +47 -0
  16. package/dist/controls/ZoomControl.svelte.d.ts +7 -0
  17. package/dist/controls/zoom.d.ts +6 -0
  18. package/dist/controls/zoom.js +12 -0
  19. package/dist/diagrams/CallTree.svelte +135 -0
  20. package/dist/diagrams/CallTree.svelte.d.ts +37 -0
  21. package/dist/diagrams/DependencyDiagram.svelte +124 -0
  22. package/dist/diagrams/DependencyDiagram.svelte.d.ts +30 -0
  23. package/dist/diagrams/DependencyMatrix.svelte +177 -0
  24. package/dist/diagrams/DependencyMatrix.svelte.d.ts +34 -0
  25. package/dist/diagrams/DiagramFrame.svelte +36 -0
  26. package/dist/diagrams/DiagramFrame.svelte.d.ts +20 -0
  27. package/dist/diagrams/ErDiagram.svelte +128 -0
  28. package/dist/diagrams/ErDiagram.svelte.d.ts +33 -0
  29. package/dist/diagrams/Neighborhood.svelte +127 -0
  30. package/dist/diagrams/Neighborhood.svelte.d.ts +33 -0
  31. package/dist/diagrams/StructureDiagram.svelte +131 -0
  32. package/dist/diagrams/StructureDiagram.svelte.d.ts +31 -0
  33. package/dist/diagrams/Sunburst.svelte +124 -0
  34. package/dist/diagrams/Sunburst.svelte.d.ts +32 -0
  35. package/dist/diagrams/Treemap.svelte +124 -0
  36. package/dist/diagrams/Treemap.svelte.d.ts +32 -0
  37. package/dist/icons.d.ts +5 -0
  38. package/dist/icons.js +5 -0
  39. package/dist/index.d.ts +21 -1
  40. package/dist/index.js +25 -1
  41. package/dist/layout/arc.d.ts +13 -0
  42. package/dist/layout/arc.js +58 -0
  43. package/dist/layout/bundle.d.ts +30 -0
  44. package/dist/layout/bundle.js +80 -0
  45. package/dist/layout/cluster.js +2 -0
  46. package/dist/layout/edges.js +11 -0
  47. package/dist/layout/flow.d.ts +8 -0
  48. package/dist/layout/flow.js +141 -0
  49. package/dist/layout/hierarchy.d.ts +42 -0
  50. package/dist/layout/hierarchy.js +175 -0
  51. package/dist/layout/index.d.ts +7 -1
  52. package/dist/layout/index.js +8 -2
  53. package/dist/layout/matrix.d.ts +38 -0
  54. package/dist/layout/matrix.js +90 -0
  55. package/dist/layout/neighborhood.js +164 -28
  56. package/dist/layout/options.d.ts +44 -0
  57. package/dist/layout/options.js +106 -0
  58. package/dist/layout/order.d.ts +11 -0
  59. package/dist/layout/order.js +153 -0
  60. package/dist/layout/points.js +27 -35
  61. package/dist/layout/radial.d.ts +16 -0
  62. package/dist/layout/radial.js +214 -0
  63. package/dist/layout/rank.d.ts +15 -0
  64. package/dist/layout/rank.js +105 -0
  65. package/dist/layout/rings.d.ts +20 -0
  66. package/dist/layout/rings.js +92 -0
  67. package/dist/layout/sizing.d.ts +37 -0
  68. package/dist/layout/sizing.js +79 -0
  69. package/dist/layout/squarify.d.ts +14 -0
  70. package/dist/layout/squarify.js +114 -0
  71. package/dist/layout/structure.d.ts +9 -0
  72. package/dist/layout/structure.js +367 -0
  73. package/dist/layout/sunburst.d.ts +8 -0
  74. package/dist/layout/sunburst.js +126 -0
  75. package/dist/layout/types.d.ts +145 -0
  76. package/dist/layout/world.d.ts +9 -0
  77. package/dist/layout/world.js +144 -0
  78. package/dist/model/normalize.js +66 -4
  79. package/dist/model/tree.d.ts +27 -0
  80. package/dist/model/tree.js +182 -0
  81. package/dist/types.d.ts +75 -3
  82. package/package.json +9 -4
@@ -0,0 +1,17 @@
1
+ /**
2
+ * How many levels of a containment tree to materialise.
3
+ *
4
+ * The control that makes a large tree readable at all. A radial dendrogram over a whole
5
+ * codebase renders every leaf on one rim, where each is a fraction of a degree wide and
6
+ * nothing is legible — show the crates first, then drill. Depth and drilling are the same
7
+ * idea from two directions: this caps how far DOWN, `focusPath` moves where you start.
8
+ */
9
+ type Props = {
10
+ levels?: number;
11
+ /** Deepest level offered. Beyond the tree's own depth the extra buttons do nothing. */
12
+ max?: number;
13
+ onchange?: (value: number) => void;
14
+ };
15
+ declare const DepthControl: import("svelte").Component<Props, {}, "">;
16
+ type DepthControl = ReturnType<typeof DepthControl>;
17
+ export default DepthControl;
@@ -0,0 +1,31 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Curved or orthogonal connectors.
4
+ *
5
+ * A toggle rather than a picker: there are exactly two, and a two-option `<select>` costs a
6
+ * reader more interaction than the thing it is choosing between is worth.
7
+ */
8
+ import type { EdgeStyle } from '../layout/types.js'
9
+
10
+ type Props = {
11
+ edgeStyle?: EdgeStyle
12
+ onchange?: (value: EdgeStyle) => void
13
+ }
14
+
15
+ let { edgeStyle = 'curved', onchange }: Props = $props()
16
+
17
+ // `curved` is the default, so `orthogonal` is the pressed state — a toggle reads as "the
18
+ // non-default is on", not as "one of two things is selected".
19
+ const orthogonal = $derived(edgeStyle === 'orthogonal')
20
+ </script>
21
+
22
+ <button
23
+ type="button"
24
+ data-graph-edge-style={edgeStyle}
25
+ aria-pressed={orthogonal}
26
+ title={orthogonal ? 'Right-angle connectors' : 'Curved connectors'}
27
+ onclick={() => onchange?.(orthogonal ? 'curved' : 'orthogonal')}
28
+ >
29
+ <span data-graph-edge-style-glyph aria-hidden="true">{orthogonal ? '⌐' : '︵'}</span>
30
+ <span data-graph-control-label>{orthogonal ? 'Angled' : 'Curved'}</span>
31
+ </button>
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Curved or orthogonal connectors.
3
+ *
4
+ * A toggle rather than a picker: there are exactly two, and a two-option `<select>` costs a
5
+ * reader more interaction than the thing it is choosing between is worth.
6
+ */
7
+ import type { EdgeStyle } from '../layout/types.js';
8
+ type Props = {
9
+ edgeStyle?: EdgeStyle;
10
+ onchange?: (value: EdgeStyle) => void;
11
+ };
12
+ declare const EdgeStyleControl: import("svelte").Component<Props, {}, "">;
13
+ type EdgeStyleControl = ReturnType<typeof EdgeStyleControl>;
14
+ export default EdgeStyleControl;
@@ -0,0 +1,47 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Zoom in, out, and back to fit.
4
+ *
5
+ * Buttons rather than a slider: each is one tab stop and one keypress, so the diagram is
6
+ * navigable without a trackpad. The canvas handles ctrl+wheel and pinch itself — this is
7
+ * the discoverable path to the same value, not the only one.
8
+ */
9
+ import { ZOOM_MAX, ZOOM_MIN, ZOOM_STEP, clampZoom } from './zoom.js'
10
+
11
+ type Props = {
12
+ zoom?: number
13
+ onchange?: (value: number) => void
14
+ }
15
+
16
+ let { zoom = 1, onchange }: Props = $props()
17
+
18
+ const percent = $derived(Math.round(zoom * 100))
19
+
20
+ // Clamped here as well as in the canvas: the buttons disable at the ends, but a caller can
21
+ // hand this control a value from anywhere, and reporting one outside the range would push
22
+ // the canvas past a limit it has already agreed to.
23
+ const step = (factor: number) => onchange?.(clampZoom(zoom * factor))
24
+ </script>
25
+
26
+ <div data-graph-zoom-controls role="group" aria-label="Zoom">
27
+ <button
28
+ type="button"
29
+ data-graph-zoom="out"
30
+ aria-label="Zoom out"
31
+ disabled={zoom <= ZOOM_MIN}
32
+ onclick={() => step(1 / ZOOM_STEP)}>−</button
33
+ >
34
+ <button
35
+ type="button"
36
+ data-graph-zoom="reset"
37
+ aria-label="Reset zoom to fit"
38
+ onclick={() => onchange?.(1)}>{percent}%</button
39
+ >
40
+ <button
41
+ type="button"
42
+ data-graph-zoom="in"
43
+ aria-label="Zoom in"
44
+ disabled={zoom >= ZOOM_MAX}
45
+ onclick={() => step(ZOOM_STEP)}>+</button
46
+ >
47
+ </div>
@@ -0,0 +1,7 @@
1
+ type Props = {
2
+ zoom?: number;
3
+ onchange?: (value: number) => void;
4
+ };
5
+ declare const ZoomControl: import("svelte").Component<Props, {}, "">;
6
+ type ZoomControl = ReturnType<typeof ZoomControl>;
7
+ export default ZoomControl;
@@ -0,0 +1,6 @@
1
+ export declare const ZOOM_MIN = 0.25;
2
+ export declare const ZOOM_MAX = 4;
3
+ /** One step of the +/- buttons, and of one wheel notch. */
4
+ export declare const ZOOM_STEP = 1.25;
5
+ /** `value` brought inside the range. */
6
+ export declare const clampZoom: (value: number) => number;
@@ -0,0 +1,12 @@
1
+ /* Zoom bounds, shared by the canvas and the zoom control.
2
+ *
3
+ * Both have to agree: the canvas clamps a ctrl+wheel gesture and the control disables its
4
+ * buttons at the ends, and if those two numbers drift the control goes dead a step before the
5
+ * gesture does — which reads as a broken button.
6
+ */
7
+ export const ZOOM_MIN = 0.25;
8
+ export const ZOOM_MAX = 4;
9
+ /** One step of the +/- buttons, and of one wheel notch. */
10
+ export const ZOOM_STEP = 1.25;
11
+ /** `value` brought inside the range. */
12
+ export const clampZoom = (value) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, value));
@@ -0,0 +1,135 @@
1
+ <script lang="ts">
2
+ /**
3
+ * A call graph as a tidy tree or dendrogram around a circle.
4
+ *
5
+ * Angle separates the subtrees, radius carries the depth — which is the question a call
6
+ * graph asks. Packing it into boxes instead answers "how much is there" and, at any real
7
+ * size, reads as a stack of colliding labels.
8
+ *
9
+ * `levels` is the control that makes it legible over a codebase: a dendrogram of a whole
10
+ * repo puts every leaf on one rim, each a fraction of a degree wide. Show the crates, then
11
+ * set `root` to drill into one — a dendrogram of dendrograms.
12
+ */
13
+ import Graph from '../Graph.svelte'
14
+ import GraphLegend from '../GraphLegend.svelte'
15
+ import DiagramFrame from './DiagramFrame.svelte'
16
+ import DepthControl from '../controls/DepthControl.svelte'
17
+ import ZoomControl from '../controls/ZoomControl.svelte'
18
+ import { GraphState } from '../GraphState.svelte.js'
19
+ import type { GraphFields } from '../types.js'
20
+ import type { GraphPreset } from '../preset.js'
21
+
22
+ type Props = {
23
+ /**
24
+ * Share one state across this diagram and, say, an entity table beside it. Given one,
25
+ * the caller owns it: this component reads it and never calls `update`, exactly as
26
+ * `Graph` does — two owners writing one state fight on every render.
27
+ */
28
+ state?: GraphState
29
+ nodes?: unknown[]
30
+ edges?: unknown[]
31
+ fields?: GraphFields
32
+ /** `tree` reads radius as depth; `dendrogram` pins every leaf to the rim. */
33
+ radialMode?: 'tree' | 'dendrogram'
34
+ /** How many levels out from the root to draw. Unset draws the whole tree. */
35
+ levels?: number
36
+ /** The node to centre on — drilling. Unset roots at whatever nothing calls. */
37
+ root?: string | null
38
+ /** What a node's area encodes. `degree` counts edges. */
39
+ sizeBy?: string
40
+ sizeScale?: 'linear' | 'log'
41
+ controls?: boolean
42
+ /** Deepest level the depth control offers. */
43
+ maxLevels?: number
44
+ legend?: boolean
45
+ value?: string | null
46
+ preset?: GraphPreset
47
+ mode?: 'light' | 'dark'
48
+ arrows?: boolean
49
+ label?: string
50
+ onselect?: (id: string | null) => void
51
+ class?: string
52
+ }
53
+
54
+ let {
55
+ state: provided,
56
+ nodes = [],
57
+ edges = [],
58
+ fields = {},
59
+ radialMode = 'tree',
60
+ levels = $bindable(undefined),
61
+ root = $bindable(null),
62
+ sizeBy = 'degree',
63
+ sizeScale = 'linear',
64
+ controls = false,
65
+ maxLevels = 4,
66
+ legend = false,
67
+ value = $bindable(undefined),
68
+ preset = undefined,
69
+ mode = 'light',
70
+ arrows = true,
71
+ label = undefined,
72
+ onselect = undefined,
73
+ class: className = ''
74
+ }: Props = $props()
75
+
76
+ let zoom = $state(1)
77
+
78
+ const config = () => ({
79
+ nodes,
80
+ edges,
81
+ fields,
82
+ layout: 'radial',
83
+ radialMode,
84
+ levels,
85
+ root,
86
+ sizeBy,
87
+ sizeScale,
88
+ value,
89
+ preset,
90
+ mode,
91
+ label,
92
+ onselect
93
+ })
94
+
95
+ // svelte-ignore state_referenced_locally
96
+ const graph = provided ?? new GraphState(config())
97
+
98
+ $effect(() => {
99
+ if (!provided) {
100
+ graph.update(config())
101
+
102
+ return
103
+ }
104
+
105
+ // A supplied state is the caller's: their data, preset, mode and selection stay theirs
106
+ // and are never reset here. What this component DOES own is the options its own
107
+ // controls drive — the layout above all, since choosing this component is choosing it.
108
+ // `apply` merges; `update` would revert everything it does not name.
109
+ const { nodes: _n, edges: _e, fields: _f, preset: _p, mode: _m, value: _v, ...mine } =
110
+ config()
111
+ graph.apply(mine)
112
+ })
113
+ </script>
114
+
115
+ <!-- Snippets are declared OUTSIDE the frame and passed conditionally: one declared
116
+ inside is always defined, so the frame would render an empty control bar over
117
+ every diagram that asked for none. -->
118
+ {#snippet controlBar()}
119
+ <DepthControl levels={levels ?? maxLevels} max={maxLevels} onchange={(v) => (levels = v)} />
120
+ <ZoomControl {zoom} onchange={(v) => (zoom = v)} />
121
+ {/snippet}
122
+
123
+ {#snippet legendBar()}
124
+ <GraphLegend state={graph} kinds />
125
+ {/snippet}
126
+
127
+ <DiagramFrame
128
+ class={className}
129
+ overlay={controls ? controlBar : undefined}
130
+ footer={legend ? legendBar : undefined}
131
+ >
132
+ {#snippet canvas()}
133
+ <Graph state={graph} bind:zoom {arrows} />
134
+ {/snippet}
135
+ </DiagramFrame>
@@ -0,0 +1,37 @@
1
+ import { GraphState } from '../GraphState.svelte.js';
2
+ import type { GraphFields } from '../types.js';
3
+ import type { GraphPreset } from '../preset.js';
4
+ type Props = {
5
+ /**
6
+ * Share one state across this diagram and, say, an entity table beside it. Given one,
7
+ * the caller owns it: this component reads it and never calls `update`, exactly as
8
+ * `Graph` does — two owners writing one state fight on every render.
9
+ */
10
+ state?: GraphState;
11
+ nodes?: unknown[];
12
+ edges?: unknown[];
13
+ fields?: GraphFields;
14
+ /** `tree` reads radius as depth; `dendrogram` pins every leaf to the rim. */
15
+ radialMode?: 'tree' | 'dendrogram';
16
+ /** How many levels out from the root to draw. Unset draws the whole tree. */
17
+ levels?: number;
18
+ /** The node to centre on — drilling. Unset roots at whatever nothing calls. */
19
+ root?: string | null;
20
+ /** What a node's area encodes. `degree` counts edges. */
21
+ sizeBy?: string;
22
+ sizeScale?: 'linear' | 'log';
23
+ controls?: boolean;
24
+ /** Deepest level the depth control offers. */
25
+ maxLevels?: number;
26
+ legend?: boolean;
27
+ value?: string | null;
28
+ preset?: GraphPreset;
29
+ mode?: 'light' | 'dark';
30
+ arrows?: boolean;
31
+ label?: string;
32
+ onselect?: (id: string | null) => void;
33
+ class?: string;
34
+ };
35
+ declare const CallTree: import("svelte").Component<Props, {}, "value" | "levels" | "root">;
36
+ type CallTree = ReturnType<typeof CallTree>;
37
+ export default CallTree;
@@ -0,0 +1,124 @@
1
+ <script lang="ts">
2
+ /**
3
+ * What reads, writes, calls and contains what — the other half of a schema.
4
+ *
5
+ * dbd's v2 model splits `tables`/`refs` from `entities`/`deps` because an ER renderer
6
+ * wants one and a call-graph renderer wants the other. Pass
7
+ * `toGraphInput(model, 'dependencies')` and this draws the second.
8
+ *
9
+ * Keyed by RELATION rather than by kind: on this canvas the verb is the information, and
10
+ * `reads` versus `writes` is the distinction a reader is here for.
11
+ */
12
+ import Graph from '../Graph.svelte'
13
+ import GraphLegend from '../GraphLegend.svelte'
14
+ import DiagramFrame from './DiagramFrame.svelte'
15
+ import DensityControl from '../controls/DensityControl.svelte'
16
+ import EdgeStyleControl from '../controls/EdgeStyleControl.svelte'
17
+ import ZoomControl from '../controls/ZoomControl.svelte'
18
+ import { GraphState } from '../GraphState.svelte.js'
19
+ import type { Density, EdgeStyle } from '../layout/types.js'
20
+ import type { GraphFields } from '../types.js'
21
+ import type { GraphPreset } from '../preset.js'
22
+
23
+ type Props = {
24
+ /**
25
+ * Share one state across this diagram and, say, an entity table beside it. Given one,
26
+ * the caller owns it: this component reads it and never calls `update`, exactly as
27
+ * `Graph` does — two owners writing one state fight on every render.
28
+ */
29
+ state?: GraphState
30
+ nodes?: unknown[]
31
+ edges?: unknown[]
32
+ fields?: GraphFields
33
+ density?: Density
34
+ edgeStyle?: EdgeStyle
35
+ groupTint?: boolean
36
+ controls?: boolean
37
+ legend?: boolean
38
+ value?: string | null
39
+ preset?: GraphPreset
40
+ mode?: 'light' | 'dark'
41
+ arrows?: boolean
42
+ label?: string
43
+ onselect?: (id: string | null) => void
44
+ class?: string
45
+ }
46
+
47
+ let {
48
+ state: provided,
49
+ nodes = [],
50
+ edges = [],
51
+ fields = {},
52
+ density = $bindable('names'),
53
+ edgeStyle = $bindable('curved'),
54
+ groupTint = true,
55
+ controls = false,
56
+ legend = false,
57
+ value = $bindable(undefined),
58
+ preset = undefined,
59
+ mode = 'light',
60
+ arrows = true,
61
+ label = undefined,
62
+ onselect = undefined,
63
+ class: className = ''
64
+ }: Props = $props()
65
+
66
+ let zoom = $state(1)
67
+
68
+ const config = () => ({
69
+ nodes,
70
+ edges,
71
+ fields,
72
+ layout: 'flow',
73
+ density,
74
+ edgeStyle,
75
+ groupTint,
76
+ value,
77
+ preset,
78
+ mode,
79
+ label,
80
+ onselect
81
+ })
82
+
83
+ // svelte-ignore state_referenced_locally
84
+ const graph = provided ?? new GraphState(config())
85
+
86
+ $effect(() => {
87
+ if (!provided) {
88
+ graph.update(config())
89
+
90
+ return
91
+ }
92
+
93
+ // A supplied state is the caller's: their data, preset, mode and selection stay theirs
94
+ // and are never reset here. What this component DOES own is the options its own
95
+ // controls drive — the layout above all, since choosing this component is choosing it.
96
+ // `apply` merges; `update` would revert everything it does not name.
97
+ const { nodes: _n, edges: _e, fields: _f, preset: _p, mode: _m, value: _v, ...mine } =
98
+ config()
99
+ graph.apply(mine)
100
+ })
101
+ </script>
102
+
103
+ <!-- Snippets are declared OUTSIDE the frame and passed conditionally: one declared
104
+ inside is always defined, so the frame would render an empty control bar over
105
+ every diagram that asked for none. -->
106
+ {#snippet controlBar()}
107
+ <DensityControl {density} onchange={(v) => (density = v)} />
108
+ <EdgeStyleControl {edgeStyle} onchange={(v) => (edgeStyle = v)} />
109
+ <ZoomControl {zoom} onchange={(v) => (zoom = v)} />
110
+ {/snippet}
111
+
112
+ {#snippet legendBar()}
113
+ <GraphLegend state={graph} relations kinds />
114
+ {/snippet}
115
+
116
+ <DiagramFrame
117
+ class={className}
118
+ overlay={controls ? controlBar : undefined}
119
+ footer={legend ? legendBar : undefined}
120
+ >
121
+ {#snippet canvas()}
122
+ <Graph state={graph} bind:zoom {arrows} />
123
+ {/snippet}
124
+ </DiagramFrame>
@@ -0,0 +1,30 @@
1
+ import { GraphState } from '../GraphState.svelte.js';
2
+ import type { Density, EdgeStyle } from '../layout/types.js';
3
+ import type { GraphFields } from '../types.js';
4
+ import type { GraphPreset } from '../preset.js';
5
+ type Props = {
6
+ /**
7
+ * Share one state across this diagram and, say, an entity table beside it. Given one,
8
+ * the caller owns it: this component reads it and never calls `update`, exactly as
9
+ * `Graph` does — two owners writing one state fight on every render.
10
+ */
11
+ state?: GraphState;
12
+ nodes?: unknown[];
13
+ edges?: unknown[];
14
+ fields?: GraphFields;
15
+ density?: Density;
16
+ edgeStyle?: EdgeStyle;
17
+ groupTint?: boolean;
18
+ controls?: boolean;
19
+ legend?: boolean;
20
+ value?: string | null;
21
+ preset?: GraphPreset;
22
+ mode?: 'light' | 'dark';
23
+ arrows?: boolean;
24
+ label?: string;
25
+ onselect?: (id: string | null) => void;
26
+ class?: string;
27
+ };
28
+ declare const DependencyDiagram: import("svelte").Component<Props, {}, "value" | "density" | "edgeStyle">;
29
+ type DependencyDiagram = ReturnType<typeof DependencyDiagram>;
30
+ export default DependencyDiagram;
@@ -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;