Spaces:
Running
Running
| <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% ; | |
| height: 100% ; | |
| } | |
| .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 & 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> |