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

View File
Whitespace-only changes.
+47
View File
@@ -0,0 +1,47 @@
:root { --blue:#3B75AF; --orange:#E8871E; --amber:#F2C14E; --grey:#9A9A9A; --bg:#fafafa; --line:#ddd; }
* { box-sizing: border-box; }
body { margin:0; font:14px/1.4 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; color:#222; background:var(--bg); }
header { padding:10px 16px; background:#fff; border-bottom:1px solid var(--line); }
header .title { font-size:20px; font-weight:600; }
.muted { color:#666; font-size:13px; }
.notice { margin-top:6px; padding:6px 10px; background:#FFF6DA; border:1px solid var(--amber); border-radius:4px; font-size:13px; }
#layout { display:flex; min-height:calc(100vh - 70px); }
nav { width:190px; flex:none; background:#fff; border-right:1px solid var(--line); padding:8px; }
nav button { display:block; width:100%; text-align:left; margin:2px 0; padding:8px 10px; border:0; background:none; border-radius:4px; cursor:pointer; font-size:14px; }
nav button.active { background:#E3EDF7; font-weight:600; }
nav button:hover { background:#eef3f8; }
main { flex:1; min-width:0; padding:12px 16px; }
.controls { display:flex; flex-wrap:wrap; gap:12px; align-items:end; margin-bottom:10px; }
.controls label { display:flex; flex-direction:column; font-size:12px; color:#555; }
select, input[type=text], input[type=number] { font-size:13px; padding:3px 5px; border:1px solid #bbb; border-radius:3px; }
button.btn { font-size:13px; padding:4px 10px; border:1px solid #999; background:#fff; border-radius:3px; cursor:pointer; }
button.btn:hover { background:#f0f0f0; }
.grid2 { display:grid; grid-template-columns:repeat(2, minmax(0,1fr)); gap:12px; }
@media (max-width: 1100px) { .grid2 { grid-template-columns:1fr; } }
.card { background:#fff; border:1px solid var(--line); border-radius:6px; padding:8px 10px; }
.card h3 { margin:0 0 2px; font-size:15px; }
.card .help { font-size:12px; color:#666; margin-bottom:4px; }
.plot { width:100%; height:300px; }
.plot.small { height:220px; }
.tabs { display:flex; gap:4px; flex-wrap:wrap; margin:6px 0; }
.tabs button { border:1px solid #bbb; background:#fff; padding:3px 8px; border-radius:3px; cursor:pointer; font-size:12px; }
.tabs button.on { background:#E3EDF7; border-color:var(--blue); }
table.dt { border-collapse:collapse; width:100%; font-size:12px; background:#fff; }
table.dt th, table.dt td { border:1px solid #e3e3e3; padding:3px 5px; white-space:nowrap; }
table.dt th { background:#f3f3f3; cursor:pointer; position:sticky; top:0; }
table.dt tfoot input { width:100%; min-width:50px; font-size:11px; }
table.dt tr.sel td { outline:1px solid var(--blue); background:#EAF2FB !important; }
.tablewrap { overflow:auto; max-height:520px; border:1px solid var(--line); }
td.PASS, td.CLEAR { background:#D6E6F5; } td.FAIL { background:#F9D3AC; } td.FLAGGED, td.code { background:#FFE7A3; }
td.fout { background:#F9D3AC; } td.cout { background:#FFF1C2; }
.pager { display:flex; gap:8px; align-items:center; margin:6px 0; font-size:12px; flex-wrap:wrap; }
.dropdown { position:relative; display:inline-block; }
.dropdown .menu { position:absolute; z-index:10; background:#fff; border:1px solid #bbb; padding:6px; max-height:300px; overflow:auto; min-width:180px; box-shadow:0 2px 6px rgba(0,0,0,.15); }
.modal { position:fixed; inset:0; background:rgba(0,0,0,.35); display:flex; align-items:flex-start; justify-content:center; padding-top:40px; z-index:50; }
.modal[hidden] { display:none; }
.modal-box { background:#fff; border-radius:6px; padding:14px 18px; width:min(1000px, 96vw); max-height:88vh; overflow:auto; }
.modal-box table { border-collapse:collapse; width:100%; font-size:13px; }
.modal-box td, .modal-box th { padding:3px 6px; border-bottom:1px solid #eee; text-align:left; }
.err { color:#B00020; font-size:12px; }
.legend-sw { display:inline-block; width:12px; height:12px; vertical-align:middle; margin:0 4px 0 10px; border:1px solid #888; }
.summarytable th { text-align:center; }
+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;
});
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>__TITLE__</title>
<style>/*__STYLE__*/</style>
</head>
<body>
<header id="hdr">
<div class="title">__TITLE__</div>
<div id="hdrinfo" class="muted">loading…</div>
<div id="metanotice" class="notice" hidden>This report embeds sample-sheet metadata. Check it before sharing
(use <code>--no-metadata</code>, <code>--drop-metadata</code> or <code>--keep-metadata</code> to remove columns).</div>
<div id="scalenotice" class="notice" hidden></div>
</header>
<div id="layout">
<nav id="nav">
<button data-view="dash" class="active">Control Dashboard</button>
<button data-view="auto">Automated QC</button>
<button data-view="heat">Sample Heatmaps</button>
<button data-view="trend">Trend Analysis</button>
<button data-view="thr" class="gear">&#9881; QC Thresholds</button>
</nav>
<main>
<section id="v-dash" class="view"></section>
<section id="v-auto" class="view" hidden></section>
<section id="v-heat" class="view" hidden></section>
<section id="v-trend" class="view" hidden></section>
</main>
</div>
<div id="modal" class="modal" hidden><div class="modal-box" id="modal-box"></div></div>
<script id="data" type="application/octet-stream">__DATA__</script>
<script>/*__PLOTLY__*/</script>
<script>/*__APP__*/</script>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2021 Plotly, Inc
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+1
View File
@@ -0,0 +1 @@
Couldn't find the requested file /plotly.min.js.LICENSE.txt in plotly.js-dist-min.
Binary file not shown.