@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,106 @@
1
+ /**
2
+ * Every key `LayoutOptions` defines. Kept beside the type on purpose — a key added there and
3
+ * missed here would warn about a legitimate option, which is worse than the silence this
4
+ * exists to fix. `spec/options.spec.ts` compares the two.
5
+ */
6
+ export const LAYOUT_OPTION_KEYS = [
7
+ 'density',
8
+ 'arrange',
9
+ 'edgeStyle',
10
+ 'focus',
11
+ 'expanded',
12
+ 'groupBy',
13
+ 'nestBy',
14
+ 'sizeBy',
15
+ 'sizeScale',
16
+ 'depth',
17
+ 'focusPath',
18
+ 'levels',
19
+ 'radialMode',
20
+ 'root',
21
+ 'bundleTension'
22
+ ];
23
+ /**
24
+ * Which options have an EFFECT under each built-in layout.
25
+ *
26
+ * A control wired to an option the active layout ignores is worse than a missing one: it looks
27
+ * like a knob, it moves, and the picture does not change — so the reader concludes the view is
28
+ * broken rather than that the control does not apply. `names | keys | full` over a treemap is
29
+ * the plainest case, since a treemap box has no row list to thin.
30
+ *
31
+ * Exported because it is a fact about the layout, not about any one consumer's control panel.
32
+ * A hand-maintained copy in a UI drifts the first time a layout gains or drops an option.
33
+ *
34
+ * Two entries are not simply "what the layout function reads", and both are deliberate:
35
+ * - `edgeStyle` is applied by `GraphState` when it draws a path, not by the layout. It
36
+ * therefore applies wherever a layout produces edges — which `world` does not.
37
+ * - `neighborhood` takes no `density`: it builds its cards at `full` unconditionally, because
38
+ * a portrait of one node's surroundings that hides the columns the edges land on defeats
39
+ * its own purpose.
40
+ */
41
+ export const LAYOUT_OPTIONS = {
42
+ cluster: ['density', 'arrange', 'edgeStyle', 'expanded', 'groupBy', 'nestBy'],
43
+ flow: ['density', 'edgeStyle', 'expanded'],
44
+ neighborhood: ['edgeStyle', 'expanded', 'focus', 'depth'],
45
+ points: ['edgeStyle', 'sizeBy', 'sizeScale'],
46
+ radial: ['edgeStyle', 'sizeBy', 'sizeScale', 'radialMode', 'levels', 'root'],
47
+ sunburst: ['sizeBy', 'focusPath', 'levels'],
48
+ structure: ['sizeBy', 'sizeScale', 'focusPath', 'levels', 'bundleTension'],
49
+ world: ['sizeBy', 'focusPath', 'levels']
50
+ };
51
+ /**
52
+ * Layouts that draw a node as a DOT rather than a card.
53
+ *
54
+ * A card is the wrong unit once a node is 20px across: the head, the kind tag and the
55
+ * more-row are all still in the DOM, overflowing a box the layout sized for a dot. Published
56
+ * as a fact about the layout so the theme can key on the SHAPE instead of naming every layout
57
+ * that happens to have one — `radial` inherited the whole dot treatment by joining this set.
58
+ */
59
+ const DOT_LAYOUTS = new Set(['points', 'radial', 'structure']);
60
+ /** `dot` or `card` — what shape this layout draws a node as. */
61
+ export function nodeShapeOf(layout) {
62
+ return DOT_LAYOUTS.has(layout) ? 'dot' : 'card';
63
+ }
64
+ /** Whether a control for `option` should be offered while `layout` is active. */
65
+ export function appliesTo(layout, option) {
66
+ return LAYOUT_OPTIONS[layout]?.includes(option) ?? true;
67
+ }
68
+ /** Warn once per unknown key, ever. A layout re-runs on every render. */
69
+ const warned = new Set();
70
+ /** Bundlers replace this; `process` is undefined in a browser, hence the guarded read. */
71
+ function isProduction() {
72
+ return typeof process !== 'undefined' && process.env?.NODE_ENV === 'production';
73
+ }
74
+ /**
75
+ * Tell a caller when an option was discarded.
76
+ *
77
+ * `LayoutOptions` is not a closed check once the object reaches a layout through a variable —
78
+ * TypeScript's excess-property check only fires on an object literal at the call site. So
79
+ * `neighborhood(model, opts)` where `opts` carries `depth: 2` type-checks, runs, and returns
80
+ * a correct-looking diagram that silently ignored the option (#162).
81
+ *
82
+ * Silence is the problem, not the unknown key: the caller has no way to tell "this layout
83
+ * does not support depth" from "depth worked and two hops is what you are looking at".
84
+ */
85
+ export function warnUnknownOptions(options, layout) {
86
+ if (isProduction())
87
+ return;
88
+ for (const key of Object.keys(options)) {
89
+ if (LAYOUT_OPTION_KEYS.includes(key))
90
+ continue;
91
+ const seen = `${layout}:${key}`;
92
+ if (warned.has(seen))
93
+ continue;
94
+ warned.add(seen);
95
+ // The one place a console call is the POINT rather than a leftover: a discarded option
96
+ // has no other channel back to the caller, and throwing would make an unknown key fatal
97
+ // for a diagram that still renders correctly. Dev-only and deduped above.
98
+ // eslint-disable-next-line no-console
99
+ console.warn(`[@rokkit/graph] \`${layout}\` received unknown option \`${key}\` and ignored it. ` +
100
+ `Known options: ${LAYOUT_OPTION_KEYS.join(', ')}.`);
101
+ }
102
+ }
103
+ /** Test seam — the dedupe is module state and would leak between cases otherwise. */
104
+ export function resetOptionWarnings() {
105
+ warned.clear();
106
+ }
@@ -0,0 +1,11 @@
1
+ import type { GraphModel } from '../types.js';
2
+ /**
3
+ * Crossings between every adjacent pair of layers.
4
+ *
5
+ * Two edges cross when their endpoints are in opposite relative order on the two sides. The
6
+ * naive pair-count is fine here: a layer holds tens of nodes, not thousands, and being
7
+ * obviously correct matters more than being O(n log n) — this is the score the sweep is
8
+ * optimising, so an error in it would silently make the heuristic choose the worse layout.
9
+ */
10
+ export declare function countCrossings(layers: string[][], model: GraphModel, back: Set<string>): number;
11
+ export declare function order(model: GraphModel, ranks: Map<string, number>, back: Set<string>): string[][];
@@ -0,0 +1,153 @@
1
+ /* Ordering within a column — the crossing-reduction half of a layered layout.
2
+ *
3
+ * Minimising edge crossings is NP-hard, so this is the standard heuristic: sweep the layers
4
+ * assigning each node the average position of its neighbours in the adjacent layer, sort by
5
+ * that, repeat. See docs/design/25-flow-layout.md.
6
+ *
7
+ * Two properties are load-bearing and neither is incidental:
8
+ *
9
+ * - It is DETERMINISTIC. A fixed pass count and a stable tie-break, so the same model lays
10
+ * out identically every run — which `LayoutFn` promises and every layout spec relies on.
11
+ * - It never returns worse than it started. Each candidate is scored and the best kept, so
12
+ * a graph the heuristic cannot improve comes back unchanged rather than churned.
13
+ */
14
+ /** Sweeps of down-then-up. Four is where the standard heuristic stops paying. */
15
+ const PASSES = 4;
16
+ /** An edge that is actually drawn between two adjacent columns. */
17
+ function drawn(edges, back, byId) {
18
+ return edges.filter((e) => !back.has(e.id) && e.source !== e.target && byId.has(e.source) && byId.has(e.target));
19
+ }
20
+ /**
21
+ * Crossings between every adjacent pair of layers.
22
+ *
23
+ * Two edges cross when their endpoints are in opposite relative order on the two sides. The
24
+ * naive pair-count is fine here: a layer holds tens of nodes, not thousands, and being
25
+ * obviously correct matters more than being O(n log n) — this is the score the sweep is
26
+ * optimising, so an error in it would silently make the heuristic choose the worse layout.
27
+ */
28
+ export function countCrossings(layers, model, back) {
29
+ const pos = new Map();
30
+ layers.forEach((layer) => layer.forEach((id, i) => pos.set(id, i)));
31
+ const layerOf = new Map();
32
+ layers.forEach((layer, i) => layer.forEach((id) => layerOf.set(id, i)));
33
+ let total = 0;
34
+ const edges = drawn(model.edges, back, model.byId);
35
+ for (let i = 0; i < edges.length; i++) {
36
+ for (let j = i + 1; j < edges.length; j++) {
37
+ const a = edges[i];
38
+ const b = edges[j];
39
+ // Only edges spanning the SAME pair of adjacent layers can cross in this model.
40
+ if (layerOf.get(a.source) !== layerOf.get(b.source))
41
+ continue;
42
+ if (layerOf.get(a.target) !== layerOf.get(b.target))
43
+ continue;
44
+ const as = pos.get(a.source);
45
+ const bs = pos.get(b.source);
46
+ const at = pos.get(a.target);
47
+ const bt = pos.get(b.target);
48
+ if ((as - bs) * (at - bt) < 0)
49
+ total++;
50
+ }
51
+ }
52
+ return total;
53
+ }
54
+ /** Mean position of `ids` in the previous ordering, or nothing when there are none. */
55
+ function barycenter(ids, pos) {
56
+ const known = ids.map((id) => pos.get(id)).filter((p) => p !== undefined);
57
+ if (known.length === 0)
58
+ return undefined;
59
+ return known.reduce((sum, p) => sum + p, 0) / known.length;
60
+ }
61
+ /**
62
+ * Re-sort one layer by the average position of each node's neighbours on `side`.
63
+ *
64
+ * A node with no neighbour on that side keeps its current index as its key, so it holds
65
+ * station instead of collapsing to one end — an unconstrained node drifting to position 0 is
66
+ * how a sweep makes a layout worse than the order it was given.
67
+ */
68
+ function sweepLayer(layer, neighbors, pos) {
69
+ const keyed = layer.map((id, i) => ({
70
+ id,
71
+ key: barycenter(neighbors.get(id) ?? [], pos) ?? i,
72
+ i
73
+ }));
74
+ // Index breaks ties, so equal barycenters keep their relative order — a stable sort by
75
+ // hand, because the tie-break IS the determinism guarantee.
76
+ keyed.sort((a, b) => a.key - b.key || a.i - b.i);
77
+ return keyed.map((k) => k.id);
78
+ }
79
+ /** Positions within each layer, for the scoring pass and the next sweep. */
80
+ function positions(layers) {
81
+ const pos = new Map();
82
+ layers.forEach((layer) => layer.forEach((id, i) => pos.set(id, i)));
83
+ return pos;
84
+ }
85
+ function adjacency(edges, forward) {
86
+ const out = new Map();
87
+ for (const edge of edges) {
88
+ const [from, to] = forward ? [edge.target, edge.source] : [edge.source, edge.target];
89
+ const list = out.get(from);
90
+ if (list)
91
+ list.push(to);
92
+ else
93
+ out.set(from, [to]);
94
+ }
95
+ return out;
96
+ }
97
+ /**
98
+ * Nodes grouped into their columns and ordered to reduce crossings.
99
+ *
100
+ * Layers start in model order — which normalisation fixed — so the starting point is stable
101
+ * and the result is reproducible.
102
+ */
103
+ /** Nodes bucketed into their columns, in model order — the stable starting arrangement. */
104
+ function layersOf(model, ranks) {
105
+ const layers = [];
106
+ for (const node of model.nodes) {
107
+ const r = ranks.get(node.id) ?? 0;
108
+ (layers[r] ??= []).push(node.id);
109
+ }
110
+ // A rank with no nodes cannot come out of `rank()`, but an array hole would crash the
111
+ // sweep rather than lay out oddly, and the cost of not assuming is one filter.
112
+ return layers.filter((layer) => layer !== undefined);
113
+ }
114
+ /**
115
+ * One sweep across every layer.
116
+ *
117
+ * Down means each layer follows the one to its left, up means the one to its right.
118
+ * Alternating is what lets an improvement propagate in both directions rather than piling up
119
+ * against whichever end the sweep started from.
120
+ */
121
+ function sweep(layers, down, predecessors, successors) {
122
+ const next = layers.map((layer) => [...layer]);
123
+ const indices = down
124
+ ? next.map((_, i) => i).slice(1)
125
+ : next
126
+ .map((_, i) => i)
127
+ .slice(0, -1)
128
+ .reverse();
129
+ for (const i of indices) {
130
+ next[i] = sweepLayer(next[i], down ? predecessors : successors, positions(next));
131
+ }
132
+ return next;
133
+ }
134
+ export function order(model, ranks, back) {
135
+ const filled = layersOf(model, ranks);
136
+ if (filled.length === 0)
137
+ return [];
138
+ const edges = drawn(model.edges, back, model.byId);
139
+ const predecessors = adjacency(edges, true);
140
+ const successors = adjacency(edges, false);
141
+ let best = filled;
142
+ let bestScore = countCrossings(filled, model, back);
143
+ let current = filled;
144
+ for (let pass = 0; pass < PASSES; pass++) {
145
+ current = sweep(current, pass % 2 === 0, predecessors, successors);
146
+ const score = countCrossings(current, model, back);
147
+ if (score < bestScore) {
148
+ bestScore = score;
149
+ best = current;
150
+ }
151
+ }
152
+ return best;
153
+ }
@@ -28,16 +28,20 @@
28
28
  seed. The same model always produces the same picture, so it is unit-testable to exact
29
29
  pixels and a re-render never reshuffles the diagram under the reader. */
30
30
  import { buildEdges } from './edges.js';
31
+ import { degreeOf, measureOf, normalise, sizeFor } from './sizing.js';
32
+ import { warnUnknownOptions } from './options.js';
33
+ /* Node geometry — area, aspect and the measure curve now live in `sizing.ts`, shared with
34
+ `radial`, so the same node is the same size in both views. */
35
+ const NODE_GAP = 6;
31
36
  /**
32
- * Node geometry. AREA — not width — tracks degree, so ten edges reads as ten rather than as
33
- * a hundred. Areas are carried over from the disc version (pi*5^2 to pi*26^2) so the ink
34
- * weight of a diagram is unchanged; only the shape and the packing are different.
37
+ * Vertical allowance under each node for its label.
38
+ *
39
+ * The label is drawn BELOW the dot and outside it — at these sizes there is no inside to put
40
+ * text in — so the shelf has to reserve for it or a 6px dot's 19px label lands in the row
41
+ * beneath. Reserved unconditionally: at the `minimal` and `dot` tiers the label is hidden and
42
+ * this is simply air, which costs a little density in exchange for never colliding.
35
43
  */
36
- const MIN_AREA = Math.PI * 5 * 5;
37
- const MAX_AREA = Math.PI * 26 * 26;
38
- /** Width:height. 2:1 reads as a "chip" and shelves well; a square wastes shelf height. */
39
- const ASPECT = 2;
40
- const NODE_GAP = 6;
44
+ const LABEL_H = 18;
41
45
  const CLUSTER_PAD = 20;
42
46
  /** Room for the group's own label above its first shelf. */
43
47
  const CLUSTER_TITLE = 26;
@@ -49,24 +53,6 @@ const CANVAS_PAD = 60;
49
53
  * grid of groups better than a tall column.
50
54
  */
51
55
  const SHELF_ASPECT = 1.6;
52
- function degreeOf(model) {
53
- const degree = new Map();
54
- for (const node of model.nodes)
55
- degree.set(node.id, 0);
56
- for (const edge of model.edges) {
57
- degree.set(edge.source, (degree.get(edge.source) ?? 0) + 1);
58
- if (edge.target !== edge.source) {
59
- degree.set(edge.target, (degree.get(edge.target) ?? 0) + 1);
60
- }
61
- }
62
- return degree;
63
- }
64
- /** Size by degree. Area is linear in degree, so width grows as its square root. */
65
- function sizeFor(degree, maxDegree) {
66
- const t = maxDegree <= 0 ? 0 : degree / maxDegree;
67
- const area = MIN_AREA + (MAX_AREA - MIN_AREA) * t;
68
- return { w: Math.sqrt(area * ASPECT), h: Math.sqrt(area / ASPECT) };
69
- }
70
56
  /**
71
57
  * Shelf-pack one group (first-fit decreasing height).
72
58
  *
@@ -78,7 +64,7 @@ function sizeFor(degree, maxDegree) {
78
64
  */
79
65
  function packGroup(members) {
80
66
  const ordered = [...members].sort((a, b) => b.h - a.h || b.w - a.w || a.node.id.localeCompare(b.node.id));
81
- const ink = ordered.reduce((sum, m) => sum + (m.w + NODE_GAP) * (m.h + NODE_GAP), 0);
67
+ const ink = ordered.reduce((sum, m) => sum + (m.w + NODE_GAP) * (m.h + LABEL_H + NODE_GAP), 0);
82
68
  // At least one node wide, or a single oversized hub would wrap onto its own shelf forever.
83
69
  const limit = Math.max(ordered[0].w, Math.sqrt(ink * SHELF_ASPECT));
84
70
  const nodes = [];
@@ -94,7 +80,8 @@ function packGroup(members) {
94
80
  }
95
81
  nodes.push({ ...member, dx: x + member.w / 2, dy: y + member.h / 2 });
96
82
  x += member.w + NODE_GAP;
97
- shelfHeight = Math.max(shelfHeight, member.h);
83
+ // The SHELF is as tall as the node plus its label; the node itself is not.
84
+ shelfHeight = Math.max(shelfHeight, member.h + LABEL_H);
98
85
  widest = Math.max(widest, x - NODE_GAP);
99
86
  }
100
87
  return { nodes, w: widest, h: y + shelfHeight };
@@ -135,17 +122,21 @@ function placeGroups(packs) {
135
122
  });
136
123
  return clusters;
137
124
  }
138
- /** Nodes bucketed by group, each carrying the size its degree earns it. */
139
- function sizeByGroup(model) {
125
+ /** Nodes bucketed by group, each carrying the size its measure earns it. */
126
+ function sizeByGroup(model, sizeBy, scale) {
140
127
  const degree = degreeOf(model);
141
- const maxDegree = Math.max(0, ...degree.values());
128
+ const measures = model.nodes.map((n) => measureOf(n, degree, sizeBy));
129
+ // Normalised against the range PRESENT, not against zero: a graph whose smallest module
130
+ // holds 400 declarations should still show its internal spread, not one flat block.
131
+ const min = Math.min(0, ...measures);
132
+ const max = Math.max(0, ...measures);
142
133
  const byGroup = new Map();
143
- for (const node of model.nodes) {
134
+ model.nodes.forEach((node, i) => {
144
135
  const key = node.group ?? '';
145
136
  const list = byGroup.get(key) ?? [];
146
- list.push({ node, ...sizeFor(degree.get(node.id) ?? 0, maxDegree) });
137
+ list.push({ node, ...sizeFor(normalise(measures[i], min, max, scale)) });
147
138
  byGroup.set(key, list);
148
- }
139
+ });
149
140
  return byGroup;
150
141
  }
151
142
  /** One rect card for a placed node. `pos` holds the CENTRE; a card is positioned top-left. */
@@ -179,10 +170,11 @@ function buildDots(model, clusters, sizeOf) {
179
170
  * `arrange` is ignored because placement is fully determined by degree and group.
180
171
  */
181
172
  export const points = (model, _options) => {
173
+ warnUnknownOptions(_options, 'points');
182
174
  if (model.nodes.length === 0) {
183
175
  return { clusters: [], cards: {}, edges: [], size: { w: 0, h: 0 } };
184
176
  }
185
- const byGroup = sizeByGroup(model);
177
+ const byGroup = sizeByGroup(model, _options.sizeBy ?? 'degree', _options.sizeScale ?? 'linear');
186
178
  const packs = [...byGroup.entries()].map(([name, members]) => ({
187
179
  name,
188
180
  pack: packGroup(members)
@@ -0,0 +1,16 @@
1
+ import type { LayoutFn } from './types.js';
2
+ /**
3
+ * A map read with a floor.
4
+ *
5
+ * Every pass below keys on the same node set, so a miss should not happen — but a node
6
+ * rendered at NaN coordinates disappears silently, and one rendered at the centre is at least
7
+ * visible and clickable. One helper rather than a `??` at each of three use sites.
8
+ */
9
+ export declare function read<T>(map: Map<string, T>, id: string, fallback: T): T;
10
+ /**
11
+ * A call graph around a circle.
12
+ *
13
+ * No clusters: the rings already carry structure, and a group box drawn over a radial
14
+ * arrangement cuts across every one of them.
15
+ */
16
+ export declare const radial: LayoutFn;
@@ -0,0 +1,214 @@
1
+ /* The radial layout: a call graph as a tidy tree, or a dendrogram, around a circle.
2
+ *
3
+ * `points` shelf-packs a call graph into group boxes, and at seven services it already reads
4
+ * as a stack — tiny rects with each label colliding with the row beneath. Packing answers
5
+ * "how much is there"; a call graph's question is "who calls whom, and how deep does it go",
6
+ * and that is a SHAPE. Angle separates the subtrees, radius carries the depth.
7
+ *
8
+ * Two modes, because they answer different questions:
9
+ * - `tree` (default) puts radius = depth, so the rings ARE the call depth.
10
+ * - `dendrogram` puts every leaf on the rim, so leaves are compared against each other and
11
+ * the internal structure stretches to reach them.
12
+ *
13
+ * A call graph is not a tree — see `hierarchy.ts`. The spanning tree decides position; the
14
+ * edges it could not use are still drawn, marked `back`, because "b also calls this" is
15
+ * exactly what a reader is looking for.
16
+ */
17
+ import { childrenIn, hierarchy } from './hierarchy.js';
18
+ import { nodeSizes } from './sizing.js';
19
+ import { warnUnknownOptions } from './options.js';
20
+ /** Gap between rings. Wide enough that a node plus its label clears the ring inside it. */
21
+ const RING = 130;
22
+ /** Arc length reserved per leaf. The outer ring's circumference is derived from this. */
23
+ const LEAF_ARC = 92;
24
+ /**
25
+ * Margin outside the widest ring.
26
+ *
27
+ * Just over half the largest node (sqrt(MAX_AREA * ASPECT) / 2 is ~33px), so a node on the rim
28
+ * clears the canvas edge with a little air for its label — and no more. Every pixel here is
29
+ * dead space that the fit-to-container scale then divides the whole diagram by.
30
+ */
31
+ const PAD = 48;
32
+ /**
33
+ * A map read with a floor.
34
+ *
35
+ * Every pass below keys on the same node set, so a miss should not happen — but a node
36
+ * rendered at NaN coordinates disappears silently, and one rendered at the centre is at least
37
+ * visible and clickable. One helper rather than a `??` at each of three use sites.
38
+ */
39
+ export function read(map, id, fallback) {
40
+ return map.get(id) ?? fallback;
41
+ }
42
+ /**
43
+ * One angular slot per leaf, then every parent centred on its children.
44
+ *
45
+ * Centring is the "tidy" part: without it a parent sits at a fixed slot and its links cross
46
+ * the neighbouring subtrees to reach their own children. Computed bottom-up, so a parent sees
47
+ * children that are already placed.
48
+ */
49
+ function angles(h, step) {
50
+ const angle = new Map();
51
+ h.leaves.forEach((id, i) => angle.set(id, i * step));
52
+ const place = (id) => {
53
+ const cached = angle.get(id);
54
+ if (cached !== undefined)
55
+ return cached;
56
+ const kids = childrenIn(h, id);
57
+ const mean = kids.reduce((sum, kid) => sum + place(kid), 0) / kids.length;
58
+ angle.set(id, mean);
59
+ return mean;
60
+ };
61
+ for (const root of h.roots)
62
+ place(root);
63
+ return angle;
64
+ }
65
+ /**
66
+ * How far out each node sits.
67
+ *
68
+ * `tree` reads radius as depth. `dendrogram` pins leaves to the outermost ring and lifts an
69
+ * internal node to just inside its deepest descendant, which is what makes leaves comparable.
70
+ */
71
+ function radii(h, mode, maxDepth) {
72
+ const radius = new Map();
73
+ if (mode === 'tree') {
74
+ for (const [id, depth] of h.depthOf)
75
+ radius.set(id, depth * RING);
76
+ return radius;
77
+ }
78
+ // Deepest descendant, bottom-up: a leaf is its own, and a parent sits one ring inside its
79
+ // deepest child so the tree still reads outward.
80
+ const deepest = (id) => {
81
+ const kids = childrenIn(h, id);
82
+ if (kids.length === 0)
83
+ return maxDepth;
84
+ return Math.min(...kids.map((kid) => deepest(kid))) - 1;
85
+ };
86
+ for (const id of h.depthOf.keys())
87
+ radius.set(id, Math.max(0, deepest(id)) * RING);
88
+ return radius;
89
+ }
90
+ /**
91
+ * The circle the tree has to fit in.
92
+ *
93
+ * The rim's circumference has to hold every leaf plus its label, so it follows from the LEAF
94
+ * COUNT rather than dividing a fixed circle into ever-thinner slices — which is how a radial
95
+ * layout turns into the same unreadable stack it was meant to replace.
96
+ */
97
+ function geometry(h) {
98
+ const leafCount = Math.max(1, h.leaves.length);
99
+ const maxDepth = Math.max(0, ...h.depthOf.values());
100
+ const outer = Math.max(RING, (LEAF_ARC * leafCount) / (Math.PI * 2));
101
+ return {
102
+ step: (Math.PI * 2) / leafCount,
103
+ maxDepth,
104
+ outer,
105
+ // Rings spaced to reach the rim the leaf count demands, so a wide tree spreads rather
106
+ // than piling every level on top of the last.
107
+ spacing: maxDepth > 0 ? outer / maxDepth : 0,
108
+ extent: outer + PAD
109
+ };
110
+ }
111
+ /** Centre-out placement, converted to top-left card coordinates. */
112
+ function place(h, sizeOf, mode, model) {
113
+ const { step, maxDepth, spacing, extent } = geometry(h);
114
+ const angle = angles(h, step);
115
+ const radius = radii(h, mode, maxDepth);
116
+ const cards = {};
117
+ for (const node of model.nodes) {
118
+ // Only what the hierarchy materialised. A pruned node has no angle and no radius, and
119
+ // placing it anyway would stack every dropped node on the centre point.
120
+ if (!h.depthOf.has(node.id))
121
+ continue;
122
+ const size = read(sizeOf, node.id, { w: 1, h: 1 });
123
+ const r = (read(radius, node.id, 0) / RING) * spacing;
124
+ const a = read(angle, node.id, 0);
125
+ cards[node.id] = {
126
+ node,
127
+ vis: [],
128
+ more: node.rows.length,
129
+ w: size.w,
130
+ h: size.h,
131
+ x: extent + Math.cos(a) * r - size.w / 2,
132
+ y: extent + Math.sin(a) * r - size.h / 2,
133
+ groupIndex: 0
134
+ };
135
+ }
136
+ return { cards, size: { w: extent * 2, h: extent * 2 } };
137
+ }
138
+ /** Identity half of a routed edge — everything that is not geometry. */
139
+ function identity(edge, i) {
140
+ return {
141
+ i,
142
+ id: edge.id,
143
+ fromKey: edge.source,
144
+ toKey: edge.target,
145
+ kind: edge.kind,
146
+ relation: edge.relation
147
+ };
148
+ }
149
+ /**
150
+ * Centre-to-centre links.
151
+ *
152
+ * `s1`/`s2` are derived from which way the link actually points rather than fixed: on a circle
153
+ * "the right side" is meaningless, and a curve control point pushed the wrong way loops back
154
+ * through the node it came from.
155
+ */
156
+ function route(edge, i, cards, extra) {
157
+ const a = cards[edge.source];
158
+ const b = cards[edge.target];
159
+ if (!a || !b)
160
+ return null;
161
+ const [ax, ay] = [a.x + a.w / 2, a.y + a.h / 2];
162
+ const [bx, by] = [b.x + b.w / 2, b.y + b.h / 2];
163
+ if (a === b) {
164
+ return {
165
+ ...identity(edge, i),
166
+ self: true,
167
+ x1: a.x + a.w,
168
+ y1: ay,
169
+ x2: a.x + a.w,
170
+ y2: ay + 14,
171
+ s1: 1,
172
+ s2: 1
173
+ };
174
+ }
175
+ return {
176
+ ...identity(edge, i),
177
+ // Not a broken cycle here but the same idea: a relationship the tree could not express,
178
+ // still drawn. A theme distinguishes it rather than the reader wondering why one link
179
+ // ignores the rings.
180
+ back: extra.has(edge.id) || undefined,
181
+ self: false,
182
+ x1: ax,
183
+ y1: ay,
184
+ x2: bx,
185
+ y2: by,
186
+ s1: bx >= ax ? 1 : -1,
187
+ s2: bx >= ax ? -1 : 1
188
+ };
189
+ }
190
+ function routeAll(model, cards, extra) {
191
+ const routed = [];
192
+ model.edges.forEach((edge, i) => {
193
+ const e = route(edge, i, cards, extra);
194
+ if (e)
195
+ routed.push(e);
196
+ });
197
+ return routed;
198
+ }
199
+ /**
200
+ * A call graph around a circle.
201
+ *
202
+ * No clusters: the rings already carry structure, and a group box drawn over a radial
203
+ * arrangement cuts across every one of them.
204
+ */
205
+ export const radial = (model, options) => {
206
+ warnUnknownOptions(options, 'radial');
207
+ if (model.nodes.length === 0) {
208
+ return { clusters: [], cards: {}, edges: [], size: { w: 0, h: 0 } };
209
+ }
210
+ const h = hierarchy(model, { focus: options.root, levels: options.levels });
211
+ const sizeOf = nodeSizes(model, options.sizeBy ?? 'degree', options.sizeScale ?? 'linear');
212
+ const { cards, size } = place(h, sizeOf, options.radialMode ?? 'tree', model);
213
+ return { clusters: [], cards, edges: routeAll(model, cards, h.extra), size };
214
+ };
@@ -0,0 +1,15 @@
1
+ import type { GraphModel } from '../types.js';
2
+ export type Ranking = {
3
+ /** Node id → column index, 0-based. Every node in the model is present. */
4
+ ranks: Map<string, number>;
5
+ /** Ids of the edges reversed to make the graph acyclic. */
6
+ back: Set<string>;
7
+ };
8
+ /**
9
+ * Columns for every node, and the edges broken to get them.
10
+ *
11
+ * A node constrained by nothing — disconnected, or reached only by a broken edge — ranks 0 and
12
+ * still gets a box. Dropping it would hide a table from the diagram because of how it happens
13
+ * to be referenced.
14
+ */
15
+ export declare function rank(model: GraphModel): Ranking;