0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
A hobby quadcopter has no idea what "six metres" means — all it can do is spin its props harder or softer, hundreds of times a second, based on how far off it currently is, and the three numbers that turn that gap into throttle are the whole flight controller. It opens mid-air on a deliberately middling tune that has just flown the standard 3 → 6 m test step: 25% overshoot, 3.2 s to settle. Hit gust for a downdraft that knocks it about a third of a metre out of the sky, then try the presets — every tune sits the same exam, so sluggish (18 s of creeping) and twitchy (95% past the line) and well tuned (5%, on the line in 2.5 s) are honestly comparable. The knob to feel first is Ki: drag it to zero and the quad parks itself exactly 9.81 / Kp below the line and stays there forever, because proportional throttle alone needs a permanent error to generate the thrust that cancels gravity.
Source — the code you see is the code that just ran 1204 lines 50.5 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">
<title>Quadcopter Hover Tuner — sloprun.dev</title>
<style>
/* ── sloprun design tokens (verbatim from demos/tokens.css) ───────────────── */
: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); }
/* ── end tokens ───────────────────────────────────────────────────────────── */
/* demo-local additions (scrims need alpha; tokens are opaque hex) */
:root { --scrim: rgba(246,246,243,.88); --shadow: 0 1px 2px rgba(26,29,33,.05); }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { --scrim: rgba(20,22,26,.9); --shadow: 0 1px 2px rgba(0,0,0,.35); }
}
:root[data-theme="dark"] { --scrim: rgba(20,22,26,.9); --shadow: 0 1px 2px rgba(0,0,0,.35); }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body { min-height: 100%; overflow-x: hidden; }
.wrap {
max-width: 1140px; margin: 0 auto;
padding: 22px 16px 26px;
display: flex; flex-direction: column; gap: 16px;
}
h1 { margin: 0 0 6px; font-size: clamp(21px, 3.6vw, 29px); letter-spacing: -.021em; line-height: 1.15; }
.lede { margin: 0; max-width: 68ch; color: var(--muted); font-size: 14.5px; line-height: 1.55; }
.lede b { color: var(--ink); font-weight: 600; }
.stage { display: grid; grid-template-columns: minmax(0,1fr) 300px; gap: 16px; align-items: start; }
@media (max-width: 900px) {
.stage { grid-template-columns: minmax(0,1fr); }
/* gust is the fun bit — do not bury it under three cards on a phone */
.panel > #ctlCard { order: -1; }
}
.left { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.panel { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.card {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 12px 13px; box-shadow: var(--shadow);
}
.card h2 {
margin: 0 0 10px; font-family: var(--font-mono); font-size: 10.5px;
letter-spacing: .1em; text-transform: uppercase; color: var(--muted); font-weight: 600;
}
/* the flight scene */
.field {
position: relative; overflow: hidden;
border: 1px solid var(--line); border-radius: var(--radius);
background-color: var(--surface);
background-image: radial-gradient(var(--line) 1.1px, transparent 1.2px);
background-size: 30px 30px; background-position: -15px -15px;
aspect-ratio: 16 / 10; box-shadow: var(--shadow);
}
@media (max-width: 900px) { .field { aspect-ratio: 4 / 3; } }
@media (max-width: 520px) { .field { aspect-ratio: 1 / 1; } }
canvas { display: block; width: 100%; height: 100%; touch-action: manipulation; }
#scene { cursor: crosshair; }
canvas:focus { outline: none; }
canvas:focus-visible { outline: 2px solid var(--accent); outline-offset: -3px; }
.hud {
position: absolute; left: 10px; top: 10px; display: flex; flex-wrap: wrap; gap: 4px 6px;
pointer-events: none; max-width: calc(100% - 20px);
}
.hud span {
font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .04em;
color: var(--muted); background: var(--scrim);
border: 1px solid var(--line); border-radius: 999px; padding: 3px 8px;
font-variant-numeric: tabular-nums; white-space: nowrap;
}
.hud span b { color: var(--ink); font-weight: 600; }
.hint {
position: absolute; right: 10px; bottom: 10px; pointer-events: none;
font-family: var(--font-mono); font-size: 11px; letter-spacing: .01em;
color: var(--muted); background: var(--scrim);
border: 1px solid var(--line); border-radius: 999px; padding: 5px 10px;
transition: opacity .45s ease; max-width: calc(100% - 20px);
}
.hint[hidden] { display: none; }
.hint.gone { opacity: 0; }
.hint .h-narrow { display: none; }
@media (max-width: 560px) {
/* the long version ran over the GROUND label on a phone */
.hint { font-size: 10px; padding: 4px 9px; }
.hint .h-wide { display: none; }
.hint .h-narrow { display: inline; }
}
/* the paused card sits at the foot of the scene instead of covering it, so a
reduced-motion visitor still sees the quad, the line and the ruler at once */
.overlay { position: absolute; inset: auto 10px 10px; display: flex; justify-content: center; pointer-events: none; }
.overlay[hidden] { display: none; }
.overlay .box {
pointer-events: auto; display: flex; align-items: center; gap: 10px; text-align: left;
max-width: 100%; background: var(--scrim); border: 1px solid var(--line);
border-radius: var(--radius); padding: 8px 10px; box-shadow: var(--shadow);
}
.overlay .box button { flex: none; }
.overlay p { margin: 0; font-size: 11.5px; line-height: 1.4; color: var(--muted); }
@media (max-width: 560px) { .overlay .box { flex-direction: column; align-items: stretch; gap: 7px; } }
/* response plot */
.plotwrap { position: relative; height: 152px; }
@media (max-width: 520px) { .plotwrap { height: 124px; } }
.plotwrap canvas { border-radius: 6px; }
.legend {
display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: 8px;
font-family: var(--font-mono); font-size: 10px; letter-spacing: .06em;
text-transform: uppercase; color: var(--muted);
}
.legend i { display: inline-block; width: 14px; height: 0; vertical-align: middle;
border-top: 2px solid var(--muted); margin-right: 6px; }
.legend .alt i { border-top-color: var(--accent); }
.legend .set i { border-top-style: dashed; }
.legend .gst i { border-top-color: var(--danger); }
/* metrics */
.stats { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 10px; }
.stat { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.stat .v {
font-family: var(--font-mono); font-size: clamp(16px, 4.2vw, 20px); line-height: 1.05;
font-variant-numeric: tabular-nums; letter-spacing: -.02em; white-space: nowrap;
}
.stat .v .u { font-size: 11px; color: var(--muted); margin-left: 2px; letter-spacing: 0; }
.stat .k {
font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .09em;
text-transform: uppercase; color: var(--muted);
}
.stat .v.warn { color: var(--accent); }
.stat .v.bad { color: var(--danger); }
.stat .v.good { color: var(--run); }
.pill {
display: inline-flex; align-items: center; gap: 6px; margin-top: 11px;
font-family: var(--font-mono); font-size: 11px; letter-spacing: .04em;
padding: 3px 9px 3px 7px; border-radius: 999px;
border: 1px solid var(--line); color: var(--muted); background: var(--bg);
}
.pill .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); flex: none; }
.pill.on { color: var(--run); background: var(--run-soft); border-color: transparent; }
.pill.on .dot { background: var(--run); }
.pill.work { color: var(--accent); background: var(--accent-soft); border-color: transparent; }
.pill.work .dot { background: var(--accent); }
.pill.bad { color: var(--danger); border-color: var(--danger); background: var(--surface); }
.pill.bad .dot { background: var(--danger); }
.cap { margin: 9px 0 0; font-size: 11.5px; line-height: 1.45; color: var(--muted); }
.cap b { color: var(--ink); font-weight: 600; }
/* controls */
.ctl { display: block; margin-bottom: 14px; }
.ctl:last-child { margin-bottom: 2px; }
.ctl-top { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; margin-bottom: 3px; }
.ctl-top label { font-size: 13px; }
.ctl-top label .g { font-family: var(--font-mono); color: var(--accent); font-size: 12px; margin-right: 5px; }
.ctl-top .val {
font-family: var(--font-mono); font-size: 12px; color: var(--muted);
font-variant-numeric: tabular-nums;
}
.ctl .desc { display: block; font-size: 11.5px; color: var(--muted); line-height: 1.4; margin-top: 3px; }
input[type=range] {
-webkit-appearance: none; appearance: none; display: block;
width: 100%; height: 20px; margin: 0; background: transparent; cursor: pointer;
}
input[type=range]::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: var(--line); }
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none; width: 15px; height: 15px; margin-top: -5.5px;
border-radius: 50%; background: var(--accent); border: 2px solid var(--surface);
box-shadow: 0 0 0 1px var(--line);
}
input[type=range]::-moz-range-track { height: 4px; border-radius: 2px; background: var(--line); }
input[type=range]::-moz-range-thumb {
width: 13px; height: 13px; border-radius: 50%; background: var(--accent);
border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--line);
}
input[type=range]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }
button {
font: inherit; font-size: 13px; font-family: var(--font-sans);
color: var(--ink); background: var(--surface);
border: 1px solid var(--line); border-radius: 8px;
padding: 7px 11px; cursor: pointer; line-height: 1.2;
transition: border-color .12s ease, color .12s ease, background .12s ease;
}
button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button:disabled { opacity: .42; cursor: not-allowed; }
button.primary { background: var(--accent); color: var(--surface); border-color: var(--accent); font-weight: 600; }
button.primary:hover:not(:disabled) { color: var(--surface); filter: brightness(1.07); }
button.big { font-size: 14px; padding: 9px 18px; }
.row { display: flex; flex-wrap: wrap; gap: 7px; }
.row > button { flex: 1 1 auto; }
.chips { display: flex; flex-wrap: wrap; gap: 7px; }
.chips button { flex: 1 1 auto; font-family: var(--font-mono); font-size: 11.5px; letter-spacing: .02em; padding: 6px 9px; }
.chips button[aria-pressed="true"] {
background: var(--accent-soft); color: var(--accent); border-color: var(--accent);
}
footer {
font-family: var(--font-mono); font-size: 11px; color: var(--muted);
letter-spacing: .05em; padding-top: 2px;
}
.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;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition-duration: .001ms !important; animation-duration: .001ms !important; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Quadcopter Hover Tuner</h1>
<p class="lede">
A hobby quadcopter cannot simply "hold 6 metres" — something has to decide, hundreds of times
a second, how much throttle to give the props. That something is a <b>PID controller</b>, and it
has exactly three knobs. Tune them, <b>click the sky</b> to ask for a different altitude, and hit
<b>gust</b> to shove the quad off its perch.
</p>
</header>
<main class="stage">
<div class="left">
<div class="field" id="field">
<canvas id="scene" tabindex="0" aria-label="Side view of a hovering quadcopter. Click, or press the up and down arrow keys, to change the altitude it is asked to hold. Press Enter or Space for a wind gust.">
A side-view animation of a quadcopter holding altitude. Your browser does not support canvas.
</canvas>
<div class="hud" aria-hidden="true">
<span>ALT <b id="hAlt">6.00</b> m</span>
<span>V/S <b id="hVs">+0.00</b> m/s</span>
<span>THR <b id="hThr">38</b> %</span>
</div>
<div class="hint" id="hint"><span class="h-wide">click the sky to change the altitude</span><span class="h-narrow">tap the sky to set the altitude</span></div>
<div class="overlay" id="overlay" hidden>
<div class="box">
<button class="primary big" id="overlayPlay" type="button">▶ Fly it</button>
<p id="overlayNote">Paused. Every control still works.</p>
</div>
</div>
</div>
<section class="card" aria-label="Altitude response over time">
<h2>Response — last 12 seconds</h2>
<div class="plotwrap">
<canvas id="plot" role="img" aria-label="Line chart of altitude against time with the requested altitude drawn as a dashed line.">
A chart of altitude over time.
</canvas>
</div>
<div class="legend">
<span class="alt"><i></i>altitude</span>
<span class="set"><i></i>asked for</span>
<span class="gst"><i></i>gust</span>
</div>
</section>
</div>
<div class="panel">
<section class="card" aria-label="Hold quality">
<h2>Hold quality</h2>
<div class="stats">
<div class="stat"><span class="v" id="sOver">—</span><span class="k">overshoot</span></div>
<div class="stat"><span class="v" id="sSettle">—</span><span class="k">settle time</span></div>
<div class="stat"><span class="v" id="sErr">—</span><span class="k">hold error</span></div>
</div>
<span class="pill" id="pill"><span class="dot"></span><span id="pillText">idle</span></span>
<p class="cap">Measured from the last test step, altitude change or gust. <b>Settled</b> means it stayed inside the band around the line for a full second.</p>
</section>
<section class="card" aria-label="Controller gains">
<h2>The three knobs</h2>
<div class="ctl">
<div class="ctl-top">
<label for="kp"><span class="g">Kp</span> Push</label><span class="val" id="kpV" aria-hidden="true">16.0</span>
</div>
<input type="range" id="kp" min="0" max="30" step="0.5" value="16" aria-describedby="kpD">
<span class="desc" id="kpD">Reacts to the gap right now. Too little and it hangs below the line; too much and it charges straight past it.</span>
</div>
<div class="ctl">
<div class="ctl-top">
<label for="ki"><span class="g">Ki</span> Memory</label><span class="val" id="kiV" aria-hidden="true">6.00</span>
</div>
<input type="range" id="ki" min="0" max="15" step="0.25" value="6" aria-describedby="kiD">
<span class="desc" id="kiD">Notices a gap that never closes and quietly adds throttle. Drag it to zero and watch the quad sag below the line forever.</span>
</div>
<div class="ctl">
<div class="ctl-top">
<label for="kd"><span class="g">Kd</span> Brakes</label><span class="val" id="kdV" aria-hidden="true">3.50</span>
</div>
<input type="range" id="kd" min="0" max="12" step="0.1" value="3.5" aria-describedby="kdD">
<span class="desc" id="kdD">Watches how fast the gap is closing and eases off early. Damps the bounce — too much and the throttle gets fidgety.</span>
</div>
</section>
<section class="card" aria-label="Presets">
<h2>Try a tune</h2>
<div class="chips" id="presets" role="group" aria-label="Gain presets">
<button type="button" data-p="0" aria-pressed="false">sluggish</button>
<button type="button" data-p="1" aria-pressed="false">twitchy</button>
<button type="button" data-p="2" aria-pressed="false">well tuned</button>
</div>
<p class="cap" id="presetNote">Loaded with a middling tune — it holds the line, but it bounces on the way. Every tune flies the <b>same</b> 3 → 6 m test step, so the three numbers above compare like for like.</p>
</section>
<section class="card" id="ctlCard" aria-label="Flight controls">
<h2>Controls</h2>
<div class="row" style="margin-bottom:7px">
<button type="button" class="primary" id="gustBtn">Gust</button>
<button type="button" id="takeoffBtn">Land & take off</button>
</div>
<div class="row">
<button type="button" id="playBtn">Pause</button>
<button type="button" id="upBtn" title="Re-fly the standard 3 to 6 metre step with the current gains">Test step</button>
</div>
</section>
</div>
</main>
<footer>demo · sloprun.dev</footer>
</div>
<div class="sr-only" role="status" aria-live="polite" id="live"></div>
<script>
(function () {
"use strict";
var root = document.documentElement;
var mqDark = window.matchMedia ? matchMedia("(prefers-color-scheme: dark)") : null;
var mqRM = window.matchMedia ? matchMedia("(prefers-reduced-motion: reduce)") : null;
var reduced = !!(mqRM && mqRM.matches);
/* ── dom ────────────────────────────────────────────────────────────────── */
var field = document.getElementById("field");
var scene = document.getElementById("scene");
var plot = document.getElementById("plot");
var sctx = scene.getContext ? scene.getContext("2d") : null;
var pctx = plot.getContext ? plot.getContext("2d") : null;
if (!sctx || !pctx) return;
var hAlt = document.getElementById("hAlt");
var hVs = document.getElementById("hVs");
var hThr = document.getElementById("hThr");
var hint = document.getElementById("hint");
var overlay = document.getElementById("overlay");
var overlayPlay = document.getElementById("overlayPlay");
var overlayNote = document.getElementById("overlayNote");
var sOver = document.getElementById("sOver");
var sSettle = document.getElementById("sSettle");
var sErr = document.getElementById("sErr");
var pill = document.getElementById("pill");
var pillText = document.getElementById("pillText");
var live = document.getElementById("live");
var presetsEl = document.getElementById("presets");
var presetNote = document.getElementById("presetNote");
var gustBtn = document.getElementById("gustBtn");
var takeoffBtn = document.getElementById("takeoffBtn");
var playBtn = document.getElementById("playBtn");
var upBtn = document.getElementById("upBtn");
var sliders = { kp: document.getElementById("kp"), ki: document.getElementById("ki"), kd: document.getElementById("kd") };
var vals = { kp: document.getElementById("kpV"), ki: document.getElementById("kiV"), kd: document.getElementById("kdV") };
/* ── colours read from the tokens, so both themes just work ─────────────── */
var COL = {
surface: [255,255,255], bg: [246,246,243], ink: [26,29,33], muted: [90,96,104],
line: [227,226,220], accent: [192,86,0], run: [26,127,55], danger: [201,60,55]
};
function hexToRgb(h) {
h = String(h).trim();
if (h.charAt(0) === "#") h = h.slice(1);
if (h.length === 3) h = h.charAt(0)+h.charAt(0)+h.charAt(1)+h.charAt(1)+h.charAt(2)+h.charAt(2);
var n = parseInt(h, 16);
if (h.length !== 6 || isNaN(n)) return null;
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function tok(name, fb) {
var v = getComputedStyle(root).getPropertyValue(name);
return hexToRgb(v) || fb;
}
function rgba(c, a) { return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")"; }
function readTheme() {
COL.surface = tok("--surface", COL.surface);
COL.bg = tok("--bg", COL.bg);
COL.ink = tok("--ink", COL.ink);
COL.muted = tok("--muted", COL.muted);
COL.line = tok("--line", COL.line);
COL.accent = tok("--accent", COL.accent);
COL.run = tok("--run", COL.run);
COL.danger = tok("--danger", COL.danger);
}
var MONO = 'ui-monospace, "SF Mono", Menlo, Consolas, monospace';
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(); render();
}
});
if (mqDark) {
var onScheme = function () { readTheme(); render(); };
if (mqDark.addEventListener) mqDark.addEventListener("change", onScheme);
else if (mqDark.addListener) mqDark.addListener(onScheme);
}
/* ── flight model ───────────────────────────────────────────────────────────
One axis only: altitude. Thrust points up, gravity pulls down, air drags.
The controller may command 0 .. U_MAX of upward acceleration; hovering needs
exactly G of it, which is the whole reason the "memory" knob exists. */
var G = 9.81; /* m/s^2 */
var DRAG = 0.35; /* 1/s, linear air drag on climb rate */
var U_MAX = 26; /* m/s^2 at full throttle -> thrust-to-weight ~2.6 */
var ALT_MAX = 11.0; /* ceiling of the scene, metres */
var ALT_VIEW = 11.0;
var SUB = 1 / 300; /* physics step */
var SAMPLE = 1 / 60; /* history sample rate */
var WINDOW = 12; /* seconds of plot */
var y = 0, v = 0, I = 0, u = 0;
var sp = 3.0;
var simT = 0;
var Kp = 16, Ki = 6, Kd = 3.5;
var gustT0 = -99, gustDur = 0.42, gustAmp = 0;
var sway = 0, swayV = 0, spin = 0;
/* ── rolling history (ring buffer) ──────────────────────────────────────── */
var CAP = 1024;
var hT = new Float32Array(CAP), hY = new Float32Array(CAP), hS = new Float32Array(CAP);
var hHead = 0, hLen = 0, sampleAcc = 0;
var gusts = [];
function pushSample(t, alt, set) {
var i;
if (hLen < CAP) { i = (hHead + hLen) % CAP; hLen++; }
else { i = hHead; hHead = (hHead + 1) % CAP; }
hT[i] = t; hY[i] = alt; hS[i] = set;
}
function clearHistory() { hHead = 0; hLen = 0; gusts.length = 0; sampleAcc = 0; }
/* ── the episode: everything the metrics are measured against ───────────── */
var ep = null, ssErr = 0;
function newEpisode(kind) {
ep = { t0: simT, y0: y, kind: kind, dev: 0, sgn: (y > sp ? 1 : -1),
over: 0, lastExceed: simT, settled: false, settleT: 0 };
ssErr = Math.abs(y - sp);
}
function updateMetrics(dt) {
if (!ep) return;
var e = y - sp; /* signed: above the line is positive */
var ae = Math.abs(e);
if (ae > ep.dev) { ep.dev = ae; ep.sgn = e >= 0 ? 1 : -1; }
var band = Math.max(0.05, 0.02 * ep.dev);
ep.band = band;
if (ep.dev > 0.02) {
var os = (-ep.sgn * e) / ep.dev;
if (os > ep.over) ep.over = os;
}
if (ae > band) { ep.lastExceed = simT; ep.settled = false; }
else if (!ep.settled && simT - ep.lastExceed >= 1.0) {
ep.settled = true; ep.settleT = ep.lastExceed - ep.t0;
}
var k = Math.min(1, dt / 0.7);
ssErr += (ae - ssErr) * k;
}
/* ── one physics substep ────────────────────────────────────────────────── */
function substep(dt) {
var e = sp - y;
/* derivative acts on the measurement, not the error, so moving the line
never produces a throttle spike */
var raw = Kp * e + Ki * I - Kd * v;
u = raw < 0 ? 0 : (raw > U_MAX ? U_MAX : raw);
/* anti-windup: stop stuffing the integrator once the props are maxed out */
var saturated = (raw > U_MAX && e > 0) || (raw < 0 && e < 0);
if (!saturated) I += e * dt;
var lim = Ki > 0.01 ? U_MAX / Ki : 0;
if (I > lim) I = lim; else if (I < -lim) I = -lim;
var g = 0;
if (simT - gustT0 < gustDur) {
g = gustAmp * Math.sin(Math.PI * (simT - gustT0) / gustDur);
}
var a = u - G - DRAG * v + g;
v += a * dt;
if (v > 40) v = 40; else if (v < -40) v = -40;
y += v * dt;
if (y <= 0) { y = 0; if (v < 0) v = 0; }
if (y >= ALT_MAX) { y = ALT_MAX; if (v > 0) v = 0; }
/* horizontal sway is decoration only — a gust shoves it sideways too */
swayV += (g * 0.55) * dt;
swayV += (-sway * 7.0 - swayV * 1.9) * dt;
sway += swayV * dt;
if (sway > 3) sway = 3; else if (sway < -3) sway = -3;
spin += (0.6 + 2.4 * (u / U_MAX)) * 60 * dt;
simT += dt;
updateMetrics(dt);
sampleAcc += dt;
if (sampleAcc >= SAMPLE) { sampleAcc -= SAMPLE; pushSample(simT, y, sp); }
}
var acc = 0;
function advance(dt) {
acc += dt;
var guard = 0;
while (acc >= SUB && guard < 2000) { substep(SUB); acc -= SUB; guard++; }
if (guard >= 2000) acc = 0;
}
/* ── canvas sizing ──────────────────────────────────────────────────────── */
var SW = 0, SH = 0, PW = 0, PH = 0;
function fit(cv, ctx2) {
var rect = cv.getBoundingClientRect();
var w = Math.max(80, Math.round(rect.width));
var h = Math.max(60, Math.round(rect.height));
var dpr = Math.min(2, window.devicePixelRatio || 1);
var nw = Math.round(w * dpr), nh = Math.round(h * dpr);
if (cv.width !== nw || cv.height !== nh) { cv.width = nw; cv.height = nh; }
ctx2.setTransform(dpr, 0, 0, dpr, 0, 0);
return [w, h];
}
function resize() {
var a = fit(scene, sctx); SW = a[0]; SH = a[1];
var b = fit(plot, pctx); PW = b[0]; PH = b[1];
render();
}
/* ── scene ──────────────────────────────────────────────────────────────── */
function drawScene() {
var w = SW, h = SH, ctx = sctx;
if (!w || !h) return;
ctx.clearRect(0, 0, w, h);
var padT = 16, padB = 30, padL = w < 340 ? 34 : 38, padR = 14;
var span = h - padT - padB;
function ay(alt) { return h - padB - (alt / ALT_VIEW) * span; }
/* ground */
var gy = ay(0);
ctx.fillStyle = rgba(COL.muted, 0.07);
ctx.fillRect(0, gy, w, h - gy);
ctx.strokeStyle = rgba(COL.muted, 0.45); ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, gy + 0.5); ctx.lineTo(w, gy + 0.5); ctx.stroke();
ctx.strokeStyle = rgba(COL.muted, 0.2);
ctx.beginPath();
for (var gx = -20; gx < w + 20; gx += 11) { ctx.moveTo(gx, h); ctx.lineTo(gx + 12, gy); }
ctx.stroke();
/* altitude ruler */
ctx.font = "10px " + MONO;
ctx.textBaseline = "middle"; ctx.textAlign = "right";
for (var m = 0; m <= 10; m += 2) {
var ty = ay(m);
ctx.strokeStyle = rgba(COL.line, 0.9);
ctx.beginPath(); ctx.moveTo(padL, ty + 0.5); ctx.lineTo(w - padR, ty + 0.5); ctx.stroke();
ctx.fillStyle = rgba(COL.muted, 0.95);
ctx.fillText(m === 10 ? "10 m" : String(m), padL - 8, ty);
}
/* the band we call "settled", then the line itself */
var band = ep && ep.band ? ep.band : 0.05;
var bt = ay(sp + band), bb = ay(sp - band);
var holding = !!(ep && ep.settled);
if (bb - bt >= 1.5) {
ctx.fillStyle = holding ? rgba(COL.run, 0.13) : rgba(COL.accent, 0.08);
ctx.fillRect(padL, bt, w - padL - padR, bb - bt);
}
var sy = ay(sp);
ctx.save();
ctx.setLineDash([7, 5]);
ctx.strokeStyle = holding ? rgba(COL.run, 0.95) : rgba(COL.accent, 0.95);
ctx.lineWidth = 1.6;
ctx.beginPath(); ctx.moveTo(padL, sy + 0.5); ctx.lineTo(w - padR, sy + 0.5); ctx.stroke();
ctx.restore();
/* label chip on the line */
var lbl = "HOLD " + sp.toFixed(2) + " m";
ctx.font = "10px " + MONO;
var lw = ctx.measureText(lbl).width + 12;
var lx = w - padR - lw, ly = sy - 9 < padT ? sy + 9 : sy - 9;
ctx.fillStyle = rgba(COL.surface, 0.92);
roundRect(ctx, lx, ly - 8, lw, 16, 8); ctx.fill();
ctx.strokeStyle = holding ? rgba(COL.run, 0.5) : rgba(COL.accent, 0.45);
ctx.lineWidth = 1; roundRect(ctx, lx, ly - 8, lw, 16, 8); ctx.stroke();
ctx.fillStyle = holding ? rgba(COL.run, 1) : rgba(COL.accent, 1);
ctx.textAlign = "center";
ctx.fillText(lbl, lx + lw / 2, ly);
/* the quad */
var qx = w * 0.5 + sway * (w < 400 ? 5 : 8);
var qy = ay(y);
var s = Math.max(0.62, Math.min(1.25, w / 620));
var tilt = Math.max(-0.28, Math.min(0.28, -swayV * 0.06));
/* gap marker between craft and line */
var e = y - sp;
if (Math.abs(e) > 0.06) {
ctx.save();
ctx.setLineDash([2, 3]);
ctx.strokeStyle = rgba(COL.muted, 0.75); ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(qx, qy); ctx.lineTo(qx, sy); ctx.stroke();
ctx.restore();
if (Math.abs(e) > 0.3) {
ctx.font = "10px " + MONO;
ctx.textBaseline = "middle";
var gapTxt = (e > 0 ? "+" : "−") + Math.abs(e).toFixed(2) + " m";
var gw = ctx.measureText(gapTxt).width + 10;
var off = 46 * s, gcx;
if (qx + off + gw < w - padR) gcx = qx + off + gw / 2;
else gcx = qx - off - gw / 2;
var gcy = (qy + sy) / 2;
ctx.fillStyle = rgba(COL.surface, 0.88);
roundRect(ctx, gcx - gw / 2, gcy - 8, gw, 16, 8); ctx.fill();
ctx.fillStyle = rgba(COL.muted, 1);
ctx.textAlign = "center";
ctx.fillText(gapTxt, gcx, gcy);
}
}
/* downwash under each rotor, opacity tracks throttle */
var thr = u / U_MAX;
ctx.save();
ctx.translate(qx, qy); ctx.rotate(tilt);
var arm = 26 * s;
for (var k = -1; k <= 1; k += 2) {
var dx = k * arm;
var gdt = ctx.createLinearGradient(0, -4 * s, 0, 46 * s);
gdt.addColorStop(0, rgba(COL.accent, 0.20 * thr + 0.03));
gdt.addColorStop(1, rgba(COL.accent, 0));
ctx.fillStyle = gdt;
ctx.beginPath();
ctx.moveTo(dx - 9 * s, -2 * s); ctx.lineTo(dx + 9 * s, -2 * s);
ctx.lineTo(dx + 15 * s, 46 * s); ctx.lineTo(dx - 15 * s, 46 * s);
ctx.closePath(); ctx.fill();
}
/* legs */
ctx.strokeStyle = rgba(COL.muted, 0.9); ctx.lineWidth = 1.6 * s;
ctx.beginPath();
ctx.moveTo(-9 * s, 5 * s); ctx.lineTo(-13 * s, 13 * s);
ctx.moveTo(9 * s, 5 * s); ctx.lineTo(13 * s, 13 * s);
ctx.moveTo(-16 * s, 13 * s); ctx.lineTo(-10 * s, 13 * s);
ctx.moveTo(16 * s, 13 * s); ctx.lineTo(10 * s, 13 * s);
ctx.stroke();
/* arms + motors */
ctx.strokeStyle = rgba(COL.ink, 0.85); ctx.lineWidth = 3 * s; ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(-8 * s, -1 * s); ctx.lineTo(-arm, -7 * s);
ctx.moveTo(8 * s, -1 * s); ctx.lineTo(arm, -7 * s);
ctx.stroke();
ctx.lineCap = "butt";
ctx.fillStyle = rgba(COL.ink, 0.85);
for (k = -1; k <= 1; k += 2) ctx.fillRect(k * arm - 2.6 * s, -13 * s, 5.2 * s, 7 * s);
/* body */
ctx.fillStyle = rgba(COL.ink, 0.92);
roundRect(ctx, -15 * s, -6 * s, 30 * s, 12 * s, 4 * s); ctx.fill();
/* status light: amber while it works, green only once it is genuinely holding */
ctx.fillStyle = holding ? rgba(COL.run, 1) : rgba(COL.accent, 1);
ctx.beginPath(); ctx.arc(9 * s, 0, 2.2 * s, 0, 6.2832); ctx.fill();
/* props — side on, so a spinning blade foreshortens to a moving sliver */
var pr = 17 * s;
for (k = -1; k <= 1; k += 2) {
var mx = k * arm, my = -14 * s;
ctx.strokeStyle = rgba(COL.accent, 0.28 + 0.35 * thr);
ctx.lineWidth = 1.2 * s;
ctx.beginPath(); ctx.ellipse(mx, my, pr, 2.6 * s, 0, 0, 6.2832); ctx.stroke();
var f = reduced ? 0.85 : Math.abs(Math.cos(spin * (k > 0 ? 1 : -1.13)));
ctx.strokeStyle = rgba(COL.ink, 0.75);
ctx.lineWidth = 2 * s;
ctx.beginPath(); ctx.moveTo(mx - pr * f, my); ctx.lineTo(mx + pr * f, my); ctx.stroke();
}
ctx.restore();
/* gust streaks */
var age = simT - gustT0;
if (age >= 0 && age < gustDur + 0.35) {
var fade = Math.max(0, 1 - age / (gustDur + 0.35));
ctx.strokeStyle = rgba(COL.danger, 0.55 * fade);
ctx.lineWidth = 1.4;
var dir = gustAmp < 0 ? 1 : -1;
for (var q = 0; q < 5; q++) {
var ry = qy - 34 * s + q * 17 * s + (q % 2) * 5;
var len = (26 + q * 9) * s;
var ox = qx - 46 * s - q * 6 * s;
ctx.beginPath();
ctx.moveTo(ox, ry); ctx.lineTo(ox + len, ry + dir * 8 * s);
ctx.stroke();
}
ctx.font = "10px " + MONO;
ctx.textAlign = "right"; ctx.textBaseline = "top";
ctx.fillStyle = rgba(COL.danger, fade);
ctx.fillText(gustAmp < 0 ? "GUST ↓" : "GUST ↑", w - 12, padT - 6);
}
/* ground label */
ctx.font = "9.5px " + MONO;
ctx.textAlign = "left"; ctx.textBaseline = "top";
ctx.fillStyle = rgba(COL.muted, 0.9);
ctx.fillText("GROUND", padL, gy + 6);
}
function roundRect(ctx, x, yy, w, h, r) {
var rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, yy);
ctx.arcTo(x + w, yy, x + w, yy + h, rr);
ctx.arcTo(x + w, yy + h, x, yy + h, rr);
ctx.arcTo(x, yy + h, x, yy, rr);
ctx.arcTo(x, yy, x + w, yy, rr);
ctx.closePath();
}
/* ── response plot ──────────────────────────────────────────────────────────
The vertical axis zooms to whatever the last twelve seconds actually did.
A 21% overshoot on a 1.5 m step is 30 cm — invisible on a fixed 0–11 m
axis, which is exactly the thing this plot exists to show. The scene
canvas keeps the absolute ruler, so nothing is lost. */
var pv0 = 0, pv1 = ALT_VIEW, pvSet = false;
function plotRange() {
var lo = sp, hi = sp, k, i, t0 = simT - WINDOW;
if (y < lo) lo = y; if (y > hi) hi = y;
for (k = 0; k < hLen; k++) {
i = (hHead + k) % CAP;
if (hT[i] < t0) continue;
if (hY[i] < lo) lo = hY[i]; else if (hY[i] > hi) hi = hY[i];
if (hS[i] < lo) lo = hS[i]; else if (hS[i] > hi) hi = hS[i];
}
var MIN = 1.0, mid;
var pad = Math.max(0.08, (hi - lo) * 0.18);
lo -= pad; hi += pad;
if (hi - lo < MIN) { mid = (lo + hi) / 2; lo = mid - MIN / 2; hi = mid + MIN / 2; }
if (lo < 0) { hi -= lo; lo = 0; }
if (hi > ALT_MAX) { lo = Math.max(0, lo - (hi - ALT_MAX)); hi = ALT_MAX; }
return [lo, hi];
}
/* smallest round step (1, 2 or 5 times a power of ten) that keeps the axis
down to five gaps — so there are always three or four labelled lines */
var STEPS = [1, 2, 5, 10];
function niceStep(span) {
var mag = Math.pow(10, Math.floor(Math.log(span / 4) / Math.LN10));
for (var i = 0; i < STEPS.length; i++) {
if (span / (STEPS[i] * mag) <= 5) return STEPS[i] * mag;
}
return 10 * mag;
}
function drawPlot() {
var w = PW, h = PH, ctx = pctx;
if (!w || !h) return;
ctx.clearRect(0, 0, w, h);
/* ease the axis so a new step slides into frame instead of snapping */
var want = plotRange();
if (!pvSet || reduced || !active) { pv0 = want[0]; pv1 = want[1]; pvSet = true; }
else { pv0 += (want[0] - pv0) * 0.09; pv1 += (want[1] - pv1) * 0.09; }
if (pv1 - pv0 < 0.2) pv1 = pv0 + 0.2;
var padL = 40, padR = 8, padT = 8, padB = 16;
var iw = w - padL - padR, ih = h - padT - padB;
var t1 = simT, t0 = simT - WINDOW;
function px(t) { return padL + (t - t0) / WINDOW * iw; }
function py(alt) { return padT + ih - (alt - pv0) / (pv1 - pv0) * ih; }
/* frame + gridlines */
ctx.strokeStyle = rgba(COL.line, 1); ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(padL + .5, padT); ctx.lineTo(padL + .5, padT + ih + .5); ctx.lineTo(w - padR, padT + ih + .5);
ctx.stroke();
ctx.font = "9.5px " + MONO;
ctx.textAlign = "right"; ctx.textBaseline = "middle";
var step = niceStep(pv1 - pv0);
var dec = step >= 1 ? 0 : (step >= 0.1 ? 1 : 2);
var ticks = [];
for (var m = Math.ceil(pv0 / step) * step; m <= pv1 + 1e-9; m += step) {
var gy = py(m);
if (gy < padT + 5 || gy > padT + ih - 1) continue;
ticks.push([m, gy]);
}
for (var ti = 0; ti < ticks.length; ti++) {
ctx.strokeStyle = rgba(COL.line, 0.85);
ctx.beginPath(); ctx.moveTo(padL, ticks[ti][1] + .5); ctx.lineTo(w - padR, ticks[ti][1] + .5); ctx.stroke();
ctx.fillStyle = rgba(COL.muted, 0.95);
/* the topmost tick carries the unit, so the axis says it once */
ctx.fillText(ticks[ti][0].toFixed(dec) + (ti === ticks.length - 1 ? " m" : ""), padL - 6, ticks[ti][1]);
}
ctx.textAlign = "center"; ctx.textBaseline = "top";
for (var tt = 0; tt <= WINDOW; tt += 4) {
var x = px(t0 + tt);
ctx.fillStyle = rgba(COL.muted, 0.9);
ctx.fillText(tt === WINDOW ? "now" : "-" + (WINDOW - tt) + "s", x, padT + ih + 4);
}
if (hLen < 2) return;
ctx.save();
ctx.beginPath(); ctx.rect(padL, padT, iw, ih); ctx.clip();
/* settle band around the current line */
var band = ep && ep.band ? ep.band : 0.05;
var holding = !!(ep && ep.settled);
var bt = py(sp + band), bb = py(sp - band);
if (bb - bt < 2) { var mid = (bb + bt) / 2; bt = mid - 1; bb = mid + 1; }
ctx.fillStyle = holding ? rgba(COL.run, 0.14) : rgba(COL.accent, 0.09);
ctx.fillRect(padL, bt, iw, bb - bt);
/* gust marks */
for (var q = 0; q < gusts.length; q++) {
if (gusts[q] < t0) continue;
var gx = px(gusts[q]);
ctx.strokeStyle = rgba(COL.danger, 0.55); ctx.lineWidth = 1.2;
ctx.beginPath(); ctx.moveTo(gx, padT); ctx.lineTo(gx, padT + ih); ctx.stroke();
}
var i, k, first;
/* what the pilot asked for (a staircase) */
ctx.strokeStyle = rgba(COL.muted, 0.95); ctx.lineWidth = 1.3;
ctx.save(); ctx.setLineDash([5, 4]);
ctx.beginPath(); first = true;
var prevS = 0;
for (k = 0; k < hLen; k++) {
i = (hHead + k) % CAP;
if (hT[i] < t0) { prevS = hS[i]; continue; }
var xx = px(hT[i]);
if (first) { ctx.moveTo(xx, py(hS[i])); first = false; }
else {
if (hS[i] !== prevS) ctx.lineTo(xx, py(prevS));
ctx.lineTo(xx, py(hS[i]));
}
prevS = hS[i];
}
if (!first) ctx.lineTo(px(t1), py(sp));
ctx.stroke();
ctx.restore();
/* the altitude actually flown */
ctx.strokeStyle = rgba(COL.accent, 1); ctx.lineWidth = 1.8;
ctx.lineJoin = "round";
ctx.beginPath(); first = true;
for (k = 0; k < hLen; k++) {
i = (hHead + k) % CAP;
if (hT[i] < t0) continue;
var x2 = px(hT[i]), y2 = py(hY[i]);
if (first) { ctx.moveTo(x2, y2); first = false; } else ctx.lineTo(x2, y2);
}
ctx.stroke();
ctx.restore();
/* live dot at the right edge */
var dx = px(t1), dy = py(y);
if (dx > padL && dy > padT - 4 && dy < padT + ih + 4) {
ctx.fillStyle = holding ? rgba(COL.run, 1) : rgba(COL.accent, 1);
ctx.beginPath(); ctx.arc(Math.min(dx, w - padR - 1), dy, 2.8, 0, 6.2832); ctx.fill();
}
}
function render() { drawScene(); drawPlot(); }
/* ── readouts ───────────────────────────────────────────────────────────── */
var lastUi = -1;
function updateUi(force) {
if (!force && simT - lastUi < 0.1) return;
lastUi = simT;
hAlt.textContent = y.toFixed(2);
hVs.textContent = (v > -0.005 ? "+" : "−") + Math.abs(v).toFixed(2);
hThr.textContent = String(Math.round(u / U_MAX * 100));
var elapsed = ep ? simT - ep.t0 : 0;
var real = ep && ep.dev > 0.08;
if (!real) { sOver.textContent = "—"; sOver.className = "v"; }
else {
var op = Math.round(ep.over * 100);
if (op > 999) op = 999;
sOver.innerHTML = op + '<span class="u">%</span>';
sOver.className = "v" + (op >= 40 ? " warn" : (op <= 12 && ep.settled ? " good" : ""));
}
if (ep && ep.settled) {
sSettle.innerHTML = ep.settleT.toFixed(1) + '<span class="u">s</span>';
sSettle.className = "v" + (ep.settleT <= 3 ? " good" : (ep.settleT > 8 ? " warn" : ""));
} else if (elapsed > 20) {
sSettle.innerHTML = '>20<span class="u">s</span>';
sSettle.className = "v bad";
} else { sSettle.textContent = "—"; sSettle.className = "v"; }
var cm = ssErr * 100;
sErr.innerHTML = (cm < 100 ? "±" + cm.toFixed(cm < 10 ? 1 : 0) : "±" + (cm / 100).toFixed(1)) +
'<span class="u">' + (cm < 100 ? "cm" : "m") + '</span>';
sErr.className = "v" + (ep && ep.settled && ssErr < 0.06 ? " good" : (ssErr > 0.5 ? " warn" : ""));
var cls = "pill", txt;
if (!userRunning) { txt = "paused"; }
else if (y <= 0.01 && sp > 0.3) { cls = "pill bad"; txt = "on the ground"; }
else if (ep && ep.settled && ssErr < 0.10) { cls = "pill on"; txt = "holding"; }
else if (elapsed > 12 && !(ep && ep.settled)) { cls = "pill bad"; txt = "not settling"; }
else { cls = "pill work"; txt = "correcting"; }
pill.className = cls; pillText.textContent = txt;
}
/* ── loop ───────────────────────────────────────────────────────────────── */
var userRunning = true, active = false, rafId = 0, lastT = 0;
function frame(now) {
rafId = requestAnimationFrame(frame);
var dt = (now - lastT) / 1000;
lastT = now;
if (!(dt > 0)) return;
if (dt > 0.1) dt = 0.1;
advance(dt);
render();
updateUi(false);
}
function start() {
if (active) return;
active = true; lastT = performance.now(); acc = 0;
rafId = requestAnimationFrame(frame);
}
function stop() {
if (!active) return;
active = false; cancelAnimationFrame(rafId);
}
function setStatus() {
playBtn.textContent = userRunning ? "Pause" : "Play";
playBtn.setAttribute("aria-label", userRunning ? "Pause the simulation" : "Play the simulation");
overlay.hidden = userRunning;
updateUi(true);
}
function setRunning(on) {
userRunning = on;
if (!on) { overlayNote.textContent = "Paused. Gust, test step and the presets each play a couple of seconds so you can still read the answer."; dismissHint(); }
if (on && !document.hidden) start(); else stop();
setStatus();
say(on ? "Flying." : "Paused.");
}
/* ── actions ────────────────────────────────────────────────────────────── */
/* While paused (reduced motion parks the demo on purpose) every action still
has to answer, so it plays a couple of seconds of flight instead of freezing
on a line that moved and a quad that did not. */
var PAUSE_PEEK = 2.5;
function peek() {
if (active) return;
advance(PAUSE_PEEK);
render(); updateUi(true);
}
/* The standard exam. Three tunes are only worth comparing if they sit the same
paper, so this trims the quad to a dead-steady hover at TEST_LO (Ki*I cancels
gravity exactly), back-fills a flat baseline on the plot, holds a beat, then
asks for TEST_HI. Same start, same 3 m step, same direction, every time —
which is what makes "sluggish takes 18 s, well tuned takes 2.5 s" a fact
rather than an accident of which chip you clicked first. */
var TEST_LO = 3.0, TEST_HI = 6.0;
function armTestStep() {
y = TEST_LO; v = 0; u = G; sway = 0; swayV = 0;
I = Ki > 0.01 ? G / Ki : 0;
sp = TEST_LO;
clearHistory();
for (var b = 7.0; b > 1e-6; b -= SAMPLE) pushSample(simT - b, TEST_LO, TEST_LO);
newEpisode("trim");
var n = Math.round(1.0 / SUB);
for (var i = 0; i < n; i++) substep(SUB);
sp = TEST_HI;
newEpisode("step");
}
function flyTestStep() {
armTestStep();
dismissHint();
peek();
}
function testStepSaid() {
return "Flying the standard " + TEST_LO.toFixed(0) + " to " + TEST_HI.toFixed(0) + " metre test step.";
}
function setSetpoint(alt, how) {
alt = Math.max(0.4, Math.min(10.4, Math.round(alt * 20) / 20));
if (Math.abs(alt - sp) < 0.001) return;
sp = alt;
newEpisode("step");
dismissHint();
peek();
say("Now holding " + sp.toFixed(2) + " metres" + (how ? " (" + how + ")" : "") + ".");
}
function doGust() {
var down = Math.random() < 0.72;
gustAmp = (down ? -1 : 1) * (7 + Math.random() * 4);
gustDur = 0.34 + Math.random() * 0.16;
gustT0 = simT;
gusts.push(simT);
while (gusts.length && gusts[0] < simT - WINDOW - 2) gusts.shift();
newEpisode("gust");
dismissHint();
peek();
updateUi(true);
say(down ? "Downdraft. Watch it recover." : "Updraft. Watch it recover.");
}
function takeoff() {
y = 0; v = 0; I = 0; u = 0; sway = 0; swayV = 0;
simT += 0.001;
clearHistory();
newEpisode("takeoff");
pushSample(simT, y, sp);
if (!active) { render(); }
updateUi(true);
say("Landed. Taking off again to " + sp.toFixed(2) + " metres.");
}
/* ── gains + presets ────────────────────────────────────────────────────── */
var PRESETS = [
{ name: "sluggish", kp: 4, ki: 0.5, kd: 5.0, note: "Sluggish: barely any push, so it drifts up to the line like it is thinking about something else — 11% over, and 18 s before it stops creeping." },
{ name: "twitchy", kp: 28, ki: 12, kd: 0.8, note: "Twitchy: plenty of push, almost no brakes. It sails 95% past the line and rings for another ten seconds." },
{ name: "well tuned", kp: 26, ki: 5, kd: 7.5, note: "Well tuned: nearly the same push as twitchy, but real brakes behind it. 5% overshoot, on the line in 2.5 s." }
];
function syncSlider(key) {
var val = parseFloat(sliders[key].value);
if (key === "kp") { Kp = val; vals.kp.textContent = val.toFixed(1); }
else if (key === "ki") { Ki = val; vals.ki.textContent = val.toFixed(2); }
else { Kd = val; vals.kd.textContent = val.toFixed(2); }
}
function markPreset() {
var btns = presetsEl.querySelectorAll("button"), hit = -1;
for (var i = 0; i < PRESETS.length; i++) {
var p = PRESETS[i];
if (Math.abs(p.kp - Kp) < 1e-6 && Math.abs(p.ki - Ki) < 1e-6 && Math.abs(p.kd - Kd) < 1e-6) hit = i;
}
for (var b = 0; b < btns.length; b++) btns[b].setAttribute("aria-pressed", String(b === hit));
return hit;
}
Object.keys(sliders).forEach(function (key) {
sliders[key].addEventListener("input", function () {
syncSlider(key);
var hit = markPreset();
if (hit < 0) presetNote.textContent = "Your own tune. Hit test step to sit it the same 3 → 6 m exam as the presets, or gust to see it take a punch.";
else presetNote.textContent = PRESETS[hit].note;
if (!active) { render(); updateUi(true); }
});
});
presetsEl.addEventListener("click", function (e) {
var btn = e.target.closest ? e.target.closest("button[data-p]") : null;
if (!btn) return;
var p = PRESETS[parseInt(btn.getAttribute("data-p"), 10)];
sliders.kp.value = p.kp; sliders.ki.value = p.ki; sliders.kd.value = p.kd;
syncSlider("kp"); syncSlider("ki"); syncSlider("kd");
markPreset();
presetNote.textContent = p.note;
/* always re-fly the standard step — otherwise a second preset click looks
dead, and the metrics still read from whatever the previous tune did */
flyTestStep();
say(p.name + " tune loaded. Kp " + p.kp + ", Ki " + p.ki + ", Kd " + p.kd + ". " + testStepSaid());
});
gustBtn.addEventListener("click", doGust);
takeoffBtn.addEventListener("click", takeoff);
playBtn.addEventListener("click", function () { setRunning(!userRunning); });
overlayPlay.addEventListener("click", function () { setRunning(true); scene.focus(); });
upBtn.addEventListener("click", function () { flyTestStep(); say(testStepSaid()); });
/* ── pointer + keyboard on the scene ────────────────────────────────────── */
function altFromEvent(e) {
var r = scene.getBoundingClientRect();
var h = r.height;
var padT = 16, padB = 30;
var span = h - padT - padB;
var py2 = e.clientY - r.top;
return ((h - padB) - py2) / span * ALT_VIEW;
}
scene.addEventListener("pointerdown", function (e) {
if (e.button !== undefined && e.button !== 0) return;
setSetpoint(altFromEvent(e), "clicked");
scene.focus();
});
scene.addEventListener("keydown", function (e) {
var k = e.key, stepq = e.shiftKey ? 1 : 0.25;
if (k === "ArrowUp") { e.preventDefault(); setSetpoint(sp + stepq, "keyboard"); }
else if (k === "ArrowDown") { e.preventDefault(); setSetpoint(sp - stepq, "keyboard"); }
else if (k === "Enter" || k === " " || k === "Spacebar") { e.preventDefault(); doGust(); }
});
/* ── announcements + hint ───────────────────────────────────────────────── */
var sayTimer = 0;
function say(msg) {
clearTimeout(sayTimer);
sayTimer = setTimeout(function () { live.textContent = msg; }, 140);
}
var hintTimer = 0;
function dismissHint() {
if (hint.hidden || hint.classList.contains("gone")) return;
hint.classList.add("gone");
clearTimeout(hintTimer);
setTimeout(function () { hint.hidden = true; }, 500);
}
/* ── lifecycle ──────────────────────────────────────────────────────────── */
document.addEventListener("visibilitychange", function () {
if (document.hidden) stop();
else if (userRunning) start();
});
if (window.ResizeObserver) new ResizeObserver(function () { resize(); }).observe(field);
window.addEventListener("resize", resize);
/* ── boot: fly the first twelve seconds before anyone looks ──────────────── */
readTheme();
syncSlider("kp"); syncSlider("ki"); syncSlider("kd");
markPreset();
/* Fly the standard step before anyone looks, so the very first paint is a
finished measurement — flat hover, the ask, the bounce, the recovery — and
clicking "test step" reproduces it exactly. */
simT = 20; /* keeps the back-filled baseline off t < 0 */
armTestStep();
var wi, w1 = Math.round(6.5 / SUB);
for (wi = 0; wi < w1; wi++) substep(SUB);
resize();
updateUi(true);
if (reduced) {
userRunning = false;
overlayNote.textContent = "Reduced motion is on, so the quad is parked mid-hover. Gust, test step and the presets each play a couple of seconds, so every control still answers.";
setStatus();
hint.hidden = true;
} else {
setStatus();
start();
hintTimer = setTimeout(dismissHint, 8000);
}
})();
</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.