@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,367 @@
1
+ /* The structure view: a codebase as a radial dendrogram with its calls bundled through it.
2
+ *
3
+ * The layout `CallTree` could not be. A call graph's own spanning tree has hundreds of roots
4
+ * over a real repo, so capping its depth prunes almost nothing and the rim comes out a grey
5
+ * smear. A codebase's structure is CONTAINMENT — crate, module, file — and that is a real
6
+ * tree with one root, which is what makes "show two levels, then drill" mean something.
7
+ *
8
+ * So the hierarchy here is `buildTree` over `path`, exactly the tree `world` and `sunburst`
9
+ * use, and the calls are drawn ON it rather than defining it:
10
+ *
11
+ * - every LEAF sits on one rim, so leaves are comparable and the rim can hold labels
12
+ * - each ancestor level gets an arc outside that rim, naming the region under it
13
+ * - an edge is routed up to the two ends' lowest common ancestor and back down, then
14
+ * relaxed toward the straight chord by `bundleTension`
15
+ *
16
+ * Bundling is the part that makes it readable at scale: edges travelling the same way through
17
+ * the tree are pulled together, so a few hundred chords become visible flows between parts of
18
+ * the structure instead of a solid disc of ink.
19
+ *
20
+ * Modelled on Sensei's Structure board (docs/mockups/Sensei, "Project window · Diagrams ·
21
+ * Structure"), which is d3.cluster + lineRadial + curveBundle over the same hierarchy.
22
+ */
23
+ import { buildTree, findNode } from '../model/tree.js';
24
+ import { bundlePath, commonDepth } from './bundle.js';
25
+ import { nodeSizes } from './sizing.js';
26
+ import { warnUnknownOptions } from './options.js';
27
+ /** Smallest leaf rim. Everything inside is tree, everything outside is annotation. */
28
+ const MIN_RIM = 300;
29
+ /**
30
+ * Arc length reserved per leaf, which is what the rim's circumference has to satisfy.
31
+ *
32
+ * The rim GROWS with the leaf count rather than dividing a fixed circle into ever-thinner
33
+ * slices — 470 modules on a 300px rim is four pixels each, which is the grey smear this
34
+ * layout exists to replace.
35
+ */
36
+ const LEAF_ARC = 22;
37
+ /** Gap from the rim to the first band of ancestor arcs, leaving room for a leaf label. */
38
+ const BAND_GAP = 58;
39
+ /** Thickness of one ancestor band. */
40
+ const BAND = 8;
41
+ /** Space between bands, and the room a band's own label needs. */
42
+ const BAND_STEP = 26;
43
+ /** Margin outside the outermost band. */
44
+ const PAD = 40;
45
+ /**
46
+ * Cut the tree off at `limit`, making everything at that depth a leaf.
47
+ *
48
+ * This is what `levels` means here, and the thing that makes the view usable: the rim holds
49
+ * whatever level you asked for, so 1 is a dozen crates you can read and 3 is every file. Left
50
+ * uncut, a repo puts 470 leaves on one rim at a fraction of a degree each — unreadable at any
51
+ * zoom, with a depth control that appeared to do nothing because it only drew rings.
52
+ *
53
+ * `buildTree` has already summed each subtree, so a cut node keeps the weight of everything
54
+ * it now stands for.
55
+ */
56
+ function truncate(node, limit) {
57
+ if (node.depth >= limit)
58
+ return { ...node, children: [] };
59
+ return { ...node, children: node.children.map((child) => truncate(child, limit)) };
60
+ }
61
+ /** Leaves in walk order, so a parent's children stay adjacent on the rim. */
62
+ function leavesOf(root) {
63
+ const out = [];
64
+ const visit = (n) => {
65
+ if (n.children.length === 0) {
66
+ out.push(n);
67
+ return;
68
+ }
69
+ for (const child of n.children)
70
+ visit(child);
71
+ };
72
+ visit(root);
73
+ return out;
74
+ }
75
+ /**
76
+ * An angle for every node: leaves get equal slots, a parent sits at the mean of its children.
77
+ *
78
+ * The "cluster" in d3.cluster. Without the centring a parent sits at a fixed slot and every
79
+ * edge routed through it crosses the neighbouring subtrees to get there — which defeats the
80
+ * bundling, since the bundle follows exactly those parents.
81
+ */
82
+ function place(root, step, rim) {
83
+ const placed = new Map();
84
+ const byPath = new Map();
85
+ const depth = maxDepthOf(root);
86
+ const visit = (node, slot) => {
87
+ const leaf = node.children.length === 0;
88
+ let angle;
89
+ if (leaf) {
90
+ angle = slot.next * step;
91
+ slot.next++;
92
+ }
93
+ else {
94
+ const kids = node.children.map((child) => visit(child, slot));
95
+ angle = kids.reduce((sum, a) => sum + a, 0) / kids.length;
96
+ }
97
+ // Every LEAF on the rim, whatever its depth — that is the dendrogram property, and what
98
+ // makes leaves comparable and the rim able to hold labels. A folded single-child
99
+ // wrapper leaves its leaf a level shallower than its neighbours, so a depth-derived
100
+ // radius would put two files on different rings for a reason about the TREE rather
101
+ // than about them. Ancestors sit proportionally inside, so a bundle bows inward.
102
+ // No zero guard on `depth`: the layout returns early for a root with no children, so a
103
+ // tree that reaches here is at least one level deep.
104
+ const radius = leaf ? rim : (node.depth / depth) * rim;
105
+ placed.set(node.id, { node, angle, radius });
106
+ byPath.set(node.path.join('/'), node);
107
+ return angle;
108
+ };
109
+ visit(root, { next: 0 });
110
+ return { placed, byPath };
111
+ }
112
+ function maxDepthOf(root) {
113
+ let deepest = 0;
114
+ const visit = (n) => {
115
+ deepest = Math.max(deepest, n.depth);
116
+ for (const child of n.children)
117
+ visit(child);
118
+ };
119
+ visit(root);
120
+ return deepest;
121
+ }
122
+ /** Polar to canvas coordinates. 0 is 12 o'clock, so the first leaf is at the top. */
123
+ function at(centre, angle, radius) {
124
+ return {
125
+ x: centre + Math.sin(angle) * radius,
126
+ y: centre - Math.cos(angle) * radius
127
+ };
128
+ }
129
+ /**
130
+ * An ancestor band, drawn as a wedge outside the rim.
131
+ *
132
+ * Reuses `Cluster.wedge`, the same geometry a sunburst emits — so a renderer that can draw one
133
+ * can draw these, and the theme does not learn a second vocabulary for the same shape.
134
+ */
135
+ function band(node, ctx, index) {
136
+ const leaves = leavesOf(node);
137
+ const first = ctx.placed.get(leaves[0].id);
138
+ const last = ctx.placed.get(leaves[leaves.length - 1].id);
139
+ // Half a slot of padding at each end, so adjacent bands read as separate regions rather
140
+ // than as one ring with hairlines in it.
141
+ const half = ctx.step * 0.48;
142
+ // Depth RELATIVE to the focus, the same convention `world` and `sunburst` use: the
143
+ // innermost band is always 0 whatever the reader has drilled into.
144
+ const depth = node.depth - ctx.origin - 1;
145
+ const inner = ctx.rim + BAND_GAP + depth * BAND_STEP;
146
+ return {
147
+ name: node.label,
148
+ caption: String(leaves.length),
149
+ depth,
150
+ // The enclosing band's PATH, not its label. Two crates can each hold a module called
151
+ // `lib`, and `clusterKey` is `depth:parent:name` — keyed by label alone that is a
152
+ // duplicate key, Svelte throws `each_key_duplicate` and the whole render aborts, so
153
+ // no band appears at all rather than one looking wrong.
154
+ parent: node.path.slice(0, -1).join('/'),
155
+ ramp: node.path[ctx.origin],
156
+ count: node.children.length,
157
+ list: node.children.map((c) => c.node).filter((n) => n !== undefined),
158
+ groupIndex: index,
159
+ wedge: { r0: inner, r1: inner + BAND, a0: first.angle - half, a1: last.angle + half },
160
+ x: 0,
161
+ y: 0,
162
+ w: 0,
163
+ h: 0
164
+ };
165
+ }
166
+ /** Every ancestor level below the focus, outermost band last. */
167
+ function bands(root, ctx, levels) {
168
+ const out = [];
169
+ const limit = ctx.origin + levels;
170
+ const visit = (node) => {
171
+ if (node.depth > ctx.origin && node.children.length > 0 && node.depth <= limit) {
172
+ out.push(band(node, ctx, out.length));
173
+ }
174
+ if (node.depth < limit)
175
+ for (const child of node.children)
176
+ visit(child);
177
+ };
178
+ visit(root);
179
+ return out;
180
+ }
181
+ /** The route an edge takes through the tree: up to the common ancestor, then back down. */
182
+ function route(a, b, ctx) {
183
+ const shared = commonDepth(a.path, b.path);
184
+ const spot = (path) => {
185
+ const node = ctx.byPath.get(path.join('/'));
186
+ const p = node && ctx.placed.get(node.id);
187
+ return p ? at(ctx.centre, p.angle, p.radius) : undefined;
188
+ };
189
+ const up = [];
190
+ const down = [];
191
+ for (let d = a.path.length; d >= shared; d--) {
192
+ const p = spot(a.path.slice(0, d));
193
+ if (p)
194
+ up.push(p);
195
+ }
196
+ for (let d = shared + 1; d <= b.path.length; d++) {
197
+ const p = spot(b.path.slice(0, d));
198
+ if (p)
199
+ down.push(p);
200
+ }
201
+ return [...up, ...down];
202
+ }
203
+ /**
204
+ * How far an edge travels in the TREE, which is what a reader filters on.
205
+ *
206
+ * `local` stays inside one module, `crate` crosses modules within a package, `cross` leaves
207
+ * the package. The same three the Sensei board toggles, and the reason is the same: "which
208
+ * calls leave this crate" is a different question from "how chatty is this module".
209
+ */
210
+ function reach(a, b) {
211
+ const shared = commonDepth(a.path, b.path);
212
+ // Measured against each end's OWN depth rather than against a fixed level, because the
213
+ // tree folds single-child wrappers — `repo/site/ui/render` and `repo/ui/render` are the
214
+ // same file, and a rule counting absolute depth would classify it differently depending on
215
+ // how many siblings its module happened to have.
216
+ const sameParent = shared >= a.path.length - 1 && shared >= b.path.length - 1;
217
+ const sameGrandparent = shared >= a.path.length - 2 && shared >= b.path.length - 2;
218
+ if (sameParent)
219
+ return 'local';
220
+ return sameGrandparent ? 'crate' : 'cross';
221
+ }
222
+ function leafIndex(root, model) {
223
+ const byNode = new Map();
224
+ const leaves = leavesOf(root);
225
+ // By PATH, not by node: after a cut, a file's own node is no longer a leaf — the region
226
+ // standing for it is. An edge between two files in the same crate becomes an edge from
227
+ // that crate to itself, which is then dropped as a self-link. That is the aggregation
228
+ // that makes a cut view readable: only calls that actually cross the boundary survive.
229
+ const byPathKey = new Map(leaves.map((leaf) => [leaf.path.join('/'), leaf]));
230
+ for (const node of model.nodes) {
231
+ const path = node.path ?? [];
232
+ for (let d = path.length; d >= 0; d--) {
233
+ const leaf = byPathKey.get(path.slice(0, d).join('/'));
234
+ if (leaf) {
235
+ byNode.set(node.id, leaf);
236
+ break;
237
+ }
238
+ }
239
+ }
240
+ return byNode;
241
+ }
242
+ function routeAll(model, root, ctx, tension) {
243
+ const byNode = leafIndex(root, model);
244
+ const routed = [];
245
+ model.edges.forEach((edge, i) => {
246
+ const a = byNode.get(edge.source);
247
+ const b = byNode.get(edge.target);
248
+ // A self-call has no route through the tree, and an endpoint outside the materialised
249
+ // levels has no point to start from. Both are dropped rather than drawn to the centre.
250
+ if (!a || !b || a === b)
251
+ return;
252
+ // At least two points by construction: distinct leaves contribute one each, whatever
253
+ // the chain between them, so there is no degenerate route to guard against.
254
+ const points = route(a, b, ctx);
255
+ const from = points[0];
256
+ const to = points[points.length - 1];
257
+ routed.push({
258
+ i,
259
+ id: edge.id,
260
+ fromKey: edge.source,
261
+ toKey: edge.target,
262
+ kind: edge.kind,
263
+ relation: edge.relation ?? reach(a, b),
264
+ self: false,
265
+ x1: from.x,
266
+ y1: from.y,
267
+ x2: to.x,
268
+ y2: to.y,
269
+ s1: 1,
270
+ s2: -1,
271
+ // Pre-built, because the shape depends on the whole tree route rather than on the
272
+ // two endpoints — `edgePath` only ever sees the ends.
273
+ path: bundlePath(points, tension)
274
+ });
275
+ });
276
+ return routed;
277
+ }
278
+ /**
279
+ * How a label sits at this angle on the rim.
280
+ *
281
+ * Tangential, and flipped through the left half so nothing reads upside down — the same rule
282
+ * the Sensei board uses. Degrees, because that is what a CSS rotation takes.
283
+ */
284
+ function labelAt(angle) {
285
+ const degrees = (angle * 180) / Math.PI;
286
+ const onLeft = degrees % 360 > 180;
287
+ return {
288
+ labelAngle: onLeft ? degrees + 90 : degrees - 90,
289
+ labelSide: onLeft ? 'end' : 'start'
290
+ };
291
+ }
292
+ /** A stand-in node for a cut leaf, which is a region rather than anything declared. */
293
+ function region(leaf) {
294
+ return { id: leaf.id, label: leaf.label, rows: [], meta: {}, kind: 'module' };
295
+ }
296
+ /**
297
+ * How big a region's dot is.
298
+ *
299
+ * A cut leaf has no entry in the measure map — it is not a node anyone declared — so it takes
300
+ * a size from what it CONTAINS, which is the same question the measure answers for a real one.
301
+ */
302
+ function sizeFor(leaf, ring) {
303
+ const scaled = Math.min(18, 5 + Math.sqrt(Math.max(0, leaf.value)) * 0.8);
304
+ return { w: scaled, h: scaled * (ring.rim > 0 ? 1 : 1) };
305
+ }
306
+ /** One dot per leaf, on the rim. Sized by the measure, as `points` and `radial` size theirs. */
307
+ function dots(leaves, ring, ctx) {
308
+ const { placed, model } = ctx;
309
+ const sizeOf = nodeSizes(model, ctx.sizeBy ?? 'degree', ctx.sizeScale ?? 'linear');
310
+ const cards = {};
311
+ for (const leaf of leaves) {
312
+ // A CUT leaf is often a region rather than a declared node — `core` standing for the
313
+ // forty files under it. It still needs a dot and a label, so one is synthesised for
314
+ // it; the box IS the thing at this level.
315
+ const node = leaf.node ?? region(leaf);
316
+ const spot = placed.get(leaf.id);
317
+ const size = sizeOf.get(node.id) ?? sizeFor(leaf, ring);
318
+ const point = at(ring.centre, spot.angle, spot.radius);
319
+ const label = labelAt(spot.angle);
320
+ cards[node.id] = {
321
+ node,
322
+ vis: [],
323
+ more: node.rows.length,
324
+ w: size.w,
325
+ h: size.h,
326
+ x: point.x - size.w / 2,
327
+ y: point.y - size.h / 2,
328
+ groupIndex: 0,
329
+ ...label
330
+ };
331
+ }
332
+ return cards;
333
+ }
334
+ /**
335
+ * A codebase as a radial dendrogram, with its calls bundled through the structure.
336
+ *
337
+ * `levels` caps how many ancestor bands are drawn; `focusPath` scopes the whole canvas to a
338
+ * subtree, which is drilling. Both mean exactly what they mean in `world` and `sunburst`, so
339
+ * switching between the three views keeps your place.
340
+ */
341
+ export const structure = (model, options) => {
342
+ warnUnknownOptions(options, 'structure');
343
+ const tree = buildTree(model, { measure: options.sizeBy ?? 'weight' });
344
+ const root = findNode(tree, options.focusPath ?? []);
345
+ if (!root || root.children.length === 0) {
346
+ return { clusters: [], cards: {}, edges: [], size: { w: 0, h: 0 } };
347
+ }
348
+ const origin = root.path.length;
349
+ const levels = Math.max(1, options.levels ?? 2);
350
+ // `levels` decides what sits ON the rim: 1 is a dozen crates, 3 is every file. The bands
351
+ // are then the levels ABOVE it, so at 1 there are none and the leaves label themselves.
352
+ const cut = truncate(root, origin + levels);
353
+ const leaves = leavesOf(cut);
354
+ const step = (Math.PI * 2) / Math.max(1, leaves.length);
355
+ const bandCount = Math.max(0, levels - 1);
356
+ const rim = Math.max(MIN_RIM, (LEAF_ARC * leaves.length) / (Math.PI * 2));
357
+ const extent = rim + BAND_GAP + Math.max(0, bandCount - 1) * BAND_STEP + BAND + PAD;
358
+ const ring = { rim, centre: extent };
359
+ const { placed, byPath } = place(cut, step, rim);
360
+ const ctx = { placed, byPath, centre: ring.centre };
361
+ return {
362
+ clusters: bands(cut, { placed, step, origin, rim }, bandCount),
363
+ cards: dots(leaves, ring, { placed, model, sizeBy: options.sizeBy, sizeScale: options.sizeScale }),
364
+ edges: routeAll(model, cut, ctx, options.bundleTension ?? 0.85),
365
+ size: { w: extent * 2, h: extent * 2 }
366
+ };
367
+ };
@@ -0,0 +1,8 @@
1
+ import type { LayoutFn } from './types.js';
2
+ /**
3
+ * Containment as angle, depth as radius.
4
+ *
5
+ * `focusPath` and `levels` mean exactly what they mean in `world`, so a reader switching
6
+ * between the two views keeps their place.
7
+ */
8
+ export declare const sunburst: LayoutFn;
@@ -0,0 +1,126 @@
1
+ /* The sunburst: the treemap's question asked radially.
2
+ *
3
+ * Same tree, same measure, same captions as `world` — deliberately, so the two are one dataset
4
+ * drawn twice rather than two features that drift. What changes is which channel carries what:
5
+ *
6
+ * treemap area = measure, depth = nesting you have to look for
7
+ * sunburst ANGLE = measure, RADIUS = depth, which you read at a glance
8
+ *
9
+ * A treemap spends its pixels better; a sunburst tells you how deep the tree goes before you
10
+ * have read a single label. Neither is the right one, which is why both exist.
11
+ *
12
+ * Geometry only — a wedge is `{ r0, r1, a0, a1 }` and the renderer turns that into an arc.
13
+ * That keeps this a pure `LayoutFn` like every other: DOM-free, synchronous, unit-testable to
14
+ * exact radians.
15
+ */
16
+ import { buildTree, findNode } from '../model/tree.js';
17
+ import { warnUnknownOptions } from './options.js';
18
+ /** Outer radius of the deepest materialised ring. The canvas is derived from it. */
19
+ const RADIUS = 380;
20
+ /** Margin outside the rim. */
21
+ const PAD = 16;
22
+ /**
23
+ * Angular floor, as a share of one child's EQUAL slice of its parent.
24
+ *
25
+ * The same reasoning as the treemap's area floor, and the same trap avoided: a per-child floor
26
+ * measured against the TOTAL sums with the child count, so it is negligible at two children and
27
+ * swamps the circle at 470. Against the equal slice the total floor caps at this constant
28
+ * whatever the child count.
29
+ */
30
+ const MIN_EQUAL_SHARE = 0.12;
31
+ /** Compact enough to sit in a label strip: 1200 reads as 1.2k. */
32
+ function compact(value) {
33
+ if (value >= 1_000_000)
34
+ return `${(value / 1_000_000).toFixed(1)}M`;
35
+ if (value >= 1000)
36
+ return `${(value / 1000).toFixed(1)}k`;
37
+ return String(Math.round(value));
38
+ }
39
+ /**
40
+ * Divide a parent's angular span among its children, proportional to measure.
41
+ *
42
+ * Recurses `levels` deep and no further — the same refusal the treemap makes, and for the same
43
+ * reason: the cost of a large tree is in building it, not in drawing it.
44
+ */
45
+ function split(node, span, ctx) {
46
+ const { levels, out, parent, origin, ramp } = ctx;
47
+ if (levels <= 0 || node.children.length === 0)
48
+ return;
49
+ const total = node.children.reduce((sum, c) => sum + Math.max(c.value, 0), 0);
50
+ const floor = (total / node.children.length) * MIN_EQUAL_SHARE;
51
+ // Equal slices when nothing is measured. Returning early instead drew NOTHING for a tree
52
+ // whose measure happens to be zero everywhere — a codebase under `sizeBy: 'degree'` where
53
+ // no module imports another, which is a blank canvas rather than a visible "unmeasured".
54
+ // `squarify` makes the same fallback for the treemap, so the two views agree.
55
+ const values = total > 0 ? node.children.map((c) => Math.max(c.value, floor)) : node.children.map(() => 1);
56
+ const sum = values.reduce((a, b) => a + b, 0);
57
+ let a = span.a0;
58
+ const width = span.a1 - span.a0;
59
+ node.children.forEach((child, i) => {
60
+ const slice = (values[i] / sum) * width;
61
+ const range = { a0: a, a1: a + slice };
62
+ // Depth is RELATIVE to the focus, so the innermost rendered ring is always 0 whatever
63
+ // the reader has drilled into — the same convention `world` uses.
64
+ // The outermost wedge names the ramp entry and its whole subtree inherits it, so a
65
+ // package reads as one coloured sector rather than a bright inner ring above a grey
66
+ // outer one.
67
+ const key = ramp ?? child.label;
68
+ out.push({ tree: child, depth: node.path.length - origin, parent, ramp: key, ...range });
69
+ split(child, range, { levels: levels - 1, out, parent: child.id, origin, ramp: key });
70
+ a += slice;
71
+ });
72
+ }
73
+ function wedgeOf(entry, groupIndex, ring) {
74
+ const leaf = entry.tree.children.length === 0 ? entry.tree.node : undefined;
75
+ return {
76
+ name: entry.tree.label,
77
+ caption: compact(entry.tree.value),
78
+ parent: entry.parent,
79
+ depth: entry.depth,
80
+ ramp: entry.ramp,
81
+ nodeId: leaf?.id,
82
+ kind: leaf?.kind,
83
+ list: entry.tree.children.map((c) => c.node).filter((n) => n !== undefined),
84
+ count: entry.tree.children.length,
85
+ groupIndex,
86
+ wedge: {
87
+ r0: entry.depth * ring,
88
+ r1: (entry.depth + 1) * ring,
89
+ a0: entry.a0,
90
+ a1: entry.a1
91
+ },
92
+ // A wedge is drawn from its own polar geometry, but the box still reports where it sits
93
+ // so selection, hit-testing and anything measuring the canvas keep working unchanged.
94
+ x: RADIUS + PAD + Math.cos((entry.a0 + entry.a1) / 2) * ((entry.depth + 0.5) * ring),
95
+ y: RADIUS + PAD + Math.sin((entry.a0 + entry.a1) / 2) * ((entry.depth + 0.5) * ring),
96
+ w: 0,
97
+ h: 0
98
+ };
99
+ }
100
+ /**
101
+ * Containment as angle, depth as radius.
102
+ *
103
+ * `focusPath` and `levels` mean exactly what they mean in `world`, so a reader switching
104
+ * between the two views keeps their place.
105
+ */
106
+ export const sunburst = (model, options) => {
107
+ warnUnknownOptions(options, 'sunburst');
108
+ const tree = buildTree(model, { measure: options.sizeBy ?? 'weight' });
109
+ const root = findNode(tree, options.focusPath ?? []);
110
+ if (!root || root.children.length === 0) {
111
+ return { clusters: [], cards: {}, edges: [], size: { w: 0, h: 0 } };
112
+ }
113
+ const levels = options.levels ?? 2;
114
+ const placed = [];
115
+ split(root, { a0: 0, a1: Math.PI * 2 }, { levels, out: placed, origin: root.path.length });
116
+ const ring = RADIUS / Math.max(1, levels);
117
+ const extent = (RADIUS + PAD) * 2;
118
+ return {
119
+ clusters: placed.map((entry, i) => wedgeOf(entry, i, ring)),
120
+ cards: {},
121
+ // Edges are absent for the same reason as the treemap: containment IS the relationship,
122
+ // and an adjacency graph cannot be drawn at this scale.
123
+ edges: [],
124
+ size: { w: extent, h: extent }
125
+ };
126
+ };
@@ -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,17 @@ 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;
129
+ /** GraphEdge.overlay — routed after the layout ran, over a picture it did not shape. */
130
+ overlay?: boolean;
131
+ /** GraphEdge.weight, raw. `GraphState.edgeWeight` normalises it for drawing. */
132
+ weight?: number;
66
133
  x1: number;
67
134
  y1: number;
68
135
  x2: number;
@@ -70,6 +137,30 @@ export type RoutedEdge = {
70
137
  /** 1 = right side of the card, -1 = left. */
71
138
  s1: number;
72
139
  s2: number;
140
+ /**
141
+ * A ready-made path, when the shape depends on more than the two endpoints.
142
+ *
143
+ * `edgePath` only ever sees the ends, which is enough for a curve or a right-angle route.
144
+ * A bundled edge follows the whole chain of ancestors between its endpoints, so the layout
145
+ * that knows that chain builds the path and the renderer draws what it is given.
146
+ */
147
+ path?: string;
148
+ };
149
+ /**
150
+ * One column of a multi-column layout, with the heading it needs to be readable.
151
+ *
152
+ * Three columns get away with none — the focus is visibly central, so the sides are obvious.
153
+ * Five do not: without headings a depth-2 portrait is ambiguous about which direction is
154
+ * which, and the reader has to trace an arrow to find out.
155
+ */
156
+ export type Column = {
157
+ x: number;
158
+ /** Carried here so the view needs no layout constant to size a heading. */
159
+ w: number;
160
+ depth: number;
161
+ /** `in` = things that reach the focus, `out` = things it reaches, `focus` = the centre. */
162
+ side: 'in' | 'out' | 'focus';
163
+ label: string;
73
164
  };
74
165
  export type Cards = Record<string, Card>;
75
166
  export type Size = {
@@ -100,11 +191,65 @@ export type LayoutOptions = {
100
191
  * and the layout runs its original single-level path untouched.
101
192
  */
102
193
  nestBy?: NodeAxis;
194
+ /**
195
+ * What `points` sizes a node by. `degree` (the default) counts edges; `weight` reads
196
+ * `GraphNode.weight`.
197
+ *
198
+ * Degree is a good default and is not always the measure the reader is asking about — a
199
+ * 40-file module with few cross-edges should not render smaller than a 2-file one that
200
+ * happens to be chatty.
201
+ */
202
+ sizeBy?: string;
203
+ /**
204
+ * How the measure maps onto area. `linear` (the default), or `log` for a measure spanning
205
+ * orders of magnitude — declaration counts across a real repo span three or four, and
206
+ * linear flattens everything below the top few onto the minimum.
207
+ */
208
+ sizeScale?: 'linear' | 'log';
209
+ /**
210
+ * `neighborhood` only — how many hops out from the focus. Defaults to 1.
211
+ *
212
+ * One hop answers "what touches this". Two answers "what does changing this reach", which
213
+ * is the question a reader has before editing something.
214
+ */
215
+ depth?: number;
216
+ /**
217
+ * `world` only — the subtree to render as the whole canvas. `[]` is the root.
218
+ *
219
+ * This is DRILLING, not zooming: it re-runs the layout with a new root so the subtree gets
220
+ * the full canvas and its own children become visible. Zoom magnifies what is already
221
+ * there, which at world scale is a field of sub-pixel boxes.
222
+ */
223
+ focusPath?: string[];
224
+ /** `world` only — how many levels below the focus to MATERIALISE. Defaults to 2. */
225
+ levels?: number;
226
+ /**
227
+ * `radial` only — `tree` (the default) reads radius as DEPTH; `dendrogram` pins every
228
+ * leaf to the rim so leaves are compared against each other.
229
+ */
230
+ radialMode?: 'tree' | 'dendrogram';
231
+ /**
232
+ * `structure` only — how hard edges are pulled onto the tree, 0..1.
233
+ *
234
+ * 0 is a straight chord and 1 is the full route through the hierarchy. The default of 0.85
235
+ * is Holten's, and what the Sensei board's Straight/Bundled toggle switches between.
236
+ */
237
+ bundleTension?: number;
238
+ /**
239
+ * `radial` only — the node to treat as the centre, which is what drilling into a crate
240
+ * does. Its subtree becomes the whole picture.
241
+ *
242
+ * Deliberately NOT `focus`: that one defaults to the SELECTION, so clicking a node to
243
+ * highlight it would silently re-root the diagram.
244
+ */
245
+ root?: string | null;
103
246
  };
104
247
  export type LayoutResult = {
105
248
  clusters: Cluster[];
106
249
  cards: Cards;
107
250
  edges: RoutedEdge[];
251
+ /** Column headings, when the layout has columns. Empty for the grid layouts. */
252
+ columns?: Column[];
108
253
  size: Size;
109
254
  };
110
255
  /**