@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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -0
  3. package/dist/Graph.svelte +369 -0
  4. package/dist/Graph.svelte.d.ts +4 -0
  5. package/dist/GraphState.svelte.d.ts +171 -0
  6. package/dist/GraphState.svelte.js +398 -0
  7. package/dist/icons.d.ts +13 -0
  8. package/dist/icons.js +38 -0
  9. package/dist/index.d.ts +11 -0
  10. package/dist/index.js +8 -0
  11. package/dist/layout/cards.d.ts +19 -0
  12. package/dist/layout/cards.js +31 -0
  13. package/dist/layout/cluster.d.ts +11 -0
  14. package/dist/layout/cluster.js +39 -0
  15. package/dist/layout/clusters.d.ts +39 -0
  16. package/dist/layout/clusters.js +217 -0
  17. package/dist/layout/constants.d.ts +12 -0
  18. package/dist/layout/constants.js +15 -0
  19. package/dist/layout/edges.d.ts +5 -0
  20. package/dist/layout/edges.js +110 -0
  21. package/dist/layout/index.d.ts +9 -0
  22. package/dist/layout/index.js +7 -0
  23. package/dist/layout/neighborhood.d.ts +9 -0
  24. package/dist/layout/neighborhood.js +123 -0
  25. package/dist/layout/nested.d.ts +13 -0
  26. package/dist/layout/nested.js +156 -0
  27. package/dist/layout/points.d.ts +6 -0
  28. package/dist/layout/points.js +204 -0
  29. package/dist/layout/types.d.ts +115 -0
  30. package/dist/layout/types.js +1 -0
  31. package/dist/model/normalize.d.ts +7 -0
  32. package/dist/model/normalize.js +215 -0
  33. package/dist/model/path.d.ts +13 -0
  34. package/dist/model/path.js +23 -0
  35. package/dist/preset.d.ts +47 -0
  36. package/dist/preset.js +61 -0
  37. package/dist/schema/EntitiesView.svelte +83 -0
  38. package/dist/schema/EntitiesView.svelte.d.ts +13 -0
  39. package/dist/schema/EntityView.svelte +116 -0
  40. package/dist/schema/EntityView.svelte.d.ts +13 -0
  41. package/dist/schema/NoteBlocks.svelte +31 -0
  42. package/dist/schema/NoteBlocks.svelte.d.ts +6 -0
  43. package/dist/schema/column-type.d.ts +4 -0
  44. package/dist/schema/column-type.js +19 -0
  45. package/dist/schema/fromSchemaModel.d.ts +66 -0
  46. package/dist/schema/fromSchemaModel.js +92 -0
  47. package/dist/schema/index.d.ts +8 -0
  48. package/dist/schema/index.js +7 -0
  49. package/dist/schema/notes.d.ts +12 -0
  50. package/dist/schema/notes.js +65 -0
  51. package/dist/types.d.ts +160 -0
  52. package/dist/types.js +1 -0
  53. 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
+ }
@@ -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
+ };
@@ -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;