import { useState, useEffect, useRef, useMemo } from 'react'; // ===== Physics ===== const V_EARTH_KMS = 29.78; const SCALE_PX_PER_AU = 180; const CANVAS_SIZE = 580; const SUN_HIT_RADIUS_AU = 0.02; const SUN_VISUAL_RADIUS_PX = 8; const ESCAPE_DISTANCE = 4.5; const DT = 0.003; const MAX_STEPS = 4500; const C = { paper: '#ece1c3', paperLight: '#f3ebd1', ink: '#1c1610', inkDim: '#4a3d2c', inkMuted: '#7a6b52', rule: '#c8b993', ruleSoft: '#dccdab', amber: '#a64a08', amberDeep: '#7c3a06', space: '#0c0a0f', earthBlue: '#5d8aa8', orbitTrail: '#d6c388', escapeColor: '#a3c982', hitColor: '#c2553f', sunCore: '#ffe4a8', sunGlow: '#dc7c1c', }; function simulateTrajectory(dvMagKms, dvAngleDeg) { const dv = dvMagKms / V_EARTH_KMS; const angleRad = (dvAngleDeg * Math.PI) / 180; let x = 1, y = 0; let vx = dv * Math.sin(angleRad); let vy = 1 + dv * Math.cos(angleRad); const v0sq = vx * vx + vy * vy; const energy0 = 0.5 * v0sq - 1; const L = x * vy - y * vx; const points = [[x, y]]; let status = 'orbit'; for (let i = 0; i < MAX_STEPS; i++) { const r = Math.sqrt(x * x + y * y); if (r < SUN_HIT_RADIUS_AU) { status = 'hit'; break; } if (r > ESCAPE_DISTANCE) { status = 'escaped'; break; } const r3 = r * r * r; const ax = -x / r3, ay = -y / r3; const vxh = vx + ax * DT / 2; const vyh = vy + ay * DT / 2; x += vxh * DT; y += vyh * DT; const r2 = Math.sqrt(x * x + y * y); const r23 = r2 * r2 * r2; const ax2 = -x / r23, ay2 = -y / r23; vx = vxh + ax2 * DT / 2; vy = vyh + ay2 * DT / 2; points.push([x, y]); } let perihelion = null, aphelion = null; if (energy0 < 0) { const a = -1 / (2 * energy0); const eSq = Math.max(0, 1 + 2 * energy0 * L * L); const e = Math.sqrt(eSq); perihelion = a * (1 - e); aphelion = a * (1 + e); } return { points, status, energy: energy0, perihelion, aphelion }; } // Brief reactive remark in voice function remark(dvMag, dvAngle, status, perihelion) { if (dvMag < 0.3) return "Try a burn — the Sun is gently pulling on you the entire time."; const a = ((dvAngle % 360) + 360) % 360; const isRetrograde = a > 150 && a < 210; const isPrograde = a < 30 || a > 330; const isRadial = (a >= 60 && a <= 120) || (a >= 240 && a <= 300); if (status === 'hit') return `Solar impact, at considerable expense (~${Math.round(dvMag)} km/s) — very nearly all of Earth's sideways motion, spent on cancelling itself out.`; if (status === 'escaped') return `Free of the Sun for only ~${Math.round(dvMag)} km/s — embarrassingly less than what it costs to merely visit.`; if (isRadial) return "Radial burns reshape the orbit but barely touch your sideways speed, which is the actual problem. The Sun, blissfully unaware, carries on."; if (isRetrograde && perihelion !== null && perihelion < 0.1) return `Tantalisingly close (perihelion ${perihelion.toFixed(3)} AU) — but the Sun is only 0.0046 AU across, and 'close' is not 'touching'.`; if (isRetrograde) return "Pushing against the orbit drops the far side closer in — but cancelling thirty km/s of sideways motion takes a great deal of fuel."; if (isPrograde) return "Faster in the direction of motion stretches the orbit outwards. About 12 km/s more and the Sun loses its grip altogether."; return "An off-axis burn — mathematically tidy, intuitively confusing."; } export default function App() { const [dvMag, setDvMag] = useState(0); const [dvAngle, setDvAngle] = useState(180); const canvasRef = useRef(null); const animFrameRef = useRef(0); useEffect(() => { const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = 'https://fonts.googleapis.com/css2?family=Spectral:ital,wght@0,400;0,500;1,400;1,500&family=Space+Mono:wght@400;700&display=swap'; document.head.appendChild(link); return () => { document.head.removeChild(link); }; }, []); const result = useMemo(() => simulateTrajectory(dvMag, dvAngle), [dvMag, dvAngle]); useEffect(() => { animFrameRef.current = 0; }, [dvMag, dvAngle]); const stars = useMemo(() => { const arr = []; let seed = 73; const rand = () => { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; }; for (let i = 0; i < 130; i++) { arr.push({ x: rand() * CANVAS_SIZE, y: rand() * CANVAS_SIZE, r: rand() * 1.0 + 0.2, a: rand() * 0.55 + 0.12, }); } return arr; }, []); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); let raf; const draw = () => { const cx = CANVAS_SIZE / 2, cy = CANVAS_SIZE / 2; const total = result.points.length + 80; animFrameRef.current = (animFrameRef.current + 6) % total; ctx.fillStyle = C.space; ctx.fillRect(0, 0, CANVAS_SIZE, CANVAS_SIZE); const vg = ctx.createRadialGradient(cx, cy, CANVAS_SIZE * 0.25, cx, cy, CANVAS_SIZE * 0.7); vg.addColorStop(0, 'rgba(40, 30, 18, 0)'); vg.addColorStop(1, 'rgba(0, 0, 0, 0.6)'); ctx.fillStyle = vg; ctx.fillRect(0, 0, CANVAS_SIZE, CANVAS_SIZE); for (const s of stars) { ctx.fillStyle = `rgba(237, 226, 194, ${s.a})`; ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); ctx.fill(); } ctx.strokeStyle = 'rgba(214, 195, 136, 0.28)'; ctx.setLineDash([2, 6]); ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(cx, cy, SCALE_PX_PER_AU, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); const trajColor = { orbit: C.orbitTrail, escaped: C.escapeColor, hit: C.hitColor, }[result.status]; ctx.strokeStyle = trajColor + '88'; ctx.lineWidth = 1.4; ctx.beginPath(); for (let i = 0; i < result.points.length; i++) { const [px, py] = result.points[i]; const sx = cx + px * SCALE_PX_PER_AU; const sy = cy - py * SCALE_PX_PER_AU; if (i === 0) ctx.moveTo(sx, sy); else ctx.lineTo(sx, sy); } ctx.stroke(); const sg = ctx.createRadialGradient(cx, cy, 0, cx, cy, 80); sg.addColorStop(0, 'rgba(255, 224, 168, 0.55)'); sg.addColorStop(0.3, 'rgba(220, 124, 28, 0.35)'); sg.addColorStop(1, 'rgba(220, 124, 28, 0)'); ctx.fillStyle = sg; ctx.beginPath(); ctx.arc(cx, cy, 80, 0, Math.PI * 2); ctx.fill(); const sc = ctx.createRadialGradient(cx, cy, 0, cx, cy, SUN_VISUAL_RADIUS_PX); sc.addColorStop(0, '#ffffff'); sc.addColorStop(0.5, C.sunCore); sc.addColorStop(1, C.sunGlow); ctx.fillStyle = sc; ctx.beginPath(); ctx.arc(cx, cy, SUN_VISUAL_RADIUS_PX, 0, Math.PI * 2); ctx.fill(); const ex = cx + SCALE_PX_PER_AU, ey = cy; ctx.fillStyle = C.earthBlue + '55'; ctx.beginPath(); ctx.arc(ex, ey, 10, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = C.earthBlue; ctx.beginPath(); ctx.arc(ex, ey, 4.5, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = C.earthBlue + 'aa'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(ex, ey); ctx.lineTo(ex, ey - 26); ctx.stroke(); ctx.beginPath(); ctx.moveTo(ex, ey - 26); ctx.lineTo(ex - 3.5, ey - 20); ctx.lineTo(ex + 3.5, ey - 20); ctx.closePath(); ctx.fillStyle = C.earthBlue + 'aa'; ctx.fill(); if (dvMag > 0.5) { const angleRad = (dvAngle * Math.PI) / 180; const dvx = Math.sin(angleRad); const dvy = Math.cos(angleRad); const len = Math.min(48, 10 + dvMag * 1.4); ctx.strokeStyle = '#e8a85c'; ctx.lineWidth = 2.2; ctx.beginPath(); ctx.moveTo(ex, ey); ctx.lineTo(ex + dvx * len, ey - dvy * len); ctx.stroke(); const ax = ex + dvx * len, ay = ey - dvy * len; const perpX = -dvy, perpY = -dvx; ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(ax - dvx * 6 + perpX * 3.5, ay + dvy * 6 + perpY * 3.5); ctx.lineTo(ax - dvx * 6 - perpX * 3.5, ay + dvy * 6 - perpY * 3.5); ctx.closePath(); ctx.fillStyle = '#e8a85c'; ctx.fill(); } const probeIdx = Math.min(animFrameRef.current, result.points.length - 1); if (probeIdx >= 0 && probeIdx < result.points.length) { const trailLen = 28; const trailStart = Math.max(0, probeIdx - trailLen); for (let i = trailStart; i < probeIdx; i++) { const [px, py] = result.points[i]; const sx = cx + px * SCALE_PX_PER_AU; const sy = cy - py * SCALE_PX_PER_AU; const t = (i - trailStart) / trailLen; ctx.strokeStyle = trajColor + Math.floor(t * 200).toString(16).padStart(2, '0'); ctx.lineWidth = 2 + t * 1.7; ctx.beginPath(); ctx.moveTo(sx, sy); const [npx, npy] = result.points[i + 1]; ctx.lineTo(cx + npx * SCALE_PX_PER_AU, cy - npy * SCALE_PX_PER_AU); ctx.stroke(); } const [px, py] = result.points[probeIdx]; const sx = cx + px * SCALE_PX_PER_AU; const sy = cy - py * SCALE_PX_PER_AU; const pg = ctx.createRadialGradient(sx, sy, 0, sx, sy, 14); pg.addColorStop(0, trajColor); pg.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = pg; ctx.beginPath(); ctx.arc(sx, sy, 14, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.arc(sx, sy, 3.5, 0, Math.PI * 2); ctx.fill(); } raf = requestAnimationFrame(draw); }; raf = requestAnimationFrame(draw); return () => cancelAnimationFrame(raf); }, [result, stars, dvAngle, dvMag]); const apply = (mag, angle) => { setDvMag(mag); setDvAngle(angle); }; const statusLabel = { orbit: 'in orbit', escaped: 'escaped', hit: 'solar impact' }[result.status]; const statusColor = { orbit: C.amber, escaped: '#558034', hit: '#a8401d' }[result.status]; return (
{/* Compact title */}

On the surprising difficulty of falling into the Sun.

{' '} an explorable, in which the Sun is harder to hit than to flee.

{/* Hero: simulator + controls */}
{/* Canvas */}
{/* Controls column */}
{/* Direction */}
Burn direction
ANGLE setDvAngle(Number(e.target.value))} style={{ flex: 1 }} /> {Math.round(dvAngle)}°
{/* Magnitude */}
Fuel expended (Δv)
{dvMag.toFixed(1)} KM/S
setDvMag(Number(e.target.value))} />
017.535
= 12 && dvMag <= 14 && (dvAngle < 30 || dvAngle > 330)} /> = 23 && dvMag <= 28 && dvAngle > 150 && dvAngle < 210} />
{/* Trajectory readout */}
Trajectory
{result.perihelion !== null && ( )} {result.aphelion !== null && ( 99 ? '> 100 AU' : `${result.aphelion.toFixed(2)} AU`} /> )}
{/* Reactive remark */}

{remark(dvMag, dvAngle, result.status, result.perihelion)}

{/* Inline presets */}

Try {' · '} {' · '} {' · '} {' · '}

{/* Brief explanation, secondary */}

Newtonian two-body, velocity-Verlet integrator. Sun enlarged for visibility; impact registers within 0.02 AU of centre.

); } function ReferenceLine({ label, value, hi }) { return (
{label}{value} km/s
); } function RowRead({ label, value, color, ital }) { return (
{label} {value}
); }