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