$sloprun.dev

Lens Lab

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

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

▶ 9 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 magnifying glass, a camera and a film projector are the same piece of curved glass; the only thing that changes is how far away you hold it. Drag the arrow along the bench and the three principal rays redraw live, the image swells and flips, and a movable screen catches either a sharp picture or a soft orange smear — the blur is exact geometry too, the cone from a lens that wide measured in millimetres by the time it reaches wherever you parked the screen. It opens as a projector already in focus, throwing a slide twice life size onto a screen 21 cm out, so start by walking the arrow slowly toward the lens: the image races away and balloons, then at the focal point it stops existing at all (the rays leave dead parallel and never meet), and one nudge further it reappears on the wrong side of the lens, upright and huge. Try Focus it to snap the screen onto the sharp spot — it greys out for a virtual image, because no screen anywhere can catch light that never actually went there.

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/lens-optics" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Lens Lab — 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/lens-optics

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1228 lines 58.0 KB index.html
demos/lens-optics/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<title>Lens Lab — sloprun.dev</title>
<link rel="icon" href="data:,">
<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 (alpha values; tokens themselves are opaque hex) */
:root { --scrim: rgba(246,246,243,.88); --shadow: 0 1px 2px rgba(26,29,33,.05); --dot: #E3E2DC; }
@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); --dot: #2A2E36; }
}
:root[data-theme="dark"] { --scrim: rgba(20,22,26,.9); --shadow: 0 1px 2px rgba(0,0,0,.35); --dot: #2A2E36; }

* { 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: 72ch; 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) 316px; gap: 16px; align-items: start; }
@media (max-width: 940px) { .stage { grid-template-columns: minmax(0,1fr); } }
.left, .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 optical bench ───────────────────────────────────────────────────── */
.bench {
  position: relative; width: 100%; height: clamp(252px, 45vw, 404px);
  border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden;
  background-color: var(--surface);
  background-image: radial-gradient(var(--dot) 1.1px, transparent 1.2px);
  background-size: 26px 26px; background-position: -13px -13px;
  box-shadow: var(--shadow); touch-action: none; -webkit-tap-highlight-color: transparent;
  user-select: none; -webkit-user-select: none; cursor: crosshair;
}
canvas { display: block; position: absolute; inset: 0; width: 100%; height: 100%; }

.handle { position: absolute; width: 40px; transform: translateX(-50%);
  cursor: ew-resize; touch-action: none; border-radius: 9px; z-index: 2; }
.handle:focus { outline: none; }
.handle:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.handle:hover { background: var(--accent-soft); opacity: .55; }
@media (hover: none) { .handle:hover { background: none; } }

.hint { position: absolute; left: 10px; bottom: 10px; z-index: 3; pointer-events: none;
  font-family: var(--font-mono); font-size: 11px; 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.gone { opacity: 0; }

.legend { display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: 9px;
  font-family: var(--font-mono); font-size: 10px; letter-spacing: .06em;
  text-transform: uppercase; color: var(--muted); }
.legend i { display: inline-block; width: 15px; height: 0; vertical-align: middle;
  border-top: 2px solid var(--accent); margin-right: 6px; }
.legend .seem i { border-top: 2px dashed var(--accent); }
.legend .obj i { border-top: 2px solid var(--ink); }

/* ── zone strip ──────────────────────────────────────────────────────────── */
.zone { margin-bottom: 12px; }
/* the pin lives OUTSIDE .zbar: .zbar has to clip its segments, and anything
   riding the top edge would be clipped away with them */
.zwrap { position: relative; padding-top: 7px; }
.zbar { position: relative; display: flex; height: 24px; border: 1px solid var(--line);
  border-radius: 7px; overflow: hidden; background: var(--bg); }
.zs { position: relative; display: flex; align-items: center; justify-content: center;
  min-width: 0; overflow: hidden; border-right: 1px solid var(--line);
  font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .07em; text-transform: uppercase;
  color: var(--muted); white-space: nowrap; transition: background-color .15s ease, color .15s ease; }
.zs:last-child { border-right: 0; }
.zs.on { background: var(--accent-soft); color: var(--accent); }
.zs i { font-style: normal; padding: 0 4px; }
/* a pin that rides the top edge, so it never cuts through the segment labels */
.zmark { position: absolute; top: 3px; height: 12px; width: 2px; background: var(--accent);
  transform: translateX(-50%); z-index: 2; border-radius: 2px; }
.zmark::after { content: ""; position: absolute; left: 50%; top: 0; width: 7px; height: 7px;
  border-radius: 50%; background: var(--accent); transform: translate(-50%,-50%); }
.zticks { position: relative; height: 15px; margin-top: 3px;
  font-family: var(--font-mono); font-size: 9.5px; color: var(--muted); letter-spacing: .06em; }
.zticks span { position: absolute; top: 0; white-space: nowrap; }
.zticks .mid { transform: translateX(-50%); color: var(--accent); }
.zticks .e0 { left: 0; } .zticks .e1 { right: 0; }

/* ── stats ───────────────────────────────────────────────────────────────── */
.stats { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 10px; }
@media (max-width: 560px) { .stats { grid-template-columns: repeat(2, minmax(0,1fr)); gap: 12px 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); }

.pills { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 11px; }
.pill { display: inline-flex; align-items: center; gap: 6px;
  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); white-space: nowrap; }
.pill .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); flex: none; }
.pill.acc { color: var(--accent); background: var(--accent-soft); border-color: transparent; }
.pill.acc .dot { background: var(--accent); }
.pill.ok { color: var(--run); background: var(--run-soft); border-color: transparent; }
.pill.ok .dot { background: var(--run); }

.eq { margin-top: 11px; font-family: var(--font-mono); font-size: 12.5px; line-height: 1.5;
  color: var(--ink); background: var(--bg); border: 1px solid var(--line); border-radius: 8px;
  padding: 8px 10px; overflow-x: auto; white-space: nowrap; font-variant-numeric: tabular-nums; }
.eq .p { color: var(--muted); }
.eq .neg { color: var(--accent); }
/* on a phone let the equation wrap rather than hide its right-hand side */
@media (max-width: 560px) { .eq { white-space: normal; overflow-x: visible; } }

.alert { margin-top: 11px; border-left: 3px solid var(--accent); background: var(--accent-soft);
  border-radius: 0 8px 8px 0; padding: 9px 11px; }
.alert .k { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .1em;
  text-transform: uppercase; color: var(--accent); display: block; margin-bottom: 3px; }
.alert p { margin: 0; font-size: 12.5px; line-height: 1.5; }

.cap { margin: 11px 0 0; font-size: 13px; line-height: 1.55; color: var(--muted); }
.cap b { color: var(--ink); font-weight: 600; }
.cap .k { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .1em;
  text-transform: uppercase; color: var(--accent); display: block; margin-bottom: 3px; }

/* ── 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 .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: .5; 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); }

.row { display: flex; flex-wrap: wrap; gap: 7px; align-items: center; }
.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); }
.why { font-family: var(--font-mono); font-size: 10.5px; color: var(--muted);
  line-height: 1.45; margin-top: 8px; }

.note { font-size: 12.5px; line-height: 1.55; color: var(--muted); margin: 0; }
.note b { color: var(--ink); font-weight: 600; }
.note code { font-family: var(--font-mono); font-size: 11.5px; 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>Lens Lab</h1>
    <p class="lede">
      A magnifying glass, a camera and a film projector are the same lump of curved glass — the only
      thing that changes is how far away you hold it. <b>Drag the arrow</b> along the bench and watch
      the picture the lens makes grow, flip over, and at one exact spot vanish completely.
    </p>
  </header>

  <main class="stage">

    <div class="left">

      <div class="bench" id="bench">
        <canvas id="cv" role="img" aria-label="Ray diagram: an object arrow to the left of a converging lens, three rays passing through it, the image they build, and a movable screen. All the live numbers are repeated as text below the diagram."></canvas>
        <div class="handle" id="hObj" role="slider" tabindex="0"
             aria-label="Object distance from the lens"
             aria-valuemin="1.5" aria-valuemax="26" aria-valuenow="10.5"
             aria-valuetext="10.5 centimetres in front of the lens"></div>
        <div class="handle" id="hScr" role="slider" tabindex="0"
             aria-label="Screen distance from the lens"
             aria-valuemin="0.5" aria-valuemax="27.5" aria-valuenow="21"
             aria-valuetext="21.0 centimetres past the lens"></div>
        <div class="hint" id="hint">drag the arrow · drag the screen · or use arrow keys</div>
      </div>

      <div class="legend" aria-hidden="true">
        <span class="obj"><i></i>object &amp; image</span>
        <span><i></i>light that really goes there</span>
        <span class="seem"><i></i>where the light only seems to come from</span>
      </div>

      <section class="card" aria-label="What the lens is doing">
        <h2>What the lens is doing</h2>

        <div class="zone" aria-hidden="true">
          <div class="zwrap">
            <div class="zbar">
              <span class="zs" id="zA"><i>magnifier</i></span>
              <span class="zs" id="zB"><i>projector</i></span>
              <span class="zs" id="zC"><i>camera</i></span>
            </div>
            <span class="zmark" id="zMark"></span>
          </div>
          <div class="zticks">
            <span class="e0">0</span>
            <span class="mid" id="ztF">F</span>
            <span class="mid" id="zt2F">2F</span>
            <span class="e1">26 cm</span>
          </div>
        </div>

        <div class="stats">
          <div class="stat"><span class="v" id="sObj">—</span><span class="k">object distance</span></div>
          <div class="stat"><span class="v" id="sImg">—</span><span class="k">image distance</span></div>
          <div class="stat"><span class="v" id="sMag">—</span><span class="k">magnification</span></div>
          <div class="stat"><span class="v" id="sHt">—</span><span class="k">image height</span></div>
        </div>

        <div class="pills">
          <span class="pill" id="pKind"><span class="dot"></span><span id="pKindT">—</span></span>
          <span class="pill" id="pOrient"><span class="dot"></span><span id="pOrientT">—</span></span>
          <span class="pill" id="pScreen"><span class="dot"></span><span id="pScreenT">—</span></span>
        </div>

        <div class="eq" id="eq" aria-label="Thin lens equation with the current numbers"></div>

        <div class="alert" id="alert" hidden>
          <span class="k" id="alertK">at the focal point</span>
          <p id="alertT"></p>
        </div>

        <p class="cap" id="cap"><span class="k" id="capK"></span><span id="capT"></span></p>
      </section>

    </div>

    <div class="panel">

      <section class="card" aria-label="Presets">
        <h2>Same lens, five spots on the bench</h2>
        <div class="chips" id="presets" role="group" aria-label="Presets">
          <button type="button" data-p="0" aria-pressed="false">magnifier</button>
          <button type="button" data-p="1" aria-pressed="true">projector</button>
          <button type="button" data-p="2" aria-pressed="false">camera</button>
          <button type="button" data-p="3" aria-pressed="false">same&nbsp;size</button>
          <button type="button" data-p="4" aria-pressed="false">right&nbsp;on&nbsp;F</button>
        </div>
      </section>

      <section class="card" aria-label="Controls">
        <h2>Knobs</h2>

        <div class="ctl">
          <div class="ctl-top">
            <label for="rF">Focal length</label>
            <span class="val" id="vF">7.0 cm</span>
          </div>
          <input type="range" id="rF" min="2.5" max="13" step="0.1" value="7">
          <span class="desc">How strongly the glass bends light. Small number = fat, strong lens.</span>
        </div>

        <div class="ctl">
          <div class="ctl-top">
            <label for="rU">Object distance</label>
            <span class="val" id="vU">10.5 cm</span>
          </div>
          <input type="range" id="rU" min="1.5" max="26" step="0.1" value="10.5">
          <span class="desc">How far the arrow sits in front of the lens. Same as dragging it.</span>
        </div>

        <div class="ctl">
          <div class="ctl-top">
            <label for="rH">Object height</label>
            <span class="val" id="vH">4.0 cm</span>
          </div>
          <input type="range" id="rH" min="1" max="5" step="0.1" value="4">
          <span class="desc">How tall the arrow is. The lens opening grows to match it.</span>
        </div>
      </section>

      <section class="card" aria-label="Screen">
        <h2>The screen</h2>
        <div class="row">
          <button type="button" class="primary" id="btnFocus">Focus it</button>
          <button type="button" id="btnLock" aria-pressed="false">Follow: off</button>
        </div>
        <div class="row" style="margin-top:7px">
          <button type="button" id="btnScreen" aria-pressed="true">Screen: on</button>
          <button type="button" id="btnReset">Reset</button>
        </div>
        <div class="why" id="why"></div>
      </section>

      <section class="card" aria-label="Reading the minus sign">
        <p class="note">
          <b>Reading the minus sign.</b> Distances measured out past the lens count as positive.
          When the image distance comes out negative the picture is sitting back on the same side as
          the object — light never actually goes there, so no screen in the world can catch it.
          That is the whole difference between <code>real</code> and <code>virtual</code>.
        </p>
      </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";

  /* ── elements ─────────────────────────────────────────────────────────── */
  var root   = document.documentElement;
  var bench  = document.getElementById("bench");
  var cv     = document.getElementById("cv");
  var ctx    = cv.getContext && cv.getContext("2d");
  var hObj   = document.getElementById("hObj");
  var hScr   = document.getElementById("hScr");
  var hint   = document.getElementById("hint");
  var live   = document.getElementById("live");

  var sObj = document.getElementById("sObj"), sImg = document.getElementById("sImg");
  var sMag = document.getElementById("sMag"), sHt  = document.getElementById("sHt");
  var pKind = document.getElementById("pKind"), pKindT = document.getElementById("pKindT");
  var pOrient = document.getElementById("pOrient"), pOrientT = document.getElementById("pOrientT");
  var pScreen = document.getElementById("pScreen"), pScreenT = document.getElementById("pScreenT");
  var eqEl = document.getElementById("eq");
  var alertEl = document.getElementById("alert"), alertT = document.getElementById("alertT"), alertK = document.getElementById("alertK");
  var capK = document.getElementById("capK"), capT = document.getElementById("capT");
  var zA = document.getElementById("zA"), zB = document.getElementById("zB"), zC = document.getElementById("zC");
  var zMark = document.getElementById("zMark"), ztF = document.getElementById("ztF"), zt2F = document.getElementById("zt2F");
  var rF = document.getElementById("rF"), rU = document.getElementById("rU"), rH = document.getElementById("rH");
  var vF = document.getElementById("vF"), vU = document.getElementById("vU"), vH = document.getElementById("vH");
  var btnFocus = document.getElementById("btnFocus"), btnLock = document.getElementById("btnLock");
  var btnScreen = document.getElementById("btnScreen"), btnReset = document.getElementById("btnReset");
  var why = document.getElementById("why"), presets = document.getElementById("presets");

  /* no 2d canvas (ancient or locked-down browser): say so plainly and stop —
     every number in the panel below is text, so the demo is still readable */
  if (!ctx) {
    cv.remove();
    hObj.remove(); hScr.remove();
    hint.className = "hint";
    hint.textContent = "this browser can't draw the ray diagram — the numbers below still work";
    bench.style.cursor = "default";
  }

  var mqDark = window.matchMedia ? window.matchMedia("(prefers-color-scheme: dark)") : null;

  /* ── the bench, in centimetres ────────────────────────────────────────────
     Everything is done in real centimetres and converted to pixels at the last
     moment, so the vertical and horizontal scales are always equal — which is
     the only way ray angles on screen mean anything.                        */
  var XMIN = -28, XMAX = 28, XSPAN = XMAX - XMIN;
  var LIM = { f: [2.5, 13], u: [1.5, 26], h: [1, 5], s: [0.5, 27.5] };
  var SHARP = 0.12;          /* cm of blur we are willing to call "sharp"     */
  var NEAR_F = 0.5;          /* cm either side of F that counts as "on it"    */
  var SCRH = 9;              /* the screen is 2 x this tall, in cm            */

  var st = { f: 7, u: 10.5, h: 4, s: 21, screenOn: true, lock: false };

  var PRESETS = [
    { name: "magnifier", f: 7, u: 4,    h: 4, s: 18 },
    { name: "projector", f: 7, u: 10.5, h: 4, s: 21 },
    { name: "camera",    f: 7, u: 25,   h: 4, s: 9.7 },
    { name: "same size", f: 7, u: 14,   h: 4, s: 14 },
    { name: "right on F",f: 7, u: 7,    h: 4, s: 18 }
  ];

  /* ── colours, read back out of the tokens so themes just work ─────────── */
  var COL = { bg:[246,246,243], surface:[255,255,255], ink:[26,29,33], muted:[90,96,104],
              line:[227,226,220], accent:[192,86,0], run:[26,127,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) { return hexToRgb(getComputedStyle(root).getPropertyValue(name)) || fb; }
  function readTheme() {
    COL.bg      = tok("--bg", COL.bg);
    COL.surface = tok("--surface", COL.surface);
    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);
  }
  function rgba(c, a) { return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")"; }
  var MONO = 'ui-monospace, "SF Mono", Menlo, Consolas, monospace';

  /* ── canvas sizing ────────────────────────────────────────────────────── */
  var CSSW = 1, CSSH = 1, DPR = 1, S = 1, OX = 0, OY = 0;
  function layout() {
    if (!ctx) return;
    var r = bench.getBoundingClientRect();
    CSSW = Math.max(160, Math.round(r.width));
    CSSH = Math.max(140, Math.round(r.height));
    DPR  = Math.min(window.devicePixelRatio || 1, 2.5);
    cv.width  = Math.round(CSSW * DPR);
    cv.height = Math.round(CSSH * DPR);
    S  = CSSW / XSPAN;
    OX = -XMIN * S;
    OY = Math.round(CSSH * 0.5);
    render();
  }
  function X(cm) { return OX + cm * S; }
  function Y(cm) { return OY - cm * S; }
  function yHalfCm() { return (CSSH / 2) / S; }

  /* ── the physics ──────────────────────────────────────────────────────────
     Thin lens, one equation:   1/u + 1/v = 1/f
       u  object distance in front of the lens        (always positive here)
       v  image distance, positive = out past the lens (real), negative = back
          on the object's side (virtual)
       m  magnification = -v/u   (negative means upside down)
     The screen model is exact geometry too: the ray through the middle of the
     lens is undeviated, so at a screen s the centre of the patch sits at
     -h*s/u whatever the lens is doing, and the cone from a lens of opening D
     has widened to D*|1 - s*(1/f - 1/u)| by the time it gets there.        */
  function derive() {
    var f = st.f, u = st.u, h = st.h, s = st.s;
    var invv = 1 / f - 1 / u;
    var parallel = Math.abs(invv) < 1e-6;
    var v  = parallel ? Infinity : 1 / invv;
    var m  = parallel ? Infinity : -v / u;
    var hi = parallel ? Infinity : m * h;
    var lensHalf = Math.max(4.5, h * 1.15);
    var Dap = 2 * lensHalf;
    var blur = Dap * Math.abs(1 - s * invv);
    var patch = -h * s / u;
    return {
      f: f, u: u, h: h, s: s, v: v, m: m, hi: hi,
      parallel: parallel,
      real: !parallel && v > 0,
      lensHalf: lensHalf, Dap: Dap,
      blur: blur, patch: patch,
      /* once the cone is wider than the whole screen there is no picture left
         on it at all, just an even wash of light */
      wash: blur > 2 * SCRH,
      sharp: !parallel && v > 0 && blur <= SHARP,
      onF: Math.abs(u - f) <= NEAR_F,
      on2F: Math.abs(u - 2 * f) <= NEAR_F
    };
  }

  /* ── little canvas helpers ────────────────────────────────────────────── */
  function seg(x1, y1, x2, y2, col, w, dash) {
    ctx.save();
    ctx.beginPath();
    ctx.lineWidth = w;
    ctx.strokeStyle = col;
    ctx.lineCap = "round";
    if (dash) ctx.setLineDash(dash); 
    ctx.moveTo(X(x1), Y(y1));
    ctx.lineTo(X(x2), Y(y2));
    ctx.stroke();
    ctx.restore();
  }
  function label(text, px, py, col, align, baseline, size) {
    ctx.save();
    ctx.font = (size || 11) + "px " + MONO;
    /* a centred word parked against either end of the bench would have half of
       itself cropped off, so keep it inside the canvas */
    if (!align || align === "center") {
      var half = ctx.measureText(text).width / 2;
      px = Math.max(half + 4, Math.min(CSSW - half - 4, px));
    }
    ctx.fillStyle = col;
    ctx.textAlign = align || "center";
    ctx.textBaseline = baseline || "middle";
    ctx.fillText(text, px, py);
    ctx.restore();
  }
  /* a mono note on a little slab, so it stays readable over the rays */
  function chip(text, px, py, col, align) {
    ctx.save();
    ctx.font = "11px " + MONO;
    var w = ctx.measureText(text).width + 16, h = 20;
    var x0 = align === "right" ? px - w : px;
    x0 = Math.max(6, Math.min(CSSW - w - 6, x0));
    var y0 = Math.max(4, Math.min(CSSH - h - 4, py - h / 2));
    ctx.beginPath();
    if (ctx.roundRect) ctx.roundRect(x0, y0, w, h, 999); else ctx.rect(x0, y0, w, h);
    ctx.fillStyle = rgba(COL.bg, 0.94); ctx.fill();
    ctx.strokeStyle = rgba(COL.line, 1); ctx.lineWidth = 1; ctx.stroke();
    ctx.fillStyle = col; ctx.textAlign = "left"; ctx.textBaseline = "middle";
    ctx.fillText(text, x0 + 8, y0 + h / 2 + 0.5);
    ctx.restore();
  }

  /* a vertical arrow standing on the axis at world x */
  function arrowAt(x, top, col, w, dash) {
    var px = X(x), y0 = Y(0), y1 = Y(top);
    var dir = top >= 0 ? 1 : -1;
    var head = Math.min(11, Math.max(6, Math.abs(y1 - y0) * 0.32));
    ctx.save();
    ctx.strokeStyle = col; ctx.fillStyle = col; ctx.lineWidth = w; ctx.lineCap = "round";
    ctx.beginPath();
    if (dash) ctx.setLineDash(dash);
    ctx.moveTo(px, y0); ctx.lineTo(px, y1); ctx.stroke();
    ctx.setLineDash([]);
    ctx.beginPath();
    ctx.moveTo(px, y1);
    ctx.lineTo(px - head * 0.52, y1 + dir * head);
    ctx.lineTo(px + head * 0.52, y1 + dir * head);
    ctx.closePath(); ctx.fill();
    ctx.restore();
  }

  /* draw a refracted ray from the lens, fading past the screen if one is up */
  function refracted(y0, k, col, w) {
    var xEnd = XMAX + 2;
    var cut = (st.screenOn && st.s > 0.05 && st.s < xEnd) ? st.s : null;
    if (cut === null) {
      seg(0, y0, xEnd, y0 + k * xEnd, col, w, null);
    } else {
      seg(0, y0, cut, y0 + k * cut, col, w, null);
      ctx.save(); ctx.globalAlpha = 0.28;
      seg(cut, y0 + k * cut, xEnd, y0 + k * xEnd, col, w, null);
      ctx.restore();
    }
  }

  /* ── the screen: an edge-on bar plus a face-on preview inset ──────────── */
  function drawScreen(d) {
    var sx = X(d.s);
    var halfCm = Math.min(SCRH, yHalfCm() * 0.9);
    var top = Y(halfCm), bot = Y(-halfCm), w = Math.max(5, S * 0.42);

    /* the bar itself, translucent so the construction stays readable */
    ctx.save();
    ctx.globalAlpha = 0.9;
    ctx.fillStyle = rgba(COL.surface, 0.86);
    ctx.strokeStyle = d.sharp ? rgba(COL.run, 0.95) : rgba(COL.line, 1);
    ctx.lineWidth = d.sharp ? 2 : 1.25;
    if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(sx - w / 2, top, w, bot - top, 3); ctx.fill(); ctx.stroke(); }
    else { ctx.fillRect(sx - w / 2, top, w, bot - top); ctx.strokeRect(sx - w / 2, top, w, bot - top); }
    ctx.restore();

    /* the light actually landing on it, drawn on the bar edge-on */
    var p0 = Y(0), p1 = Y(d.patch);
    var blurPx = Math.max(0, d.blur * S / 2);
    ctx.save();
    ctx.beginPath();
    ctx.rect(sx - w / 2 + 0.6, top, w - 1.2, bot - top);
    ctx.clip();
    if (typeof ctx.filter === "string") ctx.filter = "blur(" + Math.min(28, blurPx).toFixed(1) + "px)";
    ctx.globalAlpha = d.sharp ? 0.95 : 0.55;
    ctx.strokeStyle = d.sharp ? rgba(COL.run, 1) : rgba(COL.accent, 1);
    ctx.lineWidth = Math.max(2, w - 2.4);
    ctx.lineCap = "butt";
    ctx.beginPath(); ctx.moveTo(sx, p0); ctx.lineTo(sx, p1); ctx.stroke();
    ctx.restore();

    label("screen", sx, Math.min(CSSH - 7, bot + 12), rgba(COL.muted, 1), "center", "middle", 10.5);
    /* grip */
    ctx.save();
    ctx.strokeStyle = rgba(COL.muted, 0.75); ctx.lineWidth = 1.4; ctx.lineCap = "round";
    for (var i = -1; i <= 1; i++) {
      ctx.beginPath();
      ctx.moveTo(sx - 3.5, bot - 8 + i * 4); ctx.lineTo(sx + 3.5, bot - 8 + i * 4);
      ctx.stroke();
    }
    ctx.restore();
  }

  /* Where the face-on preview parks: whichever top corner has clear air. The
     object always sits left of the lens and the screen always right of it, so
     the roomier side is the one that keeps the bench readable. A virtual image
     lands out on the left too, so it gets a vote.                            */
  function insetBox(d) {
    var pw = Math.max(74, Math.min(126, CSSW * 0.24));
    var ph = Math.max(104, Math.min(168, CSSH * 0.46));
    var leftMost = X(-d.u);
    if (!d.parallel && !d.real && isFinite(d.v)) leftMost = Math.min(leftMost, X(d.v));
    /* leave room for the word under each of them, not just the mark itself */
    var roomR = CSSW - X(d.s) - 34;
    var roomL = leftMost - 34;
    var left = roomL > roomR;
    var room = left ? roomL : roomR;
    var tight = room < pw;
    if (tight) pw = Math.max(68, Math.min(pw, room));
    /* if even the roomier corner can't clear the arrow (object shoved hard left
       AND screen hard right), drop the box to the floor: the object and any
       virtual image always stand above the axis, so down there is clear. On a
       short bench trim it so it still starts below the axis and its F marks. */
    var y = 10;
    if (left && tight) {
      ph = Math.max(84, Math.min(ph, CSSH - 8 - (OY + 26)));
      y = Math.max(10, CSSH - ph - 8);
    }
    return { x: left ? 10 : (CSSW - pw - 10), y: y, w: pw, h: ph, left: left };
  }

  /* nudge a caption clear of the preview box instead of letting it hide behind */
  function dodge(px, py, text, size, box) {
    if (!box) return px;
    if (py < box.y - 9 || py > box.y + box.h + 9) return px;
    ctx.save();
    ctx.font = (size || 11) + "px " + MONO;
    var tw = ctx.measureText(text).width;
    ctx.restore();
    if (px + tw / 2 < box.x - 6 || px - tw / 2 > box.x + box.w + 6) return px;
    return box.left
      ? Math.min(CSSW - tw / 2 - 6, box.x + box.w + 6 + tw / 2)
      : Math.max(tw / 2 + 6, box.x - 6 - tw / 2);
  }

  function drawInset(d, b) {
    var pw = b.w, ph = b.h, px = b.x, py = b.y;
    /* the drawable band sits between the two label rows, and the whole screen
       height maps into it, so a picture that fits the screen fits the preview */
    var bandTop = py + 20, bandH = ph - 40;
    var cx = px + pw / 2, cy = bandTop + bandH / 2;
    var scale = bandH / (2 * SCRH);

    ctx.save();
    ctx.beginPath();
    if (ctx.roundRect) ctx.roundRect(px, py, pw, ph, 8); else ctx.rect(px, py, pw, ph);
    ctx.fillStyle = rgba(COL.surface, 0.94); ctx.fill();
    ctx.strokeStyle = d.sharp ? rgba(COL.run, 1) : rgba(COL.line, 1);
    ctx.lineWidth = d.sharp ? 1.8 : 1;
    ctx.stroke();
    ctx.clip();

    /* faint centre line so the flip is obvious */
    ctx.beginPath();
    ctx.strokeStyle = rgba(COL.line, 1); ctx.lineWidth = 1;
    ctx.setLineDash([3, 3]);
    ctx.moveTo(px + 8, cy); ctx.lineTo(px + pw - 8, cy); ctx.stroke();
    ctx.setLineDash([]);

    /* the patch, face on — kept clear of the two label bands */
    var tip = cy - d.patch * scale;
    var blurPx = Math.max(0, d.blur * scale / 2);
    ctx.save();
    ctx.beginPath();
    ctx.rect(px + 4, bandTop, pw - 8, bandH);
    ctx.clip();
    if (typeof ctx.filter === "string") ctx.filter = "blur(" + Math.min(34, blurPx).toFixed(1) + "px)";
    ctx.globalAlpha = d.sharp ? 1 : 0.62;
    var c = d.sharp ? COL.run : COL.accent;
    ctx.strokeStyle = rgba(c, 1); ctx.fillStyle = rgba(c, 1);
    ctx.lineWidth = Math.max(2.4, 3.2);
    ctx.lineCap = "round";
    ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx, tip); ctx.stroke();
    var dir = d.patch >= 0 ? 1 : -1, head = Math.min(12, Math.max(5, Math.abs(tip - cy) * 0.3));
    ctx.beginPath();
    ctx.moveTo(cx, tip);
    ctx.lineTo(cx - head * 0.55, tip + dir * head);
    ctx.lineTo(cx + head * 0.55, tip + dir * head);
    ctx.closePath(); ctx.fill();
    ctx.restore();

    /* both label rows have to survive the narrow phone-width box */
    label(pw < 100 ? "ON SCREEN" : "ON THE SCREEN", px + pw / 2, py + 11, rgba(COL.muted, 1), "center", "middle", 9);
    var mm = d.blur * 10;
    var foot = d.sharp ? "SHARP" : d.wash ? "NO PICTURE"
             : ("BLUR " + mm.toFixed(mm >= 100 ? 0 : 1) + " mm");
    label(foot, px + pw / 2, py + ph - 11, d.sharp ? rgba(COL.run, 1) : rgba(COL.muted, 1),
          "center", "middle", 10);
    ctx.restore();
  }

  /* ── the whole diagram ────────────────────────────────────────────────── */
  function render() {
    var d = derive();
    if (!ctx) return d;          /* no canvas: the panel below still tells the story */
    var yH = yHalfCm();
    /* work out where the face-on preview will park before anything is drawn,
       so every caption can step around it instead of hiding underneath */
    var box = st.screenOn ? insetBox(d) : null;

    ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
    ctx.clearRect(0, 0, CSSW, CSSH);

    /* optical axis */
    seg(XMIN, 0, XMAX, 0, rgba(COL.line, 1), 1.25, null);

    /* focal marks */
    function focal(x, name, strong) {
      var col = strong ? rgba(COL.accent, 1) : rgba(COL.muted, 0.85);
      seg(x, -0.55, x, 0.55, col, strong ? 2 : 1.4, null);
      ctx.save();
      ctx.beginPath();
      ctx.arc(X(x), Y(0), strong ? 4 : 3, 0, Math.PI * 2);
      ctx.fillStyle = col; ctx.fill();
      ctx.restore();
      label(name, X(x), Y(0) + 16, col, "center", "middle", 10.5);
    }
    focal(-2 * d.f, "2F", false); focal(2 * d.f, "2F", false);
    focal(-d.f, "F", d.onF);      focal(d.f, "F", d.onF);

    /* the lens */
    var lh = d.lensHalf, bow = Math.max(0.7, lh * 0.20);
    ctx.save();
    ctx.beginPath();
    ctx.moveTo(X(0), Y(lh));
    ctx.quadraticCurveTo(X(bow), Y(0), X(0), Y(-lh));
    ctx.quadraticCurveTo(X(-bow), Y(0), X(0), Y(lh));
    ctx.closePath();
    ctx.fillStyle = rgba(COL.accent, 0.10);
    ctx.fill();
    ctx.strokeStyle = rgba(COL.accent, 0.85);
    ctx.lineWidth = 1.6;
    ctx.stroke();
    ctx.restore();
    /* the lens PLANE, full height. The third principal ray legitimately crosses
       well outside the drawn glass, and without this line it looks like it bends
       in mid-air; the dashed plane is where every ray is taken to turn. */
    seg(0, yH, 0, -yH, rgba(COL.accent, 0.22), 1, [4, 4]);

    /* ── the three rays ─────────────────────────────────────────────────── */
    var u = d.u, h = d.h, f = d.f;
    var rayCol = rgba(COL.accent, 0.95);
    var ghost  = rgba(COL.accent, 0.5);
    var w = 1.7;

    /* 1. in parallel to the axis, out through the far focus */
    seg(-u, h, 0, h, rayCol, w, null);
    refracted(h, -h / f, rayCol, w);

    /* 2. straight through the middle, undeviated */
    seg(-u, h, 0, 0, rayCol, w, null);
    refracted(0, -h / u, rayCol, w);

    /* 3. in along the line through the near focus, out parallel to the axis */
    var y3 = (Math.abs(u - f) < 1e-6) ? Infinity : (-h * f / (u - f));
    if (isFinite(y3) && Math.abs(y3) < yH * 6) {
      var fade = Math.abs(y3) > yH ? 0.45 : 1;
      ctx.save(); ctx.globalAlpha = fade;
      if (u < f) seg(-f, 0, -u, h, ghost, w, [5, 4]);   /* it only seems to start at F */
      seg(-u, h, 0, y3, rayCol, w, null);
      refracted(y3, 0, rayCol, w);
      ctx.restore();
    }

    /* ── the image ──────────────────────────────────────────────────────── */
    if (d.parallel) {
      chip("no image: the rays leave parallel", X(2.5), Y(0) + 38, rgba(COL.accent, 1), "left");
    } else if (d.real) {
      if (d.v <= XMAX - 0.5 && Math.abs(d.hi) < yH * 3) {
        /* dotted extensions of the rays are unnecessary: they truly cross here */
        arrowAt(d.v, d.hi, rgba(COL.ink, 1), 2.6, null);
        var lp = Y(d.hi) + (d.hi >= 0 ? -14 : 14);
        /* when the screen is parked on the image the two words land on top of
           each other, so step this one aside */
        var lx = X(d.v);
        if (st.screenOn && Math.abs(lx - X(d.s)) < 58) lx -= 52;
        lx = Math.max(26, Math.min(CSSW - 26, lx));
        var ly = Math.max(12, Math.min(CSSH - 12, lp));
        label("image", dodge(lx, ly, "image", 10.5, box), ly, rgba(COL.ink, 1), "center", "middle", 10.5);
      } else if (d.v > XMAX - 0.5) {
        chip("image lands " + d.v.toFixed(0) + " cm out, off to the right",
             CSSW - 8, Y(0) + 38, rgba(COL.muted, 1), "right");
      } else {
        arrowAt(d.v, Math.sign(d.hi) * yH * 0.94, rgba(COL.ink, 1), 2.6, null);
        var ox = Math.max(70, Math.min(CSSW - 70, X(d.v))), oy = Y(0) + (d.hi >= 0 ? 18 : -18);
        label("image runs off the view", dodge(ox, oy, "image runs off the view", 10.5, box), oy,
              rgba(COL.muted, 1), "center", "middle", 10.5);
      }
    } else {
      /* virtual: trace the outgoing rays backwards to where they seem to start */
      var back = [[h, -h / f], [0, -h / u]];
      if (isFinite(y3) && Math.abs(y3) < yH * 6) back.push([y3, 0]);
      for (var i = 0; i < back.length; i++) {
        seg(d.v, back[i][0] + back[i][1] * d.v, 0, back[i][0], ghost, 1.4, [6, 5]);
      }
      arrowAt(d.v, d.hi, rgba(COL.ink, 1), 2.4, [5, 4]);
      var vt = "image (nothing is really there)";
      var vx = Math.max(96, Math.min(CSSW - 96, X(d.v))), vy = Math.max(12, Y(d.hi) - 14);
      label(vt, dodge(vx, vy, vt, 10.5, box), vy, rgba(COL.muted, 1), "center", "middle", 10.5);
    }

    /* ── the object ─────────────────────────────────────────────────────── */
    arrowAt(-u, h, rgba(COL.ink, 1), 2.8, null);
    var oby = Math.max(12, Y(h) - 14);
    label("object", dodge(X(-u), oby, "object", 10.5, box), oby, rgba(COL.ink, 1), "center", "middle", 10.5);
    ctx.save();
    ctx.strokeStyle = rgba(COL.muted, 0.8); ctx.lineWidth = 1.4; ctx.lineCap = "round";
    for (var g = -1; g <= 1; g++) {
      ctx.beginPath();
      ctx.moveTo(X(-u) - 4, Y(0) + 8 + g * 4); ctx.lineTo(X(-u) + 4, Y(0) + 8 + g * 4);
      ctx.stroke();
    }
    ctx.restore();

    if (st.screenOn && box) { drawScreen(d); drawInset(d, box); }

    placeHandles(d);
    return d;
  }

  function placeHandles(d) {
    var top = Math.min(Y(d.h), Y(0)) - 14, bot = Y(0) + 20;
    hObj.style.left = X(-d.u) + "px";
    hObj.style.top = Math.max(0, top) + "px";
    hObj.style.height = Math.max(34, bot - Math.max(0, top)) + "px";
    var halfCm = Math.min(SCRH, yHalfCm() * 0.9);
    hScr.style.left = X(d.s) + "px";
    hScr.style.top = Y(halfCm) + "px";
    hScr.style.height = Math.max(34, Y(-halfCm) - Y(halfCm)) + "px";
    hScr.hidden = !st.screenOn;
  }

  /* ── words ────────────────────────────────────────────────────────────── */
  function story(d) {
    if (d.parallel) return {
      k: "nothing forms",
      t: "The arrow is sitting exactly on the focal point. Every ray comes out of the lens " +
         "perfectly parallel to its neighbour, so they never meet each other again and there is " +
         "no picture anywhere — not out front, not behind. This is the moment the image flips sides."
    };
    if (d.u < d.f) return {
      k: "magnifying glass",
      t: "Closer than the focal point. The lens can't pull this light back into a point — it only " +
         "makes it spread more gently — so your eye traces the rays back to a big upright picture " +
         "hanging behind the object. That is a magnifying glass, and it is why nothing lands on the screen."
    };
    if (d.on2F) return {
      k: "exactly life size",
      t: "At twice the focal length something neat happens: the picture is exactly the same size as " +
         "the object, upside down, and sits exactly as far past the lens as the arrow is in front of it. " +
         "This is the closest a single lens can bring the two."
    };
    if (d.u < 2 * d.f) return {
      k: "projector",
      t: "Between one and two focal lengths. The picture lands out past the lens, upside down and " +
         "bigger than the arrow — exactly what a projector does to a small slide on a big wall. " +
         "Notice it also lands a long way out; big pictures need room."
    };
    return {
      k: "camera",
      t: "Further out than twice the focal length. The picture lands past the lens, upside down and " +
         "smaller than the real thing — which is exactly what your eye and every phone camera do " +
         "to a whole room, squeezing it onto something the size of a fingernail."
    };
  }

  function num(x, dp) { return x.toFixed(dp); }

  var lastSay = "";
  function update(announce) {
    var d = render();

    /* sliders + values */
    if (rF.value !== String(st.f)) rF.value = st.f;
    if (rU.value !== String(st.u)) rU.value = st.u;
    if (rH.value !== String(st.h)) rH.value = st.h;
    vF.textContent = num(st.f, 1) + " cm";
    vU.textContent = num(st.u, 1) + " cm";
    vH.textContent = num(st.h, 1) + " cm";

    /* stats */
    sObj.innerHTML = num(d.u, 1) + '<span class="u">cm</span>';
    if (d.parallel)          sImg.innerHTML = '∞<span class="u">no image</span>';
    else if (Math.abs(d.v) >= 1000) sImg.innerHTML = (d.v > 0 ? "" : "−") + '999+<span class="u">cm</span>';
    else sImg.innerHTML = (d.v < 0 ? "−" + num(-d.v, 1) : num(d.v, 1)) + '<span class="u">cm</span>';
    sMag.innerHTML = d.parallel ? '—' : ("×" + num(Math.abs(d.m), 2));
    sHt.innerHTML  = d.parallel ? '—' :
      (Math.abs(d.hi) >= 1000 ? '999+<span class="u">cm</span>' : num(Math.abs(d.hi), 2) + '<span class="u">cm</span>');

    /* pills */
    pKind.className = "pill" + (d.parallel ? "" : " acc");
    pKindT.textContent = d.parallel ? "no image forms" : (d.real ? "real image" : "virtual image");
    pOrient.className = "pill";
    pOrientT.textContent = d.parallel ? "—" : (d.m < 0 ? "upside down" : "upright");
    if (!st.screenOn) {
      pScreen.className = "pill";
      pScreenT.textContent = "screen put away";
    } else if (d.sharp) {
      pScreen.className = "pill ok";
      pScreenT.textContent = "sharp on the screen";
    } else if (d.wash) {
      pScreen.className = "pill";
      pScreenT.textContent = "no picture, just light";
    } else {
      pScreen.className = "pill";
      pScreenT.textContent = "blur " + num(d.blur * 10, 1) + " mm";
    }

    /* the equation, live */
    if (d.parallel) {
      eqEl.innerHTML = '<span class="p">1/u + 1/v = 1/f →</span> 1/' + num(d.u, 1) +
                       ' + 1/<span class="neg">∞</span> = 1/' + num(d.f, 1);
    } else {
      var vs = Math.abs(d.v) >= 1000 ? (d.v > 0 ? "999+" : "(−999+)")
             : (d.v < 0 ? '(−' + num(-d.v, 1) + ')' : num(d.v, 1));
      eqEl.innerHTML = '<span class="p">1/u + 1/v = 1/f →</span> 1/' + num(d.u, 1) + ' + 1/' +
                       (d.v < 0 ? '<span class="neg">' + vs + '</span>' : vs) +
                       ' = 1/' + num(d.f, 1);
    }

    /* the focal-point moment */
    if (d.onF) {
      alertEl.hidden = false;
      if (d.parallel) {
        alertK.textContent = "sitting exactly on F";
        alertT.textContent = "Right on the focal point. The rays come out dead parallel, meet nowhere, " +
          "and there is no image to find or to catch. Nudge the arrow either way and it snaps back " +
          "into existence — on opposite sides, facing opposite ways.";
      } else if (d.u < d.f) {
        alertK.textContent = "just inside F";
        alertT.textContent = "A whisker inside the focal point. The picture has jumped behind the object, " +
          "turned upright, and blown up to " + num(Math.abs(d.m), 1) + "×. Push the arrow " +
          num(d.f - d.u, 1) + " cm further out and it flips to the other side of the lens, upside down.";
      } else {
        alertK.textContent = "just outside F";
        alertT.textContent = "A whisker outside the focal point. The picture is real and upside down but " +
          "thrown " + (Math.abs(d.v) >= 1000 ? "practically infinitely" : num(d.v, 0) + " cm") +
          " away and " + num(Math.abs(d.m), 1) + "× life size. Pull the arrow " +
          num(d.u - d.f, 1) + " cm closer and it flips behind the lens, upright.";
      }
    } else {
      alertEl.hidden = true;
    }

    /* caption */
    var s2 = story(d);
    capK.textContent = s2.k;
    capT.textContent = s2.t;

    /* zone strip */
    var uMax = LIM.u[1];
    var pA = Math.max(0, Math.min(100, d.f / uMax * 100));
    var pB = Math.max(0, Math.min(100 - pA, (Math.min(2 * d.f, uMax) - d.f) / uMax * 100));
    var pC = Math.max(0, 100 - pA - pB);
    zA.style.width = pA + "%"; zB.style.width = pB + "%"; zC.style.width = pC + "%";
    zA.firstChild.style.display = pA > 17 ? "" : "none";
    zB.firstChild.style.display = pB > 17 ? "" : "none";
    zC.firstChild.style.display = pC > 17 ? "" : "none";
    /* sitting exactly on F belongs to neither job, so light nothing up there */
    zA.className = "zs" + (!d.parallel && d.u < d.f ? " on" : "");
    zB.className = "zs" + (!d.parallel && d.u > d.f && d.u < 2 * d.f ? " on" : "");
    zC.className = "zs" + (!d.parallel && d.u >= 2 * d.f ? " on" : "");
    zMark.style.left = (d.u / uMax * 100) + "%";
    ztF.style.left = pA + "%";
    zt2F.style.left = (pA + pB) + "%";
    zt2F.style.display = (pB > 9 && pA + pB < 92) ? "" : "none";

    /* screen controls */
    var reachable = d.real && d.v >= LIM.s[0] && d.v <= LIM.s[1];
    btnFocus.disabled = !st.screenOn || !reachable;
    if (!st.screenOn) why.textContent = "the screen is put away — the diagram alone is showing.";
    else if (!d.real && !d.parallel) why.textContent = "no screen can catch this one: the picture is virtual.";
    else if (d.parallel) why.textContent = "there is no image anywhere to focus on right now.";
    else if (!reachable) why.textContent = "the image lands " + num(d.v, 0) + " cm out — past the end of the bench.";
    else if (d.sharp) why.textContent = "in focus: the screen is sitting right where the rays cross.";
    else why.textContent = "the screen is " + num(Math.abs(d.s - d.v), 1) + " cm off the sharp spot.";

    /* handles */
    hObj.setAttribute("aria-valuenow", num(d.u, 1));
    hObj.setAttribute("aria-valuetext", num(d.u, 1) + " centimetres in front of the lens");
    hScr.setAttribute("aria-valuenow", num(d.s, 1));
    hScr.setAttribute("aria-valuetext", num(d.s, 1) + " centimetres past the lens");

    if (announce) {
      var say = "Object " + num(d.u, 1) + " centimetres out. " +
        (d.parallel ? "No image forms: the rays leave parallel."
                    : (d.real ? "Real image " + num(Math.abs(d.v), 1) + " centimetres past the lens, upside down, "
                              : "Virtual image " + num(Math.abs(d.v), 1) + " centimetres behind the object, upright, ") +
                      num(Math.abs(d.m), 2) + " times life size.") +
        (st.screenOn ? (d.sharp ? " The screen is sharp."
                      : d.wash ? " The screen is showing only a wash of light."
                      : " Screen blur " + num(d.blur * 10, 1) + " millimetres.") : "");
      if (say !== lastSay) { live.textContent = say; lastSay = say; }
    }
    markPresets();
  }

  /* ── input ────────────────────────────────────────────────────────────── */
  function clamp(v, lim) { return Math.max(lim[0], Math.min(lim[1], v)); }
  function round1(v) { return Math.round(v * 10) / 10; }

  var hinted = false;
  function usedIt() { if (!hinted) { hinted = true; hint.className = "hint gone"; } }

  function setU(v, announce) {
    st.u = clamp(round1(v), LIM.u);
    if (st.lock) followScreen();
    update(announce);
  }
  function setS(v, announce) { st.s = clamp(round1(v), LIM.s); update(announce); }
  function followScreen() {
    var d = derive();
    if (d.real && d.v >= LIM.s[0] && d.v <= LIM.s[1]) st.s = d.v;
  }

  /* dragging on the bench: left of the lens grabs the arrow, right of it the screen */
  function cmFromEvent(e) {
    var r = cv.getBoundingClientRect();
    return (e.clientX - r.left - OX) / S;
  }
  var dragging = null;
  function benchDown(e) {
    if (!ctx) return;
    if (e.button !== undefined && e.button !== 0) return;
    var cm = cmFromEvent(e);
    usedIt();
    if (cm < 0) { dragging = "obj"; setU(-cm, false); focusSafe(hObj); }
    else if (st.screenOn) { dragging = "scr"; setS(cm, false); focusSafe(hScr); }
    else { return; }
    try { bench.setPointerCapture(e.pointerId); } catch (err) {}
    e.preventDefault();
  }
  function benchMove(e) {
    if (!dragging) return;
    var cm = cmFromEvent(e);
    if (dragging === "obj") setU(-cm, false); else setS(cm, false);
    e.preventDefault();
  }
  function benchUp(e) {
    if (!dragging) return;
    dragging = null;
    update(true);
    try { bench.releasePointerCapture(e.pointerId); } catch (err) {}
  }
  function focusSafe(el) { try { el.focus({ preventScroll: true }); } catch (err) { el.focus(); } }

  bench.addEventListener("pointerdown", benchDown);
  bench.addEventListener("pointermove", benchMove);
  bench.addEventListener("pointerup", benchUp);
  bench.addEventListener("pointercancel", benchUp);

  /* the two handles sit on top and take the drag themselves */
  function handleDrag(el, kind) {
    el.addEventListener("pointerdown", function (e) {
      if (e.button !== undefined && e.button !== 0) return;
      usedIt();
      dragging = kind;
      try { el.setPointerCapture(e.pointerId); } catch (err) {}
      e.stopPropagation(); e.preventDefault();
    });
    el.addEventListener("pointermove", function (e) {
      if (dragging !== kind) return;
      var cm = cmFromEvent(e);
      if (kind === "obj") setU(-cm, false); else setS(cm, false);
      e.stopPropagation(); e.preventDefault();
    });
    function up(e) {
      if (dragging !== kind) return;
      dragging = null; update(true);
      try { el.releasePointerCapture(e.pointerId); } catch (err) {}
      e.stopPropagation();
    }
    el.addEventListener("pointerup", up);
    el.addEventListener("pointercancel", up);
    el.addEventListener("keydown", function (e) {
      var k = e.key, step = e.shiftKey ? 1 : 0.2, cur = (kind === "obj") ? st.u : st.s, lim = (kind === "obj") ? LIM.u : LIM.s, v = null;
      /* left/right follow the screen: for the object, further left IS a bigger
         distance. up/down follow the reported value, so they agree with
         PageUp/PageDown and with what a screen reader announces. */
      var flip = (kind === "obj") ? -1 : 1;
      if (k === "ArrowLeft")       v = cur - flip * step;
      else if (k === "ArrowRight") v = cur + flip * step;
      else if (k === "ArrowUp")    v = cur + step;
      else if (k === "ArrowDown")  v = cur - step;
      else if (k === "PageUp") v = cur + 2;
      else if (k === "PageDown") v = cur - 2;
      else if (k === "Home") v = lim[0];
      else if (k === "End") v = lim[1];
      if (v === null) return;
      usedIt();
      if (kind === "obj") setU(v, true); else setS(v, true);
      e.preventDefault();
    });
  }
  handleDrag(hObj, "obj");
  handleDrag(hScr, "scr");

  /* sliders */
  rF.addEventListener("input", function () { st.f = parseFloat(rF.value); if (st.lock) followScreen(); usedIt(); update(false); });
  rF.addEventListener("change", function () { update(true); });
  rU.addEventListener("input", function () { setU(parseFloat(rU.value), false); usedIt(); });
  rU.addEventListener("change", function () { update(true); });
  rH.addEventListener("input", function () { st.h = parseFloat(rH.value); usedIt(); update(false); });
  rH.addEventListener("change", function () { update(true); });

  /* buttons */
  btnFocus.addEventListener("click", function () {
    var d = derive();
    if (d.real && d.v >= LIM.s[0] && d.v <= LIM.s[1]) { st.s = d.v; usedIt(); update(true); }
  });
  btnLock.addEventListener("click", function () {
    setLock(!st.lock);
    if (st.lock) followScreen();
    update(true);
  });
  function setScreen(on) {
    st.screenOn = on;
    btnScreen.setAttribute("aria-pressed", on ? "true" : "false");
    btnScreen.textContent = "Screen: " + (on ? "on" : "off");
  }
  function setLock(on) {
    st.lock = on;
    btnLock.setAttribute("aria-pressed", on ? "true" : "false");
    btnLock.textContent = "Follow: " + (on ? "on" : "off");
  }
  btnScreen.addEventListener("click", function () { setScreen(!st.screenOn); update(true); });
  /* Reset means the state the demo opened in — including the two toggles */
  btnReset.addEventListener("click", function () {
    setScreen(true); setLock(false);
    applyPreset(1, true);
  });

  function applyPreset(i, announce) {
    var p = PRESETS[i];
    st.f = p.f; st.u = p.u; st.h = p.h; st.s = p.s;
    if (st.lock) followScreen();
    usedIt();
    update(announce);
  }
  presets.addEventListener("click", function (e) {
    var b = e.target.closest ? e.target.closest("button[data-p]") : null;
    if (!b) return;
    applyPreset(parseInt(b.getAttribute("data-p"), 10), true);
  });
  function markPresets() {
    var bs = presets.querySelectorAll("button[data-p]");
    for (var i = 0; i < bs.length; i++) {
      var p = PRESETS[parseInt(bs[i].getAttribute("data-p"), 10)];
      var on = Math.abs(p.f - st.f) < 0.05 && Math.abs(p.u - st.u) < 0.05 &&
               Math.abs(p.h - st.h) < 0.05 && Math.abs(p.s - st.s) < 0.06;
      bs[i].setAttribute("aria-pressed", on ? "true" : "false");
    }
  }

  /* ── theme + resize ───────────────────────────────────────────────────── */
  window.addEventListener("message", function (e) {
    var m = e.data;
    if (m && m.type === "sloprun:theme" && (m.theme === "light" || m.theme === "dark")) {
      root.setAttribute("data-theme", m.theme);
      readTheme(); render();
    }
  });
  function onScheme() { readTheme(); render(); }
  if (mqDark) {
    if (mqDark.addEventListener) mqDark.addEventListener("change", onScheme);
    else if (mqDark.addListener) mqDark.addListener(onScheme);
  }

  if (window.ResizeObserver) {
    var ro = new ResizeObserver(function () { layout(); });
    ro.observe(bench);
  } else {
    window.addEventListener("resize", layout);
  }

  /* ── go ───────────────────────────────────────────────────────────────── */
  readTheme();
  layout();
  update(false);
})();
</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