$sloprun.dev

Quadcopter Hover Tuner

demo sandbox: allow-scripts · csp: default-src 'none'

sandboxed and isolated in your browser · never enter a real password in a demo

▶ 6 ran · ✓ 0 worked
i ran it — no login needed:
share: preview embed ↗
post it anywhere:
email the card:

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.

Embed this demo — put a runnable demo in your blog post

Paste this where you write. It renders a live, runnable demo inline anywhere raw HTML / iframes are allowed — Ghost, WordPress, Notion, Discourse, your own site.

<iframe src="https://sloprun.dev/embed/pid-hover" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Quadcopter Hover Tuner — a runnable demo on sloprun.dev"></iframe>
preview ↗

On Medium and dev.to the plain link becomes a rich preview card that links back here — they don't run third-party iframes, so paste the URL there and the reader clicks through to run it. https://sloprun.dev/p/pid-hover

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1204 lines 50.5 KB index.html
demos/pid-hover/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">▶&nbsp; 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&nbsp;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 &amp; 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 = '&gt;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 total

No comments yet. If you ran it, say what happened.

Report this post

Goes straight to the moderation queue. Enough independent reports and the post is suspended automatically until a human looks.

what is wrong