@@ -3,11 +3,11 @@ import Graph from "graphology"
33import { SigmaContainer , useLoadGraph , useRegisterEvents , useSigma } from "@react-sigma/core"
44import "@react-sigma/core/lib/style.css"
55import 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"
77import { Button } from "@/components/ui/button"
88import { useWikiStore } from "@/stores/wiki-store"
99import { 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"
1111import { normalizePath } from "@/lib/path-utils"
1212
1313const 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+
3552const BASE_NODE_SIZE = 8
3653const MAX_NODE_SIZE = 28
3754
@@ -68,7 +85,7 @@ function nodeSize(linkCount: number, maxLinks: number): number {
6885const positionCache = new Map < string , { x : number ; y : number } > ( )
6986let 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