← back to Norma

components/congress/OpportunityMap.tsx

1237 lines

'use client'

/**
 * OpportunityMap — SVG force-directed mind map
 *
 * Usage:
 *   import OpportunityMap from '@/components/congress/OpportunityMap'
 *   <OpportunityMap />
 *
 * Data sources:
 *   GET /api/congress?limit=50  → congress members + community_colleges + staffers
 *   GET /api/geo/hotspots        → geo_hotspots { zip, hotspot_type, severity, z_score }
 */

import React, {
  useEffect,
  useRef,
  useState,
  useCallback,
  useMemo,
} from 'react'
import {
  Users,
  GraduationCap,
  AlertTriangle,
  UserCircle,
  X,
  ChevronDown,
  Eye,
  EyeOff,
  Mail,
  Phone,
  Linkedin,
  RefreshCw,
  ZoomIn,
  ZoomOut,
  Maximize2,
} from 'lucide-react'

// ─── Types ────────────────────────────────────────────────────────────────────

interface Staffer {
  id: string
  name: string
  title: string
  email?: string
  phone?: string
  linkedin?: string
}

interface CommunityCollege {
  id: string
  name: string
  median_debt: number
  zip?: string
  city?: string
  state?: string
}

interface CongressMember {
  id: string
  name: string
  party: 'D' | 'R' | 'I' | string
  state: string
  district: string
  photo_url?: string
  phone?: string
  email?: string
  website?: string
  community_colleges: CommunityCollege[]
  staffers: Staffer[]
}

interface Hotspot {
  id: string
  zip: string
  hotspot_type: string
  severity: 'critical' | 'high' | 'medium' | 'low'
  z_score: number
  college_id?: string
}

// ─── Graph node / edge types ──────────────────────────────────────────────────

type NodeType = 'congress' | 'college' | 'hotspot' | 'staffer'

interface GraphNode {
  id: string
  type: NodeType
  label: string
  sublabel?: string
  x: number
  y: number
  vx: number
  vy: number
  mass: number
  radius: number
  data: CongressMember | CommunityCollege | Hotspot | Staffer
  parentId?: string   // for staffers and colleges → their congress member id
}

interface GraphEdge {
  source: string
  target: string
  type: 'congress-college' | 'congress-staffer' | 'college-hotspot'
  weight: number  // 1 = normal, 2 = critical
}

// ─── Constants ────────────────────────────────────────────────────────────────

const NODE_COLORS: Record<NodeType, string> = {
  congress: '#2563eb',
  college:  '#22c55e',
  hotspot:  '#ef4444',
  staffer:  '#14b8a6',
}

const NODE_RADII: Record<NodeType, number> = {
  congress: 28,
  college:  18,
  hotspot:  12,
  staffer:  10,
}

const REPULSION   = 4500
const ATTRACTION  = 0.025
const DAMPING     = 0.82
const SIM_ITERS   = 220

// ─── Force simulation (runs synchronously for N iterations) ──────────────────

function runSimulation(nodes: GraphNode[], edges: GraphEdge[], W: number, H: number) {
  const cx = W / 2
  const cy = H / 2

  // index nodes by id for O(1) lookup
  const byId: Record<string, GraphNode> = {}
  for (const n of nodes) byId[n.id] = n

  for (let iter = 0; iter < SIM_ITERS; iter++) {
    // repulsion between every pair
    for (let i = 0; i < nodes.length; i++) {
      for (let j = i + 1; j < nodes.length; j++) {
        const a = nodes[i]
        const b = nodes[j]
        const dx = b.x - a.x || 0.01
        const dy = b.y - a.y || 0.01
        const dist2 = dx * dx + dy * dy
        const dist  = Math.sqrt(dist2) || 0.01
        const force = REPULSION / dist2
        const fx = (dx / dist) * force
        const fy = (dy / dist) * force
        a.vx -= fx / a.mass
        a.vy -= fy / a.mass
        b.vx += fx / b.mass
        b.vy += fy / b.mass
      }
    }

    // attraction along edges
    for (const edge of edges) {
      const a = byId[edge.source]
      const b = byId[edge.target]
      if (!a || !b) continue
      const dx = b.x - a.x
      const dy = b.y - a.y
      const dist = Math.sqrt(dx * dx + dy * dy) || 0.01
      const targetDist = (a.radius + b.radius) * 3.5
      const stretch = dist - targetDist
      const fx = (dx / dist) * stretch * ATTRACTION * edge.weight
      const fy = (dy / dist) * stretch * ATTRACTION * edge.weight
      a.vx += fx / a.mass
      a.vy += fy / a.mass
      b.vx -= fx / b.mass
      b.vy -= fy / b.mass
    }

    // center gravity — lighter for congress members
    for (const n of nodes) {
      const gStrength = n.type === 'congress' ? 0.004 : 0.012
      n.vx += (cx - n.x) * gStrength
      n.vy += (cy - n.y) * gStrength
    }

    // integrate + damp
    for (const n of nodes) {
      n.vx *= DAMPING
      n.vy *= DAMPING
      n.x  += n.vx
      n.y  += n.vy
      // clamp to canvas
      const pad = n.radius + 20
      n.x = Math.max(pad, Math.min(W - pad, n.x))
      n.y = Math.max(pad, Math.min(H - pad, n.y))
    }
  }
}

// ─── Opportunity score helper ─────────────────────────────────────────────────

function computeOpportunityScore(member: CongressMember, hotspots: Hotspot[]): number {
  const collegeIds = new Set(member.community_colleges.map(c => c.id))
  const relatedHotspots = hotspots.filter(h => h.college_id && collegeIds.has(h.college_id))
  const criticalCount   = relatedHotspots.filter(h => h.severity === 'critical').length
  const stafferScore    = Math.min(member.staffers.length * 10, 30)
  const hotspotScore    = Math.min(relatedHotspots.length * 5 + criticalCount * 15, 50)
  const collegeScore    = Math.min(member.community_colleges.length * 4, 20)
  return Math.min(stafferScore + hotspotScore + collegeScore, 100)
}

// ─── Detail Panel ─────────────────────────────────────────────────────────────

interface DetailPanelProps {
  node: GraphNode | null
  allHotspots: Hotspot[]
  onClose: () => void
}

function DetailPanel({ node, allHotspots, onClose }: DetailPanelProps) {
  if (!node) return null

  const isCongress = node.type === 'congress'
  const member     = isCongress ? (node.data as CongressMember) : null
  const score      = member ? computeOpportunityScore(member, allHotspots) : 0

  const partyColor = member?.party === 'D' ? '#3b82f6'
    : member?.party === 'R' ? '#ef4444'
    : '#a1a1aa'

  return (
    <div
      style={{
        width: '350px',
        flexShrink: 0,
        background: 'var(--color-surface)',
        borderLeft: '1px solid var(--color-border)',
        display: 'flex',
        flexDirection: 'column',
        overflow: 'hidden',
      }}
      role="complementary"
      aria-label="Node detail panel"
    >
      {/* Header */}
      <div style={{
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'space-between',
        padding: '12px 16px',
        borderBottom: '1px solid var(--color-border)',
        flexShrink: 0,
      }}>
        <span style={{ fontSize: '0.8125rem', fontWeight: 600, color: 'var(--color-text-secondary)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
          {node.type === 'congress' ? 'Congress Member' : node.type === 'college' ? 'Community College' : node.type === 'hotspot' ? 'Debt Hotspot' : 'Staffer'}
        </span>
        <button
          onClick={onClose}
          className="btn btn-ghost btn-sm"
          aria-label="Close detail panel"
          style={{ padding: '4px' }}
        >
          <X size={16} />
        </button>
      </div>

      <div style={{ flex: 1, overflowY: 'auto', padding: '16px' }}>

        {/* ── Congress Member ── */}
        {member && (
          <>
            {/* Photo + name */}
            <div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '16px' }}>
              {member.photo_url ? (
                <img
                  src={member.photo_url}
                  alt={member.name}
                  style={{ width: 56, height: 56, borderRadius: '50%', objectFit: 'cover', border: `2px solid ${partyColor}` }}
                />
              ) : (
                <div style={{
                  width: 56, height: 56, borderRadius: '50%',
                  background: 'var(--color-surface-el)',
                  border: `2px solid ${partyColor}`,
                  display: 'flex', alignItems: 'center', justifyContent: 'center',
                }}>
                  <Users size={24} color="var(--color-text-secondary)" />
                </div>
              )}
              <div>
                <div style={{ fontWeight: 700, fontSize: '1rem', color: 'var(--color-text)' }}>{member.name}</div>
                <div style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', marginTop: 2 }}>
                  <span style={{ color: partyColor, fontWeight: 600 }}>{member.party}</span>
                  {' · '}{member.state}-{member.district}
                </div>
              </div>
            </div>

            {/* Opportunity score gauge */}
            <div className="card" style={{ marginBottom: '16px', padding: '12px' }}>
              <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
                <span style={{ fontSize: '0.8125rem', fontWeight: 600, color: 'var(--color-text-secondary)' }}>Opportunity Score</span>
                <span style={{ fontWeight: 700, fontSize: '1.125rem', color: score >= 70 ? '#22c55e' : score >= 40 ? '#f59e0b' : '#ef4444' }}>
                  {score}
                </span>
              </div>
              <div style={{ height: 8, background: 'var(--color-surface-el)', borderRadius: 4, overflow: 'hidden' }}>
                <div style={{
                  height: '100%',
                  width: `${score}%`,
                  background: score >= 70 ? '#22c55e' : score >= 40 ? '#f59e0b' : '#ef4444',
                  borderRadius: 4,
                  transition: 'width 0.6s ease',
                }} />
              </div>
            </div>

            {/* Contact */}
            {(member.phone || member.email) && (
              <div className="card" style={{ marginBottom: '16px', padding: '12px' }}>
                <div style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--color-text-secondary)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Contact</div>
                {member.phone && (
                  <a href={`tel:${member.phone}`} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '0.875rem', color: 'var(--color-text)', textDecoration: 'none', marginBottom: 4 }}>
                    <Phone size={14} color="var(--color-text-secondary)" />
                    {member.phone}
                  </a>
                )}
                {member.email && (
                  <a href={`mailto:${member.email}`} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '0.875rem', color: 'var(--color-primary)', textDecoration: 'none' }}>
                    <Mail size={14} />
                    {member.email}
                  </a>
                )}
              </div>
            )}

            {/* Staffers */}
            {member.staffers.length > 0 && (
              <div style={{ marginBottom: '16px' }}>
                <div style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--color-text-secondary)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
                  Staffers ({member.staffers.length})
                </div>
                <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
                  {member.staffers.map(s => (
                    <div key={s.id} className="card" style={{ padding: '10px 12px' }}>
                      <div style={{ fontWeight: 600, fontSize: '0.875rem', color: 'var(--color-text)', marginBottom: 2 }}>{s.name}</div>
                      <div style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', marginBottom: 6 }}>{s.title}</div>
                      <div style={{ display: 'flex', gap: 10 }}>
                        {s.email && (
                          <a href={`mailto:${s.email}`} title={s.email} style={{ color: 'var(--color-primary)', lineHeight: 1 }}>
                            <Mail size={13} />
                          </a>
                        )}
                        {s.phone && (
                          <a href={`tel:${s.phone}`} title={s.phone} style={{ color: 'var(--color-text-secondary)', lineHeight: 1 }}>
                            <Phone size={13} />
                          </a>
                        )}
                        {s.linkedin && (
                          <a href={s.linkedin} target="_blank" rel="noreferrer" title="LinkedIn" style={{ color: '#0a66c2', lineHeight: 1 }}>
                            <Linkedin size={13} />
                          </a>
                        )}
                      </div>
                    </div>
                  ))}
                </div>
              </div>
            )}

            {/* Community Colleges */}
            {member.community_colleges.length > 0 && (
              <div>
                <div style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--color-text-secondary)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
                  Community Colleges ({member.community_colleges.length})
                </div>
                <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
                  {member.community_colleges.map(c => (
                    <div key={c.id} className="card" style={{ padding: '10px 12px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
                      <div>
                        <div style={{ fontSize: '0.8125rem', fontWeight: 600, color: 'var(--color-text)' }}>{c.name}</div>
                        {c.city && <div style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)' }}>{c.city}, {c.state}</div>}
                      </div>
                      <div style={{ textAlign: 'right', flexShrink: 0, marginLeft: 8 }}>
                        <div style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)' }}>Median Debt</div>
                        <div style={{ fontSize: '0.875rem', fontWeight: 700, color: '#f59e0b' }}>
                          ${c.median_debt?.toLocaleString() ?? '—'}
                        </div>
                      </div>
                    </div>
                  ))}
                </div>
              </div>
            )}
          </>
        )}

        {/* ── Community College ── */}
        {node.type === 'college' && (() => {
          const c = node.data as CommunityCollege
          return (
            <>
              <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
                <div style={{ width: 44, height: 44, borderRadius: '50%', background: 'rgba(34,197,94,0.15)', border: '2px solid #22c55e', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
                  <GraduationCap size={20} color="#22c55e" />
                </div>
                <div>
                  <div style={{ fontWeight: 700, color: 'var(--color-text)' }}>{c.name}</div>
                  {c.city && <div style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)' }}>{c.city}, {c.state}</div>}
                </div>
              </div>
              <div className="card" style={{ padding: '12px' }}>
                <div style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', marginBottom: 4 }}>Median Debt</div>
                <div style={{ fontSize: '1.5rem', fontWeight: 700, color: '#f59e0b' }}>
                  ${c.median_debt?.toLocaleString() ?? 'N/A'}
                </div>
              </div>
              {c.zip && <div style={{ marginTop: 12, fontSize: '0.8125rem', color: 'var(--color-text-secondary)' }}>ZIP: {c.zip}</div>}
            </>
          )
        })()}

        {/* ── Hotspot ── */}
        {node.type === 'hotspot' && (() => {
          const h = node.data as Hotspot
          const severityColor = h.severity === 'critical' ? '#ef4444' : h.severity === 'high' ? '#f59e0b' : h.severity === 'medium' ? '#eab308' : '#a1a1aa'
          return (
            <>
              <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
                <div style={{ width: 44, height: 44, borderRadius: 4, background: 'rgba(239,68,68,0.15)', border: `2px solid ${severityColor}`, display: 'flex', alignItems: 'center', justifyContent: 'center', transform: 'rotate(45deg)' }}>
                  <AlertTriangle size={18} color={severityColor} style={{ transform: 'rotate(-45deg)' }} />
                </div>
                <div>
                  <div style={{ fontWeight: 700, color: 'var(--color-text)' }}>ZIP {h.zip}</div>
                  <div style={{ fontSize: '0.8125rem', color: severityColor, fontWeight: 600, textTransform: 'capitalize' }}>{h.severity} severity</div>
                </div>
              </div>
              <div className="card" style={{ padding: '12px', marginBottom: 8 }}>
                <div style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', marginBottom: 4 }}>Distress Z-Score</div>
                <div style={{ fontSize: '1.5rem', fontWeight: 700, color: severityColor }}>{h.z_score?.toFixed(2) ?? '—'}</div>
              </div>
              <div style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', textTransform: 'capitalize' }}>Type: {h.hotspot_type}</div>
            </>
          )
        })()}

        {/* ── Staffer ── */}
        {node.type === 'staffer' && (() => {
          const s = node.data as Staffer
          return (
            <>
              <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
                <div style={{ width: 44, height: 44, borderRadius: '50%', background: 'rgba(20,184,166,0.15)', border: '2px solid #14b8a6', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
                  <UserCircle size={22} color="#14b8a6" />
                </div>
                <div>
                  <div style={{ fontWeight: 700, color: 'var(--color-text)' }}>{s.name}</div>
                  <div style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)' }}>{s.title}</div>
                </div>
              </div>
              <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
                {s.email && (
                  <a href={`mailto:${s.email}`} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '0.875rem', color: 'var(--color-primary)', textDecoration: 'none' }}>
                    <Mail size={14} />{s.email}
                  </a>
                )}
                {s.phone && (
                  <a href={`tel:${s.phone}`} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '0.875rem', color: 'var(--color-text)', textDecoration: 'none' }}>
                    <Phone size={14} color="var(--color-text-secondary)" />{s.phone}
                  </a>
                )}
                {s.linkedin && (
                  <a href={s.linkedin} target="_blank" rel="noreferrer" style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '0.875rem', color: '#0a66c2', textDecoration: 'none' }}>
                    <Linkedin size={14} />View on LinkedIn
                  </a>
                )}
              </div>
            </>
          )
        })()}

      </div>
    </div>
  )
}

// ─── Toolbar ──────────────────────────────────────────────────────────────────

interface ToolbarProps {
  states: string[]
  selectedState: string
  onStateChange: (s: string) => void
  showStaffers: boolean
  showColleges: boolean
  showHotspots: boolean
  onToggleStaffers: () => void
  onToggleColleges: () => void
  onToggleHotspots: () => void
  onZoomIn: () => void
  onZoomOut: () => void
  onReset: () => void
  onRefresh: () => void
  loading: boolean
  nodeCount: number
  edgeCount: number
}

function Toolbar({
  states, selectedState, onStateChange,
  showStaffers, showColleges, showHotspots,
  onToggleStaffers, onToggleColleges, onToggleHotspots,
  onZoomIn, onZoomOut, onReset, onRefresh,
  loading, nodeCount, edgeCount,
}: ToolbarProps) {
  return (
    <div style={{
      display: 'flex',
      alignItems: 'center',
      gap: 8,
      padding: '10px 16px',
      borderBottom: '1px solid var(--color-border)',
      background: 'var(--color-surface)',
      flexWrap: 'wrap',
      flexShrink: 0,
    }}>
      {/* Title */}
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginRight: 8 }}>
        <span style={{ fontWeight: 700, fontSize: '0.9375rem', color: 'var(--color-text)' }}>Opportunity Map</span>
        <span style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)' }}>
          {nodeCount} nodes · {edgeCount} edges
        </span>
      </div>

      {/* State filter */}
      <div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
        <select
          value={selectedState}
          onChange={e => onStateChange(e.target.value)}
          className="input"
          style={{ paddingRight: 28, fontSize: '0.8125rem', height: 32, appearance: 'none', cursor: 'pointer', minWidth: 120 }}
          aria-label="Filter by state"
        >
          <option value="">All States</option>
          {states.map(s => <option key={s} value={s}>{s}</option>)}
        </select>
        <ChevronDown size={14} color="var(--color-text-secondary)" style={{ position: 'absolute', right: 8, pointerEvents: 'none' }} />
      </div>

      {/* Toggle switches */}
      <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
        <ToggleBtn active={showStaffers} onClick={onToggleStaffers} color="#14b8a6" label="Staffers" />
        <ToggleBtn active={showColleges} onClick={onToggleColleges} color="#22c55e" label="Colleges" />
        <ToggleBtn active={showHotspots} onClick={onToggleHotspots} color="#ef4444" label="Hotspots" />
      </div>

      {/* Spacer */}
      <div style={{ flex: 1 }} />

      {/* Zoom controls */}
      <div style={{ display: 'flex', gap: 4 }}>
        <button className="btn btn-secondary btn-sm" onClick={onZoomIn} aria-label="Zoom in" title="Zoom in">
          <ZoomIn size={14} />
        </button>
        <button className="btn btn-secondary btn-sm" onClick={onZoomOut} aria-label="Zoom out" title="Zoom out">
          <ZoomOut size={14} />
        </button>
        <button className="btn btn-secondary btn-sm" onClick={onReset} aria-label="Reset view" title="Reset view">
          <Maximize2 size={14} />
        </button>
        <button
          className="btn btn-secondary btn-sm"
          onClick={onRefresh}
          disabled={loading}
          aria-label="Refresh data"
          title="Refresh data"
        >
          <RefreshCw size={14} style={{ animation: loading ? 'spin 0.7s linear infinite' : 'none' }} />
        </button>
      </div>
    </div>
  )
}

function ToggleBtn({ active, onClick, color, label }: { active: boolean; onClick: () => void; color: string; label: string }) {
  return (
    <button
      onClick={onClick}
      style={{
        display: 'flex', alignItems: 'center', gap: 5,
        padding: '4px 10px',
        borderRadius: 9999,
        border: `1px solid ${active ? color : 'var(--color-border)'}`,
        background: active ? `${color}22` : 'var(--color-surface-el)',
        color: active ? color : 'var(--color-text-secondary)',
        fontSize: '0.75rem', fontWeight: 600,
        cursor: 'pointer', transition: 'all 150ms ease',
      }}
      aria-pressed={active}
      aria-label={`${active ? 'Hide' : 'Show'} ${label}`}
    >
      {active ? <Eye size={12} /> : <EyeOff size={12} />}
      {label}
    </button>
  )
}

// ─── Legend ───────────────────────────────────────────────────────────────────

function Legend() {
  return (
    <div style={{
      position: 'absolute', bottom: 16, left: 16,
      background: 'var(--color-surface)',
      border: '1px solid var(--color-border)',
      borderRadius: 'var(--radius-md)',
      padding: '10px 14px',
      display: 'flex', flexDirection: 'column', gap: 6,
      fontSize: '0.75rem',
      pointerEvents: 'none',
    }}>
      {[
        { color: '#2563eb', label: 'Congress Member', shape: 'circle', size: 14 },
        { color: '#22c55e', label: 'Community College', shape: 'circle', size: 10 },
        { color: '#ef4444', label: 'Debt Hotspot', shape: 'diamond', size: 10 },
        { color: '#14b8a6', label: 'Staffer', shape: 'circle', size: 8 },
      ].map(({ color, label, shape, size }) => (
        <div key={label} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
          <svg width={16} height={16} style={{ flexShrink: 0 }}>
            {shape === 'circle' ? (
              <circle cx={8} cy={8} r={size / 2} fill={color} opacity={0.9} />
            ) : (
              <polygon points="8,2 14,8 8,14 2,8" fill={color} opacity={0.9} />
            )}
          </svg>
          <span style={{ color: 'var(--color-text-secondary)' }}>{label}</span>
        </div>
      ))}
      <hr style={{ border: 'none', borderTop: '1px solid var(--color-border)', margin: '2px 0' }} />
      {[
        { color: '#2563eb', label: 'Member → College', dash: false },
        { color: '#6b7280', label: 'Member → Staffer', dash: false },
        { color: '#ef4444', label: 'College → Hotspot', dash: true },
      ].map(({ color, label, dash }) => (
        <div key={label} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
          <svg width={24} height={10} style={{ flexShrink: 0 }}>
            <line x1={0} y1={5} x2={24} y2={5} stroke={color} strokeWidth={1.5} strokeDasharray={dash ? '4,3' : undefined} />
          </svg>
          <span style={{ color: 'var(--color-text-secondary)' }}>{label}</span>
        </div>
      ))}
    </div>
  )
}

// ─── Tooltip ──────────────────────────────────────────────────────────────────

interface TooltipProps {
  node: GraphNode | null
  transform: { x: number; y: number; scale: number }
}

function Tooltip({ node, transform }: TooltipProps) {
  if (!node) return null

  const screenX = node.x * transform.scale + transform.x
  const screenY = node.y * transform.scale + transform.y
  const color = NODE_COLORS[node.type]

  return (
    <div
      style={{
        position: 'absolute',
        left: screenX + node.radius * transform.scale + 8,
        top: screenY - 20,
        background: 'var(--color-surface-el)',
        border: `1px solid ${color}55`,
        borderRadius: 'var(--radius-md)',
        padding: '6px 10px',
        fontSize: '0.8125rem',
        color: 'var(--color-text)',
        pointerEvents: 'none',
        zIndex: 10,
        maxWidth: 220,
        boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
        whiteSpace: 'nowrap',
      }}
      role="tooltip"
    >
      <div style={{ fontWeight: 600, color }}>{node.label}</div>
      {node.sublabel && <div style={{ color: 'var(--color-text-secondary)', fontSize: '0.75rem', marginTop: 2 }}>{node.sublabel}</div>}
    </div>
  )
}

// ─── Main Component ───────────────────────────────────────────────────────────

export default function OpportunityMap() {
  const svgRef       = useRef<SVGSVGElement>(null)
  const containerRef = useRef<HTMLDivElement>(null)

  const [members,  setMembers]  = useState<CongressMember[]>([])
  const [hotspots, setHotspots] = useState<Hotspot[]>([])
  const [loading,  setLoading]  = useState(true)
  const [error,    setError]    = useState<string | null>(null)

  const [nodes, setNodes] = useState<GraphNode[]>([])
  const [edges, setEdges] = useState<GraphEdge[]>([])

  const [hoveredId,  setHoveredId]  = useState<string | null>(null)
  const [selectedId, setSelectedId] = useState<string | null>(null)

  const [selectedState, setSelectedState] = useState('')
  const [showStaffers,  setShowStaffers]  = useState(true)
  const [showColleges,  setShowColleges]  = useState(true)
  const [showHotspots,  setShowHotspots]  = useState(true)

  // viewport transform: pan + zoom
  const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 })
  const isPanning = useRef(false)
  const panStart  = useRef({ x: 0, y: 0 })
  const transformRef = useRef(transform)
  useEffect(() => { transformRef.current = transform }, [transform])

  const [svgSize, setSvgSize] = useState({ w: 900, h: 600 })

  // ── Fetch data ──────────────────────────────────────────────────────────────

  const fetchData = useCallback(async () => {
    setLoading(true)
    setError(null)
    try {
      const [congressRes, hotspotsRes] = await Promise.all([
        fetch('/api/congress?limit=50'),
        fetch('/api/geo/hotspots'),
      ])

      if (!congressRes.ok) throw new Error(`Congress API: ${congressRes.status}`)
      if (!hotspotsRes.ok) throw new Error(`Hotspots API: ${hotspotsRes.status}`)

      const congressData = await congressRes.json()
      const hotspotsData = await hotspotsRes.json()

      const memberList: CongressMember[] = Array.isArray(congressData)
        ? congressData
        : congressData.members ?? congressData.data ?? []

      const hotspotList: Hotspot[] = Array.isArray(hotspotsData)
        ? hotspotsData
        : hotspotsData.hotspots ?? hotspotsData.data ?? []

      setMembers(memberList)
      setHotspots(hotspotList)
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to load data')
    } finally {
      setLoading(false)
    }
  }, [])

  useEffect(() => { fetchData() }, [fetchData])

  // ── Measure SVG container ───────────────────────────────────────────────────

  useEffect(() => {
    if (!containerRef.current) return
    const ro = new ResizeObserver(entries => {
      for (const e of entries) {
        const { width, height } = e.contentRect
        setSvgSize({ w: Math.floor(width), h: Math.floor(height) })
      }
    })
    ro.observe(containerRef.current)
    return () => ro.disconnect()
  }, [])

  // ── Build graph ─────────────────────────────────────────────────────────────

  const filteredMembers = useMemo(() =>
    selectedState ? members.filter(m => m.state === selectedState) : members
  , [members, selectedState])

  useEffect(() => {
    if (!filteredMembers.length && !hotspots.length) return

    const { w: svgW, h: svgH } = svgSize
    const cx = svgW / 2
    const cy = svgH / 2

    const newNodes: GraphNode[] = []
    const newEdges: GraphEdge[] = []
    const collegeSet = new Set<string>()
    const hotspotSet = new Set<string>()

    // Congress member nodes — spread in a ring around center
    filteredMembers.forEach((m, i) => {
      const angle  = (i / filteredMembers.length) * Math.PI * 2
      const radius = Math.min(svgW, svgH) * 0.28
      newNodes.push({
        id: `congress-${m.id}`,
        type: 'congress',
        label: m.name,
        sublabel: `${m.party} · ${m.state}-${m.district}`,
        x: cx + Math.cos(angle) * radius + (Math.random() - 0.5) * 40,
        y: cy + Math.sin(angle) * radius + (Math.random() - 0.5) * 40,
        vx: 0, vy: 0,
        mass: 4,
        radius: NODE_RADII.congress,
        data: m,
      })

      // College nodes
      if (showColleges) {
        m.community_colleges.forEach(c => {
          const colId = `college-${c.id}`
          if (!collegeSet.has(colId)) {
            collegeSet.add(colId)
            newNodes.push({
              id: colId,
              type: 'college',
              label: c.name,
              sublabel: `$${c.median_debt?.toLocaleString() ?? '—'} median debt`,
              x: cx + Math.cos(angle) * radius * 1.5 + (Math.random() - 0.5) * 80,
              y: cy + Math.sin(angle) * radius * 1.5 + (Math.random() - 0.5) * 80,
              vx: 0, vy: 0,
              mass: 2,
              radius: NODE_RADII.college,
              data: c,
              parentId: `congress-${m.id}`,
            })
          }
          newEdges.push({ source: `congress-${m.id}`, target: colId, type: 'congress-college', weight: 1 })

          // Hotspot nodes connected to this college
          if (showHotspots) {
            hotspots
              .filter(h => h.college_id === c.id)
              .forEach(h => {
                const hotId = `hotspot-${h.id}`
                if (!hotspotSet.has(hotId)) {
                  hotspotSet.add(hotId)
                  newNodes.push({
                    id: hotId,
                    type: 'hotspot',
                    label: `ZIP ${h.zip}`,
                    sublabel: `${h.severity} · z=${h.z_score != null ? Number(h.z_score).toFixed(2) : 'N/A'}`,
                    x: cx + Math.cos(angle) * radius * 2 + (Math.random() - 0.5) * 100,
                    y: cy + Math.sin(angle) * radius * 2 + (Math.random() - 0.5) * 100,
                    vx: 0, vy: 0,
                    mass: 1,
                    radius: NODE_RADII.hotspot,
                    data: h,
                    parentId: colId,
                  })
                }
                newEdges.push({
                  source: colId,
                  target: hotId,
                  type: 'college-hotspot',
                  weight: h.severity === 'critical' ? 2 : 1,
                })
              })
          }
        })
      }

      // Staffer nodes
      if (showStaffers) {
        m.staffers.forEach(s => {
          const staffId = `staffer-${s.id}`
          newNodes.push({
            id: staffId,
            type: 'staffer',
            label: s.name,
            sublabel: s.title,
            x: cx + Math.cos(angle) * radius * 0.6 + (Math.random() - 0.5) * 60,
            y: cy + Math.sin(angle) * radius * 0.6 + (Math.random() - 0.5) * 60,
            vx: 0, vy: 0,
            mass: 1,
            radius: NODE_RADII.staffer,
            data: s,
            parentId: `congress-${m.id}`,
          })
          newEdges.push({ source: `congress-${m.id}`, target: staffId, type: 'congress-staffer', weight: 1 })
        })
      }
    })

    // Hotspots not linked to any college (standalone)
    if (showHotspots) {
      hotspots
        .filter(h => !h.college_id)
        .forEach(h => {
          const hotId = `hotspot-${h.id}`
          if (!hotspotSet.has(hotId)) {
            hotspotSet.add(hotId)
            newNodes.push({
              id: hotId,
              type: 'hotspot',
              label: `ZIP ${h.zip}`,
              sublabel: `${h.severity} · z=${h.z_score != null ? Number(h.z_score).toFixed(2) : 'N/A'}`,
              x: cx + (Math.random() - 0.5) * svgW * 0.7,
              y: cy + (Math.random() - 0.5) * svgH * 0.7,
              vx: 0, vy: 0,
              mass: 1,
              radius: NODE_RADII.hotspot,
              data: h,
            })
          }
        })
    }

    // Run force simulation
    runSimulation(newNodes, newEdges, svgW, svgH)

    setNodes(newNodes)
    setEdges(newEdges)
    setSelectedId(null)
    setHoveredId(null)
  }, [filteredMembers, hotspots, showStaffers, showColleges, showHotspots, svgSize])

  // ── States list for dropdown ────────────────────────────────────────────────

  const states = useMemo(() =>
    [...new Set(members.map(m => m.state))].sort()
  , [members])

  // ── Derived sets for highlight ──────────────────────────────────────────────

  const connectedIds = useMemo<Set<string>>(() => {
    const activeId = hoveredId ?? selectedId
    if (!activeId) return new Set()
    const ids = new Set<string>()
    for (const e of edges) {
      if (e.source === activeId) ids.add(e.target)
      if (e.target === activeId) ids.add(e.source)
    }
    return ids
  }, [hoveredId, selectedId, edges])

  const activeId = hoveredId ?? selectedId

  // ── Mouse handlers ──────────────────────────────────────────────────────────

  const handleWheel = useCallback((e: React.WheelEvent) => {
    e.preventDefault()
    const delta  = e.deltaY > 0 ? 0.88 : 1 / 0.88
    const rect   = svgRef.current!.getBoundingClientRect()
    const mouseX = e.clientX - rect.left
    const mouseY = e.clientY - rect.top
    setTransform(prev => {
      const newScale = Math.max(0.25, Math.min(4, prev.scale * delta))
      const ratio    = newScale / prev.scale
      return {
        x: mouseX - ratio * (mouseX - prev.x),
        y: mouseY - ratio * (mouseY - prev.y),
        scale: newScale,
      }
    })
  }, [])

  const handleMouseDown = useCallback((e: React.MouseEvent) => {
    if ((e.target as SVGElement).closest('[data-node]')) return
    isPanning.current = true
    panStart.current  = { x: e.clientX - transformRef.current.x, y: e.clientY - transformRef.current.y }
  }, [])

  const handleMouseMove = useCallback((e: React.MouseEvent) => {
    if (!isPanning.current) return
    setTransform(prev => ({
      ...prev,
      x: e.clientX - panStart.current.x,
      y: e.clientY - panStart.current.y,
    }))
  }, [])

  const handleMouseUp = useCallback(() => { isPanning.current = false }, [])

  const handleZoomIn  = () => setTransform(p => ({ ...p, scale: Math.min(4, p.scale * 1.25) }))
  const handleZoomOut = () => setTransform(p => ({ ...p, scale: Math.max(0.25, p.scale / 1.25) }))
  const handleReset   = () => setTransform({ x: 0, y: 0, scale: 1 })

  // ── Selected node for detail panel ─────────────────────────────────────────

  const selectedNode = useMemo(() => nodes.find(n => n.id === selectedId) ?? null, [nodes, selectedId])
  const hoveredNode  = useMemo(() => nodes.find(n => n.id === hoveredId)  ?? null, [nodes, hoveredId])

  // ── SVG rendering helpers ───────────────────────────────────────────────────

  function renderEdges() {
    return edges.map((e, i) => {
      const src = nodes.find(n => n.id === e.source)
      const tgt = nodes.find(n => n.id === e.target)
      if (!src || !tgt) return null

      const isActive = activeId && (e.source === activeId || e.target === activeId)
      const isDimmed = activeId && !isActive

      let stroke = '#4b5563'
      if (e.type === 'congress-college') stroke = '#2563eb'
      if (e.type === 'college-hotspot')  stroke = '#ef4444'

      const opacity = isDimmed ? 0.08 : isActive ? 0.9 : 0.28
      const sw = e.type === 'college-hotspot' && e.weight === 2 ? 2 : 1

      return (
        <line
          key={i}
          x1={src.x} y1={src.y}
          x2={tgt.x} y2={tgt.y}
          stroke={stroke}
          strokeWidth={sw}
          strokeOpacity={opacity}
          strokeDasharray={e.type === 'college-hotspot' ? '5,4' : undefined}
        />
      )
    })
  }

  function renderNodes() {
    return nodes.map(n => {
      const isHovered  = n.id === hoveredId
      const isSelected = n.id === selectedId
      const isConnected = connectedIds.has(n.id)
      const isDimmed   = activeId && !isHovered && !isSelected && !isConnected && n.id !== activeId
      const color      = NODE_COLORS[n.type]
      const r          = n.radius
      const opacity    = isDimmed ? 0.15 : 1
      const glowSize   = isHovered || isSelected ? r + 6 : 0

      const partyColor = n.type === 'congress'
        ? ((n.data as CongressMember).party === 'D' ? '#3b82f6' : (n.data as CongressMember).party === 'R' ? '#ef4444' : '#a1a1aa')
        : null

      return (
        <g
          key={n.id}
          data-node="true"
          style={{ cursor: 'pointer', opacity }}
          onMouseEnter={() => setHoveredId(n.id)}
          onMouseLeave={() => setHoveredId(null)}
          onClick={() => setSelectedId(prev => prev === n.id ? null : n.id)}
          role="button"
          aria-label={`${n.type}: ${n.label}`}
          tabIndex={0}
          onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') setSelectedId(prev => prev === n.id ? null : n.id) }}
        >
          {/* Glow ring on hover/select */}
          {glowSize > 0 && n.type !== 'hotspot' && (
            <circle cx={n.x} cy={n.y} r={glowSize} fill="none" stroke={color} strokeWidth={2} strokeOpacity={0.4} />
          )}

          {/* Node shape */}
          {n.type === 'hotspot' ? (
            // Diamond shape
            <>
              {(isHovered || isSelected) && (
                <polygon
                  points={`${n.x},${n.y - r - 6} ${n.x + r + 6},${n.y} ${n.x},${n.y + r + 6} ${n.x - r - 6},${n.y}`}
                  fill="none" stroke={color} strokeWidth={2} strokeOpacity={0.4}
                />
              )}
              <polygon
                points={`${n.x},${n.y - r} ${n.x + r},${n.y} ${n.x},${n.y + r} ${n.x - r},${n.y}`}
                fill={color}
                fillOpacity={isSelected ? 1 : 0.82}
                stroke={isSelected ? '#fff' : color}
                strokeWidth={isSelected ? 2 : 1}
              />
            </>
          ) : (
            <>
              <circle cx={n.x} cy={n.y} r={r} fill={color} fillOpacity={isSelected ? 1 : 0.85}
                stroke={isSelected ? '#fff' : partyColor ?? color}
                strokeWidth={isSelected ? 2.5 : n.type === 'congress' ? 2 : 1}
                strokeOpacity={0.9}
              />
            </>
          )}

          {/* Label for congress + colleges */}
          {(n.type === 'congress' || (n.type === 'college' && isHovered)) && (
            <text
              x={n.x}
              y={n.y + r + 13}
              textAnchor="middle"
              fontSize={n.type === 'congress' ? 11 : 10}
              fontWeight={n.type === 'congress' ? 600 : 400}
              fill="var(--color-text)"
              fillOpacity={isDimmed ? 0.2 : 0.9}
              pointerEvents="none"
              style={{ userSelect: 'none' }}
            >
              {n.label.length > 20 ? n.label.slice(0, 18) + '…' : n.label}
            </text>
          )}

          {/* Party badge on congress nodes */}
          {n.type === 'congress' && (
            <text x={n.x} y={n.y + 4} textAnchor="middle" fontSize={11} fontWeight={700}
              fill="#fff" pointerEvents="none" style={{ userSelect: 'none' }}>
              {(n.data as CongressMember).party}
            </text>
          )}
        </g>
      )
    })
  }

  // ── Loading / error states ──────────────────────────────────────────────────

  if (loading && nodes.length === 0) {
    return (
      <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--color-bg)', flexDirection: 'column', gap: 16 }}>
        <div className="spinner" style={{ width: 36, height: 36 }} />
        <span style={{ color: 'var(--color-text-secondary)', fontSize: '0.9375rem' }}>Building opportunity map…</span>
      </div>
    )
  }

  if (error && nodes.length === 0) {
    return (
      <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--color-bg)', flexDirection: 'column', gap: 12 }}>
        <AlertTriangle size={32} color="var(--color-error)" />
        <span style={{ color: 'var(--color-error)', fontWeight: 600 }}>Failed to load map data</span>
        <span style={{ color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>{error}</span>
        <button className="btn btn-primary btn-sm" onClick={fetchData}>Retry</button>
      </div>
    )
  }

  // ── Render ─────────────────────────────────────────────────────────────────

  return (
    <div
      style={{ display: 'flex', flexDirection: 'column', flex: 1, height: '100%', background: 'var(--color-bg)', overflow: 'hidden' }}
      aria-label="Congressional opportunity network map"
    >
      {/* Toolbar */}
      <Toolbar
        states={states}
        selectedState={selectedState}
        onStateChange={setSelectedState}
        showStaffers={showStaffers}
        showColleges={showColleges}
        showHotspots={showHotspots}
        onToggleStaffers={() => setShowStaffers(v => !v)}
        onToggleColleges={() => setShowColleges(v => !v)}
        onToggleHotspots={() => setShowHotspots(v => !v)}
        onZoomIn={handleZoomIn}
        onZoomOut={handleZoomOut}
        onReset={handleReset}
        onRefresh={fetchData}
        loading={loading}
        nodeCount={nodes.length}
        edgeCount={edges.length}
      />

      {/* Main area: canvas + detail panel */}
      <div style={{ flex: 1, display: 'flex', overflow: 'hidden', position: 'relative' }}>

        {/* SVG canvas */}
        <div
          ref={containerRef}
          style={{ flex: 1, position: 'relative', overflow: 'hidden', cursor: isPanning.current ? 'grabbing' : 'grab' }}
          onWheel={handleWheel}
          onMouseDown={handleMouseDown}
          onMouseMove={handleMouseMove}
          onMouseUp={handleMouseUp}
          onMouseLeave={handleMouseUp}
        >
          <svg
            ref={svgRef}
            width="100%"
            height="100%"
            style={{ display: 'block', userSelect: 'none' }}
            aria-hidden="true"
          >
            {/* Background dots pattern */}
            <defs>
              <pattern id="omap-dot" x={0} y={0} width={24} height={24} patternUnits="userSpaceOnUse">
                <circle cx={1} cy={1} r={0.8} fill="#2a2a3c" />
              </pattern>
            </defs>
            <rect width="100%" height="100%" fill="url(#omap-dot)" />

            {/* Graph content group — transform applied here */}
            <g transform={`translate(${transform.x},${transform.y}) scale(${transform.scale})`}>
              {/* Edges layer */}
              <g aria-hidden="true">
                {renderEdges()}
              </g>

              {/* Nodes layer */}
              <g>
                {renderNodes()}
              </g>
            </g>
          </svg>

          {/* Tooltip — positioned in screen space */}
          <Tooltip node={hoveredId && !selectedId ? hoveredNode : null} transform={transform} />

          {/* Legend */}
          <Legend />

          {/* Empty state */}
          {nodes.length === 0 && !loading && (
            <div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 8, pointerEvents: 'none' }}>
              <Users size={40} color="var(--color-border)" />
              <span style={{ color: 'var(--color-text-muted)' }}>No data for selected filters</span>
            </div>
          )}

          {/* Hint */}
          {nodes.length > 0 && (
            <div style={{
              position: 'absolute', bottom: 16, right: 16,
              fontSize: '0.75rem', color: 'var(--color-text-muted)',
              pointerEvents: 'none',
            }}>
              Scroll to zoom · Drag to pan · Click node for details
            </div>
          )}
        </div>

        {/* Detail Panel */}
        {selectedNode && (
          <DetailPanel
            node={selectedNode}
            allHotspots={hotspots}
            onClose={() => setSelectedId(null)}
          />
        )}
      </div>
    </div>
  )
}