← back to Patty

app/pulse/PulseCanvas.tsx

312 lines

'use client';

/**
 * PulseCanvas — SVG ECG/heartbeat visualization of petition signature velocity.
 *
 * Each petition is a pulse spike on a horizontal sweep:
 *   x  = days since launch (right = newest, max ~30d)
 *   y  = signature velocity (top = high signatures/day)
 *   color = category (climate / ai_safety / governance / etc.)
 *   height = velocity bar, with a glowing spike at peak
 *
 * Mirrors Grant's RiverCanvas structure (pure SVG, hover detail, legend in a
 * corner, slow tick drives the animation) — different visual metaphor and
 * different palette so the two visualizations feel like sister tools, not
 * forks of one template.
 */

import { Fragment, useState, useMemo, useEffect } from 'react';

export type Petition = {
  id: string;
  slug: string;
  title: string;
  category: 'climate' | 'ai_safety' | 'governance' | 'civil_rights' | 'economic_justice' | 'healthcare';
  signatures: number;
  goal: number;
  velocity: number;       // signatures/day
  daysSinceStart: number; // 0 = launched today, 30 = a month ago
};

// ---------------------------------------------------------------------------
// Category palette — distinct hues that read well on near-black.
// ---------------------------------------------------------------------------
const CATEGORY_COLOR: Record<Petition['category'], string> = {
  climate:           '#10b981', // emerald
  ai_safety:         '#a78bfa', // violet-400 (the house color)
  governance:        '#fbbf24', // amber
  civil_rights:      '#fb7185', // rose
  economic_justice:  '#38bdf8', // sky
  healthcare:        '#ec4899', // pink
};

const CATEGORY_LABEL: Record<Petition['category'], string> = {
  climate:           'Climate',
  ai_safety:         'AI Safety',
  governance:        'Governance',
  civil_rights:      'Civil Rights',
  economic_justice:  'Economic Justice',
  healthcare:        'Healthcare',
};

// ---------------------------------------------------------------------------
// Viewport
// ---------------------------------------------------------------------------
const W = 1400;
const H = 520;
const MARGIN = { top: 40, right: 60, bottom: 50, left: 80 };
const WINDOW_DAYS = 30;   // x-axis horizon
const MAX_VELOCITY = 1000; // y-axis ceiling (signatures/day)

// ---------------------------------------------------------------------------
// Build a stable ECG-like baseline path so the canvas has a heartbeat
// background even with no data. The path is regenerated on every tick to
// give the sweep its "running" feel. Tiny noise — pure decoration.
// ---------------------------------------------------------------------------
function ecgBaseline(tick: number): string {
  const seg = 14; // px between baseline samples
  const yMid = H - MARGIN.bottom - 8;
  let d = `M${MARGIN.left} ${yMid}`;
  for (let x = MARGIN.left; x < W - MARGIN.right; x += seg) {
    // Mostly flat, with a faint sinusoidal jitter
    const phase = (x + tick * 2) * 0.018;
    const y = yMid + Math.sin(phase) * 0.6 + Math.sin(phase * 3.3) * 0.3;
    d += ` L${x + seg} ${y}`;
  }
  return d;
}

export default function PulseCanvas({ petitions }: { petitions: Petition[] }) {
  const [hover, setHover] = useState<Petition | null>(null);
  const [tick, setTick] = useState(0);

  // Slow tick drives the sweep + spike pulsing
  useEffect(() => {
    const id = setInterval(() => setTick((t) => t + 1), 90);
    return () => clearInterval(id);
  }, []);

  // Lay out each petition as a pulse spike at its x,y coordinate.
  const spikes = useMemo(() => {
    return petitions.map((p) => {
      // X: 0d = right edge (= today), WINDOW_DAYS = left edge.
      // Newer petitions on the right matches Grant's "approaching now" feel.
      const xFrac = 1 - Math.min(1, p.daysSinceStart / WINDOW_DAYS);
      const x = MARGIN.left + xFrac * (W - MARGIN.left - MARGIN.right);

      // Y: high velocity → near top. Clamp into the drawing area.
      const yFrac = Math.min(1, p.velocity / MAX_VELOCITY);
      const yPeak = H - MARGIN.bottom - 8 - yFrac * (H - MARGIN.top - MARGIN.bottom - 8);

      // Progress (signatures/goal) controls the spike's "alive" intensity
      const progress = Math.min(1, p.signatures / Math.max(1, p.goal));
      const color = CATEGORY_COLOR[p.category];
      return { p, x, yPeak, progress, color };
    });
  }, [petitions]);

  return (
    <div style={{ position: 'relative', width: '100%', overflow: 'hidden',
      border: '1px solid #1a1326', borderRadius: 10, background: '#08060e' }}>
      <svg viewBox={`0 0 ${W} ${H}`} width="100%" style={{ display: 'block' }}>
        {/* Glow defs — one radial per category so highest-velocity spikes pop */}
        <defs>
          <linearGradient id="pulse-bg" x1="0" y1="0" x2="0" y2="1">
            <stop offset="0" stopColor="#0d0a18" />
            <stop offset="1" stopColor="#06050a" />
          </linearGradient>
          {Object.entries(CATEGORY_COLOR).map(([cat, color]) => (
            <radialGradient key={cat} id={`glow-${cat}`}>
              <stop offset="0" stopColor={color} stopOpacity="0.55" />
              <stop offset="1" stopColor={color} stopOpacity="0" />
            </radialGradient>
          ))}
        </defs>
        <rect x="0" y="0" width={W} height={H} fill="url(#pulse-bg)" />

        {/* Velocity reference lines */}
        {[200, 400, 600, 800].map((v) => {
          const y = H - MARGIN.bottom - 8 - (v / MAX_VELOCITY) * (H - MARGIN.top - MARGIN.bottom - 8);
          return (
            <g key={v}>
              <line x1={MARGIN.left} y1={y} x2={W - MARGIN.right} y2={y}
                stroke="#1a1326" strokeDasharray="2,6" strokeWidth="0.5" />
              <text x={MARGIN.left - 10} y={y + 3} textAnchor="end"
                fill="#5a4c70" fontSize="10" fontFamily="ui-monospace, monospace">
                {v}/d
              </text>
            </g>
          );
        })}

        {/* Time axis ticks */}
        {[30, 21, 14, 7, 0].map((d) => {
          const xFrac = 1 - d / WINDOW_DAYS;
          const x = MARGIN.left + xFrac * (W - MARGIN.left - MARGIN.right);
          return (
            <g key={d}>
              <line x1={x} y1={MARGIN.top} x2={x} y2={H - MARGIN.bottom}
                stroke="#1a1326" strokeDasharray="2,6" strokeWidth="0.5" />
              <text x={x} y={H - MARGIN.bottom + 22} textAnchor="middle"
                fill="#5a4c70" fontSize="10" fontFamily="ui-monospace, monospace">
                {d === 0 ? 'today' : `${d}d ago`}
              </text>
            </g>
          );
        })}

        {/* ECG baseline — animated */}
        <path d={ecgBaseline(tick)} stroke="#3b2a52" strokeWidth="0.8"
          fill="none" opacity="0.7" />

        {/* Active baseline highlight (rightmost ~20% = "now") */}
        <rect x={MARGIN.left + 0.8 * (W - MARGIN.left - MARGIN.right)}
          y={MARGIN.top} width={0.2 * (W - MARGIN.left - MARGIN.right)}
          height={H - MARGIN.top - MARGIN.bottom}
          fill="#a78bfa" opacity="0.04" />

        {/* Pulse spikes */}
        {spikes.map(({ p, x, yPeak, progress, color }) => {
          // Each spike is a P-Q-R-S-T mini-shape (an ECG beat). The R-peak is
          // tallest; surrounding shoulders give it the heartbeat-look. Spike
          // amplitude scales with velocity already (yPeak).
          const baselineY = H - MARGIN.bottom - 8;
          const peakH = baselineY - yPeak;
          // Mini-beat: pre-shoulder dip, then R-peak, then post-shoulder dip
          const preX = x - 18;
          const postX = x + 18;
          const dipY = baselineY + 4;
          const beatPath =
            `M${preX} ${baselineY} L${x - 9} ${baselineY - peakH * 0.12} ` +
            `L${x - 4} ${dipY} L${x} ${yPeak} L${x + 4} ${dipY} ` +
            `L${x + 9} ${baselineY - peakH * 0.18} L${postX} ${baselineY}`;

          // Pulse the glow softly on tick
          const pulse = 0.7 + Math.sin((tick + p.id.length * 11) / 8) * 0.25;
          const isHover = hover?.id === p.id;
          const glowR = 18 + peakH * 0.18 + (isHover ? 14 : 0);

          return (
            <g
              key={p.id}
              onMouseEnter={() => setHover(p)}
              onMouseLeave={() => setHover(null)}
              onClick={() => window.open(`/petitions/${p.slug}`, '_blank')}
              style={{ cursor: 'pointer' }}
            >
              {/* Glow at the peak — intensity = progress toward goal */}
              <circle cx={x} cy={yPeak} r={glowR}
                fill={`url(#glow-${p.category})`}
                opacity={pulse * (0.5 + progress * 0.5)} />

              {/* The beat shape */}
              <path d={beatPath} stroke={color} strokeWidth={isHover ? 2.2 : 1.4}
                fill="none" strokeLinejoin="round" opacity={0.9} />

              {/* Velocity reading above the peak — only show if there's room */}
              {peakH > 30 && (
                <text x={x} y={yPeak - 10} textAnchor="middle"
                  fill={color} fontSize="10" fontFamily="ui-monospace, monospace"
                  opacity={isHover ? 1 : 0.7}>
                  {p.velocity}
                </text>
              )}

              {/* Hover ring */}
              {isHover && (
                <circle cx={x} cy={yPeak} r={glowR - 4} fill="none"
                  stroke={color} strokeWidth="1" strokeOpacity="0.7"
                  strokeDasharray="3,3" />
              )}
            </g>
          );
        })}

        {/* "NOW" tick — pinned to the right edge */}
        <line x1={W - MARGIN.right} y1={MARGIN.top}
          x2={W - MARGIN.right} y2={H - MARGIN.bottom}
          stroke="#a78bfa" strokeWidth="0.8" opacity="0.5" />
        <text x={W - MARGIN.right + 4} y={MARGIN.top + 12}
          fill="#a78bfa" fontSize="10" fontFamily="ui-monospace, monospace"
          opacity="0.7">
          NOW
        </text>
      </svg>

      {/* Hover detail card — top-left, mirrors Grant's pattern */}
      {hover && (
        <div style={{
          position: 'absolute', top: 16, left: 16, maxWidth: 460,
          background: '#0d0a18',
          border: `1px solid ${CATEGORY_COLOR[hover.category]}`,
          borderRadius: 8, padding: '14px 16px', pointerEvents: 'none',
          boxShadow: '0 4px 24px rgba(0,0,0,0.5)',
        }}>
          <div style={{ fontSize: 13, fontWeight: 600, color: '#e4d8f1', marginBottom: 6, lineHeight: 1.35 }}>
            {hover.title}
          </div>
          <div style={{ fontSize: 11, color: '#7a6c8a', display: 'flex', gap: 14, flexWrap: 'wrap' }}>
            <span style={{ color: CATEGORY_COLOR[hover.category] }}>
              {CATEGORY_LABEL[hover.category]}
            </span>
            <span>· launched {hover.daysSinceStart}d ago</span>
            <span>· velocity <strong style={{ color: '#e4d8f1' }}>{hover.velocity}/day</strong></span>
          </div>
          <div style={{ marginTop: 10, fontSize: 11, color: '#7a6c8a' }}>
            <strong style={{ color: '#e4d8f1' }}>
              {hover.signatures.toLocaleString()}
            </strong>
            {' '}of {hover.goal.toLocaleString()} signatures
            {' · '}
            <strong style={{ color: CATEGORY_COLOR[hover.category] }}>
              {Math.round(100 * hover.signatures / Math.max(1, hover.goal))}%
            </strong>
          </div>
          {/* progress bar */}
          <div style={{ marginTop: 8, height: 4, background: '#1a1326', borderRadius: 2, overflow: 'hidden' }}>
            <div style={{
              width: `${Math.min(100, 100 * hover.signatures / Math.max(1, hover.goal))}%`,
              height: '100%', background: CATEGORY_COLOR[hover.category], opacity: 0.85,
            }} />
          </div>
          <div style={{ fontSize: 10, color: CATEGORY_COLOR[hover.category], marginTop: 10 }}>
            click spike to open petition
          </div>
        </div>
      )}

      {/* Legend — bottom-right, mirrors Grant's pattern */}
      {!hover && (
        <div style={{
          position: 'absolute', bottom: 12, right: 12, padding: '12px 14px',
          background: 'rgba(13,10,24,0.92)', border: '1px solid #1a1326',
          borderRadius: 6, fontSize: 10, color: '#7a6c8a', maxWidth: 220,
          lineHeight: 1.55,
        }}>
          <div style={{
            color: '#e4d8f1', marginBottom: 6, fontWeight: 600,
            letterSpacing: '0.08em', textTransform: 'uppercase', fontSize: 9,
          }}>
            Legend
          </div>
          <div style={{ display: 'grid', gridTemplateColumns: '14px 1fr', gap: '5px 8px', alignItems: 'center' }}>
            {(Object.keys(CATEGORY_COLOR) as Array<Petition['category']>).map((cat) => (
              <Fragment key={cat}>
                <span style={{
                  width: 12, height: 5, background: CATEGORY_COLOR[cat], borderRadius: 3,
                }} />
                <span>{CATEGORY_LABEL[cat]}</span>
              </Fragment>
            ))}
          </div>
          <div style={{ marginTop: 8, paddingTop: 8, borderTop: '1px solid #1a1326' }}>
            height → signatures/day<br />
            glow → progress toward goal<br />
            rightmost → most recent
          </div>
        </div>
      )}
    </div>
  );
}