$sloprun.dev

Correlation Playground

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 scatter plot you can edit with your hands. Click the grid to drop a point, drag it anywhere, and Pearson's r, the spread and the least-squares line recompute on every pixel of movement. It opens on Anscombe's first dataset, already fitted, so there is nothing to set up — and the strip of four datasets underneath is where it earns its keep. Load them in order and the summary barely twitches (same means, same spread, same r = 0.82, same line y = 3.00 + 0.500x) while the picture goes from an honest trend, to a smooth arch, to a tight line wrecked by one stray point, to a vertical stack where a single far-off point invents the entire correlation. The comparison table at the bottom runs the same arithmetic over all four sets in your browser rather than quoting numbers typed in by hand, and only claims they match after checking that they do.

Try set IV first, then drag its lone right-hand point downward and watch a "strong correlation" fall apart in your hands. Turn on residuals over set II to see exactly what the fit is minimising — they bend, because a straight line is the wrong shape for an arch. Then hit random cloud a few times to build an eye for what r = 0.4 looks like next to r = 0.8. A few dozen lines of arithmetic on a plain canvas: no libraries, no network, and operable end to end from the keyboard.

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

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1040 lines 45.4 KB index.html
demos/correlation-playground/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>Correlation Playground</title>
<link rel="icon" href="data:,">
<style>
/* sloprun design tokens — inline this block into every demo (self-contained rule).
   Identity: instrument-panel. Machine facts in mono; human words in sans.
   Green is EARNED: only for "it ran / it worked" states, never decoration. */
:root {
  --bg: #F6F6F3;          /* neutral paper, not cream */
  --surface: #FFFFFF;
  --ink: #1A1D21;
  --muted: #5A6068;
  --line: #E3E2DC;
  --accent: #C05600;      /* phosphor amber, darkened for light bg */
  --accent-soft: #FFF3E6;
  --run: #1A7F37;         /* earned green */
  --run-soft: #E7F4EA;
  --danger: #C93C37;
  --radius: 10px;
  --font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
    --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
    --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
  }
}
:root[data-theme="dark"] {
  --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
  --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
  --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }

/* ---------- demo-local palette: the fitted line is never accent, never green ---- */
:root { --fit: #2F6DB0; --fit-soft: #E8F0FA; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { --fit: #74B4FF; --fit-soft: #17222F; }
}
:root[data-theme="dark"] { --fit: #74B4FF; --fit-soft: #17222F; }

/* ---------- base ---------- */
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body { line-height: 1.5; overflow-x: hidden; -webkit-text-size-adjust: 100%; font-size: 15px; }
.wrap { max-width: 1060px; margin: 0 auto; padding: 18px 14px 28px; }
h1, h2, h3 { margin: 0; font-weight: 600; letter-spacing: -0.01em; }
p { margin: 0; }
.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
      overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 5px; }

/* ---------- header ---------- */
.head { display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: flex-start;
        justify-content: space-between; padding-bottom: 14px; margin-bottom: 14px;
        border-bottom: 1px solid var(--line); }
.head h1 { font-size: 1.34rem; }
.lede { margin-top: 6px; color: var(--muted); max-width: 66ch; font-size: 0.95rem; }
.pill { display: inline-flex; align-items: center; gap: 8px; font-family: var(--font-mono);
  font-size: 0.74rem; letter-spacing: 0.02em; padding: 6px 11px; border: 1px solid var(--line);
  border-radius: 999px; background: var(--surface); color: var(--muted); white-space: nowrap;
  flex-shrink: 0; font-variant-numeric: tabular-nums; }
.pill .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); flex: none; }
.pill b { font-weight: 600; color: var(--ink); }

/* ---------- panels ---------- */
.panel { background: var(--surface); border: 1px solid var(--line);
         border-radius: var(--radius); padding: 13px; min-width: 0; }
.ptitle { font-family: var(--font-mono); font-size: 0.68rem; text-transform: uppercase;
  letter-spacing: 0.09em; color: var(--muted); font-weight: 600; }

/* ---------- top layout ---------- */
.top { display: grid; grid-template-columns: minmax(0,1fr) 236px; gap: 12px; align-items: start; }
@media (max-width: 780px) { .top { grid-template-columns: minmax(0,1fr); } }

.plot-head { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: baseline;
             justify-content: space-between; margin-bottom: 9px; }
.hint { font-family: var(--font-mono); font-size: 0.66rem; color: var(--muted); letter-spacing: 0.01em; }
.hint kbd { font: inherit; border: 1px solid var(--line); border-radius: 3px; padding: 0 3px;
            background: var(--bg); color: var(--ink); }
canvas.plot { display: block; width: 100%; height: 340px; border-radius: 7px;
              touch-action: none; cursor: crosshair; }
canvas.plot:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.probe { margin-top: 9px; padding: 7px 10px; border: 1px solid var(--line); border-radius: 7px;
  background: var(--bg); font-family: var(--font-mono); font-size: 0.7rem;
  font-variant-numeric: tabular-nums; color: var(--muted); min-height: 32px;
  display: flex; align-items: center; gap: 3px 12px; flex-wrap: wrap; }
.probe b { font-weight: 600; color: var(--ink); }
.probe .sep { color: var(--line); }

/* ---------- toolbar ---------- */
.bar { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; align-items: center; }
button { font: inherit; color: var(--ink); background: var(--surface); border: 1px solid var(--line);
  border-radius: 7px; padding: 7px 11px; min-height: 34px; cursor: pointer;
  font-family: var(--font-mono); font-size: 0.72rem; letter-spacing: 0.01em;
  transition: background .12s ease, border-color .12s ease, color .12s ease; }
button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
button:disabled { opacity: 0.42; cursor: default; }
button[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
.tgl { display: inline-flex; align-items: center; gap: 7px; }
.tgl .box { width: 10px; height: 10px; border-radius: 2px; border: 1px solid currentColor;
            background: transparent; flex: none; }
button[aria-pressed="true"] .box { background: currentColor; }
.spacer { flex: 1 1 auto; min-width: 0; }

/* ---------- r readout ---------- */
.rbox { border: 1px solid var(--line); border-radius: 8px; background: var(--bg);
        padding: 11px 12px 12px; }
.rlab { font-family: var(--font-mono); font-size: 0.62rem; text-transform: uppercase;
        letter-spacing: 0.09em; color: var(--muted); }
.rval { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 2.3rem;
        line-height: 1.06; font-weight: 600; letter-spacing: -0.035em; color: var(--accent);
        margin-top: 2px; }
.rword { font-size: 0.86rem; color: var(--muted); margin-top: 1px; }
.meter { margin-top: 10px; }
.mtrack { position: relative; height: 8px; border-radius: 4px; background: var(--line);
          overflow: hidden; }
.mzero { position: absolute; left: 50%; top: 0; bottom: 0; width: 1px;
         background: var(--surface); transform: translateX(-0.5px); z-index: 2; }
.mfill { position: absolute; top: 0; bottom: 0; background: var(--accent); border-radius: 3px;
         transition: left .12s ease, width .12s ease; }
.mscale { display: flex; justify-content: space-between; font-family: var(--font-mono);
          font-size: 0.6rem; color: var(--muted); margin-top: 3px; letter-spacing: 0.03em; }
.rows { margin-top: 12px; display: grid; gap: 1px; background: var(--line);
        border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
.row { background: var(--surface); display: flex; justify-content: space-between; gap: 10px;
       padding: 6px 10px; font-family: var(--font-mono); font-size: 0.71rem;
       font-variant-numeric: tabular-nums; }
.row .k { color: var(--muted); }
.row .v { font-weight: 600; }
.eqbox { margin-top: 10px; border: 1px solid var(--fit); border-radius: 8px;
         background: var(--fit-soft); padding: 8px 10px; }
.eqbox .rlab { color: var(--fit); }
.eq { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 0.82rem;
      font-weight: 600; color: var(--fit); margin-top: 2px; word-break: break-word; }
@media (max-width: 780px) {
  .rows { grid-template-columns: 1fr 1fr; }
}

/* ---------- preset chips ---------- */
.chips { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 8px; margin-top: 9px; }
@media (max-width: 620px) { .chips { grid-template-columns: repeat(2, minmax(0,1fr)); } }
.chip { display: block; width: 100%; padding: 8px 8px 7px; text-align: left; border-radius: 8px;
        background: var(--bg); border: 1px solid var(--line); }
.chip[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); }
.chip canvas { display: block; width: 100%; height: 74px; margin-bottom: 6px; border-radius: 4px; }
.chip .nm { display: block; font-family: var(--font-mono); font-size: 0.72rem; font-weight: 600;
            letter-spacing: 0.02em; }
.chip .sb { display: block; font-family: var(--font-sans); font-size: 0.7rem; color: var(--muted);
            margin-top: 1px; line-height: 1.35; }
.chip[aria-pressed="true"] .sb { color: var(--accent); }
.chip:hover:not([aria-pressed="true"]) .sb { color: var(--accent); }

/* ---------- lesson ---------- */
.lesson { border: 1px solid var(--line); border-left: 4px solid var(--accent);
  background: var(--accent-soft); border-radius: var(--radius); padding: 11px 14px; margin-top: 12px; }
.lesson strong { font-family: var(--font-mono); font-size: 0.64rem; text-transform: uppercase;
  letter-spacing: 0.09em; color: var(--accent); display: block; margin-bottom: 2px; }
.lesson p { font-size: 0.94rem; max-width: 76ch; }

/* ---------- comparison table ---------- */
.tscroll { overflow-x: auto; margin-top: 9px; border: 1px solid var(--line); border-radius: 8px;
            -webkit-overflow-scrolling: touch; }
.swipe { display: none; font-family: var(--font-mono); font-size: 0.64rem; color: var(--muted);
         margin-top: 6px; letter-spacing: 0.03em; }
@media (max-width: 660px) { .swipe { display: block; } }
table { border-collapse: collapse; width: 100%; min-width: 520px; font-family: var(--font-mono);
        font-size: 0.72rem; font-variant-numeric: tabular-nums; }
th, td { text-align: right; padding: 7px 10px; border-bottom: 1px solid var(--line); white-space: nowrap; }
th:first-child, td:first-child { text-align: left; }
thead th { font-size: 0.6rem; text-transform: uppercase; letter-spacing: 0.07em;
           color: var(--muted); font-weight: 600; background: var(--bg); }
tbody tr:last-child th, tbody tr:last-child td { border-bottom: 0; }
tbody th { font-weight: 600; }
tbody tr[data-on="1"] th, tbody tr[data-on="1"] td { background: var(--accent-soft); color: var(--accent); }
.shape { font-family: var(--font-sans); color: var(--muted); font-size: 0.72rem; }
tbody tr[data-on="1"] .shape { color: var(--accent); }
.verified { display: flex; align-items: center; gap: 8px; margin-top: 9px; padding: 7px 10px;
  border: 1px solid var(--run); background: var(--run-soft); color: var(--run); border-radius: 7px;
  font-family: var(--font-mono); font-size: 0.68rem; letter-spacing: 0.01em; }
.verified .tick { flex: none; font-weight: 700; }
.verified[hidden] { display: none; }

/* ---------- footer ---------- */
.foot { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--line);
  font-family: var(--font-mono); font-size: 0.7rem; color: var(--muted);
  display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; }

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important;
    transition-duration: .001ms !important; }
}
@media (max-width: 560px) {
  .wrap { padding: 14px 11px 22px; }
  .head h1 { font-size: 1.16rem; }
  canvas.plot { height: 290px; }
  .rval { font-size: 2rem; }
}
@media (max-width: 380px) {
  canvas.plot { height: 262px; }
}
</style>
</head>
<body>
<div class="wrap">

  <header class="head">
    <div>
      <h1>Correlation Playground</h1>
      <p class="lede">Click the grid to drop a point, drag it to move it. The correlation
        <em>r</em> and the best-fit line recompute on every pixel. Then load the four classic
        datasets below: identical averages, identical spread, identical <em>r</em>, identical
        line — and four pictures that look nothing alike.</p>
    </div>
    <div class="pill" id="pill">
      <span class="dot" aria-hidden="true"></span>
      <span id="pillText">set I &middot; n 11 &middot; r 0.816</span>
    </div>
  </header>

  <div class="top">

    <section class="panel" aria-labelledby="plotH">
      <div class="plot-head">
        <h2 id="plotH" class="ptitle">Scatter &middot; drag anything</h2>
        <p class="hint"><kbd>click</kbd> add &middot; <kbd>drag</kbd> move &middot;
           <kbd>alt</kbd>+click remove</p>
      </div>

      <canvas id="plot" class="plot" tabindex="0" role="application"
        aria-label="Editable scatter plot"
        aria-describedby="kbhelp"></canvas>

      <p id="kbhelp" class="sr">Keyboard: arrow keys move a crosshair, hold shift for bigger steps.
        Enter drops a point at the crosshair. Press N or P to step through the existing points; with a
        point selected the arrow keys move that point. Delete removes it. Escape deselects.</p>

      <div class="probe" id="probe"><span>hover a point to inspect it</span></div>

      <div class="bar" role="group" aria-label="Plot tools">
        <button type="button" id="bRandom">random cloud</button>
        <button type="button" id="bClear">clear</button>
        <button type="button" id="bUndo" disabled>undo</button>
        <span class="spacer"></span>
        <button type="button" id="bResid" class="tgl" aria-pressed="false"><span class="box" aria-hidden="true"></span>residuals</button>
        <button type="button" id="bMeans" class="tgl" aria-pressed="false"><span class="box" aria-hidden="true"></span>averages</button>
      </div>

      <p class="sr" id="live" role="status" aria-live="polite"></p>
    </section>

    <aside class="panel" aria-labelledby="statH">
      <h2 id="statH" class="sr">Statistics</h2>

      <div class="rbox">
        <p class="rlab">Pearson r</p>
        <p class="rval" id="rVal">0.816</p>
        <p class="rword" id="rWord">strong positive</p>
        <div class="meter" aria-hidden="true">
          <div class="mtrack">
            <div class="mfill" id="mFill" style="left:50%;width:0%"></div>
            <div class="mzero"></div>
          </div>
          <div class="mscale"><span>&minus;1</span><span>0</span><span>+1</span></div>
        </div>
      </div>

      <div class="rows">
        <div class="row"><span class="k">points</span><span class="v" id="sN">11</span></div>
        <div class="row"><span class="k">mean x</span><span class="v" id="sMx">9.00</span></div>
        <div class="row"><span class="k">mean y</span><span class="v" id="sMy">7.50</span></div>
        <div class="row"><span class="k">sd x</span><span class="v" id="sSx">3.32</span></div>
        <div class="row"><span class="k">sd y</span><span class="v" id="sSy">2.03</span></div>
        <div class="row"><span class="k">r&#178;</span><span class="v" id="sR2">0.67</span></div>
      </div>

      <div class="eqbox">
        <p class="rlab">least-squares fit</p>
        <p class="eq" id="sFit">y = 3.00 + 0.500x</p>
      </div>
    </aside>
  </div>

  <section class="panel" style="margin-top:12px" aria-labelledby="chipH">
    <h2 id="chipH" class="ptitle">Load a dataset &middot; the four look different, the numbers do not</h2>
    <div class="chips" id="chips" role="group" aria-label="Preset datasets"></div>
  </section>

  <div class="lesson">
    <strong>the lesson</strong>
    <p id="lessonText">A plain rising trend with ordinary scatter. This is what people picture
      when they hear &ldquo;r = 0.82&rdquo; — and it is only one of the four shapes that produce it.</p>
  </div>

  <section class="panel" style="margin-top:12px" aria-labelledby="tblH">
    <h2 id="tblH" class="ptitle">Same summary, four shapes &middot; every number computed in your browser</h2>
    <div class="tscroll">
      <table>
        <thead>
          <tr>
            <th scope="col">set</th><th scope="col">mean x</th><th scope="col">mean y</th>
            <th scope="col">sd x</th><th scope="col">sd y</th><th scope="col">r</th>
            <th scope="col">fit</th><th scope="col">what it looks like</th>
          </tr>
        </thead>
        <tbody id="tbody"></tbody>
      </table>
    </div>
    <p class="swipe" aria-hidden="true">&rarr; the table scrolls sideways</p>
    <p class="verified" id="verified" hidden><span class="tick" aria-hidden="true">&#10003;</span><span id="verifiedText"></span></p>
  </section>

  <footer class="foot">
    <span>demo &middot; sloprun.dev</span>
    <span id="footRight">4 datasets &middot; 44 points &middot; 1 identical summary</span>
  </footer>
</div>

<script>
(function () {
  "use strict";

  /* ================================================================== *
   * Data — Anscombe's quartet (F.J. Anscombe, 1973).
   * ================================================================== */
  var XA = [10, 8, 13, 9, 11, 14, 6, 4, 12, 7, 5];
  var SETS = [
    { id: "I", name: "set I", shape: "a plain trend",
      sub: "ordinary scatter around a line",
      lesson: "A plain rising trend with ordinary scatter. This is what people picture when they hear “r = 0.82” — and it is only one of the four shapes that produce it.",
      x: XA, y: [8.04, 6.95, 7.58, 8.81, 8.33, 9.96, 7.24, 4.26, 10.84, 4.82, 5.68] },
    { id: "II", name: "set II", shape: "a smooth curve",
      sub: "a perfect arch, not a line",
      lesson: "Not scatter at all — a smooth arch. Every point sits exactly on a curve, so the relationship is perfect, and a straight line is simply the wrong shape for it. Turn on residuals: they bend.",
      x: XA, y: [9.14, 8.14, 8.74, 8.77, 9.26, 8.10, 6.13, 3.10, 9.13, 7.26, 4.74] },
    { id: "III", name: "set III", shape: "one point off",
      sub: "a tight line plus one stray",
      lesson: "Ten points sit on a near-perfect line, and one stray point up top tilts the fit away from all of them. Drag that stray back down and watch r jump to almost 1.",
      x: XA, y: [7.46, 6.77, 12.74, 7.11, 7.81, 8.84, 6.08, 5.39, 8.15, 6.42, 5.73] },
    { id: "IV", name: "set IV", shape: "one point rules",
      sub: "a stack, and one far-off point",
      lesson: "Ten points share the exact same x, so on their own they say nothing about a trend. The single point far to the right invents the whole correlation — drag it downward and r collapses.",
      x: [8, 8, 8, 8, 8, 8, 8, 19, 8, 8, 8],
      y: [6.58, 5.76, 7.71, 8.84, 8.47, 7.04, 5.25, 12.50, 5.56, 7.91, 6.89] }
  ];

  var XD = [0, 20], YD = [0, 14];   /* fixed data window, so every dataset is comparable */
  var TXD = [3, 20], TYD = [2.4, 13.6];  /* tighter window for the 66px thumbnails */

  /* ================================================================== *
   * Stats
   * ================================================================== */
  function stats(pts) {
    var n = pts.length, i;
    var out = { n: n, ok: false, mx: NaN, my: NaN, sx: NaN, sy: NaN, r: NaN,
                slope: NaN, icept: NaN, why: "" };
    if (n === 0) { out.why = "no points yet"; return out; }
    var sx = 0, sy = 0;
    for (i = 0; i < n; i++) { sx += pts[i].x; sy += pts[i].y; }
    out.mx = sx / n; out.my = sy / n;
    if (n < 2) { out.why = "one point — r needs at least two"; return out; }
    var sxx = 0, syy = 0, sxy = 0, dx, dy;
    for (i = 0; i < n; i++) {
      dx = pts[i].x - out.mx; dy = pts[i].y - out.my;
      sxx += dx * dx; syy += dy * dy; sxy += dx * dy;
    }
    out.sx = Math.sqrt(sxx / (n - 1));
    out.sy = Math.sqrt(syy / (n - 1));
    if (sxx <= 1e-12 && syy <= 1e-12) { out.why = "every point is in the same spot"; return out; }
    if (sxx <= 1e-12) { out.why = "x never changes — r needs both to vary"; return out; }
    if (syy <= 1e-12) { out.why = "y never changes — r needs both to vary"; return out; }
    out.r = Math.max(-1, Math.min(1, sxy / Math.sqrt(sxx * syy)));
    out.slope = sxy / sxx;
    out.icept = out.my - out.slope * out.mx;
    out.ok = true;
    return out;
  }

  function fmt(v, d) { return (isFinite(v) ? v.toFixed(d) : "—"); }
  function eqText(s) {
    if (!s.ok) return "—";
    var b = s.slope, a = s.icept;
    return "y = " + a.toFixed(2) + (b < 0 ? " − " : " + ") + Math.abs(b).toFixed(3) + "x";
  }
  function word(r) {
    var a = Math.abs(r), dir = r < 0 ? "negative" : "positive";
    if (a >= 0.9995) return "perfectly " + dir;   /* rounds to 1.000 on screen */
    if (a >= 0.9) return "very strong " + dir;
    if (a >= 0.7) return "strong " + dir;
    if (a >= 0.4) return "moderate " + dir;
    if (a >= 0.2) return "weak " + dir;
    return "essentially none";
  }

  /* ================================================================== *
   * State
   * ================================================================== */
  var S = {
    pts: SETS[0].x.map(function (v, i) { return { x: v, y: SETS[0].y[i] }; }),
    preset: "I",
    sel: -1, hover: -1, drag: -1, moved: false,
    kb: false, cx: 10, cy: 7,
    fresh: false, editRun: -2,
    resid: false, means: false,
    undo: []
  };

  var $ = function (id) { return document.getElementById(id); };
  var cv = $("plot"), ctx = cv.getContext("2d");

  function snapshot() {
    S.undo.push({ pts: S.pts.map(function (p) { return { x: p.x, y: p.y }; }),
                  preset: S.preset, lesson: $("lessonText").textContent });
    if (S.undo.length > 40) S.undo.shift();
    $("bUndo").disabled = false;
  }
  function clampX(v) { return Math.max(XD[0], Math.min(XD[1], v)); }
  function clampY(v) { return Math.max(YD[0], Math.min(YD[1], v)); }

  /* ================================================================== *
   * Canvas geometry + drawing
   * ================================================================== */
  var G = { L: 44, R: 12, T: 12, B: 30, w: 0, h: 0, W: 0, H: 0 };

  function C(name) {
    return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
  }

  function sizeCanvas() {
    var dpr = Math.min(window.devicePixelRatio || 1, 2.5);
    var rect = cv.getBoundingClientRect();
    G.W = Math.max(200, Math.round(rect.width));
    G.H = Math.max(160, Math.round(rect.height));
    var bw = Math.round(G.W * dpr), bh = Math.round(G.H * dpr);
    if (cv.width !== bw || cv.height !== bh) { cv.width = bw; cv.height = bh; }
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    if (G.W < 420) { G.L = 32; G.B = 26; } else { G.L = 44; G.B = 30; }
    G.w = G.W - G.L - G.R;
    G.h = G.H - G.T - G.B;
  }
  function px(x) { return G.L + (x - XD[0]) / (XD[1] - XD[0]) * G.w; }
  function py(y) { return G.T + (1 - (y - YD[0]) / (YD[1] - YD[0])) * G.h; }
  function ux(p) { return XD[0] + (p - G.L) / G.w * (XD[1] - XD[0]); }
  function uy(p) { return YD[0] + (1 - (p - G.T) / G.h) * (YD[1] - YD[0]); }

  function draw() {
    sizeCanvas();
    var col = {
      surface: C("--surface"), bg: C("--bg"), ink: C("--ink"), muted: C("--muted"),
      line: C("--line"), accent: C("--accent"), fit: C("--fit"), danger: C("--danger")
    };
    var s = stats(S.pts);

    ctx.clearRect(0, 0, G.W, G.H);
    ctx.fillStyle = col.bg;
    roundRect(G.L, G.T, G.w, G.h, 6); ctx.fill();

    /* grid */
    ctx.strokeStyle = col.line; ctx.lineWidth = 1;
    ctx.font = "11px " + C("--font-mono");
    ctx.fillStyle = col.muted;
    var i, v;
    ctx.textAlign = "center"; ctx.textBaseline = "top";
    for (v = XD[0]; v <= XD[1] + 0.001; v += 4) {
      var gx = Math.round(px(v)) + 0.5;
      ctx.beginPath(); ctx.moveTo(gx, G.T); ctx.lineTo(gx, G.T + G.h); ctx.stroke();
      ctx.fillText(String(v), gx, G.T + G.h + 7);
    }
    ctx.textAlign = "right"; ctx.textBaseline = "middle";
    for (v = YD[0]; v <= YD[1] + 0.001; v += 2) {
      var gy = Math.round(py(v)) + 0.5;
      ctx.beginPath(); ctx.moveTo(G.L, gy); ctx.lineTo(G.L + G.w, gy); ctx.stroke();
      ctx.fillText(String(v), G.L - 7, gy);
    }
    ctx.save();
    ctx.globalAlpha = 0.85;
    ctx.textAlign = "left"; ctx.textBaseline = "top";
    ctx.fillText("y", G.L + 7, G.T + 6);
    ctx.textAlign = "right"; ctx.textBaseline = "bottom";
    ctx.fillText("x", G.L + G.w - 7, G.T + G.h - 5);
    ctx.restore();

    /* average lines */
    if (S.means && s.n >= 1) {
      ctx.save();
      ctx.strokeStyle = col.muted; ctx.lineWidth = 1; ctx.setLineDash([4, 4]);
      ctx.globalAlpha = 0.75;
      var mxp = px(clampX(s.mx)), myp = py(clampY(s.my));
      ctx.beginPath(); ctx.moveTo(mxp, G.T); ctx.lineTo(mxp, G.T + G.h); ctx.stroke();
      ctx.beginPath(); ctx.moveTo(G.L, myp); ctx.lineTo(G.L + G.w, myp); ctx.stroke();
      ctx.setLineDash([]); ctx.globalAlpha = 1;
      ctx.font = "10px " + C("--font-mono");
      ctx.fillStyle = col.muted; ctx.textAlign = "left"; ctx.textBaseline = "bottom";
      ctx.fillText("mean y " + s.my.toFixed(2), G.L + 4, myp - 3);
      ctx.textAlign = "center"; ctx.textBaseline = "top";
      ctx.fillText("mean x " + s.mx.toFixed(2),
                   Math.min(Math.max(mxp, G.L + 34), G.L + G.w - 34), G.T + 4);
      ctx.restore();
    }

    /* residuals */
    if (S.resid && s.ok) {
      ctx.save();
      ctx.strokeStyle = col.muted; ctx.lineWidth = 1.5; ctx.globalAlpha = 0.7;
      ctx.setLineDash([3, 3]);
      for (i = 0; i < S.pts.length; i++) {
        var p = S.pts[i], yh = s.icept + s.slope * p.x;
        ctx.beginPath();
        ctx.moveTo(px(p.x), py(clampY(p.y)));
        ctx.lineTo(px(p.x), py(clampY(yh)));
        ctx.stroke();
      }
      ctx.restore();
    }

    /* fitted line */
    if (s.ok) {
      ctx.save();
      roundRect(G.L, G.T, G.w, G.h, 6); ctx.clip();   /* clip FIRST — roundRect resets the path */
      var yl = s.icept + s.slope * XD[0], yr = s.icept + s.slope * XD[1];
      ctx.strokeStyle = col.fit; ctx.lineWidth = 2.25; ctx.lineCap = "round";
      ctx.beginPath();
      ctx.moveTo(px(XD[0]), py(yl));
      ctx.lineTo(px(XD[1]), py(yr));
      ctx.stroke();
      ctx.restore();
    }

    /* keyboard crosshair */
    if (S.kb && S.sel < 0) {
      ctx.save();
      ctx.strokeStyle = col.accent; ctx.lineWidth = 1; ctx.setLineDash([2, 3]);
      var kx = px(S.cx), ky = py(S.cy);
      ctx.beginPath(); ctx.moveTo(kx, G.T); ctx.lineTo(kx, G.T + G.h); ctx.stroke();
      ctx.beginPath(); ctx.moveTo(G.L, ky); ctx.lineTo(G.L + G.w, ky); ctx.stroke();
      ctx.setLineDash([]);
      ctx.beginPath(); ctx.arc(kx, ky, 6, 0, 6.2832); ctx.stroke();
      ctx.restore();
    }

    /* empty grid — say so on the canvas, not just in the panel */
    if (S.pts.length === 0) {
      ctx.save();
      ctx.fillStyle = col.muted; ctx.globalAlpha = 0.9;
      ctx.textAlign = "center"; ctx.textBaseline = "middle";
      ctx.font = "13px " + C("--font-sans");
      ctx.fillText("click anywhere to place a point",
                   G.L + G.w / 2, G.T + G.h * 0.31);
      ctx.font = "11px " + C("--font-mono");
      ctx.globalAlpha = 0.75;
      ctx.fillText("or load one of the four sets below",
                   G.L + G.w / 2, G.T + G.h * 0.31 + 21);
      ctx.restore();
    }

    /* points */
    for (i = 0; i < S.pts.length; i++) {
      var q = S.pts[i], X = px(q.x), Y = py(q.y);
      var on = (i === S.sel), hv = (i === S.hover || i === S.drag);
      if (on || hv) {
        ctx.beginPath(); ctx.arc(X, Y, on ? 11 : 9, 0, 6.2832);
        ctx.strokeStyle = col.accent; ctx.lineWidth = on ? 2 : 1;
        ctx.globalAlpha = on ? 1 : 0.6; ctx.stroke(); ctx.globalAlpha = 1;
      }
      ctx.beginPath(); ctx.arc(X, Y, on ? 6.5 : 5.5, 0, 6.2832);
      ctx.fillStyle = col.accent; ctx.fill();
      ctx.lineWidth = 1.5; ctx.strokeStyle = col.surface; ctx.stroke();
    }
  }

  function roundRect(x, y, w, h, r) {
    ctx.beginPath();
    ctx.moveTo(x + r, y);
    ctx.arcTo(x + w, y, x + w, y + h, r);
    ctx.arcTo(x + w, y + h, x, y + h, r);
    ctx.arcTo(x, y + h, x, y, r);
    ctx.arcTo(x, y, x + w, y, r);
    ctx.closePath();
  }

  /* ================================================================== *
   * Panel rendering
   * ================================================================== */
  var SEP = '<span class="sep" aria-hidden="true">&middot;</span>';
  function setProbe(html) { $("probe").innerHTML = html; }

  function describePoint(i) {
    var s = stats(S.pts), p = S.pts[i];
    var bits = "<span>point <b>" + (i + 1) + "</b></span>" + SEP +
               "<span>x <b>" + p.x.toFixed(2) + "</b></span>" + SEP +
               "<span>y <b>" + p.y.toFixed(2) + "</b></span>";
    if (s.ok) {
      var res = p.y - (s.icept + s.slope * p.x);
      bits += SEP + "<span>off the line by <b>" + (res >= 0 ? "+" : "−") +
              Math.abs(res).toFixed(2) + "</b></span>";
    }
    return bits;
  }

  function render() {
    var s = stats(S.pts);

    $("sN").textContent = String(s.n);
    $("sMx").textContent = fmt(s.mx, 2);
    $("sMy").textContent = fmt(s.my, 2);
    $("sSx").textContent = fmt(s.sx, 2);
    $("sSy").textContent = fmt(s.sy, 2);
    $("sR2").textContent = s.ok ? (s.r * s.r).toFixed(2) : "—";
    $("sFit").textContent = eqText(s);

    if (s.ok) {
      $("rVal").textContent = (s.r >= 0 ? "" : "−") + Math.abs(s.r).toFixed(3);
      $("rWord").textContent = word(s.r);
      var pct = Math.abs(s.r) * 50;
      $("mFill").style.left = (s.r >= 0 ? 50 : 50 - pct) + "%";
      $("mFill").style.width = pct + "%";
    } else {
      $("rVal").textContent = "—";
      $("rWord").textContent = s.why;
      $("mFill").style.left = "50%";
      $("mFill").style.width = "0%";
    }

    var label = S.preset ? ("set " + S.preset) : "your points";
    $("pillText").textContent = label + " · n " + s.n + " · r " +
      (s.ok ? (s.r >= 0 ? "" : "−") + Math.abs(s.r).toFixed(3) : "—");

    if (S.sel >= 0) setProbe(describePoint(S.sel) + SEP + "<span>selected</span>");
    else if (S.hover >= 0) setProbe(describePoint(S.hover));
    else if (!s.ok && s.n > 0) setProbe("<span>" + s.why + "</span>");
    else if (s.n === 0) setProbe("<span>empty grid — click anywhere to start placing points</span>");
    else setProbe("<span>hover a point to inspect it</span>");

    /* chips + table active row */
    var chips = document.querySelectorAll(".chip"), t;
    for (t = 0; t < chips.length; t++) {
      chips[t].setAttribute("aria-pressed", chips[t].getAttribute("data-set") === S.preset ? "true" : "false");
    }
    var rows = document.querySelectorAll("#tbody tr");
    for (t = 0; t < rows.length; t++) {
      rows[t].setAttribute("data-on", rows[t].getAttribute("data-set") === S.preset ? "1" : "0");
    }
    $("bUndo").disabled = S.undo.length === 0;
    $("footRight").textContent = S.preset
      ? ("4 datasets · 44 points · 1 identical summary")
      : ("your own points · n " + s.n);

    draw();
  }

  function say(msg) { $("live").textContent = msg; }

  /* ================================================================== *
   * Mutations
   * ================================================================== */
  function loadSet(id) {
    var set = null, i;
    for (i = 0; i < SETS.length; i++) if (SETS[i].id === id) set = SETS[i];
    if (!set) return;
    snapshot();
    S.pts = set.x.map(function (v, k) { return { x: v, y: set.y[k] }; });
    S.preset = id; S.sel = -1; S.hover = -1; S.editRun = -2;
    $("lessonText").textContent = set.lesson;
    render();
    var s = stats(S.pts);
    say("Loaded set " + id + ", " + set.shape + ". r is " + s.r.toFixed(3) +
        ", fit " + eqText(s) + ".");
  }

  function markCustom() {
    if (S.preset === null) return;
    var from = S.preset;
    S.preset = null;
    $("lessonText").textContent = "You are editing set " + from + " now — so it is your data, not " +
      "Anscombe's. Keep an eye on the right-hand column while you move things: the summary " +
      "always follows the picture, and never the other way round.";
  }

  function addPoint(x, y) {
    snapshot(); markCustom();
    S.pts.push({ x: clampX(x), y: clampY(y) });
    S.editRun = -2;
    return S.pts.length - 1;
  }

  function removePoint(i) {
    if (i < 0 || i >= S.pts.length) return;
    snapshot(); markCustom();
    S.pts.splice(i, 1);
    S.editRun = -2;
    if (S.sel === i) S.sel = -1; else if (S.sel > i) S.sel--;
    if (S.hover === i) S.hover = -1; else if (S.hover > i) S.hover--;
  }

  function gauss() {
    var u = 0, v = 0;
    while (u === 0) u = Math.random();
    while (v === 0) v = Math.random();
    return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
  }

  function randomCloud() {
    snapshot(); markCustom();
    var rho = (Math.random() * 1.8 - 0.9);
    var n = 18 + Math.floor(Math.random() * 12);
    var pts = [], i, z1, z2;
    for (i = 0; i < n; i++) {
      z1 = gauss(); z2 = gauss();
      pts.push({
        x: clampX(10 + 3.5 * z1),
        y: clampY(7 + 2.4 * (rho * z1 + Math.sqrt(1 - rho * rho) * z2))
      });
    }
    S.pts = pts; S.sel = -1; S.hover = -1; S.editRun = -2;
    $("lessonText").textContent = "A fresh random cloud, aimed at r ≈ " + rho.toFixed(2) +
      ". Do this a few times and you build an eye for what a given r actually looks like — " +
      "the number alone is a poor picture.";
    render();
    var rs = stats(S.pts);
    say("Random cloud of " + n + " points, r " + (rs.ok ? rs.r.toFixed(3) : "undefined") + ".");
  }

  /* ================================================================== *
   * Pointer interaction
   * ================================================================== */
  function localPos(e) {
    var r = cv.getBoundingClientRect();
    return { px: e.clientX - r.left, py: e.clientY - r.top };
  }
  function hitTest(mx, my) {
    var best = -1, bd = 15 * 15, i, dx, dy, d;
    for (i = S.pts.length - 1; i >= 0; i--) {
      dx = px(S.pts[i].x) - mx; dy = py(S.pts[i].y) - my; d = dx * dx + dy * dy;
      if (d < bd) { bd = d; best = i; }
    }
    return best;
  }

  cv.addEventListener("pointerdown", function (e) {
    if (e.button !== 0 && e.pointerType === "mouse") return;
    var m = localPos(e), hit = hitTest(m.px, m.py);
    S.kb = false;

    if ((e.altKey || e.metaKey) && hit >= 0) {
      removePoint(hit); render(); say("Point removed. " + S.pts.length + " left."); return;
    }
    if (hit < 0 && (m.px < G.L - 6 || m.px > G.L + G.w + 6 || m.py < G.T - 6 || m.py > G.T + G.h + 6)) return;

    if (hit >= 0) { S.drag = hit; S.sel = hit; S.fresh = false; }
    else { S.drag = addPoint(ux(m.px), uy(m.py)); S.sel = S.drag; S.fresh = true; }
    S.moved = false; S.editRun = -2;
    try { cv.setPointerCapture(e.pointerId); } catch (err) {}
    e.preventDefault();
    render();
  });

  cv.addEventListener("pointermove", function (e) {
    var m = localPos(e);
    if (S.drag >= 0) {
      /* one undo step per drag: a freshly added point already snapshotted on add */
      if (!S.moved && !S.fresh) snapshot();
      markCustom();
      S.pts[S.drag].x = clampX(ux(m.px));
      S.pts[S.drag].y = clampY(uy(m.py));
      S.moved = true;
      render();
    } else {
      var h = hitTest(m.px, m.py);
      cv.style.cursor = h >= 0 ? "grab" : "crosshair";
      if (h !== S.hover) { S.hover = h; render(); }
    }
  });

  function endDrag(e) {
    if (S.drag < 0) return;
    var i = S.drag;
    S.drag = -1;
    try { cv.releasePointerCapture(e.pointerId); } catch (err) {}
    var s = stats(S.pts);
    say((S.moved ? "Point moved to x " : "Point placed at x ") + S.pts[i].x.toFixed(2) +
        ", y " + S.pts[i].y.toFixed(2) + ". r is now " + (s.ok ? s.r.toFixed(3) : "undefined") + ".");
    render();
  }
  cv.addEventListener("pointerup", endDrag);
  cv.addEventListener("pointercancel", endDrag);
  cv.addEventListener("pointerleave", function () {
    if (S.drag < 0 && S.hover !== -1) { S.hover = -1; render(); }
  });

  cv.addEventListener("contextmenu", function (e) {
    var m = localPos(e), hit = hitTest(m.px, m.py);
    if (hit >= 0) {
      e.preventDefault();
      removePoint(hit); render(); say("Point removed. " + S.pts.length + " left.");
    }
  });

  /* ================================================================== *
   * Keyboard interaction
   * ================================================================== */
  cv.addEventListener("focus", function () {
    var vis = true;
    try { vis = cv.matches(":focus-visible"); } catch (err) { vis = true; }
    if (S.kb !== vis) { S.kb = vis; render(); }
  });
  cv.addEventListener("blur", function () { S.kb = false; render(); });

  var HANDLED = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Enter", " ", "Spacebar",
                 "n", "N", "p", "P", "Delete", "Backspace", "Escape"];

  cv.addEventListener("keydown", function (e) {
    /* never swallow a browser shortcut (ctrl/cmd/alt + key) */
    if (e.ctrlKey || e.metaKey || e.altKey) return;
    var step = e.shiftKey ? 1 : 0.25, k = e.key, dx = 0, dy = 0, used = true;
    if (HANDLED.indexOf(k) < 0) return;   /* leave Tab, typing, F-keys alone */
    S.kb = true;
    if (k === "ArrowLeft") dx = -step;
    else if (k === "ArrowRight") dx = step;
    else if (k === "ArrowUp") dy = step;
    else if (k === "ArrowDown") dy = -step;
    else if (k === "Enter" || k === " " || k === "Spacebar") {
      if (S.sel >= 0) {
        S.cx = S.pts[S.sel].x; S.cy = S.pts[S.sel].y; S.sel = -1; S.editRun = -2;
        say("Deselected. Crosshair at x " + S.cx.toFixed(2) + ", y " + S.cy.toFixed(2) + ".");
      } else {
        S.sel = addPoint(S.cx, S.cy);
        var sa = stats(S.pts);
        say("Point placed at x " + S.cx.toFixed(2) + ", y " + S.cy.toFixed(2) + ". " +
            S.pts.length + (S.pts.length === 1 ? " point" : " points") + ", r " +
            (sa.ok ? sa.r.toFixed(3) : "undefined") +
            ". It is selected: arrow keys move it, Enter drops it and frees the crosshair.");
      }
      e.preventDefault(); render(); return;
    }
    else if (k === "n" || k === "N" || k === "p" || k === "P") {
      if (!S.pts.length) { say("No points to step through."); e.preventDefault(); return; }
      var d = (k === "n" || k === "N") ? 1 : -1;
      S.sel = (S.sel < 0 ? (d > 0 ? 0 : S.pts.length - 1)
                         : (S.sel + d + S.pts.length) % S.pts.length);
      S.editRun = -2;
      var ps = S.pts[S.sel];
      say("Point " + (S.sel + 1) + " of " + S.pts.length + " selected, x " +
          ps.x.toFixed(2) + ", y " + ps.y.toFixed(2) + ". Arrow keys move it.");
      e.preventDefault(); render(); return;
    }
    else if (k === "Delete" || k === "Backspace") {
      if (S.sel >= 0) {
        removePoint(S.sel);
        say("Point removed. " + S.pts.length + " left.");
      } else say("Nothing selected. Press N to select a point.");
      e.preventDefault(); render(); return;
    }
    else if (k === "Escape") {
      if (S.sel >= 0) { S.cx = S.pts[S.sel].x; S.cy = S.pts[S.sel].y; S.sel = -1; S.editRun = -2; say("Deselected."); }
      e.preventDefault(); render(); return;
    }
    else used = false;

    if (!used) return;
    e.preventDefault();
    if (S.sel >= 0) {
      if (S.editRun !== S.sel) { snapshot(); S.editRun = S.sel; }
      markCustom();
      S.pts[S.sel].x = clampX(S.pts[S.sel].x + dx);
      S.pts[S.sel].y = clampY(S.pts[S.sel].y + dy);
      var s2 = stats(S.pts);
      say("x " + S.pts[S.sel].x.toFixed(2) + ", y " + S.pts[S.sel].y.toFixed(2) +
          ". r " + (s2.ok ? s2.r.toFixed(3) : "undefined") + ".");
    } else {
      S.cx = clampX(S.cx + dx); S.cy = clampY(S.cy + dy);
      say("Crosshair x " + S.cx.toFixed(2) + ", y " + S.cy.toFixed(2) + ". Enter to place.");
    }
    render();
  });

  /* ================================================================== *
   * Buttons
   * ================================================================== */
  $("bClear").addEventListener("click", function () {
    snapshot(); markCustom();
    S.pts = []; S.sel = -1; S.hover = -1; S.editRun = -2;
    $("lessonText").textContent = "Empty grid. Click to place points one at a time and watch r " +
      "wander — two points always give exactly ±1, and the number only starts meaning " +
      "something once there is a real cloud.";
    render(); say("Grid cleared.");
  });

  $("bRandom").addEventListener("click", randomCloud);

  $("bUndo").addEventListener("click", function () {
    var prev = S.undo.pop();
    if (!prev) return;
    S.pts = prev.pts; S.preset = prev.preset; S.sel = -1; S.hover = -1; S.editRun = -2;
    $("lessonText").textContent = prev.lesson;
    render(); say("Undone. " + S.pts.length + " points.");
  });

  function toggle(btn, key, onMsg, offMsg) {
    btn.addEventListener("click", function () {
      S[key] = !S[key];
      btn.setAttribute("aria-pressed", S[key] ? "true" : "false");
      render(); say(S[key] ? onMsg : offMsg);
    });
  }
  toggle($("bResid"), "resid",
    "Residuals shown — each dashed stub is the gap the fit is minimising.",
    "Residuals hidden.");
  toggle($("bMeans"), "means",
    "Average lines shown.", "Average lines hidden.");

  /* ================================================================== *
   * Preset chips + comparison table (built from the data, not hardcoded)
   * ================================================================== */
  var thumbs = [];

  function buildChips() {
    var host = $("chips"), html = "", i;
    for (i = 0; i < SETS.length; i++) {
      html += '<button type="button" class="chip" data-set="' + SETS[i].id +
              '" aria-pressed="false">' +
              '<canvas aria-hidden="true"></canvas>' +
              '<span class="nm">' + SETS[i].name + '</span>' +
              '<span class="sb">' + SETS[i].sub + '</span></button>';
    }
    host.innerHTML = html;
    var btns = host.querySelectorAll(".chip");
    for (i = 0; i < btns.length; i++) {
      thumbs.push(btns[i].querySelector("canvas"));
      btns[i].addEventListener("click", function () { loadSet(this.getAttribute("data-set")); });
    }
  }

  function drawThumbs() {
    var dpr = Math.min(window.devicePixelRatio || 1, 2.5);
    for (var i = 0; i < thumbs.length; i++) {
      var c = thumbs[i], k = c.getContext("2d"), set = SETS[i];
      var r = c.getBoundingClientRect();
      var W = Math.max(40, Math.round(r.width)), H = Math.max(30, Math.round(r.height));
      c.width = Math.round(W * dpr); c.height = Math.round(H * dpr);
      k.setTransform(dpr, 0, 0, dpr, 0, 0);
      var pad = 7;
      var fx = function (x) { return pad + (x - TXD[0]) / (TXD[1] - TXD[0]) * (W - 2 * pad); };
      var fy = function (y) { return pad + (1 - (y - TYD[0]) / (TYD[1] - TYD[0])) * (H - 2 * pad); };
      k.clearRect(0, 0, W, H);
      k.fillStyle = C("--surface");
      k.fillRect(0, 0, W, H);
      k.strokeStyle = C("--line"); k.lineWidth = 1;
      k.strokeRect(0.5, 0.5, W - 1, H - 1);
      var st = stats(set.x.map(function (v, j) { return { x: v, y: set.y[j] }; }));
      k.strokeStyle = C("--fit"); k.lineWidth = 1.5;
      k.beginPath();
      k.moveTo(fx(TXD[0]), fy(st.icept + st.slope * TXD[0]));
      k.lineTo(fx(TXD[1]), fy(st.icept + st.slope * TXD[1]));
      k.stroke();
      k.fillStyle = C("--accent");
      for (var j = 0; j < set.x.length; j++) {
        k.beginPath(); k.arc(fx(set.x[j]), fy(set.y[j]), 2.5, 0, 6.2832); k.fill();
      }
    }
  }

  function buildTable() {
    var body = $("tbody"), html = "", i, keys = [], allSame = true;
    for (i = 0; i < SETS.length; i++) {
      var st = stats(SETS[i].x.map(function (v, j) { return { x: v, y: SETS[i].y[j] }; }));
      var cells = [st.mx.toFixed(2), st.my.toFixed(2), st.sx.toFixed(2), st.sy.toFixed(2),
                   st.r.toFixed(2), eqText(st)];
      keys.push(cells.join("|"));
      html += '<tr data-set="' + SETS[i].id + '" data-on="0"><th scope="row">' + SETS[i].name + '</th>' +
              '<td>' + cells[0] + '</td><td>' + cells[1] + '</td><td>' + cells[2] + '</td>' +
              '<td>' + cells[3] + '</td><td>' + cells[4] + '</td><td>' + cells[5] + '</td>' +
              '<td class="shape">' + SETS[i].shape + '</td></tr>';
    }
    body.innerHTML = html;
    for (i = 1; i < keys.length; i++) if (keys[i] !== keys[0]) allSame = false;
    if (allSame) {
      $("verified").hidden = false;
      $("verifiedText").textContent = "checked in your browser · all 4 sets agree on every " +
        "column to 2 decimal places";
    }
  }

  /* ================================================================== *
   * Theme + resize
   * ================================================================== */
  window.addEventListener("message", function (e) {
    var d = e.data;
    if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
      document.documentElement.setAttribute("data-theme", d.theme);
      draw(); drawThumbs();
    }
  });
  /* canvases hold baked-in colours, so any theme flip must repaint them — including a
     data-theme attribute set directly on <html> with no message behind it */
  if (window.MutationObserver) {
    try {
      new MutationObserver(function () { draw(); drawThumbs(); })
        .observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
    } catch (err) {}
  }

  var mq = window.matchMedia("(prefers-color-scheme: dark)");
  var onScheme = function () { draw(); drawThumbs(); };
  if (mq.addEventListener) mq.addEventListener("change", onScheme);
  else if (mq.addListener) mq.addListener(onScheme);

  var raf = 0;
  function onResize() {
    if (raf) return;
    raf = requestAnimationFrame(function () { raf = 0; draw(); drawThumbs(); });
  }
  window.addEventListener("resize", onResize);
  if (window.ResizeObserver) {
    try { new ResizeObserver(onResize).observe(document.body); } catch (err) {}
  }

  /* ================================================================== *
   * Go
   * ================================================================== */
  buildChips();
  buildTable();
  render();
  drawThumbs();
  /* fonts can land after first paint and shift metrics slightly */
  if (document.fonts && document.fonts.ready && document.fonts.ready.then) {
    document.fonts.ready.then(function () { draw(); drawThumbs(); });
  }
})();
</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