@rokkit/graph 1.7.0 → 1.8.1
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 +67 -1
- package/dist/Graph.svelte +234 -259
- package/dist/Graph.svelte.d.ts +1 -1
- package/dist/GraphLegend.svelte +81 -10
- package/dist/GraphLegend.svelte.d.ts +15 -0
- package/dist/GraphState.svelte.d.ts +168 -51
- package/dist/GraphState.svelte.js +406 -305
- package/dist/actions/canvas.d.ts +18 -0
- package/dist/actions/canvas.js +59 -0
- package/dist/actions/choices.d.ts +12 -0
- package/dist/actions/choices.js +23 -0
- package/dist/actions/interactions.d.ts +20 -0
- package/dist/actions/interactions.js +57 -0
- package/dist/actions/listen.d.ts +2 -0
- package/dist/actions/listen.js +10 -0
- package/dist/canvas/frame.d.ts +41 -0
- package/dist/canvas/frame.js +34 -0
- package/dist/controls/BundleControl.svelte +11 -10
- package/dist/controls/BundleControl.svelte.d.ts +0 -7
- package/dist/controls/DensityControl.svelte +17 -9
- package/dist/controls/DensityControl.svelte.d.ts +0 -8
- package/dist/controls/DepthControl.svelte +11 -4
- package/dist/controls/DepthControl.svelte.d.ts +0 -9
- package/dist/controls/DrillBar.svelte +63 -0
- package/dist/controls/DrillBar.svelte.d.ts +9 -0
- package/dist/controls/EdgeStyleControl.svelte +10 -10
- package/dist/controls/EdgeStyleControl.svelte.d.ts +0 -6
- package/dist/controls/HiddenControl.svelte +27 -0
- package/dist/controls/HiddenControl.svelte.d.ts +7 -0
- package/dist/controls/MeasureControl.svelte +39 -0
- package/dist/controls/MeasureControl.svelte.d.ts +12 -0
- package/dist/controls/ToggleControl.svelte +33 -0
- package/dist/controls/ToggleControl.svelte.d.ts +13 -0
- package/dist/controls/ViolationsControl.svelte +27 -0
- package/dist/controls/ViolationsControl.svelte.d.ts +7 -0
- package/dist/controls/ZoomControl.svelte +21 -18
- package/dist/controls/zoom.d.ts +3 -0
- package/dist/controls/zoom.js +6 -0
- package/dist/diagrams/ArcDiagram.svelte +116 -0
- package/dist/diagrams/ArcDiagram.svelte.d.ts +31 -0
- package/dist/diagrams/CallTree.svelte +17 -5
- package/dist/diagrams/CallTree.svelte.d.ts +5 -1
- package/dist/diagrams/DependencyDiagram.svelte +18 -6
- package/dist/diagrams/DependencyDiagram.svelte.d.ts +5 -1
- package/dist/diagrams/DependencyMatrix.svelte +156 -0
- package/dist/diagrams/DependencyMatrix.svelte.d.ts +34 -0
- package/dist/diagrams/ErDiagram.svelte +18 -6
- package/dist/diagrams/ErDiagram.svelte.d.ts +5 -1
- package/dist/diagrams/LayersDiagram.svelte +124 -0
- package/dist/diagrams/LayersDiagram.svelte.d.ts +30 -0
- package/dist/diagrams/Neighborhood.svelte +26 -6
- package/dist/diagrams/Neighborhood.svelte.d.ts +11 -1
- package/dist/diagrams/PolymetricTree.svelte +116 -0
- package/dist/diagrams/PolymetricTree.svelte.d.ts +28 -0
- package/dist/diagrams/StructureDiagram.svelte +38 -8
- package/dist/diagrams/StructureDiagram.svelte.d.ts +9 -1
- package/dist/diagrams/Sunburst.svelte +44 -8
- package/dist/diagrams/Sunburst.svelte.d.ts +12 -2
- package/dist/diagrams/Treemap.svelte +44 -8
- package/dist/diagrams/Treemap.svelte.d.ts +12 -2
- package/dist/index.d.ts +13 -3
- package/dist/index.js +11 -2
- package/dist/layout/arcs.d.ts +6 -0
- package/dist/layout/arcs.js +141 -0
- package/dist/layout/cards.js +9 -2
- package/dist/layout/edges.d.ts +13 -1
- package/dist/layout/edges.js +35 -0
- package/dist/layout/extent.d.ts +17 -0
- package/dist/layout/extent.js +27 -0
- package/dist/layout/flow.js +3 -1
- package/dist/layout/index.d.ts +4 -1
- package/dist/layout/index.js +17 -2
- package/dist/layout/layers.d.ts +6 -0
- package/dist/layout/layers.js +180 -0
- package/dist/layout/matrix.d.ts +50 -0
- package/dist/layout/matrix.js +110 -0
- package/dist/layout/neighborhood.js +16 -7
- package/dist/layout/options.d.ts +1 -1
- package/dist/layout/options.js +16 -3
- package/dist/layout/percentile.d.ts +8 -0
- package/dist/layout/percentile.js +13 -0
- package/dist/layout/polymetric.d.ts +3 -0
- package/dist/layout/polymetric.js +221 -0
- package/dist/layout/radial.js +3 -1
- package/dist/layout/structure.js +7 -1
- package/dist/layout/sunburst.js +11 -4
- package/dist/layout/types.d.ts +83 -0
- package/dist/layout/world.js +11 -4
- package/dist/messages.d.ts +14 -0
- package/dist/messages.js +17 -0
- package/dist/model/condense.d.ts +8 -0
- package/dist/model/condense.js +114 -0
- package/dist/model/entities.d.ts +12 -0
- package/dist/model/entities.js +12 -0
- package/dist/model/normalize.d.ts +2 -6
- package/dist/model/normalize.js +83 -5
- package/dist/model/relationships.d.ts +30 -0
- package/dist/model/relationships.js +51 -0
- package/dist/model/share.d.ts +9 -0
- package/dist/model/share.js +43 -0
- package/dist/model/tree.d.ts +7 -1
- package/dist/model/tree.js +11 -4
- package/dist/preset.d.ts +18 -0
- package/dist/preset.js +34 -3
- package/dist/schema/EntitiesView.svelte +8 -6
- package/dist/schema/EntityView.svelte +13 -10
- package/dist/state/GraphConfig.svelte.d.ts +124 -0
- package/dist/state/GraphConfig.svelte.js +253 -0
- package/dist/state/GraphDrill.svelte.d.ts +52 -0
- package/dist/state/GraphDrill.svelte.js +115 -0
- package/dist/state/GraphGroups.svelte.d.ts +35 -0
- package/dist/state/GraphGroups.svelte.js +73 -0
- package/dist/state/GraphSelection.svelte.d.ts +53 -0
- package/dist/state/GraphSelection.svelte.js +94 -0
- package/dist/state/intents.d.ts +33 -0
- package/dist/state/intents.js +30 -0
- package/dist/state/text.d.ts +28 -0
- package/dist/state/text.js +56 -0
- package/dist/types.d.ts +92 -1
- package/package.json +5 -4
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/* The polymetric view (#168): Lanza & Marinescu's System Complexity.
|
|
2
|
+
*
|
|
3
|
+
* A containment tree, drawn top-down, where every LEAF is a box whose width, height and shade
|
|
4
|
+
* carry three independent measures — the three-at-once is the point: narrow-tall-dark reads
|
|
5
|
+
* differently from wide-short-pale, and neither is expressible as area (that is `world`).
|
|
6
|
+
*
|
|
7
|
+
* Degenerate data is the normal case for code metrics, so it is designed for:
|
|
8
|
+
* - each channel scales to the 95th percentile of its values, so one file with 10x the
|
|
9
|
+
* functions of the rest does not shrink every other box to the minimum; a value past the
|
|
10
|
+
* cap is clamped to the maximum and the box says so (`clamped`);
|
|
11
|
+
* - a zero is a value and draws at the minimum; a MISSING measure also draws at the minimum
|
|
12
|
+
* but is marked (`missing`), never passed off as a zero.
|
|
13
|
+
* Every box at one depth is topped on one line, so heights compare at a glance.
|
|
14
|
+
*/
|
|
15
|
+
import { buildTree } from '../model/tree.js';
|
|
16
|
+
import { degreeOf } from './sizing.js';
|
|
17
|
+
import { warnUnknownOptions } from './options.js';
|
|
18
|
+
import { capOf } from './percentile.js';
|
|
19
|
+
const MIN = 10;
|
|
20
|
+
const MAX = { width: 140, height: 140, color: 1 };
|
|
21
|
+
/** A container is a label, not a measured thing. */
|
|
22
|
+
const CONTAINER = { w: 132, h: 26 };
|
|
23
|
+
const GAP_X = 18;
|
|
24
|
+
/** Between depth rows — room for the leaf labels under the boxes and the links. */
|
|
25
|
+
const GAP_Y = 46;
|
|
26
|
+
const PAD = 16;
|
|
27
|
+
/** One encoding: the measure it reads, and the value its full extent stands for. */
|
|
28
|
+
class Channel {
|
|
29
|
+
name;
|
|
30
|
+
measure;
|
|
31
|
+
cap;
|
|
32
|
+
#degree;
|
|
33
|
+
constructor(name, measure, leaves, degree) {
|
|
34
|
+
this.name = name;
|
|
35
|
+
this.measure = measure;
|
|
36
|
+
this.#degree = degree;
|
|
37
|
+
this.cap = capOf(leaves.map((l) => this.valueOf(l.node)));
|
|
38
|
+
}
|
|
39
|
+
/** The node's value, or undefined when it has none — distinct from zero. */
|
|
40
|
+
valueOf(node) {
|
|
41
|
+
if (this.measure === 'degree')
|
|
42
|
+
return this.#degree.get(node.id) ?? 0;
|
|
43
|
+
if (this.measure === 'weight')
|
|
44
|
+
return node.weight;
|
|
45
|
+
return node.measures?.[this.measure];
|
|
46
|
+
}
|
|
47
|
+
/** 0..1 of the cap (undefined when missing), and whether the value passed the cap. */
|
|
48
|
+
read(node) {
|
|
49
|
+
const value = this.valueOf(node);
|
|
50
|
+
if (value === undefined)
|
|
51
|
+
return { clamped: false };
|
|
52
|
+
return { t: Math.min(1, Math.max(0, value / this.cap)), clamped: value > this.cap };
|
|
53
|
+
}
|
|
54
|
+
get scale() {
|
|
55
|
+
return { measure: this.measure, cap: this.cap };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** One channel's reading onto the box: a size between MIN and the max, or a shade. */
|
|
59
|
+
function apply(out, name, t) {
|
|
60
|
+
if (name === 'color') {
|
|
61
|
+
if (t !== undefined)
|
|
62
|
+
out.shade = t;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
out[name === 'width' ? 'w' : 'h'] = MIN + (t ?? 0) * (MAX[name] - MIN);
|
|
66
|
+
}
|
|
67
|
+
/** A leaf's width, height and shade, with what was missing or clamped. */
|
|
68
|
+
function encode(node, channels) {
|
|
69
|
+
const out = {};
|
|
70
|
+
const missing = [];
|
|
71
|
+
const clamped = [];
|
|
72
|
+
for (const channel of channels) {
|
|
73
|
+
const { t, clamped: over } = channel.read(node);
|
|
74
|
+
if (t === undefined)
|
|
75
|
+
missing.push(channel.name);
|
|
76
|
+
if (over)
|
|
77
|
+
clamped.push(channel.name);
|
|
78
|
+
apply(out, channel.name, t);
|
|
79
|
+
}
|
|
80
|
+
if (missing.length)
|
|
81
|
+
out.missing = missing;
|
|
82
|
+
if (clamped.length)
|
|
83
|
+
out.clamped = clamped;
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
const rowSpan = (boxes) => boxes.reduce((s, b) => s + b.span, 0) + GAP_X * Math.max(0, boxes.length - 1);
|
|
87
|
+
function boxFor(tree, encoding, kids) {
|
|
88
|
+
// From the TREE, not from `kids`: a container's kids are attached after it is created.
|
|
89
|
+
const leaf = tree.children.length === 0;
|
|
90
|
+
return {
|
|
91
|
+
name: tree.label,
|
|
92
|
+
list: [],
|
|
93
|
+
count: tree.children.length,
|
|
94
|
+
groupIndex: 0,
|
|
95
|
+
x: 0,
|
|
96
|
+
y: 0,
|
|
97
|
+
depth: tree.depth - 1,
|
|
98
|
+
path: tree.address,
|
|
99
|
+
declared: tree.node?.id,
|
|
100
|
+
...(leaf ? { nodeId: tree.node?.id, kind: tree.node?.kind } : {}),
|
|
101
|
+
...encoding,
|
|
102
|
+
tree,
|
|
103
|
+
kids,
|
|
104
|
+
span: 0
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/** Every box in the subtree, sized; `span` is the subtree's width for the tidy layout. */
|
|
108
|
+
function size(tree, channels, out) {
|
|
109
|
+
if (tree.children.length === 0) {
|
|
110
|
+
// A leaf is always a declared node: a synthesised box exists only because something
|
|
111
|
+
// lies below it.
|
|
112
|
+
const leaf = boxFor(tree, encode(tree.node, channels), []);
|
|
113
|
+
leaf.span = leaf.w;
|
|
114
|
+
out.push(leaf);
|
|
115
|
+
return leaf;
|
|
116
|
+
}
|
|
117
|
+
const box = boxFor(tree, { ...CONTAINER }, []);
|
|
118
|
+
out.push(box);
|
|
119
|
+
box.kids = tree.children.map((child) => size(child, channels, out));
|
|
120
|
+
box.span = Math.max(CONTAINER.w, rowSpan(box.kids));
|
|
121
|
+
return box;
|
|
122
|
+
}
|
|
123
|
+
/** Lay the subtree out from `left`: children side by side, the parent centred over them. */
|
|
124
|
+
function place(box, left, rowTop) {
|
|
125
|
+
box.y = rowTop[box.depth ?? 0];
|
|
126
|
+
box.x = left + (box.span - box.w) / 2;
|
|
127
|
+
let x = left + (box.span - rowSpan(box.kids)) / 2;
|
|
128
|
+
for (const kid of box.kids) {
|
|
129
|
+
place(kid, x, rowTop);
|
|
130
|
+
x += kid.span + GAP_X;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** The top of each depth row: every box at a depth is topped on one line. */
|
|
134
|
+
function rowTops(boxes) {
|
|
135
|
+
const tallest = [];
|
|
136
|
+
for (const b of boxes)
|
|
137
|
+
tallest[b.depth ?? 0] = Math.max(tallest[b.depth ?? 0] ?? 0, b.h);
|
|
138
|
+
const tops = [];
|
|
139
|
+
let y = PAD;
|
|
140
|
+
for (const h of tallest) {
|
|
141
|
+
tops.push(y);
|
|
142
|
+
y += h + GAP_Y;
|
|
143
|
+
}
|
|
144
|
+
return tops;
|
|
145
|
+
}
|
|
146
|
+
const keyOf = (b) => b.declared ?? b.tree.id;
|
|
147
|
+
/** An elbow from the bottom of a container to the top of what it holds. */
|
|
148
|
+
function link(parent, child, i) {
|
|
149
|
+
const x1 = parent.x + parent.w / 2;
|
|
150
|
+
const y1 = parent.y + parent.h;
|
|
151
|
+
const x2 = child.x + child.w / 2;
|
|
152
|
+
const y2 = child.y;
|
|
153
|
+
return {
|
|
154
|
+
i,
|
|
155
|
+
id: `contains:${keyOf(parent)}>${keyOf(child)}`,
|
|
156
|
+
fromKey: keyOf(parent),
|
|
157
|
+
toKey: keyOf(child),
|
|
158
|
+
kind: 'containment',
|
|
159
|
+
self: false,
|
|
160
|
+
x1,
|
|
161
|
+
y1,
|
|
162
|
+
x2,
|
|
163
|
+
y2,
|
|
164
|
+
s1: 1,
|
|
165
|
+
s2: 1,
|
|
166
|
+
path: `M ${x1} ${y1} V ${(y1 + y2) / 2} H ${x2} V ${y2}`
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
function linksOf(roots) {
|
|
170
|
+
const edges = [];
|
|
171
|
+
const walk = (b) => {
|
|
172
|
+
for (const kid of b.kids) {
|
|
173
|
+
edges.push(link(b, kid, edges.length));
|
|
174
|
+
walk(kid);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
roots.forEach(walk);
|
|
178
|
+
return edges;
|
|
179
|
+
}
|
|
180
|
+
/** The channels the options bind; colour only when a measure is named for it. */
|
|
181
|
+
function channelsFor(options, leaves, degree) {
|
|
182
|
+
const channels = [
|
|
183
|
+
new Channel('width', options.widthBy ?? 'weight', leaves, degree),
|
|
184
|
+
new Channel('height', options.heightBy ?? 'weight', leaves, degree)
|
|
185
|
+
];
|
|
186
|
+
if (options.colorBy)
|
|
187
|
+
channels.push(new Channel('color', options.colorBy, leaves, degree));
|
|
188
|
+
return channels;
|
|
189
|
+
}
|
|
190
|
+
/** Size and place the forest under the tree root; returns the boxes and the canvas width. */
|
|
191
|
+
function lay(roots, channels) {
|
|
192
|
+
const boxes = [];
|
|
193
|
+
const tops = roots.map((root) => size(root, channels, boxes));
|
|
194
|
+
const rowTop = rowTops(boxes);
|
|
195
|
+
let left = PAD;
|
|
196
|
+
for (const top of tops) {
|
|
197
|
+
place(top, left, rowTop);
|
|
198
|
+
left += top.span + GAP_X * 2;
|
|
199
|
+
}
|
|
200
|
+
return { boxes, tops, width: left - GAP_X * 2 + PAD };
|
|
201
|
+
}
|
|
202
|
+
/** The containment tree with each leaf's width, height and shade bound to three measures. */
|
|
203
|
+
export const polymetric = (model, options) => {
|
|
204
|
+
warnUnknownOptions(options, 'polymetric');
|
|
205
|
+
const tree = buildTree(model);
|
|
206
|
+
if (tree.children.length === 0)
|
|
207
|
+
return { clusters: [], cards: {}, edges: [], size: { w: 0, h: 0 } };
|
|
208
|
+
const leaves = (function all(n) {
|
|
209
|
+
return n.children.length === 0 ? [n] : n.children.flatMap(all);
|
|
210
|
+
})(tree);
|
|
211
|
+
const channels = channelsFor(options, leaves, degreeOf(model));
|
|
212
|
+
const { boxes, tops, width } = lay(tree.children, channels);
|
|
213
|
+
const [w, h, color] = channels;
|
|
214
|
+
return {
|
|
215
|
+
clusters: boxes.map(({ tree: _t, span: _s, kids: _k, ...cluster }) => cluster),
|
|
216
|
+
cards: {},
|
|
217
|
+
edges: linksOf(tops),
|
|
218
|
+
size: { w: width, h: Math.max(...boxes.map((b) => b.y + b.h)) + PAD + GAP_Y / 2 },
|
|
219
|
+
channels: { width: w.scale, height: h.scale, ...(color ? { color: color.scale } : {}) }
|
|
220
|
+
};
|
|
221
|
+
};
|
package/dist/layout/radial.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* edges it could not use are still drawn, marked `back`, because "b also calls this" is
|
|
15
15
|
* exactly what a reader is looking for.
|
|
16
16
|
*/
|
|
17
|
+
import { carried } from './edges.js';
|
|
17
18
|
import { childrenIn, hierarchy } from './hierarchy.js';
|
|
18
19
|
import { nodeSizes } from './sizing.js';
|
|
19
20
|
import { warnUnknownOptions } from './options.js';
|
|
@@ -143,7 +144,8 @@ function identity(edge, i) {
|
|
|
143
144
|
fromKey: edge.source,
|
|
144
145
|
toKey: edge.target,
|
|
145
146
|
kind: edge.kind,
|
|
146
|
-
relation: edge.relation
|
|
147
|
+
relation: edge.relation,
|
|
148
|
+
...carried(edge)
|
|
147
149
|
};
|
|
148
150
|
}
|
|
149
151
|
/**
|
package/dist/layout/structure.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* Modelled on Sensei's Structure board (docs/mockups/Sensei, "Project window · Diagrams ·
|
|
21
21
|
* Structure"), which is d3.cluster + lineRadial + curveBundle over the same hierarchy.
|
|
22
22
|
*/
|
|
23
|
+
import { carried } from './edges.js';
|
|
23
24
|
import { buildTree, findNode } from '../model/tree.js';
|
|
24
25
|
import { bundlePath, commonDepth } from './bundle.js';
|
|
25
26
|
import { nodeSizes } from './sizing.js';
|
|
@@ -153,6 +154,10 @@ function band(node, ctx, index) {
|
|
|
153
154
|
// no band appears at all rather than one looking wrong.
|
|
154
155
|
parent: node.path.slice(0, -1).join('/'),
|
|
155
156
|
ramp: node.path[ctx.origin],
|
|
157
|
+
// A band is a container by construction — its leaves are the node cards on the rim.
|
|
158
|
+
path: node.address,
|
|
159
|
+
leaf: false,
|
|
160
|
+
declared: node.node?.id,
|
|
156
161
|
count: node.children.length,
|
|
157
162
|
list: node.children.map((c) => c.node).filter((n) => n !== undefined),
|
|
158
163
|
groupIndex: index,
|
|
@@ -261,6 +266,7 @@ function routeAll(model, root, ctx, tension) {
|
|
|
261
266
|
toKey: edge.target,
|
|
262
267
|
kind: edge.kind,
|
|
263
268
|
relation: edge.relation ?? reach(a, b),
|
|
269
|
+
...carried(edge),
|
|
264
270
|
self: false,
|
|
265
271
|
x1: from.x,
|
|
266
272
|
y1: from.y,
|
|
@@ -340,7 +346,7 @@ function dots(leaves, ring, ctx) {
|
|
|
340
346
|
*/
|
|
341
347
|
export const structure = (model, options) => {
|
|
342
348
|
warnUnknownOptions(options, 'structure');
|
|
343
|
-
const tree = buildTree(model, { measure: options.sizeBy ?? 'weight' });
|
|
349
|
+
const tree = buildTree(model, { measure: options.sizeBy ?? 'weight', keep: options.focusPath });
|
|
344
350
|
const root = findNode(tree, options.focusPath ?? []);
|
|
345
351
|
if (!root || root.children.length === 0) {
|
|
346
352
|
return { clusters: [], cards: {}, edges: [], size: { w: 0, h: 0 } };
|
package/dist/layout/sunburst.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* exact radians.
|
|
15
15
|
*/
|
|
16
16
|
import { buildTree, findNode } from '../model/tree.js';
|
|
17
|
+
import { shadeFields, shares } from '../model/share.js';
|
|
17
18
|
import { warnUnknownOptions } from './options.js';
|
|
18
19
|
/** Outer radius of the deepest materialised ring. The canvas is derived from it. */
|
|
19
20
|
const RADIUS = 380;
|
|
@@ -70,7 +71,7 @@ function split(node, span, ctx) {
|
|
|
70
71
|
a += slice;
|
|
71
72
|
});
|
|
72
73
|
}
|
|
73
|
-
function wedgeOf(entry, groupIndex, ring) {
|
|
74
|
+
function wedgeOf(entry, groupIndex, ring, shaded) {
|
|
74
75
|
const leaf = entry.tree.children.length === 0 ? entry.tree.node : undefined;
|
|
75
76
|
return {
|
|
76
77
|
name: entry.tree.label,
|
|
@@ -80,6 +81,9 @@ function wedgeOf(entry, groupIndex, ring) {
|
|
|
80
81
|
ramp: entry.ramp,
|
|
81
82
|
nodeId: leaf?.id,
|
|
82
83
|
kind: leaf?.kind,
|
|
84
|
+
path: entry.tree.address,
|
|
85
|
+
leaf: entry.tree.children.length === 0,
|
|
86
|
+
declared: entry.tree.node?.id,
|
|
83
87
|
list: entry.tree.children.map((c) => c.node).filter((n) => n !== undefined),
|
|
84
88
|
count: entry.tree.children.length,
|
|
85
89
|
groupIndex,
|
|
@@ -94,7 +98,8 @@ function wedgeOf(entry, groupIndex, ring) {
|
|
|
94
98
|
x: RADIUS + PAD + Math.cos((entry.a0 + entry.a1) / 2) * ((entry.depth + 0.5) * ring),
|
|
95
99
|
y: RADIUS + PAD + Math.sin((entry.a0 + entry.a1) / 2) * ((entry.depth + 0.5) * ring),
|
|
96
100
|
w: 0,
|
|
97
|
-
h: 0
|
|
101
|
+
h: 0,
|
|
102
|
+
...shadeFields(shaded, entry.tree.id)
|
|
98
103
|
};
|
|
99
104
|
}
|
|
100
105
|
/**
|
|
@@ -105,18 +110,20 @@ function wedgeOf(entry, groupIndex, ring) {
|
|
|
105
110
|
*/
|
|
106
111
|
export const sunburst = (model, options) => {
|
|
107
112
|
warnUnknownOptions(options, 'sunburst');
|
|
108
|
-
const tree = buildTree(model, { measure: options.sizeBy ?? 'weight' });
|
|
113
|
+
const tree = buildTree(model, { measure: options.sizeBy ?? 'weight', keep: options.focusPath });
|
|
109
114
|
const root = findNode(tree, options.focusPath ?? []);
|
|
110
115
|
if (!root || root.children.length === 0) {
|
|
111
116
|
return { clusters: [], cards: {}, edges: [], size: { w: 0, h: 0 } };
|
|
112
117
|
}
|
|
113
118
|
const levels = options.levels ?? 2;
|
|
119
|
+
// #164: as in `world` — a share per wedge when a measure is named for it.
|
|
120
|
+
const shaded = options.shadeBy ? shares(root, options.shadeBy) : undefined;
|
|
114
121
|
const placed = [];
|
|
115
122
|
split(root, { a0: 0, a1: Math.PI * 2 }, { levels, out: placed, origin: root.path.length });
|
|
116
123
|
const ring = RADIUS / Math.max(1, levels);
|
|
117
124
|
const extent = (RADIUS + PAD) * 2;
|
|
118
125
|
return {
|
|
119
|
-
clusters: placed.map((entry, i) => wedgeOf(entry, i, ring)),
|
|
126
|
+
clusters: placed.map((entry, i) => wedgeOf(entry, i, ring, shaded)),
|
|
120
127
|
cards: {},
|
|
121
128
|
// Edges are absent for the same reason as the treemap: containment IS the relationship,
|
|
122
129
|
// and an adjacency graph cannot be drawn at this scale.
|
package/dist/layout/types.d.ts
CHANGED
|
@@ -103,6 +103,30 @@ export type Cluster = {
|
|
|
103
103
|
a0: number;
|
|
104
104
|
a1: number;
|
|
105
105
|
};
|
|
106
|
+
/**
|
|
107
|
+
* A containment box's full path from the tree root, outermost first — its ADDRESS.
|
|
108
|
+
*
|
|
109
|
+
* The name repeats across branches (a `table` box under `public` and under `billing`); the
|
|
110
|
+
* path cannot. Drilling into a box sets `focusPath` to exactly this, and it stays absolute
|
|
111
|
+
* when the canvas is itself scoped by `focusPath`. Set by the containment layouts only.
|
|
112
|
+
*/
|
|
113
|
+
path?: string[];
|
|
114
|
+
/** Whether nothing lies below this box in the data — so only a host loader can go deeper. */
|
|
115
|
+
leaf?: boolean;
|
|
116
|
+
/** `polymetric` (#168) — the colour measure as 0..1 of its cap; absent when not bound or missing. */
|
|
117
|
+
shade?: number;
|
|
118
|
+
/** `polymetric` — the channels whose measure this box lacks, drawn at the minimum and marked. */
|
|
119
|
+
missing?: PolymetricChannel[];
|
|
120
|
+
/** `polymetric` — the channels whose value exceeded the cap and was clamped to the maximum. */
|
|
121
|
+
clamped?: PolymetricChannel[];
|
|
122
|
+
/**
|
|
123
|
+
* The id of the node this box IS, when one is declared — a leaf OR a container.
|
|
124
|
+
*
|
|
125
|
+
* `nodeId` is set only on leaves, because only a leaf is selectable; a container that is
|
|
126
|
+
* also a real node (a module that holds files) needs its identity for drilling, which is
|
|
127
|
+
* not selection. Undefined on a container nothing declared.
|
|
128
|
+
*/
|
|
129
|
+
declared?: string;
|
|
106
130
|
};
|
|
107
131
|
/**
|
|
108
132
|
* Which node property a layout groups on. Both already exist on `GraphNode`, so nesting
|
|
@@ -126,6 +150,26 @@ export type RoutedEdge = {
|
|
|
126
150
|
* than the reader wondering why one link behaves differently.
|
|
127
151
|
*/
|
|
128
152
|
back?: boolean;
|
|
153
|
+
/** GraphEdge.overlay — routed after the layout ran, over a picture it did not shape. */
|
|
154
|
+
overlay?: boolean;
|
|
155
|
+
/** GraphEdge.weight, raw. `GraphState.edgeWeight` normalises it for drawing. */
|
|
156
|
+
weight?: number;
|
|
157
|
+
/** GraphEdge.weakest — the cheapest link to cut in a cycle (#166). */
|
|
158
|
+
weakest?: boolean;
|
|
159
|
+
/** GraphEdge.hidden — coupling no import explains (#169). */
|
|
160
|
+
hidden?: boolean;
|
|
161
|
+
/** `arcs` — which side of the axis the arc is drawn on (#169). */
|
|
162
|
+
side?: 'above' | 'below';
|
|
163
|
+
/**
|
|
164
|
+
* 0..1 against its OWN set's 95th-percentile weight, clamped past it, when a layout normalises
|
|
165
|
+
* per set (`arcs`, #169): imports and shared commits are different units, and one outlying
|
|
166
|
+
* pair should not flatten the rest. Preferred over the global weight scale.
|
|
167
|
+
*/
|
|
168
|
+
strength?: number;
|
|
169
|
+
/** GraphEdge.count — how many model edges a condensed edge stands for. */
|
|
170
|
+
count?: number;
|
|
171
|
+
/** How the edge sits against the layering (#167) — given by the host or derived by `layers`. */
|
|
172
|
+
conformance?: import('../types.js').Conformance;
|
|
129
173
|
x1: number;
|
|
130
174
|
y1: number;
|
|
131
175
|
x2: number;
|
|
@@ -239,8 +283,47 @@ export type LayoutOptions = {
|
|
|
239
283
|
* highlight it would silently re-root the diagram.
|
|
240
284
|
*/
|
|
241
285
|
root?: string | null;
|
|
286
|
+
/**
|
|
287
|
+
* Which edges to draw. `layers`: `violations` keeps just the edges that climb (#167).
|
|
288
|
+
* `arcs`: `hidden` keeps just the hidden coupling (#169). On a real codebase the ordinary
|
|
289
|
+
* edges are the vast majority and drown the signal.
|
|
290
|
+
*/
|
|
291
|
+
showEdges?: ShowEdges;
|
|
292
|
+
/** `layers` only — band names by layer index. Unnamed layers read "Layer N". */
|
|
293
|
+
layerLabels?: string[];
|
|
294
|
+
/** `polymetric` only — the measure box WIDTH encodes. A `measures` key, `weight` or `degree`. */
|
|
295
|
+
widthBy?: string;
|
|
296
|
+
/** `polymetric` only — the measure box HEIGHT encodes. */
|
|
297
|
+
heightBy?: string;
|
|
298
|
+
/** `polymetric` only — the measure box SHADE encodes. Omit for no colour channel. */
|
|
299
|
+
colorBy?: string;
|
|
300
|
+
/**
|
|
301
|
+
* `arcs` only — the relation drawn on the right of the axis (#169). Omitted, overlays go
|
|
302
|
+
* right and structural edges left: what the history reveals against what the code declares.
|
|
303
|
+
*/
|
|
304
|
+
above?: string;
|
|
305
|
+
/**
|
|
306
|
+
* `world` / `sunburst` — the measure each box is SHADED by, a share in 0..1 (#164). A
|
|
307
|
+
* container takes the size-weighted mean of what it holds unless it declares its own.
|
|
308
|
+
*/
|
|
309
|
+
shadeBy?: string;
|
|
310
|
+
};
|
|
311
|
+
/** Every edge, or only the ones a view is about. */
|
|
312
|
+
export type ShowEdges = 'all' | 'violations' | 'hidden';
|
|
313
|
+
/** The three encodings of the polymetric view (#168). */
|
|
314
|
+
export type PolymetricChannel = 'width' | 'height' | 'color';
|
|
315
|
+
/** Which measure a channel carries, and the value its full extent stands for. */
|
|
316
|
+
export type ChannelScale = {
|
|
317
|
+
measure: string;
|
|
318
|
+
cap: number;
|
|
242
319
|
};
|
|
243
320
|
export type LayoutResult = {
|
|
321
|
+
/** `polymetric` — what each channel encodes, for the legend. */
|
|
322
|
+
channels?: {
|
|
323
|
+
width: ChannelScale;
|
|
324
|
+
height: ChannelScale;
|
|
325
|
+
color?: ChannelScale;
|
|
326
|
+
};
|
|
244
327
|
clusters: Cluster[];
|
|
245
328
|
cards: Cards;
|
|
246
329
|
edges: RoutedEdge[];
|
package/dist/layout/world.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* have to be able to read what you are about to click.
|
|
18
18
|
*/
|
|
19
19
|
import { buildTree, findNode } from '../model/tree.js';
|
|
20
|
+
import { shadeFields, shares } from '../model/share.js';
|
|
20
21
|
import { squarify } from './squarify.js';
|
|
21
22
|
import { warnUnknownOptions } from './options.js';
|
|
22
23
|
/** Canvas the layout lays out into. Fit-to-container scales it; the ratios are what matter. */
|
|
@@ -89,7 +90,7 @@ function compact(value) {
|
|
|
89
90
|
* A childless box keeps its node's id and kind, so uniform structure costs no identity: the
|
|
90
91
|
* box is still selectable and still colours by what it is.
|
|
91
92
|
*/
|
|
92
|
-
function clusterOf(entry, groupIndex) {
|
|
93
|
+
function clusterOf(entry, groupIndex, shaded) {
|
|
93
94
|
const leaf = entry.tree.children.length === 0 ? entry.tree.node : undefined;
|
|
94
95
|
return {
|
|
95
96
|
name: entry.tree.label,
|
|
@@ -101,13 +102,17 @@ function clusterOf(entry, groupIndex) {
|
|
|
101
102
|
ramp: entry.ramp,
|
|
102
103
|
nodeId: leaf?.id,
|
|
103
104
|
kind: leaf?.kind,
|
|
105
|
+
path: entry.tree.address,
|
|
106
|
+
leaf: entry.tree.children.length === 0,
|
|
107
|
+
declared: entry.tree.node?.id,
|
|
104
108
|
list: entry.tree.children.map((c) => c.node).filter((n) => n !== undefined),
|
|
105
109
|
count: entry.tree.children.length,
|
|
106
110
|
groupIndex,
|
|
107
111
|
x: entry.rect.x,
|
|
108
112
|
y: entry.rect.y,
|
|
109
113
|
w: entry.rect.w,
|
|
110
|
-
h: entry.rect.h
|
|
114
|
+
h: entry.rect.h,
|
|
115
|
+
...shadeFields(shaded, entry.tree.id)
|
|
111
116
|
};
|
|
112
117
|
}
|
|
113
118
|
/**
|
|
@@ -119,11 +124,13 @@ function clusterOf(entry, groupIndex) {
|
|
|
119
124
|
*/
|
|
120
125
|
export const world = (model, options) => {
|
|
121
126
|
warnUnknownOptions(options, 'world');
|
|
122
|
-
const tree = buildTree(model, { measure: options.sizeBy ?? 'weight' });
|
|
127
|
+
const tree = buildTree(model, { measure: options.sizeBy ?? 'weight', keep: options.focusPath });
|
|
123
128
|
const root = findNode(tree, options.focusPath ?? []);
|
|
124
129
|
if (!root || root.children.length === 0) {
|
|
125
130
|
return { clusters: [], cards: {}, edges: [], size: { w: 0, h: 0 } };
|
|
126
131
|
}
|
|
132
|
+
// #164: a share per box when a measure is named for it; the layout's geometry is unchanged.
|
|
133
|
+
const shaded = options.shadeBy ? shares(root, options.shadeBy) : undefined;
|
|
127
134
|
const placed = [];
|
|
128
135
|
place(root, { x: 0, y: 0, w: CANVAS.w, h: CANVAS.h }, {
|
|
129
136
|
levels: options.levels ?? 2,
|
|
@@ -136,7 +143,7 @@ export const world = (model, options) => {
|
|
|
136
143
|
// `cards` is empty by construction rather than by filtering — there is no box in this
|
|
137
144
|
// layout that a card could represent.
|
|
138
145
|
return {
|
|
139
|
-
clusters: placed.map(clusterOf),
|
|
146
|
+
clusters: placed.map((entry, i) => clusterOf(entry, i, shaded)),
|
|
140
147
|
cards: {},
|
|
141
148
|
edges: [],
|
|
142
149
|
size: CANVAS
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graph's words live in `messages.graph` (`@rokkit/states`), so a locale can replace any of
|
|
3
|
+
* them. Templates name their values in braces — `Open {name}` — and `fill` puts them in.
|
|
4
|
+
*/
|
|
5
|
+
import { messages } from '@rokkit/states';
|
|
6
|
+
/** The graph namespace of the active locale. Reactive: read it where the text is shown. */
|
|
7
|
+
export type GraphMessages = typeof messages.graph;
|
|
8
|
+
export type GraphMessageKey = keyof GraphMessages;
|
|
9
|
+
/** `template` with each `{token}` replaced; a token with no value stays as written. */
|
|
10
|
+
export declare function fill(template: string, values: Record<string, string | number>): string;
|
|
11
|
+
/** `{n} node` or `{n} nodes`, from the active locale. */
|
|
12
|
+
export declare function counted(n: number, one: GraphMessageKey, many: GraphMessageKey): string;
|
|
13
|
+
/** One graph string, filled. */
|
|
14
|
+
export declare function say(key: GraphMessageKey, values?: Record<string, string | number>): string;
|
package/dist/messages.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graph's words live in `messages.graph` (`@rokkit/states`), so a locale can replace any of
|
|
3
|
+
* them. Templates name their values in braces — `Open {name}` — and `fill` puts them in.
|
|
4
|
+
*/
|
|
5
|
+
import { messages } from '@rokkit/states';
|
|
6
|
+
/** `template` with each `{token}` replaced; a token with no value stays as written. */
|
|
7
|
+
export function fill(template, values) {
|
|
8
|
+
return template.replace(/\{(\w+)\}/g, (whole, token) => token in values ? String(values[token]) : whole);
|
|
9
|
+
}
|
|
10
|
+
/** `{n} node` or `{n} nodes`, from the active locale. */
|
|
11
|
+
export function counted(n, one, many) {
|
|
12
|
+
return fill(messages.graph[n === 1 ? one : many], { n });
|
|
13
|
+
}
|
|
14
|
+
/** One graph string, filled. */
|
|
15
|
+
export function say(key, values = {}) {
|
|
16
|
+
return fill(messages.graph[key], values);
|
|
17
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { GraphModel } from '../types.js';
|
|
2
|
+
/** Every group node, mapped to its members that exist in the model, in the order listed. */
|
|
3
|
+
export declare function groupsOf(model: GraphModel): Map<string, string[]>;
|
|
4
|
+
/**
|
|
5
|
+
* The model with each group in `collapsed` standing in for its members, and every other group
|
|
6
|
+
* expanded into them. The model itself when it has no groups.
|
|
7
|
+
*/
|
|
8
|
+
export declare function condense(model: GraphModel, collapsed: Set<string>): GraphModel;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { buildNeighbors } from './normalize.js';
|
|
2
|
+
/** Every group node, mapped to its members that exist in the model, in the order listed. */
|
|
3
|
+
export function groupsOf(model) {
|
|
4
|
+
const groups = new Map();
|
|
5
|
+
for (const node of model.nodes) {
|
|
6
|
+
if (!node.members || node.members.length === 0)
|
|
7
|
+
continue;
|
|
8
|
+
groups.set(node.id, node.members.filter((id) => model.byId.has(id)));
|
|
9
|
+
}
|
|
10
|
+
return groups;
|
|
11
|
+
}
|
|
12
|
+
/** A collapsed group takes its members' place. */
|
|
13
|
+
function collapseInto(group, members, { into, hidden }) {
|
|
14
|
+
for (const member of members) {
|
|
15
|
+
// A member listed in two groups belongs to the first; nesting is not modelled.
|
|
16
|
+
if (!into.has(member))
|
|
17
|
+
into.set(member, group);
|
|
18
|
+
hidden.add(member);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function routingFor(groups, collapsed) {
|
|
22
|
+
const routing = { into: new Map(), hidden: new Set() };
|
|
23
|
+
for (const [group, members] of groups) {
|
|
24
|
+
if (collapsed.has(group))
|
|
25
|
+
collapseInto(group, members, routing);
|
|
26
|
+
// Expanded, and its members are here: the group gives way to them. With no member in
|
|
27
|
+
// the model yet it stays — there is nothing to show in its place.
|
|
28
|
+
else if (members.length > 0)
|
|
29
|
+
routing.hidden.add(group);
|
|
30
|
+
}
|
|
31
|
+
return routing;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* An edge as the condensed view draws it, or null when it has no place there: inside a
|
|
35
|
+
* collapsed group, or touching something hidden (a pre-aggregated edge to a group that is now
|
|
36
|
+
* expanded into its members).
|
|
37
|
+
*/
|
|
38
|
+
/** Where an endpoint is drawn: its collapsed group, or itself. */
|
|
39
|
+
const shownAs = (id, into) => into.get(id) ?? id;
|
|
40
|
+
function reroute(edge, { into, hidden }) {
|
|
41
|
+
const source = shownAs(edge.source, into);
|
|
42
|
+
const target = shownAs(edge.target, into);
|
|
43
|
+
if (hidden.has(source) || hidden.has(target))
|
|
44
|
+
return null;
|
|
45
|
+
const moved = source !== edge.source || target !== edge.target;
|
|
46
|
+
if (!moved)
|
|
47
|
+
return edge;
|
|
48
|
+
if (source === target)
|
|
49
|
+
return null;
|
|
50
|
+
// Row anchors named a member's rows; the group card has none.
|
|
51
|
+
const { sourceRow: _s, targetRow: _t, ...rest } = edge;
|
|
52
|
+
return { ...rest, source, target };
|
|
53
|
+
}
|
|
54
|
+
const keyOf = (e) => `${e.source}>${e.target}>${e.kind}>${e.relation ?? ''}`;
|
|
55
|
+
/** Summed weight, or undefined when neither side carries one. */
|
|
56
|
+
const sumWeights = (a, b) => a === undefined && b === undefined ? undefined : (a ?? 0) + (b ?? 0);
|
|
57
|
+
/** Two parallel edges as one: it stands for both, and is no single edge — so not the weakest. */
|
|
58
|
+
function merge(seen, edge, key) {
|
|
59
|
+
const { weakest: _w, ...rest } = seen;
|
|
60
|
+
const merged = { ...rest, id: `condensed:${key}`, count: (seen.count ?? 1) + 1 };
|
|
61
|
+
const weight = sumWeights(seen.weight, edge.weight);
|
|
62
|
+
if (weight !== undefined)
|
|
63
|
+
merged.weight = weight;
|
|
64
|
+
return merged;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Parallel edges that rerouting created, folded into one: weights summed (when any carries
|
|
68
|
+
* one), `count` = how many model edges it stands for. A folded edge is no longer any single
|
|
69
|
+
* edge, so it is not the weakest one either.
|
|
70
|
+
*/
|
|
71
|
+
function fold(edges, original) {
|
|
72
|
+
const byKey = new Map();
|
|
73
|
+
edges.forEach((edge, i) => {
|
|
74
|
+
const key = keyOf(edge);
|
|
75
|
+
const seen = byKey.get(key);
|
|
76
|
+
if (seen)
|
|
77
|
+
byKey.set(key, merge(seen, edge, key));
|
|
78
|
+
// A rerouted edge is no longer the model edge it came from, so it takes a new id.
|
|
79
|
+
else
|
|
80
|
+
byKey.set(key, edge === original[i] ? edge : { ...edge, id: `condensed:${key}` });
|
|
81
|
+
});
|
|
82
|
+
return [...byKey.values()];
|
|
83
|
+
}
|
|
84
|
+
function condenseEdges(edges, routing) {
|
|
85
|
+
const kept = [];
|
|
86
|
+
const originals = [];
|
|
87
|
+
for (const edge of edges) {
|
|
88
|
+
const routed = reroute(edge, routing);
|
|
89
|
+
if (!routed)
|
|
90
|
+
continue;
|
|
91
|
+
kept.push(routed);
|
|
92
|
+
originals.push(edge);
|
|
93
|
+
}
|
|
94
|
+
return fold(kept, originals);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The model with each group in `collapsed` standing in for its members, and every other group
|
|
98
|
+
* expanded into them. The model itself when it has no groups.
|
|
99
|
+
*/
|
|
100
|
+
export function condense(model, collapsed) {
|
|
101
|
+
const groups = groupsOf(model);
|
|
102
|
+
if (groups.size === 0)
|
|
103
|
+
return model;
|
|
104
|
+
const routing = routingFor(groups, collapsed);
|
|
105
|
+
const nodes = model.nodes.filter((node) => !routing.hidden.has(node.id));
|
|
106
|
+
const edges = condenseEdges(model.edges, routing);
|
|
107
|
+
return {
|
|
108
|
+
nodes,
|
|
109
|
+
edges,
|
|
110
|
+
overlays: condenseEdges(model.overlays, routing),
|
|
111
|
+
byId: new Map(nodes.map((node) => [node.id, node])),
|
|
112
|
+
neighbors: buildNeighbors(edges)
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { GraphModel } from '../types.js';
|
|
2
|
+
export type EntityRow = {
|
|
3
|
+
id: string;
|
|
4
|
+
label: string;
|
|
5
|
+
group?: string;
|
|
6
|
+
kind?: string;
|
|
7
|
+
rowCount: number;
|
|
8
|
+
refCount: number;
|
|
9
|
+
note?: string;
|
|
10
|
+
};
|
|
11
|
+
/** One row per node for an entity list: its rows and the structural edges touching it. */
|
|
12
|
+
export declare function entityRows(model: GraphModel): EntityRow[];
|