@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,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
|
+
}
|
package/dist/layout/points.js
CHANGED
|
@@ -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
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
134
|
+
model.nodes.forEach((node, i) => {
|
|
144
135
|
const key = node.group ?? '';
|
|
145
136
|
const list = byGroup.get(key) ?? [];
|
|
146
|
-
list.push({ node, ...sizeFor(
|
|
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;
|