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 = { // --- 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 `
${esc( f.verdict.toUpperCase(), )}
${esc(f.title)}
${fmtValue(f)}
want ${esc( f.threshold, )}
${ af != null ? `
` : "" } ${rootCause ? `
Root cause

${esc(rootCause)}

` : ""} ${ fix ? `
Suggested fix

${esc(fix)}

${ refs?.length ? `
References — where to look
` : "" }
` : "" }
`; } /** 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 `
${mark}${esc( f.title, )}${fmtValue(f)}
${detail ? `
What was measured

${esc(detail)}

` : ""}
Passing threshold: ${esc(f.threshold)}
`; } /** 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 = `
QC report${meta.afterFixes ? " · after fixes" : ""}

${esc(meta.projectName)}

${esc(report.platform ?? "")} · spec ${esc( report.spec_id ?? "", )} · run ${esc(meta.runId)}
${score}/100
${esc(BAND_LABEL[band])}
${ profile ? nano ? `
${profile.n_pairs.toLocaleString()}
reads
${profile.r1_len.modal} bp
modal read length
${profile.r1_len.max.toLocaleString()} bp
longest read
` : `
${profile.n_pairs.toLocaleString()}
read pairs
${profile.r1_len.modal} bp
R1 modal
${profile.r2_len.modal} bp
R2 modal
` : "" } ${ issues.length ? `

Issues ${issues.length} of ${ findings.length } checks need attention

${issues.map(issueCard).join("")}
` : `

No issues

All ${findings.length} checks passed.

` } ${ others.length ? `

Other checks ${others.length} passed

${others .map(otherRow) .join("")}
` : "" }`; return ` QC report · ${esc(meta.projectName)} ${body} `; }