@rokkit/graph 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -2
- package/dist/Graph.svelte +121 -72
- package/dist/GraphLegend.svelte +112 -0
- package/dist/GraphLegend.svelte.d.ts +33 -0
- package/dist/GraphState.svelte.d.ts +107 -15
- package/dist/GraphState.svelte.js +186 -3
- package/dist/controls/BundleControl.svelte +30 -0
- package/dist/controls/BundleControl.svelte.d.ts +16 -0
- package/dist/controls/DensityControl.svelte +39 -0
- package/dist/controls/DensityControl.svelte.d.ts +16 -0
- package/dist/controls/DepthControl.svelte +34 -0
- package/dist/controls/DepthControl.svelte.d.ts +17 -0
- package/dist/controls/EdgeStyleControl.svelte +31 -0
- package/dist/controls/EdgeStyleControl.svelte.d.ts +14 -0
- package/dist/controls/ZoomControl.svelte +47 -0
- package/dist/controls/ZoomControl.svelte.d.ts +7 -0
- package/dist/controls/zoom.d.ts +6 -0
- package/dist/controls/zoom.js +12 -0
- package/dist/diagrams/CallTree.svelte +135 -0
- package/dist/diagrams/CallTree.svelte.d.ts +37 -0
- package/dist/diagrams/DependencyDiagram.svelte +124 -0
- package/dist/diagrams/DependencyDiagram.svelte.d.ts +30 -0
- package/dist/diagrams/DiagramFrame.svelte +36 -0
- package/dist/diagrams/DiagramFrame.svelte.d.ts +20 -0
- package/dist/diagrams/ErDiagram.svelte +128 -0
- package/dist/diagrams/ErDiagram.svelte.d.ts +33 -0
- package/dist/diagrams/Neighborhood.svelte +127 -0
- package/dist/diagrams/Neighborhood.svelte.d.ts +33 -0
- package/dist/diagrams/StructureDiagram.svelte +131 -0
- package/dist/diagrams/StructureDiagram.svelte.d.ts +31 -0
- package/dist/diagrams/Sunburst.svelte +124 -0
- package/dist/diagrams/Sunburst.svelte.d.ts +32 -0
- package/dist/diagrams/Treemap.svelte +124 -0
- package/dist/diagrams/Treemap.svelte.d.ts +32 -0
- package/dist/icons.d.ts +5 -0
- package/dist/icons.js +5 -0
- package/dist/index.d.ts +18 -1
- package/dist/index.js +23 -1
- package/dist/layout/arc.d.ts +13 -0
- package/dist/layout/arc.js +58 -0
- package/dist/layout/bundle.d.ts +30 -0
- package/dist/layout/bundle.js +80 -0
- package/dist/layout/cluster.js +2 -0
- package/dist/layout/flow.d.ts +8 -0
- package/dist/layout/flow.js +141 -0
- package/dist/layout/hierarchy.d.ts +42 -0
- package/dist/layout/hierarchy.js +175 -0
- package/dist/layout/index.d.ts +7 -1
- package/dist/layout/index.js +8 -2
- package/dist/layout/neighborhood.js +164 -28
- package/dist/layout/options.d.ts +44 -0
- package/dist/layout/options.js +106 -0
- package/dist/layout/order.d.ts +11 -0
- package/dist/layout/order.js +153 -0
- package/dist/layout/points.js +27 -35
- package/dist/layout/radial.d.ts +16 -0
- package/dist/layout/radial.js +214 -0
- package/dist/layout/rank.d.ts +15 -0
- package/dist/layout/rank.js +105 -0
- package/dist/layout/rings.d.ts +20 -0
- package/dist/layout/rings.js +92 -0
- package/dist/layout/sizing.d.ts +37 -0
- package/dist/layout/sizing.js +79 -0
- package/dist/layout/squarify.d.ts +14 -0
- package/dist/layout/squarify.js +114 -0
- package/dist/layout/structure.d.ts +9 -0
- package/dist/layout/structure.js +367 -0
- package/dist/layout/sunburst.d.ts +8 -0
- package/dist/layout/sunburst.js +126 -0
- package/dist/layout/types.d.ts +141 -0
- package/dist/layout/world.d.ts +9 -0
- package/dist/layout/world.js +144 -0
- package/dist/model/normalize.js +47 -1
- package/dist/model/tree.d.ts +27 -0
- package/dist/model/tree.js +182 -0
- package/dist/types.d.ts +55 -3
- package/package.json +9 -4
|
@@ -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;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every key `LayoutOptions` defines. Kept beside the type on purpose — a key added there and
|
|
3
|
+
* missed here would warn about a legitimate option, which is worse than the silence this
|
|
4
|
+
* exists to fix. `spec/options.spec.ts` compares the two.
|
|
5
|
+
*/
|
|
6
|
+
export const LAYOUT_OPTION_KEYS = [
|
|
7
|
+
'density',
|
|
8
|
+
'arrange',
|
|
9
|
+
'edgeStyle',
|
|
10
|
+
'focus',
|
|
11
|
+
'expanded',
|
|
12
|
+
'groupBy',
|
|
13
|
+
'nestBy',
|
|
14
|
+
'sizeBy',
|
|
15
|
+
'sizeScale',
|
|
16
|
+
'depth',
|
|
17
|
+
'focusPath',
|
|
18
|
+
'levels',
|
|
19
|
+
'radialMode',
|
|
20
|
+
'root',
|
|
21
|
+
'bundleTension'
|
|
22
|
+
];
|
|
23
|
+
/**
|
|
24
|
+
* Which options have an EFFECT under each built-in layout.
|
|
25
|
+
*
|
|
26
|
+
* A control wired to an option the active layout ignores is worse than a missing one: it looks
|
|
27
|
+
* like a knob, it moves, and the picture does not change — so the reader concludes the view is
|
|
28
|
+
* broken rather than that the control does not apply. `names | keys | full` over a treemap is
|
|
29
|
+
* the plainest case, since a treemap box has no row list to thin.
|
|
30
|
+
*
|
|
31
|
+
* Exported because it is a fact about the layout, not about any one consumer's control panel.
|
|
32
|
+
* A hand-maintained copy in a UI drifts the first time a layout gains or drops an option.
|
|
33
|
+
*
|
|
34
|
+
* Two entries are not simply "what the layout function reads", and both are deliberate:
|
|
35
|
+
* - `edgeStyle` is applied by `GraphState` when it draws a path, not by the layout. It
|
|
36
|
+
* therefore applies wherever a layout produces edges — which `world` does not.
|
|
37
|
+
* - `neighborhood` takes no `density`: it builds its cards at `full` unconditionally, because
|
|
38
|
+
* a portrait of one node's surroundings that hides the columns the edges land on defeats
|
|
39
|
+
* its own purpose.
|
|
40
|
+
*/
|
|
41
|
+
export const LAYOUT_OPTIONS = {
|
|
42
|
+
cluster: ['density', 'arrange', 'edgeStyle', 'expanded', 'groupBy', 'nestBy'],
|
|
43
|
+
flow: ['density', 'edgeStyle', 'expanded'],
|
|
44
|
+
neighborhood: ['edgeStyle', 'expanded', 'focus', 'depth'],
|
|
45
|
+
points: ['edgeStyle', 'sizeBy', 'sizeScale'],
|
|
46
|
+
radial: ['edgeStyle', 'sizeBy', 'sizeScale', 'radialMode', 'levels', 'root'],
|
|
47
|
+
sunburst: ['sizeBy', 'focusPath', 'levels'],
|
|
48
|
+
structure: ['sizeBy', 'sizeScale', 'focusPath', 'levels', 'bundleTension'],
|
|
49
|
+
world: ['sizeBy', 'focusPath', 'levels']
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Layouts that draw a node as a DOT rather than a card.
|
|
53
|
+
*
|
|
54
|
+
* A card is the wrong unit once a node is 20px across: the head, the kind tag and the
|
|
55
|
+
* more-row are all still in the DOM, overflowing a box the layout sized for a dot. Published
|
|
56
|
+
* as a fact about the layout so the theme can key on the SHAPE instead of naming every layout
|
|
57
|
+
* that happens to have one — `radial` inherited the whole dot treatment by joining this set.
|
|
58
|
+
*/
|
|
59
|
+
const DOT_LAYOUTS = new Set(['points', 'radial', 'structure']);
|
|
60
|
+
/** `dot` or `card` — what shape this layout draws a node as. */
|
|
61
|
+
export function nodeShapeOf(layout) {
|
|
62
|
+
return DOT_LAYOUTS.has(layout) ? 'dot' : 'card';
|
|
63
|
+
}
|
|
64
|
+
/** Whether a control for `option` should be offered while `layout` is active. */
|
|
65
|
+
export function appliesTo(layout, option) {
|
|
66
|
+
return LAYOUT_OPTIONS[layout]?.includes(option) ?? true;
|
|
67
|
+
}
|
|
68
|
+
/** Warn once per unknown key, ever. A layout re-runs on every render. */
|
|
69
|
+
const warned = new Set();
|
|
70
|
+
/** Bundlers replace this; `process` is undefined in a browser, hence the guarded read. */
|
|
71
|
+
function isProduction() {
|
|
72
|
+
return typeof process !== 'undefined' && process.env?.NODE_ENV === 'production';
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Tell a caller when an option was discarded.
|
|
76
|
+
*
|
|
77
|
+
* `LayoutOptions` is not a closed check once the object reaches a layout through a variable —
|
|
78
|
+
* TypeScript's excess-property check only fires on an object literal at the call site. So
|
|
79
|
+
* `neighborhood(model, opts)` where `opts` carries `depth: 2` type-checks, runs, and returns
|
|
80
|
+
* a correct-looking diagram that silently ignored the option (#162).
|
|
81
|
+
*
|
|
82
|
+
* Silence is the problem, not the unknown key: the caller has no way to tell "this layout
|
|
83
|
+
* does not support depth" from "depth worked and two hops is what you are looking at".
|
|
84
|
+
*/
|
|
85
|
+
export function warnUnknownOptions(options, layout) {
|
|
86
|
+
if (isProduction())
|
|
87
|
+
return;
|
|
88
|
+
for (const key of Object.keys(options)) {
|
|
89
|
+
if (LAYOUT_OPTION_KEYS.includes(key))
|
|
90
|
+
continue;
|
|
91
|
+
const seen = `${layout}:${key}`;
|
|
92
|
+
if (warned.has(seen))
|
|
93
|
+
continue;
|
|
94
|
+
warned.add(seen);
|
|
95
|
+
// The one place a console call is the POINT rather than a leftover: a discarded option
|
|
96
|
+
// has no other channel back to the caller, and throwing would make an unknown key fatal
|
|
97
|
+
// for a diagram that still renders correctly. Dev-only and deduped above.
|
|
98
|
+
// eslint-disable-next-line no-console
|
|
99
|
+
console.warn(`[@rokkit/graph] \`${layout}\` received unknown option \`${key}\` and ignored it. ` +
|
|
100
|
+
`Known options: ${LAYOUT_OPTION_KEYS.join(', ')}.`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** Test seam — the dedupe is module state and would leak between cases otherwise. */
|
|
104
|
+
export function resetOptionWarnings() {
|
|
105
|
+
warned.clear();
|
|
106
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { GraphModel } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Crossings between every adjacent pair of layers.
|
|
4
|
+
*
|
|
5
|
+
* Two edges cross when their endpoints are in opposite relative order on the two sides. The
|
|
6
|
+
* naive pair-count is fine here: a layer holds tens of nodes, not thousands, and being
|
|
7
|
+
* obviously correct matters more than being O(n log n) — this is the score the sweep is
|
|
8
|
+
* optimising, so an error in it would silently make the heuristic choose the worse layout.
|
|
9
|
+
*/
|
|
10
|
+
export declare function countCrossings(layers: string[][], model: GraphModel, back: Set<string>): number;
|
|
11
|
+
export declare function order(model: GraphModel, ranks: Map<string, number>, back: Set<string>): string[][];
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/* Ordering within a column — the crossing-reduction half of a layered layout.
|
|
2
|
+
*
|
|
3
|
+
* Minimising edge crossings is NP-hard, so this is the standard heuristic: sweep the layers
|
|
4
|
+
* assigning each node the average position of its neighbours in the adjacent layer, sort by
|
|
5
|
+
* that, repeat. See docs/design/25-flow-layout.md.
|
|
6
|
+
*
|
|
7
|
+
* Two properties are load-bearing and neither is incidental:
|
|
8
|
+
*
|
|
9
|
+
* - It is DETERMINISTIC. A fixed pass count and a stable tie-break, so the same model lays
|
|
10
|
+
* out identically every run — which `LayoutFn` promises and every layout spec relies on.
|
|
11
|
+
* - It never returns worse than it started. Each candidate is scored and the best kept, so
|
|
12
|
+
* a graph the heuristic cannot improve comes back unchanged rather than churned.
|
|
13
|
+
*/
|
|
14
|
+
/** Sweeps of down-then-up. Four is where the standard heuristic stops paying. */
|
|
15
|
+
const PASSES = 4;
|
|
16
|
+
/** An edge that is actually drawn between two adjacent columns. */
|
|
17
|
+
function drawn(edges, back, byId) {
|
|
18
|
+
return edges.filter((e) => !back.has(e.id) && e.source !== e.target && byId.has(e.source) && byId.has(e.target));
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Crossings between every adjacent pair of layers.
|
|
22
|
+
*
|
|
23
|
+
* Two edges cross when their endpoints are in opposite relative order on the two sides. The
|
|
24
|
+
* naive pair-count is fine here: a layer holds tens of nodes, not thousands, and being
|
|
25
|
+
* obviously correct matters more than being O(n log n) — this is the score the sweep is
|
|
26
|
+
* optimising, so an error in it would silently make the heuristic choose the worse layout.
|
|
27
|
+
*/
|
|
28
|
+
export function countCrossings(layers, model, back) {
|
|
29
|
+
const pos = new Map();
|
|
30
|
+
layers.forEach((layer) => layer.forEach((id, i) => pos.set(id, i)));
|
|
31
|
+
const layerOf = new Map();
|
|
32
|
+
layers.forEach((layer, i) => layer.forEach((id) => layerOf.set(id, i)));
|
|
33
|
+
let total = 0;
|
|
34
|
+
const edges = drawn(model.edges, back, model.byId);
|
|
35
|
+
for (let i = 0; i < edges.length; i++) {
|
|
36
|
+
for (let j = i + 1; j < edges.length; j++) {
|
|
37
|
+
const a = edges[i];
|
|
38
|
+
const b = edges[j];
|
|
39
|
+
// Only edges spanning the SAME pair of adjacent layers can cross in this model.
|
|
40
|
+
if (layerOf.get(a.source) !== layerOf.get(b.source))
|
|
41
|
+
continue;
|
|
42
|
+
if (layerOf.get(a.target) !== layerOf.get(b.target))
|
|
43
|
+
continue;
|
|
44
|
+
const as = pos.get(a.source);
|
|
45
|
+
const bs = pos.get(b.source);
|
|
46
|
+
const at = pos.get(a.target);
|
|
47
|
+
const bt = pos.get(b.target);
|
|
48
|
+
if ((as - bs) * (at - bt) < 0)
|
|
49
|
+
total++;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return total;
|
|
53
|
+
}
|
|
54
|
+
/** Mean position of `ids` in the previous ordering, or nothing when there are none. */
|
|
55
|
+
function barycenter(ids, pos) {
|
|
56
|
+
const known = ids.map((id) => pos.get(id)).filter((p) => p !== undefined);
|
|
57
|
+
if (known.length === 0)
|
|
58
|
+
return undefined;
|
|
59
|
+
return known.reduce((sum, p) => sum + p, 0) / known.length;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Re-sort one layer by the average position of each node's neighbours on `side`.
|
|
63
|
+
*
|
|
64
|
+
* A node with no neighbour on that side keeps its current index as its key, so it holds
|
|
65
|
+
* station instead of collapsing to one end — an unconstrained node drifting to position 0 is
|
|
66
|
+
* how a sweep makes a layout worse than the order it was given.
|
|
67
|
+
*/
|
|
68
|
+
function sweepLayer(layer, neighbors, pos) {
|
|
69
|
+
const keyed = layer.map((id, i) => ({
|
|
70
|
+
id,
|
|
71
|
+
key: barycenter(neighbors.get(id) ?? [], pos) ?? i,
|
|
72
|
+
i
|
|
73
|
+
}));
|
|
74
|
+
// Index breaks ties, so equal barycenters keep their relative order — a stable sort by
|
|
75
|
+
// hand, because the tie-break IS the determinism guarantee.
|
|
76
|
+
keyed.sort((a, b) => a.key - b.key || a.i - b.i);
|
|
77
|
+
return keyed.map((k) => k.id);
|
|
78
|
+
}
|
|
79
|
+
/** Positions within each layer, for the scoring pass and the next sweep. */
|
|
80
|
+
function positions(layers) {
|
|
81
|
+
const pos = new Map();
|
|
82
|
+
layers.forEach((layer) => layer.forEach((id, i) => pos.set(id, i)));
|
|
83
|
+
return pos;
|
|
84
|
+
}
|
|
85
|
+
function adjacency(edges, forward) {
|
|
86
|
+
const out = new Map();
|
|
87
|
+
for (const edge of edges) {
|
|
88
|
+
const [from, to] = forward ? [edge.target, edge.source] : [edge.source, edge.target];
|
|
89
|
+
const list = out.get(from);
|
|
90
|
+
if (list)
|
|
91
|
+
list.push(to);
|
|
92
|
+
else
|
|
93
|
+
out.set(from, [to]);
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Nodes grouped into their columns and ordered to reduce crossings.
|
|
99
|
+
*
|
|
100
|
+
* Layers start in model order — which normalisation fixed — so the starting point is stable
|
|
101
|
+
* and the result is reproducible.
|
|
102
|
+
*/
|
|
103
|
+
/** Nodes bucketed into their columns, in model order — the stable starting arrangement. */
|
|
104
|
+
function layersOf(model, ranks) {
|
|
105
|
+
const layers = [];
|
|
106
|
+
for (const node of model.nodes) {
|
|
107
|
+
const r = ranks.get(node.id) ?? 0;
|
|
108
|
+
(layers[r] ??= []).push(node.id);
|
|
109
|
+
}
|
|
110
|
+
// A rank with no nodes cannot come out of `rank()`, but an array hole would crash the
|
|
111
|
+
// sweep rather than lay out oddly, and the cost of not assuming is one filter.
|
|
112
|
+
return layers.filter((layer) => layer !== undefined);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* One sweep across every layer.
|
|
116
|
+
*
|
|
117
|
+
* Down means each layer follows the one to its left, up means the one to its right.
|
|
118
|
+
* Alternating is what lets an improvement propagate in both directions rather than piling up
|
|
119
|
+
* against whichever end the sweep started from.
|
|
120
|
+
*/
|
|
121
|
+
function sweep(layers, down, predecessors, successors) {
|
|
122
|
+
const next = layers.map((layer) => [...layer]);
|
|
123
|
+
const indices = down
|
|
124
|
+
? next.map((_, i) => i).slice(1)
|
|
125
|
+
: next
|
|
126
|
+
.map((_, i) => i)
|
|
127
|
+
.slice(0, -1)
|
|
128
|
+
.reverse();
|
|
129
|
+
for (const i of indices) {
|
|
130
|
+
next[i] = sweepLayer(next[i], down ? predecessors : successors, positions(next));
|
|
131
|
+
}
|
|
132
|
+
return next;
|
|
133
|
+
}
|
|
134
|
+
export function order(model, ranks, back) {
|
|
135
|
+
const filled = layersOf(model, ranks);
|
|
136
|
+
if (filled.length === 0)
|
|
137
|
+
return [];
|
|
138
|
+
const edges = drawn(model.edges, back, model.byId);
|
|
139
|
+
const predecessors = adjacency(edges, true);
|
|
140
|
+
const successors = adjacency(edges, false);
|
|
141
|
+
let best = filled;
|
|
142
|
+
let bestScore = countCrossings(filled, model, back);
|
|
143
|
+
let current = filled;
|
|
144
|
+
for (let pass = 0; pass < PASSES; pass++) {
|
|
145
|
+
current = sweep(current, pass % 2 === 0, predecessors, successors);
|
|
146
|
+
const score = countCrossings(current, model, back);
|
|
147
|
+
if (score < bestScore) {
|
|
148
|
+
bestScore = score;
|
|
149
|
+
best = current;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return best;
|
|
153
|
+
}
|
package/dist/layout/points.js
CHANGED
|
@@ -28,16 +28,20 @@
|
|
|
28
28
|
seed. The same model always produces the same picture, so it is unit-testable to exact
|
|
29
29
|
pixels and a re-render never reshuffles the diagram under the reader. */
|
|
30
30
|
import { buildEdges } from './edges.js';
|
|
31
|
+
import { degreeOf, measureOf, normalise, sizeFor } from './sizing.js';
|
|
32
|
+
import { warnUnknownOptions } from './options.js';
|
|
33
|
+
/* Node geometry — area, aspect and the measure curve now live in `sizing.ts`, shared with
|
|
34
|
+
`radial`, so the same node is the same size in both views. */
|
|
35
|
+
const NODE_GAP = 6;
|
|
31
36
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
37
|
+
* Vertical allowance under each node for its label.
|
|
38
|
+
*
|
|
39
|
+
* The label is drawn BELOW the dot and outside it — at these sizes there is no inside to put
|
|
40
|
+
* text in — so the shelf has to reserve for it or a 6px dot's 19px label lands in the row
|
|
41
|
+
* beneath. Reserved unconditionally: at the `minimal` and `dot` tiers the label is hidden and
|
|
42
|
+
* this is simply air, which costs a little density in exchange for never colliding.
|
|
35
43
|
*/
|
|
36
|
-
const
|
|
37
|
-
const MAX_AREA = Math.PI * 26 * 26;
|
|
38
|
-
/** Width:height. 2:1 reads as a "chip" and shelves well; a square wastes shelf height. */
|
|
39
|
-
const ASPECT = 2;
|
|
40
|
-
const NODE_GAP = 6;
|
|
44
|
+
const LABEL_H = 18;
|
|
41
45
|
const CLUSTER_PAD = 20;
|
|
42
46
|
/** Room for the group's own label above its first shelf. */
|
|
43
47
|
const CLUSTER_TITLE = 26;
|
|
@@ -49,24 +53,6 @@ const CANVAS_PAD = 60;
|
|
|
49
53
|
* grid of groups better than a tall column.
|
|
50
54
|
*/
|
|
51
55
|
const SHELF_ASPECT = 1.6;
|
|
52
|
-
function degreeOf(model) {
|
|
53
|
-
const degree = new Map();
|
|
54
|
-
for (const node of model.nodes)
|
|
55
|
-
degree.set(node.id, 0);
|
|
56
|
-
for (const edge of model.edges) {
|
|
57
|
-
degree.set(edge.source, (degree.get(edge.source) ?? 0) + 1);
|
|
58
|
-
if (edge.target !== edge.source) {
|
|
59
|
-
degree.set(edge.target, (degree.get(edge.target) ?? 0) + 1);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
return degree;
|
|
63
|
-
}
|
|
64
|
-
/** Size by degree. Area is linear in degree, so width grows as its square root. */
|
|
65
|
-
function sizeFor(degree, maxDegree) {
|
|
66
|
-
const t = maxDegree <= 0 ? 0 : degree / maxDegree;
|
|
67
|
-
const area = MIN_AREA + (MAX_AREA - MIN_AREA) * t;
|
|
68
|
-
return { w: Math.sqrt(area * ASPECT), h: Math.sqrt(area / ASPECT) };
|
|
69
|
-
}
|
|
70
56
|
/**
|
|
71
57
|
* Shelf-pack one group (first-fit decreasing height).
|
|
72
58
|
*
|
|
@@ -78,7 +64,7 @@ function sizeFor(degree, maxDegree) {
|
|
|
78
64
|
*/
|
|
79
65
|
function packGroup(members) {
|
|
80
66
|
const ordered = [...members].sort((a, b) => b.h - a.h || b.w - a.w || a.node.id.localeCompare(b.node.id));
|
|
81
|
-
const ink = ordered.reduce((sum, m) => sum + (m.w + NODE_GAP) * (m.h + NODE_GAP), 0);
|
|
67
|
+
const ink = ordered.reduce((sum, m) => sum + (m.w + NODE_GAP) * (m.h + LABEL_H + NODE_GAP), 0);
|
|
82
68
|
// At least one node wide, or a single oversized hub would wrap onto its own shelf forever.
|
|
83
69
|
const limit = Math.max(ordered[0].w, Math.sqrt(ink * SHELF_ASPECT));
|
|
84
70
|
const nodes = [];
|
|
@@ -94,7 +80,8 @@ function packGroup(members) {
|
|
|
94
80
|
}
|
|
95
81
|
nodes.push({ ...member, dx: x + member.w / 2, dy: y + member.h / 2 });
|
|
96
82
|
x += member.w + NODE_GAP;
|
|
97
|
-
|
|
83
|
+
// The SHELF is as tall as the node plus its label; the node itself is not.
|
|
84
|
+
shelfHeight = Math.max(shelfHeight, member.h + LABEL_H);
|
|
98
85
|
widest = Math.max(widest, x - NODE_GAP);
|
|
99
86
|
}
|
|
100
87
|
return { nodes, w: widest, h: y + shelfHeight };
|
|
@@ -135,17 +122,21 @@ function placeGroups(packs) {
|
|
|
135
122
|
});
|
|
136
123
|
return clusters;
|
|
137
124
|
}
|
|
138
|
-
/** Nodes bucketed by group, each carrying the size its
|
|
139
|
-
function sizeByGroup(model) {
|
|
125
|
+
/** Nodes bucketed by group, each carrying the size its measure earns it. */
|
|
126
|
+
function sizeByGroup(model, sizeBy, scale) {
|
|
140
127
|
const degree = degreeOf(model);
|
|
141
|
-
const
|
|
128
|
+
const measures = model.nodes.map((n) => measureOf(n, degree, sizeBy));
|
|
129
|
+
// Normalised against the range PRESENT, not against zero: a graph whose smallest module
|
|
130
|
+
// holds 400 declarations should still show its internal spread, not one flat block.
|
|
131
|
+
const min = Math.min(0, ...measures);
|
|
132
|
+
const max = Math.max(0, ...measures);
|
|
142
133
|
const byGroup = new Map();
|
|
143
|
-
|
|
134
|
+
model.nodes.forEach((node, i) => {
|
|
144
135
|
const key = node.group ?? '';
|
|
145
136
|
const list = byGroup.get(key) ?? [];
|
|
146
|
-
list.push({ node, ...sizeFor(
|
|
137
|
+
list.push({ node, ...sizeFor(normalise(measures[i], min, max, scale)) });
|
|
147
138
|
byGroup.set(key, list);
|
|
148
|
-
}
|
|
139
|
+
});
|
|
149
140
|
return byGroup;
|
|
150
141
|
}
|
|
151
142
|
/** One rect card for a placed node. `pos` holds the CENTRE; a card is positioned top-left. */
|
|
@@ -179,10 +170,11 @@ function buildDots(model, clusters, sizeOf) {
|
|
|
179
170
|
* `arrange` is ignored because placement is fully determined by degree and group.
|
|
180
171
|
*/
|
|
181
172
|
export const points = (model, _options) => {
|
|
173
|
+
warnUnknownOptions(_options, 'points');
|
|
182
174
|
if (model.nodes.length === 0) {
|
|
183
175
|
return { clusters: [], cards: {}, edges: [], size: { w: 0, h: 0 } };
|
|
184
176
|
}
|
|
185
|
-
const byGroup = sizeByGroup(model);
|
|
177
|
+
const byGroup = sizeByGroup(model, _options.sizeBy ?? 'degree', _options.sizeScale ?? 'linear');
|
|
186
178
|
const packs = [...byGroup.entries()].map(([name, members]) => ({
|
|
187
179
|
name,
|
|
188
180
|
pack: packGroup(members)
|