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