Team Ai
Apppublic

MeMoModelling/gene-expression-bootstrapper

sourceHugging Faceupdated 3mo agoView on Hugging Face
0likes
index.html786 linesDownload Raw Back to frontend
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 &nbsp;·&nbsp;441    Free for academic research and education &nbsp;·&nbsp;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 &nbsp;·&nbsp; <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