@reddb-io/ui 0.1.1 → 0.3.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/BjFADNkA.js +992 -0
- package/build/_app/immutable/chunks/{ALqaDrNQ.js → C278awGv.js} +1 -1
- package/build/_app/immutable/chunks/{Ov8N-vxt.js → C9rXzYLu.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/{Bgso8cDH.js → DqvWKbSc.js} +1 -1
- package/build/_app/immutable/chunks/pDJnDxXM.js +1 -0
- package/build/_app/immutable/entry/app.DdaMXHAv.js +2 -0
- package/build/_app/immutable/entry/start.-MLBtQXB.js +1 -0
- package/build/_app/immutable/nodes/{0.CHPiFxDo.js → 0.Ysoz3Y3h.js} +1 -1
- package/build/_app/immutable/nodes/{1.BC_4YZ0h.js → 1.C2jbGTOJ.js} +1 -1
- package/build/_app/immutable/nodes/11.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/{10.B7DMKSNc.js → 12.DQG5zpEx.js} +5 -5
- package/build/_app/immutable/nodes/7.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/{7.BZX9Iird.js → 8.CrHI4F9p.js} +1 -1
- package/build/_app/version.json +1 -1
- package/build/index.html +12 -12
- package/dist/embed/{RedUiEmbedRoot-BGoLxJf1.js → RedUiEmbedRoot-BwFUA5X-.js} +12715 -10653
- package/dist/embed/RedUiEmbedRoot-BwFUA5X-.js.map +1 -0
- package/dist/embed/index-DGhgtnaK.js +577 -0
- package/dist/embed/{index-B6ZQhCsu.js.map → index-DGhgtnaK.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-D5yoExsH.js} +3 -3
- package/dist/embed/{sigma.esm-y0qgY-OC.js.map → sigma.esm-D5yoExsH.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 +376 -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 +267 -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/BMHKbvs8.js +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
|
@@ -5,18 +5,36 @@
|
|
|
5
5
|
import {
|
|
6
6
|
buildSigmaGraph,
|
|
7
7
|
colorForType,
|
|
8
|
+
computeCommunityBoxes,
|
|
8
9
|
nodeVisualRadius,
|
|
10
|
+
supernodeSvgRadius,
|
|
9
11
|
reduceSigmaEdge,
|
|
10
12
|
reduceSigmaNode,
|
|
13
|
+
routeInterCommunityEdges,
|
|
11
14
|
FOCUS_EDGE_COLOR,
|
|
12
15
|
FOCUS_NODE_STROKE_COLOR,
|
|
16
|
+
type CommunityBox,
|
|
17
|
+
type RoutedEdge,
|
|
13
18
|
type SigmaReducerState,
|
|
14
19
|
type SigmaThemeColors,
|
|
15
20
|
} from './graph-sigma'
|
|
21
|
+
import {
|
|
22
|
+
bundleEdges,
|
|
23
|
+
edgeCurvature,
|
|
24
|
+
BUNDLE_EDGE_LIMIT,
|
|
25
|
+
type BundleOptions,
|
|
26
|
+
type BundlePoint,
|
|
27
|
+
} from './graph-bundle'
|
|
28
|
+
import {
|
|
29
|
+
collapseCommunities,
|
|
30
|
+
communitiesInLayout,
|
|
31
|
+
isSupernodeId,
|
|
32
|
+
supernodeCommunityOf,
|
|
33
|
+
} from './graph-communities'
|
|
16
34
|
import { collectionPageHref } from '$lib/collection-pages'
|
|
17
35
|
import { connection } from '$lib/connections.svelte'
|
|
18
36
|
import { activity } from '$lib/activity.svelte'
|
|
19
|
-
import { Network, Table2, Search, X, Info, Maximize2, Play, Code2, RotateCw, Loader2, Route, AlertTriangle, ZoomIn, ZoomOut } from 'lucide-svelte'
|
|
37
|
+
import { Network, Table2, Search, X, Info, Maximize2, Play, Code2, RotateCw, Loader2, Route, AlertTriangle, ZoomIn, ZoomOut, Group, Spline, Boxes } from 'lucide-svelte'
|
|
20
38
|
|
|
21
39
|
interface Props {
|
|
22
40
|
result: QueryResult
|
|
@@ -37,6 +55,11 @@
|
|
|
37
55
|
y1: number
|
|
38
56
|
x2: number
|
|
39
57
|
y2: number
|
|
58
|
+
/** Bundled polyline points (`"x,y x,y …"`) when edge bundling is on. */
|
|
59
|
+
points?: string
|
|
60
|
+
/** Label anchor — midpoint of the chord, or of the bundled polyline. */
|
|
61
|
+
mx: number
|
|
62
|
+
my: number
|
|
40
63
|
}
|
|
41
64
|
|
|
42
65
|
let { result, collection, subpage }: Props = $props()
|
|
@@ -64,6 +87,14 @@
|
|
|
64
87
|
let canvasError = $state<string | null>(null)
|
|
65
88
|
let renderBudget = $state(350)
|
|
66
89
|
let svgZoom = $state(1)
|
|
90
|
+
// Group-in-a-box: one tinted box per community, inter-community edges routed
|
|
91
|
+
// at the box borders. Off-state is the plain #59 sigma/SVG render.
|
|
92
|
+
let groupInBox = $state(false)
|
|
93
|
+
// Force-directed edge bundling — off by default (precomputed, not per-frame).
|
|
94
|
+
let bundleEnabled = $state(false)
|
|
95
|
+
// Communities (from runGraphLayout's Louvain pass) folded into supernodes.
|
|
96
|
+
// Keyed by community id; reversible — expanding just drops the id.
|
|
97
|
+
let collapsedCommunities = $state<Set<number>>(new Set())
|
|
67
98
|
|
|
68
99
|
const selectedNodeDetail = $derived(
|
|
69
100
|
selectedNode
|
|
@@ -229,12 +260,69 @@
|
|
|
229
260
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
230
261
|
let sigmaInstance: any = null
|
|
231
262
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
232
|
-
let sigmaModules: { Sigma: any; NodeBorderProgram: any } | null = null
|
|
263
|
+
let sigmaModules: { Sigma: any; NodeBorderProgram: any; EdgeCurveProgram: any } | null = null
|
|
233
264
|
let cameraRatio = $state(1)
|
|
234
265
|
let pulseProgress = $state(0)
|
|
235
266
|
let pulseRaf = 0
|
|
236
267
|
let graphThemeVersion = $state(0)
|
|
237
268
|
|
|
269
|
+
// Canvas group-in-a-box overlay: boxes/edges projected into viewport pixels.
|
|
270
|
+
// sigma draws node/edge geometry in WebGL; the overlay is a plain SVG synced
|
|
271
|
+
// to the camera (graphToViewport) so the boxes pan/zoom with the graph.
|
|
272
|
+
interface ViewportBox extends CommunityBox {
|
|
273
|
+
vx: number
|
|
274
|
+
vy: number
|
|
275
|
+
vw: number
|
|
276
|
+
vh: number
|
|
277
|
+
}
|
|
278
|
+
interface ViewportEdge {
|
|
279
|
+
id: string
|
|
280
|
+
vx1: number
|
|
281
|
+
vy1: number
|
|
282
|
+
vx2: number
|
|
283
|
+
vy2: number
|
|
284
|
+
}
|
|
285
|
+
let overlayBoxes = $state<ViewportBox[]>([])
|
|
286
|
+
let overlayEdges = $state<ViewportEdge[]>([])
|
|
287
|
+
|
|
288
|
+
// Map a layout-space point into viewport pixels. Sigma stores nodes at y=-y
|
|
289
|
+
// (see buildSigmaGraph), so the flip is reapplied before projecting.
|
|
290
|
+
function projectPoint(x: number, y: number): { x: number; y: number } | null {
|
|
291
|
+
const instance = sigmaInstance
|
|
292
|
+
if (!instance) return null
|
|
293
|
+
return instance.graphToViewport({ x, y: -y })
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function syncOverlay() {
|
|
297
|
+
if (!groupInBox || !sigmaInstance || viewMode !== 'canvas') {
|
|
298
|
+
if (overlayBoxes.length) overlayBoxes = []
|
|
299
|
+
if (overlayEdges.length) overlayEdges = []
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
const boxes: ViewportBox[] = []
|
|
303
|
+
for (const box of communityBoxes) {
|
|
304
|
+
const p1 = projectPoint(box.minX, box.minY)
|
|
305
|
+
const p2 = projectPoint(box.maxX, box.maxY)
|
|
306
|
+
if (!p1 || !p2) continue
|
|
307
|
+
boxes.push({
|
|
308
|
+
...box,
|
|
309
|
+
vx: Math.min(p1.x, p2.x),
|
|
310
|
+
vy: Math.min(p1.y, p2.y),
|
|
311
|
+
vw: Math.abs(p2.x - p1.x),
|
|
312
|
+
vh: Math.abs(p2.y - p1.y),
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
const edges: ViewportEdge[] = []
|
|
316
|
+
for (const edge of routedEdges) {
|
|
317
|
+
const a = projectPoint(edge.x1, edge.y1)
|
|
318
|
+
const b = projectPoint(edge.x2, edge.y2)
|
|
319
|
+
if (!a || !b) continue
|
|
320
|
+
edges.push({ id: edge.id, vx1: a.x, vy1: a.y, vx2: b.x, vy2: b.y })
|
|
321
|
+
}
|
|
322
|
+
overlayBoxes = boxes
|
|
323
|
+
overlayEdges = edges
|
|
324
|
+
}
|
|
325
|
+
|
|
238
326
|
function prefersReducedMotion(): boolean {
|
|
239
327
|
return (
|
|
240
328
|
typeof window !== 'undefined' &&
|
|
@@ -384,11 +472,125 @@
|
|
|
384
472
|
// See packages/ui/src/lib/renderers/graph-render.ts → runGraphLayout.
|
|
385
473
|
const layout = $derived(runGraphLayout(drawNodes, drawEdges))
|
|
386
474
|
|
|
475
|
+
// Dark/light is theme-aware; re-read on the data-theme mutation observer tick.
|
|
476
|
+
const isDark = $derived.by(() => {
|
|
477
|
+
void graphThemeVersion
|
|
478
|
+
return typeof document === 'undefined'
|
|
479
|
+
? true
|
|
480
|
+
: document.documentElement.dataset.theme !== 'light'
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
// Group-in-a-box geometry in layout ([0,100]) space. Shared by the SVG
|
|
484
|
+
// fallback (drawn directly) and the canvas overlay (mapped through sigma's
|
|
485
|
+
// graph→viewport transform). Empty unless the toggle is on.
|
|
486
|
+
const communityBoxes = $derived<CommunityBox[]>(
|
|
487
|
+
groupInBox ? computeCommunityBoxes(drawNodes, layout, { dark: isDark }) : [],
|
|
488
|
+
)
|
|
489
|
+
const routedEdges = $derived<RoutedEdge[]>(
|
|
490
|
+
groupInBox ? routeInterCommunityEdges(drawEdges, layout, communityBoxes) : [],
|
|
491
|
+
)
|
|
492
|
+
// Edge ids the box overlay re-routes — skipped by the straight SVG renderer.
|
|
493
|
+
const routedEdgeIds = $derived(new Set(routedEdges.map((e) => e.id)))
|
|
494
|
+
|
|
495
|
+
// ─── force-directed edge bundling (precomputed) ──────────────────────────
|
|
496
|
+
// FDEB is O(E²) in the compatibility pass, so it runs only when toggled on,
|
|
497
|
+
// off the per-frame path (memoised by $derived — recomputed when the draw
|
|
498
|
+
// set or layout changes, never on a camera move or focus change), and only
|
|
499
|
+
// up to the #59 node ceiling's matching edge cap.
|
|
500
|
+
const bundleTooLarge = $derived(bundleEnabled && drawEdges.length > BUNDLE_EDGE_LIMIT)
|
|
501
|
+
|
|
502
|
+
// Scale the iteration budget down as the edge count grows so the one-time
|
|
503
|
+
// precompute stays responsive even near the cap.
|
|
504
|
+
function bundleOptionsForSize(edgeCount: number): Partial<BundleOptions> {
|
|
505
|
+
if (edgeCount > 800) return { cycles: 4, iterations: 45 }
|
|
506
|
+
if (edgeCount > 300) return { cycles: 5, iterations: 55 }
|
|
507
|
+
return {}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const bundledPaths = $derived.by<Map<string, BundlePoint[]> | null>(() => {
|
|
511
|
+
if (!bundleEnabled || viewMode === 'table') return null
|
|
512
|
+
if (drawEdges.length === 0 || drawEdges.length > BUNDLE_EDGE_LIMIT) return null
|
|
513
|
+
const positions = drawNodes.map((n) => {
|
|
514
|
+
const pos = layout.get(n.id)
|
|
515
|
+
return { id: n.id, x: pos?.x ?? 50, y: pos?.y ?? 50 }
|
|
516
|
+
})
|
|
517
|
+
return bundleEdges(
|
|
518
|
+
positions,
|
|
519
|
+
drawEdges.map((e) => ({ id: e.id, source: e.source, target: e.target })),
|
|
520
|
+
bundleOptionsForSize(drawEdges.length),
|
|
521
|
+
)
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
// Per-edge sigma curvature. The bundle is computed in layout space; sigma's
|
|
525
|
+
// scene flips y, so the curvature sign is negated for the WebGL curve program.
|
|
526
|
+
const bundleCurvatures = $derived.by<Map<string, number> | null>(() => {
|
|
527
|
+
if (!bundledPaths) return null
|
|
528
|
+
const out = new Map<string, number>()
|
|
529
|
+
for (const [id, pts] of bundledPaths) out.set(id, -edgeCurvature(pts))
|
|
530
|
+
return out
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
// ─── community expand / collapse ────────────────────────────────────────
|
|
534
|
+
// Fold the collapsed communities into supernodes (positions reused from the
|
|
535
|
+
// base layout, so collapsing never re-runs FA2 and never jitters). The
|
|
536
|
+
// result feeds the same sigma/SVG pipeline an expanded graph uses.
|
|
537
|
+
const collapsed = $derived(
|
|
538
|
+
collapseCommunities(drawNodes, drawEdges, layout, collapsedCommunities),
|
|
539
|
+
)
|
|
540
|
+
const renderNodes = $derived(collapsed.nodes)
|
|
541
|
+
const renderEdges = $derived(collapsed.edges)
|
|
542
|
+
const renderLayout = $derived(collapsed.layout)
|
|
543
|
+
|
|
544
|
+
// Community → member count over the *base* draw set, so the toolbar can offer
|
|
545
|
+
// "collapse clusters" independently of what is collapsed right now.
|
|
546
|
+
const communityCounts = $derived.by(() => {
|
|
547
|
+
const groups = communitiesInLayout(drawNodes, layout)
|
|
548
|
+
return new Map([...groups].map(([community, ids]) => [community, ids.length]))
|
|
549
|
+
})
|
|
550
|
+
// A community is only worth collapsing if it has more than one member.
|
|
551
|
+
const collapsibleCommunities = $derived(
|
|
552
|
+
[...communityCounts].filter(([, count]) => count >= 2).map(([community]) => community),
|
|
553
|
+
)
|
|
554
|
+
const anyCollapsed = $derived(collapsedCommunities.size > 0)
|
|
555
|
+
|
|
556
|
+
function collapseCommunity(community: number) {
|
|
557
|
+
const next = new Set(collapsedCommunities)
|
|
558
|
+
next.add(community)
|
|
559
|
+
collapsedCommunities = next
|
|
560
|
+
clearGraphFocus()
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function expandCommunity(community: number) {
|
|
564
|
+
const next = new Set(collapsedCommunities)
|
|
565
|
+
next.delete(community)
|
|
566
|
+
collapsedCommunities = next
|
|
567
|
+
clearGraphFocus()
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function toggleCollapseAll() {
|
|
571
|
+
collapsedCommunities = anyCollapsed ? new Set() : new Set(collapsibleCommunities)
|
|
572
|
+
clearGraphFocus()
|
|
573
|
+
clearSelection()
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// The community a node currently sits in (for the side-panel collapse action).
|
|
577
|
+
const selectedNodeCommunity = $derived(
|
|
578
|
+
selectedNode ? layout.get(selectedNode.id)?.community ?? null : null,
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
// Drop a selection that just got folded into a supernode (keeps state honest).
|
|
582
|
+
$effect(() => {
|
|
583
|
+
if (!selectedNode) return
|
|
584
|
+
if (collapsed.collapsedMembers.has(selectedNode.id)) clearSelection()
|
|
585
|
+
})
|
|
586
|
+
|
|
387
587
|
const fallbackSvgNodes = $derived.by<FallbackSvgNode[]>(() => {
|
|
388
|
-
return
|
|
588
|
+
return renderNodes.map((node) => {
|
|
389
589
|
const type = String(node.data.node_type ?? 'node')
|
|
390
590
|
const incomingScale = nodeSizeScales.get(node.id) ?? 1
|
|
391
|
-
const pos =
|
|
591
|
+
const pos = renderLayout.get(node.id)
|
|
592
|
+
const isSupernode = node.data.__supernode === true
|
|
593
|
+
const memberCount = typeof node.data.__memberCount === 'number' ? node.data.__memberCount : 0
|
|
392
594
|
// Community tint as primary color so clusters read at a glance;
|
|
393
595
|
// type still drives the radius/stroke contract.
|
|
394
596
|
const color = pos ? colorForCommunity(pos.community) : colorForType(type)
|
|
@@ -396,7 +598,7 @@
|
|
|
396
598
|
...node,
|
|
397
599
|
x: pos?.x ?? 50,
|
|
398
600
|
y: pos?.y ?? 50,
|
|
399
|
-
r: nodeVisualRadius(type, incomingScale),
|
|
601
|
+
r: isSupernode ? supernodeSvgRadius(memberCount) : nodeVisualRadius(type, incomingScale),
|
|
400
602
|
color,
|
|
401
603
|
}
|
|
402
604
|
})
|
|
@@ -405,16 +607,31 @@
|
|
|
405
607
|
const fallbackSvgNodeById = $derived(new Map(fallbackSvgNodes.map((n) => [n.id, n])))
|
|
406
608
|
const fallbackSvgEdges = $derived.by<FallbackSvgEdge[]>(() => {
|
|
407
609
|
const out: FallbackSvgEdge[] = []
|
|
408
|
-
|
|
610
|
+
// renderEdges (not drawEdges) so collapsed clusters draw their aggregated
|
|
611
|
+
// supernode edges; expanded, renderEdges === drawEdges so nothing changes.
|
|
612
|
+
for (const edge of renderEdges) {
|
|
409
613
|
const source = fallbackSvgNodeById.get(edge.source)
|
|
410
614
|
const target = fallbackSvgNodeById.get(edge.target)
|
|
411
615
|
if (!source || !target) continue
|
|
616
|
+
const bundled = bundledPaths?.get(edge.id)
|
|
617
|
+
let points: string | undefined
|
|
618
|
+
let mx = (source.x + target.x) / 2
|
|
619
|
+
let my = (source.y + target.y) / 2
|
|
620
|
+
if (bundled && bundled.length > 2) {
|
|
621
|
+
points = bundled.map((p) => `${p.x},${p.y}`).join(' ')
|
|
622
|
+
const midPoint = bundled[Math.floor(bundled.length / 2)]
|
|
623
|
+
mx = midPoint.x
|
|
624
|
+
my = midPoint.y
|
|
625
|
+
}
|
|
412
626
|
out.push({
|
|
413
627
|
...edge,
|
|
414
628
|
x1: source.x,
|
|
415
629
|
y1: source.y,
|
|
416
630
|
x2: target.x,
|
|
417
631
|
y2: target.y,
|
|
632
|
+
points,
|
|
633
|
+
mx,
|
|
634
|
+
my,
|
|
418
635
|
})
|
|
419
636
|
}
|
|
420
637
|
return out
|
|
@@ -502,30 +719,44 @@
|
|
|
502
719
|
selectedNodeId: selectedNode?.id ?? null,
|
|
503
720
|
pulse: pulseProgress,
|
|
504
721
|
colors: sigmaThemeColors(),
|
|
722
|
+
groupInBox,
|
|
723
|
+
bundle: bundleEnabled && bundleCurvatures !== null,
|
|
505
724
|
}
|
|
506
725
|
}
|
|
507
726
|
|
|
508
727
|
function buildCurrentSigmaGraph() {
|
|
509
728
|
return buildSigmaGraph({
|
|
510
|
-
nodes:
|
|
511
|
-
edges:
|
|
512
|
-
layout,
|
|
729
|
+
nodes: renderNodes,
|
|
730
|
+
edges: renderEdges,
|
|
731
|
+
layout: renderLayout,
|
|
513
732
|
sizeScales: nodeSizeScales,
|
|
514
733
|
orphanIds: orphanNodeIds,
|
|
515
734
|
dark: document.documentElement.dataset.theme !== 'light',
|
|
516
735
|
edgeColor: sigmaThemeColors().edge,
|
|
736
|
+
bundledCurvatures: bundleCurvatures ?? undefined,
|
|
517
737
|
})
|
|
518
738
|
}
|
|
519
739
|
|
|
520
740
|
function edgeById(id: string): GraphEdge | null {
|
|
521
|
-
return
|
|
741
|
+
return renderEdges.find((e) => e.id === id) ?? null
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// A click on any rendered node: a supernode expands its cluster, an ordinary
|
|
745
|
+
// node follows the focus/selection path. Routed from both renderers.
|
|
746
|
+
function onGraphNodeClick(id: string) {
|
|
747
|
+
if (isSupernodeId(id)) {
|
|
748
|
+
const community = supernodeCommunityOf(id)
|
|
749
|
+
if (community !== null) expandCommunity(community)
|
|
750
|
+
return
|
|
751
|
+
}
|
|
752
|
+
const gn = nodeById(id)
|
|
753
|
+
if (gn) clickGraphNode(gn)
|
|
522
754
|
}
|
|
523
755
|
|
|
524
756
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
525
757
|
function wireSigmaEvents(instance: any) {
|
|
526
758
|
instance.on('clickNode', ({ node }: { node: string }) => {
|
|
527
|
-
|
|
528
|
-
if (gn) clickGraphNode(gn)
|
|
759
|
+
onGraphNodeClick(node)
|
|
529
760
|
})
|
|
530
761
|
instance.on('enterNode', ({ node }: { node: string }) => {
|
|
531
762
|
if (!pinnedFocusNodeId) hoveredFocusNodeId = node
|
|
@@ -550,6 +781,9 @@
|
|
|
550
781
|
camera.on('updated', () => {
|
|
551
782
|
cameraRatio = camera.ratio
|
|
552
783
|
})
|
|
784
|
+
// Every repaint (pan, zoom, resize, refresh) reprojects the group-in-a-box
|
|
785
|
+
// overlay so its boxes/edges track the camera. No-op when the mode is off.
|
|
786
|
+
instance.on('afterRender', () => syncOverlay())
|
|
553
787
|
}
|
|
554
788
|
|
|
555
789
|
// Create / tear down the sigma (WebGL) instance with the canvas view.
|
|
@@ -563,18 +797,26 @@
|
|
|
563
797
|
void (async () => {
|
|
564
798
|
try {
|
|
565
799
|
if (!sigmaModules) {
|
|
566
|
-
const [sig, nb] = await Promise.all([
|
|
800
|
+
const [sig, nb, ec] = await Promise.all([
|
|
567
801
|
import('sigma'),
|
|
568
802
|
import('@sigma/node-border'),
|
|
803
|
+
import('@sigma/edge-curve'),
|
|
569
804
|
])
|
|
570
|
-
sigmaModules = {
|
|
805
|
+
sigmaModules = {
|
|
806
|
+
Sigma: sig.default,
|
|
807
|
+
NodeBorderProgram: nb.NodeBorderProgram,
|
|
808
|
+
EdgeCurveProgram: ec.default,
|
|
809
|
+
}
|
|
571
810
|
}
|
|
572
811
|
if (killed || !sigmaContainer) return
|
|
573
|
-
const { Sigma, NodeBorderProgram } = sigmaModules
|
|
812
|
+
const { Sigma, NodeBorderProgram, EdgeCurveProgram } = sigmaModules
|
|
574
813
|
instance = new Sigma(buildCurrentSigmaGraph(), container, {
|
|
575
814
|
allowInvalidContainer: true,
|
|
576
815
|
defaultNodeType: 'border',
|
|
577
816
|
nodeProgramClasses: { border: NodeBorderProgram },
|
|
817
|
+
// 'line' is the built-in straight program; 'curved' bows bundled
|
|
818
|
+
// edges. The edge reducer picks per-edge based on the bundle toggle.
|
|
819
|
+
edgeProgramClasses: { curved: EdgeCurveProgram },
|
|
578
820
|
defaultEdgeType: 'line',
|
|
579
821
|
renderEdgeLabels: true,
|
|
580
822
|
labelFont: '"JetBrains Mono Variable", monospace',
|
|
@@ -610,12 +852,14 @@
|
|
|
610
852
|
|
|
611
853
|
// Swap the graph in place when the draw set, layout, or theme changes.
|
|
612
854
|
$effect(() => {
|
|
613
|
-
void
|
|
614
|
-
void
|
|
615
|
-
void
|
|
855
|
+
void renderNodes
|
|
856
|
+
void renderEdges
|
|
857
|
+
void renderLayout
|
|
616
858
|
void nodeSizeScales
|
|
617
859
|
void orphanNodeIds
|
|
618
860
|
void graphThemeVersion
|
|
861
|
+
void bundleCurvatures
|
|
862
|
+
void bundleEnabled
|
|
619
863
|
const instance = sigmaInstance
|
|
620
864
|
if (!instance || viewMode !== 'canvas') return
|
|
621
865
|
// Label colours are static settings (reducers can't set them), so refresh
|
|
@@ -637,6 +881,19 @@
|
|
|
637
881
|
instance.refresh({ skipIndexation: true })
|
|
638
882
|
})
|
|
639
883
|
|
|
884
|
+
// Group-in-a-box: toggling re-runs the edge reducer (cross-community edges
|
|
885
|
+
// hide), and any geometry/theme change re-projects the overlay onto the
|
|
886
|
+
// current camera. `afterRender` keeps it tracking subsequent repaints.
|
|
887
|
+
$effect(() => {
|
|
888
|
+
void groupInBox
|
|
889
|
+
void communityBoxes
|
|
890
|
+
void routedEdges
|
|
891
|
+
const instance = sigmaInstance
|
|
892
|
+
if (!instance || viewMode !== 'canvas') return
|
|
893
|
+
instance.refresh({ skipIndexation: true })
|
|
894
|
+
syncOverlay()
|
|
895
|
+
})
|
|
896
|
+
|
|
640
897
|
function onSvgNodePointerEnter(node: GraphNode) {
|
|
641
898
|
if (!pinnedFocusNodeId) hoveredFocusNodeId = node.id
|
|
642
899
|
}
|
|
@@ -658,6 +915,7 @@
|
|
|
658
915
|
}
|
|
659
916
|
|
|
660
917
|
function showSvgLabel(node: FallbackSvgNode): boolean {
|
|
918
|
+
if (node.data.__supernode === true) return true
|
|
661
919
|
if (!activeFocusNodeId) return false
|
|
662
920
|
if (activeFocusNodeId === node.id) return true
|
|
663
921
|
return focusNodeIds.size <= 36 && focusNodeIds.has(node.id)
|
|
@@ -784,6 +1042,55 @@
|
|
|
784
1042
|
</div>
|
|
785
1043
|
{/if}
|
|
786
1044
|
|
|
1045
|
+
{#if viewMode !== 'table'}
|
|
1046
|
+
<button
|
|
1047
|
+
type="button"
|
|
1048
|
+
class={[
|
|
1049
|
+
'inline-flex items-center gap-1 h-6 px-2 rounded border cursor-pointer transition-colors',
|
|
1050
|
+
groupInBox ? 'border-accent/40 bg-accent/10 text-accent' : 'border-line-1 text-fg-3 hover:text-fg-1 hover:border-line-2',
|
|
1051
|
+
].join(' ')}
|
|
1052
|
+
onclick={() => (groupInBox = !groupInBox)}
|
|
1053
|
+
aria-pressed={groupInBox}
|
|
1054
|
+
title="Group in a box — draw one tinted box per community and route inter-community edges at the borders"
|
|
1055
|
+
>
|
|
1056
|
+
<Group class="size-3" />
|
|
1057
|
+
groups
|
|
1058
|
+
</button>
|
|
1059
|
+
<button
|
|
1060
|
+
type="button"
|
|
1061
|
+
class={[
|
|
1062
|
+
'inline-flex items-center gap-1 h-6 px-2 rounded border cursor-pointer transition-colors',
|
|
1063
|
+
bundleEnabled ? 'border-accent/40 bg-accent/10 text-accent' : 'border-line-1 text-fg-3 hover:text-fg-1 hover:border-line-2',
|
|
1064
|
+
].join(' ')}
|
|
1065
|
+
onclick={() => (bundleEnabled = !bundleEnabled)}
|
|
1066
|
+
aria-pressed={bundleEnabled}
|
|
1067
|
+
title="Force-directed edge bundling — collapse parallel edges onto shared trunks to untangle dense subgraphs"
|
|
1068
|
+
>
|
|
1069
|
+
<Spline class="size-3" />
|
|
1070
|
+
bundle
|
|
1071
|
+
</button>
|
|
1072
|
+
{#if bundleTooLarge}
|
|
1073
|
+
<span class="text-warn" title={`Bundling is skipped above ${BUNDLE_EDGE_LIMIT.toLocaleString()} drawn edges — narrow the filter or lower the limit`}>
|
|
1074
|
+
bundling skipped ({drawEdges.length.toLocaleString()} edges)
|
|
1075
|
+
</span>
|
|
1076
|
+
{/if}
|
|
1077
|
+
{#if collapsibleCommunities.length > 0}
|
|
1078
|
+
<button
|
|
1079
|
+
type="button"
|
|
1080
|
+
class={[
|
|
1081
|
+
'inline-flex items-center gap-1 h-6 px-2 rounded border cursor-pointer transition-colors',
|
|
1082
|
+
anyCollapsed ? 'border-accent/40 bg-accent/10 text-accent' : 'border-line-1 text-fg-3 hover:text-fg-1 hover:border-line-2',
|
|
1083
|
+
].join(' ')}
|
|
1084
|
+
onclick={toggleCollapseAll}
|
|
1085
|
+
aria-pressed={anyCollapsed}
|
|
1086
|
+
title={anyCollapsed ? 'Expand all collapsed clusters back to their members' : 'Collapse each community into a single supernode'}
|
|
1087
|
+
>
|
|
1088
|
+
{#if anyCollapsed}<Group class="size-3" />{:else}<Boxes class="size-3" />{/if}
|
|
1089
|
+
{anyCollapsed ? `expand (${collapsedCommunities.size})` : `collapse ${collapsibleCommunities.length} clusters`}
|
|
1090
|
+
</button>
|
|
1091
|
+
{/if}
|
|
1092
|
+
{/if}
|
|
1093
|
+
|
|
787
1094
|
<button
|
|
788
1095
|
type="button"
|
|
789
1096
|
class={[
|
|
@@ -944,6 +1251,36 @@
|
|
|
944
1251
|
aria-label={`${drawNodes.length} graph nodes and ${drawEdges.length} graph edges`}
|
|
945
1252
|
onkeydown={(event) => { if (event.key === 'Escape') { clearGraphFocus(); clearSelection() } }}
|
|
946
1253
|
></div>
|
|
1254
|
+
{#if groupInBox && (overlayBoxes.length > 0 || overlayEdges.length > 0)}
|
|
1255
|
+
<!-- Group-in-a-box overlay, projected into viewport pixels and synced
|
|
1256
|
+
to the camera. pointer-events stay off so sigma keeps the input. -->
|
|
1257
|
+
<svg class="pointer-events-none absolute inset-0 h-full w-full" aria-hidden="true">
|
|
1258
|
+
{#each overlayBoxes as box (box.community)}
|
|
1259
|
+
<rect
|
|
1260
|
+
x={box.vx}
|
|
1261
|
+
y={box.vy}
|
|
1262
|
+
width={box.vw}
|
|
1263
|
+
height={box.vh}
|
|
1264
|
+
rx="6"
|
|
1265
|
+
fill={box.fill}
|
|
1266
|
+
stroke={box.stroke}
|
|
1267
|
+
stroke-width="1.5"
|
|
1268
|
+
/>
|
|
1269
|
+
{/each}
|
|
1270
|
+
{#each overlayEdges as e (e.id)}
|
|
1271
|
+
<line
|
|
1272
|
+
x1={e.vx1}
|
|
1273
|
+
y1={e.vy1}
|
|
1274
|
+
x2={e.vx2}
|
|
1275
|
+
y2={e.vy2}
|
|
1276
|
+
stroke="var(--color-line-3)"
|
|
1277
|
+
stroke-width="1"
|
|
1278
|
+
stroke-dasharray="4 3"
|
|
1279
|
+
opacity="0.7"
|
|
1280
|
+
/>
|
|
1281
|
+
{/each}
|
|
1282
|
+
</svg>
|
|
1283
|
+
{/if}
|
|
947
1284
|
{:else if viewMode === 'svg'}
|
|
948
1285
|
<div class="absolute inset-0 overflow-hidden bg-bg-0">
|
|
949
1286
|
<svg
|
|
@@ -959,31 +1296,85 @@
|
|
|
959
1296
|
>
|
|
960
1297
|
<rect x="0" y="0" width="100" height="100" fill="transparent" />
|
|
961
1298
|
<g transform={graphTransform}>
|
|
1299
|
+
{#if groupInBox}
|
|
1300
|
+
<!-- Community boxes behind everything; routed inter-community
|
|
1301
|
+
edges connect box borders rather than node centres. -->
|
|
1302
|
+
<g class="pointer-events-none">
|
|
1303
|
+
{#each communityBoxes as box (box.community)}
|
|
1304
|
+
<rect
|
|
1305
|
+
x={box.minX}
|
|
1306
|
+
y={box.minY}
|
|
1307
|
+
width={box.maxX - box.minX}
|
|
1308
|
+
height={box.maxY - box.minY}
|
|
1309
|
+
rx="1.5"
|
|
1310
|
+
fill={box.fill}
|
|
1311
|
+
stroke={box.stroke}
|
|
1312
|
+
stroke-width="0.18"
|
|
1313
|
+
vector-effect="non-scaling-stroke"
|
|
1314
|
+
/>
|
|
1315
|
+
{/each}
|
|
1316
|
+
{#each routedEdges as e (e.id)}
|
|
1317
|
+
<line
|
|
1318
|
+
x1={e.x1}
|
|
1319
|
+
y1={e.y1}
|
|
1320
|
+
x2={e.x2}
|
|
1321
|
+
y2={e.y2}
|
|
1322
|
+
stroke="currentColor"
|
|
1323
|
+
stroke-width="0.12"
|
|
1324
|
+
stroke-dasharray="0.8 0.6"
|
|
1325
|
+
opacity="0.6"
|
|
1326
|
+
class="text-line-3"
|
|
1327
|
+
vector-effect="non-scaling-stroke"
|
|
1328
|
+
/>
|
|
1329
|
+
{/each}
|
|
1330
|
+
</g>
|
|
1331
|
+
{/if}
|
|
962
1332
|
<g>
|
|
963
1333
|
{#each fallbackSvgEdges as e (e.id)}
|
|
1334
|
+
{#if !(groupInBox && routedEdgeIds.has(e.id))}
|
|
964
1335
|
{@const focusedEdge = focusEdgeIds.has(e.id)}
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
1336
|
+
{#if e.points}
|
|
1337
|
+
<polyline
|
|
1338
|
+
points={e.points}
|
|
1339
|
+
fill="none"
|
|
1340
|
+
stroke={activeFocusNodeId ? (focusedEdge ? focusEdgeColor : 'currentColor') : 'currentColor'}
|
|
1341
|
+
stroke-width={activeFocusNodeId ? (focusedEdge ? '0.27' : '0.055') : '0.105'}
|
|
1342
|
+
opacity={activeFocusNodeId ? (focusedEdge ? '0.98' : '0.07') : '0.4'}
|
|
1343
|
+
stroke-linejoin="round"
|
|
1344
|
+
class="text-line-3 cursor-pointer hover:text-accent"
|
|
1345
|
+
vector-effect="non-scaling-stroke"
|
|
1346
|
+
role="button"
|
|
1347
|
+
tabindex="0"
|
|
1348
|
+
aria-label={`${e.label ?? 'edge'} from ${e.source} to ${e.target}`}
|
|
1349
|
+
onclick={(event) => { event.stopPropagation(); clearGraphFocus(); selectEdge(e) }}
|
|
1350
|
+
onkeydown={(event) => onSelectableKey(event, () => { clearGraphFocus(); selectEdge(e) })}
|
|
1351
|
+
>
|
|
1352
|
+
<title>{e.label ?? 'edge'}: {e.source} -> {e.target}</title>
|
|
1353
|
+
</polyline>
|
|
1354
|
+
{:else}
|
|
1355
|
+
<line
|
|
1356
|
+
x1={e.x1}
|
|
1357
|
+
y1={e.y1}
|
|
1358
|
+
x2={e.x2}
|
|
1359
|
+
y2={e.y2}
|
|
1360
|
+
stroke={activeFocusNodeId ? (focusedEdge ? focusEdgeColor : 'currentColor') : 'currentColor'}
|
|
1361
|
+
stroke-width={activeFocusNodeId ? (focusedEdge ? '0.27' : '0.055') : '0.105'}
|
|
1362
|
+
opacity={activeFocusNodeId ? (focusedEdge ? '0.98' : '0.055') : '0.36'}
|
|
1363
|
+
class="text-line-3 cursor-pointer hover:text-accent"
|
|
1364
|
+
vector-effect="non-scaling-stroke"
|
|
1365
|
+
role="button"
|
|
1366
|
+
tabindex="0"
|
|
1367
|
+
aria-label={`${e.label ?? 'edge'} from ${e.source} to ${e.target}`}
|
|
1368
|
+
onclick={(event) => { event.stopPropagation(); clearGraphFocus(); selectEdge(e) }}
|
|
1369
|
+
onkeydown={(event) => onSelectableKey(event, () => { clearGraphFocus(); selectEdge(e) })}
|
|
1370
|
+
>
|
|
1371
|
+
<title>{e.label ?? 'edge'}: {e.source} -> {e.target}</title>
|
|
1372
|
+
</line>
|
|
1373
|
+
{/if}
|
|
983
1374
|
{#if showSvgEdgeLabel(e)}
|
|
984
1375
|
<text
|
|
985
|
-
x={
|
|
986
|
-
y={
|
|
1376
|
+
x={e.mx + 0.45}
|
|
1377
|
+
y={e.my - 0.45}
|
|
987
1378
|
fill="#f6d36b"
|
|
988
1379
|
opacity="0.96"
|
|
989
1380
|
font-size="1"
|
|
@@ -997,6 +1388,7 @@
|
|
|
997
1388
|
{e.label?.slice(0, 28)}
|
|
998
1389
|
</text>
|
|
999
1390
|
{/if}
|
|
1391
|
+
{/if}
|
|
1000
1392
|
{/each}
|
|
1001
1393
|
</g>
|
|
1002
1394
|
<g>
|
|
@@ -1015,26 +1407,27 @@
|
|
|
1015
1407
|
class="pointer-events-none"
|
|
1016
1408
|
/>
|
|
1017
1409
|
{/if}
|
|
1410
|
+
{@const supernode = n.data.__supernode === true}
|
|
1018
1411
|
<circle
|
|
1019
1412
|
cx={n.x}
|
|
1020
1413
|
cy={n.y}
|
|
1021
1414
|
r={svgNodeRadius(n)}
|
|
1022
1415
|
fill={n.color}
|
|
1023
1416
|
opacity={svgNodeOpacity(n)}
|
|
1024
|
-
stroke={activeFocusNodeId === n.id ? focusNodeStrokeColor : selectedNode?.id === n.id ? '#0a0a0b' : orphan ? 'var(--color-warn)' : '#ffffff'}
|
|
1025
|
-
stroke-width={activeFocusNodeId === n.id ? '0.55' : focusNodeIds.has(n.id) ? '0.22' : selectedNode?.id === n.id || orphan ? '0.4' : '0.14'}
|
|
1417
|
+
stroke={activeFocusNodeId === n.id ? focusNodeStrokeColor : supernode ? '#e2e8f0' : selectedNode?.id === n.id ? '#0a0a0b' : orphan ? 'var(--color-warn)' : '#ffffff'}
|
|
1418
|
+
stroke-width={activeFocusNodeId === n.id ? '0.55' : supernode ? '0.5' : focusNodeIds.has(n.id) ? '0.22' : selectedNode?.id === n.id || orphan ? '0.4' : '0.14'}
|
|
1026
1419
|
class="cursor-pointer graph-node"
|
|
1027
1420
|
class:focus-primary={activeFocusNodeId === n.id}
|
|
1028
1421
|
vector-effect="non-scaling-stroke"
|
|
1029
1422
|
role="button"
|
|
1030
1423
|
tabindex="0"
|
|
1031
|
-
aria-label={n.label}
|
|
1424
|
+
aria-label={supernode ? `${n.label} — click to expand` : n.label}
|
|
1032
1425
|
onpointerenter={() => onSvgNodePointerEnter(n)}
|
|
1033
1426
|
onpointerleave={() => onSvgNodePointerLeave(n)}
|
|
1034
|
-
onclick={(event) => { event.stopPropagation();
|
|
1035
|
-
onkeydown={(event) => onSelectableKey(event, () =>
|
|
1427
|
+
onclick={(event) => { event.stopPropagation(); onGraphNodeClick(n.id) }}
|
|
1428
|
+
onkeydown={(event) => onSelectableKey(event, () => onGraphNodeClick(n.id))}
|
|
1036
1429
|
>
|
|
1037
|
-
<title>{n.label}</title>
|
|
1430
|
+
<title>{supernode ? `${n.label} — click to expand` : n.label}</title>
|
|
1038
1431
|
</circle>
|
|
1039
1432
|
{#if showSvgLabel(n)}
|
|
1040
1433
|
<text
|
|
@@ -1123,6 +1516,17 @@
|
|
|
1123
1516
|
{/if}
|
|
1124
1517
|
</div>
|
|
1125
1518
|
<div class="mt-1 break-all text-accent">{String(selectedNode.data.rid ?? selectedNode.id)}</div>
|
|
1519
|
+
{#if selectedNodeCommunity !== null && (communityCounts.get(selectedNodeCommunity) ?? 0) >= 2}
|
|
1520
|
+
<button
|
|
1521
|
+
type="button"
|
|
1522
|
+
class="mt-2 inline-flex items-center gap-1.5 rounded border border-line-2 bg-bg-0 px-2 py-1 text-[11px] text-fg-2 hover:border-accent hover:text-fg-0 cursor-pointer"
|
|
1523
|
+
onclick={() => collapseCommunity(selectedNodeCommunity!)}
|
|
1524
|
+
title="Fold this node's community into a single supernode"
|
|
1525
|
+
>
|
|
1526
|
+
<Boxes class="size-3" />
|
|
1527
|
+
collapse cluster {selectedNodeCommunity} · {communityCounts.get(selectedNodeCommunity)} nodes
|
|
1528
|
+
</button>
|
|
1529
|
+
{/if}
|
|
1126
1530
|
</div>
|
|
1127
1531
|
|
|
1128
1532
|
<div class="mb-3 border-t border-line-1 pt-3">
|