← back to Patty
components/heatmap/HeatMapTab.tsx
710 lines
'use client';
import { useState, useEffect, useMemo } from 'react';
import { Loader2, MapPin, Network, DollarSign, Megaphone } from 'lucide-react';
import { Grant } from '../grants/types';
interface StateData {
state: string;
topics: string[] | null;
intensity: number;
sentiment: number;
article_count: number;
}
interface TopicData {
id: string;
title: string;
category: string;
geo_states: string[];
urgency: string;
article_count: number;
avg_sentiment: number;
}
interface PetitionData {
id: string;
title: string;
category: string | null;
tags: string[] | null;
status: string;
signature_count: number;
}
const STATE_NAMES: Record<string, string> = {
AL:'Alabama',AK:'Alaska',AZ:'Arizona',AR:'Arkansas',CA:'California',CO:'Colorado',
CT:'Connecticut',DE:'Delaware',FL:'Florida',GA:'Georgia',HI:'Hawaii',ID:'Idaho',
IL:'Illinois',IN:'Indiana',IA:'Iowa',KS:'Kansas',KY:'Kentucky',LA:'Louisiana',
ME:'Maine',MD:'Maryland',MA:'Massachusetts',MI:'Michigan',MN:'Minnesota',MS:'Mississippi',
MO:'Missouri',MT:'Montana',NE:'Nebraska',NV:'Nevada',NH:'New Hampshire',NJ:'New Jersey',
NM:'New Mexico',NY:'New York',NC:'North Carolina',ND:'North Dakota',OH:'Ohio',OK:'Oklahoma',
OR:'Oregon',PA:'Pennsylvania',RI:'Rhode Island',SC:'South Carolina',SD:'South Dakota',
TN:'Tennessee',TX:'Texas',UT:'Utah',VT:'Vermont',VA:'Virginia',WA:'Washington',
WV:'West Virginia',WI:'Wisconsin',WY:'Wyoming',DC:'Dist. of Columbia',
};
/* ─── Tile Grid Map Positions ────────────────────────────────────────────────
Standard FiveThirtyEight-style tile grid. Each state gets [col, row].
Grid is 12 columns × 8 rows. */
const TILE_POS: Record<string, [number, number]> = {
AK:[0,0], ME:[11,0],
WI:[6,1], VT:[10,1], NH:[11,1],
WA:[1,1], ID:[2,1], MT:[3,1], ND:[4,1], MN:[5,1], IL:[6,2], MI:[7,1], NY:[9,1], MA:[10,2], RI:[11,2],
OR:[1,2], NV:[2,2], WY:[3,2], SD:[4,2], IA:[5,2], IN:[7,2], OH:[8,2], PA:[9,2], NJ:[10,3], CT:[11,3],
CA:[1,3], UT:[2,3], CO:[3,3], NE:[4,3], MO:[5,3], KY:[6,3], WV:[7,3], VA:[8,3], MD:[9,3], DC:[10,4],
AZ:[2,4], NM:[3,4], KS:[4,4], AR:[5,4], TN:[6,4], NC:[7,4], SC:[8,4], DE:[9,4],
OK:[4,5], LA:[5,5], MS:[6,5], AL:[7,5], GA:[8,5],
HI:[1,6], TX:[4,6], FL:[9,5],
};
/* ─── Color Scales ──────────────────────────────────────────────────────────── */
function heatColor(articles: number, maxArticles: number): string {
if (maxArticles === 0) return '#1e1b2e';
const t = Math.min(1, articles / maxArticles);
// Dark purple → Blue → Yellow → Red gradient
if (t < 0.15) return '#2d1f5e';
if (t < 0.3) return '#3b2d8b';
if (t < 0.45) return '#4f46b8';
if (t < 0.6) return '#6366f1';
if (t < 0.75) return '#f59e0b';
if (t < 0.9) return '#f97316';
return '#ef4444';
}
function sentimentColor(s: number): string {
if (s > 0.1) return '#22c55e';
if (s < -0.1) return '#ef4444';
return '#6b7280';
}
/* ─── Topic colors for mind map ─────────────────────────────────────────────── */
const TOPIC_COLORS = [
'#8b5cf6', '#3b82f6', '#06b6d4', '#10b981', '#f59e0b',
'#ef4444', '#ec4899', '#f97316', '#14b8a6', '#a78bfa',
];
export default function HeatMapTab() {
const [states, setStates] = useState<StateData[]>([]);
const [topics, setTopics] = useState<TopicData[]>([]);
const [petitions, setPetitions] = useState<PetitionData[]>([]);
const [grants, setGrants] = useState<Grant[]>([]);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<StateData | null>(null);
const [view, setView] = useState<'map' | 'mind'>('map');
const [hoveredNode, setHoveredNode] = useState<string | null>(null);
useEffect(() => {
Promise.all([
fetch('/api/pulse/geo').then(r => r.ok ? r.json() : { states: [] }),
fetch('/api/pulse/topics').then(r => r.ok ? r.json() : { topics: [] }),
fetch('/api/petitions').then(r => r.ok ? r.json() : { rows: [] }),
fetch('/api/grants-proxy/grants').then(r => r.ok ? r.json() : { rows: [] }).catch(() => ({ rows: [] })),
]).then(([geoData, topicData, petData, grantData]) => {
setStates(geoData.states || []);
setTopics((topicData.topics || []).filter((t: TopicData) => t.geo_states?.length > 0));
setPetitions(petData.rows || []);
setGrants(grantData.rows || []);
}).catch(err => console.error('HeatMap load error:', err))
.finally(() => setLoading(false));
}, []);
const stateMap = useMemo(() => {
const m: Record<string, StateData> = {};
states.forEach(s => { m[s.state] = s; });
return m;
}, [states]);
const maxArticles = useMemo(() => {
return Math.max(1, ...states.map(s => Number(s.article_count)));
}, [states]);
if (loading) {
return (
<div style={{ padding: 32, color: 'var(--color-text-muted)', display: 'flex', alignItems: 'center', gap: 8 }}>
<Loader2 size={18} style={{ animation: 'spin 1s linear infinite' }} /> Loading geographic data...
</div>
);
}
return (
<div style={{ padding: 24, maxWidth: 1400 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
<h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text)', margin: 0 }}>
Heat Map
<span style={{ fontSize: 13, fontWeight: 400, color: 'var(--color-text-muted)', marginLeft: 10 }}>
{states.length} states with activity
</span>
</h2>
{/* View Toggle */}
<div style={{ display: 'flex', gap: 4, padding: 3, borderRadius: 10, backgroundColor: 'var(--color-bg)', border: '1px solid var(--color-border)' }}>
<button onClick={() => setView('map')} style={{
padding: '6px 14px', borderRadius: 8, border: 'none', fontSize: 12, fontWeight: 600, cursor: 'pointer',
backgroundColor: view === 'map' ? '#7c3aed' : 'transparent',
color: view === 'map' ? '#fff' : 'var(--color-text-muted)',
}}>
<MapPin size={13} style={{ marginRight: 4, verticalAlign: -2 }} /> US Map
</button>
<button onClick={() => setView('mind')} style={{
padding: '6px 14px', borderRadius: 8, border: 'none', fontSize: 12, fontWeight: 600, cursor: 'pointer',
backgroundColor: view === 'mind' ? '#7c3aed' : 'transparent',
color: view === 'mind' ? '#fff' : 'var(--color-text-muted)',
}}>
<Network size={13} style={{ marginRight: 4, verticalAlign: -2 }} /> Network
</button>
</div>
</div>
{states.length === 0 ? (
<div style={{
padding: 40, textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 14,
backgroundColor: 'var(--color-surface)', borderRadius: 12, border: '1px solid var(--color-border)',
}}>
<MapPin size={32} style={{ marginBottom: 8, opacity: 0.4 }} />
<div>No geographic data yet. Run topic generation to populate state-level data.</div>
</div>
) : view === 'map' ? (
<TileGridMap states={states} stateMap={stateMap} maxArticles={maxArticles} selected={selected} onSelect={setSelected} />
) : (
<NetworkMindMap topics={topics} petitions={petitions} grants={grants} stateMap={stateMap} hoveredNode={hoveredNode} onHoverNode={setHoveredNode} />
)}
</div>
);
}
/* ═══════════════════════════════════════════════════════════════════════════════
TILE GRID MAP — Geographic US Map with colored tiles
═══════════════════════════════════════════════════════════════════════════════ */
function TileGridMap({ states, stateMap, maxArticles, selected, onSelect }: {
states: StateData[];
stateMap: Record<string, StateData>;
maxArticles: number;
selected: StateData | null;
onSelect: (s: StateData | null) => void;
}) {
const TILE = 58;
const GAP = 4;
const STEP = TILE + GAP;
const COLS = 12;
const ROWS = 7;
const W = COLS * STEP + 40;
const H = ROWS * STEP + 40;
return (
<div style={{ display: 'flex', gap: 20, flexWrap: 'wrap' }}>
{/* Map */}
<div style={{
flex: '1 1 700px', padding: 20, borderRadius: 12,
backgroundColor: 'var(--color-surface)', border: '1px solid var(--color-border)',
overflow: 'auto',
}}>
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: '100%', maxWidth: 800, display: 'block', margin: '0 auto' }}>
{/* All 50 states + DC */}
{Object.entries(TILE_POS).map(([abbr, [col, row]]) => {
const data = stateMap[abbr];
const x = col * STEP + 20;
const y = row * STEP + 10;
const articles = data ? Number(data.article_count) : 0;
const fill = data ? heatColor(articles, maxArticles) : '#1a1726';
const isSelected = selected?.state === abbr;
const sentiment = data ? Number(data.sentiment) : 0;
return (
<g key={abbr} onClick={() => data && onSelect(isSelected ? null : data)} style={{ cursor: data ? 'pointer' : 'default' }}>
<rect
x={x} y={y} width={TILE} height={TILE} rx={6}
fill={fill}
stroke={isSelected ? '#a78bfa' : data ? '#ffffff15' : '#ffffff08'}
strokeWidth={isSelected ? 2.5 : 1}
/>
<text
x={x + TILE / 2} y={y + TILE / 2 - 4}
textAnchor="middle" dominantBaseline="middle"
fill={data ? '#fff' : '#555'}
fontSize={14} fontWeight={700} fontFamily="system-ui"
>
{abbr}
</text>
{data && (
<text
x={x + TILE / 2} y={y + TILE / 2 + 12}
textAnchor="middle" dominantBaseline="middle"
fill="#ffffff99" fontSize={8} fontFamily="system-ui"
>
{articles}
</text>
)}
{/* Sentiment indicator dot */}
{data && (
<circle
cx={x + TILE - 6} cy={y + 6} r={3}
fill={sentimentColor(sentiment)}
/>
)}
</g>
);
})}
</svg>
{/* Legend */}
<div style={{ display: 'flex', justifyContent: 'center', gap: 20, marginTop: 16, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--color-text-muted)' }}>Low</span>
{['#2d1f5e', '#3b2d8b', '#4f46b8', '#6366f1', '#f59e0b', '#f97316', '#ef4444'].map((c, i) => (
<span key={i} style={{ width: 20, height: 10, borderRadius: 2, backgroundColor: c }} />
))}
<span style={{ fontSize: 10, color: 'var(--color-text-muted)' }}>High</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', backgroundColor: '#22c55e' }} />
<span style={{ fontSize: 10, color: 'var(--color-text-muted)' }}>Positive</span>
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', backgroundColor: '#6b7280' }} />
<span style={{ fontSize: 10, color: 'var(--color-text-muted)' }}>Neutral</span>
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', backgroundColor: '#ef4444' }} />
<span style={{ fontSize: 10, color: 'var(--color-text-muted)' }}>Negative</span>
</span>
</div>
</div>
</div>
{/* Detail Panel */}
{selected && (
<div style={{
flex: '0 0 300px', padding: 20, borderRadius: 12,
backgroundColor: 'var(--color-surface)', border: '1px solid var(--color-border)',
alignSelf: 'flex-start',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div style={{
width: 40, height: 40, borderRadius: 10,
backgroundColor: heatColor(Number(selected.article_count), maxArticles),
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 16, fontWeight: 800,
}}>{selected.state}</div>
<div>
<div style={{ fontSize: 16, fontWeight: 700, color: 'var(--color-text)' }}>
{STATE_NAMES[selected.state] || selected.state}
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
{Number(selected.article_count)} articles | Intensity: {Number(selected.intensity).toFixed(0)}
</div>
</div>
</div>
{/* Sentiment bar */}
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 4 }}>Sentiment</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{
flex: 1, height: 6, borderRadius: 3, backgroundColor: 'var(--color-bg)',
overflow: 'hidden', position: 'relative',
}}>
<div style={{
position: 'absolute',
left: '50%', top: 0, bottom: 0,
width: `${Math.abs(Number(selected.sentiment)) * 200}%`,
transform: Number(selected.sentiment) < 0 ? 'translateX(-100%)' : 'none',
backgroundColor: sentimentColor(Number(selected.sentiment)),
borderRadius: 3,
}} />
</div>
<span style={{
fontSize: 12, fontWeight: 600,
color: sentimentColor(Number(selected.sentiment)),
}}>
{Number(selected.sentiment).toFixed(3)}
</span>
</div>
</div>
{/* Topics list */}
{selected.topics && selected.topics.length > 0 && (
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 6 }}>
Source Categories ({selected.topics.length})
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{selected.topics.map((t, i) => (
<span key={i} style={{
fontSize: 10, padding: '3px 8px', borderRadius: 8,
backgroundColor: '#7c3aed22', color: '#a78bfa',
}}>
{t.replace(/_/g, ' ')}
</span>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}
/* ═══════════════════════════════════════════════════════════════════════════════
NETWORK MIND MAP — 3-ring radial network: Topics → Petitions+Grants → States
═══════════════════════════════════════════════════════════════════════════════ */
const NODE_COLORS = {
topic: '#8b5cf6',
petition: '#3b82f6',
grant: '#10b981',
state: '#6b7280',
};
function textMatch(a: string | null | undefined, b: string | null | undefined): boolean {
if (!a || !b) return false;
return a.toLowerCase().includes(b.toLowerCase()) || b.toLowerCase().includes(a.toLowerCase());
}
function tagsOverlap(a: string[] | null | undefined, b: string[] | null | undefined): boolean {
if (!a?.length || !b?.length) return false;
const setB = new Set(b.map(t => t.toLowerCase()));
return a.some(t => setB.has(t.toLowerCase()));
}
function NetworkMindMap({ topics, petitions, grants, stateMap, hoveredNode, onHoverNode }: {
topics: TopicData[];
petitions: PetitionData[];
grants: Grant[];
stateMap: Record<string, StateData>;
hoveredNode: string | null;
onHoverNode: (id: string | null) => void;
}) {
const displayTopics = topics.slice(0, 10);
const displayPetitions = petitions.slice(0, 8);
const displayGrants = grants.slice(0, 8);
const CX = 500;
const CY = 400;
const R1 = 100; // Topics ring
const R2 = 220; // Petitions + Grants ring
const R3 = 340; // States ring
const W = 1000;
const H = 800;
// Collect unique states
const stateSet = new Set<string>();
displayTopics.forEach(t => t.geo_states?.forEach(s => stateSet.add(s)));
const uniqueStates = Array.from(stateSet);
// Positions: Ring 3 — States
const statePos: Record<string, { x: number; y: number }> = {};
uniqueStates.forEach((s, i) => {
const angle = (i / uniqueStates.length) * Math.PI * 2 - Math.PI / 2;
statePos[s] = { x: CX + Math.cos(angle) * R3, y: CY + Math.sin(angle) * R3 };
});
// Positions: Ring 1 — Topics
const topicNodes = displayTopics.map((t, i) => {
const angle = (i / displayTopics.length) * Math.PI * 2 - Math.PI / 2;
return { id: `t-${t.id}`, data: t, x: CX + Math.cos(angle) * R1, y: CY + Math.sin(angle) * R1, type: 'topic' as const };
});
// Positions: Ring 2 — Petitions (left half) + Grants (right half)
const midCount = displayPetitions.length + displayGrants.length;
const petitionNodes = displayPetitions.map((p, i) => {
const angle = (i / midCount) * Math.PI * 2 - Math.PI / 2;
return { id: `p-${p.id}`, data: p, x: CX + Math.cos(angle) * R2, y: CY + Math.sin(angle) * R2, type: 'petition' as const };
});
const grantNodes = displayGrants.map((g, i) => {
const angle = ((i + displayPetitions.length) / midCount) * Math.PI * 2 - Math.PI / 2;
return { id: `g-${g.id}`, data: g, x: CX + Math.cos(angle) * R2, y: CY + Math.sin(angle) * R2, type: 'grant' as const };
});
// Build connections
type Connection = { from: string; to: string; fx: number; fy: number; tx: number; ty: number; color: string };
const connections: Connection[] = [];
// Topic → State (from geo_states)
topicNodes.forEach(tn => {
(tn.data as TopicData).geo_states?.forEach(s => {
const sp = statePos[s];
if (sp) connections.push({ from: tn.id, to: `s-${s}`, fx: tn.x, fy: tn.y, tx: sp.x, ty: sp.y, color: NODE_COLORS.topic });
});
});
// Topic → Petition (matched by category)
topicNodes.forEach(tn => {
const topicCat = (tn.data as TopicData).category;
petitionNodes.forEach(pn => {
const petCat = (pn.data as PetitionData).category;
const petTags = (pn.data as PetitionData).tags;
if (textMatch(topicCat, petCat) || (petTags && petTags.some(tag => textMatch(topicCat, tag)))) {
connections.push({ from: tn.id, to: pn.id, fx: tn.x, fy: tn.y, tx: pn.x, ty: pn.y, color: NODE_COLORS.petition });
}
});
});
// Topic → Grant (matched by focus_areas/category)
topicNodes.forEach(tn => {
const topicCat = (tn.data as TopicData).category;
grantNodes.forEach(gn => {
const grantData = gn.data as Grant;
if (grantData.focus_areas?.some(fa => textMatch(topicCat, fa)) || tagsOverlap(grantData.tags, [topicCat])) {
connections.push({ from: tn.id, to: gn.id, fx: tn.x, fy: tn.y, tx: gn.x, ty: gn.y, color: NODE_COLORS.grant });
}
});
});
// Grant → Petition (matched by tags overlap)
grantNodes.forEach(gn => {
const grantTags = (gn.data as Grant).tags;
const grantFocus = (gn.data as Grant).focus_areas;
petitionNodes.forEach(pn => {
const petTags = (pn.data as PetitionData).tags;
if (tagsOverlap(grantTags, petTags) || tagsOverlap(grantFocus, petTags)) {
connections.push({ from: gn.id, to: pn.id, fx: gn.x, fy: gn.y, tx: pn.x, ty: pn.y, color: '#f59e0b' });
}
});
});
// Determine which nodes are connected to the hovered node
const connectedToHovered = useMemo(() => {
if (!hoveredNode) return new Set<string>();
const set = new Set<string>();
set.add(hoveredNode);
connections.forEach(c => {
if (c.from === hoveredNode) set.add(c.to);
if (c.to === hoveredNode) set.add(c.from);
});
return set;
}, [hoveredNode, connections]);
const isNodeDimmed = (nodeId: string) => hoveredNode !== null && !connectedToHovered.has(nodeId);
const isConnDimmed = (c: Connection) => hoveredNode !== null && !connectedToHovered.has(c.from) && !connectedToHovered.has(c.to);
const isConnHighlighted = (c: Connection) => hoveredNode !== null && (c.from === hoveredNode || c.to === hoveredNode);
return (
<div style={{
padding: 20, borderRadius: 12,
backgroundColor: 'var(--color-surface)', border: '1px solid var(--color-border)',
}}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12, textAlign: 'center' }}>
Topics ↔ Petitions ↔ Grants ↔ States — hover any node to highlight connections
</div>
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: '100%', maxWidth: 1000, display: 'block', margin: '0 auto' }}>
{/* Ring guides */}
<circle cx={CX} cy={CY} r={R1} fill="none" stroke="#ffffff06" strokeWidth={1} />
<circle cx={CX} cy={CY} r={R2} fill="none" stroke="#ffffff06" strokeWidth={1} />
<circle cx={CX} cy={CY} r={R3} fill="none" stroke="#ffffff06" strokeWidth={1} />
{/* Connection lines */}
{connections.map((c, i) => (
<line
key={i}
x1={c.fx} y1={c.fy} x2={c.tx} y2={c.ty}
stroke={c.color}
strokeWidth={isConnHighlighted(c) ? 2.5 : 1}
strokeOpacity={isConnDimmed(c) ? 0.03 : isConnHighlighted(c) ? 0.8 : 0.2}
strokeDasharray={isConnHighlighted(c) ? '' : '4 2'}
/>
))}
{/* Center hub */}
<circle cx={CX} cy={CY} r={28} fill="#7c3aed22" stroke="#7c3aed" strokeWidth={1.5} />
<text x={CX} y={CY - 5} textAnchor="middle" fill="#a78bfa" fontSize={9} fontWeight={700}>Pulse</text>
<text x={CX} y={CY + 7} textAnchor="middle" fill="#a78bfa88" fontSize={7}>Network</text>
{/* Ring 3: State nodes */}
{uniqueStates.map(s => {
const pos = statePos[s];
const data = stateMap[s];
const nodeId = `s-${s}`;
const dimmed = isNodeDimmed(nodeId);
return (
<g key={nodeId}
onMouseEnter={() => onHoverNode(nodeId)}
onMouseLeave={() => onHoverNode(null)}
style={{ cursor: 'pointer' }}
>
<circle
cx={pos.x} cy={pos.y} r={16}
fill={dimmed ? '#1e1b2e55' : '#1e1b2e'}
stroke={dimmed ? '#ffffff08' : '#ffffff20'}
strokeWidth={1}
opacity={dimmed ? 0.3 : 1}
/>
<text
x={pos.x} y={pos.y - 1}
textAnchor="middle" dominantBaseline="middle"
fill={dimmed ? '#555' : '#aaa'}
fontSize={10} fontWeight={700} fontFamily="system-ui"
>
{s}
</text>
{data && (
<text
x={pos.x} y={pos.y + 9}
textAnchor="middle" dominantBaseline="middle"
fill="#ffffff44" fontSize={6} fontFamily="system-ui"
>
{Number(data.article_count)}
</text>
)}
</g>
);
})}
{/* Ring 2: Petition nodes */}
{petitionNodes.map(({ id, data, x, y }) => {
const dimmed = isNodeDimmed(id);
const pet = data as PetitionData;
const label = pet.title.length > 16 ? pet.title.substring(0, 14) + '…' : pet.title;
return (
<g key={id}
onMouseEnter={() => onHoverNode(id)}
onMouseLeave={() => onHoverNode(null)}
style={{ cursor: 'pointer' }}
>
<circle
cx={x} cy={y} r={22}
fill={dimmed ? NODE_COLORS.petition + '08' : NODE_COLORS.petition + '22'}
stroke={NODE_COLORS.petition}
strokeWidth={1.5}
opacity={dimmed ? 0.2 : 1}
/>
<text x={x} y={y - 3} textAnchor="middle" dominantBaseline="middle"
fill={dimmed ? '#555' : '#fff'} fontSize={6} fontWeight={600} fontFamily="system-ui"
>
{label.split(' ').slice(0, 2).join(' ')}
</text>
<text x={x} y={y + 5} textAnchor="middle" dominantBaseline="middle"
fill={dimmed ? '#555' : '#fff'} fontSize={6} fontWeight={600} fontFamily="system-ui"
>
{label.split(' ').slice(2, 4).join(' ')}
</text>
{/* Petition icon badge */}
<circle cx={x + 16} cy={y - 16} r={6} fill={NODE_COLORS.petition} opacity={dimmed ? 0.2 : 1} />
<text x={x + 16} y={y - 16} textAnchor="middle" dominantBaseline="middle"
fill="#fff" fontSize={6} fontWeight={700}>P</text>
</g>
);
})}
{/* Ring 2: Grant nodes */}
{grantNodes.map(({ id, data, x, y }) => {
const dimmed = isNodeDimmed(id);
const g = data as Grant;
const label = g.title.length > 16 ? g.title.substring(0, 14) + '…' : g.title;
return (
<g key={id}
onMouseEnter={() => onHoverNode(id)}
onMouseLeave={() => onHoverNode(null)}
style={{ cursor: 'pointer' }}
>
<rect
x={x - 22} y={y - 16} width={44} height={32} rx={8}
fill={dimmed ? NODE_COLORS.grant + '08' : NODE_COLORS.grant + '22'}
stroke={NODE_COLORS.grant}
strokeWidth={1.5}
opacity={dimmed ? 0.2 : 1}
/>
<text x={x} y={y - 3} textAnchor="middle" dominantBaseline="middle"
fill={dimmed ? '#555' : '#fff'} fontSize={6} fontWeight={600} fontFamily="system-ui"
>
{label.split(' ').slice(0, 2).join(' ')}
</text>
<text x={x} y={y + 5} textAnchor="middle" dominantBaseline="middle"
fill={dimmed ? '#555' : '#fff'} fontSize={6} fontWeight={600} fontFamily="system-ui"
>
{label.split(' ').slice(2, 4).join(' ')}
</text>
{/* Grant icon badge */}
<circle cx={x + 18} cy={y - 12} r={6} fill={NODE_COLORS.grant} opacity={dimmed ? 0.2 : 1} />
<text x={x + 18} y={y - 12} textAnchor="middle" dominantBaseline="middle"
fill="#fff" fontSize={6} fontWeight={700}>$</text>
</g>
);
})}
{/* Ring 1: Topic nodes */}
{topicNodes.map(({ id, data, x, y }, i) => {
const dimmed = isNodeDimmed(id);
const topic = data as TopicData;
const color = TOPIC_COLORS[i % TOPIC_COLORS.length];
const shortTitle = topic.title.length > 20 ? topic.title.substring(0, 18) + '…' : topic.title;
return (
<g key={id}
onMouseEnter={() => onHoverNode(id)}
onMouseLeave={() => onHoverNode(null)}
style={{ cursor: 'pointer' }}
>
<circle
cx={x} cy={y} r={26}
fill={dimmed ? color + '08' : color + '22'}
stroke={color}
strokeWidth={dimmed ? 1 : 1.5}
opacity={dimmed ? 0.2 : 1}
/>
{topic.urgency === 'critical' && !dimmed && (
<circle cx={x} cy={y} r={30} fill="none" stroke="#ef444466" strokeWidth={1} strokeDasharray="3 3" />
)}
<text x={x} y={y - 4} textAnchor="middle" dominantBaseline="middle"
fill={dimmed ? '#555' : '#fff'} fontSize={7} fontWeight={600} fontFamily="system-ui"
>
{shortTitle.split(' ').slice(0, 2).join(' ')}
</text>
<text x={x} y={y + 6} textAnchor="middle" dominantBaseline="middle"
fill={dimmed ? '#555' : '#fff'} fontSize={7} fontWeight={600} fontFamily="system-ui"
>
{shortTitle.split(' ').slice(2, 4).join(' ')}
</text>
<circle cx={x + 20} cy={y - 20} r={8} fill={color} opacity={dimmed ? 0.2 : 1} />
<text x={x + 20} y={y - 20} textAnchor="middle" dominantBaseline="middle"
fill="#fff" fontSize={7} fontWeight={700}
>
{topic.article_count}
</text>
</g>
);
})}
</svg>
{/* Legend */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 16, marginTop: 16, justifyContent: 'center' }}>
{[
{ label: 'Topics', color: NODE_COLORS.topic, count: displayTopics.length },
{ label: 'Petitions', color: NODE_COLORS.petition, count: displayPetitions.length },
{ label: 'Grants', color: NODE_COLORS.grant, count: displayGrants.length },
{ label: 'States', color: NODE_COLORS.state, count: uniqueStates.length },
].map(item => (
<div key={item.label} style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '4px 10px',
borderRadius: 8, backgroundColor: 'var(--color-bg)',
border: '1px solid var(--color-border)',
}}>
<span style={{ width: 10, height: 10, borderRadius: item.label === 'Grants' ? 3 : '50%', backgroundColor: item.color, flexShrink: 0 }} />
<span style={{ fontSize: 12, color: 'var(--color-text)', fontWeight: 600 }}>{item.label}</span>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>({item.count})</span>
</div>
))}
</div>
{/* Node list — all entities */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 10, justifyContent: 'center' }}>
{topicNodes.map(({ id, data }, i) => (
<div key={id}
onMouseEnter={() => onHoverNode(id)}
onMouseLeave={() => onHoverNode(null)}
style={{
display: 'flex', alignItems: 'center', gap: 4, padding: '3px 8px',
borderRadius: 6, cursor: 'pointer', fontSize: 10,
backgroundColor: hoveredNode === id ? TOPIC_COLORS[i % TOPIC_COLORS.length] + '22' : 'transparent',
border: `1px solid ${hoveredNode === id ? TOPIC_COLORS[i % TOPIC_COLORS.length] : 'transparent'}`,
color: 'var(--color-text-muted)',
}}
>
<span style={{ width: 6, height: 6, borderRadius: '50%', backgroundColor: TOPIC_COLORS[i % TOPIC_COLORS.length] }} />
{(data as TopicData).title.substring(0, 30)}
</div>
))}
</div>
</div>
);
}