@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.
Files changed (60) hide show
  1. package/build/_app/immutable/assets/0.DQDLkSdR.css +1 -0
  2. package/build/_app/immutable/assets/Workspace.CvP5De7i.css +1 -0
  3. package/build/_app/immutable/chunks/65XjEwaF.js +367 -0
  4. package/build/_app/immutable/chunks/BjFADNkA.js +992 -0
  5. package/build/_app/immutable/chunks/{ALqaDrNQ.js → C278awGv.js} +1 -1
  6. package/build/_app/immutable/chunks/{Ov8N-vxt.js → C9rXzYLu.js} +1 -1
  7. package/build/_app/immutable/chunks/CDKIdLnm.js +187 -0
  8. package/build/_app/immutable/chunks/{DXY_Rxa7.js → CXNPJgv1.js} +7 -7
  9. package/build/_app/immutable/chunks/{Bgso8cDH.js → DqvWKbSc.js} +1 -1
  10. package/build/_app/immutable/chunks/pDJnDxXM.js +1 -0
  11. package/build/_app/immutable/entry/app.DdaMXHAv.js +2 -0
  12. package/build/_app/immutable/entry/start.-MLBtQXB.js +1 -0
  13. package/build/_app/immutable/nodes/{0.CHPiFxDo.js → 0.Ysoz3Y3h.js} +1 -1
  14. package/build/_app/immutable/nodes/{1.BC_4YZ0h.js → 1.C2jbGTOJ.js} +1 -1
  15. package/build/_app/immutable/nodes/11.CAG5_cGP.js +1 -0
  16. package/build/_app/immutable/nodes/{10.B7DMKSNc.js → 12.DQG5zpEx.js} +5 -5
  17. package/build/_app/immutable/nodes/7.CAG5_cGP.js +1 -0
  18. package/build/_app/immutable/nodes/{7.BZX9Iird.js → 8.CrHI4F9p.js} +1 -1
  19. package/build/_app/version.json +1 -1
  20. package/build/index.html +12 -12
  21. package/dist/embed/{RedUiEmbedRoot-BGoLxJf1.js → RedUiEmbedRoot-BwFUA5X-.js} +12715 -10653
  22. package/dist/embed/RedUiEmbedRoot-BwFUA5X-.js.map +1 -0
  23. package/dist/embed/index-DGhgtnaK.js +577 -0
  24. package/dist/embed/{index-B6ZQhCsu.js.map → index-DGhgtnaK.js.map} +1 -1
  25. package/dist/embed/{index-fad77a13.esm-BqPj0saH.js → index-fad77a13.esm-BMULHdcE.js} +22 -20
  26. package/dist/embed/{index-fad77a13.esm-BqPj0saH.js.map → index-fad77a13.esm-BMULHdcE.js.map} +1 -1
  27. package/dist/embed/index.js +1 -1
  28. package/dist/embed/sigma-edge-curve.esm-CAT5Xrom.js +536 -0
  29. package/dist/embed/sigma-edge-curve.esm-CAT5Xrom.js.map +1 -0
  30. package/dist/embed/{sigma-node-border.esm-CGtlgqmo.js → sigma-node-border.esm-Bnk4BRbw.js} +32 -32
  31. package/dist/embed/{sigma-node-border.esm-CGtlgqmo.js.map → sigma-node-border.esm-Bnk4BRbw.js.map} +1 -1
  32. package/dist/embed/{sigma.esm-y0qgY-OC.js → sigma.esm-D5yoExsH.js} +3 -3
  33. package/dist/embed/{sigma.esm-y0qgY-OC.js.map → sigma.esm-D5yoExsH.js.map} +1 -1
  34. package/package.json +5 -3
  35. package/src/lib/Appearance.svelte +90 -0
  36. package/src/lib/CommandPalette.svelte +2 -0
  37. package/src/lib/SettingsView.svelte +376 -0
  38. package/src/lib/Topbar.svelte +15 -1
  39. package/src/lib/Workspace.svelte +14 -1
  40. package/src/lib/index.ts +2 -0
  41. package/src/lib/renderers/GraphRenderer.svelte +448 -44
  42. package/src/lib/renderers/graph-bundle.ts +401 -0
  43. package/src/lib/renderers/graph-communities.ts +193 -0
  44. package/src/lib/renderers/graph-render.ts +48 -5
  45. package/src/lib/renderers/graph-sigma.ts +274 -8
  46. package/src/lib/router.svelte.ts +84 -57
  47. package/src/lib/settings-sections.ts +267 -0
  48. package/src/lib/skins/skins.svelte.ts +64 -0
  49. package/src/lib/skins/skins.ts +178 -0
  50. package/build/_app/immutable/assets/0.C63jY5jX.css +0 -1
  51. package/build/_app/immutable/assets/Workspace.BCzG1DsH.css +0 -1
  52. package/build/_app/immutable/chunks/BMHKbvs8.js +0 -1
  53. package/build/_app/immutable/chunks/BQHbwf54.js +0 -916
  54. package/build/_app/immutable/chunks/Bmgx1mfv.js +0 -310
  55. package/build/_app/immutable/entry/app.D_IVtpdv.js +0 -2
  56. package/build/_app/immutable/entry/start.3oC0UQ_Q.js +0 -1
  57. package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js.map +0 -1
  58. package/dist/embed/index-B6ZQhCsu.js +0 -577
  59. /package/build/_app/immutable/assets/{10.Dblyk0Kr.css → 12.Dblyk0Kr.css} +0 -0
  60. /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 drawNodes.map((node) => {
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 = layout.get(node.id)
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
- for (const edge of drawEdges) {
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: drawNodes,
511
- edges: drawEdges,
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 drawEdges.find((e) => e.id === id) ?? null
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
- const gn = nodeById(node)
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 = { Sigma: sig.default, NodeBorderProgram: nb.NodeBorderProgram }
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 drawNodes
614
- void drawEdges
615
- void layout
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
- <line
966
- x1={e.x1}
967
- y1={e.y1}
968
- x2={e.x2}
969
- y2={e.y2}
970
- stroke={activeFocusNodeId ? (focusedEdge ? focusEdgeColor : 'currentColor') : 'currentColor'}
971
- stroke-width={activeFocusNodeId ? (focusedEdge ? '0.27' : '0.055') : '0.105'}
972
- opacity={activeFocusNodeId ? (focusedEdge ? '0.98' : '0.055') : '0.36'}
973
- class="text-line-3 cursor-pointer hover:text-accent"
974
- vector-effect="non-scaling-stroke"
975
- role="button"
976
- tabindex="0"
977
- aria-label={`${e.label ?? 'edge'} from ${e.source} to ${e.target}`}
978
- onclick={(event) => { event.stopPropagation(); clearGraphFocus(); selectEdge(e) }}
979
- onkeydown={(event) => onSelectableKey(event, () => { clearGraphFocus(); selectEdge(e) })}
980
- >
981
- <title>{e.label ?? 'edge'}: {e.source} -> {e.target}</title>
982
- </line>
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={(e.x1 + e.x2) / 2 + 0.45}
986
- y={(e.y1 + e.y2) / 2 - 0.45}
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(); clickGraphNode(n) }}
1035
- onkeydown={(event) => onSelectableKey(event, () => clickGraphNode(n))}
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">