@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,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;
@@ -0,0 +1,105 @@
1
+ /* Which column a node belongs in, and which edges had to be broken to answer that.
2
+ *
3
+ * Pure graph work, no geometry — see docs/design/25-flow-layout.md. The rule the whole `flow`
4
+ * layout rests on: an edge leaves its source's RIGHT and enters its target's LEFT, so a target
5
+ * must sit in a later column than its source. Rank is that constraint solved.
6
+ */
7
+ /**
8
+ * Whether an edge can constrain a column.
9
+ *
10
+ * A self-loop spans no columns. An unplaced end is not a node — 59.6% of Sensei's 4.08M edges
11
+ * have a null target — and treating the raw string as one would invent a column for something
12
+ * that has no box.
13
+ */
14
+ function spans(edge, byId) {
15
+ if (edge.source === edge.target)
16
+ return false;
17
+ return byId.has(edge.source) && byId.has(edge.target);
18
+ }
19
+ /**
20
+ * Edge ids that must be reversed to make the graph acyclic, by DFS.
21
+ *
22
+ * An edge back to a node still on the recursion stack closes a cycle, so it is the one to
23
+ * break. Real schemas have mutual foreign keys; refusing to rank a cyclic graph would take the
24
+ * whole diagram down for a pair of tables that reference each other.
25
+ *
26
+ * Iteration follows `model.nodes` order, which normalisation fixes, so WHICH edge of a cycle
27
+ * gets broken is stable across runs — determinism `LayoutFn` promises.
28
+ */
29
+ function findBackEdges(nodes, out) {
30
+ const back = new Set();
31
+ const state = new Map();
32
+ const visit = (id) => {
33
+ state.set(id, 'open');
34
+ for (const edge of out.get(id) ?? []) {
35
+ const next = edge.target;
36
+ const seen = state.get(next);
37
+ if (seen === 'open')
38
+ back.add(edge.id);
39
+ else if (seen === undefined)
40
+ visit(next);
41
+ }
42
+ state.set(id, 'done');
43
+ };
44
+ for (const id of nodes) {
45
+ if (!state.has(id))
46
+ visit(id);
47
+ }
48
+ return back;
49
+ }
50
+ /**
51
+ * Longest-path ranking over the acyclic remainder.
52
+ *
53
+ * Longest, not shortest: with `a→b→c` and `a→c`, ranking `c` at 1 satisfies `a→c` and leaves
54
+ * `b→c` pointing leftward. Taking the longest path to each node satisfies every kept edge at
55
+ * once.
56
+ *
57
+ * Memoised depth-first rather than a topological sweep — the graph is already known acyclic
58
+ * here, so recursion terminates, and the memo makes it linear in edges.
59
+ */
60
+ function longestPaths(nodes, into) {
61
+ const ranks = new Map();
62
+ const depth = (id) => {
63
+ const cached = ranks.get(id);
64
+ if (cached !== undefined)
65
+ return cached;
66
+ // Set before recursing: the graph is acyclic by construction, and this also stops a
67
+ // pathological input from recursing forever rather than producing a wrong number.
68
+ ranks.set(id, 0);
69
+ let best = 0;
70
+ for (const edge of into.get(id) ?? []) {
71
+ best = Math.max(best, depth(edge.source) + 1);
72
+ }
73
+ ranks.set(id, best);
74
+ return best;
75
+ };
76
+ for (const id of nodes)
77
+ depth(id);
78
+ return ranks;
79
+ }
80
+ /** Group edges by one endpoint, keeping input order. */
81
+ function index(edges, key) {
82
+ const out = new Map();
83
+ for (const edge of edges) {
84
+ const list = out.get(key(edge));
85
+ if (list)
86
+ list.push(edge);
87
+ else
88
+ out.set(key(edge), [edge]);
89
+ }
90
+ return out;
91
+ }
92
+ /**
93
+ * Columns for every node, and the edges broken to get them.
94
+ *
95
+ * A node constrained by nothing — disconnected, or reached only by a broken edge — ranks 0 and
96
+ * still gets a box. Dropping it would hide a table from the diagram because of how it happens
97
+ * to be referenced.
98
+ */
99
+ export function rank(model) {
100
+ const ids = model.nodes.map((n) => n.id);
101
+ const usable = model.edges.filter((e) => spans(e, model.byId));
102
+ const back = findBackEdges(ids, index(usable, (e) => e.source));
103
+ const forward = usable.filter((e) => !back.has(e.id));
104
+ return { ranks: longestPaths(ids, index(forward, (e) => e.target)), back };
105
+ }
@@ -0,0 +1,20 @@
1
+ import type { GraphModel } from '../types.js';
2
+ /** Which side of the focus a ring sits on. */
3
+ export type Side = 'in' | 'out';
4
+ export type Ring = {
5
+ depth: number;
6
+ side: Side;
7
+ ids: string[];
8
+ };
9
+ /**
10
+ * Rings outward from `focus`, to `depth` hops, on both sides.
11
+ *
12
+ * `claimed` is seeded with the focus and grows as rings are built, which is what enforces
13
+ * nearest-depth-wins across BOTH sides — a node reached leftward at depth 1 is not re-reached
14
+ * rightward at depth 2.
15
+ *
16
+ * `seedSide` lets the caller decide ring 1 by its own rule (the dominant-direction split in
17
+ * `neighborhood`), and every ring beyond it inherits its ancestor's side: a caller's caller is
18
+ * further left, a callee's callee further right. That is what makes the columns read outward.
19
+ */
20
+ export declare function rings(model: GraphModel, focusId: string, depth: number, seedSide: (id: string) => Side): Ring[];
@@ -0,0 +1,92 @@
1
+ /* Breadth-first rings around a focus node — the shape a depth-N neighbourhood needs.
2
+ *
3
+ * Separate from `neighborhood.ts` because it is pure graph traversal with no geometry, and
4
+ * because the rule that makes it correct is worth stating once: a node is claimed at its
5
+ * NEAREST depth and never re-placed. Without that, a node reachable both directly and via a
6
+ * detour gets two cards for one identity, or gets drawn further away than it actually is.
7
+ */
8
+ /**
9
+ * The far end of an edge from `id` in one direction, or nothing.
10
+ *
11
+ * An unplaced end is not a node and has no card; a self-loop leads back where it started.
12
+ * Neither can seed a ring.
13
+ */
14
+ function across(edge, id, side) {
15
+ if (edge.unplaced || edge.source === edge.target)
16
+ return undefined;
17
+ if (side === 'out')
18
+ return edge.source === id ? edge.target : undefined;
19
+ return edge.target === id ? edge.source : undefined;
20
+ }
21
+ /** Every node one step from `id` in one direction. */
22
+ function step(edges, id, side) {
23
+ const out = [];
24
+ for (const edge of edges) {
25
+ const far = across(edge, id, side);
26
+ if (far !== undefined)
27
+ out.push(far);
28
+ }
29
+ return out;
30
+ }
31
+ const toRings = (bucket, depth) => ['in', 'out'].map((side) => ({ depth, side, ids: bucket.get(side) }));
32
+ /**
33
+ * Rings outward from `focus`, to `depth` hops, on both sides.
34
+ *
35
+ * `claimed` is seeded with the focus and grows as rings are built, which is what enforces
36
+ * nearest-depth-wins across BOTH sides — a node reached leftward at depth 1 is not re-reached
37
+ * rightward at depth 2.
38
+ *
39
+ * `seedSide` lets the caller decide ring 1 by its own rule (the dominant-direction split in
40
+ * `neighborhood`), and every ring beyond it inherits its ancestor's side: a caller's caller is
41
+ * further left, a callee's callee further right. That is what makes the columns read outward.
42
+ */
43
+ export function rings(model, focusId, depth, seedSide) {
44
+ const claimed = new Set([focusId]);
45
+ const out = [];
46
+ let previous = seedRing(model, focusId, claimed, seedSide);
47
+ out.push(...toRings(previous, 1));
48
+ for (let d = 2; d <= depth; d++) {
49
+ previous = nextRing(model, previous, claimed);
50
+ out.push(...toRings(previous, d));
51
+ }
52
+ return out;
53
+ }
54
+ /** An empty bucket per side. */
55
+ function sides() {
56
+ return new Map([
57
+ ['in', []],
58
+ ['out', []]
59
+ ]);
60
+ }
61
+ /**
62
+ * Ring 1, sided by the CALLER's rule rather than by a direction walk — that is what lets a
63
+ * mutual neighbour land on the side its dominant direction earns it (#160).
64
+ */
65
+ function seedRing(model, focusId, claimed, seedSide) {
66
+ const first = sides();
67
+ const reachable = new Set([
68
+ ...step(model.edges, focusId, 'in'),
69
+ ...step(model.edges, focusId, 'out')
70
+ ]);
71
+ // No `claimed` check: `reachable` is a Set, and `step` drops self-loops, so the
72
+ // pre-claimed focus cannot appear in it. Ring 1 is claimed here for the rings that follow.
73
+ for (const id of reachable) {
74
+ claimed.add(id);
75
+ first.get(seedSide(id)).push(id);
76
+ }
77
+ return first;
78
+ }
79
+ /** One ring further out, each side continuing in its own direction. */
80
+ function nextRing(model, previous, claimed) {
81
+ const next = sides();
82
+ for (const side of ['in', 'out']) {
83
+ const reached = previous.get(side).flatMap((from) => step(model.edges, from, side));
84
+ for (const id of reached) {
85
+ if (claimed.has(id))
86
+ continue;
87
+ claimed.add(id);
88
+ next.get(side).push(id);
89
+ }
90
+ }
91
+ return next;
92
+ }
@@ -0,0 +1,37 @@
1
+ import type { GraphModel, GraphNode } from '../types.js';
2
+ export type Sized = {
3
+ w: number;
4
+ h: number;
5
+ };
6
+ export type Scale = 'linear' | 'log';
7
+ /** Edges touching each node. A self-loop counts once — it is one edge, not two. */
8
+ export declare function degreeOf(model: GraphModel): Map<string, number>;
9
+ /**
10
+ * Position of `value` in `[min, max]`, 0..1.
11
+ *
12
+ * `log` is for a measure spanning orders of magnitude — declaration counts across a real repo
13
+ * span three or four, and a linear map puts everything below the top few on the floor. `+1`
14
+ * shifts the domain off zero, which has no logarithm.
15
+ *
16
+ * A flat measure — every node equal, so `max === min` — is 0, not a division by zero.
17
+ */
18
+ export declare function normalise(value: number, min: number, max: number, scale: Scale): number;
19
+ /** Area is LINEAR in the measure, so width grows as its square root — ten reads as ten. */
20
+ export declare function sizeFor(t: number): Sized;
21
+ /**
22
+ * The number each node is sized by.
23
+ *
24
+ * A node with no weight under `sizeBy: 'weight'` floors rather than vanishing or exploding —
25
+ * "unknown" is a normal state in a partially-indexed graph, and the alternative is a NaN
26
+ * width that renders as nothing at all.
27
+ */
28
+ export declare function measureOf(node: GraphNode, degree: Map<string, number>, sizeBy: string): number;
29
+ /**
30
+ * One size per node, from whichever measure the caller named.
31
+ *
32
+ * The domain is anchored at 0 rather than at the smallest value present, matching `points`:
33
+ * that is what makes a node the same size in both views, which is the point of sharing this
34
+ * at all. Against the present minimum, the smallest node in every graph would be identical
35
+ * whether it had one edge or four hundred.
36
+ */
37
+ export declare function nodeSizes(model: GraphModel, sizeBy: string, scale: Scale): Map<string, Sized>;
@@ -0,0 +1,79 @@
1
+ /* How big a node is drawn when the layout has no room for a card.
2
+ *
3
+ * Shared by `points` and `radial`: both replace the card with a small rect whose AREA — not
4
+ * width — tracks a measure, so ten edges reads as ten rather than as a hundred. Extracted from
5
+ * `points` rather than copied, because two copies of a size curve drift and the drift shows up
6
+ * as "the same node is bigger in one view than the other".
7
+ */
8
+ /**
9
+ * Area bounds, carried over from `points`' disc version (pi*5^2 to pi*26^2) so the ink weight
10
+ * of a diagram is unchanged by the switch to rects.
11
+ */
12
+ const MIN_AREA = Math.PI * 5 * 5;
13
+ const MAX_AREA = Math.PI * 26 * 26;
14
+ /** Width:height. 2:1 reads as a "chip"; a square wastes shelf height. */
15
+ const ASPECT = 2;
16
+ /** Edges touching each node. A self-loop counts once — it is one edge, not two. */
17
+ export function degreeOf(model) {
18
+ const degree = new Map();
19
+ for (const node of model.nodes)
20
+ degree.set(node.id, 0);
21
+ for (const edge of model.edges) {
22
+ degree.set(edge.source, (degree.get(edge.source) ?? 0) + 1);
23
+ if (edge.source !== edge.target) {
24
+ degree.set(edge.target, (degree.get(edge.target) ?? 0) + 1);
25
+ }
26
+ }
27
+ return degree;
28
+ }
29
+ /**
30
+ * Position of `value` in `[min, max]`, 0..1.
31
+ *
32
+ * `log` is for a measure spanning orders of magnitude — declaration counts across a real repo
33
+ * span three or four, and a linear map puts everything below the top few on the floor. `+1`
34
+ * shifts the domain off zero, which has no logarithm.
35
+ *
36
+ * A flat measure — every node equal, so `max === min` — is 0, not a division by zero.
37
+ */
38
+ export function normalise(value, min, max, scale) {
39
+ if (max <= min)
40
+ return 0;
41
+ if (scale === 'log') {
42
+ return Math.log(value - min + 1) / Math.log(max - min + 1);
43
+ }
44
+ return (value - min) / (max - min);
45
+ }
46
+ /** Area is LINEAR in the measure, so width grows as its square root — ten reads as ten. */
47
+ export function sizeFor(t) {
48
+ const area = MIN_AREA + (MAX_AREA - MIN_AREA) * t;
49
+ return { w: Math.sqrt(area * ASPECT), h: Math.sqrt(area / ASPECT) };
50
+ }
51
+ /**
52
+ * The number each node is sized by.
53
+ *
54
+ * A node with no weight under `sizeBy: 'weight'` floors rather than vanishing or exploding —
55
+ * "unknown" is a normal state in a partially-indexed graph, and the alternative is a NaN
56
+ * width that renders as nothing at all.
57
+ */
58
+ export function measureOf(node, degree, sizeBy) {
59
+ if (sizeBy === 'degree')
60
+ return degree.get(node.id) ?? 0;
61
+ if (sizeBy === 'weight')
62
+ return node.weight ?? 0;
63
+ return node.measures?.[sizeBy] ?? 0;
64
+ }
65
+ /**
66
+ * One size per node, from whichever measure the caller named.
67
+ *
68
+ * The domain is anchored at 0 rather than at the smallest value present, matching `points`:
69
+ * that is what makes a node the same size in both views, which is the point of sharing this
70
+ * at all. Against the present minimum, the smallest node in every graph would be identical
71
+ * whether it had one edge or four hundred.
72
+ */
73
+ export function nodeSizes(model, sizeBy, scale) {
74
+ const degree = degreeOf(model);
75
+ const measures = model.nodes.map((n) => measureOf(n, degree, sizeBy));
76
+ const min = Math.min(0, ...measures);
77
+ const max = Math.max(0, ...measures);
78
+ return new Map(model.nodes.map((node, i) => [node.id, sizeFor(normalise(measures[i], min, max, scale))]));
79
+ }
@@ -0,0 +1,14 @@
1
+ export type Rect = {
2
+ x: number;
3
+ y: number;
4
+ w: number;
5
+ h: number;
6
+ };
7
+ export type Sized = {
8
+ value: number;
9
+ };
10
+ export type Placed<T> = {
11
+ item: T;
12
+ rect: Rect;
13
+ };
14
+ export declare function squarify<T extends Sized>(items: T[], rect: Rect, minimum?: number): Placed<T>[];