MeMoModelling/gene-expression-bootstrapper
0
1<!DOCTYPE html>2<html lang="en">3<head>4<meta charset="UTF-8"/>5<meta name="viewport" content="width=device-width, initial-scale=1.0"/>6<title>Gene Expression Bootstrapper</title>7<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,700;1,400&family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet"/>8<style>9:root {10 --bg: #EEF1F8;11 --surface: #FFFFFF;12 --border: #D8DDED;13 --ink: #0A0E1F;14 --ink2: #4A5270;15 --ink3: #8A91AD;16 --teal: #4F46E5;17 --teal-l: #EFEEFD;18 --teal-d: #3730A3;19 --coral: #7C3AED;20 --coral-l: #F5F3FF;21 --r: 10px;22 --shadow: 0 1px 4px rgba(10,14,31,0.07);23 --shadow-md: 0 4px 16px rgba(10,14,31,0.12);24}25*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }26html, body { height: 100%; }27body {28 background: var(--bg);29 color: var(--ink);30 font-family: 'DM Sans', -apple-system, sans-serif;31 font-size: 16px;32 line-height: 1.6;33}34 35.page {36 max-width: 1200px;37 margin: 0 auto;38 padding: 1.25rem 2rem 1rem;39 display: flex;40 flex-direction: column;41 min-height: 100vh;42}43 44/* Copyright bar */45.copyright-bar {46 font-size: 12px;47 color: var(--ink3);48 margin-bottom: 0.75rem;49 padding-bottom: 0.75rem;50 border-bottom: 1px solid var(--border);51}52.copyright-bar a { color: var(--ink2); text-decoration: none; }53.copyright-bar a:hover { color: var(--ink); }54 55/* Header */56header {57 display: flex;58 align-items: center;59 gap: 2rem;60 margin-bottom: 1.25rem;61 flex-wrap: wrap;62}63.header-left { flex: 0 0 auto; }64.header-tag {65 font-size: 11px;66 font-weight: 600;67 letter-spacing: 0.12em;68 text-transform: uppercase;69 color: var(--coral);70 margin-bottom: 0.25rem;71 display: block;72}73header h1 {74 font-family: 'Playfair Display', Georgia, serif;75 font-size: 2.2rem;76 font-weight: 700;77 line-height: 1.15;78 color: var(--ink);79}80header h1 em {81 font-style: italic;82 font-weight: 400;83 color: var(--teal);84}85.header-rule {86 width: 32px; height: 3px;87 background: var(--coral);88 border-radius: 2px;89 margin-top: 0.4rem;90}91.header-sub {92 font-size: 16px;93 color: var(--ink2);94 line-height: 1.6;95 flex: 1;96 min-width: 180px;97}98 99/* 2x2 grid */100.main-grid {101 display: grid;102 grid-template-columns: 1fr 1fr;103 gap: 1.25rem;104 flex: 1;105}106 107/* Column cards */108.col-card {109 background: var(--surface);110 border: 1px solid var(--border);111 border-radius: var(--r);112 padding: 1.25rem;113 box-shadow: var(--shadow);114}115.col-label {116 font-size: 14px;117 font-weight: 600;118 letter-spacing: 0.12em;119 text-transform: uppercase;120 color: var(--ink3);121 margin-bottom: 0.85rem;122 display: flex;123 align-items: center;124 gap: 8px;125}126.col-label::after {127 content: '';128 flex: 1;129 height: 1px;130 background: var(--border);131}132 133/* Optional block */134.opt-tag {135 background: var(--coral-l);136 color: var(--coral);137 border: 1px solid var(--coral);138 font-size: 9px;139 font-weight: 800;140 letter-spacing: 0.1em;141 text-transform: uppercase;142 padding: 3px 7px;143 border-radius: 4px;144 display: inline-block;145 margin-bottom: 0.5rem;146}147.opt-title {148 font-size: 16px;149 font-weight: 600;150 color: var(--ink);151 margin-bottom: 0.25rem;152}153.opt-desc {154 font-size: 16px;155 color: var(--ink2);156 margin-bottom: 0.85rem;157 line-height: 1.5;158}159 160/* Slider */161.slider-row {162 display: flex;163 align-items: center;164 gap: 1rem;165}166.slider-val {167 font-family: 'Playfair Display', serif;168 font-size: 1.4rem;169 font-weight: 700;170 color: var(--teal);171 min-width: 58px;172 text-align: right;173 letter-spacing: -0.02em;174}175input[type="range"] {176 flex: 1;177 -webkit-appearance: none;178 height: 3px;179 background: var(--border);180 border-radius: 2px;181 outline: none;182 cursor: pointer;183}184input[type="range"]::-webkit-slider-thumb {185 -webkit-appearance: none;186 width: 18px; height: 18px;187 border-radius: 50%;188 background: var(--teal);189 cursor: pointer;190 border: 3px solid var(--surface);191 box-shadow: 0 0 0 2px var(--teal);192}193.slider-labels {194 display: flex;195 justify-content: space-between;196 font-size: 12px;197 color: var(--ink3);198 margin-top: 0.4rem;199}200.slider-note {201 font-size: 13px;202 color: var(--ink3);203 margin-top: 0.5rem;204}205 206/* Steps */207.step-num {208 width: 28px; height: 28px;209 border-radius: 7px;210 background: transparent;211 border: 2px solid var(--ink);212 color: var(--ink);213 font-size: 13px;214 font-weight: 700;215 display: inline-flex; align-items: center; justify-content: center;216 font-family: 'DM Sans', sans-serif;217 margin-bottom: 0.5rem;218 transition: all 0.2s;219}220.step-num.done {221 background: var(--teal);222 border-color: var(--teal);223 color: #fff;224}225.step-title {226 font-size: 16px;227 font-weight: 600;228 color: var(--ink);229 margin-bottom: 0.2rem;230}231.step-desc {232 font-size: 16px;233 color: var(--ink2);234 margin-bottom: 0.75rem;235 line-height: 1.5;236}237 238/* Drop zone */239.drop-zone {240 border: 1.5px dashed var(--border);241 border-radius: 8px;242 padding: 1.5rem 1rem;243 text-align: center;244 cursor: pointer;245 position: relative;246 transition: all 0.15s;247 background: var(--bg);248}249.drop-zone:hover {250 border-color: var(--teal);251 background: var(--teal-l);252}253.drop-zone input[type="file"] {254 position: absolute; inset: 0;255 opacity: 0; cursor: pointer;256 width: 100%; height: 100%;257}258.drop-icon { font-size: 1.4rem; margin-bottom: 0.3rem; display: block; }259.drop-text { font-size: 16px; color: var(--ink2); line-height: 1.5; }260.drop-text strong { color: var(--ink); display: block; font-weight: 600; }261.drop-text small { color: var(--ink3); font-size: 13px; margin-top: 2px; display: block; }262 263/* File chips */264.file-chips { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.6rem; }265.chip {266 display: inline-flex; align-items: center; gap: 5px;267 background: var(--teal-l);268 border: 1px solid #B2D4D4;269 color: var(--teal-d);270 padding: 3px 10px;271 border-radius: 100px;272 font-size: 13px;273 font-weight: 500;274}275.mismatch-warning {276 display: flex;277 align-items: flex-start;278 gap: 8px;279 background: #FFF7ED;280 border: 1.5px solid #FED7AA;281 border-radius: 8px;282 padding: 0.75rem 1rem;283 margin-top: 0.75rem;284 font-size: 14px;285 color: #9A3412;286 line-height: 1.5;287}288 289/* Bottom bar */290.bottom-bar {291 margin-top: 1.25rem;292 display: grid;293 grid-template-columns: 1fr 1fr;294 gap: 1.25rem;295 align-items: center;296}297.run-col {298 display: flex;299 justify-content: center;300 grid-column: 1 / -1;301}302.btn-run {303 width: 40%;304 padding: 0.65rem 1.25rem;305 background: var(--ink);306 color: var(--bg);307 border: none;308 border-radius: var(--r);309 font-family: 'DM Sans', sans-serif;310 font-size: 16px;311 font-weight: 600;312 cursor: pointer;313 transition: all 0.15s;314 display: flex; align-items: center; justify-content: center; gap: 8px;315}316.btn-run:hover:not(:disabled) {317 background: var(--teal);318 box-shadow: var(--shadow-md);319 transform: translateY(-1px);320}321.btn-run:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }322 323/* Progress */324.progress-section {325 display: none;326 background: var(--surface);327 border: 1px solid var(--border);328 border-radius: var(--r);329 padding: 0.9rem 1.25rem;330 box-shadow: var(--shadow);331 flex-direction: column;332 grid-column: 1 / -1;333}334.progress-header {335 display: flex; justify-content: space-between; align-items: center;336 font-size: 16px; margin-bottom: 0.5rem;337}338.progress-label { font-weight: 600; color: var(--ink); }339.progress-pct { color: var(--ink2); font-variant-numeric: tabular-nums; }340.progress-track { height: 5px; background: var(--border); border-radius: 3px; overflow: hidden; }341.progress-fill {342 height: 100%;343 background: linear-gradient(90deg, var(--teal), #4A9F9F);344 border-radius: 3px;345 transition: width 0.45s ease;346 width: 0%;347}348.log-box { display: none; }349 350/* Results */351.results-section {352 display: none;353 background: var(--surface);354 border: 1px solid var(--border);355 border-radius: var(--r);356 padding: 1rem 1.25rem;357 box-shadow: var(--shadow);358 flex-direction: column;359 align-items: center;360 gap: 0.6rem;361 grid-column: 1 / -1;362}363.stats-summary {364 font-size: 14px;365 color: var(--ink3);366 font-weight: 500;367}368.btn-download {369 display: flex; align-items: center; justify-content: center; gap: 8px;370 padding: 0.7rem 1.25rem;371 background: var(--teal-l);372 color: var(--teal-d);373 border: 1.5px solid var(--teal);374 border-radius: var(--r);375 font-family: 'DM Sans', sans-serif;376 font-size: 16px; font-weight: 600;377 cursor: pointer; text-decoration: none; white-space: nowrap;378 transition: all 0.15s;379}380.btn-download:hover:not(:disabled) {381 background: var(--teal);382 color: white;383 transform: translateY(-1px);384 box-shadow: var(--shadow-md);385}386.btn-download:disabled { opacity: 0.7; cursor: not-allowed; transform: none; }387 388.download-progress-wrap { margin-top: 0.65rem; display: none; }389.download-progress-wrap.show { display: block; }390.download-progress-track { height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; }391.download-progress-fill {392 height: 100%;393 background: linear-gradient(90deg, var(--teal), #6B8FCF);394 border-radius: 2px;395 width: 0%;396 transition: width 0.3s ease;397}398.download-progress-label { font-size: 13px; color: var(--ink2); margin-top: 0.3rem; text-align: center; }399@keyframes indeterminate {400 0% { left: -40%; width: 40%; }401 60% { left: 100%; width: 40%; }402 100% { left: 100%; width: 40%; }403}404.download-progress-fill.indeterminate {405 position: relative;406 width: 40%;407 animation: indeterminate 1.4s ease infinite;408}409 410.sample-link {411 display: block;412 width: fit-content;413 margin: 0.35rem 0 0.75rem;414 font-size: 15px;415 color: var(--teal);416 text-decoration: none;417 font-weight: 600;418}419.sample-link:hover { color: var(--teal-d); text-decoration: underline; }420.drop-text .file-type-hint {421 display: block;422 margin-top: 0.35rem;423 color: var(--ink3);424 font-size: 12px;425 line-height: 1;426 text-align: center;427}428 429.hidden { display: none !important; }430::-webkit-scrollbar { width: 5px; }431::-webkit-scrollbar-track { background: transparent; }432::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }433</style>434</head>435<body>436<div class="page">437 438 <!-- Copyright bar -->439 <div class="copyright-bar">440 © 2026 A*STAR Bioinformatics Institute · 441 Free for academic research and education · 442 Commercial licensing: <a href="mailto:yeohc@a-star.edu.sg">yeohc@a-star.edu.sg</a>443 </div>444 445 <!-- Header -->446 <header>447 <div class="header-left">448 <span class="header-tag">MeMo Modelling · A*STAR</span>449 <h1>Gene Expression <em>Bootstrapper</em></h1>450 <div class="header-rule"></div>451 </div>452 <p class="header-sub">Fills in missing expression values for unmapped genes by sampling from the same metabolic subsystem. Output serves as input for MeMo.</p>453 </header>454 455 <!-- 2x2 grid -->456 <div class="main-grid">457 458 <!-- Top-left: Optional -->459 <div class="col-card">460 <div class="col-label">Parameters</div>461 <div class="opt-tag">Optional</div>462 <div class="opt-title">Bootstrap iterations</div>463 <div class="opt-desc">Number of output CSV files to generate. Default of 1,000 is recommended.</div>464 <div class="slider-row">465 <input type="range" id="batchSlider" min="100" max="5000" step="100" value="1000"466 oninput="updateSlider(this.value)"/>467 <div class="slider-val" id="batchVal">1,000</div>468 </div>469 <div class="slider-labels"><span>100</span><span>5,000</span></div>470 <div class="slider-note">Higher values = more robust results, longer processing time.</div>471 </div>472 473 <!-- Top-right: Step 1 -->474 <div class="col-card">475 <div class="col-label">Step 1</div>476 <div class="step-title">Prefixed model files</div>477 <div class="step-desc">One file per species — output from Step 2 of SBML2FEA.</div>478 <a href="/sample_model.xlsx" download="LC_model.xlsx" class="sample-link">⬇ Download sample file</a>479 <div class="drop-zone" id="dropModel">480 <input type="file" id="fileModel" accept=".xlsx" multiple onchange="handleFiles('model', this.files)"/>481 <span class="drop-icon">📄</span>482 <div class="drop-text">483 <strong>Drop file here</strong>484 or click to browse485 <small class="file-type-hint">.xlsx</small>486 </div>487 </div>488 <div class="file-chips" id="chipsModel"></div>489 </div>490 491 <!-- Bottom-left: Step 2 -->492 <div class="col-card">493 <div class="col-label">Step 2</div>494 <div class="step-title">Identifier mapping files</div>495 <div class="step-desc">One file per species, in the same order as model files — output from Step 3 of SBML2FEA.</div>496 <a href="/sample_mapping.xlsx" download="LC_mapping.xlsx" class="sample-link">⬇ Download sample file</a>497 <div class="drop-zone" id="dropMapping">498 <input type="file" id="fileMapping" accept=".xlsx" multiple onchange="handleFiles('mapping', this.files)"/>499 <span class="drop-icon">📄</span>500 <div class="drop-text">501 <strong>Drop file here</strong>502 or click to browse503 <small class="file-type-hint">.xlsx</small>504 </div>505 </div>506 <div class="file-chips" id="chipsMapping"></div>507 <div class="mismatch-warning hidden" id="prefixWarning"></div>508 </div>509 510 <!-- Bottom-right: Step 3 -->511 <div class="col-card">512 <div class="col-label">Step 3</div>513 <div class="step-title">Combined gene expression file</div>514 <div class="step-desc">Normalised gene expression counts for all species and samples from your metatranscriptomics data.</div>515 <a href="/sample_combined_geneExpr.csv" download="combined_geneExpr.csv" class="sample-link">⬇ Download sample file</a>516 <div class="drop-zone" id="dropExpr">517 <input type="file" id="fileExpr" accept=".csv" onchange="handleFiles('expr', this.files)"/>518 <span class="drop-icon">📄</span>519 <div class="drop-text">520 <strong>Drop file here</strong>521 or click to browse · single file only522 <small class="file-type-hint">.csv</small>523 </div>524 </div>525 <div class="file-chips" id="chipsExpr"></div>526 </div>527 528 </div><!-- /main-grid -->529 530 <!-- Bottom bar -->531 <div class="bottom-bar">532 <div class="run-col">533 <button class="btn-run" id="runBtn" onclick="startJob()">Generate Bootstrapped Files</button>534 </div>535 536 <div class="progress-section hidden" id="progressSection">537 <div class="progress-header">538 <span class="progress-label" id="progressLabel">Starting…</span>539 <span class="progress-pct" id="progressPct">0%</span>540 </div>541 <div class="progress-track">542 <div class="progress-fill" id="progressBar"></div>543 </div>544 <div class="log-box" id="logBox"></div>545 </div>546 547 <div class="results-section hidden" id="resultsSection">548 <div class="stats-summary"><span id="statFiles">—</span> files generated · <span id="statSpecies">—</span> species processed</div>549 <button class="btn-download" id="downloadBtn" onclick="triggerDownload()">550 ⬇ Download Bootstrapped ZIP551 </button>552 <div class="download-progress-wrap" id="dlProgressWrap">553 <div class="download-progress-track">554 <div class="download-progress-fill indeterminate" id="dlProgressFill"></div>555 </div>556 <div class="download-progress-label" id="dlProgressLabel">Preparing ZIP…</div>557 </div>558 </div>559 </div>560 561</div>562<script>563const state = { model: [], mapping: [], expr: [] };564 565function updateSlider(val) {566 document.getElementById('batchVal').textContent = Number(val).toLocaleString();567 const pct = (val - 100) / (5000 - 100) * 100;568 document.getElementById('batchSlider').style.background =569 `linear-gradient(to right, var(--teal) ${pct}%, var(--border) ${pct}%)`;570}571 572window.addEventListener('DOMContentLoaded', () => updateSlider(1000));573 574function getPrefix(filename) {575 return filename.split('_')[0].toLowerCase();576}577 578function checkPrefixMatch() {579 const warn = document.getElementById('prefixWarning');580 if (!state.model.length || !state.mapping.length) {581 warn.classList.add('hidden');582 return true;583 }584 const modelPrefixes = state.model.map(f => getPrefix(f.name)).sort();585 const mappingPrefixes = state.mapping.map(f => getPrefix(f.name)).sort();586 const mismatch =587 modelPrefixes.length !== mappingPrefixes.length ||588 modelPrefixes.some((p, i) => p !== mappingPrefixes[i]);589 if (mismatch) {590 warn.innerHTML = `⚠️ <span>Species prefix mismatch — model files (${modelPrefixes.join(', ')}) don't match mapping files (${mappingPrefixes.join(', ')}). Make sure files are uploaded in the same order and belong to the same species.</span>`;591 warn.classList.remove('hidden');592 return false;593 }594 warn.classList.add('hidden');595 return true;596}597 598function handleFiles(type, files) {599 const newFiles = Array.from(files);600 // Accumulate — skip duplicates by name601 newFiles.forEach(f => {602 if (!state[type].find(e => e.name === f.name)) {603 state[type].push(f);604 }605 });606 const key = { model: 'chipsModel', mapping: 'chipsMapping', expr: 'chipsExpr' }[type];607 const num = { model: 'num1', mapping: 'num2', expr: 'num3' }[type];608 document.getElementById(key).innerHTML =609 state[type].map(f => `<div class="chip">✓ ${f.name} <span style="cursor:pointer;margin-left:3px" onclick="removeFile('${type}','${f.name}')">×</span></div>`).join('');610 const el = document.getElementById(num); if (el) el.classList.add('done');611 checkPrefixMatch();612}613 614function removeFile(type, name) {615 state[type] = state[type].filter(f => f.name !== name);616 const key = { model: 'chipsModel', mapping: 'chipsMapping', expr: 'chipsExpr' }[type];617 const num = { model: 'num1', mapping: 'num2', expr: 'num3' }[type];618 document.getElementById(key).innerHTML =619 state[type].map(f => `<div class="chip">✓ ${f.name} <span style="cursor:pointer;margin-left:3px" onclick="removeFile('${type}','${f.name}')">×</span></div>`).join('');620 if (!state[type].length) document.getElementById(num).classList.remove('done');621 checkPrefixMatch();622}623 624['dropModel','dropMapping','dropExpr'].forEach(id => {625 const el = document.getElementById(id);626 el.addEventListener('dragover', e => { e.preventDefault(); el.style.borderColor = 'var(--teal)'; });627 el.addEventListener('dragleave', () => el.style.borderColor = '');628 el.addEventListener('drop', () => el.style.borderColor = '');629});630 631let activeJobId = null;632 633async function startJob() {634 if (!state.model.length) { alert('Please upload at least one model file.'); return; }635 if (!state.mapping.length) { alert('Please upload at least one mapping file.'); return; }636 if (!state.expr.length) { alert('Please upload a gene expression CSV.'); return; }637 if (!checkPrefixMatch()) { alert('Fix the species prefix mismatch before running.'); return; }638 639 document.getElementById('runBtn').disabled = true;640 document.getElementById('progressSection').classList.remove('hidden');641 document.getElementById('progressSection').style.display = 'flex';642 document.getElementById('resultsSection').classList.add('hidden');643 document.getElementById('logBox').innerHTML = '';644 document.getElementById('progressBar').style.width = '0%';645 document.getElementById('progressPct').textContent = '0%';646 document.getElementById('progressLabel').textContent = 'Starting…';647 648 const fd = new FormData();649 state.model.forEach(f => fd.append('model_files', f));650 state.mapping.forEach(f => fd.append('mapping_files', f));651 fd.append('expr_file', state.expr[0]);652 fd.append('batch_count', document.getElementById('batchSlider').value);653 654 let jobId;655 try {656 const res = await fetch('/api/process', { method: 'POST', body: fd });657 if (!res.ok) throw new Error((await res.json()).detail || res.statusText);658 jobId = (await res.json()).job_id;659 activeJobId = jobId;660 appendLog('Files received. Starting bootstrap…', 'info');661 } catch (e) {662 appendLog('Error: ' + e.message, 'err');663 document.getElementById('runBtn').disabled = false;664 return;665 }666 667 const sse = new EventSource(`/api/progress/${jobId}`);668 669 sse.onmessage = e => {670 const d = JSON.parse(e.data);671 if (d.message) appendLog(d.message, d.type || '');672 if (d.progress !== undefined) {673 document.getElementById('progressBar').style.width = d.progress + '%';674 document.getElementById('progressPct').textContent = d.progress + '%';675 const cleanLabels = ['Starting…', 'Files received. Starting bootstrap…', 'Writing output files…', 'Compressing output files…', 'Processing…'];676 if (d.label && cleanLabels.some(l => d.label.startsWith(l.split('…')[0]))) {677 document.getElementById('progressLabel').textContent = d.label;678 } else if (d.progress > 0 && d.progress < 100) {679 document.getElementById('progressLabel').textContent = 'Processing…';680 }681 }682 if (d.status === 'done') {683 sse.close();684 document.getElementById('statFiles').textContent = (d.file_count || '—').toLocaleString();685 document.getElementById('statSpecies').textContent = d.species_count || '—';686 document.getElementById('downloadBtn').dataset.jobId = jobId;687 document.getElementById('progressSection').classList.add('hidden');688 document.getElementById('resultsSection').classList.remove('hidden');689 document.getElementById('resultsSection').style.display = 'flex';690 document.getElementById('runBtn').disabled = false;691 }692 if (d.status === 'error') {693 sse.close();694 appendLog('Fatal error: ' + d.error, 'err');695 document.getElementById('runBtn').disabled = false;696 }697 };698 699 sse.onerror = () => {700 sse.close();701 appendLog('Connection lost. Check logs.', 'err');702 document.getElementById('runBtn').disabled = false;703 };704}705 706async function triggerDownload() {707 const jobId = document.getElementById('downloadBtn').dataset.jobId;708 if (!jobId) return;709 710 const btn = document.getElementById('downloadBtn');711 const wrap = document.getElementById('dlProgressWrap');712 const fill = document.getElementById('dlProgressFill');713 const label = document.getElementById('dlProgressLabel');714 715 btn.disabled = true;716 btn.textContent = 'Preparing download…';717 wrap.classList.add('show');718 fill.classList.add('indeterminate');719 label.textContent = 'Fetching ZIP from server…';720 721 try {722 const res = await fetch(`/api/download/${jobId}`);723 if (!res.ok) throw new Error('Download failed');724 725 const contentLength = res.headers.get('Content-Length');726 let blob;727 728 if (contentLength) {729 const total = parseInt(contentLength);730 const chunks = [];731 let received = 0;732 const reader = res.body.getReader();733 fill.classList.remove('indeterminate');734 while (true) {735 const { done, value } = await reader.read();736 if (done) break;737 chunks.push(value);738 received += value.length;739 const pct = Math.min(Math.round(received / total * 100), 99);740 fill.style.width = pct + '%';741 label.textContent = `Downloading… ${pct}%`;742 }743 blob = new Blob(chunks, { type: 'application/zip' });744 } else {745 label.textContent = 'Downloading…';746 blob = await res.blob();747 }748 749 fill.classList.remove('indeterminate');750 fill.style.width = '100%';751 label.textContent = 'Done!';752 753 const url = URL.createObjectURL(blob);754 const a = document.createElement('a');755 a.href = url; a.download = 'geneExpr_bootstrapped.zip';756 document.body.appendChild(a); a.click();757 document.body.removeChild(a);758 URL.revokeObjectURL(url);759 760 setTimeout(() => {761 btn.disabled = false;762 btn.textContent = '⬇ Download Bootstrapped ZIP';763 wrap.classList.remove('show');764 fill.style.width = '0%';765 }, 2000);766 767 } catch (e) {768 btn.disabled = false;769 btn.textContent = '⬇ Download Bootstrapped ZIP';770 wrap.classList.remove('show');771 alert('Download failed: ' + e.message);772 }773}774 775function appendLog(text, cls = '') {776 const box = document.getElementById('logBox');777 const span = document.createElement('span');778 if (cls) span.className = 'log-' + cls;779 span.textContent = '› ' + text + '\n';780 box.appendChild(span);781 box.scrollTop = box.scrollHeight;782}783</script>784</body>785</html>786 