@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,9 @@
|
|
|
1
|
+
import type { LayoutFn } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Containment as area.
|
|
4
|
+
*
|
|
5
|
+
* `focusPath` scopes the canvas to a subtree — the drill-down operation — and `levels` caps
|
|
6
|
+
* how far below it is built. Every box is a cluster whatever its depth, so the outermost
|
|
7
|
+
* materialised level is what a reader clicks.
|
|
8
|
+
*/
|
|
9
|
+
export declare const world: LayoutFn;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/* The world view: containment as nested rectangles, area ∝ measure.
|
|
2
|
+
*
|
|
3
|
+
* Two rules do the work, and the second is the one that makes it survive real data.
|
|
4
|
+
*
|
|
5
|
+
* 1. A box's area is what it CONTAINS. That is the question the view answers — where is the
|
|
6
|
+
* mass — and it is why the tree sums subtrees rather than counting nodes.
|
|
7
|
+
*
|
|
8
|
+
* 2. Only `levels` are MATERIALISED. The cost of 1.18M nodes is in building them, not in
|
|
9
|
+
* packing them, so the layout refuses to build what it is not showing. Descending is a
|
|
10
|
+
* re-layout with a new root, not a zoom: `Graph`'s zoom is a scale transform over the whole
|
|
11
|
+
* canvas, and at that size every box is sub-pixel — the exact failure `points.ts` documents
|
|
12
|
+
* for cards.
|
|
13
|
+
*
|
|
14
|
+
* Rectangles rather than circles, decided in docs/design/24-world-view.md: a circular container
|
|
15
|
+
* discards 1 - pi/4 = 21% of its bounding box before anything is placed in it, nesting
|
|
16
|
+
* multiplies that per level, and a rect holds a legible label where a circle does not. You
|
|
17
|
+
* have to be able to read what you are about to click.
|
|
18
|
+
*/
|
|
19
|
+
import { buildTree, findNode } from '../model/tree.js';
|
|
20
|
+
import { squarify } from './squarify.js';
|
|
21
|
+
import { warnUnknownOptions } from './options.js';
|
|
22
|
+
/** Canvas the layout lays out into. Fit-to-container scales it; the ratios are what matter. */
|
|
23
|
+
const CANVAS = { w: 1200, h: 800 };
|
|
24
|
+
/** Inset of a container's children, leaving room for its own border. */
|
|
25
|
+
const PAD = 4;
|
|
26
|
+
/** Room above a container's children for its label. */
|
|
27
|
+
const TITLE = 16;
|
|
28
|
+
/**
|
|
29
|
+
* Area floor, as a share of one child's EQUAL slice of its parent.
|
|
30
|
+
*
|
|
31
|
+
* Exact proportion gives a zero-measure node no box at all, which reads as missing rather than
|
|
32
|
+
* as unmeasured — and "unmeasured" is a normal state in a partially-indexed graph: 79 of
|
|
33
|
+
* rokkit's own 470 modules have degree 0.
|
|
34
|
+
*
|
|
35
|
+
* Measured against the equal slice rather than against the TOTAL, because the floor is applied
|
|
36
|
+
* per child and so sums with the child count. A flat `total * k` is therefore self-defeating
|
|
37
|
+
* exactly where it is needed: at two children it costs 2k of the canvas and leaves a 2px
|
|
38
|
+
* hairline, and at 470 it claims 470k — nearly the whole canvas — and proportion stops reading
|
|
39
|
+
* altogether. Against the equal slice the total floor is at most `k` whatever n is.
|
|
40
|
+
*/
|
|
41
|
+
const MIN_EQUAL_SHARE = 0.12;
|
|
42
|
+
/** Walk the materialised levels, squarifying each container's children into its own rect. */
|
|
43
|
+
function place(node, rect, ctx) {
|
|
44
|
+
const { levels, out, parent, origin, ramp } = ctx;
|
|
45
|
+
if (levels <= 0 || node.children.length === 0)
|
|
46
|
+
return;
|
|
47
|
+
// Children are inset for the parent's border and label strip. Below that the box is all
|
|
48
|
+
// chrome and no content, so it stops rather than rendering a frame around nothing.
|
|
49
|
+
const inner = {
|
|
50
|
+
x: rect.x + PAD,
|
|
51
|
+
y: rect.y + PAD + TITLE,
|
|
52
|
+
w: rect.w - PAD * 2,
|
|
53
|
+
h: rect.h - PAD * 2 - TITLE
|
|
54
|
+
};
|
|
55
|
+
if (inner.w <= 0 || inner.h <= 0)
|
|
56
|
+
return;
|
|
57
|
+
const total = node.children.reduce((sum, c) => sum + Math.max(c.value, 0), 0);
|
|
58
|
+
const equal = total / node.children.length;
|
|
59
|
+
const placed = squarify(node.children, inner, equal * MIN_EQUAL_SHARE);
|
|
60
|
+
for (const { item, rect: box } of placed) {
|
|
61
|
+
// Depth is RELATIVE to the focus, so the outermost rendered box is always 0 whatever
|
|
62
|
+
// the reader has drilled into. A renderer styles by "how deep in this view", not by
|
|
63
|
+
// "how deep in the whole tree", and the same convention holds in `nested.ts`.
|
|
64
|
+
// The outermost box names the ramp entry; everything under it inherits, so a whole
|
|
65
|
+
// subtree reads as one region instead of every descendant falling through to the
|
|
66
|
+
// default colour.
|
|
67
|
+
const key = ramp ?? item.label;
|
|
68
|
+
out.push({ tree: item, rect: box, depth: node.path.length - origin, parent, ramp: key });
|
|
69
|
+
place(item, box, { levels: levels - 1, out, parent: item.id, origin, ramp: key });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Compact enough to sit in a label strip: 1200 reads as 1.2k. */
|
|
73
|
+
function compact(value) {
|
|
74
|
+
if (value >= 1_000_000)
|
|
75
|
+
return `${(value / 1_000_000).toFixed(1)}M`;
|
|
76
|
+
if (value >= 1000)
|
|
77
|
+
return `${(value / 1000).toFixed(1)}k`;
|
|
78
|
+
return String(Math.round(value));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Every placed box, leaf or region, as the SAME shape.
|
|
82
|
+
*
|
|
83
|
+
* A treemap is a hierarchy of one thing: a box with a label and an area. Emitting a leaf as a
|
|
84
|
+
* node card and a region as a label box put two visual structures inside one nesting, which
|
|
85
|
+
* reads as two unrelated kinds of object rather than as depth — and it dragged the card's
|
|
86
|
+
* furniture along: icon, kind tag and a row count, where a codebase module has no rows and so
|
|
87
|
+
* showed a literal `0` beside its name.
|
|
88
|
+
*
|
|
89
|
+
* A childless box keeps its node's id and kind, so uniform structure costs no identity: the
|
|
90
|
+
* box is still selectable and still colours by what it is.
|
|
91
|
+
*/
|
|
92
|
+
function clusterOf(entry, groupIndex) {
|
|
93
|
+
const leaf = entry.tree.children.length === 0 ? entry.tree.node : undefined;
|
|
94
|
+
return {
|
|
95
|
+
name: entry.tree.label,
|
|
96
|
+
// The measure, not the child count: this box's area IS its value, so that is the
|
|
97
|
+
// number a reader should see beside it.
|
|
98
|
+
caption: compact(entry.tree.value),
|
|
99
|
+
parent: entry.parent,
|
|
100
|
+
depth: entry.depth,
|
|
101
|
+
ramp: entry.ramp,
|
|
102
|
+
nodeId: leaf?.id,
|
|
103
|
+
kind: leaf?.kind,
|
|
104
|
+
list: entry.tree.children.map((c) => c.node).filter((n) => n !== undefined),
|
|
105
|
+
count: entry.tree.children.length,
|
|
106
|
+
groupIndex,
|
|
107
|
+
x: entry.rect.x,
|
|
108
|
+
y: entry.rect.y,
|
|
109
|
+
w: entry.rect.w,
|
|
110
|
+
h: entry.rect.h
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Containment as area.
|
|
115
|
+
*
|
|
116
|
+
* `focusPath` scopes the canvas to a subtree — the drill-down operation — and `levels` caps
|
|
117
|
+
* how far below it is built. Every box is a cluster whatever its depth, so the outermost
|
|
118
|
+
* materialised level is what a reader clicks.
|
|
119
|
+
*/
|
|
120
|
+
export const world = (model, options) => {
|
|
121
|
+
warnUnknownOptions(options, 'world');
|
|
122
|
+
const tree = buildTree(model, { measure: options.sizeBy ?? 'weight' });
|
|
123
|
+
const root = findNode(tree, options.focusPath ?? []);
|
|
124
|
+
if (!root || root.children.length === 0) {
|
|
125
|
+
return { clusters: [], cards: {}, edges: [], size: { w: 0, h: 0 } };
|
|
126
|
+
}
|
|
127
|
+
const placed = [];
|
|
128
|
+
place(root, { x: 0, y: 0, w: CANVAS.w, h: CANVAS.h }, {
|
|
129
|
+
levels: options.levels ?? 2,
|
|
130
|
+
out: placed,
|
|
131
|
+
origin: root.path.length
|
|
132
|
+
});
|
|
133
|
+
// Edges are deliberately absent: containment IS the relationship here, and at world scale
|
|
134
|
+
// the adjacency graph cannot be drawn. Cross-container aggregation is its own design.
|
|
135
|
+
//
|
|
136
|
+
// `cards` is empty by construction rather than by filtering — there is no box in this
|
|
137
|
+
// layout that a card could represent.
|
|
138
|
+
return {
|
|
139
|
+
clusters: placed.map(clusterOf),
|
|
140
|
+
cards: {},
|
|
141
|
+
edges: [],
|
|
142
|
+
size: CANVAS
|
|
143
|
+
};
|
|
144
|
+
};
|
package/dist/model/normalize.js
CHANGED
|
@@ -3,6 +3,39 @@ const BADGE_ORDER = ['pk', 'fk', 'uq', 'nn'];
|
|
|
3
3
|
function str(value) {
|
|
4
4
|
return typeof value === 'string' ? value : undefined;
|
|
5
5
|
}
|
|
6
|
+
/**
|
|
7
|
+
* Named quantities, keeping only the finite numbers — a measure is a quantity, and a string
|
|
8
|
+
* or a NaN reaching a size calculation produces a card with no width at all.
|
|
9
|
+
*/
|
|
10
|
+
function measures(value) {
|
|
11
|
+
if (value === null || typeof value !== 'object')
|
|
12
|
+
return undefined;
|
|
13
|
+
const out = {};
|
|
14
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
15
|
+
const n = num(raw);
|
|
16
|
+
if (n !== undefined)
|
|
17
|
+
out[key] = n;
|
|
18
|
+
}
|
|
19
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A containment path, from either an array or a delimited string.
|
|
23
|
+
*
|
|
24
|
+
* A file path arrives as `'src/lib/parse.ts'`; requiring every consumer to split it is asking
|
|
25
|
+
* for the same three lines everywhere. Empty segments are dropped, so a leading, trailing or
|
|
26
|
+
* doubled separator is harmless rather than producing a nameless container.
|
|
27
|
+
*/
|
|
28
|
+
function segments(value, delimiter) {
|
|
29
|
+
const raw = typeof value === 'string' ? value.split(delimiter) : value;
|
|
30
|
+
if (!Array.isArray(raw))
|
|
31
|
+
return undefined;
|
|
32
|
+
const parts = raw.filter((v) => typeof v === 'string' && v.length > 0);
|
|
33
|
+
return parts.length > 0 ? parts : undefined;
|
|
34
|
+
}
|
|
35
|
+
/** A finite number, or nothing. NaN and Infinity are not measures. */
|
|
36
|
+
function num(value) {
|
|
37
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
38
|
+
}
|
|
6
39
|
function pick(row, path, fallbackKey) {
|
|
7
40
|
return readPath(row, path ?? fallbackKey);
|
|
8
41
|
}
|
|
@@ -26,7 +59,17 @@ function buildRows(source, fields) {
|
|
|
26
59
|
}
|
|
27
60
|
// Every node-level field the canonical model reads. A claimed key must NOT also
|
|
28
61
|
// land in `meta`, or the same value sits in two places and the two can drift.
|
|
29
|
-
const CLAIMED_NODE_KEYS = [
|
|
62
|
+
const CLAIMED_NODE_KEYS = [
|
|
63
|
+
'id',
|
|
64
|
+
'label',
|
|
65
|
+
'group',
|
|
66
|
+
'kind',
|
|
67
|
+
'weight',
|
|
68
|
+
'path',
|
|
69
|
+
'measures',
|
|
70
|
+
'rows',
|
|
71
|
+
'note'
|
|
72
|
+
];
|
|
30
73
|
function buildMeta(source, fields) {
|
|
31
74
|
if (source === null || typeof source !== 'object')
|
|
32
75
|
return {};
|
|
@@ -51,6 +94,9 @@ function buildNode(source, fields) {
|
|
|
51
94
|
label,
|
|
52
95
|
group,
|
|
53
96
|
kind: str(pick(source, fields.kind, 'kind')),
|
|
97
|
+
weight: num(pick(source, fields.weight, 'weight')),
|
|
98
|
+
path: segments(pick(source, fields.path, 'path'), fields.pathDelimiter ?? '/'),
|
|
99
|
+
measures: measures(pick(source, fields.measures, 'measures')),
|
|
54
100
|
rows: buildRows(source, fields),
|
|
55
101
|
note: str(pick(source, fields.note, 'note')),
|
|
56
102
|
meta: buildMeta(source, fields)
|
|
@@ -156,9 +202,23 @@ function buildEdge(source, fields, byId, seen) {
|
|
|
156
202
|
relation,
|
|
157
203
|
cardinality: str(pick(source, fields.cardinality, 'cardinality')),
|
|
158
204
|
action: str(pick(source, fields.action, 'action')),
|
|
159
|
-
unplaced
|
|
205
|
+
unplaced,
|
|
206
|
+
...edgeExtras(source, fields)
|
|
160
207
|
};
|
|
161
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* `overlay` and `weight`, added only when present so every edge a v1 consumer already has
|
|
211
|
+
* stays key-for-key identical — `toEqual` on an edge is a contract downstream specs rely on.
|
|
212
|
+
*/
|
|
213
|
+
function edgeExtras(source, fields) {
|
|
214
|
+
const extras = {};
|
|
215
|
+
if (pick(source, fields.overlay, 'overlay'))
|
|
216
|
+
extras.overlay = true;
|
|
217
|
+
const weight = num(pick(source, fields.edgeWeight, 'weight'));
|
|
218
|
+
if (weight !== undefined)
|
|
219
|
+
extras.weight = weight;
|
|
220
|
+
return extras;
|
|
221
|
+
}
|
|
162
222
|
/**
|
|
163
223
|
* The source row an edge makes a foreign key, if any.
|
|
164
224
|
*
|
|
@@ -210,6 +270,8 @@ export function normalizeGraph(nodes, edges, fields = {}) {
|
|
|
210
270
|
const resolved = edges
|
|
211
271
|
.map((source) => buildEdge(source, fields, byId, seen))
|
|
212
272
|
.filter((edge) => edge !== null);
|
|
213
|
-
|
|
214
|
-
|
|
273
|
+
const structural = resolved.filter((edge) => !edge.overlay);
|
|
274
|
+
const overlays = resolved.filter((edge) => edge.overlay);
|
|
275
|
+
markForeignKeys(byId, structural);
|
|
276
|
+
return { nodes: built, edges: structural, overlays, byId, neighbors: buildNeighbors(structural) };
|
|
215
277
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { GraphModel, GraphNode } from '../types.js';
|
|
2
|
+
export type TreeNode = {
|
|
3
|
+
/** Path joined by `/`. The root is `''`. */
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
/** Full path to this node, outermost first. Empty at the root. */
|
|
7
|
+
path: string[];
|
|
8
|
+
/** 0 at the root. A layout materialising N levels reads this. */
|
|
9
|
+
depth: number;
|
|
10
|
+
/** The real node behind this box. Absent only on a container nothing declared. */
|
|
11
|
+
node?: GraphNode;
|
|
12
|
+
children: TreeNode[];
|
|
13
|
+
/** This subtree's summed measure, including its own leaves. */
|
|
14
|
+
value: number;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* The containment tree for a model.
|
|
18
|
+
*
|
|
19
|
+
* Containment is PREFIX: a node whose path extends past another's is inside it. That is what
|
|
20
|
+
* makes a container an ordinary node — it keeps its own id, label, note and edges, and there
|
|
21
|
+
* is no convention to satisfy. A prefix nothing declares is synthesised.
|
|
22
|
+
*/
|
|
23
|
+
export declare function buildTree(model: GraphModel, options?: {
|
|
24
|
+
measure?: string;
|
|
25
|
+
}): TreeNode;
|
|
26
|
+
/** The node at `path`, or undefined. */
|
|
27
|
+
export declare function findNode(root: TreeNode, path: string[]): TreeNode | undefined;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/* The containment tree, derived from `GraphNode.path`.
|
|
2
|
+
*
|
|
3
|
+
* Three jobs, none of them geometry:
|
|
4
|
+
*
|
|
5
|
+
* 1. SYNTHESISE the containers the data implies but never states. A codebase indexes files
|
|
6
|
+
* and symbols; the folders between them are implied by the path, and requiring a node per
|
|
7
|
+
* folder would make the model refuse data that is perfectly well-formed.
|
|
8
|
+
* 2. FOLD a container that adds no level. `src › lib › index.ts` with nothing else at either
|
|
9
|
+
* level is three boxes for one file — that is punctuation, not a hierarchy.
|
|
10
|
+
* 3. SUM each subtree's measure, so a container's area can be what it CONTAINS rather than
|
|
11
|
+
* what it is.
|
|
12
|
+
*
|
|
13
|
+
* Pure and deterministic like every layout here, and deliberately separate from
|
|
14
|
+
* `normalizeGraph`: most graphs are flat, and building a tree they never read would be waste
|
|
15
|
+
* on every re-render.
|
|
16
|
+
*/
|
|
17
|
+
const ROOT = '';
|
|
18
|
+
/** A synthesised box's id is its path joined. A declared node replaces it with its own. */
|
|
19
|
+
const idOf = (path) => path.join('/');
|
|
20
|
+
function container(path) {
|
|
21
|
+
return {
|
|
22
|
+
id: idOf(path),
|
|
23
|
+
label: path[path.length - 1] ?? ROOT,
|
|
24
|
+
path,
|
|
25
|
+
depth: path.length,
|
|
26
|
+
children: [],
|
|
27
|
+
value: 0
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Walk to `path`, synthesising any box along the way that nothing has declared yet.
|
|
32
|
+
*
|
|
33
|
+
* Matched on the path SEGMENT, not on the label: a declared node may label its box anything
|
|
34
|
+
* ('Lexer' for the segment 'lexer'), and matching on label would then fail to find the box it
|
|
35
|
+
* had already created.
|
|
36
|
+
*/
|
|
37
|
+
function ensure(root, path) {
|
|
38
|
+
let current = root;
|
|
39
|
+
for (let i = 0; i < path.length; i++) {
|
|
40
|
+
const segment = path[i];
|
|
41
|
+
let next = current.children.find((c) => c.path[c.path.length - 1] === segment);
|
|
42
|
+
if (!next) {
|
|
43
|
+
next = container(path.slice(0, i + 1));
|
|
44
|
+
current.children.push(next);
|
|
45
|
+
}
|
|
46
|
+
current = next;
|
|
47
|
+
}
|
|
48
|
+
return current;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The number a node contributes to its subtree.
|
|
52
|
+
*
|
|
53
|
+
* `weight` is the named default, so a caller that shipped against #161 is unaffected; any
|
|
54
|
+
* other name reads the measures bag. Absent is 0 rather than a break — "unknown" is a normal
|
|
55
|
+
* state in a partially-indexed graph.
|
|
56
|
+
*/
|
|
57
|
+
function measureOf(node, measure, degree) {
|
|
58
|
+
if (measure === 'degree')
|
|
59
|
+
return degree.get(node.id) ?? 0;
|
|
60
|
+
if (measure === 'weight')
|
|
61
|
+
return node.weight ?? 0;
|
|
62
|
+
return node.measures?.[measure] ?? 0;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Edges touching each node.
|
|
66
|
+
*
|
|
67
|
+
* `degree` is the package-wide default for `sizeBy`, so the tree has to understand it or a
|
|
68
|
+
* caller that never set a measure gets a silent zero for every node — and a tree of zeroes
|
|
69
|
+
* falls through to an even split, which renders a 27:1 ratio as 1:1 with no error anywhere.
|
|
70
|
+
*/
|
|
71
|
+
function degreesIn(model) {
|
|
72
|
+
const degree = new Map();
|
|
73
|
+
for (const edge of model.edges) {
|
|
74
|
+
if (edge.unplaced)
|
|
75
|
+
continue;
|
|
76
|
+
degree.set(edge.source, (degree.get(edge.source) ?? 0) + 1);
|
|
77
|
+
if (edge.target !== edge.source) {
|
|
78
|
+
degree.set(edge.target, (degree.get(edge.target) ?? 0) + 1);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return degree;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Attach a real node to the box at its own path.
|
|
85
|
+
*
|
|
86
|
+
* The box may already exist — synthesised by a descendant that arrived first — in which case
|
|
87
|
+
* this fills in its identity rather than creating a sibling. That is the whole of "a container
|
|
88
|
+
* is an ordinary node": no claiming, no id convention, just the same walk.
|
|
89
|
+
*/
|
|
90
|
+
function attach(root, node, measure, degree) {
|
|
91
|
+
const box = ensure(root, node.path ?? []);
|
|
92
|
+
if (box === root) {
|
|
93
|
+
// No path: a root-level leaf, not a redefinition of the root itself.
|
|
94
|
+
root.children.push({
|
|
95
|
+
id: node.id,
|
|
96
|
+
label: node.label,
|
|
97
|
+
path: [],
|
|
98
|
+
depth: 1,
|
|
99
|
+
node,
|
|
100
|
+
children: [],
|
|
101
|
+
value: measureOf(node, measure, degree)
|
|
102
|
+
});
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
box.id = node.id;
|
|
106
|
+
box.label = node.label;
|
|
107
|
+
box.node = node;
|
|
108
|
+
// A container can hold declarations of its OWN — a file has top-level code as well as the
|
|
109
|
+
// functions in it — so this is added to the subtree rather than replaced by it.
|
|
110
|
+
box.value += measureOf(node, measure, degree);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Sum every subtree bottom-up, and re-derive depth after folding has shortened paths.
|
|
114
|
+
*
|
|
115
|
+
* A leaf's value is its own measure; a container's is what it holds. That is what lets a box's
|
|
116
|
+
* area mean "how much is in here" rather than "how big is this one thing".
|
|
117
|
+
*/
|
|
118
|
+
function summarise(node, depth) {
|
|
119
|
+
node.depth = depth;
|
|
120
|
+
if (node.children.length === 0)
|
|
121
|
+
return node.value;
|
|
122
|
+
// `node.value` starts as the container's OWN measure, if it declared one — a module can
|
|
123
|
+
// hold top-level declarations as well as the functions inside it.
|
|
124
|
+
node.value += node.children.reduce((total, child) => total + summarise(child, depth + 1), 0);
|
|
125
|
+
return node.value;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Collapse a container that holds exactly one child and nothing of its own.
|
|
129
|
+
*
|
|
130
|
+
* NOT applied to a DECLARED container: a node that exists carries its own label, note and
|
|
131
|
+
* edges, and folding it would silently drop them. The rule is "a wrapper is not a level", and
|
|
132
|
+
* a thing the data names is not a wrapper.
|
|
133
|
+
*/
|
|
134
|
+
function fold(node) {
|
|
135
|
+
node.children = node.children.map(fold);
|
|
136
|
+
if (node.children.length === 1 && node.node === undefined && node.depth > 0) {
|
|
137
|
+
const only = node.children[0];
|
|
138
|
+
// The child takes this box's PLACE, so the segment this box contributed is spliced out
|
|
139
|
+
// of its path — not merely trimmed from the end, which would leave a leaf sitting at
|
|
140
|
+
// the very path that was just removed.
|
|
141
|
+
const cut = node.path.length - 1;
|
|
142
|
+
return { ...only, path: [...only.path.slice(0, cut), ...only.path.slice(cut + 1)] };
|
|
143
|
+
}
|
|
144
|
+
return node;
|
|
145
|
+
}
|
|
146
|
+
/** Depth-first, alphabetical: a re-render must never reshuffle the picture. */
|
|
147
|
+
function sort(node) {
|
|
148
|
+
node.children.sort((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id));
|
|
149
|
+
for (const child of node.children)
|
|
150
|
+
sort(child);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The containment tree for a model.
|
|
154
|
+
*
|
|
155
|
+
* Containment is PREFIX: a node whose path extends past another's is inside it. That is what
|
|
156
|
+
* makes a container an ordinary node — it keeps its own id, label, note and edges, and there
|
|
157
|
+
* is no convention to satisfy. A prefix nothing declares is synthesised.
|
|
158
|
+
*/
|
|
159
|
+
export function buildTree(model, options = {}) {
|
|
160
|
+
const measure = options.measure ?? 'weight';
|
|
161
|
+
const degree = measure === 'degree' ? degreesIn(model) : new Map();
|
|
162
|
+
const root = container([]);
|
|
163
|
+
root.depth = 0;
|
|
164
|
+
// One pass, one rule: every node is the box at its own path. Order does not matter —
|
|
165
|
+
// a descendant arriving first synthesises the box, and its owner fills in the identity.
|
|
166
|
+
for (const node of model.nodes)
|
|
167
|
+
attach(root, node, measure, degree);
|
|
168
|
+
const folded = fold(root);
|
|
169
|
+
sort(folded);
|
|
170
|
+
summarise(folded, 0);
|
|
171
|
+
return folded;
|
|
172
|
+
}
|
|
173
|
+
/** The node at `path`, or undefined. */
|
|
174
|
+
export function findNode(root, path) {
|
|
175
|
+
let current = root;
|
|
176
|
+
for (const segment of path) {
|
|
177
|
+
current = current?.children.find((c) => c.path[c.path.length - 1] === segment);
|
|
178
|
+
if (!current)
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
return current;
|
|
182
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -21,6 +21,41 @@ export type GraphNode = {
|
|
|
21
21
|
group?: string;
|
|
22
22
|
/** Closed vocabulary — table | view | matview | function | procedure | enum. */
|
|
23
23
|
kind?: string;
|
|
24
|
+
/**
|
|
25
|
+
* A quantity this node carries — declarations in a module, rows in a table, bytes in a
|
|
26
|
+
* bundle. Drives size in `points` (`sizeBy: 'weight'`) and the sequential colour channel.
|
|
27
|
+
*
|
|
28
|
+
* Optional because most graphs have no such number, and degree stays the default so no
|
|
29
|
+
* existing caller changes. A node without one sizes at the floor rather than vanishing.
|
|
30
|
+
*/
|
|
31
|
+
weight?: number;
|
|
32
|
+
/**
|
|
33
|
+
* Named quantities this node carries — `{ declarations: 900, unresolved: 0.9 }`.
|
|
34
|
+
*
|
|
35
|
+
* A bag rather than more fields because size and shade are INDEPENDENT measures with
|
|
36
|
+
* different ranges, and the control that motivates them switches between several at
|
|
37
|
+
* runtime: naming a key re-runs the layout, where re-mapping `fields` would re-normalize
|
|
38
|
+
* the whole model on every toggle. `weight` remains the named default for size.
|
|
39
|
+
*/
|
|
40
|
+
measures?: Record<string, number>;
|
|
41
|
+
/**
|
|
42
|
+
* This node's OWN full path, outermost first and INCLUDING itself —
|
|
43
|
+
* `['dbd', 'core', 'lexer', 'parse']`.
|
|
44
|
+
*
|
|
45
|
+
* `group` is one flat key and a codebase is containment six deep: project › repository ›
|
|
46
|
+
* folder › module › file › symbol. A path needs nothing to exist that does not, and cannot
|
|
47
|
+
* dangle the way a `parent` id can. A node without one sits at the root.
|
|
48
|
+
*
|
|
49
|
+
* **Containment is PREFIX**, which is what makes a container an ordinary node: anything
|
|
50
|
+
* whose path extends past this one is inside it. A container therefore keeps its own id,
|
|
51
|
+
* label, note, measures and edges — there is no id convention to satisfy and no second
|
|
52
|
+
* kind of node. A prefix nothing declares is synthesised, so the easy case still needs
|
|
53
|
+
* only leaves.
|
|
54
|
+
*
|
|
55
|
+
* Accepts a delimited STRING too (`'dbd/core/lexer/parse'`), since that is how a file path
|
|
56
|
+
* arrives. See `GraphFields.pathDelimiter`.
|
|
57
|
+
*/
|
|
58
|
+
path?: string[];
|
|
24
59
|
rows: GraphRow[];
|
|
25
60
|
note?: string;
|
|
26
61
|
/** Source fields the map did not claim, passed through untouched. */
|
|
@@ -59,10 +94,26 @@ export type GraphEdge = {
|
|
|
59
94
|
* edge is kept and marked, and it is the renderer's job to dim it.
|
|
60
95
|
*/
|
|
61
96
|
unplaced?: 'source' | 'target' | 'both';
|
|
97
|
+
/**
|
|
98
|
+
* Drawn over the picture but never allowed to shape it — a co-change pair, a layering
|
|
99
|
+
* rule broken, a suggested dependency.
|
|
100
|
+
*
|
|
101
|
+
* Such an edge is exactly what the reader wants to see and exactly what must not re-rank a
|
|
102
|
+
* layered layout or pull a cluster ordering: files that change together with no import
|
|
103
|
+
* between them would otherwise be drawn next to each other, hiding the very coupling the
|
|
104
|
+
* overlay exists to expose. So they never enter `GraphModel.edges`; they live in
|
|
105
|
+
* `GraphModel.overlays` and are routed after the layout has run.
|
|
106
|
+
*/
|
|
107
|
+
overlay?: boolean;
|
|
108
|
+
/** A strength this edge carries — co-change count, call frequency. Drives stroke width. */
|
|
109
|
+
weight?: number;
|
|
62
110
|
};
|
|
63
111
|
export type GraphModel = {
|
|
64
112
|
nodes: GraphNode[];
|
|
113
|
+
/** Structural edges — the ones layouts, neighbours and relationships see. */
|
|
65
114
|
edges: GraphEdge[];
|
|
115
|
+
/** Overlay edges (`GraphEdge.overlay`) — routed after layout, never shaping it. */
|
|
116
|
+
overlays: GraphEdge[];
|
|
66
117
|
byId: Map<string, GraphNode>;
|
|
67
118
|
/** Undirected adjacency, self-edges excluded. */
|
|
68
119
|
neighbors: Map<string, Set<string>>;
|
|
@@ -85,8 +136,17 @@ export type GraphProps = {
|
|
|
85
136
|
arrange?: import('./layout/types.js').Arrange;
|
|
86
137
|
/** Outer grouping axis — `group` (default) or `kind`. */
|
|
87
138
|
groupBy?: import('./layout/types.js').NodeAxis;
|
|
139
|
+
/** Paint each card with its group's ramp — schema stays visible in a clusterless layout. */
|
|
140
|
+
groupTint?: boolean;
|
|
88
141
|
/** Subdivide each cluster by a second axis. Omit for one level. */
|
|
89
142
|
nestBy?: import('./layout/types.js').NodeAxis;
|
|
143
|
+
/**
|
|
144
|
+
* `points` only — what a node's size encodes. `degree` (default) counts edges, `weight`
|
|
145
|
+
* reads `GraphNode.weight`, and any other string names a key in `GraphNode.measures`.
|
|
146
|
+
*/
|
|
147
|
+
sizeBy?: string;
|
|
148
|
+
/** How the measure maps onto area — `linear` (default) or `log`. */
|
|
149
|
+
sizeScale?: 'linear' | 'log';
|
|
90
150
|
edgeStyle?: import('./layout/types.js').EdgeStyle;
|
|
91
151
|
/** `neighborhood` only — the node the view centres on. Defaults to the selection. */
|
|
92
152
|
focus?: string | null;
|
|
@@ -99,16 +159,16 @@ export type GraphProps = {
|
|
|
99
159
|
*/
|
|
100
160
|
zoom?: number;
|
|
101
161
|
/** Built-in zoom controls, ctrl/pinch-wheel zoom and drag-to-pan. Default true. */
|
|
162
|
+
/** Ctrl/pinch-wheel zoom and drag-to-pan. The BUTTON bar is `ZoomControl`, placed by you. */
|
|
102
163
|
zoomable?: boolean;
|
|
103
|
-
/** Built-in names/keys/all detail toggle on the canvas. Default true. */
|
|
104
|
-
densityToggle?: boolean;
|
|
105
164
|
/**
|
|
106
165
|
* Draw an arrowhead at each edge's target instead of a plain anchor dot. Default true —
|
|
107
166
|
* an edge is directed, and two identical dots discard that.
|
|
108
167
|
*/
|
|
109
168
|
arrows?: boolean;
|
|
110
169
|
label?: string;
|
|
111
|
-
|
|
170
|
+
/** Fires on selection AND on clear, where it receives `null`. */
|
|
171
|
+
onselect?: (id: string | null) => void;
|
|
112
172
|
/** Icon class per node kind and row badge, merged over the built-in map. */
|
|
113
173
|
icons?: Record<string, string>;
|
|
114
174
|
class?: string;
|
|
@@ -123,6 +183,14 @@ export type GraphFields = {
|
|
|
123
183
|
label?: string;
|
|
124
184
|
group?: string;
|
|
125
185
|
kind?: string;
|
|
186
|
+
/** Path to a per-node quantity — see `GraphNode.weight`. */
|
|
187
|
+
weight?: string;
|
|
188
|
+
/** Path to a containment chain — see `GraphNode.path`. */
|
|
189
|
+
path?: string;
|
|
190
|
+
/** Separator when `path` resolves to a STRING rather than an array. Defaults to `/`. */
|
|
191
|
+
pathDelimiter?: string;
|
|
192
|
+
/** Path to an object of named quantities — see `GraphNode.measures`. */
|
|
193
|
+
measures?: string;
|
|
126
194
|
rows?: string;
|
|
127
195
|
note?: string;
|
|
128
196
|
/** Row-level paths, read against each entry of the `rows` array. */
|
|
@@ -155,6 +223,10 @@ export type GraphFields = {
|
|
|
155
223
|
defaultEdgeKind?: EdgeKind;
|
|
156
224
|
/** Path to the consumer's own relationship word — see `GraphEdge.relation`. */
|
|
157
225
|
relation?: string;
|
|
226
|
+
/** Path to a truthy flag marking an overlay edge — see `GraphEdge.overlay`. */
|
|
227
|
+
overlay?: string;
|
|
228
|
+
/** Path to an edge's strength — see `GraphEdge.weight`. */
|
|
229
|
+
edgeWeight?: string;
|
|
158
230
|
cardinality?: string;
|
|
159
231
|
action?: string;
|
|
160
232
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rokkit/graph",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Node-link diagram components — pluggable layouts, ER/schema views",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -20,6 +20,11 @@
|
|
|
20
20
|
"svelte": "./dist/index.js",
|
|
21
21
|
"default": "./dist/index.js"
|
|
22
22
|
},
|
|
23
|
+
"./icons": {
|
|
24
|
+
"types": "./dist/icons.d.ts",
|
|
25
|
+
"svelte": "./dist/icons.js",
|
|
26
|
+
"default": "./dist/icons.js"
|
|
27
|
+
},
|
|
23
28
|
"./schema": {
|
|
24
29
|
"types": "./dist/schema/index.d.ts",
|
|
25
30
|
"svelte": "./dist/schema/index.js",
|
|
@@ -41,10 +46,10 @@
|
|
|
41
46
|
"check:types": "tsc --noEmit"
|
|
42
47
|
},
|
|
43
48
|
"dependencies": {
|
|
44
|
-
"@rokkit/core": "1.
|
|
49
|
+
"@rokkit/core": "1.8.0"
|
|
45
50
|
},
|
|
46
51
|
"peerDependencies": {
|
|
47
|
-
"@rokkit/ui": "1.
|
|
52
|
+
"@rokkit/ui": "1.8.0",
|
|
48
53
|
"svelte": "^5.0.0"
|
|
49
54
|
},
|
|
50
55
|
"peerDependenciesMeta": {
|
|
@@ -53,7 +58,7 @@
|
|
|
53
58
|
}
|
|
54
59
|
},
|
|
55
60
|
"devDependencies": {
|
|
56
|
-
"@rokkit/ui": "1.
|
|
61
|
+
"@rokkit/ui": "1.8.0",
|
|
57
62
|
"@sveltejs/package": "^2.5.8",
|
|
58
63
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
|
59
64
|
"svelte": "^5.55.7",
|