0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
Three everyday things — a feather, a tennis ball and a brick — are released from the same height at the same instant, and the demo just lets them fall. Air pushes back harder the faster something goes, so each object climbs toward its own ceiling speed and then stops accelerating entirely: the curves bend over and flatten, and the terminal speeds sit right there in the readout. The feather tops out near 0.9 m/s, the brick near 44 m/s, and from fifty metres that turns a shared start into a fifty-second gap.
Try this first: hit Vacuum. With no air to push back there is no ceiling and nothing to separate them, so all three land together to the hundredth of a second — the classic lesson, run rather than asserted. Then switch the air back on and press Slow-mo the landings to watch each touchdown up close with its impact speed, swap any lane for an open umbrella or a bowling ball, and flip the chart to a log speed axis to see the feather's own flattening curve that the linear view pins flat against the floor.
Source — the code you see is the code that just ran 1177 lines 57.0 KB index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<title>Falling Objects Race — sloprun.dev</title>
<link rel="icon" href="data:,">
<style>
/* ── sloprun design tokens (verbatim from demos/tokens.css) ───────────────── */
/* 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); }
/* ── lane series colours (theme-matched, never green) ─────────────────────── */
:root { --s1: #B0472B; --s2: #1F6F8B; --s3: #6E4AA6; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { --s1: #FF9E6B; --s2: #5CC8E8; --s3: #C0A0F5; }
}
:root[data-theme="dark"] { --s1: #FF9E6B; --s2: #5CC8E8; --s3: #C0A0F5; }
:root[data-theme="dark"] { color-scheme: dark; }
:root[data-theme="light"] { color-scheme: light; }
/* ── 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: 1140px; margin: 0 auto; padding: 18px 14px 26px; }
h1, h2 { margin: 0; font-weight: 600; letter-spacing: -0.01em; }
.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 5px; }
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
/* ── header ───────────────────────────────────────────────────────────────── */
.head {
display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: flex-start;
justify-content: space-between; padding-bottom: 13px; margin-bottom: 14px;
border-bottom: 1px solid var(--line);
}
.head h1 { font-size: 1.3rem; }
.lede { margin: 5px 0 0; color: var(--muted); max-width: 66ch; font-size: 0.95rem; }
.status {
display: inline-flex; align-items: center; gap: 8px; flex: none;
font-family: var(--font-mono); font-size: 0.72rem; letter-spacing: 0.05em;
padding: 6px 11px; border: 1px solid var(--line); border-radius: 999px;
background: var(--surface); color: var(--muted); white-space: nowrap;
}
.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.15s ease-in-out infinite; }
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: 0.2 } }
/* ── layout ───────────────────────────────────────────────────────────────── */
.grid {
display: grid; gap: 14px; align-items: stretch;
grid-template-columns: minmax(0, 1fr) 330px;
grid-template-areas: "tower readout" "tower lanes" "chart controls" "chart .";
grid-template-rows: auto auto auto 1fr;
}
@media (max-width: 940px) {
.grid { grid-template-columns: minmax(0, 1fr); grid-template-rows: none;
grid-template-areas: "readout" "tower" "chart" "lanes" "controls"; }
}
/* the tower spans two rows next to two stacked panels — let it fill that
height instead of leaving a hole under it */
.a-tower { grid-area: tower; display: flex; flex-direction: column }
.a-chart { grid-area: chart; display: flex; flex-direction: column }
.a-readout { grid-area: readout } .a-lanes { grid-area: lanes } .a-controls { grid-area: controls }
.a-readout, .a-lanes, .a-controls { align-self: start }
.panel {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 13px; min-width: 0;
}
.panel > h2 {
font-family: var(--font-mono); font-size: 0.68rem; text-transform: uppercase;
letter-spacing: 0.1em; color: var(--muted); font-weight: 600; margin-bottom: 10px;
}
.panel > h2 .side { float: right; text-transform: none; letter-spacing: 0.04em; }
/* ── pickers over the lanes ───────────────────────────────────────────────── */
.picks { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 7px; margin-bottom: 10px; }
.pick { min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.pick .tag {
font-family: var(--font-mono); font-size: 0.62rem; letter-spacing: 0.09em;
text-transform: uppercase; color: var(--muted); display: flex; align-items: center; gap: 5px;
}
.pick .tag i { width: 8px; height: 8px; border-radius: 2px; flex: none; }
select {
font: inherit; font-size: 0.8rem; width: 100%; min-width: 0;
padding: 6px 7px; border-radius: 8px; border: 1px solid var(--line);
background: var(--surface); color: var(--ink); cursor: pointer;
}
select:hover { border-color: var(--accent); }
/* ── canvases ─────────────────────────────────────────────────────────────── */
.tower { position: relative; width: 100%; flex: 1 1 auto;
min-height: clamp(330px, 62vh, 540px); }
.chartbox { position: relative; width: 100%; flex: 1 1 auto;
min-height: clamp(190px, 27vh, 260px); }
.tower canvas, .chartbox canvas {
position: absolute; inset: 0; width: 100%; height: 100%; display: block;
border-radius: 8px; border: 1px solid var(--line); background: var(--surface);
}
.caption {
margin: 9px 0 0; font-family: var(--font-mono); font-size: 0.69rem;
color: var(--muted); line-height: 1.6; letter-spacing: 0.01em;
}
.caption b { color: var(--ink); font-weight: 600; }
/* ── readout ──────────────────────────────────────────────────────────────── */
.clockrow { display: flex; align-items: flex-end; justify-content: space-between; gap: 10px; }
.big {
font-family: var(--font-mono); font-variant-numeric: tabular-nums;
font-size: clamp(2.3rem, 8.5vw, 3.1rem); font-weight: 600; line-height: 1.02;
letter-spacing: -0.035em;
}
.big u { text-decoration: none; font-size: 0.4em; color: var(--muted); margin-left: 4px; }
.big small { display: block; font-size: 0.7rem; font-weight: 500; letter-spacing: 0.1em;
text-transform: uppercase; color: var(--muted); margin-top: 7px; }
.warp {
font-family: var(--font-mono); font-size: 0.72rem; color: var(--muted);
border: 1px solid var(--line); border-radius: 999px; padding: 3px 9px; white-space: nowrap;
background: var(--bg); flex: none; margin-bottom: 4px;
}
.warp[data-hot="1"] { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
.fit {
display: flex; align-items: flex-start; gap: 9px; margin-top: 12px; padding: 9px 10px;
border: 1px solid var(--line); border-radius: 8px; background: var(--bg);
font-size: 0.79rem; color: var(--muted); line-height: 1.4;
}
.fit .v { font-family: var(--font-mono); font-variant-numeric: tabular-nums;
font-weight: 600; font-size: 0.86rem; color: var(--ink); flex: none; }
.fit[data-ok="1"] { border-color: var(--run); background: var(--run-soft); color: var(--run); }
.fit[data-ok="1"] .v { color: var(--run); }
/* ── lane cards ───────────────────────────────────────────────────────────── */
.lane { padding: 9px 0; border-top: 1px solid var(--line); }
.lane:first-child { border-top: 0; padding-top: 2px; }
.lrow { display: flex; align-items: baseline; gap: 8px; }
.lrow i { width: 9px; height: 9px; border-radius: 2px; flex: none; align-self: center; }
.lrow b { font-weight: 600; font-size: 0.87rem; flex: 1 1 auto; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lrow .now { font-family: var(--font-mono); font-variant-numeric: tabular-nums;
font-size: 0.92rem; font-weight: 600; white-space: nowrap; }
.bar { height: 5px; border-radius: 3px; background: var(--line); margin: 6px 0 6px; overflow: hidden; }
.bar > i { display: block; height: 100%; width: 0; border-radius: 3px; transition: width 90ms linear; }
.meta { font-family: var(--font-mono); font-size: 0.68rem; color: var(--muted); line-height: 1.65;
letter-spacing: 0.01em; word-break: break-word; }
.meta b { color: var(--ink); font-weight: 600; }
.meta .win { color: var(--accent); font-weight: 600; }
/* ── controls ─────────────────────────────────────────────────────────────── */
.ctl { display: grid; gap: 13px; }
.field label, .flab { display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
font-size: 0.8rem; color: var(--muted); margin-bottom: 5px; }
.field label .n, .flab .n { font-family: var(--font-mono); font-variant-numeric: tabular-nums;
color: var(--ink); font-size: 0.85rem; font-weight: 600; }
input[type=range] {
-webkit-appearance: none; appearance: none; width: 100%; height: 22px;
background: none; margin: 0; display: block; cursor: pointer;
}
input[type=range]::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: var(--line); }
input[type=range]::-moz-range-track { height: 4px; border-radius: 2px; background: var(--line); }
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none; width: 16px; height: 16px; margin-top: -6px;
border-radius: 50%; background: var(--accent); border: 2px solid var(--surface);
box-shadow: 0 0 0 1px var(--accent);
}
input[type=range]::-moz-range-thumb {
width: 14px; height: 14px; border-radius: 50%; background: var(--accent);
border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--accent);
}
.btns, .seg { display: flex; flex-wrap: wrap; gap: 8px; }
.seg { gap: 6px; }
.btn {
flex: 1 1 auto; min-width: 78px; font: inherit; font-size: 0.82rem;
font-family: var(--font-mono); letter-spacing: 0.02em;
padding: 8px 10px; border-radius: 8px; cursor: pointer;
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
}
.btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
.btn.primary { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); font-weight: 600; }
.btn[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); font-weight: 600; }
.btn:disabled { opacity: 0.42; cursor: not-allowed; }
.btn.sm { font-size: 0.72rem; padding: 6px 8px; min-width: 0; }
.check { display: flex; align-items: flex-start; gap: 9px; font-size: 0.79rem; color: var(--muted); cursor: pointer; }
.check input { margin: 2px 0 0; accent-color: var(--accent); width: 15px; height: 15px; flex: none; }
.check b { color: var(--ink); font-weight: 600; display: block; }
.keys { margin: 2px 0 0; font-family: var(--font-mono); font-size: 0.67rem; color: var(--muted); line-height: 1.7; }
.keys kbd { border: 1px solid var(--line); border-radius: 4px; padding: 0 4px; background: var(--bg); font: inherit; }
footer {
margin-top: 18px; padding-top: 11px; border-top: 1px solid var(--line);
font-family: var(--font-mono); font-size: 0.7rem; color: var(--muted); letter-spacing: 0.04em;
}
@media (prefers-reduced-motion: reduce) {
* { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important; }
}
</style>
</head>
<body>
<div class="wrap">
<header class="head">
<div>
<h1>Falling Objects Race</h1>
<p class="lede">Three everyday things, dropped from the same height at the same moment. Air pushes back harder
the faster you go — until it pushes exactly as hard as gravity pulls, and the speed stops rising. Take the air
away and the race is a dead heat.</p>
</div>
<div class="status" id="status" data-state="run"><span class="dot"></span><span id="statusText">FALLING</span></div>
</header>
<main class="grid">
<section class="panel a-tower">
<div class="picks" id="picks">
<div class="pick">
<label class="tag" for="obj0"><i style="background:var(--s1)"></i> Lane 1</label>
<select id="obj0"></select>
</div>
<div class="pick">
<label class="tag" for="obj1"><i style="background:var(--s2)"></i> Lane 2</label>
<select id="obj1"></select>
</div>
<div class="pick">
<label class="tag" for="obj2"><i style="background:var(--s3)"></i> Lane 3</label>
<select id="obj2"></select>
</div>
</div>
<div class="tower">
<canvas id="tower" role="img" aria-label="Three objects falling side by side" aria-describedby="desc"></canvas>
</div>
<p class="caption" id="towerCap"></p>
</section>
<section class="panel a-readout">
<h2>Race clock</h2>
<div class="clockrow">
<div class="big"><span id="clock">0.00</span><u>s</u><small id="clockSub">since release</small></div>
<span class="warp mono" id="warp">TIME ×1</span>
</div>
<div class="fit" id="fit" data-ok="0">
<span class="v" id="fitv">—</span>
<span id="fitmsg">Waiting for the first landing.</span>
</div>
</section>
<section class="panel a-lanes">
<h2>Readout <span class="side" id="modeTag">air</span></h2>
<div id="lanes"></div>
<p class="keys" id="laneNote">Drag is how bluff a shape is: a flat sheet is about 1.3, a smooth ball about 0.5.</p>
</section>
<section class="panel a-chart">
<h2>Speed curves <span class="side" id="chartTag"></span></h2>
<div class="chartbox">
<canvas id="chart" role="img" aria-label="Speed curves for the three falling objects" aria-describedby="cdesc"></canvas>
</div>
<div class="seg" style="margin-top:10px" role="group" aria-label="Chart axes">
<button class="btn sm" id="xDist" type="button" aria-pressed="true">x: distance</button>
<button class="btn sm" id="xTime" type="button" aria-pressed="false">x: time</button>
<button class="btn sm" id="yLin" type="button" aria-pressed="true">y: linear</button>
<button class="btn sm" id="yLog" type="button" aria-pressed="false">y: log</button>
</div>
<p class="caption" id="chartCap"></p>
</section>
<section class="panel a-controls">
<h2>Controls</h2>
<div class="ctl">
<div>
<span class="flab"><span>Air around them</span><span class="n" id="airN">ON</span></span>
<div class="seg" role="group" aria-label="Air or vacuum">
<button class="btn" id="mAir" type="button" aria-pressed="true">Air</button>
<button class="btn" id="mVac" type="button" aria-pressed="false">Vacuum</button>
</div>
</div>
<div class="field">
<label for="height">Drop height <span class="n" id="heightN">50 m</span></label>
<input type="range" id="height" min="5" max="150" step="5" value="50">
<p class="keys" id="storeys"></p>
</div>
<div class="btns">
<button class="btn primary" id="drop" type="button">Drop again</button>
<button class="btn" id="pause" type="button">Pause</button>
</div>
<div class="btns">
<button class="btn" id="replay" type="button" disabled>Slow-mo the landings</button>
</div>
<label class="check" for="anim">
<input type="checkbox" id="anim" checked>
<span><b>Animate the fall</b>Off = every drop resolves instantly, no motion.</span>
</label>
<p class="keys"><kbd>D</kbd> drop · <kbd>V</kbd> air/vacuum · <kbd>R</kbd> slow-mo · <kbd>Space</kbd> pause</p>
</div>
</section>
</main>
<footer>demo · sloprun.dev</footer>
</div>
<div class="sr-only" id="desc"></div>
<div class="sr-only" id="cdesc"></div>
<div class="sr-only" role="status" aria-live="polite" id="live"></div>
<script>
(function () {
"use strict";
var root = document.documentElement;
var $ = function (id) { return document.getElementById(id); };
var live = $("live"), desc = $("desc"), cdesc = $("cdesc");
var towerCv = $("tower"), chartCv = $("chart");
/* ── constants ──────────────────────────────────────────────────────────── */
var G = 9.80665; /* metres per second squared */
var RHO = 1.225; /* air density at sea level, kg per cubic metre */
var MONO = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace';
var SANS = 'system-ui, -apple-system, "Segoe UI", sans-serif';
/* mass kg · frontal area m^2 · drag coefficient · draw size · shape */
var OBJECTS = [
{ id: "feather", name: "Feather", m: 0.0008, a: 0.0110, cd: 1.40, r: 15, s: "feather" },
{ id: "paper", name: "Paper sheet", m: 0.0045, a: 0.0623, cd: 1.30, r: 19, s: "sheet" },
{ id: "umbrella", name: "Open umbrella",m: 0.4500, a: 0.6000, cd: 1.40, r: 25, s: "umbrella" },
{ id: "beach", name: "Beach ball", m: 0.1500, a: 0.1134, cd: 0.47, r: 22, s: "beach" },
{ id: "pingpong", name: "Ping-pong", m: 0.0027, a: 0.00126,cd: 0.47, r: 7, s: "plain" },
{ id: "coin", name: "Coin", m: 0.0057, a: 0.00045,cd: 1.20, r: 8, s: "coin" },
{ id: "tennis", name: "Tennis ball", m: 0.0580, a: 0.00358,cd: 0.50, r: 11, s: "tennis" },
{ id: "basket", name: "Basketball", m: 0.6200, a: 0.04558,cd: 0.47, r: 19, s: "basket" },
{ id: "brick", name: "Brick", m: 2.3000, a: 0.0171, cd: 1.10, r: 19, s: "brick" },
{ id: "bowling", name: "Bowling ball", m: 6.3500, a: 0.03664,cd: 0.50, r: 16, s: "bowling" }
];
var byId = {};
for (var oi = 0; oi < OBJECTS.length; oi++) byId[OBJECTS[oi].id] = OBJECTS[oi];
/* ── physics (closed form — quadratic drag from rest) ────────────────────── */
function termV(o) { return Math.sqrt((2 * o.m * G) / (RHO * o.cd * o.a)); }
function lncosh(x) { return x > 20 ? x - Math.LN2 : Math.log(Math.cosh(x)); }
function fallenAir(vt, t) { return (vt * vt / G) * lncosh(G * t / vt); }
function speedAir(vt, t) { return vt * Math.tanh(G * t / vt); }
function speedAirAtY(vt, y) { return vt * Math.sqrt(Math.max(0, 1 - Math.exp(-2 * G * y / (vt * vt)))); }
function landTimeAir(vt, h) {
var x = G * h / (vt * vt), a;
if (x > 20) { a = x + Math.LN2; }
else { var e = Math.exp(x); a = Math.log(e + Math.sqrt(Math.max(0, e * e - 1))); }
return vt / G * a;
}
function landTimeVac(h) { return Math.sqrt(2 * h / G); }
/* ── state ──────────────────────────────────────────────────────────────── */
var S = {
ids: ["feather", "tennis", "brick"],
lanes: [],
H: 50,
air: true,
t: 0,
state: "falling", /* falling | paused | done | replay */
paused: "falling", /* what to resume into */
rate: 1, rateTarget: 1,
view: { b0: 0, b1: 50, t0: 0, t1: 50 },
chartX: "dist", chartY: "lin",
segs: [], segIdx: -1,
announced: [],
animate: true
};
var REPLAY_RATE = 0.30;
var mqRM = window.matchMedia ? matchMedia("(prefers-reduced-motion: reduce)") : null;
var reduced = !!(mqRM && mqRM.matches);
if (reduced) { S.animate = false; $("anim").checked = false; }
if (mqRM) {
var onRM = function () {
reduced = mqRM.matches;
if (reduced && S.animate) { S.animate = false; $("anim").checked = false; restart(true); }
};
if (mqRM.addEventListener) mqRM.addEventListener("change", onRM);
else if (mqRM.addListener) mqRM.addListener(onRM);
}
/* ── theme ──────────────────────────────────────────────────────────────── */
var C = {};
function hx(h) {
h = String(h).trim();
if (h.charAt(0) === "#") {
if (h.length === 4) return [parseInt(h[1] + h[1], 16), parseInt(h[2] + h[2], 16), parseInt(h[3] + h[3], 16)];
return [parseInt(h.substr(1, 2), 16), parseInt(h.substr(3, 2), 16), parseInt(h.substr(5, 2), 16)];
}
return [128, 128, 128];
}
function rgba(c, a) { return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")"; }
function rgb(c) { return "rgb(" + c[0] + "," + c[1] + "," + c[2] + ")"; }
function readTheme() {
var s = getComputedStyle(root);
C.surface = hx(s.getPropertyValue("--surface"));
C.bg = hx(s.getPropertyValue("--bg"));
C.ink = hx(s.getPropertyValue("--ink"));
C.muted = hx(s.getPropertyValue("--muted"));
C.line = hx(s.getPropertyValue("--line"));
C.accent = hx(s.getPropertyValue("--accent"));
C.run = hx(s.getPropertyValue("--run"));
C.s = [hx(s.getPropertyValue("--s1")), hx(s.getPropertyValue("--s2")), hx(s.getPropertyValue("--s3"))];
}
readTheme();
window.addEventListener("message", function (e) {
var d = e.data;
if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
root.setAttribute("data-theme", d.theme); readTheme(); draw();
}
});
var mqDark = window.matchMedia ? matchMedia("(prefers-color-scheme: dark)") : null;
if (mqDark) {
var onScheme = function () { readTheme(); draw(); };
if (mqDark.addEventListener) mqDark.addEventListener("change", onScheme);
else if (mqDark.addListener) mqDark.addListener(onScheme);
}
/* the canvases bake the palette in, so re-read it however the theme arrives —
postMessage, media query, or the attribute being set on <html> directly */
if (window.MutationObserver) {
new MutationObserver(function () { readTheme(); draw(); })
.observe(root, { attributes: true, attributeFilter: ["data-theme", "style", "class"] });
}
/* ── format helpers ─────────────────────────────────────────────────────── */
function clamp(v, a, b) { return v < a ? a : v > b ? b : v; }
function f2(v) { return v.toFixed(2); }
function sp(v) { return (v >= 100 ? v.toFixed(0) : v.toFixed(v < 10 ? 2 : 1)); }
function secs(v) { return v >= 100 ? v.toFixed(1) : v.toFixed(2); }
function kmh(v) { return (v * 3.6).toFixed(v * 3.6 >= 100 ? 0 : 1); }
function gram(m) { return m < 1 ? (m * 1000).toFixed(m < 0.01 ? 1 : 0) + " g" : m.toFixed(2) + " kg"; }
function areaCm(a) { return a >= 0.1 ? (a * 10000).toFixed(0) + " cm2" : (a * 10000).toFixed(a < 0.005 ? 1 : 0) + " cm2"; }
function rateTxt(r) { return r < 1 ? "×" + r.toFixed(2) : r < 10 ? "×" + r.toFixed(1) : "×" + Math.round(r); }
/* axis tick label: zero always prints bare, so a 0/10/20 axis never reads "0.0" */
function tick(v, step) { return v === 0 ? "0" : step < 1 ? v.toFixed(1) : String(Math.round(v)); }
function niceStep(x) {
if (!(x > 0)) return 1;
var p = Math.pow(10, Math.floor(Math.log(x) / Math.LN10)), f = x / p;
return (f < 1.5 ? 1 : f < 3 ? 2 : f < 7 ? 5 : 10) * p;
}
/* ── lane model ─────────────────────────────────────────────────────────── */
function rebuild() {
S.lanes = [];
for (var i = 0; i < 3; i++) {
var o = byId[S.ids[i]];
var vt = termV(o);
var tl = S.air ? landTimeAir(vt, S.H) : landTimeVac(S.H);
var vi = S.air ? speedAir(vt, tl) : G * tl;
S.lanes.push({ o: o, i: i, vt: vt, tLand: tl, vImp: vi });
}
}
function fallenAt(L, t) {
var tt = Math.min(t, L.tLand);
return S.air ? Math.min(S.H, fallenAir(L.vt, tt)) : Math.min(S.H, 0.5 * G * tt * tt);
}
function speedAt(L, t) {
var tt = Math.min(t, L.tLand);
return S.air ? speedAir(L.vt, tt) : G * tt;
}
/* true when all three trace one and the same speed curve: always in vacuum,
and in air whenever the three objects have matching terminal speeds */
function coincident() {
if (!S.air) return true;
var lo = Infinity, hi = 0;
for (var i = 0; i < 3; i++) { var v = S.lanes[i].vt; if (v < lo) lo = v; if (v > hi) hi = v; }
return hi - lo < hi * 0.002;
}
function tMax() { var m = 0; for (var i = 0; i < 3; i++) m = Math.max(m, S.lanes[i].tLand); return m; }
function tMin() { var m = Infinity; for (var i = 0; i < 3; i++) m = Math.min(m, S.lanes[i].tLand); return m; }
function nextLanding(t) {
var n = null;
for (var i = 0; i < 3; i++) { var x = S.lanes[i].tLand; if (x > t + 1e-6 && (n === null || x < n)) n = x; }
return n;
}
function setRateTarget(first) {
var n = nextLanding(S.t);
if (n === null) { S.rateTarget = 1; return; }
S.rateTarget = clamp((n - S.t) / (first ? 3.0 : 2.2), 0.2, 60);
}
/* ── run control ────────────────────────────────────────────────────────── */
/* silent = true while a slider is being dragged, so the live region is not
flooded with one announcement per pixel of travel */
function restart(silent) {
rebuild();
S.t = 0; S.announced = [false, false, false]; S.segs = []; S.segIdx = -1;
S.view.t0 = 0; S.view.t1 = S.H; S.view.b0 = 0; S.view.b1 = S.H;
if (!S.animate) { S.t = tMax(); S.state = "done"; }
else { S.state = "falling"; setRateTarget(true); S.rate = S.rateTarget; }
syncButtons(); sync(); draw(); kick();
if (!silent) {
announce(S.air
? "Dropped from " + S.H + " metres through air."
: "Dropped from " + S.H + " metres in a vacuum.");
}
}
function startReplay() {
if (!S.animate) return;
S.segs = buildSegments();
if (!S.segs.length) return;
S.segIdx = 0; S.state = "replay"; S.t = S.segs[0].from;
S.view.b0 = 0; S.view.b1 = S.segs[0].zoom;
syncButtons(); sync(); draw(); kick();
announce("Slow-motion replay of the landings.");
}
function buildSegments() {
var order = [];
for (var i = 0; i < 3; i++) order.push({ i: i, t: S.lanes[i].tLand });
order.sort(function (a, b) { return a.t - b.t; });
var groups = [];
for (var k = 0; k < order.length; k++) {
var last = groups[groups.length - 1];
if (last && Math.abs(order[k].t - last.t) < 0.03) { last.ids.push(order[k].i); }
else groups.push({ t: order[k].t, ids: [order[k].i] });
}
return groups.map(function (g) {
var vmax = 0;
for (var j = 0; j < g.ids.length; j++) vmax = Math.max(vmax, S.lanes[g.ids[j]].vImp);
var zoom = clamp(vmax * 0.75, 1.2, S.H);
var win = clamp(zoom / Math.max(vmax, 0.01), 0.30, 0.70);
return { from: Math.max(0, g.t - win), to: g.t + Math.min(0.35, win * 0.6), zoom: zoom, ids: g.ids, at: g.t };
});
}
/* ── animation loop ─────────────────────────────────────────────────────── */
var raf = 0, last = 0;
function kick() { if (!raf) { last = 0; raf = requestAnimationFrame(frame); } }
function frame(ts) {
raf = 0;
var dt = last ? Math.min(0.05, (ts - last) / 1000) : 0.016;
last = ts;
if (S.state === "falling") {
var k = 1 - Math.exp(-dt * 3.5);
S.rate += (S.rateTarget - S.rate) * k;
var prev = S.t;
S.t += dt * S.rate;
for (var i = 0; i < 3; i++) {
var L = S.lanes[i];
if (!S.announced[i] && S.t >= L.tLand) {
S.announced[i] = true;
announce(L.o.name + " landed at " + secs(L.tLand) + " seconds, hitting " + sp(L.vImp) + " metres per second.");
setRateTarget(false);
}
}
if (S.t >= tMax()) { S.t = tMax(); S.state = "done"; syncButtons(); }
} else if (S.state === "replay") {
S.t += dt * REPLAY_RATE;
var seg = S.segs[S.segIdx];
if (seg && S.t >= seg.to) {
S.segIdx++;
if (S.segIdx >= S.segs.length) { S.state = "done"; S.t = tMax(); S.view.b1 = S.H; syncButtons(); }
else { S.t = S.segs[S.segIdx].from; S.view.b1 = S.segs[S.segIdx].zoom; }
}
}
/* camera easing */
var ck = 1 - Math.exp(-dt * 6);
S.view.t0 += (S.view.b0 - S.view.t0) * ck;
S.view.t1 += (S.view.b1 - S.view.t1) * ck;
if (Math.abs(S.view.t1 - S.view.b1) < S.H * 0.002) S.view.t1 = S.view.b1;
sync(); draw();
if (S.state === "falling" || S.state === "replay" || Math.abs(S.view.t1 - S.view.b1) > 1e-4) kick();
}
function announce(msg) { live.textContent = msg; }
/* ── canvas plumbing ────────────────────────────────────────────────────── */
function fit(cv) {
var r = cv.getBoundingClientRect();
var dpr = Math.min(window.devicePixelRatio || 1, 2);
var w = Math.max(1, Math.round(r.width)), h = Math.max(1, Math.round(r.height));
if (cv.width !== Math.round(w * dpr) || cv.height !== Math.round(h * dpr)) {
cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr);
}
var ctx = cv.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return { ctx: ctx, w: w, h: h };
}
/* ── object glyphs ──────────────────────────────────────────────────────── */
function glyph(ctx, o, x, y, col, sc, stretch, spin) {
var r = o.r * sc;
ctx.save();
ctx.translate(x, y);
if (spin) ctx.rotate(spin);
if (stretch && stretch > 1) ctx.scale(1 / Math.sqrt(stretch), stretch);
ctx.lineWidth = Math.max(1, r * 0.09);
ctx.strokeStyle = rgb(col);
ctx.fillStyle = rgba(col, 0.30);
var s = o.s;
if (s === "feather") {
ctx.beginPath();
ctx.moveTo(0, -r);
ctx.bezierCurveTo(r * 0.75, -r * 0.3, r * 0.55, r * 0.6, 0, r);
ctx.bezierCurveTo(-r * 0.55, r * 0.6, -r * 0.75, -r * 0.3, 0, -r);
ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0, -r * 0.95); ctx.lineTo(0, r * 1.05); ctx.stroke();
} else if (s === "sheet") {
ctx.beginPath();
ctx.moveTo(-r, -r * 0.42); ctx.lineTo(r * 0.86, -r * 0.72);
ctx.lineTo(r, r * 0.42); ctx.lineTo(-r * 0.86, r * 0.72);
ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.moveTo(-r * 0.5, -r * 0.55); ctx.lineTo(-r * 0.36, r * 0.66); ctx.stroke();
} else if (s === "umbrella") {
ctx.beginPath(); ctx.arc(0, r * 0.32, r, Math.PI, 0); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.moveTo(-r * 0.5, r * 0.32); ctx.lineTo(-r * 0.28, -r * 0.5); ctx.stroke();
ctx.beginPath(); ctx.moveTo(r * 0.5, r * 0.32); ctx.lineTo(r * 0.28, -r * 0.5); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0, r * 0.32); ctx.lineTo(0, r * 1.3); ctx.stroke();
} else if (s === "brick") {
var bw = r * 1.25, bh = r * 0.62;
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(-bw, -bh, bw * 2, bh * 2, r * 0.14);
else ctx.rect(-bw, -bh, bw * 2, bh * 2);
ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.moveTo(-bw, 0); ctx.lineTo(bw, 0); ctx.stroke();
ctx.beginPath(); ctx.moveTo(-bw * 0.3, -bh); ctx.lineTo(-bw * 0.3, 0); ctx.stroke();
ctx.beginPath(); ctx.moveTo(bw * 0.35, 0); ctx.lineTo(bw * 0.35, bh); ctx.stroke();
} else if (s === "coin") {
ctx.beginPath(); ctx.ellipse(0, 0, r, r * 0.34, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.ellipse(0, 0, r * 0.55, r * 0.18, 0, 0, Math.PI * 2); ctx.stroke();
} else {
ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
if (s === "tennis") {
ctx.beginPath(); ctx.arc(-r * 1.15, 0, r * 1.35, -0.72, 0.72); ctx.stroke();
ctx.beginPath(); ctx.arc(r * 1.15, 0, r * 1.35, Math.PI - 0.72, Math.PI + 0.72); ctx.stroke();
} else if (s === "basket") {
ctx.beginPath(); ctx.moveTo(-r, 0); ctx.lineTo(r, 0); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0, -r); ctx.lineTo(0, r); ctx.stroke();
ctx.beginPath(); ctx.arc(-r * 1.3, 0, r * 1.1, -0.9, 0.9); ctx.stroke();
ctx.beginPath(); ctx.arc(r * 1.3, 0, r * 1.1, Math.PI - 0.9, Math.PI + 0.9); ctx.stroke();
} else if (s === "beach") {
for (var q = 0; q < 3; q++) {
ctx.beginPath();
ctx.ellipse(0, 0, r * (0.28 + q * 0.3), r, 0, -Math.PI / 2, Math.PI / 2);
ctx.stroke();
}
} else if (s === "bowling") {
ctx.fillStyle = rgb(col);
for (var d = 0; d < 3; d++) {
ctx.beginPath();
ctx.arc(Math.cos(d * 2.1 - 1.6) * r * 0.42, Math.sin(d * 2.1 - 1.6) * r * 0.42 - r * 0.05, r * 0.15, 0, Math.PI * 2);
ctx.fill();
}
}
}
ctx.restore();
}
/* ── tower ──────────────────────────────────────────────────────────────── */
var numbersShown = true; /* set by drawTower: are per-object speed labels drawn? */
function drawTower() {
var f = fit(towerCv), ctx = f.ctx, w = f.w, h = f.h;
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = rgb(C.surface); ctx.fillRect(0, 0, w, h);
var gr = ctx.createLinearGradient(0, 0, 0, h);
gr.addColorStop(0, rgba(C.line, 0.55)); gr.addColorStop(0.75, rgba(C.line, 0.05));
ctx.fillStyle = gr; ctx.fillRect(0, 0, w, h);
var padL = w < 380 ? 32 : 42, padR = 8, padT = 18, padB = 30;
var groundY = h - padB, topY = padT, plotH = groundY - topY;
var laneW = (w - padL - padR) / 3;
var b0 = S.view.t0, b1 = Math.max(S.view.t1, 0.2);
var span = b1 - b0;
function ypx(above) { return groundY - (above - b0) / span * plotH; }
var sc = clamp(laneW / 168, 0.5, 1.35) * clamp(Math.pow(S.H / span, 0.35), 1, 2.4);
var zoomed = span < S.H * 0.98;
/* ruler */
var step = niceStep(span / 5);
ctx.font = "10px " + MONO;
ctx.textAlign = "right"; ctx.textBaseline = "middle";
for (var m = Math.ceil(b0 / step) * step; m <= b1 + 1e-9; m += step) {
var y = ypx(m);
ctx.strokeStyle = rgba(C.line, 0.9); ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(padL - 5, y + 0.5); ctx.lineTo(w - padR, y + 0.5); ctx.stroke();
ctx.fillStyle = rgba(C.muted, 0.95);
ctx.fillText(tick(m, step), padL - 9, y);
}
ctx.textAlign = "left";
ctx.fillStyle = rgba(C.muted, 0.9);
ctx.fillText("m", 4, topY - 6 < 8 ? 10 : topY - 6);
/* release line */
if (!zoomed) {
var ry = ypx(S.H);
ctx.strokeStyle = rgba(C.accent, 0.8); ctx.lineWidth = 1.5;
ctx.setLineDash([5, 4]);
ctx.beginPath(); ctx.moveTo(padL - 6, ry); ctx.lineTo(w - padR, ry); ctx.stroke();
ctx.setLineDash([]);
}
/* lanes */
for (var i = 0; i < 3; i++) {
var cx = padL + laneW * (i + 0.5);
ctx.strokeStyle = rgba(C.line, 0.85); ctx.lineWidth = 1;
ctx.setLineDash([2, 5]);
ctx.beginPath(); ctx.moveTo(cx, topY); ctx.lineTo(cx, groundY); ctx.stroke();
ctx.setLineDash([]);
}
/* ground */
ctx.fillStyle = rgba(C.line, 0.55);
ctx.fillRect(0, groundY, w, h - groundY);
ctx.strokeStyle = rgba(C.ink, 0.55); ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(0, groundY + 1); ctx.lineTo(w, groundY + 1); ctx.stroke();
ctx.strokeStyle = rgba(C.muted, 0.35); ctx.lineWidth = 1;
for (var gx = -20; gx < w; gx += 9) {
ctx.beginPath(); ctx.moveTo(gx, h); ctx.lineTo(gx + 14, groundY + 2); ctx.stroke();
}
var trailDt = Math.max(0.012, (S.state === "replay" ? REPLAY_RATE : S.rate) * 0.045);
var showNum = laneW > 92;
numbersShown = showNum; /* the caption must not promise labels we dropped */
for (var j = 0; j < 3; j++) {
var L = S.lanes[j], col = C.s[j];
var fallen = fallenAt(L, S.t);
var above = S.H - fallen;
var landed = S.t >= L.tLand - 1e-9;
var v = speedAt(L, S.t);
var cx2 = padL + laneW * (j + 0.5);
var y2 = ypx(above);
/* trail */
if (S.animate && !landed) {
for (var k = 1; k <= 7; k++) {
var tk = S.t - k * trailDt;
if (tk <= 0) break;
var yk = ypx(S.H - fallenAt(L, tk));
if (yk < topY - 40) break;
ctx.fillStyle = rgba(col, 0.20 * (1 - k / 8));
ctx.beginPath();
ctx.ellipse(cx2, yk, L.o.r * sc * 0.42, L.o.r * sc * 0.30, 0, 0, Math.PI * 2);
ctx.fill();
}
}
/* off-view marker */
if (y2 < topY - 4) {
ctx.fillStyle = rgba(col, 0.85);
ctx.beginPath();
ctx.moveTo(cx2, topY - 1); ctx.lineTo(cx2 - 6, topY + 9); ctx.lineTo(cx2 + 6, topY + 9);
ctx.closePath(); ctx.fill();
if (showNum) {
ctx.font = "10px " + MONO; ctx.textAlign = "center"; ctx.textBaseline = "top";
ctx.fillStyle = rgba(C.muted, 0.95);
ctx.fillText("still falling", cx2, topY + 12);
}
continue;
}
if (landed) {
/* resting on the ground + impact tick */
ctx.strokeStyle = rgba(col, 0.9); ctx.lineWidth = 2.5;
ctx.beginPath(); ctx.moveTo(cx2 - laneW * 0.24, groundY); ctx.lineTo(cx2 + laneW * 0.24, groundY); ctx.stroke();
glyph(ctx, L.o, cx2, groundY - L.o.r * sc * 0.55, col, sc, 1, 0);
if (showNum) {
ctx.font = "600 11px " + MONO; ctx.textAlign = "center"; ctx.textBaseline = "top";
ctx.fillStyle = rgba(col, 1);
ctx.fillText(sp(L.vImp) + " m/s", cx2, groundY + 8);
}
} else {
var stretch = S.animate ? clamp(1 + v / Math.max(L.vt, 8) * 0.22, 1, 1.28) : 1;
var spin = 0;
if (S.animate && (L.o.s === "feather" || L.o.s === "sheet" || L.o.s === "coin")) {
spin = Math.sin(S.t * (L.o.s === "coin" ? 9 : 2.4)) * 0.5;
}
glyph(ctx, L.o, cx2, y2, col, sc, stretch, spin);
if (showNum) {
ctx.font = "600 11px " + MONO; ctx.textAlign = "left"; ctx.textBaseline = "middle";
ctx.fillStyle = rgba(col, 0.95);
var lbl = sp(v);
var lx = cx2 + L.o.r * sc + 7;
if (lx + 34 > w - padR) { ctx.textAlign = "right"; lx = cx2 - L.o.r * sc - 7; }
ctx.fillText(lbl, lx, y2);
}
}
}
/* slow-mo caption */
if (S.state === "replay" && S.segs[S.segIdx]) {
var seg = S.segs[S.segIdx];
var names = seg.ids.map(function (id) { return S.lanes[id].o.name.toUpperCase(); }).join(" + ");
var txt = "SLOW-MO " + rateTxt(REPLAY_RATE) + " · " + names + " · " + sp(S.lanes[seg.ids[0]].vImp) + " m/s";
ctx.font = "600 11px " + MONO; ctx.textAlign = "center"; ctx.textBaseline = "middle";
var tw = ctx.measureText(txt).width + 20;
var bx = Math.max(4, w / 2 - tw / 2), by = topY + 2;
ctx.fillStyle = rgba(C.bg, 0.94);
ctx.strokeStyle = rgba(C.accent, 0.75); ctx.lineWidth = 1;
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(bx, by, Math.min(tw, w - 8), 22, 11);
else ctx.rect(bx, by, Math.min(tw, w - 8), 22);
ctx.fill(); ctx.stroke();
ctx.fillStyle = rgb(C.accent);
ctx.fillText(txt, bx + Math.min(tw, w - 8) / 2, by + 11);
}
}
/* ── chart ──────────────────────────────────────────────────────────────── */
function drawChart() {
var f = fit(chartCv), ctx = f.ctx, w = f.w, h = f.h;
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = rgb(C.surface); ctx.fillRect(0, 0, w, h);
var padL = w < 380 ? 34 : 44, padR = w < 380 ? 10 : 16, padT = 12, padB = 31;
var pw = w - padL - padR, ph = h - padT - padB;
if (pw < 20 || ph < 20) return;
var byDist = S.chartX === "dist";
var xmax = byDist ? S.H : Math.max(tMax(), 0.1);
var vmaxAll = 0, vminAll = Infinity;
for (var i = 0; i < 3; i++) {
vmaxAll = Math.max(vmaxAll, S.air ? S.lanes[i].vt : S.lanes[i].vImp);
vminAll = Math.min(vminAll, S.lanes[i].vImp);
}
var logY = S.chartY === "log";
var ymax = vmaxAll * 1.06;
var ymin = logY ? Math.max(0.05, Math.min(vminAll * 0.45, ymax / 200)) : 0;
function X(u) { return padL + (u / xmax) * pw; }
function Y(v) {
if (!logY) return padT + ph - clamp(v / ymax, 0, 1) * ph;
var lv = Math.log(Math.max(v, ymin * 0.999)), a = Math.log(ymin), b = Math.log(ymax);
return padT + ph - clamp((lv - a) / (b - a), 0, 1) * ph;
}
/* grid + axes */
ctx.font = "10px " + MONO; ctx.fillStyle = rgba(C.muted, 0.95);
ctx.strokeStyle = rgba(C.line, 0.9); ctx.lineWidth = 1;
ctx.textAlign = "right"; ctx.textBaseline = "middle";
var yticks = [];
if (logY) {
var dec = [1, 2, 5];
for (var e = -2; e <= 3; e++) for (var d = 0; d < 3; d++) {
var val = dec[d] * Math.pow(10, e);
if (val >= ymin && val <= ymax) yticks.push(val);
}
} else {
var ys0 = niceStep(ymax / 4);
for (var yv = 0; yv <= ymax + 1e-9; yv += ys0) yticks.push(yv);
}
var ystep = logY ? 0.1 : niceStep(ymax / 4);
for (var yi = 0; yi < yticks.length; yi++) {
var yy = Math.round(Y(yticks[yi])) + 0.5;
ctx.beginPath(); ctx.moveTo(padL, yy); ctx.lineTo(padL + pw, yy); ctx.stroke();
ctx.fillText(tick(yticks[yi], logY ? (yticks[yi] < 1 ? 0.1 : 1) : ystep), padL - 6, yy);
}
ctx.textAlign = "center"; ctx.textBaseline = "top";
var xs = niceStep(xmax / 4);
for (var xv = 0; xv <= xmax + 1e-9; xv += xs) {
var xx = Math.round(X(xv)) + 0.5;
ctx.strokeStyle = rgba(C.line, 0.6);
ctx.beginPath(); ctx.moveTo(xx, padT); ctx.lineTo(xx, padT + ph); ctx.stroke();
ctx.fillStyle = rgba(C.muted, 0.95);
ctx.fillText(tick(xv, xs), xx, padT + ph + 5);
}
ctx.textAlign = "left";
ctx.fillStyle = rgba(C.muted, 0.9);
ctx.fillText(byDist ? "metres fallen" : "seconds", padL + 2, padT + ph + 18);
ctx.save();
ctx.translate(11, padT + ph / 2); ctx.rotate(-Math.PI / 2);
ctx.textAlign = "center"; ctx.textBaseline = "top";
ctx.fillText("speed m/s", 0, 0);
ctx.restore();
/* terminal-speed asymptotes */
if (S.air) {
ctx.setLineDash([5, 4]); ctx.lineWidth = 1.25;
for (var a = 0; a < 3; a++) {
var ya = Y(S.lanes[a].vt);
if (ya < padT - 2 || ya > padT + ph + 2) continue;
ctx.strokeStyle = rgba(C.s[a], 0.55);
ctx.beginPath(); ctx.moveTo(padL, ya); ctx.lineTo(padL + pw, ya); ctx.stroke();
}
ctx.setLineDash([]);
}
/* curves — "same" means all three trace one identical curve, so draw them
nested (thick → thin, dashed) instead of hiding two under the third */
var same = coincident();
var widths = same ? [4.2, 2.4, 1.2] : [2.2, 2.2, 2.2];
var dashes = same ? [[], [7, 5], [2, 4]] : [[], [], []];
var placed = []; /* label anchors already used, so two heads never collide */
for (var c = 0; c < 3; c++) {
var L = S.lanes[c];
var prog = byDist ? fallenAt(L, S.t) : Math.min(S.t, L.tLand);
var lim = byDist ? S.H : L.tLand;
var upto = Math.min(prog, lim);
if (upto <= 0) continue;
var N = 140, px = 0, py = 0;
ctx.strokeStyle = rgb(C.s[c]);
ctx.lineWidth = widths[c]; ctx.setLineDash(dashes[c]);
ctx.lineJoin = "round"; ctx.lineCap = "round";
ctx.beginPath();
for (var n = 0; n <= N; n++) {
var u = upto * n / N, v;
if (byDist) v = S.air ? speedAirAtY(L.vt, u) : Math.sqrt(2 * G * u);
else v = S.air ? speedAir(L.vt, u) : G * u;
px = X(u); py = Y(v);
if (n === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
ctx.stroke(); ctx.setLineDash([]);
/* head / landing dot */
var done = upto >= lim - 1e-9;
ctx.fillStyle = rgb(C.s[c]);
ctx.beginPath(); ctx.arc(px, py, done ? 3.6 : 3, 0, Math.PI * 2); ctx.fill();
if (done && !same) {
ctx.font = "600 10px " + MONO; ctx.textBaseline = "middle";
var label = L.o.name;
var lw = ctx.measureText(label).width;
var ly = py - 8;
for (var q = 0; q < placed.length; q++) {
if (Math.abs(placed[q].x - px) < lw + 16 && Math.abs(placed[q].y - ly) < 12) ly = placed[q].y + 12;
}
ly = clamp(ly, padT + 6, padT + ph - 5);
placed.push({ x: px, y: ly });
if (px + 8 + lw < w - 2) { ctx.textAlign = "left"; ctx.fillText(label, px + 7, ly); }
else { ctx.textAlign = "right"; ctx.fillText(label, px - 7, ly); }
}
}
if (same) {
/* park the note in whichever corner the curve is not using: sample the
curve a quarter of the way across and take the opposite half */
var uq = xmax * 0.25;
var vq = byDist ? (S.air ? speedAirAtY(S.lanes[0].vt, uq) : Math.sqrt(2 * G * uq))
: (S.air ? speedAir(S.lanes[0].vt, uq) : G * uq);
var lowLeft = Y(vq) > padT + ph * 0.5; /* curve still near the floor on the left */
var note = pw < 300 ? "all three curves overlap" : "all three curves lie on top of each other";
ctx.font = "600 10px " + MONO; ctx.textAlign = "left"; ctx.textBaseline = "middle";
var nw = Math.min(ctx.measureText(note).width + 14, pw - 6);
var nx = lowLeft ? padL + 4 : padL + pw - 4 - nw;
var ny = lowLeft ? padT + 4 : padT + ph - 22;
ctx.fillStyle = rgba(C.surface, 0.92);
ctx.strokeStyle = rgba(C.line, 0.9); ctx.lineWidth = 1;
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(nx, ny, nw, 18, 9); else ctx.rect(nx, ny, nw, 18);
ctx.fill(); ctx.stroke();
ctx.fillStyle = rgba(C.muted, 1);
ctx.fillText(note, nx + 7, ny + 10);
}
}
function draw() { drawTower(); drawChart(); }
/* ── panels ─────────────────────────────────────────────────────────────── */
var laneHost = $("lanes");
function buildLanes() {
laneHost.innerHTML = "";
for (var i = 0; i < 3; i++) {
var d = document.createElement("div");
d.className = "lane";
d.innerHTML =
'<div class="lrow"><i style="background:var(--s' + (i + 1) + ')"></i><b id="ln' + i + '"></b>' +
'<span class="now" id="lv' + i + '"></span></div>' +
'<div class="bar"><i id="lb' + i + '" style="background:var(--s' + (i + 1) + ')"></i></div>' +
'<div class="meta" id="lm' + i + '"></div>';
laneHost.appendChild(d);
}
}
function sync() {
var i, L;
/* clock */
$("clock").textContent = secs(S.t);
$("clockSub").textContent = S.state === "replay" ? "replaying the landings"
: S.state === "done" ? "all three are down" : "since release";
var wr = $("warp");
var shown = S.state === "replay" ? REPLAY_RATE : S.state === "falling" ? S.rate : 1;
wr.textContent = "TIME " + rateTxt(shown);
wr.setAttribute("data-hot", (S.state === "replay" || shown > 1.8 || shown < 0.7) ? "1" : "0");
/* status */
var st = $("status"), stt = $("statusText");
var label = S.state === "falling" ? "FALLING" : S.state === "paused" ? "PAUSED" :
S.state === "replay" ? "SLOW-MO" : "LANDED";
stt.textContent = label;
st.setAttribute("data-state", (S.state === "falling" || S.state === "replay") ? "run" : "idle");
/* lanes */
var order = [0, 1, 2].sort(function (a, b) { return S.lanes[a].tLand - S.lanes[b].tLand; });
var winner = order[0], spread = S.lanes[order[2]].tLand - S.lanes[order[0]].tLand;
var tie = spread < 0.02;
for (i = 0; i < 3; i++) {
L = S.lanes[i];
var v = speedAt(L, S.t), landed = S.t >= L.tLand - 1e-9;
$("ln" + i).textContent = L.o.name;
$("lv" + i).textContent = sp(v) + " m/s";
$("lv" + i).style.color = "var(--s" + (i + 1) + ")";
var frac = S.air ? v / L.vt : v / Math.max(L.vImp, 0.001);
$("lb" + i).style.width = (clamp(frac, 0, 1) * 100).toFixed(1) + "%";
var spec = gram(L.o.m) + " · " + areaCm(L.o.a) + " · drag " + L.o.cd.toFixed(2);
var res;
if (S.air) {
res = "terminal <b>" + sp(L.vt) + " m/s</b> · " + (Math.round(clamp(frac, 0, 1) * 100)) + "% of it";
} else {
res = "no air · nothing to level off against";
}
var land = landed
? "landed <b>" + secs(L.tLand) + " s</b> at <b>" + sp(L.vImp) + " m/s</b> (" + kmh(L.vImp) + " km/h)"
: "lands at " + secs(L.tLand) + " s";
if (landed && i === winner && !tie) land += " <span class='win'>· first down</span>";
$("lm" + i).innerHTML = spec + "<br>" + res + "<br>" + land;
}
/* result strip */
var fitEl = $("fit"), fv = $("fitv"), fm = $("fitmsg");
var allDown = S.t >= tMax() - 1e-9;
if (!allDown) {
fitEl.setAttribute("data-ok", "0");
fv.textContent = secs(tMin()) + " s";
fm.textContent = "is when the first one touches down. " +
(S.air ? "Watch each speed stop climbing." : "With no air they cannot come apart.");
} else if (tie) {
fitEl.setAttribute("data-ok", "1");
fv.textContent = "DEAD HEAT";
fm.textContent = !S.air
? "All three land at " + secs(S.lanes[0].tLand) + " s. Nothing pushes back, so mass and shape stop mattering."
: coincident()
? "All three land at " + secs(S.lanes[0].tLand) + " s — same terminal speed, so the same fall."
: "All three land at " + secs(S.lanes[0].tLand) + " s — different objects, but their terminal speeds land them together.";
} else {
fitEl.setAttribute("data-ok", "0");
fv.textContent = S.lanes[winner].o.name;
fm.textContent = "lands first at " + secs(S.lanes[winner].tLand) + " s — " +
S.lanes[order[2]].o.name.toLowerCase() + " needs " + secs(S.lanes[order[2]].tLand) +
" s, a gap of " + secs(spread) + " s.";
}
/* captions + a11y text */
$("modeTag").textContent = S.air ? "air" : "vacuum";
$("chartTag").textContent = (S.chartX === "dist" ? "speed vs distance" : "speed vs time") + (S.chartY === "log" ? " · log" : "");
$("towerCap").innerHTML = S.air
? "Dashes mark the release line at <b>" + S.H + " m</b>." +
(numbersShown ? " Numbers beside each object are its speed right now, in m/s."
: " Live speeds are in the readout.")
: "Vacuum: no air to push back, so every object gains <b>9.81 m/s</b> of speed every second, whatever it weighs.";
$("chartCap").innerHTML = S.air
? "Dashed lines are each object's terminal speed — <b>v = sqrt(2mg / (rho·Cd·A))</b>. The curves bend over and hug them."
: S.chartX === "time"
? "In vacuum the three curves are the same curve: <b>v = g·t</b>, a straight climb that never levels off."
: "In vacuum the three curves are the same curve: <b>v = sqrt(2g·y)</b> — still climbing when it hits the ground, with nothing to level it off.";
desc.textContent = "Three objects falling from " + S.H + " metres " + (S.air ? "through air" : "in a vacuum") +
". Clock " + secs(S.t) + " seconds. " +
S.lanes.map(function (L) { return L.o.name + " at " + sp(speedAt(L, S.t)) + " metres per second"; }).join(", ") + ".";
cdesc.textContent = "Speed curves. " + S.lanes.map(function (L) {
return L.o.name + (S.air ? ": terminal speed " + sp(L.vt) + " metres per second, " : ": no terminal speed, ") +
"lands at " + secs(L.tLand) + " seconds hitting " + sp(L.vImp) + " metres per second";
}).join(". ") + ".";
}
function syncButtons() {
$("pause").textContent = S.state === "paused" ? "Resume" : "Pause";
$("pause").disabled = !S.animate || (S.state === "done");
$("replay").disabled = !S.animate || (S.state !== "done" && S.state !== "paused");
$("mAir").setAttribute("aria-pressed", S.air ? "true" : "false");
$("mVac").setAttribute("aria-pressed", S.air ? "false" : "true");
$("airN").textContent = S.air ? "ON" : "OFF";
$("xDist").setAttribute("aria-pressed", S.chartX === "dist" ? "true" : "false");
$("xTime").setAttribute("aria-pressed", S.chartX === "time" ? "true" : "false");
$("yLin").setAttribute("aria-pressed", S.chartY === "lin" ? "true" : "false");
$("yLog").setAttribute("aria-pressed", S.chartY === "log" ? "true" : "false");
$("heightN").textContent = S.H + " m";
var st = Math.max(1, Math.round(S.H / 3.2));
$("storeys").textContent = "about " + st + (st === 1 ? " storey" : " storeys") + " up";
}
/* ── wiring ─────────────────────────────────────────────────────────────── */
function fillSelects() {
for (var i = 0; i < 3; i++) {
var sel = $("obj" + i);
var html = "";
for (var k = 0; k < OBJECTS.length; k++) {
html += '<option value="' + OBJECTS[k].id + '">' + OBJECTS[k].name + "</option>";
}
sel.innerHTML = html;
sel.value = S.ids[i];
(function (idx, s) {
s.addEventListener("change", function () { S.ids[idx] = s.value; restart(); });
})(i, sel);
}
}
$("mAir").addEventListener("click", function () { if (!S.air) { S.air = true; restart(); } });
$("mVac").addEventListener("click", function () { if (S.air) { S.air = false; restart(); } });
$("height").addEventListener("input", function () { S.H = parseInt(this.value, 10); restart(true); });
$("height").addEventListener("change", function () {
announce("Drop height " + S.H + " metres.");
});
$("drop").addEventListener("click", function () { restart(); });
$("pause").addEventListener("click", function () {
if (S.state === "falling" || S.state === "replay") { S.paused = S.state; S.state = "paused"; }
else if (S.state === "paused") { S.state = S.paused || "falling"; kick(); }
syncButtons(); sync(); draw();
});
$("replay").addEventListener("click", startReplay);
$("anim").addEventListener("change", function () {
S.animate = this.checked;
restart();
});
$("xDist").addEventListener("click", function () { S.chartX = "dist"; syncButtons(); sync(); draw(); });
$("xTime").addEventListener("click", function () { S.chartX = "time"; syncButtons(); sync(); draw(); });
$("yLin").addEventListener("click", function () { S.chartY = "lin"; syncButtons(); sync(); draw(); });
$("yLog").addEventListener("click", function () { S.chartY = "log"; syncButtons(); sync(); draw(); });
document.addEventListener("keydown", function (e) {
var tag = e.target && e.target.tagName;
if (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") return;
var k = e.key.toLowerCase();
if (k === "d") { e.preventDefault(); restart(); }
else if (k === "v") { e.preventDefault(); S.air = !S.air; restart(); }
else if (k === "r") { if (!$("replay").disabled) { e.preventDefault(); startReplay(); } }
else if (k === " " || k === "spacebar") {
if (tag === "BUTTON") return;
e.preventDefault(); $("pause").click();
}
});
var rto = 0;
window.addEventListener("resize", function () {
if (rto) clearTimeout(rto);
rto = setTimeout(function () { rto = 0; sync(); draw(); }, 90);
});
/* the tower stretches to whatever the side panels need, so its box can change
without the window changing — repaint on the box, not just on resize */
if (window.ResizeObserver) {
var ro = new ResizeObserver(function () {
var before = numbersShown;
draw();
if (before !== numbersShown) sync(); /* only text that actually changed */
});
ro.observe(towerCv.parentNode);
ro.observe(chartCv.parentNode);
}
/* ── boot ───────────────────────────────────────────────────────────────── */
fillSelects();
buildLanes();
restart();
})();
</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.