@rokkit/graph 1.6.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/LICENSE +21 -0
- package/README.md +100 -0
- package/dist/Graph.svelte +369 -0
- package/dist/Graph.svelte.d.ts +4 -0
- package/dist/GraphState.svelte.d.ts +171 -0
- package/dist/GraphState.svelte.js +398 -0
- package/dist/icons.d.ts +13 -0
- package/dist/icons.js +38 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +8 -0
- package/dist/layout/cards.d.ts +19 -0
- package/dist/layout/cards.js +31 -0
- package/dist/layout/cluster.d.ts +11 -0
- package/dist/layout/cluster.js +39 -0
- package/dist/layout/clusters.d.ts +39 -0
- package/dist/layout/clusters.js +217 -0
- package/dist/layout/constants.d.ts +12 -0
- package/dist/layout/constants.js +15 -0
- package/dist/layout/edges.d.ts +5 -0
- package/dist/layout/edges.js +110 -0
- package/dist/layout/index.d.ts +9 -0
- package/dist/layout/index.js +7 -0
- package/dist/layout/neighborhood.d.ts +9 -0
- package/dist/layout/neighborhood.js +123 -0
- package/dist/layout/nested.d.ts +13 -0
- package/dist/layout/nested.js +156 -0
- package/dist/layout/points.d.ts +6 -0
- package/dist/layout/points.js +204 -0
- package/dist/layout/types.d.ts +115 -0
- package/dist/layout/types.js +1 -0
- package/dist/model/normalize.d.ts +7 -0
- package/dist/model/normalize.js +215 -0
- package/dist/model/path.d.ts +13 -0
- package/dist/model/path.js +23 -0
- package/dist/preset.d.ts +47 -0
- package/dist/preset.js +61 -0
- package/dist/schema/EntitiesView.svelte +83 -0
- package/dist/schema/EntitiesView.svelte.d.ts +13 -0
- package/dist/schema/EntityView.svelte +116 -0
- package/dist/schema/EntityView.svelte.d.ts +13 -0
- package/dist/schema/NoteBlocks.svelte +31 -0
- package/dist/schema/NoteBlocks.svelte.d.ts +6 -0
- package/dist/schema/column-type.d.ts +4 -0
- package/dist/schema/column-type.js +19 -0
- package/dist/schema/fromSchemaModel.d.ts +66 -0
- package/dist/schema/fromSchemaModel.js +92 -0
- package/dist/schema/index.d.ts +8 -0
- package/dist/schema/index.js +7 -0
- package/dist/schema/notes.d.ts +12 -0
- package/dist/schema/notes.js +65 -0
- package/dist/types.d.ts +160 -0
- package/dist/types.js +1 -0
- package/package.json +62 -0
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { LayoutFn } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Rect layout for dense graphs. `density` is ignored on purpose — a dot has no rows — and
|
|
4
|
+
* `arrange` is ignored because placement is fully determined by degree and group.
|
|
5
|
+
*/
|
|
6
|
+
export declare const points: LayoutFn;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/* Dense-graph layout: every node is a sized rounded RECT, shelf-packed into its group.
|
|
2
|
+
|
|
3
|
+
Cards stop working long before the data does. Measured on a 1000-node call graph fitted
|
|
4
|
+
into 1440x900, the `cluster` layout produces an 1780x18090 canvas at scale 0.047 — a 248px
|
|
5
|
+
card renders 11.6px wide and a 12px label lands at 0.56px. The geometry is fine and the
|
|
6
|
+
maths is fast; the CARD is what fails.
|
|
7
|
+
|
|
8
|
+
So this layout drops the card. A node becomes a small rect sized by its degree, packed into
|
|
9
|
+
its group's box, and groups are laid out in a grid ordered by size. Reading shifts from
|
|
10
|
+
"what columns does this table have" to "which things are central, and which cluster do they
|
|
11
|
+
live in" — the question you actually ask of a code index.
|
|
12
|
+
|
|
13
|
+
WHY RECTS AND NOT DISCS. The first version spiral-packed circles, and it wasted most of its
|
|
14
|
+
canvas: measured on the 7-node service graph, a declared 607x595 world held only 362x364 of
|
|
15
|
+
actual dots — 36% fill. Two compounding causes, both intrinsic to the shape:
|
|
16
|
+
|
|
17
|
+
1. One spiral step, derived from the LARGEST dot present, applied to every ring. A group
|
|
18
|
+
holding one hub (r=26) and two leaves (r=5) stepped all three at 60.7px, giving a
|
|
19
|
+
238px disc for three dots.
|
|
20
|
+
2. A circular cluster throws away 1 - pi/4 = 21% of its bounding box before anything is
|
|
21
|
+
placed in it, and the canvas is made of bounding boxes.
|
|
22
|
+
|
|
23
|
+
Fit-to-container then frames all that emptiness, so the diagram opens zoomed further out
|
|
24
|
+
than its content warrants and still clips. Rects remove both causes: shelf packing has no
|
|
25
|
+
global step, and a rectangular group box is the area it occupies.
|
|
26
|
+
|
|
27
|
+
Deterministic and DOM-free like every other LayoutFn: no physics, no simulation, no random
|
|
28
|
+
seed. The same model always produces the same picture, so it is unit-testable to exact
|
|
29
|
+
pixels and a re-render never reshuffles the diagram under the reader. */
|
|
30
|
+
import { buildEdges } from './edges.js';
|
|
31
|
+
/**
|
|
32
|
+
* Node geometry. AREA — not width — tracks degree, so ten edges reads as ten rather than as
|
|
33
|
+
* a hundred. Areas are carried over from the disc version (pi*5^2 to pi*26^2) so the ink
|
|
34
|
+
* weight of a diagram is unchanged; only the shape and the packing are different.
|
|
35
|
+
*/
|
|
36
|
+
const MIN_AREA = Math.PI * 5 * 5;
|
|
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;
|
|
41
|
+
const CLUSTER_PAD = 20;
|
|
42
|
+
/** Room for the group's own label above its first shelf. */
|
|
43
|
+
const CLUSTER_TITLE = 26;
|
|
44
|
+
const CLUSTER_GAP = 40;
|
|
45
|
+
const CANVAS_PAD = 60;
|
|
46
|
+
/**
|
|
47
|
+
* How wide a group is allowed to get before it wraps, as a multiple of sqrt(total ink).
|
|
48
|
+
* Above 1 the group box is wider than tall, which suits a label above it and tiles into a
|
|
49
|
+
* grid of groups better than a tall column.
|
|
50
|
+
*/
|
|
51
|
+
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
|
+
/**
|
|
71
|
+
* Shelf-pack one group (first-fit decreasing height).
|
|
72
|
+
*
|
|
73
|
+
* Tallest first, so each shelf is set by its first member and later rows never have to grow —
|
|
74
|
+
* that is what keeps the wasted band under each node small. It also puts the hubs top-left,
|
|
75
|
+
* which is where the eye starts, matching the disc version's "hubs at the centre" intent.
|
|
76
|
+
*
|
|
77
|
+
* `dx`/`dy` are the node's CENTRE, the convention `buildDots` and the cluster `pos` share.
|
|
78
|
+
*/
|
|
79
|
+
function packGroup(members) {
|
|
80
|
+
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);
|
|
82
|
+
// At least one node wide, or a single oversized hub would wrap onto its own shelf forever.
|
|
83
|
+
const limit = Math.max(ordered[0].w, Math.sqrt(ink * SHELF_ASPECT));
|
|
84
|
+
const nodes = [];
|
|
85
|
+
let x = 0;
|
|
86
|
+
let y = 0;
|
|
87
|
+
let shelfHeight = 0;
|
|
88
|
+
let widest = 0;
|
|
89
|
+
for (const member of ordered) {
|
|
90
|
+
if (x > 0 && x + member.w > limit) {
|
|
91
|
+
y += shelfHeight + NODE_GAP;
|
|
92
|
+
x = 0;
|
|
93
|
+
shelfHeight = 0;
|
|
94
|
+
}
|
|
95
|
+
nodes.push({ ...member, dx: x + member.w / 2, dy: y + member.h / 2 });
|
|
96
|
+
x += member.w + NODE_GAP;
|
|
97
|
+
shelfHeight = Math.max(shelfHeight, member.h);
|
|
98
|
+
widest = Math.max(widest, x - NODE_GAP);
|
|
99
|
+
}
|
|
100
|
+
return { nodes, w: widest, h: y + shelfHeight };
|
|
101
|
+
}
|
|
102
|
+
function clusterFrom(entry, x, y) {
|
|
103
|
+
return {
|
|
104
|
+
name: entry.name,
|
|
105
|
+
list: entry.pack.nodes.map((n) => n.node),
|
|
106
|
+
count: entry.pack.nodes.length,
|
|
107
|
+
groupIndex: 0,
|
|
108
|
+
x,
|
|
109
|
+
y,
|
|
110
|
+
w: entry.pack.w + CLUSTER_PAD * 2,
|
|
111
|
+
h: entry.pack.h + CLUSTER_PAD * 2 + CLUSTER_TITLE,
|
|
112
|
+
pos: entry.pack.nodes.map((n) => ({
|
|
113
|
+
key: n.node.id,
|
|
114
|
+
dx: CLUSTER_PAD + n.dx,
|
|
115
|
+
dy: CLUSTER_PAD + CLUSTER_TITLE + n.dy
|
|
116
|
+
}))
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** Lay the packed groups out in a grid, biggest first, wrapping to keep the canvas squarish. */
|
|
120
|
+
function placeGroups(packs) {
|
|
121
|
+
const ordered = [...packs].sort((a, b) => b.pack.w * b.pack.h - a.pack.w * a.pack.h || a.name.localeCompare(b.name));
|
|
122
|
+
const columns = Math.max(1, Math.ceil(Math.sqrt(ordered.length)));
|
|
123
|
+
const clusters = [];
|
|
124
|
+
const cursor = { x: CANVAS_PAD, top: CANVAS_PAD, rowHeight: 0 };
|
|
125
|
+
ordered.forEach((entry, i) => {
|
|
126
|
+
if (i > 0 && i % columns === 0) {
|
|
127
|
+
cursor.x = CANVAS_PAD;
|
|
128
|
+
cursor.top += cursor.rowHeight + CLUSTER_GAP;
|
|
129
|
+
cursor.rowHeight = 0;
|
|
130
|
+
}
|
|
131
|
+
const cluster = clusterFrom(entry, cursor.x, cursor.top);
|
|
132
|
+
clusters.push(cluster);
|
|
133
|
+
cursor.x += (cluster.w ?? 0) + CLUSTER_GAP;
|
|
134
|
+
cursor.rowHeight = Math.max(cursor.rowHeight, cluster.h ?? 0);
|
|
135
|
+
});
|
|
136
|
+
return clusters;
|
|
137
|
+
}
|
|
138
|
+
/** Nodes bucketed by group, each carrying the size its degree earns it. */
|
|
139
|
+
function sizeByGroup(model) {
|
|
140
|
+
const degree = degreeOf(model);
|
|
141
|
+
const maxDegree = Math.max(0, ...degree.values());
|
|
142
|
+
const byGroup = new Map();
|
|
143
|
+
for (const node of model.nodes) {
|
|
144
|
+
const key = node.group ?? '';
|
|
145
|
+
const list = byGroup.get(key) ?? [];
|
|
146
|
+
list.push({ node, ...sizeFor(degree.get(node.id) ?? 0, maxDegree) });
|
|
147
|
+
byGroup.set(key, list);
|
|
148
|
+
}
|
|
149
|
+
return byGroup;
|
|
150
|
+
}
|
|
151
|
+
/** One rect card for a placed node. `pos` holds the CENTRE; a card is positioned top-left. */
|
|
152
|
+
function dotFor(node, cluster, p, size) {
|
|
153
|
+
const w = size?.w ?? 1;
|
|
154
|
+
const h = size?.h ?? 1;
|
|
155
|
+
return {
|
|
156
|
+
node,
|
|
157
|
+
vis: [],
|
|
158
|
+
more: node.rows.length,
|
|
159
|
+
w,
|
|
160
|
+
h,
|
|
161
|
+
x: cluster.x + p.dx - w / 2,
|
|
162
|
+
y: cluster.y + p.dy - h / 2,
|
|
163
|
+
groupIndex: cluster.groupIndex
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/** One rect card per placed node. */
|
|
167
|
+
function buildDots(model, clusters, sizeOf) {
|
|
168
|
+
const cards = {};
|
|
169
|
+
for (const cluster of clusters) {
|
|
170
|
+
for (const p of cluster.pos ?? []) {
|
|
171
|
+
const node = model.byId.get(p.key);
|
|
172
|
+
cards[p.key] = dotFor(node, cluster, p, sizeOf.get(p.key));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return cards;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Rect layout for dense graphs. `density` is ignored on purpose — a dot has no rows — and
|
|
179
|
+
* `arrange` is ignored because placement is fully determined by degree and group.
|
|
180
|
+
*/
|
|
181
|
+
export const points = (model, _options) => {
|
|
182
|
+
if (model.nodes.length === 0) {
|
|
183
|
+
return { clusters: [], cards: {}, edges: [], size: { w: 0, h: 0 } };
|
|
184
|
+
}
|
|
185
|
+
const byGroup = sizeByGroup(model);
|
|
186
|
+
const packs = [...byGroup.entries()].map(([name, members]) => ({
|
|
187
|
+
name,
|
|
188
|
+
pack: packGroup(members)
|
|
189
|
+
}));
|
|
190
|
+
const clusters = placeGroups(packs);
|
|
191
|
+
// Alphabetical group index so a group keeps its colour regardless of how the grid ordered
|
|
192
|
+
// it by size — the same stability rule resolveGroupStyles follows.
|
|
193
|
+
const alphabetical = [...byGroup.keys()].sort();
|
|
194
|
+
for (const cluster of clusters)
|
|
195
|
+
cluster.groupIndex = alphabetical.indexOf(cluster.name);
|
|
196
|
+
const sizeOf = new Map();
|
|
197
|
+
for (const { pack } of packs)
|
|
198
|
+
for (const n of pack.nodes)
|
|
199
|
+
sizeOf.set(n.node.id, n);
|
|
200
|
+
const cards = buildDots(model, clusters, sizeOf);
|
|
201
|
+
const width = Math.max(...clusters.map((c) => c.x + (c.w ?? 0))) + CANVAS_PAD;
|
|
202
|
+
const height = Math.max(...clusters.map((c) => c.y + (c.h ?? 0))) + CANVAS_PAD;
|
|
203
|
+
return { clusters, cards, edges: buildEdges(model.edges, cards), size: { w: width, h: height } };
|
|
204
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { GraphModel, GraphNode, GraphRow } from '../types.js';
|
|
2
|
+
/** How much of each node's row list a card shows. */
|
|
3
|
+
export type Density = 'names' | 'keys' | 'full';
|
|
4
|
+
/** Cluster and in-cluster ordering strategy. */
|
|
5
|
+
export type Arrange = 'untangle' | 'a-z';
|
|
6
|
+
export type EdgeStyle = 'curved' | 'orthogonal';
|
|
7
|
+
/** A positioned node card. */
|
|
8
|
+
export type Card = {
|
|
9
|
+
node: GraphNode;
|
|
10
|
+
/** The rows this card shows at the current density. */
|
|
11
|
+
vis: GraphRow[];
|
|
12
|
+
/** Count of rows hidden by the density. */
|
|
13
|
+
more: number;
|
|
14
|
+
w: number;
|
|
15
|
+
h: number;
|
|
16
|
+
x: number;
|
|
17
|
+
y: number;
|
|
18
|
+
/**
|
|
19
|
+
* Position of the card's group in the sorted group list. Replaces dbd's
|
|
20
|
+
* `hue` — colour now comes from CSS plus the preset, never from a
|
|
21
|
+
* hardcoded oklch angle baked into the layout.
|
|
22
|
+
*/
|
|
23
|
+
groupIndex?: number;
|
|
24
|
+
};
|
|
25
|
+
export type Cluster = {
|
|
26
|
+
name: string;
|
|
27
|
+
list: GraphNode[];
|
|
28
|
+
count: number;
|
|
29
|
+
groupIndex: number;
|
|
30
|
+
x: number;
|
|
31
|
+
y: number;
|
|
32
|
+
w?: number;
|
|
33
|
+
h?: number;
|
|
34
|
+
pos?: {
|
|
35
|
+
key: string;
|
|
36
|
+
dx: number;
|
|
37
|
+
dy: number;
|
|
38
|
+
}[];
|
|
39
|
+
/**
|
|
40
|
+
* 0 for an outer box, 1 for one nested inside it. Absent means the same as 0.
|
|
41
|
+
*
|
|
42
|
+
* Nesting needs no DOM tree: clusters are absolutely positioned boxes, so a flat list
|
|
43
|
+
* where the outer ones are larger and emitted FIRST renders correctly by paint order.
|
|
44
|
+
* Depth is what lets CSS tell the two apart — an inner box is a faint subdivision, not a
|
|
45
|
+
* second coloured region competing with the one containing it.
|
|
46
|
+
*/
|
|
47
|
+
depth?: number;
|
|
48
|
+
/** Name of the enclosing cluster, for a depth-1 box. */
|
|
49
|
+
parent?: string;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Which node property a layout groups on. Both already exist on `GraphNode`, so nesting
|
|
53
|
+
* needs no new model field — only a choice of which is outer.
|
|
54
|
+
*/
|
|
55
|
+
export type NodeAxis = 'group' | 'kind';
|
|
56
|
+
export type RoutedEdge = {
|
|
57
|
+
i: number;
|
|
58
|
+
/** GraphEdge.id */
|
|
59
|
+
id: string;
|
|
60
|
+
fromKey: string;
|
|
61
|
+
toKey: string;
|
|
62
|
+
kind: GraphModel['edges'][number]['kind'];
|
|
63
|
+
/** GraphEdge.relation — the consumer's own verb, carried through for theming. */
|
|
64
|
+
relation?: string;
|
|
65
|
+
self: boolean;
|
|
66
|
+
x1: number;
|
|
67
|
+
y1: number;
|
|
68
|
+
x2: number;
|
|
69
|
+
y2: number;
|
|
70
|
+
/** 1 = right side of the card, -1 = left. */
|
|
71
|
+
s1: number;
|
|
72
|
+
s2: number;
|
|
73
|
+
};
|
|
74
|
+
export type Cards = Record<string, Card>;
|
|
75
|
+
export type Size = {
|
|
76
|
+
w: number;
|
|
77
|
+
h: number;
|
|
78
|
+
};
|
|
79
|
+
export type LayoutOptions = {
|
|
80
|
+
density?: Density;
|
|
81
|
+
arrange?: Arrange;
|
|
82
|
+
edgeStyle?: EdgeStyle;
|
|
83
|
+
/** `neighborhood` only — the node the view centres on. */
|
|
84
|
+
focus?: string | null;
|
|
85
|
+
/**
|
|
86
|
+
* Node ids shown at FULL detail regardless of `density`, and uncapped.
|
|
87
|
+
* "+3 more" on a card is otherwise a statement with no corresponding action.
|
|
88
|
+
*/
|
|
89
|
+
expanded?: ReadonlySet<string>;
|
|
90
|
+
/**
|
|
91
|
+
* Outer grouping axis. Defaults to `group`, which is every existing caller's behaviour.
|
|
92
|
+
*
|
|
93
|
+
* Schema is the right axis for an ER diagram and a poor one for a dependency graph, where
|
|
94
|
+
* a single schema holds a table, a trigger and a procedure and the question is usually
|
|
95
|
+
* "what are the routines and what do they touch".
|
|
96
|
+
*/
|
|
97
|
+
groupBy?: NodeAxis;
|
|
98
|
+
/**
|
|
99
|
+
* Subdivide each outer cluster by this second axis. Unset (the default) means one level,
|
|
100
|
+
* and the layout runs its original single-level path untouched.
|
|
101
|
+
*/
|
|
102
|
+
nestBy?: NodeAxis;
|
|
103
|
+
};
|
|
104
|
+
export type LayoutResult = {
|
|
105
|
+
clusters: Cluster[];
|
|
106
|
+
cards: Cards;
|
|
107
|
+
edges: RoutedEdge[];
|
|
108
|
+
size: Size;
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* A layout is a pure function of the model and its options. DOM-free,
|
|
112
|
+
* synchronous, deterministic: card heights come from row counts, nothing is
|
|
113
|
+
* measured. That is what makes layouts unit-testable to exact pixels.
|
|
114
|
+
*/
|
|
115
|
+
export type LayoutFn = (model: GraphModel, options: LayoutOptions) => LayoutResult;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,7 @@
|
|
|
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
|
+
*/
|
|
7
|
+
export declare function normalizeGraph(nodes: unknown[], edges: unknown[], fields?: GraphFields): GraphModel;
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { readPath } from './path.js';
|
|
2
|
+
const BADGE_ORDER = ['pk', 'fk', 'uq', 'nn'];
|
|
3
|
+
function str(value) {
|
|
4
|
+
return typeof value === 'string' ? value : undefined;
|
|
5
|
+
}
|
|
6
|
+
function pick(row, path, fallbackKey) {
|
|
7
|
+
return readPath(row, path ?? fallbackKey);
|
|
8
|
+
}
|
|
9
|
+
function buildRows(source, fields) {
|
|
10
|
+
const raw = pick(source, fields.rows, 'rows');
|
|
11
|
+
if (!Array.isArray(raw))
|
|
12
|
+
return [];
|
|
13
|
+
const badgePaths = fields.rowBadges ?? {};
|
|
14
|
+
return raw.map((entry) => {
|
|
15
|
+
const badges = BADGE_ORDER.filter((badge) => {
|
|
16
|
+
const path = badgePaths[badge];
|
|
17
|
+
return path ? Boolean(readPath(entry, path)) : false;
|
|
18
|
+
});
|
|
19
|
+
return {
|
|
20
|
+
name: String(pick(entry, fields.rowName, 'name') ?? ''),
|
|
21
|
+
type: str(pick(entry, fields.rowType, 'type')),
|
|
22
|
+
badges,
|
|
23
|
+
note: str(pick(entry, fields.rowNote, 'note'))
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
// Every node-level field the canonical model reads. A claimed key must NOT also
|
|
28
|
+
// land in `meta`, or the same value sits in two places and the two can drift.
|
|
29
|
+
const CLAIMED_NODE_KEYS = ['id', 'label', 'group', 'kind', 'rows', 'note'];
|
|
30
|
+
function buildMeta(source, fields) {
|
|
31
|
+
if (source === null || typeof source !== 'object')
|
|
32
|
+
return {};
|
|
33
|
+
const claimed = new Set();
|
|
34
|
+
for (const key of CLAIMED_NODE_KEYS) {
|
|
35
|
+
// Only the FIRST segment is claimed: a map of `from.t` claims `from`.
|
|
36
|
+
claimed.add((fields[key] ?? key).split('.')[0]);
|
|
37
|
+
}
|
|
38
|
+
const meta = {};
|
|
39
|
+
for (const [key, value] of Object.entries(source)) {
|
|
40
|
+
if (!claimed.has(key))
|
|
41
|
+
meta[key] = value;
|
|
42
|
+
}
|
|
43
|
+
return meta;
|
|
44
|
+
}
|
|
45
|
+
function buildNode(source, fields) {
|
|
46
|
+
const label = String(pick(source, fields.label, 'label') ?? '');
|
|
47
|
+
const group = str(pick(source, fields.group, 'group'));
|
|
48
|
+
const explicitId = str(pick(source, fields.id, 'id'));
|
|
49
|
+
return {
|
|
50
|
+
id: explicitId ?? (group ? `${group}.${label}` : label),
|
|
51
|
+
label,
|
|
52
|
+
group,
|
|
53
|
+
kind: str(pick(source, fields.kind, 'kind')),
|
|
54
|
+
rows: buildRows(source, fields),
|
|
55
|
+
note: str(pick(source, fields.note, 'note')),
|
|
56
|
+
meta: buildMeta(source, fields)
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function resolveEndpoint(source, spec, byId) {
|
|
60
|
+
const raw = str(pick(source, spec.path, spec.fallbackKey));
|
|
61
|
+
if (raw === undefined)
|
|
62
|
+
return undefined;
|
|
63
|
+
if (byId.has(raw))
|
|
64
|
+
return raw;
|
|
65
|
+
const group = spec.groupPath ? str(readPath(source, spec.groupPath)) : undefined;
|
|
66
|
+
if (group && byId.has(`${group}.${raw}`))
|
|
67
|
+
return `${group}.${raw}`;
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Both endpoints, each flagged when the map could not resolve it to a node.
|
|
72
|
+
*
|
|
73
|
+
* Deliberately NOT "resolve or drop". Refusing to GUESS is still right — see resolveEndpoint
|
|
74
|
+
* — but discarding is a different decision, and a wrong one: an endpoint that cannot be
|
|
75
|
+
* placed is the normal case at scale, and dropping the edge hides that the relationship
|
|
76
|
+
* exists at all.
|
|
77
|
+
*/
|
|
78
|
+
function resolveEndpoints(source, fields, byId) {
|
|
79
|
+
const from = resolveEndpoint(source, {
|
|
80
|
+
path: fields.source,
|
|
81
|
+
fallbackKey: 'source',
|
|
82
|
+
groupPath: fields.sourceGroup ?? fields.group
|
|
83
|
+
}, byId);
|
|
84
|
+
const to = resolveEndpoint(source, {
|
|
85
|
+
path: fields.target,
|
|
86
|
+
fallbackKey: 'target',
|
|
87
|
+
groupPath: fields.targetGroup ?? fields.group
|
|
88
|
+
}, byId);
|
|
89
|
+
// The raw value is kept when it does not resolve, so an unplaced edge still carries the
|
|
90
|
+
// name written at the use site rather than becoming anonymous.
|
|
91
|
+
const rawFrom = str(pick(source, fields.source, 'source'));
|
|
92
|
+
const rawTo = str(pick(source, fields.target, 'target'));
|
|
93
|
+
if (rawFrom === undefined || rawTo === undefined)
|
|
94
|
+
return null;
|
|
95
|
+
return { from: from ?? rawFrom, to: to ?? rawTo, unplaced: whichUnplaced(from, to) };
|
|
96
|
+
}
|
|
97
|
+
/** Which end (if either) the map could not resolve. */
|
|
98
|
+
function whichUnplaced(from, to) {
|
|
99
|
+
if (from && to)
|
|
100
|
+
return undefined;
|
|
101
|
+
if (!from && !to)
|
|
102
|
+
return 'both';
|
|
103
|
+
return from ? 'target' : 'source';
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Makes `base` unique within one normalize pass.
|
|
107
|
+
*
|
|
108
|
+
* `kind` is already in `base`, and this counter breaks the remaining ties. Endpoints plus row
|
|
109
|
+
* names are NOT unique on their own: two dependency edges between the same pair (a procedure
|
|
110
|
+
* calling a function twice, a view reaching a table by two paths) carry no row anchors at all,
|
|
111
|
+
* so they would both key as `a:->b:`. A duplicate id silently breaks every id-keyed use —
|
|
112
|
+
* `{#each … as e (e.id)}` first among them.
|
|
113
|
+
*/
|
|
114
|
+
function uniqueEdgeId(base, seen) {
|
|
115
|
+
const seq = seen.get(base) ?? 0;
|
|
116
|
+
seen.set(base, seq + 1);
|
|
117
|
+
return seq === 0 ? base : `${base}#${seq}`;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The coarse edge kind: what the row declares, else the map's default, else a reference.
|
|
121
|
+
*
|
|
122
|
+
* Only the two canonical values are accepted from the data. A row carrying dbd v2's verb
|
|
123
|
+
* (`reads`) would otherwise fall through to `reference` and draw a view's dependency as a
|
|
124
|
+
* foreign key — which is why `defaultEdgeKind` exists for a list that is wholly one kind.
|
|
125
|
+
*/
|
|
126
|
+
function edgeKindOf(source, fields) {
|
|
127
|
+
const declared = str(pick(source, fields.edgeKind, 'kind'));
|
|
128
|
+
if (declared === 'dependency' || declared === 'reference')
|
|
129
|
+
return declared;
|
|
130
|
+
return fields.defaultEdgeKind ?? 'reference';
|
|
131
|
+
}
|
|
132
|
+
function buildEdge(source, fields, byId, seen) {
|
|
133
|
+
const endpoints = resolveEndpoints(source, fields, byId);
|
|
134
|
+
if (!endpoints)
|
|
135
|
+
return null;
|
|
136
|
+
const { from, to, unplaced } = endpoints;
|
|
137
|
+
const sourceRow = str(pick(source, fields.sourceRow, 'sourceRow'));
|
|
138
|
+
const targetRow = str(pick(source, fields.targetRow, 'targetRow'));
|
|
139
|
+
const kind = edgeKindOf(source, fields);
|
|
140
|
+
const relation = str(pick(source, fields.relation, 'relation'));
|
|
141
|
+
// `relation` joins the id because it is what separates otherwise-identical edges: a
|
|
142
|
+
// procedure that both reads AND writes one table produces two dep edges with the same
|
|
143
|
+
// endpoints and no row anchors, which would key identically and lose one to `{#each}`.
|
|
144
|
+
//
|
|
145
|
+
// Appended only when present, so every edge id a v1 consumer already has stays
|
|
146
|
+
// byte-identical — these are `{#each}` keys, and churning them relayouts for no reason.
|
|
147
|
+
const verb = relation ? `${kind}/${relation}` : kind;
|
|
148
|
+
const base = `${verb}:${from}:${sourceRow ?? ''}->${to}:${targetRow ?? ''}`;
|
|
149
|
+
return {
|
|
150
|
+
id: uniqueEdgeId(base, seen),
|
|
151
|
+
source: from,
|
|
152
|
+
target: to,
|
|
153
|
+
sourceRow,
|
|
154
|
+
targetRow,
|
|
155
|
+
kind,
|
|
156
|
+
relation,
|
|
157
|
+
cardinality: str(pick(source, fields.cardinality, 'cardinality')),
|
|
158
|
+
action: str(pick(source, fields.action, 'action')),
|
|
159
|
+
unplaced
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The source row an edge makes a foreign key, if any.
|
|
164
|
+
*
|
|
165
|
+
* An unplaced TARGET still qualifies — the reference is real, only its destination is
|
|
166
|
+
* unknown. An unplaced SOURCE has no row to mark.
|
|
167
|
+
*/
|
|
168
|
+
function foreignKeyRow(byId, edge) {
|
|
169
|
+
if (edge.unplaced === 'source' || edge.unplaced === 'both')
|
|
170
|
+
return undefined;
|
|
171
|
+
if (edge.kind !== 'reference' || !edge.sourceRow)
|
|
172
|
+
return undefined;
|
|
173
|
+
return byId.get(edge.source)?.rows.find((r) => r.name === edge.sourceRow);
|
|
174
|
+
}
|
|
175
|
+
/** Adds the derived `fk` badge to each reference edge's source row. */
|
|
176
|
+
function markForeignKeys(byId, edges) {
|
|
177
|
+
for (const edge of edges) {
|
|
178
|
+
const row = foreignKeyRow(byId, edge);
|
|
179
|
+
if (row && !row.badges.includes('fk')) {
|
|
180
|
+
row.badges = BADGE_ORDER.filter((b) => b === 'fk' || row.badges.includes(b));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function buildNeighbors(edges) {
|
|
185
|
+
const neighbors = new Map();
|
|
186
|
+
const link = (a, b) => {
|
|
187
|
+
const set = neighbors.get(a) ?? new Set();
|
|
188
|
+
set.add(b);
|
|
189
|
+
neighbors.set(a, set);
|
|
190
|
+
};
|
|
191
|
+
for (const edge of edges) {
|
|
192
|
+
// An unplaced endpoint is not a node, so it cannot be a neighbour. `related` and the
|
|
193
|
+
// dim/highlight states must only ever name things the reader can actually click.
|
|
194
|
+
if (edge.unplaced || edge.source === edge.target)
|
|
195
|
+
continue;
|
|
196
|
+
link(edge.source, edge.target);
|
|
197
|
+
link(edge.target, edge.source);
|
|
198
|
+
}
|
|
199
|
+
return neighbors;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Resolves a consumer's `nodes`/`edges`/`fields` into the canonical model — ONCE.
|
|
203
|
+
* Everything downstream (layouts, routing, components) sees concrete types, so no
|
|
204
|
+
* field mapping leaks past this function.
|
|
205
|
+
*/
|
|
206
|
+
export function normalizeGraph(nodes, edges, fields = {}) {
|
|
207
|
+
const built = nodes.map((source) => buildNode(source, fields));
|
|
208
|
+
const byId = new Map(built.map((node) => [node.id, node]));
|
|
209
|
+
const seen = new Map();
|
|
210
|
+
const resolved = edges
|
|
211
|
+
.map((source) => buildEdge(source, fields, byId, seen))
|
|
212
|
+
.filter((edge) => edge !== null);
|
|
213
|
+
markForeignKeys(byId, resolved);
|
|
214
|
+
return { nodes: built, edges: resolved, byId, neighbors: buildNeighbors(resolved) };
|
|
215
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads a dotted field path off a source row.
|
|
3
|
+
*
|
|
4
|
+
* Field maps name object paths only. Arrays are rejected on purpose: a map like
|
|
5
|
+
* `rows.0.name` would break the moment the data reorders, so it is better to
|
|
6
|
+
* return undefined than to make it appear to work.
|
|
7
|
+
*
|
|
8
|
+
* Only OWN properties are read. The map is consumer-supplied and the rows are
|
|
9
|
+
* consumer data, so a path naming `constructor` or `toString` must read as
|
|
10
|
+
* absent — walking the prototype chain would resolve `constructor.name` to
|
|
11
|
+
* 'Object' and hand a layout a label no row actually carries.
|
|
12
|
+
*/
|
|
13
|
+
export declare function readPath(row: unknown, path: string): unknown;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads a dotted field path off a source row.
|
|
3
|
+
*
|
|
4
|
+
* Field maps name object paths only. Arrays are rejected on purpose: a map like
|
|
5
|
+
* `rows.0.name` would break the moment the data reorders, so it is better to
|
|
6
|
+
* return undefined than to make it appear to work.
|
|
7
|
+
*
|
|
8
|
+
* Only OWN properties are read. The map is consumer-supplied and the rows are
|
|
9
|
+
* consumer data, so a path naming `constructor` or `toString` must read as
|
|
10
|
+
* absent — walking the prototype chain would resolve `constructor.name` to
|
|
11
|
+
* 'Object' and hand a layout a label no row actually carries.
|
|
12
|
+
*/
|
|
13
|
+
export function readPath(row, path) {
|
|
14
|
+
let current = row;
|
|
15
|
+
for (const segment of path.split('.')) {
|
|
16
|
+
if (current === null || typeof current !== 'object' || Array.isArray(current))
|
|
17
|
+
return undefined;
|
|
18
|
+
if (!Object.hasOwn(current, segment))
|
|
19
|
+
return undefined;
|
|
20
|
+
current = current[segment];
|
|
21
|
+
}
|
|
22
|
+
return current;
|
|
23
|
+
}
|
package/dist/preset.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How open-ended groups are differentiated.
|
|
3
|
+
*
|
|
4
|
+
* `pattern` is the colour-blind- and print-safe path and reuses chart's patterns.js.
|
|
5
|
+
* A `symbol` channel is deliberately NOT here in slice 1: the preset could emit a name, but
|
|
6
|
+
* nothing renders a glyph (chart's Shape.svelte stays in chart), so it would be a documented
|
|
7
|
+
* API that silently does nothing. It returns when it has a renderer.
|
|
8
|
+
*/
|
|
9
|
+
export type GraphChannel = 'color' | 'pattern';
|
|
10
|
+
export type GraphShades = {
|
|
11
|
+
fill: string;
|
|
12
|
+
stroke: string;
|
|
13
|
+
label: string;
|
|
14
|
+
};
|
|
15
|
+
export type GraphPreset = {
|
|
16
|
+
/** Ordered ramp assigned to open-ended group names, wrapping when exhausted. */
|
|
17
|
+
groups: string[];
|
|
18
|
+
shades: {
|
|
19
|
+
light: GraphShades;
|
|
20
|
+
dark: GraphShades;
|
|
21
|
+
};
|
|
22
|
+
patterns: string[];
|
|
23
|
+
using: GraphChannel;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Mirrors `createChartPreset`'s shape on purpose — a consumer who has met one
|
|
27
|
+
* has met both. Shade picks differ from chart's because a cluster is a large
|
|
28
|
+
* background area rather than a small mark.
|
|
29
|
+
*
|
|
30
|
+
* The `groups` ramp and the `patterns` list are chart's own leading entries, in
|
|
31
|
+
* chart's order, so a graph and a chart on one page differentiate alike.
|
|
32
|
+
*/
|
|
33
|
+
export declare const defaultGraphPreset: GraphPreset;
|
|
34
|
+
export declare function createGraphPreset(overrides?: Partial<GraphPreset>): GraphPreset;
|
|
35
|
+
/**
|
|
36
|
+
* Resolves group names to CSS custom properties.
|
|
37
|
+
*
|
|
38
|
+
* Returns custom properties rather than concrete fills so a theme or an app can
|
|
39
|
+
* still override with one attribute rule — the JS decides the DEFAULT, never the
|
|
40
|
+
* final paint.
|
|
41
|
+
*
|
|
42
|
+
* Assignment is by SORTED group name so a group keeps its colour when `arrange`
|
|
43
|
+
* reorders the layout. Callers pass one name per NODE, so names are de-duplicated
|
|
44
|
+
* first; otherwise the ramp would advance per node and two nodes in the same group
|
|
45
|
+
* would disagree.
|
|
46
|
+
*/
|
|
47
|
+
export declare function resolveGroupStyles(groups: string[], mode: 'light' | 'dark', preset?: GraphPreset): Map<string, Record<string, string>>;
|