<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Web App | FLH Website</title><link>https://francish.net/tags/web-app/</link><atom:link href="https://francish.net/tags/web-app/index.xml" rel="self" type="application/rss+xml"/><description>Web App</description><generator>Hugo Blox Builder (https://hugoblox.com)</generator><language>en-us</language><lastBuildDate>Mon, 07 Sep 2026 00:00:00 +0000</lastBuildDate><image><url>https://francish.net/media/icon_hu8315282870087596650.png</url><title>Web App</title><link>https://francish.net/tags/web-app/</link></image><item><title>Calculating Standard Errors Using Replicate Weights</title><link>https://francish.net/post/2026_repwgt_app/</link><pubDate>Mon, 07 Sep 2026 00:00:00 +0000</pubDate><guid>https://francish.net/post/2026_repwgt_app/</guid><description>
&lt;html lang="en">
&lt;head>
&lt;meta charset="utf-8">
&lt;meta name="viewport" content="width=device-width, initial-scale=1">
&lt;title>Replicate Weight Statistics Calculator&lt;/title>
&lt;meta name="description" content="Survey estimates with replicate-weight standard errors (BRR/Fay, JK1, JK2, bootstrap) and plausible-value imputation variance, as used in PISA, TIMSS and PIRLS. Runs entirely in your browser.">
&lt;style>
:root {
--bg: #f9f8f6; --card: #fff; --border: #e6e1d8; --text: #2a2722;
--muted: #6f6a62; --accent: #2f6feb; --warn: #b45309; --danger: #b91c1c;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--text);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
}
.wrap { max-width: 1080px; margin: 0 auto; padding: 24px 16px 64px; }
header.app { display: flex; gap: 14px; align-items: flex-start; margin-bottom: 20px; }
.badge {
flex: none; width: 40px; height: 40px; border-radius: 10px; background: #2a2722; color: #fff;
display: grid; place-items: center; font-size: 20px; font-weight: 600;
}
header.app h1 { margin: 0; font-size: 21px; line-height: 1.25; }
header.app p { margin: 4px 0 0; color: var(--muted); font-size: 14px; max-width: 70ch; }
.card {
background: var(--card); border: 1px solid var(--border); border-radius: 12px;
padding: 18px; margin-bottom: 16px;
}
.card h2 {
margin: 0 0 14px; font-size: 15px; font-weight: 650;
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
}
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(215px, 1fr)); gap: 14px; }
.field { display: flex; flex-direction: column; gap: 5px; font-size: 13px; }
.lbl { font-weight: 600; }
.hint { font-weight: 400; color: var(--muted); }
input[type=text], input[type=number], select {
font: inherit; font-size: 14px; padding: 8px 10px; min-height: 40px;
border: 1px solid var(--border); border-radius: 8px; background: #fff; color: var(--text); width: 100%;
}
input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
.toggle { display: flex; gap: 10px; align-items: flex-start; font-size: 13px; margin-bottom: 14px; }
.toggle input { margin-top: 3px; width: 18px; height: 18px; flex: none; }
.btn {
font: inherit; font-weight: 600; border: 1px solid transparent; border-radius: 8px;
background: var(--accent); color: #fff; padding: 10px 18px; min-height: 42px; cursor: pointer;
}
.btn:disabled { opacity: .6; cursor: default; }
.btn.small { padding: 6px 12px; min-height: 34px; font-size: 13px; margin-left: auto; }
.btn.ghost { background: #fff; color: var(--text); border-color: var(--border); }
.btn.ghost.active { border-color: var(--accent); color: var(--accent); background: #f2f6ff; }
.sourceRow { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
#dropZone {
border: 1.5px dashed var(--border); border-radius: 10px; padding: 14px; margin-top: 12px;
display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between;
}
#dropZone.over { border-color: var(--accent); background: #f2f6ff; }
#fileLabel { font-size: 13px; }
.muted { color: var(--muted); }
.small { font-size: 12px; }
.runRow { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-top: 16px; }
.pill {
display: inline-block; padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 600;
background: #fef3c7; color: #92400e;
}
.banner { border-radius: 10px; padding: 12px 14px; font-size: 13px; margin-bottom: 16px; }
.banner.err { background: #fef2f2; border: 1px solid #fecaca; color: var(--danger); }
.banner.info { background: #f4f6fb; border: 1px solid #dde3f0; color: var(--muted); }
p.warn { background: #fffbeb; border: 1px solid #fde68a; color: var(--warn); padding: 10px 12px; border-radius: 8px; font-size: 13px; }
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; }
.kpi { border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px; }
.kpiLabel { display: block; font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
.kpiValue { display: block; font-size: 16px; font-weight: 650; margin-top: 3px; word-break: break-word; }
.tableWrap { overflow-x: auto; border: 1px solid var(--border); border-radius: 10px; }
table { border-collapse: collapse; width: 100%; font-size: 13px; }
th, td { padding: 8px 12px; border-bottom: 1px solid var(--border); white-space: nowrap; }
th { background: #faf9f7; text-align: left; cursor: pointer; user-select: none; font-weight: 600; position: sticky; top: 0; }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
tbody tr:last-child td { border-bottom: none; }
.chartWrap { width: 100%; overflow-x: auto; }
.chartWrap svg { width: 100%; min-width: 460px; height: auto; }
footer { color: var(--muted); font-size: 12px; margin-top: 24px; }
footer a { color: var(--accent); }
@media (max-width: 560px) { .wrap { padding: 16px 12px 48px; } .card { padding: 14px; } }
&lt;/style>
&lt;/head>
&lt;body>
&lt;div class="wrap">
&lt;header class="app">
&lt;div class="badge">Σ&lt;/div>
&lt;div>
&lt;h1>Using Replicate Weights&lt;/h1>
&lt;P>&lt;p style="color: red;">&lt;u>CAUTION&lt;/u>&lt;/p>I have not tested this. I had AI make this for an example in class... Doesn't look bad!&lt;/P>
&lt;P>Here are two example files for download: &lt;a href="example.csv" download>Example 1&lt;/a> (w PVs)| &lt;a href="timss_ex2.csv" download>Example 2&lt;/a> (no PVs)&lt;/P>
&lt;P>You can compare results for example 1 &lt;a href="https://francish.net/post/2026_rw2">here&lt;/a>.&lt;/P>
&lt;p>Survey estimates with replicate-weight standard errors — BRR/Fay, JK1, JK2 and bootstrap — plus
plausible-value imputation variance, as used in PISA, TIMSS and PIRLS.&lt;/p>
&lt;/div>
&lt;/header>
&lt;div id="error" class="banner err" hidden>&lt;/div>
&lt;section class="card">
&lt;h2>Survey data file&lt;/h2>
&lt;div class="sourceRow">
&lt;button id="demoBtn" class="btn ghost">Use demo file&lt;/button>
&lt;label class="btn ghost" style="cursor:pointer">Choose a file
&lt;input id="fileInput" type="file" accept=".csv,.tsv,.txt,.xlsx,.xls" hidden>
&lt;/label>
&lt;/div>
&lt;div id="dropZone">
&lt;div id="fileLabel" class="muted">Drop a file here, or choose one above.&lt;/div>
&lt;/div>
&lt;p class="muted small" style="margin-bottom:0">
One row per respondent, with a final weight and its replicate weight set. CSV, TSV and Excel are read
locally — &lt;strong>nothing is uploaded anywhere&lt;/strong>. SPSS &lt;code>.sav&lt;/code> files must be exported
to CSV first.
&lt;/p>
&lt;/section>
&lt;section class="card">
&lt;h2>Replicate design&lt;/h2>
&lt;div id="design" class="grid">&lt;/div>
&lt;div class="banner info" style="margin:14px 0 0">
Variance is the sum of squared replicate deviations, scaled by the estimator: 1/(G(1−k)²) for Fay BRR,
unscaled for paired JK2, (G−1)/G for JK1, 1/(G−1) around the replicate mean for bootstrap weights.
Use whichever your survey's technical report specifies.
&lt;/div>
&lt;/section>
&lt;section class="card">
&lt;h2>Statistic of interest&lt;/h2>
&lt;div id="statistic" class="grid">&lt;/div>
&lt;hr style="border:none;border-top:1px solid var(--border);margin:16px 0">
&lt;div id="analysis">&lt;/div>
&lt;div class="runRow">
&lt;button id="runBtn" class="btn">Run estimate&lt;/button>
&lt;span id="stale" class="pill" hidden>results out of date&lt;/span>
&lt;span id="runNote" class="muted small">&lt;/span>
&lt;/div>
&lt;/section>
&lt;div id="results">&lt;/div>
&lt;footer>
App runs entirely in your browser. Use with caution!
&lt;/footer>
&lt;/div>
&lt;script type="module">
// ── rwstats.js ──
/*
* rwstats.js — replicate-weight variance estimation in the browser.
*
* Pure ES module, no dependencies. Port of the Python/scipy estimator that
* backs the Replicate Weight Statistics Calculator mini-app:
* BRR / Fay, JK1, JK2, bootstrap replicate variance
* + plausible-value imputation variance by Rubin's rule.
*
* Data is held column-wise: { columns: string[], col: {name: any[]}, n: int }
*/
// ── normal distribution helpers ──────────────────────────────────────────────
// Cody (1969) rational Chebyshev approximation — full double precision, so
// tail p-values match scipy.stats.norm.sf rather than a 7-digit approximation.
const _A = [3.1611237438705656, 113.864154151050156, 377.485237685302021, 3209.37758913846947, 0.185777706184603153];
const _B = [23.6012909523441209, 244.024637934444173, 1282.61652607737228, 2844.23683343917062];
const _C = [0.564188496988670089, 8.88314979438837594, 66.1191906371416295, 298.635138197400131,
881.95222124176909, 1712.04761263407058, 2051.07837782607147, 1230.33935479799725, 2.15311535474403846e-8];
const _D = [15.7449261107098347, 117.693950891312499, 537.181101862009858, 1621.38957456669019,
3290.79923573345963, 4362.61909014324716, 3439.36767414372164, 1230.33935480374942];
const _P = [0.305326634961232344, 0.360344899949804439, 0.125781726111229246, 0.0160837851487422766,
6.58749161529837803e-4, 0.0163153871373020978];
const _Q = [2.56852019228982242, 1.87295284992346047, 0.527905102951428412, 0.0605183413124413191, 0.00233520497626869185];
const _SQRPI = 0.56418958354775628695;
function erfc(x) {
const y = Math.abs(x);
let num, den, res, z, del;
if (y &lt;= 0.46875) {
z = y > 1e-16 ? y * y : 0;
num = _A[4] * z; den = z;
for (let i = 0; i &lt; 3; i++) { num = (num + _A[i]) * z; den = (den + _B[i]) * z; }
return 1 - (x * (num + _A[3])) / (den + _B[3]);
}
if (y &lt;= 4) {
num = _C[8] * y; den = y;
for (let i = 0; i &lt; 7; i++) { num = (num + _C[i]) * y; den = (den + _D[i]) * y; }
res = (num + _C[7]) / (den + _D[7]);
} else {
z = 1 / (y * y);
num = _P[5] * z; den = z;
for (let i = 0; i &lt; 4; i++) { num = (num + _P[i]) * z; den = (den + _Q[i]) * z; }
res = (z * (num + _P[4])) / (den + _Q[4]);
res = (_SQRPI - res) / y;
}
z = Math.floor(y * 16) / 16;
del = (y - z) * (y + z);
res = Math.exp(-z * z) * Math.exp(-del) * res;
return x &lt; 0 ? 2 - res : res;
}
/** Upper tail of the standard normal, P(Z > x). */
function normSf(x) {
return 0.5 * erfc(x / Math.SQRT2);
}
/** Inverse standard normal CDF (Acklam) refined by one Halley step. */
function normPpf(p) {
if (!(p > 0 &amp;&amp; p &lt; 1)) return NaN;
const a = [-3.969683028665376e1, 2.209460984245205e2, -2.759285104469687e2, 1.38357751867269e2, -3.066479806614716e1, 2.506628277459239];
const b = [-5.447609879822406e1, 1.615858368580409e2, -1.556989798598866e2, 6.680131188771972e1, -1.328068155288572e1];
const c = [-7.784894002430293e-3, -3.223964580411365e-1, -2.400758277161838, -2.549732539343734, 4.374664141464968, 2.938163982698783];
const d = [7.784695709041462e-3, 3.224671290700398e-1, 2.445134137142996, 3.754408661907416];
const pLow = 0.02425, pHigh = 1 - pLow;
let q, r, x;
if (p &lt; pLow) {
q = Math.sqrt(-2 * Math.log(p));
x = (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) /
((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);
} else if (p &lt;= pHigh) {
q = p - 0.5; r = q * q;
x = (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q /
(((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1);
} else {
q = Math.sqrt(-2 * Math.log(1 - p));
x = -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) /
((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);
}
// Halley refinement against the true CDF
const e = 0.5 * erfc(-x / Math.SQRT2) - p;
const u = e * Math.sqrt(2 * Math.PI) * Math.exp((x * x) / 2);
return x - u / (1 + (x * u) / 2);
}
// ── weighted estimators ──────────────────────────────────────────────────────
function wMean(x, w, idx) {
let sw = 0, s = 0;
for (const i of idx) { sw += w[i]; s += w[i] * x[i]; }
return sw > 0 ? s / sw : NaN;
}
function wTotal(x, w, idx) {
let s = 0;
for (const i of idx) s += w[i] * x[i];
return s;
}
function wSd(x, w, idx) {
let sw = 0, s = 0;
for (const i of idx) { sw += w[i]; s += w[i] * x[i]; }
if (!(sw > 0)) return NaN;
const m = s / sw;
let v = 0;
for (const i of idx) v += w[i] * (x[i] - m) * (x[i] - m);
return Math.sqrt(Math.max(v / sw, 0));
}
/** Weighted quantile, lower-CDF convention (matches numpy searchsorted 'left'). */
function wQuantile(x, w, orderedIdx, q) {
let total = 0;
for (const i of orderedIdx) total += w[i];
if (!(total > 0)) return NaN;
const target = q * total;
let cum = 0;
for (const i of orderedIdx) {
cum += w[i];
if (cum >= target) return x[i];
}
return x[orderedIdx[orderedIdx.length - 1]];
}
/** Weighted least squares via normal equations, Gauss-Jordan with pivoting. */
function wls(X, y, w, idx, k) {
const A = Array.from({ length: k }, () => new Float64Array(k + 1));
for (const i of idx) {
const wi = w[i];
if (!(wi > 0)) continue;
const row = X[i];
for (let a = 0; a &lt; k; a++) {
const wa = wi * row[a];
for (let b = a; b &lt; k; b++) A[a][b] += wa * row[b];
A[a][k] += wa * y[i];
}
}
for (let a = 0; a &lt; k; a++) for (let b = 0; b &lt; a; b++) A[a][b] = A[b][a];
for (let c = 0; c &lt; k; c++) {
let piv = c;
for (let r = c + 1; r &lt; k; r++) if (Math.abs(A[r][c]) > Math.abs(A[piv][c])) piv = r;
if (Math.abs(A[piv][c]) &lt; 1e-12) return new Array(k).fill(NaN);
if (piv !== c) { const t = A[piv]; A[piv] = A[c]; A[c] = t; }
const d = A[c][c];
for (let j = c; j &lt;= k; j++) A[c][j] /= d;
for (let r = 0; r &lt; k; r++) {
if (r === c) continue;
const f = A[r][c];
if (f === 0) continue;
for (let j = c; j &lt;= k; j++) A[r][j] -= f * A[c][j];
}
}
return Array.from({ length: k }, (_, a) => A[a][k]);
}
// ── parsing ──────────────────────────────────────────────────────────────────
function toNum(v) {
if (typeof v === "number") return v;
if (v == null) return NaN;
const s = String(v).trim();
if (s === "" || s === "NA" || s === "NaN" || s === "." || s === "null") return NaN;
const n = Number(s);
return Number.isFinite(n) ? n : NaN;
}
function sniffSep(headerLine) {
const cands = [",", "\t", ";", "|"];
let best = ",", bestN = 0;
for (const c of cands) {
const n = headerLine.split(c).length - 1;
if (n > bestN) { best = c; bestN = n; }
}
return bestN === 0 ? "," : best;
}
/** RFC4180-ish delimited parser with quoted fields and delimiter sniffing. */
function parseDelimited(text) {
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
const nl = text.indexOf("\n");
const sep = sniffSep(nl === -1 ? text : text.slice(0, nl));
const rows = [];
let field = "", row = [], inQ = false;
for (let i = 0; i &lt; text.length; i++) {
const ch = text[i];
if (inQ) {
if (ch === '"') {
if (text[i + 1] === '"') { field += '"'; i++; } else inQ = false;
} else field += ch;
} else if (ch === '"') inQ = true;
else if (ch === sep) { row.push(field); field = ""; }
else if (ch === "\n") { row.push(field); rows.push(row); row = []; field = ""; }
else if (ch !== "\r") field += ch;
}
if (field !== "" || row.length) { row.push(field); rows.push(row); }
if (!rows.length) throw new Error("The file appears to be empty.");
const headers = rows[0].map((h) => h.trim());
const body = rows.slice(1).filter((r) => r.length > 1 || (r.length === 1 &amp;&amp; r[0] !== ""));
const col = {};
headers.forEach((h, j) => {
col[h] = body.map((r) => (j &lt; r.length ? r[j] : ""));
});
return { columns: headers.slice(), col, n: body.length, sep };
}
// ── fabricated demo file (PISA-style BRR design) ─────────────────────────────
function mulberry32(seed) {
let a = seed >>> 0;
return function () {
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function makeDemo() {
const rand = mulberry32(20260907);
const normal = () => {
let u = 0, v = 0;
while (u === 0) u = rand();
while (v === 0) v = rand();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
};
const pick = (levels, probs) => {
const r = rand();
let acc = 0;
for (let i = 0; i &lt; levels.length; i++) { acc += probs[i]; if (r &lt; acc) return levels[i]; }
return levels[levels.length - 1];
};
const nSchools = 160, perSchool = 20, G = 80;
const n = nSchools * perSchool;
const cntOfSchool = [], schoolEff = [], escsEff = [];
for (let s = 0; s &lt; nSchools; s++) {
cntOfSchool.push(pick(["Alphaland", "Betamark", "Gammia"], [0.4, 0.35, 0.25]));
schoolEff.push(normal() * 26);
escsEff.push(normal() * 0.35);
}
const columns = ["CNT", "SCHOOLID", "ST004D01T", "IMMIG", "ESCS", "GRADEREP", "W_FSTUWT"];
for (let m = 1; m &lt;= 5; m++) columns.push(`PV${m}MATH`);
for (let g = 1; g &lt;= G; g++) columns.push(`W_FSTURWT${g}`);
const col = {};
for (const c of columns) col[c] = new Array(n);
for (let i = 0; i &lt; n; i++) {
const schoolIdx = Math.floor(i / perSchool);
const school = schoolIdx + 1;
const zone = (schoolIdx % G) + 1;
const half = Math.floor(schoolIdx / G) % 2;
const cnt = cntOfSchool[schoolIdx];
const gender = pick(["Female", "Male"], [0.5, 0.5]);
const immig = pick(["Native", "Second-generation", "First-generation"], [0.82, 0.11, 0.07]);
const escs = Math.round((normal() + escsEff[schoolIdx]) * 1000) / 1000;
const w = Math.round(Math.exp(normal() * 0.35) * 42 * 1e4) / 1e4;
const latent =
472 +
(cnt === "Betamark" ? 21 : 0) +
(cnt === "Gammia" ? -14 : 0) +
29 * escs +
(gender === "Male" ? 5.5 : 0) +
(immig === "First-generation" ? -18 : 0) +
schoolEff[schoolIdx];
col.CNT[i] = cnt;
col.SCHOOLID[i] = school;
col.ST004D01T[i] = gender;
col.IMMIG[i] = immig;
col.ESCS[i] = escs;
col.GRADEREP[i] = pick(["No", "Yes"], [0.88, 0.12]);
col.W_FSTUWT[i] = w;
for (let m = 1; m &lt;= 5; m++) {
col[`PV${m}MATH`][i] = Math.round((latent + normal() * 29) * 1000) / 1000;
}
for (let g = 1; g &lt;= G; g++) {
const mult = zone === g ? (half === 1 ? 1.5 : 0.5) : 1;
col[`W_FSTURWT${g}`][i] = Math.round(w * mult * 1e4) / 1e4;
}
}
return { columns, col, n };
}
// ── the estimator ────────────────────────────────────────────────────────────
const cmp = (a, b) => (a &lt; b ? -1 : a > b ? 1 : 0);
function computeEstimates(data, p) {
const warnings = [];
const result = { error: null, warnings, meta: {}, rows: [] };
try {
const stat = p.statistic || "mean";
const method = String(p.method || "BRR").toUpperCase();
const fay = Number.isFinite(+p.fayFactor) ? +p.fayFactor : 0.5;
const ciLevel = Number.isFinite(+p.ciLevel) ? +p.ciLevel : 95;
const zcrit = normPpf(0.5 + ciLevel / 200);
const groupBy = (p.groupBy || "").trim();
const weightCol = (p.weightCol || "").trim();
const repPrefix = (p.repPrefix || "").trim();
const usePV = !!p.usePV;
const pvSuffix = (p.pvSuffix || "").trim();
const analysisVar = (p.analysisVar || "").trim();
const category = (p.category || "").trim();
const qpct = Number.isFinite(+p.quantile) ? +p.quantile : 50;
const predictors = String(p.predictors || "").split(",").map((s) => s.trim()).filter(Boolean);
const cols = data.columns;
const has = (c) => Object.prototype.hasOwnProperty.call(data.col, c);
if (!has(weightCol)) throw new Error(`Final weight column '${weightCol}' not found in the data.`);
// replicate weight columns
let repCols = [];
if (repPrefix) {
const esc = repPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\/*__BUNDLE__*/");
const re = new RegExp("^" + esc + "(\\d+)$");
repCols = cols.filter((c) => re.test(c)).sort((a, b) => +a.match(re)[1] - +b.match(re)[1]);
}
if (method !== "SRS" &amp;&amp; repCols.length &lt; 2) {
throw new Error(
`Found ${repCols.length} replicate weight columns for prefix '${repPrefix}'. ` +
"Check the prefix (e.g. W_FSTURWT for PISA, the JK replicate set for TIMSS)."
);
}
// analysis variable(s)
let yCols;
if (usePV) {
const re = new RegExp("^PV(\\d+)" + pvSuffix.replace(/[.*+?^${}()|[\]\\]/g, "\\/*__BUNDLE__*/") + "$", "i");
yCols = cols.filter((c) => re.test(c)).sort((a, b) => +a.match(re)[1] - +b.match(re)[1]);
if (yCols.length &lt; 2) {
throw new Error(`Expected several plausible-value columns matching PV&lt;n>${pvSuffix}; found ${yCols.length}.`);
}
} else {
if (!has(analysisVar)) throw new Error(`Analysis variable '${analysisVar}' not found in the data.`);
yCols = [analysisVar];
}
if (stat === "regression") {
if (!predictors.length) throw new Error("Add at least one predictor for the regression.");
for (const pr of predictors) if (!has(pr)) throw new Error(`Predictor '${pr}' not found in the data.`);
}
if (stat === "proportion" &amp;&amp; !category) {
throw new Error("Enter the category whose proportion you want (e.g. Female).");
}
if (groupBy &amp;&amp; !has(groupBy)) throw new Error(`Grouping variable '${groupBy}' not found in the data.`);
// ── listwise deletion ────────────────────────────────────────────────────
const nRaw = data.n;
const need = [weightCol, ...yCols, ...(stat === "regression" ? predictors : [])];
if (groupBy) need.push(groupBy);
const isBlank = (v) => v == null || (typeof v === "string" &amp;&amp; v.trim() === "") ||
(typeof v === "number" &amp;&amp; !Number.isFinite(v)) ||
(typeof v === "string" &amp;&amp; /^(na|nan|null)$/i.test(v.trim()));
const keep = [];
const wAll = data.col[weightCol].map(toNum);
for (let i = 0; i &lt; nRaw; i++) {
let ok = Number.isFinite(wAll[i]) &amp;&amp; wAll[i] > 0;
if (ok) for (const c of new Set(need)) { if (isBlank(data.col[c][i])) { ok = false; break; } }
if (ok &amp;&amp; stat !== "proportion") {
for (const c of yCols) { if (!Number.isFinite(toNum(data.col[c][i]))) { ok = false; break; } }
}
if (ok) keep.push(i);
}
if (!keep.length) throw new Error("No rows left after dropping missing weights / analysis values.");
if (keep.length &lt; nRaw) {
warnings.push(
`Dropped ${(nRaw - keep.length).toLocaleString()} of ${nRaw.toLocaleString()} rows with missing weights, ` +
"analysis values, predictors or grouping values (listwise deletion)."
);
}
// materialise the kept rows into typed arrays
const N = keep.length;
const w0 = new Float64Array(N);
for (let i = 0; i &lt; N; i++) w0[i] = wAll[keep[i]];
const G = repCols.length;
const Wrep = repCols.map((c) => {
const src = data.col[c];
const arr = new Float64Array(N);
for (let i = 0; i &lt; N; i++) { const v = toNum(src[keep[i]]); arr[i] = Number.isFinite(v) ? v : 0; }
return arr;
});
const yNum = {};
const yStr = {};
for (const c of yCols) {
const src = data.col[c];
if (stat === "proportion") {
yStr[c] = keep.map((i) => String(src[i]));
} else {
const arr = new Float64Array(N);
for (let i = 0; i &lt; N; i++) arr[i] = toNum(src[keep[i]]);
yNum[c] = arr;
}
}
// ── groups ───────────────────────────────────────────────────────────────
let groups;
if (groupBy) {
const gv = keep.map((i) => String(data.col[groupBy][i]));
const levels = Array.from(new Set(gv)).sort(cmp);
groups = levels.map((L) => [L, gv.map((v, i) => (v === L ? i : -1)).filter((i) => i >= 0)]);
} else {
groups = [["Overall", Array.from({ length: N }, (_, i) => i)]];
}
// ── regression design (weights change between replicates, X does not) ────
function buildDesign(idx) {
const parts = [], names = ["(Intercept)"];
parts.push(null); // intercept handled below
for (const pr of predictors) {
const src = data.col[pr];
const vals = idx.map((i) => src[keep[i]]);
const numericFrac = vals.filter((v) => Number.isFinite(toNum(v))).length / (vals.length || 1);
if (numericFrac > 0.95) {
parts.push({ kind: "num", vals: vals.map(toNum) });
names.push(pr);
} else {
const lev = Array.from(new Set(vals.map((v) => String(v)))).sort(cmp);
for (let li = 1; li &lt; lev.length; li++) {
parts.push({ kind: "dum", vals: vals.map((v) => (String(v) === lev[li] ? 1 : 0)) });
names.push(`${pr}[${lev[li]}] vs [${lev[0]}]`);
}
}
}
const k = names.length;
const X = new Array(N);
idx.forEach((rowIdx, j) => {
const row = new Float64Array(k);
row[0] = 1;
for (let a = 1; a &lt; k; a++) row[a] = parts[a].vals[j];
X[rowIdx] = row;
});
return { X, names, k };
}
// ── one estimate over a weight vector ────────────────────────────────────
function estimate(ycol, w, idx, design, order) {
if (stat === "regression") {
return wls(design.X, yNum[ycol], w, idx, design.k);
}
if (stat === "proportion") {
const s = yStr[ycol];
let sw = 0, num = 0;
for (const i of idx) { sw += w[i]; if (s[i] === category) num += w[i]; }
return [sw > 0 ? num / sw : NaN];
}
const x = yNum[ycol];
if (stat === "mean") return [wMean(x, w, idx)];
if (stat === "total") return [wTotal(x, w, idx)];
if (stat === "sd") return [wSd(x, w, idx)];
if (stat === "quantile") return [wQuantile(x, w, order, qpct / 100)];
throw new Error(`Unknown statistic '${stat}'.`);
}
function samplingVar(dev2) {
if (method === "BRR") return dev2 / (G * Math.pow(1 - fay, 2));
if (method === "JK2") return dev2;
if (method === "JK1") return (dev2 * (G - 1)) / G;
if (method === "BOOTSTRAP") return dev2 / (G - 1);
throw new Error(`Unknown variance method '${method}'.`);
}
// ── loop over groups × plausible values × replicates ─────────────────────
const rows = [];
for (const [gname, idx] of groups) {
const design = stat === "regression" ? buildDesign(idx) : null;
const terms =
stat === "regression" ? design.names
: stat === "proportion" ? [`P(${category})`]
: stat === "mean" ? ["Mean"]
: stat === "total" ? ["Total"]
: stat === "sd" ? ["SD"]
: [`P${qpct}`];
const T = terms.length;
const estM = terms.map(() => []);
const varM = terms.map(() => []);
let sumW = 0;
for (const i of idx) sumW += w0[i];
for (const ycol of yCols) {
// stable order by x for the quantile estimator
let order = null;
if (stat === "quantile") {
const x = yNum[ycol];
order = idx.slice().sort((a, b) => (x[a] - x[b]) || (a - b));
}
const full = estimate(ycol, w0, idx, design, order);
const dev2 = new Float64Array(T);
if (method !== "SRS") {
const reps = new Array(G);
for (let r = 0; r &lt; G; r++) reps[r] = estimate(ycol, Wrep[r], idx, design, order);
const centre = new Float64Array(T);
if (method === "BOOTSTRAP") {
for (let t = 0; t &lt; T; t++) {
let s = 0, c = 0;
for (let r = 0; r &lt; G; r++) if (Number.isFinite(reps[r][t])) { s += reps[r][t]; c++; }
centre[t] = c ? s / c : NaN;
}
} else {
for (let t = 0; t &lt; T; t++) centre[t] = full[t];
}
for (let t = 0; t &lt; T; t++) {
let s = 0;
for (let r = 0; r &lt; G; r++) {
const d = reps[r][t] - centre[t];
if (Number.isFinite(d)) s += d * d;
}
dev2[t] = s;
}
}
for (let t = 0; t &lt; T; t++) {
estM[t].push(full[t]);
if (method === "SRS") {
if (stat === "mean") {
const x = yNum[ycol];
let sw = 0, sw2 = 0, ss = 0;
for (const i of idx) { sw += w0[i]; sw2 += w0[i] * w0[i]; ss += w0[i] * Math.pow(x[i] - full[t], 2); }
varM[t].push(((ss / sw) * sw2) / (sw * sw));
} else varM[t].push(NaN);
} else varM[t].push(samplingVar(dev2[t]));
}
}
const M = yCols.length;
for (let t = 0; t &lt; T; t++) {
const thetas = estM[t].filter(Number.isFinite);
const theta = thetas.length ? thetas.reduce((a, b) => a + b, 0) / thetas.length : NaN;
const vs = varM[t].filter(Number.isFinite);
const vSamp = vs.length ? vs.reduce((a, b) => a + b, 0) / vs.length : NaN;
let vImp = 0;
if (M > 1) {
let B = 0;
for (const th of estM[t]) if (Number.isFinite(th)) B += Math.pow(th - theta, 2);
B /= M - 1;
vImp = (1 + 1 / M) * B;
}
const vTot = vSamp + vImp;
const se = Number.isFinite(vTot) &amp;&amp; vTot >= 0 ? Math.sqrt(vTot) : NaN;
const seS = Number.isFinite(vSamp) &amp;&amp; vSamp >= 0 ? Math.sqrt(vSamp) : NaN;
const seI = Number.isFinite(vImp) &amp;&amp; vImp >= 0 ? Math.sqrt(vImp) : NaN;
const tval = Number.isFinite(se) &amp;&amp; se > 0 ? theta / se : NaN;
const pval = Number.isFinite(tval) ? 2 * normSf(Math.abs(tval)) : NaN;
rows.push({
group: gname,
term: terms[t],
n: idx.length,
sumWeights: sumW,
estimate: theta,
se,
seSampling: seS,
seImputation: seI,
ciLo: Number.isFinite(se) ? theta - zcrit * se : NaN,
ciHi: Number.isFinite(se) ? theta + zcrit * se : NaN,
t: tval,
p: pval,
pctVarImputation: vTot > 0 ? (100 * vImp) / vTot : 0,
});
}
}
// ── column inventory (drives the dropdowns and the category picker) ──────
const repSet = new Set(repCols);
const colInfo = [];
for (const c of cols) {
if (repSet.has(c)) continue;
const src = data.col[c];
const sample = keep.length > 4000 ? keep.filter((_, i) => i % Math.ceil(keep.length / 4000) === 0) : keep;
const vals = sample.map((i) => src[i]);
const numericFrac = vals.filter((v) => Number.isFinite(toNum(v))).length / (vals.length || 1);
const uniq = new Set(vals.map((v) => String(v)));
const entry = { name: c, kind: numericFrac > 0.9 ? "numeric" : "other", levels: null };
if (uniq.size &lt;= 25) {
if (entry.kind !== "numeric" || uniq.size &lt;= 12) entry.kind = "categorical";
entry.levels = Array.from(uniq).sort(cmp).slice(0, 25);
}
colInfo.push(entry);
}
let sumAll = 0;
for (let i = 0; i &lt; N; i++) sumAll += w0[i];
result.meta = {
source: p.sourceName || "(unnamed file)",
statistic: stat,
method,
fayFactor: method === "BRR" ? fay : null,
nReplicates: G,
weightColumn: weightCol,
repPrefix,
yColumns: yCols,
plausibleValues: usePV &amp;&amp; yCols.length > 1,
nPlausibleValues: yCols.length,
groupBy: groupBy || null,
nRows: N,
nRowsRaw: nRaw,
sumWeights: sumAll,
ciLevel,
quantile: stat === "quantile" ? qpct : null,
category: stat === "proportion" ? category : null,
predictors: stat === "regression" ? predictors : [],
columns: colInfo,
};
result.rows = rows;
} catch (e) {
result.error = `${e &amp;&amp; e.name ? e.name : "Error"}: ${e &amp;&amp; e.message ? e.message : String(e)}`;
}
return result;
}
/** Results table → CSV text, same columns as the mini-app's export. */
function rowsToCsv(rows) {
const keys = ["group", "term", "n", "sumWeights", "estimate", "se", "seSampling", "seImputation",
"ciLo", "ciHi", "t", "p", "pctVarImputation"];
const esc = (v) => {
if (v == null || (typeof v === "number" &amp;&amp; !Number.isFinite(v))) return "";
const s = String(v);
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
return [keys.join(","), ...rows.map((r) => keys.map((k) => esc(r[k])).join(","))].join("\n") + "\n";
}
// ── app.js ──
/* app.js — UI layer for the standalone Replicate Weight Statistics Calculator. */
const SHEETJS_CDN = "https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js";
const METHODS = [
["BRR", "BRR / Fay — PISA, PIAAC"],
["JK2", "JK2 paired jackknife — TIMSS, PIRLS"],
["JK1", "JK1 jackknife — one PSU dropped per replicate"],
["BOOTSTRAP", "Bootstrap replicate weights"],
["SRS", "No replicates (SRS approximation, mean only)"],
];
const STATISTICS = [
["mean", "Mean"],
["total", "Population total (sum of w·x)"],
["proportion", "Proportion in a category"],
["quantile", "Percentile / median"],
["sd", "Standard deviation"],
["regression", "Weighted linear regression (OLS)"],
];
const P = {
weightCol: "W_FSTUWT",
repPrefix: "W_FSTURWT",
method: "BRR",
fayFactor: 0.5,
ciLevel: 95,
statistic: "mean",
usePV: true,
pvSuffix: "MATH",
analysisVar: "ESCS",
category: "",
quantile: 50,
predictors: "ESCS, ST004D01T",
groupBy: "CNT",
};
let DATA = null; // { columns, col, n }
let SOURCE = ""; // display name of the loaded file
let RESULT = null;
let SORT = { key: null, dir: 1 };
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s).replace(/[&amp;&lt;>"]/g, (c) => ({ "&amp;": "&amp;amp;", "&lt;": "&amp;lt;", ">": "&amp;gt;", '"': "&amp;quot;" }[c]));
function fmt(v, d = 3) {
if (v == null || !Number.isFinite(v)) return "—";
const a = Math.abs(v);
if (a !== 0 &amp;&amp; (a >= 1e6 || a &lt; 1e-4)) return v.toExponential(2);
return v.toLocaleString(undefined, { minimumFractionDigits: d, maximumFractionDigits: d });
}
function fmtP(v) {
if (v == null || !Number.isFinite(v)) return "—";
return v &lt; 0.001 ? "&amp;lt; 0.001" : v.toFixed(3);
}
// ── data loading ─────────────────────────────────────────────────────────────
function repColsFor(prefix, columns) {
if (!prefix) return [];
const re = new RegExp("^" + prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\/*__BUNDLE__*/") + "(\\d+)$");
return columns.filter((c) => re.test(c));
}
/** Guess the design columns from the header row, PISA/TIMSS style. */
function autodetect(columns) {
const groups = new Map();
for (const h of columns) {
const m = h.match(/^(.*?[A-Za-z_])(\d+)$/);
if (m) groups.set(m[1], (groups.get(m[1]) || 0) + 1);
}
let prefix = "", best = 0;
for (const [pre, n] of groups) if (n >= 10 &amp;&amp; n > best) { prefix = pre; best = n; }
const weight =
columns.find((h) => /^(w_fstuwt|totwgt|senwgt|spfwt0|finalwgt|weight)$/i.test(h)) ||
columns.find((h) => /wgt|weight/i.test(h) &amp;&amp; !/\d+$/.test(h)) ||
"";
const pv = new Set();
for (const h of columns) { const m = h.match(/^PV(\d+)(.+)$/i); if (m) pv.add(m[2]); }
const suffixes = Array.from(pv).sort();
if (prefix) P.repPrefix = prefix;
if (weight) P.weightCol = weight;
if (suffixes.length) { P.usePV = true; P.pvSuffix = suffixes[0]; }
else P.usePV = false;
const nonRep = columns.filter((c) => !repColsFor(prefix, columns).includes(c));
if (!nonRep.includes(P.analysisVar)) {
P.analysisVar = nonRep.find((c) => c !== weight &amp;&amp; c !== P.groupBy) || nonRep[0] || "";
}
if (!nonRep.includes(P.groupBy)) P.groupBy = "";
const preds = P.predictors.split(",").map((s) => s.trim()).filter((s) => nonRep.includes(s));
P.predictors = preds.join(", ");
}
function loadDemo() {
DATA = makeDemo();
SOURCE = "Fabricated PISA-style demo file (3,200 students, 160 schools)";
Object.assign(P, {
weightCol: "W_FSTUWT", repPrefix: "W_FSTURWT", usePV: true, pvSuffix: "MATH",
analysisVar: "ESCS", groupBy: "CNT", predictors: "ESCS, ST004D01T",
});
$("fileLabel").innerHTML =
'&lt;strong>Demo file&lt;/strong> — 3,200 students, 160 schools, 80 BRR replicate weights, 5 plausible values' +
'&lt;br>&lt;span class="muted">Fabricated PISA-style data — illustrative only, not from a real assessment&lt;/span>';
$("demoBtn").classList.add("active");
renderControls();
run();
}
function loadScript(src) {
return new Promise((resolve, reject) => {
const s = document.createElement("script");
s.src = src;
s.onload = resolve;
s.onerror = () => reject(new Error("could not load the spreadsheet reader from the network"));
document.head.appendChild(s);
});
}
async function loadFile(file) {
setError("");
const name = file.name;
try {
if (/\.sav$/i.test(name)) {
throw new Error(
"SPSS .sav files aren't readable in the browser. Export the file to CSV first " +
"(in SPSS: File → Save As → Comma delimited, or in R: haven::read_sav() then write.csv())."
);
}
if (/\.(xlsx|xls)$/i.test(name)) {
if (!window.XLSX) await loadScript(SHEETJS_CDN);
const buf = await file.arrayBuffer();
const wb = window.XLSX.read(buf, { type: "array" });
const sheet = wb.Sheets[wb.SheetNames[0]];
const aoa = window.XLSX.utils.sheet_to_json(sheet, { header: 1, raw: true, defval: "" });
const headers = aoa[0].map((h) => String(h).trim());
const col = {};
headers.forEach((h, j) => { col[h] = aoa.slice(1).map((r) => (j &lt; r.length ? r[j] : "")); });
DATA = { columns: headers, col, n: aoa.length - 1 };
} else {
DATA = parseDelimited(await file.text());
}
SOURCE = name;
autodetect(DATA.columns);
$("demoBtn").classList.remove("active");
const reps = repColsFor(P.repPrefix, DATA.columns).length;
$("fileLabel").innerHTML =
`&lt;strong>${esc(name)}&lt;/strong> — ${DATA.n.toLocaleString()} rows, ${DATA.columns.length} columns` +
`&lt;br>&lt;span class="muted">${reps ? `${reps} replicate weight columns matched on “${esc(P.repPrefix)}”` :
"no replicate weight set auto-detected — set the prefix below"}&lt;/span>`;
renderControls();
run();
} catch (e) {
setError(e.message || String(e));
}
}
// ── controls ─────────────────────────────────────────────────────────────────
function selectHtml(id, value, options, blankLabel) {
const opts = [];
if (blankLabel) opts.push(`&lt;option value=""${value === "" ? " selected" : ""}>${esc(blankLabel)}&lt;/option>`);
for (const o of options) {
const [v, l] = Array.isArray(o) ? o : [o, o];
opts.push(`&lt;option value="${esc(v)}"${v === value ? " selected" : ""}>${esc(l)}&lt;/option>`);
}
if (value &amp;&amp; !options.some((o) => (Array.isArray(o) ? o[0] : o) === value)) {
opts.push(`&lt;option value="${esc(value)}" selected>${esc(value)} (typed)&lt;/option>`);
}
return `&lt;select id="${id}">${opts.join("")}&lt;/select>`;
}
function field(label, hint, control) {
return `&lt;label class="field">&lt;span class="lbl">${esc(label)}${
hint ? ` &lt;span class="hint">${esc(hint)}&lt;/span>` : ""
}&lt;/span>${control}&lt;/label>`;
}
function levelsFor(colName) {
const info = RESULT &amp;&amp; RESULT.meta &amp;&amp; RESULT.meta.columns;
const hit = info &amp;&amp; info.find((c) => c.name === colName);
if (hit &amp;&amp; hit.levels &amp;&amp; hit.levels.length) return hit.levels;
if (!DATA || !DATA.col[colName]) return null;
const seen = new Set();
for (let i = 0; i &lt; DATA.n &amp;&amp; seen.size &lt;= 26; i++) seen.add(String(DATA.col[colName][i]));
return seen.size &lt;= 25 ? Array.from(seen).sort() : null;
}
function renderControls() {
if (!DATA) return;
const cols = DATA.columns;
const repSet = new Set(repColsFor(P.repPrefix, cols));
const nonRep = cols.filter((c) => !repSet.has(c));
const nReps = repSet.size;
const pvSuffixes = [];
const seen = new Set();
for (const c of cols) {
const m = c.match(/^PV(\d+)(.+)$/i);
if (m &amp;&amp; !seen.has(m[2])) { seen.add(m[2]); pvSuffixes.push(m[2]); }
}
pvSuffixes.sort();
$("design").innerHTML =
field("Final (full-sample) weight", "PISA: W_FSTUWT · TIMSS: TOTWGT", selectHtml("weightCol", P.weightCol, nonRep)) +
field("Replicate weight prefix", nReps ? `${nReps} replicate columns matched` : "columns are prefix + 1, 2, 3 …",
`&lt;input id="repPrefix" type="text" value="${esc(P.repPrefix)}" placeholder="W_FSTURWT">`) +
field("Variance estimator", "", selectHtml("method", P.method, METHODS)) +
(P.method === "BRR"
? field("Fay factor k", "PISA uses k = 0.5", `&lt;input id="fayFactor" type="number" min="0" max="0.99" step="0.05" value="${P.fayFactor}">`)
: "") +
field("Confidence level", "%", `&lt;input id="ciLevel" type="number" min="50" max="99.9" step="1" value="${P.ciLevel}">`);
const catLevels = levelsFor(P.usePV ? "" : P.analysisVar);
$("statistic").innerHTML =
field("Statistic", "", selectHtml("stat", P.statistic, STATISTICS)) +
field("Break down by", "blank = whole sample", selectHtml("groupBy", P.groupBy, nonRep, "— whole sample —")) +
(P.statistic === "quantile"
? field("Percentile", "50 = median", `&lt;input id="quantile" type="number" min="1" max="99" step="1" value="${P.quantile}">`)
: "");
$("analysis").innerHTML =
`&lt;label class="toggle">&lt;input id="usePV" type="checkbox"${P.usePV ? " checked" : ""}>
&lt;span>&lt;strong>Analysis variable is a plausible-value set&lt;/strong>&lt;br>
&lt;span class="muted">Averages the estimate over all PV&amp;lt;n&amp;gt; columns and adds imputation variance by
Rubin's rule: V = V̄&lt;sub>sampling&lt;/sub> + (1 + 1/M)·B.&lt;/span>&lt;/span>&lt;/label>
&lt;div class="grid">` +
(P.usePV
? pvSuffixes.length
? field("Plausible-value scale", `columns PV1…M${P.pvSuffix}`,
selectHtml("pvSuffix", P.pvSuffix, pvSuffixes.map((s) => [s, `PV&lt;n>${s}`])))
: field("Plausible-value scale suffix", "e.g. MATH, or ASMMAT for TIMSS",
`&lt;input id="pvSuffix" type="text" value="${esc(P.pvSuffix)}">`)
: field(P.statistic === "regression" ? "Dependent variable" : "Analysis variable", "",
selectHtml("analysisVar", P.analysisVar, nonRep))) +
(P.statistic === "proportion"
? field("Category", "the level whose proportion you want",
catLevels
? selectHtml("category", P.category || catLevels[0], catLevels)
: `&lt;input id="category" type="text" value="${esc(P.category)}" placeholder="Female">`)
: "") +
(P.statistic === "regression"
? field("Predictors", "comma-separated; text columns become dummies",
`&lt;input id="predictors" type="text" value="${esc(P.predictors)}" placeholder="ESCS, ST004D01T">`)
: "") +
`&lt;/div>`;
$("runNote").textContent =
P.method === "SRS"
? "SRS mode ignores the replicate weights."
: `${nReps || "?"} replicate estimates${P.usePV ? " × each plausible value" : ""} per group.`;
// wire every control
const bind = (id, key, cast) => {
const el = $(id);
if (!el) return;
const ev = el.tagName === "SELECT" || el.type === "checkbox" ? "change" : "input";
el.addEventListener(ev, () => {
P[key] = cast ? cast(el) : el.value;
if (["method", "stat", "usePV", "repPrefix", "analysisVar"].includes(id)) renderControls();
markStale();
});
};
bind("weightCol", "weightCol");
bind("repPrefix", "repPrefix");
bind("method", "method");
bind("fayFactor", "fayFactor", (el) => +el.value);
bind("ciLevel", "ciLevel", (el) => +el.value);
bind("stat", "statistic");
bind("groupBy", "groupBy");
bind("quantile", "quantile", (el) => +el.value);
bind("usePV", "usePV", (el) => el.checked);
bind("pvSuffix", "pvSuffix");
bind("analysisVar", "analysisVar");
bind("category", "category");
bind("predictors", "predictors");
}
function markStale() {
if (RESULT &amp;&amp; RESULT.rows &amp;&amp; RESULT.rows.length) $("stale").hidden = false;
}
function setError(msg) {
const box = $("error");
box.hidden = !msg;
box.innerHTML = msg ? `&lt;strong>Run failed&lt;/strong>&lt;br>${esc(msg)}` : "";
}
// ── run + render ─────────────────────────────────────────────────────────────
function run() {
if (!DATA) { setError("Load a data file, or use the demo file, first."); return; }
const btn = $("runBtn");
btn.disabled = true;
btn.textContent = "Estimating…";
setError("");
// yield once so the button state paints before the (synchronous) compute
setTimeout(() => {
const t0 = performance.now();
const out = computeEstimates(DATA, { ...P, sourceName: SOURCE });
const secs = (performance.now() - t0) / 1000;
btn.disabled = false;
btn.textContent = "Run estimate";
$("stale").hidden = true;
if (out.error) {
RESULT = out;
setError(out.error);
$("results").innerHTML = "";
return;
}
RESULT = out;
SORT = { key: null, dir: 1 };
renderResults(secs);
renderControls();
}, 10);
}
function renderResults(secs) {
const { meta, rows, warnings } = RESULT;
const multiTerm = new Set(rows.map((r) => r.term)).size > 1;
const statLabel = (STATISTICS.find((s) => s[0] === meta.statistic) || ["", meta.statistic])[1];
const stats = [
["Statistic", statLabel.split(" ")[0]],
["Variance method", meta.method === "BRR" ? `BRR, k = ${meta.fayFactor}` : meta.method],
["Replicate weights", meta.nReplicates],
["Plausible values", meta.plausibleValues ? meta.nPlausibleValues : "none"],
["Respondents used", meta.nRows.toLocaleString()],
["Σ final weights", Math.round(meta.sumWeights).toLocaleString()],
["Analysis variable(s)", meta.yColumns.length > 2
? `${meta.yColumns[0]} … ${meta.yColumns[meta.yColumns.length - 1]}` : meta.yColumns.join(", ")],
["Source", meta.source],
];
const cols = [
["group", meta.groupBy || "Group", "l"],
...(multiTerm ? [["term", "Term", "l"]] : []),
["estimate", "Estimate", "r"],
["se", "SE (total)", "r"],
["seSampling", "SE sampling", "r"],
["seImputation", "SE imputation", "r"],
["ciLo", `${meta.ciLevel}% CI`, "r"],
["t", "t", "r"],
["p", "p", "r"],
["n", "n", "r"],
["sumWeights", "Σ weights", "r"],
];
const sorted = rows.slice();
if (SORT.key) {
sorted.sort((a, b) => {
const x = a[SORT.key], y = b[SORT.key];
if (typeof x === "string" || typeof y === "string") return SORT.dir * String(x).localeCompare(String(y));
const xn = Number.isFinite(x) ? x : -Infinity, yn = Number.isFinite(y) ? y : -Infinity;
return SORT.dir * (xn - yn);
});
}
const cell = (r, key) => {
if (key === "ciLo") return `${fmt(r.ciLo)} – ${fmt(r.ciHi)}`;
if (key === "p") return fmtP(r.p);
if (key === "t") return fmt(r.t, 2);
if (key === "n") return r.n.toLocaleString();
if (key === "sumWeights") return Math.round(r.sumWeights).toLocaleString();
if (key === "group" || key === "term") return esc(r[key]);
return fmt(r[key]);
};
$("results").innerHTML = `
&lt;section class="card">
&lt;h2>What was estimated&lt;/h2>
&lt;div class="kpis">${stats.map(([l, v]) =>
`&lt;div class="kpi">&lt;span class="kpiLabel">${esc(l)}&lt;/span>&lt;span class="kpiValue">${esc(String(v))}&lt;/span>&lt;/div>`).join("")}&lt;/div>
${(warnings || []).map((w) => `&lt;p class="warn">${esc(w)}&lt;/p>`).join("")}
&lt;/section>
&lt;section class="card">
&lt;h2>Estimates and standard errors
&lt;button id="csvBtn" class="btn small">Download CSV&lt;/button>&lt;/h2>
&lt;div class="tableWrap">&lt;table>
&lt;thead>&lt;tr>${cols.map(([k, l, a]) =>
`&lt;th class="${a === "r" ? "num" : ""}" data-sort="${k}">${esc(l)}${
SORT.key === k ? (SORT.dir > 0 ? " ▲" : " ▼") : ""}&lt;/th>`).join("")}&lt;/tr>&lt;/thead>
&lt;tbody>${sorted.map((r) => `&lt;tr>${cols.map(([k, , a]) =>
`&lt;td class="${a === "r" ? "num" : ""}">${cell(r, k)}&lt;/td>`).join("")}&lt;/tr>`).join("")}&lt;/tbody>
&lt;/table>&lt;/div>
&lt;p class="muted small">Computed in ${secs.toFixed(2)} s, entirely in this browser tab.&lt;/p>
&lt;/section>
&lt;section class="card">
&lt;h2>Estimates with ${meta.ciLevel}% confidence intervals&lt;/h2>
${chartSvg(rows, multiTerm)}
&lt;/section>`;
$("csvBtn").addEventListener("click", () => {
const blob = new Blob([rowsToCsv(rows)], { type: "text/csv" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "replicate_weight_estimates.csv";
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
});
document.querySelectorAll("th[data-sort]").forEach((th) => {
th.addEventListener("click", () => {
const k = th.dataset.sort;
SORT = { key: k, dir: SORT.key === k ? -SORT.dir : 1 };
renderResults(secs);
});
});
}
// ── SVG estimate ± CI chart ──────────────────────────────────────────────────
function chartSvg(rows, multiTerm) {
const W = 820, H = 420, mL = 78, mR = 20, mT = 16, mB = 110;
const iw = W - mL - mR, ih = H - mT - mB;
const labels = rows.map((r) => (multiTerm ? `${r.group} · ${r.term}` : r.group));
const lo = rows.map((r) => (Number.isFinite(r.ciLo) ? r.ciLo : r.estimate));
const hi = rows.map((r) => (Number.isFinite(r.ciHi) ? r.ciHi : r.estimate));
const vals = rows.map((r) => (Number.isFinite(r.estimate) ? r.estimate : 0));
let yMin = Math.min(0, ...lo.filter(Number.isFinite));
let yMax = Math.max(0, ...hi.filter(Number.isFinite));
if (!Number.isFinite(yMin) || !Number.isFinite(yMax) || yMin === yMax) { yMin = 0; yMax = 1; }
const pad = (yMax - yMin) * 0.08;
yMin -= pad; yMax += pad;
const y = (v) => mT + ih - ((v - yMin) / (yMax - yMin)) * ih;
const n = rows.length;
const slot = iw / n;
const bw = Math.min(64, slot * 0.55);
// y gridlines at ~6 "nice" steps
const raw = (yMax - yMin) / 6;
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) || mag * 10;
const ticks = [];
for (let v = Math.ceil(yMin / step) * step; v &lt;= yMax; v += step) ticks.push(v);
const bars = rows.map((r, i) => {
const cx = mL + slot * (i + 0.5);
const y0 = y(0), yv = y(vals[i]);
const barTop = Math.min(y0, yv), barH = Math.abs(y0 - yv);
const eLo = Number.isFinite(r.ciLo) ? y(r.ciLo) : null;
const eHi = Number.isFinite(r.ciHi) ? y(r.ciHi) : null;
const errs = eLo != null &amp;&amp; eHi != null
? `&lt;line x1="${cx}" x2="${cx}" y1="${eLo}" y2="${eHi}" stroke="#2a2722" stroke-width="1.4"/>
&lt;line x1="${cx - 5}" x2="${cx + 5}" y1="${eLo}" y2="${eLo}" stroke="#2a2722" stroke-width="1.4"/>
&lt;line x1="${cx - 5}" x2="${cx + 5}" y1="${eHi}" y2="${eHi}" stroke="#2a2722" stroke-width="1.4"/>` : "";
const tick = labels[i];
const rot = n > 4 ? ` transform="rotate(-35 ${cx} ${mT + ih + 16})"` : "";
return `&lt;rect x="${cx - bw / 2}" y="${barTop}" width="${bw}" height="${Math.max(barH, 0.5)}" fill="#636efa" rx="2">
&lt;title>${esc(tick)}: ${fmt(r.estimate)} (${fmt(r.ciLo)} – ${fmt(r.ciHi)})&lt;/title>&lt;/rect>
${errs}
&lt;text x="${cx}" y="${mT + ih + 16}" text-anchor="${n > 4 ? "end" : "middle"}"${rot}
font-size="11" fill="#777">${esc(tick.length > 26 ? tick.slice(0, 25) + "…" : tick)}&lt;/text>`;
}).join("");
return `&lt;div class="chartWrap">&lt;svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" role="img"
aria-label="Estimates with confidence intervals">
&lt;rect x="${mL}" y="${mT}" width="${iw}" height="${ih}" fill="#fafafa"/>
${ticks.map((t) => `&lt;line x1="${mL}" x2="${mL + iw}" y1="${y(t)}" y2="${y(t)}" stroke="#f0f0f0"/>
&lt;text x="${mL - 8}" y="${y(t) + 4}" text-anchor="end" font-size="11" fill="#777">${fmt(t, 2)}&lt;/text>`).join("")}
&lt;line x1="${mL}" x2="${mL + iw}" y1="${y(0)}" y2="${y(0)}" stroke="#ddd"/>
${bars}
&lt;line x1="${mL}" x2="${mL}" y1="${mT}" y2="${mT + ih}" stroke="#ddd"/>
&lt;text transform="rotate(-90 16 ${mT + ih / 2})" x="16" y="${mT + ih / 2}" text-anchor="middle"
font-size="13" fill="#555">Estimate&lt;/text>
&lt;/svg>&lt;/div>`;
}
// ── boot ─────────────────────────────────────────────────────────────────────
$("demoBtn").addEventListener("click", loadDemo);
$("fileInput").addEventListener("change", (e) => {
const f = e.target.files &amp;&amp; e.target.files[0];
if (f) loadFile(f);
});
$("runBtn").addEventListener("click", run);
const drop = $("dropZone");
["dragenter", "dragover"].forEach((ev) => drop.addEventListener(ev, (e) => {
e.preventDefault(); drop.classList.add("over");
}));
["dragleave", "drop"].forEach((ev) => drop.addEventListener(ev, (e) => {
e.preventDefault(); drop.classList.remove("over");
}));
drop.addEventListener("drop", (e) => {
const f = e.dataTransfer.files &amp;&amp; e.dataTransfer.files[0];
if (f) loadFile(f);
});
loadDemo();
&lt;/script>
&lt;/body>
&lt;/html></description></item></channel></rss>