@rokkit/graph 1.6.0 → 1.7.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 (77) hide show
  1. package/README.md +59 -2
  2. package/dist/Graph.svelte +121 -72
  3. package/dist/GraphLegend.svelte +112 -0
  4. package/dist/GraphLegend.svelte.d.ts +33 -0
  5. package/dist/GraphState.svelte.d.ts +107 -15
  6. package/dist/GraphState.svelte.js +186 -3
  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/DiagramFrame.svelte +36 -0
  24. package/dist/diagrams/DiagramFrame.svelte.d.ts +20 -0
  25. package/dist/diagrams/ErDiagram.svelte +128 -0
  26. package/dist/diagrams/ErDiagram.svelte.d.ts +33 -0
  27. package/dist/diagrams/Neighborhood.svelte +127 -0
  28. package/dist/diagrams/Neighborhood.svelte.d.ts +33 -0
  29. package/dist/diagrams/StructureDiagram.svelte +131 -0
  30. package/dist/diagrams/StructureDiagram.svelte.d.ts +31 -0
  31. package/dist/diagrams/Sunburst.svelte +124 -0
  32. package/dist/diagrams/Sunburst.svelte.d.ts +32 -0
  33. package/dist/diagrams/Treemap.svelte +124 -0
  34. package/dist/diagrams/Treemap.svelte.d.ts +32 -0
  35. package/dist/icons.d.ts +5 -0
  36. package/dist/icons.js +5 -0
  37. package/dist/index.d.ts +18 -1
  38. package/dist/index.js +23 -1
  39. package/dist/layout/arc.d.ts +13 -0
  40. package/dist/layout/arc.js +58 -0
  41. package/dist/layout/bundle.d.ts +30 -0
  42. package/dist/layout/bundle.js +80 -0
  43. package/dist/layout/cluster.js +2 -0
  44. package/dist/layout/flow.d.ts +8 -0
  45. package/dist/layout/flow.js +141 -0
  46. package/dist/layout/hierarchy.d.ts +42 -0
  47. package/dist/layout/hierarchy.js +175 -0
  48. package/dist/layout/index.d.ts +7 -1
  49. package/dist/layout/index.js +8 -2
  50. package/dist/layout/neighborhood.js +164 -28
  51. package/dist/layout/options.d.ts +44 -0
  52. package/dist/layout/options.js +106 -0
  53. package/dist/layout/order.d.ts +11 -0
  54. package/dist/layout/order.js +153 -0
  55. package/dist/layout/points.js +27 -35
  56. package/dist/layout/radial.d.ts +16 -0
  57. package/dist/layout/radial.js +214 -0
  58. package/dist/layout/rank.d.ts +15 -0
  59. package/dist/layout/rank.js +105 -0
  60. package/dist/layout/rings.d.ts +20 -0
  61. package/dist/layout/rings.js +92 -0
  62. package/dist/layout/sizing.d.ts +37 -0
  63. package/dist/layout/sizing.js +79 -0
  64. package/dist/layout/squarify.d.ts +14 -0
  65. package/dist/layout/squarify.js +114 -0
  66. package/dist/layout/structure.d.ts +9 -0
  67. package/dist/layout/structure.js +367 -0
  68. package/dist/layout/sunburst.d.ts +8 -0
  69. package/dist/layout/sunburst.js +126 -0
  70. package/dist/layout/types.d.ts +141 -0
  71. package/dist/layout/world.d.ts +9 -0
  72. package/dist/layout/world.js +144 -0
  73. package/dist/model/normalize.js +47 -1
  74. package/dist/model/tree.d.ts +27 -0
  75. package/dist/model/tree.js +182 -0
  76. package/dist/types.d.ts +55 -3
  77. package/package.json +9 -4
@@ -21,6 +21,17 @@ export type Card = {
21
21
  * hardcoded oklch angle baked into the layout.
22
22
  */
23
23
  groupIndex?: number;
24
+ /**
25
+ * Degrees to rotate this node's label so it reads outward from a rim, already flipped on
26
+ * the left half so nothing is upside down.
27
+ *
28
+ * The layout knows the angle it placed the node at; the renderer would have to re-derive
29
+ * it from x/y and the canvas centre, which is the kind of geometry a component should not
30
+ * be doing. Absent on every layout that is not radial.
31
+ */
32
+ labelAngle?: number;
33
+ /** Which way the label runs from the dot — `end` on the left half of the circle. */
34
+ labelSide?: 'start' | 'end';
24
35
  };
25
36
  export type Cluster = {
26
37
  name: string;
@@ -47,6 +58,51 @@ export type Cluster = {
47
58
  depth?: number;
48
59
  /** Name of the enclosing cluster, for a depth-1 box. */
49
60
  parent?: string;
61
+ /**
62
+ * What to show after the name, when the layout has something better than a child count.
63
+ *
64
+ * A number beside a box reads as the thing driving its size, so in a layout where AREA
65
+ * encodes a measure the count is actively misleading — `components · 63` was 63 files in a
66
+ * box sized by 63 declarations, and the two being equal was a coincidence. A layout that
67
+ * sizes by a measure says so here; one that does not leaves it unset and the count stands.
68
+ */
69
+ caption?: string;
70
+ /**
71
+ * The node this box IS, when it is one. Set on a childless box in a containment layout;
72
+ * absent on a region, which is not selectable because it is not a thing.
73
+ *
74
+ * A treemap is a hierarchy of ONE shape — a box with a label and an area — so a leaf is a
75
+ * cluster too rather than a node card. Emitting the card put two structures in one nesting
76
+ * and dragged the card's furniture in with it: a codebase module has no rows, so every leaf
77
+ * rendered a literal `0` beside its name. Identity survives here instead.
78
+ */
79
+ nodeId?: string;
80
+ /** The node's kind, for colour, when this box is a node. */
81
+ kind?: string;
82
+ /**
83
+ * Which entry of the group ramp this box takes its colour from, when that is not its own
84
+ * name.
85
+ *
86
+ * A containment layout nests boxes many levels deep, and only the outermost ones are group
87
+ * names the ramp knows. Looked up by its own name, every descendant falls through to the
88
+ * default — which is how a sunburst's outer ring came out uniformly grey while the inner
89
+ * ring was correctly coloured. Inheriting the outermost ancestor's key makes a whole
90
+ * subtree read as one region, which is the thing the reader is tracing.
91
+ */
92
+ ramp?: string;
93
+ /**
94
+ * Polar geometry, when the layout draws this box as a WEDGE rather than a rectangle.
95
+ *
96
+ * Angles are radians from 3 o'clock, radii are from the canvas centre. Kept as numbers
97
+ * rather than a path string so the layout stays DOM-free and unit-testable to exact
98
+ * radians — building the arc is the renderer's job.
99
+ */
100
+ wedge?: {
101
+ r0: number;
102
+ r1: number;
103
+ a0: number;
104
+ a1: number;
105
+ };
50
106
  };
51
107
  /**
52
108
  * Which node property a layout groups on. Both already exist on `GraphNode`, so nesting
@@ -63,6 +119,13 @@ export type RoutedEdge = {
63
119
  /** GraphEdge.relation — the consumer's own verb, carried through for theming. */
64
120
  relation?: string;
65
121
  self: boolean;
122
+ /**
123
+ * Set when a layered layout had to reverse this edge to rank the graph — a mutual foreign
124
+ * key, which a real schema has. It is still drawn, and still leaves right and enters left,
125
+ * so it is the one connector that visibly doubles back. A theme distinguishes it rather
126
+ * than the reader wondering why one link behaves differently.
127
+ */
128
+ back?: boolean;
66
129
  x1: number;
67
130
  y1: number;
68
131
  x2: number;
@@ -70,6 +133,30 @@ export type RoutedEdge = {
70
133
  /** 1 = right side of the card, -1 = left. */
71
134
  s1: number;
72
135
  s2: number;
136
+ /**
137
+ * A ready-made path, when the shape depends on more than the two endpoints.
138
+ *
139
+ * `edgePath` only ever sees the ends, which is enough for a curve or a right-angle route.
140
+ * A bundled edge follows the whole chain of ancestors between its endpoints, so the layout
141
+ * that knows that chain builds the path and the renderer draws what it is given.
142
+ */
143
+ path?: string;
144
+ };
145
+ /**
146
+ * One column of a multi-column layout, with the heading it needs to be readable.
147
+ *
148
+ * Three columns get away with none — the focus is visibly central, so the sides are obvious.
149
+ * Five do not: without headings a depth-2 portrait is ambiguous about which direction is
150
+ * which, and the reader has to trace an arrow to find out.
151
+ */
152
+ export type Column = {
153
+ x: number;
154
+ /** Carried here so the view needs no layout constant to size a heading. */
155
+ w: number;
156
+ depth: number;
157
+ /** `in` = things that reach the focus, `out` = things it reaches, `focus` = the centre. */
158
+ side: 'in' | 'out' | 'focus';
159
+ label: string;
73
160
  };
74
161
  export type Cards = Record<string, Card>;
75
162
  export type Size = {
@@ -100,11 +187,65 @@ export type LayoutOptions = {
100
187
  * and the layout runs its original single-level path untouched.
101
188
  */
102
189
  nestBy?: NodeAxis;
190
+ /**
191
+ * What `points` sizes a node by. `degree` (the default) counts edges; `weight` reads
192
+ * `GraphNode.weight`.
193
+ *
194
+ * Degree is a good default and is not always the measure the reader is asking about — a
195
+ * 40-file module with few cross-edges should not render smaller than a 2-file one that
196
+ * happens to be chatty.
197
+ */
198
+ sizeBy?: string;
199
+ /**
200
+ * How the measure maps onto area. `linear` (the default), or `log` for a measure spanning
201
+ * orders of magnitude — declaration counts across a real repo span three or four, and
202
+ * linear flattens everything below the top few onto the minimum.
203
+ */
204
+ sizeScale?: 'linear' | 'log';
205
+ /**
206
+ * `neighborhood` only — how many hops out from the focus. Defaults to 1.
207
+ *
208
+ * One hop answers "what touches this". Two answers "what does changing this reach", which
209
+ * is the question a reader has before editing something.
210
+ */
211
+ depth?: number;
212
+ /**
213
+ * `world` only — the subtree to render as the whole canvas. `[]` is the root.
214
+ *
215
+ * This is DRILLING, not zooming: it re-runs the layout with a new root so the subtree gets
216
+ * the full canvas and its own children become visible. Zoom magnifies what is already
217
+ * there, which at world scale is a field of sub-pixel boxes.
218
+ */
219
+ focusPath?: string[];
220
+ /** `world` only — how many levels below the focus to MATERIALISE. Defaults to 2. */
221
+ levels?: number;
222
+ /**
223
+ * `radial` only — `tree` (the default) reads radius as DEPTH; `dendrogram` pins every
224
+ * leaf to the rim so leaves are compared against each other.
225
+ */
226
+ radialMode?: 'tree' | 'dendrogram';
227
+ /**
228
+ * `structure` only — how hard edges are pulled onto the tree, 0..1.
229
+ *
230
+ * 0 is a straight chord and 1 is the full route through the hierarchy. The default of 0.85
231
+ * is Holten's, and what the Sensei board's Straight/Bundled toggle switches between.
232
+ */
233
+ bundleTension?: number;
234
+ /**
235
+ * `radial` only — the node to treat as the centre, which is what drilling into a crate
236
+ * does. Its subtree becomes the whole picture.
237
+ *
238
+ * Deliberately NOT `focus`: that one defaults to the SELECTION, so clicking a node to
239
+ * highlight it would silently re-root the diagram.
240
+ */
241
+ root?: string | null;
103
242
  };
104
243
  export type LayoutResult = {
105
244
  clusters: Cluster[];
106
245
  cards: Cards;
107
246
  edges: RoutedEdge[];
247
+ /** Column headings, when the layout has columns. Empty for the grid layouts. */
248
+ columns?: Column[];
108
249
  size: Size;
109
250
  };
110
251
  /**
@@ -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)
@@ -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
+ }