Power Play source
Every file that ships, readable right here. MIT licence. Prefer it offline? Download the .zip.
README.md64 lines
# Power PlayA Medical Affairs stats suite that runs entirely in the browser: from sample size to a simulated readout, and from a published hazard ratio to a number you can say to a doctor.Live at [mafflab.xyz/apps/power-play/](https://mafflab.xyz/apps/power-play/). Installable, works offline, no cookies, no tracking, no generative AI.## Modules| Module | What it does ||---|---|| **01 Design** | Sample size and power for two proportions, two means or time to event; superiority or non-inferiority; allocation ratio, dropout, accrual and follow-up. Live power curve, a "what if the effect is smaller" table and a protocol-ready paragraph. || **02 Simulate** | Runs the design 1,000 to 10,000 times: Kaplan–Meier curves of the first 70 trials, the distribution of observed effects, power converging trial by trial, the winner's curse, readout timing and, optionally, assurance (probability of success with an uncertain effect). || **03 Interpret** | From a HR, RR or OR with its CI and the control-arm risk: absolute risk reduction, NNT with Altman's NNTB/NNTH interval, p from the CI, an icon array of 100 patients and NNT by baseline risk. From raw counts: RR, OR, Newcombe risk difference, Fisher's exact test and the fragility index. || **04 Diagnose** | Sensitivity, specificity and prevalence become PPV, NPV and likelihood ratios, with 1,000 people drawn as icons and predictive values across prevalence. || **Methods** | Methods, references and limits, plus a self-test that re-checks the engine against reference values on the visitor's own device. |## Files```power-play/├── index.html the page and its Content Security Policy├── app.js interface, charts (canvas), sharing, PWA plumbing├── stats.js the statistics engine: pure functions, no DOM; also runs in Node├── sim-worker.js Monte Carlo simulation in a Web Worker, streamed to the page├── selftest.js reference values for the in-app self-test (generated)├── style.css layout on top of mafflab-theme.css├── mafflab-theme.css lab colours and components├── manifest.webmanifest, sw.js, .htaccess, icons/, fonts/└── tests/ ├── make_reference.py computes reference values with SciPy, statsmodels and lifelines ├── reference.json 70 reference cases └── validate.js 88 checks: formulas, random number generators, simulated vs planned power```## Validation```pip install scipy statsmodels lifelines # only to regenerate the referencespython3 tests/make_reference.py # writes tests/reference.json and selftest.jsnode tests/validate.js # 88/88 checks, about 2 seconds```The checks compare every formula with independent software (SciPy 1.18, statsmodels 0.15, lifelines 0.30), test the random number generators against their exact distributions, and confirm that simulated power matches the design formula for eight designs within Monte Carlo error. Expect the time-to-event simulation to land about a point below the Schoenfeld formula for strong effects: that is a known property of the formula.## Run it locally```python3 -m http.server 8000 # from the folder above power-play/```Then open `http://localhost:8000/power-play/`. Opening `index.html` from disk will not work: Web Workers and service workers need a server.## PrivacyNothing typed leaves the device. Inputs are kept in local storage under `power-play.v1`. "Copy link" encodes the inputs after the `#` of the address, which browsers never send to a server.## LimitsTwo-arm, fixed-sample designs only: no interim analyses, multiplicity, clustering, crossover or adaptive designs. Time-to-event designs assume proportional hazards and exponential survival. A planning and teaching aid: final sample sizes belong to a qualified statistician, and anything used externally goes through Medical, Legal and Regulatory review.## LicenceMIT. © 2026 Aitor Martínez Rosell.stats.js589 lines
/* Power Play stats engine. Pure functions, no DOM: the same file runs in the page, in the simulation worker and in Node for the validation tests. MIT licence. */(function (root) { 'use strict'; /* ================================================================ distributions */ // Cumulative standard normal. Hart (1968) as given by West (2005): double precision. function normCdf(x) { const ax = Math.abs(x); let c; if (ax > 37) c = 0; else { const e = Math.exp(-ax * ax / 2); if (ax < 7.07106781186547) { let b = 3.52624965998911e-02 * ax + 0.700383064443688; b = b * ax + 6.37396220353165; b = b * ax + 33.912866078383; b = b * ax + 112.079291497871; b = b * ax + 221.213596169931; b = b * ax + 220.206867912376; c = e * b; b = 8.83883476483184e-02 * ax + 1.75566716318264; b = b * ax + 16.064177579207; b = b * ax + 86.7807322029461; b = b * ax + 296.564248779674; b = b * ax + 637.333633378831; b = b * ax + 793.826512519948; b = b * ax + 440.413735824752; c = c / b; } else { let b = ax + 0.65; b = ax + 4 / b; b = ax + 3 / b; b = ax + 2 / b; b = ax + 1 / b; c = e / b / 2.506628274631; } } return x > 0 ? 1 - c : c; } // Inverse normal. Acklam's rational approximation, then one Halley step: full double precision. const NA = [-3.969683028665376e+01, 2.209460984245205e+02, -2.759285104469687e+02, 1.383577518672690e+02, -3.066479806614716e+01, 2.506628277459239e+00]; const NB = [-5.447609879822406e+01, 1.615858368580409e+02, -1.556989798598866e+02, 6.680131188771972e+01, -1.328068155288572e+01]; const NC = [-7.784894002430293e-03, -3.223964580411365e-01, -2.400758277161838e+00, -2.549732539343734e+00, 4.374664141464968e+00, 2.938163982698783e+00]; const ND = [7.784695709041462e-03, 3.224671290700398e-01, 2.445134137142996e+00, 3.754408661907416e+00]; function normInv(p) { if (!(p > 0)) return -Infinity; if (!(p < 1)) return Infinity; let q, r, x; if (p < 0.02425) { q = Math.sqrt(-2 * Math.log(p)); x = (((((NC[0] * q + NC[1]) * q + NC[2]) * q + NC[3]) * q + NC[4]) * q + NC[5]) / ((((ND[0] * q + ND[1]) * q + ND[2]) * q + ND[3]) * q + 1); } else if (p <= 0.97575) { q = p - 0.5; r = q * q; x = (((((NA[0] * r + NA[1]) * r + NA[2]) * r + NA[3]) * r + NA[4]) * r + NA[5]) * q / (((((NB[0] * r + NB[1]) * r + NB[2]) * r + NB[3]) * r + NB[4]) * r + 1); } else { q = Math.sqrt(-2 * Math.log(1 - p)); x = -(((((NC[0] * q + NC[1]) * q + NC[2]) * q + NC[3]) * q + NC[4]) * q + NC[5]) / ((((ND[0] * q + ND[1]) * q + ND[2]) * q + ND[3]) * q + 1); } const e = (p < 0.5 ? normCdf(x) - p : (1 - p) - normCdf(-x)); const u = e * Math.sqrt(2 * Math.PI) * Math.exp(x * x / 2); return x - u / (1 + x * u / 2); } // log Gamma, Lanczos (g = 7, n = 9). const LG = [0.99999999999980993, 676.5203681218851, -1259.1392167224028, 771.32342877765313, -176.61502916214059, 12.507343278686905, -0.13857109526572012, 9.9843695780195716e-6, 1.5056327351493116e-7]; function lgamma(x) { if (x < 0.5) return Math.log(Math.PI / Math.abs(Math.sin(Math.PI * x))) - lgamma(1 - x); x -= 1; let a = LG[0]; const t = x + 7.5; for (let i = 1; i < 9; i++) a += LG[i] / (x + i); return 0.5 * Math.log(2 * Math.PI) + (x + 0.5) * Math.log(t) - t + Math.log(a); } const LF = [0]; function lfact(n) { if (n < LF.length) return LF[n]; if (n > 200000) return lgamma(n + 1); for (let i = LF.length; i <= n; i++) LF[i] = LF[i - 1] + Math.log(i); return LF[n]; } // Regularised incomplete beta, continued fraction (Numerical Recipes, Lentz). function betacf(a, b, x) { const FPMIN = 1e-300; const qab = a + b, qap = a + 1, qam = a - 1; let c = 1, d = 1 - qab * x / qap; if (Math.abs(d) < FPMIN) d = FPMIN; d = 1 / d; let h = d; for (let m = 1; m <= 400; m++) { const m2 = 2 * m; let aa = m * (b - m) * x / ((qam + m2) * (a + m2)); d = 1 + aa * d; if (Math.abs(d) < FPMIN) d = FPMIN; c = 1 + aa / c; if (Math.abs(c) < FPMIN) c = FPMIN; d = 1 / d; h *= d * c; aa = -(a + m) * (qab + m) * x / ((a + m2) * (qap + m2)); d = 1 + aa * d; if (Math.abs(d) < FPMIN) d = FPMIN; c = 1 + aa / c; if (Math.abs(c) < FPMIN) c = FPMIN; d = 1 / d; const del = d * c; h *= del; if (Math.abs(del - 1) < 3e-16) break; } return h; } function ibeta(x, a, b) { if (x <= 0) return 0; if (x >= 1) return 1; const bt = Math.exp(lgamma(a + b) - lgamma(a) - lgamma(b) + a * Math.log(x) + b * Math.log(1 - x)); return x < (a + 1) / (a + b + 2) ? bt * betacf(a, b, x) / a : 1 - bt * betacf(b, a, 1 - x) / b; } // Student t function tCdf(t, df) { if (!(df < 1e7)) return normCdf(t); const tail = 0.5 * ibeta(df / (df + t * t), df / 2, 0.5); return t > 0 ? 1 - tail : tail; } function tPdf(t, df) { return Math.exp(lgamma((df + 1) / 2) - lgamma(df / 2) - 0.5 * Math.log(df * Math.PI) - (df + 1) / 2 * Math.log(1 + t * t / df)); } function tInv(p, df) { if (!(df < 1e7)) return normInv(p); let x = normInv(p); for (let i = 0; i < 60; i++) { const step = (tCdf(x, df) - p) / tPdf(x, df); x -= step; if (Math.abs(step) < 1e-13 * Math.max(1, Math.abs(x))) break; } return x; } /* ================================================================ design: sample size and power */ const clamp = (v, a, b) => Math.min(b, Math.max(a, v)); const ceil = v => Math.ceil(v - 1e-9); // Distance between the assumed effect and the non-inferiority boundary, in the "good" direction. // better = 'higher' (response, mean score) or 'lower' (event rate, symptom score). function niDistance(diff, margin, better) { return better === 'lower' ? margin - diff : diff + margin; } function arms(nc, k) { const c = ceil(nc), t = ceil(k * nc); return { nc: c, nt: t, n: c + t }; } /* Two proportions. Superiority: Fleiss (1981) normal approximation, pooled variance under H0. Non-inferiority: Blackwelder (1982), unpooled, one-sided alpha. k = treatment : control. */ function ssProp(o) { const pc = o.pc, pt = o.pt, k = o.k || 1, zb = normInv(o.power); if (!(pc > 0 && pc < 1 && pt > 0 && pt < 1)) return { error: 'Rates must be between 0% and 100%.' }; const vAlt = pc * (1 - pc) + pt * (1 - pt) / k; let nc; if (o.hyp === 'ni') { const dist = niDistance(pt - pc, o.margin, o.better); if (!(dist > 0)) return { error: 'The assumed difference already sits beyond the non-inferiority margin.' }; nc = Math.pow(normInv(1 - o.alpha) + zb, 2) * vAlt / (dist * dist); } else { const d = Math.abs(pt - pc); if (!(d > 1e-9)) return { error: 'Treatment and control rates are equal: there is no difference to detect.' }; const pbar = (pc + k * pt) / (1 + k), za = normInv(1 - o.alpha / o.sides); nc = Math.pow(za * Math.sqrt(pbar * (1 - pbar) * (1 + 1 / k)) + zb * Math.sqrt(vAlt), 2) / (d * d); } return Object.assign(arms(nc, k), { raw: nc }); } function powerProp(o, nc) { const pc = o.pc, pt = o.pt, k = o.k || 1, nt = k * nc; const se1 = Math.sqrt(pc * (1 - pc) / nc + pt * (1 - pt) / nt); if (o.hyp === 'ni') return normCdf(niDistance(pt - pc, o.margin, o.better) / se1 - normInv(1 - o.alpha)); const pbar = (pc + k * pt) / (1 + k), se0 = Math.sqrt(pbar * (1 - pbar) * (1 / nc + 1 / nt)); return normCdf((Math.abs(pt - pc) - normInv(1 - o.alpha / o.sides) * se0) / se1); } /* Two means, equal variance t-test. Power uses the shifted central t approximation to the noncentral t; n is the smallest integer reaching the target. */ function powerMeansInt(o, nc, nt) { const df = nc + nt - 2, se = o.sd * Math.sqrt(1 / nc + 1 / nt); if (o.hyp === 'ni') { const tc = tInv(1 - o.alpha, df); return 1 - tCdf(tc - niDistance(o.delta, o.margin, o.better) / se, df); } const tc = tInv(1 - o.alpha / o.sides, df); return 1 - tCdf(tc - Math.abs(o.delta) / se, df); } function powerMeans(o, nc) { const k = o.k || 1; return powerMeansInt(o, Math.max(2, nc), Math.max(2, k * nc)); } function ssMeans(o) { const k = o.k || 1, zb = normInv(o.power); if (!(o.sd > 0)) return { error: 'The standard deviation must be above zero.' }; let dist, za; if (o.hyp === 'ni') { dist = niDistance(o.delta, o.margin, o.better); if (!(dist > 0)) return { error: 'The assumed difference already sits beyond the non-inferiority margin.' }; za = normInv(1 - o.alpha); } else { dist = Math.abs(o.delta); if (!(dist > 0)) return { error: 'A mean difference of zero cannot be detected.' }; za = normInv(1 - o.alpha / o.sides); } const raw = Math.pow(za + zb, 2) * o.sd * o.sd * (1 + 1 / k) / (dist * dist); let nc = Math.max(2, Math.floor(raw) - 2); while (nc > 2 && powerMeansInt(o, nc - 1, Math.max(2, ceil(k * (nc - 1)))) >= o.power) nc--; while (powerMeansInt(o, nc, Math.max(2, ceil(k * nc))) < o.power) nc++; const nt = Math.max(2, ceil(k * nc)); return { nc, nt, n: nc + nt, raw }; } /* Time to event. Events: Schoenfeld (1981); Freedman (1982) shown for comparison. Patients: exponential survival, uniform accrual over A months, F further months of follow-up, exponential dropout. Analysis when the target number of events is reached. */ const monthlyHazard = medianMonths => Math.LN2 / medianMonths; const dropHazard = annualPct => annualPct > 0 ? -Math.log(1 - annualPct) / 12 : 0; // Probability that a patient has the event by calendar time tau (months from first enrolment). function eventProbAt(lam, eta, A, tau) { const h = lam + eta, share = lam / h; if (!(tau > 0)) return 0; if (A <= 1e-9) return share * (1 - Math.exp(-h * tau)); if (tau >= A) return share * (1 - (Math.exp(-h * (tau - A)) - Math.exp(-h * tau)) / (h * A)); return share * (tau - (1 - Math.exp(-h * tau)) / h) / A; } function survSetup(o) { const lamC = monthlyHazard(o.medC), lamT = lamC * o.hr, eta = dropHazard(o.dropY || 0); return { lamC, lamT, eta }; } function eventsNeeded(o) { const k = o.k || 1, zb = normInv(o.power); let z, lnd; if (o.hyp === 'ni') { z = normInv(1 - o.alpha); lnd = Math.log(o.margin) - Math.log(o.hr); } else { z = normInv(1 - o.alpha / o.sides); lnd = Math.abs(Math.log(o.hr)); } if (!(lnd > 0)) return { error: o.hyp === 'ni' ? 'The assumed hazard ratio must be below the margin.' : 'A hazard ratio of 1 cannot be detected.' }; const d = Math.pow(z + zb, 2) * Math.pow(1 + k, 2) / (k * lnd * lnd); const fr = o.hyp === 'ni' ? null : Math.pow(z + zb, 2) * Math.pow(1 + k * o.hr, 2) / (k * Math.pow(1 - o.hr, 2)); return { d, freedman: fr }; } function ssSurv(o) { if (!(o.hr > 0) || !(o.medC > 0)) return { error: 'Enter a control median and a hazard ratio above zero.' }; if (!(o.accrual >= 0) || !(o.follow >= 0) || !(o.accrual + o.follow > 0)) return { error: 'Accrual plus follow-up must be longer than zero.' }; const k = o.k || 1, ev = eventsNeeded(o); if (ev.error) return ev; const s = survSetup(o), tau = o.accrual + o.follow; const pc = eventProbAt(s.lamC, s.eta, o.accrual, tau), pt = eventProbAt(s.lamT, s.eta, o.accrual, tau); const pbar = (pc + k * pt) / (1 + k); const N = ev.d / pbar, nc = N / (1 + k); return Object.assign(arms(nc, k), { events: ceil(ev.d), eventsRaw: ev.d, freedman: ev.freedman ? ceil(ev.freedman) : null, pEventC: pc, pEventT: pt, raw: nc }); } function powerSurv(o, nc) { const k = o.k || 1, s = survSetup(o), tau = o.accrual + o.follow; const pc = eventProbAt(s.lamC, s.eta, o.accrual, tau), pt = eventProbAt(s.lamT, s.eta, o.accrual, tau); const D = nc * pc + k * nc * pt; let z, lnd; if (o.hyp === 'ni') { z = normInv(1 - o.alpha); lnd = Math.log(o.margin) - Math.log(o.hr); } else { z = normInv(1 - o.alpha / o.sides); lnd = Math.abs(Math.log(o.hr)); } return normCdf(Math.sqrt(D * k) / (1 + k) * lnd - z); } // Calendar month when the expected number of events reaches D (for the readout estimate). function expectedEventTime(o, nc, nt, D) { const s = survSetup(o); const ev = tau => nc * eventProbAt(s.lamC, s.eta, o.accrual, tau) + nt * eventProbAt(s.lamT, s.eta, o.accrual, tau); let lo = 0, hi = Math.max(1, o.accrual + o.follow); for (let i = 0; i < 60 && ev(hi) < D; i++) hi *= 1.6; if (ev(hi) < D) return Infinity; for (let i = 0; i < 80; i++) { const m = (lo + hi) / 2; if (ev(m) < D) lo = m; else hi = m; } return (lo + hi) / 2; } function design(type, o) { if (type === 'bin') return ssProp(o); if (type === 'cont') return ssMeans(o); return ssSurv(o); } function power(type, o, nc) { if (type === 'bin') return powerProp(o, nc); if (type === 'cont') return powerMeans(o, nc); return powerSurv(o, nc); } /* ================================================================ interpret */ function riskFromRatio(kind, r, pc) { if (kind === 'HR') return 1 - Math.pow(1 - pc, r); if (kind === 'RR') return clamp(r * pc, 0, 1); return r * pc / (1 - pc + r * pc); } // NNT from a ratio and the control-arm risk. CI from the ratio's CI (Altman & Andersen 1999). function fromRatio(kind, est, lo, hi, pc) { const pt = riskFromRatio(kind, est, pc); const a1 = pc - riskFromRatio(kind, lo, pc), a2 = pc - riskFromRatio(kind, hi, pc); const arr = pc - pt; return { pc, pt, arr, arrLo: Math.min(a1, a2), arrHi: Math.max(a1, a2), rrr: pc > 0 ? arr / pc : NaN }; } // Altman & Bland (2011): p from a ratio's 95% CI. function pFromRatioCI(est, lo, hi) { const se = (Math.log(hi) - Math.log(lo)) / (2 * normInv(0.975)); const z = Math.log(est) / se; return { z, p: 2 * normCdf(-Math.abs(z)), se }; } // NNT label for an absolute risk reduction and its CI (Altman 1998): NNTB = benefit, NNTH = harm. function nntText(arr, lo, hi) { const one = a => a > 0 ? `NNTB ${fmtN(1 / a)}` : a < 0 ? `NNTH ${fmtN(-1 / a)}` : 'no difference'; const main = one(arr); if (lo > 0 || hi < 0) return { main, ci: `${one(lo > 0 ? hi : lo)} to ${one(lo > 0 ? lo : hi)}`, crosses: false }; return { main, ci: `${one(hi)} to ∞ to ${one(lo)}`, crosses: true }; } // NNTs are conventionally rounded up to the next whole patient. function fmtN(v) { return !isFinite(v) ? '∞' : String(Math.max(1, Math.ceil(v - 1e-9))); } function wilson(x, n, z) { z = z || normInv(0.975); const p = x / n, z2 = z * z, den = 1 + z2 / n; const centre = (p + z2 / (2 * n)) / den, half = z * Math.sqrt(p * (1 - p) / n + z2 / (4 * n * n)) / den; return [Math.max(0, centre - half), Math.min(1, centre + half)]; } // Fisher's exact test, two-sided: sum of tables no more likely than the one observed. function fisher(a, b, c, d) { const r1 = a + b, r2 = c + d, c1 = a + c, n = r1 + r2; const lo = Math.max(0, c1 - r2), hi = Math.min(r1, c1); const base = lfact(r1) + lfact(r2) + lfact(c1) + lfact(n - c1) - lfact(n); const lp = x => base - lfact(x) - lfact(r1 - x) - lfact(c1 - x) - lfact(r2 - c1 + x); const obs = lp(a); let p = 0; for (let x = lo; x <= hi; x++) { const v = lp(x); if (v <= obs + 1e-7) p += Math.exp(v); } return Math.min(1, p); } // Fragility index (Walsh et al. 2014): events added to the arm with fewer events until p >= 0.05. function fragility(et, nt, ec, nc, alpha) { alpha = alpha || 0.05; let p = fisher(et, nt - et, ec, nc - ec); if (p >= alpha) return { fi: null, p }; let a = et, c = ec, steps = 0; const onT = et <= ec; while (p < alpha) { if (onT) { if (a >= nt) break; a++; } else { if (c >= nc) break; c++; } steps++; p = fisher(a, nt - a, c, nc - c); } return { fi: steps, fq: steps / (nt + nc), arm: onT ? 'treatment' : 'control', p0: fisher(et, nt - et, ec, nc - ec) }; } function fromCounts(et, nt, ec, nc) { const z = normInv(0.975), pt = et / nt, pc = ec / nc; const zero = !et || !ec || et === nt || ec === nc, h = zero ? 0.5 : 0; const a = et + h, b = nt - et + h, c = ec + h, d = nc - ec + h; const rr = (a / (a + b)) / (c / (c + d)), seRR = Math.sqrt(1 / a - 1 / (a + b) + 1 / c - 1 / (c + d)); const or = (a * d) / (b * c), seOR = Math.sqrt(1 / a + 1 / b + 1 / c + 1 / d); const [l1, u1] = wilson(et, nt, z), [l2, u2] = wilson(ec, nc, z); const rd = pt - pc; const rdLo = rd - Math.sqrt(Math.pow(pt - l1, 2) + Math.pow(u2 - pc, 2)); const rdHi = rd + Math.sqrt(Math.pow(u1 - pt, 2) + Math.pow(pc - l2, 2)); return { pt, pc, rd, rdLo, rdHi, arr: -rd, arrLo: -rdHi, arrHi: -rdLo, rr, rrLo: Math.exp(Math.log(rr) - z * seRR), rrHi: Math.exp(Math.log(rr) + z * seRR), or, orLo: Math.exp(Math.log(or) - z * seOR), orHi: Math.exp(Math.log(or) + z * seOR), zeroCorrected: zero, p: fisher(et, nt - et, ec, nc - ec), frag: fragility(et, nt, ec, nc), }; } /* ================================================================ diagnose */ // Largest-remainder rounding so that the parts add up to the total. function apportion(parts, total) { const raw = parts.map(v => v * total), fl = raw.map(Math.floor); let left = total - fl.reduce((s, v) => s + v, 0); raw.map((v, i) => [v - fl[i], i]).sort((x, y) => y[0] - x[0]).forEach(([, i]) => { if (left > 0) { fl[i]++; left--; } }); return fl; } function diag(sens, spec, prev, N) { N = N || 1000; const tp = sens * prev, fn = (1 - sens) * prev, fp = (1 - spec) * (1 - prev), tn = spec * (1 - prev); const [TP, FN, FP, TN] = apportion([tp, fn, fp, tn], N); return { ppv: tp + fp > 0 ? tp / (tp + fp) : NaN, npv: tn + fn > 0 ? tn / (tn + fn) : NaN, lrPos: spec < 1 ? sens / (1 - spec) : Infinity, lrNeg: spec > 0 ? (1 - sens) / spec : Infinity, TP, FN, FP, TN, }; } /* ================================================================ random numbers */ // xoshiro128** seeded through splitmix32. Reproducible across browsers and Node. function rng(seed) { let s = seed >>> 0; const sm = () => { s = (s + 0x9e3779b9) | 0; let z = s; z = Math.imul(z ^ (z >>> 16), 0x85ebca6b); z = Math.imul(z ^ (z >>> 13), 0xc2b2ae35); return (z ^ (z >>> 16)) >>> 0; }; let a = sm(), b = sm(), c = sm(), d = sm(); const rotl = (x, k) => (x << k) | (x >>> (32 - k)); function next() { const r = Math.imul(rotl(Math.imul(b, 5), 7), 9) >>> 0; const t = b << 9; c ^= a; d ^= b; b ^= c; a ^= d; c ^= t; d = rotl(d, 11); return r; } const u = () => (next() + 0.5) / 4294967296; let spare = null; function normal() { if (spare !== null) { const v = spare; spare = null; return v; } let x, y, r; do { x = 2 * u() - 1; y = 2 * u() - 1; r = x * x + y * y; } while (r >= 1 || r === 0); const f = Math.sqrt(-2 * Math.log(r) / r); spare = y * f; return x * f; } function gamma(shape) { if (shape < 1) return gamma(shape + 1) * Math.pow(u(), 1 / shape); const dd = shape - 1 / 3, cc = 1 / Math.sqrt(9 * dd); for (;;) { let x, v; do { x = normal(); v = 1 + cc * x; } while (v <= 0); v = v * v * v; const uu = u(); if (uu < 1 - 0.0331 * x * x * x * x) return dd * v; if (Math.log(uu) < 0.5 * x * x + dd * (1 - v + Math.log(v))) return dd * v; } } // Binomial: inversion when n·p < 10, otherwise BTRS (Hörmann 1993). function binom(n, p) { if (n <= 0 || p <= 0) return 0; if (p >= 1) return n; if (p > 0.5) return n - binom(n, 1 - p); const q = 1 - p; if (n * p < 10) { const sr = p / q, aa = (n + 1) * sr; for (;;) { let r = Math.pow(q, n), uu = u(), x = 0, ok = true; while (uu > r) { uu -= r; x++; if (x > n) { ok = false; break; } r *= aa / x - sr; } if (ok) return x; } } const spq = Math.sqrt(n * p * q), bb = 1.15 + 2.53 * spq, aa = -0.0873 + 0.0248 * bb + 0.01 * p; const cc = n * p + 0.5, vr = 0.92 - 4.2 / bb, alpha = (2.83 + 5.1 / bb) * spq, lpq = Math.log(p / q); const m = Math.floor((n + 1) * p), h = lfact(m) + lfact(n - m); for (;;) { const uu = u() - 0.5, v0 = u(), us = 0.5 - Math.abs(uu); const k = Math.floor((2 * aa / us + bb) * uu + cc); if (k < 0 || k > n) continue; if (us >= 0.07 && v0 <= vr) return k; const v = Math.log(v0 * alpha / (aa / (us * us) + bb)); if (v <= h - lfact(k) - lfact(n - k) + (k - m) * lpq) return k; } } return { u, normal, gamma, binom, chisq: k => 2 * gamma(k / 2), exp: rate => -Math.log(u()) / rate }; } /* ================================================================ simulation */ // Log-rank on (time, arm, status) records packed as sortable numbers. const PACK = 1e6; function logrankPacked(keys, m, nT, nC, km) { let rT = nT, rC = nC, O = 0, E = 0, V = 0, events = 0; let sT = 1, sC = 1, g = 0; for (let i = 0; i < m; i++) { const key = keys[i], code = key % 4, arm = code >> 1, ev = code & 1; if (km) { const t = Math.floor(key / 4) / PACK; while (g < km.grid.length && km.grid[g] < t) { km.t[g] = sT; km.c[g] = sC; g++; } } if (ev) { const n = rT + rC, e = rT / n; O += arm; E += e; V += e * (1 - e); events++; if (km) { if (arm) sT *= 1 - 1 / rT; else sC *= 1 - 1 / rC; } } if (arm) rT--; else rC--; } if (km) { const tEnd = m ? Math.floor(keys[m - 1] / 4) / PACK : 0; for (; g < km.grid.length; g++) { const ok = km.grid[g] <= tEnd; km.t[g] = ok ? sT : NaN; km.c[g] = ok ? sC : NaN; } } return { O, E, V, events }; } // Log-rank chi-square for a small dataset of [time, arm (1 = treatment), event (1/0)] rows. function logrankChi2(rows) { const keys = new Float64Array(rows.length); let nT = 0, nC = 0; rows.forEach((r, i) => { keys[i] = Math.floor(r[0] * PACK) * 4 + r[1] * 2 + r[2]; if (r[1]) nT++; else nC++; }); keys.sort(); const lr = logrankPacked(keys, keys.length, nT, nC, null); return Math.pow(lr.O - lr.E, 2) / lr.V; } // k-th smallest value of arr[0..n) (0-based), in place. Floyd–Rivest-free quickselect. function select(arr, n, k) { let l = 0, r = n - 1; while (l < r) { const x = arr[(l + r) >> 1]; let i = l, j = r; while (i <= j) { while (arr[i] < x) i++; while (arr[j] > x) j--; if (i <= j) { const t = arr[i]; arr[i] = arr[j]; arr[j] = t; i++; j--; } } if (k <= j) r = j; else if (k >= i) l = i; else break; } return arr[k]; } /* One batch of simulated trials. cfg is built by the page from the design; out accumulates. Effects are on the analysis scale: risk difference (t − c), mean difference, log hazard ratio. */ function makeSim(cfg, seed) { const R = rng(seed), nt = cfg.nt, nc = cfg.nc; const zc2 = normInv(1 - cfg.alpha / cfg.sides), z1 = normInv(1 - cfg.alpha); const dir = cfg.effectSign || 1; // direction of the assumed effect, for superiority const df = nt + nc - 2; const tc2 = cfg.type === 'cont' ? tInv(1 - cfg.alpha / cfg.sides, df) : 0, tc1 = cfg.type === 'cont' ? tInv(1 - cfg.alpha, df) : 0; const prior = cfg.prior && cfg.prior.on ? cfg.prior : null; let keys, cal, ent, T, Dr; if (cfg.type === 'tte') { const N = nt + nc; keys = new Float64Array(N); cal = new Float64Array(N); ent = new Float64Array(N); T = new Float64Array(N); Dr = new Float64Array(N); } function trueEffect() { if (!prior) return cfg.type === 'bin' ? cfg.pt : cfg.type === 'cont' ? cfg.delta : cfg.hr; const v = prior.mean + prior.sd * R.normal(); if (cfg.type === 'bin') return clamp(v, 0.0005, 0.9995); if (cfg.type === 'tte') return Math.exp(v); return v; } function one(km) { const te = trueEffect(); if (cfg.type === 'bin') { const xt = R.binom(nt, te), xc = R.binom(nc, cfg.pc), pt = xt / nt, pc = xc / nc, d = pt - pc; let ok; if (cfg.hyp === 'ni') { const qt = clamp(pt, 0.5 / nt, 1 - 0.5 / nt), qc = clamp(pc, 0.5 / nc, 1 - 0.5 / nc); const se = Math.sqrt(qt * (1 - qt) / nt + qc * (1 - qc) / nc); ok = niDistance(d, cfg.margin, cfg.better) / se > z1; } else { const pb = (xt + xc) / (nt + nc), se = Math.sqrt(pb * (1 - pb) * (1 / nt + 1 / nc)); ok = se > 0 && dir * d / se > zc2; } return { est: d, ok, te }; } if (cfg.type === 'cont') { const mt = te + cfg.sd * R.normal() / Math.sqrt(nt), mc = cfg.sd * R.normal() / Math.sqrt(nc); const sp = cfg.sd * Math.sqrt(R.chisq(df) / df), se = sp * Math.sqrt(1 / nt + 1 / nc), d = mt - mc; const ok = cfg.hyp === 'ni' ? niDistance(d, cfg.margin, cfg.better) / se > tc1 : dir * d / se > tc2; return { est: d, ok, te }; } // time to event const lamC = cfg.lamC, lamT = lamC * te, eta = cfg.eta, A = cfg.accrual, N = nt + nc; let nEv = 0; for (let i = 0; i < N; i++) { const arm = i < nt ? 1 : 0; ent[i] = A > 0 ? A * R.u() : 0; T[i] = R.exp(arm ? lamT : lamC); Dr[i] = eta > 0 ? R.exp(eta) : Infinity; if (T[i] <= Dr[i]) cal[nEv++] = ent[i] + T[i]; } let cutoff; if (cfg.analysis === 'events' && cfg.events > 0) { cutoff = nEv >= cfg.events ? select(cal, nEv, cfg.events - 1) : (nEv ? select(cal, nEv, nEv - 1) : A + cfg.follow); } else cutoff = A + cfg.follow; let m = 0, nT = 0, nC = 0; for (let i = 0; i < N; i++) { const fu = cutoff - ent[i]; if (fu <= 0) continue; const arm = i < nt ? 1 : 0, t = Math.min(T[i], Dr[i], fu), ev = (T[i] <= Dr[i] && T[i] <= fu) ? 1 : 0; keys[m++] = Math.floor(t * PACK) * 4 + arm * 2 + ev; if (arm) nT++; else nC++; } const sub = keys.subarray(0, m); sub.sort(); const lr = logrankPacked(sub, m, nT, nC, km); const est = lr.V > 0 ? (lr.O - lr.E) / lr.V : 0; // Peto log hazard ratio let ok; if (!(lr.V > 0)) ok = false; else if (cfg.hyp === 'ni') ok = est + z1 / Math.sqrt(lr.V) < Math.log(cfg.margin); else ok = dir * (-(lr.O - lr.E) / Math.sqrt(lr.V)) > zc2; // dir = +1 when HR < 1 is assumed return { est, ok, te, dur: cutoff, events: lr.events }; } return { one }; } const API = { normCdf, normInv, lgamma, ibeta, tCdf, tInv, wilson, fisher, fragility, fromCounts, fromRatio, riskFromRatio, pFromRatioCI, nntText, fmtN, diag, apportion, ssProp, powerProp, ssMeans, powerMeans, ssSurv, powerSurv, eventsNeeded, eventProbAt, expectedEventTime, monthlyHazard, dropHazard, design, power, niDistance, rng, makeSim, logrankPacked, logrankChi2, PACK, select, }; root.PP = API; if (typeof module !== 'undefined' && module.exports) module.exports = API;})(typeof self !== 'undefined' ? self : this);sim-worker.js54 lines
/* Power Play simulation worker: runs the virtual trials off the main thread and streams results. Paced so a short run is still watchable; "fast" removes the pacing. */'use strict';importScripts('stats.js');let fast = false;self.onmessage = e => { const m = e.data; if (m.type === 'fast') { fast = true; return; } if (m.type === 'run') run(m);};function run(m) { const { cfg, seed, nsims, bins, kmCount, kmGrid, minMs } = m; fast = !!m.fast; const sim = PP.makeSim(cfg, seed); const sig = new Float64Array(bins.n), non = new Float64Array(bins.n); const durs = cfg.type === 'tte' ? new Float64Array(nsims) : null; const evs = cfg.type === 'tte' ? new Float64Array(nsims) : null; let done = 0, ok = 0, sumSig = 0, sumAll = 0, sumTrue = 0; let kmBatch = [], trace = []; const t0 = Date.now(); const width = (bins.hi - bins.lo) / bins.n; function chunk() { const target = fast ? nsims : Math.min(nsims, Math.ceil(nsims * (Date.now() - t0 + 70) / minMs)); const stopAt = Math.max(done + 1, target), sliceEnd = Date.now() + 45; while (done < stopAt && done < nsims && (done % 16 || Date.now() < sliceEnd)) { let km = null; if (done < kmCount) km = { grid: kmGrid, t: new Float64Array(kmGrid.length), c: new Float64Array(kmGrid.length) }; const r = sim.one(km); const b = Math.min(bins.n - 1, Math.max(0, Math.floor((r.est - bins.lo) / width))); if (r.ok) { ok++; sig[b]++; sumSig += r.est; } else non[b]++; sumAll += r.est; sumTrue += cfg.type === 'tte' ? Math.log(r.te) : cfg.type === 'bin' ? r.te - cfg.pc : r.te; if (durs) { durs[done] = r.dur; evs[done] = r.events; } if (km) kmBatch.push({ t: km.t, c: km.c, ok: r.ok }); done++; } trace.push([done, ok]); const msg = { type: done >= nsims ? 'done' : 'progress', done, ok, sig, non, km: kmBatch, trace, sumSig, sumAll, sumTrue }; if (done >= nsims && durs) { const d = Array.from(durs).sort((a, b) => a - b), v = Array.from(evs).sort((a, b) => a - b); const q = (a, p) => a[Math.min(a.length - 1, Math.floor(p * a.length))]; msg.dur = { p05: q(d, 0.05), p50: q(d, 0.5), p95: q(d, 0.95) }; msg.events = { p05: q(v, 0.05), p50: q(v, 0.5), p95: q(v, 0.95) }; } self.postMessage(msg); kmBatch = []; trace = []; if (done < nsims) setTimeout(chunk, fast ? 0 : 33); } chunk();}app.js826 lines
/* Power Play: Medical Affairs stats suite. Interface layer; all statistics live in stats.js. MIT licence. */'use strict';(() => { const $ = (s, r = document) => r.querySelector(s), $$ = (s, r = document) => Array.from(r.querySelectorAll(s)); const PP = window.PP; const COL = { mag: '#FF2A85', cyan: '#00F0FF', purple: '#7A00FF', lilac: '#B57BFF', white: '#FFFFFF', silver: '#CBD5E1', slate: '#64748B', ok: '#3BFF9E', warn: '#F4FF5A', bg: '#0D0D12' }; const FONT = '11px ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace'; const REDUCED = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches; const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const int = v => Math.round(v).toLocaleString('en-US'); const pct = (v, d = 1) => (v * 100).toFixed(d).replace(/\.0+$/, '') + '%'; const num = (v, d = 2) => { if (!isFinite(v)) return '∞'; const s = Number(v).toFixed(d); return s.includes('.') ? s.replace(/0+$/, '').replace(/\.$/, '') : s; }; const fx = (v, d) => Number(v).toFixed(d); const ratioTxt = k => k === 1 ? '1:1' : `${k}:1 (treatment:control)`; /* ================================================================ state */ const DEF = { tab: 'design', d: { type: 'tte', hyp: 'sup', better: 'higher', pc: 30, pt: 20, delta: 4, sd: 10, medC: 12, hr: 0.7, accrual: 18, follow: 12, dropY: 5, alpha: 0.05, alphaNI: 0.025, sides: 2, power: 80, k: 1, drop: 10, margin: 10, marginC: 3, marginH: 1.3, supMem: null }, s: { n: 1000, seed: 2026, prior: false, plo: null, phi: null, ptype: null, analysis: 'events' }, i: { mode: 'ratio', kind: 'HR', est: 0.75, lo: 0.62, hi: 0.91, pc: 20, horizon: '3 years', outcome: 'a major cardiovascular event', et: 41, nt: 1000, ec: 68, nc: 1000 }, x: { sens: 90, spec: 90, prev: 1 }, }; const KEY = 'power-play.v1'; const merge = (a, b) => { const o = JSON.parse(JSON.stringify(a)); if (b && typeof b === 'object') for (const k in o) if (k in b) o[k] = (o[k] && typeof o[k] === 'object') ? merge(o[k], b[k]) : b[k]; return o; }; const b64 = { enc: s => btoa(unescape(encodeURIComponent(s))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''), dec: s => decodeURIComponent(escape(atob(s.replace(/-/g, '+').replace(/_/g, '/')))) }; function loadState() { const h = location.hash.match(/[#&]s=([\w-]+)/); if (h) { try { return merge(DEF, JSON.parse(b64.dec(h[1]))); } catch (e) { } } try { const v = localStorage.getItem(KEY); if (v) return merge(DEF, JSON.parse(v)); } catch (e) { } return merge(DEF, {}); } const S = loadState(); let saveT; const save = () => { clearTimeout(saveT); saveT = setTimeout(() => { try { localStorage.setItem(KEY, JSON.stringify(S)); } catch (e) { } }, 250); }; /* ================================================================ forms */ const F = { d: [ ['Endpoint', [ { k: 'type', kind: 'seg', label: 'Primary endpoint', opts: [['bin', 'Binary'], ['cont', 'Continuous'], ['tte', 'Time to event']] }, { k: 'hyp', kind: 'seg', label: 'Hypothesis', opts: [['sup', 'Superiority'], ['ni', 'Non-inferiority']] }, { k: 'better', kind: 'seg', label: 'Direction of benefit', opts: [['higher', 'Higher is better'], ['lower', 'Lower is better']], show: d => d.hyp === 'ni' && d.type !== 'tte' }, ]], ['Effect you expect', [ { k: 'pc', label: 'Control rate', unit: '%', min: 0.5, max: 99.5, step: 0.5, show: d => d.type === 'bin' }, { k: 'pt', label: 'Treatment rate', unit: '%', min: 0.5, max: 99.5, step: 0.5, show: d => d.type === 'bin' }, { k: 'margin', label: 'Non-inferiority margin', unit: 'percentage points', min: 0.5, max: 30, step: 0.5, show: d => d.type === 'bin' && d.hyp === 'ni' }, { k: 'delta', label: 'Mean difference (treatment − control)', unit: 'units', min: -50, max: 50, step: 0.1, show: d => d.type === 'cont' }, { k: 'sd', label: 'Standard deviation', unit: 'units', min: 0.1, max: 100, step: 0.1, show: d => d.type === 'cont' }, { k: 'marginC', label: 'Non-inferiority margin', unit: 'units', min: 0.1, max: 50, step: 0.1, show: d => d.type === 'cont' && d.hyp === 'ni' }, { k: 'medC', label: 'Control median survival', unit: 'months', min: 1, max: 120, step: 0.5, show: d => d.type === 'tte' }, { k: 'hr', label: 'Hazard ratio (treatment vs control)', unit: 'below 1 = benefit', min: 0.3, max: 1.5, step: 0.01, show: d => d.type === 'tte' }, { k: 'marginH', label: 'Non-inferiority margin', unit: 'hazard ratio', min: 1.02, max: 2.5, step: 0.01, show: d => d.type === 'tte' && d.hyp === 'ni' }, ]], ['Trial', [ { k: 'accrual', label: 'Accrual period', unit: 'months', min: 0, max: 72, step: 1, show: d => d.type === 'tte' }, { k: 'follow', label: 'Follow-up after last patient', unit: 'months', min: 0, max: 72, step: 1, show: d => d.type === 'tte' }, { k: 'dropY', label: 'Dropout', unit: '% per year', min: 0, max: 30, step: 0.5, show: d => d.type === 'tte' }, { k: 'drop', label: 'Dropout', unit: '% of patients', min: 0, max: 50, step: 1, show: d => d.type !== 'tte' }, { k: 'k', kind: 'seg', label: 'Allocation (treatment : control)', opts: [[1, '1:1'], [2, '2:1'], [3, '3:1']] }, ]], ['Test', [ { k: 'power', kind: 'seg', label: 'Target power', opts: [[80, '80%'], [85, '85%'], [90, '90%'], [95, '95%']] }, { k: 'alpha', label: 'Significance level α', unit: '', min: 0.001, max: 0.2, step: 0.001, show: d => d.hyp === 'sup' }, { k: 'sides', kind: 'seg', label: 'Test', opts: [[2, 'Two-sided'], [1, 'One-sided']], show: d => d.hyp === 'sup' }, { k: 'alphaNI', label: 'Significance level α (one-sided)', unit: '', min: 0.001, max: 0.1, step: 0.001, show: d => d.hyp === 'ni' }, ]], ], i: [ ['Source', [ { k: 'mode', kind: 'seg', label: 'What the paper reports', opts: [['ratio', 'A ratio and its CI'], ['counts', 'Event counts']] }, { k: 'kind', kind: 'seg', label: 'Measure', opts: [['HR', 'Hazard ratio'], ['RR', 'Risk ratio'], ['OR', 'Odds ratio']], show: i => i.mode === 'ratio' }, ]], ['Result', [ { k: 'est', label: 'Estimate', min: 0.05, max: 5, step: 0.01, solo: true, show: i => i.mode === 'ratio' }, { k: 'lo', label: '95% CI, lower limit', min: 0.01, max: 5, step: 0.01, solo: true, show: i => i.mode === 'ratio' }, { k: 'hi', label: '95% CI, upper limit', min: 0.02, max: 10, step: 0.01, solo: true, show: i => i.mode === 'ratio' }, { k: 'pc', label: 'Control-arm risk at that time point', unit: '%', min: 0.1, max: 95, step: 0.1, log: true, show: i => i.mode === 'ratio' }, { k: 'et', label: 'Treatment arm: patients with the event', min: 0, max: 1e6, step: 1, solo: true, show: i => i.mode === 'counts' }, { k: 'nt', label: 'Treatment arm: patients', min: 1, max: 1e6, step: 1, solo: true, show: i => i.mode === 'counts' }, { k: 'ec', label: 'Control arm: patients with the event', min: 0, max: 1e6, step: 1, solo: true, show: i => i.mode === 'counts' }, { k: 'nc', label: 'Control arm: patients', min: 1, max: 1e6, step: 1, solo: true, show: i => i.mode === 'counts' }, ]], ['Words', [ { k: 'outcome', kind: 'text', label: 'Outcome', placeholder: 'a major cardiovascular event' }, { k: 'horizon', kind: 'text', label: 'Time point', placeholder: '3 years' }, ]], ], x: [ ['The test', [ { k: 'sens', label: 'Sensitivity', unit: '%', min: 50, max: 99.9, step: 0.1 }, { k: 'spec', label: 'Specificity', unit: '%', min: 50, max: 99.9, step: 0.1 }, ]], ['The people tested', [ { k: 'prev', label: 'Prevalence (pre-test probability)', unit: '%', min: 0.1, max: 60, step: 0.1, log: true }, ]], ], }; const vis = (f, st) => !f.show || f.show(st); function buildForm(form, sec, groups, onChange) { const st = S[sec]; const html = groups.map(([title, fields]) => `<div class="fgroup"><h3>${esc(title)}</h3>${fields.map(f => fieldHTML(sec, f, st)).join('')}</div>`).join(''); form.insertAdjacentHTML('beforeend', html); groups.forEach(([, fields]) => fields.forEach(f => bindField(form, sec, f, onChange))); syncForm(form, sec, groups); } function fieldHTML(sec, f, st) { const id = `${sec}-${f.k}`; if (f.kind === 'seg') { return `<div class="field" data-k="${f.k}"><span class="lbl" id="${id}-l">${esc(f.label)}</span><div class="seg" role="group" aria-labelledby="${id}-l">${f.opts.map(([v, t]) => `<button type="button" data-v="${esc(v)}" aria-pressed="${String(st[f.k]) === String(v)}">${esc(t)}</button>`).join('')}</div></div>`; } if (f.kind === 'text') { return `<div class="field" data-k="${f.k}"><div class="lab"><label for="${id}">${esc(f.label)}</label></div><div class="row solo"><input class="input" type="text" id="${id}" maxlength="80" placeholder="${esc(f.placeholder || '')}" value="${esc(st[f.k])}"></div></div>`; } const slider = f.solo ? '' : `<input type="range" id="${id}-r" aria-label="${esc(f.label)}" min="${f.log ? 0 : f.min}" max="${f.log ? 1000 : f.max}" step="${f.log ? 1 : f.step}">`; return `<div class="field" data-k="${f.k}"><div class="lab"><label for="${id}">${esc(f.label)}</label>${f.unit ? `<span class="hint">${esc(f.unit)}</span>` : ''}</div> <div class="row${f.solo ? ' solo' : ''}">${slider}<input class="input" type="number" id="${id}" inputmode="decimal" min="${f.min}" max="${f.max}" step="${f.step}"></div></div>`; } const toSlider = (f, v) => f.log ? Math.round(1000 * (Math.log(v) - Math.log(f.min)) / (Math.log(f.max) - Math.log(f.min))) : v; const fromSlider = (f, t) => { if (!f.log) return +t; const v = Math.exp(Math.log(f.min) + t / 1000 * (Math.log(f.max) - Math.log(f.min))); return +v.toFixed(v < 1 ? 2 : 1); }; function bindField(form, sec, f, onChange) { const wrap = form.querySelector(`.field[data-k="${f.k}"]`); if (f.kind === 'seg') { wrap.addEventListener('click', e => { const b = e.target.closest('button[data-v]'); if (!b) return; const v = typeof f.opts[0][0] === 'number' ? +b.dataset.v : b.dataset.v; S[sec][f.k] = v; onChange(f.k); }); return; } const inp = $('input[type=number],input[type=text]', wrap), rng = $('input[type=range]', wrap); if (f.kind === 'text') { inp.addEventListener('input', () => { S[sec][f.k] = inp.value.trim().slice(0, 80); onChange(f.k, true); }); return; } inp.value = S[sec][f.k]; if (rng) rng.value = toSlider(f, S[sec][f.k]); inp.addEventListener('input', () => { const v = parseFloat(inp.value); const ok = isFinite(v) && v >= f.min && v <= f.max; wrap.classList.toggle('bad', !ok); if (!ok) return; S[sec][f.k] = v; if (rng) rng.value = toSlider(f, v); onChange(f.k, true); }); inp.addEventListener('blur', () => { inp.value = S[sec][f.k]; wrap.classList.remove('bad'); }); if (rng) rng.addEventListener('input', () => { const v = fromSlider(f, rng.value); S[sec][f.k] = v; inp.value = v; wrap.classList.remove('bad'); onChange(f.k, true); }); } function syncForm(form, sec, groups) { const st = S[sec]; groups.forEach(([, fields]) => fields.forEach(f => { const wrap = form.querySelector(`.field[data-k="${f.k}"]`); wrap.hidden = !vis(f, st); if (f.kind === 'seg') $$('button', wrap).forEach(b => b.setAttribute('aria-pressed', String(String(st[f.k]) === b.dataset.v))); else if (f.kind !== 'text') { const inp = $('input[type=number]', wrap), rng = $('input[type=range]', wrap); if (document.activeElement !== inp) inp.value = st[f.k]; if (rng && document.activeElement !== rng) rng.value = toSlider(f, st[f.k]); } })); groups.forEach(([, fields], gi) => { const g = form.querySelectorAll('.fgroup')[gi]; if (g) g.hidden = fields.every(f => !vis(f, st)); }); } /* ================================================================ charts */ function prep(cv) { const dpr = Math.min(2.5, window.devicePixelRatio || 1); if (!cv.dataset.h) cv.dataset.h = cv.getAttribute('height'); const h = +cv.dataset.h, w = Math.max(220, cv.clientWidth || cv.parentNode.clientWidth || 300); cv.style.height = h + 'px'; const W = Math.round(w * dpr), H = Math.round(h * dpr); if (cv.width !== W) cv.width = W; if (cv.height !== H) cv.height = H; const ctx = cv.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, w, h); return { ctx, w, h }; } const lin = (d0, d1, r0, r1) => v => r0 + (v - d0) / (d1 - d0) * (r1 - r0); const logS = (d0, d1, r0, r1) => { const a = Math.log(d0), b = Math.log(d1); return v => r0 + (Math.log(v) - a) / (b - a) * (r1 - r0); }; function nice(min, max, n) { n = n || 5; const span = max - min; if (!(span > 0)) return [min]; const s0 = span / n, mag = Math.pow(10, Math.floor(Math.log10(s0))), e = s0 / mag; const step = (e >= 7.5 ? 10 : e >= 3.5 ? 5 : e >= 1.5 ? 2 : 1) * mag, out = []; for (let v = Math.ceil(min / step - 1e-9) * step; v <= max + step * 1e-9; v += step) out.push(+v.toFixed(10)); return out; } const logTicks = (lo, hi, list) => list.filter(v => v >= lo * 0.999 && v <= hi * 1.001); function box(w, h, o) { o = o || {}; return { l: o.l || 52, t: o.t || 28, r: w - (o.r || 16), b: h - (o.b || 46) }; } function axes(ctx, B, xs, ys, xt, yt, xf, yf, o) { o = o || {}; ctx.save(); ctx.font = FONT; ctx.lineWidth = 1; ctx.strokeStyle = 'rgba(203,213,225,.08)'; ctx.fillStyle = COL.slate; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; yt.forEach(v => { const y = Math.round(ys(v)) + 0.5; ctx.beginPath(); ctx.moveTo(B.l, y); ctx.lineTo(B.r, y); ctx.stroke(); ctx.fillText(yf(v), B.l - 8, y); }); ctx.textAlign = 'center'; ctx.textBaseline = 'top'; xt.forEach(v => { const x = Math.round(xs(v)) + 0.5; ctx.beginPath(); ctx.moveTo(x, B.t); ctx.lineTo(x, B.b); ctx.stroke(); ctx.textAlign = x > B.r - 18 ? 'right' : x < B.l + 6 ? 'left' : 'center'; ctx.fillText(xf(v), x > B.r - 18 ? B.r + 2 : x, B.b + 7); }); ctx.strokeStyle = 'rgba(0,240,255,.28)'; ctx.beginPath(); ctx.moveTo(B.l, B.b + 0.5); ctx.lineTo(B.r, B.b + 0.5); ctx.stroke(); ctx.fillStyle = COL.silver; if (o.xl) { ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillText(o.xl, (B.l + B.r) / 2, B.b + 25); } if (o.yl) { ctx.textAlign = 'left'; ctx.textBaseline = 'bottom'; ctx.fillText(o.yl, 4, B.t - 10); } ctx.restore(); } function vline(ctx, B, x, color, label, dash, top) { if (!(x >= B.l - 1 && x <= B.r + 1)) return; ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = 1.4; if (dash) ctx.setLineDash(dash); ctx.beginPath(); ctx.moveTo(Math.round(x) + 0.5, B.t); ctx.lineTo(Math.round(x) + 0.5, B.b); ctx.stroke(); if (label) { ctx.setLineDash([]); ctx.font = FONT; const tw = ctx.measureText(label).width, right = x > (B.l + B.r) / 2; const lx = right ? x - 6 - tw : x + 6, ly = B.t + (top || 2); ctx.fillStyle = 'rgba(13,13,18,.82)'; ctx.fillRect(lx - 3, ly - 2, tw + 6, 15); ctx.fillStyle = color; ctx.textBaseline = 'top'; ctx.textAlign = 'left'; ctx.fillText(label, lx, ly); } ctx.restore(); } function path(ctx, pts, color, width, glow, dash) { ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = width || 2; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; if (dash) ctx.setLineDash(dash); if (glow) { ctx.shadowColor = color; ctx.shadowBlur = glow; } ctx.beginPath(); let pen = false; pts.forEach(([x, y]) => { if (!isFinite(x) || !isFinite(y)) { pen = false; return; } if (!pen) { ctx.moveTo(x, y); pen = true; } else ctx.lineTo(x, y); }); ctx.stroke(); ctx.restore(); } function dot(ctx, x, y, r, color, glow) { ctx.save(); ctx.fillStyle = color; if (glow) { ctx.shadowColor = color; ctx.shadowBlur = glow; } ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } function rrect(ctx, 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(); } function tag(ctx, x, y, text, color, align) { ctx.save(); ctx.font = FONT; const w = ctx.measureText(text).width + 12, h = 20; let bx = align === 'left' ? x + 8 : x - w - 8; bx = Math.max(2, Math.min(ctx.canvas.width / (window.devicePixelRatio || 1) - w - 2, bx)); ctx.fillStyle = 'rgba(13,13,18,.9)'; ctx.strokeStyle = color; rrect(ctx, bx, y - h / 2, w, h, 4); ctx.fill(); ctx.stroke(); ctx.fillStyle = COL.white; ctx.textBaseline = 'middle'; ctx.textAlign = 'left'; ctx.fillText(text, bx + 6, y + 0.5); ctx.restore(); } function legend(el, items) { el.innerHTML = items.map(([cls, shape, t]) => `<span><i class="${cls} ${shape || ''}"></i>${esc(t)}</span>`).join(''); } /* ================================================================ 01 design */ function designInputs(d) { d = d || S.d; const ni = d.hyp === 'ni', base = { alpha: ni ? d.alphaNI : d.alpha, sides: ni ? 1 : d.sides, power: d.power / 100, k: d.k, hyp: d.hyp, better: d.better }; if (d.type === 'bin') return Object.assign(base, { pc: d.pc / 100, pt: d.pt / 100, margin: d.margin / 100 }); if (d.type === 'cont') return Object.assign(base, { delta: d.delta, sd: d.sd, margin: d.marginC }); return Object.assign(base, { hr: d.hr, medC: d.medC, accrual: d.accrual, follow: d.follow, dropY: d.dropY / 100, margin: d.marginH }); } let DR = null; // current design result function computeDesign() { const d = S.d, o = designInputs(d), r = PP.design(d.type, o); if (r.error) return { error: r.error, type: d.type, o }; r.type = d.type; r.o = o; r.achieved = PP.power(d.type, o, r.nc); if (d.type === 'tte') r.readout = PP.expectedEventTime(o, r.nc, r.nt, r.events); else r.enrol = Math.ceil(r.n / (1 - d.drop / 100) - 1e-9); return r; } function effectLabel(type, o) { if (type === 'bin') return `${pct(o.pc)} vs ${pct(o.pt)}`; if (type === 'cont') return `difference ${num(o.delta, 2)}, SD ${num(o.sd, 2)}`; return `HR ${fx(o.hr, 2)}, control median ${num(o.medC, 1)} months`; } function scenarios(type, o) { const out = []; if (o.hyp !== 'ni') { [[1, 'As assumed'], [0.9, '10% smaller'], [0.75, '25% smaller'], [0.5, 'Half the effect']].forEach(([f, label]) => { const p = Object.assign({}, o); if (type === 'bin') p.pt = o.pc + (o.pt - o.pc) * f; else if (type === 'cont') p.delta = o.delta * f; else p.hr = Math.pow(o.hr, f); out.push({ label, o: p, show: type === 'bin' ? `${pct(p.pt)} vs ${pct(p.pc)}` : type === 'cont' ? num(p.delta, 2) : `HR ${fx(p.hr, 2)}` }); }); } else { [[0, 'As assumed'], [0.25, 'A quarter of the margin worse'], [0.5, 'Half the margin worse']].forEach(([s, label]) => { const p = Object.assign({}, o), sgn = o.better === 'lower' ? 1 : -1; if (type === 'bin') p.pt = o.pt + sgn * o.margin * s; else if (type === 'cont') p.delta = o.delta + sgn * o.margin * s; else p.hr = o.hr * Math.pow(o.margin / o.hr, s); out.push({ label, o: p, show: type === 'bin' ? `${pct(p.pt)} vs ${pct(p.pc)}` : type === 'cont' ? num(p.delta, 2) : `HR ${fx(p.hr, 2)}` }); }); } return out; } function protocolText(r) { const d = S.d, o = r.o, alpha = num(o.alpha, 3), sides = o.sides === 2 ? 'two-sided' : 'one-sided'; const arms = `${int(r.n)} patients (${int(r.nt)} treatment, ${int(r.nc)} control; ${ratioTxt(o.k)} allocation)`; const dropTxt = d.drop > 0 ? ` Allowing for ${num(d.drop, 1)}% dropout, ${int(r.enrol)} patients will be enrolled.` : ''; if (r.type === 'bin') { if (o.hyp === 'ni') return `A total of ${arms} gives ${d.power}% power to show that treatment is non-inferior to control, with a non-inferiority margin of ${num(o.margin * 100, 1)} percentage points, assuming ${Math.abs(o.pt - o.pc) < 1e-9 ? `a true rate of ${pct(o.pc)} in both arms` : `true rates of ${pct(o.pc)} with control and ${pct(o.pt)} with treatment`}, using a one-sided test at α = ${alpha} (normal approximation, Blackwelder 1982).${dropTxt}`; return `A total of ${arms} gives ${d.power}% power to detect a difference in the proportion of patients with the outcome from ${pct(o.pc)} with control to ${pct(o.pt)} with treatment, using a ${sides} test at α = ${alpha} (normal approximation with pooled variance, Fleiss 1981).${dropTxt}`; } if (r.type === 'cont') { if (o.hyp === 'ni') return `A total of ${arms} gives ${d.power}% power to show non-inferiority with a margin of ${num(o.margin, 2)}, assuming a true mean difference of ${num(o.delta, 2)} and a common standard deviation of ${num(o.sd, 2)}, using a one-sided t-test at α = ${alpha}.${dropTxt}`; return `A total of ${arms} gives ${d.power}% power to detect a mean difference of ${num(o.delta, 2)} between treatment and control, assuming a common standard deviation of ${num(o.sd, 2)}, using a ${sides} t-test at α = ${alpha}.${dropTxt}`; } const surv = `Assuming exponential survival with a control median of ${num(o.medC, 1)} months, uniform accrual over ${num(o.accrual, 1)} months, ${num(o.follow, 1)} months of further follow-up and ${num(d.dropY, 1)}% dropout per year, ${arms} are needed. The primary analysis is expected about ${isFinite(r.readout) ? Math.round(r.readout) : '?'} months after the first patient is enrolled.`; if (o.hyp === 'ni') return `A total of ${int(r.events)} events gives ${d.power}% power to show non-inferiority with a hazard ratio margin of ${fx(o.margin, 2)}, assuming a true hazard ratio of ${fx(o.hr, 2)}, using a one-sided test at α = ${alpha}. ${surv}`; return `A total of ${int(r.events)} events gives ${d.power}% power to detect a hazard ratio of ${fx(o.hr, 2)} using a ${sides} log-rank test at α = ${alpha} (Schoenfeld 1981). ${surv}`; } function renderDesign() { const r = DR = computeDesign(), out = $('#d-result'); $('#to-sim').disabled = !!r.error; if (r.error) { out.innerHTML = `<p class="err">${esc(r.error)}</p>`; $('#d-text').textContent = ''; $('#d-shrink').innerHTML = ''; prep($('#c-power')); return; } const o = r.o, chips = [`<span class="c">Control <b>${int(r.nc)}</b></span>`, `<span class="t">Treatment <b>${int(r.nt)}</b></span>`]; if (r.type === 'tte') { chips.push(`<span>Events <b>${int(r.events)}</b></span>`, ...(r.freedman ? [`<span>Freedman formula: <b>${int(r.freedman)}</b> events</span>`] : []), `<span>Readout <b>≈ month ${isFinite(r.readout) ? Math.round(r.readout) : '?'}</b></span>`); } else chips.push(`<span>Enrol <b>${int(r.enrol)}</b> with ${num(S.d.drop, 1)}% dropout</span>`); chips.push(`<span>Power at this n <b>${pct(r.achieved)}</b></span>`); out.innerHTML = `<div class="hero-n"><b>${int(r.n)}</b><span>${r.type === 'tte' ? `patients · ${int(r.events)} events` : 'patients analysed'}</span></div><div class="kv">${chips.join('')}</div>`; $('#d-text').textContent = protocolText(r); // shrink table const rows = scenarios(r.type, o).map((sc, i) => { const need = PP.design(r.type, Object.assign({}, sc.o, { power: o.power })); const pw = PP.power(r.type, sc.o, r.nc); return `<tr${i === 0 ? ' class="here"' : ''}><td>${esc(sc.label)}</td><td class="num">${esc(sc.show)}</td><td class="num">${isFinite(pw) ? pct(pw) : '—'}</td><td class="num">${need.error ? '—' : int(need.n)}</td></tr>`; }).join(''); $('#d-shrink').innerHTML = `<table class="tbl"><thead><tr><th>True effect</th><th class="num">Value</th><th class="num">Power with ${int(r.n)}</th><th class="num">Patients needed</th></tr></thead><tbody>${rows}</tbody></table>`; drawPower(r); } function drawPower(r) { const cv = $('#c-power'), { ctx, w, h } = prep(cv), B = box(w, h, { l: 46, b: 36 }); if (!r || r.error) return; const o = r.o, k = o.k, maxN = Math.max(20, r.n * 2.2), xs = lin(0, maxN, B.l, B.r), ys = lin(0, 1, B.b, B.t); axes(ctx, B, xs, ys, nice(0, maxN, w < 420 ? 4 : 6), [0, 0.2, 0.4, 0.6, 0.8, 1], v => int(v), v => (v * 100) + '%', { xl: 'total patients' }); const pts = (oo) => { const a = []; for (let i = 0; i <= 120; i++) { const n = Math.max(4, maxN * i / 120), nc = n / (1 + k); a.push([xs(n), ys(Math.max(0, Math.min(1, PP.power(r.type, oo, Math.max(2, nc)))))]); } return a; }; const smaller = scenarios(r.type, o)[o.hyp === 'ni' ? 1 : 2].o; path(ctx, pts(smaller), COL.cyan, 1.6, 0, [5, 4]); path(ctx, pts(o), COL.mag, 2.6, 12); ctx.save(); ctx.setLineDash([2, 4]); ctx.strokeStyle = 'rgba(244,255,90,.6)'; ctx.beginPath(); ctx.moveTo(B.l, ys(o.power)); ctx.lineTo(B.r, ys(o.power)); ctx.stroke(); ctx.restore(); const px = xs(r.n), py = ys(r.achieved); ctx.save(); ctx.strokeStyle = 'rgba(255,255,255,.25)'; ctx.setLineDash([3, 3]); ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(px, B.b); ctx.stroke(); ctx.restore(); dot(ctx, px, py, 5, COL.white, 14); tag(ctx, px, py - 16, `${int(r.n)} patients · ${pct(r.achieved)}`, COL.mag, px > (B.l + B.r) / 2 ? 'right' : 'left'); legend($('#d-legend'), [['sw-m', '', 'Effect as assumed'], ['sw-c', 'dash', o.hyp === 'ni' ? 'A quarter of the margin worse' : 'Effect 25% smaller'], ['sw-y', 'dash', `Target ${pct(o.power)}`]]); } /* ================================================================ 02 simulate */ let RUN = null, worker = null; const nsFor = () => S.s.n; function priorDefaults(r) { const o = r.o; if (r.type === 'bin') { const d = Math.abs(o.pt - o.pc) || 0.05; return [Math.max(0.5, (o.pt - d * 0.6) * 100), Math.min(99.5, (o.pt + d * 0.6) * 100)].map(v => +v.toFixed(1)); } if (r.type === 'cont') { const d = Math.abs(o.delta) || o.sd * 0.3; return [o.delta - d * 0.6, o.delta + d * 0.6].map(v => +v.toFixed(2)); } const l = Math.log(o.hr) || -0.1; return [Math.exp(l * 1.6), Math.exp(l * 0.4)].map(v => +v.toFixed(2)).sort((a, b) => a - b); } function priorUnit(type) { return type === 'bin' ? 'treatment rate, %' : type === 'cont' ? 'mean difference' : 'hazard ratio'; } function buildSimForm() { const f = $('#f-sim'); f.insertAdjacentHTML('beforeend', ` <div class="fgroup"><h3>Run</h3> <div class="field" data-k="n"><span class="lbl" id="s-n-l">Number of virtual trials</span><div class="seg" role="group" aria-labelledby="s-n-l"> <button type="button" data-v="1000">1,000</button><button type="button" data-v="5000">5,000</button><button type="button" data-v="10000">10,000</button></div></div> <div class="field" data-k="analysis" id="s-analysis"><span class="lbl" id="s-a-l">Analyse when</span><div class="seg" role="group" aria-labelledby="s-a-l"> <button type="button" data-v="events">Target events reached</button><button type="button" data-v="time">Follow-up ends</button></div></div> <div class="field"><div class="lab"><label for="s-seed">Random seed</label><span class="hint">same seed, same result</span></div><div class="row solo"><input class="input" type="number" id="s-seed" min="1" max="999999999" step="1"></div></div> </div> <div class="fgroup"><h3>Uncertain effect</h3> <label class="toggle"><input type="checkbox" id="s-prior"> Draw the true effect from a plausible range</label> <p class="small" id="s-prior-help">Power assumes you know the true effect. Assurance asks: given what we believe, how likely is success?</p> <div class="field" id="s-prior-f" hidden><div class="lab"><span class="lbl">95% plausible range</span><span class="hint" id="s-prior-u"></span></div> <div class="row solo two-in"><input class="input" type="number" id="s-plo" step="0.01" aria-label="Lower end of plausible range"><input class="input" type="number" id="s-phi" step="0.01" aria-label="Upper end of plausible range"></div></div> </div> <div class="run-row"><button class="btn btn-mag btn-run" type="button" id="s-run">Run 1,000 trials</button><button class="btn" type="button" id="s-skip" disabled>Skip</button></div>`); $$('.field[data-k] .seg', f).forEach(seg => seg.addEventListener('click', e => { const b = e.target.closest('button[data-v]'); if (!b) return; const k = seg.closest('.field').dataset.k; S.s[k] = k === 'n' ? +b.dataset.v : b.dataset.v; save(); syncSim(); markStale(); })); $('#s-seed').addEventListener('input', e => { const v = parseInt(e.target.value, 10); if (v >= 1) { S.s.seed = v; save(); markStale(); } }); $('#s-prior').addEventListener('change', e => { S.s.prior = e.target.checked; save(); syncSim(); markStale(); }); ['plo', 'phi'].forEach(k => $('#s-' + k).addEventListener('input', e => { const v = parseFloat(e.target.value); if (isFinite(v)) { S.s[k] = v; S.s.ptype = S.d.type; save(); markStale(); } })); $('#s-run').addEventListener('click', () => runSim()); $('#s-skip').addEventListener('click', () => { if (worker) worker.postMessage({ type: 'fast' }); }); } function syncSim() { const r = DR || computeDesign(); $$('#f-sim .field[data-k] button[data-v]').forEach(b => { const k = b.closest('.field').dataset.k; b.setAttribute('aria-pressed', String(String(S.s[k]) === b.dataset.v)); }); $('#s-analysis').hidden = S.d.type !== 'tte'; $('#s-seed').value = S.s.seed; $('#s-prior').checked = !!S.s.prior; $('#s-prior-f').hidden = !S.s.prior; if (!r.error) { if (S.s.ptype !== S.d.type || S.s.plo == null || S.s.phi == null) { const [a, b] = priorDefaults(r); S.s.plo = a; S.s.phi = b; S.s.ptype = S.d.type; } $('#s-plo').value = S.s.plo; $('#s-phi').value = S.s.phi; $('#s-prior-u').textContent = priorUnit(r.type); } $('#s-run').textContent = RUN && RUN.running ? 'Stop' : `Run ${int(nsFor())} trials`; const sum = $('#s-design'); if (r.error) { sum.innerHTML = `<p class="err">${esc(r.error)}</p><button class="linkbtn" type="button" data-go="design">Fix the design</button>`; $('#s-run').disabled = true; return; } $('#s-run').disabled = false; const hyp = r.o.hyp === 'ni' ? 'non-inferiority' : 'superiority', type = { bin: 'Binary', cont: 'Continuous', tte: 'Time to event' }[r.type]; sum.innerHTML = `<span>From step 1: <b>${type}, ${hyp}</b></span><span>${esc(effectLabel(r.type, r.o))}</span> <span><b>${int(r.n)}</b> patients${r.type === 'tte' ? `, <b>${int(r.events)}</b> events` : ''} · planned power <b>${pct(r.achieved)}</b></span> <button class="linkbtn" type="button" data-go="design">Change the design</button>`; } function markStale() { if (RUN && !RUN.running) { RUN.stale = true; $('.stage').classList.add('stale'); $('#s-insight').innerHTML = '<p>Settings changed since this run. <b>Run again</b> to update.</p>'; } syncSim(); } function simConfig(r) { const o = r.o, cfg = { type: r.type, nc: r.nc, nt: r.nt, alpha: o.alpha, sides: o.sides, hyp: o.hyp, margin: o.margin, better: o.better }; let truth, se, bound = null; const k = o.k; if (r.type === 'bin') { Object.assign(cfg, { pc: o.pc, pt: o.pt, effectSign: Math.sign(o.pt - o.pc) || 1 }); truth = o.pt - o.pc; se = Math.sqrt(o.pc * (1 - o.pc) / r.nc + o.pt * (1 - o.pt) / r.nt); if (o.hyp === 'ni') bound = o.better === 'lower' ? o.margin : -o.margin; } else if (r.type === 'cont') { Object.assign(cfg, { delta: o.delta, sd: o.sd, effectSign: Math.sign(o.delta) || 1 }); truth = o.delta; se = o.sd * Math.sqrt(1 / r.nc + 1 / r.nt); if (o.hyp === 'ni') bound = o.better === 'lower' ? o.margin : -o.margin; } else { Object.assign(cfg, { hr: o.hr, lamC: PP.monthlyHazard(o.medC), eta: PP.dropHazard(o.dropY), accrual: o.accrual, follow: o.follow, events: r.events, analysis: S.s.analysis, effectSign: o.hr < 1 ? 1 : -1 }); truth = Math.log(o.hr); se = (1 + k) / Math.sqrt(k * r.events); if (o.hyp === 'ni') bound = Math.log(o.margin); } let psd = 0; if (S.s.prior && isFinite(S.s.plo) && isFinite(S.s.phi) && S.s.phi > S.s.plo) { let lo = S.s.plo, hi = S.s.phi; if (r.type === 'bin') { lo /= 100; hi /= 100; } if (r.type === 'tte') { lo = Math.log(Math.max(0.01, lo)); hi = Math.log(Math.max(0.02, hi)); } const mean = (lo + hi) / 2; psd = (hi - lo) / (2 * PP.normInv(0.975)); cfg.prior = { on: true, mean, sd: psd }; truth = r.type === 'bin' ? mean - o.pc : mean; } const sdT = Math.sqrt(se * se + psd * psd); let lo = truth - 4.2 * sdT, hi = truth + 4.2 * sdT; [0, bound].forEach(v => { if (v !== null && isFinite(v)) { lo = Math.min(lo, v - 0.6 * sdT); hi = Math.max(hi, v + 0.6 * sdT); } }); return { cfg, bins: { lo, hi, n: 56 }, truth, bound }; } function runSim() { if (RUN && RUN.running) { stopSim(); return; } const r = DR || computeDesign(); if (r.error) return; const { cfg, bins, truth, bound } = simConfig(r), n = nsFor(); let kmGrid = null, tmax = 0; if (r.type === 'tte') { tmax = Math.max(r.o.accrual + r.o.follow, isFinite(r.readout) ? r.readout : 0) * 1.08; kmGrid = Array.from({ length: 73 }, (_, i) => tmax * i / 72); } if (worker) worker.terminate(); worker = new Worker('sim-worker.js'); RUN = { r, cfg, bins, truth, bound, n, tmax, kmGrid, km: [], trace: [[0, 0]], done: 0, ok: 0, sig: new Float64Array(bins.n), non: new Float64Array(bins.n), running: true, t0: performance.now(), stale: false }; $('.stage').classList.remove('stale'); worker.onmessage = e => { const m = e.data; if (!RUN) return; RUN.done = m.done; RUN.ok = m.ok; RUN.sig = m.sig; RUN.non = m.non; RUN.sumSig = m.sumSig; RUN.sumAll = m.sumAll; RUN.sumTrue = m.sumTrue; m.km.forEach(c => RUN.km.push(Object.assign(c, { born: performance.now() }))); RUN.trace.push(...m.trace); if (m.type === 'done') { RUN.running = false; RUN.dur = m.dur; RUN.events = m.events; worker.terminate(); worker = null; $('#s-skip').disabled = true; syncSim(); renderSimText(); } queueDraw(); }; worker.onerror = () => { stopSim(); toast('The simulation stopped unexpectedly. Try fewer trials.'); }; worker.postMessage({ type: 'run', cfg, seed: S.s.seed >>> 0, nsims: n, bins, kmCount: r.type === 'tte' ? 70 : 0, kmGrid: kmGrid || [], minMs: n <= 1000 ? 2600 : n <= 5000 ? 3600 : 4400, fast: REDUCED }); $('#s-skip').disabled = false; syncSim(); renderSimText(); queueDraw(); } function stopSim() { if (worker) { worker.terminate(); worker = null; } if (RUN) { RUN.running = false; RUN.stopped = true; } $('#s-skip').disabled = true; syncSim(); renderSimText(); queueDraw(); } let rafQ = false; function queueDraw() { if (rafQ) return; rafQ = true; requestAnimationFrame(() => { rafQ = false; drawSim(); if (RUN && RUN.km.some(c => performance.now() - c.born < 400)) queueDraw(); }); } const estShow = (type, v) => type === 'tte' ? `HR ${fx(Math.exp(v), 2)}` : type === 'bin' ? `${v > 0 ? '+' : ''}${num(v * 100, 1)} pts` : `${v > 0 ? '+' : ''}${num(v, 2)}`; function drawSim() { const R = RUN, type = R ? R.r.type : (DR && DR.type) || 'tte'; $('#s-hist-card').hidden = type !== 'tte'; $('#s-main-title').textContent = type === 'tte' ? 'Kaplan–Meier curves of the first 70 trials' : 'Observed difference in each trial'; $('#s-counter').textContent = R ? `${int(R.done)} / ${int(R.n)} trials` : '0 trials'; if (!R) { prep($('#c-main')); prep($('#c-conv')); if (type === 'tte') prep($('#c-hist')); return; } if (type === 'tte') { drawKM(R); drawHist($('#c-hist'), R, true); } else drawHist($('#c-main'), R, false); drawConv(R); renderSimTiles(R); } function drawKM(R) { const { ctx, w, h } = prep($('#c-main')), B = box(w, h, { l: 44, b: 34 }), o = R.r.o; const xs = lin(0, R.tmax, B.l, B.r), ys = lin(0, 1, B.b, B.t); axes(ctx, B, xs, ys, nice(0, R.tmax, w < 420 ? 4 : 7), [0, 0.25, 0.5, 0.75, 1], v => num(v, 0), v => (v * 100) + '%', { xl: 'months from first patient', yl: 'event-free' }); const g = R.kmGrid, now = performance.now(); R.km.forEach(c => { const age = Math.min(1, (now - c.born) / 400); ctx.globalAlpha = 0.14 + (1 - age) * 0.6; path(ctx, g.map((t, i) => [xs(t), ys(c.c[i])]), COL.cyan, 1.1); path(ctx, g.map((t, i) => [xs(t), ys(c.t[i])]), COL.mag, 1.1); }); ctx.globalAlpha = 1; const lamC = R.cfg.lamC, hr = R.cfg.prior ? Math.exp(R.cfg.prior.mean) : o.hr; path(ctx, g.map(t => [xs(t), ys(Math.exp(-lamC * t))]), COL.white, 1.6, 0, [6, 5]); path(ctx, g.map(t => [xs(t), ys(Math.exp(-lamC * hr * t))]), COL.white, 1.6, 0, [2, 4]); if (R.dur) { vline(ctx, B, xs(R.dur.p50), COL.warn, `readout ≈ month ${Math.round(R.dur.p50)}`, [4, 4], 4); } legend($('#s-legend'), [['sw-c', '', 'Control, simulated'], ['sw-m', '', 'Treatment, simulated'], ['sw-w', 'dash', 'True control curve'], ['sw-w', 'dash dots', 'True treatment curve']].concat(R.dur ? [['sw-y', 'dash', 'Median analysis time']] : [])); } function drawHist(cv, R, isHR) { const { ctx, w, h } = prep(cv), B = box(w, h, { l: 44, b: 34 }), bins = R.bins, n = bins.n, width = (bins.hi - bins.lo) / n; let max = 1; for (let i = 0; i < n; i++) max = Math.max(max, R.sig[i] + R.non[i]); const xs = lin(bins.lo, bins.hi, B.l, B.r), ys = lin(0, max * 1.12, B.b, B.t), type = R.r.type; const xt = isHR ? logTicks(Math.exp(bins.lo), Math.exp(bins.hi), [0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.5, 1.75, 2, 2.5]).map(Math.log) : nice(bins.lo, bins.hi, w < 420 ? 4 : 6); const xf = isHR ? v => num(Math.exp(v), 2) : type === 'bin' ? v => num(v * 100, 1) : v => num(v, 2); axes(ctx, B, xs, ys, xt, nice(0, max * 1.1, 3).filter(v => v === Math.round(v)), xf, v => int(v), { xl: isHR ? 'observed hazard ratio' : type === 'bin' ? 'treatment − control, percentage points' : 'treatment − control' }); const bw = (B.r - B.l) / n; for (let i = 0; i < n; i++) { const x = B.l + i * bw, s = R.sig[i], ns = R.non[i]; if (ns) { ctx.fillStyle = 'rgba(181,123,255,.55)'; ctx.fillRect(x + 0.5, ys(ns), Math.max(1, bw - 1), B.b - ys(ns)); } if (s) { ctx.fillStyle = COL.mag; ctx.fillRect(x + 0.5, ys(ns + s), Math.max(1, bw - 1), ys(ns) - ys(ns + s)); } } const nullX = xs(0), truthX = xs(R.truth); vline(ctx, B, nullX, COL.slate, isHR ? 'no effect' : 'no difference', [3, 3], 4); if (R.bound !== null && isFinite(R.bound)) vline(ctx, B, xs(R.bound), COL.warn, 'margin', [5, 3], 20); vline(ctx, B, truthX, COL.white, R.cfg.prior ? 'mean belief' : 'true effect', null, 36); if (cv.id === 'c-main') legend($('#s-legend'), [['sw-m', 'sq', 'Trial succeeded'], ['sw-p', 'sq', 'Trial missed'], ['sw-w', '', R.cfg.prior ? 'Centre of the plausible range' : 'True effect']].concat(R.bound !== null ? [['sw-y', 'dash', 'Non-inferiority margin']] : [])); } function drawConv(R) { const { ctx, w, h } = prep($('#c-conv')), B = box(w, h, { l: 44, b: 34 }), planned = R.r.achieved, n = R.n; const tr = R.trace.filter(p => p[0] > 0), last = tr.length ? tr[tr.length - 1] : [0, 0]; let lo = Math.max(0, planned - 0.2), hi = Math.min(1, planned + 0.12); tr.forEach(([d, k]) => { if (d >= 30) { const p = k / d; lo = Math.min(lo, p - 0.03); hi = Math.max(hi, p + 0.03); } }); lo = Math.max(0, lo); hi = Math.min(1, hi); const xs = lin(0, n, B.l, B.r), ys = lin(lo, hi, B.b, B.t); axes(ctx, B, xs, ys, nice(0, n, w < 420 ? 3 : 5), nice(lo, hi, 4), v => int(v), v => num(v * 100, 0) + '%', { xl: 'trials run' }); if (!R.cfg.prior) { ctx.save(); ctx.fillStyle = 'rgba(0,240,255,.09)'; ctx.beginPath(); const steps = 80, top = [], bot = []; for (let i = 1; i <= steps; i++) { const d = Math.max(20, n * i / steps), se = Math.sqrt(planned * (1 - planned) / d); top.push([xs(d), ys(Math.min(hi, planned + 2 * se))]); bot.push([xs(d), ys(Math.max(lo, planned - 2 * se))]); } top.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)); bot.reverse().forEach(([x, y]) => ctx.lineTo(x, y)); ctx.closePath(); ctx.fill(); ctx.restore(); ctx.save(); ctx.setLineDash([5, 4]); ctx.strokeStyle = COL.cyan; ctx.beginPath(); ctx.moveTo(B.l, ys(planned)); ctx.lineTo(B.r, ys(planned)); ctx.stroke(); ctx.restore(); } path(ctx, tr.filter(p => p[0] >= 10).map(([d, k]) => [xs(d), ys(Math.max(lo, Math.min(hi, k / d)))]), COL.mag, 2.2, 10); if (last[0]) { const x = xs(last[0]), y = ys(Math.max(lo, Math.min(hi, last[1] / last[0]))); dot(ctx, x, y, 4, COL.white, 10); } ctx.save(); ctx.font = FONT; ctx.fillStyle = COL.cyan; ctx.textAlign = 'right'; ctx.textBaseline = 'bottom'; if (!R.cfg.prior) ctx.fillText(`formula ${pct(planned)}`, B.r, ys(planned) - 4); ctx.restore(); } function renderSimTiles(R) { const r = R.r, p = R.done ? R.ok / R.done : 0, se = R.done ? Math.sqrt(p * (1 - p) / R.done) : 0, type = r.type; const nSig = R.ok, meanSig = nSig ? R.sumSig / nSig : NaN, meanTrue = R.done ? R.sumTrue / R.done : NaN; const method = type === 'bin' ? (r.o.hyp === 'ni' ? 'Blackwelder' : 'Fleiss') : type === 'cont' ? 't-test' : 'Schoenfeld'; const tiles = [ [pct(p), R.cfg.prior ? 'Probability of success (assurance)' : 'Simulated power', `± ${pct(se)} Monte Carlo error`, 'is-m'], [pct(r.achieved), 'Formula power', method + (R.cfg.prior ? ', effect as assumed' : ''), 'is-c'], [nSig ? estShow(type, meanSig) : '—', 'Average effect in trials that succeeded', `true ${estShow(type, R.truth)}`, ''], ]; if (type === 'tte') tiles.push([R.dur ? `Month ${Math.round(R.dur.p50)}` : '…', 'Readout (median)', R.dur ? `90% of trials: ${Math.round(R.dur.p05)} to ${Math.round(R.dur.p95)}` : 'when the run ends', '']); else tiles.push([int(R.done), 'Trials simulated', `seed ${S.s.seed}`, '']); $('#s-tiles').innerHTML = tiles.map(([b, l, s, c]) => `<div class="stat ${c}"><b>${esc(b)}</b><span>${esc(l)}</span><span class="sub">${esc(s)}</span></div>`).join(''); } function renderSimText() { const R = RUN, el = $('#s-insight'); if (!R) { el.innerHTML = '<p>Press <b>Run</b> to play the design forward. Each trial is drawn at random from the assumptions in step 1, then analysed exactly as planned.</p>'; return; } if (R.running) { el.innerHTML = `<p>Running ${int(R.n)} virtual trials with seed ${S.s.seed}…</p>`; return; } const r = R.r, type = r.type, p = R.ok / R.done, se = Math.sqrt(p * (1 - p) / R.done), diff = p - r.achieved, out = []; const what = R.cfg.prior ? 'probability of success' : 'power'; out.push(`Across <b>${int(R.done)}</b> simulated trials, <b>${pct(p)}</b> succeeded (Monte Carlo error ± ${pct(se)}). The formula promised ${pct(r.achieved)}${R.cfg.prior ? ' if the effect were exactly as assumed' : ''}.`); if (!R.cfg.prior) { if (Math.abs(diff) <= 2.5 * se + 0.005) out.push('Simulation and formula agree: the design does what the sample size calculation says, under its own assumptions.'); else if (diff < 0) out.push(`The simulation lands <b>${num(-diff * 100, 1)} points below</b> the formula. ${type === 'tte' ? 'The Schoenfeld formula is slightly optimistic for strong effects; consider a few more events.' : 'The normal approximation is optimistic here; consider a slightly larger sample.'}`); else out.push(`The simulation lands ${num(diff * 100, 1)} points above the formula, which is conservative here.`); } else { out.push(`With the true effect anywhere in your plausible range, the ${what} is <b>${pct(p)}</b> against ${pct(r.achieved)} for the single assumed value. This is the number to plan a portfolio or a launch around.`); } if (R.ok >= 20 && r.o.hyp !== 'ni') { const ms = R.sumSig / R.ok, tr = R.cfg.prior ? R.sumTrue / R.done : R.truth; const bigger = Math.abs(ms) > Math.abs(tr) * 1.03; if (bigger) out.push(`Winner's curse: the trials that succeeded overestimated the effect (average ${estShow(type, ms)} against a true ${estShow(type, tr)}). The first published estimate of a real effect tends to look better than the truth, and later trials often regress.`); } if (type === 'tte' && R.dur) out.push(`Timing: the analysis triggers at a median of <b>month ${Math.round(R.dur.p50)}</b> from the first patient (90% of trials between months ${Math.round(R.dur.p05)} and ${Math.round(R.dur.p95)}), with ${int(R.events.p50)} events at analysis.`); if (R.stopped) out.push('Stopped early: figures cover the trials completed so far.'); el.innerHTML = out.map(t => `<p>${t}</p>`).join(''); } /* ================================================================ 03 interpret */ function computeInt() { const i = S.i; if (i.mode === 'ratio') { if (!(i.lo > 0 && i.lo < i.est && i.est < i.hi)) return { error: 'The estimate must sit inside its confidence interval, and all three must be above zero.' }; const pc = i.pc / 100, r = PP.fromRatio(i.kind, i.est, i.lo, i.hi, pc); r.pv = PP.pFromRatioCI(i.est, i.lo, i.hi).p; r.mode = 'ratio'; r.kind = i.kind; r.est = i.est; r.lo = i.lo; r.hi = i.hi; return r; } if (!(i.nt >= 1 && i.nc >= 1 && i.et >= 0 && i.ec >= 0 && i.et <= i.nt && i.ec <= i.nc) || [i.et, i.nt, i.ec, i.nc].some(v => v !== Math.round(v))) return { error: 'Counts must be whole numbers, with events no larger than the number of patients.' }; if (i.et + i.ec === 0) return { error: 'There are no events in either arm.' }; const r = PP.fromCounts(i.et, i.nt, i.ec, i.nc); r.mode = 'counts'; r.kind = 'RR'; r.est = r.rr; r.lo = r.rrLo; r.hi = r.rrHi; return r; } function renderInt() { const i = S.i, r = computeInt(), tiles = $('#i-tiles'); $('#i-nnt-card').hidden = false; if (r.error) { tiles.innerHTML = `<div class="stat is-bad"><b>!</b><span>${esc(r.error)}</span></div>`; $('#i-text').textContent = ''; $('#i-notes').innerHTML = ''; prep($('#c-icons')); prep($('#c-nnt')); return; } const nnt = PP.nntText(r.arr, r.arrLo, r.arrHi), hz = i.horizon || 'the time point', oc = i.outcome || 'the outcome'; const t = [ [num(Math.abs(r.arr) * 100, 1), r.arr >= 0 ? 'Fewer patients with the event, per 100' : 'More patients with the event, per 100', `risk reduction 95% CI ${num(r.arrLo * 100, 1)} to ${num(r.arrHi * 100, 1)} points`, r.arr >= 0 ? 'is-c' : 'is-m'], [nnt.main, 'Number needed to treat', `95% CI ${nnt.ci}`, 'is-m'], [pct(Math.abs(r.pc - r.pt) / r.pc, 0), r.pt <= r.pc ? 'Relative risk reduction' : 'Relative risk increase', `${pct(r.pc)} → ${pct(r.pt)} at ${hz}`, ''], ]; if (r.mode === 'ratio') t.push([r.pv < 0.001 ? '< 0.001' : num(r.pv, 3), 'p value from the CI', `${i.kind} ${num(r.est, 2)} (${num(r.lo, 2)} to ${num(r.hi, 2)})`, '']); else { t.push([r.p < 0.001 ? '< 0.001' : num(r.p, 3), "Fisher's exact p", `RR ${num(r.rr, 2)} (${num(r.rrLo, 2)} to ${num(r.rrHi, 2)})`, '']); t.push([r.frag.fi == null ? 'n/a' : String(r.frag.fi), 'Fragility index', r.frag.fi == null ? 'result not significant at 0.05' : `${pct(r.frag.fq, 1)} of patients`, r.frag.fi != null && r.frag.fi <= 3 ? 'is-warn' : '']); } tiles.innerHTML = t.map(([b, l, s, c]) => `<div class="stat ${c}"><b>${esc(b)}</b><span>${esc(l)}</span><span class="sub">${esc(s)}</span></div>`).join(''); // sentence const per100c = Math.round(r.pc * 100), per100t = Math.round(r.pt * 100); let s = `Out of 100 patients like those in the trial, about ${per100c} on control had ${oc} by ${hz}, against about ${per100t} on treatment. `; if (r.arr > 0) s += `That is ${num(r.arr * 100, 1)} fewer per 100: treating about ${PP.fmtN(1 / r.arr)} patients for ${hz} prevents one ${oc.replace(/^(a|an) /, '')} (95% CI ${nnt.ci}).`; else if (r.arr < 0) s += `That is ${num(-r.arr * 100, 1)} more per 100: about one additional ${oc.replace(/^(a|an) /, '')} for every ${PP.fmtN(-1 / r.arr)} patients treated (95% CI ${nnt.ci}).`; else s += 'There is no difference in absolute terms.'; $('#i-text').textContent = s; const notes = []; if (nnt.crosses) notes.push('The confidence interval includes no effect, so the NNT interval runs through infinity: the data are compatible with benefit and with harm.'); if (r.mode === 'ratio' && i.kind === 'HR') notes.push(`A hazard ratio becomes an absolute risk only with a baseline: here ${pct(r.pc)} of control patients by ${hz}. The conversion assumes proportional hazards.`); if (r.mode === 'ratio' && i.kind === 'OR' && r.pc > 0.1) notes.push('With a common outcome, an odds ratio exaggerates the relative risk. The absolute numbers above use the control risk, so they are not affected.'); const lowR = r.pc / 2, highR = Math.min(0.95, r.pc * 2); const at = pc => { const a = pc - PP.riskFromRatio(r.kind, r.est, pc); return a > 0 ? PP.fmtN(1 / a) : '∞'; }; notes.push(`Patients at half the trial's baseline risk (${pct(lowR)}) would have an NNT of about ${at(lowR)}; at double the risk (${pct(highR)}), about ${at(highR)}.`); if (r.mode === 'counts' && r.frag.fi != null) notes.push(`Fragility: the result stops being significant (Fisher's exact test) if ${r.frag.fi} patient${r.frag.fi === 1 ? '' : 's'} in the ${r.frag.arm} arm had had the event instead. ${r.frag.fi <= 3 ? 'That is fewer than many trials lose to follow-up.' : ''}`); if (r.mode === 'counts' && r.zeroCorrected) notes.push('A zero cell was found, so 0.5 was added to every cell for the ratio estimates (Haldane correction).'); $('#i-notes').innerHTML = notes.map(n => `<li>${esc(n)}</li>`).join(''); drawIcons100(r); drawNNT(r); } function person(ctx, x, y, s, color, outline) { ctx.save(); if (outline) { ctx.strokeStyle = color; ctx.lineWidth = 1; } else ctx.fillStyle = color; ctx.beginPath(); ctx.arc(x, y - s * 0.24, s * 0.15, 0, Math.PI * 2); outline ? ctx.stroke() : ctx.fill(); rrect(ctx, x - s * 0.24, y - s * 0.04, s * 0.48, s * 0.38, s * 0.16); outline ? ctx.stroke() : ctx.fill(); ctx.restore(); } function drawIcons100(r) { const cv = $('#c-icons'), { ctx, w, h } = prep(cv); const nC = Math.round(r.pc * 100), nT = Math.round(r.pt * 100), prevented = Math.max(0, nC - nT), extra = Math.max(0, nT - nC), still = Math.min(nC, nT); const cols = 10, rows = 10, cell = Math.min((w - 8) / cols, (h - 8) / rows), ox = (w - cell * cols) / 2, oy = (h - cell * rows) / 2; for (let k = 0; k < 100; k++) { const cx = ox + (k % cols + 0.5) * cell, cy = oy + (Math.floor(k / cols) + 0.5) * cell + cell * 0.05; if (k < still) person(ctx, cx, cy, cell, COL.mag); else if (k < still + prevented) { ctx.save(); ctx.shadowColor = COL.ok; ctx.shadowBlur = 8; person(ctx, cx, cy, cell, COL.ok); ctx.restore(); } else if (k < still + prevented + extra) person(ctx, cx, cy, cell, COL.warn); else person(ctx, cx, cy, cell, 'rgba(203,213,225,.35)', true); } const it = [['sw-m', 'dot', `${still} have the event even with treatment`]]; if (prevented) it.push(['sw-g', 'dot', `${prevented} avoid it thanks to treatment`]); if (extra) it.push(['sw-y', 'dot', `${extra} more have it with treatment`]); it.push(['sw-s', 'dot', `${100 - still - prevented - extra} would not have it either way`]); legend($('#i-legend'), it); $('#i-icons-title').textContent = `100 patients like those in the trial, at ${S.i.horizon || 'the time point'}`; } function drawNNT(r) { const cv = $('#c-nnt'), { ctx, w, h } = prep(cv), B = box(w, h, { l: 46, b: 34 }); const x0 = 0.005, x1 = 0.6, y0 = 1, y1 = 1000, xs = logS(x0, x1, B.l, B.r), ys = logS(y0, y1, B.b, B.t); axes(ctx, B, xs, ys, logTicks(x0, x1, [0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.4, 0.6]), [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000], v => pct(v, 1), v => int(v), { xl: 'baseline (control) risk', yl: 'NNT' }); const nntAt = (ratio, pc) => { const a = pc - PP.riskFromRatio(r.kind, ratio, pc); return a > 0 ? 1 / a : Infinity; }; const xsP = Array.from({ length: 90 }, (_, k) => Math.exp(Math.log(x0) + k / 89 * (Math.log(x1) - Math.log(x0)))); const clampY = v => ys(Math.max(y0, Math.min(y1, v))); // CI band: the bound with the bigger benefit gives the lower NNT const bLo = r.lo < 1 ? r.lo : null, bHi = r.hi < 1 ? r.hi : null; ctx.save(); ctx.fillStyle = 'rgba(255,42,133,.13)'; ctx.beginPath(); xsP.forEach((p, k) => { const v = bLo ? nntAt(bLo, p) : y0; k ? ctx.lineTo(xs(p), clampY(v)) : ctx.moveTo(xs(p), clampY(v)); }); xsP.slice().reverse().forEach(p => { const v = bHi ? nntAt(bHi, p) : Infinity; ctx.lineTo(xs(p), clampY(v)); }); ctx.closePath(); ctx.fill(); ctx.restore(); if (r.est < 1) path(ctx, xsP.map(p => [xs(p), clampY(nntAt(r.est, p))]), COL.mag, 2.4, 10); const pc = r.pc, v = nntAt(r.est, pc); if (isFinite(v) && pc >= x0 && pc <= x1) { dot(ctx, xs(pc), clampY(v), 5, COL.white, 12); tag(ctx, xs(pc), clampY(v) - 16, `${pct(pc)} risk → NNT ${PP.fmtN(v)}`, COL.mag, xs(pc) > (B.l + B.r) / 2 ? 'right' : 'left'); } if (r.est >= 1) { ctx.save(); ctx.font = FONT; ctx.fillStyle = COL.silver; ctx.textAlign = 'center'; ctx.fillText('No benefit at the point estimate: NNT is not defined.', (B.l + B.r) / 2, (B.t + B.b) / 2); ctx.restore(); } } /* ================================================================ 04 diagnose */ function renderDx() { const x = S.x, r = PP.diag(x.sens / 100, x.spec / 100, x.prev / 100, 1000); const lr = v => !isFinite(v) ? '∞' : num(v, v < 1 ? 2 : 1); $('#x-tiles').innerHTML = [ [pct(r.ppv), 'Positive predictive value', 'of positive results are right', 'is-m'], [pct(r.npv, 2), 'Negative predictive value', 'of negative results are right', 'is-c'], [lr(r.lrPos), 'Positive likelihood ratio', '10 or more: strong evidence for', ''], [lr(r.lrNeg), 'Negative likelihood ratio', '0.1 or less: strong evidence against', ''], ].map(([b, l, s, c]) => `<div class="stat ${c}"><b>${esc(b)}</b><span>${esc(l)}</span><span class="sub">${esc(s)}</span></div>`).join(''); const sick = r.TP + r.FN, pos = r.TP + r.FP; $('#x-text').textContent = `Of 1,000 people tested, ${sick} have the condition. The test finds ${r.TP} of them and misses ${r.FN}. It also flags ${r.FP} people who do not have it. So of the ${pos} positive results, ${r.TP} (${pct(r.ppv, 0)}) are true positives${r.ppv < 0.5 ? ': most positives are false alarms at this prevalence' : ''}.`; drawDx(r); drawPPV(); } function drawDx(r) { const cv = $('#c-dx'), { ctx, w, h } = prep(cv), cols = w >= 560 ? 50 : 25, rows = 1000 / cols; const cell = Math.min((w - 4) / cols, (h - 4) / rows), ox = (w - cell * cols) / 2, oy = (h - cell * rows) / 2, s = cell * 0.72; const seq = [[r.TP, COL.mag, false], [r.FN, COL.mag, true], [r.FP, COL.warn, false], [r.TN, 'rgba(100,116,139,.45)', false]]; let k = 0; seq.forEach(([n, color, outline]) => { for (let j = 0; j < n; j++, k++) { const x = ox + (k % cols) * cell + (cell - s) / 2, y = oy + Math.floor(k / cols) * cell + (cell - s) / 2; ctx.save(); if (outline) { ctx.strokeStyle = color; ctx.lineWidth = 1.2; rrect(ctx, x + 0.6, y + 0.6, s - 1.2, s - 1.2, s * 0.3); ctx.stroke(); } else { ctx.fillStyle = color; if (color === COL.mag) { ctx.shadowColor = color; ctx.shadowBlur = 6; } rrect(ctx, x, y, s, s, s * 0.3); ctx.fill(); } ctx.restore(); } }); legend($('#x-legend'), [['sw-m', 'sq', `${r.TP} true positives`], ['sw-m', 'ring', `${r.FN} missed (false negatives)`], ['sw-y', 'sq', `${r.FP} false alarms`], ['sw-s', 'sq', `${r.TN} correctly negative`]]); } function drawPPV() { const cv = $('#c-ppv'), { ctx, w, h } = prep(cv), B = box(w, h, { l: 46, b: 34 }), x = S.x; const x0 = 0.001, x1 = 0.6, xs = logS(x0, x1, B.l, B.r), ys = lin(0, 1, B.b, B.t); axes(ctx, B, xs, ys, logTicks(x0, x1, [0.001, 0.003, 0.01, 0.03, 0.1, 0.3, 0.6]), [0, 0.25, 0.5, 0.75, 1], v => pct(v, 1), v => (v * 100) + '%', { xl: 'prevalence' }); const ps = Array.from({ length: 100 }, (_, k) => Math.exp(Math.log(x0) + k / 99 * (Math.log(x1) - Math.log(x0)))); path(ctx, ps.map(p => [xs(p), ys(PP.diag(x.sens / 100, x.spec / 100, p).npv)]), COL.cyan, 2, 8); path(ctx, ps.map(p => [xs(p), ys(PP.diag(x.sens / 100, x.spec / 100, p).ppv)]), COL.mag, 2.4, 10); const p = x.prev / 100, v = PP.diag(x.sens / 100, x.spec / 100, p).ppv; vline(ctx, B, xs(p), 'rgba(255,255,255,.35)', null, [3, 3]); dot(ctx, xs(p), ys(v), 5, COL.white, 12); tag(ctx, xs(p), ys(v) - 16, `PPV ${pct(v, 0)} at ${pct(p, 1)}`, COL.mag, xs(p) > (B.l + B.r) / 2 ? 'right' : 'left'); legend($('#x-legend2'), [['sw-m', '', 'Positive predictive value'], ['sw-c', '', 'Negative predictive value']]); } /* ================================================================ methods: self-test */ function selfTest() { const cases = window.PP_REF || [], pick = (v, p) => p ? p.split('.').reduce((o, k) => (o == null ? o : o[k]), v) : v; let pass = 0; const rows = cases.map(c => { let got, ok = false; try { const [fn, ...args] = c.call; got = pick(PP[fn](...JSON.parse(JSON.stringify(args))), c.pick); const lim = c.rel ? c.tol * Math.abs(c.expect) : c.tol; ok = Math.abs(got - c.expect) <= lim + 1e-15; } catch (e) { got = NaN; } if (ok) pass++; const show = v => Number.isInteger(v) ? String(v) : Math.abs(v) < 1e-3 ? Number(v).toExponential(6) : Number(v).toPrecision(10); return `<tr><td>${esc(c.area)}</td><td>${esc(c.label)}<br><span class="src">${esc(c.source)}</span></td><td class="num">${show(c.expect)}</td><td class="num">${show(got)}</td><td class="${ok ? 'pass' : 'fail'}">${ok ? 'PASS' : 'FAIL'}</td></tr>`; }).join(''); $('#m-table').innerHTML = `<thead><tr><th>Area</th><th>Check and reference</th><th>Reference value</th><th>This device</th><th>Result</th></tr></thead><tbody>${rows}</tbody>`; $('#m-sum').textContent = `${pass} of ${cases.length} reference checks pass on this device`; } /* ================================================================ wiring */ const TABS = ['design', 'simulate', 'interpret', 'diagnose', 'methods']; function render(tab) { tab = tab || S.tab; if (tab === 'design') renderDesign(); if (tab === 'simulate') { if (!DR) DR = computeDesign(); syncSim(); drawSim(); renderSimText(); } if (tab === 'interpret') renderInt(); if (tab === 'diagnose') renderDx(); if (tab === 'methods') selfTest(); } function setTab(tab, focus) { if (!TABS.includes(tab)) tab = 'design'; S.tab = tab; save(); $$('.tabs [role=tab]').forEach(b => { const on = b.dataset.tab === tab; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1; if (on && focus) b.focus(); }); TABS.forEach(t => { $('#p-' + t).hidden = t !== tab; }); const cur = $(`.tabs [data-tab="${tab}"]`); if (cur && cur.scrollIntoView) cur.scrollIntoView({ block: 'nearest', inline: 'nearest' }); render(tab); if (tab === 'simulate' && !RUN && DR && !DR.error) runSim(); if (window.scrollY > $('.tabs').offsetTop) window.scrollTo({ top: $('.tabs').offsetTop, behavior: REDUCED ? 'auto' : 'smooth' }); } $('.tabs').addEventListener('click', e => { const b = e.target.closest('[role=tab]'); if (b) setTab(b.dataset.tab); }); $('.tabs').addEventListener('keydown', e => { const i = TABS.indexOf(S.tab); if (e.key === 'ArrowRight') { e.preventDefault(); setTab(TABS[(i + 1) % TABS.length], true); } if (e.key === 'ArrowLeft') { e.preventDefault(); setTab(TABS[(i + TABS.length - 1) % TABS.length], true); } }); document.addEventListener('click', e => { const g = e.target.closest('[data-go]'); if (g) setTab(g.dataset.go); }); let dT; buildForm($('#f-design'), 'd', F.d, k => { const d = S.d; if (k === 'hyp' && d.hyp === 'ni' && !d.supMem) { // Non-inferiority trials usually assume no true difference; remember the superiority effect for the way back. d.supMem = { pt: d.pt, delta: d.delta, hr: d.hr }; d.pt = d.pc; d.delta = 0; d.hr = 1; } else if (k === 'hyp' && d.hyp === 'sup' && d.supMem) { Object.assign(d, d.supMem); delete d.supMem; } syncForm($('#f-design'), 'd', F.d); save(); clearTimeout(dT); dT = setTimeout(() => { renderDesign(); if (RUN) { if (RUN.running) stopSim(); markStale(); } else syncSim(); }, 16); }); buildSimForm(); buildForm($('#f-int'), 'i', F.i, () => { syncForm($('#f-int'), 'i', F.i); save(); renderInt(); }); buildForm($('#f-dx'), 'x', F.x, () => { syncForm($('#f-dx'), 'x', F.x); save(); renderDx(); }); $('#to-sim').addEventListener('click', () => { RUN = null; setTab('simulate'); }); $('#m-rerun').addEventListener('click', () => { selfTest(); toast('Self-test run again'); }); // copy, PNG, share function toast(msg) { const t = $('#toast'); t.textContent = msg; t.classList.add('on'); clearTimeout(toast.t); toast.t = setTimeout(() => t.classList.remove('on'), 2200); } async function copyText(text) { try { await navigator.clipboard.writeText(text); return true; } catch (e) { const ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', ''); ta.className = 'sr-only'; document.body.appendChild(ta); ta.select(); let ok = false; try { ok = document.execCommand('copy'); } catch (e2) { } ta.remove(); return ok; } } document.addEventListener('click', async e => { const c = e.target.closest('[data-copy]'); if (c) { const ok = await copyText($('#' + c.dataset.copy).textContent); toast(ok ? 'Copied' : 'Select the text and copy it'); } const p = e.target.closest('[data-png]'); if (p) { const src = $('#' + p.dataset.png), out = document.createElement('canvas'); out.width = src.width; out.height = src.height; const x = out.getContext('2d'); x.fillStyle = COL.bg; x.fillRect(0, 0, out.width, out.height); x.drawImage(src, 0, 0); out.toBlob(b => { if (!b) return; const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = `power-play-${p.dataset.png.replace('c-', '')}.png`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 4000); toast('Chart saved as PNG'); }, 'image/png'); } }); $('#share').addEventListener('click', async () => { const st = JSON.parse(JSON.stringify(S)); const url = location.href.split('#')[0] + '#s=' + b64.enc(JSON.stringify(st)); try { history.replaceState(null, '', url); } catch (e) { } const ok = await copyText(url); toast(ok ? 'Link copied. It holds your inputs, and never reaches a server.' : 'Copy the address bar to share'); }); // redraw on resize let rzT, lastW = 0; const ro = new ResizeObserver(() => { const wNow = document.documentElement.clientWidth; if (Math.abs(wNow - lastW) < 2) return; lastW = wNow; clearTimeout(rzT); rzT = setTimeout(() => render(), 80); }); ro.observe(document.documentElement); // boot setTab(S.tab === 'simulate' ? 'design' : S.tab); if (location.hash.startsWith('#s=')) toast('Opened from a shared link'); /* ---------- PWA: offline + install ---------- */ const off = $('#offline'); const net = () => { off.hidden = navigator.onLine; }; addEventListener('online', net); addEventListener('offline', net); net(); if ('serviceWorker' in navigator && window.isSecureContext) navigator.serviceWorker.register('sw.js', { scope: './' }).catch(() => { }); let prompt = null; const ib = $('#install'); addEventListener('beforeinstallprompt', e => { e.preventDefault(); prompt = e; ib.hidden = false; }); ib.addEventListener('click', async () => { if (!prompt) return; prompt.prompt(); try { await prompt.userChoice; } catch (e) { } prompt = null; ib.hidden = true; }); addEventListener('appinstalled', () => { ib.hidden = true; });})();index.html209 lines
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; manifest-src 'self'; worker-src 'self'; object-src 'none'; base-uri 'self'; form-action 'none'"><meta name="referrer" content="no-referrer"><title>Power Play · Medical Affairs stats suite</title><meta name="description" content="From sample size to simulated readout: design a trial, run it thousands of times, translate published results into absolute numbers and test performance into real-world predictive values. Runs offline, on your device."><meta name="theme-color" content="#0D0D12"><link rel="manifest" href="manifest.webmanifest"><link rel="icon" href="icons/icon.svg" type="image/svg+xml"><link rel="apple-touch-icon" href="icons/apple-touch-icon.png"><link rel="stylesheet" href="mafflab-theme.css"><link rel="stylesheet" href="style.css"><script src="stats.js" defer></script><script src="selftest.js" defer></script><script src="app.js" defer></script></head><body><a class="skip" href="#main">Skip to content</a><header class="app-head"> <span class="pp-logo" aria-hidden="true"><svg viewBox="0 0 32 32"><path d="M3 26C9 26 10 6 16 6s7 20 13 20" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/><path d="M3 29h26" stroke="currentColor" stroke-width="1.6" opacity=".45"/></svg></span> <span class="app-name">Power Play</span> <span class="app-tag">Medical Affairs stats suite</span> <span class="spacer"></span> <span class="offline" id="offline" hidden>Offline, still working</span> <button class="btn btn-s" id="share" type="button" title="Copy a link that reopens every input exactly as it is now">Copy link</button> <button class="btn btn-s" id="install" type="button" hidden>Install</button></header><nav class="tabs" role="tablist" aria-label="Modules"> <button role="tab" id="tab-design" aria-controls="p-design" aria-selected="true" data-tab="design"><i>01</i>Design</button> <button role="tab" id="tab-simulate" aria-controls="p-simulate" aria-selected="false" tabindex="-1" data-tab="simulate"><i>02</i>Simulate</button> <button role="tab" id="tab-interpret" aria-controls="p-interpret" aria-selected="false" tabindex="-1" data-tab="interpret"><i>03</i>Interpret</button> <button role="tab" id="tab-diagnose" aria-controls="p-diagnose" aria-selected="false" tabindex="-1" data-tab="diagnose"><i>04</i>Diagnose</button> <button role="tab" id="tab-methods" aria-controls="p-methods" aria-selected="false" tabindex="-1" data-tab="methods"><i>∑</i>Methods</button></nav><main id="main" class="pp-main"> <!-- ===================================================== 01 DESIGN --> <section class="panel" id="p-design" role="tabpanel" aria-labelledby="tab-design"> <div class="panel-head"> <h1>Sample size and power</h1> <p>Set the assumptions; the sample size, the power curve and a protocol-ready paragraph update as you type.</p> </div> <div class="cols"> <form class="card controls" id="f-design" autocomplete="off" aria-label="Design assumptions"></form> <div class="out"> <div class="card result" id="d-result" aria-live="polite"></div> <div class="card"> <div class="bar"><h2 class="h2s">Power by sample size</h2><button class="btn btn-s" type="button" data-png="c-power">PNG</button></div> <canvas id="c-power" class="chart" height="280" role="img" aria-label="Power curve"></canvas> <div class="legend" id="d-legend"></div> </div> <div class="card"> <h2 class="h2s">If the true effect is smaller</h2> <div id="d-shrink" class="tbl-wrap"></div> </div> <div class="card"> <div class="bar"><h2 class="h2s">Protocol paragraph</h2><button class="btn btn-s" type="button" data-copy="d-text">Copy</button></div> <p class="prose" id="d-text"></p> <p class="small">A starting point for your protocol or study proposal. Agree the assumptions with your statistician before you rely on them.</p> </div> <button class="btn btn-mag btn-run" type="button" id="to-sim">Simulate this design <span aria-hidden="true">→</span></button> </div> </div> </section> <!-- ===================================================== 02 SIMULATE --> <section class="panel" id="p-simulate" role="tabpanel" aria-labelledby="tab-simulate" hidden> <div class="panel-head"> <h1>Run the trial thousands of times</h1> <p>Every virtual trial enrols patients, draws their outcomes at random and runs the planned test. The share that succeeds is your real power.</p> </div> <div class="cols"> <form class="card controls" id="f-sim" autocomplete="off" aria-label="Simulation settings"> <div class="design-sum" id="s-design"></div> </form> <div class="out"> <div class="card stage"> <div class="bar"><h2 class="h2s" id="s-main-title">Simulated trials</h2> <div class="bar-r"><span class="counter" id="s-counter" aria-live="off">0 trials</span><button class="btn btn-s" type="button" data-png="c-main">PNG</button></div></div> <canvas id="c-main" class="chart chart-main" height="360" role="img" aria-label="Simulated trials"></canvas> <div class="legend" id="s-legend"></div> </div> <div class="grid stats" id="s-tiles"></div> <div class="two"> <div class="card" id="s-hist-card"> <h2 class="h2s" id="s-hist-title">Observed effects</h2> <canvas id="c-hist" class="chart" height="220" role="img" aria-label="Distribution of observed effects"></canvas> </div> <div class="card"> <h2 class="h2s">Power, trial by trial</h2> <canvas id="c-conv" class="chart" height="220" role="img" aria-label="Running estimate of power"></canvas> </div> </div> <div class="card insight" id="s-insight" aria-live="polite"></div> </div> </div> </section> <!-- ===================================================== 03 INTERPRET --> <section class="panel" id="p-interpret" role="tabpanel" aria-labelledby="tab-interpret" hidden> <div class="panel-head"> <h1>Read a published result</h1> <p>Turn a hazard ratio, risk ratio, odds ratio or raw event counts into absolute numbers you can say out loud.</p> </div> <div class="cols"> <form class="card controls" id="f-int" autocomplete="off" aria-label="Published result"></form> <div class="out"> <div class="grid stats" id="i-tiles" aria-live="polite"></div> <div class="two"> <div class="card"> <h2 class="h2s" id="i-icons-title">100 patients like those in the trial</h2> <canvas id="c-icons" class="chart" height="300" role="img" aria-label="Icon array of 100 patients"></canvas> <div class="legend" id="i-legend"></div> </div> <div class="card" id="i-nnt-card"> <h2 class="h2s">NNT depends on baseline risk</h2> <canvas id="c-nnt" class="chart" height="300" role="img" aria-label="Number needed to treat by baseline risk"></canvas> </div> </div> <div class="card"> <div class="bar"><h2 class="h2s">Say it in one breath</h2><button class="btn btn-s" type="button" data-copy="i-text">Copy</button></div> <p class="prose" id="i-text"></p> <ul class="notes" id="i-notes"></ul> </div> </div> </div> </section> <!-- ===================================================== 04 DIAGNOSE --> <section class="panel" id="p-diagnose" role="tabpanel" aria-labelledby="tab-diagnose" hidden> <div class="panel-head"> <h1>Test performance in the real world</h1> <p>Sensitivity and specificity describe the test. Predictive values describe what a result means for the person in front of you, and they move with prevalence.</p> </div> <div class="cols"> <form class="card controls" id="f-dx" autocomplete="off" aria-label="Test characteristics"></form> <div class="out"> <div class="grid stats" id="x-tiles" aria-live="polite"></div> <div class="card"> <h2 class="h2s">1,000 people tested</h2> <canvas id="c-dx" class="chart" height="300" role="img" aria-label="Icon array of 1,000 people tested"></canvas> <div class="legend" id="x-legend"></div> </div> <div class="card"> <h2 class="h2s">Predictive values by prevalence</h2> <canvas id="c-ppv" class="chart" height="260" role="img" aria-label="Predictive values by prevalence"></canvas> <div class="legend" id="x-legend2"></div> </div> <div class="card"> <div class="bar"><h2 class="h2s">Say it in one breath</h2><button class="btn btn-s" type="button" data-copy="x-text">Copy</button></div> <p class="prose" id="x-text"></p> </div> </div> </div> </section> <!-- ===================================================== METHODS --> <section class="panel" id="p-methods" role="tabpanel" aria-labelledby="tab-methods" hidden> <div class="panel-head"> <h1>Methods and validation</h1> <p>Every number comes from a published method. On this device, right now, the app re-checks itself against reference values computed with independent statistical software.</p> </div> <div class="card"> <div class="bar"><h2 class="h2s" id="m-sum">Self-test</h2><button class="btn btn-s" type="button" id="m-rerun">Run again</button></div> <div class="tbl-wrap"><table class="tbl" id="m-table"></table></div> <p class="small">The full suite (88 checks, including random-number generators and simulated versus planned power for eight designs) ships with the source code: <code>tests/validate.js</code>.</p> </div> <div class="two"> <div class="card prose-card"> <h2 class="h2s">Methods</h2> <ul class="notes"> <li><b>Two proportions:</b> normal approximation with pooled variance under the null (Fleiss 1981). Non-inferiority: unpooled variance, one-sided test (Blackwelder 1982).</li> <li><b>Two means:</b> equal-variance t-test; power from the shifted central t approximation to the noncentral t, smallest whole sample reaching the target.</li> <li><b>Time to event:</b> required events from Schoenfeld (1981), with Freedman (1982) shown for comparison. Patients from exponential survival, uniform accrual, extra follow-up and exponential dropout.</li> <li><b>Simulation:</b> each trial draws every patient (time to event) or the sufficient statistics (binomial counts; sample means and pooled variance). Binary: pooled z-test. Continuous: t-test. Time to event: log-rank test, hazard ratio by the Peto method, analysis when the target number of events is reached.</li> <li><b>Uncertain effect (assurance):</b> each simulated trial draws its true effect from a normal distribution (log scale for hazard ratios) matching the plausible range you enter (O'Hagan, Stevens & Campbell 2005).</li> <li><b>Absolute effects:</b> treated-arm risk from a hazard ratio as 1 − (1 − control risk)<sup>HR</sup> (Altman & Andersen 1999); NNT confidence intervals as NNTB/NNTH (Altman 1998); p from a confidence interval (Altman & Bland 2011).</li> <li><b>Event counts:</b> risk ratio (log method), odds ratio (Woolf), risk difference with the Newcombe hybrid score interval, Fisher's exact test, fragility index (Walsh et al. 2014).</li> <li><b>Random numbers:</b> xoshiro128** seeded by splitmix32, so a seed reproduces a run exactly. Binomial draws by inversion or BTRS (Hörmann 1993); gamma by Marsaglia & Tsang (2000).</li> </ul> </div> <div class="card prose-card"> <h2 class="h2s">Limits</h2> <ul class="notes"> <li>Designs cover two-arm, fixed-sample trials. No interim analyses, multiplicity adjustment, clustering, crossover or adaptive designs.</li> <li>Time-to-event designs assume proportional hazards and exponential survival. Real survival curves often bend; the simulation shows how the formula behaves under its own assumptions, not under yours.</li> <li>The Schoenfeld formula is known to be slightly optimistic for strong effects: the simulation usually lands a point or so below the planned power. That is the method, not a bug.</li> <li>Interpret uses the trial's own control-arm risk unless you change it. Absolute numbers are only as good as that baseline.</li> <li>A planning and teaching aid. Final sample sizes belong to a qualified statistician; anything used externally goes through Medical, Legal and Regulatory review.</li> </ul> <p class="small">Nothing you type leaves this device. Inputs are kept in this browser's local storage so they survive a reload; "Copy link" puts them in the link itself, after the #, which browsers never send to a server.</p> </div> </div> </section></main><footer class="app-foot"> <span>Power Play v1.0 · open source (MIT) · part of <a href="https://mafflab.xyz/" rel="noopener">mafflab</a></span> <span>A planning and teaching aid. It does not replace a statistician, professional medical judgment or your employer's internal tools.</span></footer><div class="toast" id="toast" role="status" aria-live="polite"></div></body></html>style.css136 lines
/* Power Play: layout and components on top of mafflab-theme.css */:root{--ctl:#00F0FF;--trt:#FF2A85;--tabs-h:50px}html{scroll-padding-top:calc(var(--tabs-h) + 12px)}body{overflow-x:hidden}.skip{position:absolute;left:-999px;top:8px;z-index:50;background:var(--bg-2);color:var(--white);padding:8px 12px;border:1px solid var(--cyan);border-radius:6px}.skip:focus{left:8px}/* header + tabs */.app-head{position:relative;flex-wrap:wrap;gap:10px 14px;padding-top:12px;padding-bottom:12px}.pp-logo{width:30px;height:30px;display:grid;place-items:center;color:var(--mag);filter:drop-shadow(0 0 6px rgba(255,42,133,.55))}.pp-logo svg{width:30px;height:30px}.tabs{position:sticky;top:0;z-index:20;display:flex;gap:4px;overflow-x:auto;scrollbar-width:none;padding:6px var(--pad);background:rgba(13,13,18,.92);border-bottom:1px solid var(--line);-webkit-overflow-scrolling:touch}.tabs::-webkit-scrollbar{display:none}.tabs button{flex:0 0 auto;display:inline-flex;align-items:center;gap:8px;min-height:38px;padding:8px 14px;border:1px solid transparent;border-radius:6px;background:none;color:var(--silver);font-family:var(--f-display);font-weight:600;font-size:.95rem;cursor:pointer;white-space:nowrap}.tabs button i{font-style:normal;font-family:var(--f-mono);font-size:.7rem;color:var(--slate)}.tabs button:hover{color:var(--white);border-color:var(--line)}.tabs button[aria-selected="true"]{color:var(--white);background:rgba(255,42,133,.1);border-color:var(--line-m);box-shadow:inset 0 -2px 0 var(--mag)}.tabs button[aria-selected="true"] i{color:var(--mag)}/* page */.pp-main{max-width:1280px;margin:0 auto;padding:clamp(18px,3vw,34px) var(--pad) 50px}.panel-head{margin-bottom:18px;max-width:760px}.panel-head h1{font-size:clamp(1.5rem,3.4vw,2.2rem);margin-bottom:6px}.panel-head p{color:var(--silver)}.cols{display:grid;grid-template-columns:minmax(290px,380px) minmax(0,1fr);gap:20px;align-items:start}.out{display:grid;gap:18px;min-width:0}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}.card{min-width:0}.card:hover{box-shadow:none}.controls{display:grid;gap:16px}.bar{margin-bottom:10px}.bar-r{display:flex;align-items:center;gap:10px}.h2s{margin-bottom:0}.bar .h2s{margin:0}/* fields */.fgroup{display:grid;gap:14px;padding-top:14px;border-top:1px solid var(--line)}.fgroup:first-child{padding-top:0;border-top:0}.fgroup>h3{font-family:var(--f-mono);font-size:.7rem;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--slate)}.field{display:grid;gap:6px}.field .lab{display:flex;justify-content:space-between;align-items:baseline;gap:8px}.field label,.field .lbl{font-size:.88rem}.field .hint{font-size:.74rem;color:var(--slate);text-align:right}.field .row{display:grid;grid-template-columns:1fr 92px;gap:10px;align-items:center}.field .row.solo{grid-template-columns:1fr}.field input[type=number],.field input[type=text]{padding:9px 10px;font-family:var(--f-mono);font-size:.9rem;text-align:right}.field input[type=text]{text-align:left;font-family:var(--f-body)}.field.bad input{border-color:var(--mag)}.seg{display:flex;flex-wrap:wrap;width:100%}.seg button{flex:1 1 auto;min-height:34px}.seg.small button{font-size:.7rem;padding:6px 8px}input[type=range]{-webkit-appearance:none;appearance:none;width:100%;height:28px;background:none;margin:0;cursor:pointer}input[type=range]::-webkit-slider-runnable-track{height:4px;border-radius:2px;background:linear-gradient(90deg,var(--purple),var(--mag))}input[type=range]::-moz-range-track{height:4px;border-radius:2px;background:linear-gradient(90deg,var(--purple),var(--mag))}input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:18px;height:18px;margin-top:-7px;border-radius:50%;background:var(--white);border:2px solid var(--mag);box-shadow:0 0 10px rgba(255,42,133,.7)}input[type=range]::-moz-range-thumb{width:16px;height:16px;border-radius:50%;background:var(--white);border:2px solid var(--mag);box-shadow:0 0 10px rgba(255,42,133,.7)}input[type=range]:focus-visible{outline:2px solid var(--cyan);outline-offset:2px}.toggle{display:flex;align-items:center;gap:10px;cursor:pointer;font-family:var(--f-display);font-weight:600;color:var(--white);font-size:.9rem}.toggle input{width:18px;height:18px;accent-color:var(--mag)}.err{color:var(--mag);font-size:.85rem}/* results */.result{display:grid;gap:12px;border-color:var(--line-m);background:linear-gradient(135deg,rgba(255,42,133,.08),rgba(122,0,255,.06) 60%,rgba(24,24,38,.75))}.hero-n{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 18px}.hero-n b{font-family:var(--f-display);font-size:clamp(2.6rem,7vw,4.2rem);line-height:.95;color:var(--white);text-shadow:-2px 0 rgba(255,42,133,.55),2px 0 rgba(0,240,255,.45)}.hero-n span{font-family:var(--f-mono);font-size:.8rem;color:var(--silver)}.kv{display:flex;flex-wrap:wrap;gap:8px}.kv span{font-family:var(--f-mono);font-size:.74rem;padding:5px 9px;border:1px solid var(--line);border-radius:4px;color:var(--silver);background:rgba(13,13,18,.5)}.kv span b{color:var(--white);font-weight:600}.kv .c{border-color:rgba(0,240,255,.35)}.kv .t{border-color:rgba(255,42,133,.45)}.stats{grid-template-columns:repeat(auto-fit,minmax(170px,1fr))}.stat b{font-size:1.65rem}.stat .sub{font-family:var(--f-mono);font-size:.7rem;color:var(--silver)}.stat.is-c b{color:var(--cyan)}.stat.is-m b{color:var(--mag)}.chart{display:block;width:100%;touch-action:pan-y}.legend{display:flex;flex-wrap:wrap;gap:6px 16px;margin-top:10px;font-size:.76rem;color:var(--silver)}.legend i{display:inline-block;width:14px;height:3px;border-radius:2px;margin-right:7px;vertical-align:middle;background:currentColor}.legend i.dot{width:10px;height:10px;border-radius:50%}.legend i.sq{width:10px;height:10px;border-radius:2px}.legend i.dash{background:repeating-linear-gradient(90deg,currentColor 0 4px,transparent 4px 7px)}.prose{color:var(--white);line-height:1.7}.notes{margin:12px 0 0;padding-left:18px;display:grid;gap:8px;font-size:.88rem}.notes li::marker{color:var(--mag)}.prose-card .notes{margin-top:8px}.insight{border-color:var(--line-m)}.insight p+p{margin-top:10px}.insight b{color:var(--white)}.counter{font-family:var(--f-mono);font-size:.75rem;color:var(--cyan)}.stage{position:relative}.btn-run{width:100%;padding:16px 18px;font-size:.95rem;letter-spacing:.04em}.run-row{display:grid;grid-template-columns:1fr auto;gap:8px}.design-sum{display:grid;gap:8px;padding:12px;border:1px dashed var(--line-c);border-radius:var(--r);font-size:.85rem}.design-sum b{color:var(--white)}.linkbtn{background:none;border:0;padding:0;color:var(--cyan);font:inherit;text-decoration:underline;cursor:pointer;justify-self:start}/* tables */.tbl-wrap{overflow-x:auto}.tbl{width:100%;border-collapse:collapse;font-size:.82rem}.tbl th,.tbl td{padding:8px 10px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}.tbl th{font-family:var(--f-mono);font-size:.68rem;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:var(--slate)}.tbl td.num{font-family:var(--f-mono);text-align:right;white-space:nowrap}.tbl .pass{color:var(--ok);font-family:var(--f-mono)}.tbl .fail{color:var(--mag);font-family:var(--f-mono)}.tbl tr.here td{background:rgba(255,42,133,.08)}.tbl .src{color:var(--slate);font-size:.74rem}.toast{position:fixed;left:50%;bottom:24px;transform:translate(-50%,20px);opacity:0;pointer-events:none;z-index:60;padding:10px 16px;border:1px solid var(--line-c);border-radius:6px;background:rgba(13,13,18,.95);color:var(--white);font-family:var(--f-mono);font-size:.8rem;transition:opacity .2s,transform .2s}.toast.on{opacity:1;transform:translate(-50%,0)}.app-foot{display:flex;flex-wrap:wrap;gap:6px 24px;justify-content:space-between;max-width:1280px}@media (max-width:980px){ .cols{grid-template-columns:1fr} /* on one column, show the answer first, then the inputs, then the detail */ .cols>.out{display:contents} #d-result,.stage,#i-tiles,#x-tiles{order:-1}}@media (max-width:720px){ .two{grid-template-columns:1fr} .app-tag{display:none} .hero-n b{font-size:2.6rem} .stats{grid-template-columns:repeat(2,minmax(0,1fr))} .stat{padding:12px} .stat b{font-size:1.35rem}}@media (pointer:coarse){ .card,.app-head{backdrop-filter:none;-webkit-backdrop-filter:none} .field input[type=number]{font-size:16px}}.sw-c{color:var(--cyan)}.sw-m{color:var(--mag)}.sw-w{color:var(--white)}.sw-s{color:var(--slate)}.sw-y{color:var(--warn)}.sw-g{color:var(--ok)}.sw-p{color:#B57BFF}.stale{opacity:.45;transition:opacity .2s}.field .row.two-in{grid-template-columns:1fr 1fr}.btn:disabled{opacity:.4;cursor:not-allowed;box-shadow:none}.tbl th.num{text-align:right}.legend i.ring{background:none;border:1.5px solid currentColor;width:10px;height:10px;border-radius:2px}.panel>.card+.two{margin-top:18px}.legend i.dots{background:repeating-linear-gradient(90deg,currentColor 0 2px,transparent 2px 5px)}sw.js21 lines
/* Power Play service worker: the whole app works offline once it has loaded one time. Caches only the app's own files. Change VERSION on every release so installed copies update. */const VERSION = 'power-play-1.0.0';const SHELL = ['./', './index.html', './app.js', './stats.js', './sim-worker.js', './selftest.js', './style.css', './mafflab-theme.css', './manifest.webmanifest', './fonts/space-grotesk.woff2', './fonts/inter.woff2', './icons/icon.svg', './icons/icon-192.png', './icons/icon-512.png', './icons/maskable-512.png', './icons/apple-touch-icon.png'];const PREFIX = 'power-play-';self.addEventListener('install', e => e.waitUntil(caches.open(VERSION).then(c => c.addAll(SHELL)).then(() => self.skipWaiting())));self.addEventListener('activate', e => e.waitUntil( caches.keys().then(ks => Promise.all(ks.filter(k => k.startsWith(PREFIX) && k !== VERSION).map(k => caches.delete(k)))).then(() => self.clients.claim())));self.addEventListener('fetch', e => { const url = new URL(e.request.url); if (e.request.method !== 'GET' || url.origin !== location.origin) return; if (e.request.mode === 'navigate') { e.respondWith(fetch(e.request).then(r => { const c = r.clone(); caches.open(VERSION).then(x => x.put('./index.html', c)); return r; }).catch(() => caches.match('./index.html'))); return; } e.respondWith(caches.match(e.request, { ignoreSearch: true }).then(hit => hit || fetch(e.request)));});manifest.webmanifest21 lines
{ "id": "./", "name": "Power Play: Medical Affairs stats suite", "short_name": "Power Play", "description": "Sample size, Monte Carlo trial simulation, absolute effects and predictive values. Runs offline, on your device.", "start_url": "./", "scope": "./", "display": "standalone", "orientation": "any", "background_color": "#0D0D12", "theme_color": "#0D0D12", "lang": "en", "categories": ["medical", "productivity", "education"], "icons": [ { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { "src": "icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }, { "src": "icons/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" } ]}tests/validate.js93 lines
/* Power Play validation. Run from the app folder: node tests/validate.js 1. Every formula against reference values from SciPy, statsmodels and lifelines (tests/reference.json). 2. Random number generators against their exact distributions. 3. Simulated power against the design formulas (the two must agree within Monte Carlo error). */'use strict';const path = require('path');const PP = require(path.join(__dirname, '..', 'stats.js'));const ref = require(path.join(__dirname, 'reference.json'));const pick = (v, p) => p ? p.split('.').reduce((o, k) => (o == null ? o : o[k]), v) : v;let fails = 0, n = 0;const line = (ok, area, label, got, exp, src) => { n++; if (!ok) fails++; console.log(`${ok ? 'PASS' : 'FAIL'} ${area.padEnd(13)} ${label.slice(0, 74).padEnd(74)} got ${String(got).slice(0, 16).padEnd(16)} ref ${String(exp).slice(0, 16)}`);};console.log(`\n1. Formulas vs ${ref.generated_with.join(', ')}\n`);for (const c of ref.cases) { const [fn, ...args] = c.call; const got = pick(PP[fn](...args), c.pick); const err = Math.abs(got - c.expect), lim = c.rel ? c.tol * Math.abs(c.expect) : c.tol; line(err <= lim + 1e-15, c.area, c.label, got, c.expect, c.source);}console.log('\n2. Random number generators (200,000 draws each)\n');{ const R = PP.rng(42), N = 200000; const check = (label, draw, mean, sd) => { let s = 0, s2 = 0; for (let i = 0; i < N; i++) { const x = draw(); s += x; s2 += x * x; } const m = s / N, v = s2 / N - m * m, se = sd / Math.sqrt(N); const ok = Math.abs(m - mean) < 4 * se && Math.abs(Math.sqrt(v) / sd - 1) < 0.01; line(ok, 'RNG', label, m.toFixed(5), `${mean} (sd ${sd.toFixed(4)})`); }; check('Uniform (0,1)', R.u, 0.5, Math.sqrt(1 / 12)); check('Normal (0,1)', R.normal, 0, 1); check('Exponential, rate 0.25', () => R.exp(0.25), 4, 4); check('Gamma, shape 3.5', () => R.gamma(3.5), 3.5, Math.sqrt(3.5)); check('Chi-square, 7 df', () => R.chisq(7), 7, Math.sqrt(14)); for (const [bn, bp] of [[40, 0.1], [300, 0.02], [1000, 0.3], [5000, 0.62]]) { check(`Binomial n ${bn}, p ${bp} (${bn * bp < 10 ? 'inversion' : 'BTRS'})`, () => R.binom(bn, bp), bn * bp, Math.sqrt(bn * bp * (1 - bp))); } // Exact distribution check for BTRS: chi-square goodness of fit against the binomial pmf const bn = 200, bp = 0.25, cnt = new Float64Array(bn + 1); for (let i = 0; i < N; i++) cnt[R.binom(bn, bp)]++; const lf = k => PP.lgamma(k + 1); let chi = 0, cells = 0, pool = 0, poolE = 0; for (let k = 0; k <= bn; k++) { const e = N * Math.exp(lf(bn) - lf(k) - lf(bn - k) + k * Math.log(bp) + (bn - k) * Math.log(1 - bp)); if (e < 5) { pool += cnt[k]; poolE += e; continue; } chi += (cnt[k] - e) ** 2 / e; cells++; } if (poolE > 0) { chi += (pool - poolE) ** 2 / poolE; cells++; } // 99.9th percentile of chi-square with (cells - 1) df, Wilson–Hilferty const df = cells - 1, crit = df * Math.pow(1 - 2 / (9 * df) + 3.0902 * Math.sqrt(2 / (9 * df)), 3); line(chi < crit, 'RNG', `Binomial n 200, p 0.25: goodness of fit, ${df} df`, chi.toFixed(1), `< ${crit.toFixed(1)}`);}console.log('\n3. Simulated power vs design formula (4,000 trials each, seed 2026)\n');const SIMS = 4000;function simPower(type, o, d) { const cfg = Object.assign({ type, nc: d.nc, nt: d.nt }, o); if (type === 'bin') cfg.effectSign = Math.sign(o.pt - o.pc) || 1; if (type === 'cont') cfg.effectSign = Math.sign(o.delta) || 1; if (type === 'tte') { cfg.effectSign = o.hr < 1 ? 1 : -1; cfg.lamC = PP.monthlyHazard(o.medC); cfg.eta = PP.dropHazard(o.dropY || 0); cfg.events = d.events; cfg.analysis = 'events'; } const sim = PP.makeSim(cfg, 2026); let ok = 0; for (let i = 0; i < SIMS; i++) if (sim.one(null).ok) ok++; return ok / SIMS;}const scen = [ ['bin', 'Binary superiority 30% vs 20%, 80%', { pc: 0.3, pt: 0.2, alpha: 0.05, sides: 2, power: 0.8, k: 1, hyp: 'sup' }], ['bin', 'Binary superiority 40% vs 55%, 2:1, 90%', { pc: 0.4, pt: 0.55, alpha: 0.05, sides: 2, power: 0.9, k: 2, hyp: 'sup' }], ['bin', 'Binary non-inferiority 85%, margin 10 points', { pc: 0.85, pt: 0.85, alpha: 0.025, sides: 1, power: 0.9, k: 1, hyp: 'ni', margin: 0.1, better: 'higher' }], ['cont', 'Continuous superiority d = 0.4, 80%', { delta: 4, sd: 10, alpha: 0.05, sides: 2, power: 0.8, k: 1, hyp: 'sup' }], ['cont', 'Continuous non-inferiority, margin 3, sd 8', { delta: 0, sd: 8, alpha: 0.025, sides: 1, power: 0.9, k: 1, hyp: 'ni', margin: 3, better: 'higher' }], ['tte', 'Time to event HR 0.70, median 12 mo, 80%', { hr: 0.7, medC: 12, accrual: 18, follow: 12, dropY: 0.05, alpha: 0.05, sides: 2, power: 0.8, k: 1, hyp: 'sup' }], ['tte', 'Time to event HR 0.75, 2:1, 90%', { hr: 0.75, medC: 20, accrual: 24, follow: 18, dropY: 0.05, alpha: 0.05, sides: 2, power: 0.9, k: 2, hyp: 'sup' }], ['tte', 'Time to event non-inferiority, margin 1.3', { hr: 1.0, medC: 24, accrual: 18, follow: 18, dropY: 0.03, alpha: 0.025, sides: 1, power: 0.8, k: 1, hyp: 'ni', margin: 1.3 }],];const t0 = Date.now();for (const [type, label, o] of scen) { const d = PP.design(type, o), planned = PP.power(type, o, d.nc), got = simPower(type, o, d); const se = Math.sqrt(planned * (1 - planned) / SIMS); line(Math.abs(got - planned) < 3.5 * se + 0.01, 'Simulate', `${label} (n ${d.n}${d.events ? ', ' + d.events + ' events' : ''})`, `${(got * 100).toFixed(1)}%`, `${(planned * 100).toFixed(1)}% ± ${(se * 100).toFixed(1)}`);}console.log(`\n${n - fails}/${n} checks passed${fails ? `, ${fails} FAILED` : ''} (simulations ${((Date.now() - t0) / 1000).toFixed(1)} s)\n`);process.exit(fails ? 1 : 0);tests/make_reference.py154 lines
"""Reference values for Power Play, computed with independent software (SciPy, statsmodels, lifelines).Run: python3 tests/make_reference.pyWrites tests/reference.json (all cases, used by tests/validate.js) and selftest.js (the cases the appre-checks on every visitor's device, shown in the Methods tab)."""import json, math, sysfrom pathlib import Pathimport numpy as npimport scipy, statsmodels, lifelinesfrom scipy import stats, integratefrom statsmodels.stats.power import TTestIndPowerfrom statsmodels.stats.proportion import (samplesize_proportions_2indep_onetail, power_proportions_2indep, confint_proportions_2indep)from lifelines.statistics import logrank_test, sample_size_necessary_under_cphHERE = Path(__file__).resolve().parentSCIPY, SM, LL = f'SciPy {scipy.__version__}', f'statsmodels {statsmodels.__version__}', f'lifelines {lifelines.__version__}'cases = []def add(area, label, call, expect, source, tol=1e-9, pick=None, rel=False, app=True): cases.append(dict(id=f'c{len(cases)+1:02d}', area=area, label=label, call=call, pick=pick, expect=float(expect), tol=tol, rel=rel, source=source, app=app))# ---------------------------------------------------------------- distributionsfor x in (-5.0, -1.96, 0.3, 2.5): add('Distributions', f'Normal CDF at {x}', ['normCdf', x], stats.norm.cdf(x), f'{SCIPY} norm.cdf', 1e-13, app=x in (-1.96,))for p in (0.001, 0.025, 0.8, 0.975): add('Distributions', f'Normal quantile at {p}', ['normInv', p], stats.norm.ppf(p), f'{SCIPY} norm.ppf', 1e-11, app=p == 0.975)for t, df in ((2.0, 10), (-1.5, 4), (2.6, 120)): add('Distributions', f't CDF at {t}, {df} df', ['tCdf', t, df], stats.t.cdf(t, df), f'{SCIPY} t.cdf', 1e-12, app=df == 10)for p, df in ((0.975, 20), (0.9, 5), (0.995, 60)): add('Distributions', f't quantile {p}, {df} df', ['tInv', p, df], stats.t.ppf(p, df), f'{SCIPY} t.ppf', 1e-9, app=df == 20)# ---------------------------------------------------------------- design: means (exact noncentral t in statsmodels)tt = TTestIndPower()for d, k, pw in ((0.5, 1, 0.8), (0.8, 1, 0.8), (0.2, 1, 0.8), (0.4, 2, 0.9), (0.35, 1, 0.9)): n1 = tt.solve_power(effect_size=d, alpha=0.05, power=pw, ratio=k, alternative='two-sided') o = dict(delta=d, sd=1, alpha=0.05, sides=2, power=pw, k=k, hyp='sup') add('Design', f'Means: d = {d}, power {int(pw*100)}%, {k}:1, control arm n', ['ssMeans', o], math.ceil(n1), f'{SM} TTestIndPower (exact noncentral t): {n1:.2f}', 0, pick='nc', app=(d, k) in ((0.5, 1), (0.4, 2)))# ---------------------------------------------------------------- design: proportions (Fleiss, pooled null variance)for pc, pt, pw, k in ((0.5, 0.75, 0.9, 1), (0.30, 0.20, 0.8, 1), (0.40, 0.55, 0.8, 2), (0.12, 0.08, 0.9, 1)): n1 = samplesize_proportions_2indep_onetail(diff=pc - pt, prop2=pt, power=pw, ratio=k, alpha=0.05, alternative='two-sided') o = dict(pc=pc, pt=pt, alpha=0.05, sides=2, power=pw, k=k, hyp='sup') add('Design', f'Proportions: {pc:.0%} vs {pt:.0%}, power {int(pw*100)}%, {k}:1, control arm n (unrounded)', ['ssProp', o], n1, f'{SM} samplesize_proportions_2indep_onetail', 1e-6, pick='raw', rel=True, app=(pc, pt) in ((0.5, 0.75), (0.40, 0.55)))for pc, pt, n1, k in ((0.5, 0.75, 70, 1), (0.30, 0.20, 250, 1)): pw = power_proportions_2indep(diff=pc - pt, prop2=pt, nobs1=n1, ratio=k, alpha=0.05, alternative='two-sided', return_results=False) o = dict(pc=pc, pt=pt, alpha=0.05, sides=2, k=k, hyp='sup') add('Design', f'Proportions: power at {n1} per arm, {pc:.0%} vs {pt:.0%}', ['powerProp', o, n1], pw, f'{SM} power_proportions_2indep (adds the far tail, < 1e-5)', 1e-5, app=n1 == 250)# Non-inferiority, two proportions (Blackwelder): independent implementation with SciPy quantilesdef ni_prop(pc, pt, margin, better, alpha, pw, k): dist = (margin - (pt - pc)) if better == 'lower' else ((pt - pc) + margin) return (stats.norm.ppf(1 - alpha) + stats.norm.ppf(pw)) ** 2 * (pc * (1 - pc) + pt * (1 - pt) / k) / dist ** 2for pc, pt, m, b in ((0.85, 0.85, 0.10, 'higher'), (0.10, 0.10, 0.05, 'lower')): o = dict(pc=pc, pt=pt, alpha=0.025, sides=1, power=0.9, k=1, hyp='ni', margin=m, better=b) add('Design', f'Non-inferiority proportions: {pc:.0%}, margin {m:.0%}, control arm n (unrounded)', ['ssProp', o], ni_prop(pc, pt, m, b, 0.025, 0.9, 1), 'Independent Python (Blackwelder 1982) with SciPy quantiles', 1e-9, pick='raw', rel=True, app=b == 'higher')# ---------------------------------------------------------------- design: time to eventz = lambda a, s, pw: stats.norm.ppf(1 - a / s) + stats.norm.ppf(pw)for hr, k in ((0.75, 1), (0.70, 2), (0.60, 1)): o = dict(hr=hr, alpha=0.05, sides=2, power=0.8, k=k, hyp='sup') add('Design', f'Events, Schoenfeld: HR {hr}, {k}:1', ['eventsNeeded', o], z(0.05, 2, 0.8) ** 2 * (1 + k) ** 2 / (k * math.log(hr) ** 2), 'Independent Python (Schoenfeld 1981)', 1e-9, pick='d', rel=True, app=hr == 0.75) tiny = 1e-6 ne, nc_ = sample_size_necessary_under_cph(0.8, k, tiny, tiny, hr, 0.05) m_freedman = ne * (k * tiny + tiny) / k # lifelines rounds n up; with p = 1e-6 that rounding is negligible add('Design', f'Events, Freedman: HR {hr}, {k}:1', ['eventsNeeded', o], m_freedman, f'{LL} sample_size_necessary_under_cph (Freedman 1982)', 1e-5, pick='freedman', rel=True, app=hr == 0.75)def p_event(lam, eta, A, tau): h = lam + eta f = lambda e: lam / h * (1 - math.exp(-h * max(0.0, tau - e))) return integrate.quad(f, 0, A, epsabs=1e-13, epsrel=1e-13, limit=200)[0] / Afor lam, eta, A, tau in ((math.log(2) / 12, -math.log(0.95) / 12, 18, 30), (math.log(2) / 30, 0.0, 24, 12), (0.05, 0.01, 6, 40)): add('Design', f'Event probability: λ {lam:.4f}/month, accrual {A}, analysis at month {tau}', ['eventProbAt', lam, eta, A, tau], p_event(lam, eta, A, tau), f'Numerical integration, {SCIPY} integrate.quad', 1e-10, app=A == 18)# ---------------------------------------------------------------- interpretfor a, n1, c, n2 in ((12, 200, 25, 200), (3, 50, 11, 50), (0, 30, 6, 30), (41, 1000, 68, 1000)): add('Interpret', f"Fisher's exact p: {a}/{n1} vs {c}/{n2}", ['fisher', a, n1 - a, c, n2 - c], stats.fisher_exact([[a, n1 - a], [c, n2 - c]])[1], f'{SCIPY} fisher_exact', 1e-10, app=(a, c) == (12, 25)) lo, hi = confint_proportions_2indep(a, n1, c, n2, method='newcomb', compare='diff') add('Interpret', f'Risk difference 95% CI (Newcombe), lower: {a}/{n1} vs {c}/{n2}', ['fromCounts', a, n1, c, n2], lo, f'{SM} confint_proportions_2indep (newcomb)', 1e-10, pick='rdLo', app=(a, c) == (12, 25)) add('Interpret', f'Risk difference 95% CI (Newcombe), upper: {a}/{n1} vs {c}/{n2}', ['fromCounts', a, n1, c, n2], hi, f'{SM} confint_proportions_2indep (newcomb)', 1e-10, pick='rdHi', app=False) if a and c: lo, hi = confint_proportions_2indep(a, n1, c, n2, method='log', compare='ratio') add('Interpret', f'Risk ratio 95% CI (log), lower: {a}/{n1} vs {c}/{n2}', ['fromCounts', a, n1, c, n2], lo, f'{SM} confint_proportions_2indep (log ratio)', 1e-10, pick='rrLo', app=(a, c) == (12, 25)) lo, hi = confint_proportions_2indep(a, n1, c, n2, method='logit', compare='odds-ratio') add('Interpret', f'Odds ratio 95% CI (Woolf), upper: {a}/{n1} vs {c}/{n2}', ['fromCounts', a, n1, c, n2], hi, f'{SM} confint_proportions_2indep (logit)', 1e-10, pick='orHi', app=False)def fragility(a, n1, c, n2, alpha=0.05): p = stats.fisher_exact([[a, n1 - a], [c, n2 - c]])[1] if p >= alpha: return None steps = 0 while p < alpha: if a <= c: a += 1 else: c += 1 steps += 1 p = stats.fisher_exact([[a, n1 - a], [c, n2 - c]])[1] return stepsfor a, n1, c, n2 in ((12, 200, 25, 200), (41, 1000, 68, 1000), (10, 150, 30, 150)): fi = fragility(a, n1, c, n2) add('Interpret', f'Fragility index: {a}/{n1} vs {c}/{n2}', ['fromCounts', a, n1, c, n2], fi, f'Independent Python (Walsh 2014 rule) with {SCIPY} fisher_exact', 0, pick='frag.fi', app=(a, c) == (41, 68))for kind, est, pc in (('HR', 0.7, 0.2), ('HR', 0.85, 0.35), ('RR', 0.6, 0.1), ('OR', 0.5, 0.3)): pt = 1 - (1 - pc) ** est if kind == 'HR' else (est * pc if kind == 'RR' else est * pc / (1 - pc + est * pc)) add('Interpret', f'Treated-arm risk from {kind} {est} at control risk {pc:.0%}', ['riskFromRatio', kind, est, pc], pt, 'Independent Python (Altman & Andersen 1999 for HR)', 1e-12, app=kind == 'HR' and est == 0.7)for est, lo, hi in ((0.75, 0.62, 0.91), (0.9, 0.78, 1.04)): se = (math.log(hi) - math.log(lo)) / (2 * stats.norm.ppf(0.975)) add('Interpret', f'p value from ratio {est} (95% CI {lo} to {hi})', ['pFromRatioCI', est, lo, hi], 2 * stats.norm.sf(abs(math.log(est) / se)), f'Independent Python (Altman & Bland 2011), {SCIPY}', 1e-12, pick='p', app=est == 0.75)# Log-rank on a fixed dataset (no ties), lifelines as referencers = np.random.default_rng(20261104)nA = nB = 30tA = rs.exponential(14, nA); tB = rs.exponential(9, nB)cA = rs.uniform(4, 30, nA); cB = rs.uniform(4, 30, nB)TA, EA = np.minimum(tA, cA), (tA <= cA).astype(int)TB, EB = np.minimum(tB, cB), (tB <= cB).astype(int)TA, TB = np.round(TA, 5), np.round(TB, 5)lr = logrank_test(TA, TB, event_observed_A=EA, event_observed_B=EB)data = [[float(t), 1, int(e)] for t, e in zip(TA, EA)] + [[float(t), 0, int(e)] for t, e in zip(TB, EB)]add('Simulate', 'Log-rank chi-square on a 60-patient dataset', ['logrankChi2', data], lr.test_statistic, f'{LL} logrank_test', 1e-9, rel=True, app=True)# ---------------------------------------------------------------- diagnosefor se_, sp_, pv in ((0.9, 0.9, 0.01), (0.95, 0.8, 0.2), (0.7, 0.99, 0.05)): ppv = se_ * pv / (se_ * pv + (1 - sp_) * (1 - pv)); npv = sp_ * (1 - pv) / (sp_ * (1 - pv) + (1 - se_) * pv) add('Diagnose', f'PPV: sensitivity {se_:.0%}, specificity {sp_:.0%}, prevalence {pv:.0%}', ['diag', se_, sp_, pv], ppv, "Bayes' theorem, independent Python", 1e-12, pick='ppv', app=pv == 0.01) add('Diagnose', f'NPV: sensitivity {se_:.0%}, specificity {sp_:.0%}, prevalence {pv:.0%}', ['diag', se_, sp_, pv], npv, "Bayes' theorem, independent Python", 1e-12, pick='npv', app=False)(HERE / 'reference.json').write_text(json.dumps(dict(generated_with=[SCIPY, SM, LL, f'NumPy {np.__version__}'], cases=cases), indent=1))app_cases = [{k: v for k, v in c.items() if k != 'app'} for c in cases if c['app']](HERE.parent / 'selftest.js').write_text( '/* Reference values the app re-checks on every device (Methods tab). Generated by tests/make_reference.py\n' f' with {SCIPY}, {SM} and {LL}. Do not edit by hand. */\n' 'self.PP_REF = ' + json.dumps(app_cases, ensure_ascii=False, separators=(',', ':')) + ';\n')print(f'{len(cases)} cases, {len(app_cases)} in the in-app self-test').htaccess13 lines
# Power Play: security headers for Apache and LiteSpeed (Hostinger, IONOS and most shared hosting).DirectoryIndex index.htmlAddType application/manifest+json .webmanifest<IfModule mod_headers.c> Header always set Referrer-Policy "no-referrer" Header always set X-Content-Type-Options "nosniff" Header always set X-Frame-Options "SAMEORIGIN" Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(), browsing-topics=()" <FilesMatch "^(sw\.js|index\.html|manifest\.webmanifest)$"> Header set Cache-Control "no-cache" </FilesMatch></IfModule>LICENSE22 lines
MIT LicenseCopyright (c) 2026 Aitor Martínez RosellPermission is hereby granted, free of charge, to any person obtaining a copyof this software and associated documentation files (the "Software"), to dealin the Software without restriction, including without limitation the rightsto use, copy, modify, merge, publish, distribute, sublicense, and/or sellcopies of the Software, and to permit persons to whom the Software isfurnished to do so, subject to the following conditions:The above copyright notice and this permission notice shall be included in allcopies or substantial portions of the Software.THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS ORIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THEAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHERLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THESOFTWARE.