Skip to content

Commit 1fcce99

Browse files
committed
feat(graph): clickable cluster legend + tighter post panel on mobile
1 parent e67c04c commit 1fcce99

1 file changed

Lines changed: 67 additions & 20 deletions

File tree

front/src/pages/BlogGraphPage.jsx

Lines changed: 67 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)