"use client"; import React, { useState, useMemo, useRef, useEffect, useCallback } from "react"; import { Chart, ChartConfiguration, registerables } from "chart.js"; Chart.register(...registerables); const VERSION = "v0.5"; // --- Types --- interface Destination { name: string; distanceLy: number; category: "stellar" | "galactic" | "extragalactic" | "cosmological"; tableOnly?: boolean; } interface TravelResults { shipTimeYears: number; earthTimeYears: number; peakVelocityFractionC: number; peakGamma: number; timeDilationRatio: number; massRatioOneWay: number; massRatioBrach: number; } // --- Constants --- // In natural units: c = 1 ly/yr // 1g ≈ 1.03227 ly/yr² (exact: 9.80665 m/s² converted) const G_TO_LY_YR2 = 1.03227; const G_STANDARD_MS2 = 9.80665; const DESTINATIONS: Destination[] = [ // Stellar neighborhood { name: "Proxima Centauri", distanceLy: 4.246, category: "stellar" }, { name: "Alpha Centauri", distanceLy: 4.37, category: "stellar" }, { name: "Tau Ceti", distanceLy: 11.91, category: "stellar" }, { name: "40 Eridani", distanceLy: 16.34, category: "stellar" }, { name: "Vega", distanceLy: 25.04, category: "stellar" }, { name: "Zeta Reticuli", distanceLy: 39.3, category: "stellar", tableOnly: true }, { name: "Aldebaran", distanceLy: 65.3, category: "stellar", tableOnly: true }, { name: "Algol", distanceLy: 90, category: "stellar", tableOnly: true }, { name: "Canopus", distanceLy: 310, category: "stellar", tableOnly: true }, { name: "Betelgeuse", distanceLy: 500, category: "stellar" }, { name: "Gaia BH1 (black hole)", distanceLy: 1_576, category: "stellar" }, // Galactic { name: "Orion Nebula", distanceLy: 1344, category: "galactic" }, { name: "Sagittarius A* (Galactic Center)", distanceLy: 26_000, category: "galactic" }, { name: "Far side of Milky Way", distanceLy: 100_000, category: "galactic" }, // Extragalactic { name: "Large Magellanic Cloud", distanceLy: 160_000, category: "extragalactic" }, { name: "Andromeda Galaxy", distanceLy: 2_537_000, category: "extragalactic" }, // Cosmological { name: "Virgo Cluster", distanceLy: 53_800_000, category: "cosmological" }, { name: "Edge of Observable Universe", distanceLy: 46_500_000_000, category: "cosmological" }, { name: "100 Trillion Light-Years", distanceLy: 100_000_000_000_000, category: "cosmological" }, ]; const CATEGORY_LABELS: Record = { stellar: "Stellar Neighborhood", galactic: "Milky Way", extragalactic: "Extragalactic", cosmological: "Cosmological", }; const ACCELERATION_PRESETS = [ { label: "0.1g", value: 0.1 }, { label: "0.3g", value: 0.3 }, { label: "1g", value: 1 }, { label: "1.5g", value: 1.5 }, { label: "2g", value: 2 }, { label: "4g", value: 4 }, { label: "10g", value: 10 }, ]; // Reference masses (kg) for feasibility comparisons const REFERENCE_MASSES = [ { name: "the ISS", kg: 4.2e5 }, { name: "the Great Pyramid of Giza", kg: 6e9 }, { name: "Earth's oceans", kg: 1.4e21 }, { name: "the Moon", kg: 7.342e22 }, { name: "Earth", kg: 5.972e24 }, { name: "Jupiter", kg: 1.898e27 }, { name: "the Sun", kg: 1.989e30 }, { name: "Sagittarius A*", kg: 8.26e36 }, { name: "the Milky Way", kg: 1.5e42 }, { name: "the observable universe", kg: 1.5e53 }, ]; // Schwarzschild radius: r_s = 2GM/c² → factor ≈ 1.485e-27 m/kg const SCHWARZSCHILD_FACTOR = (2 * 6.674e-11) / (2.998e8 * 2.998e8); // Reference densities (kg/m³) for radius comparisons — lowest to highest const DENSITY_COMPARISONS = [ { name: "water", density: 1_000 }, { name: "tungsten", density: 19_250 }, { name: "neutronium", density: 4e17 }, ]; // Tolman-Oppenheimer-Volkoff limit: ~3 solar masses — above this, gravitational collapse is inevitable const TOV_LIMIT_KG = 3 * 1.989e30; // c² in J/kg for energy calculations const C_SQUARED = 2.998e8 * 2.998e8; // Reference energies const GLOBAL_ANNUAL_ENERGY = 5.8e20; // ~580 EJ, global energy consumption per year const SUN_ANNUAL_ENERGY = 1.21e34; // Sun's total energy output per year // --- Computation --- function computeTravel( distanceLy: number, accelerationG: number, brachistochrone: boolean, coastFraction: number = 0, ): TravelResults { const a = accelerationG * G_TO_LY_YR2; // ly/yr² const d = distanceLy; const cf = Math.max(0, Math.min(1, coastFraction)); // Acceleration distance per phase; brachistochrone has 2 symmetric phases const dAccel = brachistochrone ? (d * (1 - cf)) / 2 : d * (1 - cf); const dCoast = d * cf; const phases = brachistochrone ? 2 : 1; const gamma = 1 + a * dAccel; const beta = Math.sqrt(1 - 1 / (gamma * gamma)); // Acceleration/deceleration phase times const tauAccel = (1 / a) * Math.acosh(gamma); const tAccel = (1 / a) * Math.sqrt(gamma * gamma - 1); // Coast phase times (constant velocity β) const tCoast = beta > 0 ? dCoast / beta : 0; const tauCoast = beta > 0 ? dCoast / (beta * gamma) : 0; const tau = phases * tauAccel + tauCoast; const t = phases * tAccel + tCoast; const oneWayRatio = gamma * (1 + beta); // γ(1+β) mass ratio return { shipTimeYears: tau, earthTimeYears: t, peakVelocityFractionC: beta, peakGamma: gamma, timeDilationRatio: t / tau, massRatioOneWay: oneWayRatio, massRatioBrach: brachistochrone ? oneWayRatio * oneWayRatio : oneWayRatio, }; } // --- Formatting --- function formatTime(years: number): string { if (!isFinite(years) || isNaN(years)) return "—"; if (years < 1 / 365.25 / 24) { const minutes = years * 365.25 * 24 * 60; if (minutes < 1) return `${(minutes * 60).toFixed(1)} seconds`; return `${Math.round(minutes)} minutes`; } else if (years < 1 / 365.25) { return `${(years * 365.25 * 24).toFixed(1)} hours`; } else if (years < 1 / 12) { return `${Math.round(years * 365.25)} days`; } else if (years < 1) { const months = Math.floor(years * 12); const days = Math.round((years * 12 - months) * 30.44); return days > 0 ? `${months} months, ${days} days` : `${months} months`; } else if (years < 100) { const y = Math.floor(years); const months = Math.round((years - y) * 12); if (months === 12) return `${y + 1} years`; return months > 0 ? `${y} years, ${months} months` : `${y} years`; } else if (years < 1_000) { return `${Math.round(years).toLocaleString()} years`; } else if (years < 1e6) { return `${(years / 1_000).toFixed(1)} thousand years`; } else if (years < 1e9) { return `${(years / 1e6).toFixed(2)} million years`; } else if (years < 1e12) { return `${(years / 1e9).toFixed(2)} billion years`; } else { return `${(years / 1e12).toFixed(2)} trillion years`; } } function formatTimeShort(years: number): string { if (!isFinite(years) || isNaN(years)) return "—"; if (years < 1 / 365.25 / 24) { const minutes = years * 365.25 * 24 * 60; if (minutes < 1) return `${(minutes * 60).toFixed(0)}s`; return `${Math.round(minutes)}m`; } else if (years < 1 / 365.25) { return `${(years * 365.25 * 24).toFixed(1)}h`; } else if (years < 1 / 12) { return `${Math.round(years * 365.25)}d`; } else if (years < 1) { return `${(years * 12).toFixed(1)} mo`; } else if (years < 1_000) { return `${years.toFixed(1)} yr`; } else if (years < 1e6) { return `${(years / 1_000).toFixed(1)}k yr`; } else if (years < 1e9) { return `${(years / 1e6).toFixed(1)}M yr`; } else if (years < 1e12) { return `${(years / 1e9).toFixed(1)}B yr`; } else { return `${(years / 1e12).toFixed(1)}T yr`; } } function formatVelocity(fractionC: number, gamma?: number): string { if (!isFinite(fractionC) || isNaN(fractionC)) return "—"; if (fractionC < 0.001) { return `${(fractionC * 299_792.458).toFixed(1)} km/s`; } else if (fractionC > 0.9999) { // When float64 can't distinguish v from c, compute nines from γ: 1-β ≈ 1/(2γ²) const nines = fractionC >= 1.0 && gamma && gamma > 1 ? Math.log10(2) + 2 * Math.log10(gamma) : -Math.log10(1 - fractionC); if (nines >= 2) { return `0.${"9".repeat(Math.min(Math.floor(nines), 15))}…c (${Math.floor(nines)} nines)`; } return `${(fractionC * 100).toFixed(4)}% c`; } else { return `${(fractionC * 100).toFixed(2)}% c`; } } function formatDistance(ly: number): string { if (ly < 100) { return `${ly.toFixed(ly < 10 ? 2 : 1)} ly`; } else if (ly < 1e6) { return `${ly.toLocaleString(undefined, { maximumFractionDigits: 0 })} ly`; } else if (ly < 1e9) { return `${(ly / 1e6).toFixed(1)}M ly`; } else if (ly < 1e12) { return `${(ly / 1e9).toFixed(1)}B ly`; } else { return `${(ly / 1e12).toFixed(1)}T ly`; } } function formatMassRatio(ratio: number): string { if (!isFinite(ratio) || isNaN(ratio)) return "—"; if (ratio < 100) return `${ratio.toFixed(2)} : 1`; if (ratio < 1e6) return `${ratio.toLocaleString(undefined, { maximumFractionDigits: 0 })} : 1`; return `${ratio.toExponential(2)} : 1`; } function formatMass(kg: number): string { if (!isFinite(kg) || isNaN(kg)) return "—"; const tonnes = kg / 1000; if (kg < 1000) return `${kg.toFixed(1)} kg`; if (tonnes < 1e3) return `${tonnes.toFixed(1)} tonnes`; if (tonnes < 1e6) return `${(tonnes / 1e3).toFixed(2)} thousand tonnes`; if (tonnes < 1e9) return `${(tonnes / 1e6).toFixed(2)} million tonnes`; if (tonnes < 1e12) return `${(tonnes / 1e9).toFixed(2)} billion tonnes`; if (tonnes < 1e15) return `${(tonnes / 1e12).toFixed(2)} trillion tonnes`; if (tonnes < 1e18) return `${(tonnes / 1e15).toFixed(2)} quadrillion tonnes`; if (tonnes < 1e21) return `${(tonnes / 1e18).toFixed(2)} quintillion tonnes`; if (tonnes < 1e24) return `${(tonnes / 1e21).toFixed(2)} sextillion tonnes`; if (tonnes < 1e27) return `${(tonnes / 1e24).toFixed(2)} septillion tonnes`; if (tonnes < 1e30) return `${(tonnes / 1e27).toFixed(2)} octillion tonnes`; return `${tonnes.toExponential(2)} tonnes`; } function formatGamma(gamma: number): string { if (!isFinite(gamma) || isNaN(gamma)) return "—"; if (gamma < 10) return gamma.toFixed(3); if (gamma < 1e6) return gamma.toLocaleString(undefined, { maximumFractionDigits: 0 }); return gamma.toExponential(2); } function formatRapidity(phi: number): string { if (!isFinite(phi) || isNaN(phi)) return "—"; if (phi < 10) return phi.toFixed(3); if (phi < 1e3) return phi.toFixed(1); return phi.toExponential(2); } function formatRadius(meters: number): string { if (!isFinite(meters) || isNaN(meters)) return "—"; if (meters < 1e-15) return `${(meters * 1e18).toFixed(1)} am`; if (meters < 1e-12) return `${(meters * 1e15).toFixed(1)} fm`; if (meters < 1e-9) return `${(meters * 1e12).toFixed(1)} pm`; if (meters < 1e-6) return `${(meters * 1e9).toFixed(1)} nm`; if (meters < 1e-3) return `${(meters * 1e6).toFixed(1)} μm`; if (meters < 1) return `${(meters * 1e3).toFixed(1)} mm`; if (meters < 1e3) return `${meters.toFixed(1)} m`; if (meters < 1e9) { const km = meters / 1e3; return `${km < 100 ? km.toFixed(1) : Math.round(km).toLocaleString()} km`; } if (meters < 1.496e11) return `${(meters / 1e9).toFixed(1)} million km`; if (meters < 9.461e15) return `${(meters / 1.496e11).toFixed(1)} AU`; if (meters < 9.461e21) return `${(meters / 9.461e15).toFixed(1)} ly`; return `${(meters / 9.461e21).toFixed(1)} million ly`; } function getRadiusComparison(meters: number): string { if (meters < 1e-15) return "smaller than a proton"; if (meters < 1e-10) return "sub-atomic"; if (meters < 1e-4) return "microscopic"; if (meters < 0.01) return "marble-sized"; if (meters < 0.15) return "grapefruit-sized"; if (meters < 2) return "human-sized"; if (meters < 30) return "building-sized"; if (meters < 1e4) return "city-sized"; if (meters < 6.371e6) return "planet-sized"; if (meters < 6.957e8) return "star-sized"; if (meters < 1.496e11) return "larger than the Sun"; if (meters < 9.461e15) return "solar-system-scale"; return "interstellar-scale"; } // Radius of a sphere: r = (3M / (4πρ))^(1/3) function sphereRadius(massKg: number, densityKgM3: number): number { return Math.cbrt((3 * massKg) / (4 * Math.PI * densityKgM3)); } function getMassComparison(kg: number): { name: string; multiple: number } | null { for (let i = REFERENCE_MASSES.length - 1; i >= 0; i--) { if (kg >= REFERENCE_MASSES[i].kg) { return { name: REFERENCE_MASSES[i].name, multiple: kg / REFERENCE_MASSES[i].kg }; } } return null; } function formatMultiple(n: number): string { if (n < 10) return n.toFixed(1); if (n < 1e4) return Math.round(n).toLocaleString(); if (n < 1e6) return `${(n / 1e3).toFixed(0)} thousand`; if (n < 1e9) return `${(n / 1e6).toFixed(1)} million`; if (n < 1e12) return `${(n / 1e9).toFixed(1)} billion`; if (n < 1e15) return `${(n / 1e12).toFixed(1)} trillion`; return n.toExponential(1); } function formatEnergy(joules: number): string { if (!isFinite(joules) || isNaN(joules)) return "—"; if (joules < 1e12) return `${(joules / 1e9).toFixed(1)} GJ`; if (joules < 1e15) return `${(joules / 1e12).toFixed(1)} TJ`; if (joules < 1e18) return `${(joules / 1e15).toFixed(1)} PJ`; if (joules < 1e21) return `${(joules / 1e18).toFixed(1)} EJ`; if (joules < 1e24) return `${(joules / 1e21).toFixed(1)} ZJ`; if (joules < 1e27) return `${(joules / 1e24).toFixed(1)} YJ`; return `${joules.toExponential(2)} J`; } function getEnergyComparison(joules: number): string | null { if (joules >= SUN_ANNUAL_ENERGY) { return `${formatMultiple(joules / SUN_ANNUAL_ENERGY)}× the Sun's annual output`; } if (joules >= GLOBAL_ANNUAL_ENERGY) { return `${formatMultiple(joules / GLOBAL_ANNUAL_ENERGY)}× global annual energy use`; } return null; } // --- Component --- const CalcRelativisticRocket = () => { const [distanceInput, setDistanceInput] = useState("11.91"); const [selectedDestination, setSelectedDestination] = useState("Tau Ceti"); const [gInput, setGInput] = useState("1.5"); const [brachistochrone, setBrachistochrone] = useState(true); const [showAllDestinations, setShowAllDestinations] = useState(false); const [showAccelChart, setShowAccelChart] = useState(false); const [showPHM, setShowPHM] = useState(false); const [dryMassInput, setDryMassInput] = useState("100000"); const [efficiencyInput, setEfficiencyInput] = useState("100"); const [coastInput, setCoastInput] = useState("0"); const distanceLy = parseFloat(distanceInput) || 0; const accelerationG = parseFloat(gInput) || 0; const dryMassKg = parseFloat(dryMassInput) || 0; const efficiency = Math.max(0.01, Math.min(100, parseFloat(efficiencyInput) || 100)) / 100; const coastFraction = brachistochrone ? Math.max(0, Math.min(99, parseFloat(coastInput) || 0)) / 100 : 0; const isPHM = selectedDestination === "Tau Ceti" && Math.abs(accelerationG - 1.5) < 0.01 && brachistochrone; // Compute results reactively const results = useMemo(() => { if (distanceLy <= 0 || accelerationG <= 0) return null; return computeTravel(distanceLy, accelerationG, brachistochrone, coastFraction); }, [distanceLy, accelerationG, brachistochrone, coastFraction]); // Efficiency-adjusted mass ratios: R_adjusted = R^(1/η) const adjustedOneWay = results ? Math.pow(results.massRatioOneWay, 1 / efficiency) : 0; const adjustedBrach = results ? Math.pow(results.massRatioBrach, 1 / efficiency) : 0; // Compute all-destinations table const allDestinationResults = useMemo(() => { if (accelerationG <= 0) return []; return DESTINATIONS.map((dest) => ({ ...dest, results: computeTravel(dest.distanceLy, accelerationG, brachistochrone, coastFraction), })); }, [accelerationG, brachistochrone, coastFraction]); // --- Acceleration Comparison Chart --- const [accelChartLinear, setAccelChartLinear] = useState(true); const [clockRateChartLinear, setClockRateChartLinear] = useState(true); const [dilationChartLogX, setDilationChartLogX] = useState(false); const [dilationChartLogY, setDilationChartLogY] = useState(true); const [dilationZoomLogX, setDilationZoomLogX] = useState(false); const [dilationZoomLogY, setDilationZoomLogY] = useState(true); const accelChartRef = useRef(null); const accelChartInstanceRef = useRef(null); const clockRateChartRef = useRef(null); const clockRateChartInstanceRef = useRef(null); const velocityChartRef = useRef(null); const velocityChartInstanceRef = useRef(null); const rapidityShipTimeChartRef = useRef(null); const rapidityShipTimeChartInstanceRef = useRef(null); const rapidityAccelTimeChartRef = useRef(null); const rapidityAccelTimeChartInstanceRef = useRef(null); const rapidityDistanceChartRef = useRef(null); const rapidityDistanceChartInstanceRef = useRef(null); const dilationChartRef = useRef(null); const dilationChartInstanceRef = useRef(null); const dilationZoomChartRef = useRef(null); const dilationZoomChartInstanceRef = useRef(null); const COMPARISON_GS = [0.1, 0.3, 1, 1.5, 10]; const COMPARISON_COLORS: Record = { 0.1: "#71717a", // zinc-500 0.3: "#a1a1aa", // zinc-400 1: "#488193", // muted blue 1.5: "#49b7d8", // cyan 10: "#e07753", // orange }; const getComparisonDistances = (linear: boolean) => { const distances: number[] = []; if (linear) { const maxDist = distanceLy > 0 ? distanceLy * 3 : 100; const step = maxDist / 200; distances.push(0); for (let d = step; d <= maxDist; d += step) { distances.push(d); } } else { for (let exp = -2; exp <= 14; exp += 0.1) { distances.push(Math.pow(10, exp)); } } return distances; }; const getComparisonGValues = () => { const gValues = [...COMPARISON_GS]; const userGIsPreset = COMPARISON_GS.some((g) => Math.abs(g - accelerationG) < 0.01); if (!userGIsPreset && accelerationG > 0) { gValues.push(accelerationG); gValues.sort((a, b) => a - b); } return gValues; }; const fmtDistTick = (v: number) => { if (v < 1000) return `${v % 1 === 0 ? v : v.toPrecision(3)}`; if (v < 1e6) return `${(v / 1e3).toFixed(0)}k`; if (v < 1e9) return `${(v / 1e6).toFixed(0)}M`; if (v < 1e12) return `${(v / 1e9).toFixed(0)}B`; return `${(v / 1e12).toFixed(0)}T`; }; const fmtTimeTick = (v: number) => { if (v < 1) return `${(v * 365.25).toFixed(0)}d`; if (v < 1000) return `${v % 1 === 0 ? v : v.toFixed(1)} yr`; if (v < 1e6) return `${(v / 1e3).toFixed(0)}k yr`; if (v < 1e9) return `${(v / 1e6).toFixed(0)}M yr`; return `${(v / 1e9).toFixed(0)}B yr`; }; const fmtClockRateTick = (v: number) => { if (v >= 0.1) return `${(v * 100).toFixed(0)}%`; if (v >= 0.01) return `${(v * 100).toFixed(1)}%`; if (v >= 0.001) return `${(v * 100).toFixed(2)}%`; return `${v.toExponential(0)}`; }; const fmtVelocityPercentTick = (v: number) => { if (v >= 1) return "c"; if (v >= 0.9999) return `${(v * 100).toFixed(3)}%`; if (v >= 0.99) return `${(v * 100).toFixed(2)}%`; return `${(v * 100).toFixed(0)}%`; }; const filterLogTicks = (axis: { ticks: { value: number }[] }) => { axis.ticks = axis.ticks.filter((t) => { const log = Math.log10(t.value); return Math.abs(log - Math.round(log)) < 0.01; }); }; const transformSpeedDatasetsForLogX = (datasets: ChartConfiguration["data"]["datasets"]) => datasets.map((dataset) => ({ ...dataset, data: Array.isArray(dataset.data) ? dataset.data .map((point) => { if (typeof point !== "object" || point === null || !("x" in point) || !("y" in point)) return null; const x = point.x as number; const y = point.y as number; return { x: Math.max(1 - x, 1e-16), y }; }) .filter((point): point is { x: number; y: number } => point !== null) : dataset.data, })); const getLogXMin = (datasets: ChartConfiguration["data"]["datasets"], visibleMax: number) => { let minGap = Number.POSITIVE_INFINITY; for (const dataset of datasets) { if (!Array.isArray(dataset.data)) continue; for (const point of dataset.data) { if (typeof point !== "object" || point === null || !("x" in point)) continue; const x = point.x as number; if (x > 0 && x <= visibleMax) { minGap = Math.min(minGap, x); } } } return Number.isFinite(minGap) ? Math.pow(10, Math.floor(Math.log10(minGap))) : 1e-12; }; const buildAccelChart = useCallback(() => { if (!accelChartRef.current || !showAccelChart) return; if (accelChartInstanceRef.current) { accelChartInstanceRef.current.destroy(); } const ctx = accelChartRef.current.getContext("2d"); if (!ctx) return; const distances = getComparisonDistances(accelChartLinear); const gValues = getComparisonGValues(); // Pre-compute travel results for all g-values and distances const travelData = gValues.map((g) => distances.map((d) => computeTravel(d, g, brachistochrone, coastFraction))); const datasets = gValues.map((g, gi) => { const isUserG = Math.abs(g - accelerationG) < 0.01; const isCustom = !COMPARISON_GS.some((pg) => Math.abs(pg - g) < 0.01); const color = isCustom ? "#7dd3fc" : COMPARISON_COLORS[g]; return { label: `${g}g${isUserG ? " (selected)" : ""}`, data: travelData[gi].map((r) => r.shipTimeYears), borderColor: color, backgroundColor: "transparent", borderWidth: isUserG ? 3 : 1.5, borderDash: isUserG ? [] : [4, 2], pointRadius: 0, pointHitRadius: 8, fill: false, tension: 0.3, }; }); const config: ChartConfiguration = { type: "line", data: { labels: distances.map((d) => d.toString()), datasets, }, options: { responsive: true, maintainAspectRatio: false, animation: { duration: 600, easing: "easeOutQuart" }, interaction: { mode: "index", intersect: false }, plugins: { legend: { labels: { color: "#a1a1aa", font: { size: 12 }, usePointStyle: true, pointStyle: "line" }, }, tooltip: { backgroundColor: "#27272a", titleColor: "#fafafa", bodyColor: "#d4d4d8", borderColor: "#52525b", borderWidth: 1, callbacks: { title: (items) => { const d = parseFloat(items[0].label); return `Distance: ${formatDistance(d)}`; }, label: (item) => { const y = item.parsed.y as number; const result = travelData[item.datasetIndex]?.[item.dataIndex]; if (!result) return ` ${item.dataset.label ?? ""}`; const velStr = ` (${formatVelocity(result.peakVelocityFractionC, result.peakGamma)})`; return ` ${item.dataset.label ?? ""}: ${formatTime(y)}${velStr}`; }, }, }, }, scales: { x: { type: accelChartLinear ? "linear" : "logarithmic", ...(accelChartLinear ? { min: 0 } : {}), title: { display: true, text: "Distance (light-years)", color: "#d4d4d8", font: { size: 13 } }, afterBuildTicks: accelChartLinear ? undefined : (axis: { ticks: { value: number }[] }) => { axis.ticks = axis.ticks.filter((t) => { const log = Math.log10(t.value); return Math.abs(log - Math.round(log)) < 0.01; }); }, ticks: { color: "#a1a1aa", font: { size: 11 }, callback: function (value) { return fmtDistTick(value as number); }, }, grid: { color: "#3f3f46", lineWidth: 0.5 }, }, y: { type: accelChartLinear ? "linear" : "logarithmic", beginAtZero: accelChartLinear, title: { display: true, text: "Ship Time (years)", color: "#d4d4d8", font: { size: 13 }, }, afterBuildTicks: accelChartLinear ? undefined : filterLogTicks, ticks: { color: "#a1a1aa", font: { size: 11 }, callback: function (value) { return fmtTimeTick(value as number); }, }, grid: { color: "#3f3f46", lineWidth: 0.5 }, }, }, }, plugins: [ { id: "accelCurrentDist", afterDraw: (chart) => { if (distanceLy <= 0) return; const xScale = chart.scales.x; const xPixel = xScale.getPixelForValue(distanceLy); // Don't draw if outside visible range if (xPixel < xScale.left || xPixel > xScale.right) return; const drawCtx = chart.ctx; drawCtx.save(); drawCtx.strokeStyle = "#a1a1aa"; drawCtx.lineWidth = 1; drawCtx.setLineDash([4, 4]); drawCtx.beginPath(); drawCtx.moveTo(xPixel, chart.scales.y.top); drawCtx.lineTo(xPixel, chart.scales.y.bottom); drawCtx.stroke(); drawCtx.restore(); }, }, ], }; accelChartInstanceRef.current = new Chart(ctx, config); }, [accelerationG, brachistochrone, coastFraction, distanceLy, showAccelChart, accelChartLinear]); const buildClockRateChart = useCallback(() => { if (!clockRateChartRef.current || !showAccelChart) return; if (clockRateChartInstanceRef.current) { clockRateChartInstanceRef.current.destroy(); } const ctx = clockRateChartRef.current.getContext("2d"); if (!ctx) return; const distances = getComparisonDistances(clockRateChartLinear); const gValues = getComparisonGValues(); const travelData = gValues.map((g) => distances.map((d) => computeTravel(d, g, brachistochrone, coastFraction))); const minClockRate = Math.min(...travelData.flatMap((series) => series.map((r) => 1 / r.peakGamma))); const datasets = gValues.map((g, gi) => { const isUserG = Math.abs(g - accelerationG) < 0.01; const isCustom = !COMPARISON_GS.some((pg) => Math.abs(pg - g) < 0.01); const color = isCustom ? "#7dd3fc" : COMPARISON_COLORS[g]; return { label: `${g}g${isUserG ? " (selected)" : ""}`, data: travelData[gi].map((r) => 1 / r.peakGamma), borderColor: color, backgroundColor: "transparent", borderWidth: isUserG ? 3 : 1.5, borderDash: isUserG ? [] : [4, 2], pointRadius: 0, pointHitRadius: 8, fill: false, tension: 0.3, }; }); const config: ChartConfiguration = { type: "line", data: { labels: distances.map((d) => d.toString()), datasets, }, options: { responsive: true, maintainAspectRatio: false, animation: { duration: 600, easing: "easeOutQuart" }, interaction: { mode: "index", intersect: false }, plugins: { legend: { labels: { color: "#a1a1aa", font: { size: 12 }, usePointStyle: true, pointStyle: "line" }, }, tooltip: { backgroundColor: "#27272a", titleColor: "#fafafa", bodyColor: "#d4d4d8", borderColor: "#52525b", borderWidth: 1, callbacks: { title: (items) => { const d = parseFloat(items[0].label); return `Distance: ${formatDistance(d)}`; }, label: (item) => { const result = travelData[item.datasetIndex]?.[item.dataIndex]; if (!result) return ` ${item.dataset.label ?? ""}`; const clockRate = 1 / result.peakGamma; const tripRatio = result.timeDilationRatio < 1000 ? `${result.timeDilationRatio.toFixed(2)}x` : result.timeDilationRatio.toExponential(2); return ` ${item.dataset.label ?? ""}: ship clock ${fmtClockRateTick(clockRate)} of Earth at peak (γ=${formatGamma(result.peakGamma)}, trip avg ${tripRatio})`; }, }, }, }, scales: { x: { type: clockRateChartLinear ? "linear" : "logarithmic", ...(clockRateChartLinear ? { min: 0 } : {}), title: { display: true, text: "Distance (light-years)", color: "#d4d4d8", font: { size: 13 } }, afterBuildTicks: clockRateChartLinear ? undefined : filterLogTicks, ticks: { color: "#a1a1aa", font: { size: 11 }, callback: function (value) { return fmtDistTick(value as number); }, }, grid: { color: "#3f3f46", lineWidth: 0.5 }, }, y: { type: clockRateChartLinear ? "linear" : "logarithmic", ...(clockRateChartLinear ? { min: 0, max: 1 } : { min: minClockRate, max: 1 }), title: { display: true, text: "Ship Clock Rate (1/γ)", color: "#d4d4d8", font: { size: 13 }, }, afterBuildTicks: clockRateChartLinear ? undefined : filterLogTicks, ticks: { color: "#a1a1aa", font: { size: 11 }, callback: function (value) { return fmtClockRateTick(value as number); }, }, grid: { color: "#3f3f46", lineWidth: 0.5 }, }, }, }, plugins: [ { id: "clockRateCurrentDist", afterDraw: (chart) => { if (distanceLy <= 0) return; const xScale = chart.scales.x; const xPixel = xScale.getPixelForValue(distanceLy); if (xPixel < xScale.left || xPixel > xScale.right) return; const drawCtx = chart.ctx; drawCtx.save(); drawCtx.strokeStyle = "#a1a1aa"; drawCtx.lineWidth = 1; drawCtx.setLineDash([4, 4]); drawCtx.beginPath(); drawCtx.moveTo(xPixel, chart.scales.y.top); drawCtx.lineTo(xPixel, chart.scales.y.bottom); drawCtx.stroke(); drawCtx.restore(); }, }, ], }; clockRateChartInstanceRef.current = new Chart(ctx, config); }, [accelerationG, brachistochrone, coastFraction, distanceLy, showAccelChart, clockRateChartLinear]); // --- Velocity vs Distance Chart --- const buildVelocityChart = useCallback(() => { if (!velocityChartRef.current || !showAccelChart) return; if (velocityChartInstanceRef.current) { velocityChartInstanceRef.current.destroy(); } const ctx = velocityChartRef.current.getContext("2d"); if (!ctx) return; const distances: number[] = []; if (accelChartLinear) { const maxDist = distanceLy > 0 ? distanceLy * 3 : 100; const step = maxDist / 200; distances.push(0); for (let d = step; d <= maxDist; d += step) { distances.push(d); } } else { for (let exp = -2; exp <= 14; exp += 0.1) { distances.push(Math.pow(10, exp)); } } const gValues = [...COMPARISON_GS]; const userGIsPreset = COMPARISON_GS.some((g) => Math.abs(g - accelerationG) < 0.01); if (!userGIsPreset && accelerationG > 0) { gValues.push(accelerationG); gValues.sort((a, b) => a - b); } const fmtDistTick = (v: number) => { if (v < 1000) return `${v % 1 === 0 ? v : v.toPrecision(3)}`; if (v < 1e6) return `${(v / 1e3).toFixed(0)}k`; if (v < 1e9) return `${(v / 1e6).toFixed(0)}M`; if (v < 1e12) return `${(v / 1e9).toFixed(0)}B`; return `${(v / 1e12).toFixed(0)}T`; }; const datasets = gValues.map((g) => { const isUserG = Math.abs(g - accelerationG) < 0.01; const isCustom = !COMPARISON_GS.some((pg) => Math.abs(pg - g) < 0.01); const color = isCustom ? "#7dd3fc" : COMPARISON_COLORS[g]; return { label: `${g}g${isUserG ? " (selected)" : ""}`, data: distances.map((d) => computeTravel(d, g, brachistochrone, coastFraction).peakVelocityFractionC), borderColor: color, backgroundColor: "transparent", borderWidth: isUserG ? 3 : 1.5, borderDash: isUserG ? [] : [4, 2], pointRadius: 0, pointHitRadius: 8, fill: false, tension: 0.3, }; }); const velConfig: ChartConfiguration = { type: "line", data: { labels: distances.map((d) => d.toString()), datasets, }, options: { responsive: true, maintainAspectRatio: false, animation: { duration: 600, easing: "easeOutQuart" }, interaction: { mode: "index", intersect: false }, plugins: { legend: { labels: { color: "#a1a1aa", font: { size: 12 }, usePointStyle: true, pointStyle: "line" }, }, tooltip: { backgroundColor: "#27272a", titleColor: "#fafafa", bodyColor: "#d4d4d8", borderColor: "#52525b", borderWidth: 1, callbacks: { title: (items) => { const d = parseFloat(items[0].label); return `Distance: ${formatDistance(d)}`; }, label: (item) => { const v = item.parsed.y as number; return ` ${item.dataset.label ?? ""}: ${formatVelocity(v)}`; }, }, }, }, scales: { x: { type: accelChartLinear ? "linear" : "logarithmic", ...(accelChartLinear ? { min: 0 } : {}), title: { display: true, text: "Distance (light-years)", color: "#d4d4d8", font: { size: 13 } }, afterBuildTicks: accelChartLinear ? undefined : (axis: { ticks: { value: number }[] }) => { axis.ticks = axis.ticks.filter((t) => { const log = Math.log10(t.value); return Math.abs(log - Math.round(log)) < 0.01; }); }, ticks: { color: "#a1a1aa", font: { size: 11 }, callback: function (value) { return fmtDistTick(value as number); }, }, grid: { color: "#3f3f46", lineWidth: 0.5 }, }, y: { type: "linear", min: 0, max: 1.05, title: { display: true, text: "Peak Velocity (v/c)", color: "#d4d4d8", font: { size: 13 } }, ticks: { color: "#a1a1aa", font: { size: 11 }, stepSize: 0.1, callback: function (value) { const v = value as number; if (v === 1) return "c"; return `${(v * 100).toFixed(0)}%`; }, }, grid: { color: "#3f3f46", lineWidth: 0.5 }, }, }, }, plugins: [ { id: "speedOfLight", afterDraw: (chart) => { const yScale = chart.scales.y; const yPixel = yScale.getPixelForValue(1.0); const drawCtx = chart.ctx; drawCtx.save(); drawCtx.strokeStyle = "#ef4444"; drawCtx.lineWidth = 1; drawCtx.setLineDash([6, 3]); drawCtx.beginPath(); drawCtx.moveTo(chart.scales.x.left, yPixel); drawCtx.lineTo(chart.scales.x.right, yPixel); drawCtx.stroke(); drawCtx.fillStyle = "#ef4444"; drawCtx.font = "11px sans-serif"; drawCtx.fillText("c", chart.scales.x.right + 4, yPixel + 4); drawCtx.restore(); }, }, { id: "velCurrentDist", afterDraw: (chart) => { if (distanceLy <= 0) return; const xScale = chart.scales.x; const xPixel = xScale.getPixelForValue(distanceLy); if (xPixel < xScale.left || xPixel > xScale.right) return; const drawCtx = chart.ctx; drawCtx.save(); drawCtx.strokeStyle = "#a1a1aa"; drawCtx.lineWidth = 1; drawCtx.setLineDash([4, 4]); drawCtx.beginPath(); drawCtx.moveTo(xPixel, chart.scales.y.top); drawCtx.lineTo(xPixel, chart.scales.y.bottom); drawCtx.stroke(); drawCtx.restore(); }, }, ], }; velocityChartInstanceRef.current = new Chart(ctx, velConfig); }, [accelerationG, brachistochrone, coastFraction, distanceLy, showAccelChart, accelChartLinear]); const getSpeedClockRateChartData = useCallback(() => { const velocities: number[] = []; const clockRateData: number[] = []; for (let v = 0; v <= 0.9; v += 0.02) { velocities.push(v); clockRateData.push(Math.sqrt(1 - v * v)); } const gammaMax = 1e5; for (let logG = Math.log10(2); logG <= Math.log10(gammaMax); logG += 0.02) { const g = Math.pow(10, logG); const v = Math.sqrt(1 - 1 / (g * g)); velocities.push(v); clockRateData.push(1 / g); } const gValues = [...COMPARISON_GS]; const userGIsPreset = COMPARISON_GS.some((g) => Math.abs(g - accelerationG) < 0.01); if (!userGIsPreset && accelerationG > 0) { gValues.push(accelerationG); gValues.sort((a, b) => a - b); } const markerPoints = distanceLy > 0 ? gValues.map((g) => { const r = computeTravel(distanceLy, g, brachistochrone, coastFraction); const isUserG = Math.abs(g - accelerationG) < 0.01; const isCustom = !COMPARISON_GS.some((pg) => Math.abs(pg - g) < 0.01); const color = isCustom ? "#7dd3fc" : COMPARISON_COLORS[g]; return { g, v: r.peakVelocityFractionC, gamma: r.peakGamma, clockRate: 1 / r.peakGamma, color, isUserG }; }) : []; const datasets: ChartConfiguration["data"]["datasets"] = [ { label: "Ship clock rate (1/γ)", data: velocities.map((v, i) => ({ x: v, y: clockRateData[i] })), borderColor: "#49b7d8", backgroundColor: "transparent", borderWidth: 2, pointRadius: 0, pointHitRadius: 8, fill: false, tension: 0.3, }, ...markerPoints.map((mp) => ({ label: `${mp.g}g${mp.isUserG ? " (selected)" : ""} — ${fmtClockRateTick(mp.clockRate)}`, data: [{ x: mp.v, y: mp.clockRate }], borderColor: mp.color, backgroundColor: mp.color, borderWidth: mp.isUserG ? 3 : 2, pointRadius: mp.isUserG ? 7 : 5, pointStyle: "circle" as const, showLine: false, })), ]; const minClockRateInData = Math.min(...clockRateData, ...markerPoints.map((mp) => mp.clockRate)); const yMin = Math.pow(10, Math.floor(Math.log10(minClockRateInData))); return { datasets, yMin }; }, [accelerationG, brachistochrone, coastFraction, distanceLy]); const getRapidityComparisonData = useCallback(() => { const maxDistanceLy = Math.max(...DESTINATIONS.map((dest) => dest.distanceLy)); const maxExp = Math.ceil(Math.log10(maxDistanceLy)); const distances: number[] = []; for (let exp = -2; exp <= maxExp; exp += 0.1) { distances.push(Math.pow(10, exp)); } const gValues = getComparisonGValues(); const travelData = gValues.map((g) => { const a = g * G_TO_LY_YR2; return distances.map((distance) => { const result = computeTravel(distance, g, brachistochrone, coastFraction); const rapidity = Math.acosh(result.peakGamma); return { distance, shipTimeYears: result.shipTimeYears, accelProperTimeYears: rapidity / a, rapidity, }; }); }); const yMax = Math.ceil(Math.max(...travelData.flatMap((series) => series.map((point) => point.rapidity)))); return { distances, gValues, travelData, yMax: Math.max(1, yMax) }; }, [accelerationG, brachistochrone, coastFraction]); const buildRapidityShipTimeChart = useCallback(() => { if (!rapidityShipTimeChartRef.current || !showAccelChart) return; if (rapidityShipTimeChartInstanceRef.current) { rapidityShipTimeChartInstanceRef.current.destroy(); } const ctx = rapidityShipTimeChartRef.current.getContext("2d"); if (!ctx) return; const { gValues, travelData, yMax } = getRapidityComparisonData(); const datasets = gValues.map((g, gi) => { const isUserG = Math.abs(g - accelerationG) < 0.01; const isCustom = !COMPARISON_GS.some((pg) => Math.abs(pg - g) < 0.01); const color = isCustom ? "#7dd3fc" : COMPARISON_COLORS[g]; return { label: `${g}g${isUserG ? " (selected)" : ""}`, data: travelData[gi].map((point) => ({ x: point.shipTimeYears, y: point.rapidity })), borderColor: color, backgroundColor: "transparent", borderWidth: isUserG ? 3 : 1.5, borderDash: isUserG ? [] : [4, 2], pointRadius: 0, pointHitRadius: 8, fill: false, tension: 0.3, }; }); const config: ChartConfiguration = { type: "line", data: { datasets }, options: { responsive: true, maintainAspectRatio: false, animation: { duration: 600, easing: "easeOutQuart" }, interaction: { mode: "nearest", intersect: false }, plugins: { legend: { labels: { color: "#a1a1aa", font: { size: 12 }, usePointStyle: true, pointStyle: "line" }, }, tooltip: { backgroundColor: "#27272a", titleColor: "#fafafa", bodyColor: "#d4d4d8", borderColor: "#52525b", borderWidth: 1, callbacks: { title: (items) => `Ship time: ${formatTime(items[0].parsed.x as number)}`, label: (item) => ` ${item.dataset.label ?? ""}: φ = ${formatRapidity(item.parsed.y as number)}`, }, }, }, scales: { x: { type: "logarithmic", title: { display: true, text: "Ship Time (years)", color: "#d4d4d8", font: { size: 13 } }, afterBuildTicks: filterLogTicks, ticks: { color: "#a1a1aa", font: { size: 11 }, callback: function (value) { return fmtTimeTick(value as number); }, }, grid: { color: "#3f3f46", lineWidth: 0.5 }, }, y: { type: "linear", min: 0, max: yMax, title: { display: true, text: "Rapidity (φ)", color: "#d4d4d8", font: { size: 13 } }, ticks: { color: "#a1a1aa", font: { size: 11 }, callback: function (value) { return formatRapidity(value as number); }, }, grid: { color: "#3f3f46", lineWidth: 0.5 }, }, }, }, }; rapidityShipTimeChartInstanceRef.current = new Chart(ctx, config); }, [accelerationG, getRapidityComparisonData, showAccelChart]); const buildRapidityAccelTimeChart = useCallback(() => { if (!rapidityAccelTimeChartRef.current || !showAccelChart) return; if (rapidityAccelTimeChartInstanceRef.current) { rapidityAccelTimeChartInstanceRef.current.destroy(); } const ctx = rapidityAccelTimeChartRef.current.getContext("2d"); if (!ctx) return; const { gValues, travelData, yMax } = getRapidityComparisonData(); const xMax = Math.max(...travelData.flatMap((series) => series.map((point) => point.accelProperTimeYears))); const datasets = gValues.map((g, gi) => { const isUserG = Math.abs(g - accelerationG) < 0.01; const isCustom = !COMPARISON_GS.some((pg) => Math.abs(pg - g) < 0.01); const color = isCustom ? "#7dd3fc" : COMPARISON_COLORS[g]; return { label: `${g}g${isUserG ? " (selected)" : ""}`, data: travelData[gi].map((point) => ({ x: point.accelProperTimeYears, y: point.rapidity })), borderColor: color, backgroundColor: "transparent", borderWidth: isUserG ? 3 : 1.5, borderDash: isUserG ? [] : [4, 2], pointRadius: 0, pointHitRadius: 8, fill: false, tension: 0.3, }; }); const config: ChartConfiguration = { type: "line", data: { datasets }, options: { responsive: true, maintainAspectRatio: false, animation: { duration: 600, easing: "easeOutQuart" }, interaction: { mode: "nearest", intersect: false }, plugins: { legend: { labels: { color: "#a1a1aa", font: { size: 12 }, usePointStyle: true, pointStyle: "line" }, }, tooltip: { backgroundColor: "#27272a", titleColor: "#fafafa", bodyColor: "#d4d4d8", borderColor: "#52525b", borderWidth: 1, callbacks: { title: (items) => `Powered ship time: ${formatTime(items[0].parsed.x as number)}`, label: (item) => ` ${item.dataset.label ?? ""}: φ = ${formatRapidity(item.parsed.y as number)}`, }, }, }, scales: { x: { type: "linear", min: 0, max: xMax, title: { display: true, text: "Powered Ship Time (years)", color: "#d4d4d8", font: { size: 13 } }, ticks: { color: "#a1a1aa", font: { size: 11 }, callback: function (value) { return fmtTimeTick(value as number); }, }, grid: { color: "#3f3f46", lineWidth: 0.5 }, }, y: { type: "linear", min: 0, max: yMax, title: { display: true, text: "Rapidity (φ)", color: "#d4d4d8", font: { size: 13 } }, ticks: { color: "#a1a1aa", font: { size: 11 }, callback: function (value) { return formatRapidity(value as number); }, }, grid: { color: "#3f3f46", lineWidth: 0.5 }, }, }, }, }; rapidityAccelTimeChartInstanceRef.current = new Chart(ctx, config); }, [accelerationG, getRapidityComparisonData, showAccelChart]); const buildRapidityDistanceChart = useCallback(() => { if (!rapidityDistanceChartRef.current || !showAccelChart) return; if (rapidityDistanceChartInstanceRef.current) { rapidityDistanceChartInstanceRef.current.destroy(); } const ctx = rapidityDistanceChartRef.current.getContext("2d"); if (!ctx) return; const { gValues, travelData, yMax } = getRapidityComparisonData(); const datasets = gValues.map((g, gi) => { const isUserG = Math.abs(g - accelerationG) < 0.01; const isCustom = !COMPARISON_GS.some((pg) => Math.abs(pg - g) < 0.01); const color = isCustom ? "#7dd3fc" : COMPARISON_COLORS[g]; return { label: `${g}g${isUserG ? " (selected)" : ""}`, data: travelData[gi].map((point) => ({ x: point.distance, y: point.rapidity })), borderColor: color, backgroundColor: "transparent", borderWidth: isUserG ? 3 : 1.5, borderDash: isUserG ? [] : [4, 2], pointRadius: 0, pointHitRadius: 8, fill: false, tension: 0.3, }; }); const config: ChartConfiguration = { type: "line", data: { datasets }, options: { responsive: true, maintainAspectRatio: false, animation: { duration: 600, easing: "easeOutQuart" }, interaction: { mode: "nearest", intersect: false }, plugins: { legend: { labels: { color: "#a1a1aa", font: { size: 12 }, usePointStyle: true, pointStyle: "line" }, }, tooltip: { backgroundColor: "#27272a", titleColor: "#fafafa", bodyColor: "#d4d4d8", borderColor: "#52525b", borderWidth: 1, callbacks: { title: (items) => `Distance: ${formatDistance(items[0].parsed.x as number)}`, label: (item) => ` ${item.dataset.label ?? ""}: φ = ${formatRapidity(item.parsed.y as number)}`, }, }, }, scales: { x: { type: "logarithmic", title: { display: true, text: "Distance (light-years)", color: "#d4d4d8", font: { size: 13 } }, afterBuildTicks: filterLogTicks, ticks: { color: "#a1a1aa", font: { size: 11 }, callback: function (value) { return fmtDistTick(value as number); }, }, grid: { color: "#3f3f46", lineWidth: 0.5 }, }, y: { type: "linear", min: 0, max: yMax, title: { display: true, text: "Rapidity (φ)", color: "#d4d4d8", font: { size: 13 } }, ticks: { color: "#a1a1aa", font: { size: 11 }, callback: function (value) { return formatRapidity(value as number); }, }, grid: { color: "#3f3f46", lineWidth: 0.5 }, }, }, }, }; rapidityDistanceChartInstanceRef.current = new Chart(ctx, config); }, [accelerationG, getRapidityComparisonData, showAccelChart]); // --- Time Dilation vs Speed Chart --- const buildDilationChart = useCallback(() => { if (!dilationChartRef.current || !showAccelChart) return; if (dilationChartInstanceRef.current) { dilationChartInstanceRef.current.destroy(); } const ctx = dilationChartRef.current.getContext("2d"); if (!ctx) return; const { datasets, yMin } = getSpeedClockRateChartData(); const xDatasets = dilationChartLogX ? transformSpeedDatasetsForLogX(datasets) : datasets; const xMin = dilationChartLogX ? getLogXMin(xDatasets, 1) : undefined; const dilConfig: ChartConfiguration = { type: "line", data: { datasets: xDatasets }, options: { responsive: true, maintainAspectRatio: false, animation: { duration: 600, easing: "easeOutQuart" }, interaction: { mode: "nearest", intersect: false }, plugins: { legend: { labels: { color: "#a1a1aa", font: { size: 12 }, usePointStyle: true }, }, tooltip: { backgroundColor: "#27272a", titleColor: "#fafafa", bodyColor: "#d4d4d8", borderColor: "#52525b", borderWidth: 1, callbacks: { title: (items) => { const rawX = items[0].parsed.x as number; const v = dilationChartLogX ? 1 - rawX : rawX; return `Velocity: ${formatVelocity(v)}`; }, label: (item) => { const clockRate = item.parsed.y as number; const gamma = 1 / clockRate; return ` ship clock = ${fmtClockRateTick(clockRate)} of Earth (γ=${formatGamma(gamma)})`; }, }, }, }, scales: { x: { type: dilationChartLogX ? "logarithmic" : "linear", ...(dilationChartLogX ? { min: xMin, max: 1, reverse: true } : { min: 0, max: 1 }), title: { display: true, text: dilationChartLogX ? "Distance from c (1 - v/c), log scale" : "Velocity (v/c)", color: "#d4d4d8", font: { size: 13 }, }, afterBuildTicks: dilationChartLogX ? filterLogTicks : undefined, ticks: { color: "#a1a1aa", font: { size: 11 }, callback: function (value) { const rawX = value as number; const v = dilationChartLogX ? 1 - rawX : rawX; return fmtVelocityPercentTick(v); }, }, grid: { color: "#3f3f46", lineWidth: 0.5 }, }, y: { type: dilationChartLogY ? "logarithmic" : "linear", ...(dilationChartLogY ? { min: yMin, max: 1 } : { min: 0, max: 1 }), position: "left", title: { display: true, text: "Ship Clock Rate (1/γ)", color: "#d4d4d8", font: { size: 13 } }, afterBuildTicks: dilationChartLogY ? filterLogTicks : undefined, ticks: { color: "#a1a1aa", font: { size: 11 }, callback: function (value) { return fmtClockRateTick(value as number); }, }, grid: { color: "#3f3f46", lineWidth: 0.5 }, }, yRight: { type: dilationChartLogY ? "logarithmic" : "linear", ...(dilationChartLogY ? { min: yMin, max: 1 } : { min: 0, max: 1 }), position: "right", title: { display: true, text: "Ship hours per Earth day", color: "#d4d4d8", font: { size: 13 } }, afterBuildTicks: dilationChartLogY ? filterLogTicks : undefined, ticks: { color: "#a1a1aa", font: { size: 11 }, callback: function (value) { const clockRate = value as number; const hoursPerDay = 24 * clockRate; if (hoursPerDay >= 1) return `${hoursPerDay.toFixed(hoursPerDay < 10 ? 1 : 0)} h`; if (hoursPerDay >= 1 / 60) return `${(hoursPerDay * 60).toFixed(0)} m`; return `${(hoursPerDay * 3600).toFixed(0)} s`; }, }, grid: { drawOnChartArea: false }, }, }, }, }; dilationChartInstanceRef.current = new Chart(ctx, dilConfig); }, [dilationChartLogX, dilationChartLogY, getSpeedClockRateChartData, showAccelChart]); const buildDilationZoomChart = useCallback(() => { if (!dilationZoomChartRef.current || !showAccelChart) return; if (dilationZoomChartInstanceRef.current) { dilationZoomChartInstanceRef.current.destroy(); } const ctx = dilationZoomChartRef.current.getContext("2d"); if (!ctx) return; const { datasets, yMin } = getSpeedClockRateChartData(); const xDatasets = dilationZoomLogX ? transformSpeedDatasetsForLogX(datasets) : datasets; const xMin = dilationZoomLogX ? getLogXMin(xDatasets, 1e-2) : undefined; const zoomConfig: ChartConfiguration = { type: "line", data: { datasets: xDatasets }, options: { responsive: true, maintainAspectRatio: false, animation: { duration: 600, easing: "easeOutQuart" }, interaction: { mode: "nearest", intersect: false }, plugins: { legend: { labels: { color: "#a1a1aa", font: { size: 12 }, usePointStyle: true }, }, tooltip: { backgroundColor: "#27272a", titleColor: "#fafafa", bodyColor: "#d4d4d8", borderColor: "#52525b", borderWidth: 1, callbacks: { title: (items) => { const rawX = items[0].parsed.x as number; const v = dilationZoomLogX ? 1 - rawX : rawX; return `Velocity: ${formatVelocity(v)}`; }, label: (item) => { const clockRate = item.parsed.y as number; const gamma = 1 / clockRate; return ` ship clock = ${fmtClockRateTick(clockRate)} of Earth (γ=${formatGamma(gamma)})`; }, }, }, }, scales: { x: { type: dilationZoomLogX ? "logarithmic" : "linear", ...(dilationZoomLogX ? { min: xMin, max: 1e-2, reverse: true } : { min: 0.99, max: 1 }), title: { display: true, text: dilationZoomLogX ? "Distance from c (1 - v/c), log scale" : "Velocity (v/c), zoomed to 99%+", color: "#d4d4d8", font: { size: 13 }, }, afterBuildTicks: dilationZoomLogX ? filterLogTicks : undefined, ticks: { color: "#a1a1aa", font: { size: 11 }, callback: function (value) { const rawX = value as number; const v = dilationZoomLogX ? 1 - rawX : rawX; return fmtVelocityPercentTick(v); }, }, grid: { color: "#3f3f46", lineWidth: 0.5 }, }, y: { type: dilationZoomLogY ? "logarithmic" : "linear", ...(dilationZoomLogY ? { min: yMin, max: 1 } : { min: 0, max: 1 }), position: "left", title: { display: true, text: "Ship Clock Rate (1/γ)", color: "#d4d4d8", font: { size: 13 } }, afterBuildTicks: dilationZoomLogY ? filterLogTicks : undefined, ticks: { color: "#a1a1aa", font: { size: 11 }, callback: function (value) { return fmtClockRateTick(value as number); }, }, grid: { color: "#3f3f46", lineWidth: 0.5 }, }, yRight: { type: dilationZoomLogY ? "logarithmic" : "linear", ...(dilationZoomLogY ? { min: yMin, max: 1 } : { min: 0, max: 1 }), position: "right", title: { display: true, text: "Ship hours per Earth day", color: "#d4d4d8", font: { size: 13 } }, afterBuildTicks: dilationZoomLogY ? filterLogTicks : undefined, ticks: { color: "#a1a1aa", font: { size: 11 }, callback: function (value) { const clockRate = value as number; const hoursPerDay = 24 * clockRate; if (hoursPerDay >= 1) return `${hoursPerDay.toFixed(hoursPerDay < 10 ? 1 : 0)} h`; if (hoursPerDay >= 1 / 60) return `${(hoursPerDay * 60).toFixed(0)} m`; return `${(hoursPerDay * 3600).toFixed(0)} s`; }, }, grid: { drawOnChartArea: false }, }, }, }, }; dilationZoomChartInstanceRef.current = new Chart(ctx, zoomConfig); }, [dilationZoomLogX, dilationZoomLogY, getSpeedClockRateChartData, showAccelChart]); useEffect(() => { buildAccelChart(); buildClockRateChart(); buildVelocityChart(); buildDilationChart(); buildDilationZoomChart(); buildRapidityShipTimeChart(); buildRapidityAccelTimeChart(); buildRapidityDistanceChart(); return () => { if (accelChartInstanceRef.current) { accelChartInstanceRef.current.destroy(); accelChartInstanceRef.current = null; } if (clockRateChartInstanceRef.current) { clockRateChartInstanceRef.current.destroy(); clockRateChartInstanceRef.current = null; } if (velocityChartInstanceRef.current) { velocityChartInstanceRef.current.destroy(); velocityChartInstanceRef.current = null; } if (dilationChartInstanceRef.current) { dilationChartInstanceRef.current.destroy(); dilationChartInstanceRef.current = null; } if (dilationZoomChartInstanceRef.current) { dilationZoomChartInstanceRef.current.destroy(); dilationZoomChartInstanceRef.current = null; } if (rapidityShipTimeChartInstanceRef.current) { rapidityShipTimeChartInstanceRef.current.destroy(); rapidityShipTimeChartInstanceRef.current = null; } if (rapidityAccelTimeChartInstanceRef.current) { rapidityAccelTimeChartInstanceRef.current.destroy(); rapidityAccelTimeChartInstanceRef.current = null; } if (rapidityDistanceChartInstanceRef.current) { rapidityDistanceChartInstanceRef.current.destroy(); rapidityDistanceChartInstanceRef.current = null; } }; }, [ buildAccelChart, buildClockRateChart, buildVelocityChart, buildDilationChart, buildDilationZoomChart, buildRapidityShipTimeChart, buildRapidityAccelTimeChart, buildRapidityDistanceChart, ]); // Handle destination dropdown const handleDestinationChange = (e: React.ChangeEvent) => { const name = e.target.value; setSelectedDestination(name); if (name !== "Custom") { const dest = DESTINATIONS.find((d) => d.name === name); if (dest) { setDistanceInput(dest.distanceLy.toString()); } } }; // Handle manual distance input const handleDistanceChange = (e: React.ChangeEvent) => { const val = e.target.value; setDistanceInput(val); // Check if it matches a preset destination const parsed = parseFloat(val); const match = DESTINATIONS.find((d) => d.distanceLy === parsed); setSelectedDestination(match ? match.name : "Custom"); }; // Handle acceleration input const handleGChange = (e: React.ChangeEvent) => { setGInput(e.target.value); }; // Group destinations by category for optgroup (exclude tableOnly entries) const groupedDestinations = DESTINATIONS.filter((d) => !d.tableOnly).reduce( (acc, dest) => { if (!acc[dest.category]) acc[dest.category] = []; acc[dest.category].push(dest); return acc; }, {} as Record, ); return (
{/* Left Column - Inputs */}

Journey Parameters

{/* Destination Dropdown */}
{/* Distance Input */}
{/* Mode Toggle */}
{/* Acceleration Input */}
{/* Preset Buttons */}
{ACCELERATION_PRESETS.map((preset) => ( ))}
g
{/* Coast Phase (brachistochrone only) */}
setCoastInput(e.target.value)} disabled={!brachistochrone} className="h-1.5 flex-1 cursor-pointer appearance-none rounded-lg bg-zinc-600 accent-TOElightERblue" />
setCoastInput(e.target.value)} onBlur={() => setCoastInput(String(Math.max(0, Math.min(99, parseFloat(coastInput) || 0))))} disabled={!brachistochrone} className="w-14 rounded border border-zinc-600 bg-zinc-800 px-2 py-1 text-right text-sm text-zinc-200 focus:border-TOElightERblue focus:ring-1 focus:ring-TOElightERblue focus:outline-none" /> %
{/* Exhaust Velocity */}

100% = photon drive (eg. Spin Drive)

setEfficiencyInput(e.target.value)} className="h-1.5 flex-1 cursor-pointer appearance-none rounded-lg bg-zinc-600 accent-TOElightERblue" />
setEfficiencyInput(e.target.value)} className="w-14 rounded border border-zinc-600 bg-zinc-800 px-2 py-1 text-right text-sm text-zinc-200 focus:border-TOElightERblue focus:ring-1 focus:ring-TOElightERblue focus:outline-none" /> %
{efficiency < 1 && (

Effective exhaust velocity: {(efficiency * 100).toFixed(0)}% of c ({(efficiency * 299792).toFixed(0)}{" "} km/s)

)}
{/* Ship Dry Mass */}
setDryMassInput(e.target.value)} placeholder="e.g. 100000" className="flex-1 rounded-md border border-zinc-600 bg-zinc-700 px-3 py-2 text-zinc-200 shadow-sm focus:border-TOElightERblue focus:ring-1 focus:ring-TOElightERblue focus:outline-none" /> kg
{dryMassKg > 0 &&

{formatMass(dryMassKg)}

}
{/* Toggle Buttons */}
{/* Right Column - Results */}

Journey Results

{!results ? (
Enter a distance and acceleration to see results.
) : ( <> {/* Primary Time Cards */}

Ship Time

{formatTime(results.shipTimeYears)}

{results.timeDilationRatio > 1.01 ? `You'd age ${formatTime(results.shipTimeYears)} while ${formatTime(results.earthTimeYears)} pass on Earth — a time dilation factor of ${results.timeDilationRatio < 1000 ? results.timeDilationRatio.toFixed(1) : results.timeDilationRatio.toExponential(2)}×.` : `Relativistic effects are negligible at this distance and acceleration.`}

Earth Time

{formatTime(results.earthTimeYears)}

{results.timeDilationRatio > 1.01 ? `You'd age ${formatTime(results.shipTimeYears)} while ${formatTime(results.earthTimeYears)} pass on Earth — a time dilation factor of ${results.timeDilationRatio < 1000 ? results.timeDilationRatio.toFixed(1) : results.timeDilationRatio.toExponential(2)}×.` : `Relativistic effects are negligible at this distance and acceleration.`}
{/* Journey Details */}

Journey Details

  • Distance: {formatDistance(distanceLy)}
  • Acceleration: {accelerationG}g ({(accelerationG * G_STANDARD_MS2).toFixed(2)} m/s²)
  • Mode: {brachistochrone ? "Brachistochrone" : "Fly-by"}
  • Peak Velocity: {formatVelocity(results.peakVelocityFractionC, results.peakGamma)}
  • Peak Lorentz Factor (γ): {formatGamma(results.peakGamma)}
  • Time Dilation Ratio: {results.timeDilationRatio < 1000 ? `${results.timeDilationRatio.toFixed(2)}×` : `${results.timeDilationRatio.toExponential(2)}×`}
{/* Coast Phase */} {coastFraction > 0 && distanceLy > 0 && (

Coast Phase

{brachistochrone ? `Accel ${formatDistance((distanceLy * (1 - coastFraction)) / 2)} → Coast ${formatDistance(distanceLy * coastFraction)} → Decel ${formatDistance((distanceLy * (1 - coastFraction)) / 2)}` : `Accel ${formatDistance(distanceLy * (1 - coastFraction))} → Coast ${formatDistance(distanceLy * coastFraction)}`}

Coasting at {formatVelocity(results.peakVelocityFractionC, results.peakGamma)} for{" "} {(coastFraction * 100).toFixed(0)}% of the distance

)} {/* Rocket Mass Ratio & Fuel */}

Rocket Mass Ratio

  • Mass ratio: {formatMassRatio(adjustedBrach)}
{dryMassKg > 0 && (
  • Total launch mass: {formatMass(dryMassKg * adjustedBrach)}
  • Fuel required: {formatMass(dryMassKg * (adjustedBrach - 1))}
  • {(() => { const fuelEnergy = dryMassKg * (adjustedBrach - 1) * C_SQUARED; if (!isFinite(fuelEnergy)) return null; const comparison = getEnergyComparison(fuelEnergy); return ( <>
  • Energy (mc²): {formatEnergy(fuelEnergy)}
  • {comparison &&
  • {comparison}
  • } ); })()}
)} {dryMassKg > 0 && (() => { const totalMass = dryMassKg * adjustedBrach; // Handle infinite/overflow mass ratios if (!isFinite(totalMass) || !isFinite(adjustedBrach)) { return (

Feasibility

The required fuel mass overflows calculable numbers. This trip is physically impossible regardless of available fuel — the mass ratio exceeds the number of atoms in the observable universe many times over.

); } if (totalMass < 1e5) return null; const rs = totalMass * SCHWARZSCHILD_FACTOR; const ref = getMassComparison(totalMass); const isBlackHole = totalMass > TOV_LIMIT_KG; return (

Feasibility

{ref && (

Launch mass ={" "} {formatMultiple(ref.multiple)}×{" "} the mass of {ref.name}

)} {DENSITY_COMPARISONS.map(({ name, density }) => { const r = sphereRadius(totalMass, density); if (r <= rs) return null; // inside its own black hole — meaningless return (

If {name} density : {formatRadius(r)} — {getRadiusComparison(r)}

); })}

Schwarzschild radius : {formatRadius(rs)} — {getRadiusComparison(rs)}

{isBlackHole && (

This fuel mass exceeds the TOV limit (~3 M). No known force can prevent gravitational collapse — your rocket is a black hole.

)}
); })()} {/* Project Hail Mary Discrepancy */} {isPHM && (() => { const noCoast = computeTravel(distanceLy, accelerationG, true, 0); return (
{showPHM && (

The Hail Mary carries ~2 million kg of Astrophage fuel for a 100,000 kg ship (~21:1 ratio) on a brachistochrone trip to Tau Ceti at 1.5g.

Book's fuel

2,000 tonnes

“two million kilograms of Astrophage”

Correct fuel (no coast)

{formatMass(100000 * (noCoast.massRatioBrach - 1))}

For brachistochrone, you need fuel to decelerate at the destination (mass ratio R applied to dry mass), and fuel to accelerate that decel-loaded ship (R again). The ratios multiply: Rtotal = R².

The cruise phase fix

Andy Weir has acknowledged the mass ratio error, noting that the Hail Mary includes a cruise phase — coasting at peak velocity for part of the trip — which dramatically reduces the required fuel. Try the coast phase slider above: at ~85% coast, the brachistochrone mass ratio drops to roughly 21:1, matching the book's fuel load, while adding less than a year to the Earth-frame journey.

{coastFraction > 0 && (

Current ({(coastFraction * 100).toFixed(0)}% coast): mass ratio{" "} {results.massRatioBrach.toFixed(1)} : 1 , fuel{" "} {formatMass(100000 * (results.massRatioBrach - 1))} {" "} (vs. {noCoast.massRatioBrach.toFixed(1)} : 1 without coast)

)}

Thrust vs. consumption

The book also states 6.045 g/s fuel consumption and 30 MN thrust (3 engines × 10⁷ N). An ideal engine converting 6 g/s would produce only{" "} 1.81 MN of thrust (F = ṁc). To get 30 MN you'd need ~100 g/s — or to match 6 g/s at 1.5g on a 2.17M kg ship, the engine would need >16× the momentum of light, which is physically impossible. These two numbers are inconsistent.

)}
); })()}
)}
{/* Acceleration Comparison Chart */} {showAccelChart && (

Acceleration Comparison — {brachistochrone ? "Brachistochrone" : "Fly-by"}

{accelChartLinear ? `Ship time vs distance (0–${formatDistance(distanceLy > 0 ? distanceLy * 3 : 100)}) — focused on the selected destination` : "Ship time vs distance at different accelerations — parallel curves on log-log show that higher g mainly shifts travel time down"}

Ship Clock Rate vs Distance

{clockRateChartLinear ? `Ship clock rate vs distance (0–${formatDistance(distanceLy > 0 ? distanceLy * 3 : 100)}) — lower means onboard time is running more slowly` : "Ship clock rate vs distance at different accelerations — higher g drives the onboard clock lower sooner"}

{/* Velocity vs Distance Chart */}

Velocity vs Distance

Peak velocity as fraction of c — all curves plateau as they approach the speed of light

{/* Time Dilation vs Speed Chart */}

Ship Clock Rate vs Speed

The left axis shows the ship's clock rate, `1/γ`. Lower values mean time onboard is running more slowly relative to Earth.

{dilationChartLogX ? "Log X uses distance from light speed, `1 - v/c`, so the near-c compression becomes easier to read across the full curve." : "Linear X shows the familiar velocity scale from rest to c."}{" "} {dilationChartLogY ? "Log Y emphasizes the steep slowdown in the ship clock." : "Linear Y shows clock-rate changes in direct percentage space."}

{distanceLy > 0 && ` Each dot marks the slowest onboard clock rate reached traveling to ${selectedDestination !== "Custom" ? selectedDestination : "the selected destination"} at that acceleration.`}

Ship Clock Rate vs Speed (99%+ Inset)

Zoomed into the last 1% below light speed, where the clock-rate falloff gets compressed in the full chart.

{dilationZoomLogX ? "Log X uses distance from light speed, `1 - v/c`, so the remaining gap to c is easier to read." : "Linear X shows the familiar velocity scale, but only from 99% c up to c."}{" "} {dilationZoomLogY ? "Log Y preserves the full drop in clock rate." : "Linear Y expands the upper part of the curve near 100% clock rate."}

What Is Rapidity?

Rapidity, `φ`, is the velocity coordinate relativity wants you to use. Instead of treating speed itself as the fundamental quantity, special relativity rewrites it as:

v/c = tanh(φ), γ = cosh(φ), γv/c = sinh(φ)

For a constantly accelerating rocket, rapidity grows linearly while you are under thrust. That is why it is more informative than velocity once the ship gets close to `c`.

Rapidity vs Ship Time

Peak rapidity reached during the trip against total ship time. The x-axis is logarithmic because total trip time spans orders of magnitude.

Rapidity vs Powered Ship Time

This uses only the ship time spent under thrust up to peak speed. For constant proper acceleration, rapidity grows linearly during powered flight, so these curves are the cleanest statement of the rule.

Rapidity vs Distance

Peak rapidity against distance on a logarithmic distance axis. This makes it easier to see how relativistic travel “spreads out” cosmic distances.

)} {/* All Destinations Table */} {showAllDestinations && (

All Destinations at {accelerationG}g ({brachistochrone ? "Brachistochrone" : "Fly-by"}) {efficiency < 1 ? `, v_e = ${(efficiency * 100).toFixed(0)}% c` : ""}

{allDestinationResults.map((entry) => ( ))}
Destination Distance Ship Time Earth Time Peak v Mass Ratio
{entry.name} {formatDistance(entry.distanceLy)} {formatTimeShort(entry.results.shipTimeYears)} {formatTimeShort(entry.results.earthTimeYears)} {formatVelocity(entry.results.peakVelocityFractionC, entry.results.peakGamma)} {(() => { const r = Math.pow(entry.results.massRatioBrach, 1 / efficiency); return r < 1e6 ? `${r.toFixed(r < 100 ? 1 : 0)} : 1` : `${r.toExponential(2)} : 1`; })()}
)} {/* Footer */}
{VERSION} Source Code
); }; export default CalcRelativisticRocket;