$sloprun.dev

Dose-Response Curve Fitter

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

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

▶ 5 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

An 8-point inhibition assay is already loaded, and a Nelder-Mead simplex written from scratch in Python fits the 4-parameter Hill equation to it — no scipy, no curve-fitting library, just numpy and a page of arithmetic. Every edit refits in milliseconds and re-runs a 200-sample residual bootstrap — all 200 simplexes marching in lockstep as numpy array ops — so the IC50 arrives with a real confidence interval and a shaded band instead of a lone number pretending to be certain. Try compound C first: R² sits at a respectable 0.92 while the CI spans 29–809 nM, because the bottom plateau was never measured — then tick "constrain bottom = 0%" and watch the interval collapse to 110–299 nM. It is a teaching tool, not a lab tool: the point is to see how much, or how little, eight wells actually pin down.

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/dose-response" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Dose-Response Curve Fitter — 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/dose-response

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1416 lines 57.0 KB index.html
demos/dose-response/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dose-Response Curve Fitter</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); }

/* ---------- base ---------- */
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html, body { margin: 0; padding: 0; }
body { -webkit-text-size-adjust: 100%; line-height: 1.5; overflow-x: hidden; }
.wrap { max-width: 1120px; margin: 0 auto; padding: 22px 16px 28px; }

h1, h2, h3 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
p { margin: 0; }

:where(button, input, select, summary, [tabindex]):focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: 6px;
}
.sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
      overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }

/* ---------- header ---------- */
header { margin-bottom: 16px; }
.eyebrow {
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .12em;
  text-transform: uppercase; color: var(--muted); display: flex;
  align-items: center; gap: 8px; flex-wrap: wrap;
}
.eyebrow .dot { width: 5px; height: 5px; border-radius: 50%; background: var(--accent); flex: none; }
h1 { font-size: clamp(21px, 4.4vw, 29px); margin: 6px 0 6px; }
.lede { color: var(--muted); font-size: 15px; max-width: 64ch; text-wrap: pretty; }
.warnbar {
  margin-top: 12px; display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap;
  border: 1px solid var(--line); border-left: 3px solid var(--accent);
  background: var(--accent-soft); border-radius: 4px; padding: 8px 11px;
}
.warnbar b { font-family: var(--font-mono); font-size: 11px; letter-spacing: .1em;
  text-transform: uppercase; color: var(--accent); }
.warnbar span { font-size: 13px; color: var(--ink); }

/* ---------- layout ---------- */
.grid { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); gap: 14px; align-items: start; }
@media (max-width: 900px) {
  /* one flat column: chart, numbers, how it reads, then the data you edit.
     the order has to track source order or tabbing jumps around the page */
  .grid { grid-template-columns: minmax(0, 1fr); }
  .grid > .col { display: contents; }
  #cChart { order: 1; } #cReadout { order: 2; } #cMethod { order: 3; }
  #cData { order: 4; } #cSets { order: 5; }
}
@media (max-width: 430px) { .tiles > .tile:last-child { grid-column: 1 / -1; } }
.col { display: grid; gap: 14px; min-width: 0; }

.card {
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); padding: 13px; min-width: 0;
}
.card > h2 {
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .12em;
  text-transform: uppercase; color: var(--muted); font-weight: 600;
  display: flex; justify-content: space-between; align-items: center; gap: 8px;
  margin-bottom: 10px;
}

/* ---------- chart ---------- */
.chartbox { position: relative; }
canvas { display: block; width: 100%; height: auto; touch-action: pan-y; }
.hoverline {
  font-family: var(--font-mono); font-size: 11.5px; color: var(--muted);
  margin-top: 6px; min-height: 17px; white-space: nowrap; overflow: hidden;
  text-overflow: ellipsis;
}
.hoverline b { color: var(--ink); font-weight: 600; }
@media (max-width: 620px) {
  /* let the point readout wrap — clipping it drops the residual, the useful half */
  .hoverline { white-space: normal; overflow: visible; min-height: 34px; }
}
.legend { display: flex; gap: 13px; flex-wrap: wrap; font-family: var(--font-mono);
  font-size: 11px; color: var(--muted); margin-top: 2px; }
.legend i { display: inline-block; width: 14px; height: 3px; border-radius: 2px;
  vertical-align: middle; margin-right: 5px; background: var(--accent); }
.legend .pt { width: 8px; height: 8px; border-radius: 50%; background: var(--ink); }
.legend .band { background: var(--accent); opacity: .3; height: 9px; }

/* ---------- readouts ---------- */
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(104px, 1fr)); gap: 1px;
  background: var(--line); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
.tile { background: var(--surface); padding: 9px 10px; min-width: 0; }
.tile .k { font-family: var(--font-mono); font-size: 10px; letter-spacing: .1em;
  text-transform: uppercase; color: var(--muted); }
.tile .v { font-family: var(--font-mono); font-size: 19px; font-weight: 600;
  line-height: 1.25; margin-top: 2px; overflow-wrap: anywhere; }
.tile .v small { font-size: 11px; font-weight: 500; color: var(--muted); }
.tile .s { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: -.01em;
  color: var(--muted); margin-top: 1px; }
.tile.hero .v { color: var(--accent); font-size: 23px; }

.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px;
  font-family: var(--font-mono); font-size: 11px; }
.chip { border: 1px solid var(--line); border-radius: 999px; padding: 2px 9px; color: var(--muted); }
.chip.ok { color: var(--run); border-color: var(--run); background: var(--run-soft); }
.chip.bad { color: var(--danger); border-color: var(--danger); }
.chip b { font-weight: 600; color: var(--ink); }
.chip.ok b { color: var(--run); }

/* ---------- table ---------- */
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
caption { text-align: left; }
th { font-family: var(--font-mono); font-size: 10px; letter-spacing: .08em;
  text-transform: uppercase; color: var(--muted); font-weight: 600;
  text-align: left; padding: 0 4px 6px; }
th.n { width: 24px; padding-left: 0; }
th.r { width: 52px; text-align: right; }
th.x { width: 34px; }
td { padding: 2px 4px; }
td.n { font-family: var(--font-mono); font-size: 11px; color: var(--muted); padding-left: 0; }
td.r { font-family: var(--font-mono); font-size: 11.5px; text-align: right; color: var(--muted); }
tr.hi td.n { color: var(--accent); font-weight: 700; }
tr.hi input { border-color: var(--accent); }
input[type=number] {
  width: 100%; min-width: 0; font-family: var(--font-mono); font-size: 13px;
  padding: 5px 6px; border: 1px solid var(--line); border-radius: 6px;
  background: var(--bg); color: var(--ink); -moz-appearance: textfield;
}
input[type=number]::-webkit-outer-spin-button,
input[type=number]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
input[type=number]:hover { border-color: var(--muted); }
input.invalid { border-color: var(--danger); color: var(--danger); }
.xbtn {
  width: 30px; height: 30px; display: grid; place-items: center; padding: 0;
  border: 1px solid transparent; border-radius: 6px; background: none;
  color: var(--muted); font-family: var(--font-mono); font-size: 14px; cursor: pointer;
}
.xbtn:hover { border-color: var(--danger); color: var(--danger); }

/* ---------- controls ---------- */
.row { display: flex; flex-wrap: wrap; gap: 7px; align-items: center; }
.row + .row { margin-top: 9px; }
button.b {
  font-family: var(--font-mono); font-size: 12px; padding: 6px 11px; cursor: pointer;
  border: 1px solid var(--line); border-radius: 7px; background: var(--surface);
  color: var(--ink); transition: border-color .12s, background .12s;
}
button.b:hover:not(:disabled) { border-color: var(--accent); }
button.b[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); }
button.b:disabled { opacity: .45; cursor: not-allowed; }
button.b.primary { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
label.ck { display: flex; align-items: center; gap: 7px; font-size: 13px; cursor: pointer; }
label.ck input { accent-color: var(--accent); width: 15px; height: 15px; }
select {
  font-family: var(--font-mono); font-size: 12px; padding: 5px 7px;
  border: 1px solid var(--line); border-radius: 7px; background: var(--surface); color: var(--ink);
}
select:disabled { opacity: .45; cursor: not-allowed; }
label.ck:has(input:disabled) { opacity: .45; cursor: not-allowed; }
label.ck input:disabled { cursor: not-allowed; }
.lbl { font-family: var(--font-mono); font-size: 11px; letter-spacing: .06em;
  text-transform: uppercase; color: var(--muted); }
.hint { font-size: 12.5px; color: var(--muted); margin-top: 9px; }

/* ---------- loading / error ---------- */
.boot { border: 1px solid var(--line); border-radius: var(--radius); padding: 13px;
  background: var(--surface); }
.boot .stage { font-family: var(--font-mono); font-size: 12px; display: flex;
  justify-content: space-between; gap: 10px; }
.boot .stage .pct { color: var(--muted); }
.bar { height: 4px; border-radius: 999px; background: var(--line); margin-top: 8px; overflow: hidden; }
.bar > i { display: block; height: 100%; width: 0%; background: var(--accent);
  transition: width .3s ease; }
.bar.done > i { background: var(--run); }
.boot .sub { font-size: 12.5px; color: var(--muted); margin-top: 7px; }
.fail { border-color: var(--danger); }
.fail .stage { color: var(--danger); }

/* ---------- method ---------- */
details.method { border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--surface); padding: 11px 13px; }
details.method summary { font-family: var(--font-mono); font-size: 11px;
  letter-spacing: .12em; text-transform: uppercase; color: var(--muted);
  cursor: pointer; font-weight: 600; }
details.method summary::marker { color: var(--accent); }
.method .body { margin-top: 10px; font-size: 13.5px; color: var(--muted); display: grid; gap: 9px; }
.method .body b { color: var(--ink); font-weight: 600; }
.eq { font-family: var(--font-mono); font-size: 13px; color: var(--ink);
  background: var(--bg); border: 1px solid var(--line); border-radius: 7px;
  padding: 9px 11px; overflow-x: auto; white-space: pre; }

footer { margin-top: 22px; padding-top: 12px; border-top: 1px solid var(--line);
  font-family: var(--font-mono); font-size: 11px; color: var(--muted);
  display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; }

@media (prefers-reduced-motion: reduce) {
  * { transition-duration: .01ms !important; animation-duration: .01ms !important; }
}
</style>
</head>
<body>
<div class="wrap">

<header>
  <div class="eyebrow"><span class="dot" aria-hidden="true"></span>pharma · pyodide + numpy · no libraries</div>
  <h1>Dose-Response Curve Fitter</h1>
  <p class="lede">Type in concentrations and % response; Python fits a 4-parameter Hill curve
  by hand-rolled Nelder-Mead and bootstraps the IC<sub>50</sub> confidence interval — all in your browser.</p>
  <div class="warnbar">
    <b>teaching tool, not a lab tool</b>
    <span>It shows how curve fitting and its uncertainty behave. Do not use it for regulated or reportable results.</span>
  </div>
</header>

<div id="boot" class="boot" role="status" aria-live="polite">
  <div class="stage"><span id="bootStage">starting python runtime…</span><span class="pct" id="bootPct">0%</span></div>
  <div class="bar" id="bootBar"><i></i></div>
  <div class="sub" id="bootSub">CPython + numpy compiled to WebAssembly. First load pulls the runtime once, then it is cached.</div>
</div>

<div class="grid" id="app">
  <div class="col">

    <section class="card" id="cChart">
      <h2><span>fitted curve</span><span id="chartTag">8 points · log scale</span></h2>
      <div class="chartbox">
        <canvas id="plot" role="img" aria-label="Dose-response scatter plot with fitted curve"></canvas>
      </div>
      <div class="hoverline" id="hover">hover the chart, or focus a row below, to inspect a point</div>
      <div class="legend" aria-hidden="true">
        <span><i class="pt"></i>measured</span>
        <span><i></i>Hill fit</span>
        <span><i class="band"></i>bootstrap 95% band</span>
        <span><i style="background:var(--muted)"></i>IC50</span>
      </div>
    </section>

    <section class="card" id="cReadout">
      <h2><span>readout</span><span id="fitTime">—</span></h2>
      <div class="tiles">
        <div class="tile hero"><div class="k">IC50</div><div class="v" id="oIc50">—</div><div class="s" id="oIc50ci">95% CI —</div></div>
        <div class="tile"><div class="k">Hill slope</div><div class="v" id="oHill">—</div><div class="s" id="oHillci">95% CI —</div></div>
        <div class="tile"><div class="k">Top</div><div class="v" id="oTop">—</div><div class="s">% response</div></div>
        <div class="tile"><div class="k">Bottom</div><div class="v" id="oBot">—</div><div class="s">% response</div></div>
        <div class="tile"><div class="k">R²</div><div class="v" id="oR2">—</div><div class="s" id="oRmse">RMSE —</div></div>
      </div>
      <div class="chips" id="chips"></div>
      <p class="hint" id="note"></p>
    </section>

    <details class="method" id="cMethod">
      <summary>how it works</summary>
      <div class="body">
        <div>The model is the <b>4-parameter Hill (logistic) equation</b> — the standard shape for a
        competitive dose-response:</div>
        <div class="eq">response(x) = bottom + (top - bottom) / (1 + (x / IC50) ^ hill)</div>
        <div><b>Fitting.</b> Least squares on the four parameters, minimised by a Nelder-Mead simplex
        written from scratch in numpy — no scipy, no solver library. IC<sub>50</sub> is fitted in log10 space
        (that is the scale the data lives on) and the model is evaluated as
        <span style="font-family:var(--font-mono)">1/(1+exp(hill·ln10·(log10 x − log10 IC50)))</span>
        so it never overflows. Three starting guesses (hill = 0.5, 1, 2) are tried and the best kept.</div>
        <div><b>Uncertainty.</b> Residual bootstrap: the residuals from the best fit are resampled with
        replacement, added back onto the fitted curve, and the whole fit is re-run — a few hundred times.
        The 2.5th and 97.5th percentiles of those refits give the CI and the shaded band. Residual
        resampling (rather than resampling points) is used because an 8-point dilution series is a
        designed set — dropping a point at random would destroy the design.</div>
        <div><b>Why it is fast.</b> Running hundreds of simplexes one after another in Python takes seconds.
        Instead every resample gets its own simplex and they all march in lockstep: one reflection, one
        expansion and two contractions for the entire batch, as four numpy calls per iteration on a
        <span style="font-family:var(--font-mono)">B × 5 × 4</span> array. Same algorithm, one pass, and the
        whole bootstrap lands in a fraction of a second.</div>
        <div><b>Why it matters.</b> A single IC<sub>50</sub> number hides how much the data actually pins it down.
        Push the curve so its bottom plateau is off the edge of the tested range and watch the CI blow up
        while R² stays comfortably high.</div>
      </div>
    </details>

  </div>

  <div class="col">

    <section class="card" id="cData">
      <h2><span>data</span><span id="dataTag">nM · % response</span></h2>
      <table>
        <caption class="sr">Editable assay data: concentration in nanomolar and percent response, with fit residual per point.</caption>
        <thead>
          <tr>
            <th class="n" scope="col"><span class="sr">point</span>#</th>
            <th scope="col">conc nM</th>
            <th scope="col">% resp</th>
            <th class="r" scope="col">resid</th>
            <th class="x" scope="col"><span class="sr">remove</span></th>
          </tr>
        </thead>
        <tbody id="tbody"></tbody>
      </table>
      <div class="row" style="margin-top:10px">
        <button class="b" id="addPt" type="button">+ add point</button>
        <button class="b primary" id="refit" type="button">refit ▸</button>
      </div>
      <p class="hint" id="dataNote">Edit any cell — the curve refits as you type.</p>
    </section>

    <section class="card" id="cSets">
      <h2><span>datasets</span></h2>
      <div class="row" role="group" aria-label="Example datasets" id="presets"></div>
      <div class="row">
        <span class="lbl">bootstrap</span>
        <select id="bootN" aria-label="Number of bootstrap resamples">
          <option value="100">100 resamples</option>
          <option value="200" selected>200 resamples</option>
          <option value="500">500 resamples</option>
          <option value="1000">1000 resamples</option>
        </select>
      </div>
      <div class="row">
        <label class="ck"><input type="checkbox" id="fixBottom"> constrain bottom = 0%</label>
      </div>
      <p class="hint">Constraining the bottom plateau is what you do when the assay never reaches full
      inhibition — it trades a free parameter for a much tighter IC<sub>50</sub>. Try it on <b>compound C</b>.</p>
    </section>

  </div>
</div>

<footer>
  <span>demo · sloprun.dev</span>
  <span id="engine">python: —</span>
</footer>

</div>

<script type="text/python" id="pysrc">
import json, math, time
import numpy as np

LN10 = 2.302585092994046
STATE = {}


def _clean(o):
    """NaN / Infinity are not valid JSON — emit null rather than a payload
    JSON.parse will reject (all-identical responses give an undefined R2)."""
    if isinstance(o, float):
        return o if math.isfinite(o) else None
    if isinstance(o, list):
        return [_clean(v) for v in o]
    if isinstance(o, dict):
        return dict((k, _clean(v)) for k, v in o.items())
    return o


def jd(d):
    return json.dumps(_clean(d), allow_nan=False)


def model(lx, p):
    """4-parameter Hill, evaluated in log space so it never overflows."""
    top, bot, lic, h = p[0], p[1], p[2], p[3]
    t = np.clip(h * (lx - lic) * LN10, -500.0, 500.0)
    return bot + (top - bot) / (1.0 + np.exp(t))


def expand(p, fix_bottom):
    if fix_bottom:
        return np.array([p[0], 0.0, p[1], p[2]])
    return p


def soft(v, lo, hi, w):
    if v < lo:
        return w * (lo - v) ** 2
    if v > hi:
        return w * (v - hi) ** 2
    return 0.0


def make_obj(lx, y, fix_bottom, lo, hi):
    def obj(p):
        q = expand(p, fix_bottom)
        pen = soft(q[3], 0.05, 12.0, 1e4)
        pen += soft(q[2], lo - 2.0, hi + 2.0, 1e4)
        pen += soft(q[0], -60.0, 220.0, 1e2)
        pen += soft(q[1], -60.0, 220.0, 1e2)
        r = y - model(lx, q)
        return float(np.dot(r, r)) + pen
    return obj


def nelder_mead(f, x0, steps, maxiter=700, ftol=1e-12, xtol=1e-10):
    x0 = np.asarray(x0, dtype=float)
    n = x0.size
    sim = np.tile(x0, (n + 1, 1))
    for i in range(n):
        sim[i + 1, i] = x0[i] + steps[i]
    fv = np.array([f(s) for s in sim])
    nev = n + 1
    it = 0
    conv = False
    while it < maxiter:
        o = np.argsort(fv)
        sim = sim[o]
        fv = fv[o]
        if (np.max(np.abs(fv[1:] - fv[0])) <= ftol * (abs(fv[0]) + ftol)
                and np.max(np.abs(sim[1:] - sim[0])) <= xtol * (np.max(np.abs(sim[0])) + xtol)):
            conv = True
            break
        it += 1
        cen = np.mean(sim[:-1], axis=0)
        d = cen - sim[-1]
        xr = cen + d
        fr = f(xr)
        nev += 1
        if fr < fv[0]:
            xe = cen + 2.0 * d
            fe = f(xe)
            nev += 1
            if fe < fr:
                sim[-1], fv[-1] = xe, fe
            else:
                sim[-1], fv[-1] = xr, fr
        elif fr < fv[-2]:
            sim[-1], fv[-1] = xr, fr
        else:
            if fr < fv[-1]:
                xc = cen + 0.5 * d
                fc = f(xc)
                nev += 1
                ok = fc <= fr
            else:
                xc = cen - 0.5 * d
                fc = f(xc)
                nev += 1
                ok = fc < fv[-1]
            if ok:
                sim[-1], fv[-1] = xc, fc
            else:
                sim[1:] = sim[0] + 0.5 * (sim[1:] - sim[0])
                fv[1:] = np.array([f(s) for s in sim[1:]])
                nev += n
    o = np.argsort(fv)
    return sim[o][0], float(fv[o][0]), it, nev, conv


def guess(lx, y):
    o = np.argsort(lx)
    ys = y[o]
    ls = lx[o]
    k = max(1, len(ys) // 4)
    top = float(np.mean(ys[:k]))
    bot = float(np.mean(ys[-k:]))
    mid = 0.5 * (top + bot)
    lic = float(np.median(ls))
    for i in range(len(ys) - 1):
        a, b = ys[i], ys[i + 1]
        if (a - mid) * (b - mid) <= 0 and a != b:
            lic = float(ls[i] + (a - mid) / (a - b) * (ls[i + 1] - ls[i]))
            break
    return top, bot, lic


def core_fit(lx, y, fix_bottom, start=None):
    lo, hi = float(np.min(lx)), float(np.max(lx))
    obj = make_obj(lx, y, fix_bottom, lo, hi)
    best = None
    if start is not None:
        s = np.array(start, dtype=float)
        steps = np.maximum(np.abs(s) * 0.06, 0.06)
        best = nelder_mead(obj, s, steps, maxiter=400)
    else:
        top, bot, lic = guess(lx, y)
        for h0 in (0.5, 1.0, 2.0):
            if fix_bottom:
                p0 = np.array([top, lic, h0])
                steps = np.array([max(5.0, abs(top) * 0.1), 0.4, 0.35])
            else:
                p0 = np.array([top, bot, lic, h0])
                steps = np.array([max(5.0, abs(top) * 0.1), max(5.0, abs(bot) * 0.1 + 3.0), 0.4, 0.35])
            r = nelder_mead(obj, p0, steps)
            if best is None or r[1] < best[1]:
                best = r
    return best


def fit(payload):
    t0 = time.perf_counter()
    d = json.loads(payload)
    x = np.array(d["x"], dtype=float)
    y = np.array(d["y"], dtype=float)
    fix_bottom = bool(d["fix_bottom"])
    lx = np.log10(x)
    p, sse, it, nev, conv = core_fit(lx, y, fix_bottom)
    q = expand(p, fix_bottom)
    yhat = model(lx, q)
    resid = y - yhat
    sse = float(np.dot(resid, resid))
    sst = float(np.dot(y - np.mean(y), y - np.mean(y)))
    r2 = 1.0 - sse / sst if sst > 0 else float("nan")
    k = 3 if fix_bottom else 4
    dof = max(1, len(y) - k)
    rmse = float(np.sqrt(sse / dof))
    g0, g1 = float(np.min(lx)) - 0.6, float(np.max(lx)) + 0.6
    grid = np.linspace(g0, g1, 180)
    curve = model(grid, q)
    STATE.clear()
    STATE.update(dict(lx=lx, y=y, yhat=yhat, resid=resid - float(np.mean(resid)),
                      fix_bottom=fix_bottom, p=p, grid=grid))
    return jd(dict(
        ok=True, converged=bool(conv),
        top=float(q[0]), bottom=float(q[1]), ic50=float(10.0 ** q[2]),
        logic50=float(q[2]), hill=float(q[3]),
        sse=float(sse), r2=float(r2), rmse=rmse, dof=dof,
        iters=int(it), nev=int(nev), ms=round((time.perf_counter() - t0) * 1000.0, 1),
        resid=[round(float(v), 3) for v in resid],
        grid=[round(float(v), 5) for v in grid],
        curve=[round(float(v), 4) for v in curve],
    ))


def expand_batch(P, fix_bottom):
    """(K, n) parameter rows -> (K, 4) as [top, bottom, log10 IC50, hill]."""
    if fix_bottom:
        z = np.zeros((P.shape[0], 1))
        return np.hstack([P[:, 0:1], z, P[:, 1:2], P[:, 2:3]])
    return P


def model_batch(lx, Q):
    """One curve per parameter row: (K, 4) x (m,) -> (K, m)."""
    t = np.clip(Q[:, 3:4] * (lx[None, :] - Q[:, 2:3]) * LN10, -500.0, 500.0)
    return Q[:, 1:2] + (Q[:, 0:1] - Q[:, 1:2]) / (1.0 + np.exp(t))


def soft_b(v, lo, hi, w):
    return w * (np.maximum(lo - v, 0.0) ** 2 + np.maximum(v - hi, 0.0) ** 2)


def batch_sse(P, ridx, lx, Y, fix_bottom, lo, hi):
    """Objective for K candidate parameter rows, each scored against dataset Y[ridx]."""
    q = expand_batch(P, fix_bottom)
    r = Y[ridx] - model_batch(lx, q)
    pen = (soft_b(q[:, 3], 0.05, 12.0, 1e4)
           + soft_b(q[:, 2], lo - 2.0, hi + 2.0, 1e4)
           + soft_b(q[:, 0], -60.0, 220.0, 1e2)
           + soft_b(q[:, 1], -60.0, 220.0, 1e2))
    return np.sum(r * r, axis=1) + pen


def nelder_mead_batch(P0, steps, lx, Y, fix_bottom, lo, hi, maxiter=600,
                      ftol=1e-10, xtol=1e-8):
    """B independent simplexes marched in lockstep — every reflection, expansion
    and contraction for the whole bootstrap is four numpy calls per iteration."""
    B, n = P0.shape
    m = n + 1
    sim = np.repeat(P0[:, None, :], m, axis=1)
    for i in range(n):
        sim[:, i + 1, i] += steps[i]
    rows = np.arange(B)
    fv = batch_sse(sim.reshape(B * m, n), np.repeat(rows, m),
                   lx, Y, fix_bottom, lo, hi).reshape(B, m)
    done = np.zeros(B, dtype=bool)
    it = 0
    while it < maxiter:
        o = np.argsort(fv, axis=1)
        sim = np.take_along_axis(sim, o[:, :, None], axis=1)
        fv = np.take_along_axis(fv, o, axis=1)
        sf = np.max(np.abs(fv[:, 1:] - fv[:, 0:1]), axis=1)
        sx = np.max(np.abs(sim[:, 1:, :] - sim[:, 0:1, :]), axis=(1, 2))
        done = ((sf <= ftol * (np.abs(fv[:, 0]) + ftol))
                & (sx <= xtol * (np.max(np.abs(sim[:, 0, :]), axis=1) + xtol)))
        if bool(np.all(done)):
            break
        it += 1
        cen = np.mean(sim[:, :-1, :], axis=1)
        d = cen - sim[:, -1, :]
        xr = cen + d
        xe = cen + 2.0 * d
        xoc = cen + 0.5 * d
        xic = cen - 0.5 * d
        fr = batch_sse(xr, rows, lx, Y, fix_bottom, lo, hi)
        fe = batch_sse(xe, rows, lx, Y, fix_bottom, lo, hi)
        foc = batch_sse(xoc, rows, lx, Y, fix_bottom, lo, hi)
        fic = batch_sse(xic, rows, lx, Y, fix_bottom, lo, hi)

        best, second, worst = fv[:, 0], fv[:, -2], fv[:, -1]
        m_exp = fr < best
        m_ref = (~m_exp) & (fr < second)
        rest = (~m_exp) & (~m_ref)
        m_oc = rest & (fr < worst) & (foc <= fr)
        m_ic = rest & (fr >= worst) & (fic < worst)
        m_shrink = ~(m_exp | m_ref | m_oc | m_ic)

        cx, cf = xr.copy(), fr.copy()
        ue = m_exp & (fe < fr)
        cx[ue], cf[ue] = xe[ue], fe[ue]
        cx[m_oc], cf[m_oc] = xoc[m_oc], foc[m_oc]
        cx[m_ic], cf[m_ic] = xic[m_ic], fic[m_ic]

        keep = (~m_shrink) & (~done)
        sim[keep, -1, :] = cx[keep]
        fv[keep, -1] = cf[keep]

        sh = m_shrink & (~done)
        k = int(np.count_nonzero(sh))
        if k:
            base = sim[sh, 0:1, :]
            sim[sh, 1:, :] = base + 0.5 * (sim[sh, 1:, :] - base)
            pts = sim[sh, 1:, :].reshape(k * n, n)
            fv[sh, 1:] = batch_sse(pts, np.repeat(rows[sh], n),
                                   lx, Y, fix_bottom, lo, hi).reshape(k, n)
    o = np.argsort(fv, axis=1)
    bestx = np.take_along_axis(sim, o[:, :, None], axis=1)[:, 0, :]
    return bestx, int(np.count_nonzero(~done)), it


def boot_start(n, seed):
    n = int(n)
    STATE["rng"] = np.random.default_rng(int(seed))
    STATE["n"] = n
    STATE["done"] = 0
    STATE["lic"] = np.zeros(n)
    STATE["hill"] = np.zeros(n)
    STATE["bad"] = 0
    STATE["rounds"] = 0
    STATE["curves"] = np.zeros((n, STATE["grid"].size))
    STATE["t0"] = time.perf_counter()
    return n


def boot_run(k):
    """Resample residuals for the next chunk and refit that whole chunk at once."""
    lx, yhat, res = STATE["lx"], STATE["yhat"], STATE["resid"]
    fix_bottom, p0, grid = STATE["fix_bottom"], STATE["p"], STATE["grid"]
    rng, m = STATE["rng"], yhat.size
    i = STATE["done"]
    end = min(STATE["n"], i + int(k))
    B = end - i
    if B <= 0:
        return i
    Y = yhat[None, :] + res[rng.integers(0, m, (B, m))]
    p0 = np.asarray(p0, dtype=float)
    P0 = np.tile(p0, (B, 1))
    steps = np.maximum(np.abs(p0) * 0.06, 0.06)
    lo, hi = float(np.min(lx)), float(np.max(lx))
    P, bad, it = nelder_mead_batch(P0, steps, lx, Y, fix_bottom, lo, hi)
    Q = expand_batch(P, fix_bottom)
    STATE["lic"][i:end] = Q[:, 2]
    STATE["hill"][i:end] = Q[:, 3]
    STATE["curves"][i:end] = model_batch(grid, Q)
    STATE["bad"] += bad
    STATE["rounds"] = max(STATE["rounds"], it)
    STATE["done"] = end
    return end


def boot_finish():
    lic, hill, curves = STATE["lic"], STATE["hill"], STATE["curves"]
    lo, hi = np.percentile(lic, [2.5, 97.5])
    hlo, hhi = np.percentile(hill, [2.5, 97.5])
    blo = np.percentile(curves, 2.5, axis=0)
    bhi = np.percentile(curves, 97.5, axis=0)
    return jd(dict(
        n=int(STATE["n"]),
        ic50_lo=float(10.0 ** lo), ic50_hi=float(10.0 ** hi),
        hill_lo=float(hlo), hill_hi=float(hhi),
        nonconv=int(STATE["bad"]), rounds=int(STATE["rounds"]),
        ms=round((time.perf_counter() - STATE["t0"]) * 1000.0, 1),
        band_lo=[round(float(v), 4) for v in blo],
        band_hi=[round(float(v), 4) for v in bhi],
    ))
</script>

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

  /* ---------------- data ---------------- */
  var CONC = [0.1, 0.3, 1, 3, 10, 30, 100, 300];
  var PRESETS = [
    { id: 'A', label: 'compound A · potent',
      note: 'A clean 8-point half-log series. The curve is fully bracketed: both plateaus are measured, so the IC50 interval is tight.',
      x: CONC, y: [101.2, 97.4, 96.8, 82.1, 58.9, 25.4, 11.7, 4.2] },
    { id: 'B', label: 'compound B · steep',
      note: 'Hill slope near 3 — cooperative, switch-like. Steep curves pin the IC50 down hard but leave the plateaus poorly sampled.',
      x: CONC, y: [99.6, 101.1, 98.4, 100.7, 96.2, 73.5, 6.8, 1.5] },
    { id: 'C', label: 'compound C · unbracketed',
      note: 'Weak and noisy: the bottom plateau is off the right edge of the tested range. R² still looks fine — the CI does not. Try constraining bottom = 0%.',
      x: CONC, y: [92.0, 105.3, 91.4, 102.9, 83.7, 86.1, 55.0, 42.9] }
  ];

  var DATA = PRESETS[0].x.map(function (v, i) { return { x: v, y: PRESETS[0].y[i] }; });
  var activePreset = 'A';
  var FIT = null, BOOT = null, hoverIdx = -1, curveT = 1, fixBottom = false, bootN = 200;
  var py = null, pyfn = {}, busy = false, runToken = 0;

  var $ = function (id) { return document.getElementById(id); };
  var reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;

  /* ---------------- formatting ---------------- */
  function fin(v) { return typeof v === 'number' && isFinite(v); }
  function sig(v, n) {
    if (!fin(v)) return '—';
    var a = Math.abs(v);
    if (a === 0) return '0';
    if (a >= 1e5 || a < 1e-3) return v.toExponential(Math.max(0, n - 1));
    var d = Math.max(0, n - 1 - Math.floor(Math.log10(a)));
    return v.toFixed(Math.min(d, 6));
  }
  function num(v, d) { return fin(v) ? v.toFixed(d) : '—'; }

  /* ---------------- table ---------------- */
  function renderTable() {
    var tb = $('tbody');
    tb.textContent = '';
    DATA.forEach(function (pt, i) {
      var tr = document.createElement('tr');
      tr.dataset.i = i;

      var tdn = document.createElement('td');
      tdn.className = 'n'; tdn.textContent = String(i + 1);
      tr.appendChild(tdn);

      tr.appendChild(cell(i, 'x', pt.x, 'concentration in nanomolar for point ' + (i + 1)));
      tr.appendChild(cell(i, 'y', pt.y, 'percent response for point ' + (i + 1)));

      var tdr = document.createElement('td');
      tdr.className = 'r'; tdr.dataset.resid = i; tdr.textContent = '—';
      tr.appendChild(tdr);

      var tdx = document.createElement('td');
      var b = document.createElement('button');
      b.type = 'button'; b.className = 'xbtn'; b.textContent = '✕';
      b.setAttribute('aria-label', 'remove point ' + (i + 1));
      b.addEventListener('click', function () {
        if (DATA.length <= 4) { flashNote('need at least 4 points to fit 4 parameters'); return; }
        DATA.splice(i, 1); activePreset = null; renderTable(); markPresets(); scheduleFit(0);
      });
      tdx.appendChild(b);
      tr.appendChild(tdx);
      tb.appendChild(tr);
    });
    $('chartTag').textContent = DATA.length + ' points · log scale';
  }

  function cell(i, key, val, label) {
    var td = document.createElement('td');
    var inp = document.createElement('input');
    inp.type = 'number'; inp.inputMode = 'decimal'; inp.step = 'any';
    inp.value = String(val);
    inp.setAttribute('aria-label', label);
    inp.addEventListener('input', function () {
      var v = parseFloat(inp.value);
      var bad = !isFinite(v) || (key === 'x' && v <= 0);
      inp.classList.toggle('invalid', bad);
      /* an unusable cell drops its point from the fit rather than quietly fitting a stale value */
      DATA[i][key] = bad ? NaN : v;
      activePreset = null; markPresets();
      var td = $('tbody').querySelector('[data-resid="' + i + '"]');
      if (bad && td) td.textContent = '·';
      scheduleFit(bad ? 700 : 380);
      syncHover();
    });
    inp.addEventListener('focus', function () { hoverIdx = i; syncHover(); });
    inp.addEventListener('blur', function () { if (hoverIdx === i) { hoverIdx = -1; syncHover(); } });
    td.appendChild(inp);
    return td;
  }

  function validPoints() {
    var out = [];
    DATA.forEach(function (p, i) {
      if (isFinite(p.x) && isFinite(p.y) && p.x > 0) out.push({ x: p.x, y: p.y, i: i });
    });
    return out;
  }

  var noteTimer = null;
  var noteIdle = 'Edit any cell — the curve refits as you type.';
  function flashNote(msg) {
    var n = $('dataNote');
    clearTimeout(noteTimer);
    n.textContent = msg;
    n.style.color = 'var(--danger)';
    noteTimer = setTimeout(function () {
      n.textContent = noteIdle;
      n.style.color = '';
    }, 2600);
  }

  /* ---------------- presets ---------------- */
  function buildPresets() {
    var box = $('presets');
    PRESETS.forEach(function (p) {
      var b = document.createElement('button');
      b.type = 'button'; b.className = 'b'; b.textContent = p.label;
      b.dataset.p = p.id;
      b.addEventListener('click', function () {
        DATA = p.x.map(function (v, i) { return { x: v, y: p.y[i] }; });
        activePreset = p.id;
        renderTable(); markPresets(); $('note').textContent = p.note; scheduleFit(0);
      });
      box.appendChild(b);
    });
    markPresets();
  }
  function markPresets() {
    Array.prototype.forEach.call($('presets').children, function (b) {
      b.setAttribute('aria-pressed', String(b.dataset.p === activePreset));
    });
  }

  /* ---------------- canvas ---------------- */
  var cv = $('plot'), ctx = cv.getContext('2d'), PLOT = null;

  function css(name) {
    return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
  }
  function rgba(hex, a) {
    var h = hex.replace('#', '');
    if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
    var n = parseInt(h, 16);
    return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + a + ')';
  }

  /* y labels have a 37px gutter — a typo like 1e9 in a response cell must not
     render as a clipped row of zeros sitting on top of the axis title */
  function trimz(s) {
    return s.indexOf('.') < 0 ? s : s.replace(/0+$/, '').replace(/\.$/, '');
  }
  function axisNum(v) {
    var a = Math.abs(v);
    if (a >= 1e9) return v.toExponential(1);
    if (a >= 1e6) return trimz((v / 1e6).toPrecision(3)) + 'M';
    if (a >= 1e4) return trimz((v / 1e3).toPrecision(3)) + 'k';
    return trimz(String(Math.round(v * 1e4) / 1e4));
  }

  function niceStep(range, target) {
    var raw = range / Math.max(1, target);
    if (!isFinite(raw) || raw <= 0) return 1;
    var mag = Math.pow(10, Math.floor(Math.log10(raw)));
    var k = raw / mag;
    return (k <= 1 ? 1 : k <= 2 ? 2 : k <= 5 ? 5 : 10) * mag;
  }

  function draw() {
    var box = cv.parentElement.getBoundingClientRect();
    var w = Math.max(240, Math.round(box.width));
    var h = Math.round(Math.min(390, Math.max(230, w * 0.62)));
    var dpr = Math.min(2.5, window.devicePixelRatio || 1);
    if (cv.width !== Math.round(w * dpr) || cv.height !== Math.round(h * dpr)) {
      cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr);
    }
    cv.style.height = h + 'px';
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.clearRect(0, 0, w, h);

    var ink = css('--ink'), muted = css('--muted'), line = css('--line'),
        accent = css('--accent'), surface = css('--surface');
    ctx.fillStyle = surface; ctx.fillRect(0, 0, w, h);

    var pts = validPoints();
    var L = 44, R = 12, T = 12, B = 34;
    var pw = Math.max(20, w - L - R), ph = Math.max(20, h - T - B);

    var lxs = pts.map(function (p) { return Math.log10(p.x); });
    /* an all-identical column makes R2 undefined, and python sends null rather than
       NaN — every value off the fit is checked before it can poison a coordinate */
    var fitOk = !!FIT && fin(FIT.top) && fin(FIT.bottom) && fin(FIT.logic50);
    var gridOk = !!FIT && fin(FIT.grid[0]) && fin(FIT.grid[FIT.grid.length - 1]);
    var lo, hi;
    if (gridOk) { lo = FIT.grid[0]; hi = FIT.grid[FIT.grid.length - 1]; }
    else if (lxs.length) { lo = Math.min.apply(null, lxs) - 0.6; hi = Math.max.apply(null, lxs) + 0.6; }
    else { lo = -1; hi = 3; }
    if (hi - lo < 0.5) { lo -= 0.5; hi += 0.5; }

    var ys = pts.map(function (p) { return p.y; });
    var ymin = Math.min.apply(null, ys.concat([0])) - 4;
    var ymax = Math.max.apply(null, ys.concat([100])) + 6;
    if (fitOk) { ymin = Math.min(ymin, FIT.bottom - 4); ymax = Math.max(ymax, FIT.top + 6); }

    var X = function (lx) { return L + (lx - lo) / (hi - lo) * pw; };
    var Y = function (v) { return T + ph - (v - ymin) / (ymax - ymin) * ph; };
    PLOT = { L: L, T: T, pw: pw, ph: ph, lo: lo, hi: hi, ymin: ymin, ymax: ymax, X: X, Y: Y };

    ctx.font = '10px ' + css('--font-mono');
    ctx.lineWidth = 1;

    /* y grid — the step has to stay coarse enough that a typo like 1e9 in a
       response cell cannot spin this loop millions of times and hang the tab */
    var yr = ymax - ymin;
    var step = yr > 160 ? 50 : 25;
    if (yr / step > 12) step = niceStep(yr, 8);
    ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
    for (var v = Math.ceil(ymin / step) * step; v <= ymax; v += step) {
      var yy = Math.round(Y(v)) + 0.5;
      ctx.strokeStyle = line; ctx.beginPath();
      ctx.moveTo(L, yy); ctx.lineTo(L + pw, yy); ctx.stroke();
      ctx.fillStyle = muted; ctx.fillText(axisNum(v), L - 7, yy);
    }

    /* x grid: decades + minor ticks */
    ctx.textAlign = 'center'; ctx.textBaseline = 'top';
    var d0 = Math.floor(lo), d1 = Math.ceil(hi);
    var dstep = Math.max(1, Math.ceil((d1 - d0) / 10));   /* never more than ~11 decades */
    var minorLine = rgba(css('--line'), 0.55);
    for (var d = d0; d <= d1; d += dstep) {
      for (var m = 1; m < 10; m++) {
        if (m > 1 && dstep > 1) break;   /* minor ticks only while decades are readable */
        var lx = d + Math.log10(m);
        if (lx < lo || lx > hi) continue;
        var xx = Math.round(X(lx)) + 0.5;
        ctx.strokeStyle = m === 1 ? line : minorLine;
        ctx.beginPath();
        ctx.moveTo(xx, T); ctx.lineTo(xx, T + ph); ctx.stroke();
      }
      if (d < lo || d > hi) continue;
      var px = X(d);
      var val = Math.pow(10, d);
      var lab = (d >= 5 || d <= -4) ? '1e' + d
        : (val >= 1000 ? (val / 1000) + 'k' : (val >= 1 ? String(val) : String(+val.toFixed(4))));
      ctx.fillStyle = muted;
      if (px > L + 8 && px < L + pw - 8) ctx.fillText(lab, px, T + ph + 7);
    }

    /* bootstrap band */
    if (gridOk && BOOT && BOOT.bandOk && curveT >= 1) {
      ctx.fillStyle = rgba(accent, 0.16);
      ctx.beginPath();
      FIT.grid.forEach(function (g, i) {
        var xp = X(g), yp = Y(Math.min(ymax, Math.max(ymin, BOOT.band_hi[i])));
        if (i === 0) ctx.moveTo(xp, yp); else ctx.lineTo(xp, yp);
      });
      for (var i2 = FIT.grid.length - 1; i2 >= 0; i2--) {
        ctx.lineTo(X(FIT.grid[i2]), Y(Math.min(ymax, Math.max(ymin, BOOT.band_lo[i2]))));
      }
      ctx.closePath(); ctx.fill();
    }

    /* IC50 CI on the x axis */
    if (gridOk && BOOT && fin(BOOT.ic50_lo) && fin(BOOT.ic50_hi) && BOOT.ic50_lo > 0) {
      var a = X(Math.max(lo, Math.log10(BOOT.ic50_lo)));
      var b2 = X(Math.min(hi, Math.log10(BOOT.ic50_hi)));
      ctx.fillStyle = rgba(muted, 0.22);
      ctx.fillRect(a, T + ph - 12, Math.max(1, b2 - a), 12);
      ctx.strokeStyle = rgba(muted, 0.9);
      ctx.beginPath();
      ctx.moveTo(a, T + ph - 12); ctx.lineTo(a, T + ph);
      ctx.moveTo(b2, T + ph - 12); ctx.lineTo(b2, T + ph);
      ctx.stroke();
    }

    /* fitted curve */
    if (fitOk && gridOk) {
      var n = Math.max(2, Math.round(FIT.grid.length * curveT));
      ctx.strokeStyle = accent; ctx.lineWidth = 2; ctx.lineJoin = 'round';
      ctx.beginPath();
      for (var i3 = 0; i3 < n; i3++) {
        var xp3 = X(FIT.grid[i3]), yp3 = Y(FIT.curve[i3]);
        if (i3 === 0) ctx.moveTo(xp3, yp3); else ctx.lineTo(xp3, yp3);
      }
      ctx.stroke();
      ctx.lineWidth = 1;

      /* IC50 crosshair */
      if (curveT >= 1) {
        var mid = (FIT.top + FIT.bottom) / 2;
        var cx = X(FIT.logic50), cy = Y(mid);
        ctx.save();
        ctx.setLineDash([4, 4]); ctx.strokeStyle = rgba(muted, 0.95);
        ctx.beginPath();
        ctx.moveTo(cx, cy); ctx.lineTo(cx, T + ph);
        ctx.moveTo(L, cy); ctx.lineTo(cx, cy);
        ctx.stroke();
        ctx.restore();
        ctx.fillStyle = surface; ctx.strokeStyle = accent; ctx.lineWidth = 2;
        ctx.beginPath(); ctx.arc(cx, cy, 4.5, 0, 6.2832); ctx.fill(); ctx.stroke();
        ctx.lineWidth = 1;
        ctx.fillStyle = muted; ctx.textAlign = 'left'; ctx.textBaseline = 'bottom';
        var lbl = 'IC50 ' + sig(FIT.ic50, 3) + ' nM';
        var tw = ctx.measureText(lbl).width;
        ctx.fillText(lbl, Math.min(cx + 7, L + pw - tw - 2), Math.max(T + 11, cy - 7));
      }
    }

    /* points */
    pts.forEach(function (p) {
      var xp = X(Math.log10(p.x)), yp = Y(p.y);
      var on = p.i === hoverIdx;
      ctx.beginPath(); ctx.arc(xp, yp, on ? 6.5 : 4.2, 0, 6.2832);
      ctx.fillStyle = on ? accent : ink;
      ctx.strokeStyle = surface; ctx.lineWidth = 2;
      ctx.fill(); ctx.stroke();
      ctx.lineWidth = 1;
    });

    /* axis titles */
    ctx.fillStyle = muted; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
    ctx.fillText('concentration nM (log)', L + pw / 2, h - 1);
    ctx.save();
    ctx.translate(9, T + ph / 2); ctx.rotate(-Math.PI / 2);
    ctx.textBaseline = 'top'; ctx.fillText('% response', 0, 0);
    ctx.restore();
  }

  /* hover */
  function nearest(ev) {
    if (!PLOT) return -1;
    var r = cv.getBoundingClientRect();
    var mx = ev.clientX - r.left, my = ev.clientY - r.top;
    var best = -1, bd = 26 * 26;
    validPoints().forEach(function (p) {
      var dx = PLOT.X(Math.log10(p.x)) - mx, dy = PLOT.Y(p.y) - my;
      var d = dx * dx + dy * dy;
      if (d < bd) { bd = d; best = p.i; }
    });
    return best;
  }
  cv.addEventListener('pointermove', function (e) {
    var i = nearest(e);
    if (i !== hoverIdx) { hoverIdx = i; syncHover(); }
  });
  cv.addEventListener('pointerleave', function () { hoverIdx = -1; syncHover(); });

  function hoverText() {
    var pts = validPoints();
    var k = -1;
    pts.forEach(function (p, j) { if (p.i === hoverIdx) k = j; });
    Array.prototype.forEach.call($('tbody').children, function (tr, i) {
      tr.classList.toggle('hi', i === hoverIdx);
    });
    var el = $('hover');
    if (k < 0) {
      el.textContent = 'hover the chart, or focus a row below, to inspect a point';
    } else {
      var p = pts[k];
      var j = FIT && FIT.idx ? FIT.idx.indexOf(p.i) : -1;
      var r = j >= 0 && FIT.resid && fin(FIT.resid[j]) ? FIT.resid[j] : null;
      el.innerHTML = 'point <b>' + (p.i + 1) + '</b> · <b>' + sig(p.x, 3) + '</b> nM → <b>'
        + num(p.y, 1) + '</b> % response'
        + (r == null ? '' : ' · residual <b>' + (r >= 0 ? '+' : '') + num(r, 2) + '</b>');
    }
  }
  function syncHover() { hoverText(); draw(); }

  /* ---------------- readouts ---------------- */
  var live = document.createElement('p');
  live.className = 'sr'; live.setAttribute('aria-live', 'polite');
  document.body.appendChild(live);

  function chip(txt, cls) {
    var s = document.createElement('span');
    s.className = 'chip' + (cls ? ' ' + cls : '');
    s.innerHTML = txt;
    return s;
  }

  /* when a fit dies, the previous numbers and the previous curve are no longer
     about the data on screen — blank them rather than let them read as a result */
  function clearReadouts(why) {
    ['oIc50', 'oHill', 'oTop', 'oBot', 'oR2'].forEach(function (k) { $(k).textContent = '—'; });
    $('oIc50ci').textContent = '95% CI —';
    $('oHillci').textContent = '95% CI —';
    $('oRmse').textContent = 'RMSE —';
    $('fitTime').textContent = '—';
    $('chips').textContent = '';
    $('chips').appendChild(chip(why, 'bad'));
    Array.prototype.forEach.call($('tbody').querySelectorAll('[data-resid]'),
      function (td) { td.textContent = '·'; });
    cv.setAttribute('aria-label', 'Dose-response plot: ' + validPoints().length
      + ' measured points on a log concentration axis, no fitted curve.');
    hoverText();
  }

  function updateReadouts(progress) {
    if (!FIT) return;
    $('oIc50').innerHTML = sig(FIT.ic50, 3) + ' <small>nM</small>';
    $('oHill').textContent = num(FIT.hill, 2);
    $('oTop').textContent = num(FIT.top, 1);
    $('oBot').textContent = num(FIT.bottom, 1);
    $('oR2').textContent = num(FIT.r2, 4);
    $('oRmse').textContent = 'RMSE ' + num(FIT.rmse, 2);
    $('fitTime').textContent = num(FIT.ms, 1) + ' ms · ' + FIT.nev + ' evals';

    if (BOOT) {
      $('oIc50ci').textContent = '95% CI ' + sig(BOOT.ic50_lo, 3) + '–' + sig(BOOT.ic50_hi, 3);
      /* the hill slope is bounded at 0.05..12 by the penalty; say so instead of
         printing the bound as if it were an estimate */
      var hl = !fin(BOOT.hill_lo) ? '—' : BOOT.hill_lo <= 0.06 ? 'under 0.05' : num(BOOT.hill_lo, 2);
      var hh = !fin(BOOT.hill_hi) ? '—' : BOOT.hill_hi >= 11.5 ? 'over 12' : num(BOOT.hill_hi, 2);
      var atBound = hl === 'under 0.05' || hh === 'over 12';
      $('oHillci').textContent = '95% CI ' + hl + (atBound ? ' to ' : '–') + hh;
    } else {
      $('oIc50ci').textContent = progress ? '95% CI · ' + progress : '95% CI —';
      $('oHillci').textContent = '95% CI —';
    }

    var c = $('chips');
    c.textContent = '';
    c.appendChild(chip(FIT.converged ? '✓ simplex converged' : '⚠ hit iteration cap',
      FIT.converged ? 'ok' : 'bad'));
    c.appendChild(chip('iterations <b>' + FIT.iters + '</b>'));
    c.appendChild(chip('dof <b>' + FIT.dof + '</b>'));
    if (BOOT) {
      c.appendChild(chip('bootstrap <b>' + BOOT.n + '</b> refits · ' + num(BOOT.ms, 0) + ' ms', 'ok'));
      var w = BOOT.ic50_hi / BOOT.ic50_lo;
      c.appendChild(chip('CI width <b>' + num(w, 1) + '×</b>', w > 4 ? 'bad' : ''));
      if (BOOT.nonconv) c.appendChild(chip('<b>' + BOOT.nonconv + '</b> refits hit the cap', 'bad'));
    } else if (progress) {
      c.appendChild(chip('bootstrapping <b>' + progress + '</b>'));
    }

    var cells = $('tbody').querySelectorAll('[data-resid]');
    Array.prototype.forEach.call(cells, function (td) { td.textContent = '·'; });
    /* residuals belong to the rows that were fitted, not to whatever the table
       holds now — rows can be edited or deleted while a fit is in flight */
    (FIT.idx || []).forEach(function (row, j) {
      var td = cells[row], v = FIT.resid[j];
      if (td && fin(v)) td.textContent = (v >= 0 ? '+' : '') + num(v, 1);
    });

    hoverText();
    cv.setAttribute('aria-label',
      'Dose-response plot: ' + validPoints().length + ' measured points on a log concentration axis '
      + 'with a fitted Hill curve. IC50 ' + sig(FIT.ic50, 3) + ' nanomolar, hill slope '
      + num(FIT.hill, 2) + ', top ' + num(FIT.top, 1) + ' percent, bottom ' + num(FIT.bottom, 1)
      + ' percent, R squared ' + num(FIT.r2, 4) + '.');

    if (BOOT) {
      live.textContent = 'Fit complete. IC50 ' + sig(FIT.ic50, 3) + ' nanomolar, 95% confidence interval '
        + sig(BOOT.ic50_lo, 3) + ' to ' + sig(BOOT.ic50_hi, 3) + '. Hill slope ' + num(FIT.hill, 2)
        + '. R squared ' + num(FIT.r2, 4) + '.';
    }
  }

  var animId = 0;
  function animateCurve() {
    var id = ++animId;
    if (reduceMotion) { curveT = 1; draw(); return; }
    curveT = 0;
    var t0 = performance.now();
    (function step() {
      if (id !== animId) return;
      var k = Math.min(1, (performance.now() - t0) / 420);
      curveT = k * k * (3 - 2 * k);
      draw();
      if (k < 1) requestAnimationFrame(step);
      else { curveT = 1; draw(); }
    })();
  }

  /* ---------------- fit pipeline ---------------- */
  var timer = null;
  function scheduleFit(delay) {
    clearTimeout(timer);
    timer = setTimeout(runFit, delay || 0);
  }
  function setBusy(v) {
    busy = v;
    $('refit').disabled = v;
    $('refit').textContent = v ? 'fitting…' : 'refit ▸';
  }
  function frame() {
    return new Promise(function (r) { requestAnimationFrame(function () { setTimeout(r, 0); }); });
  }

  function errText(err) {
    if (!err) return 'unknown error';
    var m = err.message || String(err);
    return m.split('\n').filter(function (l) { return l.trim(); }).pop() || m;
  }

  async function runFit() {
    /* the chart still has to reflect the edit even when there is nothing to fit with */
    if (!py) { draw(); return; }
    var pts = validPoints();
    if (pts.length < 4) {
      /* cancel whatever is in flight and hand the controls back, otherwise the
         superseded run never releases them and refit sits at "fitting…" */
      runToken++;
      setBusy(false);
      draw();
      flashNote('need at least 4 valid points (concentration > 0)');
      return;
    }
    var token = ++runToken;
    setBusy(true);
    BOOT = null;

    try {
      FIT = JSON.parse(pyfn.fit(JSON.stringify({
        x: pts.map(function (p) { return p.x; }),
        y: pts.map(function (p) { return p.y; }),
        fix_bottom: fixBottom
      })));
      FIT.idx = pts.map(function (p) { return p.i; });
    } catch (err) {
      /* a superseded run must not hand the controls back, or report — the newer one owns them */
      if (token !== runToken) return;
      setBusy(false);
      FIT = null;
      clearReadouts('fit failed');
      flashNote('fit failed: ' + errText(err));
      draw();
      return;
    }
    if (token !== runToken) return;

    updateReadouts('0/' + bootN);
    animateCurve();

    try {
      pyfn.boot_start(bootN, 7);
      /* one chunk per frame. 100 keeps each blocking numpy pass around 100ms and
         leaves the counter something to say at 500 and 1000 resamples — a chunk of
         500 froze the tab for half a second at a time */
      var chunk = 100;
      for (var done = 0; done < bootN;) {
        await frame();
        if (token !== runToken) return;
        done = pyfn.boot_run(Math.min(chunk, bootN - done));
        updateReadouts(done + '/' + bootN);
      }
      BOOT = JSON.parse(pyfn.boot_finish());
      BOOT.bandOk = BOOT.band_lo.every(fin) && BOOT.band_hi.every(fin);
    } catch (err) {
      if (token !== runToken) return;
      BOOT = null;
      flashNote('bootstrap failed: ' + errText(err));
    }
    if (token !== runToken) return;
    curveT = 1;
    updateReadouts();
    draw();
    setBusy(false);
  }

  /* ---------------- controls ---------------- */
  $('refit').addEventListener('click', function () { scheduleFit(0); });
  $('addPt').addEventListener('click', function () {
    var last = DATA[DATA.length - 1];
    var lx = last && isFinite(last.x) && last.x > 0 ? last.x : 1;
    var ly = last && isFinite(last.y) ? last.y : 50;
    DATA.push({ x: +(lx * 3).toPrecision(3), y: Math.max(0, +(ly * 0.6).toFixed(1)) });
    activePreset = null; renderTable(); markPresets(); scheduleFit(0);
    var rows = $('tbody').children;
    rows[rows.length - 1].querySelector('input').focus();
  });
  $('fixBottom').addEventListener('change', function (e) {
    fixBottom = e.target.checked; scheduleFit(0);
  });
  $('bootN').addEventListener('change', function (e) {
    bootN = parseInt(e.target.value, 10); scheduleFit(0);
  });

  /* ---------------- 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();
    }
  });
  var dark = matchMedia('(prefers-color-scheme: dark)');
  if (dark.addEventListener) dark.addEventListener('change', function () { draw(); });
  else if (dark.addListener) dark.addListener(function () { draw(); });
  /* the canvas paints its own background and axes, so it has to repaint whenever the
     tokens change — including a host that sets data-theme on <html> directly */
  new MutationObserver(function () { draw(); })
    .observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });

  /* only width drives the canvas geometry; reacting to the height this very function
     sets is how a ResizeObserver ends up looping */
  var lastW = -1;
  var ro = new ResizeObserver(function (entries) {
    var w = Math.round(entries[0].contentRect.width);
    if (w === lastW) return;
    lastW = w;
    draw();
  });
  ro.observe(cv.parentElement);

  /* ---------------- boot ---------------- */
  var bootPct = 0, creepTimer = 0;
  function paintBoot(done) {
    $('bootPct').textContent = Math.round(bootPct) + '%';
    $('bootBar').firstElementChild.style.width = bootPct + '%';
    $('bootBar').classList.toggle('done', !!done);
  }
  function setStage(txt, pct, done) {
    clearInterval(creepTimer); creepTimer = 0;
    $('bootStage').textContent = txt;
    bootPct = Math.max(bootPct, pct);
    paintBoot(done);
  }
  /* loadPyodide and loadPackage report nothing while they run, so the bar eases
     toward the next milestone instead of sitting frozen on a cold cache */
  function creep(cap) {
    clearInterval(creepTimer);
    creepTimer = setInterval(function () {
      if (bootPct >= cap - 0.4) { clearInterval(creepTimer); creepTimer = 0; return; }
      bootPct += (cap - bootPct) * 0.07;
      paintBoot(false);
    }, 200);
  }
  function bootFail(msg) {
    clearInterval(creepTimer); creepTimer = 0;
    var b = $('boot');
    b.classList.add('fail');
    $('bootStage').textContent = 'python runtime unavailable';
    $('bootPct').textContent = '—';
    $('bootBar').firstElementChild.style.width = '100%';
    $('bootBar').firstElementChild.style.background = 'var(--danger)';
    $('bootSub').innerHTML = msg;
    $('refit').disabled = true;
    $('refit').textContent = 'no runtime';
    /* these two only ever change the fit — with no fitter they are decoration */
    $('fixBottom').disabled = true;
    $('bootN').disabled = true;
    $('chips').textContent = '';
    $('chips').appendChild(chip('runtime <b>unavailable</b>', 'bad'));
    $('note').textContent = 'The data below is real and the plot is live — only the Python fit is missing.';
    noteIdle = 'Edit any cell — the plot follows along, but fitting needs the Python runtime.';
    $('dataNote').textContent = noteIdle;
    draw();
  }
  function loadScript(src) {
    return new Promise(function (res, rej) {
      var s = document.createElement('script');
      s.src = src;
      s.onload = res;
      s.onerror = function () { rej(new Error('could not load ' + src)); };
      document.head.appendChild(s);
    });
  }

  async function boot() {
    /* first paint is the demo itself: real assay data in the table, real points
       on the chart. Only the fit is gated on the runtime landing. */
    renderTable(); buildPresets();
    $('note').textContent = PRESETS[0].note;
    $('refit').disabled = true;
    $('refit').textContent = 'waiting…';
    $('chips').appendChild(chip('runtime <b>loading…</b>'));
    draw();
    try {
      setStage('fetching python runtime…', 8); creep(30);
      await loadScript('/vendor/pyodide/pyodide.js');
      if (typeof loadPyodide !== 'function') throw new Error('loadPyodide missing');
      setStage('starting CPython (wasm)…', 34); creep(58);
      await frame();
      py = await loadPyodide({ indexURL: '/vendor/pyodide/' });
      setStage('loading numpy…', 62); creep(80);
      await frame();
      await py.loadPackage('numpy');
      setStage('compiling the fitter…', 84);
      await frame();
      py.runPython($('pysrc').textContent);
      ['fit', 'boot_start', 'boot_run', 'boot_finish'].forEach(function (k) {
        pyfn[k] = py.globals.get(k);
      });
      $('engine').textContent = 'python: ' + py.runPython(
        'import sys, numpy; sys.version.split()[0] + " · numpy " + numpy.__version__');
    } catch (err) {
      bootFail('Pyodide was not found at <span style="font-family:var(--font-mono)">/vendor/pyodide/</span>. '
        + 'On sloprun.dev it is served from the platform bundle; opened as a bare local file there is no '
        + 'runtime to fit with, and this demo never reaches out to a CDN.');
      return;
    }
    setStage('✓ runtime ready', 100, true);
    $('refit').disabled = false;
    $('refit').textContent = 'refit ▸';
    draw();
    await runFit();
    setTimeout(function () { $('boot').hidden = true; }, 700);
  }

  boot();
})();
</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