@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,33 @@
1
+ /**
2
+ * What the colours and strokes on the canvas mean.
3
+ *
4
+ * Graph-native rather than `@rokkit/chart`'s legend: that package carries eight d3 modules
5
+ * plus ramda, and `@rokkit/graph` has exactly one dependency — so reusing it would cost a
6
+ * consumer ten transitive packages for one component, and there is no package both already
7
+ * share (chart does not depend on `@rokkit/ui` either). The vocabularies differ anyway: a
8
+ * chart swatch is a symbol path, a graph swatch is a kind ICON or a LINE STYLE. If the
9
+ * generic legend is ever promoted somewhere both can reach, this is what it replaces.
10
+ *
11
+ * Every section is opt-in. A diagram whose nodes are all one kind wants the relations key
12
+ * and nothing else, and a legend that lists a constant is noise.
13
+ */
14
+ import type { GraphState } from './GraphState.svelte.js';
15
+ type Section = 'kind' | 'relation' | 'group';
16
+ type Props = {
17
+ state: GraphState;
18
+ /** Node kinds present, each with its glyph. */
19
+ kinds?: boolean;
20
+ /** Edge relations present, each drawn as the stroke it uses. */
21
+ relations?: boolean;
22
+ /** Group names present, each with its ramp colour. */
23
+ groups?: boolean;
24
+ icons?: Record<string, string>;
25
+ /**
26
+ * Fires with the entry a reader activated. Given one, entries become real buttons;
27
+ * without one they stay inert text, because a static key is not a control.
28
+ */
29
+ onpick?: (section: Section, value: string) => void;
30
+ };
31
+ declare const GraphLegend: import("svelte").Component<Props, {}, "">;
32
+ type GraphLegend = ReturnType<typeof GraphLegend>;
33
+ export default GraphLegend;
@@ -1,6 +1,6 @@
1
1
  import { SvelteSet } from 'svelte/reactivity';
2
2
  import type { GraphPreset } from './preset.js';
3
- import type { Arrange, Cards, Cluster, Density, EdgeStyle, LayoutFn, NodeAxis, RoutedEdge, Size } from './layout/types.js';
3
+ import type { Arrange, Cards, Cluster, Column, Density, EdgeStyle, LayoutFn, NodeAxis, RoutedEdge, Size } from './layout/types.js';
4
4
  import type { GraphEdge, GraphFields, GraphModel, GraphNode } from './types.js';
5
5
  export type EntityRow = {
6
6
  id: string;
@@ -32,27 +32,41 @@ export type GraphStateConfig = {
32
32
  groupBy?: NodeAxis;
33
33
  /** Second axis subdividing each outer cluster. Unset means one level. */
34
34
  nestBy?: NodeAxis | null;
35
+ /**
36
+ * `points` only — what a node's size encodes. `degree` (default), `weight`, or any key in
37
+ * `GraphNode.measures`.
38
+ */
39
+ sizeBy?: string;
40
+ /** How the measure maps onto area. Defaults to `linear`. */
41
+ sizeScale?: 'linear' | 'log';
42
+ /** `neighborhood` only — hops out from the focus. Defaults to 1. */
43
+ depth?: number;
44
+ /** `world` only — the subtree rendered as the whole canvas. `[]` is the root. */
45
+ focusPath?: string[];
46
+ /** `world` only — levels below the focus to materialise. Defaults to 2. */
47
+ levels?: number;
48
+ /** `radial` only — `tree` ranks by depth, `dendrogram` pins leaves to the rim. */
49
+ radialMode?: 'tree' | 'dendrogram';
50
+ /** `radial` only — the node to centre on. Drilling; NOT the selection. */
51
+ root?: string | null;
52
+ /** `structure` only — how hard edges are pulled onto the tree, 0..1. */
53
+ bundleTension?: number;
54
+ /**
55
+ * Paint each CARD with its group's ramp, not just the cluster box around it.
56
+ *
57
+ * A clusterless layout — `flow`, `neighborhood` — has no box for the ramp to land on,
58
+ * so schema membership becomes invisible the moment you leave `cluster`. The cards
59
+ * already carry the custom properties; this is what tells CSS to use them.
60
+ */
61
+ groupTint?: boolean;
35
62
  edgeStyle?: EdgeStyle;
36
63
  focus?: string | null;
37
64
  value?: string | null;
38
65
  preset?: GraphPreset;
39
66
  mode?: 'light' | 'dark';
40
67
  label?: string;
41
- onselect?: (id: string) => void;
68
+ onselect?: (id: string | null) => void;
42
69
  };
43
- /**
44
- * The store. Turns raw `nodes`/`edges`/`fields` into the reactive shape the visuals
45
- * render, and owns every transition.
46
- *
47
- * Components read from this and call its methods — they never compute. That is what
48
- * lets the geometry, badge derivation and selection logic be covered exhaustively
49
- * with no DOM, and lets the component specs assert only attributes.
50
- *
51
- * `update()` is re-callable and FULLY re-applies config rather than merging deltas,
52
- * matching `SparkState.update` — so a prop reverting to undefined actually reverts.
53
- * Deliberately NOT `PlotState.update`, which guards each field with
54
- * `if (config.X !== undefined)` and therefore merges.
55
- */
56
70
  export declare class GraphState {
57
71
  #private;
58
72
  constructor(config?: GraphStateConfig);
@@ -64,7 +78,27 @@ export declare class GraphState {
64
78
  * field by field.
65
79
  */
66
80
  update(config?: GraphStateConfig): void;
81
+ /**
82
+ * Merge a partial config, leaving every key it does not name alone.
83
+ *
84
+ * `update()` fully RE-APPLIES — an omitted key reverts to its default, which is what makes
85
+ * it safe to call from a render. That is the wrong verb for a component writing the few
86
+ * options its own controls drive into a state someone else owns: it would reset the
87
+ * owner's data on every keystroke. This merges over the last full config instead.
88
+ */
89
+ apply(config: Partial<GraphStateConfig>): void;
67
90
  select(id: string): void;
91
+ /**
92
+ * Drop the selection, and SAY SO.
93
+ *
94
+ * Found by dbd consuming the package: it owns `selected` in its route and branches on it
95
+ * to show the entity panel, so a silent clear left that panel open over nothing. A
96
+ * controlled consumer cannot observe an internal `#value` — the callback is the only
97
+ * channel, which is why it carries `null` rather than being skipped.
98
+ *
99
+ * Guarded on an actual change, or a background click on an already-empty canvas
100
+ * round-trips through the consumer's setter on every stray click.
101
+ */
68
102
  clear(): void;
69
103
  /**
70
104
  * Show one node's rows in full, whatever the density says.
@@ -109,9 +143,26 @@ export declare class GraphState {
109
143
  */
110
144
  groupStyleAttr(group: string | undefined): string;
111
145
  get model(): GraphModel;
146
+ /**
147
+ * Clusters drawn as rectangles — everything the box renderer handles.
148
+ *
149
+ * A wedge has no box, so it is drawn as an SVG arc instead. Split here rather than in the
150
+ * template so the component keeps computing nothing.
151
+ */
152
+ get boxes(): Cluster[];
153
+ /** Whether any cluster is a wedge, which is what puts the arc layer on the canvas. */
154
+ get hasWedges(): boolean;
112
155
  get clusters(): Cluster[];
113
156
  get cards(): Cards;
157
+ /** Structural edges as the layout routed them, then any overlay edges on top. */
114
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;
115
166
  get size(): Size;
116
167
  /**
117
168
  * The true content extent, as distinct from `size`.
@@ -147,9 +198,58 @@ export declare class GraphState {
147
198
  * render aborts, and no inner box appears at all, which reads as nesting silently not
148
199
  * working rather than as an error.
149
200
  */
201
+ /**
202
+ * The SVG path for a wedge-shaped box, centred on the canvas.
203
+ *
204
+ * Here rather than in the component for the same reason `edgePath` is: it is arithmetic,
205
+ * and the view's job is to render what state derives, not to compute geometry.
206
+ */
207
+ wedgePath(cluster: Cluster): string;
208
+ /**
209
+ * The data attributes every containment box carries, wedge or rectangle.
210
+ *
211
+ * Built here so the two render branches — interactive and scenery — cannot drift: they
212
+ * differ only in the element and its role, which is exactly the difference that matters.
213
+ */
214
+ boxAttrs(cluster: Cluster): Record<string, string | null | undefined>;
150
215
  clusterKey(cluster: Cluster): string;
151
216
  get groupBy(): NodeAxis;
217
+ /**
218
+ * The node kinds actually present, sorted.
219
+ *
220
+ * What a LEGEND needs: naming the whole vocabulary over a diagram that uses three of it is
221
+ * a key to a different picture, and the reader hunts for a `trigger` that is not there.
222
+ */
223
+ get kindsPresent(): string[];
224
+ /**
225
+ * The edge relations present, sorted — the producer's verb where it gave one, else the
226
+ * coarse kind.
227
+ *
228
+ * A plain foreign key carries no verb, and listing nothing for it would leave the one
229
+ * stroke actually on the canvas unexplained.
230
+ */
231
+ get relationsPresent(): string[];
232
+ /** The group names present, sorted. Same reasoning as `kindsPresent`. */
233
+ get groupsPresent(): string[];
234
+ /** Whether cards carry the group ramp themselves. See `GraphStateConfig.groupTint`. */
235
+ /** `dot` or `card` — what the active layout draws a node as. */
236
+ get nodeShape(): 'dot' | 'card';
237
+ get groupTint(): boolean;
152
238
  get nestBy(): NodeAxis | null;
239
+ get sizeBy(): string;
240
+ get sizeScale(): 'linear' | 'log';
241
+ get depth(): number;
242
+ get focusPath(): string[];
243
+ /** The configured cap, or the package default of 2 when none was set. */
244
+ get levels(): number;
245
+ /**
246
+ * Column headings for a multi-column layout, empty for the grid ones.
247
+ *
248
+ * Exposed so the view can render headings without knowing which layout is active or what
249
+ * it means — the layout decides both the position and the wording, and the component
250
+ * prints them.
251
+ */
252
+ get columns(): Column[];
153
253
  /**
154
254
  * Change the grouping axes from inside the component.
155
255
  *
@@ -1,7 +1,9 @@
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
+ import { nodeShapeOf } from './layout/options.js';
6
+ import { arcPath } from './layout/arc.js';
5
7
  import { defaultGraphPreset, resolveGroupStyles } from './preset.js';
6
8
  const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
7
9
  /** How `id` sits on an edge: not on it, both ends of it, or one end with a far side. */
@@ -60,15 +62,49 @@ function extentOf(boxes) {
60
62
  * Deliberately NOT `PlotState.update`, which guards each field with
61
63
  * `if (config.X !== undefined)` and therefore merges.
62
64
  */
65
+ /**
66
+ * Sorted unique strings, dropping empties.
67
+ *
68
+ * Sort-then-dedupe rather than a `Set`: these vocabularies are tiny and the result has to be
69
+ * sorted anyway, so the Set would be an extra structure for nothing — and a plain Set inside a
70
+ * reactive class is exactly what `svelte/prefer-svelte-reactivity` is right to flag.
71
+ */
72
+ /**
73
+ * A level cap, or nothing.
74
+ *
75
+ * Left UNDEFINED when the caller said nothing: a state-level default of 2 is indistinguishable
76
+ * from an explicit 2, and `radial` needs "no cap" to mean the whole tree — inventing a number
77
+ * here silently pruned a codebase dendrogram to two rings.
78
+ */
79
+ function floorLevels(levels) {
80
+ return levels === undefined ? undefined : Math.max(1, Math.floor(levels));
81
+ }
82
+ function unique(values) {
83
+ return values
84
+ .filter((v) => Boolean(v))
85
+ .sort()
86
+ .filter((v, i, all) => i === 0 || v !== all[i - 1]);
87
+ }
63
88
  export class GraphState {
64
89
  #nodes = $state([]);
65
90
  #edges = $state([]);
66
91
  #fields = $state({});
67
- #layout = $state('cluster');
92
+ #layout = $state('flow');
93
+ #groupTint = $state(false);
94
+ #radialMode = $state('tree');
95
+ #root = $state(null);
96
+ #bundleTension = $state(undefined);
97
+ /** The last FULL config, so `apply` can merge over it rather than reset around it. */
98
+ #last = {};
68
99
  #density = $state('keys');
69
100
  #arrange = $state('untangle');
70
101
  #groupBy = $state('group');
71
102
  #nestBy = $state(null);
103
+ #sizeBy = $state('degree');
104
+ #sizeScale = $state('linear');
105
+ #depth = $state(1);
106
+ #focusPath = $state([]);
107
+ #levels = $state(undefined);
72
108
  #edgeStyle = $state('curved');
73
109
  #focus = $state(null);
74
110
  #value = $state(null);
@@ -84,10 +120,29 @@ export class GraphState {
84
120
  arrange: this.#arrange,
85
121
  groupBy: this.#groupBy,
86
122
  nestBy: this.#nestBy ?? undefined,
123
+ sizeBy: this.#sizeBy,
124
+ sizeScale: this.#sizeScale,
125
+ depth: this.#depth,
126
+ focusPath: this.#focusPath,
127
+ levels: this.#levels,
128
+ radialMode: this.#radialMode,
129
+ root: this.#root,
130
+ bundleTension: this.#bundleTension,
87
131
  edgeStyle: this.#edgeStyle,
88
132
  focus: this.#focus ?? this.#value,
89
133
  expanded: this.#expanded
90
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)));
91
146
  // Duplicates are left in on purpose: `resolveGroupStyles` de-duplicates and sorts, because
92
147
  // that is where ramp assignment lives. A Set here would be a second, redundant de-dup.
93
148
  #groups = $derived(this.#model.nodes.map((n) => n.group).filter((g) => Boolean(g)));
@@ -156,6 +211,7 @@ export class GraphState {
156
211
  * field by field.
157
212
  */
158
213
  update(config = {}) {
214
+ this.#last = config;
159
215
  this.#applyData(config);
160
216
  this.#applyView(config);
161
217
  // `value` is input AND output, so it is only adopted when the caller supplies
@@ -163,11 +219,22 @@ export class GraphState {
163
219
  if (config.value !== undefined)
164
220
  this.#value = config.value;
165
221
  }
222
+ /**
223
+ * Merge a partial config, leaving every key it does not name alone.
224
+ *
225
+ * `update()` fully RE-APPLIES — an omitted key reverts to its default, which is what makes
226
+ * it safe to call from a render. That is the wrong verb for a component writing the few
227
+ * options its own controls drive into a state someone else owns: it would reset the
228
+ * owner's data on every keystroke. This merges over the last full config instead.
229
+ */
230
+ apply(config) {
231
+ this.update({ ...this.#last, ...config });
232
+ }
166
233
  #applyData(config) {
167
234
  this.#nodes = config.nodes ?? [];
168
235
  this.#edges = config.edges ?? [];
169
236
  this.#fields = config.fields ?? {};
170
- this.#layout = config.layout ?? 'cluster';
237
+ this.#layout = config.layout ?? 'flow';
171
238
  this.#focus = config.focus ?? null;
172
239
  }
173
240
  /** How the boxes are organised — one axis, or one subdivided by a second. */
@@ -177,11 +244,26 @@ export class GraphState {
177
244
  // Rejected rather than rendered: a box subdivided by its OWN axis yields exactly one
178
245
  // child containing everything, which reads as a rendering fault, not a no-op.
179
246
  this.#nestBy = config.nestBy === outer ? null : (config.nestBy ?? null);
247
+ this.#groupTint = config.groupTint ?? false;
248
+ }
249
+ /** What a node's size encodes, and how far a neighbourhood reaches. */
250
+ #applyMeasure(config) {
251
+ this.#sizeBy = config.sizeBy ?? 'degree';
252
+ this.#sizeScale = config.sizeScale ?? 'linear';
253
+ // Floored at 1: a depth of 0 or -1 is a request for nothing, and returning an empty
254
+ // canvas for it looks identical to a broken focus.
255
+ this.#depth = Math.max(1, Math.floor(config.depth ?? 1));
256
+ this.#focusPath = config.focusPath ?? [];
257
+ this.#levels = floorLevels(config.levels);
258
+ this.#root = config.root ?? null;
259
+ this.#bundleTension = config.bundleTension;
260
+ this.#radialMode = config.radialMode ?? 'tree';
180
261
  }
181
262
  #applyView(config) {
182
263
  this.#density = config.density ?? 'keys';
183
264
  this.#arrange = config.arrange ?? 'untangle';
184
265
  this.#applyGrouping(config);
266
+ this.#applyMeasure(config);
185
267
  this.#edgeStyle = config.edgeStyle ?? 'curved';
186
268
  this.#preset = config.preset ?? defaultGraphPreset;
187
269
  this.#mode = config.mode ?? 'light';
@@ -193,8 +275,22 @@ export class GraphState {
193
275
  this.#value = id;
194
276
  this.#onselect?.(id);
195
277
  }
278
+ /**
279
+ * Drop the selection, and SAY SO.
280
+ *
281
+ * Found by dbd consuming the package: it owns `selected` in its route and branches on it
282
+ * to show the entity panel, so a silent clear left that panel open over nothing. A
283
+ * controlled consumer cannot observe an internal `#value` — the callback is the only
284
+ * channel, which is why it carries `null` rather than being skipped.
285
+ *
286
+ * Guarded on an actual change, or a background click on an already-empty canvas
287
+ * round-trips through the consumer's setter on every stray click.
288
+ */
196
289
  clear() {
290
+ if (this.#value === null)
291
+ return;
197
292
  this.#value = null;
293
+ this.#onselect?.(null);
198
294
  }
199
295
  /**
200
296
  * Show one node's rows in full, whatever the density says.
@@ -258,7 +354,9 @@ export class GraphState {
258
354
  return edge.fromKey === this.#value || edge.toKey === this.#value ? 'highlight' : 'dim';
259
355
  }
260
356
  edgePath(edge) {
261
- return edgePath(edge, this.#edgeStyle);
357
+ // A layout that knows more than the endpoints builds its own — a bundled edge follows
358
+ // the whole chain of ancestors between them, which `edgePath` never sees.
359
+ return edge.path ?? edgePath(edge, this.#edgeStyle);
262
360
  }
263
361
  groupStyle(group) {
264
362
  return (group && this.#groupStyles.get(group)) || {};
@@ -279,14 +377,41 @@ export class GraphState {
279
377
  get model() {
280
378
  return this.#model;
281
379
  }
380
+ /**
381
+ * Clusters drawn as rectangles — everything the box renderer handles.
382
+ *
383
+ * A wedge has no box, so it is drawn as an SVG arc instead. Split here rather than in the
384
+ * template so the component keeps computing nothing.
385
+ */
386
+ get boxes() {
387
+ return this.clusters.filter((c) => c.wedge === undefined);
388
+ }
389
+ /** Whether any cluster is a wedge, which is what puts the arc layer on the canvas. */
390
+ get hasWedges() {
391
+ return this.clusters.some((c) => c.wedge !== undefined);
392
+ }
282
393
  get clusters() {
283
394
  return this.#result.clusters;
284
395
  }
285
396
  get cards() {
286
397
  return this.#result.cards;
287
398
  }
399
+ /** Structural edges as the layout routed them, then any overlay edges on top. */
288
400
  get routedEdges() {
289
- 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;
290
415
  }
291
416
  get size() {
292
417
  return this.#result.size;
@@ -354,15 +479,98 @@ export class GraphState {
354
479
  * render aborts, and no inner box appears at all, which reads as nesting silently not
355
480
  * working rather than as an error.
356
481
  */
482
+ /**
483
+ * The SVG path for a wedge-shaped box, centred on the canvas.
484
+ *
485
+ * Here rather than in the component for the same reason `edgePath` is: it is arithmetic,
486
+ * and the view's job is to render what state derives, not to compute geometry.
487
+ */
488
+ wedgePath(cluster) {
489
+ if (!cluster.wedge)
490
+ return '';
491
+ return arcPath(this.size.w / 2, this.size.h / 2, cluster.wedge);
492
+ }
493
+ /**
494
+ * The data attributes every containment box carries, wedge or rectangle.
495
+ *
496
+ * Built here so the two render branches — interactive and scenery — cannot drift: they
497
+ * differ only in the element and its role, which is exactly the difference that matters.
498
+ */
499
+ boxAttrs(cluster) {
500
+ return {
501
+ 'data-cluster-depth': String(cluster.depth ?? 0),
502
+ 'data-node-group': cluster.name,
503
+ 'data-graph-node-id': cluster.nodeId,
504
+ 'data-node-kind': cluster.kind,
505
+ 'data-node-state': cluster.nodeId ? this.nodeState(cluster.nodeId) : undefined
506
+ };
507
+ }
357
508
  clusterKey(cluster) {
358
509
  return `${cluster.depth ?? 0}:${cluster.parent ?? ''}:${cluster.name}`;
359
510
  }
360
511
  get groupBy() {
361
512
  return this.#groupBy;
362
513
  }
514
+ /**
515
+ * The node kinds actually present, sorted.
516
+ *
517
+ * What a LEGEND needs: naming the whole vocabulary over a diagram that uses three of it is
518
+ * a key to a different picture, and the reader hunts for a `trigger` that is not there.
519
+ */
520
+ get kindsPresent() {
521
+ return unique(this.#model.nodes.map((n) => n.kind));
522
+ }
523
+ /**
524
+ * The edge relations present, sorted — the producer's verb where it gave one, else the
525
+ * coarse kind.
526
+ *
527
+ * A plain foreign key carries no verb, and listing nothing for it would leave the one
528
+ * stroke actually on the canvas unexplained.
529
+ */
530
+ get relationsPresent() {
531
+ return unique(this.#model.edges.map((e) => e.relation ?? e.kind));
532
+ }
533
+ /** The group names present, sorted. Same reasoning as `kindsPresent`. */
534
+ get groupsPresent() {
535
+ return unique(this.#model.nodes.map((n) => n.group));
536
+ }
537
+ /** Whether cards carry the group ramp themselves. See `GraphStateConfig.groupTint`. */
538
+ /** `dot` or `card` — what the active layout draws a node as. */
539
+ get nodeShape() {
540
+ return nodeShapeOf(this.layoutName);
541
+ }
542
+ get groupTint() {
543
+ return this.#groupTint;
544
+ }
363
545
  get nestBy() {
364
546
  return this.#nestBy;
365
547
  }
548
+ get sizeBy() {
549
+ return this.#sizeBy;
550
+ }
551
+ get sizeScale() {
552
+ return this.#sizeScale;
553
+ }
554
+ get depth() {
555
+ return this.#depth;
556
+ }
557
+ get focusPath() {
558
+ return this.#focusPath;
559
+ }
560
+ /** The configured cap, or the package default of 2 when none was set. */
561
+ get levels() {
562
+ return this.#levels ?? 2;
563
+ }
564
+ /**
565
+ * Column headings for a multi-column layout, empty for the grid ones.
566
+ *
567
+ * Exposed so the view can render headings without knowing which layout is active or what
568
+ * it means — the layout decides both the position and the wording, and the component
569
+ * prints them.
570
+ */
571
+ get columns() {
572
+ return this.#result.columns ?? [];
573
+ }
366
574
  /**
367
575
  * Change the grouping axes from inside the component.
368
576
  *
@@ -0,0 +1,30 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Straight chords or bundled ones.
4
+ *
5
+ * A toggle rather than a slider: the useful values are the two ends. Bundled is the reason
6
+ * the view is readable at all — a few hundred straight chords across a rim are a solid disc
7
+ * of ink — and straight is what you switch to when you want to see one edge exactly.
8
+ */
9
+ type Props = {
10
+ bundleTension?: number
11
+ /** Tension used for the bundled state. Holten's 0.85 by default. */
12
+ bundled?: number
13
+ onchange?: (value: number) => void
14
+ }
15
+
16
+ let { bundleTension = 0.85, bundled = 0.85, onchange }: Props = $props()
17
+
18
+ const on = $derived(bundleTension > 0)
19
+ </script>
20
+
21
+ <button
22
+ type="button"
23
+ data-graph-bundle={on ? 'bundled' : 'straight'}
24
+ aria-pressed={on}
25
+ title={on ? 'Edges follow the hierarchy' : 'Edges run straight across'}
26
+ onclick={() => onchange?.(on ? 0 : bundled)}
27
+ >
28
+ <span data-graph-bundle-glyph aria-hidden="true">{on ? '❨' : '/'}</span>
29
+ <span data-graph-control-label>{on ? 'Bundled' : 'Straight'}</span>
30
+ </button>
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Straight chords or bundled ones.
3
+ *
4
+ * A toggle rather than a slider: the useful values are the two ends. Bundled is the reason
5
+ * the view is readable at all — a few hundred straight chords across a rim are a solid disc
6
+ * of ink — and straight is what you switch to when you want to see one edge exactly.
7
+ */
8
+ type Props = {
9
+ bundleTension?: number;
10
+ /** Tension used for the bundled state. Holten's 0.85 by default. */
11
+ bundled?: number;
12
+ onchange?: (value: number) => void;
13
+ };
14
+ declare const BundleControl: import("svelte").Component<Props, {}, "">;
15
+ type BundleControl = ReturnType<typeof BundleControl>;
16
+ export default BundleControl;
@@ -0,0 +1,39 @@
1
+ <script lang="ts">
2
+ /**
3
+ * How much of each node's row list a card shows.
4
+ *
5
+ * A component rather than canvas chrome: only some diagrams have rows to thin, and a
6
+ * control that moves while the picture does not reads as a broken view. The diagram that
7
+ * wants it places it; the rest never render it.
8
+ */
9
+ import type { Density } from '../layout/types.js'
10
+
11
+ type Props = {
12
+ density?: Density
13
+ /** Fires with the chosen value. The caller owns the value — this control stores none. */
14
+ onchange?: (value: Density) => void
15
+ }
16
+
17
+ let { density = 'keys', onchange }: Props = $props()
18
+
19
+ const options: [Density, string, string][] = [
20
+ ['names', 'Names', 'Titles only'],
21
+ ['keys', 'Keys', 'Key rows only'],
22
+ ['full', 'All', 'All rows']
23
+ ]
24
+ </script>
25
+
26
+ <!-- Named, because three unlabelled buttons announce as "Names Keys All" and say nothing
27
+ about what they control. -->
28
+ <div data-graph-density-controls role="group" aria-label="Detail level">
29
+ {#each options as [value, short, title] (value)}
30
+ <button
31
+ type="button"
32
+ data-graph-density={value}
33
+ data-selected={density === value ? '' : undefined}
34
+ aria-pressed={density === value}
35
+ {title}
36
+ onclick={() => onchange?.(value)}>{short}</button
37
+ >
38
+ {/each}
39
+ </div>
@@ -0,0 +1,16 @@
1
+ /**
2
+ * How much of each node's row list a card shows.
3
+ *
4
+ * A component rather than canvas chrome: only some diagrams have rows to thin, and a
5
+ * control that moves while the picture does not reads as a broken view. The diagram that
6
+ * wants it places it; the rest never render it.
7
+ */
8
+ import type { Density } from '../layout/types.js';
9
+ type Props = {
10
+ density?: Density;
11
+ /** Fires with the chosen value. The caller owns the value — this control stores none. */
12
+ onchange?: (value: Density) => void;
13
+ };
14
+ declare const DensityControl: import("svelte").Component<Props, {}, "">;
15
+ type DensityControl = ReturnType<typeof DensityControl>;
16
+ export default DensityControl;
@@ -0,0 +1,34 @@
1
+ <script lang="ts">
2
+ /**
3
+ * How many levels of a containment tree to materialise.
4
+ *
5
+ * The control that makes a large tree readable at all. A radial dendrogram over a whole
6
+ * codebase renders every leaf on one rim, where each is a fraction of a degree wide and
7
+ * nothing is legible — show the crates first, then drill. Depth and drilling are the same
8
+ * idea from two directions: this caps how far DOWN, `focusPath` moves where you start.
9
+ */
10
+ type Props = {
11
+ levels?: number
12
+ /** Deepest level offered. Beyond the tree's own depth the extra buttons do nothing. */
13
+ max?: number
14
+ onchange?: (value: number) => void
15
+ }
16
+
17
+ let { levels = 2, max = 4, onchange }: Props = $props()
18
+
19
+ // From 1: depth 0 materialises nothing at all, so offering it is offering a blank canvas.
20
+ const steps = $derived(Array.from({ length: Math.max(1, max) }, (_, i) => i + 1))
21
+ </script>
22
+
23
+ <div data-graph-depth-controls role="group" aria-label="Levels shown">
24
+ {#each steps as level (level)}
25
+ <button
26
+ type="button"
27
+ data-graph-depth={level}
28
+ data-selected={levels === level ? '' : undefined}
29
+ aria-pressed={levels === level}
30
+ title="{level} level{level === 1 ? '' : 's'}"
31
+ onclick={() => onchange?.(level)}>{level}</button
32
+ >
33
+ {/each}
34
+ </div>