/* 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 = [`${s.sample_id}`, `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("
"); }; 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}
${s.sample_name}
${rowLab[p[0]]} ${colLab[p[1]]}
${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; });