@reddb-io/ui 0.1.1 → 0.2.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/build/_app/immutable/assets/0.DQDLkSdR.css +1 -0
- package/build/_app/immutable/assets/Workspace.CvP5De7i.css +1 -0
- package/build/_app/immutable/chunks/65XjEwaF.js +367 -0
- package/build/_app/immutable/chunks/{Ov8N-vxt.js → BHBXmd1Y.js} +1 -1
- package/build/_app/immutable/chunks/BMc_qoms.js +973 -0
- package/build/_app/immutable/chunks/{ALqaDrNQ.js → C278awGv.js} +1 -1
- package/build/_app/immutable/chunks/CDKIdLnm.js +187 -0
- package/build/_app/immutable/chunks/{DXY_Rxa7.js → CXNPJgv1.js} +7 -7
- package/build/_app/immutable/chunks/{BMHKbvs8.js → CwxFwvow.js} +1 -1
- package/build/_app/immutable/chunks/{Bgso8cDH.js → DqvWKbSc.js} +1 -1
- package/build/_app/immutable/entry/app.CGfmRH-N.js +2 -0
- package/build/_app/immutable/entry/start._LhFJimm.js +1 -0
- package/build/_app/immutable/nodes/{0.CHPiFxDo.js → 0.BYhY6O5-.js} +1 -1
- package/build/_app/immutable/nodes/{1.BC_4YZ0h.js → 1.4XYGRMul.js} +1 -1
- package/build/_app/immutable/nodes/11.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/{10.B7DMKSNc.js → 12.DXZfNPNK.js} +5 -5
- package/build/_app/immutable/nodes/7.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/{7.BZX9Iird.js → 8.C7OOsMOJ.js} +1 -1
- package/build/_app/version.json +1 -1
- package/build/index.html +12 -12
- package/dist/embed/{RedUiEmbedRoot-BGoLxJf1.js → RedUiEmbedRoot-Dia9lxfb.js} +12622 -10614
- package/dist/embed/RedUiEmbedRoot-Dia9lxfb.js.map +1 -0
- package/dist/embed/index-Bq0TT7Ao.js +577 -0
- package/dist/embed/{index-B6ZQhCsu.js.map → index-Bq0TT7Ao.js.map} +1 -1
- package/dist/embed/{index-fad77a13.esm-BqPj0saH.js → index-fad77a13.esm-BMULHdcE.js} +22 -20
- package/dist/embed/{index-fad77a13.esm-BqPj0saH.js.map → index-fad77a13.esm-BMULHdcE.js.map} +1 -1
- package/dist/embed/index.js +1 -1
- package/dist/embed/sigma-edge-curve.esm-CAT5Xrom.js +536 -0
- package/dist/embed/sigma-edge-curve.esm-CAT5Xrom.js.map +1 -0
- package/dist/embed/{sigma-node-border.esm-CGtlgqmo.js → sigma-node-border.esm-Bnk4BRbw.js} +32 -32
- package/dist/embed/{sigma-node-border.esm-CGtlgqmo.js.map → sigma-node-border.esm-Bnk4BRbw.js.map} +1 -1
- package/dist/embed/{sigma.esm-y0qgY-OC.js → sigma.esm-C2pqg5JH.js} +3 -3
- package/dist/embed/{sigma.esm-y0qgY-OC.js.map → sigma.esm-C2pqg5JH.js.map} +1 -1
- package/package.json +5 -3
- package/src/lib/Appearance.svelte +90 -0
- package/src/lib/CommandPalette.svelte +2 -0
- package/src/lib/SettingsView.svelte +322 -0
- package/src/lib/Topbar.svelte +15 -1
- package/src/lib/Workspace.svelte +14 -1
- package/src/lib/index.ts +2 -0
- package/src/lib/renderers/GraphRenderer.svelte +448 -44
- package/src/lib/renderers/graph-bundle.ts +401 -0
- package/src/lib/renderers/graph-communities.ts +193 -0
- package/src/lib/renderers/graph-render.ts +48 -5
- package/src/lib/renderers/graph-sigma.ts +274 -8
- package/src/lib/router.svelte.ts +84 -57
- package/src/lib/settings-sections.ts +249 -0
- package/src/lib/skins/skins.svelte.ts +64 -0
- package/src/lib/skins/skins.ts +178 -0
- package/build/_app/immutable/assets/0.C63jY5jX.css +0 -1
- package/build/_app/immutable/assets/Workspace.BCzG1DsH.css +0 -1
- package/build/_app/immutable/chunks/BQHbwf54.js +0 -916
- package/build/_app/immutable/chunks/Bmgx1mfv.js +0 -310
- package/build/_app/immutable/entry/app.D_IVtpdv.js +0 -2
- package/build/_app/immutable/entry/start.3oC0UQ_Q.js +0 -1
- package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js.map +0 -1
- package/dist/embed/index-B6ZQhCsu.js +0 -577
- /package/build/_app/immutable/assets/{10.Dblyk0Kr.css → 12.Dblyk0Kr.css} +0 -0
- /package/build/_app/immutable/nodes/{8.CAG5_cGP.js → 10.CAG5_cGP.js} +0 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
// Force-directed edge bundling (FDEB) for the graph renderer.
|
|
2
|
+
//
|
|
3
|
+
// Pure, DOM-free, and deterministic — no sigma/WebGL imports — so it stays
|
|
4
|
+
// unit-testable in the node environment alongside graph-render/graph-sigma.
|
|
5
|
+
// The algorithm is the Holten & van Wijk force-directed scheme (the same one
|
|
6
|
+
// d3.ForceBundle popularised): each edge is subdivided into points that are
|
|
7
|
+
// pulled toward the corresponding points of *compatible* edges, so edges that
|
|
8
|
+
// run roughly parallel collapse onto shared trunks and the "hairball" of a
|
|
9
|
+
// dense subgraph untangles into legible bundles.
|
|
10
|
+
//
|
|
11
|
+
// It is meant to be **precomputed** (memoised by the caller, recomputed only
|
|
12
|
+
// when the draw set or layout changes) — never run per animation frame. The
|
|
13
|
+
// caller caps edge count via BUNDLE_EDGE_LIMIT because the compatibility step
|
|
14
|
+
// is O(E²).
|
|
15
|
+
|
|
16
|
+
export interface BundlePoint {
|
|
17
|
+
x: number;
|
|
18
|
+
y: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface BundleNodePosition {
|
|
22
|
+
id: string;
|
|
23
|
+
x: number;
|
|
24
|
+
y: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface BundleEdgeInput {
|
|
28
|
+
id: string;
|
|
29
|
+
source: string;
|
|
30
|
+
target: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface BundleOptions {
|
|
34
|
+
/** Global bundling stiffness (spring constant K). */
|
|
35
|
+
stiffness: number;
|
|
36
|
+
/** Initial displacement step size, halved every cycle. */
|
|
37
|
+
stepSize: number;
|
|
38
|
+
/** Number of cycles (each doubles the subdivision count). */
|
|
39
|
+
cycles: number;
|
|
40
|
+
/** Iterations in the first cycle, decayed by iterationRate each cycle. */
|
|
41
|
+
iterations: number;
|
|
42
|
+
/** Multiplicative decay applied to the iteration count per cycle. */
|
|
43
|
+
iterationRate: number;
|
|
44
|
+
/** Factor the subdivision-point count grows by each cycle. */
|
|
45
|
+
subdivisionRate: number;
|
|
46
|
+
/** Edges with a combined compatibility below this don't attract. */
|
|
47
|
+
compatibilityThreshold: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const DEFAULT_BUNDLE_OPTIONS: BundleOptions = {
|
|
51
|
+
stiffness: 0.1,
|
|
52
|
+
stepSize: 0.1,
|
|
53
|
+
cycles: 6,
|
|
54
|
+
iterations: 60,
|
|
55
|
+
iterationRate: 2 / 3,
|
|
56
|
+
subdivisionRate: 2,
|
|
57
|
+
compatibilityThreshold: 0.6,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Above this many edges in the draw set, the O(E²) compatibility pass gets too
|
|
61
|
+
// costly to run synchronously; the renderer skips bundling and says so.
|
|
62
|
+
export const BUNDLE_EDGE_LIMIT = 1500;
|
|
63
|
+
|
|
64
|
+
const EPS = 1e-6;
|
|
65
|
+
|
|
66
|
+
interface Segment {
|
|
67
|
+
id: string;
|
|
68
|
+
s: BundlePoint;
|
|
69
|
+
t: BundlePoint;
|
|
70
|
+
len: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function distance(a: BundlePoint, b: BundlePoint): number {
|
|
74
|
+
return Math.hypot(a.x - b.x, a.y - b.y);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function midpoint(s: BundlePoint, t: BundlePoint): BundlePoint {
|
|
78
|
+
return { x: (s.x + t.x) / 2, y: (s.y + t.y) / 2 };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Resample a polyline to exactly `interior + 2` points evenly spaced by arc
|
|
83
|
+
* length (endpoints preserved). FDEB redistributes subdivision points this way
|
|
84
|
+
* at the start of every cycle so the spring forces stay balanced.
|
|
85
|
+
*/
|
|
86
|
+
function resample(points: BundlePoint[], interior: number): BundlePoint[] {
|
|
87
|
+
const count = interior + 2;
|
|
88
|
+
const cum: number[] = [0];
|
|
89
|
+
for (let i = 1; i < points.length; i++) {
|
|
90
|
+
cum.push(cum[i - 1] + distance(points[i], points[i - 1]));
|
|
91
|
+
}
|
|
92
|
+
const total = cum[cum.length - 1];
|
|
93
|
+
if (total < EPS) {
|
|
94
|
+
return Array.from({ length: count }, () => ({ ...points[0] }));
|
|
95
|
+
}
|
|
96
|
+
const out: BundlePoint[] = [];
|
|
97
|
+
let seg = 1;
|
|
98
|
+
for (let k = 0; k < count; k++) {
|
|
99
|
+
const target = (total * k) / (count - 1);
|
|
100
|
+
while (seg < points.length - 1 && cum[seg] < target) seg++;
|
|
101
|
+
const t0 = cum[seg - 1];
|
|
102
|
+
const span = cum[seg] - t0 || 1;
|
|
103
|
+
const pct = Math.max(0, Math.min(1, (target - t0) / span));
|
|
104
|
+
out.push({
|
|
105
|
+
x: points[seg - 1].x + pct * (points[seg].x - points[seg - 1].x),
|
|
106
|
+
y: points[seg - 1].y + pct * (points[seg].y - points[seg - 1].y),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─── pairwise edge compatibility (angle · scale · position · visibility) ─────
|
|
113
|
+
|
|
114
|
+
function angleCompatibility(p: Segment, q: Segment): number {
|
|
115
|
+
const dot =
|
|
116
|
+
(p.t.x - p.s.x) * (q.t.x - q.s.x) + (p.t.y - p.s.y) * (q.t.y - q.s.y);
|
|
117
|
+
return Math.abs(dot / (p.len * q.len));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function scaleCompatibility(p: Segment, q: Segment): number {
|
|
121
|
+
const lavg = (p.len + q.len) / 2;
|
|
122
|
+
return 2 / (lavg / Math.min(p.len, q.len) + Math.max(p.len, q.len) / lavg);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function positionCompatibility(p: Segment, q: Segment): number {
|
|
126
|
+
const lavg = (p.len + q.len) / 2;
|
|
127
|
+
return lavg / (lavg + distance(midpoint(p.s, p.t), midpoint(q.s, q.t)));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function projectOntoLine(point: BundlePoint, line: Segment): BundlePoint {
|
|
131
|
+
const l2 = line.len * line.len;
|
|
132
|
+
const r =
|
|
133
|
+
((line.s.y - point.y) * (line.s.y - line.t.y) -
|
|
134
|
+
(line.s.x - point.x) * (line.t.x - line.s.x)) /
|
|
135
|
+
l2;
|
|
136
|
+
return {
|
|
137
|
+
x: line.s.x + r * (line.t.x - line.s.x),
|
|
138
|
+
y: line.s.y + r * (line.t.y - line.s.y),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function edgeVisibility(p: Segment, q: Segment): number {
|
|
143
|
+
const i0 = projectOntoLine(q.s, p);
|
|
144
|
+
const i1 = projectOntoLine(q.t, p);
|
|
145
|
+
const im = midpoint(i0, i1);
|
|
146
|
+
const denom = distance(i0, i1);
|
|
147
|
+
if (denom < EPS) return 0;
|
|
148
|
+
return Math.max(0, 1 - (2 * distance(im, midpoint(q.s, q.t))) / denom);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function visibilityCompatibility(p: Segment, q: Segment): number {
|
|
152
|
+
return Math.min(edgeVisibility(p, q), edgeVisibility(q, p));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function compatibilityScore(p: Segment, q: Segment): number {
|
|
156
|
+
return (
|
|
157
|
+
angleCompatibility(p, q) *
|
|
158
|
+
scaleCompatibility(p, q) *
|
|
159
|
+
positionCompatibility(p, q) *
|
|
160
|
+
visibilityCompatibility(p, q)
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function compatibilityLists(
|
|
165
|
+
segments: Segment[],
|
|
166
|
+
threshold: number
|
|
167
|
+
): number[][] {
|
|
168
|
+
const lists: number[][] = segments.map(() => []);
|
|
169
|
+
for (let i = 0; i < segments.length; i++) {
|
|
170
|
+
for (let j = i + 1; j < segments.length; j++) {
|
|
171
|
+
if (compatibilityScore(segments[i], segments[j]) >= threshold) {
|
|
172
|
+
lists[i].push(j);
|
|
173
|
+
lists[j].push(i);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return lists;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Run force-directed edge bundling. Returns a polyline (source → … → target,
|
|
182
|
+
* endpoints included) for every input edge whose endpoints resolve. Edges that
|
|
183
|
+
* are dropped, self-loops, or zero-length get a straight two-point polyline so
|
|
184
|
+
* the caller always has geometry to draw.
|
|
185
|
+
*/
|
|
186
|
+
export function bundleEdges(
|
|
187
|
+
nodes: BundleNodePosition[],
|
|
188
|
+
edges: BundleEdgeInput[],
|
|
189
|
+
options: Partial<BundleOptions> = {}
|
|
190
|
+
): Map<string, BundlePoint[]> {
|
|
191
|
+
const opts = { ...DEFAULT_BUNDLE_OPTIONS, ...options };
|
|
192
|
+
const pos = new Map(nodes.map((n) => [n.id, { x: n.x, y: n.y }]));
|
|
193
|
+
const result = new Map<string, BundlePoint[]>();
|
|
194
|
+
|
|
195
|
+
const segments: Segment[] = [];
|
|
196
|
+
for (const e of edges) {
|
|
197
|
+
const s = pos.get(e.source);
|
|
198
|
+
const t = pos.get(e.target);
|
|
199
|
+
if (!s || !t) continue;
|
|
200
|
+
// Straight fallback for every resolvable edge; bundleable ones overwrite it.
|
|
201
|
+
result.set(e.id, [{ ...s }, { ...t }]);
|
|
202
|
+
if (e.source === e.target) continue;
|
|
203
|
+
const len = distance(s, t);
|
|
204
|
+
if (len < EPS) continue;
|
|
205
|
+
segments.push({ id: e.id, s: { ...s }, t: { ...t }, len });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (segments.length < 2) return result;
|
|
209
|
+
|
|
210
|
+
const subdivisions: BundlePoint[][] = segments.map((seg) => [
|
|
211
|
+
{ ...seg.s },
|
|
212
|
+
{ ...seg.t },
|
|
213
|
+
]);
|
|
214
|
+
const compat = compatibilityLists(segments, opts.compatibilityThreshold);
|
|
215
|
+
|
|
216
|
+
let p = 1;
|
|
217
|
+
let step = opts.stepSize;
|
|
218
|
+
let iterations = opts.iterations;
|
|
219
|
+
|
|
220
|
+
for (let i = 0; i < subdivisions.length; i++) {
|
|
221
|
+
subdivisions[i] = resample(subdivisions[i], p);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
for (let cycle = 0; cycle < opts.cycles; cycle++) {
|
|
225
|
+
const rounds = Math.max(1, Math.round(iterations));
|
|
226
|
+
for (let iter = 0; iter < rounds; iter++) {
|
|
227
|
+
const forces = segments.map((_, ei) =>
|
|
228
|
+
computeForces(
|
|
229
|
+
ei,
|
|
230
|
+
subdivisions,
|
|
231
|
+
compat,
|
|
232
|
+
segments[ei].len,
|
|
233
|
+
p,
|
|
234
|
+
step,
|
|
235
|
+
opts.stiffness
|
|
236
|
+
)
|
|
237
|
+
);
|
|
238
|
+
for (let ei = 0; ei < segments.length; ei++) {
|
|
239
|
+
for (let i = 1; i <= p; i++) {
|
|
240
|
+
subdivisions[ei][i].x += forces[ei][i].x;
|
|
241
|
+
subdivisions[ei][i].y += forces[ei][i].y;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
step *= 0.5;
|
|
246
|
+
p *= opts.subdivisionRate;
|
|
247
|
+
iterations *= opts.iterationRate;
|
|
248
|
+
for (let i = 0; i < subdivisions.length; i++) {
|
|
249
|
+
subdivisions[i] = resample(subdivisions[i], p);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
for (let ei = 0; ei < segments.length; ei++) {
|
|
254
|
+
result.set(
|
|
255
|
+
segments[ei].id,
|
|
256
|
+
subdivisions[ei].map((pt) => ({ x: pt.x, y: pt.y }))
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
return result;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function computeForces(
|
|
263
|
+
ei: number,
|
|
264
|
+
subdivisions: BundlePoint[][],
|
|
265
|
+
compat: number[][],
|
|
266
|
+
edgeLen: number,
|
|
267
|
+
p: number,
|
|
268
|
+
step: number,
|
|
269
|
+
stiffness: number
|
|
270
|
+
): BundlePoint[] {
|
|
271
|
+
const kp = stiffness / (edgeLen * (p + 1));
|
|
272
|
+
const points = subdivisions[ei];
|
|
273
|
+
const forces: BundlePoint[] = [{ x: 0, y: 0 }];
|
|
274
|
+
for (let i = 1; i <= p; i++) {
|
|
275
|
+
const prev = points[i - 1];
|
|
276
|
+
const cur = points[i];
|
|
277
|
+
const next = points[i + 1];
|
|
278
|
+
// Spring force keeps the subdivision points evenly distributed.
|
|
279
|
+
let fx = kp * (prev.x - 2 * cur.x + next.x);
|
|
280
|
+
let fy = kp * (prev.y - 2 * cur.y + next.y);
|
|
281
|
+
// Electrostatic force pulls toward the matching point of compatible edges.
|
|
282
|
+
for (const oe of compat[ei]) {
|
|
283
|
+
const other = subdivisions[oe][i];
|
|
284
|
+
const dx = other.x - cur.x;
|
|
285
|
+
const dy = other.y - cur.y;
|
|
286
|
+
if (Math.abs(dx) > EPS || Math.abs(dy) > EPS) {
|
|
287
|
+
const d = Math.hypot(dx, dy);
|
|
288
|
+
fx += dx / d;
|
|
289
|
+
fy += dy / d;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
forces.push({ x: step * fx, y: step * fy });
|
|
293
|
+
}
|
|
294
|
+
forces.push({ x: 0, y: 0 });
|
|
295
|
+
return forces;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Map a bundled polyline onto a single sigma `curvature` value: the signed
|
|
300
|
+
* perpendicular deviation of its most-displaced point, scaled so a quadratic
|
|
301
|
+
* Bézier reproduces that bow (mid-curve deviation = ½·curvature·length).
|
|
302
|
+
*
|
|
303
|
+
* Sigma's WebGL scene flips y relative to layout space, so the caller negates
|
|
304
|
+
* this when feeding @sigma/edge-curve. Returns 0 for straight / degenerate
|
|
305
|
+
* polylines.
|
|
306
|
+
*/
|
|
307
|
+
export function edgeCurvature(points: BundlePoint[]): number {
|
|
308
|
+
if (points.length < 3) return 0;
|
|
309
|
+
const s = points[0];
|
|
310
|
+
const t = points[points.length - 1];
|
|
311
|
+
const dx = t.x - s.x;
|
|
312
|
+
const dy = t.y - s.y;
|
|
313
|
+
const len = Math.hypot(dx, dy);
|
|
314
|
+
if (len < EPS) return 0;
|
|
315
|
+
// Signed distance to the source→target line along the left normal (-dy, dx).
|
|
316
|
+
let maxDev = 0;
|
|
317
|
+
for (let i = 1; i < points.length - 1; i++) {
|
|
318
|
+
const px = points[i].x - s.x;
|
|
319
|
+
const py = points[i].y - s.y;
|
|
320
|
+
const dev = (px * -dy + py * dx) / len;
|
|
321
|
+
if (Math.abs(dev) > Math.abs(maxDev)) maxDev = dev;
|
|
322
|
+
}
|
|
323
|
+
const curvature = (2 * maxDev) / len;
|
|
324
|
+
return Math.max(-1, Math.min(1, curvature));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ─── crossing metric (validates "measurable drop in crossings") ──────────────
|
|
328
|
+
|
|
329
|
+
function cross(a: BundlePoint, b: BundlePoint, c: BundlePoint): number {
|
|
330
|
+
return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function segmentsCross(
|
|
334
|
+
p1: BundlePoint,
|
|
335
|
+
p2: BundlePoint,
|
|
336
|
+
p3: BundlePoint,
|
|
337
|
+
p4: BundlePoint
|
|
338
|
+
): boolean {
|
|
339
|
+
const d1 = cross(p3, p4, p1);
|
|
340
|
+
const d2 = cross(p3, p4, p2);
|
|
341
|
+
const d3 = cross(p1, p2, p3);
|
|
342
|
+
const d4 = cross(p1, p2, p4);
|
|
343
|
+
// Strict signs ⇒ proper crossings only; segments that merely touch at a
|
|
344
|
+
// shared node endpoint (a d of 0) are not counted.
|
|
345
|
+
return (
|
|
346
|
+
((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) &&
|
|
347
|
+
((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Count proper crossings between distinct polylines (straight edges are
|
|
353
|
+
* two-point polylines). A correctness primitive for the geometry; note that
|
|
354
|
+
* FDEB does *not* aim to reduce topological crossings (two edges with
|
|
355
|
+
* interleaved endpoints must cross however they are routed) — use `edgeInk`
|
|
356
|
+
* to measure the clutter reduction bundling actually delivers.
|
|
357
|
+
*/
|
|
358
|
+
export function countEdgeCrossings(polylines: BundlePoint[][]): number {
|
|
359
|
+
let crossings = 0;
|
|
360
|
+
for (let a = 0; a < polylines.length; a++) {
|
|
361
|
+
const A = polylines[a];
|
|
362
|
+
for (let b = a + 1; b < polylines.length; b++) {
|
|
363
|
+
const B = polylines[b];
|
|
364
|
+
for (let i = 1; i < A.length; i++) {
|
|
365
|
+
for (let j = 1; j < B.length; j++) {
|
|
366
|
+
if (segmentsCross(A[i - 1], A[i], B[j - 1], B[j])) crossings++;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return crossings;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* "Edge ink": the number of distinct grid cells the edge polylines paint, at a
|
|
376
|
+
* given cell size. This is the standard quality measure for edge bundling —
|
|
377
|
+
* the visual signal behind "untangle the hairball". Bundling routes compatible
|
|
378
|
+
* edges onto shared trunks, so their strokes overlap and the painted area (and
|
|
379
|
+
* thus the visible crossing clutter) drops measurably. Lower is tighter.
|
|
380
|
+
*/
|
|
381
|
+
export function edgeInk(polylines: BundlePoint[][], cellSize: number): number {
|
|
382
|
+
const cells = new Set<string>();
|
|
383
|
+
const half = cellSize / 2;
|
|
384
|
+
for (const poly of polylines) {
|
|
385
|
+
for (let i = 1; i < poly.length; i++) {
|
|
386
|
+
const a = poly[i - 1];
|
|
387
|
+
const b = poly[i];
|
|
388
|
+
const steps = Math.max(
|
|
389
|
+
1,
|
|
390
|
+
Math.ceil(Math.hypot(b.x - a.x, b.y - a.y) / half)
|
|
391
|
+
);
|
|
392
|
+
for (let s = 0; s <= steps; s++) {
|
|
393
|
+
const u = s / steps;
|
|
394
|
+
const x = Math.floor((a.x + u * (b.x - a.x)) / cellSize);
|
|
395
|
+
const y = Math.floor((a.y + u * (b.y - a.y)) / cellSize);
|
|
396
|
+
cells.add(`${x},${y}`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return cells.size;
|
|
401
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Community expand/collapse — pure, DOM-free, and sigma-free so the node test
|
|
2
|
+
// environment can exercise every branch. Given the community ids produced by
|
|
3
|
+
// `runGraphLayout` (Phase 1's Louvain pass), this folds a community's members
|
|
4
|
+
// into a single supernode and aggregates the edges that crossed its boundary.
|
|
5
|
+
// The GraphRenderer feeds the result into the same `buildSigmaGraph` / SVG
|
|
6
|
+
// pipeline a non-collapsed graph uses, so collapsed and expanded states render
|
|
7
|
+
// through one code path and stay reversible.
|
|
8
|
+
|
|
9
|
+
import type { GraphEdge, GraphLayout, GraphNode } from "./graph-render";
|
|
10
|
+
|
|
11
|
+
/** Synthetic-id namespace so supernodes never collide with real node ids. */
|
|
12
|
+
export const SUPERNODE_PREFIX = "__cluster__:";
|
|
13
|
+
|
|
14
|
+
export function communitySupernodeId(community: number): string {
|
|
15
|
+
return `${SUPERNODE_PREFIX}${community}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isSupernodeId(id: string): boolean {
|
|
19
|
+
return id.startsWith(SUPERNODE_PREFIX);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function supernodeCommunityOf(id: string): number | null {
|
|
23
|
+
if (!isSupernodeId(id)) return null;
|
|
24
|
+
const n = Number(id.slice(SUPERNODE_PREFIX.length));
|
|
25
|
+
return Number.isFinite(n) ? n : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CommunitySummary {
|
|
29
|
+
community: number;
|
|
30
|
+
memberIds: string[];
|
|
31
|
+
size: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Group the visible nodes by the community id their layout position carries. */
|
|
35
|
+
export function communitiesInLayout(
|
|
36
|
+
nodes: GraphNode[],
|
|
37
|
+
layout: GraphLayout
|
|
38
|
+
): Map<number, string[]> {
|
|
39
|
+
const out = new Map<number, string[]>();
|
|
40
|
+
for (const node of nodes) {
|
|
41
|
+
const pos = layout.get(node.id);
|
|
42
|
+
if (!pos) continue;
|
|
43
|
+
const list = out.get(pos.community);
|
|
44
|
+
if (list) list.push(node.id);
|
|
45
|
+
else out.set(pos.community, [node.id]);
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CollapseResult {
|
|
51
|
+
nodes: GraphNode[];
|
|
52
|
+
edges: GraphEdge[];
|
|
53
|
+
layout: GraphLayout;
|
|
54
|
+
/** supernode id → the community it stands in for. */
|
|
55
|
+
supernodes: Map<string, CommunitySummary>;
|
|
56
|
+
/** member id → supernode id, for collapsed communities only. */
|
|
57
|
+
collapsedMembers: Map<string, string>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const passthrough = (
|
|
61
|
+
nodes: GraphNode[],
|
|
62
|
+
edges: GraphEdge[],
|
|
63
|
+
layout: GraphLayout
|
|
64
|
+
): CollapseResult => ({
|
|
65
|
+
nodes,
|
|
66
|
+
edges,
|
|
67
|
+
layout,
|
|
68
|
+
supernodes: new Map(),
|
|
69
|
+
collapsedMembers: new Map(),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Fold every community in `collapsed` into one supernode positioned at its
|
|
74
|
+
* members' centroid. Edges that crossed the community boundary are remapped to
|
|
75
|
+
* the supernode and aggregated (parallel edges merge, carrying a `weight`);
|
|
76
|
+
* edges fully inside a collapsed community are dropped (they live "inside" the
|
|
77
|
+
* supernode). Edges untouched by any collapse pass through unchanged so their
|
|
78
|
+
* labels and detail survive a click.
|
|
79
|
+
*
|
|
80
|
+
* A community is only collapsed if it actually has members in the current
|
|
81
|
+
* draw set — collapsing then filtering can leave a stale id in the set, and we
|
|
82
|
+
* silently ignore it rather than emit an empty supernode.
|
|
83
|
+
*/
|
|
84
|
+
export function collapseCommunities(
|
|
85
|
+
nodes: GraphNode[],
|
|
86
|
+
edges: GraphEdge[],
|
|
87
|
+
layout: GraphLayout,
|
|
88
|
+
collapsed: Set<number>
|
|
89
|
+
): CollapseResult {
|
|
90
|
+
if (collapsed.size === 0) return passthrough(nodes, edges, layout);
|
|
91
|
+
|
|
92
|
+
const groups = communitiesInLayout(nodes, layout);
|
|
93
|
+
const active = new Set<number>();
|
|
94
|
+
for (const community of collapsed) {
|
|
95
|
+
const members = groups.get(community);
|
|
96
|
+
if (members && members.length > 0) active.add(community);
|
|
97
|
+
}
|
|
98
|
+
if (active.size === 0) return passthrough(nodes, edges, layout);
|
|
99
|
+
|
|
100
|
+
const memberToSuper = new Map<string, string>();
|
|
101
|
+
const supernodes = new Map<string, CommunitySummary>();
|
|
102
|
+
const outNodes: GraphNode[] = [];
|
|
103
|
+
const outLayout: GraphLayout = new Map();
|
|
104
|
+
|
|
105
|
+
for (const node of nodes) {
|
|
106
|
+
const pos = layout.get(node.id);
|
|
107
|
+
if (pos && active.has(pos.community)) {
|
|
108
|
+
memberToSuper.set(node.id, communitySupernodeId(pos.community));
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
outNodes.push(node);
|
|
112
|
+
if (pos) outLayout.set(node.id, pos);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const community of active) {
|
|
116
|
+
const members = groups.get(community)!;
|
|
117
|
+
const superId = communitySupernodeId(community);
|
|
118
|
+
let sx = 0;
|
|
119
|
+
let sy = 0;
|
|
120
|
+
let n = 0;
|
|
121
|
+
for (const id of members) {
|
|
122
|
+
const pos = layout.get(id);
|
|
123
|
+
if (!pos) continue;
|
|
124
|
+
sx += pos.x;
|
|
125
|
+
sy += pos.y;
|
|
126
|
+
n += 1;
|
|
127
|
+
}
|
|
128
|
+
supernodes.set(superId, {
|
|
129
|
+
community,
|
|
130
|
+
memberIds: members,
|
|
131
|
+
size: members.length,
|
|
132
|
+
});
|
|
133
|
+
outNodes.push({
|
|
134
|
+
id: superId,
|
|
135
|
+
label: `cluster ${community} · ${members.length}`,
|
|
136
|
+
data: {
|
|
137
|
+
node_type: "cluster",
|
|
138
|
+
__supernode: true,
|
|
139
|
+
__community: community,
|
|
140
|
+
__memberCount: members.length,
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
outLayout.set(superId, {
|
|
144
|
+
x: n ? sx / n : 50,
|
|
145
|
+
y: n ? sy / n : 50,
|
|
146
|
+
community,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const remap = (id: string) => memberToSuper.get(id) ?? id;
|
|
151
|
+
const outEdges: GraphEdge[] = [];
|
|
152
|
+
const aggregated = new Map<
|
|
153
|
+
string,
|
|
154
|
+
{ source: string; target: string; weight: number; members: string[] }
|
|
155
|
+
>();
|
|
156
|
+
|
|
157
|
+
for (const edge of edges) {
|
|
158
|
+
const source = remap(edge.source);
|
|
159
|
+
const target = remap(edge.target);
|
|
160
|
+
const touchesSuper = source !== edge.source || target !== edge.target;
|
|
161
|
+
if (!touchesSuper) {
|
|
162
|
+
outEdges.push(edge);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (source === target) continue; // intra-cluster — hidden inside the supernode
|
|
166
|
+
const key = `${source} ${target}`;
|
|
167
|
+
const agg = aggregated.get(key);
|
|
168
|
+
if (agg) {
|
|
169
|
+
agg.weight += 1;
|
|
170
|
+
agg.members.push(edge.id);
|
|
171
|
+
} else {
|
|
172
|
+
aggregated.set(key, { source, target, weight: 1, members: [edge.id] });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
for (const [key, agg] of aggregated) {
|
|
177
|
+
outEdges.push({
|
|
178
|
+
id: `__agg__:${key}`,
|
|
179
|
+
source: agg.source,
|
|
180
|
+
target: agg.target,
|
|
181
|
+
label: agg.weight > 1 ? `×${agg.weight}` : undefined,
|
|
182
|
+
data: { __aggregated: true, weight: agg.weight, members: agg.members },
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
nodes: outNodes,
|
|
188
|
+
edges: outEdges,
|
|
189
|
+
layout: outLayout,
|
|
190
|
+
supernodes,
|
|
191
|
+
collapsedMembers: memberToSuper,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import type { QueryResult, QueryRow } from "#reddb";
|
|
6
6
|
import Graph from "graphology";
|
|
7
|
+
import leiden from "@aflsolutions/graphology-communities-leiden";
|
|
7
8
|
import louvain from "graphology-communities-louvain";
|
|
8
9
|
import forceAtlas2 from "graphology-layout-forceatlas2";
|
|
9
10
|
|
|
@@ -506,11 +507,53 @@ function seededRng(seed: number): () => number {
|
|
|
506
507
|
};
|
|
507
508
|
}
|
|
508
509
|
|
|
510
|
+
/** Algorithm that actually labelled communities on the graph. */
|
|
511
|
+
export type CommunityAlgorithm = "leiden" | "louvain";
|
|
512
|
+
|
|
513
|
+
/** Minimal surface we depend on from a community-detection package. */
|
|
514
|
+
interface CommunityAssigner {
|
|
515
|
+
assign(graph: Graph, options?: Record<string, unknown>): void;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Assign a `community` number to every node, preferring Leiden.
|
|
520
|
+
*
|
|
521
|
+
* Leiden (Traag et al. 2019) is the state-of-the-art successor to Louvain
|
|
522
|
+
* and — unlike Louvain — guarantees every community is internally
|
|
523
|
+
* well-connected. We adopt the third-party `@aflsolutions/...-leiden` fork
|
|
524
|
+
* (the canonical package is unpublished); to contain its supply-chain risk
|
|
525
|
+
* a yanked/broken fork must never break the graph view, so any failure
|
|
526
|
+
* (missing/partial module or a throw mid-run) falls back to the canonical
|
|
527
|
+
* Louvain pass. Both share the seeded `rng` so renders stay deterministic.
|
|
528
|
+
*
|
|
529
|
+
* The `leidenImpl` parameter is injectable purely so the fallback path is
|
|
530
|
+
* testable; production callers always use the bundled fork.
|
|
531
|
+
*/
|
|
532
|
+
export function assignGraphCommunities(
|
|
533
|
+
g: Graph,
|
|
534
|
+
rng: () => number,
|
|
535
|
+
leidenImpl: CommunityAssigner | null | undefined = leiden as CommunityAssigner
|
|
536
|
+
): CommunityAlgorithm {
|
|
537
|
+
try {
|
|
538
|
+
if (!leidenImpl || typeof leidenImpl.assign !== "function") {
|
|
539
|
+
throw new Error("Leiden community detection unavailable");
|
|
540
|
+
}
|
|
541
|
+
leidenImpl.assign(g, { attributes: { community: "community" }, rng });
|
|
542
|
+
return "leiden";
|
|
543
|
+
} catch {
|
|
544
|
+
// Fork yanked, broken, or threw — fall back to canonical Louvain so the
|
|
545
|
+
// graph view always renders with communities.
|
|
546
|
+
louvain.assign(g, { nodeCommunityAttribute: "community", rng });
|
|
547
|
+
return "louvain";
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
509
551
|
/**
|
|
510
552
|
* Compute a community-aware layout for the graph.
|
|
511
553
|
*
|
|
512
|
-
* Pipeline:
|
|
513
|
-
* communities cluster spatially. Output is normalised
|
|
554
|
+
* Pipeline: Leiden detects communities (Louvain fallback), ForceAtlas2
|
|
555
|
+
* positions nodes so communities cluster spatially. Output is normalised
|
|
556
|
+
* to a [0, 100] box
|
|
514
557
|
* matching the existing renderer's coordinate space, with a 5% padding so
|
|
515
558
|
* nodes don't touch the viewport edge.
|
|
516
559
|
*
|
|
@@ -552,8 +595,8 @@ export function runGraphLayout(
|
|
|
552
595
|
g.addEdge(e.source, e.target);
|
|
553
596
|
}
|
|
554
597
|
|
|
555
|
-
//
|
|
556
|
-
// its own community and a grid fallback below.
|
|
598
|
+
// Community detection needs at least one edge; degenerate graphs get
|
|
599
|
+
// every node in its own community and a grid fallback below.
|
|
557
600
|
if (g.size === 0) {
|
|
558
601
|
const cols = Math.ceil(Math.sqrt(nodes.length));
|
|
559
602
|
nodes.forEach((n, i) => {
|
|
@@ -568,7 +611,7 @@ export function runGraphLayout(
|
|
|
568
611
|
return out;
|
|
569
612
|
}
|
|
570
613
|
|
|
571
|
-
|
|
614
|
+
assignGraphCommunities(g, rng);
|
|
572
615
|
|
|
573
616
|
const settings = forceAtlas2.inferSettings(g);
|
|
574
617
|
forceAtlas2.assign(g, {
|