@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
@@ -0,0 +1,114 @@
1
+ /* Squarified treemap — Bruls, Huizing & van Wijk (2000).
2
+ *
3
+ * Fills a rectangle with child rectangles whose areas are proportional to their values, laid
4
+ * out in rows chosen to keep each rectangle as square as possible.
5
+ *
6
+ * The naive alternative (slice-and-dice: split the rect by value along one axis, alternating
7
+ * per level) is far simpler and unusable here — it produces 1px-wide slivers at any real
8
+ * value spread, and a sliver holds no label. A box you cannot read is a box you cannot
9
+ * decide to click, which is the whole of drill-down navigation.
10
+ *
11
+ * Hand-written rather than taken from `d3-hierarchy`: the algorithm is small and naturally
12
+ * deterministic, and `@rokkit/graph` has no third-party runtime dependency — a property
13
+ * `dependencies.spec.js` asserts, so acquiring the first one should buy more than 80 lines.
14
+ */
15
+ /**
16
+ * Worst aspect ratio in a row of areas laid along `side`. Lower is squarer.
17
+ *
18
+ * No zero guard: a side or total of 0 divides to Infinity in JS, which is exactly the answer —
19
+ * a row with no room is infinitely bad and the caller closes it. A guard here would be a
20
+ * statement no input can reach.
21
+ */
22
+ function worst(areas, side, total) {
23
+ const max = Math.max(...areas);
24
+ const min = Math.min(...areas);
25
+ const s2 = side * side;
26
+ const t2 = total * total;
27
+ return Math.max((s2 * max) / t2, t2 / (s2 * min));
28
+ }
29
+ /** Lay one finished row along the short side of `rect`, and return what remains. */
30
+ function placeRow(row, rect, out) {
31
+ const total = row.reduce((sum, r) => sum + r.area, 0);
32
+ const vertical = rect.w >= rect.h;
33
+ // Thickness of the band this row occupies, derived from its total area so the row exactly
34
+ // consumes the share of the rectangle its values earned.
35
+ const thickness = total / (vertical ? rect.h : rect.w);
36
+ let offset = vertical ? rect.y : rect.x;
37
+ for (const entry of row) {
38
+ const length = entry.area / thickness;
39
+ out.push({
40
+ item: entry.item,
41
+ rect: vertical
42
+ ? { x: rect.x, y: offset, w: thickness, h: length }
43
+ : { x: offset, y: rect.y, w: length, h: thickness }
44
+ });
45
+ offset += length;
46
+ }
47
+ return vertical
48
+ ? { x: rect.x + thickness, y: rect.y, w: rect.w - thickness, h: rect.h }
49
+ : { x: rect.x, y: rect.y + thickness, w: rect.w, h: rect.h - thickness };
50
+ }
51
+ /**
52
+ * Place `items` inside `rect`, area proportional to `value`.
53
+ *
54
+ * Largest first: the greedy row-closing rule only produces near-square boxes if the big ones
55
+ * are placed while there is room to choose. Ties break on the caller's order, which is already
56
+ * sorted, so the result is deterministic.
57
+ *
58
+ * A zero or negative value gets the same floor as a tiny one — "unknown" is a normal state in
59
+ * a partially-indexed graph, and a zero-area box is indistinguishable from a missing one.
60
+ */
61
+ /**
62
+ * Does adding `area` make this row's worst aspect ratio worse?
63
+ *
64
+ * That comparison IS the algorithm — it trades row length against squareness, and closing the
65
+ * row at the moment the answer turns yes is what keeps boxes near-square instead of slivers.
66
+ */
67
+ function shouldClose(areas, area, side) {
68
+ if (areas.length === 0)
69
+ return false;
70
+ const total = areas.reduce((sum, a) => sum + a, 0);
71
+ return worst([...areas, area], side, total + area) > worst(areas, side, total);
72
+ }
73
+ export function squarify(items, rect, minimum = 0) {
74
+ if (items.length === 0 || rect.w <= 0 || rect.h <= 0)
75
+ return [];
76
+ const ordered = [...items].sort((a, b) => b.value - a.value);
77
+ const floor = Math.max(minimum, 0);
78
+ const values = ordered.map((item) => Math.max(item.value, floor));
79
+ const total = values.reduce((sum, v) => sum + v, 0);
80
+ if (total <= 0)
81
+ return evenly(ordered, rect);
82
+ // Values are scaled into pixels ONCE, so every row shares one area currency.
83
+ const scale = (rect.w * rect.h) / total;
84
+ const out = [];
85
+ let remaining = rect;
86
+ let row = [];
87
+ ordered.forEach((item, i) => {
88
+ const area = values[i] * scale;
89
+ if (shouldClose(row.map((r) => r.area), area, Math.min(remaining.w, remaining.h))) {
90
+ remaining = placeRow(row, remaining, out);
91
+ row = [];
92
+ }
93
+ row.push({ item, area });
94
+ });
95
+ // `row` always holds at least the final item, so no guard: the loop pushes before it ends.
96
+ placeRow(row, remaining, out);
97
+ return out;
98
+ }
99
+ /** Equal shares, for when every value is zero and proportion means nothing. */
100
+ function evenly(items, rect) {
101
+ const columns = Math.ceil(Math.sqrt(items.length));
102
+ const rows = Math.ceil(items.length / columns);
103
+ const w = rect.w / columns;
104
+ const h = rect.h / rows;
105
+ return items.map((item, i) => ({
106
+ item,
107
+ rect: {
108
+ x: rect.x + (i % columns) * w,
109
+ y: rect.y + Math.floor(i / columns) * h,
110
+ w,
111
+ h
112
+ }
113
+ }));
114
+ }
@@ -0,0 +1,9 @@
1
+ import type { LayoutFn } from './types.js';
2
+ /**
3
+ * A codebase as a radial dendrogram, with its calls bundled through the structure.
4
+ *
5
+ * `levels` caps how many ancestor bands are drawn; `focusPath` scopes the whole canvas to a
6
+ * subtree, which is drilling. Both mean exactly what they mean in `world` and `sunburst`, so
7
+ * switching between the three views keeps your place.
8
+ */
9
+ export declare const structure: LayoutFn;
@@ -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
+ };