P5b: self-contained HTML QC report and QC table (simurg qc report)

- pipeline/qc_report: dataset discovery/labels/types (QC-041..043), robust TSV/CSV reading (QC-061),
  join + unmatched warning (QC-040), privacy options (QC-046), xlsx (4 sheets, conditional fills) / csv
  table (QC-045), byte-deterministic HTML with gzip+base64 embedded data (QC-044, QC-047)
- qc/report: template, CSS and JS app — Control Dashboard, Automated QC (table with sort/filter/search/
  paging/column menu/export, metric scatters with threshold lines), Sample Heatmaps (chip/plate,
  continuous/binary), Trend Analysis (grouped summary, Tukey box plots, counts, sex, PCA), thresholds modal
  (apply / download / upload YAML), linked selection across plots and table (QC-048..060)
- bundled plotly.js 2.35.2 (MIT); openpyxl dependency (MIT)
- tests: discovery/labels/errors/determinism/xlsx + headless-browser self-test
- known_unknowns K-020: remaining report deviations
This commit is contained in:
kemal committed 2026-09-28 09:20:18 +01:00
1 parent cea710f7b7
commit c9a6173fbd
13 files changed
+1601

No files matched your search

+645
View File
@@ -0,0 +1,645 @@
/* SimurgArray QC report (spec QC-047 … QC-062). Plain JS + bundled Plotly.js; no network access. */
(async function () {
"use strict";
const COL = { PASS: "#3B75AF", CLEAR: "#3B75AF", FAIL: "#E8871E", FLAGGED: "#F2C14E", NA: "#9A9A9A" };
const PALETTES = {
"Okabe-Ito": ["#0072B2", "#E69F00", "#009E73", "#CC79A7", "#56B4E9", "#D55E00", "#F0E442", "#000000"],
"Tol bright": ["#4477AA", "#EE6677", "#228833", "#CCBB44", "#66CCEE", "#AA3377", "#BBBBBB"],
"Tol muted": ["#332288", "#88CCEE", "#44AA99", "#117733", "#999933", "#DDCC77", "#CC6677", "#882255", "#AA4499"],
};
const CATEGORIES = ["Staining", "Extension", "Target Removal", "Hybridization", "Restoration", "Stringency",
"Non-Specific Binding", "Non-Polymorphic"];
const EXPECT = {
"Staining|Green": "Biotin (High) should be strong; DNP (High/Bkg) and Biotin (Bkg) should be near background.",
"Staining|Red": "DNP (High) should be strong; Biotin (High/Bkg) and DNP (Bkg) should be near background.",
"Extension|Green": "Extension (C) and (G) should be strong; (A) and (T) near background.",
"Extension|Red": "Extension (A) and (T) should be strong; (C) and (G) near background.",
"Target Removal|Green": "Should stay near background (target was washed off).",
"Target Removal|Red": "Not used; near background.",
"Hybridization|Green": "High > Medium > Low, all clearly above background.",
"Hybridization|Red": "Not used; near background.",
"Restoration|Green": "Above background for restored (FFPE) samples.",
"Restoration|Red": "Not used; near background.",
"Stringency|Green": "Not used; near background.",
"Stringency|Red": "PM should be clearly higher than MM.",
"Non-Specific Binding|Green": "All NSB probes should be low (background).",
"Non-Specific Binding|Red": "All NSB probes should be low (background).",
"Non-Polymorphic|Green": "NP (C) and (G) should be strong; (A) and (T) low.",
"Non-Polymorphic|Red": "NP (A) and (T) should be strong; (C) and (G) low.",
};
const CONFIG = { displaylogo: false, responsive: true,
modeBarButtonsToAdd: ["select2d", "lasso2d"], toImageButtonOptions: { format: "png", scale: 2 } };
// ------------------------------------------------------------------------------------------ data
const b64 = document.getElementById("data").textContent.trim();
const bin = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const D = JSON.parse(await new Response(new Blob([bin]).stream().pipeThrough(new DecompressionStream("gzip"))).text());
const TH = JSON.parse(JSON.stringify(D.thresholds));
const MDEF = Object.fromEntries(D.metricDefs.map((m) => [m.column, m]));
let cur = 0; // current dataset index (shared by views A-C)
let selection = null; // Set of sample_ids or null (QC-051)
const nTotal = D.datasets.reduce((a, d) => a + d.samples.length, 0);
document.getElementById("hdrinfo").textContent =
`${D.product} ${D.version} · generated ${D.generated} · ${D.datasets.length} dataset(s) · ${nTotal} sample(s)` +
(D.profile ? ` · profile ${D.profile}` : "");
document.getElementById("metanotice").hidden = !D.metadataNotice;
if (nTotal > 20000) {
const n = document.getElementById("scalenotice");
n.hidden = false;
n.textContent = `${nTotal} samples: scatter plots use WebGL and may offer a density mode (QC-057).`;
}
// ------------------------------------------------------------------------------------------ helpers
const $ = (tag, attrs = {}, ...kids) => {
const e = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === "class") e.className = v;
else if (k.startsWith("on")) e.addEventListener(k.slice(2), v);
else if (v !== undefined && v !== null) e.setAttribute(k, v);
}
for (const k of kids.flat()) if (k !== null && k !== undefined) e.append(k instanceof Node ? k : String(k));
return e;
};
const select = (label, options, value, onchange) => {
const s = $("select", { onchange: (e) => onchange(e.target.value) },
options.map(([v, t]) => { const o = $("option", { value: v }, t); if (String(v) === String(value)) o.selected = true; return o; }));
return $("label", {}, label, s);
};
const fmt = (v, d = 4) => (v === null || v === undefined || Number.isNaN(v) ? "" : typeof v === "number" ? +v.toFixed(d) : v);
const ds = () => D.datasets[cur];
const val = (s, col) => (col in s.m ? s.m[col] : s[col] !== undefined ? s[col] : s.meta[col]);
const trunc = (x) => { x = String(x ?? ""); return x.length > 200 ? x.slice(0, 200) + "…" : x; };
const fails = (key, v) => {
const t = TH[key];
if (!t || t.value === null || t.value === undefined || t.value === "") return false;
if (v === null || v === undefined || Number.isNaN(v)) return true;
return t.direction === "below" ? v < t.value : v > t.value;
};
const enabled = (key) => TH[key] && TH[key].value !== null && TH[key].value !== undefined && TH[key].value !== "";
function recompute() { // QC-030 / QC-031, client side (QC-055 Apply)
for (const d of D.datasets) for (const s of d.samples) {
if (!s.has_summary) s.functional_status = "NA";
else if (s.status !== "OK") s.functional_status = "FAIL";
else s.functional_status = fails("callRate", s.autosomal_call_rate) || fails("logRDev", s.logr_dev) ? "FAIL" : "PASS";
const na = new Set((s.na_metrics || "").split(";").filter(Boolean));
const codes = D.metricDefs.filter((m) => enabled(m.key) && m.column in s.m && !na.has(m.column) && fails(m.key, s.m[m.column])).map((m) => m.key);
s.control_status = codes.length ? "FLAGGED" : "CLEAR";
s.control_flag_codes = codes.join(";");
}
}
recompute();
const eligible = (d) => d.metadata.filter((c) => { // QC-056
const u = new Set(d.samples.map((s) => s.meta[c]).filter((x) => x !== "" && x !== undefined));
return u.size >= 2 && u.size <= 20;
});
const hover = (s, extra) => {
const d = ds();
const lines = [`<b>${s.sample_id}</b>`, `dataset: ${d.label}`];
if (s.barcode) lines.push(`barcode: ${s.barcode} · R${String(s.row).padStart(2, "0")}C${String(s.col).padStart(2, "0")}`);
if (s.autosomal_call_rate !== null && s.autosomal_call_rate !== undefined) lines.push(`autosomal call rate: ${fmt(s.autosomal_call_rate)}`);
if (extra) lines.push(extra);
d.metadata.slice(0, 12).forEach((c) => { if (s.meta[c]) lines.push(`${c}: ${trunc(s.meta[c])}`); });
return lines.join("<br>");
};
function sorted(samples, key) {
const arr = samples.slice();
if (key === "barcode") arr.sort((a, b) => (a.barcode + a.sample_id).localeCompare(b.barcode + b.sample_id));
else arr.sort((a, b) => {
const x = val(a, key), y = val(b, key);
if (typeof x === "number" && typeof y === "number") return x - y;
if (x === null || x === undefined) return 1;
if (y === null || y === undefined) return -1;
return String(x).localeCompare(String(y));
});
return arr;
}
const scatterType = (n) => (n > 12000 ? "scattergl" : "scatter");
const sortOptions = (d) => [["barcode", "Sentrix barcode"], ...(d.hasSummary ? [["autosomal_call_rate", "Autosomal call rate"], ["logr_dev", "LRR SD"]] : []),
...d.metrics.map((m) => [m, m]), ...d.metadata.map((c) => [c, c])];
const datasetSelect = (onchange) => select("Dataset", D.datasets.map((d, i) => [i, `${d.label} (${d.samples.length})`]), cur, (v) => { cur = +v; selection = null; onchange(); });
function download(name, text, mime) {
const a = $("a", { href: URL.createObjectURL(new Blob([text], { type: mime })), download: name });
document.body.append(a); a.click(); a.remove();
}
// ------------------------------------------------------------------------------------------ linked selection
const linked = []; // {div, samples(order)} for every rendered scatter
function registerLinked(div, order) {
linked.push({ div, order });
div.on("plotly_selected", (ev) => {
if (!ev || !ev.points) return;
selection = new Set(ev.points.map((p) => p.customdata).filter(Boolean));
applySelection(div);
});
div.on("plotly_deselect", () => { selection = null; applySelection(div); });
div.on("plotly_doubleclick", () => { selection = null; applySelection(div); });
}
function applySelection(origin) {
for (const { div, order } of linked) {
if (!document.body.contains(div) || div === origin || !div.data) continue;
const sp = div.data.map((tr) => (selection && tr.customdata ? tr.customdata.map((id, i) => (selection.has(id) ? i : -1)).filter((i) => i >= 0) : null));
Plotly.restyle(div, { selectedpoints: sp });
}
if (table) table.refresh();
}
// ------------------------------------------------------------------------------------------ box statistics (QC-054)
function quant(sortedV, p) {
if (!sortedV.length) return null;
const h = (sortedV.length - 1) * p, lo = Math.floor(h);
return sortedV[lo] + (h - lo) * ((sortedV[lo + 1] ?? sortedV[lo]) - sortedV[lo]);
}
function boxStats(values) {
const v = values.filter((x) => typeof x === "number" && !Number.isNaN(x)).sort((a, b) => a - b);
if (!v.length) return null;
const q1 = quant(v, 0.25), med = quant(v, 0.5), q3 = quant(v, 0.75), iqr = q3 - q1;
const lo = v.find((x) => x >= q1 - 1.5 * iqr), hi = [...v].reverse().find((x) => x <= q3 + 1.5 * iqr);
return { q1, med, q3, lo, hi, out: v.filter((x) => x < lo || x > hi), n: v.length,
mean: v.reduce((a, b) => a + b, 0) / v.length, min: v[0], max: v[v.length - 1],
sd: v.length > 1 ? Math.sqrt(v.reduce((a, b) => a + (b - v.reduce((c, e) => c + e, 0) / v.length) ** 2, 0) / (v.length - 1)) : 0 };
}
function boxTraces(groups, horizontal = true) { // explicit Tukey rule: whiskers to extreme points within 1.5 IQR
const names = [], q1 = [], med = [], q3 = [], lo = [], hi = [], ox = [], oy = [];
for (const [name, values] of groups) {
const b = boxStats(values); if (!b) continue;
names.push(name); q1.push(b.q1); med.push(b.med); q3.push(b.q3); lo.push(b.lo); hi.push(b.hi);
b.out.forEach((x) => { ox.push(x); oy.push(name); });
}
const box = { type: "box", q1, median: med, q3, lowerfence: lo, upperfence: hi, marker: { color: "#3B75AF" }, name: "", showlegend: false, hoverinfo: "x" };
if (horizontal) box.y = names; else box.x = names;
box.orientation = horizontal ? "h" : "v";
const pts = { type: "scatter", mode: "markers", marker: { color: "#3B75AF", size: 5 }, showlegend: false, name: "outliers" };
if (horizontal) { pts.x = ox; pts.y = oy; } else { pts.x = oy; pts.y = ox; }
return [box, pts];
}
const thLine = (key, horizontalAxis) => {
if (!enabled(key)) return { shapes: [], annotations: [] };
const t = TH[key], txt = `${key === "callRate" || key === "logRDev" ? "Fail" : "Flag"} if ${t.direction} ${t.value}`;
const s = horizontalAxis
? { type: "line", xref: "x", yref: "paper", x0: t.value, x1: t.value, y0: 0, y1: 1, line: { dash: "dash", color: "#C0392B" } }
: { type: "line", xref: "paper", yref: "y", x0: 0, x1: 1, y0: t.value, y1: t.value, line: { dash: "dash", color: "#C0392B" } };
const a = horizontalAxis
? { xref: "x", yref: "paper", x: t.value, y: 0.02, text: txt, showarrow: false, xanchor: "left", yanchor: "bottom", font: { size: 11, color: "#C0392B" } }
: { xref: "paper", yref: "y", x: 1, y: t.value, text: txt, showarrow: false, xanchor: "right", yanchor: "bottom", font: { size: 11, color: "#C0392B" } };
return { shapes: [s], annotations: [a] };
};
// ------------------------------------------------------------------------------------------ view A: dashboard
const dashState = { sort: "barcode", palette: "Okabe-Ito", facet: "", tab: null };
function renderDash() {
const root = document.getElementById("v-dash"); root.replaceChildren();
linked.length = 0;
const d = ds();
root.append($("div", { class: "controls" }, datasetSelect(renderDash),
select("Sort plots by", sortOptions(d), dashState.sort, (v) => { dashState.sort = v; renderDash(); }),
select("Colour palette", Object.keys(PALETTES).map((k) => [k, k]), dashState.palette, (v) => { dashState.palette = v; renderDash(); }),
select("Facet by", [["", "None"], ...eligible(d).map((c) => [c, c])], dashState.facet, (v) => { dashState.facet = v; renderDash(); })));
let order = sorted(d.samples, dashState.sort);
let facetShapes = [], facetNotes = [];
if (dashState.facet) { // QC-056: up to 12 facets, the rest grouped as "Other"
const counts = {};
order.forEach((s) => { const v = s.meta[dashState.facet] || "(empty)"; counts[v] = (counts[v] || 0) + 1; });
const top = Object.keys(counts).sort().slice(0, 11), lvl = (s) => { const v = s.meta[dashState.facet] || "(empty)"; return top.includes(v) || Object.keys(counts).length <= 12 ? v : "Other"; };
order = order.slice().sort((a, b) => lvl(a).localeCompare(lvl(b)));
let start = 0;
for (let i = 1; i <= order.length; i++) if (i === order.length || lvl(order[i]) !== lvl(order[i - 1])) {
facetNotes.push({ x: order[Math.floor((start + i - 1) / 2)].sample_id, y: 1, xref: "x", yref: "paper", text: lvl(order[start]), showarrow: false, yanchor: "bottom", font: { size: 11 } });
if (i < order.length) facetShapes.push({ type: "line", xref: "x", yref: "paper", x0: i - 0.5, x1: i - 0.5, y0: 0, y1: 1, line: { color: "#888", dash: "dot" } });
start = i;
}
if (Object.keys(counts).length > 12) root.append($("div", { class: "muted" }, `${Object.keys(counts).length} levels: showing 11 and "Other".`));
}
const ids = order.map((s) => s.sample_id);
if (d.hasSummary) {
const c = $("div", { class: "card" }, $("h3", {}, "Autosomal Call Rate"), $("div", { class: "plot small" }));
root.append(c);
Plotly.newPlot(c.lastChild, boxTraces([[d.label, d.samples.map((s) => s.autosomal_call_rate)]]),
{ margin: { l: 90, r: 20, t: 20, b: 40 }, ...thLine("callRate", true), xaxis: { title: "autosomal call rate" } }, CONFIG);
}
const cats = CATEGORIES.filter((c) => d.probes.some((p) => p.category === c)).concat(
[...new Set(d.probes.map((p) => p.category))].filter((c) => !CATEGORIES.includes(c)));
const cards = [];
for (const cat of cats) for (const ch of ["Green", "Red"]) cards.push({ cat, ch });
if (d.metrics.includes("assay_control_norm_r")) cards.push({ cat: "Assay control", ch: "" });
const big = d.samples.length > 1000; // QC-057: tabs per category
if (big && !dashState.tab) dashState.tab = cards[0] && cards[0].cat;
if (big) root.append($("div", { class: "tabs" }, [...new Set(cards.map((c) => c.cat))].map((c) =>
$("button", { class: c === dashState.tab ? "on" : "", onclick: () => { dashState.tab = c; renderDash(); } }, c))));
const grid = $("div", { class: "grid2" }); root.append(grid);
const pal = PALETTES[dashState.palette];
for (const { cat, ch } of cards) {
if (big && cat !== dashState.tab) continue;
const title = ch ? `${cat} – ${ch}` : cat;
const help = ch ? EXPECT[`${cat}|${ch}`] || "" : "Normalized R of the assay-control locus; should be ≥ the threshold.";
const card = $("div", { class: "card" }, $("h3", {}, title), $("div", { class: "help" }, help), $("div", { class: "plot" }));
grid.append(card);
let traces;
if (ch) {
traces = d.probes.filter((p) => p.category === cat && p.channel === ch).map((p, i) => ({
type: scatterType(order.length), mode: "markers", name: p.name, x: ids, y: order.map((s) => s.raw[p.key] ?? null),
customdata: ids, text: order.map((s) => hover(s, `${p.name} ${ch}: ${s.raw[p.key] ?? ""}`)), hoverinfo: "text",
marker: { color: pal[i % pal.length], size: 6 } }));
} else {
traces = [{ type: scatterType(order.length), mode: "markers", name: "assay control", x: ids, y: order.map((s) => s.m.assay_control_norm_r),
customdata: ids, text: order.map((s) => hover(s, `norm R: ${fmt(s.m.assay_control_norm_r)}`)), hoverinfo: "text", marker: { color: pal[0], size: 6 } }];
}
const tl = ch ? { shapes: [], annotations: [] } : thLine("assayControl", false);
Plotly.newPlot(card.lastChild, traces, { margin: { l: 60, r: 10, t: 24, b: 30 }, dragmode: "select", hovermode: "closest",
xaxis: { type: "category", showticklabels: false, title: "samples" }, yaxis: { rangemode: "tozero", title: ch ? "raw intensity" : "norm R" },
showlegend: true, legend: { orientation: "h", y: -0.15 }, shapes: [...facetShapes, ...tl.shapes], annotations: [...facetNotes, ...tl.annotations] }, CONFIG);
registerLinked(card.lastChild, order);
}
}
// ------------------------------------------------------------------------------------------ table (QC-050)
let table = null;
function makeTable(host, d) {
const cols = [
["dataset", "Dataset", (s) => d.label], ["sample_id", "Sample ID", (s) => s.sample_id],
...(d.hasSummary ? [["functional_status", "Functional QC Status", (s) => s.functional_status]] : []),
["control_status", "Control QC Status", (s) => s.control_status],
["control_flag_codes", "Control QC Flag Codes", (s) => s.control_flag_codes],
...(d.hasSummary ? [["autosomal_call_rate", "Autosomal Call Rate", (s) => s.autosomal_call_rate], ["call_rate", "Overall Call Rate", (s) => s.call_rate],
["logr_dev", "LRR SD", (s) => s.logr_dev], ["sex_est", "Sex Estimate", (s) => ({ M: "Male", F: "Female" })[s.sex_est] || "Unknown"],
["contamination", "Contamination", (s) => s.contamination], ["flags", "Flags", (s) => s.flags]] : []),
...d.metrics.map((m) => [m, m, (s) => s.m[m]]),
...d.metadata.map((c) => [c, c, (s) => s.meta[c]]),
];
const key = Object.fromEntries(D.metricDefs.map((m) => [m.column, m.key]).concat([["autosomal_call_rate", "callRate"], ["logr_dev", "logRDev"]]));
const st = { sort: null, asc: true, page: 0, per: 10, search: "", filt: {}, hidden: new Set(), rowsel: new Set() };
const wrap = $("div");
host.append(wrap);
function rows() {
let r = d.samples.filter((s) => !selection || selection.has(s.sample_id));
if (st.search) { const q = st.search.toLowerCase(); r = r.filter((s) => cols.some(([, , f]) => String(f(s) ?? "").toLowerCase().includes(q))); }
for (const [c, f] of Object.entries(st.filt)) {
const col = cols.find((x) => x[0] === c); if (!col) continue;
r = r.filter((s) => { const v = col[2](s);
if (typeof f === "string") return !f || String(v ?? "").toLowerCase().includes(f.toLowerCase());
if (f.min !== "" && f.min !== undefined && !(v >= +f.min)) return false;
if (f.max !== "" && f.max !== undefined && !(v <= +f.max)) return false; return true; });
}
if (st.sort) { const col = cols.find((x) => x[0] === st.sort); r = r.slice().sort((a, b) => {
const x = col[2](a), y = col[2](b); const c = typeof x === "number" && typeof y === "number" ? x - y : String(x ?? "").localeCompare(String(y ?? ""));
return st.asc ? c : -c; }); }
return r;
}
function exportRows() {
const r = st.rowsel.size ? d.samples.filter((s) => st.rowsel.has(s.sample_id)) : rows();
const vis = cols.filter((c) => !st.hidden.has(c[0]));
const esc = (v) => { v = v === null || v === undefined ? "" : String(v); return /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v; };
download(`${d.label}_qc_table.csv`, [vis.map((c) => esc(c[1])).join(","), ...r.map((s) => vis.map((c) => esc(c[2](s))).join(","))].join("\n") + "\n", "text/csv");
}
function refresh() {
const r = rows(), n = r.length, per = st.per === 0 ? n || 1 : st.per;
st.page = Math.min(st.page, Math.max(0, Math.ceil(n / per) - 1));
const page = r.slice(st.page * per, st.page * per + per);
const vis = cols.filter((c) => !st.hidden.has(c[0]));
const colMenu = $("div", { class: "menu", hidden: "" }, cols.map((c) => $("label", { style: "display:block" },
(() => { const cb = $("input", { type: "checkbox", onchange: (e) => { e.target.checked ? st.hidden.delete(c[0]) : st.hidden.add(c[0]); refresh(); } }); cb.checked = !st.hidden.has(c[0]); return cb; })(), " ", c[1])));
const head = $("div", { class: "pager" },
$("input", { type: "text", placeholder: "Search…", value: st.search, oninput: (e) => { st.search = e.target.value; st.page = 0; refreshSoon(); } }),
$("span", { class: "dropdown" }, $("button", { class: "btn", onclick: () => { colMenu.hidden = !colMenu.hidden; } }, "Columns ▾"), colMenu),
$("button", { class: "btn", onclick: exportRows }, "Export ▾ CSV"),
$("button", { class: "btn", onclick: () => { st.rowsel.clear(); selection = null; applySelection(null); refresh(); } }, "Select none"),
select("Show", [[10, 10], [25, 25], [50, 50], [100, 100], [0, "All"]], st.per, (v) => { st.per = +v; st.page = 0; refresh(); }));
const thead = $("tr", {}, $("th", {}, ""), vis.map((c) => $("th", { onclick: () => { st.asc = st.sort === c[0] ? !st.asc : true; st.sort = c[0]; refresh(); } },
c[1] + (st.sort === c[0] ? (st.asc ? " ▲" : " ▼") : ""))));
const foot = $("tr", {}, $("td", {}, ""), vis.map((c) => {
const numeric = d.samples.some((s) => typeof c[2](s) === "number");
if (!numeric) return $("td", {}, $("input", { type: "text", placeholder: "filter", value: st.filt[c[0]] || "", onchange: (e) => { st.filt[c[0]] = e.target.value; refresh(); } }));
const f = st.filt[c[0]] || {};
return $("td", {}, $("input", { type: "number", step: "any", placeholder: "min", value: f.min ?? "", onchange: (e) => { st.filt[c[0]] = { ...(st.filt[c[0]] || {}), min: e.target.value }; refresh(); } }),
$("input", { type: "number", step: "any", placeholder: "max", value: f.max ?? "", onchange: (e) => { st.filt[c[0]] = { ...(st.filt[c[0]] || {}), max: e.target.value }; refresh(); } }));
}));
const body = page.map((s) => {
const cb = $("input", { type: "checkbox", onchange: (e) => { e.target.checked ? st.rowsel.add(s.sample_id) : st.rowsel.delete(s.sample_id);
selection = st.rowsel.size ? new Set(st.rowsel) : null; applySelection(null); } });
cb.checked = st.rowsel.has(s.sample_id);
return $("tr", { class: st.rowsel.has(s.sample_id) ? "sel" : "" }, $("td", {}, cb), vis.map((c) => {
const v = c[2](s); let cls = "";
if (c[0] === "functional_status" || c[0] === "control_status") cls = v;
else if (c[0] === "control_flag_codes" && v) cls = "code";
else if (key[c[0]] && typeof v === "number" && enabled(key[c[0]]) && fails(key[c[0]], v)) cls = c[0] === "autosomal_call_rate" || c[0] === "logr_dev" ? "fout" : "cout";
return $("td", { class: cls }, fmt(v));
}));
});
const pager = $("div", { class: "pager" }, `Showing ${n ? st.page * per + 1 : 0} to ${Math.min(n, st.page * per + per)} of ${n} entries`,
$("button", { class: "btn", onclick: () => { st.page = Math.max(0, st.page - 1); refresh(); } }, "Prev"),
$("button", { class: "btn", onclick: () => { st.page = Math.min(Math.ceil(n / per) - 1, st.page + 1); refresh(); } }, "Next"));
wrap.replaceChildren(head, $("div", { class: "tablewrap" }, $("table", { class: "dt" }, $("thead", {}, thead), $("tbody", {}, body), $("tfoot", {}, foot))), pager);
}
let timer = null;
const refreshSoon = () => { clearTimeout(timer); timer = setTimeout(() => { refresh(); const i = wrap.querySelector("input[type=text]"); if (i) { i.focus(); i.setSelectionRange(i.value.length, i.value.length); } }, 250); };
refresh();
return { refresh };
}
// ------------------------------------------------------------------------------------------ view B: automated QC
const autoState = { colour: "", sort: "barcode" };
function renderAuto() {
const root = document.getElementById("v-auto"); root.replaceChildren();
linked.length = 0;
const d = ds();
root.append($("div", { class: "controls" }, datasetSelect(renderAuto),
select("Colour points by", [["", "Functional status"], ...eligible(d).map((c) => [c, c])], autoState.colour, (v) => { autoState.colour = v; renderAuto(); }),
select("Sort by", sortOptions(d), autoState.sort, (v) => { autoState.sort = v; renderAuto(); })));
const hist = $("div", { class: "grid2" }); root.append(hist);
const bars = (title, levels, field) => {
const n = d.samples.length, counts = levels.map((l) => d.samples.filter((s) => s[field] === l).length);
const c = $("div", { class: "card" }, $("h3", {}, title), $("div", { class: "plot small" })); hist.append(c);
Plotly.newPlot(c.lastChild, [{ type: "bar", orientation: "h", y: levels, x: counts, marker: { color: levels.map((l) => COL[l]) },
text: counts.map((k) => `${k} (${n ? Math.round((100 * k) / n) : 0}%)`), textposition: "auto", hoverinfo: "y+x" }],
{ margin: { l: 80, r: 20, t: 10, b: 30 } }, CONFIG);
};
if (d.hasSummary) bars("Functional QC", ["FAIL", "PASS"], "functional_status");
bars("Control QC", ["FLAGGED", "CLEAR"], "control_status");
const tcard = $("div", { class: "card", style: "margin:12px 0" }, $("h3", {}, "Samples")); root.append(tcard);
table = makeTable(tcard, d);
const order = sorted(d.samples, autoState.sort), ids = order.map((s) => s.sample_id);
const grid = $("div", { class: "grid2" }); root.append(grid);
const metrics = [...(d.hasSummary ? D.functionalKeys.map((f) => ({ column: f.column, key: f.key, label: f.label, formula: f.formula })) : []),
...d.metrics.map((m) => ({ column: m, key: MDEF[m] ? MDEF[m].key : m, label: m, formula: MDEF[m] ? MDEF[m].formula : "" }))];
const groupsOf = () => {
if (!autoState.colour) return ["PASS", "FAIL", "NA"].map((l) => [l, COL[l], (s) => (s.functional_status || "NA") === l]);
const lv = [...new Set(d.samples.map((s) => s.meta[autoState.colour] || "(empty)"))].sort();
const pal = PALETTES["Okabe-Ito"];
return lv.map((l, i) => [l, pal[i % pal.length], (s) => (s.meta[autoState.colour] || "(empty)") === l]);
};
for (const m of metrics) {
const card = $("div", { class: "card" }, $("h3", {}, m.label), $("div", { class: "help" }, m.formula), $("div", { class: "plot" }));
grid.append(card);
const traces = groupsOf().map(([name, color, pred]) => {
const sub = order.filter(pred);
return { type: scatterType(order.length), mode: "markers", name, x: sub.map((s) => s.sample_id), y: sub.map((s) => val(s, m.column)),
customdata: sub.map((s) => s.sample_id), text: sub.map((s) => hover(s, `${m.label}: ${fmt(val(s, m.column))}`)), hoverinfo: "text", marker: { color, size: 6 } };
}).filter((t) => t.x.length);
const tl = thLine(m.key, false);
Plotly.newPlot(card.lastChild, traces, { margin: { l: 60, r: 10, t: 24, b: 30 }, dragmode: "select", hovermode: "closest",
xaxis: { type: "category", categoryorder: "array", categoryarray: ids, showticklabels: false, title: "Sample ID" },
yaxis: { title: m.label }, showlegend: true, legend: { orientation: "h", y: -0.15 }, shapes: tl.shapes, annotations: tl.annotations }, CONFIG);
registerLinked(card.lastChild, order);
}
}
// ------------------------------------------------------------------------------------------ view C: heatmaps (QC-052)
const heatState = { scale: "Continuous", group: "Chip", per: 2, sort: "barcode", page: 0, plate: "" };
function renderHeat() {
const root = document.getElementById("v-heat"); root.replaceChildren();
const d = ds();
if (!d.hasSummary) { root.append($("div", { class: "controls" }, datasetSelect(renderHeat)), $("p", {}, "Heatmaps need the sample summary (functional metrics).")); return; }
const plates = [...new Set(d.samples.map((s) => s.meta.sample_plate).filter(Boolean))].sort();
root.append($("div", { class: "controls" }, datasetSelect(renderHeat),
select("Colour scale", [["Continuous", "Continuous"], ["Binary", "Binary"]], heatState.scale, (v) => { heatState.scale = v; renderHeat(); }),
select("Group by", [["Chip", "Chip"], ["Plate", "Plate"]], heatState.group, (v) => { heatState.group = v; heatState.page = 0; renderHeat(); }),
heatState.group === "Chip" ? select("Max chips per page", [[1, 1], [2, 2], [4, 4], [8, 8]], heatState.per, (v) => { heatState.per = +v; heatState.page = 0; renderHeat(); }) : null,
select("Sort by", [["barcode", heatState.group === "Chip" ? "Barcode" : "Plate"], ["mean", "Call-rate mean"]], heatState.sort, (v) => { heatState.sort = v; renderHeat(); }),
heatState.group === "Plate" ? select("Plate", [["", "All"], ...plates.map((p) => [p, p])], heatState.plate, (v) => { heatState.plate = v; heatState.page = 0; renderHeat(); }) : null));
let groups;
if (heatState.group === "Chip") {
const by = {}; d.samples.filter((s) => s.barcode).forEach((s) => (by[s.barcode] = by[s.barcode] || []).push(s));
groups = Object.entries(by);
} else {
const by = {}; d.samples.forEach((s) => { const p = s.meta.sample_plate; if (p && (!heatState.plate || heatState.plate === p)) (by[p] = by[p] || []).push(s); });
groups = Object.entries(by);
const noPlate = d.samples.filter((s) => !s.meta.sample_plate);
if (noPlate.length) root.append($("div", { class: "muted" }, `No plate: ${noPlate.map((s) => s.sample_id).join(", ")}`));
}
const mean = (ss) => ss.reduce((a, s) => a + (s.autosomal_call_rate || 0), 0) / ss.length;
groups.sort((a, b) => (heatState.sort === "mean" ? mean(a[1]) - mean(b[1]) : a[0].localeCompare(b[0])));
const per = heatState.group === "Chip" ? heatState.per : 1, pages = Math.max(1, Math.ceil(groups.length / per));
heatState.page = Math.min(heatState.page, pages - 1);
root.append($("div", { class: "pager" }, $("button", { class: "btn", onclick: () => { heatState.page = Math.max(0, heatState.page - 1); renderHeat(); } }, "Prev"),
`${heatState.page + 1} / ${pages}`, $("button", { class: "btn", onclick: () => { heatState.page = Math.min(pages - 1, heatState.page + 1); renderHeat(); } }, "Next")));
if (heatState.scale === "Binary") root.append($("div", { class: "muted" },
$("span", { class: "legend-sw", style: "background:#E8871E" }), "call rate below cut-off", $("span", { class: "legend-sw", style: "background:#1B7837" }), "call rate at/above cut-off",
$("span", { class: "legend-sw", style: "background:#2166AC" }), "LRR SD below cut-off", $("span", { class: "legend-sw", style: "background:#F2E14E" }), "LRR SD above cut-off"));
for (const [name, ss] of groups.slice(heatState.page * per, heatState.page * per + per)) {
let nr, nc, pos;
if (heatState.group === "Chip") {
const f = d.chipFormat; nr = f ? f[0] : Math.max(...ss.map((s) => s.row)); nc = f ? f[1] : Math.max(...ss.map((s) => s.col));
pos = (s) => [s.row - 1, s.col - 1];
} else {
nr = 8; nc = 12;
pos = (s) => { const m = /^([A-Ha-h])0?(\d{1,2})$/.exec(s.meta.sample_well || ""); return m ? [m[1].toUpperCase().charCodeAt(0) - 65, +m[2] - 1] : null; };
}
const rowLab = heatState.group === "Chip" ? [...Array(nr)].map((_, i) => `R${String(i + 1).padStart(2, "0")}`) : "ABCDEFGH".split("");
const colLab = heatState.group === "Chip" ? [...Array(nc)].map((_, i) => `C${String(i + 1).padStart(2, "0")}`) : [...Array(12)].map((_, i) => String(i + 1));
const card = $("div", { class: "card", style: "margin-bottom:12px" }, $("h3", {}, name), $("div", { class: "grid2" }));
root.append(card);
for (const [col, key, title, dec, better] of [["autosomal_call_rate", "callRate", "Autosomal Call Rate (higher is better)", 3, "high"], ["logr_dev", "logRDev", "LRR SD (lower is better)", 2, "low"]]) {
const z = [...Array(nr)].map(() => Array(nc).fill(null)), txt = [...Array(nr)].map(() => Array(nc).fill("missing")), hov = [...Array(nr)].map(() => Array(nc).fill("missing"));
for (const s of ss) { const p = pos(s); if (!p || p[0] >= nr || p[1] >= nc) continue; const v = s[col];
z[p[0]][p[1]] = heatState.scale === "Binary" ? (v === null ? null : fails(key, v) ? 1 : 0) : v;
txt[p[0]][p[1]] = v === null ? "" : v.toFixed(dec); hov[p[0]][p[1]] = `${s.sample_id}<br>${s.sample_name}<br>${rowLab[p[0]]} ${colLab[p[1]]}<br>${fmt(v)}`; }
const cs = heatState.scale === "Binary"
? (better === "high" ? [[0, "#1B7837"], [1, "#E8871E"]] : [[0, "#2166AC"], [1, "#F2E14E"]])
: (better === "high" ? [[0, "#E5F5E0"], [1, "#00441B"]] : [[0, "#08306B"], [1, "#DEEBF7"]]);
const div = $("div", { class: "plot" }); card.lastChild.append($("div", {}, $("div", { class: "help" }, `${title} · ${enabled(key) ? "Cut-off = " + TH[key].value : "Cut-off disabled"}`), div));
Plotly.newPlot(div, [{ type: "heatmap", z, x: colLab, y: rowLab, text: txt, texttemplate: "%{text}", hovertext: hov, hoverinfo: "text",
colorscale: cs, zmin: heatState.scale === "Binary" ? 0 : undefined, zmax: heatState.scale === "Binary" ? 1 : undefined, showscale: heatState.scale !== "Binary", xgap: 2, ygap: 2 }],
{ margin: { l: 50, r: 10, t: 10, b: 30 }, yaxis: { autorange: "reversed" }, plot_bgcolor: "#d9d9d9" }, CONFIG);
}
}
}
// ------------------------------------------------------------------------------------------ view D: trend (QC-054, QC-059, QC-060)
const trendState = { group: "dataset", sort: "name" };
function chipStats(d) { // QC-053
const by = {}; d.samples.filter((s) => s.barcode).forEach((s) => (by[s.barcode] = by[s.barcode] || []).push(s));
const f = d.chipFormat, chips = Object.values(by);
const exp = (ss) => (f ? f[0] * f[1] : Math.max(...ss.map((s) => s.row)) * Math.max(...ss.map((s) => s.col)));
return { n: chips.length, complete: chips.filter((ss) => ss.length >= exp(ss)).length, missing: chips.reduce((a, ss) => a + Math.max(0, exp(ss) - ss.length), 0) };
}
function renderTrend() {
const root = document.getElementById("v-trend"); root.replaceChildren();
root.append($("div", { class: "controls" },
select("Group by", [["dataset", "Dataset"], ["chip", "Chip"]], trendState.group, (v) => { trendState.group = v; renderTrend(); }),
select("Sort by", [["name", "Name"], ["median", "Median call rate"]], trendState.sort, (v) => { trendState.sort = v; renderTrend(); })));
const hdr1 = $("tr", {}, $("th", { rowspan: 2 }, "Dataset"), $("th", { colspan: 4 }, "Scan"), $("th", { colspan: 4 }, "Autosomal Call Rate"),
$("th", { colspan: 3 }, "LRR SD"), $("th", { colspan: 2 }, "Assay control"), $("th", { colspan: 3 }, "Sex estimate"));
const hdr2 = $("tr", {}, ["date range", "# samples", "# complete / # chips", "# missing positions", "min", "median", "mean (SD)", "# < threshold",
"mean (SD)", "median", "# > threshold", "mean (SD)", "# < threshold", "# M", "# F", "# U"].map((t) => $("th", {}, t)));
const body = D.datasets.map((d) => {
const dates = d.samples.map((s) => (s.meta.imaging_date || "").slice(0, 10)).filter(Boolean).sort();
const cr = boxStats(d.samples.map((s) => s.autosomal_call_rate)), ld = boxStats(d.samples.map((s) => s.logr_dev)), ac = boxStats(d.samples.map((s) => s.m.assay_control_norm_r));
const cs = chipStats(d), sex = (x) => d.samples.filter((s) => (s.sex_est || "U") === x).length;
return $("tr", {}, [d.label, dates.length ? (dates[0] === dates[dates.length - 1] ? dates[0] : `${dates[0]} to ${dates[dates.length - 1]}`) : "", d.samples.length, `${cs.complete} / ${cs.n}`, cs.missing,
cr ? fmt(cr.min) : "", cr ? fmt(cr.med) : "", cr ? `${fmt(cr.mean)} (${fmt(cr.sd)})` : "", d.samples.filter((s) => s.autosomal_call_rate !== null && fails("callRate", s.autosomal_call_rate)).length,
ld ? `${fmt(ld.mean)} (${fmt(ld.sd)})` : "", ld ? fmt(ld.med) : "", d.samples.filter((s) => s.logr_dev !== null && fails("logRDev", s.logr_dev)).length,
ac ? `${fmt(ac.mean)} (${fmt(ac.sd)})` : "", ac ? d.samples.filter((s) => s.m.assay_control_norm_r !== undefined && s.m.assay_control_norm_r !== null && fails("assayControl", s.m.assay_control_norm_r)).length : "",
sex("M"), sex("F"), sex("U")].map((v) => $("td", {}, v)));
});
root.append($("div", { class: "card tablewrap" }, $("table", { class: "dt summarytable" }, $("thead", {}, hdr1, hdr2), $("tbody", {}, body))));
const groups = [];
for (const d of D.datasets) {
if (trendState.group === "dataset") groups.push([d.label, d.samples]);
else { const by = {}; d.samples.forEach((s) => (by[`${d.label}:${s.barcode}`] = by[`${d.label}:${s.barcode}`] || []).push(s)); groups.push(...Object.entries(by)); }
}
const medOf = (ss) => (boxStats(ss.map((s) => s.autosomal_call_rate)) || { med: 0 }).med;
groups.sort((a, b) => (trendState.sort === "median" ? medOf(a[1]) - medOf(b[1]) : a[0].localeCompare(b[0])));
const grid = $("div", { class: "grid2", style: "margin-top:12px" }); root.append(grid);
const card = (title, help) => { const c = $("div", { class: "card" }, $("h3", {}, title), $("div", { class: "help" }, help || ""), $("div", { class: "plot" })); grid.append(c); return c.lastChild; };
const h = Math.max(300, 26 * groups.length + 80);
const boxCard = (title, col, key, get) => {
const div = card(title, "Box: Q1–Q3 with median; whiskers reach the most extreme points within 1.5 × IQR; points beyond are outliers.");
div.style.height = `${h}px`;
Plotly.newPlot(div, boxTraces(groups.map(([n, ss]) => [n, ss.map(get)])), { margin: { l: 150, r: 20, t: 20, b: 40 }, xaxis: { title }, ...thLine(key, true) }, CONFIG);
};
if (D.datasets.some((d) => d.hasSummary)) {
boxCard("Autosomal call rate", "autosomal_call_rate", "callRate", (s) => s.autosomal_call_rate);
boxCard("LRR SD", "logr_dev", "logRDev", (s) => s.logr_dev);
}
if (D.datasets.some((d) => d.metrics.includes("assay_control_norm_r"))) boxCard("Assay control (norm R)", "assay_control_norm_r", "assayControl", (s) => s.m.assay_control_norm_r);
const cnt = card("Sample count"); cnt.style.height = `${h}px`;
Plotly.newPlot(cnt, [{ type: "bar", orientation: "h", y: groups.map((g) => g[0]), x: groups.map((g) => g[1].length), marker: { color: "#3B75AF" }, text: groups.map((g) => g[1].length), textposition: "auto" }],
{ margin: { l: 150, r: 20, t: 20, b: 40 } }, CONFIG);
const sx = card("Sex estimates"); sx.style.height = `${h}px`;
Plotly.newPlot(sx, [["M", "Male", "#3B75AF"], ["F", "Female", "#E8871E"], ["U", "Unknown", "#9A9A9A"]].map(([k, n, c]) => ({
type: "bar", orientation: "h", name: n, y: groups.map((g) => g[0]), x: groups.map((g) => g[1].filter((s) => (s.sex_est || "U") === k).length), marker: { color: c } })),
{ barmode: "group", margin: { l: 150, r: 20, t: 20, b: 40 }, legend: { orientation: "h" } }, CONFIG);
// QC-059 sex view: X vs Y median R when the summary carries them
const all = D.datasets.flatMap((d) => d.samples);
const sexDiv = card("Sex check", "X vs Y median R per sample, coloured by the estimate; the symbol shows the check against the reported sex.");
if (all.some((s) => typeof s.meta.x_median_r === "string" && s.meta.x_median_r !== "")) {
Plotly.newPlot(sexDiv, ["M", "F", "U"].map((k) => { const ss = all.filter((s) => (s.sex_est || "U") === k);
return { type: "scatter", mode: "markers", name: k, x: ss.map((s) => +s.meta.x_median_r), y: ss.map((s) => +s.meta.y_median_r), text: ss.map((s) => s.sample_id),
marker: { color: { M: "#3B75AF", F: "#E8871E", U: "#9A9A9A" }[k], symbol: ss.map((s) => (s.sex_check === "mismatch" ? "x" : "circle")) } }; }),
{ margin: { l: 60, r: 10, t: 10, b: 40 }, xaxis: { title: "X median R" }, yaxis: { title: "Y median R" } }, CONFIG);
} else {
const counts = ["match", "mismatch", "unknown"].map((k) => all.filter((s) => (s.sex_check || "unknown") === k).length);
Plotly.newPlot(sexDiv, [{ type: "bar", x: ["match", "mismatch", "unknown"], y: counts, marker: { color: ["#3B75AF", "#E8871E", "#9A9A9A"] }, text: counts, textposition: "auto" }],
{ margin: { l: 50, r: 10, t: 10, b: 40 } }, CONFIG);
sexDiv.previousSibling.textContent = "Sex estimate checked against the reported sex (X/Y median R are not in this summary).";
}
// QC-060 control-metric PCA of the current dataset
const d = ds(), pcaDiv = card(`Control-metric PCA (${d.label})`, "log-transformed, standardised derived control metrics");
const mets = d.metrics.filter((m) => d.samples.every((s) => typeof s.m[m] === "number" && s.m[m] > 0));
if (mets.length < 3 || d.samples.length < 10) { pcaDiv.style.height = "60px"; pcaDiv.textContent = "insufficient data"; }
else {
const X = d.samples.map((s) => mets.map((m) => Math.log(s.m[m])));
const mu = mets.map((_, j) => X.reduce((a, r) => a + r[j], 0) / X.length);
const sd = mets.map((_, j) => Math.sqrt(X.reduce((a, r) => a + (r[j] - mu[j]) ** 2, 0) / (X.length - 1)) || 1);
const Z = X.map((r) => r.map((v, j) => (v - mu[j]) / sd[j]));
const C = mets.map((_, i) => mets.map((_, j) => Z.reduce((a, r) => a + r[i] * r[j], 0) / (Z.length - 1)));
const pcs = [];
for (let k = 0; k < 2; k++) { let v = mets.map((_, i) => 1 + i * 0.01);
for (let it = 0; it < 200; it++) { let w = C.map((row) => row.reduce((a, c, j) => a + c * v[j], 0));
for (const p of pcs) { const dot = w.reduce((a, x, i) => a + x * p[i], 0); w = w.map((x, i) => x - dot * p[i]); }
const n = Math.hypot(...w) || 1; v = w.map((x) => x / n); }
pcs.push(v); }
const proj = Z.map((r) => pcs.map((p) => r.reduce((a, x, i) => a + x * p[i], 0)));
const field = d.metadata.find((c) => c.toLowerCase() === "sample_group") || eligible(d)[0];
const lv = field ? [...new Set(d.samples.map((s) => s.meta[field] || "(empty)"))].sort() : ["all"];
Plotly.newPlot(pcaDiv, lv.map((l, i) => { const idx = d.samples.map((s, j) => j).filter((j) => !field || (d.samples[j].meta[field] || "(empty)") === l);
return { type: "scatter", mode: "markers", name: l, x: idx.map((j) => proj[j][0]), y: idx.map((j) => proj[j][1]), text: idx.map((j) => d.samples[j].sample_id),
marker: { color: PALETTES["Okabe-Ito"][i % 8] } }; }), { margin: { l: 50, r: 10, t: 10, b: 40 }, xaxis: { title: "PC1" }, yaxis: { title: "PC2" },
showlegend: true, legend: { orientation: "h", y: -0.2, title: { text: field || "" } } }, CONFIG);
}
}
// ------------------------------------------------------------------------------------------ view E: thresholds (QC-055)
const RANGE_OK = (k, v) => (k === "callRate" ? v >= 0 && v <= 1 : v >= 0);
function renderModal() {
const box = document.getElementById("modal-box"); box.replaceChildren();
const inputs = {};
const row = (k, label, desc, verb) => {
const t = TH[k] || { value: null, direction: "below" };
const inp = $("input", { type: "number", step: "any", value: t.value ?? "", style: "width:90px" });
const dir = $("select", {}, ["below", "above"].map((x) => { const o = $("option", { value: x }, `${verb} if ${x}`); if (x === t.direction) o.selected = true; return o; }));
const err = $("span", { class: "err" });
inputs[k] = { inp, dir, err };
return $("tr", {}, $("td", {}, inp), $("td", {}, dir), $("td", {}, label, err), $("td", { class: "muted" }, desc));
};
box.append($("h2", {}, "QC Thresholds"), $("p", { class: "muted" }, "Blank disables a check. Changes apply to this browser session only."),
$("h3", {}, "Sample QC metrics"), $("table", {}, $("tr", {}, ["Threshold", "Fail if", "Metric", "Description"].map((x) => $("th", {}, x))),
D.functionalKeys.map((f) => row(f.key, f.label, f.formula, "Fail"))),
$("h3", {}, "Control QC metrics"), $("table", {}, $("tr", {}, ["Threshold", "Flag if", "Metric", "Description"].map((x) => $("th", {}, x))),
D.metricDefs.filter((m) => D.datasets.some((d) => d.metrics.includes(m.column))).map((m) => row(m.key, m.column, m.formula, "Flag"))));
const msg = $("div", { class: "err" });
const fileIn = $("input", { type: "file", accept: ".yaml,.yml", hidden: "", onchange: async (e) => {
const f = e.target.files[0]; if (!f) return; msg.textContent = "";
try { const parsed = parseYaml(await f.text());
for (const [k, v] of Object.entries(parsed)) { if (!inputs[k]) continue; inputs[k].inp.value = v.value ?? ""; if (v.direction) inputs[k].dir.value = v.direction; }
} catch (err) { msg.textContent = String(err.message || err); } } });
box.append($("div", { class: "pager", style: "margin-top:12px" },
$("button", { class: "btn", onclick: () => {
let ok = true;
for (const [k, { inp, dir, err }] of Object.entries(inputs)) { err.textContent = ""; const raw = inp.value.trim();
if (raw === "") continue; const v = Number(raw);
if (!Number.isFinite(v)) { err.textContent = " value must be numeric or blank"; ok = false; }
else if (!RANGE_OK(k, v)) { err.textContent = k === "callRate" ? " must be within [0, 1]" : " must be ≥ 0"; ok = false; } }
if (!ok) return;
for (const [k, { inp, dir }] of Object.entries(inputs)) TH[k] = { ...(TH[k] || {}), value: inp.value.trim() === "" ? null : Number(inp.value), direction: dir.value, source: "session" };
recompute(); closeModal(); render(); } }, "Apply"),
$("button", { class: "btn", onclick: () => {
const lines = ["qc:", " report:"];
for (const [k, { inp, dir }] of Object.entries(inputs)) {
const v = inp.value.trim() === "" ? "null" : inp.value.trim(), def = k === "logRDev" ? "above" : "below";
lines.push(dir.value === def ? ` ${k}: ${v}` : ` ${k}: {threshold: ${v}, direction: ${dir.value}}`); }
if (TH.bgOffset && TH.bgOffset.value !== null) lines.push(` bgOffset: ${TH.bgOffset.value}`);
download("qc_thresholds.yaml", lines.join("\n") + "\n", "text/yaml"); } }, "Download YAML"),
$("button", { class: "btn", onclick: () => fileIn.click() }, "Upload YAML"), fileIn,
$("button", { class: "btn", onclick: closeModal }, "Close"), msg));
}
function parseYaml(text) { // the QC-020 subset: qc/QC → report/Report → key: value | {threshold, direction}
const lines = text.split(/\r?\n/).map((l) => l.replace(/#.*$/, "")).filter((l) => l.trim());
const ind = (l) => l.length - l.trimStart().length;
const top = lines.findIndex((l) => /^(qc|QC)\s*:\s*$/.test(l.trim()) && ind(l) === 0);
if (top < 0) throw new Error("missing top-level 'qc:' (or 'QC:') mapping");
const rep = lines.findIndex((l, i) => i > top && /^(report|Report)\s*:\s*$/.test(l.trim()));
if (rep < 0) throw new Error("missing 'report:' (or 'Report:') section");
const known = new Set(Object.keys(D.thresholds)), alias = { tgaControl: "assayControl", call_rate_min: "callRate", logr_dev_max: "logRDev" };
const out = {};
for (let i = rep + 1; i < lines.length && ind(lines[i]) > ind(lines[rep]); i++) {
const m = /^\s*([A-Za-z_][\w]*)\s*:\s*(.*)$/.exec(lines[i]); if (!m) throw new Error(`line not understood: ${lines[i].trim()}`);
const k = alias[m[1]] || m[1]; if (!known.has(k)) throw new Error(`unknown QC threshold key '${m[1]}'`);
let raw = m[2].trim(), direction;
const mm = /^\{\s*threshold\s*:\s*([^,}]*)\s*(?:,\s*direction\s*:\s*(\w+)\s*)?\}$/.exec(raw);
if (mm) { raw = mm[1].trim(); direction = mm[2]; if (direction && !["below", "above"].includes(direction)) throw new Error(`${k}: direction must be 'below' or 'above'`); }
if (["", "null", "~", "none"].includes(raw.toLowerCase())) { out[k] = { value: null, direction }; continue; }
const v = Number(raw); if (!Number.isFinite(v)) throw new Error(`${k}: value must be numeric or null`);
if (!RANGE_OK(k, v)) throw new Error(`${k}: value out of range`);
out[k] = { value: v, direction };
}
return out;
}
const closeModal = () => { document.getElementById("modal").hidden = true; };
document.getElementById("modal").addEventListener("click", (e) => { if (e.target.id === "modal") closeModal(); });
// ------------------------------------------------------------------------------------------ navigation
let view = ["dash", "auto", "heat", "trend"].includes(location.hash.slice(1)) ? location.hash.slice(1) : "dash";
const renderers = { dash: renderDash, auto: renderAuto, heat: renderHeat, trend: renderTrend };
function render() {
table = null;
for (const v of Object.keys(renderers)) document.getElementById(`v-${v}`).hidden = v !== view;
renderers[view]();
}
document.querySelectorAll("#nav button").forEach((b) => b.addEventListener("click", () => {
if (b.dataset.view === "thr") { renderModal(); document.getElementById("modal").hidden = false; return; }
document.querySelectorAll("#nav button").forEach((x) => x.classList.toggle("active", x === b));
view = b.dataset.view; render();
}));
document.querySelectorAll("#nav button").forEach((x) => x.classList.toggle("active", x.dataset.view === view));
render();
if (location.hash === "#selftest") { // exercised by the test suite through a headless browser
const res = {};
try { parseYaml("qc:\n report:\n bogus: 1\n"); res.yamlUnknown = "accepted"; } catch (e) { res.yamlUnknown = "rejected"; }
const y = parseYaml("QC:\n Report:\n callRate: 0.9995\n stringency: {threshold: 2, direction: above}\n npRed: null\n");
res.yaml = y.callRate.value === 0.9995 && y.stringency.direction === "above" && y.npRed.value === null;
renderModal();
document.querySelector("#modal-box input").value = "0.9985";
[...document.querySelectorAll("#modal-box button")].find((x) => x.textContent === "Apply").click();
res.failAfterApply = D.datasets[0].samples.filter((s) => s.functional_status === "FAIL").length;
view = "auto"; render();
selection = new Set(D.datasets[0].samples.slice(0, 3).map((s) => s.sample_id)); applySelection(null);
res.rowsWithSelection = document.querySelectorAll("#v-auto table.dt tbody tr").length;
res.linkedPlots = linked.length;
document.body.dataset.selftest = JSON.stringify(res);
}
document.body.dataset.ready = "1";
})().catch((e) => {
document.body.prepend(Object.assign(document.createElement("pre"), { className: "err", textContent: "Report error: " + (e && e.stack || e) }));
throw e;
});