@rokkit/graph 1.8.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 (119) hide show
  1. package/README.md +59 -0
  2. package/dist/Graph.svelte +108 -134
  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 +161 -52
  7. package/dist/GraphState.svelte.js +380 -304
  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 +11 -32
  46. package/dist/diagrams/ErDiagram.svelte +18 -6
  47. package/dist/diagrams/ErDiagram.svelte.d.ts +5 -1
  48. package/dist/diagrams/LayersDiagram.svelte +124 -0
  49. package/dist/diagrams/LayersDiagram.svelte.d.ts +30 -0
  50. package/dist/diagrams/Neighborhood.svelte +26 -6
  51. package/dist/diagrams/Neighborhood.svelte.d.ts +11 -1
  52. package/dist/diagrams/PolymetricTree.svelte +116 -0
  53. package/dist/diagrams/PolymetricTree.svelte.d.ts +28 -0
  54. package/dist/diagrams/StructureDiagram.svelte +38 -8
  55. package/dist/diagrams/StructureDiagram.svelte.d.ts +9 -1
  56. package/dist/diagrams/Sunburst.svelte +44 -8
  57. package/dist/diagrams/Sunburst.svelte.d.ts +12 -2
  58. package/dist/diagrams/Treemap.svelte +44 -8
  59. package/dist/diagrams/Treemap.svelte.d.ts +12 -2
  60. package/dist/index.d.ts +10 -3
  61. package/dist/index.js +9 -2
  62. package/dist/layout/arcs.d.ts +6 -0
  63. package/dist/layout/arcs.js +141 -0
  64. package/dist/layout/cards.js +9 -2
  65. package/dist/layout/edges.d.ts +13 -1
  66. package/dist/layout/edges.js +26 -2
  67. package/dist/layout/extent.d.ts +17 -0
  68. package/dist/layout/extent.js +27 -0
  69. package/dist/layout/flow.js +3 -1
  70. package/dist/layout/index.d.ts +4 -1
  71. package/dist/layout/index.js +17 -2
  72. package/dist/layout/layers.d.ts +6 -0
  73. package/dist/layout/layers.js +180 -0
  74. package/dist/layout/matrix.d.ts +12 -0
  75. package/dist/layout/matrix.js +20 -0
  76. package/dist/layout/neighborhood.js +16 -7
  77. package/dist/layout/options.d.ts +1 -1
  78. package/dist/layout/options.js +16 -3
  79. package/dist/layout/percentile.d.ts +8 -0
  80. package/dist/layout/percentile.js +13 -0
  81. package/dist/layout/polymetric.d.ts +3 -0
  82. package/dist/layout/polymetric.js +221 -0
  83. package/dist/layout/radial.js +3 -1
  84. package/dist/layout/structure.js +7 -1
  85. package/dist/layout/sunburst.js +11 -4
  86. package/dist/layout/types.d.ts +79 -0
  87. package/dist/layout/world.js +11 -4
  88. package/dist/messages.d.ts +14 -0
  89. package/dist/messages.js +17 -0
  90. package/dist/model/condense.d.ts +8 -0
  91. package/dist/model/condense.js +114 -0
  92. package/dist/model/entities.d.ts +12 -0
  93. package/dist/model/entities.js +12 -0
  94. package/dist/model/normalize.d.ts +2 -6
  95. package/dist/model/normalize.js +64 -2
  96. package/dist/model/relationships.d.ts +30 -0
  97. package/dist/model/relationships.js +51 -0
  98. package/dist/model/share.d.ts +9 -0
  99. package/dist/model/share.js +43 -0
  100. package/dist/model/tree.d.ts +7 -1
  101. package/dist/model/tree.js +11 -4
  102. package/dist/preset.d.ts +18 -0
  103. package/dist/preset.js +34 -3
  104. package/dist/schema/EntitiesView.svelte +8 -6
  105. package/dist/schema/EntityView.svelte +13 -10
  106. package/dist/state/GraphConfig.svelte.d.ts +124 -0
  107. package/dist/state/GraphConfig.svelte.js +253 -0
  108. package/dist/state/GraphDrill.svelte.d.ts +52 -0
  109. package/dist/state/GraphDrill.svelte.js +115 -0
  110. package/dist/state/GraphGroups.svelte.d.ts +35 -0
  111. package/dist/state/GraphGroups.svelte.js +73 -0
  112. package/dist/state/GraphSelection.svelte.d.ts +53 -0
  113. package/dist/state/GraphSelection.svelte.js +94 -0
  114. package/dist/state/intents.d.ts +33 -0
  115. package/dist/state/intents.js +30 -0
  116. package/dist/state/text.d.ts +28 -0
  117. package/dist/state/text.js +56 -0
  118. package/dist/types.d.ts +72 -1
  119. package/package.json +3 -2
@@ -2,66 +2,21 @@ import { SvelteSet } from 'svelte/reactivity';
2
2
  import { normalizeGraph } from './model/normalize.js';
3
3
  import { layouts } from './layout/index.js';
4
4
  import { buildEdges, edgePath } from './layout/edges.js';
5
- import { nodeShapeOf } from './layout/options.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,68 +24,73 @@ 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
  }));
135
95
  /**
136
96
  * Overlay edges routed over the finished layout. The layout never saw them — that is the
@@ -146,60 +106,8 @@ export class GraphState {
146
106
  // Duplicates are left in on purpose: `resolveGroupStyles` de-duplicates and sorts, because
147
107
  // that is where ramp assignment lives. A Set here would be a second, redundant de-dup.
148
108
  #groups = $derived(this.#model.nodes.map((n) => n.group).filter((g) => Boolean(g)));
149
- #groupStyles = $derived(resolveGroupStyles(this.#groups, this.#mode, this.#preset));
150
- #related = $derived(this.#value
151
- ? new SvelteSet(this.#model.neighbors.get(this.#value) ?? [])
152
- : new SvelteSet());
153
- #entities = $derived(this.#model.nodes.map((node) => ({
154
- id: node.id,
155
- label: node.label,
156
- group: node.group,
157
- kind: node.kind,
158
- rowCount: node.rows.length,
159
- refCount: this.#model.edges.filter((e) => e.source === node.id || e.target === node.id)
160
- .length,
161
- note: node.note
162
- })));
163
- #entity = $derived(this.#value ? (this.#model.byId.get(this.#value) ?? null) : null);
164
- /**
165
- * Derived from `#model.edges` — the canonical, unfiltered model — NOT from `#result.edges`.
166
- *
167
- * A layout filters: `cluster` drops edges whose endpoints were not laid out, and
168
- * `neighborhood` lays out only the focus node's 1-hop neighbourhood. Since `focus` and
169
- * `value` are independent config fields, reading the layout's edges lets the state
170
- * contradict itself: with `focus: 'audit.log'` (no neighbours) and `value: 'public.orders'`,
171
- * `relationships` would be `[]` while `entities`' `refCount` for the same node is 1.
172
- *
173
- * Routed geometry is attached opportunistically — it exists only for edges the active layout
174
- * actually placed, so `routed` is optional on `Relationship`. A relationship is a fact about
175
- * the model; its geometry is a fact about the current view.
176
- */
177
- #relationships = $derived.by(() => {
178
- const id = this.#value;
179
- if (!id)
180
- return [];
181
- // A plain Map on purpose: a call-local lookup built once from an already-derived array
182
- // and discarded when this derivation re-runs. Nothing outside can observe it, so
183
- // SvelteMap would add proxying for no reactivity.
184
- // eslint-disable-next-line svelte/prefer-svelte-reactivity
185
- const routed = new Map(this.#result.edges.map((e) => [e.id, e]));
186
- const describe = (other, direction, edge) => {
187
- // normalizeGraph drops any edge whose endpoints do not resolve, so both ends of
188
- // every edge in #model.edges are in byId. No `?? other` fallback here: an
189
- // unreachable branch cannot be tested, and the package's 100% statement bar
190
- // exists to keep that honest.
191
- const node = this.#model.byId.get(other);
192
- return {
193
- direction,
194
- id: other,
195
- label: node.label,
196
- group: node.group,
197
- edge,
198
- routed: routed.get(edge.id)
199
- };
200
- };
201
- return this.#model.edges.flatMap((edge) => describeEdge(edge, id, describe));
202
- });
109
+ #groupStyles = $derived(resolveGroupStyles(this.#groups, this.config.mode, this.config.preset));
110
+ #entities = $derived(entityRows(this.#model));
203
111
  constructor(config = {}) {
204
112
  this.update(config);
205
113
  }
@@ -211,13 +119,16 @@ export class GraphState {
211
119
  * field by field.
212
120
  */
213
121
  update(config = {}) {
214
- this.#last = config;
215
- this.#applyData(config);
216
- this.#applyView(config);
217
- // `value` is input AND output, so it is only adopted when the caller supplies
218
- // 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) {
219
130
  if (config.value !== undefined)
220
- this.#value = config.value;
131
+ this.selection.adopt(config.value);
221
132
  }
222
133
  /**
223
134
  * Merge a partial config, leaving every key it does not name alone.
@@ -228,85 +139,102 @@ export class GraphState {
228
139
  * owner's data on every keystroke. This merges over the last full config instead.
229
140
  */
230
141
  apply(config) {
231
- this.update({ ...this.#last, ...config });
232
- }
233
- #applyData(config) {
234
- this.#nodes = config.nodes ?? [];
235
- this.#edges = config.edges ?? [];
236
- this.#fields = config.fields ?? {};
237
- this.#layout = config.layout ?? 'flow';
238
- this.#focus = config.focus ?? null;
239
- }
240
- /** How the boxes are organised — one axis, or one subdivided by a second. */
241
- #applyGrouping(config) {
242
- const outer = config.groupBy ?? 'group';
243
- this.#groupBy = outer;
244
- // Rejected rather than rendered: a box subdivided by its OWN axis yields exactly one
245
- // child containing everything, which reads as a rendering fault, not a no-op.
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';
261
- }
262
- #applyView(config) {
263
- this.#density = config.density ?? 'keys';
264
- this.#arrange = config.arrange ?? 'untangle';
265
- this.#applyGrouping(config);
266
- this.#applyMeasure(config);
267
- this.#edgeStyle = config.edgeStyle ?? 'curved';
268
- this.#preset = config.preset ?? defaultGraphPreset;
269
- this.#mode = config.mode ?? 'light';
270
- this.#label = config.label;
271
- this.#onselect = config.onselect;
142
+ this.#adoptValue(this.config.apply(config));
272
143
  }
273
144
  // ─── transitions ───────────────────────────────────────────────────────────
274
145
  select(id) {
275
- this.#value = id;
276
- 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);
277
171
  }
278
172
  /**
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.
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.
288
176
  */
289
- clear() {
290
- if (this.#value === null)
291
- return;
292
- this.#value = null;
293
- 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);
294
216
  }
295
217
  /**
296
- * Show one node's rows in full, whatever the density says.
297
- *
298
- * Per NODE rather than a global density change: a reader who clicks "+3 more" on one
299
- * card is asking about that card, and expanding all of them answers a question they did
300
- * 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.
301
220
  */
302
- toggleExpanded(id) {
303
- if (this.#expanded.has(id))
304
- this.#expanded.delete(id);
305
- else
306
- 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);
307
224
  }
308
- isExpanded(id) {
309
- 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);
310
238
  }
311
239
  /**
312
240
  * The more-row's text, or null when the card is hiding nothing and needs no control.
@@ -321,14 +249,7 @@ export class GraphState {
321
249
  * answer to what was asked, not a filter that matched nothing.
322
250
  */
323
251
  moreLabel(id) {
324
- if (this.#expanded.has(id))
325
- return 'show less';
326
- const card = this.cards[id];
327
- if (!card || card.more <= 0)
328
- return null;
329
- if (this.#density !== 'keys' || card.vis.length > 0)
330
- return `+ ${card.more} more`;
331
- return `no keys · ${card.more} ${card.more === 1 ? 'row' : 'rows'}`;
252
+ return moreRowsLabel(this.selection.isExpanded(id), this.cards[id], this.config.density);
332
253
  }
333
254
  /**
334
255
  * Change the detail level from inside the component.
@@ -338,25 +259,40 @@ export class GraphState {
338
259
  * prop would render, click, and change nothing.
339
260
  */
340
261
  setDensity(density) {
341
- 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;
342
270
  }
343
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
+ }
344
282
  nodeState(id) {
345
- if (!this.#value)
346
- return null;
347
- if (id === this.#value)
348
- return 'selected';
349
- return this.#related.has(id) ? 'related' : 'dim';
283
+ if (this.#selectingFocus)
284
+ return id === this.value ? 'selected' : null;
285
+ return this.selection.nodeState(id);
350
286
  }
351
287
  edgeState(edge) {
352
- if (!this.#value)
288
+ if (this.#selectingFocus)
353
289
  return null;
354
- return edge.fromKey === this.#value || edge.toKey === this.#value ? 'highlight' : 'dim';
290
+ return this.selection.edgeState(edge);
355
291
  }
356
292
  edgePath(edge) {
357
293
  // A layout that knows more than the endpoints builds its own — a bundled edge follows
358
294
  // the whole chain of ancestors between them, which `edgePath` never sees.
359
- return edge.path ?? edgePath(edge, this.#edgeStyle);
295
+ return edge.path ?? edgePath(edge, this.config.edgeStyle);
360
296
  }
361
297
  groupStyle(group) {
362
298
  return (group && this.#groupStyles.get(group)) || {};
@@ -368,6 +304,35 @@ export class GraphState {
368
304
  * Doing that inline put the same map/join in two places in `Graph.svelte` — a derivation
369
305
  * living in a component, which is the one thing this layer exists to prevent.
370
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
+ }
371
336
  groupStyleAttr(group) {
372
337
  return Object.entries(this.groupStyle(group))
373
338
  .map(([property, value]) => `${property}:${value}`)
@@ -406,9 +371,13 @@ export class GraphState {
406
371
  }
407
372
  /**
408
373
  * 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.
374
+ * for an edge with no weight, which draws at the theme's normal width. A layout's own
375
+ * per-set `strength` wins.
410
376
  */
411
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;
412
381
  if (edge.weight === undefined || this.#maxEdgeWeight <= 0)
413
382
  return undefined;
414
383
  return edge.weight / this.#maxEdgeWeight;
@@ -427,32 +396,19 @@ export class GraphState {
427
396
  * stay in the component. Keeping this here is what lets it be tested without a renderer.
428
397
  */
429
398
  get contentSize() {
430
- const clusters = this.#result.clusters.map((c) => ({
431
- x: c.x,
432
- y: c.y,
433
- w: c.w ?? 0,
434
- h: c.h ?? 0
435
- }));
436
- let { w, h } = extentOf(clusters);
437
- // An ungrouped layout (neighborhood) reports no clusters, so fall back to the cards.
438
- if (!w || !h) {
439
- const cards = extentOf(Object.values(this.#result.cards));
440
- w = Math.max(w, cards.w);
441
- h = Math.max(h, cards.h);
442
- }
443
- 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);
444
400
  }
445
401
  get related() {
446
- return this.#related;
402
+ return this.selection.related;
447
403
  }
448
404
  get entities() {
449
405
  return this.#entities;
450
406
  }
451
407
  get entity() {
452
- return this.#entity;
408
+ return this.selection.entity;
453
409
  }
454
410
  get relationships() {
455
- return this.#relationships;
411
+ return this.selection.relationships;
456
412
  }
457
413
  /**
458
414
  * Edges the field map could not place at one or both ends.
@@ -466,19 +422,11 @@ export class GraphState {
466
422
  return this.#model.edges.filter((e) => e.unplaced !== undefined);
467
423
  }
468
424
  get density() {
469
- return this.#density;
425
+ return this.config.density;
470
426
  }
471
427
  get arrange() {
472
- return this.#arrange;
428
+ return this.config.arrange;
473
429
  }
474
- /**
475
- * A cluster's unique render key.
476
- *
477
- * NOT the name: nesting puts a `table` box under `public` AND under `billing`, and keying
478
- * an `{#each}` by name alone is a duplicate key — Svelte throws `each_key_duplicate`, the
479
- * render aborts, and no inner box appears at all, which reads as nesting silently not
480
- * working rather than as an error.
481
- */
482
430
  /**
483
431
  * The SVG path for a wedge-shaped box, centred on the canvas.
484
432
  *
@@ -498,18 +446,148 @@ export class GraphState {
498
446
  */
499
447
  boxAttrs(cluster) {
500
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)),
501
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(' '),
502
456
  'data-node-group': cluster.name,
503
457
  'data-graph-node-id': cluster.nodeId,
504
458
  'data-node-kind': cluster.kind,
505
459
  'data-node-state': cluster.nodeId ? this.nodeState(cluster.nodeId) : undefined
506
460
  };
507
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
+ */
508
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('/')}`;
509
564
  return `${cluster.depth ?? 0}:${cluster.parent ?? ''}:${cluster.name}`;
510
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
+ }
511
589
  get groupBy() {
512
- return this.#groupBy;
590
+ return this.config.groupBy;
513
591
  }
514
592
  /**
515
593
  * The node kinds actually present, sorted.
@@ -534,32 +612,32 @@ export class GraphState {
534
612
  get groupsPresent() {
535
613
  return unique(this.#model.nodes.map((n) => n.group));
536
614
  }
537
- /** Whether cards carry the group ramp themselves. See `GraphStateConfig.groupTint`. */
538
615
  /** `dot` or `card` — what the active layout draws a node as. */
539
616
  get nodeShape() {
540
617
  return nodeShapeOf(this.layoutName);
541
618
  }
619
+ /** Whether cards carry the group ramp themselves. See `GraphStateConfig.groupTint`. */
542
620
  get groupTint() {
543
- return this.#groupTint;
621
+ return this.config.groupTint;
544
622
  }
545
623
  get nestBy() {
546
- return this.#nestBy;
624
+ return this.config.nestBy;
547
625
  }
548
626
  get sizeBy() {
549
- return this.#sizeBy;
627
+ return this.config.sizeBy;
550
628
  }
551
629
  get sizeScale() {
552
- return this.#sizeScale;
630
+ return this.config.sizeScale;
553
631
  }
554
632
  get depth() {
555
- return this.#depth;
633
+ return this.config.depth;
556
634
  }
557
635
  get focusPath() {
558
- return this.#focusPath;
636
+ return this.config.focusPath;
559
637
  }
560
638
  /** The configured cap, or the package default of 2 when none was set. */
561
639
  get levels() {
562
- return this.#levels ?? 2;
640
+ return this.config.levels ?? 2;
563
641
  }
564
642
  /**
565
643
  * Column headings for a multi-column layout, empty for the grid ones.
@@ -583,24 +661,22 @@ export class GraphState {
583
661
  * a rendering fault rather than a no-op.
584
662
  */
585
663
  setGrouping(outer, inner = null) {
586
- this.#groupBy = outer;
587
- this.#nestBy = inner === outer ? null : inner;
664
+ this.config.setGrouping(outer, inner);
588
665
  }
589
666
  get edgeStyle() {
590
- return this.#edgeStyle;
667
+ return this.config.edgeStyle;
591
668
  }
592
669
  get mode() {
593
- return this.#mode;
670
+ return this.config.mode;
594
671
  }
595
672
  /** The layout's registry key, or 'custom' when a LayoutFn was passed directly. */
596
673
  get layoutName() {
597
- return typeof this.#layout === 'string' ? this.#layout : 'custom';
674
+ return typeof this.config.layout === 'string' ? this.config.layout : 'custom';
598
675
  }
599
676
  get value() {
600
- return this.#value;
677
+ return this.selection.value;
601
678
  }
602
679
  get label() {
603
- return (this.#label ??
604
- `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);
605
681
  }
606
682
  }