@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,217 @@
1
+ /* ============================================================
2
+ Cluster building, ordering, packing, flow — the core of the layout
3
+ algorithm. Groups become clusters, clusters are ordered to minimise edge
4
+ crossings, and each cluster's nodes are masonry-packed then flowed into
5
+ wrapping rows.
6
+
7
+ Ported from dbd's layout-clusters.ts. The geometry is unchanged; only the
8
+ model shape moved (LayoutData -> GraphNode[]/GraphEdge[]) and `hue` became
9
+ `groupIndex`, because colour now comes from CSS and the preset.
10
+ ============================================================ */
11
+ import { CARD_W, GAP_X, GAP_Y, CL_PAD, CL_TITLE, CL_GAP_X, CL_GAP_Y, MAX_ROW_W } from './constants.js';
12
+ /**
13
+ * Group nodes by an axis. A node with no value on it files under ''.
14
+ *
15
+ * `axis` defaults to `group`, so every existing caller — and the ported characterization
16
+ * tests — keep their exact behaviour. It exists because the single-axis case is NOT a special
17
+ * case of nesting: `groupBy: 'kind'` with no `nestBy` has to regroup the flat layout too, and
18
+ * without it the control moved while the picture did not.
19
+ */
20
+ export function groupByGroup(nodes, axis = 'group') {
21
+ const byGroup = {};
22
+ for (const node of nodes) {
23
+ const key = (axis === 'kind' ? node.kind : node.group) ?? '';
24
+ (byGroup[key] = byGroup[key] || []).push(node);
25
+ }
26
+ return byGroup;
27
+ }
28
+ /**
29
+ * Undirected node adjacency (cross-node edges) for barycenter ordering.
30
+ *
31
+ * Pushes once PER EDGE, so two edges between the same pair list the neighbour
32
+ * twice. That is deliberate: `barycenter` divides by the array length, so a
33
+ * twice-referenced neighbour is weighted 2x and pulls the node twice as hard.
34
+ * Two FKs to one table is routine (`orders.created_by` and `orders.approved_by`
35
+ * both -> `users.id`), so collapsing this to a Set would silently relayout every
36
+ * multi-FK schema.
37
+ *
38
+ * `GraphModel.neighbors` is a Set and stays one — it answers "is X related to Y",
39
+ * where multiplicity is meaningless. This answers "how strongly is X pulled
40
+ * toward Y", where multiplicity is the whole point. Different questions.
41
+ */
42
+ export function buildAdjacency(edges) {
43
+ const nbrs = new Map();
44
+ const link = (a, b) => {
45
+ const list = nbrs.get(a) ?? [];
46
+ list.push(b);
47
+ nbrs.set(a, list);
48
+ };
49
+ for (const edge of edges) {
50
+ // An unplaced endpoint has no card, and `barycenter` reads a card's y to average
51
+ // positions — so including one crashes on the lookup. The relationship is real and
52
+ // still in the model; it simply has no POSITION to contribute.
53
+ if (edge.unplaced || edge.source === edge.target)
54
+ continue;
55
+ link(edge.source, edge.target);
56
+ link(edge.target, edge.source);
57
+ }
58
+ return nbrs;
59
+ }
60
+ /** One cluster per group; groupIndex assigned by alphabetical position (stable across arranges). */
61
+ export function buildClusters(byGroup) {
62
+ return Object.keys(byGroup)
63
+ .sort()
64
+ .map((name, i) => ({
65
+ name,
66
+ list: byGroup[name].slice().sort((a, b) => a.label.localeCompare(b.label)),
67
+ count: byGroup[name].length,
68
+ groupIndex: i,
69
+ x: 0,
70
+ y: 0
71
+ }));
72
+ }
73
+ /** Index of the shortest column so far (first one wins on a tie). */
74
+ function shortestColumn(colH) {
75
+ let ci = 0;
76
+ for (let i = 1; i < colH.length; i++)
77
+ if (colH[i] < colH[ci])
78
+ ci = i;
79
+ return ci;
80
+ }
81
+ /** Masonry-pack one cluster from its current list order (sets c.pos/w/h). */
82
+ export function pack(c, cards) {
83
+ const n = c.list.length;
84
+ const ncols = Math.max(1, Math.min(6, Math.round(Math.sqrt(n * 1.15))));
85
+ const colH = new Array(ncols).fill(0);
86
+ c.pos = [];
87
+ for (const node of c.list) {
88
+ const card = cards[node.id];
89
+ const ci = shortestColumn(colH);
90
+ c.pos.push({ key: node.id, dx: ci * (CARD_W + GAP_X), dy: colH[ci] });
91
+ colH[ci] += card.h + GAP_Y;
92
+ }
93
+ c.w = ncols * CARD_W + (ncols - 1) * GAP_X + CL_PAD * 2;
94
+ c.h = Math.max(...colH) - GAP_Y + CL_PAD * 2 + CL_TITLE;
95
+ }
96
+ const byArea = (a, b) => (b.w ?? 0) * (b.h ?? 0) - (a.w ?? 0) * (a.h ?? 0);
97
+ /**
98
+ * Count inter-group edge links (symmetric), keyed 'groupA|groupB'.
99
+ *
100
+ * Groups come from the model's `byId` rather than from splitting the node id: an
101
+ * id is opaque once the normalizer has built it, and re-parsing it into parts is
102
+ * exactly the coupling the canonical model removes.
103
+ */
104
+ function bumpLink(links, a, b) {
105
+ links[`${a}|${b}`] = (links[`${a}|${b}`] || 0) + 1;
106
+ }
107
+ function countGroupLinks(model) {
108
+ const links = {};
109
+ for (const edge of model.edges) {
110
+ const from = model.byId.get(edge.source)?.group;
111
+ const to = model.byId.get(edge.target)?.group;
112
+ if (from === undefined || to === undefined || from === to)
113
+ continue;
114
+ bumpLink(links, from, to);
115
+ bumpLink(links, to, from);
116
+ }
117
+ return links;
118
+ }
119
+ /** Remove + return the `left` cluster most strongly linked to the already-`ordered` ones. */
120
+ function pickNextLinked(left, ordered, links) {
121
+ const lk = (a, b) => links[`${a.name}|${b.name}`] || 0;
122
+ let best = 0;
123
+ let bestScore = -1;
124
+ for (let i = 0; i < left.length; i++) {
125
+ let score = 0;
126
+ for (let j = 0; j < ordered.length; j++)
127
+ score += lk(left[i], ordered[j]) * (j + 1);
128
+ if (score > bestScore) {
129
+ bestScore = score;
130
+ best = i;
131
+ }
132
+ }
133
+ return left.splice(best, 1)[0];
134
+ }
135
+ /**
136
+ * Order clusters. 'untangle' greedily chains groups so heavily-linked ones stay
137
+ * adjacent (seeded by the largest cluster); otherwise sort by area, biggest first.
138
+ */
139
+ export function orderClusters(clusters, model, arrange) {
140
+ if (arrange !== 'untangle' || clusters.length <= 1) {
141
+ return clusters.sort(byArea);
142
+ }
143
+ const links = countGroupLinks(model);
144
+ const left = clusters.slice().sort(byArea);
145
+ const ordered = [left.shift()];
146
+ while (left.length)
147
+ ordered.push(pickNextLinked(left, ordered, links));
148
+ return ordered;
149
+ }
150
+ /** Start a new row when the cluster would overflow the current one. */
151
+ function wrapIfNeeded(cursor, width) {
152
+ if (cursor.x === 0 || cursor.x + width <= MAX_ROW_W)
153
+ return;
154
+ cursor.x = 0;
155
+ cursor.y += cursor.rowH + CL_GAP_Y;
156
+ cursor.rowH = 0;
157
+ }
158
+ /** Place one cluster's origin (wrapping the row first if it would overflow), then its cards. */
159
+ function placeCluster(c, cursor, cards) {
160
+ const width = c.w ?? 0;
161
+ wrapIfNeeded(cursor, width);
162
+ c.x = cursor.x;
163
+ c.y = cursor.y;
164
+ cursor.x += width + CL_GAP_X;
165
+ cursor.rowH = Math.max(cursor.rowH, c.h ?? 0);
166
+ for (const p of c.pos ?? []) {
167
+ const card = cards[p.key];
168
+ card.x = c.x + CL_PAD + p.dx;
169
+ card.y = c.y + CL_PAD + CL_TITLE + p.dy;
170
+ card.groupIndex = c.groupIndex;
171
+ }
172
+ }
173
+ /** Flow clusters into rows (wrapping at MAX_ROW_W), place each card, return canvas size. */
174
+ export function flow(clusters, cards) {
175
+ // `Math.max(...[])` is -Infinity, which would report a canvas of -Infinity + 60 and render
176
+ // as an SVG with a negative viewBox. An empty model has no canvas, not a backwards one.
177
+ if (clusters.length === 0)
178
+ return { w: 0, h: 0 };
179
+ const cursor = { x: 0, y: 0, rowH: 0 };
180
+ for (const c of clusters)
181
+ placeCluster(c, cursor, cards);
182
+ return {
183
+ w: Math.max(...clusters.map((c) => c.x + (c.w ?? 0))) + 60,
184
+ h: cursor.y + cursor.rowH + 60
185
+ };
186
+ }
187
+ /** Barycenter mean of a node's neighbors (its own center when it has none). */
188
+ function barycenter(key, nbrs, cards) {
189
+ const card = cards[key];
190
+ const ns = nbrs.get(key);
191
+ if (!ns || !ns.length)
192
+ return card.y + card.h / 2;
193
+ let sum = 0;
194
+ for (const nk of ns) {
195
+ const nc = cards[nk];
196
+ sum += nc.y + nc.h / 2;
197
+ }
198
+ return sum / ns.length;
199
+ }
200
+ /** Reorder one cluster's nodes toward their neighbors' barycenters, then re-pack it. */
201
+ function reorderTowardNeighbors(c, cards, nbrs) {
202
+ const score = {};
203
+ for (const node of c.list)
204
+ score[node.id] = barycenter(node.id, nbrs, cards);
205
+ c.list = c.list.slice().sort((a, b) => score[a.id] - score[b.id]);
206
+ pack(c, cards);
207
+ }
208
+ /** Two barycenter passes: reorder each cluster's nodes toward neighbors, re-pack, re-flow. */
209
+ export function barycenterPasses(clusters, cards, nbrs) {
210
+ let size = { w: 0, h: 0 };
211
+ for (let iter = 0; iter < 2; iter++) {
212
+ for (const c of clusters)
213
+ reorderTowardNeighbors(c, cards, nbrs);
214
+ size = flow(clusters, cards);
215
+ }
216
+ return size;
217
+ }
@@ -0,0 +1,12 @@
1
+ export declare const CARD_W = 248;
2
+ export declare const ROW_H = 24;
3
+ export declare const HEAD_H = 40;
4
+ export declare const MORE_H = 22;
5
+ export declare const PAD_B = 6;
6
+ export declare const GAP_X = 36;
7
+ export declare const GAP_Y = 30;
8
+ export declare const CL_PAD = 26;
9
+ export declare const CL_TITLE = 16;
10
+ export declare const CL_GAP_X = 110;
11
+ export declare const CL_GAP_Y = 110;
12
+ export declare const MAX_ROW_W = 2750;
@@ -0,0 +1,15 @@
1
+ /* Geometry constants for card and cluster layout. Values are carried over from
2
+ dbd's layout-types.ts unchanged — the ported characterization specs pin exact
3
+ pixel output, so changing any of these is a visible behaviour change. */
4
+ export const CARD_W = 248;
5
+ export const ROW_H = 24;
6
+ export const HEAD_H = 40;
7
+ export const MORE_H = 22;
8
+ export const PAD_B = 6;
9
+ export const GAP_X = 36;
10
+ export const GAP_Y = 30;
11
+ export const CL_PAD = 26;
12
+ export const CL_TITLE = 16;
13
+ export const CL_GAP_X = 110;
14
+ export const CL_GAP_Y = 110;
15
+ export const MAX_ROW_W = 2750;
@@ -0,0 +1,5 @@
1
+ import type { Cards, EdgeStyle, RoutedEdge } from './types.js';
2
+ import type { GraphEdge } from '../types.js';
3
+ /** Build all edge geometry, skipping edges whose endpoints aren't laid out. */
4
+ export declare function buildEdges(edges: GraphEdge[], cards: Cards): RoutedEdge[];
5
+ export declare function edgePath(e: RoutedEdge, style: EdgeStyle): string;
@@ -0,0 +1,110 @@
1
+ /* Edge routing — anchor points, connector geometry, SVG paths.
2
+
3
+ Ported from dbd's layout-edges.ts. All geometry is unchanged: the 50px side-gap
4
+ threshold, the +14 same-anchor nudge, the 46px self-loop bow, the 64px same-side
5
+ bow, the [46, 170] control-point clamp and the +52 orthogonal sweep. */
6
+ import { HEAD_H, ROW_H } from './constants.js';
7
+ /**
8
+ * Vertical anchor for a row's edge endpoint on a card.
9
+ *
10
+ * `rowName` is optional because a canonical edge need not name one — a dependency
11
+ * edge (a view reading a table) has no column anchors at all. An unset name takes
12
+ * the same head-centre fallback a non-matching name already took.
13
+ */
14
+ function anchorY(card, rowName) {
15
+ const idx = card.vis.findIndex((r) => r.name === rowName);
16
+ if (idx >= 0)
17
+ return card.y + HEAD_H + idx * ROW_H + ROW_H / 2;
18
+ return card.y + HEAD_H / 2;
19
+ }
20
+ /** A self-reference: loop on the right edge of the card. */
21
+ function selfLoop(edge, i, card, { y1, y2 }) {
22
+ return {
23
+ i,
24
+ id: edge.id,
25
+ fromKey: edge.source,
26
+ toKey: edge.target,
27
+ kind: edge.kind,
28
+ relation: edge.relation,
29
+ self: true,
30
+ x1: card.x + card.w,
31
+ y1,
32
+ x2: card.x + card.w,
33
+ y2: y2 === y1 ? y1 + 14 : y2,
34
+ s1: 1,
35
+ s2: 1
36
+ };
37
+ }
38
+ /** Which side of each card the connector leaves from. 1 = right, -1 = left. */
39
+ function sides(a, b) {
40
+ if (a.x + a.w + 50 <= b.x)
41
+ return { s1: 1, s2: -1 };
42
+ if (b.x + b.w + 50 <= a.x)
43
+ return { s1: -1, s2: 1 };
44
+ return { s1: 1, s2: 1 }; // stacked: route around the right
45
+ }
46
+ /** Geometry for a single edge: a self-loop on the right edge, or a side-routed connector. */
47
+ function buildEdge(edge, i, cards) {
48
+ const a = cards[edge.source];
49
+ const b = cards[edge.target];
50
+ if (!a || !b)
51
+ return null;
52
+ const y1 = anchorY(a, edge.sourceRow);
53
+ const y2 = anchorY(b, edge.targetRow);
54
+ if (a === b)
55
+ return selfLoop(edge, i, a, { y1, y2 });
56
+ const { s1, s2 } = sides(a, b);
57
+ return {
58
+ i,
59
+ id: edge.id,
60
+ fromKey: edge.source,
61
+ toKey: edge.target,
62
+ kind: edge.kind,
63
+ relation: edge.relation,
64
+ self: false,
65
+ x1: s1 === 1 ? a.x + a.w : a.x,
66
+ y1,
67
+ x2: s2 === 1 ? b.x + b.w : b.x,
68
+ y2,
69
+ s1,
70
+ s2
71
+ };
72
+ }
73
+ /** Build all edge geometry, skipping edges whose endpoints aren't laid out. */
74
+ export function buildEdges(edges, cards) {
75
+ const routed = [];
76
+ edges.forEach((edge, i) => {
77
+ const e = buildEdge(edge, i, cards);
78
+ if (e)
79
+ routed.push(e);
80
+ });
81
+ return routed;
82
+ }
83
+ /** Right-angle route: opposite sides join at the x-midpoint, same-side (stacked) sweeps around the right. */
84
+ function orthogonalPath(e) {
85
+ const { x1, y1, x2, y2, s1, s2 } = e;
86
+ if (s1 !== s2) {
87
+ const mid = (x1 + x2) / 2;
88
+ return `M ${x1} ${y1} H ${mid} V ${y2} H ${x2}`;
89
+ }
90
+ const out = Math.max(x1, x2) + 52;
91
+ return `M ${x1} ${y1} H ${out} V ${y2} H ${x2}`;
92
+ }
93
+ /** Bezier route: same-side bows outward by a fixed amount, opposite-side bows by half the x distance (clamped). */
94
+ function curvedPath(e) {
95
+ const { x1, y1, x2, y2, s1, s2 } = e;
96
+ if (s1 === s2) {
97
+ const bow = 64;
98
+ return `M ${x1} ${y1} C ${x1 + bow * s1} ${y1}, ${x2 + bow * s2} ${y2}, ${x2} ${y2}`;
99
+ }
100
+ const dx = Math.max(46, Math.min(170, Math.abs(x2 - x1) / 2));
101
+ return `M ${x1} ${y1} C ${x1 + dx * s1} ${y1}, ${x2 + dx * s2} ${y2}, ${x2} ${y2}`;
102
+ }
103
+ export function edgePath(e, style) {
104
+ if (e.self) {
105
+ const { x1, y1, x2, y2 } = e;
106
+ const bow = 46;
107
+ return `M ${x1} ${y1} C ${x1 + bow} ${y1}, ${x2 + bow} ${y2}, ${x2} ${y2}`;
108
+ }
109
+ return style === 'orthogonal' ? orthogonalPath(e) : curvedPath(e);
110
+ }
@@ -0,0 +1,9 @@
1
+ import { cluster } from './cluster.js';
2
+ import { neighborhood } from './neighborhood.js';
3
+ import { points } from './points.js';
4
+ import type { LayoutFn } from './types.js';
5
+ /** Built-in layouts, addressable by name from `Graph`'s `layout` prop. */
6
+ export declare const layouts: Record<string, LayoutFn>;
7
+ export type LayoutName = keyof typeof layouts;
8
+ export { cluster, neighborhood, points };
9
+ export * from './types.js';
@@ -0,0 +1,7 @@
1
+ import { cluster } from './cluster.js';
2
+ import { neighborhood } from './neighborhood.js';
3
+ import { points } from './points.js';
4
+ /** Built-in layouts, addressable by name from `Graph`'s `layout` prop. */
5
+ export const layouts = { cluster, neighborhood, points };
6
+ export { cluster, neighborhood, points };
7
+ export * from './types.js';
@@ -0,0 +1,9 @@
1
+ import type { LayoutFn } from './types.js';
2
+ /**
3
+ * Entity-centric layout. `options.focus` names the node to centre; without it, or with
4
+ * an id the model does not know, the result is empty.
5
+ *
6
+ * The focus card shows up to 16 rows unfiltered. Each neighbour shows only its keys and
7
+ * the rows the focus references, capped at 8 — both caps carried over from dbd.
8
+ */
9
+ export declare const neighborhood: LayoutFn;
@@ -0,0 +1,123 @@
1
+ /* Entity-centric layout: the focus node centred, nodes that reference it stacked on
2
+ the left, nodes it references on the right.
3
+
4
+ Ported from dbd's EntityDiagram.svelte. That component carried its own geometry
5
+ constants, buildCard, anchorY and path — roughly 100 lines duplicating cards.ts and
6
+ edges.ts with slightly different numbers. Here it reuses both, so the duplication is
7
+ gone rather than moved. The one visible consequence: self-loops now use the shared
8
+ +14 anchor nudge and 46px bow instead of EntityDiagram's +16 and 52px. */
9
+ import { buildCards } from './cards.js';
10
+ import { buildEdges } from './edges.js';
11
+ import { CARD_W } from './constants.js';
12
+ /* Local to this layout and deliberately NOT the cluster gaps in constants.ts: this is
13
+ a three-column portrait, not a masonry grid, so it wants a wide horizontal gutter and
14
+ a tight vertical one. */
15
+ const COL_GAP = 170;
16
+ const STACK_GAP = 26;
17
+ /** Floor and padding on the canvas, so a one-row neighbourhood is not a sliver. */
18
+ const MIN_H = 120;
19
+ const H_PAD = 20;
20
+ /** Extra width reserved to the right of the focus card for self-loop bows. */
21
+ const LOOP_W = 60;
22
+ const EMPTY = { clusters: [], cards: {}, edges: [], size: { w: 0, h: 0 } };
23
+ function partition(model, focusId) {
24
+ const byNeighbour = new Map();
25
+ const selfEdges = [];
26
+ const entry = (id) => {
27
+ const found = byNeighbour.get(id) ?? { id, in: [], out: [] };
28
+ byNeighbour.set(id, found);
29
+ return found;
30
+ };
31
+ for (const edge of model.edges) {
32
+ // An unplaced endpoint is not a node, so it has no card and cannot be a neighbour.
33
+ // The relationship is still real and still reported by GraphState.relationships —
34
+ // it simply has nothing to draw here.
35
+ if (edge.unplaced)
36
+ continue;
37
+ const isFrom = edge.source === focusId;
38
+ const isTo = edge.target === focusId;
39
+ if (isFrom && isTo)
40
+ selfEdges.push(edge);
41
+ else if (isFrom)
42
+ entry(edge.target).out.push(edge);
43
+ else if (isTo)
44
+ entry(edge.source).in.push(edge);
45
+ }
46
+ return { neighbours: [...byNeighbour.values()], selfEdges };
47
+ }
48
+ /**
49
+ * Rows a neighbour card reveals: its own keys, plus whichever rows the focus actually
50
+ * references. Everything else stays behind the more-count.
51
+ */
52
+ function referencedRows(neighbour) {
53
+ const names = new Set();
54
+ for (const edge of neighbour.in)
55
+ if (edge.sourceRow)
56
+ names.add(edge.sourceRow);
57
+ for (const edge of neighbour.out)
58
+ if (edge.targetRow)
59
+ names.add(edge.targetRow);
60
+ return names;
61
+ }
62
+ /** Stack height of a column of cards, gaps between but not after. */
63
+ function stackHeight(cards) {
64
+ if (cards.length === 0)
65
+ return 0;
66
+ return cards.reduce((total, card) => total + card.h + STACK_GAP, 0) - STACK_GAP;
67
+ }
68
+ /** Place a column of cards at `x`, vertically centred within `height`. */
69
+ function placeColumn(cards, x, height) {
70
+ let y = (height - stackHeight(cards)) / 2;
71
+ for (const card of cards) {
72
+ card.x = x;
73
+ card.y = y;
74
+ y += card.h + STACK_GAP;
75
+ }
76
+ }
77
+ function buildNeighbourCards(model, neighbours, expanded) {
78
+ const nodes = neighbours
79
+ .map((n) => model.byId.get(n.id))
80
+ .filter((n) => n !== undefined);
81
+ const wanted = new Map(neighbours.map((n) => [n.id, referencedRows(n)]));
82
+ return buildCards(nodes, 'full', {
83
+ limit: 8,
84
+ expanded,
85
+ select: (row, node) => row.badges.includes('pk') || (wanted.get(node.id)?.has(row.name) ?? false)
86
+ });
87
+ }
88
+ /**
89
+ * Entity-centric layout. `options.focus` names the node to centre; without it, or with
90
+ * an id the model does not know, the result is empty.
91
+ *
92
+ * The focus card shows up to 16 rows unfiltered. Each neighbour shows only its keys and
93
+ * the rows the focus references, capped at 8 — both caps carried over from dbd.
94
+ */
95
+ export const neighborhood = (model, options) => {
96
+ const focus = options.focus ? model.byId.get(options.focus) : undefined;
97
+ if (!focus)
98
+ return EMPTY;
99
+ const { neighbours, selfEdges } = partition(model, focus.id);
100
+ const cards = {
101
+ ...buildNeighbourCards(model, neighbours, options.expanded),
102
+ ...buildCards([focus], 'full', { limit: 16, expanded: options.expanded })
103
+ };
104
+ const focusCard = cards[focus.id];
105
+ // A neighbour the focus points at sits right; one that points at the focus sits left.
106
+ const right = neighbours.filter((n) => n.out.length > 0).map((n) => cards[n.id]);
107
+ const left = neighbours.filter((n) => n.out.length === 0).map((n) => cards[n.id]);
108
+ const height = Math.max(focusCard.h, stackHeight(left), stackHeight(right), MIN_H) + H_PAD;
109
+ const hasRight = right.length > 0 || selfEdges.length > 0;
110
+ const focusX = left.length > 0 ? CARD_W + COL_GAP : 0;
111
+ placeColumn(left, 0, height);
112
+ placeColumn([focusCard], focusX, height);
113
+ placeColumn(right, focusX + CARD_W + COL_GAP, height);
114
+ const width = focusX +
115
+ CARD_W +
116
+ (hasRight ? COL_GAP + CARD_W : 0) +
117
+ (selfEdges.length > 0 ? LOOP_W : 0) +
118
+ 4;
119
+ // Only edges touching the focus. buildEdges would otherwise connect two neighbours
120
+ // that happen to both be laid out, which this view never shows.
121
+ const touching = model.edges.filter((e) => e.source === focus.id || e.target === focus.id);
122
+ return { clusters: [], cards, edges: buildEdges(touching, cards), size: { w: width, h: height } };
123
+ };
@@ -0,0 +1,13 @@
1
+ import type { Cards, Cluster, NodeAxis, Size } from './types.js';
2
+ import type { GraphNode } from '../types.js';
3
+ /**
4
+ * Two-level clusters over already-built cards.
5
+ *
6
+ * Returns a FLAT list, every outer box before every inner one — absolutely positioned
7
+ * siblings paint in document order, so an outer box emitted later would cover the
8
+ * subdivisions inside it.
9
+ */
10
+ export declare function nestedClusters(nodes: GraphNode[], cards: Cards, outerAxis: NodeAxis, innerAxis: NodeAxis): {
11
+ clusters: Cluster[];
12
+ size: Size;
13
+ };
@@ -0,0 +1,156 @@
1
+ /* Two-level clustering: an outer box per value of one axis, subdivided by a second.
2
+
3
+ Schema is the right grouping for an ER diagram and a poor one for a dependency graph — a
4
+ single schema holds a table, a trigger and a procedure, while the question there is usually
5
+ "what are the routines, and what do they touch". Nesting shows both facts at once rather
6
+ than trading one for the other, and which axis is OUTER is the reader's choice.
7
+
8
+ Nesting needs no DOM tree. Clusters are absolutely positioned boxes, so a FLAT list with the
9
+ outer boxes emitted first renders correctly by paint order; `depth` is what lets CSS draw an
10
+ inner box as a faint subdivision rather than a second coloured region fighting the one
11
+ around it.
12
+
13
+ This is a separate module, and `nestBy` is strictly opt-in, because `clusters.ts` carries 20
14
+ characterization tests ported from dbd with exact pixel assertions. Single-level layout must
15
+ keep running the original code path untouched. */
16
+ import { CL_GAP_X, CL_GAP_Y, CL_PAD, CL_TITLE, MAX_ROW_W } from './constants.js';
17
+ import { pack } from './clusters.js';
18
+ /** Gap between two inner boxes inside one outer box. Tighter than the canvas-level gap. */
19
+ const INNER_GAP = 14;
20
+ /** An inner box gets a shorter label strip than an outer one — it is a subdivision. */
21
+ const INNER_TITLE = 20;
22
+ /** The axis value a node buckets under. '' when it has none, which is a bucket, not a drop. */
23
+ function axisOf(node, axis) {
24
+ return (axis === 'kind' ? node.kind : node.group) ?? '';
25
+ }
26
+ /** Stable, alphabetical bucketing so a re-render never reshuffles the boxes. */
27
+ function bucket(nodes, axis) {
28
+ const by = new Map();
29
+ for (const node of nodes) {
30
+ const key = axisOf(node, axis);
31
+ const list = by.get(key) ?? [];
32
+ list.push(node);
33
+ by.set(key, list);
34
+ }
35
+ return new Map([...by.entries()].sort(([a], [b]) => a.localeCompare(b)));
36
+ }
37
+ /**
38
+ * One inner box: its nodes masonry-packed by the SAME `pack` the single-level layout uses, so
39
+ * card geometry inside a subdivision is identical to card geometry inside a plain cluster.
40
+ */
41
+ function packInner(name, parent, nodes, cards) {
42
+ const inner = {
43
+ name,
44
+ parent,
45
+ depth: 1,
46
+ list: [...nodes].sort((a, b) => a.label.localeCompare(b.label)),
47
+ count: nodes.length,
48
+ groupIndex: 0,
49
+ x: 0,
50
+ y: 0
51
+ };
52
+ pack(inner, cards);
53
+ // `pack` sizes for a top-level cluster (CL_PAD + CL_TITLE). An inner box carries a shorter
54
+ // strip, so the height is re-derived rather than inherited.
55
+ const h = (inner.h ?? 0) - CL_TITLE + INNER_TITLE;
56
+ return { cluster: { ...inner, h }, w: inner.w ?? 0, h };
57
+ }
58
+ /** Flow inner boxes into wrapping rows inside one outer box. Returns the content extent. */
59
+ function flowInner(inners, limit) {
60
+ let x = 0;
61
+ let y = 0;
62
+ let rowHeight = 0;
63
+ let widest = 0;
64
+ for (const item of inners) {
65
+ if (x > 0 && x + item.w > limit) {
66
+ y += rowHeight + INNER_GAP;
67
+ x = 0;
68
+ rowHeight = 0;
69
+ }
70
+ item.cluster.x = x;
71
+ item.cluster.y = y;
72
+ x += item.w + INNER_GAP;
73
+ rowHeight = Math.max(rowHeight, item.h);
74
+ widest = Math.max(widest, x - INNER_GAP);
75
+ }
76
+ return { w: widest, h: y + rowHeight };
77
+ }
78
+ /** Shift an inner box and its cards from outer-relative to canvas coordinates. */
79
+ function translate(item, originX, originY, cards) {
80
+ const x = originX + item.cluster.x;
81
+ const y = originY + item.cluster.y;
82
+ item.cluster.x = x;
83
+ item.cluster.y = y;
84
+ for (const p of item.cluster.pos ?? []) {
85
+ const card = cards[p.key];
86
+ card.x = x + CL_PAD + p.dx;
87
+ card.y = y + INNER_TITLE + p.dy;
88
+ }
89
+ }
90
+ /** Build one outer box: bucket by the inner axis, pack each, flow them, size the container. */
91
+ function buildOuter(name, nodes, inner, cards) {
92
+ const inners = [...bucket(nodes, inner).entries()].map(([key, list]) => packInner(key, name, list, cards));
93
+ // Wide enough for the widest subdivision, so an inner box is never forced to overflow.
94
+ const limit = Math.max(...inners.map((i) => i.w), MAX_ROW_W / 3);
95
+ const extent = flowInner(inners, limit);
96
+ return {
97
+ cluster: {
98
+ name,
99
+ depth: 0,
100
+ list: [...nodes].sort((a, b) => a.label.localeCompare(b.label)),
101
+ count: nodes.length,
102
+ groupIndex: 0,
103
+ x: 0,
104
+ y: 0,
105
+ w: extent.w + CL_PAD * 2,
106
+ h: extent.h + CL_PAD * 2 + CL_TITLE
107
+ },
108
+ inners
109
+ };
110
+ }
111
+ /** Flow outer boxes into wrapping rows, then translate everything they contain. */
112
+ function placeOuters(outers, cards) {
113
+ let x = 0;
114
+ let y = 0;
115
+ let rowHeight = 0;
116
+ for (const outer of outers) {
117
+ const w = outer.cluster.w ?? 0;
118
+ if (x > 0 && x + w > MAX_ROW_W) {
119
+ y += rowHeight + CL_GAP_Y;
120
+ x = 0;
121
+ rowHeight = 0;
122
+ }
123
+ outer.cluster.x = x;
124
+ outer.cluster.y = y;
125
+ for (const item of outer.inners) {
126
+ translate(item, x + CL_PAD, y + CL_PAD + CL_TITLE, cards);
127
+ }
128
+ x += w + CL_GAP_X;
129
+ rowHeight = Math.max(rowHeight, outer.cluster.h ?? 0);
130
+ }
131
+ return {
132
+ w: Math.max(...outers.map((o) => o.cluster.x + (o.cluster.w ?? 0))) + 60,
133
+ h: y + rowHeight + 60
134
+ };
135
+ }
136
+ /**
137
+ * Two-level clusters over already-built cards.
138
+ *
139
+ * Returns a FLAT list, every outer box before every inner one — absolutely positioned
140
+ * siblings paint in document order, so an outer box emitted later would cover the
141
+ * subdivisions inside it.
142
+ */
143
+ export function nestedClusters(nodes, cards, outerAxis, innerAxis) {
144
+ if (nodes.length === 0)
145
+ return { clusters: [], size: { w: 0, h: 0 } };
146
+ const outers = [...bucket(nodes, outerAxis).entries()].map(([name, list]) => buildOuter(name, list, innerAxis, cards));
147
+ // Alphabetical index so a box keeps its colour however the rows happened to wrap — the
148
+ // stability rule `resolveGroupStyles` follows.
149
+ const names = outers.map((o) => o.cluster.name);
150
+ outers.forEach((o) => (o.cluster.groupIndex = names.indexOf(o.cluster.name)));
151
+ const size = placeOuters(outers, cards);
152
+ return {
153
+ clusters: [...outers.map((o) => o.cluster), ...outers.flatMap((o) => o.inners.map((i) => i.cluster))],
154
+ size
155
+ };
156
+ }