Spaces:
Paused
Paused
File size: 16,747 Bytes
8ffc9f7 37a1395 8ffc9f7 37a1395 8ffc9f7 37a1395 b5d4bb5 37a1395 8ffc9f7 37a1395 8ffc9f7 bd7cf06 8ffc9f7 bd7cf06 8ffc9f7 ee0c38c 8ffc9f7 ee0c38c 8ffc9f7 bd7cf06 8ffc9f7 bd7cf06 8ffc9f7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 | import type { QcFinding, QcReport } from "./types";
/** Escape untrusted report text before interpolating into HTML. */
function esc(s: unknown): string {
return String(s ?? "").replace(
/[&<>"]/g,
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c] as string,
);
}
const pct = (v: number | null | undefined): string =>
v == null ? "β" : `${(v * 100).toFixed(1)}%`;
const isIssue = (f: QcFinding): boolean => f.verdict === "fail" || f.verdict === "warn";
interface CheckKnowledge {
rootCause: string;
fix: string;
references?: { label: string; url: string; why: string }[];
}
const TENX_GUIDE = "https://www.10xgenomics.com/support";
/**
* Per-check root cause + suggested fix (+ optional references), keyed by the QC engine's check ids.
* This is the general rule applied to every QC execution β grounded in the diagnostic catalog's
* causes/adapters. A check not listed here falls back to whatever the finding's own detail carries.
*/
const CHECK_KNOWLEDGE: Record<string, CheckKnowledge> = {
// --- Illumina short-read checks ---
tso_at_r2_start: {
rootCause:
"Read 2 begins with template-switch-oligo (TSO) sequence instead of cDNA. This is the signature of short or empty cDNA inserts β adapter dimers and short fragments where the read runs past the tiny insert into the TSO handle.",
fix: "Trim the leading TSO from R2 before alignment. The root fix is at the bench: tighten SPRI/bead size selection to remove short inserts and adapter dimers, and confirm cDNA yield before library construction.",
references: [
{
label: "10x Chromium Single Cell 3β² Reagent Kits User Guide",
url: TENX_GUIDE,
why: "the expected R2 structure β R2 should start with cDNA, not the TSO handle",
},
],
},
adapter_readthrough: {
rootCause:
"The read runs through the insert into the library's 3β² adapter (the exact stem is in the finding detail), meaning inserts are shorter than the read length β short fragments or residual adapter dimers.",
fix: "Adapter-trim the reads (remove the 3β² adapter stem and everything after it) before alignment to recover the usable portion. To fix the source, improve size selection so inserts exceed the read length.",
},
anchor: {
rootCause:
"A large fraction of reads do not carry the expected constant anchor at its position β the rest are off-target products (mispriming, internal priming, or non-target molecules) rather than on-structure library reads.",
fix: "Filter to reads that carry the anchor at the expected offset (and re-extract UMIs relative to it) to keep only on-target molecules. Persistently low rates point to a prep issue (TSO/primer specificity) at the bench.",
},
r2_polyg_tail: {
rootCause:
"Read 2 ends in a poly-G run. On two-colour Illumina instruments (NovaSeq/NextSeq) a 'no-signal' base is called G, so poly-G tails mark reads that ran past the insert end or lost signal β usually short inserts.",
fix: "Trim 3β² poly-G tails (e.g. fastp --trim_poly_g) before alignment. Because the underlying cause is short inserts, improving size selection removes both the poly-G and the read-through.",
},
whitelist_hit_rate: {
rootCause:
"Few cell barcodes match the chemistry whitelist. This points to the wrong chemistry/whitelist being used, or a barcode-position offset where extraction reads the barcode at the wrong bases.",
fix: "Confirm the chemistry and whitelist match the kit. Scan a small barcode offset and alternative whitelists; if a shift or wrong whitelist is found, re-extract barcodes computationally β no re-sequencing needed.",
references: [
{
label: "10x Chromium Single Cell 3β² Reagent Kits User Guide",
url: TENX_GUIDE,
why: "the cell-barcode position and the correct whitelist for each chemistry",
},
],
},
r1_length: {
rootCause:
"Read 1 is not the length the chemistry expects (it must cover the 16 bp cell barcode + 12 bp UMI). A wrong R1 length means barcodes/UMIs can't be extracted β a read-configuration mismatch (wrong cycles, wrong chemistry, or R1/R2 swapped).",
fix: "Audit the sequencing read layout against the chemistry. If only extraction was misconfigured, re-extract; if the run used the wrong cycle count, additional sequencing or a rerun is required.",
references: [
{
label: "10x Chromium Single Cell 3β² Reagent Kits User Guide",
url: TENX_GUIDE,
why: "the expected R1 cycle count and barcode + UMI layout",
},
],
},
// --- Nanopore long-read checks ---
tso_concatemer: {
rootCause:
"A fraction of long reads carry an internal TSO/adapter2 copy mid-read β the signature of template-switch concatemers or two cDNAs fused into one read during library prep.",
fix: "Split reads at the internal TSO junctions computationally to recover the individual molecules. At the bench, the optional enriched profile β full-length biotinylated-primer streptavidin pull-down (ONT SST_9198) β depletes these artifacts; the baseline direct-ligation prep does not.",
references: [
{
label: "GoT-Splice β Cortes-Lopez et al., Cell Stem Cell 2023",
url: "https://www.cell.com/cell-stem-cell/fulltext/S1934-5909(23)00257-6",
why: "the sc-Nanopore MDS study this dataset models β how internal-TSO / fused reads are handled downstream",
},
{
label: "ScNaUmi-seq / Sicelore β Lebrigand et al., Nat Commun 2020",
url: "https://www.nature.com/articles/s41467-020-17800-6",
why: "the nanopore single-cell method β detecting these artifacts and splitting fused reads",
},
],
},
};
/** Resolve per-check knowledge, handling the spec-driven dynamic ids (r1/r2 adapter, anchor_*). */
export function knowledgeFor(checkId: string): CheckKnowledge | undefined {
if (CHECK_KNOWLEDGE[checkId]) return CHECK_KNOWLEDGE[checkId];
if (checkId.endsWith("_adapter_readthrough")) return CHECK_KNOWLEDGE.adapter_readthrough;
if (checkId.startsWith("anchor_")) return CHECK_KNOWLEDGE.anchor;
return undefined;
}
/** A deterministic 0β100 quality score from the finding severities (fail full weight, warn 0.6Γ). */
function scoreOf(findings: QcFinding[]): { score: number; band: "good" | "warn" | "critical" } {
let keep = 1;
for (const f of findings) {
if (!isIssue(f)) continue;
const s = Math.max(0, Math.min(1, f.severity ?? 0));
keep *= 1 - (f.verdict === "warn" ? s * 0.6 : s);
}
const score = Math.round(100 * keep);
const band = score >= 80 ? "good" : score >= 50 ? "warn" : "critical";
return { score, band };
}
const BAND_COLOR = { good: "#10b981", warn: "#f59e0b", critical: "#ef4444" } as const;
const BAND_LABEL = { good: "Good", warn: "Needs attention", critical: "Critical" } as const;
const sevColor = (v: string): string =>
v === "fail" ? "#ef4444" : v === "warn" ? "#f59e0b" : "#10b981";
/** Strip machine-format debris (raw dicts) and the trailing "Fix: β¦" from a detail string. */
function cleanDetail(detail: string): string {
return String(detail ?? "")
.replace(/\.?\s*Categories:\s*\{[^}]*\}\.?/gi, "")
.replace(/\{[^{}]*\}/g, "")
.replace(/\s+/g, " ")
.trim();
}
/** Split a detail into the root-cause description and the suggested fix (on "Fix:"). */
function splitDetail(detail: string): { cause: string; fix: string | null } {
const raw = String(detail ?? "");
const i = raw.search(/\bFix:\s*/i);
if (i === -1) return { cause: cleanDetail(raw), fix: null };
return {
cause: cleanDetail(raw.slice(0, i)),
fix: cleanDetail(raw.slice(i).replace(/^\s*Fix:\s*/i, "")) || null,
};
}
const fmtValue = (f: QcFinding): string =>
f.unit === "fraction" ? pct(f.value) : `${esc(f.value)} ${esc(f.unit)}`;
/** A structured card for a failing/warning check: issue β root cause β suggested fix (with references). */
function issueCard(f: QcFinding): string {
const c = sevColor(f.verdict);
const k = knowledgeFor(f.check_id);
const detail = splitDetail(f.detail);
const rootCause = k?.rootCause ?? detail.cause;
const fix = k?.fix ?? detail.fix;
const refs = k?.references;
const af = f.affected_fraction;
return `
<div class="issue" style="border-color:${c}44">
<div class="issue-head">
<span class="pill" style="color:${c};border-color:${c}55;background:${c}18">${esc(
f.verdict.toUpperCase(),
)}</span>
<div class="grow"><div class="issue-title">${esc(f.title)}</div></div>
<div class="fval"><div class="mono strong">${fmtValue(f)}</div><div class="muted mono xsmall">want ${esc(
f.threshold,
)}</div></div>
</div>
${
af != null
? `<div class="bar"><div style="width:${Math.min(100, af * 100)}%;background:${c}"></div></div>`
: ""
}
${rootCause ? `<div class="sec"><div class="sec-h">Root cause</div><p>${esc(rootCause)}</p></div>` : ""}
${
fix
? `<div class="sec"><div class="sec-h">Suggested fix</div><p>${esc(fix)}</p>${
refs?.length
? `<div class="refs-block"><div class="sec-h2">References β where to look</div><ul class="refs">${refs
.map(
(r) =>
`<li><a href="${esc(r.url)}" target="_blank" rel="noreferrer">${esc(
r.label,
)}</a> <span class="muted">β ${esc(r.why)}</span></li>`,
)
.join("")}</ul></div>`
: ""
}</div>`
: ""
}
</div>`;
}
/** A compact one-line row for a passing / descriptive check. */
function otherRow(f: QcFinding): string {
const mark = f.verdict === "pass" ? "β" : "Β·";
const detail = cleanDetail(f.detail);
return `<details class="orow"><summary><span class="ok">${mark}</span><span class="oname">${esc(
f.title,
)}</span><span class="mono muted oval">${fmtValue(f)}</span></summary>
<div class="odetail-body">
${detail ? `<div class="sec-h">What was measured</div><p class="muted small">${esc(detail)}</p>` : ""}
<div class="muted xsmall">Passing threshold: <span class="mono">${esc(f.threshold)}</span></div>
</div></details>`;
}
/** Render a QC report (qc_report.json) as a self-contained, theme-aware HTML document. */
export function renderQcReportHtml(
report: QcReport,
meta: { runId: string; projectName: string; afterFixes?: boolean },
): string {
const profile = report.profile;
const findings = report.findings ?? [];
const nano = report.platform === "nanopore";
const { score, band } = scoreOf(findings);
const bc = BAND_COLOR[band];
const issues = findings.filter(isIssue).sort((a, b) => (b.severity ?? 0) - (a.severity ?? 0));
const others = findings.filter((f) => !isIssue(f));
const body = `
<header class="topbar">
<div>
<div class="up muted xsmall">QC report${meta.afterFixes ? " Β· after fixes" : ""}</div>
<h1>${esc(meta.projectName)}</h1>
<div class="muted small">${esc(report.platform ?? "")} Β· spec <code>${esc(
report.spec_id ?? "",
)}</code> Β· run <code>${esc(meta.runId)}</code></div>
</div>
<div class="score" style="color:${bc};border-color:${bc}55;background:${bc}12">
<div class="score-v">${score}<span class="score-max">/100</span></div>
<div class="xsmall up strong">${esc(BAND_LABEL[band])}</div>
</div>
</header>
${
profile
? nano
? `<div class="profile">
<div><div class="mono strong">${profile.n_pairs.toLocaleString()}</div><div class="muted xsmall">reads</div></div>
<div><div class="mono strong">${profile.r1_len.modal} bp</div><div class="muted xsmall">modal read length</div></div>
<div><div class="mono strong">${profile.r1_len.max.toLocaleString()} bp</div><div class="muted xsmall">longest read</div></div>
</div>`
: `<div class="profile">
<div><div class="mono strong">${profile.n_pairs.toLocaleString()}</div><div class="muted xsmall">read pairs</div></div>
<div><div class="mono strong">${profile.r1_len.modal} bp</div><div class="muted xsmall">R1 modal</div></div>
<div><div class="mono strong">${profile.r2_len.modal} bp</div><div class="muted xsmall">R2 modal</div></div>
</div>`
: ""
}
${
issues.length
? `<section class="card"><h2>Issues <span class="muted normal">${issues.length} of ${
findings.length
} checks need attention</span></h2>${issues.map(issueCard).join("")}</section>`
: `<section class="card ok-card"><h2>No issues</h2><p class="muted small">All ${findings.length} checks passed.</p></section>`
}
${
others.length
? `<section class="card"><h2>Other checks <span class="muted normal">${others.length} passed</span></h2>${others
.map(otherRow)
.join("")}</section>`
: ""
}`;
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>QC report Β· ${esc(meta.projectName)}</title>
<style>
:root {
--bg:#ffffff; --fg:#0a0a0a; --muted:#6b7280; --card:#ffffff; --border:#e5e7eb; --code:#f3f4f6;
}
@media (prefers-color-scheme: dark) {
:root { --bg:#0a0a0a; --fg:#ededed; --muted:#9ca3af; --card:#141414; --border:#262626; --code:#1f1f1f; }
}
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--fg); font:14px/1.6 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; padding:20px; max-width:820px; }
h1 { font-size:20px; margin:2px 0; font-weight:600; }
h2 { font-size:14px; margin:0 0 14px; font-weight:600; }
p { margin:0; }
.mono { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }
.muted { color:var(--muted); } .small { font-size:12px; } .xsmall { font-size:11px; }
.strong { font-weight:600; } .normal { font-weight:400; } .up { text-transform:uppercase; letter-spacing:.04em; }
.grow { flex:1; min-width:0; }
code { background:var(--code); padding:1px 5px; border-radius:4px; font-family:ui-monospace,monospace; font-size:12px; }
a { color:inherit; }
.topbar { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; margin-bottom:16px; }
.score { border:1px solid; border-radius:12px; padding:10px 18px; text-align:center; min-width:110px; }
.score-v { font-size:30px; font-weight:700; line-height:1; }
.score-max { font-size:13px; font-weight:600; opacity:.7; }
.profile { display:flex; gap:28px; padding:12px 0 20px; border-bottom:1px solid var(--border); margin-bottom:20px; }
.card { border:1px solid var(--border); background:var(--card); border-radius:12px; padding:16px; margin-bottom:16px; }
.ok-card h2 { color:#10b981; }
.issue { border:1px solid; border-radius:10px; padding:14px; margin-bottom:12px; }
.issue:last-child { margin-bottom:0; }
.issue-head { display:flex; gap:12px; align-items:flex-start; margin-bottom:4px; }
.issue-title { font-weight:600; font-size:15px; }
.pill { border:1px solid; border-radius:6px; padding:2px 7px; font-size:10px; font-weight:700; flex-shrink:0; }
.fval { text-align:right; white-space:nowrap; }
.bar { height:6px; background:var(--code); border-radius:3px; overflow:hidden; margin:6px 0 10px; }
.bar > div { height:100%; }
.sec { margin-top:12px; }
.sec-h { font-size:11px; text-transform:uppercase; letter-spacing:.04em; color:var(--muted); font-weight:600; margin-bottom:3px; }
.refs-block { margin-top:10px; padding:8px 12px; border-left:2px solid var(--border); background:var(--code); border-radius:0 6px 6px 0; }
.sec-h2 { font-size:10px; text-transform:uppercase; letter-spacing:.04em; color:var(--muted); font-weight:600; margin-bottom:4px; }
.refs { margin:0; padding-left:18px; } .refs li { margin:3px 0; font-size:12px; }
.refs a { text-decoration:underline; text-underline-offset:2px; }
details.orow { border-bottom:1px solid var(--border); }
details.orow:last-child { border-bottom:0; }
details.orow > summary { display:flex; align-items:baseline; gap:10px; padding:9px 0; cursor:pointer; list-style:none; }
details.orow > summary::-webkit-details-marker { display:none; }
details.orow > summary::before { content:"βΈ"; color:var(--muted); font-size:10px; flex-shrink:0; transition:transform .12s; }
details.orow[open] > summary::before { transform:rotate(90deg); }
.ok { color:#10b981; width:12px; flex-shrink:0; }
.oname { font-weight:500; flex:1; min-width:0; }
.oval { flex-shrink:0; }
.odetail-body { padding:0 0 12px 24px; }
.odetail-body p { margin:2px 0 8px; }
</style>
</head>
<body>${body}</body>
</html>`;
}
|