@@ -223,6 +223,7 @@ export default function BlogGraphPage() {
223223 const [ highlightedTag , setHighlightedTag ] = useState ( null ) ;
224224 const [ minFreq , setMinFreq ] = useState ( 3 ) ;
225225 const [ categoryFilter , setCategoryFilter ] = useState ( 'all' ) ;
226+ const [ selectedCluster , setSelectedCluster ] = useState ( null ) ;
226227 const [ controlsOpen , setControlsOpen ] = useState ( false ) ;
227228 const [ dimensions , setDimensions ] = useState ( { width : 0 , height : 0 } ) ;
228229 const [ isDark , setIsDark ] = useState ( getTheme ( ) === 'dark' ) ;
@@ -331,6 +332,10 @@ export default function BlogGraphPage() {
331332 let alpha = 0.06 + ratio * 0.25 ;
332333 let lineWidth = 0.5 + ratio * 2.5 ;
333334
335+ // Cluster filter dim — fades edges that don't connect two nodes of the selected cluster
336+ const inCluster = selectedCluster == null
337+ || ( src . community === selectedCluster && tgt . community === selectedCluster ) ;
338+
334339 if ( activeNode ) {
335340 const srcId = src . id ;
336341 const tgtId = tgt . id ;
@@ -352,6 +357,8 @@ export default function BlogGraphPage() {
352357 }
353358 }
354359
360+ if ( ! inCluster ) alpha *= 0.12 ;
361+
355362 ctx . globalAlpha = Math . min ( alpha , 0.8 ) ;
356363 ctx . lineWidth = lineWidth ;
357364 ctx . beginPath ( ) ;
@@ -368,7 +375,8 @@ export default function BlogGraphPage() {
368375 const color = CLUSTER_COLORS [ n . community % CLUSTER_COLORS . length ] ;
369376 const isActive = n . id === activeNode ;
370377 const isNeighbor = activeNeighbors . has ( n . id ) ;
371- const dimmed = activeNode && ! isActive && ! isNeighbor ;
378+ const outOfCluster = selectedCluster != null && n . community !== selectedCluster ;
379+ const dimmed = ( activeNode && ! isActive && ! isNeighbor ) || outOfCluster ;
372380
373381 // Glow for large nodes
374382 if ( ratio >= 0.35 && ! dimmed ) {
@@ -417,7 +425,7 @@ export default function BlogGraphPage() {
417425
418426 ctx . restore ( ) ;
419427 ctx . globalAlpha = 1 ;
420- } , [ graphData , dimensions , hoveredNode , selectedNode , highlightedTag , isDark ] ) ;
428+ } , [ graphData , dimensions , hoveredNode , selectedNode , highlightedTag , selectedCluster , isDark ] ) ;
421429
422430 // Keep drawRef always up to date
423431 useEffect ( ( ) => { drawRef . current = draw ; } , [ draw ] ) ;
@@ -945,17 +953,49 @@ export default function BlogGraphPage() {
945953 < LocateFixed size = { 16 } />
946954 </ button >
947955
948- { /* Cluster legend — compact on mobile */ }
949- < div className = { `absolute top-4 left-4 z-10 backdrop-blur-sm border p-3 max-w-[160px] sm:max-w-[200px] ${ isDark ? 'bg-brand-bg/95 border-white/[0.1]' : 'bg-white/95 border-gray-200' } ` } >
950- < p className = { `font-mono text-[10px] tracking-[0.18em] uppercase mb-2 ${ isDark ? 'text-brand-fg-muted' : 'text-gray-500' } ` } > Clusters</ p >
951- < div className = "space-y-1 sm:space-y-1.5" >
952- { clusterLegend . map ( c => (
953- < div key = { c . id } className = "flex items-center gap-1.5 sm:gap-2" >
954- < div className = "w-2 h-2 sm:w-2.5 sm:h-2.5 rounded-full shrink-0" style = { { backgroundColor : c . color } } />
955- < span className = { `text-[11px] sm:text-xs truncate ${ isDark ? 'text-brand-fg' : 'text-gray-700' } ` } > { c . label } </ span >
956- < span className = { `font-mono text-[10px] ml-auto ${ isDark ? 'text-brand-fg-muted' : 'text-gray-500' } ` } > { c . count } </ span >
957- </ div >
958- ) ) }
956+ { /* Cluster legend — clickable to filter; click again or "All" to clear */ }
957+ < div className = { `absolute top-4 left-4 z-10 backdrop-blur-sm border p-3 max-w-[170px] sm:max-w-[210px] ${ isDark ? 'bg-brand-bg/95 border-white/[0.1]' : 'bg-white/95 border-gray-200' } ` } >
958+ < div className = "flex items-center justify-between mb-2 gap-2" >
959+ < p className = { `font-mono text-[10px] tracking-[0.18em] uppercase ${ isDark ? 'text-brand-fg-muted' : 'text-gray-500' } ` } > Clusters</ p >
960+ { selectedCluster != null && (
961+ < button
962+ type = "button"
963+ onClick = { ( ) => setSelectedCluster ( null ) }
964+ className = { `font-mono text-[9px] tracking-[0.12em] uppercase transition-colors ${ isDark ? 'text-brand-accent hover:text-brand-accent-soft' : 'text-cyan-700 hover:text-cyan-800' } ` }
965+ aria-label = "Clear cluster filter"
966+ >
967+ All
968+ </ button >
969+ ) }
970+ </ div >
971+ < div className = "space-y-0.5" >
972+ { clusterLegend . map ( c => {
973+ const active = selectedCluster === c . id ;
974+ const dim = selectedCluster != null && ! active ;
975+ return (
976+ < button
977+ key = { c . id }
978+ type = "button"
979+ onClick = { ( ) => setSelectedCluster ( active ? null : c . id ) }
980+ aria-pressed = { active }
981+ aria-label = { `Filter by ${ c . label } cluster` }
982+ className = { `w-full flex items-center gap-1.5 sm:gap-2 px-1 py-1 -mx-1 transition-opacity ${ dim ? 'opacity-40 hover:opacity-70' : 'opacity-100' } ` }
983+ >
984+ < div
985+ className = "w-2 h-2 sm:w-2.5 sm:h-2.5 rounded-full shrink-0 transition-transform"
986+ style = { {
987+ backgroundColor : c . color ,
988+ transform : active ? 'scale(1.4)' : 'scale(1)' ,
989+ boxShadow : active ? `0 0 0 2px ${ c . color } 30` : 'none' ,
990+ } }
991+ />
992+ < span className = { `text-[11px] sm:text-xs truncate text-left flex-1 transition-colors ${ active ? ( isDark ? 'text-brand-fg font-semibold' : 'text-gray-900 font-semibold' ) : ( isDark ? 'text-brand-fg' : 'text-gray-700' ) } ` } >
993+ { c . label }
994+ </ span >
995+ < span className = { `font-mono text-[10px] ${ active ? ( isDark ? 'text-brand-accent' : 'text-cyan-700' ) : ( isDark ? 'text-brand-fg-muted' : 'text-gray-500' ) } ` } > { c . count } </ span >
996+ </ button >
997+ ) ;
998+ } ) }
959999 </ div >
9601000 </ div >
9611001
@@ -995,9 +1035,14 @@ export default function BlogGraphPage() {
9951035 { selectedNodeData . connectedTags . length > 0 && (
9961036 < div className = { `px-4 py-3 border-b ${ isDark ? 'border-white/[0.08]' : 'border-gray-200' } ` } >
9971037 < p className = { `font-mono text-[10px] tracking-[0.18em] uppercase mb-2 ${ isDark ? 'text-brand-fg-muted' : 'text-gray-500' } ` } > Related Topics</ p >
998- < div className = "flex flex-wrap gap-1.5" >
999- { selectedNodeData . connectedTags . slice ( 0 , 12 ) . map ( ( { tag, weight } ) => (
1000- < button key = { tag } onClick = { ( ) => { setSelectedNode ( tag ) ; focusOnTag ( tag ) ; } } className = { `font-mono text-[10px] tracking-[0.08em] uppercase border px-2 py-1 transition-colors ${ isDark ? 'border-brand-accent/30 hover:border-brand-accent text-brand-fg-muted hover:text-brand-accent' : 'border-cyan-700/30 hover:border-cyan-700 text-gray-700 hover:text-cyan-700' } ` } >
1038+ { /* Mobile: show 6 chips. Desktop: show 12. Keep posts the primary content. */ }
1039+ < div className = "flex flex-wrap gap-1 sm:gap-1.5" >
1040+ { selectedNodeData . connectedTags . slice ( 0 , 12 ) . map ( ( { tag, weight } , i ) => (
1041+ < button
1042+ key = { tag }
1043+ onClick = { ( ) => { setSelectedNode ( tag ) ; focusOnTag ( tag ) ; } }
1044+ className = { `font-mono text-[10px] tracking-[0.06em] uppercase border px-1.5 py-0.5 transition-colors ${ i >= 6 ? 'hidden sm:inline-flex' : '' } ${ isDark ? 'border-brand-accent/30 hover:border-brand-accent text-brand-fg-muted hover:text-brand-accent' : 'border-cyan-700/30 hover:border-cyan-700 text-gray-700 hover:text-cyan-700' } ` }
1045+ >
10011046 { tag }
10021047 < span className = { `ml-1 ${ isDark ? 'text-brand-fg-muted/70' : 'text-gray-400' } ` } > { weight } </ span >
10031048 </ button >
@@ -1007,12 +1052,14 @@ export default function BlogGraphPage() {
10071052 ) }
10081053
10091054 < div className = "flex-1 overflow-y-auto px-4 py-3" >
1010- < p className = { `font-mono text-[10px] tracking-[0.18em] uppercase mb-2 ${ isDark ? 'text-brand-fg-muted' : 'text-gray-500' } ` } > Posts</ p >
1055+ < p className = { `font-mono text-[10px] tracking-[0.18em] uppercase mb-3 ${ isDark ? 'text-brand-fg-muted' : 'text-gray-500' } ` } >
1056+ Posts < span className = { `ml-1 ${ isDark ? 'text-brand-fg-muted/60' : 'text-gray-400' } ` } > · { selectedNodeData . posts . length } </ span >
1057+ </ p >
10111058 < div >
10121059 { selectedNodeData . posts . map ( post => (
1013- < Link key = { post . slug } to = { `/blog/${ post . category } /${ post . slug } ` } className = { `block py-3 border-b last:border-b-0 group transition-colors ${ isDark ? 'border-white/[0.08]' : 'border-gray-200/60' } ` } >
1014- < p className = { `text-sm leading-snug font-medium transition-colors ${ isDark ? 'text-brand-fg group-hover:text-brand-accent' : 'text-gray-900 group-hover:text-cyan-700' } ` } > { post . title } </ p >
1015- < div className = "flex items-center gap-2 mt-1 " >
1060+ < Link key = { post . slug } to = { `/blog/${ post . category } /${ post . slug } ` } className = { `block py-3.5 border-b last:border-b-0 group transition-colors ${ isDark ? 'border-white/[0.08]' : 'border-gray-200/60' } ` } >
1061+ < p className = { `text-[15px] sm:text-sm leading-snug font-semibold transition-colors mb-1 ${ isDark ? 'text-brand-fg group-hover:text-brand-accent' : 'text-gray-900 group-hover:text-cyan-700' } ` } > { post . title } </ p >
1062+ < div className = "flex items-center gap-2" >
10161063 < span className = { `font-mono text-[10px] tracking-[0.08em] uppercase ${ isDark ? 'text-brand-fg-muted' : 'text-gray-500' } ` } > { post . category } </ span >
10171064 < span className = { `text-[10px] ${ isDark ? 'text-brand-fg-muted/60' : 'text-gray-300' } ` } > ·</ span >
10181065 < span className = { `font-mono text-[10px] tracking-[0.08em] uppercase ${ isDark ? 'text-brand-fg-muted' : 'text-gray-500' } ` } > { post . readingTime } m</ span >
0 commit comments