0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
Almost every solar system diagram cheats: the planets are enlarged hundreds of times so you can see them at all. This one refuses — the sun is 60 pixels across and every planet size and every orbital distance uses that same ratio of 1 px to 23,190 km, on a strip 255,494 px wide (about 68 metres if you printed it) where Earth is 0.55 px and the empty stretches are labelled, because the emptiness is the point. Start at the sun, drag right until Mercury turns up 2,497 px later, then give up and use the jump buttons — that surrender is the lesson. Then send a photon: it moves at true light speed scaled to the strip, 12.9 px per real second, with the time compression stated honestly, so ×60 turns the 8m 19s trip to Earth into 8.3 seconds of your life.
Source — the code you see is the code that just ran 1004 lines 48.1 KB index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Solar System to Scale</title>
<style>
/* sloprun design tokens — inline this block into every demo (self-contained rule).
Identity: instrument-panel. Machine facts in mono; human words in sans.
Green is EARNED: only for "it ran / it worked" states, never decoration. */
:root {
--bg: #F6F6F3; /* neutral paper, not cream */
--surface: #FFFFFF;
--ink: #1A1D21;
--muted: #5A6068;
--line: #E3E2DC;
--accent: #C05600; /* phosphor amber, darkened for light bg */
--accent-soft: #FFF3E6;
--run: #1A7F37; /* earned green */
--run-soft: #E7F4EA;
--danger: #C93C37;
--radius: 10px;
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
}
:root[data-theme="dark"] {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* ---------- base ---------- */
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body { line-height: 1.5; font-size: 15px; overflow-x: hidden; -webkit-text-size-adjust: 100%; }
.wrap { max-width: 1180px; margin: 0 auto; padding: 18px 14px 30px; }
h1, h2, h3 { margin: 0; font-weight: 600; letter-spacing: -0.01em; }
.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
/* ---------- header ---------- */
.head { display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: flex-start;
justify-content: space-between; padding-bottom: 13px; margin-bottom: 13px; border-bottom: 1px solid var(--line); }
.head h1 { font-size: 1.32rem; }
.lede { margin: 5px 0 0; color: var(--muted); max-width: 66ch; font-size: 0.95rem; }
.lede b { color: var(--ink); font-weight: 600; }
.status { display: inline-flex; align-items: center; gap: 8px; font-family: var(--font-mono);
font-size: 0.72rem; letter-spacing: 0.02em; padding: 6px 10px; border: 1px solid var(--line);
border-radius: 999px; background: var(--surface); color: var(--muted); white-space: nowrap; flex-shrink: 0; }
.status .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex: none; }
.status[data-state="run"] { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
.status[data-state="run"] .dot { background: var(--accent); animation: pulse 1.1s ease-in-out infinite; }
.status[data-state="ok"] { color: var(--run); border-color: var(--run); background: var(--run-soft); }
.status[data-state="ok"] .dot { background: var(--run); }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.25; } }
/* ---------- the strip ---------- */
.stage { position: relative; height: 252px; border: 1px solid var(--line); border-radius: var(--radius);
background: var(--surface); overflow: hidden; }
.stage canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.scroller { position: absolute; inset: 0; overflow-x: auto; overflow-y: hidden; touch-action: pan-x;
-webkit-overflow-scrolling: touch; cursor: grab; scrollbar-width: thin; scrollbar-color: var(--muted) transparent; }
.scroller:active { cursor: grabbing; }
.scroller::-webkit-scrollbar { height: 10px; }
.scroller::-webkit-scrollbar-track { background: transparent; }
.scroller::-webkit-scrollbar-thumb { background: var(--line); border-radius: 999px; border: 2px solid transparent; background-clip: content-box; }
.scroller::-webkit-scrollbar-thumb:hover { background: var(--muted); background-clip: content-box; }
.scroller:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: var(--radius); }
.track { height: 1px; }
/* ---------- minimap ---------- */
.mapwrap { margin-top: 8px; }
.maprow { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; flex-wrap: wrap;
font-family: var(--font-mono); font-size: 0.71rem; color: var(--muted); margin-bottom: 5px; }
.maprow b { color: var(--ink); font-weight: 600; }
.minimap { position: relative; height: 48px; border: 1px solid var(--line); border-radius: 8px;
background: var(--surface); overflow: hidden; cursor: crosshair; }
.minimap canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
/* ---------- controls ---------- */
.jumps { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
.jump { flex: 1 1 auto; min-width: 78px; min-height: 46px; padding: 5px 8px; border: 1px solid var(--line);
background: var(--surface); color: var(--ink); border-radius: 8px; cursor: pointer; font: inherit;
font-size: 0.84rem; display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 1px; transition: border-color .12s, background .12s; }
.jump:hover { border-color: var(--accent); }
.jump em { font-style: normal; font-family: var(--font-mono); font-size: 0.68rem; color: var(--muted); font-variant-numeric: tabular-nums; }
.jump[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); }
.jump[aria-pressed="true"] em { color: var(--accent); }
.jump .chip { width: 100%; height: 3px; border-radius: 2px; margin-top: 3px; }
.cols { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); gap: 12px; margin-top: 12px; align-items: start; }
@media (max-width: 820px) { .cols { grid-template-columns: minmax(0,1fr); } }
.panel { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); padding: 13px 14px 14px; }
.panel h2 { font-size: 0.72rem; font-family: var(--font-mono); letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); margin-bottom: 10px; }
.row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
button.act { font: inherit; font-size: 0.88rem; padding: 9px 14px; min-height: 42px; border-radius: 8px; cursor: pointer;
border: 1px solid var(--line); background: var(--surface); color: var(--ink); transition: border-color .12s, background .12s; }
button.act:hover { border-color: var(--accent); }
button.act.primary { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); font-weight: 600; }
button.act.primary:hover { background: var(--accent); color: var(--surface); }
select { font: inherit; font-family: var(--font-mono); font-size: 0.82rem; padding: 9px 10px; min-height: 42px;
border: 1px solid var(--line); border-radius: 8px; background: var(--surface); color: var(--ink); cursor: pointer; }
.check { display: inline-flex; align-items: center; gap: 7px; font-size: 0.86rem; cursor: pointer; min-height: 42px; padding: 0 4px; }
.check input { width: 16px; height: 16px; accent-color: var(--accent); cursor: pointer; }
.fieldlab { font-family: var(--font-mono); font-size: 0.68rem; letter-spacing: 0.05em;
text-transform: uppercase; color: var(--muted); align-self: center; }
.honest { margin: 10px 0 0; font-size: 0.84rem; color: var(--muted); }
.honest b { color: var(--ink); font-weight: 600; }
.read { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 1px; margin-top: 11px;
background: var(--line); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
.read div { background: var(--surface); padding: 7px 9px; min-width: 0; }
.read dt { font-family: var(--font-mono); font-size: 0.62rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); margin: 0; }
.read dd { margin: 2px 0 0; font-family: var(--font-mono); font-size: 0.9rem; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
.read dd.big { color: var(--accent); }
.read dd.good { color: var(--run); }
/* ---------- fact card ---------- */
.cardhead { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
.cardhead .orb { width: 15px; height: 15px; border-radius: 50%; flex: none; border: 1px solid rgba(128,128,128,.35); }
.cardhead h3 { font-size: 1.06rem; }
.cardhead .kind { font-family: var(--font-mono); font-size: 0.65rem; letter-spacing: 0.06em; text-transform: uppercase;
color: var(--muted); border: 1px solid var(--line); border-radius: 999px; padding: 2px 7px; }
.blurb { margin: 8px 0 0; font-size: 0.88rem; color: var(--muted); }
.facts { list-style: none; padding: 0; margin: 11px 0 0; display: grid;
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); gap: 1px; background: var(--surface);
border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
/* box-shadow instead of a gap-showing backdrop, so a short last row leaves no orphan block */
.facts li { background: var(--surface); padding: 7px 9px; min-width: 0; box-shadow: 0 0 0 1px var(--line); }
.facts .k { display: block; font-family: var(--font-mono); font-size: 0.62rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); }
.facts .v { display: block; margin-top: 2px; font-family: var(--font-mono); font-size: 0.88rem; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
.facts .v.amber { color: var(--accent); }
.keys { margin-top: 12px; font-size: 0.8rem; color: var(--muted); }
.keys kbd { font-family: var(--font-mono); font-size: 0.72rem; border: 1px solid var(--line); border-bottom-width: 2px;
border-radius: 4px; padding: 1px 5px; background: var(--bg); color: var(--ink); }
.foot { margin-top: 18px; padding-top: 11px; border-top: 1px solid var(--line); font-family: var(--font-mono);
font-size: 0.7rem; color: var(--muted); display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; }
@media (max-width: 640px) {
.stage { height: 208px; }
.read { grid-template-columns: repeat(2, minmax(0,1fr)); }
}
@media (max-width: 400px) {
.wrap { padding: 14px 10px 24px; }
.head h1 { font-size: 1.18rem; }
.jump { min-width: 68px; font-size: 0.8rem; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; }
}
</style>
</head>
<body>
<div class="wrap">
<header class="head">
<div>
<h1>Solar System to Scale</h1>
<p class="lede">The sun is drawn <b>60 pixels</b> wide. Every planet and every gap between them uses
that exact same scale — nothing is enlarged to make the picture nicer. Drag the strip right, and keep going.</p>
</div>
<span class="status" id="status" data-state="idle"><span class="dot"></span><span id="statusText">scale locked · 1 px = 23,190 km</span></span>
</header>
<div class="stage">
<canvas id="sky" aria-hidden="true"></canvas>
<div class="scroller" id="scroller" tabindex="0" role="group"
aria-label="Solar system strip, scrollable left and right. Use arrow keys to travel, N and P to jump between worlds, Enter to open the nearest fact card.">
<div class="track" id="track"></div>
</div>
</div>
<div class="mapwrap">
<div class="maprow">
<span>camera · <b id="camAU">0.00 AU</b> from the sun · <span id="camKm">0 km</span></span>
<span id="camPx">0 px travelled of 255,494</span>
</div>
<div class="minimap" id="minimap" title="Click or drag to travel"><canvas id="map" aria-hidden="true"></canvas></div>
</div>
<div class="jumps" id="jumps"></div>
<div class="cols">
<section class="panel">
<h2>Light-speed run</h2>
<div class="row">
<button class="act primary" id="btnLight" type="button">Send a photon</button>
<button class="act" id="btnReset" type="button">Reset</button>
<label class="fieldlab" for="speed">Time compression</label>
<select id="speed">
<option value="1">×1 — real time</option>
<option value="60" selected>×60 — 1 min / sec</option>
<option value="600">×600 — 10 min / sec</option>
<option value="3600">×3600 — 1 hr / sec</option>
</select>
<label class="check"><input type="checkbox" id="follow" checked> Follow the photon</label>
</div>
<p class="honest" id="honest"></p>
<dl class="read">
<div><dt>light-time elapsed</dt><dd id="rLight" class="big">00:00</dd></div>
<div><dt>your seconds spent</dt><dd id="rWall">0.0 s</dd></div>
<div><dt>distance covered</dt><dd id="rDist">0 km</dd></div>
<div><dt>that is</dt><dd id="rAU">0.000 AU</dd></div>
<div><dt>next arrival</dt><dd id="rNext">Mercury</dd></div>
<div><dt>light-time to go</dt><dd id="rEta">3m 13s</dd></div>
</dl>
<p class="sr" id="live" role="status" aria-live="polite"></p>
</section>
<section class="panel" id="card">
<h2>Fact card</h2>
<div class="cardhead">
<span class="orb" id="cOrb"></span>
<h3 id="cName">The Sun</h3>
<span class="kind" id="cKind">star</span>
</div>
<p class="blurb" id="cBlurb"></p>
<ul class="facts" id="cFacts"></ul>
</section>
</div>
<p class="keys">
Drag the strip to pan, or scroll it sideways (<kbd>shift</kbd> + wheel).
Click a world for its card. In the strip:
<kbd>←</kbd> <kbd>→</kbd> travel · <kbd>N</kbd> <kbd>P</kbd> next / previous world ·
<kbd>Enter</kbd> open the nearest card · <kbd>Home</kbd> <kbd>End</kbd> the sun / the edge.
</p>
<footer class="foot">
<span>demo · sloprun.dev</span>
<span id="footNote">1 px = 23,190 km · printed at this scale the strip is 68 m long</span>
</footer>
</div>
<script>
(function () {
"use strict";
var $ = function (id) { return document.getElementById(id); };
/* ------------------------------------------------------------------ *
* One scale, no cheating.
* ------------------------------------------------------------------ */
var SUN_DIAM_KM = 1391400;
var PX_PER_KM = 60 / SUN_DIAM_KM; /* sun = 60 px across */
var KM_PER_PX = 1 / PX_PER_KM; /* 23,190 km per pixel */
var AU_KM = 149597870.7;
var PX_PER_AU = AU_KM * PX_PER_KM; /* 6450.96 px */
var C_KMS = 299792.458;
var C_PX = C_KMS * PX_PER_KM; /* 12.93 px per real second*/
var SUN_X = 110; /* sun centre in world px */
var BODIES = [
{ id:"sun", name:"The Sun", kind:"star", au:0, diam:SUN_DIAM_KM, color:null,
year:"—", rot:"25.4 days", moons:"8 planets", temp:"5,505 °C", grav:"274 m/s²",
blurb:"99.86% of every gram of matter in the solar system is right here. Everything else in this strip is rounding error." },
{ id:"mercury", name:"Mercury", kind:"planet", au:0.387, diam:4879, color:"#8C8378",
year:"88.0 days", rot:"58.6 days", moons:"0", temp:"167 °C", grav:"3.70 m/s²",
blurb:"A cratered ball of iron with almost no air, swinging round the sun four times a year." },
{ id:"venus", name:"Venus", kind:"planet", au:0.723, diam:12104, color:"#C08A2E",
year:"224.7 days", rot:"243 days (backwards)", moons:"0", temp:"464 °C", grav:"8.87 m/s²",
blurb:"Earth's twin by size, and hot enough at the surface to melt lead under 90 atmospheres of carbon dioxide." },
{ id:"earth", name:"Earth", kind:"planet", au:1.000, diam:12756, color:"#3D7BA8",
year:"365.25 days", rot:"23h 56m", moons:"1", temp:"15 °C", grav:"9.81 m/s²",
blurb:"The only object in this picture known to have liquid water on its surface — and the only one anybody has ever read this sentence on." },
{ id:"mars", name:"Mars", kind:"planet", au:1.524, diam:6792, color:"#B4522F",
year:"687 days", rot:"24h 37m", moons:"2", temp:"−65 °C", grav:"3.71 m/s²",
blurb:"Half Earth's width, with the tallest volcano and the thinnest weather report in the neighbourhood." },
{ id:"jupiter", name:"Jupiter", kind:"planet", au:5.203, diam:139820, color:"#A8763E",
year:"11.86 years", rot:"9h 56m", moons:"95", temp:"−110 °C", grav:"24.79 m/s²",
blurb:"Twice the mass of everything else orbiting the sun, combined. It is also the first world here big enough to draw as more than a speck." },
{ id:"saturn", name:"Saturn", kind:"planet", au:9.537, diam:116460, color:"#C2A15A",
year:"29.4 years", rot:"10h 42m", moons:"146", temp:"−140 °C", grav:"10.44 m/s²",
blurb:"Its famous rings are barely 10 metres thick in places — at this scale they would be far thinner than one atom of the screen." },
{ id:"uranus", name:"Uranus", kind:"planet", au:19.191, diam:50724, color:"#5E9E9E",
year:"84.0 years", rot:"17h 14m (on its side)", moons:"28", temp:"−195 °C", grav:"8.87 m/s²",
blurb:"Tipped over 98°, so it rolls round its orbit rather than spinning upright. Each pole gets 42 years of sunlight, then 42 years of night." },
{ id:"neptune", name:"Neptune", kind:"planet", au:30.07, diam:49244, color:"#4A6FB0",
year:"164.8 years", rot:"16h 06m", moons:"16", temp:"−200 °C", grav:"11.15 m/s²",
blurb:"Found with mathematics before anyone pointed a telescope at it. It has completed exactly one orbit since that discovery." },
{ id:"pluto", name:"Pluto", kind:"dwarf planet", au:39.48, diam:2376, color:"#8E7F72",
year:"248 years", rot:"6.4 days", moons:"5", temp:"−225 °C", grav:"0.62 m/s²",
blurb:"Smaller than several moons, with a nitrogen-ice heart. Reclassified in 2006, still the traditional end of the tour." }
];
BODIES.forEach(function (b) { b.km = b.au * AU_KM; b.x = SUN_X + b.au * PX_PER_AU; b.lt = b.km / C_KMS; });
var LAST = BODIES[BODIES.length - 1];
var WORLD_W = Math.round(LAST.x + 700);
var PRINT_M = WORLD_W / 96 * 0.0254; /* CSS px -> inches -> m */
/* ------------------------------------------------------------------ *
* Formatting
* ------------------------------------------------------------------ */
function fi(n) { return Math.round(n).toLocaleString("en-US"); }
function kmShort(km) {
if (km >= 1e9) return (km / 1e9).toFixed(2) + " bn km";
if (km >= 1e6) return (km / 1e6).toFixed(1) + " M km";
return fi(km) + " km";
}
function pad2(n) { return (n < 10 ? "0" : "") + n; }
function clock(sec) {
sec = Math.max(0, Math.floor(sec));
var h = Math.floor(sec / 3600), m = Math.floor(sec % 3600 / 60), s = sec % 60;
return h > 0 ? h + ":" + pad2(m) + ":" + pad2(s) : pad2(m) + ":" + pad2(s);
}
function human(sec) {
sec = Math.max(0, Math.round(sec));
var h = Math.floor(sec / 3600), m = Math.floor(sec % 3600 / 60), s = sec % 60;
if (h > 0) return h + "h " + pad2(m) + "m";
if (m > 0) return m + "m " + pad2(s) + "s";
return s + "s";
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
/* ------------------------------------------------------------------ *
* Elements + palette
* ------------------------------------------------------------------ */
var stage = document.querySelector(".stage");
var sky = $("sky"), ctx = sky.getContext("2d");
var mapEl = $("map"), mctx = mapEl.getContext("2d");
var scroller = $("scroller");
var minimap = $("minimap");
var live = $("live");
var W = 0, H = 0, MW = 0, MH = 0, dpr = 1;
var rmq = window.matchMedia("(prefers-reduced-motion: reduce)");
var reduced = rmq.matches;
var P = {};
function readPalette() {
var cs = getComputedStyle(document.documentElement);
["--surface", "--ink", "--muted", "--line", "--accent", "--run", "--bg"].forEach(function (k) {
P[k.slice(2)] = cs.getPropertyValue(k).trim() || "#888";
});
}
function alpha(hex, a) {
var h = hex.replace("#", "");
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
var n = parseInt(h, 16);
if (isNaN(n)) return hex;
return "rgba(" + ((n >> 16) & 255) + "," + ((n >> 8) & 255) + "," + (n & 255) + "," + a + ")";
}
var FS = '600 12.5px system-ui, -apple-system, "Segoe UI", sans-serif';
var FSS = '11.5px system-ui, -apple-system, "Segoe UI", sans-serif';
var FM = '11px ui-monospace, "SF Mono", Menlo, Consolas, monospace';
var FMB = '600 11px ui-monospace, "SF Mono", Menlo, Consolas, monospace';
/* ------------------------------------------------------------------ *
* State
* ------------------------------------------------------------------ */
var selected = BODIES[0];
var photon = { on: false, done: false, t: 0, wall: 0, last: 0, next: 1, flash: 0, flashId: null };
var dirty = true, lastCam = -1, statusHold = 0;
function cy() { return Math.round((H - 44) * 0.44) + 12; }
function rulerY() { return H - 42; }
function gapY() { var c = cy(); return c + Math.round((rulerY() - c) * 0.52); }
function photonX() { return SUN_X + photon.t * C_PX; }
/* ------------------------------------------------------------------ *
* Sizing
* ------------------------------------------------------------------ */
function sizeCanvas(c, cx2, w, h) {
dpr = Math.min(window.devicePixelRatio || 1, 2);
c.width = Math.max(1, Math.round(w * dpr));
c.height = Math.max(1, Math.round(h * dpr));
cx2.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function layout() {
W = stage.clientWidth; H = stage.clientHeight;
MW = minimap.clientWidth; MH = minimap.clientHeight;
sizeCanvas(sky, ctx, W, H);
sizeCanvas(mapEl, mctx, MW, MH);
$("track").style.width = WORLD_W + "px";
dirty = true;
}
/* deterministic pseudo-random, so the star field never shimmers.
Integer-only (Math.imul) — a float-multiply hash loses precision past ~2^26 and
collapses to a constant, which would empty the star field and the belts out here. */
function hash(n) {
n = n | 0;
n = Math.imul(n ^ (n >>> 15), 0x2c1b3c6d); n ^= n >>> 12;
n = Math.imul(n ^ (n >>> 7), 0x297a2d39); n ^= n >>> 15;
return (n >>> 0) / 4294967296;
}
/* ------------------------------------------------------------------ *
* The strip
* ------------------------------------------------------------------ */
function draw(cam, now) {
var c = cy(), ry = rulerY(), gy = gapY();
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = P.surface; ctx.fillRect(0, 0, W, H);
/* --- distant star field, parallaxed so travel feels real --- */
var sc = cam * 0.32, cell = 44;
var s0 = Math.floor(sc / cell) - 1, s1 = Math.ceil((sc + W) / cell) + 1;
for (var si = s0; si <= s1; si++) {
for (var r = 0; r < 4; r++) {
var h1 = hash(si * 17 + r * 101 + 3), h2 = hash(si * 31 + r * 7 + 11);
if (h1 < 0.52) continue;
var sx = si * cell + h2 * cell - sc;
if (sx < -2 || sx > W + 2) continue;
var sy = 12 + ((r + h1) / 4.6) * (ry - 20);
ctx.fillStyle = alpha(P.muted, 0.18 + h2 * 0.30);
ctx.fillRect(sx, sy, 1.5, 1.5);
}
}
/* --- belts of small bodies --- */
drawBelt(cam, 2.06, 3.28, "the main asteroid belt", c, ry);
drawBelt(cam, 30.0, LAST.au + 0.9, "the Kuiper belt begins", c, ry);
/* --- the orbital plane --- */
ctx.strokeStyle = alpha(P.line, 1); ctx.lineWidth = 1;
ctx.setLineDash([2, 6]);
ctx.beginPath(); ctx.moveTo(0, c + 0.5); ctx.lineTo(W, c + 0.5); ctx.stroke();
ctx.setLineDash([]);
drawRuler(cam, ry);
drawGaps(cam, gy);
drawBodies(cam, c, now);
drawPhoton(cam, c, ry, now);
drawHint(cam, c, now);
}
function drawBelt(cam, a0, a1, label, c, ry) {
var x0 = SUN_X + a0 * PX_PER_AU, x1 = SUN_X + a1 * PX_PER_AU;
var lo = Math.max(x0 - cam, 0), hi = Math.min(x1 - cam, W);
if (hi <= lo) return;
var step = 9;
var i0 = Math.floor((cam + lo) / step), i1 = Math.ceil((cam + hi) / step);
for (var i = i0; i <= i1; i++) {
var hx = hash(i * 7919 + 5), hy = hash(i * 104729 + 13);
if (hx < 0.45) continue;
var px = i * step + hx * step - cam;
if (px < lo || px > hi) continue;
ctx.fillStyle = alpha(P.muted, 0.20 + hy * 0.35);
ctx.fillRect(px, c + (hy - 0.5) * 66, 1.5, 1.5);
}
if (hi - lo > 150) {
ctx.font = FM; ctx.textAlign = "center"; ctx.textBaseline = "middle";
ctx.fillStyle = alpha(P.muted, 0.85);
ctx.fillText(label, clamp((lo + hi) / 2, 84, W - 84), ry - 13);
}
}
function drawRuler(cam, ry) {
ctx.strokeStyle = P.line; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, ry + 0.5); ctx.lineTo(W, ry + 0.5); ctx.stroke();
var stepAU = 0.05, stepPx = stepAU * PX_PER_AU;
var i0 = Math.max(0, Math.floor((cam - SUN_X) / stepPx));
var i1 = Math.min(Math.ceil((LAST.au + 0.9) / stepAU), Math.ceil((cam + W - SUN_X) / stepPx));
ctx.textBaseline = "top";
for (var i = i0; i <= i1; i++) {
var au = i * stepAU, x = Math.round(SUN_X + au * PX_PER_AU - cam) + 0.5;
if (x < -80 || x > W + 80) continue;
var whole = Math.abs(au - Math.round(au)) < 1e-9;
ctx.strokeStyle = whole ? alpha(P.accent, 0.75) : P.line;
ctx.beginPath(); ctx.moveTo(x, ry); ctx.lineTo(x, ry + (whole ? 11 : 6)); ctx.stroke();
ctx.textAlign = "left";
if (whole) {
ctx.font = FMB; ctx.fillStyle = P.accent;
ctx.fillText(au.toFixed(0) + " AU", x + 5, ry + 3);
ctx.font = FM; ctx.fillStyle = alpha(P.muted, 0.9);
ctx.fillText(fi(au * AU_KM) + " km", x + 5, ry + 17);
} else {
ctx.font = FM; ctx.fillStyle = alpha(P.muted, 0.75);
ctx.fillText(au.toFixed(2), x + 4, ry + 6);
}
}
}
/* the point of the whole thing: the emptiness gets a label */
function drawGaps(cam, gy) {
for (var i = 0; i < BODIES.length - 1; i++) {
var a = BODIES[i], b = BODIES[i + 1];
var x0 = a.x - cam + (a.id === "sun" ? 100 : 26), x1 = b.x - cam - 26;
if (x1 < 4 || x0 > W - 4) continue;
var lo = Math.max(x0, 6), hi = Math.min(x1, W - 6);
if (hi - lo < 40) continue;
ctx.strokeStyle = alpha(P.muted, 0.35); ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(lo, gy + 0.5); ctx.lineTo(hi, gy + 0.5); ctx.stroke();
if (x0 > 6) { ctx.beginPath(); ctx.moveTo(x0 + 0.5, gy - 5); ctx.lineTo(x0 + 0.5, gy + 5); ctx.stroke(); }
if (x1 < W - 6) { ctx.beginPath(); ctx.moveTo(x1 + 0.5, gy - 5); ctx.lineTo(x1 + 0.5, gy + 5); ctx.stroke(); }
var gapAU = b.au - a.au, gapPx = b.x - a.x;
var l1 = a.name + " → " + b.name;
var l2 = (W < 520 ? "" : kmShort(gapAU * AU_KM) + " · ") +
gapAU.toFixed(2) + " AU · " + fi(gapPx) + " px of nothing";
/* measure each line in the font it is drawn in, and never print a label wider
than the emptiness it labels — otherwise it collides with the planet captions */
ctx.font = FSS; var w1 = ctx.measureText(l1).width;
ctx.font = FM; var w2 = ctx.measureText(l2).width;
var wBox = Math.max(w1, w2) + 18, half = wBox / 2;
if (wBox + 16 > W || hi - lo < wBox + 20) continue;
var mx = clamp((lo + hi) / 2, Math.max(lo + half, half + 8), Math.min(hi - half, W - half - 8));
ctx.fillStyle = P.surface;
ctx.fillRect(mx - half, gy - 15, wBox, 30);
ctx.textAlign = "center"; ctx.textBaseline = "middle";
ctx.font = FSS; ctx.fillStyle = alpha(P.ink, 0.72);
ctx.fillText(l1, mx, gy - 7);
ctx.font = FM; ctx.fillStyle = alpha(P.muted, 0.95);
ctx.fillText(l2, mx, gy + 8);
}
}
function drawBodies(cam, c, now) {
for (var i = 0; i < BODIES.length; i++) {
var b = BODIES[i], x = b.x - cam;
if (x < -70 || x > W + 70) continue;
var isSel = b === selected;
var flashing = photon.flashId === b.id && now < photon.flash;
if (b.id === "sun") {
var g = ctx.createRadialGradient(x, c, 24, x, c, 108);
g.addColorStop(0, alpha(P.accent, 0.34));
g.addColorStop(1, alpha(P.accent, 0));
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(x, c, 108, 0, 6.2832); ctx.fill();
ctx.fillStyle = P.accent;
ctx.beginPath(); ctx.arc(x, c, 30, 0, 6.2832); ctx.fill();
if (isSel) {
ctx.strokeStyle = P.accent; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(x, c, 38, 0, 6.2832); ctx.stroke();
}
ctx.textAlign = "center"; ctx.textBaseline = "middle";
ctx.font = FS; ctx.fillStyle = P.ink;
ctx.fillText("The Sun", x, c + 48);
ctx.font = FM; ctx.fillStyle = P.muted;
ctx.fillText("60 px · 1,391,400 km", x, c + 64);
continue;
}
var trueR = b.diam * PX_PER_KM / 2;
var ringR = 15;
/* finder ring — a marker, never mistaken for the planet itself */
ctx.strokeStyle = flashing ? P.run : (isSel ? P.accent : alpha(P.muted, 0.5));
ctx.lineWidth = (isSel || flashing) ? 1.6 : 1;
ctx.beginPath(); ctx.arc(x, c, ringR, 0, 6.2832); ctx.stroke();
if (isSel || flashing) {
ctx.strokeStyle = alpha(flashing ? P.run : P.accent, 0.3);
ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(x, c, ringR + 6, 0, 6.2832); ctx.stroke();
}
/* the planet, at its honest size */
ctx.fillStyle = b.color;
ctx.beginPath(); ctx.arc(x, c, Math.max(trueR, 0.45), 0, 6.2832); ctx.fill();
if (trueR > 3) { ctx.strokeStyle = alpha(P.ink, 0.25); ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(x, c, trueR, 0, 6.2832); ctx.stroke(); }
ctx.textAlign = "center"; ctx.textBaseline = "middle";
ctx.font = FS; ctx.fillStyle = isSel ? P.accent : P.ink;
ctx.fillText(b.name, x, c - 34);
ctx.font = FM; ctx.fillStyle = P.muted;
ctx.fillText(b.au.toFixed(3) + " AU", x, c - 20);
ctx.fillText((trueR * 2).toFixed(2) + " px wide", x, c + 30);
}
}
function drawPhoton(cam, c, ry, now) {
if (!photon.on && photon.t <= 0) return;
var x = photonX() - cam;
if (x < -40 || x > W + 40) return;
if (!reduced) {
var g = ctx.createLinearGradient(x - 70, 0, x, 0);
g.addColorStop(0, alpha(P.accent, 0));
g.addColorStop(1, alpha(P.accent, 0.55));
ctx.strokeStyle = g; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(x - 70, c); ctx.lineTo(x, c); ctx.stroke();
}
ctx.strokeStyle = alpha(P.accent, 0.45); ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(x + 0.5, c - 40); ctx.lineTo(x + 0.5, ry); ctx.stroke();
ctx.fillStyle = P.accent;
ctx.beginPath(); ctx.arc(x, c, 3.5, 0, 6.2832); ctx.fill();
ctx.font = FMB; ctx.textAlign = "center"; ctx.textBaseline = "bottom";
ctx.fillStyle = P.accent;
ctx.fillText("photon · " + clock(photon.t), x, c - 44);
}
function drawHint(cam, c, now) {
if (cam > 240 || photon.on) return;
var bob = reduced ? 0 : Math.sin(now / 420) * 4;
var x = W - 20, fade = clamp(1 - cam / 240, 0.15, 1);
ctx.save();
ctx.globalAlpha = fade;
ctx.textAlign = "right"; ctx.textBaseline = "middle";
ctx.font = FS; ctx.fillStyle = P.accent;
ctx.fillText("keep scrolling →", x + bob, c - 74);
ctx.font = FM; ctx.fillStyle = P.muted;
ctx.fillText("Mercury is " + fi(BODIES[1].x - SUN_X) + " px away", x, c - 58);
ctx.restore();
}
/* ------------------------------------------------------------------ *
* Minimap — linear, so the emptiness shows there too
* ------------------------------------------------------------------ */
function mapX(px) { return 10 + (px / WORLD_W) * (MW - 20); }
function drawMap(cam) {
mctx.clearRect(0, 0, MW, MH);
mctx.fillStyle = P.surface; mctx.fillRect(0, 0, MW, MH);
var top = 3, bot = MH - 13;
var by = Math.round(bot * 0.72) + 0.5;
mctx.strokeStyle = P.line; mctx.lineWidth = 1;
mctx.beginPath(); mctx.moveTo(10, by); mctx.lineTo(MW - 10, by); mctx.stroke();
var x0 = mapX(cam), x1 = mapX(cam + W);
mctx.fillStyle = alpha(P.accent, 0.18);
mctx.fillRect(x0, top, Math.max(2.5, x1 - x0), bot - top);
mctx.strokeStyle = P.accent; mctx.lineWidth = 1;
mctx.strokeRect(Math.round(x0) + 0.5, top + 0.5, Math.max(2, Math.round(x1 - x0)), bot - top - 1);
for (var i = 0; i < BODIES.length; i++) {
var b = BODIES[i], x = mapX(b.x);
mctx.strokeStyle = alpha(P.muted, 0.45);
mctx.beginPath(); mctx.moveTo(x + 0.5, by - 6); mctx.lineTo(x + 0.5, by); mctx.stroke();
mctx.fillStyle = b.color || P.accent;
mctx.beginPath(); mctx.arc(x, by - 9, b === selected ? 3.6 : 2.4, 0, 6.2832); mctx.fill();
}
if (photon.t > 0) {
var px = mapX(Math.min(photonX(), WORLD_W));
mctx.strokeStyle = P.accent; mctx.lineWidth = 1.5;
mctx.beginPath(); mctx.moveTo(px + 0.5, top); mctx.lineTo(px + 0.5, bot); mctx.stroke();
}
mctx.font = '9.5px ui-monospace, Menlo, monospace';
mctx.fillStyle = P.muted; mctx.textBaseline = "bottom";
mctx.textAlign = "left"; mctx.fillText("0 AU", 10, MH - 2);
mctx.textAlign = "right"; mctx.fillText(LAST.au.toFixed(1) + " AU", MW - 10, MH - 2);
}
/* ------------------------------------------------------------------ *
* Readouts
* ------------------------------------------------------------------ */
function camReadout(cam) {
var au = Math.max(0, (cam + W / 2 - SUN_X) / PX_PER_AU);
$("camAU").textContent = au.toFixed(2) + " AU";
$("camKm").textContent = fi(au * AU_KM) + " km";
$("camPx").textContent = fi(cam) + " px travelled of " + fi(WORLD_W);
}
function secs(s) { return s < 90 ? s.toFixed(1) + " s" : human(s); }
function honestLine() {
var f = +$("speed").value, e = BODIES[3], n = BODIES[8];
var t = f === 1
? "×1 — no compression at all. Light takes " + human(e.lt) + " to reach Earth and you will sit through every second of it."
: "×" + fi(f) + " — one of your seconds is " + human(f) + " of real light travel. Light takes " +
human(e.lt) + " to reach Earth; here that is " + secs(e.lt / f) + ".";
$("honest").innerHTML = t + " Neptune: " + human(n.lt) + " real, <b>" + secs(n.lt / f) + "</b> at this setting.";
}
function readouts() {
$("rLight").textContent = clock(photon.t);
$("rWall").textContent = photon.wall.toFixed(1) + " s";
$("rDist").textContent = fi(photon.t * C_KMS) + " km";
$("rAU").textContent = (photon.t * C_KMS / AU_KM).toFixed(3) + " AU";
var nx = BODIES[photon.next];
if (nx) {
$("rNext").textContent = nx.name;
$("rEta").textContent = human(Math.max(0, nx.lt - photon.t));
$("rEta").className = "";
} else {
$("rNext").textContent = "— none left";
$("rEta").textContent = "past " + LAST.name;
$("rEta").className = "good";
}
}
function setStatus(state, text) {
$("status").setAttribute("data-state", state);
$("statusText").textContent = text;
}
/* ------------------------------------------------------------------ *
* Fact card
* ------------------------------------------------------------------ */
var note = document.createElement("p");
note.className = "blurb";
note.style.fontSize = "0.78rem";
note.textContent = "The thin ring on the strip is a 30 px finder marker, not the planet — the planet is the speck inside it, and " +
"anything under one pixel wide is drawn as the smallest dot a screen can make. Distances are orbital averages, and the " +
"worlds are lined up in a row here, which never actually happens.";
$("card").appendChild(note);
/* solar irradiance falls off with the square of the distance */
function solar(au) {
var w = 1361 / (au * au);
return (w < 10 ? w.toFixed(2) : fi(w)) + " W/m²";
}
function renderCard(b) {
$("cOrb").style.background = b.color || "var(--accent)";
$("cName").textContent = b.name;
$("cKind").textContent = b.kind;
$("cBlurb").textContent = b.blurb;
var dpx = b.diam * PX_PER_KM;
var drawn = dpx.toFixed(2) + " px" + (dpx < 1 ? " · sub-pixel" : "");
var rows = b.id === "sun"
? [["diameter", fi(b.diam) + " km"], ["drawn here", drawn, 1],
["surface temp", b.temp], ["rotation", b.rot],
["surface gravity", b.grav], ["share of system mass", "99.86 %"],
["light to Earth", human(BODIES[3].lt)], ["position on strip", "0 px"]]
: [["distance from sun", b.au.toFixed(3) + " AU", 1], ["which is", fi(b.km) + " km"],
["light-time from sun", human(b.lt), 1], ["diameter", fi(b.diam) + " km"],
["drawn here", drawn, 1], ["position on strip", fi(b.x - SUN_X) + " px"],
["orbital period", b.year], ["rotation", b.rot],
["confirmed moons", b.moons], ["mean temperature", b.temp],
["surface gravity", b.grav], ["sunlight", solar(b.au)]];
var html = "";
for (var i = 0; i < rows.length; i++) {
html += '<li><span class="k">' + rows[i][0] + '</span><span class="v' +
(rows[i][2] ? " amber" : "") + '">' + rows[i][1] + "</span></li>";
}
$("cFacts").innerHTML = html;
}
function select(b) {
selected = b;
cursorIdx = BODIES.indexOf(b);
renderCard(b);
var btns = $("jumps").children;
for (var i = 0; i < btns.length; i++) btns[i].setAttribute("aria-pressed", BODIES[i] === b ? "true" : "false");
live.textContent = b.id === "sun"
? "The Sun — 1,391,400 km across, drawn 60 px wide."
: b.name + " — " + b.au.toFixed(3) + " AU from the sun, " + human(b.lt) +
" of light-time, drawn " + (b.diam * PX_PER_KM).toFixed(2) + " px wide.";
dirty = true;
}
function scrollToBody(b) {
var left = clamp(b.x - W / 2, 0, Math.max(0, WORLD_W - W));
if (b.id === "sun") left = 0;
if (reduced || !scroller.scrollTo) { scroller.scrollLeft = left; }
else { try { scroller.scrollTo({ left: left, behavior: "smooth" }); } catch (e) { scroller.scrollLeft = left; } }
}
/* ------------------------------------------------------------------ *
* Jump buttons — scrolling is the joke, these are the mercy
* ------------------------------------------------------------------ */
(function buildJumps() {
var frag = document.createDocumentFragment();
BODIES.forEach(function (b) {
var el = document.createElement("button");
el.type = "button"; el.className = "jump";
el.setAttribute("aria-pressed", "false");
el.innerHTML = "<span>" + b.name.replace("The ", "") + "</span><em>" +
(b.id === "sun" ? "0 AU" : b.au.toFixed(2) + " AU") + "</em>";
var chip = document.createElement("span");
chip.className = "chip";
chip.style.background = b.color || "var(--accent)";
el.appendChild(chip);
el.addEventListener("click", function () { select(b); markHop(b); scrollToBody(b); });
frag.appendChild(el);
});
$("jumps").appendChild(frag);
})();
/* ------------------------------------------------------------------ *
* Light-speed run
* ------------------------------------------------------------------ */
function resetPhoton() {
photon.on = false; photon.done = false; photon.t = 0; photon.wall = 0;
photon.next = 1; photon.flashId = null; photon.flash = 0;
$("btnLight").textContent = "Send a photon";
setStatus("idle", "scale locked · 1 px = " + fi(KM_PER_PX) + " km");
statusHold = 0; live.textContent = "";
readouts(); dirty = true;
}
function startPhoton() {
if (photon.done) resetPhoton();
photon.on = true; photon.last = performance.now();
$("btnLight").textContent = "Pause";
setStatus("run", "photon in flight · ×" + fi(+$("speed").value));
statusHold = 0; dirty = true;
}
function pausePhoton() {
photon.on = false;
$("btnLight").textContent = "Resume";
setStatus("idle", "paused at " + clock(photon.t) + " of light-time");
statusHold = 0; dirty = true;
}
function arrive(b, now) {
photon.flashId = b.id; photon.flash = now + 1500;
select(b);
live.textContent = "Light reached " + b.name + " after " + human(b.lt) + ".";
setStatus("ok", "reached " + b.name + " · " + human(b.lt));
statusHold = now + 2400;
if (b === LAST) {
photon.on = false; photon.done = true;
photon.t = b.lt;
$("btnLight").textContent = "Replay";
setStatus("ok", "crossed the whole strip · " + human(b.lt) + " of light-time");
statusHold = 0;
}
}
$("btnLight").addEventListener("click", function () {
if (photon.on) pausePhoton(); else startPhoton();
});
$("btnReset").addEventListener("click", resetPhoton);
$("speed").addEventListener("change", function () {
honestLine();
if (photon.on) setStatus("run", "photon in flight · ×" + fi(+$("speed").value));
});
$("follow").addEventListener("change", function () { dirty = true; });
function dropFollow() {
if (photon.on && $("follow").checked) {
$("follow").checked = false;
live.textContent = "Following turned off — you took the wheel.";
}
}
/* ------------------------------------------------------------------ *
* Pointer: drag to pan (mouse), tap/click to open a card
* ------------------------------------------------------------------ */
var pan = { on: false, x: 0, moved: 0 }, suppressClick = false;
scroller.addEventListener("pointerdown", function (e) {
dropFollow();
if (e.pointerType !== "mouse" || e.button !== 0) return;
pan.on = true; pan.x = e.clientX; pan.moved = 0;
try { scroller.setPointerCapture(e.pointerId); } catch (err) {}
});
scroller.addEventListener("pointermove", function (e) {
if (!pan.on) return;
var dx = e.clientX - pan.x; pan.x = e.clientX;
pan.moved += Math.abs(dx);
scroller.scrollLeft -= dx;
e.preventDefault();
});
function endPan(e) {
if (!pan.on) return;
pan.on = false; suppressClick = pan.moved > 6;
try { scroller.releasePointerCapture(e.pointerId); } catch (err) {}
}
scroller.addEventListener("pointerup", endPan);
scroller.addEventListener("pointercancel", endPan);
scroller.addEventListener("click", function (e) {
if (suppressClick) { suppressClick = false; return; }
var rect = scroller.getBoundingClientRect();
var wx = e.clientX - rect.left + scroller.scrollLeft;
var wy = e.clientY - rect.top;
if (Math.abs(wy - cy()) > 62) return;
var best = null, bd = 1e9;
for (var i = 0; i < BODIES.length; i++) {
var d = Math.abs(BODIES[i].x - wx);
if (d < bd) { bd = d; best = BODIES[i]; }
}
if (best && bd <= (best.id === "sun" ? 42 : 26)) { select(best); markHop(best); }
});
/* A plain vertical wheel is left to the page. Swallowing it would pin the reader
to a 255,494 px strip with no way to scroll past the demo. Sideways wheel /
trackpad and shift+wheel pan the strip; so do drag, arrows, minimap, jumps. */
scroller.addEventListener("wheel", function (e) {
if (e.ctrlKey) return;
var d = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : (e.shiftKey ? e.deltaY : 0);
if (!d) return;
if (e.deltaMode === 1) d *= 16; else if (e.deltaMode === 2) d *= W;
var max = Math.max(0, WORLD_W - W), before = scroller.scrollLeft;
var next = clamp(before + d, 0, max);
if (next !== before) { scroller.scrollLeft = next; dropFollow(); e.preventDefault(); }
}, { passive: false });
/* ------------------------------------------------------------------ *
* Keyboard
* ------------------------------------------------------------------ */
function nearestBody() {
var c = scroller.scrollLeft + W / 2, best = BODIES[0], bd = 1e9;
for (var i = 0; i < BODIES.length; i++) {
var d = Math.abs(BODIES[i].x - c);
if (d < bd) { bd = d; best = BODIES[i]; }
}
return best;
}
/* hop from the last world you landed on; resync to the view once you settle */
var cursorIdx = 0, lastHop = -1e9, lastMove = -1e9;
function cursor() {
var t = performance.now();
if (t - lastHop > 900 && t - lastMove > 260) cursorIdx = BODIES.indexOf(nearestBody());
return cursorIdx;
}
function markHop(b) { cursorIdx = BODIES.indexOf(b); lastHop = performance.now(); }
scroller.addEventListener("keydown", function (e) {
var max = Math.max(0, WORLD_W - W), k = e.key, step = e.shiftKey ? W * 0.9 : 140;
var idx, hop = null;
if (k === "ArrowRight") { scroller.scrollLeft = clamp(scroller.scrollLeft + step, 0, max); }
else if (k === "ArrowLeft") { scroller.scrollLeft = clamp(scroller.scrollLeft - step, 0, max); }
else if (k === "PageDown") { scroller.scrollLeft = clamp(scroller.scrollLeft + W * 0.9, 0, max); }
else if (k === "PageUp") { scroller.scrollLeft = clamp(scroller.scrollLeft - W * 0.9, 0, max); }
else if (k === "Home") { scroller.scrollLeft = 0; select(BODIES[0]); }
else if (k === "End") { scroller.scrollLeft = max; select(LAST); }
else if (k === "n" || k === "N") { idx = cursor(); hop = BODIES[Math.min(BODIES.length - 1, idx + 1)]; }
else if (k === "p" || k === "P") { idx = cursor(); hop = BODIES[Math.max(0, idx - 1)]; }
else if (k === "Enter" || k === " ") { select(nearestBody()); }
else return;
if (hop) { select(hop); markHop(hop); scrollToBody(hop); }
dropFollow();
e.preventDefault();
});
/* ------------------------------------------------------------------ *
* Minimap scrubbing
* ------------------------------------------------------------------ */
var scrub = false;
function scrubTo(clientX) {
var r = minimap.getBoundingClientRect();
var frac = clamp((clientX - r.left - 10) / Math.max(1, r.width - 20), 0, 1);
scroller.scrollLeft = clamp(frac * WORLD_W - W / 2, 0, Math.max(0, WORLD_W - W));
dropFollow();
}
minimap.addEventListener("pointerdown", function (e) {
scrub = true; scrubTo(e.clientX);
try { minimap.setPointerCapture(e.pointerId); } catch (err) {}
e.preventDefault();
});
minimap.addEventListener("pointermove", function (e) { if (scrub) scrubTo(e.clientX); });
minimap.addEventListener("pointerup", function () { scrub = false; });
minimap.addEventListener("pointercancel", function () { scrub = false; });
/* ------------------------------------------------------------------ *
* Frame loop
* ------------------------------------------------------------------ */
function frame(now) {
if (photon.on) {
var dt = Math.max(0, Math.min(0.08, (now - photon.last) / 1000));
photon.last = now;
photon.t += dt * (+$("speed").value);
photon.wall += dt;
while (photon.next < BODIES.length && photon.t >= BODIES[photon.next].lt) {
arrive(BODIES[photon.next], now);
photon.next++;
}
if ($("follow").checked) {
scroller.scrollLeft = clamp(photonX() - W * 0.38, 0, Math.max(0, WORLD_W - W));
}
readouts();
}
if (statusHold && now > statusHold) {
statusHold = 0;
if (photon.on) setStatus("run", "photon in flight · ×" + fi(+$("speed").value));
else if (!photon.done) setStatus("idle", "scale locked · 1 px = " + fi(KM_PER_PX) + " km");
}
var cam = scroller.scrollLeft;
if (cam !== lastCam) lastMove = now;
if (dirty || cam !== lastCam || photon.on || (photon.flash && now < photon.flash + 40)) {
lastCam = cam; dirty = false;
draw(cam, now);
drawMap(cam);
camReadout(cam);
}
requestAnimationFrame(frame);
}
/* ------------------------------------------------------------------ *
* Theme, resize, boot
* ------------------------------------------------------------------ */
window.addEventListener("message", function (e) {
var d = e.data;
if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
document.documentElement.setAttribute("data-theme", d.theme);
readPalette(); dirty = true;
}
});
var cmq = window.matchMedia("(prefers-color-scheme: dark)");
function onScheme() { readPalette(); dirty = true; }
if (cmq.addEventListener) cmq.addEventListener("change", onScheme); else if (cmq.addListener) cmq.addListener(onScheme);
function onMotion() { reduced = rmq.matches; dirty = true; }
if (rmq.addEventListener) rmq.addEventListener("change", onMotion); else if (rmq.addListener) rmq.addListener(onMotion);
window.addEventListener("resize", layout);
if (window.ResizeObserver) { new ResizeObserver(layout).observe(stage); }
readPalette();
layout();
if (reduced) $("speed").value = "600";
honestLine();
resetPhoton();
select(BODIES[0]);
live.textContent = "";
$("footNote").textContent = "1 px = " + fi(KM_PER_PX) + " km · printed at this scale the strip is " + PRINT_M.toFixed(0) + " m long";
scroller.scrollLeft = 0;
requestAnimationFrame(frame);
})();
</script>
</body>
</html>
source-visible by construction · nothing is published here without its code
Comments
0 totalNo comments yet. If you ran it, say what happened.