@rokkit/graph 1.6.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +66 -2
  2. package/dist/Graph.svelte +219 -169
  3. package/dist/GraphLegend.svelte +112 -0
  4. package/dist/GraphLegend.svelte.d.ts +33 -0
  5. package/dist/GraphState.svelte.d.ts +115 -15
  6. package/dist/GraphState.svelte.js +213 -5
  7. package/dist/controls/BundleControl.svelte +30 -0
  8. package/dist/controls/BundleControl.svelte.d.ts +16 -0
  9. package/dist/controls/DensityControl.svelte +39 -0
  10. package/dist/controls/DensityControl.svelte.d.ts +16 -0
  11. package/dist/controls/DepthControl.svelte +34 -0
  12. package/dist/controls/DepthControl.svelte.d.ts +17 -0
  13. package/dist/controls/EdgeStyleControl.svelte +31 -0
  14. package/dist/controls/EdgeStyleControl.svelte.d.ts +14 -0
  15. package/dist/controls/ZoomControl.svelte +47 -0
  16. package/dist/controls/ZoomControl.svelte.d.ts +7 -0
  17. package/dist/controls/zoom.d.ts +6 -0
  18. package/dist/controls/zoom.js +12 -0
  19. package/dist/diagrams/CallTree.svelte +135 -0
  20. package/dist/diagrams/CallTree.svelte.d.ts +37 -0
  21. package/dist/diagrams/DependencyDiagram.svelte +124 -0
  22. package/dist/diagrams/DependencyDiagram.svelte.d.ts +30 -0
  23. package/dist/diagrams/DependencyMatrix.svelte +177 -0
  24. package/dist/diagrams/DependencyMatrix.svelte.d.ts +34 -0
  25. package/dist/diagrams/DiagramFrame.svelte +36 -0
  26. package/dist/diagrams/DiagramFrame.svelte.d.ts +20 -0
  27. package/dist/diagrams/ErDiagram.svelte +128 -0
  28. package/dist/diagrams/ErDiagram.svelte.d.ts +33 -0
  29. package/dist/diagrams/Neighborhood.svelte +127 -0
  30. package/dist/diagrams/Neighborhood.svelte.d.ts +33 -0
  31. package/dist/diagrams/StructureDiagram.svelte +131 -0
  32. package/dist/diagrams/StructureDiagram.svelte.d.ts +31 -0
  33. package/dist/diagrams/Sunburst.svelte +124 -0
  34. package/dist/diagrams/Sunburst.svelte.d.ts +32 -0
  35. package/dist/diagrams/Treemap.svelte +124 -0
  36. package/dist/diagrams/Treemap.svelte.d.ts +32 -0
  37. package/dist/icons.d.ts +5 -0
  38. package/dist/icons.js +5 -0
  39. package/dist/index.d.ts +21 -1
  40. package/dist/index.js +25 -1
  41. package/dist/layout/arc.d.ts +13 -0
  42. package/dist/layout/arc.js +58 -0
  43. package/dist/layout/bundle.d.ts +30 -0
  44. package/dist/layout/bundle.js +80 -0
  45. package/dist/layout/cluster.js +2 -0
  46. package/dist/layout/edges.js +11 -0
  47. package/dist/layout/flow.d.ts +8 -0
  48. package/dist/layout/flow.js +141 -0
  49. package/dist/layout/hierarchy.d.ts +42 -0
  50. package/dist/layout/hierarchy.js +175 -0
  51. package/dist/layout/index.d.ts +7 -1
  52. package/dist/layout/index.js +8 -2
  53. package/dist/layout/matrix.d.ts +38 -0
  54. package/dist/layout/matrix.js +90 -0
  55. package/dist/layout/neighborhood.js +164 -28
  56. package/dist/layout/options.d.ts +44 -0
  57. package/dist/layout/options.js +106 -0
  58. package/dist/layout/order.d.ts +11 -0
  59. package/dist/layout/order.js +153 -0
  60. package/dist/layout/points.js +27 -35
  61. package/dist/layout/radial.d.ts +16 -0
  62. package/dist/layout/radial.js +214 -0
  63. package/dist/layout/rank.d.ts +15 -0
  64. package/dist/layout/rank.js +105 -0
  65. package/dist/layout/rings.d.ts +20 -0
  66. package/dist/layout/rings.js +92 -0
  67. package/dist/layout/sizing.d.ts +37 -0
  68. package/dist/layout/sizing.js +79 -0
  69. package/dist/layout/squarify.d.ts +14 -0
  70. package/dist/layout/squarify.js +114 -0
  71. package/dist/layout/structure.d.ts +9 -0
  72. package/dist/layout/structure.js +367 -0
  73. package/dist/layout/sunburst.d.ts +8 -0
  74. package/dist/layout/sunburst.js +126 -0
  75. package/dist/layout/types.d.ts +145 -0
  76. package/dist/layout/world.d.ts +9 -0
  77. package/dist/layout/world.js +144 -0
  78. package/dist/model/normalize.js +66 -4
  79. package/dist/model/tree.d.ts +27 -0
  80. package/dist/model/tree.js +182 -0
  81. package/dist/types.d.ts +75 -3
  82. package/package.json +9 -4
@@ -0,0 +1,9 @@
1
+ import type { LayoutFn } from './types.js';
2
+ /**
3
+ * Containment as area.
4
+ *
5
+ * `focusPath` scopes the canvas to a subtree — the drill-down operation — and `levels` caps
6
+ * how far below it is built. Every box is a cluster whatever its depth, so the outermost
7
+ * materialised level is what a reader clicks.
8
+ */
9
+ export declare const world: LayoutFn;
@@ -0,0 +1,144 @@
1
+ /* The world view: containment as nested rectangles, area ∝ measure.
2
+ *
3
+ * Two rules do the work, and the second is the one that makes it survive real data.
4
+ *
5
+ * 1. A box's area is what it CONTAINS. That is the question the view answers — where is the
6
+ * mass — and it is why the tree sums subtrees rather than counting nodes.
7
+ *
8
+ * 2. Only `levels` are MATERIALISED. The cost of 1.18M nodes is in building them, not in
9
+ * packing them, so the layout refuses to build what it is not showing. Descending is a
10
+ * re-layout with a new root, not a zoom: `Graph`'s zoom is a scale transform over the whole
11
+ * canvas, and at that size every box is sub-pixel — the exact failure `points.ts` documents
12
+ * for cards.
13
+ *
14
+ * Rectangles rather than circles, decided in docs/design/24-world-view.md: a circular container
15
+ * discards 1 - pi/4 = 21% of its bounding box before anything is placed in it, nesting
16
+ * multiplies that per level, and a rect holds a legible label where a circle does not. You
17
+ * have to be able to read what you are about to click.
18
+ */
19
+ import { buildTree, findNode } from '../model/tree.js';
20
+ import { squarify } from './squarify.js';
21
+ import { warnUnknownOptions } from './options.js';
22
+ /** Canvas the layout lays out into. Fit-to-container scales it; the ratios are what matter. */
23
+ const CANVAS = { w: 1200, h: 800 };
24
+ /** Inset of a container's children, leaving room for its own border. */
25
+ const PAD = 4;
26
+ /** Room above a container's children for its label. */
27
+ const TITLE = 16;
28
+ /**
29
+ * Area floor, as a share of one child's EQUAL slice of its parent.
30
+ *
31
+ * Exact proportion gives a zero-measure node no box at all, which reads as missing rather than
32
+ * as unmeasured — and "unmeasured" is a normal state in a partially-indexed graph: 79 of
33
+ * rokkit's own 470 modules have degree 0.
34
+ *
35
+ * Measured against the equal slice rather than against the TOTAL, because the floor is applied
36
+ * per child and so sums with the child count. A flat `total * k` is therefore self-defeating
37
+ * exactly where it is needed: at two children it costs 2k of the canvas and leaves a 2px
38
+ * hairline, and at 470 it claims 470k — nearly the whole canvas — and proportion stops reading
39
+ * altogether. Against the equal slice the total floor is at most `k` whatever n is.
40
+ */
41
+ const MIN_EQUAL_SHARE = 0.12;
42
+ /** Walk the materialised levels, squarifying each container's children into its own rect. */
43
+ function place(node, rect, ctx) {
44
+ const { levels, out, parent, origin, ramp } = ctx;
45
+ if (levels <= 0 || node.children.length === 0)
46
+ return;
47
+ // Children are inset for the parent's border and label strip. Below that the box is all
48
+ // chrome and no content, so it stops rather than rendering a frame around nothing.
49
+ const inner = {
50
+ x: rect.x + PAD,
51
+ y: rect.y + PAD + TITLE,
52
+ w: rect.w - PAD * 2,
53
+ h: rect.h - PAD * 2 - TITLE
54
+ };
55
+ if (inner.w <= 0 || inner.h <= 0)
56
+ return;
57
+ const total = node.children.reduce((sum, c) => sum + Math.max(c.value, 0), 0);
58
+ const equal = total / node.children.length;
59
+ const placed = squarify(node.children, inner, equal * MIN_EQUAL_SHARE);
60
+ for (const { item, rect: box } of placed) {
61
+ // Depth is RELATIVE to the focus, so the outermost rendered box is always 0 whatever
62
+ // the reader has drilled into. A renderer styles by "how deep in this view", not by
63
+ // "how deep in the whole tree", and the same convention holds in `nested.ts`.
64
+ // The outermost box names the ramp entry; everything under it inherits, so a whole
65
+ // subtree reads as one region instead of every descendant falling through to the
66
+ // default colour.
67
+ const key = ramp ?? item.label;
68
+ out.push({ tree: item, rect: box, depth: node.path.length - origin, parent, ramp: key });
69
+ place(item, box, { levels: levels - 1, out, parent: item.id, origin, ramp: key });
70
+ }
71
+ }
72
+ /** Compact enough to sit in a label strip: 1200 reads as 1.2k. */
73
+ function compact(value) {
74
+ if (value >= 1_000_000)
75
+ return `${(value / 1_000_000).toFixed(1)}M`;
76
+ if (value >= 1000)
77
+ return `${(value / 1000).toFixed(1)}k`;
78
+ return String(Math.round(value));
79
+ }
80
+ /**
81
+ * Every placed box, leaf or region, as the SAME shape.
82
+ *
83
+ * A treemap is a hierarchy of one thing: a box with a label and an area. Emitting a leaf as a
84
+ * node card and a region as a label box put two visual structures inside one nesting, which
85
+ * reads as two unrelated kinds of object rather than as depth — and it dragged the card's
86
+ * furniture along: icon, kind tag and a row count, where a codebase module has no rows and so
87
+ * showed a literal `0` beside its name.
88
+ *
89
+ * A childless box keeps its node's id and kind, so uniform structure costs no identity: the
90
+ * box is still selectable and still colours by what it is.
91
+ */
92
+ function clusterOf(entry, groupIndex) {
93
+ const leaf = entry.tree.children.length === 0 ? entry.tree.node : undefined;
94
+ return {
95
+ name: entry.tree.label,
96
+ // The measure, not the child count: this box's area IS its value, so that is the
97
+ // number a reader should see beside it.
98
+ caption: compact(entry.tree.value),
99
+ parent: entry.parent,
100
+ depth: entry.depth,
101
+ ramp: entry.ramp,
102
+ nodeId: leaf?.id,
103
+ kind: leaf?.kind,
104
+ list: entry.tree.children.map((c) => c.node).filter((n) => n !== undefined),
105
+ count: entry.tree.children.length,
106
+ groupIndex,
107
+ x: entry.rect.x,
108
+ y: entry.rect.y,
109
+ w: entry.rect.w,
110
+ h: entry.rect.h
111
+ };
112
+ }
113
+ /**
114
+ * Containment as area.
115
+ *
116
+ * `focusPath` scopes the canvas to a subtree — the drill-down operation — and `levels` caps
117
+ * how far below it is built. Every box is a cluster whatever its depth, so the outermost
118
+ * materialised level is what a reader clicks.
119
+ */
120
+ export const world = (model, options) => {
121
+ warnUnknownOptions(options, 'world');
122
+ const tree = buildTree(model, { measure: options.sizeBy ?? 'weight' });
123
+ const root = findNode(tree, options.focusPath ?? []);
124
+ if (!root || root.children.length === 0) {
125
+ return { clusters: [], cards: {}, edges: [], size: { w: 0, h: 0 } };
126
+ }
127
+ const placed = [];
128
+ place(root, { x: 0, y: 0, w: CANVAS.w, h: CANVAS.h }, {
129
+ levels: options.levels ?? 2,
130
+ out: placed,
131
+ origin: root.path.length
132
+ });
133
+ // Edges are deliberately absent: containment IS the relationship here, and at world scale
134
+ // the adjacency graph cannot be drawn. Cross-container aggregation is its own design.
135
+ //
136
+ // `cards` is empty by construction rather than by filtering — there is no box in this
137
+ // layout that a card could represent.
138
+ return {
139
+ clusters: placed.map(clusterOf),
140
+ cards: {},
141
+ edges: [],
142
+ size: CANVAS
143
+ };
144
+ };
@@ -3,6 +3,39 @@ const BADGE_ORDER = ['pk', 'fk', 'uq', 'nn'];
3
3
  function str(value) {
4
4
  return typeof value === 'string' ? value : undefined;
5
5
  }
6
+ /**
7
+ * Named quantities, keeping only the finite numbers — a measure is a quantity, and a string
8
+ * or a NaN reaching a size calculation produces a card with no width at all.
9
+ */
10
+ function measures(value) {
11
+ if (value === null || typeof value !== 'object')
12
+ return undefined;
13
+ const out = {};
14
+ for (const [key, raw] of Object.entries(value)) {
15
+ const n = num(raw);
16
+ if (n !== undefined)
17
+ out[key] = n;
18
+ }
19
+ return Object.keys(out).length > 0 ? out : undefined;
20
+ }
21
+ /**
22
+ * A containment path, from either an array or a delimited string.
23
+ *
24
+ * A file path arrives as `'src/lib/parse.ts'`; requiring every consumer to split it is asking
25
+ * for the same three lines everywhere. Empty segments are dropped, so a leading, trailing or
26
+ * doubled separator is harmless rather than producing a nameless container.
27
+ */
28
+ function segments(value, delimiter) {
29
+ const raw = typeof value === 'string' ? value.split(delimiter) : value;
30
+ if (!Array.isArray(raw))
31
+ return undefined;
32
+ const parts = raw.filter((v) => typeof v === 'string' && v.length > 0);
33
+ return parts.length > 0 ? parts : undefined;
34
+ }
35
+ /** A finite number, or nothing. NaN and Infinity are not measures. */
36
+ function num(value) {
37
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
38
+ }
6
39
  function pick(row, path, fallbackKey) {
7
40
  return readPath(row, path ?? fallbackKey);
8
41
  }
@@ -26,7 +59,17 @@ function buildRows(source, fields) {
26
59
  }
27
60
  // Every node-level field the canonical model reads. A claimed key must NOT also
28
61
  // land in `meta`, or the same value sits in two places and the two can drift.
29
- const CLAIMED_NODE_KEYS = ['id', 'label', 'group', 'kind', 'rows', 'note'];
62
+ const CLAIMED_NODE_KEYS = [
63
+ 'id',
64
+ 'label',
65
+ 'group',
66
+ 'kind',
67
+ 'weight',
68
+ 'path',
69
+ 'measures',
70
+ 'rows',
71
+ 'note'
72
+ ];
30
73
  function buildMeta(source, fields) {
31
74
  if (source === null || typeof source !== 'object')
32
75
  return {};
@@ -51,6 +94,9 @@ function buildNode(source, fields) {
51
94
  label,
52
95
  group,
53
96
  kind: str(pick(source, fields.kind, 'kind')),
97
+ weight: num(pick(source, fields.weight, 'weight')),
98
+ path: segments(pick(source, fields.path, 'path'), fields.pathDelimiter ?? '/'),
99
+ measures: measures(pick(source, fields.measures, 'measures')),
54
100
  rows: buildRows(source, fields),
55
101
  note: str(pick(source, fields.note, 'note')),
56
102
  meta: buildMeta(source, fields)
@@ -156,9 +202,23 @@ function buildEdge(source, fields, byId, seen) {
156
202
  relation,
157
203
  cardinality: str(pick(source, fields.cardinality, 'cardinality')),
158
204
  action: str(pick(source, fields.action, 'action')),
159
- unplaced
205
+ unplaced,
206
+ ...edgeExtras(source, fields)
160
207
  };
161
208
  }
209
+ /**
210
+ * `overlay` and `weight`, added only when present so every edge a v1 consumer already has
211
+ * stays key-for-key identical — `toEqual` on an edge is a contract downstream specs rely on.
212
+ */
213
+ function edgeExtras(source, fields) {
214
+ const extras = {};
215
+ if (pick(source, fields.overlay, 'overlay'))
216
+ extras.overlay = true;
217
+ const weight = num(pick(source, fields.edgeWeight, 'weight'));
218
+ if (weight !== undefined)
219
+ extras.weight = weight;
220
+ return extras;
221
+ }
162
222
  /**
163
223
  * The source row an edge makes a foreign key, if any.
164
224
  *
@@ -210,6 +270,8 @@ export function normalizeGraph(nodes, edges, fields = {}) {
210
270
  const resolved = edges
211
271
  .map((source) => buildEdge(source, fields, byId, seen))
212
272
  .filter((edge) => edge !== null);
213
- markForeignKeys(byId, resolved);
214
- return { nodes: built, edges: resolved, byId, neighbors: buildNeighbors(resolved) };
273
+ const structural = resolved.filter((edge) => !edge.overlay);
274
+ const overlays = resolved.filter((edge) => edge.overlay);
275
+ markForeignKeys(byId, structural);
276
+ return { nodes: built, edges: structural, overlays, byId, neighbors: buildNeighbors(structural) };
215
277
  }
@@ -0,0 +1,27 @@
1
+ import type { GraphModel, GraphNode } from '../types.js';
2
+ export type TreeNode = {
3
+ /** Path joined by `/`. The root is `''`. */
4
+ id: string;
5
+ label: string;
6
+ /** Full path to this node, outermost first. Empty at the root. */
7
+ path: string[];
8
+ /** 0 at the root. A layout materialising N levels reads this. */
9
+ depth: number;
10
+ /** The real node behind this box. Absent only on a container nothing declared. */
11
+ node?: GraphNode;
12
+ children: TreeNode[];
13
+ /** This subtree's summed measure, including its own leaves. */
14
+ value: number;
15
+ };
16
+ /**
17
+ * The containment tree for a model.
18
+ *
19
+ * Containment is PREFIX: a node whose path extends past another's is inside it. That is what
20
+ * makes a container an ordinary node — it keeps its own id, label, note and edges, and there
21
+ * is no convention to satisfy. A prefix nothing declares is synthesised.
22
+ */
23
+ export declare function buildTree(model: GraphModel, options?: {
24
+ measure?: string;
25
+ }): TreeNode;
26
+ /** The node at `path`, or undefined. */
27
+ export declare function findNode(root: TreeNode, path: string[]): TreeNode | undefined;
@@ -0,0 +1,182 @@
1
+ /* The containment tree, derived from `GraphNode.path`.
2
+ *
3
+ * Three jobs, none of them geometry:
4
+ *
5
+ * 1. SYNTHESISE the containers the data implies but never states. A codebase indexes files
6
+ * and symbols; the folders between them are implied by the path, and requiring a node per
7
+ * folder would make the model refuse data that is perfectly well-formed.
8
+ * 2. FOLD a container that adds no level. `src › lib › index.ts` with nothing else at either
9
+ * level is three boxes for one file — that is punctuation, not a hierarchy.
10
+ * 3. SUM each subtree's measure, so a container's area can be what it CONTAINS rather than
11
+ * what it is.
12
+ *
13
+ * Pure and deterministic like every layout here, and deliberately separate from
14
+ * `normalizeGraph`: most graphs are flat, and building a tree they never read would be waste
15
+ * on every re-render.
16
+ */
17
+ const ROOT = '';
18
+ /** A synthesised box's id is its path joined. A declared node replaces it with its own. */
19
+ const idOf = (path) => path.join('/');
20
+ function container(path) {
21
+ return {
22
+ id: idOf(path),
23
+ label: path[path.length - 1] ?? ROOT,
24
+ path,
25
+ depth: path.length,
26
+ children: [],
27
+ value: 0
28
+ };
29
+ }
30
+ /**
31
+ * Walk to `path`, synthesising any box along the way that nothing has declared yet.
32
+ *
33
+ * Matched on the path SEGMENT, not on the label: a declared node may label its box anything
34
+ * ('Lexer' for the segment 'lexer'), and matching on label would then fail to find the box it
35
+ * had already created.
36
+ */
37
+ function ensure(root, path) {
38
+ let current = root;
39
+ for (let i = 0; i < path.length; i++) {
40
+ const segment = path[i];
41
+ let next = current.children.find((c) => c.path[c.path.length - 1] === segment);
42
+ if (!next) {
43
+ next = container(path.slice(0, i + 1));
44
+ current.children.push(next);
45
+ }
46
+ current = next;
47
+ }
48
+ return current;
49
+ }
50
+ /**
51
+ * The number a node contributes to its subtree.
52
+ *
53
+ * `weight` is the named default, so a caller that shipped against #161 is unaffected; any
54
+ * other name reads the measures bag. Absent is 0 rather than a break — "unknown" is a normal
55
+ * state in a partially-indexed graph.
56
+ */
57
+ function measureOf(node, measure, degree) {
58
+ if (measure === 'degree')
59
+ return degree.get(node.id) ?? 0;
60
+ if (measure === 'weight')
61
+ return node.weight ?? 0;
62
+ return node.measures?.[measure] ?? 0;
63
+ }
64
+ /**
65
+ * Edges touching each node.
66
+ *
67
+ * `degree` is the package-wide default for `sizeBy`, so the tree has to understand it or a
68
+ * caller that never set a measure gets a silent zero for every node — and a tree of zeroes
69
+ * falls through to an even split, which renders a 27:1 ratio as 1:1 with no error anywhere.
70
+ */
71
+ function degreesIn(model) {
72
+ const degree = new Map();
73
+ for (const edge of model.edges) {
74
+ if (edge.unplaced)
75
+ continue;
76
+ degree.set(edge.source, (degree.get(edge.source) ?? 0) + 1);
77
+ if (edge.target !== edge.source) {
78
+ degree.set(edge.target, (degree.get(edge.target) ?? 0) + 1);
79
+ }
80
+ }
81
+ return degree;
82
+ }
83
+ /**
84
+ * Attach a real node to the box at its own path.
85
+ *
86
+ * The box may already exist — synthesised by a descendant that arrived first — in which case
87
+ * this fills in its identity rather than creating a sibling. That is the whole of "a container
88
+ * is an ordinary node": no claiming, no id convention, just the same walk.
89
+ */
90
+ function attach(root, node, measure, degree) {
91
+ const box = ensure(root, node.path ?? []);
92
+ if (box === root) {
93
+ // No path: a root-level leaf, not a redefinition of the root itself.
94
+ root.children.push({
95
+ id: node.id,
96
+ label: node.label,
97
+ path: [],
98
+ depth: 1,
99
+ node,
100
+ children: [],
101
+ value: measureOf(node, measure, degree)
102
+ });
103
+ return;
104
+ }
105
+ box.id = node.id;
106
+ box.label = node.label;
107
+ box.node = node;
108
+ // A container can hold declarations of its OWN — a file has top-level code as well as the
109
+ // functions in it — so this is added to the subtree rather than replaced by it.
110
+ box.value += measureOf(node, measure, degree);
111
+ }
112
+ /**
113
+ * Sum every subtree bottom-up, and re-derive depth after folding has shortened paths.
114
+ *
115
+ * A leaf's value is its own measure; a container's is what it holds. That is what lets a box's
116
+ * area mean "how much is in here" rather than "how big is this one thing".
117
+ */
118
+ function summarise(node, depth) {
119
+ node.depth = depth;
120
+ if (node.children.length === 0)
121
+ return node.value;
122
+ // `node.value` starts as the container's OWN measure, if it declared one — a module can
123
+ // hold top-level declarations as well as the functions inside it.
124
+ node.value += node.children.reduce((total, child) => total + summarise(child, depth + 1), 0);
125
+ return node.value;
126
+ }
127
+ /**
128
+ * Collapse a container that holds exactly one child and nothing of its own.
129
+ *
130
+ * NOT applied to a DECLARED container: a node that exists carries its own label, note and
131
+ * edges, and folding it would silently drop them. The rule is "a wrapper is not a level", and
132
+ * a thing the data names is not a wrapper.
133
+ */
134
+ function fold(node) {
135
+ node.children = node.children.map(fold);
136
+ if (node.children.length === 1 && node.node === undefined && node.depth > 0) {
137
+ const only = node.children[0];
138
+ // The child takes this box's PLACE, so the segment this box contributed is spliced out
139
+ // of its path — not merely trimmed from the end, which would leave a leaf sitting at
140
+ // the very path that was just removed.
141
+ const cut = node.path.length - 1;
142
+ return { ...only, path: [...only.path.slice(0, cut), ...only.path.slice(cut + 1)] };
143
+ }
144
+ return node;
145
+ }
146
+ /** Depth-first, alphabetical: a re-render must never reshuffle the picture. */
147
+ function sort(node) {
148
+ node.children.sort((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id));
149
+ for (const child of node.children)
150
+ sort(child);
151
+ }
152
+ /**
153
+ * The containment tree for a model.
154
+ *
155
+ * Containment is PREFIX: a node whose path extends past another's is inside it. That is what
156
+ * makes a container an ordinary node — it keeps its own id, label, note and edges, and there
157
+ * is no convention to satisfy. A prefix nothing declares is synthesised.
158
+ */
159
+ export function buildTree(model, options = {}) {
160
+ const measure = options.measure ?? 'weight';
161
+ const degree = measure === 'degree' ? degreesIn(model) : new Map();
162
+ const root = container([]);
163
+ root.depth = 0;
164
+ // One pass, one rule: every node is the box at its own path. Order does not matter —
165
+ // a descendant arriving first synthesises the box, and its owner fills in the identity.
166
+ for (const node of model.nodes)
167
+ attach(root, node, measure, degree);
168
+ const folded = fold(root);
169
+ sort(folded);
170
+ summarise(folded, 0);
171
+ return folded;
172
+ }
173
+ /** The node at `path`, or undefined. */
174
+ export function findNode(root, path) {
175
+ let current = root;
176
+ for (const segment of path) {
177
+ current = current?.children.find((c) => c.path[c.path.length - 1] === segment);
178
+ if (!current)
179
+ return undefined;
180
+ }
181
+ return current;
182
+ }
package/dist/types.d.ts CHANGED
@@ -21,6 +21,41 @@ export type GraphNode = {
21
21
  group?: string;
22
22
  /** Closed vocabulary — table | view | matview | function | procedure | enum. */
23
23
  kind?: string;
24
+ /**
25
+ * A quantity this node carries — declarations in a module, rows in a table, bytes in a
26
+ * bundle. Drives size in `points` (`sizeBy: 'weight'`) and the sequential colour channel.
27
+ *
28
+ * Optional because most graphs have no such number, and degree stays the default so no
29
+ * existing caller changes. A node without one sizes at the floor rather than vanishing.
30
+ */
31
+ weight?: number;
32
+ /**
33
+ * Named quantities this node carries — `{ declarations: 900, unresolved: 0.9 }`.
34
+ *
35
+ * A bag rather than more fields because size and shade are INDEPENDENT measures with
36
+ * different ranges, and the control that motivates them switches between several at
37
+ * runtime: naming a key re-runs the layout, where re-mapping `fields` would re-normalize
38
+ * the whole model on every toggle. `weight` remains the named default for size.
39
+ */
40
+ measures?: Record<string, number>;
41
+ /**
42
+ * This node's OWN full path, outermost first and INCLUDING itself —
43
+ * `['dbd', 'core', 'lexer', 'parse']`.
44
+ *
45
+ * `group` is one flat key and a codebase is containment six deep: project › repository ›
46
+ * folder › module › file › symbol. A path needs nothing to exist that does not, and cannot
47
+ * dangle the way a `parent` id can. A node without one sits at the root.
48
+ *
49
+ * **Containment is PREFIX**, which is what makes a container an ordinary node: anything
50
+ * whose path extends past this one is inside it. A container therefore keeps its own id,
51
+ * label, note, measures and edges — there is no id convention to satisfy and no second
52
+ * kind of node. A prefix nothing declares is synthesised, so the easy case still needs
53
+ * only leaves.
54
+ *
55
+ * Accepts a delimited STRING too (`'dbd/core/lexer/parse'`), since that is how a file path
56
+ * arrives. See `GraphFields.pathDelimiter`.
57
+ */
58
+ path?: string[];
24
59
  rows: GraphRow[];
25
60
  note?: string;
26
61
  /** Source fields the map did not claim, passed through untouched. */
@@ -59,10 +94,26 @@ export type GraphEdge = {
59
94
  * edge is kept and marked, and it is the renderer's job to dim it.
60
95
  */
61
96
  unplaced?: 'source' | 'target' | 'both';
97
+ /**
98
+ * Drawn over the picture but never allowed to shape it — a co-change pair, a layering
99
+ * rule broken, a suggested dependency.
100
+ *
101
+ * Such an edge is exactly what the reader wants to see and exactly what must not re-rank a
102
+ * layered layout or pull a cluster ordering: files that change together with no import
103
+ * between them would otherwise be drawn next to each other, hiding the very coupling the
104
+ * overlay exists to expose. So they never enter `GraphModel.edges`; they live in
105
+ * `GraphModel.overlays` and are routed after the layout has run.
106
+ */
107
+ overlay?: boolean;
108
+ /** A strength this edge carries — co-change count, call frequency. Drives stroke width. */
109
+ weight?: number;
62
110
  };
63
111
  export type GraphModel = {
64
112
  nodes: GraphNode[];
113
+ /** Structural edges — the ones layouts, neighbours and relationships see. */
65
114
  edges: GraphEdge[];
115
+ /** Overlay edges (`GraphEdge.overlay`) — routed after layout, never shaping it. */
116
+ overlays: GraphEdge[];
66
117
  byId: Map<string, GraphNode>;
67
118
  /** Undirected adjacency, self-edges excluded. */
68
119
  neighbors: Map<string, Set<string>>;
@@ -85,8 +136,17 @@ export type GraphProps = {
85
136
  arrange?: import('./layout/types.js').Arrange;
86
137
  /** Outer grouping axis — `group` (default) or `kind`. */
87
138
  groupBy?: import('./layout/types.js').NodeAxis;
139
+ /** Paint each card with its group's ramp — schema stays visible in a clusterless layout. */
140
+ groupTint?: boolean;
88
141
  /** Subdivide each cluster by a second axis. Omit for one level. */
89
142
  nestBy?: import('./layout/types.js').NodeAxis;
143
+ /**
144
+ * `points` only — what a node's size encodes. `degree` (default) counts edges, `weight`
145
+ * reads `GraphNode.weight`, and any other string names a key in `GraphNode.measures`.
146
+ */
147
+ sizeBy?: string;
148
+ /** How the measure maps onto area — `linear` (default) or `log`. */
149
+ sizeScale?: 'linear' | 'log';
90
150
  edgeStyle?: import('./layout/types.js').EdgeStyle;
91
151
  /** `neighborhood` only — the node the view centres on. Defaults to the selection. */
92
152
  focus?: string | null;
@@ -99,16 +159,16 @@ export type GraphProps = {
99
159
  */
100
160
  zoom?: number;
101
161
  /** Built-in zoom controls, ctrl/pinch-wheel zoom and drag-to-pan. Default true. */
162
+ /** Ctrl/pinch-wheel zoom and drag-to-pan. The BUTTON bar is `ZoomControl`, placed by you. */
102
163
  zoomable?: boolean;
103
- /** Built-in names/keys/all detail toggle on the canvas. Default true. */
104
- densityToggle?: boolean;
105
164
  /**
106
165
  * Draw an arrowhead at each edge's target instead of a plain anchor dot. Default true —
107
166
  * an edge is directed, and two identical dots discard that.
108
167
  */
109
168
  arrows?: boolean;
110
169
  label?: string;
111
- onselect?: (id: string) => void;
170
+ /** Fires on selection AND on clear, where it receives `null`. */
171
+ onselect?: (id: string | null) => void;
112
172
  /** Icon class per node kind and row badge, merged over the built-in map. */
113
173
  icons?: Record<string, string>;
114
174
  class?: string;
@@ -123,6 +183,14 @@ export type GraphFields = {
123
183
  label?: string;
124
184
  group?: string;
125
185
  kind?: string;
186
+ /** Path to a per-node quantity — see `GraphNode.weight`. */
187
+ weight?: string;
188
+ /** Path to a containment chain — see `GraphNode.path`. */
189
+ path?: string;
190
+ /** Separator when `path` resolves to a STRING rather than an array. Defaults to `/`. */
191
+ pathDelimiter?: string;
192
+ /** Path to an object of named quantities — see `GraphNode.measures`. */
193
+ measures?: string;
126
194
  rows?: string;
127
195
  note?: string;
128
196
  /** Row-level paths, read against each entry of the `rows` array. */
@@ -155,6 +223,10 @@ export type GraphFields = {
155
223
  defaultEdgeKind?: EdgeKind;
156
224
  /** Path to the consumer's own relationship word — see `GraphEdge.relation`. */
157
225
  relation?: string;
226
+ /** Path to a truthy flag marking an overlay edge — see `GraphEdge.overlay`. */
227
+ overlay?: string;
228
+ /** Path to an edge's strength — see `GraphEdge.weight`. */
229
+ edgeWeight?: string;
158
230
  cardinality?: string;
159
231
  action?: string;
160
232
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokkit/graph",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "Node-link diagram components — pluggable layouts, ER/schema views",
5
5
  "repository": {
6
6
  "type": "git",
@@ -20,6 +20,11 @@
20
20
  "svelte": "./dist/index.js",
21
21
  "default": "./dist/index.js"
22
22
  },
23
+ "./icons": {
24
+ "types": "./dist/icons.d.ts",
25
+ "svelte": "./dist/icons.js",
26
+ "default": "./dist/icons.js"
27
+ },
23
28
  "./schema": {
24
29
  "types": "./dist/schema/index.d.ts",
25
30
  "svelte": "./dist/schema/index.js",
@@ -41,10 +46,10 @@
41
46
  "check:types": "tsc --noEmit"
42
47
  },
43
48
  "dependencies": {
44
- "@rokkit/core": "1.6.0"
49
+ "@rokkit/core": "1.8.0"
45
50
  },
46
51
  "peerDependencies": {
47
- "@rokkit/ui": "1.6.0",
52
+ "@rokkit/ui": "1.8.0",
48
53
  "svelte": "^5.0.0"
49
54
  },
50
55
  "peerDependenciesMeta": {
@@ -53,7 +58,7 @@
53
58
  }
54
59
  },
55
60
  "devDependencies": {
56
- "@rokkit/ui": "1.6.0",
61
+ "@rokkit/ui": "1.8.0",
57
62
  "@sveltejs/package": "^2.5.8",
58
63
  "@sveltejs/vite-plugin-svelte": "^6.2.4",
59
64
  "svelte": "^5.55.7",