Skip to content

Commit a6b0ba9

Browse files
committed
feat: add Louvain community detection to knowledge graph
- Run graphology-communities-louvain on wiki graph after edge calculation - Compute per-community cohesion score (intra-edge density) - Add Type/Community toggle in graph header to switch coloring mode - Community legend shows top node label, member count, low cohesion warning - 12-color palette for community visualization - Recursive community ID renumbering for stable sequential IDs
1 parent d6e4462 commit a6b0ba9

4 files changed

Lines changed: 273 additions & 41 deletions

File tree

package-lock.json

Lines changed: 53 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"class-variance-authority": "^0.7.1",
2424
"clsx": "^2.1.1",
2525
"graphology": "^0.26.0",
26+
"graphology-communities-louvain": "^2.0.2",
2627
"graphology-layout-forceatlas2": "^0.10.1",
2728
"i18next": "^26.0.3",
2829
"lucide-react": "^1.7.0",

src/components/graph/graph-view.tsx

Lines changed: 110 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@ import Graph from "graphology"
33
import { SigmaContainer, useLoadGraph, useRegisterEvents, useSigma } from "@react-sigma/core"
44
import "@react-sigma/core/lib/style.css"
55
import forceAtlas2 from "graphology-layout-forceatlas2"
6-
import { Network, RefreshCw, ZoomIn, ZoomOut, Maximize } from "lucide-react"
6+
import { Network, RefreshCw, ZoomIn, ZoomOut, Maximize, Layers, Tag } from "lucide-react"
77
import { Button } from "@/components/ui/button"
88
import { useWikiStore } from "@/stores/wiki-store"
99
import { readFile } from "@/commands/fs"
10-
import { buildWikiGraph, type GraphNode, type GraphEdge } from "@/lib/wiki-graph"
10+
import { buildWikiGraph, type GraphNode, type GraphEdge, type CommunityInfo } from "@/lib/wiki-graph"
1111
import { normalizePath } from "@/lib/path-utils"
1212

1313
const NODE_TYPE_COLORS: Record<string, string> = {
@@ -32,6 +32,23 @@ const NODE_TYPE_LABELS: Record<string, string> = {
3232
other: "Other",
3333
}
3434

35+
const COMMUNITY_COLORS = [
36+
"#60a5fa", // blue-400
37+
"#4ade80", // green-400
38+
"#fb923c", // orange-400
39+
"#c084fc", // purple-400
40+
"#f87171", // red-400
41+
"#2dd4bf", // teal-400
42+
"#facc15", // yellow-400
43+
"#f472b6", // pink-400
44+
"#a78bfa", // violet-400
45+
"#38bdf8", // sky-400
46+
"#34d399", // emerald-400
47+
"#fbbf24", // amber-400
48+
]
49+
50+
type ColorMode = "type" | "community"
51+
3552
const BASE_NODE_SIZE = 8
3653
const MAX_NODE_SIZE = 28
3754

@@ -68,7 +85,7 @@ function nodeSize(linkCount: number, maxLinks: number): number {
6885
const positionCache = new Map<string, { x: number; y: number }>()
6986
let lastLayoutDataKey = ""
7087

71-
function GraphLoader({ nodes, edges }: { nodes: GraphNode[]; edges: GraphEdge[] }) {
88+
function GraphLoader({ nodes, edges, colorMode }: { nodes: GraphNode[]; edges: GraphEdge[]; colorMode: ColorMode }) {
7289
const loadGraph = useLoadGraph()
7390

7491
useEffect(() => {
@@ -79,16 +96,19 @@ function GraphLoader({ nodes, edges }: { nodes: GraphNode[]; edges: GraphEdge[]
7996
const maxLinks = Math.max(...nodes.map((n) => n.linkCount), 1)
8097

8198
for (const node of nodes) {
82-
// Reuse cached positions if available, otherwise random
8399
const cached = positionCache.get(node.id)
100+
const color = colorMode === "community"
101+
? COMMUNITY_COLORS[node.community % COMMUNITY_COLORS.length]
102+
: nodeColor(node.type)
84103
graph.addNode(node.id, {
85104
x: cached?.x ?? Math.random() * 100,
86105
y: cached?.y ?? Math.random() * 100,
87106
size: nodeSize(node.linkCount, maxLinks),
88-
color: nodeColor(node.type),
107+
color,
89108
label: node.label,
90109
nodeType: node.type,
91110
nodePath: node.path,
111+
community: node.community,
92112
})
93113
}
94114

@@ -135,7 +155,7 @@ function GraphLoader({ nodes, edges }: { nodes: GraphNode[]; edges: GraphEdge[]
135155
}
136156

137157
loadGraph(graph)
138-
}, [loadGraph, nodes, edges])
158+
}, [loadGraph, nodes, edges, colorMode])
139159

140160
return null
141161
}
@@ -238,9 +258,11 @@ export function GraphView() {
238258

239259
const [nodes, setNodes] = useState<GraphNode[]>([])
240260
const [edges, setEdges] = useState<GraphEdge[]>([])
261+
const [communities, setCommunities] = useState<CommunityInfo[]>([])
241262
const [loading, setLoading] = useState(false)
242263
const [error, setError] = useState<string | null>(null)
243264
const [hoveredType, setHoveredType] = useState<string | null>(null)
265+
const [colorMode, setColorMode] = useState<ColorMode>("type")
244266
const lastLoadedVersion = useRef(-1)
245267

246268
const loadGraph = useCallback(async () => {
@@ -251,6 +273,7 @@ export function GraphView() {
251273
const result = await buildWikiGraph(normalizePath(project.path))
252274
setNodes(result.nodes)
253275
setEdges(result.edges)
276+
setCommunities(result.communities)
254277
lastLoadedVersion.current = useWikiStore.getState().dataVersion
255278
} catch (err) {
256279
const message = err instanceof Error ? err.message : "Failed to build graph"
@@ -339,10 +362,29 @@ export function GraphView() {
339362
<span className="rounded bg-muted px-1.5 py-0.5">{edges.length} links</span>
340363
</div>
341364
</div>
342-
<Button variant="ghost" size="sm" onClick={loadGraph} className="text-xs gap-1">
343-
<RefreshCw className="h-3.5 w-3.5" />
344-
Reload
345-
</Button>
365+
<div className="flex items-center gap-1">
366+
<Button
367+
variant={colorMode === "type" ? "secondary" : "ghost"}
368+
size="sm"
369+
onClick={() => setColorMode("type")}
370+
className="text-xs gap-1 h-7"
371+
>
372+
<Tag className="h-3 w-3" />
373+
Type
374+
</Button>
375+
<Button
376+
variant={colorMode === "community" ? "secondary" : "ghost"}
377+
size="sm"
378+
onClick={() => setColorMode("community")}
379+
className="text-xs gap-1 h-7"
380+
>
381+
<Layers className="h-3 w-3" />
382+
Community
383+
</Button>
384+
<Button variant="ghost" size="sm" onClick={loadGraph} className="text-xs gap-1 h-7">
385+
<RefreshCw className="h-3.5 w-3.5" />
386+
</Button>
387+
</div>
346388
</div>
347389

348390
{/* Graph canvas */}
@@ -391,38 +433,69 @@ export function GraphView() {
391433
},
392434
}}
393435
>
394-
<GraphLoader nodes={nodes} edges={edges} />
436+
<GraphLoader nodes={nodes} edges={edges} colorMode={colorMode} />
395437
<EventHandler onNodeClick={handleNodeClick} />
396438
<ZoomControls />
397439
</SigmaContainer>
398440

399441
{/* Legend */}
400-
<div className="absolute bottom-3 left-3 rounded-lg border bg-background/90 backdrop-blur-sm px-3 py-2 text-xs shadow-sm">
401-
<div className="mb-1.5 font-semibold text-foreground">Node Types</div>
402-
<div className="flex flex-col gap-0.5">
403-
{Object.entries(NODE_TYPE_LABELS)
404-
.filter(([type]) => (typeCounts[type] ?? 0) > 0)
405-
.map(([type, label]) => (
406-
<div
407-
key={type}
408-
className="flex items-center gap-2 rounded px-1 py-0.5 transition-colors hover:bg-accent/50"
409-
onMouseEnter={() => setHoveredType(type)}
410-
onMouseLeave={() => setHoveredType(null)}
411-
>
412-
<span
413-
className="inline-block h-3 w-3 rounded-full shrink-0 shadow-sm"
414-
style={{
415-
backgroundColor: NODE_TYPE_COLORS[type],
416-
boxShadow: `0 0 4px ${hexToRgba(NODE_TYPE_COLORS[type] ?? "#94a3b8", 0.4)}`,
417-
}}
418-
/>
419-
<span className={hoveredType === type ? "text-foreground font-medium" : "text-muted-foreground"}>
420-
{label}
421-
</span>
422-
<span className="text-muted-foreground/60 ml-auto">{typeCounts[type]}</span>
423-
</div>
424-
))}
425-
</div>
442+
<div className="absolute bottom-3 left-3 rounded-lg border bg-background/90 backdrop-blur-sm px-3 py-2 text-xs shadow-sm max-w-[260px]">
443+
{colorMode === "type" ? (
444+
<>
445+
<div className="mb-1.5 font-semibold text-foreground">Node Types</div>
446+
<div className="flex flex-col gap-0.5">
447+
{Object.entries(NODE_TYPE_LABELS)
448+
.filter(([type]) => (typeCounts[type] ?? 0) > 0)
449+
.map(([type, label]) => (
450+
<div
451+
key={type}
452+
className="flex items-center gap-2 rounded px-1 py-0.5 transition-colors hover:bg-accent/50"
453+
onMouseEnter={() => setHoveredType(type)}
454+
onMouseLeave={() => setHoveredType(null)}
455+
>
456+
<span
457+
className="inline-block h-3 w-3 rounded-full shrink-0 shadow-sm"
458+
style={{
459+
backgroundColor: NODE_TYPE_COLORS[type],
460+
boxShadow: `0 0 4px ${hexToRgba(NODE_TYPE_COLORS[type] ?? "#94a3b8", 0.4)}`,
461+
}}
462+
/>
463+
<span className={hoveredType === type ? "text-foreground font-medium" : "text-muted-foreground"}>
464+
{label}
465+
</span>
466+
<span className="text-muted-foreground/60 ml-auto">{typeCounts[type]}</span>
467+
</div>
468+
))}
469+
</div>
470+
</>
471+
) : (
472+
<>
473+
<div className="mb-1.5 font-semibold text-foreground">Communities</div>
474+
<div className="flex flex-col gap-0.5">
475+
{communities.map((c) => (
476+
<div
477+
key={c.id}
478+
className="flex items-center gap-2 rounded px-1 py-0.5 transition-colors hover:bg-accent/50"
479+
>
480+
<span
481+
className="inline-block h-3 w-3 rounded-full shrink-0 shadow-sm"
482+
style={{
483+
backgroundColor: COMMUNITY_COLORS[c.id % COMMUNITY_COLORS.length],
484+
boxShadow: `0 0 4px ${hexToRgba(COMMUNITY_COLORS[c.id % COMMUNITY_COLORS.length], 0.4)}`,
485+
}}
486+
/>
487+
<span className="text-muted-foreground truncate" title={c.topNodes.join(", ")}>
488+
{c.topNodes[0] ?? `Cluster ${c.id}`}
489+
</span>
490+
<span className="text-muted-foreground/60 ml-auto shrink-0">{c.nodeCount}</span>
491+
{c.cohesion < 0.15 && c.nodeCount >= 3 && (
492+
<span className="text-amber-500 shrink-0" title={`Low cohesion: ${c.cohesion.toFixed(2)}`}>!</span>
493+
)}
494+
</div>
495+
))}
496+
</div>
497+
</>
498+
)}
426499
</div>
427500
</div>
428501

0 commit comments

Comments
 (0)