prsdetector / index.html
caustino's picture
Update index.html
15cd3d8 verified
Raw
History Blame Contribute Delete
50.8 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PRS Risk Allele Quantification β€” GWAS Γ— PubMed Cross-Reference</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js">
</script>
<style>
:root {
--bg: #0a0e14;
--surface: #131820;
--surface2: #1a1f2b;
--border: #252d3a;
--text: #c8ccd4;
--text2: #8b92a0;
--accent: #4da6ff;
--accent2: #26d98b;
--danger: #ff5c5c;
--warn: #f0a030;
--purple: #9b7ed8;
--font-mono: 'SF Mono', 'JetBrains Mono', 'Cascadia Code', 'Consolas', monospace;
--font-sans: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
--radius: 10px;
--radius-sm: 6px;
--shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
--glow-accent: 0 0 20px rgba(77, 166, 255, 0.15);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-sans);
line-height: 1.6;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Header */
.header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 14px 28px;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
position: sticky;
top: 0;
z-index: 100;
box-shadow: var(--shadow);
}
.header-brand {
display: flex;
align-items: center;
gap: 10px;
font-weight: 700;
font-size: 1.1rem;
letter-spacing: -0.01em;
}
.header-brand .icon {
width: 36px;
height: 36px;
border-radius: 8px;
background: linear-gradient(135deg, var(--accent), var(--purple));
display: flex;
align-items: center;
justify-content: center;
font-size: 1.3rem;
}
.run-id {
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--text2);
background: var(--surface2);
padding: 6px 12px;
border-radius: 20px;
border: 1px solid var(--border);
letter-spacing: 0.02em;
}
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
animation: pulse 2s infinite;
}
.status-dot.idle {
background: #555;
animation: none;
}
.status-dot.active {
background: var(--accent2);
}
.status-dot.error {
background: var(--danger);
animation: none;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
/* Main Layout */
.main-container {
display: flex;
flex: 1;
gap: 0;
overflow: hidden;
}
.panel-left {
width: 420px;
min-width: 380px;
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 20px;
gap: 18px;
}
.panel-right {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 20px;
gap: 18px;
}
/* Cards */
.card {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px 20px;
box-shadow: var(--shadow);
}
.card h3 {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text2);
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.card h3 .badge {
font-size: 0.7rem;
background: var(--accent);
color: #000;
padding: 2px 8px;
border-radius: 10px;
letter-spacing: 0.03em;
font-weight: 700;
}
/* Upload Zone */
.upload-zone {
border: 2px dashed var(--border);
border-radius: var(--radius);
padding: 32px 20px;
text-align: center;
cursor: pointer;
transition: all 0.25s;
background: var(--surface);
position: relative;
}
.upload-zone:hover,
.upload-zone.drag-over {
border-color: var(--accent);
background: #1a2330;
box-shadow: var(--glow-accent);
}
.upload-zone .upload-icon {
font-size: 2.8rem;
margin-bottom: 8px;
}
.upload-zone p {
color: var(--text2);
font-size: 0.85rem;
}
.upload-zone .supported {
font-size: 0.7rem;
color: #555;
margin-top: 6px;
}
input[type="file"] {
display: none;
}
/* Buttons */
.btn {
padding: 10px 18px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--surface2);
color: var(--text);
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
letter-spacing: 0.02em;
transition: all 0.2s;
white-space: nowrap;
}
.btn:hover {
border-color: var(--accent);
box-shadow: var(--glow-accent);
}
.btn.primary {
background: var(--accent);
color: #000;
border: none;
font-weight: 700;
}
.btn.primary:hover {
background: #5bb8ff;
box-shadow: 0 0 24px rgba(77, 166, 255, 0.35);
}
.btn.danger {
border-color: var(--danger);
color: var(--danger);
}
.btn.small {
padding: 5px 10px;
font-size: 0.72rem;
}
/* Table */
.table-wrap {
overflow-x: auto;
border-radius: var(--radius);
border: 1px solid var(--border);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
font-family: var(--font-mono);
}
th {
background: var(--surface2);
padding: 10px 12px;
text-align: left;
font-weight: 700;
color: var(--text2);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 2px solid var(--border);
position: sticky;
top: 0;
}
td {
padding: 9px 12px;
border-bottom: 1px solid #1e2733;
color: var(--text);
}
tr:hover td {
background: #1e2735;
}
.risk-high {
color: var(--danger);
font-weight: 700;
}
.risk-medium {
color: var(--warn);
font-weight: 600;
}
.risk-low {
color: var(--accent2);
}
.novelty-badge {
display: inline-block;
padding: 3px 8px;
border-radius: 10px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.03em;
}
.novelty-badge.high {
background: #3d1a2e;
color: #ff6b9d;
}
.novelty-badge.medium {
background: #2e2810;
color: #f0c040;
}
.novelty-badge.low {
background: #102820;
color: #4dcc88;
}
/* Ledger */
.ledger {
font-family: var(--font-mono);
font-size: 0.7rem;
max-height: 250px;
overflow-y: auto;
background: #0a0d12;
border-radius: var(--radius-sm);
padding: 12px;
border: 1px solid var(--border);
color: #6a7;
}
.ledger .entry {
padding: 3px 0;
border-bottom: 1px solid #111;
display: flex;
gap: 8px;
}
.ledger .entry .ts {
color: #556;
flex-shrink: 0;
}
.ledger .entry .hash-frag {
color: #8ab;
font-size: 0.65rem;
}
/* Toast */
.toast {
position: fixed;
bottom: 24px;
right: 24px;
background: var(--surface2);
border: 1px solid var(--border);
padding: 12px 20px;
border-radius: var(--radius-sm);
font-size: 0.82rem;
z-index: 200;
box-shadow: var(--shadow);
animation: slideIn 0.3s ease;
max-width: 420px;
}
@keyframes slideIn {
from {
transform: translateY(30px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.toast.success {
border-color: var(--accent2);
}
.toast.error {
border-color: var(--danger);
}
/* Responsive */
@media (max-width: 900px) {
.main-container {
flex-direction: column;
}
.panel-left {
width: 100%;
min-width: auto;
border-right: none;
border-bottom: 1px solid var(--border);
max-height: 50vh;
}
.panel-right {
max-height: none;
}
.header {
padding: 10px 14px;
font-size: 0.9rem;
}
}
/* Progress bar */
.progress-bar {
height: 4px;
background: var(--border);
border-radius: 2px;
overflow: hidden;
margin-top: 8px;
}
.progress-bar .fill {
height: 100%;
background: var(--accent);
transition: width 0.3s ease;
border-radius: 2px;
}
.chart-container {
position: relative;
height: 280px;
width: 100%;
}
canvas {
width: 100% !important;
height: 100% !important;
}
.empty-state {
text-align: center;
padding: 40px;
color: var(--text2);
}
.empty-state .icon {
font-size: 3rem;
margin-bottom: 12px;
}
.tag {
display: inline-block;
padding: 3px 8px;
border-radius: 4px;
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.03em;
margin: 2px;
}
.tag.gwas {
background: #1a2d40;
color: #5ba0d9;
}
.tag.pubmed {
background: #2d1a30;
color: #c080d0;
}
</style>
</head>
<body>
<!-- HEADER -->
<header class="header">
<div class="header-brand">
<div class="icon">🧬</div>
<span>PRS Risk Quantifier</span>
<span style="font-size:0.7rem;color:var(--text2);">GWAS Γ— PubMed</span>
</div>
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap;">
<span class="run-id" id="runIdDisplay">prs_run_------</span>
<span><span class="status-dot idle" id="statusDot"></span><span id="statusText" style="font-size:0.8rem;">Idle</span></span>
<button class="btn small" onclick="downloadManifest()" title="Download audit manifest">πŸ“‹ Manifest</button>
<button class="btn small" onclick="downloadLedger()" title="Download chain-of-custody ledger">πŸ”— Ledger</button>
</div>
</header>
<!-- MAIN -->
<div class="main-container">
<!-- LEFT PANEL -->
<aside class="panel-left">
<!-- Upload -->
<div class="upload-zone" id="uploadZone" onclick="document.getElementById('fileInput').click()">
<div class="upload-icon">πŸ“€</div>
<p><strong>Drop TXT file here</strong> or click to browse</p>
<p class="supported">Supports: rsID lists, VCF-style, tab-separated variant files</p>
<input type="file" id="fileInput" accept=".txt,.tsv,.csv,.vcf" onchange="handleFileSelect(event)">
</div>
<!-- Parsed SNPs -->
<div class="card">
<h3>🧬 Parsed Variants <span class="badge" id="snpCount">0</span></h3>
<div id="snpPreview" style="max-height:160px;overflow-y:auto;font-family:var(--font-mono);font-size:0.72rem;color:var(--text2);word-break:break-all;">
<span class="empty-state" style="padding:16px;">Awaiting file upload…</span>
</div>
</div>
<!-- Actions -->
<button class="btn primary" id="runBtn" onclick="runCrossReference()" disabled style="width:100%;">
⚑ Cross-Reference &amp; Quantify Risk
</button>
<div class="progress-bar" id="progressBar" style="display:none;">
<div class="fill" id="progressFill" style="width:0%;"></div>
</div>
<div id="progressLabel" style="font-size:0.72rem;color:var(--text2);text-align:center;"></div>
<!-- Chain of Custody Ledger -->
<div class="card">
<h3>πŸ”— Chain of Custody Ledger <span style="font-size:0.65rem;color:var(--text2);">(COBOL-style)</span></h3>
<div class="ledger" id="ledger">
<div class="entry"><span class="ts">--:--:--</span> Awaiting pipeline initiation…</div>
</div>
</div>
</aside>
<!-- RIGHT PANEL -->
<section class="panel-right">
<!-- Results Table -->
<div class="card">
<h3>πŸ“Š Risk Allele Quantification Results</h3>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Variant (rsID)</th>
<th>Risk Allele</th>
<th>GWAS p-value</th>
<th>OR / Beta</th>
<th>PubMed Hits</th>
<th>Novelty</th>
<th>Risk Score</th>
<th>Confidence</th>
</tr>
</thead>
<tbody id="resultsBody">
<tr><td colspan="8" class="empty-state">Upload variants and run cross-reference to see results</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Chart -->
<div class="card">
<h3>πŸ“ˆ Risk Score Distribution</h3>
<div class="chart-container">
<canvas id="riskChart"></canvas>
</div>
</div>
<!-- Summary Stats -->
<div class="card" id="summaryCard" style="display:none;">
<h3>πŸ“‹ Analysis Summary</h3>
<div id="summaryContent" style="font-size:0.82rem;display:flex;flex-wrap:wrap;gap:16px;"></div>
</div>
</section>
</div>
<!-- TOAST CONTAINER -->
<div id="toastContainer"></div>
<script>
(function() {
// ──────────────────────────────────────────────
// GLOBALS & STATE
// ──────────────────────────────────────────────
const RUN_ID = 'prs_run_' + new Date().toISOString().replace(/[-:T]/g, '').slice(0, 15);
const MANIFEST = {
run_id: RUN_ID,
timestamp: new Date().toISOString(),
input_hash: null,
events: [],
results_hash: null,
signature_algorithm: 'SHA-256 (Web Crypto API) / SHA3-256 in production',
};
const LEDGER_ENTRIES = [];
let parsedSNPs = [];
let crossRefResults = [];
let riskChartInstance = null;
// DOM refs
const uploadZone = document.getElementById('uploadZone');
const fileInput = document.getElementById('fileInput');
const snpCountBadge = document.getElementById('snpCount');
const snpPreview = document.getElementById('snpPreview');
const runBtn = document.getElementById('runBtn');
const progressBar = document.getElementById('progressBar');
const progressFill = document.getElementById('progressFill');
const progressLabel = document.getElementById('progressLabel');
const ledgerEl = document.getElementById('ledger');
const resultsBody = document.getElementById('resultsBody');
const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
const summaryCard = document.getElementById('summaryCard');
const summaryContent = document.getElementById('summaryContent');
const runIdDisplay = document.getElementById('runIdDisplay');
const toastContainer = document.getElementById('toastContainer');
runIdDisplay.textContent = RUN_ID;
MANIFEST.run_id = RUN_ID;
MANIFEST.timestamp = new Date().toISOString();
addLedgerEntry('PIPELINE_INIT', 'Run ID: ' + RUN_ID);
// ──────────────────────────────────────────────
// UTILITY FUNCTIONS
// ──────────────────────────────────────────────
async function sha256(data) {
const encoder = new TextEncoder();
const hashBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(data));
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
async function sha256File(file) {
const buffer = await file.arrayBuffer();
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
function addLedgerEntry(stage, detail) {
const now = new Date().toISOString();
const entry = {
stage,
detail,
timestamp: now,
chain_hash: null,
};
// Chain hash: hash of previous entry + this entry's data
const prevHash = LEDGER_ENTRIES.length > 0 ? LEDGER_ENTRIES[LEDGER_ENTRIES.length - 1]
.chain_hash : '00000000';
sha256(prevHash + stage + detail + now).then(h => {
entry.chain_hash = h.slice(0, 16);
MANIFEST.events.push({ stage, detail, timestamp: now, chain_hash: entry
.chain_hash });
renderLedger();
});
LEDGER_ENTRIES.push(entry);
renderLedger();
}
function renderLedger() {
ledgerEl.innerHTML = LEDGER_ENTRIES.map(e =>
`<div class="entry"><span class="ts">${e.timestamp.slice(11,19)}</span> [${e.stage}] ${e.detail} <span class="hash-frag">#${e.chain_hash||'pending…'}</span></div>`
).join('') + (LEDGER_ENTRIES.length === 0 ?
'<div class="entry"><span class="ts">--:--:--</span> Awaiting pipeline initiation…</div>' : '');
}
function showToast(msg, type = '') {
const toast = document.createElement('div');
toast.className = 'toast ' + type;
toast.textContent = msg;
toastContainer.appendChild(toast);
setTimeout(() => { toast.remove(); }, 4000);
}
function setStatus(state) {
statusDot.className = 'status-dot ' + state;
statusText.textContent = state === 'active' ? 'Processing…' : state === 'error' ? 'Error' :
'Idle';
}
// ──────────────────────────────────────────────
// SNP PARSING
// ──────────────────────────────────────────────
function parseSNPsFromText(text) {
const rsidRegex = /\brs\d+\b/gi;
const matches = text.match(rsidRegex) || [];
// Deduplicate and normalize
const unique = [...new Set(matches.map(m => m.toLowerCase().replace(/^rs/, 'rs')))]
.map(m => m.startsWith('rs') ? m : 'rs' + m)
.filter(m => /^rs\d+$/i.test(m));
// Also try to extract allele info if present in tabular format
const lines = text.split(/\n/);
const snpData = [];
for (const snp of unique) {
const info = { rsid: snp, riskAllele: null, otherAllele: null, chrom: null, pos: null };
// Try to find additional info in lines
for (const line of lines) {
if (line.toLowerCase().includes(snp.toLowerCase())) {
const parts = line.split(/\t|,|\s+/);
// Look for allele patterns
for (const p of parts) {
if (/^[ACGT]+$/i.test(p) && p.length <= 3 && !p.match(/^\d+$/)) {
if (!info.riskAllele) info.riskAllele = p.toUpperCase();
else if (!info.otherAllele && p.toUpperCase() !== info.riskAllele)
info.otherAllele = p.toUpperCase();
}
}
}
}
snpData.push(info);
}
return snpData;
}
function handleFileSelect(event) {
const file = event.target.files[0];
if (!file) return;
processUploadedFile(file);
}
async function processUploadedFile(file) {
setStatus('active');
addLedgerEntry('FILE_UPLOAD', `File: ${file.name} (${(file.size/1024).toFixed(1)} KB)`);
const text = await file.text();
const fileHash = await sha256File(file);
MANIFEST.input_hash = fileHash;
addLedgerEntry('INPUT_HASHED', `SHA-256: ${fileHash.slice(0,32)}…`);
parsedSNPs = parseSNPsFromText(text);
snpCountBadge.textContent = parsedSNPs.length;
if (parsedSNPs.length === 0) {
snpPreview.innerHTML =
'<span style="color:var(--danger);">No rsID patterns found. Please upload a file containing rsIDs (e.g., rs429358, rs7412).</span>';
runBtn.disabled = true;
setStatus('error');
showToast('No recognizable SNP identifiers (rsIDs) found in file.', 'error');
} else {
snpPreview.innerHTML = parsedSNPs.slice(0, 60).map(s =>
`<span style="display:inline-block;margin:3px;padding:2px 7px;background:var(--surface2);border-radius:4px;border:1px solid var(--border);">${s.rsid}${s.riskAllele?' ['+s.riskAllele+']':''}</span>`
).join('') + (parsedSNPs.length > 60 ?
`<br><span style="color:var(--text2);">…and ${parsedSNPs.length - 60} more</span>` : '');
runBtn.disabled = false;
setStatus('idle');
showToast(`Parsed ${parsedSNPs.length} unique SNP identifiers. Ready to cross-reference.`,
'success');
}
}
// Drag & drop
uploadZone.addEventListener('dragover', (e) => { e.preventDefault();
uploadZone.classList.add('drag-over'); });
uploadZone.addEventListener('dragleave', () => { uploadZone.classList.remove('drag-over'); });
uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
uploadZone.classList.remove('drag-over');
const file = e.dataTransfer.files[0];
if (file && (file.name.endsWith('.txt') || file.name.endsWith('.tsv') || file.name.endsWith(
'.csv') || file.name.endsWith('.vcf') || file.type === 'text/plain')) {
processUploadedFile(file);
} else {
showToast('Please upload a .txt, .tsv, .csv, or .vcf file.', 'error');
}
});
// ──────────────────────────────────────────────
// API CROSS-REFERENCING
// ──────────────────────────────────────────────
async function queryGWASCatalog(rsid) {
const url =
`https://www.ebi.ac.uk/gwas/rest/api/singleNucleotidePolymorphisms/${encodeURIComponent(rsid)}`;
try {
const resp = await fetch(url, {
headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(12000),
});
if (!resp.ok) return null;
const data = await resp.json();
return data;
} catch (e) {
console.warn(`GWAS Catalog fetch failed for ${rsid}: ${e.message}`);
return null;
}
}
async function queryPubMed(rsid) {
const url =
`https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi?db=pubmed&term=${encodeURIComponent(rsid)}&retmode=json&sort=relevance&retmax=20`;
try {
const resp = await fetch(url, { signal: AbortSignal.timeout(10000) });
if (!resp.ok) return null;
const data = await resp.json();
return data;
} catch (e) {
console.warn(`PubMed fetch failed for ${rsid}: ${e.message}`);
return null;
}
}
async function fetchPubMedDetails(pmidList) {
if (pmidList.length === 0) return [];
const ids = pmidList.slice(0, 5).join(',');
const url =
`https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi?db=pubmed&id=${ids}&retmode=json`;
try {
const resp = await fetch(url, { signal: AbortSignal.timeout(8000) });
if (!resp.ok) return [];
const data = await resp.json();
const results = data?.result || {};
const papers = [];
for (const id of pmidList.slice(0, 5)) {
if (results[id] && typeof results[id] === 'object') {
papers.push({
pmid: id,
title: results[id].title || 'N/A',
pubdate: results[id].pubdate || 'N/A',
source: results[id].source || 'N/A',
});
}
}
return papers;
} catch (e) {
return [];
}
}
// ──────────────────────────────────────────────
// RISK QUANTIFICATION ENGINE
// ──────────────────────────────────────────────
function quantifyRisk(gwasData, pubmedData, snpInfo) {
let pValue = null;
let oddsRatio = null;
let beta = null;
let riskAllele = snpInfo.riskAllele || null;
let traitList = [];
let pubmedCount = 0;
let pubmedPapers = [];
let gwasStudyCount = 0;
// Extract from GWAS Catalog data
if (gwasData && gwasData.associations) {
gwasStudyCount = gwasData.associations.length;
for (const assoc of gwasData.associations) {
if (assoc.pValue !== undefined && assoc.pValue !== null) {
const pv = parseFloat(assoc.pValue);
if (!isNaN(pv) && (pValue === null || pv < pValue)) pValue = pv;
}
if (assoc.orPerCopyNum !== undefined && assoc.orPerCopyNum !== null) {
const orVal = parseFloat(assoc.orPerCopyNum);
if (!isNaN(orVal) && oddsRatio === null) oddsRatio = orVal;
}
if (assoc.betaNum !== undefined && assoc.betaNum !== null) {
const bVal = parseFloat(assoc.betaNum);
if (!isNaN(bVal) && beta === null) beta = bVal;
}
if (assoc.riskAllele && !riskAllele) riskAllele = assoc.riskAllele;
if (assoc.diseaseTrait && assoc.diseaseTrait.trait) {
traitList.push(assoc.diseaseTrait.trait);
}
}
}
// Extract from PubMed data
if (pubmedData && pubmedData.esearchresult) {
pubmedCount = parseInt(pubmedData.esearchresult.count) || 0;
const idList = pubmedData.esearchresult.idlist || [];
pubmedPapers = idList.slice(0, 5);
}
// Novelty score: higher for less-studied variants
// Knowledge Gap = inversely proportional to publication count
const noveltyFactor = pubmedCount === 0 ? 2.5 :
pubmedCount <= 3 ? 2.0 :
pubmedCount <= 10 ? 1.5 :
pubmedCount <= 50 ? 1.15 :
1.0;
// Base risk score from GWAS evidence
let gwasEvidenceScore = 0;
if (pValue !== null && pValue > 0) {
gwasEvidenceScore = -Math.log10(pValue); // -log10(p) β€” higher = more significant
}
if (oddsRatio !== null && oddsRatio > 1) {
gwasEvidenceScore *= Math.log2(oddsRatio + 0.5);
}
if (beta !== null && Math.abs(beta) > 0) {
gwasEvidenceScore *= (1 + Math.abs(beta));
}
// Combined Risk Score
const riskScore = gwasEvidenceScore * noveltyFactor;
// Confidence assessment
let confidence = 'Low';
let confidenceScore = 0;
if (gwasStudyCount >= 3 && pubmedCount >= 5 && pValue !== null && pValue < 1e-5) {
confidence = 'High';
confidenceScore = 85 + Math.min(15, gwasStudyCount * 2);
} else if (gwasStudyCount >= 1 && pubmedCount >= 2 && pValue !== null && pValue < 0.05) {
confidence = 'Medium';
confidenceScore = 50 + Math.min(35, gwasStudyCount * 8 + pubmedCount * 2);
} else if (gwasStudyCount >= 1 || pubmedCount >= 1) {
confidence = 'Low';
confidenceScore = 20 + Math.min(30, gwasStudyCount * 5 + pubmedCount * 3);
} else {
confidence = 'Insufficient Data';
confidenceScore = 5;
}
// Novelty label
let noveltyLabel = 'Low';
if (noveltyFactor >= 2.0) noveltyLabel = 'High';
else if (noveltyFactor >= 1.4) noveltyLabel = 'Medium';
return {
rsid: snpInfo.rsid,
riskAllele: riskAllele || '?',
otherAllele: snpInfo.otherAllele || '?',
pValue,
oddsRatio,
beta,
gwasStudyCount,
pubmedCount,
pubmedPapers,
traitList: [...new Set(traitList)].slice(0, 5),
noveltyFactor,
noveltyLabel,
riskScore: Math.round(riskScore * 100) / 100,
confidence,
confidenceScore: Math.round(confidenceScore),
isLittleKnown: noveltyFactor >= 1.8 && gwasEvidenceScore > 2,
};
}
// ──────────────────────────────────────────────
// MAIN CROSS-REFERENCE PIPELINE
// ──────────────────────────────────────────────
async function runCrossReference() {
if (parsedSNPs.length === 0) {
showToast('No SNPs to process. Please upload a file first.', 'error');
return;
}
setStatus('active');
runBtn.disabled = true;
progressBar.style.display = 'block';
progressFill.style.width = '0%';
progressLabel.textContent = 'Starting cross-reference…';
addLedgerEntry('CROSSREF_START', `Processing ${parsedSNPs.length} variants`);
const total = parsedSNPs.length;
crossRefResults = [];
const BATCH_SIZE = 5;
for (let i = 0; i < total; i += BATCH_SIZE) {
const batch = parsedSNPs.slice(i, i + BATCH_SIZE);
const batchPromises = batch.map(async (snpInfo) => {
const [gwasData, pubmedData] = await Promise.all([
queryGWASCatalog(snpInfo.rsid),
queryPubMed(snpInfo.rsid),
]);
const result = quantifyRisk(gwasData, pubmedData, snpInfo);
// Fetch detailed PubMed info for top papers
if (result.pubmedPapers.length > 0) {
const details = await fetchPubMedDetails(result.pubmedPapers);
result.pubmedDetails = details;
} else {
result.pubmedDetails = [];
}
return result;
});
const batchResults = await Promise.all(batchPromises);
crossRefResults.push(...batchResults);
const progress = Math.round(((i + batch.length) / total) * 100);
progressFill.style.width = progress + '%';
progressLabel.textContent =
`Cross-referenced ${Math.min(i+batch.length, total)} of ${total} variants…`;
renderResults();
}
// Sort by risk score descending
crossRefResults.sort((a, b) => b.riskScore - a.riskScore);
// Finalize
progressFill.style.width = '100%';
progressLabel.textContent = 'Complete!';
const resultsJson = JSON.stringify(crossRefResults);
MANIFEST.results_hash = await sha256(resultsJson);
addLedgerEntry('CROSSREF_COMPLETE',
`${crossRefResults.length} variants processed. Results hash: ${MANIFEST.results_hash.slice(0,32)}…`
);
addLedgerEntry('RISK_QUANTIFIED',
`Top risk: ${crossRefResults[0]?.rsid||'N/A'} (score: ${crossRefResults[0]?.riskScore||'N/A'})`);
addLedgerEntry('MANIFEST_SEALED', `SHA-256: ${MANIFEST.results_hash.slice(0,40)}…`);
renderResults();
renderChart();
renderSummary();
setStatus('idle');
runBtn.disabled = false;
summaryCard.style.display = 'block';
showToast(
`Cross-reference complete! ${crossRefResults.length} variants analyzed. ${crossRefResults.filter(r=>r.isLittleKnown).length} flagged as "little-known risk".`,
'success');
}
// ──────────────────────────────────────────────
// RENDER FUNCTIONS
// ──────────────────────────────────────────────
function renderResults() {
if (crossRefResults.length === 0) {
resultsBody.innerHTML =
'<tr><td colspan="8" class="empty-state">Upload variants and run cross-reference to see results</td></tr>';
return;
}
resultsBody.innerHTML = crossRefResults.map(r => {
const riskClass = r.riskScore > 20 ? 'risk-high' : r.riskScore > 5 ? 'risk-medium' :
'risk-low';
const noveltyClass = r.noveltyLabel.toLowerCase();
const littleKnownFlag = r.isLittleKnown ? ' ⚠️' : '';
return `
<tr style="${r.isLittleKnown ? 'background:#1a1520;' : ''}">
<td><strong>${r.rsid}</strong>${littleKnownFlag}</td>
<td>${r.riskAllele}/${r.otherAllele}</td>
<td>${r.pValue !== null ? r.pValue.toExponential(2) : 'β€”'}</td>
<td>${r.oddsRatio !== null ? r.oddsRatio.toFixed(3) : (r.beta !== null ? 'Ξ²='+r.beta.toFixed(3) : 'β€”')}</td>
<td>${r.pubmedCount}</td>
<td><span class="novelty-badge ${noveltyClass}">${r.noveltyLabel} (${r.noveltyFactor.toFixed(1)}Γ—)</span></td>
<td class="${riskClass}"><strong>${r.riskScore.toFixed(1)}</strong></td>
<td>${r.confidence} (${r.confidenceScore}%)</td>
</tr>
${r.traitList.length > 0 ? `
<tr style="font-size:0.68rem;background:#0f1419;">
<td colspan="8" style="padding:4px 12px;color:var(--text2);">
🏷️ Traits: ${r.traitList.map(t=>`<span class="tag gwas">${t}</span>`).join(' ')}
${r.pubmedDetails && r.pubmedDetails.length > 0 ? `
<br>πŸ“„ Recent: ${r.pubmedDetails.slice(0,2).map(p=>`<span class="tag pubmed">PMID:${p.pmid} β€” ${(p.title||'').slice(0,60)}…</span>`).join(' ')}
` : ''}
</td>
</tr>` : ''}
${r.isLittleKnown ? `
<tr style="font-size:0.7rem;background:#1d1020;">
<td colspan="8" style="padding:4px 12px;color:#ff9db8;">
πŸ”¬ <strong>Little-Known Risk Allele:</strong> Significant effect size with limited literature. Priority for further investigation.
</td>
</tr>` : ''}
`;
}).join('');
}
function renderChart() {
const ctx = document.getElementById('riskChart').getContext('2d');
if (riskChartInstance) riskChartInstance.destroy();
const top20 = crossRefResults.slice(0, 20);
const labels = top20.map(r => r.rsid);
const scores = top20.map(r => r.riskScore);
const noveltyFlags = top20.map(r => r.isLittleKnown);
const bgColors = noveltyFlags.map(f => f ? 'rgba(255,107,157,0.75)' : 'rgba(77,166,255,0.7)');
const borderColors = noveltyFlags.map(f => f ? '#ff6b9d' : '#4da6ff');
riskChartInstance = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Risk Score (GWAS evidence Γ— Novelty)',
data: scores,
backgroundColor: bgColors,
borderColor: borderColors,
borderWidth: 1.5,
borderRadius: 6,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: { color: '#c8ccd4', font: { size: 11 } },
},
tooltip: {
callbacks: {
afterLabel: function(ctx) {
const idx = ctx.dataIndex;
return noveltyFlags[idx] ?
'⚠️ Little-Known Risk (high novelty)' : '';
},
},
},
},
scales: {
x: {
ticks: { color: '#8b92a0', font: { size: 9 }, maxRotation: 60 },
grid: { color: '#1e2733' },
},
y: {
ticks: { color: '#8b92a0' },
grid: { color: '#1e2733' },
title: { display: true, text: 'Risk Score', color: '#8b92a0' },
},
},
},
});
}
function renderSummary() {
const total = crossRefResults.length;
const littleKnown = crossRefResults.filter(r => r.isLittleKnown).length;
const highConf = crossRefResults.filter(r => r.confidence === 'High').length;
const topRisk = crossRefResults[0];
const avgNovelty = crossRefResults.reduce((s, r) => s + r.noveltyFactor, 0) / Math.max(total, 1);
summaryContent.innerHTML = `
<div style="flex:1;min-width:120px;"><strong>Total Variants:</strong><br><span style="font-size:1.4rem;font-weight:700;">${total}</span></div>
<div style="flex:1;min-width:140px;"><strong>πŸ”¬ Little-Known Risk:</strong><br><span style="font-size:1.4rem;font-weight:700;color:#ff6b9d;">${littleKnown}</span> <span style="font-size:0.7rem;color:var(--text2);">(${total>0?Math.round(littleKnown/total*100):0}%)</span></div>
<div style="flex:1;min-width:120px;"><strong>High Confidence:</strong><br><span style="font-size:1.4rem;font-weight:700;color:var(--accent2);">${highConf}</span></div>
<div style="flex:1;min-width:130px;"><strong>Avg Novelty Factor:</strong><br><span style="font-size:1.4rem;font-weight:700;">${avgNovelty.toFixed(2)}Γ—</span></div>
<div style="flex:1;min-width:150px;"><strong>Top Risk Allele:</strong><br><span style="font-size:1.1rem;font-weight:700;">${topRisk?.rsid||'N/A'}</span> <span style="color:var(--warn);">${topRisk?.riskScore.toFixed(1)||''}</span></div>
`;
summaryCard.style.display = 'block';
}
// ──────────────────────────────────────────────
// EXPORT FUNCTIONS
// ──────────────────────────────────────────────
async function downloadManifest() {
MANIFEST.events = LEDGER_ENTRIES.map(e => ({
stage: e.stage,
detail: e.detail,
timestamp: e.timestamp,
chain_hash: e.chain_hash,
}));
MANIFEST.exported_at = new Date().toISOString();
const manifestJson = JSON.stringify(MANIFEST, null, 2);
const blob = new Blob([manifestJson], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `manifest_${RUN_ID}.json`;
a.click();
URL.revokeObjectURL(url);
showToast('Manifest downloaded.', 'success');
addLedgerEntry('MANIFEST_EXPORTED', 'User downloaded audit manifest');
}
function downloadLedger() {
const ledgerText = LEDGER_ENTRIES.map(e =>
`${e.timestamp} | ${e.stage} | ${e.detail} | chain_hash: ${e.chain_hash||'pending'}`
).join('\n');
const blob = new Blob([ledgerText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `chain_of_custody_${RUN_ID}.txt`;
a.click();
URL.revokeObjectURL(url);
showToast('Chain-of-custody ledger downloaded.', 'success');
addLedgerEntry('LEDGER_EXPORTED', 'User downloaded chain-of-custody ledger');
}
// ──────────────────────────────────────────────
// INITIALIZATION
// ──────────────────────────────────────────────
addLedgerEntry('APP_INIT', 'PRS Risk Quantifier initialized. Awaiting input.');
renderLedger();
setStatus('idle');
// Expose functions to global scope for onclick handlers
window.handleFileSelect = handleFileSelect;
window.runCrossReference = runCrossReference;
window.downloadManifest = downloadManifest;
window.downloadLedger = downloadLedger;
// Demo: auto-load example if URL has ?demo
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('demo')) {
const demoSample =
`rs429358\nrs7412\nrs7903146\nrs1801282\nrs9939609\nrs662799\nrs11209026\nrs10757278\nrs1333049\nrs2383206\nrs10757274\nrs5186\nrs1799983\nrs2075650\nrs4420638\nrs8050136\nrs3751812\nrs10811661\nrs4402960\nrs5219`;
const demoBlob = new Blob([demoSample], { type: 'text/plain' });
const demoFile = new File([demoBlob], 'demo_variants.txt', { type: 'text/plain' });
setTimeout(() => {
processUploadedFile(demoFile);
showToast(
'Demo mode: 20 common GWAS variants loaded. Click "Cross-Reference & Quantify Risk" to analyze.',
'success');
}, 500);
}
console.log(`%c🧬 PRS Risk Quantifier %c${RUN_ID}%c ready.
%cUpload a TXT file with rsIDs to cross-reference against GWAS Catalog & PubMed.
%cArchitecture: Python orchestration β†’ R/PRS engine β†’ COBOL ledger (emulated)
%cSHA-256 chain-of-custody active.`,
'font-weight:bold;font-size:1.1em;',
'font-family:monospace;color:#4da6ff;',
'',
'color:#8b92a0;',
'color:#555;font-size:0.8em;',
'color:#555;font-size:0.8em;');
})();
</script>
</body>
</html>