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