@rokkit/graph 1.6.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/LICENSE +21 -0
- package/README.md +100 -0
- package/dist/Graph.svelte +369 -0
- package/dist/Graph.svelte.d.ts +4 -0
- package/dist/GraphState.svelte.d.ts +171 -0
- package/dist/GraphState.svelte.js +398 -0
- package/dist/icons.d.ts +13 -0
- package/dist/icons.js +38 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +8 -0
- package/dist/layout/cards.d.ts +19 -0
- package/dist/layout/cards.js +31 -0
- package/dist/layout/cluster.d.ts +11 -0
- package/dist/layout/cluster.js +39 -0
- package/dist/layout/clusters.d.ts +39 -0
- package/dist/layout/clusters.js +217 -0
- package/dist/layout/constants.d.ts +12 -0
- package/dist/layout/constants.js +15 -0
- package/dist/layout/edges.d.ts +5 -0
- package/dist/layout/edges.js +110 -0
- package/dist/layout/index.d.ts +9 -0
- package/dist/layout/index.js +7 -0
- package/dist/layout/neighborhood.d.ts +9 -0
- package/dist/layout/neighborhood.js +123 -0
- package/dist/layout/nested.d.ts +13 -0
- package/dist/layout/nested.js +156 -0
- package/dist/layout/points.d.ts +6 -0
- package/dist/layout/points.js +204 -0
- package/dist/layout/types.d.ts +115 -0
- package/dist/layout/types.js +1 -0
- package/dist/model/normalize.d.ts +7 -0
- package/dist/model/normalize.js +215 -0
- package/dist/model/path.d.ts +13 -0
- package/dist/model/path.js +23 -0
- package/dist/preset.d.ts +47 -0
- package/dist/preset.js +61 -0
- package/dist/schema/EntitiesView.svelte +83 -0
- package/dist/schema/EntitiesView.svelte.d.ts +13 -0
- package/dist/schema/EntityView.svelte +116 -0
- package/dist/schema/EntityView.svelte.d.ts +13 -0
- package/dist/schema/NoteBlocks.svelte +31 -0
- package/dist/schema/NoteBlocks.svelte.d.ts +6 -0
- package/dist/schema/column-type.d.ts +4 -0
- package/dist/schema/column-type.js +19 -0
- package/dist/schema/fromSchemaModel.d.ts +66 -0
- package/dist/schema/fromSchemaModel.js +92 -0
- package/dist/schema/index.d.ts +8 -0
- package/dist/schema/index.js +7 -0
- package/dist/schema/notes.d.ts +12 -0
- package/dist/schema/notes.js +65 -0
- package/dist/types.d.ts +160 -0
- package/dist/types.js +1 -0
- package/package.json +62 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { SvelteSet } from 'svelte/reactivity';
|
|
2
|
+
import { normalizeGraph } from './model/normalize.js';
|
|
3
|
+
import { layouts } from './layout/index.js';
|
|
4
|
+
import { edgePath } from './layout/edges.js';
|
|
5
|
+
import { defaultGraphPreset, resolveGroupStyles } from './preset.js';
|
|
6
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
7
|
+
/** How `id` sits on an edge: not on it, both ends of it, or one end with a far side. */
|
|
8
|
+
function endpointsFor(edge, id) {
|
|
9
|
+
const isSource = edge.source === id;
|
|
10
|
+
const isTarget = edge.target === id;
|
|
11
|
+
if (!isSource && !isTarget)
|
|
12
|
+
return null;
|
|
13
|
+
return {
|
|
14
|
+
self: isSource && isTarget,
|
|
15
|
+
other: isSource ? edge.target : edge.source,
|
|
16
|
+
direction: isSource ? 'out' : 'in'
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* One edge's contribution to the selected node's relationship list.
|
|
21
|
+
*
|
|
22
|
+
* An UNPLACED edge is reported too, with the raw name as the label — the far end is not a
|
|
23
|
+
* node, so there is nothing to look up. Hiding it would be worse: "this references something
|
|
24
|
+
* we have not indexed" is exactly what a reader of a partial graph needs to see.
|
|
25
|
+
*/
|
|
26
|
+
function describeEdge(edge, id, describe) {
|
|
27
|
+
const ends = endpointsFor(edge, id);
|
|
28
|
+
if (!ends)
|
|
29
|
+
return [];
|
|
30
|
+
if (ends.self)
|
|
31
|
+
return [describe(id, 'out', edge)];
|
|
32
|
+
const { other, direction } = ends;
|
|
33
|
+
// An unplaced far end is not a node, so there is nothing to look up: the raw name IS
|
|
34
|
+
// the label. Reported rather than hidden — "references something not indexed" is what a
|
|
35
|
+
// reader of a partial graph most needs to see.
|
|
36
|
+
return edge.unplaced
|
|
37
|
+
? [{ direction, id: other, label: other, edge }]
|
|
38
|
+
: [describe(other, direction, edge)];
|
|
39
|
+
}
|
|
40
|
+
/** Bottom-right extent of a set of boxes, or {0,0} when there are none. */
|
|
41
|
+
function extentOf(boxes) {
|
|
42
|
+
let w = 0;
|
|
43
|
+
let h = 0;
|
|
44
|
+
for (const box of boxes) {
|
|
45
|
+
w = Math.max(w, box.x + box.w);
|
|
46
|
+
h = Math.max(h, box.y + box.h);
|
|
47
|
+
}
|
|
48
|
+
return { w, h };
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The store. Turns raw `nodes`/`edges`/`fields` into the reactive shape the visuals
|
|
52
|
+
* render, and owns every transition.
|
|
53
|
+
*
|
|
54
|
+
* Components read from this and call its methods — they never compute. That is what
|
|
55
|
+
* lets the geometry, badge derivation and selection logic be covered exhaustively
|
|
56
|
+
* with no DOM, and lets the component specs assert only attributes.
|
|
57
|
+
*
|
|
58
|
+
* `update()` is re-callable and FULLY re-applies config rather than merging deltas,
|
|
59
|
+
* matching `SparkState.update` — so a prop reverting to undefined actually reverts.
|
|
60
|
+
* Deliberately NOT `PlotState.update`, which guards each field with
|
|
61
|
+
* `if (config.X !== undefined)` and therefore merges.
|
|
62
|
+
*/
|
|
63
|
+
export class GraphState {
|
|
64
|
+
#nodes = $state([]);
|
|
65
|
+
#edges = $state([]);
|
|
66
|
+
#fields = $state({});
|
|
67
|
+
#layout = $state('cluster');
|
|
68
|
+
#density = $state('keys');
|
|
69
|
+
#arrange = $state('untangle');
|
|
70
|
+
#groupBy = $state('group');
|
|
71
|
+
#nestBy = $state(null);
|
|
72
|
+
#edgeStyle = $state('curved');
|
|
73
|
+
#focus = $state(null);
|
|
74
|
+
#value = $state(null);
|
|
75
|
+
#expanded = new SvelteSet();
|
|
76
|
+
#preset = $state(defaultGraphPreset);
|
|
77
|
+
#mode = $state('light');
|
|
78
|
+
#label = $state(undefined);
|
|
79
|
+
#onselect = $state(undefined);
|
|
80
|
+
#model = $derived(normalizeGraph(this.#nodes, this.#edges, this.#fields));
|
|
81
|
+
#layoutFn = $derived(typeof this.#layout === 'function' ? this.#layout : (layouts[this.#layout] ?? layouts.cluster));
|
|
82
|
+
#result = $derived(this.#layoutFn(this.#model, {
|
|
83
|
+
density: this.#density,
|
|
84
|
+
arrange: this.#arrange,
|
|
85
|
+
groupBy: this.#groupBy,
|
|
86
|
+
nestBy: this.#nestBy ?? undefined,
|
|
87
|
+
edgeStyle: this.#edgeStyle,
|
|
88
|
+
focus: this.#focus ?? this.#value,
|
|
89
|
+
expanded: this.#expanded
|
|
90
|
+
}));
|
|
91
|
+
// Duplicates are left in on purpose: `resolveGroupStyles` de-duplicates and sorts, because
|
|
92
|
+
// that is where ramp assignment lives. A Set here would be a second, redundant de-dup.
|
|
93
|
+
#groups = $derived(this.#model.nodes.map((n) => n.group).filter((g) => Boolean(g)));
|
|
94
|
+
#groupStyles = $derived(resolveGroupStyles(this.#groups, this.#mode, this.#preset));
|
|
95
|
+
#related = $derived(this.#value
|
|
96
|
+
? new SvelteSet(this.#model.neighbors.get(this.#value) ?? [])
|
|
97
|
+
: new SvelteSet());
|
|
98
|
+
#entities = $derived(this.#model.nodes.map((node) => ({
|
|
99
|
+
id: node.id,
|
|
100
|
+
label: node.label,
|
|
101
|
+
group: node.group,
|
|
102
|
+
kind: node.kind,
|
|
103
|
+
rowCount: node.rows.length,
|
|
104
|
+
refCount: this.#model.edges.filter((e) => e.source === node.id || e.target === node.id)
|
|
105
|
+
.length,
|
|
106
|
+
note: node.note
|
|
107
|
+
})));
|
|
108
|
+
#entity = $derived(this.#value ? (this.#model.byId.get(this.#value) ?? null) : null);
|
|
109
|
+
/**
|
|
110
|
+
* Derived from `#model.edges` — the canonical, unfiltered model — NOT from `#result.edges`.
|
|
111
|
+
*
|
|
112
|
+
* A layout filters: `cluster` drops edges whose endpoints were not laid out, and
|
|
113
|
+
* `neighborhood` lays out only the focus node's 1-hop neighbourhood. Since `focus` and
|
|
114
|
+
* `value` are independent config fields, reading the layout's edges lets the state
|
|
115
|
+
* contradict itself: with `focus: 'audit.log'` (no neighbours) and `value: 'public.orders'`,
|
|
116
|
+
* `relationships` would be `[]` while `entities`' `refCount` for the same node is 1.
|
|
117
|
+
*
|
|
118
|
+
* Routed geometry is attached opportunistically — it exists only for edges the active layout
|
|
119
|
+
* actually placed, so `routed` is optional on `Relationship`. A relationship is a fact about
|
|
120
|
+
* the model; its geometry is a fact about the current view.
|
|
121
|
+
*/
|
|
122
|
+
#relationships = $derived.by(() => {
|
|
123
|
+
const id = this.#value;
|
|
124
|
+
if (!id)
|
|
125
|
+
return [];
|
|
126
|
+
// A plain Map on purpose: a call-local lookup built once from an already-derived array
|
|
127
|
+
// and discarded when this derivation re-runs. Nothing outside can observe it, so
|
|
128
|
+
// SvelteMap would add proxying for no reactivity.
|
|
129
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
130
|
+
const routed = new Map(this.#result.edges.map((e) => [e.id, e]));
|
|
131
|
+
const describe = (other, direction, edge) => {
|
|
132
|
+
// normalizeGraph drops any edge whose endpoints do not resolve, so both ends of
|
|
133
|
+
// every edge in #model.edges are in byId. No `?? other` fallback here: an
|
|
134
|
+
// unreachable branch cannot be tested, and the package's 100% statement bar
|
|
135
|
+
// exists to keep that honest.
|
|
136
|
+
const node = this.#model.byId.get(other);
|
|
137
|
+
return {
|
|
138
|
+
direction,
|
|
139
|
+
id: other,
|
|
140
|
+
label: node.label,
|
|
141
|
+
group: node.group,
|
|
142
|
+
edge,
|
|
143
|
+
routed: routed.get(edge.id)
|
|
144
|
+
};
|
|
145
|
+
};
|
|
146
|
+
return this.#model.edges.flatMap((edge) => describeEdge(edge, id, describe));
|
|
147
|
+
});
|
|
148
|
+
constructor(config = {}) {
|
|
149
|
+
this.update(config);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Fully re-applies config. Safe to call on every prop change.
|
|
153
|
+
*
|
|
154
|
+
* Split across two helpers only to stay under the complexity bar — every field is still
|
|
155
|
+
* assigned unconditionally on every call, which is the contract the specs enforce
|
|
156
|
+
* field by field.
|
|
157
|
+
*/
|
|
158
|
+
update(config = {}) {
|
|
159
|
+
this.#applyData(config);
|
|
160
|
+
this.#applyView(config);
|
|
161
|
+
// `value` is input AND output, so it is only adopted when the caller supplies
|
|
162
|
+
// one — otherwise a re-render would wipe a selection the user just made.
|
|
163
|
+
if (config.value !== undefined)
|
|
164
|
+
this.#value = config.value;
|
|
165
|
+
}
|
|
166
|
+
#applyData(config) {
|
|
167
|
+
this.#nodes = config.nodes ?? [];
|
|
168
|
+
this.#edges = config.edges ?? [];
|
|
169
|
+
this.#fields = config.fields ?? {};
|
|
170
|
+
this.#layout = config.layout ?? 'cluster';
|
|
171
|
+
this.#focus = config.focus ?? null;
|
|
172
|
+
}
|
|
173
|
+
/** How the boxes are organised — one axis, or one subdivided by a second. */
|
|
174
|
+
#applyGrouping(config) {
|
|
175
|
+
const outer = config.groupBy ?? 'group';
|
|
176
|
+
this.#groupBy = outer;
|
|
177
|
+
// Rejected rather than rendered: a box subdivided by its OWN axis yields exactly one
|
|
178
|
+
// child containing everything, which reads as a rendering fault, not a no-op.
|
|
179
|
+
this.#nestBy = config.nestBy === outer ? null : (config.nestBy ?? null);
|
|
180
|
+
}
|
|
181
|
+
#applyView(config) {
|
|
182
|
+
this.#density = config.density ?? 'keys';
|
|
183
|
+
this.#arrange = config.arrange ?? 'untangle';
|
|
184
|
+
this.#applyGrouping(config);
|
|
185
|
+
this.#edgeStyle = config.edgeStyle ?? 'curved';
|
|
186
|
+
this.#preset = config.preset ?? defaultGraphPreset;
|
|
187
|
+
this.#mode = config.mode ?? 'light';
|
|
188
|
+
this.#label = config.label;
|
|
189
|
+
this.#onselect = config.onselect;
|
|
190
|
+
}
|
|
191
|
+
// ─── transitions ───────────────────────────────────────────────────────────
|
|
192
|
+
select(id) {
|
|
193
|
+
this.#value = id;
|
|
194
|
+
this.#onselect?.(id);
|
|
195
|
+
}
|
|
196
|
+
clear() {
|
|
197
|
+
this.#value = null;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Show one node's rows in full, whatever the density says.
|
|
201
|
+
*
|
|
202
|
+
* Per NODE rather than a global density change: a reader who clicks "+3 more" on one
|
|
203
|
+
* card is asking about that card, and expanding all of them answers a question they did
|
|
204
|
+
* not ask — on a large diagram it also relayouts everything under them.
|
|
205
|
+
*/
|
|
206
|
+
toggleExpanded(id) {
|
|
207
|
+
if (this.#expanded.has(id))
|
|
208
|
+
this.#expanded.delete(id);
|
|
209
|
+
else
|
|
210
|
+
this.#expanded.add(id);
|
|
211
|
+
}
|
|
212
|
+
isExpanded(id) {
|
|
213
|
+
return this.#expanded.has(id);
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* The more-row's text, or null when the card is hiding nothing and needs no control.
|
|
217
|
+
*
|
|
218
|
+
* The "no keys" case is the whole reason this is a method rather than a template
|
|
219
|
+
* expression. A view, a procedure and an enum have no pk and no fk — correctly, they are
|
|
220
|
+
* not tables — so at 'keys' their cards collapse to a title and "+3 more", which is
|
|
221
|
+
* indistinguishable from a card the reader collapsed and reads as a rendering failure.
|
|
222
|
+
* The count alone cannot say WHY the rows are hidden; naming the empty filter can.
|
|
223
|
+
*
|
|
224
|
+
* Scoped to 'keys' because 'names' hides every row BY DESIGN — an empty card there is the
|
|
225
|
+
* answer to what was asked, not a filter that matched nothing.
|
|
226
|
+
*/
|
|
227
|
+
moreLabel(id) {
|
|
228
|
+
if (this.#expanded.has(id))
|
|
229
|
+
return 'show less';
|
|
230
|
+
const card = this.cards[id];
|
|
231
|
+
if (!card || card.more <= 0)
|
|
232
|
+
return null;
|
|
233
|
+
if (this.#density !== 'keys' || card.vis.length > 0)
|
|
234
|
+
return `+ ${card.more} more`;
|
|
235
|
+
return `no keys · ${card.more} ${card.more === 1 ? 'row' : 'rows'}`;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Change the detail level from inside the component.
|
|
239
|
+
*
|
|
240
|
+
* Needed because `Graph`'s `density` PROP only ever reaches the state it owns. With a
|
|
241
|
+
* caller-supplied state — which is how all three views share one — a control that set the
|
|
242
|
+
* prop would render, click, and change nothing.
|
|
243
|
+
*/
|
|
244
|
+
setDensity(density) {
|
|
245
|
+
this.#density = density;
|
|
246
|
+
}
|
|
247
|
+
// ─── per-item lookups the templates need ───────────────────────────────────
|
|
248
|
+
nodeState(id) {
|
|
249
|
+
if (!this.#value)
|
|
250
|
+
return null;
|
|
251
|
+
if (id === this.#value)
|
|
252
|
+
return 'selected';
|
|
253
|
+
return this.#related.has(id) ? 'related' : 'dim';
|
|
254
|
+
}
|
|
255
|
+
edgeState(edge) {
|
|
256
|
+
if (!this.#value)
|
|
257
|
+
return null;
|
|
258
|
+
return edge.fromKey === this.#value || edge.toKey === this.#value ? 'highlight' : 'dim';
|
|
259
|
+
}
|
|
260
|
+
edgePath(edge) {
|
|
261
|
+
return edgePath(edge, this.#edgeStyle);
|
|
262
|
+
}
|
|
263
|
+
groupStyle(group) {
|
|
264
|
+
return (group && this.#groupStyles.get(group)) || {};
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* The same style as a `style=` attribute string.
|
|
268
|
+
*
|
|
269
|
+
* A template cannot spread an object into `style`, so something has to serialise it.
|
|
270
|
+
* Doing that inline put the same map/join in two places in `Graph.svelte` — a derivation
|
|
271
|
+
* living in a component, which is the one thing this layer exists to prevent.
|
|
272
|
+
*/
|
|
273
|
+
groupStyleAttr(group) {
|
|
274
|
+
return Object.entries(this.groupStyle(group))
|
|
275
|
+
.map(([property, value]) => `${property}:${value}`)
|
|
276
|
+
.join(';');
|
|
277
|
+
}
|
|
278
|
+
// ─── reads ─────────────────────────────────────────────────────────────────
|
|
279
|
+
get model() {
|
|
280
|
+
return this.#model;
|
|
281
|
+
}
|
|
282
|
+
get clusters() {
|
|
283
|
+
return this.#result.clusters;
|
|
284
|
+
}
|
|
285
|
+
get cards() {
|
|
286
|
+
return this.#result.cards;
|
|
287
|
+
}
|
|
288
|
+
get routedEdges() {
|
|
289
|
+
return this.#result.edges;
|
|
290
|
+
}
|
|
291
|
+
get size() {
|
|
292
|
+
return this.#result.size;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* The true content extent, as distinct from `size`.
|
|
296
|
+
*
|
|
297
|
+
* `LayoutResult.size` adds a +60 margin on the right and bottom only, so fitting to it hugs
|
|
298
|
+
* the left/top edge and floats away from the right/bottom. Centring needs the real bounds.
|
|
299
|
+
*
|
|
300
|
+
* This lives in state, not in `Graph.svelte`, because it is a pure max over `clusters` — no
|
|
301
|
+
* viewport involved. Only the `scale`/`tx`/`ty` that consume it need `clientWidth`, and those
|
|
302
|
+
* stay in the component. Keeping this here is what lets it be tested without a renderer.
|
|
303
|
+
*/
|
|
304
|
+
get contentSize() {
|
|
305
|
+
const clusters = this.#result.clusters.map((c) => ({
|
|
306
|
+
x: c.x,
|
|
307
|
+
y: c.y,
|
|
308
|
+
w: c.w ?? 0,
|
|
309
|
+
h: c.h ?? 0
|
|
310
|
+
}));
|
|
311
|
+
let { w, h } = extentOf(clusters);
|
|
312
|
+
// An ungrouped layout (neighborhood) reports no clusters, so fall back to the cards.
|
|
313
|
+
if (!w || !h) {
|
|
314
|
+
const cards = extentOf(Object.values(this.#result.cards));
|
|
315
|
+
w = Math.max(w, cards.w);
|
|
316
|
+
h = Math.max(h, cards.h);
|
|
317
|
+
}
|
|
318
|
+
return { w: w || this.#result.size.w, h: h || this.#result.size.h };
|
|
319
|
+
}
|
|
320
|
+
get related() {
|
|
321
|
+
return this.#related;
|
|
322
|
+
}
|
|
323
|
+
get entities() {
|
|
324
|
+
return this.#entities;
|
|
325
|
+
}
|
|
326
|
+
get entity() {
|
|
327
|
+
return this.#entity;
|
|
328
|
+
}
|
|
329
|
+
get relationships() {
|
|
330
|
+
return this.#relationships;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Edges the field map could not place at one or both ends.
|
|
334
|
+
*
|
|
335
|
+
* Exposed rather than hidden because at scale this is the MAJORITY: Sensei's code graph
|
|
336
|
+
* reports 59.6% of 4.08M edges with a null target. A view that silently omitted them
|
|
337
|
+
* would claim a completeness the data does not have. They are absent from `routedEdges`
|
|
338
|
+
* because there is no card to draw them between — that is geometry, not a judgement.
|
|
339
|
+
*/
|
|
340
|
+
get unplacedEdges() {
|
|
341
|
+
return this.#model.edges.filter((e) => e.unplaced !== undefined);
|
|
342
|
+
}
|
|
343
|
+
get density() {
|
|
344
|
+
return this.#density;
|
|
345
|
+
}
|
|
346
|
+
get arrange() {
|
|
347
|
+
return this.#arrange;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* A cluster's unique render key.
|
|
351
|
+
*
|
|
352
|
+
* NOT the name: nesting puts a `table` box under `public` AND under `billing`, and keying
|
|
353
|
+
* an `{#each}` by name alone is a duplicate key — Svelte throws `each_key_duplicate`, the
|
|
354
|
+
* render aborts, and no inner box appears at all, which reads as nesting silently not
|
|
355
|
+
* working rather than as an error.
|
|
356
|
+
*/
|
|
357
|
+
clusterKey(cluster) {
|
|
358
|
+
return `${cluster.depth ?? 0}:${cluster.parent ?? ''}:${cluster.name}`;
|
|
359
|
+
}
|
|
360
|
+
get groupBy() {
|
|
361
|
+
return this.#groupBy;
|
|
362
|
+
}
|
|
363
|
+
get nestBy() {
|
|
364
|
+
return this.#nestBy;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Change the grouping axes from inside the component.
|
|
368
|
+
*
|
|
369
|
+
* Same reason `setDensity` exists: a `groupBy` PROP only ever reaches the state `Graph`
|
|
370
|
+
* owns, so with a caller-supplied state — which is how all three views share one — a
|
|
371
|
+
* control that set the prop would render, click, and change nothing.
|
|
372
|
+
*
|
|
373
|
+
* Passing the same axis for both is rejected rather than silently rendered: a box
|
|
374
|
+
* subdivided by itself produces exactly one child containing everything, which looks like
|
|
375
|
+
* a rendering fault rather than a no-op.
|
|
376
|
+
*/
|
|
377
|
+
setGrouping(outer, inner = null) {
|
|
378
|
+
this.#groupBy = outer;
|
|
379
|
+
this.#nestBy = inner === outer ? null : inner;
|
|
380
|
+
}
|
|
381
|
+
get edgeStyle() {
|
|
382
|
+
return this.#edgeStyle;
|
|
383
|
+
}
|
|
384
|
+
get mode() {
|
|
385
|
+
return this.#mode;
|
|
386
|
+
}
|
|
387
|
+
/** The layout's registry key, or 'custom' when a LayoutFn was passed directly. */
|
|
388
|
+
get layoutName() {
|
|
389
|
+
return typeof this.#layout === 'string' ? this.#layout : 'custom';
|
|
390
|
+
}
|
|
391
|
+
get value() {
|
|
392
|
+
return this.#value;
|
|
393
|
+
}
|
|
394
|
+
get label() {
|
|
395
|
+
return (this.#label ??
|
|
396
|
+
`Diagram of ${plural(this.#model.nodes.length, 'node')} and ${plural(this.#model.edges.length, 'relationship')}`);
|
|
397
|
+
}
|
|
398
|
+
}
|
package/dist/icons.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default glyph per node kind and row badge.
|
|
3
|
+
*
|
|
4
|
+
* `i-glyph:<name>` is the vocabulary this repo actually ships — `packages/icons/lib/glyph.json`,
|
|
5
|
+
* 697 names, safelisted wholesale by the app's UnoCSS config. An earlier version of this map
|
|
6
|
+
* used `i-graph-<kind>`, which is defined nowhere: UnoCSS emitted no rule, so every kind
|
|
7
|
+
* rendered an identical blank box while the class attribute looked perfectly correct.
|
|
8
|
+
* `spec/icons.spec.ts` now checks each name against the collection.
|
|
9
|
+
*
|
|
10
|
+
* A consumer overrides any entry through `<Graph icons={{ view: 'i-lucide-eye' }}>`, which is
|
|
11
|
+
* how a project on a different icon collection adopts this without patching the package.
|
|
12
|
+
*/
|
|
13
|
+
export declare const DEFAULT_ICONS: Record<string, string>;
|
package/dist/icons.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default glyph per node kind and row badge.
|
|
3
|
+
*
|
|
4
|
+
* `i-glyph:<name>` is the vocabulary this repo actually ships — `packages/icons/lib/glyph.json`,
|
|
5
|
+
* 697 names, safelisted wholesale by the app's UnoCSS config. An earlier version of this map
|
|
6
|
+
* used `i-graph-<kind>`, which is defined nowhere: UnoCSS emitted no rule, so every kind
|
|
7
|
+
* rendered an identical blank box while the class attribute looked perfectly correct.
|
|
8
|
+
* `spec/icons.spec.ts` now checks each name against the collection.
|
|
9
|
+
*
|
|
10
|
+
* A consumer overrides any entry through `<Graph icons={{ view: 'i-lucide-eye' }}>`, which is
|
|
11
|
+
* how a project on a different icon collection adopts this without patching the package.
|
|
12
|
+
*/
|
|
13
|
+
export const DEFAULT_ICONS = {
|
|
14
|
+
// ─── node kinds ───────────────────────────────────────────────────────────
|
|
15
|
+
table: 'i-glyph:table',
|
|
16
|
+
/** A view is a lens over other data, not storage of its own. */
|
|
17
|
+
view: 'i-glyph:eye',
|
|
18
|
+
/** dbd v2's wire string. Materialized = the query is stored, hence layers. */
|
|
19
|
+
materialized_view: 'i-glyph:layers',
|
|
20
|
+
/** Alias: rokkit's themes and demos have said `matview` since slice 1. */
|
|
21
|
+
matview: 'i-glyph:layers',
|
|
22
|
+
function: 'i-glyph:code',
|
|
23
|
+
/** A procedure is invoked for its effects — distinct from a function's value. */
|
|
24
|
+
procedure: 'i-glyph:play-circle',
|
|
25
|
+
/** dbd emits this; it fires on an event rather than being called. */
|
|
26
|
+
trigger: 'i-glyph:bolt',
|
|
27
|
+
/** A closed set of labels. */
|
|
28
|
+
enum: 'i-glyph:tag',
|
|
29
|
+
// ─── row badges ───────────────────────────────────────────────────────────
|
|
30
|
+
/** Identity constraint. The collection has no key glyph; `lock` is the closest. */
|
|
31
|
+
pk: 'i-glyph:lock',
|
|
32
|
+
/** Points at another entity. */
|
|
33
|
+
fk: 'i-glyph:git-merge',
|
|
34
|
+
uq: 'i-glyph:star',
|
|
35
|
+
/** NOT NULL — the value is guaranteed present. */
|
|
36
|
+
nn: 'i-glyph:shield',
|
|
37
|
+
fallback: 'i-glyph:box'
|
|
38
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { default as Graph } from './Graph.svelte';
|
|
2
|
+
export { GraphState } from './GraphState.svelte.js';
|
|
3
|
+
export { normalizeGraph } from './model/normalize.js';
|
|
4
|
+
export { readPath } from './model/path.js';
|
|
5
|
+
export { createGraphPreset, defaultGraphPreset, resolveGroupStyles } from './preset.js';
|
|
6
|
+
export { DEFAULT_ICONS } from './icons.js';
|
|
7
|
+
export { cluster, neighborhood, points, layouts } from './layout/index.js';
|
|
8
|
+
export type { EntityRow, GraphStateConfig, Relationship } from './GraphState.svelte.js';
|
|
9
|
+
export type { GraphChannel, GraphPreset, GraphShades } from './preset.js';
|
|
10
|
+
export type * from './types.js';
|
|
11
|
+
export type * from './layout/types.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Public surface for `@rokkit/graph`.
|
|
2
|
+
export { default as Graph } from './Graph.svelte';
|
|
3
|
+
export { GraphState } from './GraphState.svelte.js';
|
|
4
|
+
export { normalizeGraph } from './model/normalize.js';
|
|
5
|
+
export { readPath } from './model/path.js';
|
|
6
|
+
export { createGraphPreset, defaultGraphPreset, resolveGroupStyles } from './preset.js';
|
|
7
|
+
export { DEFAULT_ICONS } from './icons.js';
|
|
8
|
+
export { cluster, neighborhood, points, layouts } from './layout/index.js';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Cards, Density } from './types.js';
|
|
2
|
+
import type { GraphNode, GraphRow } from '../types.js';
|
|
3
|
+
export type CardOptions = {
|
|
4
|
+
/** Row cap. Defaults to 14 at density 'full', 8 otherwise. */
|
|
5
|
+
limit?: number;
|
|
6
|
+
/**
|
|
7
|
+
* Extra row filter applied before the cap, in place of the density's own.
|
|
8
|
+
*
|
|
9
|
+
* `more` still counts against ALL of the node's rows, which is why this belongs here
|
|
10
|
+
* rather than at the call site: filtering a node's rows before calling would report
|
|
11
|
+
* "+0 more" on a card that is in fact hiding most of its rows, and drop the more-row's
|
|
12
|
+
* height with it.
|
|
13
|
+
*/
|
|
14
|
+
select?: (row: GraphRow, node: GraphNode) => boolean;
|
|
15
|
+
/** Nodes rendered at full detail and uncapped, whatever `density` says. */
|
|
16
|
+
expanded?: ReadonlySet<string>;
|
|
17
|
+
};
|
|
18
|
+
/** Build a card per node. Height is derived from row count — nothing is measured. */
|
|
19
|
+
export declare function buildCards(nodes: GraphNode[], density: Density, options?: CardOptions): Cards;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { CARD_W, HEAD_H, MORE_H, PAD_B, ROW_H } from './constants.js';
|
|
2
|
+
/** The rows a card shows at the given density (none at 'names'). */
|
|
3
|
+
function visibleRows(node, density, options) {
|
|
4
|
+
// An expanded node ignores the density AND the cap: the reader asked for this specific
|
|
5
|
+
// card in full, so truncating it again would answer a different question.
|
|
6
|
+
if (options.expanded?.has(node.id))
|
|
7
|
+
return node.rows;
|
|
8
|
+
if (density === 'names')
|
|
9
|
+
return [];
|
|
10
|
+
const rows = selectedRows(node, density, options.select);
|
|
11
|
+
return rows.slice(0, options.limit ?? (density === 'full' ? 14 : 8));
|
|
12
|
+
}
|
|
13
|
+
function selectedRows(node, density, select) {
|
|
14
|
+
if (select)
|
|
15
|
+
return node.rows.filter((r) => select(r, node));
|
|
16
|
+
if (density === 'keys') {
|
|
17
|
+
return node.rows.filter((r) => r.badges.includes('pk') || r.badges.includes('fk'));
|
|
18
|
+
}
|
|
19
|
+
return node.rows;
|
|
20
|
+
}
|
|
21
|
+
/** Build a card per node. Height is derived from row count — nothing is measured. */
|
|
22
|
+
export function buildCards(nodes, density, options = {}) {
|
|
23
|
+
const cards = {};
|
|
24
|
+
for (const node of nodes) {
|
|
25
|
+
const vis = visibleRows(node, density, options);
|
|
26
|
+
const more = node.rows.length - vis.length;
|
|
27
|
+
const h = HEAD_H + vis.length * ROW_H + (more > 0 ? MORE_H : 0) + (vis.length || more > 0 ? PAD_B : 0);
|
|
28
|
+
cards[node.id] = { node, vis, more, w: CARD_W, h, x: 0, y: 0 };
|
|
29
|
+
}
|
|
30
|
+
return cards;
|
|
31
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { LayoutFn } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic cluster layout: groups become clusters, clusters are ordered to
|
|
4
|
+
* reduce edge crossings, and each cluster's nodes are masonry-packed then flowed
|
|
5
|
+
* into wrapping rows.
|
|
6
|
+
*
|
|
7
|
+
* `arrange: 'untangle'` (default) chains clusters by inter-group link weight and
|
|
8
|
+
* reorders nodes inside each cluster with barycenter passes. `'a-z'` is plain
|
|
9
|
+
* area-descending order with alphabetical lists.
|
|
10
|
+
*/
|
|
11
|
+
export declare const cluster: LayoutFn;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { buildCards } from './cards.js';
|
|
2
|
+
import { barycenterPasses, buildAdjacency, buildClusters, flow, groupByGroup, orderClusters, pack } from './clusters.js';
|
|
3
|
+
import { buildEdges } from './edges.js';
|
|
4
|
+
import { nestedClusters } from './nested.js';
|
|
5
|
+
/**
|
|
6
|
+
* Deterministic cluster layout: groups become clusters, clusters are ordered to
|
|
7
|
+
* reduce edge crossings, and each cluster's nodes are masonry-packed then flowed
|
|
8
|
+
* into wrapping rows.
|
|
9
|
+
*
|
|
10
|
+
* `arrange: 'untangle'` (default) chains clusters by inter-group link weight and
|
|
11
|
+
* reorders nodes inside each cluster with barycenter passes. `'a-z'` is plain
|
|
12
|
+
* area-descending order with alphabetical lists.
|
|
13
|
+
*/
|
|
14
|
+
export const cluster = (model, options) => {
|
|
15
|
+
const density = options.density ?? 'keys';
|
|
16
|
+
const arrange = options.arrange ?? 'untangle';
|
|
17
|
+
const cards = buildCards(model.nodes, density, { expanded: options.expanded });
|
|
18
|
+
// Two-level clustering takes its own path — and only when asked. `clusters.ts` carries 20
|
|
19
|
+
// characterization assertions ported from dbd with exact pixel values, so the single-level
|
|
20
|
+
// pipeline below has to keep running byte-identically for every existing caller.
|
|
21
|
+
//
|
|
22
|
+
// Barycenter ordering is deliberately skipped here: it reorders nodes WITHIN one flat
|
|
23
|
+
// cluster to shorten edges, and a node in a nested layout is already pinned by two axes —
|
|
24
|
+
// reordering it could only move it out of the subdivision that explains where it is.
|
|
25
|
+
if (options.nestBy) {
|
|
26
|
+
const { clusters, size } = nestedClusters(model.nodes, cards, options.groupBy ?? 'group', options.nestBy);
|
|
27
|
+
return { clusters, cards, edges: buildEdges(model.edges, cards), size };
|
|
28
|
+
}
|
|
29
|
+
// NOT model.neighbors. That is a Set; barycenter needs the duplicate-preserving array so a
|
|
30
|
+
// twice-referenced neighbour weighs twice.
|
|
31
|
+
const neighbors = buildAdjacency(model.edges);
|
|
32
|
+
let clusters = buildClusters(groupByGroup(model.nodes, options.groupBy ?? 'group'));
|
|
33
|
+
clusters.forEach((c) => pack(c, cards));
|
|
34
|
+
clusters = orderClusters(clusters, model, arrange);
|
|
35
|
+
let size = flow(clusters, cards);
|
|
36
|
+
if (arrange === 'untangle')
|
|
37
|
+
size = barycenterPasses(clusters, cards, neighbors);
|
|
38
|
+
return { clusters, cards, edges: buildEdges(model.edges, cards), size };
|
|
39
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Cluster, Cards, Size, Arrange, NodeAxis } from './types.js';
|
|
2
|
+
import type { GraphEdge, GraphModel, GraphNode } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Group nodes by an axis. A node with no value on it files under ''.
|
|
5
|
+
*
|
|
6
|
+
* `axis` defaults to `group`, so every existing caller — and the ported characterization
|
|
7
|
+
* tests — keep their exact behaviour. It exists because the single-axis case is NOT a special
|
|
8
|
+
* case of nesting: `groupBy: 'kind'` with no `nestBy` has to regroup the flat layout too, and
|
|
9
|
+
* without it the control moved while the picture did not.
|
|
10
|
+
*/
|
|
11
|
+
export declare function groupByGroup(nodes: GraphNode[], axis?: NodeAxis): Record<string, GraphNode[]>;
|
|
12
|
+
/**
|
|
13
|
+
* Undirected node adjacency (cross-node edges) for barycenter ordering.
|
|
14
|
+
*
|
|
15
|
+
* Pushes once PER EDGE, so two edges between the same pair list the neighbour
|
|
16
|
+
* twice. That is deliberate: `barycenter` divides by the array length, so a
|
|
17
|
+
* twice-referenced neighbour is weighted 2x and pulls the node twice as hard.
|
|
18
|
+
* Two FKs to one table is routine (`orders.created_by` and `orders.approved_by`
|
|
19
|
+
* both -> `users.id`), so collapsing this to a Set would silently relayout every
|
|
20
|
+
* multi-FK schema.
|
|
21
|
+
*
|
|
22
|
+
* `GraphModel.neighbors` is a Set and stays one — it answers "is X related to Y",
|
|
23
|
+
* where multiplicity is meaningless. This answers "how strongly is X pulled
|
|
24
|
+
* toward Y", where multiplicity is the whole point. Different questions.
|
|
25
|
+
*/
|
|
26
|
+
export declare function buildAdjacency(edges: GraphEdge[]): Map<string, string[]>;
|
|
27
|
+
/** One cluster per group; groupIndex assigned by alphabetical position (stable across arranges). */
|
|
28
|
+
export declare function buildClusters(byGroup: Record<string, GraphNode[]>): Cluster[];
|
|
29
|
+
/** Masonry-pack one cluster from its current list order (sets c.pos/w/h). */
|
|
30
|
+
export declare function pack(c: Cluster, cards: Cards): void;
|
|
31
|
+
/**
|
|
32
|
+
* Order clusters. 'untangle' greedily chains groups so heavily-linked ones stay
|
|
33
|
+
* adjacent (seeded by the largest cluster); otherwise sort by area, biggest first.
|
|
34
|
+
*/
|
|
35
|
+
export declare function orderClusters(clusters: Cluster[], model: GraphModel, arrange: Arrange): Cluster[];
|
|
36
|
+
/** Flow clusters into rows (wrapping at MAX_ROW_W), place each card, return canvas size. */
|
|
37
|
+
export declare function flow(clusters: Cluster[], cards: Cards): Size;
|
|
38
|
+
/** Two barycenter passes: reorder each cluster's nodes toward neighbors, re-pack, re-flow. */
|
|
39
|
+
export declare function barycenterPasses(clusters: Cluster[], cards: Cards, nbrs: Map<string, string[]>): Size;
|