← 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>
  );
}