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