@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.
- package/README.md +66 -2
- package/dist/Graph.svelte +219 -169
- package/dist/GraphLegend.svelte +112 -0
- package/dist/GraphLegend.svelte.d.ts +33 -0
- package/dist/GraphState.svelte.d.ts +115 -15
- package/dist/GraphState.svelte.js +213 -5
- package/dist/controls/BundleControl.svelte +30 -0
- package/dist/controls/BundleControl.svelte.d.ts +16 -0
- package/dist/controls/DensityControl.svelte +39 -0
- package/dist/controls/DensityControl.svelte.d.ts +16 -0
- package/dist/controls/DepthControl.svelte +34 -0
- package/dist/controls/DepthControl.svelte.d.ts +17 -0
- package/dist/controls/EdgeStyleControl.svelte +31 -0
- package/dist/controls/EdgeStyleControl.svelte.d.ts +14 -0
- package/dist/controls/ZoomControl.svelte +47 -0
- package/dist/controls/ZoomControl.svelte.d.ts +7 -0
- package/dist/controls/zoom.d.ts +6 -0
- package/dist/controls/zoom.js +12 -0
- package/dist/diagrams/CallTree.svelte +135 -0
- package/dist/diagrams/CallTree.svelte.d.ts +37 -0
- package/dist/diagrams/DependencyDiagram.svelte +124 -0
- package/dist/diagrams/DependencyDiagram.svelte.d.ts +30 -0
- package/dist/diagrams/DependencyMatrix.svelte +177 -0
- package/dist/diagrams/DependencyMatrix.svelte.d.ts +34 -0
- package/dist/diagrams/DiagramFrame.svelte +36 -0
- package/dist/diagrams/DiagramFrame.svelte.d.ts +20 -0
- package/dist/diagrams/ErDiagram.svelte +128 -0
- package/dist/diagrams/ErDiagram.svelte.d.ts +33 -0
- package/dist/diagrams/Neighborhood.svelte +127 -0
- package/dist/diagrams/Neighborhood.svelte.d.ts +33 -0
- package/dist/diagrams/StructureDiagram.svelte +131 -0
- package/dist/diagrams/StructureDiagram.svelte.d.ts +31 -0
- package/dist/diagrams/Sunburst.svelte +124 -0
- package/dist/diagrams/Sunburst.svelte.d.ts +32 -0
- package/dist/diagrams/Treemap.svelte +124 -0
- package/dist/diagrams/Treemap.svelte.d.ts +32 -0
- package/dist/icons.d.ts +5 -0
- package/dist/icons.js +5 -0
- package/dist/index.d.ts +21 -1
- package/dist/index.js +25 -1
- package/dist/layout/arc.d.ts +13 -0
- package/dist/layout/arc.js +58 -0
- package/dist/layout/bundle.d.ts +30 -0
- package/dist/layout/bundle.js +80 -0
- package/dist/layout/cluster.js +2 -0
- package/dist/layout/edges.js +11 -0
- package/dist/layout/flow.d.ts +8 -0
- package/dist/layout/flow.js +141 -0
- package/dist/layout/hierarchy.d.ts +42 -0
- package/dist/layout/hierarchy.js +175 -0
- package/dist/layout/index.d.ts +7 -1
- package/dist/layout/index.js +8 -2
- package/dist/layout/matrix.d.ts +38 -0
- package/dist/layout/matrix.js +90 -0
- package/dist/layout/neighborhood.js +164 -28
- package/dist/layout/options.d.ts +44 -0
- package/dist/layout/options.js +106 -0
- package/dist/layout/order.d.ts +11 -0
- package/dist/layout/order.js +153 -0
- package/dist/layout/points.js +27 -35
- package/dist/layout/radial.d.ts +16 -0
- package/dist/layout/radial.js +214 -0
- package/dist/layout/rank.d.ts +15 -0
- package/dist/layout/rank.js +105 -0
- package/dist/layout/rings.d.ts +20 -0
- package/dist/layout/rings.js +92 -0
- package/dist/layout/sizing.d.ts +37 -0
- package/dist/layout/sizing.js +79 -0
- package/dist/layout/squarify.d.ts +14 -0
- package/dist/layout/squarify.js +114 -0
- package/dist/layout/structure.d.ts +9 -0
- package/dist/layout/structure.js +367 -0
- package/dist/layout/sunburst.d.ts +8 -0
- package/dist/layout/sunburst.js +126 -0
- package/dist/layout/types.d.ts +145 -0
- package/dist/layout/world.d.ts +9 -0
- package/dist/layout/world.js +144 -0
- package/dist/model/normalize.js +66 -4
- package/dist/model/tree.d.ts +27 -0
- package/dist/model/tree.js +182 -0
- package/dist/types.d.ts +75 -3
- package/package.json +9 -4
|
@@ -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>[];
|
|
@@ -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;
|