@rokkit/graph 1.8.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 +59 -0
- package/dist/Graph.svelte +108 -134
- 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 +161 -52
- package/dist/GraphState.svelte.js +380 -304
- 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 +11 -32
- 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 +10 -3
- package/dist/index.js +9 -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 +26 -2
- 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 +12 -0
- package/dist/layout/matrix.js +20 -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 +79 -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 +64 -2
- 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 +72 -1
- package/package.json +3 -2
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
|
|
@@ -130,6 +154,22 @@ export type RoutedEdge = {
|
|
|
130
154
|
overlay?: boolean;
|
|
131
155
|
/** GraphEdge.weight, raw. `GraphState.edgeWeight` normalises it for drawing. */
|
|
132
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;
|
|
133
173
|
x1: number;
|
|
134
174
|
y1: number;
|
|
135
175
|
x2: number;
|
|
@@ -243,8 +283,47 @@ export type LayoutOptions = {
|
|
|
243
283
|
* highlight it would silently re-root the diagram.
|
|
244
284
|
*/
|
|
245
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;
|
|
246
319
|
};
|
|
247
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
|
+
};
|
|
248
327
|
clusters: Cluster[];
|
|
249
328
|
cards: Cards;
|
|
250
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[];
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** One row per node for an entity list: its rows and the structural edges touching it. */
|
|
2
|
+
export function entityRows(model) {
|
|
3
|
+
return model.nodes.map((node) => ({
|
|
4
|
+
id: node.id,
|
|
5
|
+
label: node.label,
|
|
6
|
+
group: node.group,
|
|
7
|
+
kind: node.kind,
|
|
8
|
+
rowCount: node.rows.length,
|
|
9
|
+
refCount: model.edges.filter((e) => e.source === node.id || e.target === node.id).length,
|
|
10
|
+
note: node.note
|
|
11
|
+
}));
|
|
12
|
+
}
|
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
import type { GraphFields, GraphModel } from '../types.js';
|
|
2
|
-
|
|
3
|
-
* Resolves a consumer's `nodes`/`edges`/`fields` into the canonical model — ONCE.
|
|
4
|
-
* Everything downstream (layouts, routing, components) sees concrete types, so no
|
|
5
|
-
* field mapping leaks past this function.
|
|
6
|
-
*/
|
|
1
|
+
import type { GraphEdge, GraphFields, GraphModel } from '../types.js';
|
|
2
|
+
export declare function buildNeighbors(edges: GraphEdge[]): Map<string, Set<string>>;
|
|
7
3
|
export declare function normalizeGraph(nodes: unknown[], edges: unknown[], fields?: GraphFields): GraphModel;
|
package/dist/model/normalize.js
CHANGED
|
@@ -68,7 +68,11 @@ const CLAIMED_NODE_KEYS = [
|
|
|
68
68
|
'path',
|
|
69
69
|
'measures',
|
|
70
70
|
'rows',
|
|
71
|
-
'note'
|
|
71
|
+
'note',
|
|
72
|
+
'members',
|
|
73
|
+
'collapsed',
|
|
74
|
+
'layer',
|
|
75
|
+
'parent'
|
|
72
76
|
];
|
|
73
77
|
function buildMeta(source, fields) {
|
|
74
78
|
if (source === null || typeof source !== 'object')
|
|
@@ -99,9 +103,27 @@ function buildNode(source, fields) {
|
|
|
99
103
|
measures: measures(pick(source, fields.measures, 'measures')),
|
|
100
104
|
rows: buildRows(source, fields),
|
|
101
105
|
note: str(pick(source, fields.note, 'note')),
|
|
106
|
+
...nodeExtras(source, fields),
|
|
102
107
|
meta: buildMeta(source, fields)
|
|
103
108
|
};
|
|
104
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* `members` / `collapsed` (#166) and `layer` (#167), added only when present — like the edge
|
|
112
|
+
* extras, so every node a consumer already has stays key-for-key identical.
|
|
113
|
+
*/
|
|
114
|
+
function nodeExtras(source, fields) {
|
|
115
|
+
const extras = {};
|
|
116
|
+
const layer = num(pick(source, fields.layer, 'layer'));
|
|
117
|
+
if (layer !== undefined)
|
|
118
|
+
extras.layer = layer;
|
|
119
|
+
const members = pick(source, fields.members, 'members');
|
|
120
|
+
if (Array.isArray(members))
|
|
121
|
+
extras.members = members.map(String);
|
|
122
|
+
const collapsed = pick(source, fields.collapsed, 'collapsed');
|
|
123
|
+
if (typeof collapsed === 'boolean')
|
|
124
|
+
extras.collapsed = collapsed;
|
|
125
|
+
return extras;
|
|
126
|
+
}
|
|
105
127
|
function resolveEndpoint(source, spec, byId) {
|
|
106
128
|
const raw = str(pick(source, spec.path, spec.fallbackKey));
|
|
107
129
|
if (raw === undefined)
|
|
@@ -210,10 +232,19 @@ function buildEdge(source, fields, byId, seen) {
|
|
|
210
232
|
* `overlay` and `weight`, added only when present so every edge a v1 consumer already has
|
|
211
233
|
* stays key-for-key identical — `toEqual` on an edge is a contract downstream specs rely on.
|
|
212
234
|
*/
|
|
235
|
+
const CONFORMANCE = new Set(['down', 'skip', 'up', 'level']);
|
|
213
236
|
function edgeExtras(source, fields) {
|
|
214
237
|
const extras = {};
|
|
238
|
+
// Only the vocabulary — an unknown word would be a style hook no theme knows.
|
|
239
|
+
const conformance = str(pick(source, fields.conformance, 'conformance'));
|
|
240
|
+
if (conformance && CONFORMANCE.has(conformance))
|
|
241
|
+
extras.conformance = conformance;
|
|
215
242
|
if (pick(source, fields.overlay, 'overlay'))
|
|
216
243
|
extras.overlay = true;
|
|
244
|
+
if (pick(source, fields.weakest, 'weakest'))
|
|
245
|
+
extras.weakest = true;
|
|
246
|
+
if (pick(source, fields.hidden, 'hidden'))
|
|
247
|
+
extras.hidden = true;
|
|
217
248
|
const weight = num(pick(source, fields.edgeWeight, 'weight'));
|
|
218
249
|
if (weight !== undefined)
|
|
219
250
|
extras.weight = weight;
|
|
@@ -241,7 +272,7 @@ function markForeignKeys(byId, edges) {
|
|
|
241
272
|
}
|
|
242
273
|
}
|
|
243
274
|
}
|
|
244
|
-
function buildNeighbors(edges) {
|
|
275
|
+
export function buildNeighbors(edges) {
|
|
245
276
|
const neighbors = new Map();
|
|
246
277
|
const link = (a, b) => {
|
|
247
278
|
const set = neighbors.get(a) ?? new Set();
|
|
@@ -263,8 +294,39 @@ function buildNeighbors(edges) {
|
|
|
263
294
|
* Everything downstream (layouts, routing, components) sees concrete types, so no
|
|
264
295
|
* field mapping leaks past this function.
|
|
265
296
|
*/
|
|
297
|
+
/** A node's ancestry as ids, root first and itself last — cut where a cycle closes. */
|
|
298
|
+
function chainOf(id, parentOf) {
|
|
299
|
+
const chain = [id];
|
|
300
|
+
const seen = new Set(chain);
|
|
301
|
+
let parent = parentOf.get(id);
|
|
302
|
+
while (parent !== undefined && !seen.has(parent)) {
|
|
303
|
+
chain.unshift(parent);
|
|
304
|
+
seen.add(parent);
|
|
305
|
+
// A parent that is not a node ends the walk: it is kept as the container it names.
|
|
306
|
+
parent = parentOf.get(parent);
|
|
307
|
+
}
|
|
308
|
+
return chain;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Containment given as `parent` ids (#168) — the shape many hosts send — becomes a `path`.
|
|
312
|
+
*
|
|
313
|
+
* Only for a node with no `path` of its own, and only where the parent relation is in play: a
|
|
314
|
+
* node that HAS a parent, or that another node names as one (so a root still heads the paths
|
|
315
|
+
* of its children). Segments are node ids, since labels repeat across branches.
|
|
316
|
+
*/
|
|
317
|
+
function pathsFromParents(built, sources, fields) {
|
|
318
|
+
const parentOf = new Map();
|
|
319
|
+
built.forEach((node, i) => parentOf.set(node.id, str(pick(sources[i], fields.parent, 'parent'))));
|
|
320
|
+
const named = new Set([...parentOf.values()].filter((p) => p !== undefined));
|
|
321
|
+
for (const node of built) {
|
|
322
|
+
if (node.path || (parentOf.get(node.id) === undefined && !named.has(node.id)))
|
|
323
|
+
continue;
|
|
324
|
+
node.path = chainOf(node.id, parentOf);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
266
327
|
export function normalizeGraph(nodes, edges, fields = {}) {
|
|
267
328
|
const built = nodes.map((source) => buildNode(source, fields));
|
|
329
|
+
pathsFromParents(built, nodes, fields);
|
|
268
330
|
const byId = new Map(built.map((node) => [node.id, node]));
|
|
269
331
|
const seen = new Map();
|
|
270
332
|
const resolved = edges
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { RoutedEdge } from '../layout/types.js';
|
|
2
|
+
import type { GraphEdge, GraphModel } from '../types.js';
|
|
3
|
+
export type Relationship = {
|
|
4
|
+
direction: 'in' | 'out';
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
group?: string;
|
|
8
|
+
/** The canonical edge — always present. A relationship is a fact about the model. */
|
|
9
|
+
edge: GraphEdge;
|
|
10
|
+
/** Routed geometry, present only when the ACTIVE layout placed this edge. */
|
|
11
|
+
routed?: RoutedEdge;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* The selected node's relationships — every edge touching it, in model order.
|
|
15
|
+
*
|
|
16
|
+
* Read from `model.edges`, the canonical unfiltered model, NOT from the layout's edges. A layout
|
|
17
|
+
* filters: `cluster` drops edges whose endpoints were not laid out, and `neighborhood` lays out
|
|
18
|
+
* only the focus's neighbourhood. Since `focus` and `value` are independent, reading the
|
|
19
|
+
* layout's edges lets the state contradict itself: with `focus: 'audit.log'` (no neighbours) and
|
|
20
|
+
* `value: 'public.orders'`, `relationships` would be `[]` while the entity row's `refCount` for
|
|
21
|
+
* the same node is 1.
|
|
22
|
+
*
|
|
23
|
+
* Routed geometry is attached opportunistically — only for edges the active layout placed. A
|
|
24
|
+
* relationship is a fact about the model; its geometry is a fact about the current view.
|
|
25
|
+
*
|
|
26
|
+
* An UNPLACED far end is reported too, with the raw name as the label — it is not a node, so
|
|
27
|
+
* there is nothing to look up. Hiding it would be worse: "this references something we have not
|
|
28
|
+
* indexed" is exactly what a reader of a partial graph needs to see.
|
|
29
|
+
*/
|
|
30
|
+
export declare function relationshipsOf(model: GraphModel, id: string | null, routedEdges: RoutedEdge[]): Relationship[];
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** How `id` sits on an edge: not on it, both ends of it, or one end with a far side. */
|
|
2
|
+
function endpointsFor(edge, id) {
|
|
3
|
+
const isSource = edge.source === id;
|
|
4
|
+
const isTarget = edge.target === id;
|
|
5
|
+
if (!isSource && !isTarget)
|
|
6
|
+
return null;
|
|
7
|
+
return {
|
|
8
|
+
self: isSource && isTarget,
|
|
9
|
+
other: isSource ? edge.target : edge.source,
|
|
10
|
+
direction: isSource ? 'out' : 'in'
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The selected node's relationships — every edge touching it, in model order.
|
|
15
|
+
*
|
|
16
|
+
* Read from `model.edges`, the canonical unfiltered model, NOT from the layout's edges. A layout
|
|
17
|
+
* filters: `cluster` drops edges whose endpoints were not laid out, and `neighborhood` lays out
|
|
18
|
+
* only the focus's neighbourhood. Since `focus` and `value` are independent, reading the
|
|
19
|
+
* layout's edges lets the state contradict itself: with `focus: 'audit.log'` (no neighbours) and
|
|
20
|
+
* `value: 'public.orders'`, `relationships` would be `[]` while the entity row's `refCount` for
|
|
21
|
+
* the same node is 1.
|
|
22
|
+
*
|
|
23
|
+
* Routed geometry is attached opportunistically — only for edges the active layout placed. A
|
|
24
|
+
* relationship is a fact about the model; its geometry is a fact about the current view.
|
|
25
|
+
*
|
|
26
|
+
* An UNPLACED far end is reported too, with the raw name as the label — it is not a node, so
|
|
27
|
+
* there is nothing to look up. Hiding it would be worse: "this references something we have not
|
|
28
|
+
* indexed" is exactly what a reader of a partial graph needs to see.
|
|
29
|
+
*/
|
|
30
|
+
export function relationshipsOf(model, id, routedEdges) {
|
|
31
|
+
if (!id)
|
|
32
|
+
return [];
|
|
33
|
+
// A plain Map on purpose: a call-local lookup, discarded when the caller re-derives.
|
|
34
|
+
const routed = new Map(routedEdges.map((e) => [e.id, e]));
|
|
35
|
+
const describe = (other, direction, edge) => {
|
|
36
|
+
// normalizeGraph drops any edge whose endpoints do not resolve, so both ends of every
|
|
37
|
+
// edge in model.edges are in byId. No `?? other` fallback: an unreachable branch cannot
|
|
38
|
+
// be tested, and the package's 100% statement bar exists to keep that honest.
|
|
39
|
+
const node = model.byId.get(other);
|
|
40
|
+
return { direction, id: other, label: node.label, group: node.group, edge, routed: routed.get(edge.id) };
|
|
41
|
+
};
|
|
42
|
+
return model.edges.flatMap((edge) => {
|
|
43
|
+
const ends = endpointsFor(edge, id);
|
|
44
|
+
if (!ends)
|
|
45
|
+
return [];
|
|
46
|
+
if (ends.self)
|
|
47
|
+
return [describe(id, 'out', edge)];
|
|
48
|
+
const { other, direction } = ends;
|
|
49
|
+
return edge.unplaced ? [{ direction, id: other, label: other, edge }] : [describe(other, direction, edge)];
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { TreeNode } from './tree.js';
|
|
2
|
+
import type { Cluster } from '../layout/types.js';
|
|
3
|
+
/** Every box's share of `measure`, by tree id. A box with none is absent from the map. */
|
|
4
|
+
export declare function shares(root: TreeNode, measure: string): Map<string, number>;
|
|
5
|
+
/**
|
|
6
|
+
* What a box carries for the shade channel: its share, or — when shading is on and it has
|
|
7
|
+
* none — a mark that the value is missing, so the theme never paints it as a zero.
|
|
8
|
+
*/
|
|
9
|
+
export declare function shadeFields(all: Map<string, number> | undefined, id: string): Pick<Cluster, 'shade' | 'missing'>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const clamp = (value) => Math.min(1, Math.max(0, value));
|
|
2
|
+
/** The box's own share, when its node declares one. */
|
|
3
|
+
const ownShare = (box, measure) => {
|
|
4
|
+
const value = box.node?.measures?.[measure];
|
|
5
|
+
return value === undefined ? undefined : clamp(value);
|
|
6
|
+
};
|
|
7
|
+
/** The mean of the children's shares, weighted by their size; a plain mean if none has size. */
|
|
8
|
+
function meanOf(children) {
|
|
9
|
+
if (children.length === 0)
|
|
10
|
+
return undefined;
|
|
11
|
+
const weight = children.reduce((total, child) => total + Math.max(0, child.size), 0);
|
|
12
|
+
if (weight === 0)
|
|
13
|
+
return children.reduce((total, child) => total + child.share, 0) / children.length;
|
|
14
|
+
return children.reduce((total, child) => total + child.share * Math.max(0, child.size), 0) / weight;
|
|
15
|
+
}
|
|
16
|
+
function visit(box, measure, out) {
|
|
17
|
+
const measured = [];
|
|
18
|
+
for (const child of box.children) {
|
|
19
|
+
const share = visit(child, measure, out);
|
|
20
|
+
if (share !== undefined)
|
|
21
|
+
measured.push({ share, size: child.value });
|
|
22
|
+
}
|
|
23
|
+
const share = ownShare(box, measure) ?? meanOf(measured);
|
|
24
|
+
if (share !== undefined)
|
|
25
|
+
out.set(box.id, share);
|
|
26
|
+
return share;
|
|
27
|
+
}
|
|
28
|
+
/** Every box's share of `measure`, by tree id. A box with none is absent from the map. */
|
|
29
|
+
export function shares(root, measure) {
|
|
30
|
+
const out = new Map();
|
|
31
|
+
visit(root, measure, out);
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* What a box carries for the shade channel: its share, or — when shading is on and it has
|
|
36
|
+
* none — a mark that the value is missing, so the theme never paints it as a zero.
|
|
37
|
+
*/
|
|
38
|
+
export function shadeFields(all, id) {
|
|
39
|
+
if (!all)
|
|
40
|
+
return {};
|
|
41
|
+
const share = all.get(id);
|
|
42
|
+
return share === undefined ? { missing: ['color'] } : { shade: share };
|
|
43
|
+
}
|