@rokkit/graph 1.8.0 → 1.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -0
- package/dist/Graph.svelte +108 -134
- package/dist/Graph.svelte.d.ts +1 -1
- package/dist/GraphLegend.svelte +81 -10
- package/dist/GraphLegend.svelte.d.ts +15 -0
- package/dist/GraphState.svelte.d.ts +161 -52
- package/dist/GraphState.svelte.js +380 -304
- package/dist/actions/canvas.d.ts +18 -0
- package/dist/actions/canvas.js +59 -0
- package/dist/actions/choices.d.ts +12 -0
- package/dist/actions/choices.js +23 -0
- package/dist/actions/interactions.d.ts +20 -0
- package/dist/actions/interactions.js +57 -0
- package/dist/actions/listen.d.ts +2 -0
- package/dist/actions/listen.js +10 -0
- package/dist/canvas/frame.d.ts +41 -0
- package/dist/canvas/frame.js +34 -0
- package/dist/controls/BundleControl.svelte +11 -10
- package/dist/controls/BundleControl.svelte.d.ts +0 -7
- package/dist/controls/DensityControl.svelte +17 -9
- package/dist/controls/DensityControl.svelte.d.ts +0 -8
- package/dist/controls/DepthControl.svelte +11 -4
- package/dist/controls/DepthControl.svelte.d.ts +0 -9
- package/dist/controls/DrillBar.svelte +63 -0
- package/dist/controls/DrillBar.svelte.d.ts +9 -0
- package/dist/controls/EdgeStyleControl.svelte +10 -10
- package/dist/controls/EdgeStyleControl.svelte.d.ts +0 -6
- package/dist/controls/HiddenControl.svelte +27 -0
- package/dist/controls/HiddenControl.svelte.d.ts +7 -0
- package/dist/controls/MeasureControl.svelte +39 -0
- package/dist/controls/MeasureControl.svelte.d.ts +12 -0
- package/dist/controls/ToggleControl.svelte +33 -0
- package/dist/controls/ToggleControl.svelte.d.ts +13 -0
- package/dist/controls/ViolationsControl.svelte +27 -0
- package/dist/controls/ViolationsControl.svelte.d.ts +7 -0
- package/dist/controls/ZoomControl.svelte +21 -18
- package/dist/controls/zoom.d.ts +3 -0
- package/dist/controls/zoom.js +6 -0
- package/dist/diagrams/ArcDiagram.svelte +116 -0
- package/dist/diagrams/ArcDiagram.svelte.d.ts +31 -0
- package/dist/diagrams/CallTree.svelte +17 -5
- package/dist/diagrams/CallTree.svelte.d.ts +5 -1
- package/dist/diagrams/DependencyDiagram.svelte +18 -6
- package/dist/diagrams/DependencyDiagram.svelte.d.ts +5 -1
- package/dist/diagrams/DependencyMatrix.svelte +11 -32
- package/dist/diagrams/ErDiagram.svelte +18 -6
- package/dist/diagrams/ErDiagram.svelte.d.ts +5 -1
- package/dist/diagrams/LayersDiagram.svelte +124 -0
- package/dist/diagrams/LayersDiagram.svelte.d.ts +30 -0
- package/dist/diagrams/Neighborhood.svelte +26 -6
- package/dist/diagrams/Neighborhood.svelte.d.ts +11 -1
- package/dist/diagrams/PolymetricTree.svelte +116 -0
- package/dist/diagrams/PolymetricTree.svelte.d.ts +28 -0
- package/dist/diagrams/StructureDiagram.svelte +38 -8
- package/dist/diagrams/StructureDiagram.svelte.d.ts +9 -1
- package/dist/diagrams/Sunburst.svelte +44 -8
- package/dist/diagrams/Sunburst.svelte.d.ts +12 -2
- package/dist/diagrams/Treemap.svelte +44 -8
- package/dist/diagrams/Treemap.svelte.d.ts +12 -2
- package/dist/index.d.ts +10 -3
- package/dist/index.js +9 -2
- package/dist/layout/arcs.d.ts +6 -0
- package/dist/layout/arcs.js +141 -0
- package/dist/layout/cards.js +9 -2
- package/dist/layout/edges.d.ts +13 -1
- package/dist/layout/edges.js +26 -2
- package/dist/layout/extent.d.ts +17 -0
- package/dist/layout/extent.js +27 -0
- package/dist/layout/flow.js +3 -1
- package/dist/layout/index.d.ts +4 -1
- package/dist/layout/index.js +17 -2
- package/dist/layout/layers.d.ts +6 -0
- package/dist/layout/layers.js +180 -0
- package/dist/layout/matrix.d.ts +12 -0
- package/dist/layout/matrix.js +20 -0
- package/dist/layout/neighborhood.js +16 -7
- package/dist/layout/options.d.ts +1 -1
- package/dist/layout/options.js +16 -3
- package/dist/layout/percentile.d.ts +8 -0
- package/dist/layout/percentile.js +13 -0
- package/dist/layout/polymetric.d.ts +3 -0
- package/dist/layout/polymetric.js +221 -0
- package/dist/layout/radial.js +3 -1
- package/dist/layout/structure.js +7 -1
- package/dist/layout/sunburst.js +11 -4
- package/dist/layout/types.d.ts +79 -0
- package/dist/layout/world.js +11 -4
- package/dist/messages.d.ts +14 -0
- package/dist/messages.js +17 -0
- package/dist/model/condense.d.ts +8 -0
- package/dist/model/condense.js +114 -0
- package/dist/model/entities.d.ts +12 -0
- package/dist/model/entities.js +12 -0
- package/dist/model/normalize.d.ts +2 -6
- package/dist/model/normalize.js +64 -2
- package/dist/model/relationships.d.ts +30 -0
- package/dist/model/relationships.js +51 -0
- package/dist/model/share.d.ts +9 -0
- package/dist/model/share.js +43 -0
- package/dist/model/tree.d.ts +7 -1
- package/dist/model/tree.js +11 -4
- package/dist/preset.d.ts +18 -0
- package/dist/preset.js +34 -3
- package/dist/schema/EntitiesView.svelte +8 -6
- package/dist/schema/EntityView.svelte +13 -10
- package/dist/state/GraphConfig.svelte.d.ts +124 -0
- package/dist/state/GraphConfig.svelte.js +253 -0
- package/dist/state/GraphDrill.svelte.d.ts +52 -0
- package/dist/state/GraphDrill.svelte.js +115 -0
- package/dist/state/GraphGroups.svelte.d.ts +35 -0
- package/dist/state/GraphGroups.svelte.js +73 -0
- package/dist/state/GraphSelection.svelte.d.ts +53 -0
- package/dist/state/GraphSelection.svelte.js +94 -0
- package/dist/state/intents.d.ts +33 -0
- package/dist/state/intents.js +30 -0
- package/dist/state/text.d.ts +28 -0
- package/dist/state/text.js +56 -0
- package/dist/types.d.ts +72 -1
- package/package.json +5 -4
|
@@ -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 {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
#
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
#
|
|
112
|
-
#
|
|
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
|
|
120
|
-
arrange: this
|
|
121
|
-
groupBy: this
|
|
122
|
-
nestBy: this
|
|
123
|
-
sizeBy: this
|
|
124
|
-
sizeScale: this
|
|
125
|
-
depth: this
|
|
126
|
-
focusPath: this
|
|
127
|
-
levels: this
|
|
128
|
-
radialMode: this
|
|
129
|
-
root: this
|
|
130
|
-
bundleTension: this
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
150
|
-
#
|
|
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
|
|
215
|
-
this.#
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
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
|
|
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
|
|
276
|
-
|
|
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
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
this
|
|
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
|
-
*
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
309
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
346
|
-
return null;
|
|
347
|
-
|
|
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 (
|
|
288
|
+
if (this.#selectingFocus)
|
|
353
289
|
return null;
|
|
354
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
408
|
+
return this.selection.entity;
|
|
453
409
|
}
|
|
454
410
|
get relationships() {
|
|
455
|
-
return this
|
|
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
|
|
425
|
+
return this.config.density;
|
|
470
426
|
}
|
|
471
427
|
get arrange() {
|
|
472
|
-
return this
|
|
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
|
|
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
|
|
621
|
+
return this.config.groupTint;
|
|
544
622
|
}
|
|
545
623
|
get nestBy() {
|
|
546
|
-
return this
|
|
624
|
+
return this.config.nestBy;
|
|
547
625
|
}
|
|
548
626
|
get sizeBy() {
|
|
549
|
-
return this
|
|
627
|
+
return this.config.sizeBy;
|
|
550
628
|
}
|
|
551
629
|
get sizeScale() {
|
|
552
|
-
return this
|
|
630
|
+
return this.config.sizeScale;
|
|
553
631
|
}
|
|
554
632
|
get depth() {
|
|
555
|
-
return this
|
|
633
|
+
return this.config.depth;
|
|
556
634
|
}
|
|
557
635
|
get focusPath() {
|
|
558
|
-
return this
|
|
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
|
|
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
|
|
587
|
-
this.#nestBy = inner === outer ? null : inner;
|
|
664
|
+
this.config.setGrouping(outer, inner);
|
|
588
665
|
}
|
|
589
666
|
get edgeStyle() {
|
|
590
|
-
return this
|
|
667
|
+
return this.config.edgeStyle;
|
|
591
668
|
}
|
|
592
669
|
get mode() {
|
|
593
|
-
return this
|
|
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
|
|
674
|
+
return typeof this.config.layout === 'string' ? this.config.layout : 'custom';
|
|
598
675
|
}
|
|
599
676
|
get value() {
|
|
600
|
-
return this
|
|
677
|
+
return this.selection.value;
|
|
601
678
|
}
|
|
602
679
|
get label() {
|
|
603
|
-
return (this.#
|
|
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
|
}
|