@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.
- package/README.md +59 -2
- package/dist/Graph.svelte +121 -72
- package/dist/GraphLegend.svelte +112 -0
- package/dist/GraphLegend.svelte.d.ts +33 -0
- package/dist/GraphState.svelte.d.ts +107 -15
- package/dist/GraphState.svelte.js +186 -3
- 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/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 +18 -1
- package/dist/index.js +23 -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/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/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 +141 -0
- package/dist/layout/world.d.ts +9 -0
- package/dist/layout/world.js +144 -0
- package/dist/model/normalize.js +47 -1
- package/dist/model/tree.d.ts +27 -0
- package/dist/model/tree.js +182 -0
- package/dist/types.d.ts +55 -3
- package/package.json +9 -4
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type Point = {
|
|
2
|
+
x: number;
|
|
3
|
+
y: number;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Where two paths stop agreeing.
|
|
7
|
+
*
|
|
8
|
+
* The tree's lowest common ancestor, by prefix — `TreeNode.path` is the full chain from the
|
|
9
|
+
* root, so no parent pointers are needed and nothing has to be walked twice.
|
|
10
|
+
*/
|
|
11
|
+
export declare function commonDepth(a: string[], b: string[]): number;
|
|
12
|
+
/**
|
|
13
|
+
* Pull each control point toward the straight line between the endpoints.
|
|
14
|
+
*
|
|
15
|
+
* `beta` is the tension: 1 keeps the full route through the tree, 0 collapses it onto the
|
|
16
|
+
* chord. Endpoints are unmoved at any beta — an edge has to start and finish where its nodes
|
|
17
|
+
* are, whatever the middle does.
|
|
18
|
+
*/
|
|
19
|
+
export declare function relax(points: Point[], beta: number): Point[];
|
|
20
|
+
/**
|
|
21
|
+
* A uniform cubic B-spline through `points`, as an SVG path.
|
|
22
|
+
*
|
|
23
|
+
* B-spline rather than a curve THROUGH the points: a spline that interpolates every control
|
|
24
|
+
* point reproduces the corner at each tree level, and the whole reason to route through the
|
|
25
|
+
* tree is to get a smooth flow. The ends are duplicated so the curve actually reaches the
|
|
26
|
+
* first and last point instead of stopping short of them.
|
|
27
|
+
*/
|
|
28
|
+
export declare function splinePath(points: Point[]): string;
|
|
29
|
+
/** The bundled route between two leaves, relaxed by `beta` and drawn as a spline. */
|
|
30
|
+
export declare function bundlePath(points: Point[], beta: number): string;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/* Hierarchical edge bundling — Holten (2006).
|
|
2
|
+
*
|
|
3
|
+
* A radial dendrogram of a codebase has every module on one rim, and the calls between them
|
|
4
|
+
* are chords across the middle. Drawn straight, a few hundred of those are a solid disc of
|
|
5
|
+
* ink. Bundled, edges that travel the same way through the TREE are pulled together, and the
|
|
6
|
+
* picture turns into visible flows between parts of the structure — which is the thing the
|
|
7
|
+
* reader came for.
|
|
8
|
+
*
|
|
9
|
+
* The bundle is a B-spline through the path each edge takes up to the lowest common ancestor
|
|
10
|
+
* and back down, relaxed toward the straight line by `beta`. At `beta = 0` it IS the straight
|
|
11
|
+
* line, which is why that is the "Straight" setting rather than a separate code path.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Where two paths stop agreeing.
|
|
15
|
+
*
|
|
16
|
+
* The tree's lowest common ancestor, by prefix — `TreeNode.path` is the full chain from the
|
|
17
|
+
* root, so no parent pointers are needed and nothing has to be walked twice.
|
|
18
|
+
*/
|
|
19
|
+
export function commonDepth(a, b) {
|
|
20
|
+
let i = 0;
|
|
21
|
+
while (i < a.length && i < b.length && a[i] === b[i])
|
|
22
|
+
i++;
|
|
23
|
+
return i;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Pull each control point toward the straight line between the endpoints.
|
|
27
|
+
*
|
|
28
|
+
* `beta` is the tension: 1 keeps the full route through the tree, 0 collapses it onto the
|
|
29
|
+
* chord. Endpoints are unmoved at any beta — an edge has to start and finish where its nodes
|
|
30
|
+
* are, whatever the middle does.
|
|
31
|
+
*/
|
|
32
|
+
export function relax(points, beta) {
|
|
33
|
+
if (points.length < 2)
|
|
34
|
+
return points;
|
|
35
|
+
const first = points[0];
|
|
36
|
+
const last = points[points.length - 1];
|
|
37
|
+
const span = points.length - 1;
|
|
38
|
+
return points.map((p, i) => {
|
|
39
|
+
const t = i / span;
|
|
40
|
+
const straightX = first.x + (last.x - first.x) * t;
|
|
41
|
+
const straightY = first.y + (last.y - first.y) * t;
|
|
42
|
+
return {
|
|
43
|
+
x: beta * p.x + (1 - beta) * straightX,
|
|
44
|
+
y: beta * p.y + (1 - beta) * straightY
|
|
45
|
+
};
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const fmt = (n) => Number(n.toFixed(2));
|
|
49
|
+
/**
|
|
50
|
+
* A uniform cubic B-spline through `points`, as an SVG path.
|
|
51
|
+
*
|
|
52
|
+
* B-spline rather than a curve THROUGH the points: a spline that interpolates every control
|
|
53
|
+
* point reproduces the corner at each tree level, and the whole reason to route through the
|
|
54
|
+
* tree is to get a smooth flow. The ends are duplicated so the curve actually reaches the
|
|
55
|
+
* first and last point instead of stopping short of them.
|
|
56
|
+
*/
|
|
57
|
+
export function splinePath(points) {
|
|
58
|
+
if (points.length === 0)
|
|
59
|
+
return '';
|
|
60
|
+
if (points.length === 1)
|
|
61
|
+
return `M ${fmt(points[0].x)} ${fmt(points[0].y)}`;
|
|
62
|
+
if (points.length === 2) {
|
|
63
|
+
return `M ${fmt(points[0].x)} ${fmt(points[0].y)} L ${fmt(points[1].x)} ${fmt(points[1].y)}`;
|
|
64
|
+
}
|
|
65
|
+
// Clamped: repeating the endpoints pins the curve to them, which a plain B-spline does not do.
|
|
66
|
+
const p = [points[0], points[0], ...points, points[points.length - 1], points[points.length - 1]];
|
|
67
|
+
let d = `M ${fmt(p[1].x)} ${fmt(p[1].y)}`;
|
|
68
|
+
for (let i = 1; i < p.length - 2; i++) {
|
|
69
|
+
const [a, b, c, e] = [p[i - 1], p[i], p[i + 1], p[i + 2]];
|
|
70
|
+
// Standard uniform cubic B-spline in Bezier form.
|
|
71
|
+
const c1 = { x: b.x + (c.x - a.x) / 6, y: b.y + (c.y - a.y) / 6 };
|
|
72
|
+
const c2 = { x: c.x - (e.x - b.x) / 6, y: c.y - (e.y - b.y) / 6 };
|
|
73
|
+
d += ` C ${fmt(c1.x)} ${fmt(c1.y)}, ${fmt(c2.x)} ${fmt(c2.y)}, ${fmt(c.x)} ${fmt(c.y)}`;
|
|
74
|
+
}
|
|
75
|
+
return d;
|
|
76
|
+
}
|
|
77
|
+
/** The bundled route between two leaves, relaxed by `beta` and drawn as a spline. */
|
|
78
|
+
export function bundlePath(points, beta) {
|
|
79
|
+
return splinePath(relax(points, beta));
|
|
80
|
+
}
|
package/dist/layout/cluster.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { buildCards } from './cards.js';
|
|
2
2
|
import { barycenterPasses, buildAdjacency, buildClusters, flow, groupByGroup, orderClusters, pack } from './clusters.js';
|
|
3
3
|
import { buildEdges } from './edges.js';
|
|
4
|
+
import { warnUnknownOptions } from './options.js';
|
|
4
5
|
import { nestedClusters } from './nested.js';
|
|
5
6
|
/**
|
|
6
7
|
* Deterministic cluster layout: groups become clusters, clusters are ordered to
|
|
@@ -12,6 +13,7 @@ import { nestedClusters } from './nested.js';
|
|
|
12
13
|
* area-descending order with alphabetical lists.
|
|
13
14
|
*/
|
|
14
15
|
export const cluster = (model, options) => {
|
|
16
|
+
warnUnknownOptions(options, 'cluster');
|
|
15
17
|
const density = options.density ?? 'keys';
|
|
16
18
|
const arrange = options.arrange ?? 'untangle';
|
|
17
19
|
const cards = buildCards(model.nodes, density, { expanded: options.expanded });
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { LayoutFn } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Columns by reference direction, ordered to reduce crossings, with fixed ports.
|
|
4
|
+
*
|
|
5
|
+
* No clusters: ranking and schema grouping compete for the same axis, and a box cannot be both
|
|
6
|
+
* "in the `public` box" and "in column 3". `cluster` remains the schema view.
|
|
7
|
+
*/
|
|
8
|
+
export declare const flow: LayoutFn;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/* The `flow` layout: columns by reference direction, fixed ports, crossings reduced.
|
|
2
|
+
*
|
|
3
|
+
* Raised from the ER diagram — incoming should always enter on the LEFT and outgoing always
|
|
4
|
+
* leave from the RIGHT, and the boxes should be arranged so links are not buried behind
|
|
5
|
+
* entities. Those are ONE feature. `edges.ts` picks a connector's side by relative POSITION,
|
|
6
|
+
* so an edge's exit side tells a reader where the other box happens to sit rather than which
|
|
7
|
+
* way the reference points; and fixing the ports without ranking makes burial worse, because a
|
|
8
|
+
* right-exit connector reaching a left-hand table sweeps back across everything between.
|
|
9
|
+
*
|
|
10
|
+
* Ranking is what makes x-position mean direction, and only then are fixed ports honest.
|
|
11
|
+
*
|
|
12
|
+
* See docs/design/25-flow-layout.md — including what this deliberately does NOT do: a long
|
|
13
|
+
* edge is still drawn straight over any box between its endpoints, because routing it around
|
|
14
|
+
* needs dummy nodes on the intervening ranks and that is its own slice.
|
|
15
|
+
*/
|
|
16
|
+
import { buildCards } from './cards.js';
|
|
17
|
+
import { rank } from './rank.js';
|
|
18
|
+
import { order } from './order.js';
|
|
19
|
+
import { warnUnknownOptions } from './options.js';
|
|
20
|
+
import { CARD_W, GAP_Y, HEAD_H, ROW_H } from './constants.js';
|
|
21
|
+
/**
|
|
22
|
+
* Horizontal gap between columns.
|
|
23
|
+
*
|
|
24
|
+
* Wider than `cluster`'s `GAP_X` on purpose: this gap is where every connector between two
|
|
25
|
+
* columns is drawn, so it is working space rather than breathing room. Too tight and the
|
|
26
|
+
* curves of a fan-out overlap each other before they reach their targets.
|
|
27
|
+
*/
|
|
28
|
+
const COL_GAP = 120;
|
|
29
|
+
/** Vertical anchor for a row on a card, matching `edges.ts` so the two agree. */
|
|
30
|
+
function anchorY(card, rowName) {
|
|
31
|
+
const idx = card.vis.findIndex((r) => r.name === rowName);
|
|
32
|
+
if (idx >= 0)
|
|
33
|
+
return card.y + HEAD_H + idx * ROW_H + ROW_H / 2;
|
|
34
|
+
return card.y + HEAD_H / 2;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Place each column left to right, centring every column on a shared axis.
|
|
38
|
+
*
|
|
39
|
+
* Every id in `layers` has a card without checking: both come from `model.nodes`, and
|
|
40
|
+
* `buildCards` keys one entry per node. A guard here would be a branch no input can reach.
|
|
41
|
+
*/
|
|
42
|
+
function place(layers, cards) {
|
|
43
|
+
const heights = layers.map((layer) => layer.reduce((total, id) => total + cards[id].h + GAP_Y, -GAP_Y));
|
|
44
|
+
const tallest = Math.max(0, ...heights);
|
|
45
|
+
let x = 0;
|
|
46
|
+
layers.forEach((layer, i) => {
|
|
47
|
+
// Centred rather than top-aligned: a short column hanging off the top reads as a
|
|
48
|
+
// hierarchy that is not there, and centring keeps most connectors near-horizontal.
|
|
49
|
+
let y = (tallest - heights[i]) / 2;
|
|
50
|
+
for (const id of layer) {
|
|
51
|
+
const card = cards[id];
|
|
52
|
+
card.x = x;
|
|
53
|
+
card.y = y;
|
|
54
|
+
y += card.h + GAP_Y;
|
|
55
|
+
}
|
|
56
|
+
x += CARD_W + COL_GAP;
|
|
57
|
+
});
|
|
58
|
+
return { w: Math.max(0, x - COL_GAP), h: tallest };
|
|
59
|
+
}
|
|
60
|
+
/** The identity half of a routed edge — everything that is not geometry. */
|
|
61
|
+
function identity(edge, i) {
|
|
62
|
+
return {
|
|
63
|
+
i,
|
|
64
|
+
id: edge.id,
|
|
65
|
+
fromKey: edge.source,
|
|
66
|
+
toKey: edge.target,
|
|
67
|
+
kind: edge.kind,
|
|
68
|
+
relation: edge.relation
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* A self-reference bows off the right edge — the same shape `edges.ts` gives it, including the
|
|
73
|
+
* nudge that stops a single-row loop collapsing to a point.
|
|
74
|
+
*/
|
|
75
|
+
function selfLoop(edge, i, at) {
|
|
76
|
+
return {
|
|
77
|
+
...identity(edge, i),
|
|
78
|
+
self: true,
|
|
79
|
+
x1: at.x,
|
|
80
|
+
y1: at.y1,
|
|
81
|
+
x2: at.x,
|
|
82
|
+
y2: at.y2 === at.y1 ? at.y1 + 14 : at.y2,
|
|
83
|
+
s1: 1,
|
|
84
|
+
s2: 1
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* One connector, always leaving the right and entering the left.
|
|
89
|
+
*
|
|
90
|
+
* That is the whole point of the layout, so there is no side calculation here at all — unlike
|
|
91
|
+
* `edges.ts`, which derives sides from geometry. A back edge gets the same treatment and pays
|
|
92
|
+
* a visible sweep for it; that is the minority case ranking exists to isolate.
|
|
93
|
+
*/
|
|
94
|
+
function route(edge, i, cards, back) {
|
|
95
|
+
const a = cards[edge.source];
|
|
96
|
+
const b = cards[edge.target];
|
|
97
|
+
if (!a || !b)
|
|
98
|
+
return null;
|
|
99
|
+
const y1 = anchorY(a, edge.sourceRow);
|
|
100
|
+
const y2 = anchorY(b, edge.targetRow);
|
|
101
|
+
if (a === b)
|
|
102
|
+
return selfLoop(edge, i, { x: a.x + a.w, y1, y2 });
|
|
103
|
+
return {
|
|
104
|
+
...identity(edge, i),
|
|
105
|
+
back: back.has(edge.id) || undefined,
|
|
106
|
+
self: false,
|
|
107
|
+
x1: a.x + a.w,
|
|
108
|
+
y1,
|
|
109
|
+
x2: b.x,
|
|
110
|
+
y2,
|
|
111
|
+
s1: 1,
|
|
112
|
+
s2: -1
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function routeAll(model, cards, back) {
|
|
116
|
+
const routed = [];
|
|
117
|
+
model.edges.forEach((edge, i) => {
|
|
118
|
+
const e = route(edge, i, cards, back);
|
|
119
|
+
if (e)
|
|
120
|
+
routed.push(e);
|
|
121
|
+
});
|
|
122
|
+
return routed;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Columns by reference direction, ordered to reduce crossings, with fixed ports.
|
|
126
|
+
*
|
|
127
|
+
* No clusters: ranking and schema grouping compete for the same axis, and a box cannot be both
|
|
128
|
+
* "in the `public` box" and "in column 3". `cluster` remains the schema view.
|
|
129
|
+
*/
|
|
130
|
+
export const flow = (model, options) => {
|
|
131
|
+
warnUnknownOptions(options, 'flow');
|
|
132
|
+
const cards = buildCards(model.nodes, options.density ?? 'keys', {
|
|
133
|
+
expanded: options.expanded
|
|
134
|
+
});
|
|
135
|
+
if (model.nodes.length === 0) {
|
|
136
|
+
return { clusters: [], cards: {}, edges: [], size: { w: 0, h: 0 } };
|
|
137
|
+
}
|
|
138
|
+
const { ranks, back } = rank(model);
|
|
139
|
+
const size = place(order(model, ranks, back), cards);
|
|
140
|
+
return { clusters: [], cards, edges: routeAll(model, cards, back), size };
|
|
141
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { GraphModel } from '../types.js';
|
|
2
|
+
export type Hierarchy = {
|
|
3
|
+
/** Nodes nothing calls, plus any disconnected node. In model order. */
|
|
4
|
+
roots: string[];
|
|
5
|
+
/** Hops from the nearest root. */
|
|
6
|
+
depthOf: Map<string, number>;
|
|
7
|
+
/** Tree children, in the order they were reached. */
|
|
8
|
+
childrenOf: Map<string, string[]>;
|
|
9
|
+
/** Childless nodes, in walk order — left to right around the rim. */
|
|
10
|
+
leaves: string[];
|
|
11
|
+
/** Ids of edges the tree could not use. Real relationships, drawn but not structural. */
|
|
12
|
+
extra: Set<string>;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* A node's tree children — always an array.
|
|
16
|
+
*
|
|
17
|
+
* A childless node has no entry at all, and every caller wants an empty list rather than an
|
|
18
|
+
* `undefined` to guard. One place to say so beats the same `?? []` at each use site.
|
|
19
|
+
*/
|
|
20
|
+
export declare function childrenIn(h: Pick<Hierarchy, 'childrenOf'>, id: string): string[];
|
|
21
|
+
/**
|
|
22
|
+
* One parent per node, and the edges that did not fit.
|
|
23
|
+
*
|
|
24
|
+
* Every node is placed: a disconnected one becomes its own root rather than disappearing, and
|
|
25
|
+
* a cycle terminates because a claimed node is never re-claimed.
|
|
26
|
+
*/
|
|
27
|
+
export type HierarchyOptions = {
|
|
28
|
+
/**
|
|
29
|
+
* Start the walk here instead of at the natural roots — what drilling into a crate does.
|
|
30
|
+
* A focus naming no node is ignored rather than blanking the canvas, because a stale drill
|
|
31
|
+
* target is a normal state after the data changes underneath it.
|
|
32
|
+
*/
|
|
33
|
+
focus?: string | null;
|
|
34
|
+
/**
|
|
35
|
+
* How many levels to keep, counting the root as one. Everything deeper is dropped.
|
|
36
|
+
*
|
|
37
|
+
* The control that makes a large tree readable at all: a dendrogram over a whole codebase
|
|
38
|
+
* puts every leaf on one rim, each a fraction of a degree wide.
|
|
39
|
+
*/
|
|
40
|
+
levels?: number;
|
|
41
|
+
};
|
|
42
|
+
export declare function hierarchy(model: GraphModel, options?: HierarchyOptions): Hierarchy;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/* A spanning tree over a call graph, which is what a radial layout can actually draw.
|
|
2
|
+
*
|
|
3
|
+
* A call graph is not a tree and never was: a shared helper has several callers, and recursion
|
|
4
|
+
* makes cycles. Radial layouts need one parent per node, so this picks one — breadth-first, so
|
|
5
|
+
* a node sits at its SHORTEST distance from a root — and reports the edges it could not use.
|
|
6
|
+
*
|
|
7
|
+
* Those edges are reported rather than discarded. "b also calls this" is exactly the fact a
|
|
8
|
+
* reader is looking for in a call graph, and a tree that silently drops half its edges looks
|
|
9
|
+
* like a complete picture while being a quarter of one. The view draws them; they just do not
|
|
10
|
+
* define position.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* A node's tree children — always an array.
|
|
14
|
+
*
|
|
15
|
+
* A childless node has no entry at all, and every caller wants an empty list rather than an
|
|
16
|
+
* `undefined` to guard. One place to say so beats the same `?? []` at each use site.
|
|
17
|
+
*/
|
|
18
|
+
export function childrenIn(h, id) {
|
|
19
|
+
return h.childrenOf.get(id) ?? [];
|
|
20
|
+
}
|
|
21
|
+
/** An edge that can be a parent link: both ends are nodes, and it is not a self-loop. */
|
|
22
|
+
function usable(edge, byId) {
|
|
23
|
+
return edge.source !== edge.target && byId.has(edge.source) && byId.has(edge.target);
|
|
24
|
+
}
|
|
25
|
+
function outgoing(edges) {
|
|
26
|
+
const out = new Map();
|
|
27
|
+
for (const edge of edges) {
|
|
28
|
+
const list = out.get(edge.source);
|
|
29
|
+
if (list)
|
|
30
|
+
list.push(edge);
|
|
31
|
+
else
|
|
32
|
+
out.set(edge.source, [edge]);
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Where the walk starts: nodes nothing calls.
|
|
38
|
+
*
|
|
39
|
+
* A graph that is one big cycle has none, and rendering an empty canvas for a graph that
|
|
40
|
+
* plainly has nodes is the worse answer — so the first node in model order is drafted. Model
|
|
41
|
+
* order is fixed by normalisation, so the choice is stable.
|
|
42
|
+
*/
|
|
43
|
+
function rootsOf(model, links) {
|
|
44
|
+
const called = new Set(links.map((e) => e.target));
|
|
45
|
+
const roots = model.nodes.map((n) => n.id).filter((id) => !called.has(id));
|
|
46
|
+
if (roots.length > 0)
|
|
47
|
+
return roots;
|
|
48
|
+
return model.nodes.length > 0 ? [model.nodes[0].id] : [];
|
|
49
|
+
}
|
|
50
|
+
/** Every not-yet-claimed callee of `id`, claimed at `depth` and recorded as its children. */
|
|
51
|
+
function claimChildren(id, depth, w) {
|
|
52
|
+
const taken = [];
|
|
53
|
+
for (const edge of w.out.get(id) ?? []) {
|
|
54
|
+
if (w.claimed.has(edge.target))
|
|
55
|
+
continue;
|
|
56
|
+
w.claimed.set(edge.target, depth);
|
|
57
|
+
taken.push(edge.target);
|
|
58
|
+
}
|
|
59
|
+
if (taken.length > 0)
|
|
60
|
+
w.childrenOf.set(id, [...(w.childrenOf.get(id) ?? []), ...taken]);
|
|
61
|
+
return taken;
|
|
62
|
+
}
|
|
63
|
+
/** Breadth-first, claiming each node once at its nearest depth. */
|
|
64
|
+
function walk(roots, w) {
|
|
65
|
+
let frontier = roots.filter((id) => !w.claimed.has(id));
|
|
66
|
+
for (const id of frontier)
|
|
67
|
+
w.claimed.set(id, 0);
|
|
68
|
+
let depth = 0;
|
|
69
|
+
while (frontier.length > 0) {
|
|
70
|
+
depth++;
|
|
71
|
+
frontier = frontier.flatMap((id) => claimChildren(id, depth, w));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The node to re-root on, when there is one.
|
|
76
|
+
*
|
|
77
|
+
* A focus makes its subtree the whole picture — that is drilling. One naming no node is
|
|
78
|
+
* ignored rather than blanking the canvas: a stale drill target is a normal state once the
|
|
79
|
+
* data changes underneath it.
|
|
80
|
+
*/
|
|
81
|
+
function drillRoot(model, focus) {
|
|
82
|
+
return focus && model.byId.has(focus) ? focus : undefined;
|
|
83
|
+
}
|
|
84
|
+
/** Edge ids the spanning tree did not use as parent links. */
|
|
85
|
+
function nonTreeEdges(links, childrenOf) {
|
|
86
|
+
const tree = new Set();
|
|
87
|
+
for (const [parent, kids] of childrenOf) {
|
|
88
|
+
for (const kid of kids)
|
|
89
|
+
tree.add(`${parent}->${kid}`);
|
|
90
|
+
}
|
|
91
|
+
return new Set(links.filter((e) => !tree.has(`${e.source}->${e.target}`)).map((e) => e.id));
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Every node the natural roots could not reach, each seeding its own walk.
|
|
95
|
+
*
|
|
96
|
+
* A cycle nothing points into is unreachable from any root, and without this its members are
|
|
97
|
+
* simply absent from the diagram — which reads as missing DATA rather than as a layout choice.
|
|
98
|
+
*/
|
|
99
|
+
function sweepUnreached(model, w) {
|
|
100
|
+
const stragglers = [];
|
|
101
|
+
for (const node of model.nodes) {
|
|
102
|
+
if (w.claimed.has(node.id))
|
|
103
|
+
continue;
|
|
104
|
+
stragglers.push(node.id);
|
|
105
|
+
walk([node.id], w);
|
|
106
|
+
}
|
|
107
|
+
return stragglers;
|
|
108
|
+
}
|
|
109
|
+
/** Childless nodes in walk order, so a parent's children stay adjacent on the rim. */
|
|
110
|
+
function leavesOf(roots, childrenOf) {
|
|
111
|
+
const leaves = [];
|
|
112
|
+
const visit = (id) => {
|
|
113
|
+
const kids = childrenIn({ childrenOf }, id);
|
|
114
|
+
if (kids.length === 0) {
|
|
115
|
+
leaves.push(id);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
for (const kid of kids)
|
|
119
|
+
visit(kid);
|
|
120
|
+
};
|
|
121
|
+
for (const root of roots)
|
|
122
|
+
visit(root);
|
|
123
|
+
return leaves;
|
|
124
|
+
}
|
|
125
|
+
/** Everything outside `levels` hops of the roots, pruned. */
|
|
126
|
+
function prune(h, levels) {
|
|
127
|
+
const kept = new Set();
|
|
128
|
+
for (const [id, depth] of h.depthOf) {
|
|
129
|
+
if (depth < levels)
|
|
130
|
+
kept.add(id);
|
|
131
|
+
}
|
|
132
|
+
const childrenOf = new Map();
|
|
133
|
+
for (const [parent, kids] of h.childrenOf) {
|
|
134
|
+
if (!kept.has(parent))
|
|
135
|
+
continue;
|
|
136
|
+
const visible = kids.filter((kid) => kept.has(kid));
|
|
137
|
+
if (visible.length > 0)
|
|
138
|
+
childrenOf.set(parent, visible);
|
|
139
|
+
}
|
|
140
|
+
const roots = h.roots.filter((id) => kept.has(id));
|
|
141
|
+
return {
|
|
142
|
+
roots,
|
|
143
|
+
depthOf: new Map([...h.depthOf].filter(([id]) => kept.has(id))),
|
|
144
|
+
childrenOf,
|
|
145
|
+
leaves: leavesOf(roots, childrenOf),
|
|
146
|
+
extra: h.extra
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
export function hierarchy(model, options = {}) {
|
|
150
|
+
const links = model.edges.filter((e) => usable(e, model.byId));
|
|
151
|
+
const out = outgoing(links);
|
|
152
|
+
const claimed = new Map();
|
|
153
|
+
const childrenOf = new Map();
|
|
154
|
+
const focused = drillRoot(model, options.focus);
|
|
155
|
+
const roots = focused ? [focused] : rootsOf(model, links);
|
|
156
|
+
const w = { out, claimed, childrenOf };
|
|
157
|
+
walk(roots, w);
|
|
158
|
+
// A node inside a cycle that no root reaches is still a node. Each unclaimed one seeds its
|
|
159
|
+
// own walk, so the whole model is covered however tangled it is.
|
|
160
|
+
// Skipped entirely when a focus is set: drilling means "this subtree and nothing else", so
|
|
161
|
+
// sweeping up everything the focus cannot reach would undo the drill.
|
|
162
|
+
const stragglers = focused ? [] : sweepUnreached(model, w);
|
|
163
|
+
const allRoots = [...roots, ...stragglers];
|
|
164
|
+
const full = {
|
|
165
|
+
roots: allRoots,
|
|
166
|
+
depthOf: claimed,
|
|
167
|
+
childrenOf,
|
|
168
|
+
leaves: leavesOf(allRoots, childrenOf),
|
|
169
|
+
// An edge whose endpoints are not a parent/child pair in the tree. It is real and the
|
|
170
|
+
// view draws it; it simply did not get to decide where anything sits.
|
|
171
|
+
extra: nonTreeEdges(links, childrenOf)
|
|
172
|
+
};
|
|
173
|
+
const levels = options.levels;
|
|
174
|
+
return levels !== undefined && levels > 0 ? prune(full, levels) : full;
|
|
175
|
+
}
|
package/dist/layout/index.d.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { cluster } from './cluster.js';
|
|
2
|
+
import { flow } from './flow.js';
|
|
2
3
|
import { neighborhood } from './neighborhood.js';
|
|
4
|
+
import { world } from './world.js';
|
|
3
5
|
import { points } from './points.js';
|
|
6
|
+
import { radial } from './radial.js';
|
|
7
|
+
import { structure } from './structure.js';
|
|
8
|
+
import { sunburst } from './sunburst.js';
|
|
4
9
|
import type { LayoutFn } from './types.js';
|
|
5
10
|
/** Built-in layouts, addressable by name from `Graph`'s `layout` prop. */
|
|
6
11
|
export declare const layouts: Record<string, LayoutFn>;
|
|
7
12
|
export type LayoutName = keyof typeof layouts;
|
|
8
|
-
export { cluster, neighborhood, points };
|
|
13
|
+
export { cluster, flow, neighborhood, points, radial, structure, sunburst, world };
|
|
14
|
+
export { LAYOUT_OPTIONS, appliesTo } from './options.js';
|
|
9
15
|
export * from './types.js';
|
package/dist/layout/index.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { cluster } from './cluster.js';
|
|
2
|
+
import { flow } from './flow.js';
|
|
2
3
|
import { neighborhood } from './neighborhood.js';
|
|
4
|
+
import { world } from './world.js';
|
|
3
5
|
import { points } from './points.js';
|
|
6
|
+
import { radial } from './radial.js';
|
|
7
|
+
import { structure } from './structure.js';
|
|
8
|
+
import { sunburst } from './sunburst.js';
|
|
4
9
|
/** Built-in layouts, addressable by name from `Graph`'s `layout` prop. */
|
|
5
|
-
export const layouts = { cluster, neighborhood, points };
|
|
6
|
-
export { cluster, neighborhood, points };
|
|
10
|
+
export const layouts = { cluster, flow, neighborhood, points, radial, structure, sunburst, world };
|
|
11
|
+
export { cluster, flow, neighborhood, points, radial, structure, sunburst, world };
|
|
12
|
+
export { LAYOUT_OPTIONS, appliesTo } from './options.js';
|
|
7
13
|
export * from './types.js';
|