← back to Grant
app/river/RiverCanvas.tsx
262 lines
'use client';
/**
* RiverCanvas — SVG visualization of grants as fish swimming toward
* close-date. Pure SVG (no canvas) so we get free hover + accessibility.
*
* 2026-05-05 (tick 37 build).
*/
import { useState, useMemo, useEffect } from 'react';
type Grant = {
id: string;
number?: string;
title?: string;
agencyCode?: string;
agency?: string;
openDate?: string;
closeDate?: string;
oppStatus?: string;
cfdaList?: string[];
};
// ---------------------------------------------------------------------------
// Color by CFDA prefix (47=NSF, 84=Education, 93=HHS, 10=USDA, etc.)
// ---------------------------------------------------------------------------
function cfdaColor(cfdaList: string[] | undefined): string {
const first = cfdaList?.[0]?.split('.')?.[0] ?? '';
const map: Record<string, string> = {
'47': '#3b82f6', // NSF — blue
'84': '#10b981', // Education — emerald
'93': '#ef4444', // HHS — red
'10': '#f59e0b', // USDA — amber
'15': '#8b5cf6', // Interior — violet
'21': '#ec4899', // Treasury — pink
'12': '#06b6d4', // Defense — cyan
'14': '#fb923c', // HUD — orange
'11': '#84cc16', // Commerce — lime
'20': '#a855f7', // Transportation — purple
};
return map[first] ?? '#94a3b8';
}
// ---------------------------------------------------------------------------
// Fish silhouette path — agency determines body shape variant
// ---------------------------------------------------------------------------
function fishPath(agency: string | undefined, scale: number): string {
const s = scale;
// Default oval body + tail triangle. Variants tweak proportions.
const a = (agency || '').toLowerCase();
if (a.includes('nsf') || a.includes('science')) {
// sleek trout
return `M0,0 Q${10*s},-${4*s} ${20*s},0 Q${10*s},${4*s} 0,0 M${20*s},0 L${28*s},-${5*s} L${28*s},${5*s} Z`;
}
if (a.includes('hhs') || a.includes('health')) {
// round salmon
return `M0,0 Q${12*s},-${5*s} ${22*s},0 Q${12*s},${5*s} 0,0 M${22*s},0 L${30*s},-${6*s} L${30*s},${6*s} Z`;
}
if (a.includes('defense') || a.includes('army') || a.includes('navy')) {
// marlin
return `M-${4*s},0 L0,-${2*s} Q${14*s},-${4*s} ${24*s},0 Q${14*s},${4*s} 0,${2*s} Z M${24*s},0 L${32*s},-${6*s} L${32*s},${6*s} Z`;
}
// default catfish
return `M0,0 Q${11*s},-${4*s} ${20*s},0 Q${11*s},${4*s} 0,0 M${20*s},0 L${27*s},-${4*s} L${27*s},${4*s} Z`;
}
// ---------------------------------------------------------------------------
// Date math
// ---------------------------------------------------------------------------
function parseDate(s: string | undefined): Date | null {
if (!s) return null;
const m = s.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
if (!m) return null;
return new Date(`${m[3]}-${m[1]}-${m[2]}T00:00:00Z`);
}
function daysUntil(d: Date | null): number {
if (!d) return 365;
return Math.round((d.getTime() - Date.now()) / 86_400_000);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
const W = 1400;
const H = 600;
const MARGIN = { top: 40, right: 60, bottom: 40, left: 80 };
const HORIZON_DAYS = 540; // 18 months
export default function RiverCanvas({ grants }: { grants: Grant[] }) {
const [hover, setHover] = useState<Grant | null>(null);
const [tick, setTick] = useState(0);
// Slow tick drives the swim animation
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 80);
return () => clearInterval(id);
}, []);
// Layout grants
const fishes = useMemo(() => {
return grants.map((g) => {
const close = parseDate(g.closeDate);
const days = daysUntil(close);
// X: today=margin.left, +18mo = W - margin.right. Past-deadline = beyond right.
const xFrac = Math.min(1, Math.max(-0.05, days / HORIZON_DAYS));
const x = MARGIN.left + xFrac * (W - MARGIN.left - MARGIN.right);
// Y: hash by id for spread (we have no $ in search2 response, use deterministic distribution)
const hash = [...g.id].reduce((a, c) => a + c.charCodeAt(0), 0);
const yFrac = (hash % 100) / 100;
const y = MARGIN.top + yFrac * (H - MARGIN.top - MARGIN.bottom);
// Urgency: closer to deadline = brighter glow + faster wiggle
const urgency = Math.max(0, Math.min(1, 1 - days / 90));
return { g, x, y, urgency, days };
});
}, [grants]);
return (
<div style={{ position: 'relative', width: '100%', overflow: 'hidden' }}>
<svg viewBox={`0 0 ${W} ${H}`} width="100%" style={{ display: 'block' }}>
{/* water gradient */}
<defs>
<linearGradient id="water" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#0a1f1c" />
<stop offset="1" stopColor="#020807" />
</linearGradient>
<linearGradient id="surface" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stopColor="#1f2925" stopOpacity="0" />
<stop offset="0.5" stopColor="#1f2925" />
<stop offset="1" stopColor="#1f2925" stopOpacity="0" />
</linearGradient>
<radialGradient id="urgent">
<stop offset="0" stopColor="#10b981" stopOpacity="0.6" />
<stop offset="1" stopColor="#10b981" stopOpacity="0" />
</radialGradient>
</defs>
<rect x="0" y="0" width={W} height={H} fill="url(#water)" />
{/* time axis */}
<line
x1={MARGIN.left} y1={H - MARGIN.bottom + 10}
x2={W - MARGIN.right} y2={H - MARGIN.bottom + 10}
stroke="url(#surface)" strokeWidth="1"
/>
{[0, 90, 180, 270, 360, 450, 540].map((d) => {
const x = MARGIN.left + (d / HORIZON_DAYS) * (W - MARGIN.left - MARGIN.right);
return (
<g key={d}>
<line x1={x} y1={MARGIN.top} x2={x} y2={H - MARGIN.bottom + 8}
stroke="#1f2925" strokeDasharray="2,4" strokeWidth="0.5" />
<text x={x} y={H - MARGIN.bottom + 24} textAnchor="middle"
fill="#6c7d75" fontSize="10" fontFamily="ui-monospace, monospace">
{d === 0 ? 'today' : `+${d}d`}
</text>
</g>
);
})}
{/* danger zone (next 14 days) */}
<rect
x={MARGIN.left} y={MARGIN.top}
width={(14 / HORIZON_DAYS) * (W - MARGIN.left - MARGIN.right)}
height={H - MARGIN.top - MARGIN.bottom}
fill="#ef4444" opacity="0.04"
/>
{/* fish */}
{fishes.map(({ g, x, y, urgency, days }) => {
// Wiggle: faster + bigger as urgency rises
const wiggleAmp = 1 + urgency * 4;
const wigglePeriod = 20 - urgency * 12;
const wiggle = Math.sin((tick + g.id.length * 7) / wigglePeriod) * wiggleAmp;
const fx = x + Math.sin(tick / 30 + g.id.length) * 1.2;
const fy = y + wiggle;
const color = cfdaColor(g.cfdaList);
const scale = 1 + urgency * 0.35;
const isPast = days < 0;
const opacity = isPast ? 0.18 : 1;
const isHover = hover?.id === g.id;
return (
<g
key={g.id}
transform={`translate(${fx} ${fy})`}
onMouseEnter={() => setHover(g)}
onMouseLeave={() => setHover(null)}
onClick={() => window.open(`https://www.grants.gov/search-results-detail/${g.id}`, '_blank')}
style={{ cursor: 'pointer' }}
opacity={opacity}
>
{urgency > 0.6 && (
<circle cx={10} cy={0} r={28 * scale} fill="url(#urgent)" />
)}
{isHover && (
<circle cx={10} cy={0} r={22 * scale} fill="none" stroke={color}
strokeWidth="1.5" strokeOpacity="0.8" />
)}
<path
d={fishPath(g.agency, scale)}
fill={color}
opacity={0.85}
/>
<circle cx={4 * scale} cy={-1 * scale} r={1.2 * scale} fill="#0a1f1c" />
</g>
);
})}
</svg>
{/* hover detail */}
{hover && (
<div style={{
position: 'absolute', top: 16, left: 16, maxWidth: 420,
background: '#0f1311', border: `1px solid ${cfdaColor(hover.cfdaList)}`,
borderRadius: 8, padding: '14px 16px', pointerEvents: 'none',
boxShadow: '0 4px 24px rgba(0,0,0,0.4)',
}}>
<div style={{ fontSize: 13, fontWeight: 600, color: '#d8e8df', marginBottom: 4 }}>
{hover.title || 'Untitled'}
</div>
<div style={{ fontSize: 11, color: '#6c7d75', display: 'flex', gap: 12 }}>
<span>{hover.agency || hover.agencyCode || 'Federal'}</span>
<span>· #{hover.number || '—'}</span>
<span>· CFDA {hover.cfdaList?.join(', ') || '—'}</span>
</div>
<div style={{ fontSize: 11, color: '#6c7d75', marginTop: 6, display: 'flex', gap: 12 }}>
<span>opens: {hover.openDate || '—'}</span>
<span>closes: <strong style={{ color: '#d8e8df' }}>{hover.closeDate || '—'}</strong></span>
<span>· {hover.oppStatus}</span>
</div>
<div style={{ fontSize: 10, color: cfdaColor(hover.cfdaList), marginTop: 8 }}>
click fish to open grants.gov
</div>
</div>
)}
{/* 2026-05-05 (tick 42): legend in bottom-right */}
{!hover && (
<div style={{
position: 'absolute', bottom: 12, right: 12, padding: '12px 14px',
background: 'rgba(15,19,17,0.92)', border: '1px solid #1f2925',
borderRadius: 6, fontSize: 10, color: '#6c7d75', maxWidth: 220,
lineHeight: 1.5,
}}>
<div style={{ color: '#d8e8df', marginBottom: 6, fontWeight: 600, letterSpacing: '0.05em', textTransform: 'uppercase', fontSize: 9 }}>
Legend
</div>
<div style={{ display: 'grid', gridTemplateColumns: '14px 1fr', gap: '4px 8px', alignItems: 'center' }}>
<span style={{ width: 12, height: 5, background: '#3b82f6', borderRadius: 3 }} /> NSF (47.x)
<span style={{ width: 12, height: 5, background: '#10b981', borderRadius: 3 }} /> Education (84.x)
<span style={{ width: 12, height: 5, background: '#ef4444', borderRadius: 3 }} /> HHS (93.x)
<span style={{ width: 12, height: 5, background: '#f59e0b', borderRadius: 3 }} /> USDA (10.x)
<span style={{ width: 12, height: 5, background: '#06b6d4', borderRadius: 3 }} /> Defense (12.x)
<span style={{ width: 12, height: 5, background: '#94a3b8', borderRadius: 3 }} /> other
</div>
<div style={{ marginTop: 8, paddingTop: 8, borderTop: '1px solid #1f2925' }}>
silhouette → agency · glow → urgency<br />
past-deadline fish drift right (faded)
</div>
</div>
)}
</div>
);
}