Files
SimurgArray/src/simurg/qc/report/report.js
T
kemal c9a6173fbd 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
2026-09-28 09:20:18 +01:00

646 lines
50 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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;
});