@rokkit/graph 1.7.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +67 -1
  2. package/dist/Graph.svelte +234 -259
  3. package/dist/Graph.svelte.d.ts +1 -1
  4. package/dist/GraphLegend.svelte +81 -10
  5. package/dist/GraphLegend.svelte.d.ts +15 -0
  6. package/dist/GraphState.svelte.d.ts +168 -51
  7. package/dist/GraphState.svelte.js +406 -305
  8. package/dist/actions/canvas.d.ts +18 -0
  9. package/dist/actions/canvas.js +59 -0
  10. package/dist/actions/choices.d.ts +12 -0
  11. package/dist/actions/choices.js +23 -0
  12. package/dist/actions/interactions.d.ts +20 -0
  13. package/dist/actions/interactions.js +57 -0
  14. package/dist/actions/listen.d.ts +2 -0
  15. package/dist/actions/listen.js +10 -0
  16. package/dist/canvas/frame.d.ts +41 -0
  17. package/dist/canvas/frame.js +34 -0
  18. package/dist/controls/BundleControl.svelte +11 -10
  19. package/dist/controls/BundleControl.svelte.d.ts +0 -7
  20. package/dist/controls/DensityControl.svelte +17 -9
  21. package/dist/controls/DensityControl.svelte.d.ts +0 -8
  22. package/dist/controls/DepthControl.svelte +11 -4
  23. package/dist/controls/DepthControl.svelte.d.ts +0 -9
  24. package/dist/controls/DrillBar.svelte +63 -0
  25. package/dist/controls/DrillBar.svelte.d.ts +9 -0
  26. package/dist/controls/EdgeStyleControl.svelte +10 -10
  27. package/dist/controls/EdgeStyleControl.svelte.d.ts +0 -6
  28. package/dist/controls/HiddenControl.svelte +27 -0
  29. package/dist/controls/HiddenControl.svelte.d.ts +7 -0
  30. package/dist/controls/MeasureControl.svelte +39 -0
  31. package/dist/controls/MeasureControl.svelte.d.ts +12 -0
  32. package/dist/controls/ToggleControl.svelte +33 -0
  33. package/dist/controls/ToggleControl.svelte.d.ts +13 -0
  34. package/dist/controls/ViolationsControl.svelte +27 -0
  35. package/dist/controls/ViolationsControl.svelte.d.ts +7 -0
  36. package/dist/controls/ZoomControl.svelte +21 -18
  37. package/dist/controls/zoom.d.ts +3 -0
  38. package/dist/controls/zoom.js +6 -0
  39. package/dist/diagrams/ArcDiagram.svelte +116 -0
  40. package/dist/diagrams/ArcDiagram.svelte.d.ts +31 -0
  41. package/dist/diagrams/CallTree.svelte +17 -5
  42. package/dist/diagrams/CallTree.svelte.d.ts +5 -1
  43. package/dist/diagrams/DependencyDiagram.svelte +18 -6
  44. package/dist/diagrams/DependencyDiagram.svelte.d.ts +5 -1
  45. package/dist/diagrams/DependencyMatrix.svelte +156 -0
  46. package/dist/diagrams/DependencyMatrix.svelte.d.ts +34 -0
  47. package/dist/diagrams/ErDiagram.svelte +18 -6
  48. package/dist/diagrams/ErDiagram.svelte.d.ts +5 -1
  49. package/dist/diagrams/LayersDiagram.svelte +124 -0
  50. package/dist/diagrams/LayersDiagram.svelte.d.ts +30 -0
  51. package/dist/diagrams/Neighborhood.svelte +26 -6
  52. package/dist/diagrams/Neighborhood.svelte.d.ts +11 -1
  53. package/dist/diagrams/PolymetricTree.svelte +116 -0
  54. package/dist/diagrams/PolymetricTree.svelte.d.ts +28 -0
  55. package/dist/diagrams/StructureDiagram.svelte +38 -8
  56. package/dist/diagrams/StructureDiagram.svelte.d.ts +9 -1
  57. package/dist/diagrams/Sunburst.svelte +44 -8
  58. package/dist/diagrams/Sunburst.svelte.d.ts +12 -2
  59. package/dist/diagrams/Treemap.svelte +44 -8
  60. package/dist/diagrams/Treemap.svelte.d.ts +12 -2
  61. package/dist/index.d.ts +13 -3
  62. package/dist/index.js +11 -2
  63. package/dist/layout/arcs.d.ts +6 -0
  64. package/dist/layout/arcs.js +141 -0
  65. package/dist/layout/cards.js +9 -2
  66. package/dist/layout/edges.d.ts +13 -1
  67. package/dist/layout/edges.js +35 -0
  68. package/dist/layout/extent.d.ts +17 -0
  69. package/dist/layout/extent.js +27 -0
  70. package/dist/layout/flow.js +3 -1
  71. package/dist/layout/index.d.ts +4 -1
  72. package/dist/layout/index.js +17 -2
  73. package/dist/layout/layers.d.ts +6 -0
  74. package/dist/layout/layers.js +180 -0
  75. package/dist/layout/matrix.d.ts +50 -0
  76. package/dist/layout/matrix.js +110 -0
  77. package/dist/layout/neighborhood.js +16 -7
  78. package/dist/layout/options.d.ts +1 -1
  79. package/dist/layout/options.js +16 -3
  80. package/dist/layout/percentile.d.ts +8 -0
  81. package/dist/layout/percentile.js +13 -0
  82. package/dist/layout/polymetric.d.ts +3 -0
  83. package/dist/layout/polymetric.js +221 -0
  84. package/dist/layout/radial.js +3 -1
  85. package/dist/layout/structure.js +7 -1
  86. package/dist/layout/sunburst.js +11 -4
  87. package/dist/layout/types.d.ts +83 -0
  88. package/dist/layout/world.js +11 -4
  89. package/dist/messages.d.ts +14 -0
  90. package/dist/messages.js +17 -0
  91. package/dist/model/condense.d.ts +8 -0
  92. package/dist/model/condense.js +114 -0
  93. package/dist/model/entities.d.ts +12 -0
  94. package/dist/model/entities.js +12 -0
  95. package/dist/model/normalize.d.ts +2 -6
  96. package/dist/model/normalize.js +83 -5
  97. package/dist/model/relationships.d.ts +30 -0
  98. package/dist/model/relationships.js +51 -0
  99. package/dist/model/share.d.ts +9 -0
  100. package/dist/model/share.js +43 -0
  101. package/dist/model/tree.d.ts +7 -1
  102. package/dist/model/tree.js +11 -4
  103. package/dist/preset.d.ts +18 -0
  104. package/dist/preset.js +34 -3
  105. package/dist/schema/EntitiesView.svelte +8 -6
  106. package/dist/schema/EntityView.svelte +13 -10
  107. package/dist/state/GraphConfig.svelte.d.ts +124 -0
  108. package/dist/state/GraphConfig.svelte.js +253 -0
  109. package/dist/state/GraphDrill.svelte.d.ts +52 -0
  110. package/dist/state/GraphDrill.svelte.js +115 -0
  111. package/dist/state/GraphGroups.svelte.d.ts +35 -0
  112. package/dist/state/GraphGroups.svelte.js +73 -0
  113. package/dist/state/GraphSelection.svelte.d.ts +53 -0
  114. package/dist/state/GraphSelection.svelte.js +94 -0
  115. package/dist/state/intents.d.ts +33 -0
  116. package/dist/state/intents.js +30 -0
  117. package/dist/state/text.d.ts +28 -0
  118. package/dist/state/text.js +56 -0
  119. package/dist/types.d.ts +92 -1
  120. package/package.json +5 -4
@@ -1,67 +1,22 @@
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';
5
- import { nodeShapeOf } from './layout/options.js';
4
+ import { buildEdges, edgePath } from './layout/edges.js';
5
+ import { appliesTo, nodeShapeOf } from './layout/options.js';
6
6
  import { arcPath } from './layout/arc.js';
7
- import { defaultGraphPreset, resolveGroupStyles } from './preset.js';
8
- const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
9
- /** How `id` sits on an edge: not on it, both ends of it, or one end with a far side. */
10
- function endpointsFor(edge, id) {
11
- const isSource = edge.source === id;
12
- const isTarget = edge.target === id;
13
- if (!isSource && !isTarget)
14
- return null;
15
- return {
16
- self: isSource && isTarget,
17
- other: isSource ? edge.target : edge.source,
18
- direction: isSource ? 'out' : 'in'
19
- };
20
- }
21
- /**
22
- * One edge's contribution to the selected node's relationship list.
23
- *
24
- * An UNPLACED edge is reported too, with the raw name as the label — the far end is not a
25
- * node, so there is nothing to look up. Hiding it would be worse: "this references something
26
- * we have not indexed" is exactly what a reader of a partial graph needs to see.
27
- */
28
- function describeEdge(edge, id, describe) {
29
- const ends = endpointsFor(edge, id);
30
- if (!ends)
31
- return [];
32
- if (ends.self)
33
- return [describe(id, 'out', edge)];
34
- const { other, direction } = ends;
35
- // An unplaced far end is not a node, so there is nothing to look up: the raw name IS
36
- // the label. Reported rather than hidden — "references something not indexed" is what a
37
- // reader of a partial graph most needs to see.
38
- return edge.unplaced
39
- ? [{ direction, id: other, label: other, edge }]
40
- : [describe(other, direction, edge)];
41
- }
42
- /** Bottom-right extent of a set of boxes, or {0,0} when there are none. */
43
- function extentOf(boxes) {
44
- let w = 0;
45
- let h = 0;
46
- for (const box of boxes) {
47
- w = Math.max(w, box.x + box.w);
48
- h = Math.max(h, box.y + box.h);
49
- }
50
- return { w, h };
51
- }
52
- /**
53
- * The store. Turns raw `nodes`/`edges`/`fields` into the reactive shape the visuals
54
- * render, and owns every transition.
55
- *
56
- * Components read from this and call its methods — they never compute. That is what
57
- * lets the geometry, badge derivation and selection logic be covered exhaustively
58
- * with no DOM, and lets the component specs assert only attributes.
59
- *
60
- * `update()` is re-callable and FULLY re-applies config rather than merging deltas,
61
- * matching `SparkState.update` — so a prop reverting to undefined actually reverts.
62
- * Deliberately NOT `PlotState.update`, which guards each field with
63
- * `if (config.X !== undefined)` and therefore merges.
64
- */
7
+ import { resolveGroupStyles, resolveShade } from './preset.js';
8
+ import { entityRows } from './model/entities.js';
9
+ import { contentExtent } from './layout/extent.js';
10
+ import { GraphConfig } from './state/GraphConfig.svelte.js';
11
+ import { GraphSelection } from './state/GraphSelection.svelte.js';
12
+ import { GraphDrill } from './state/GraphDrill.svelte.js';
13
+ import { GraphGroups } from './state/GraphGroups.svelte.js';
14
+ import { condense } from './model/condense.js';
15
+ import { channelRows, diagramLabel, drillErrorText, moreRowsLabel, sideNames } from './state/text.js';
16
+ import { INTENTS, isIntent } from './state/intents.js';
17
+ import { say } from './messages.js';
18
+ /** A presence attribute: `''` when on, absent when off. */
19
+ const flag = (on) => (on ? '' : undefined);
65
20
  /**
66
21
  * Sorted unique strings, dropping empties.
67
22
  *
@@ -69,126 +24,90 @@ function extentOf(boxes) {
69
24
  * sorted anyway, so the Set would be an extra structure for nothing — and a plain Set inside a
70
25
  * reactive class is exactly what `svelte/prefer-svelte-reactivity` is right to flag.
71
26
  */
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
27
  function unique(values) {
83
28
  return values
84
29
  .filter((v) => Boolean(v))
85
30
  .sort()
86
31
  .filter((v, i, all) => i === 0 || v !== all[i - 1]);
87
32
  }
33
+ /**
34
+ * The store. Turns raw `nodes`/`edges`/`fields` into the reactive shape the visuals
35
+ * render, and owns every transition.
36
+ *
37
+ * Components read from this and call its methods — they never compute. That is what
38
+ * lets the geometry, badge derivation and selection logic be covered exhaustively
39
+ * with no DOM, and lets the component specs assert only attributes.
40
+ *
41
+ * A composition: `config` (every input, `state/GraphConfig`) feeds the layout pipeline
42
+ * derived here; the selection and the view queries read both. `update()` FULLY re-applies
43
+ * config rather than merging deltas — so a prop reverting to undefined actually reverts;
44
+ * `apply()` merges.
45
+ */
88
46
  export class GraphState {
89
- #nodes = $state([]);
90
- #edges = $state([]);
91
- #fields = $state({});
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 = {};
99
- #density = $state('keys');
100
- #arrange = $state('untangle');
101
- #groupBy = $state('group');
102
- #nestBy = $state(null);
103
- #sizeBy = $state('degree');
104
- #sizeScale = $state('linear');
105
- #depth = $state(1);
106
- #focusPath = $state([]);
107
- #levels = $state(undefined);
108
- #edgeStyle = $state('curved');
109
- #focus = $state(null);
110
- #value = $state(null);
111
- #expanded = new SvelteSet();
112
- #preset = $state(defaultGraphPreset);
113
- #mode = $state('light');
114
- #label = $state(undefined);
115
- #onselect = $state(undefined);
116
- #model = $derived(normalizeGraph(this.#nodes, this.#edges, this.#fields));
117
- #layoutFn = $derived(typeof this.#layout === 'function' ? this.#layout : (layouts[this.#layout] ?? layouts.cluster));
47
+ /** Every input — see `CONFIG_FIELDS` for what each omitted key falls back to. */
48
+ config = new GraphConfig();
49
+ /** Which subtree is the canvas, and the host events for moving it (#165). */
50
+ drill = new GraphDrill({
51
+ config: this.config,
52
+ model: () => this.#model,
53
+ layoutName: () => this.layoutName
54
+ });
55
+ /** What the reader picked and opened. */
56
+ selection = new GraphSelection({
57
+ model: () => this.#model,
58
+ routedEdges: () => this.#result.edges,
59
+ onselect: () => this.config.onselect
60
+ });
61
+ /** The model as normalised — every group AND every member. */
62
+ #canonical = $derived(normalizeGraph(this.config.nodes, this.config.edges, this.config.fields));
63
+ /** Which groups are collapsed (#166). */
64
+ groups = new GraphGroups({
65
+ model: () => this.#canonical,
66
+ config: this.config
67
+ });
68
+ /** What is drawn: the canonical model with each collapsed group standing in for its members. */
69
+ #model = $derived(condense(this.#canonical, this.groups.collapsed));
70
+ #layoutFn = $derived(typeof this.config.layout === 'function' ? this.config.layout : (layouts[this.config.layout] ?? layouts.cluster));
118
71
  #result = $derived(this.#layoutFn(this.#model, {
119
- density: this.#density,
120
- arrange: this.#arrange,
121
- groupBy: this.#groupBy,
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,
131
- edgeStyle: this.#edgeStyle,
132
- focus: this.#focus ?? this.#value,
133
- expanded: this.#expanded
72
+ density: this.config.density,
73
+ arrange: this.config.arrange,
74
+ groupBy: this.config.groupBy,
75
+ nestBy: this.config.nestBy ?? undefined,
76
+ sizeBy: this.config.sizeBy,
77
+ sizeScale: this.config.sizeScale,
78
+ depth: this.config.depth,
79
+ focusPath: this.config.focusPath,
80
+ levels: this.config.levels,
81
+ radialMode: this.config.radialMode,
82
+ root: this.config.root,
83
+ bundleTension: this.config.bundleTension,
84
+ showEdges: this.config.showEdges,
85
+ layerLabels: this.config.layerLabels,
86
+ widthBy: this.config.widthBy,
87
+ heightBy: this.config.heightBy,
88
+ colorBy: this.config.colorBy,
89
+ above: this.config.above,
90
+ shadeBy: this.config.shadeBy,
91
+ edgeStyle: this.config.edgeStyle,
92
+ focus: this.config.focus ?? this.selection.value,
93
+ expanded: this.selection.expanded
134
94
  }));
95
+ /**
96
+ * Overlay edges routed over the finished layout. The layout never saw them — that is the
97
+ * point — so they are routed here, against whatever cards it placed. A layout with no cards
98
+ * (the treemap, the sunburst) simply has nothing to route them between.
99
+ */
100
+ #overlayRouted = $derived(buildEdges(this.#model.overlays, this.#result.cards));
101
+ #routed = $derived(this.#overlayRouted.length > 0
102
+ ? [...this.#result.edges, ...this.#overlayRouted]
103
+ : this.#result.edges);
104
+ /** Heaviest weight across every edge that carries one — the 1 that `edgeWeight` scales to. */
105
+ #maxEdgeWeight = $derived(Math.max(0, ...[...this.#model.edges, ...this.#model.overlays].map((e) => e.weight ?? 0)));
135
106
  // Duplicates are left in on purpose: `resolveGroupStyles` de-duplicates and sorts, because
136
107
  // that is where ramp assignment lives. A Set here would be a second, redundant de-dup.
137
108
  #groups = $derived(this.#model.nodes.map((n) => n.group).filter((g) => Boolean(g)));
138
- #groupStyles = $derived(resolveGroupStyles(this.#groups, this.#mode, this.#preset));
139
- #related = $derived(this.#value
140
- ? new SvelteSet(this.#model.neighbors.get(this.#value) ?? [])
141
- : new SvelteSet());
142
- #entities = $derived(this.#model.nodes.map((node) => ({
143
- id: node.id,
144
- label: node.label,
145
- group: node.group,
146
- kind: node.kind,
147
- rowCount: node.rows.length,
148
- refCount: this.#model.edges.filter((e) => e.source === node.id || e.target === node.id)
149
- .length,
150
- note: node.note
151
- })));
152
- #entity = $derived(this.#value ? (this.#model.byId.get(this.#value) ?? null) : null);
153
- /**
154
- * Derived from `#model.edges` — the canonical, unfiltered model — NOT from `#result.edges`.
155
- *
156
- * A layout filters: `cluster` drops edges whose endpoints were not laid out, and
157
- * `neighborhood` lays out only the focus node's 1-hop neighbourhood. Since `focus` and
158
- * `value` are independent config fields, reading the layout's edges lets the state
159
- * contradict itself: with `focus: 'audit.log'` (no neighbours) and `value: 'public.orders'`,
160
- * `relationships` would be `[]` while `entities`' `refCount` for the same node is 1.
161
- *
162
- * Routed geometry is attached opportunistically — it exists only for edges the active layout
163
- * actually placed, so `routed` is optional on `Relationship`. A relationship is a fact about
164
- * the model; its geometry is a fact about the current view.
165
- */
166
- #relationships = $derived.by(() => {
167
- const id = this.#value;
168
- if (!id)
169
- return [];
170
- // A plain Map on purpose: a call-local lookup built once from an already-derived array
171
- // and discarded when this derivation re-runs. Nothing outside can observe it, so
172
- // SvelteMap would add proxying for no reactivity.
173
- // eslint-disable-next-line svelte/prefer-svelte-reactivity
174
- const routed = new Map(this.#result.edges.map((e) => [e.id, e]));
175
- const describe = (other, direction, edge) => {
176
- // normalizeGraph drops any edge whose endpoints do not resolve, so both ends of
177
- // every edge in #model.edges are in byId. No `?? other` fallback here: an
178
- // unreachable branch cannot be tested, and the package's 100% statement bar
179
- // exists to keep that honest.
180
- const node = this.#model.byId.get(other);
181
- return {
182
- direction,
183
- id: other,
184
- label: node.label,
185
- group: node.group,
186
- edge,
187
- routed: routed.get(edge.id)
188
- };
189
- };
190
- return this.#model.edges.flatMap((edge) => describeEdge(edge, id, describe));
191
- });
109
+ #groupStyles = $derived(resolveGroupStyles(this.#groups, this.config.mode, this.config.preset));
110
+ #entities = $derived(entityRows(this.#model));
192
111
  constructor(config = {}) {
193
112
  this.update(config);
194
113
  }
@@ -200,13 +119,16 @@ export class GraphState {
200
119
  * field by field.
201
120
  */
202
121
  update(config = {}) {
203
- this.#last = config;
204
- this.#applyData(config);
205
- this.#applyView(config);
206
- // `value` is input AND output, so it is only adopted when the caller supplies
207
- // one — otherwise a re-render would wipe a selection the user just made.
122
+ this.config.update(config);
123
+ this.#adoptValue(config);
124
+ }
125
+ /**
126
+ * `value` is input AND output, so it is only adopted when the caller supplies one —
127
+ * otherwise a re-render would wipe a selection the user just made.
128
+ */
129
+ #adoptValue(config) {
208
130
  if (config.value !== undefined)
209
- this.#value = config.value;
131
+ this.selection.adopt(config.value);
210
132
  }
211
133
  /**
212
134
  * Merge a partial config, leaving every key it does not name alone.
@@ -217,85 +139,102 @@ export class GraphState {
217
139
  * owner's data on every keystroke. This merges over the last full config instead.
218
140
  */
219
141
  apply(config) {
220
- this.update({ ...this.#last, ...config });
221
- }
222
- #applyData(config) {
223
- this.#nodes = config.nodes ?? [];
224
- this.#edges = config.edges ?? [];
225
- this.#fields = config.fields ?? {};
226
- this.#layout = config.layout ?? 'flow';
227
- this.#focus = config.focus ?? null;
228
- }
229
- /** How the boxes are organised — one axis, or one subdivided by a second. */
230
- #applyGrouping(config) {
231
- const outer = config.groupBy ?? 'group';
232
- this.#groupBy = outer;
233
- // Rejected rather than rendered: a box subdivided by its OWN axis yields exactly one
234
- // child containing everything, which reads as a rendering fault, not a no-op.
235
- this.#nestBy = config.nestBy === outer ? null : (config.nestBy ?? null);
236
- this.#groupTint = config.groupTint ?? false;
237
- }
238
- /** What a node's size encodes, and how far a neighbourhood reaches. */
239
- #applyMeasure(config) {
240
- this.#sizeBy = config.sizeBy ?? 'degree';
241
- this.#sizeScale = config.sizeScale ?? 'linear';
242
- // Floored at 1: a depth of 0 or -1 is a request for nothing, and returning an empty
243
- // canvas for it looks identical to a broken focus.
244
- this.#depth = Math.max(1, Math.floor(config.depth ?? 1));
245
- this.#focusPath = config.focusPath ?? [];
246
- this.#levels = floorLevels(config.levels);
247
- this.#root = config.root ?? null;
248
- this.#bundleTension = config.bundleTension;
249
- this.#radialMode = config.radialMode ?? 'tree';
250
- }
251
- #applyView(config) {
252
- this.#density = config.density ?? 'keys';
253
- this.#arrange = config.arrange ?? 'untangle';
254
- this.#applyGrouping(config);
255
- this.#applyMeasure(config);
256
- this.#edgeStyle = config.edgeStyle ?? 'curved';
257
- this.#preset = config.preset ?? defaultGraphPreset;
258
- this.#mode = config.mode ?? 'light';
259
- this.#label = config.label;
260
- this.#onselect = config.onselect;
142
+ this.#adoptValue(this.config.apply(config));
261
143
  }
262
144
  // ─── transitions ───────────────────────────────────────────────────────────
263
145
  select(id) {
264
- this.#value = id;
265
- this.#onselect?.(id);
146
+ this.selection.select(id);
147
+ }
148
+ /** Drop the selection and report `null` — see `GraphSelection.clear`. */
149
+ clear() {
150
+ this.selection.clear();
151
+ }
152
+ /** Show one node's rows in full, whatever the density says — see `GraphSelection`. */
153
+ toggleExpanded(id) {
154
+ this.selection.toggleExpanded(id);
155
+ }
156
+ isExpanded(id) {
157
+ return this.selection.isExpanded(id);
158
+ }
159
+ // ─── groups (#166) — see GraphGroups ────────────────────────────────────────
160
+ isGroup(id) {
161
+ return this.groups.isGroup(id);
162
+ }
163
+ isCollapsed(id) {
164
+ return this.groups.isCollapsed(id);
165
+ }
166
+ memberCount(id) {
167
+ return this.groups.memberCount(id);
168
+ }
169
+ groupOf(id) {
170
+ return this.groups.groupOf(id);
266
171
  }
267
172
  /**
268
- * Drop the selection, and SAY SO.
269
- *
270
- * Found by dbd consuming the package: it owns `selected` in its route and branches on it
271
- * to show the entity panel, so a silent clear left that panel open over nothing. A
272
- * controlled consumer cannot observe an internal `#value` — the callback is the only
273
- * channel, which is why it carries `null` rather than being skipped.
274
- *
275
- * Guarded on an actual change, or a background click on an already-empty canvas
276
- * round-trips through the consumer's setter on every stray click.
173
+ * Expand or collapse a group. Collapsing one whose member is selected moves the selection to
174
+ * the group: the thing being looked at is now inside it, and a selection naming a hidden node
175
+ * would leave the entity panel empty for no visible reason.
277
176
  */
278
- clear() {
279
- if (this.#value === null)
280
- return;
281
- this.#value = null;
282
- this.#onselect?.(null);
177
+ toggleGroup(id) {
178
+ const collapsing = this.groups.isGroup(id) && !this.groups.isCollapsed(id);
179
+ const selected = this.value;
180
+ const holdsSelection = collapsing && selected !== null && this.groups.groupOf(selected) === id;
181
+ if (!this.groups.toggle(id))
182
+ return false;
183
+ if (holdsSelection)
184
+ this.select(id);
185
+ return true;
186
+ }
187
+ /** The selection's group action — what a drill bar offers. */
188
+ get groupAction() {
189
+ return this.groups.actionFor(this.value);
190
+ }
191
+ // ─── drilling (#165) — see GraphDrill ───────────────────────────────────────
192
+ get drillPath() {
193
+ return this.drill.path;
194
+ }
195
+ get breadcrumbs() {
196
+ return this.drill.breadcrumbs;
197
+ }
198
+ /** A drill's handler returned a promise that has not settled. */
199
+ get pending() {
200
+ return this.drill.pending;
201
+ }
202
+ get drillError() {
203
+ return this.drill.error;
204
+ }
205
+ canDrill(box) {
206
+ return this.drill.canDrill(box);
207
+ }
208
+ drillInto(box) {
209
+ return this.drill.drillInto(box);
210
+ }
211
+ drillOut(levels = 1) {
212
+ return this.drill.drillOut(levels);
213
+ }
214
+ drillTo(path) {
215
+ return this.drill.drillTo(path);
283
216
  }
284
217
  /**
285
- * Show one node's rows in full, whatever the density says.
286
- *
287
- * Per NODE rather than a global density change: a reader who clicks "+3 more" on one
288
- * card is asking about that card, and expanding all of them answers a question they did
289
- * not ask — on a large diagram it also relayouts everything under them.
218
+ * The selected box, when it can be opened — what a drill bar's "Open" acts on. The keyboard
219
+ * route into a leaf: Enter selects it, and this is the next step.
290
220
  */
291
- toggleExpanded(id) {
292
- if (this.#expanded.has(id))
293
- this.#expanded.delete(id);
294
- else
295
- this.#expanded.add(id);
221
+ /** The failed drill, worded for the reader; null when the last drill did not fail. */
222
+ get drillErrorText() {
223
+ return drillErrorText(this.drillError);
296
224
  }
297
- isExpanded(id) {
298
- return this.#expanded.has(id);
225
+ get drillTarget() {
226
+ const value = this.value;
227
+ if (!value)
228
+ return null;
229
+ return this.clusters.find((c) => c.nodeId === value && this.canDrill(c)) ?? null;
230
+ }
231
+ /** Whether a drill bar has anything to show: a trail, a load, a failure, or a box to open. */
232
+ get showsDrillBar() {
233
+ return (this.drillPath.length > 0 ||
234
+ this.pending ||
235
+ this.drillError !== null ||
236
+ this.drillTarget !== null ||
237
+ this.groupAction !== null);
299
238
  }
300
239
  /**
301
240
  * The more-row's text, or null when the card is hiding nothing and needs no control.
@@ -310,14 +249,7 @@ export class GraphState {
310
249
  * answer to what was asked, not a filter that matched nothing.
311
250
  */
312
251
  moreLabel(id) {
313
- if (this.#expanded.has(id))
314
- return 'show less';
315
- const card = this.cards[id];
316
- if (!card || card.more <= 0)
317
- return null;
318
- if (this.#density !== 'keys' || card.vis.length > 0)
319
- return `+ ${card.more} more`;
320
- return `no keys · ${card.more} ${card.more === 1 ? 'row' : 'rows'}`;
252
+ return moreRowsLabel(this.selection.isExpanded(id), this.cards[id], this.config.density);
321
253
  }
322
254
  /**
323
255
  * Change the detail level from inside the component.
@@ -327,25 +259,40 @@ export class GraphState {
327
259
  * prop would render, click, and change nothing.
328
260
  */
329
261
  setDensity(density) {
330
- this.#density = density;
262
+ this.config.setDensity(density);
263
+ }
264
+ /** Every edge or only the climbing ones — what `ViolationsControl` drives (#167). */
265
+ setShowEdges(value) {
266
+ this.config.setShowEdges(value);
267
+ }
268
+ get showEdges() {
269
+ return this.config.showEdges;
331
270
  }
332
271
  // ─── per-item lookups the templates need ───────────────────────────────────
272
+ /**
273
+ * In `neighborhood`, the selection IS the focus (it defaults to it), and the canvas is built
274
+ * from adjacency to the focus — so every card is "related" and every edge "highlight" by
275
+ * construction, and ring 2 is "dim" for being the ring the reader asked for (#172). Those
276
+ * states would say nothing, so they are withheld; a selected NEIGHBOUR still lights its own.
277
+ */
278
+ get #selectingFocus() {
279
+ const value = this.value;
280
+ return this.layoutName === 'neighborhood' && value !== null && (this.config.focus ?? value) === value;
281
+ }
333
282
  nodeState(id) {
334
- if (!this.#value)
335
- return null;
336
- if (id === this.#value)
337
- return 'selected';
338
- return this.#related.has(id) ? 'related' : 'dim';
283
+ if (this.#selectingFocus)
284
+ return id === this.value ? 'selected' : null;
285
+ return this.selection.nodeState(id);
339
286
  }
340
287
  edgeState(edge) {
341
- if (!this.#value)
288
+ if (this.#selectingFocus)
342
289
  return null;
343
- return edge.fromKey === this.#value || edge.toKey === this.#value ? 'highlight' : 'dim';
290
+ return this.selection.edgeState(edge);
344
291
  }
345
292
  edgePath(edge) {
346
293
  // A layout that knows more than the endpoints builds its own — a bundled edge follows
347
294
  // the whole chain of ancestors between them, which `edgePath` never sees.
348
- return edge.path ?? edgePath(edge, this.#edgeStyle);
295
+ return edge.path ?? edgePath(edge, this.config.edgeStyle);
349
296
  }
350
297
  groupStyle(group) {
351
298
  return (group && this.#groupStyles.get(group)) || {};
@@ -357,6 +304,35 @@ export class GraphState {
357
304
  * Doing that inline put the same map/join in two places in `Graph.svelte` — a derivation
358
305
  * living in a component, which is the one thing this layer exists to prevent.
359
306
  */
307
+ /** Whether shading is on: a measure is named, and the active layout reads it (#164). */
308
+ get #shading() {
309
+ return Boolean(this.config.shadeBy) && appliesTo(this.layoutName, 'shadeBy');
310
+ }
311
+ #shaded(cluster) {
312
+ return this.#shading && cluster.shade !== undefined;
313
+ }
314
+ /**
315
+ * A box's paint as a style attribute: the shade ramp's step for its share when shading
316
+ * (#164), else its group's colour. Custom properties, so a theme still has the last word.
317
+ */
318
+ boxStyleAttr(cluster) {
319
+ if (!this.#shaded(cluster))
320
+ return this.groupStyleAttr(cluster.ramp ?? cluster.name);
321
+ return Object.entries(resolveShade(cluster.shade, this.config.mode, this.config.preset))
322
+ .map(([property, value]) => `${property}:${value}`)
323
+ .join(';');
324
+ }
325
+ /** What a legend says about the shade: the measure and the ramp's two ends, or null. */
326
+ get shadeLegend() {
327
+ if (!this.#shading)
328
+ return null;
329
+ const { mode, preset } = this.config;
330
+ return {
331
+ measure: this.config.shadeBy,
332
+ low: resolveShade(0, mode, preset)['--group-fill'],
333
+ high: resolveShade(1, mode, preset)['--group-fill']
334
+ };
335
+ }
360
336
  groupStyleAttr(group) {
361
337
  return Object.entries(this.groupStyle(group))
362
338
  .map(([property, value]) => `${property}:${value}`)
@@ -385,8 +361,26 @@ export class GraphState {
385
361
  get cards() {
386
362
  return this.#result.cards;
387
363
  }
364
+ /** Structural edges as the layout routed them, then any overlay edges on top. */
388
365
  get routedEdges() {
389
- return this.#result.edges;
366
+ return this.#routed;
367
+ }
368
+ /** Every overlay edge in the model, placed or not. */
369
+ get overlayEdges() {
370
+ return this.#model.overlays;
371
+ }
372
+ /**
373
+ * An edge's weight as 0..1 of the heaviest weighted edge, for stroke width — or undefined
374
+ * for an edge with no weight, which draws at the theme's normal width. A layout's own
375
+ * per-set `strength` wins.
376
+ */
377
+ edgeWeight(edge) {
378
+ // A layout that scales each set to its own heaviest (`arcs`) has already said.
379
+ if (edge.strength !== undefined)
380
+ return edge.strength;
381
+ if (edge.weight === undefined || this.#maxEdgeWeight <= 0)
382
+ return undefined;
383
+ return edge.weight / this.#maxEdgeWeight;
390
384
  }
391
385
  get size() {
392
386
  return this.#result.size;
@@ -402,32 +396,19 @@ export class GraphState {
402
396
  * stay in the component. Keeping this here is what lets it be tested without a renderer.
403
397
  */
404
398
  get contentSize() {
405
- const clusters = this.#result.clusters.map((c) => ({
406
- x: c.x,
407
- y: c.y,
408
- w: c.w ?? 0,
409
- h: c.h ?? 0
410
- }));
411
- let { w, h } = extentOf(clusters);
412
- // An ungrouped layout (neighborhood) reports no clusters, so fall back to the cards.
413
- if (!w || !h) {
414
- const cards = extentOf(Object.values(this.#result.cards));
415
- w = Math.max(w, cards.w);
416
- h = Math.max(h, cards.h);
417
- }
418
- return { w: w || this.#result.size.w, h: h || this.#result.size.h };
399
+ return contentExtent(this.#result.clusters, this.#result.cards, this.#result.size);
419
400
  }
420
401
  get related() {
421
- return this.#related;
402
+ return this.selection.related;
422
403
  }
423
404
  get entities() {
424
405
  return this.#entities;
425
406
  }
426
407
  get entity() {
427
- return this.#entity;
408
+ return this.selection.entity;
428
409
  }
429
410
  get relationships() {
430
- return this.#relationships;
411
+ return this.selection.relationships;
431
412
  }
432
413
  /**
433
414
  * Edges the field map could not place at one or both ends.
@@ -441,19 +422,11 @@ export class GraphState {
441
422
  return this.#model.edges.filter((e) => e.unplaced !== undefined);
442
423
  }
443
424
  get density() {
444
- return this.#density;
425
+ return this.config.density;
445
426
  }
446
427
  get arrange() {
447
- return this.#arrange;
428
+ return this.config.arrange;
448
429
  }
449
- /**
450
- * A cluster's unique render key.
451
- *
452
- * NOT the name: nesting puts a `table` box under `public` AND under `billing`, and keying
453
- * an `{#each}` by name alone is a duplicate key — Svelte throws `each_key_duplicate`, the
454
- * render aborts, and no inner box appears at all, which reads as nesting silently not
455
- * working rather than as an error.
456
- */
457
430
  /**
458
431
  * The SVG path for a wedge-shaped box, centred on the canvas.
459
432
  *
@@ -473,18 +446,148 @@ export class GraphState {
473
446
  */
474
447
  boxAttrs(cluster) {
475
448
  return {
449
+ ...this.#boxIntent(cluster),
450
+ // #164: painted from the shade ramp rather than its group's colour.
451
+ 'data-graph-shaded': flag(this.#shaded(cluster)),
476
452
  'data-cluster-depth': String(cluster.depth ?? 0),
453
+ // polymetric (#168): the channels a box could not show — missing data, or past the cap.
454
+ 'data-graph-missing': cluster.missing?.join(' '),
455
+ 'data-graph-clamped': cluster.clamped?.join(' '),
477
456
  'data-node-group': cluster.name,
478
457
  'data-graph-node-id': cluster.nodeId,
479
458
  'data-node-kind': cluster.kind,
480
459
  'data-node-state': cluster.nodeId ? this.nodeState(cluster.nodeId) : undefined
481
460
  };
482
461
  }
462
+ /**
463
+ * What a press and a double-click on a box mean (see `state/intents`): a leaf selects, and
464
+ * opens into its level; a container that can be drilled opens on press; a region does
465
+ * neither. The label says so for assistive tech.
466
+ */
467
+ #boxIntent(cluster) {
468
+ if (cluster.nodeId) {
469
+ const key = cluster.nodeId;
470
+ return { 'data-graph-press': 'select', 'data-graph-open': 'drill', 'data-graph-key': key, 'aria-label': this.caption(cluster) };
471
+ }
472
+ if (!this.canDrill(cluster))
473
+ return {};
474
+ return {
475
+ 'data-graph-drillable': '',
476
+ 'data-graph-press': 'drill',
477
+ 'data-graph-key': this.clusterKey(cluster),
478
+ 'aria-label': say('open', { name: this.caption(cluster) })
479
+ };
480
+ }
481
+ /** Whether a box takes the pointer and the keyboard: it has an intent. */
482
+ interactive(cluster) {
483
+ return Boolean(cluster.nodeId) || this.canDrill(cluster);
484
+ }
485
+ /** A box's visible caption: its name, and the layout's caption or its count. */
486
+ caption(cluster) {
487
+ return `${cluster.name} · ${cluster.caption ?? cluster.count}`;
488
+ }
489
+ /**
490
+ * A node card's hooks and intents: it selects on press, and folds or opens its group on open
491
+ * (#166). A card with nothing below its head — no rows shown, none to reveal, not a group —
492
+ * is marked head-only so the theme can drop the empty body.
493
+ */
494
+ cardAttrs(key) {
495
+ return {
496
+ ...this.#cardNodeAttrs(key),
497
+ ...this.#cardGroupAttrs(key),
498
+ 'data-graph-press': 'select',
499
+ 'data-graph-open': 'group',
500
+ 'data-graph-key': key
501
+ };
502
+ }
503
+ #cardNodeAttrs(key) {
504
+ const card = this.cards[key];
505
+ const headOnly = card !== undefined && card.vis.length === 0 && card.more <= 0 && !this.isGroup(key);
506
+ return {
507
+ 'data-node-kind': card?.node.kind,
508
+ 'data-node-group': card?.node.group,
509
+ 'data-node-state': this.nodeState(key) ?? undefined,
510
+ 'data-node-headonly': flag(headOnly)
511
+ };
512
+ }
513
+ #cardGroupAttrs(key) {
514
+ return {
515
+ 'data-graph-group': flag(this.isGroup(key)),
516
+ 'data-graph-collapsed': flag(this.isCollapsed(key)),
517
+ 'data-graph-member-of': this.groupOf(key) ?? undefined
518
+ };
519
+ }
520
+ /** What a card's head counts: a group, the members it stands for; anything else, its rows. */
521
+ cardCount(key) {
522
+ return this.isGroup(key) ? this.memberCount(key) : (this.cards[key]?.node.rows.length ?? 0);
523
+ }
524
+ /** The "more rows" control's hooks: it expands the card, and says when a filter emptied it. */
525
+ moreAttrs(key) {
526
+ const expanded = this.isExpanded(key);
527
+ return {
528
+ 'data-graph-press': 'expand',
529
+ 'data-graph-key': key,
530
+ 'data-graph-more-empty': flag(this.cards[key]?.vis.length === 0 && !expanded),
531
+ 'data-expanded': flag(expanded)
532
+ };
533
+ }
534
+ /**
535
+ * The box a `data-graph-key` names, if it is on the canvas: a container by its render key,
536
+ * a leaf by its node id — the one key a leaf carries for both its press and its open.
537
+ */
538
+ boxFor(key) {
539
+ return this.clusters.find((cluster) => cluster.nodeId === key || this.clusterKey(cluster) === key);
540
+ }
541
+ /**
542
+ * Perform an intent read off the DOM (see `state/intents`). An unknown intent, or a key
543
+ * that names nothing, does nothing — the DOM is not a trusted source.
544
+ */
545
+ act(intent, key) {
546
+ if (!isIntent(intent))
547
+ return;
548
+ INTENTS[intent](this, key ?? '');
549
+ }
550
+ /**
551
+ * A cluster's unique render key.
552
+ *
553
+ * NOT the name: nesting puts a `table` box under `public` AND under `billing`, and keying
554
+ * an `{#each}` by name alone is a duplicate key — Svelte throws `each_key_duplicate`, the
555
+ * render aborts, and no inner box appears at all, which reads as nesting silently not
556
+ * working rather than as an error.
557
+ */
483
558
  clusterKey(cluster) {
559
+ // A containment box's PATH is its address, unique by construction; the parent's label is
560
+ // not — `lib/marks` under `geoms` and under `src` share depth, parent label and name.
561
+ // A pathless box (an orphan node at the root, or a group box) keeps the label key.
562
+ if (cluster.path && cluster.path.length > 0)
563
+ return `path:${cluster.path.join('/')}`;
484
564
  return `${cluster.depth ?? 0}:${cluster.parent ?? ''}:${cluster.name}`;
485
565
  }
566
+ /** `polymetric` — which measure is on which channel, and each one's cap; for the legend. */
567
+ get channels() {
568
+ return this.#result.channels;
569
+ }
570
+ /** The polymetric legend's rows, labelled from the locale (#168). */
571
+ get channelRows() {
572
+ return channelRows(this.#result.channels);
573
+ }
574
+ /** What each side of an arc diagram shows, named from the data or the locale (#169). */
575
+ get sideNames() {
576
+ return sideNames([...this.#model.edges, ...this.#model.overlays], this.config.above);
577
+ }
578
+ /**
579
+ * Every measure a channel could bind: the keys in the nodes' `measures`, plus `weight` and
580
+ * `degree`, which every node has an answer for. Sorted, so a picker does not reshuffle.
581
+ */
582
+ get measureKeys() {
583
+ return unique([
584
+ 'degree',
585
+ 'weight',
586
+ ...this.#canonical.nodes.flatMap((node) => Object.keys(node.measures ?? {}))
587
+ ]);
588
+ }
486
589
  get groupBy() {
487
- return this.#groupBy;
590
+ return this.config.groupBy;
488
591
  }
489
592
  /**
490
593
  * The node kinds actually present, sorted.
@@ -509,32 +612,32 @@ export class GraphState {
509
612
  get groupsPresent() {
510
613
  return unique(this.#model.nodes.map((n) => n.group));
511
614
  }
512
- /** Whether cards carry the group ramp themselves. See `GraphStateConfig.groupTint`. */
513
615
  /** `dot` or `card` — what the active layout draws a node as. */
514
616
  get nodeShape() {
515
617
  return nodeShapeOf(this.layoutName);
516
618
  }
619
+ /** Whether cards carry the group ramp themselves. See `GraphStateConfig.groupTint`. */
517
620
  get groupTint() {
518
- return this.#groupTint;
621
+ return this.config.groupTint;
519
622
  }
520
623
  get nestBy() {
521
- return this.#nestBy;
624
+ return this.config.nestBy;
522
625
  }
523
626
  get sizeBy() {
524
- return this.#sizeBy;
627
+ return this.config.sizeBy;
525
628
  }
526
629
  get sizeScale() {
527
- return this.#sizeScale;
630
+ return this.config.sizeScale;
528
631
  }
529
632
  get depth() {
530
- return this.#depth;
633
+ return this.config.depth;
531
634
  }
532
635
  get focusPath() {
533
- return this.#focusPath;
636
+ return this.config.focusPath;
534
637
  }
535
638
  /** The configured cap, or the package default of 2 when none was set. */
536
639
  get levels() {
537
- return this.#levels ?? 2;
640
+ return this.config.levels ?? 2;
538
641
  }
539
642
  /**
540
643
  * Column headings for a multi-column layout, empty for the grid ones.
@@ -558,24 +661,22 @@ export class GraphState {
558
661
  * a rendering fault rather than a no-op.
559
662
  */
560
663
  setGrouping(outer, inner = null) {
561
- this.#groupBy = outer;
562
- this.#nestBy = inner === outer ? null : inner;
664
+ this.config.setGrouping(outer, inner);
563
665
  }
564
666
  get edgeStyle() {
565
- return this.#edgeStyle;
667
+ return this.config.edgeStyle;
566
668
  }
567
669
  get mode() {
568
- return this.#mode;
670
+ return this.config.mode;
569
671
  }
570
672
  /** The layout's registry key, or 'custom' when a LayoutFn was passed directly. */
571
673
  get layoutName() {
572
- return typeof this.#layout === 'string' ? this.#layout : 'custom';
674
+ return typeof this.config.layout === 'string' ? this.config.layout : 'custom';
573
675
  }
574
676
  get value() {
575
- return this.#value;
677
+ return this.selection.value;
576
678
  }
577
679
  get label() {
578
- return (this.#label ??
579
- `Diagram of ${plural(this.#model.nodes.length, 'node')} and ${plural(this.#model.edges.length, 'relationship')}`);
680
+ return this.config.label ?? diagramLabel(this.#model.nodes.length, this.#model.edges.length);
580
681
  }
581
682
  }