@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,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';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { NodeAxis } from './types.js';
|
|
2
|
+
import type { GraphModel } from '../types.js';
|
|
3
|
+
export type MatrixCell = {
|
|
4
|
+
/** Index into `order` of the dependent (the edge's source). */
|
|
5
|
+
row: number;
|
|
6
|
+
/** Index into `order` of the dependency (the edge's target). */
|
|
7
|
+
col: number;
|
|
8
|
+
source: string;
|
|
9
|
+
target: string;
|
|
10
|
+
/** Edges folded into this cell — two FKs, a read and a write — each counted by its weight. */
|
|
11
|
+
count: number;
|
|
12
|
+
/** Against the grain: a cycle or a layer reaching up. */
|
|
13
|
+
above: boolean;
|
|
14
|
+
};
|
|
15
|
+
export type MatrixBlock = {
|
|
16
|
+
name: string;
|
|
17
|
+
start: number;
|
|
18
|
+
end: number;
|
|
19
|
+
};
|
|
20
|
+
export type Matrix = {
|
|
21
|
+
/** Node ids, providers first. Row i and column i are the same node. */
|
|
22
|
+
order: string[];
|
|
23
|
+
labels: string[];
|
|
24
|
+
/** Each node's group key on the grouping axis, parallel to `order` ('' when ungrouped). */
|
|
25
|
+
groups: string[];
|
|
26
|
+
cells: MatrixCell[];
|
|
27
|
+
/** Contiguous group runs along the diagonal. Empty when not grouping. */
|
|
28
|
+
blocks: MatrixBlock[];
|
|
29
|
+
/** Cells above the diagonal. */
|
|
30
|
+
above: number;
|
|
31
|
+
/** The largest `count`, for scaling cell intensity. */
|
|
32
|
+
maxCount: number;
|
|
33
|
+
};
|
|
34
|
+
export type MatrixOptions = {
|
|
35
|
+
/** Keep each group contiguous and outline it on the diagonal. Omit for one flat order. */
|
|
36
|
+
groupBy?: NodeAxis;
|
|
37
|
+
};
|
|
38
|
+
export declare function buildMatrix(model: GraphModel, options?: MatrixOptions): Matrix;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/* The dependency structure matrix (DSM): every node is both a row and a column, and a cell at
|
|
2
|
+
* (row, col) says "row depends on col".
|
|
3
|
+
*
|
|
4
|
+
* Its strength is the ORDER. Providers come first, so in a layered architecture every
|
|
5
|
+
* dependency points up-and-left and the whole matrix is lower-triangular. A mark ABOVE the
|
|
6
|
+
* diagonal is then a dependency against the grain — a cycle, or a layer that reaches up —
|
|
7
|
+
* and it is visible at a glance at sizes where a node-link drawing is a hairball.
|
|
8
|
+
*
|
|
9
|
+
* The order reuses `flow`'s ranking (longest path, DFS cycle breaking), reversed: `rank` puts
|
|
10
|
+
* a target in a later column than its source, and a DSM wants the target FIRST. Grouping keeps
|
|
11
|
+
* each group contiguous, ordered by where its first member ranks, so a module's block sits on
|
|
12
|
+
* the diagonal and a cross-module dependency against the grain lands above it.
|
|
13
|
+
*
|
|
14
|
+
* Pure and DOM-free, like every layout.
|
|
15
|
+
*/
|
|
16
|
+
import { rank } from './rank.js';
|
|
17
|
+
const axisOf = (node, axis) => (axis === 'kind' ? node.kind : node.group) ?? '';
|
|
18
|
+
/** Node ids, providers first; with `groupBy`, each group contiguous in first-ranked order. */
|
|
19
|
+
function orderNodes(model, groupBy) {
|
|
20
|
+
const { ranks } = rank(model);
|
|
21
|
+
// `rank` places every node, so each lookup hits; carrying the model index alongside the
|
|
22
|
+
// rank keeps ties in model order without a second map.
|
|
23
|
+
const byRank = model.nodes
|
|
24
|
+
.map((node, i) => ({ node, i, r: ranks.get(node.id) }))
|
|
25
|
+
.sort((a, b) => b.r - a.r || a.i - b.i)
|
|
26
|
+
.map((entry) => entry.node);
|
|
27
|
+
if (!groupBy)
|
|
28
|
+
return byRank;
|
|
29
|
+
const runs = new Map();
|
|
30
|
+
for (const node of byRank) {
|
|
31
|
+
const key = axisOf(node, groupBy);
|
|
32
|
+
const run = runs.get(key) ?? [];
|
|
33
|
+
run.push(node);
|
|
34
|
+
runs.set(key, run);
|
|
35
|
+
}
|
|
36
|
+
return [...runs.values()].flat();
|
|
37
|
+
}
|
|
38
|
+
function blocksOf(groups) {
|
|
39
|
+
const blocks = [];
|
|
40
|
+
groups.forEach((name, i) => {
|
|
41
|
+
const last = blocks[blocks.length - 1];
|
|
42
|
+
if (last && last.name === name)
|
|
43
|
+
last.end = i;
|
|
44
|
+
else
|
|
45
|
+
blocks.push({ name, start: i, end: i });
|
|
46
|
+
});
|
|
47
|
+
return blocks;
|
|
48
|
+
}
|
|
49
|
+
/** One cell per (dependent, dependency) pair, parallel edges folded into its count. */
|
|
50
|
+
function collectCells(model, index) {
|
|
51
|
+
const cells = new Map();
|
|
52
|
+
for (const edge of model.edges) {
|
|
53
|
+
const row = index.get(edge.source);
|
|
54
|
+
const col = index.get(edge.target);
|
|
55
|
+
if (row === undefined || col === undefined || row === col)
|
|
56
|
+
continue;
|
|
57
|
+
const key = `${row}:${col}`;
|
|
58
|
+
// A weighted edge already IS a count — an import list aggregated to components says
|
|
59
|
+
// "7 imports" once rather than repeating the edge seven times.
|
|
60
|
+
const n = edge.weight ?? 1;
|
|
61
|
+
const cell = cells.get(key);
|
|
62
|
+
if (cell)
|
|
63
|
+
cell.count += n;
|
|
64
|
+
else
|
|
65
|
+
cells.set(key, {
|
|
66
|
+
row,
|
|
67
|
+
col,
|
|
68
|
+
source: edge.source,
|
|
69
|
+
target: edge.target,
|
|
70
|
+
count: n,
|
|
71
|
+
above: col > row
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return [...cells.values()].sort((a, b) => a.row - b.row || a.col - b.col);
|
|
75
|
+
}
|
|
76
|
+
export function buildMatrix(model, options = {}) {
|
|
77
|
+
const nodes = orderNodes(model, options.groupBy);
|
|
78
|
+
const index = new Map(nodes.map((n, i) => [n.id, i]));
|
|
79
|
+
const cells = collectCells(model, index);
|
|
80
|
+
const groups = nodes.map((n) => (options.groupBy ? axisOf(n, options.groupBy) : ''));
|
|
81
|
+
return {
|
|
82
|
+
order: nodes.map((n) => n.id),
|
|
83
|
+
labels: nodes.map((n) => n.label),
|
|
84
|
+
groups,
|
|
85
|
+
cells,
|
|
86
|
+
blocks: options.groupBy ? blocksOf(groups) : [],
|
|
87
|
+
above: cells.filter((c) => c.above).length,
|
|
88
|
+
maxCount: Math.max(0, ...cells.map((c) => c.count))
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/* Entity-centric layout: the focus node centred, nodes that reference it stacked on
|
|
2
2
|
the left, nodes it references on the right.
|
|
3
3
|
|
|
4
|
+
EXACTLY ONE HOP, always. There is no depth option, and a caller passing one gets a warning
|
|
5
|
+
rather than silence (#162) — a two-hop view is a different picture with its own problems
|
|
6
|
+
(a hub explodes, and five columns need headings to be readable at all), not a parameter.
|
|
7
|
+
|
|
4
8
|
Ported from dbd's EntityDiagram.svelte. That component carried its own geometry
|
|
5
9
|
constants, buildCard, anchorY and path — roughly 100 lines duplicating cards.ts and
|
|
6
10
|
edges.ts with slightly different numbers. Here it reuses both, so the duplication is
|
|
@@ -8,6 +12,8 @@
|
|
|
8
12
|
+14 anchor nudge and 46px bow instead of EntityDiagram's +16 and 52px. */
|
|
9
13
|
import { buildCards } from './cards.js';
|
|
10
14
|
import { buildEdges } from './edges.js';
|
|
15
|
+
import { warnUnknownOptions } from './options.js';
|
|
16
|
+
import { rings } from './rings.js';
|
|
11
17
|
import { CARD_W } from './constants.js';
|
|
12
18
|
/* Local to this layout and deliberately NOT the cluster gaps in constants.ts: this is
|
|
13
19
|
a three-column portrait, not a masonry grid, so it wants a wide horizontal gutter and
|
|
@@ -19,7 +25,23 @@ const MIN_H = 120;
|
|
|
19
25
|
const H_PAD = 20;
|
|
20
26
|
/** Extra width reserved to the right of the focus card for self-loop bows. */
|
|
21
27
|
const LOOP_W = 60;
|
|
22
|
-
const EMPTY = { clusters: [], cards: {}, edges: [], size: { w: 0, h: 0 } };
|
|
28
|
+
const EMPTY = { clusters: [], cards: {}, edges: [], size: { w: 0, h: 0 }, columns: [] };
|
|
29
|
+
/**
|
|
30
|
+
* Column headings, worded for the KIND of graph rather than configured.
|
|
31
|
+
*
|
|
32
|
+
* The same layout draws a call graph and an ER diagram, and "calls" is simply wrong for the
|
|
33
|
+
* latter. The model already knows which it is, so the wording follows the edges instead of
|
|
34
|
+
* asking the consumer to supply strings that must then be kept in step.
|
|
35
|
+
*/
|
|
36
|
+
function headings(dependency) {
|
|
37
|
+
return dependency
|
|
38
|
+
? { in: ['called by', 'callers of callers'], out: ['calls', 'which call'] }
|
|
39
|
+
: { in: ['referenced by', 'their references'], out: ['references', 'which reference'] };
|
|
40
|
+
}
|
|
41
|
+
function headingFor(side, depth, dependency) {
|
|
42
|
+
const words = headings(dependency)[side];
|
|
43
|
+
return words[depth - 1] ?? `${words[0]} · ${depth} hops`;
|
|
44
|
+
}
|
|
23
45
|
function partition(model, focusId) {
|
|
24
46
|
const byNeighbour = new Map();
|
|
25
47
|
const selfEdges = [];
|
|
@@ -59,11 +81,15 @@ function referencedRows(neighbour) {
|
|
|
59
81
|
names.add(edge.targetRow);
|
|
60
82
|
return names;
|
|
61
83
|
}
|
|
62
|
-
/**
|
|
84
|
+
/**
|
|
85
|
+
* Stack height of a column of cards — gaps between them, not after the last.
|
|
86
|
+
*
|
|
87
|
+
* Seeded at `-STACK_GAP` so the trailing gap cancels without a subtraction, and clamped at 0
|
|
88
|
+
* so an empty column is 0 rather than a negative height. No early return: the callers filter
|
|
89
|
+
* empty rings already, so a guard here would be a branch no test could reach.
|
|
90
|
+
*/
|
|
63
91
|
function stackHeight(cards) {
|
|
64
|
-
|
|
65
|
-
return 0;
|
|
66
|
-
return cards.reduce((total, card) => total + card.h + STACK_GAP, 0) - STACK_GAP;
|
|
92
|
+
return Math.max(0, cards.reduce((total, card) => total + card.h + STACK_GAP, -STACK_GAP));
|
|
67
93
|
}
|
|
68
94
|
/** Place a column of cards at `x`, vertically centred within `height`. */
|
|
69
95
|
function placeColumn(cards, x, height) {
|
|
@@ -85,6 +111,122 @@ function buildNeighbourCards(model, neighbours, expanded) {
|
|
|
85
111
|
select: (row, node) => row.badges.includes('pk') || (wanted.get(node.id)?.has(row.name) ?? false)
|
|
86
112
|
});
|
|
87
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* Place every ring, size the canvas, and name the columns.
|
|
116
|
+
*
|
|
117
|
+
* An EMPTY ring costs no width: a depth-2 request on a graph with nothing at depth 2 must not
|
|
118
|
+
* leave a gap where that column would have been, so the width is measured from the rightmost
|
|
119
|
+
* column that actually exists rather than from the depth asked for.
|
|
120
|
+
*/
|
|
121
|
+
function arrange(built, cards, ctx) {
|
|
122
|
+
const occupied = built.filter((r) => r.ids.length > 0);
|
|
123
|
+
const { focusX, xFor } = geometry(occupied);
|
|
124
|
+
const focusCard = cards[ctx.focus.id];
|
|
125
|
+
const height = placeRings(occupied, cards, { focusCard, focusX, xFor });
|
|
126
|
+
const columns = buildColumns(occupied, {
|
|
127
|
+
focusX,
|
|
128
|
+
focusLabel: ctx.focus.label,
|
|
129
|
+
xFor,
|
|
130
|
+
dependency: ctx.dependency
|
|
131
|
+
});
|
|
132
|
+
const width = xFor('out', furthest(occupied, 'out')) + CARD_W + (ctx.hasLoop ? LOOP_W : 0) + 4;
|
|
133
|
+
return { columns, size: { w: width, h: height } };
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Which side ring 1 puts each neighbour on.
|
|
137
|
+
*
|
|
138
|
+
* The DOMINANT direction, not "has an out edge at all" (#160). A node with edges both ways is
|
|
139
|
+
* routine in a call graph — mutual recursion, a callback registered with its invoker, a
|
|
140
|
+
* visitor dispatching back into its walker — and testing `out.length > 0` put every one of
|
|
141
|
+
* them on the right, drawing its inbound edge right-to-left against the convention this
|
|
142
|
+
* layout states.
|
|
143
|
+
*
|
|
144
|
+
* One card per node is the constraint, so a TIE still draws one edge backwards whichever side
|
|
145
|
+
* wins. `in` is the deliberate pick: that column reads "things that reach this", which is what
|
|
146
|
+
* a reader scans for before changing something.
|
|
147
|
+
*/
|
|
148
|
+
function sideOf(neighbours) {
|
|
149
|
+
const outward = new Set(neighbours.filter((n) => n.out.length > n.in.length).map((n) => n.id));
|
|
150
|
+
return (id) => (outward.has(id) ? 'out' : 'in');
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Edges with BOTH ends placed, rather than "touches the focus".
|
|
154
|
+
*
|
|
155
|
+
* At depth 1 those are the same set — everything on the canvas is a direct neighbour — so the
|
|
156
|
+
* one-hop picture is unchanged. At depth 2 they differ: an edge from ring 1 to ring 2 touches
|
|
157
|
+
* no focus, and showing it is exactly what the second ring is for.
|
|
158
|
+
*/
|
|
159
|
+
function drawableIn(model, cards) {
|
|
160
|
+
return model.edges.filter((e) => cards[e.source] !== undefined && cards[e.target] !== undefined);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Where each ring sits. Ring d on the `in` side is d steps left of the focus; on the `out`
|
|
164
|
+
* side, d steps right. The focus is pushed right by however deep the inbound side goes.
|
|
165
|
+
*/
|
|
166
|
+
function geometry(occupied) {
|
|
167
|
+
const columnStep = CARD_W + COL_GAP;
|
|
168
|
+
const focusX = furthest(occupied, 'in') * columnStep;
|
|
169
|
+
return {
|
|
170
|
+
focusX,
|
|
171
|
+
xFor: (side, d) => (side === 'in' ? focusX - d * columnStep : focusX + d * columnStep)
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Cards for everything on the canvas.
|
|
176
|
+
*
|
|
177
|
+
* Three populations, three treatments: the focus shows up to 16 rows unfiltered, a direct
|
|
178
|
+
* neighbour shows its keys plus the rows the focus references, and a node further out shows
|
|
179
|
+
* keys only — at two hops the reader is tracing reach, not reading columns.
|
|
180
|
+
*/
|
|
181
|
+
function buildRingCards(model, neighbours, ring) {
|
|
182
|
+
const { placed, focus, expanded } = ring;
|
|
183
|
+
const direct = new Set(neighbours.map((n) => n.id));
|
|
184
|
+
return {
|
|
185
|
+
...buildNeighbourCards(model, neighbours, expanded),
|
|
186
|
+
...buildCards(placed.filter((n) => !direct.has(n.id)), 'keys', { expanded }),
|
|
187
|
+
...buildCards([focus], 'full', { limit: 16, expanded })
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Stack every ring into its column and centre the focus beside them. Returns the canvas
|
|
192
|
+
* height, which is the tallest stack — every column is vertically centred against it, so a
|
|
193
|
+
* short ring beside a long one reads as beside rather than above.
|
|
194
|
+
*/
|
|
195
|
+
function placeRings(occupied, cards, ctx) {
|
|
196
|
+
const stacks = occupied.map((ring) => ({
|
|
197
|
+
ring,
|
|
198
|
+
cards: ring.ids.map((id) => cards[id]).filter((c) => c !== undefined)
|
|
199
|
+
}));
|
|
200
|
+
const height = Math.max(ctx.focusCard.h, ...stacks.map((s) => stackHeight(s.cards)), MIN_H) + H_PAD;
|
|
201
|
+
for (const { ring, cards: column } of stacks) {
|
|
202
|
+
placeColumn(column, ctx.xFor(ring.side, ring.depth), height);
|
|
203
|
+
}
|
|
204
|
+
placeColumn([ctx.focusCard], ctx.focusX, height);
|
|
205
|
+
return height;
|
|
206
|
+
}
|
|
207
|
+
/** Deepest occupied ring on one side, or 0 when that side is empty. */
|
|
208
|
+
function furthest(occupied, side) {
|
|
209
|
+
const depths = occupied.filter((r) => r.side === side).map((r) => r.depth);
|
|
210
|
+
return depths.length > 0 ? Math.max(...depths) : 0;
|
|
211
|
+
}
|
|
212
|
+
/** Headings left-to-right: inbound rings, the focus, then outbound rings. */
|
|
213
|
+
function buildColumns(occupied, ctx) {
|
|
214
|
+
const side = (which) => occupied
|
|
215
|
+
.filter((r) => r.side === which)
|
|
216
|
+
.map((r) => ({
|
|
217
|
+
x: ctx.xFor(which, r.depth),
|
|
218
|
+
w: CARD_W,
|
|
219
|
+
depth: r.depth,
|
|
220
|
+
side: which,
|
|
221
|
+
label: headingFor(which, r.depth, ctx.dependency)
|
|
222
|
+
}))
|
|
223
|
+
.sort((a, b) => a.x - b.x);
|
|
224
|
+
return [
|
|
225
|
+
...side('in'),
|
|
226
|
+
{ x: ctx.focusX, w: CARD_W, depth: 0, side: 'focus', label: ctx.focusLabel },
|
|
227
|
+
...side('out')
|
|
228
|
+
];
|
|
229
|
+
}
|
|
88
230
|
/**
|
|
89
231
|
* Entity-centric layout. `options.focus` names the node to centre; without it, or with
|
|
90
232
|
* an id the model does not know, the result is empty.
|
|
@@ -93,31 +235,25 @@ function buildNeighbourCards(model, neighbours, expanded) {
|
|
|
93
235
|
* the rows the focus references, capped at 8 — both caps carried over from dbd.
|
|
94
236
|
*/
|
|
95
237
|
export const neighborhood = (model, options) => {
|
|
238
|
+
warnUnknownOptions(options, 'neighborhood');
|
|
96
239
|
const focus = options.focus ? model.byId.get(options.focus) : undefined;
|
|
97
240
|
if (!focus)
|
|
98
241
|
return EMPTY;
|
|
99
242
|
const { neighbours, selfEdges } = partition(model, focus.id);
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
(hasRight ? COL_GAP + CARD_W : 0) +
|
|
117
|
-
(selfEdges.length > 0 ? LOOP_W : 0) +
|
|
118
|
-
4;
|
|
119
|
-
// Only edges touching the focus. buildEdges would otherwise connect two neighbours
|
|
120
|
-
// that happen to both be laid out, which this view never shows.
|
|
121
|
-
const touching = model.edges.filter((e) => e.source === focus.id || e.target === focus.id);
|
|
122
|
-
return { clusters: [], cards, edges: buildEdges(touching, cards), size: { w: width, h: height } };
|
|
243
|
+
const built = rings(model, focus.id, Math.max(1, Math.floor(options.depth ?? 1)), sideOf(neighbours));
|
|
244
|
+
const placedNodes = built
|
|
245
|
+
.flatMap((r) => r.ids)
|
|
246
|
+
.map((id) => model.byId.get(id))
|
|
247
|
+
.filter((n) => n !== undefined);
|
|
248
|
+
const cards = buildRingCards(model, neighbours, {
|
|
249
|
+
placed: placedNodes,
|
|
250
|
+
focus,
|
|
251
|
+
expanded: options.expanded
|
|
252
|
+
});
|
|
253
|
+
const { columns, size } = arrange(built, cards, {
|
|
254
|
+
focus,
|
|
255
|
+
hasLoop: selfEdges.length > 0,
|
|
256
|
+
dependency: model.edges.some((e) => e.kind === 'dependency')
|
|
257
|
+
});
|
|
258
|
+
return { clusters: [], cards, edges: buildEdges(drawableIn(model, cards), cards), size, columns };
|
|
123
259
|
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { LayoutOptions } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Every key `LayoutOptions` defines. Kept beside the type on purpose — a key added there and
|
|
4
|
+
* missed here would warn about a legitimate option, which is worse than the silence this
|
|
5
|
+
* exists to fix. `spec/options.spec.ts` compares the two.
|
|
6
|
+
*/
|
|
7
|
+
export declare const LAYOUT_OPTION_KEYS: readonly ["density", "arrange", "edgeStyle", "focus", "expanded", "groupBy", "nestBy", "sizeBy", "sizeScale", "depth", "focusPath", "levels", "radialMode", "root", "bundleTension"];
|
|
8
|
+
/**
|
|
9
|
+
* Which options have an EFFECT under each built-in layout.
|
|
10
|
+
*
|
|
11
|
+
* A control wired to an option the active layout ignores is worse than a missing one: it looks
|
|
12
|
+
* like a knob, it moves, and the picture does not change — so the reader concludes the view is
|
|
13
|
+
* broken rather than that the control does not apply. `names | keys | full` over a treemap is
|
|
14
|
+
* the plainest case, since a treemap box has no row list to thin.
|
|
15
|
+
*
|
|
16
|
+
* Exported because it is a fact about the layout, not about any one consumer's control panel.
|
|
17
|
+
* A hand-maintained copy in a UI drifts the first time a layout gains or drops an option.
|
|
18
|
+
*
|
|
19
|
+
* Two entries are not simply "what the layout function reads", and both are deliberate:
|
|
20
|
+
* - `edgeStyle` is applied by `GraphState` when it draws a path, not by the layout. It
|
|
21
|
+
* therefore applies wherever a layout produces edges — which `world` does not.
|
|
22
|
+
* - `neighborhood` takes no `density`: it builds its cards at `full` unconditionally, because
|
|
23
|
+
* a portrait of one node's surroundings that hides the columns the edges land on defeats
|
|
24
|
+
* its own purpose.
|
|
25
|
+
*/
|
|
26
|
+
export declare const LAYOUT_OPTIONS: Record<string, readonly (typeof LAYOUT_OPTION_KEYS)[number][]>;
|
|
27
|
+
/** `dot` or `card` — what shape this layout draws a node as. */
|
|
28
|
+
export declare function nodeShapeOf(layout: string): 'dot' | 'card';
|
|
29
|
+
/** Whether a control for `option` should be offered while `layout` is active. */
|
|
30
|
+
export declare function appliesTo(layout: string, option: string): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Tell a caller when an option was discarded.
|
|
33
|
+
*
|
|
34
|
+
* `LayoutOptions` is not a closed check once the object reaches a layout through a variable —
|
|
35
|
+
* TypeScript's excess-property check only fires on an object literal at the call site. So
|
|
36
|
+
* `neighborhood(model, opts)` where `opts` carries `depth: 2` type-checks, runs, and returns
|
|
37
|
+
* a correct-looking diagram that silently ignored the option (#162).
|
|
38
|
+
*
|
|
39
|
+
* Silence is the problem, not the unknown key: the caller has no way to tell "this layout
|
|
40
|
+
* does not support depth" from "depth worked and two hops is what you are looking at".
|
|
41
|
+
*/
|
|
42
|
+
export declare function warnUnknownOptions(options: LayoutOptions, layout: string): void;
|
|
43
|
+
/** Test seam — the dedupe is module state and would leak between cases otherwise. */
|
|
44
|
+
export declare function resetOptionWarnings(): void;
|