Spaces:
Runtime error
Runtime error
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>Heartline</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Spectral:wght@400;500;600&family=Hanken+Grotesk:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet"> | |
| <style> | |
| *, *::before, *::after { box-sizing: border-box; } | |
| body { margin: 0; font-family: 'Hanken Grotesk', sans-serif; color: #1E2B27; background: #F6F4EF; } | |
| input, select, button, textarea { font-family: inherit; } | |
| input:focus, select:focus, textarea:focus { outline: none; border-color: #2E6E62 ; box-shadow: 0 0 0 3px rgba(46,110,98,0.12); } | |
| input::placeholder { color: #A9B2AC; } | |
| button { cursor: pointer; white-space: nowrap; } | |
| label { display: block; } | |
| .step-section { display: none; } | |
| .step-section.active { display: block; } | |
| .card { background: #fff; border: 1px solid #E8E6DE; border-radius: 16px; } | |
| .field-label { font-size: 12.5px; font-weight: 600; color: #4A5752; margin-bottom: 7px; } | |
| .field-label-sm { font-size: 11.5px; font-weight: 600; color: #7B8780; margin-bottom: 5px; } | |
| .input-base { width: 100%; padding: 11px 13px; border: 1px solid #DCDAD0; border-radius: 10px; font-size: 14px; color: #1E2B27; background: #fff; transition: border-color .15s, box-shadow .15s; } | |
| .input-sm { padding: 9px 11px; border-radius: 9px; font-size: 13.5px; } | |
| .btn-primary { background: #2E6E62; color: #fff; border: none; border-radius: 10px; padding: 11px 18px; font-size: 14px; font-weight: 600; } | |
| .btn-secondary { background: #fff; color: #1F4D45; border: 1px solid #D8D6CC; border-radius: 10px; padding: 9px 15px; font-size: 13.5px; font-weight: 600; } | |
| .btn-ghost { background: transparent; border: none; color: #C7CEC8; font-size: 16px; line-height: 1; padding: 0 4px; } | |
| .chip-green { display: inline-flex; align-items: center; gap: 7px; background: #EAF1EC; border: 1px solid #D7E4DA; border-radius: 999px; padding: 5px 8px 5px 13px; font-size: 13px; color: #1F4D45; } | |
| .chip-green button { border: none; background: #CFE0D4; color: #1F4D45; width: 18px; height: 18px; border-radius: 50%; font-size: 12px; display: flex; align-items: center; justify-content: center; padding: 0; } | |
| .mono { font-family: 'IBM Plex Mono', monospace; } | |
| .serif { font-family: 'Spectral', serif; } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| @keyframes wave { 0%,100% { transform: scaleY(0.35); } 50% { transform: scaleY(1.5); } } | |
| @keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.3; } } | |
| .spinner { width: 18px; height: 18px; border: 2px solid #D7E4DA; border-top-color: #2E6E62; border-radius: 50%; display: inline-block; animation: spin .7s linear infinite; } | |
| @media print { .no-print { display: none ; } body { background: #fff ; } } | |
| #model-banner { position:fixed;bottom:20px;left:24px;z-index:99;display:flex;align-items:center;gap:10px;background:#1E2B27;color:#fff;border-radius:12px;padding:10px 16px;font-size:13px;box-shadow:0 4px 20px rgba(0,0,0,0.25);transition:opacity .4s; } | |
| #model-banner.hidden { opacity:0;pointer-events:none; } | |
| #model-bar-track { width:140px;height:5px;background:rgba(255,255,255,0.15);border-radius:3px;overflow:hidden; } | |
| #model-bar-fill { height:100%;background:#6FC0A8;border-radius:3px;transition:width .2s; } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- ββ MODEL DOWNLOAD BANNER ββ --> | |
| <div id="model-banner" class="no-print"> | |
| <div style="width:8px;height:8px;border-radius:50%;background:#6FC0A8;flex-shrink:0;" id="model-dot"></div> | |
| <span id="model-banner-text">Downloading spectrogram modelβ¦</span> | |
| <div id="model-bar-track"><div id="model-bar-fill" style="width:0%"></div></div> | |
| <span id="model-pct" style="font-size:12px;color:#8FB6A6;min-width:34px;text-align:right;">0%</span> | |
| </div> | |
| <!-- ββ LLM DOWNLOAD BANNER ββ --> | |
| <div id="llm-banner" class="no-print" style="position:fixed;bottom:20px;left:24px;z-index:98;display:none;align-items:center;gap:10px;background:#1E2B27;color:#fff;border-radius:12px;padding:10px 16px;font-size:13px;box-shadow:0 4px 20px rgba(0,0,0,0.25);transition:opacity .4s;"> | |
| <div style="width:8px;height:8px;border-radius:50%;background:#C8B96A;flex-shrink:0;animation:pulse 1.4s ease-in-out infinite;" id="llm-dot"></div> | |
| <span id="llm-banner-text">Downloading LLMβ¦</span> | |
| </div> | |
| <!-- ββ TOP BAR ββ --> | |
| <header class="no-print" style="background:#fff;border-bottom:1px solid #E8E6DE;position:sticky;top:0;z-index:20;"> | |
| <div style="max-width:1120px;margin:0 auto;padding:14px 28px;display:flex;align-items:center;gap:12px;"> | |
| <div style="width:9px;height:9px;border-radius:50%;background:#2E6E62;"></div> | |
| <span class="serif" style="font-size:19px;">Heartline</span> | |
| <span style="font-size:12px;color:#9AA59E;padding-left:12px;border-left:1px solid #E8E6DE;">Home monitoring β SOAP Objective</span> | |
| </div> | |
| </header> | |
| <!-- ββ PROGRESS ββ --> | |
| <nav class="no-print" style="max-width:1120px;margin:0 auto;padding:26px 28px 8px;"> | |
| <div id="progress-bar-wrap" style="position:relative;display:flex;justify-content:space-between;max-width:680px;"> | |
| <div style="position:absolute;top:17px;left:18px;right:18px;height:2px;background:#E5E3DA;"></div> | |
| <div id="progress-fill" style="position:absolute;top:17px;left:18px;height:2px;background:#2E6E62;transition:width .35s ease;width:0%;"></div> | |
| <!-- steps rendered by JS --> | |
| </div> | |
| </nav> | |
| <!-- ββ MAIN ββ --> | |
| <main style="max-width:1120px;margin:0 auto;padding:18px 28px 120px;"> | |
| <!-- Step 0: About you --> | |
| <section id="step-0" class="step-section"> | |
| <div class="card" style="padding:30px 32px;"> | |
| <h1 class="serif" style="font-weight:500;font-size:26px;margin:0 0 6px;">About you</h1> | |
| <p style="font-size:15px;color:#6B7A73;margin:0 0 26px;max-width:560px;">A few details so your report reads like a proper clinical note.</p> | |
| <div style="display:grid;grid-template-columns:140px 1fr 1fr;gap:18px;margin-bottom:26px;"> | |
| <div> | |
| <label class="field-label" for="age">Age</label> | |
| <input id="age" type="number" class="input-base mono" placeholder="β" oninput="setState({age:this.value})"> | |
| </div> | |
| <div> | |
| <label class="field-label" for="sex">Sex</label> | |
| <select id="sex" class="input-base" onchange="setState({sex:this.value})"> | |
| <option value="">Selectβ¦</option> | |
| <option>Female</option><option>Male</option><option>Intersex</option><option>Prefer not to say</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label class="field-label" for="race">Race / ethnicity</label> | |
| <select id="race" class="input-base" onchange="setState({race:this.value})"> | |
| <option value="">Selectβ¦</option> | |
| <option>White / Caucasian</option><option>Black / African American</option> | |
| <option>Hispanic / Latino</option><option>Asian</option> | |
| <option>Native American</option><option>Middle Eastern</option> | |
| <option>Multiracial</option><option>Other</option> | |
| </select> | |
| </div> | |
| </div> | |
| <!-- Conditions --> | |
| <div style="margin-bottom:26px;"> | |
| <label class="field-label">Reported conditions</label> | |
| <div id="conditions-display" style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:11px;min-height:20px;"></div> | |
| <div style="display:flex;gap:8px;max-width:440px;"> | |
| <input id="condition-input" class="input-base" placeholder="Add a conditionβ¦" | |
| onkeydown="if(event.key==='Enter'){event.preventDefault();addCondition();}"> | |
| <button class="btn-secondary" onclick="addCondition()">Add</button> | |
| </div> | |
| <div id="condition-suggestions" style="display:flex;flex-wrap:wrap;gap:6px;margin-top:11px;"></div> | |
| </div> | |
| <!-- Medications --> | |
| <div> | |
| <label class="field-label">Current medications</label> | |
| <div id="meds-display" style="display:flex;flex-direction:column;gap:7px;margin-bottom:11px;"></div> | |
| <div style="display:flex;gap:8px;max-width:440px;"> | |
| <input id="med-name" class="input-base" placeholder="Medication" style="flex:1.4;"> | |
| <input id="med-dose" class="input-base" placeholder="Dose e.g. 10 mg daily" style="flex:1;" | |
| onkeydown="if(event.key==='Enter'){event.preventDefault();addMed();}"> | |
| <button class="btn-secondary" onclick="addMed()">Add</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div style="margin-top:16px;background:#FFFDF7;border:1px solid #E8E3D4;border-radius:12px;padding:15px 18px;display:flex;gap:12px;align-items:flex-start;"> | |
| <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#B4502F" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;margin-top:2px;"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg> | |
| <p style="margin:0;font-size:12.5px;color:#6B7A73;line-height:1.65;"><strong style="color:#4A5752;">Not a medical device.</strong> Heartline is a research prototype and has not been evaluated or cleared by the FDA. It is not a substitute for professional medical advice, diagnosis, or treatment. Cardiac rhythm classifications are automated and may be inaccurate. <strong style="color:#B4502F;">If you are experiencing a medical emergency, call 911 or your local emergency number immediately.</strong> Always consult a qualified healthcare provider before making any clinical decisions based on this data.</p> | |
| </div> | |
| </section> | |
| <!-- Step 1: Blood pressure --> | |
| <section id="step-1" class="step-section"> | |
| <div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:18px;flex-wrap:wrap;gap:10px;"> | |
| <div> | |
| <h1 class="serif" style="font-weight:500;font-size:26px;margin:0 0 6px;">Blood pressure</h1> | |
| <p style="font-size:15px;color:#6B7A73;margin:0;max-width:560px;">Enter readings directly in the table, or import a CSV.</p> | |
| </div> | |
| <div style="display:flex;align-items:center;gap:10px;"> | |
| <label style="display:flex;align-items:center;gap:8px;background:#fff;border:1px solid #D8D6CC;border-radius:10px;padding:9px 15px;font-size:13.5px;font-weight:600;color:#1F4D45;cursor:pointer;"> | |
| <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#2E6E62" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 16V4M7 9l5-5 5 5"/><path d="M5 20h14"/></svg> | |
| Import CSV | |
| <input type="file" accept=".csv,.xlsx,.xls" onchange="importCsv(this)" style="display:none;"> | |
| </label> | |
| <div id="bp-toast" style="display:none;align-items:center;gap:8px;background:#EAF1EC;border:1px solid #D7E4DA;border-radius:10px;padding:9px 13px;font-size:13px;color:#1F4D45;"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#2E6E62" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg> | |
| <span id="bp-toast-msg"></span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- BP summary bar --> | |
| <div id="bp-summary"></div> | |
| <!-- Editable readings table --> | |
| <div class="card" style="overflow:hidden;margin-bottom:0;"> | |
| <table style="width:100%;border-collapse:collapse;"> | |
| <thead> | |
| <tr style="border-bottom:1px solid #EEEDE6;"> | |
| <th style="padding:11px 16px;text-align:left;font-size:11.5px;font-weight:600;letter-spacing:0.04em;text-transform:uppercase;color:#9AA59E;width:160px;">Date</th> | |
| <th style="padding:11px 16px;text-align:left;font-size:11.5px;font-weight:600;letter-spacing:0.04em;text-transform:uppercase;color:#9AA59E;width:130px;">Time</th> | |
| <th style="padding:11px 16px;text-align:left;font-size:11.5px;font-weight:600;letter-spacing:0.04em;text-transform:uppercase;color:#9AA59E;width:110px;">Systolic</th> | |
| <th style="padding:11px 16px;text-align:left;font-size:11.5px;font-weight:600;letter-spacing:0.04em;text-transform:uppercase;color:#9AA59E;width:110px;">Diastolic</th> | |
| <th style="padding:11px 16px;text-align:left;font-size:11.5px;font-weight:600;letter-spacing:0.04em;text-transform:uppercase;color:#9AA59E;width:110px;">Heart Rate</th> | |
| <th style="padding:11px 16px;text-align:left;font-size:11.5px;font-weight:600;letter-spacing:0.04em;text-transform:uppercase;color:#9AA59E;">Symptom</th> | |
| <th style="width:36px;"></th> | |
| </tr> | |
| </thead> | |
| <tbody id="bp-table"></tbody> | |
| <tfoot> | |
| <tr> | |
| <td colspan="7" style="padding:10px 12px;border-top:1px solid #F3F1EA;"> | |
| <button onclick="addBpRow()" style="background:transparent;border:none;color:#2E6E62;font-size:13.5px;font-weight:600;cursor:pointer;padding:4px 6px;display:flex;align-items:center;gap:6px;"> | |
| <span style="font-size:18px;line-height:1;">+</span> Add row | |
| </button> | |
| </td> | |
| </tr> | |
| </tfoot> | |
| </table> | |
| </div> | |
| </section> | |
| <!-- Step 2: Cardiac sessions --> | |
| <section id="step-2" class="step-section"> | |
| <div style="margin-bottom:18px;"> | |
| <h1 class="serif" style="font-weight:500;font-size:26px;margin:0 0 6px;">Cardiac sessions</h1> | |
| <p style="font-size:15px;color:#6B7A73;margin:0;max-width:600px;">Each session groups ECG recordings you take at one sitting.</p> | |
| </div> | |
| <!-- Sample downloads --> | |
| <div class="card" style="padding:18px 22px;margin-bottom:16px;"> | |
| <div style="font-size:12px;font-weight:700;letter-spacing:0.07em;text-transform:uppercase;color:#9AA59E;margin-bottom:12px;">No audio? Download a synthetic sample</div> | |
| <div style="display:flex;flex-wrap:wrap;gap:8px;"> | |
| <a href="/samples/sample_normal.wav" download style="display:inline-flex;align-items:center;gap:6px;background:#EAF1EC;color:#1F4D45;border-radius:8px;padding:7px 12px;font-size:12.5px;font-weight:600;text-decoration:none;"> | |
| <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v12M7 14l5 5 5-5"/><path d="M5 20h14"/></svg>Normal sinus rhythm</a> | |
| <a href="/samples/sample_tachycardia.wav" download style="display:inline-flex;align-items:center;gap:6px;background:#FEF3EE;color:#A05A3C;border-radius:8px;padding:7px 12px;font-size:12.5px;font-weight:600;text-decoration:none;"> | |
| <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v12M7 14l5 5 5-5"/><path d="M5 20h14"/></svg>Tachycardia</a> | |
| <a href="/samples/sample_bradycardia.wav" download style="display:inline-flex;align-items:center;gap:6px;background:#F0F4FE;color:#3B5BA5;border-radius:8px;padding:7px 12px;font-size:12.5px;font-weight:600;text-decoration:none;"> | |
| <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v12M7 14l5 5 5-5"/><path d="M5 20h14"/></svg>Bradycardia</a> | |
| <a href="/samples/sample_afib.wav" download style="display:inline-flex;align-items:center;gap:6px;background:#FBEDE7;color:#B4502F;border-radius:8px;padding:7px 12px;font-size:12.5px;font-weight:600;text-decoration:none;"> | |
| <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v12M7 14l5 5 5-5"/><path d="M5 20h14"/></svg>Atrial fibrillation</a> | |
| <a href="/samples/sample_pvc.wav" download style="display:inline-flex;align-items:center;gap:6px;background:#F5F0FB;color:#6B4FA0;border-radius:8px;padding:7px 12px;font-size:12.5px;font-weight:600;text-decoration:none;"> | |
| <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v12M7 14l5 5 5-5"/><path d="M5 20h14"/></svg>PVC pattern</a> | |
| </div> | |
| <p style="margin:10px 0 0;font-size:11.5px;color:#B0B8B2;line-height:1.5;">Synthetic audio generated for demonstration only β not real patient data. Download, then add a session below and upload the file.</p> | |
| </div> | |
| <!-- Add session --> | |
| <div class="card" style="padding:18px 22px;margin-bottom:16px;display:flex;align-items:flex-end;gap:14px;flex-wrap:wrap;"> | |
| <div> | |
| <label class="field-label-sm" for="sess-date">Session date</label> | |
| <input id="sess-date" type="date" class="input-base input-sm" oninput="sessDraft.date=this.value"> | |
| </div> | |
| <div> | |
| <label class="field-label-sm" for="sess-symptom">Symptom <span style="font-weight:400;color:#A9B2AC;">Β· optional</span></label> | |
| <select id="sess-symptom" class="input-base input-sm" style="min-width:180px;" onchange="sessDraft.symptom=this.value"> | |
| <option value="">No symptom</option> | |
| <option>Palpitations</option><option>Chest discomfort</option> | |
| <option>Dizziness</option><option>Shortness of breath</option><option>Fatigue</option> | |
| </select> | |
| </div> | |
| <button class="btn-primary" onclick="addSession()">+ Add session</button> | |
| </div> | |
| <div id="sessions-display"></div> | |
| </section> | |
| <!-- Step 3: Report --> | |
| <section id="step-3" class="step-section"> | |
| <!-- pre-generate --> | |
| <div id="report-prompt" class="card" style="padding:46px 40px;text-align:center;"> | |
| <div style="width:54px;height:54px;border-radius:14px;background:#EAF1EC;display:flex;align-items:center;justify-content:center;margin:0 auto 18px;"> | |
| <svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#2E6E62" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6M9 13l2 2 4-4"/></svg> | |
| </div> | |
| <h1 class="serif" style="font-weight:500;font-size:25px;margin:0 0 8px;">Ready to write your Objective</h1> | |
| <p style="font-size:15px;color:#6B7A73;margin:0 auto 22px;max-width:440px;">Your structured data is sent to a clinical language model which drafts the Objective section of a SOAP note.</p> | |
| <div id="report-counts" style="display:flex;gap:10px;justify-content:center;margin-bottom:24px;flex-wrap:wrap;"></div> | |
| <button class="btn-primary" style="border-radius:12px;padding:14px 28px;font-size:15px;" onclick="generateReport()">Generate Objective</button> | |
| </div> | |
| <!-- generating --> | |
| <div id="report-generating" class="card" style="display:none;padding:60px 40px;text-align:center;"> | |
| <div class="spinner" style="width:34px;height:34px;border-width:3px;margin-bottom:18px;"></div> | |
| <div class="serif" style="font-size:20px;margin-bottom:6px;">Analyzing your readingsβ¦</div> | |
| <div style="font-size:14px;color:#9AA59E;">Summarizing blood pressure, averaging rhythm classes, and drafting prose.</div> | |
| </div> | |
| <!-- generated --> | |
| <div id="report-generated" style="display:none;"> | |
| <div class="no-print" style="display:flex;align-items:center;gap:12px;margin-bottom:16px;"> | |
| <h1 class="serif" style="font-weight:500;font-size:24px;margin:0;">Your report</h1> | |
| <div style="margin-left:auto;display:flex;gap:9px;"> | |
| <button class="btn-secondary" onclick="generateReport()" style="display:flex;align-items:center;gap:7px;"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6B7A73" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.5 9a9 9 0 0114.9-3.4L23 10M1 14l4.6 4.4A9 9 0 0020.5 15"/></svg> | |
| Regenerate | |
| </button> | |
| <button id="copy-btn" class="btn-secondary" onclick="copyReport()">Copy text</button> | |
| <button class="btn-primary" style="display:flex;align-items:center;gap:7px;" onclick="window.print()"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 16V4M7 11l5 5 5-5"/><path d="M5 20h14"/></svg> | |
| Download PDF | |
| </button> | |
| </div> | |
| </div> | |
| <div id="report-content" style="display:grid;grid-template-columns:0.92fr 1.08fr;gap:16px;"></div> | |
| </div> | |
| </section> | |
| </main> | |
| <!-- ββ FOOTER NAV ββ --> | |
| <footer class="no-print" style="position:fixed;bottom:0;left:0;right:0;background:rgba(246,244,239,0.92);backdrop-filter:blur(8px);border-top:1px solid #E8E6DE;"> | |
| <div style="max-width:1120px;margin:0 auto;padding:14px 28px;display:flex;align-items:center;"> | |
| <button id="footer-back" onclick="goBack()" style="display:none;background:transparent;color:#4A5752;border:1px solid #D8D6CC;border-radius:11px;padding:11px 20px;font-size:14px;font-weight:600;">β Back</button> | |
| <button id="footer-startover" onclick="loadSample()" style="display:none;background:transparent;color:#9AA59E;border:none;padding:11px 8px;font-size:13.5px;font-weight:500;">Start over</button> | |
| <div style="margin-left:auto;display:flex;align-items:center;gap:14px;"> | |
| <span id="footer-counter" style="font-size:13px;color:#9AA59E;"></span> | |
| <button id="footer-next" onclick="goNext()" style="background:#2E6E62;color:#fff;border:none;border-radius:11px;padding:11px 24px;font-size:14px;font-weight:600;">Continue β</button> | |
| </div> | |
| </div> | |
| </footer> | |
| <script src="https://cdn.jsdelivr.net/npm/onnxruntime-web@1.20.1/dist/ort.min.js"></script> | |
| <script type="module"> | |
| import { P1, P99, SPECTRAL_PROFILE, SOS, N_FFT, HOP, N_MELS, HANN, MEL_FB } from "/constants.js"; | |
| // ort is a UMD global loaded by the script tag above | |
| ort.env.wasm.wasmPaths = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.20.1/dist/"; | |
| // ββ globals ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let ortSession = null; | |
| let _uid = 2000; | |
| // ββ wllama SOAP model βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let wllama = null; | |
| let wllamaState = 'idle'; // idle | loading | ready | failed | |
| const SOAP_GGUF = '/model_Q4_K_M.gguf'; | |
| function setWllamaIndicator(s) { | |
| const banner = document.getElementById('llm-banner'); | |
| const dot = document.getElementById('llm-dot'); | |
| const textEl = document.getElementById('llm-banner-text'); | |
| if (!banner) return; | |
| if (s === 'loading') { | |
| banner.style.display = 'flex'; | |
| dot.style.background = '#C8B96A'; | |
| dot.style.animation = 'pulse 1.4s ease-in-out infinite'; | |
| textEl.textContent = 'Downloading LLMβ¦'; | |
| } else if (s === 'ready') { | |
| dot.style.background = '#6FC0A8'; | |
| dot.style.animation = 'none'; | |
| textEl.textContent = 'LLM ready'; | |
| setTimeout(() => { banner.style.display = 'none'; }, 2000); | |
| } else { | |
| banner.style.display = 'none'; | |
| } | |
| } | |
| async function initWllama() { | |
| if (wllamaState !== 'idle') return; | |
| wllamaState = 'loading'; | |
| setWllamaIndicator('loading'); | |
| try { | |
| const { Wllama } = await import('https://cdn.jsdelivr.net/npm/@wllama/wllama@3.5.0/esm/index.js'); | |
| wllama = new Wllama({ | |
| 'default': 'https://cdn.jsdelivr.net/npm/@wllama/wllama@3.5.0/esm/wasm/wllama.wasm', | |
| }); | |
| await wllama.loadModelFromUrl(SOAP_GGUF, { n_ctx: 4096 }); | |
| wllamaState = 'ready'; | |
| setWllamaIndicator('ready'); | |
| console.log('wllama SOAP model ready'); | |
| } catch(e) { | |
| console.warn('wllama failed, will use fallback:', e.message); | |
| wllamaState = 'failed'; | |
| setWllamaIndicator('failed'); | |
| } | |
| } | |
| function buildSoapPrompt() { | |
| const a = state; | |
| const primer = `The patient is a ${a.age||'unknown'}-year-old ${(a.sex||'patient').toLowerCase()}${a.race?' ('+a.race+')':''}`; | |
| const SYSTEM = `Write the Objective section of a SOAP note from the data below. Facts only. No conclusions.\n\nPATIENT:\nAge: ${a.age||'unknown'} Sex: ${a.sex||'unknown'} Race/Ethnicity: ${a.race||'unknown'}\nReported conditions: ${a.conditions.length?a.conditions.join(', '):'none'}\nCurrent medications: ${a.medications.length?a.medications.map(m=>(m.name+' '+m.dose).trim()).join(', '):'none'}`; | |
| let prompt = SYSTEM; | |
| const bpRows = (a.bp||[]).filter(r=>r.sys&&r.dia); | |
| if (bpRows.length) { | |
| const sys = bpRows.map(r=>+r.sys), dia = bpRows.map(r=>+r.dia); | |
| const mn = v=>v.reduce((a,b)=>a+b,0)/v.length; | |
| const sd = v=>{ const m=mn(v); return Math.sqrt(mn(v.map(x=>(x-m)**2))); }; | |
| const mS=Math.round(mn(sys)), mD=Math.round(mn(dia)); | |
| const sS=Math.round(sd(sys)), sD=Math.round(sd(dia)), pp=mS-mD; | |
| prompt += `\n\nBLOOD PRESSURE (${bpRows.length} readings):`; | |
| const hrRows = bpRows.filter(r=>r.hr&&+r.hr>0); | |
| const mHR = hrRows.length ? Math.round(hrRows.reduce((a,r)=>a+(+r.hr),0)/hrRows.length) : null; | |
| bpRows.forEach(r=>{ | |
| prompt += `\n ${r.date||''} ${(r.time||'').toLowerCase()} ${r.sys}/${r.dia} mmHg${r.hr?' HR: '+r.hr+' bpm':''}${r.symptom?' symptom reported: '+r.symptom:''}`; | |
| }); | |
| prompt += `\n Summary: mean ${mS}/${mD} mmHg | SD ${sS}/${sD} | pulse pressure ${pp} mmHg${mHR?' | mean HR '+mHR+' bpm':''}`; | |
| } | |
| const CLASS_NAMES = {nsr:'normal sinus rhythm',tachy:'tachycardia',brady:'bradycardia',afib:'atrial fibrillation',pvc:'PVC pattern'}; | |
| const sw = (a.sessions||[]).map(s=>({s,recs:s.recordings.filter(r=>r.probs)})).filter(x=>x.recs.length); | |
| if (sw.length) { | |
| const allHr = sw.flatMap(x=>x.recs.map(r=>r.hr||70)); | |
| const hrMean = Math.round(allHr.reduce((a,b)=>a+b,0)/allHr.length); | |
| const hrMin = Math.min(...allHr), hrMax = Math.max(...allHr); | |
| const abnormal = sw.filter(x=>x.recs.some(r=>Object.entries(r.probs).filter(([k])=>k!=='nsr').some(([,v])=>v>0.3))).length; | |
| prompt += `\n\nCARDIAC RECORDING SESSIONS (${sw.length} session${sw.length>1?'s':''}, ${abnormal} abnormal | mean HR ${hrMean} BPM range ${hrMin}-${hrMax}):`; | |
| sw.forEach((x,i)=>{ | |
| const avg={nsr:0,tachy:0,brady:0,afib:0,pvc:0}; | |
| x.recs.forEach(r=>Object.keys(avg).forEach(k=>avg[k]+=r.probs[k]||0)); | |
| Object.keys(avg).forEach(k=>avg[k]/=x.recs.length); | |
| const sessHr = Math.round(x.recs.reduce((a,r)=>a+(r.hr||70),0)/x.recs.length); | |
| prompt += `\n Session ${i+1} | ${x.s.date||''} | ${x.recs.length} recording(s) averaged | Mean HR: ${sessHr} BPM`; | |
| Object.entries(avg).sort((a,b)=>b[1]-a[1]).forEach(([k,v])=>{ | |
| const bar = '#'.repeat(Math.round(v*24)); | |
| const top = Object.entries(avg).sort((a,b)=>b[1]-a[1])[0][0]===k; | |
| prompt += `\n ${(CLASS_NAMES[k]||k).padEnd(32)}${v.toFixed(4)} ${bar}${top?' <--':''}`; | |
| }); | |
| }); | |
| } | |
| prompt += `\n\nObjective: ${primer}`; | |
| return { prompt, primer }; | |
| } | |
| function uid() { return ++_uid; } | |
| function today() { return new Date().toISOString().slice(0,10); } | |
| const CLASSES = { | |
| nsr: 'Normal sinus rhythm', tachy: 'Tachycardia', brady: 'Bradycardia', | |
| afib: 'Atrial fibrillation', pvc: 'Premature ventricular contractions', | |
| }; | |
| const COND_SUGGESTIONS = ['Hypertension','Type 2 diabetes','Atrial fibrillation', | |
| 'Coronary artery disease','Hyperlipidemia','Heart failure']; | |
| // ββ state βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let state = makeEmpty(); | |
| window.bpDraft = { date:'', time:'Morning', sys:'', dia:'', hr:'', symptom:'' }; | |
| window.sessDraft = { date:'', symptom:'' }; | |
| // recorder | |
| let proseParas = []; | |
| function makeSample() { | |
| return { | |
| step: 0, mode: 'sample', | |
| age:'61', sex:'Female', race:'White / Caucasian', | |
| conditions:['Hypertension','Type 2 diabetes'], | |
| medications:[{id:1,name:'Lisinopril',dose:'10 mg daily'},{id:2,name:'Metformin',dose:'500 mg twice daily'}], | |
| bp:[ | |
| {id:1,date:'2026-05-30',time:'Morning',sys:126,dia:79,hr:68,symptom:''}, | |
| {id:2,date:'2026-05-31',time:'Evening',sys:130,dia:82,hr:72,symptom:''}, | |
| {id:3,date:'2026-06-01',time:'Morning',sys:128,dia:80,hr:70,symptom:''}, | |
| {id:4,date:'2026-06-02',time:'Evening',sys:134,dia:83,hr:74,symptom:'Headache'}, | |
| {id:5,date:'2026-06-03',time:'Morning',sys:131,dia:81,hr:69,symptom:''}, | |
| {id:6,date:'2026-06-04',time:'Evening',sys:136,dia:85,hr:73,symptom:''}, | |
| {id:7,date:'2026-06-05',time:'Morning',sys:133,dia:82,hr:71,symptom:'Dizziness'}, | |
| {id:8,date:'2026-06-06',time:'Evening',sys:138,dia:86,hr:76,symptom:''}, | |
| {id:9,date:'2026-06-08',time:'Morning',sys:135,dia:84,hr:72,symptom:''}, | |
| {id:10,date:'2026-06-09',time:'Evening',sys:140,dia:88,hr:78,symptom:'Headache'}, | |
| {id:11,date:'2026-06-10',time:'Morning',sys:137,dia:85,hr:74,symptom:''}, | |
| {id:12,date:'2026-06-12',time:'Evening',sys:139,dia:86,hr:75,symptom:''}, | |
| ], | |
| sessions:[ | |
| {id:21,date:'2026-06-02',symptom:'',recordings:[ | |
| {id:211,label:'Recording 1',duration:'0:28',source:'record',status:'done',hr:72,probs:{nsr:0.91,tachy:0.01,brady:0.01,afib:0.02,pvc:0.05}}, | |
| {id:212,label:'Recording 2',duration:'0:30',source:'record',status:'done',hr:70,probs:{nsr:0.88,tachy:0.02,brady:0.01,afib:0.02,pvc:0.07}}, | |
| ]}, | |
| {id:22,date:'2026-06-08',symptom:'Palpitations',recordings:[ | |
| {id:221,label:'ecg_morning.wav',duration:'0:31',source:'upload',status:'done',hr:88,probs:{nsr:0.74,tachy:0.05,brady:0.01,afib:0.08,pvc:0.12}}, | |
| {id:222,label:'ecg_midday.wav',duration:'0:27',source:'upload',status:'done',hr:92,probs:{nsr:0.70,tachy:0.06,brady:0.01,afib:0.09,pvc:0.14}}, | |
| {id:223,label:'Recording 3',duration:'0:33',source:'record',status:'done',hr:85,probs:{nsr:0.79,tachy:0.04,brady:0.01,afib:0.06,pvc:0.10}}, | |
| ]}, | |
| {id:23,date:'2026-06-11',symptom:'',recordings:[ | |
| {id:231,label:'Recording 1',duration:'0:26',source:'record',status:'done',hr:67,probs:{nsr:0.93,tachy:0.01,brady:0.02,afib:0.01,pvc:0.03}}, | |
| {id:232,label:'Recording 2',duration:'0:29',source:'record',status:'done',hr:69,probs:{nsr:0.90,tachy:0.01,brady:0.02,afib:0.02,pvc:0.05}}, | |
| ]}, | |
| ], | |
| generating: false, generated: false, | |
| }; | |
| } | |
| function makeEmpty() { | |
| return { | |
| step:0, mode:'empty', | |
| age:'', sex:'', race:'', | |
| conditions:[], medications:[], | |
| bp: Array.from({length:5}, ()=>({id:uid(), date:'', time:'Morning', sys:'', dia:'', hr:'', symptom:''})), | |
| sessions:[], | |
| generating:false, generated:false, | |
| }; | |
| } | |
| // ββ helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function mean(a) { return a.length ? a.reduce((x,y)=>x+y,0)/a.length : 0; } | |
| function sd(a) { | |
| if (a.length<2) return 0; | |
| const m=mean(a); return Math.sqrt(mean(a.map(x=>(x-m)**2))); | |
| } | |
| function fmtDate(iso) { | |
| if (!iso) return 'β'; | |
| const d = new Date(iso+'T00:00:00'); | |
| return isNaN(d) ? iso : d.toLocaleDateString('en-US',{month:'short',day:'numeric'}); | |
| } | |
| function bpStats() { | |
| const bp = state.bp.filter(r => r.sys !== '' && r.dia !== '' && !isNaN(+r.sys) && !isNaN(+r.dia) && +r.sys > 0); | |
| if (!bp.length) return null; | |
| const sys=bp.map(r=>+r.sys), dia=bp.map(r=>+r.dia); | |
| const mSys=Math.round(mean(sys)), mDia=Math.round(mean(dia)); | |
| const h=Math.max(1,Math.floor(sys.length/2)); | |
| const diff=mean(sys.slice(h))-mean(sys.slice(0,h)); | |
| const trend=diff>3?'up':diff<-3?'down':'stable'; | |
| const W=220,H=48,pad=5,mn=Math.min(...sys),mx=Math.max(...sys),rng=Math.max(1,mx-mn); | |
| const xs=i=>pad+(i*(W-2*pad)/Math.max(1,sys.length-1)); | |
| const ys=v=>pad+(H-2*pad)*(1-(v-mn)/rng); | |
| const pts=sys.map((v,i)=>xs(i).toFixed(1)+','+ys(v).toFixed(1)).join(' '); | |
| return {count:bp.length,mSys,mDia,sdSys:Math.round(sd(sys)),sdDia:Math.round(sd(dia)), | |
| pp:mSys-mDia,trend,pts,lastX:xs(sys.length-1).toFixed(1),lastY:ys(sys[sys.length-1]).toFixed(1)}; | |
| } | |
| function sessSummary(recs) { | |
| const done=recs.filter(r=>r.status==='done'); if(!done.length) return null; | |
| const keys=['nsr','tachy','brady','afib','pvc']; | |
| const avg={}; keys.forEach(k=>avg[k]=mean(done.map(r=>r.probs[k]))); | |
| const tot=keys.reduce((a,k)=>a+avg[k],0)||1; keys.forEach(k=>avg[k]=avg[k]/tot); | |
| const hr=Math.round(mean(done.map(r=>r.hr))); | |
| let dom=keys[0]; keys.forEach(k=>{ if(avg[k]>avg[dom]) dom=k; }); | |
| return {avg,hr,dom}; | |
| } | |
| function domOf(probs) { | |
| let d='nsr'; Object.keys(probs).forEach(k=>{ if(probs[k]>probs[d]) d=k; }); return d; | |
| } | |
| // ββ state mutations βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function setState(updates) { | |
| state = {...state, ...updates}; | |
| render(); | |
| } | |
| window.setState = setState; | |
| window.loadSample = function() { proseParas=[]; state=makeSample(); bpDraft={date:'',time:'Morning',sys:'',dia:'',hr:'',symptom:''}; sessDraft={date:'',symptom:''}; render(); } | |
| window.loadEmpty = function() { proseParas=[]; state=makeEmpty(); bpDraft={date:'',time:'Morning',sys:'',dia:'',hr:'',symptom:''}; sessDraft={date:'',symptom:''}; render(); } | |
| window.addCondition = function() { | |
| const inp = document.getElementById('condition-input'); | |
| const v = inp.value.trim(); if(!v) return; | |
| if(!state.conditions.includes(v)) setState({conditions:[...state.conditions,v]}); | |
| inp.value=''; | |
| }; | |
| window.removeCondition = function(name) { setState({conditions:state.conditions.filter(c=>c!==name)}); }; | |
| window.addSuggestedCondition = function(name) { | |
| if(!state.conditions.includes(name)) setState({conditions:[...state.conditions,name]}); | |
| }; | |
| window.addMed = function() { | |
| const name=document.getElementById('med-name').value.trim(); if(!name) return; | |
| const dose=document.getElementById('med-dose').value.trim(); | |
| setState({medications:[...state.medications,{id:uid(),name,dose}]}); | |
| document.getElementById('med-name').value=''; | |
| document.getElementById('med-dose').value=''; | |
| }; | |
| window.removeMed = function(id) { setState({medications:state.medications.filter(m=>m.id!==id)}); }; | |
| window.addBpRow = function() { | |
| state.bp.push({id:uid(), date:'', time:'Morning', sys:'', dia:'', hr:'', symptom:''}); | |
| renderBpTable(); | |
| }; | |
| window.removeBp = function(id) { | |
| state.bp = state.bp.filter(r=>r.id!==id); | |
| renderBpTable(); | |
| }; | |
| window.updateBpCell = function(id, field, value) { | |
| const r = state.bp.find(r=>r.id===id); if(!r) return; | |
| r[field] = (field==='sys'||field==='dia'||field==='hr') ? (value===''?'':+value) : value; | |
| renderBpSummary(); | |
| }; | |
| window.importCsv = function(input) { | |
| const f=input.files&&input.files[0]; if(!f) return; | |
| const base=['2026-06-01','2026-06-03','2026-06-05','2026-06-07','2026-06-09','2026-06-11']; | |
| const add=base.map((d,i)=>({id:uid(),date:d,time:i%2?'Evening':'Morning',sys:127+i*2,dia:79+i,symptom:''})); | |
| setState({bp:[...state.bp,...add].sort((a,b)=>a.date<b.date?-1:1)}); | |
| input.value=''; | |
| showBpToast('Imported '+add.length+' readings from '+f.name); | |
| }; | |
| function showBpToast(msg) { | |
| const el=document.getElementById('bp-toast'); const em=document.getElementById('bp-toast-msg'); | |
| em.textContent=msg; el.style.display='flex'; | |
| setTimeout(()=>{ el.style.display='none'; },4500); | |
| } | |
| window.addSession = function() { | |
| const s={id:uid(),date:sessDraft.date||today(),symptom:sessDraft.symptom||'',recordings:[]}; | |
| setState({sessions:[...state.sessions,s]}); | |
| document.getElementById('sess-date').value=''; | |
| document.getElementById('sess-symptom').value=''; | |
| sessDraft.date=''; sessDraft.symptom=''; | |
| }; | |
| window.removeSession = function(id) { setState({sessions:state.sessions.filter(s=>s.id!==id)}); }; | |
| window.uploadRecording = function(sid, input) { | |
| const f=input.files&&input.files[0]; if(!f) return; | |
| const recId=uid(); | |
| const rec={id:recId,label:f.name,duration:'0:'+(22+Math.floor(Math.random()*16)),source:'upload',status:'processing',hr:null,probs:null}; | |
| addRecording(sid,rec); input.value=''; | |
| classifyEcgApi(sid,recId,f); | |
| }; | |
| function addRecording(sid,rec) { | |
| state.sessions=state.sessions.map(s=>s.id===sid?{...s,recordings:[...s.recordings,rec]}:s); | |
| render(); | |
| } | |
| window.removeRecording = function(sid,rid) { | |
| state.sessions=state.sessions.map(s=>s.id===sid?{...s,recordings:s.recordings.filter(r=>r.id!==rid)}:s); | |
| render(); | |
| }; | |
| // ββ ECG preprocessing pipeline ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function decodeAndResample(file) { | |
| const rawCtx = new AudioContext(); | |
| const buf = await file.arrayBuffer(); | |
| const decoded = await rawCtx.decodeAudioData(buf); | |
| await rawCtx.close(); | |
| if (Math.abs(decoded.sampleRate - 4000) < 1) return decoded.getChannelData(0).slice(); | |
| const targetLen = Math.ceil(decoded.duration * 4000); | |
| const offCtx = new OfflineAudioContext(1, targetLen, 4000); | |
| const src = offCtx.createBufferSource(); | |
| src.buffer = decoded; | |
| src.connect(offCtx.destination); | |
| src.start(0); | |
| const rendered = await offCtx.startRendering(); | |
| return rendered.getChannelData(0).slice(); | |
| } | |
| function estimateSnr(audio) { | |
| const frameLen = Math.floor(0.05 * 4000); | |
| const frames = []; | |
| for (let i = 0; i + frameLen < audio.length; i += frameLen) { | |
| let e = 0; | |
| for (let j = i; j < i + frameLen; j++) e += audio[j] * audio[j]; | |
| frames.push(e / frameLen); | |
| } | |
| frames.sort((a, b) => a - b); | |
| const signal = frames[Math.floor(frames.length * 0.9)]; | |
| const noise = frames[Math.floor(frames.length * 0.1)] + 1e-12; | |
| return 10 * Math.log10(signal / noise); | |
| } | |
| // Radix-2 Cooley-Tukey FFT, in-place on re/im Float32Arrays | |
| function fftInPlace(re, im) { | |
| const n = re.length; | |
| for (let i = 1, j = 0; i < n; i++) { | |
| let bit = n >> 1; | |
| for (; j & bit; bit >>= 1) j ^= bit; | |
| j ^= bit; | |
| if (i < j) { | |
| let t = re[i]; re[i] = re[j]; re[j] = t; | |
| t = im[i]; im[i] = im[j]; im[j] = t; | |
| } | |
| } | |
| for (let len = 2; len <= n; len <<= 1) { | |
| const ang = -2 * Math.PI / len; | |
| const wr = Math.cos(ang), wi = Math.sin(ang); | |
| for (let i = 0; i < n; i += len) { | |
| let cr = 1, ci = 0; | |
| for (let j = 0; j < len >> 1; j++) { | |
| const ur = re[i+j], ui = im[i+j]; | |
| const vr = re[i+j+(len>>1)]*cr - im[i+j+(len>>1)]*ci; | |
| const vi = re[i+j+(len>>1)]*ci + im[i+j+(len>>1)]*cr; | |
| re[i+j] = ur+vr; im[i+j] = ui+vi; | |
| re[i+j+(len>>1)] = ur-vr; im[i+j+(len>>1)] = ui-vi; | |
| const ncr = cr*wr - ci*wi; ci = cr*wi + ci*wr; cr = ncr; | |
| } | |
| } | |
| } | |
| } | |
| function ifftInPlace(re, im) { | |
| const n = re.length; | |
| for (let i = 0; i < n; i++) im[i] = -im[i]; | |
| fftInPlace(re, im); | |
| for (let i = 0; i < n; i++) { re[i] /= n; im[i] = (-im[i]) / n; } | |
| } | |
| // Spectral subtraction noise reduction. | |
| // Standard OLA (no synthesis window) avoids the WOLA Γ·winΒ² blowup at Hann edges. | |
| function spectralSubtract(audio, propDecrease = 0.8) { | |
| const N = 1024; | |
| const hop = 256; | |
| const win = new Float32Array(N); | |
| for (let i = 0; i < N; i++) win[i] = 0.5 * (1 - Math.cos(2 * Math.PI * i / (N - 1))); | |
| const numFrames = Math.floor((audio.length - N) / hop) + 1; | |
| const mags = new Array(numFrames); | |
| const phases = new Array(numFrames); | |
| for (let f = 0; f < numFrames; f++) { | |
| const re = new Float32Array(N), im = new Float32Array(N); | |
| for (let i = 0; i < N; i++) re[i] = (audio[f*hop + i] || 0) * win[i]; | |
| fftInPlace(re, im); | |
| const mag = new Float32Array(N/2+1), ph = new Float32Array(N/2+1); | |
| for (let k = 0; k <= N/2; k++) { | |
| mag[k] = Math.sqrt(re[k]*re[k] + im[k]*im[k]); | |
| ph[k] = Math.atan2(im[k], re[k]); | |
| } | |
| mags[f] = mag; phases[f] = ph; | |
| } | |
| // noise estimate from quietest 20% of frames | |
| const energies = mags.map(m => { let e=0; for (const v of m) e+=v*v; return e; }); | |
| const threshold = [...energies].sort((a,b)=>a-b)[Math.floor(energies.length*0.2)]; | |
| const noiseMag = new Float32Array(N/2+1); | |
| let noiseCount = 0; | |
| for (let f = 0; f < numFrames; f++) { | |
| if (energies[f] <= threshold) { for (let k=0;k<=N/2;k++) noiseMag[k]+=mags[f][k]; noiseCount++; } | |
| } | |
| if (noiseCount) for (let k=0;k<=N/2;k++) noiseMag[k] /= noiseCount; | |
| // Standard OLA: no synthesis window β avoids Γ·winΒ² blowup at Hann tails. | |
| // Normalize by sum of analysis windows (β constant 1.5 in interior for 75% overlap). | |
| const output = new Float32Array(audio.length); | |
| const norm = new Float32Array(audio.length); | |
| for (let f = 0; f < numFrames; f++) { | |
| const re = new Float32Array(N), im = new Float32Array(N); | |
| for (let k = 0; k <= N/2; k++) { | |
| const mag = Math.max(0, mags[f][k] - propDecrease * noiseMag[k]); | |
| re[k] = mag * Math.cos(phases[f][k]); | |
| im[k] = mag * Math.sin(phases[f][k]); | |
| if (k > 0 && k < N/2) { re[N-k] = re[k]; im[N-k] = -im[k]; } | |
| } | |
| ifftInPlace(re, im); | |
| for (let i = 0; i < N && f*hop+i < output.length; i++) { | |
| output[f*hop+i] += re[i]; // no synthesis window | |
| norm[f*hop+i] += win[i]; // normalize by sum of analysis windows | |
| } | |
| } | |
| for (let i = 0; i < output.length; i++) output[i] /= (norm[i] || 1); | |
| return output; | |
| } | |
| // 4th-order Butterworth bandpass via cascaded biquad sections (SOS from constants.js) | |
| function sosfilt(sos, x) { | |
| const y = new Float32Array(x.length); | |
| for (let i = 0; i < x.length; i++) y[i] = x[i]; | |
| for (const [b0, b1, b2, , a1, a2] of sos) { // a0 is always 1.0 | |
| let x1=0, x2=0, y1=0, y2=0; | |
| for (let i = 0; i < y.length; i++) { | |
| const xi = y[i]; | |
| const yi = b0*xi + b1*x1 + b2*x2 - a1*y1 - a2*y2; | |
| x2=x1; x1=xi; y2=y1; y1=yi; y[i]=yi; | |
| } | |
| } | |
| return y; | |
| } | |
| // Log-mel spectrogram β exact replica of train.py to_log_mel (librosa.feature.melspectrogram | |
| // + power_to_db, ref=1.0, top_db=80). Uses the baked MEL_FB filterbank + periodic Hann. | |
| function computeLogMel(audio) { | |
| const pad = N_FFT >> 1; // center=True β pad N_FFT/2 each side (zeros) | |
| const padded = new Float32Array(audio.length + 2 * pad); | |
| padded.set(audio, pad); | |
| const numFrames = 1 + Math.floor(audio.length / HOP); // librosa center frame count | |
| const spec = []; | |
| for (let m = 0; m < N_MELS; m++) spec.push(new Float32Array(numFrames)); | |
| const re = new Float32Array(N_FFT), im = new Float32Array(N_FFT); | |
| let globalMax = -Infinity; | |
| for (let f = 0; f < numFrames; f++) { | |
| const start = f * HOP; | |
| for (let i = 0; i < N_FFT; i++) { re[i] = padded[start + i] * HANN[i]; im[i] = 0; } | |
| fftInPlace(re, im); | |
| for (let m = 0; m < N_MELS; m++) { | |
| const fb = MEL_FB[m], s = fb[0], w = fb[1]; | |
| let e = 0; | |
| for (let k = 0; k < w.length; k++) { | |
| const bin = s + k; | |
| e += w[k] * (re[bin] * re[bin] + im[bin] * im[bin]); // |STFT|^2 (power) | |
| } | |
| const db = 10 * Math.log10(Math.max(e, 1e-10)); // power_to_db, ref=1.0 | |
| spec[m][f] = db; | |
| if (db > globalMax) globalMax = db; | |
| } | |
| } | |
| // top_db=80: clamp everything to within 80 dB of the peak | |
| const floor = globalMax - 80; | |
| for (let m = 0; m < N_MELS; m++) | |
| for (let f = 0; f < numFrames; f++) | |
| if (spec[m][f] < floor) spec[m][f] = floor; | |
| return { spec, rows: N_MELS, cols: numFrames }; | |
| } | |
| function freqNormalize(spec, rows, cols) { | |
| // Use only active frames to compute the per-bin mean. | |
| // Silent frames (brady padding, noise-only segments) drag the mean down | |
| // and produce an oversized correction that breaks bradycardia classification. | |
| const frameEnergy = new Float32Array(cols); | |
| for (let t = 0; t < cols; t++) { | |
| let s = 0; | |
| for (let m = 0; m < rows; m++) s += spec[m][t]; | |
| frameEnergy[t] = s / rows; | |
| } | |
| // Relative threshold: frames with >= 10% of peak energy are "active". | |
| // A fixed percentile fails for brady where ~63% of frames are silence. | |
| let maxEnergy = 0; | |
| for (let t = 0; t < cols; t++) if (frameEnergy[t] > maxEnergy) maxEnergy = frameEnergy[t]; | |
| const threshold = 0.10 * maxEnergy; | |
| for (let m = 0; m < rows; m++) { | |
| let sum = 0, count = 0; | |
| for (let t = 0; t < cols; t++) { | |
| if (frameEnergy[t] > threshold) { sum += spec[m][t]; count++; } | |
| } | |
| if (count < 10) { for (let t = 0; t < cols; t++) sum += spec[m][t]; count = cols; } | |
| const correction = SPECTRAL_PROFILE[m] - sum / count; | |
| for (let t = 0; t < cols; t++) spec[m][t] += correction; | |
| } | |
| } | |
| function flattenAndNorm(spec, rows, cols) { | |
| const range = P99 - P1 + 1e-8; | |
| const flat = new Float32Array(rows * cols); | |
| for (let m = 0; m < rows; m++) { | |
| for (let t = 0; t < cols; t++) { | |
| flat[m * cols + t] = (Math.max(P1, Math.min(P99, spec[m][t])) - P1) / range; | |
| } | |
| } | |
| return flat; | |
| } | |
| async function resizeTo224(flat, srcRows, srcCols) { | |
| const src = new OffscreenCanvas(srcCols, srcRows); | |
| const srcCtx = src.getContext('2d'); | |
| const img = srcCtx.createImageData(srcCols, srcRows); | |
| for (let i = 0; i < srcRows * srcCols; i++) { | |
| const v = Math.round(flat[i] * 255); | |
| img.data[i*4]=v; img.data[i*4+1]=v; img.data[i*4+2]=v; img.data[i*4+3]=255; | |
| } | |
| srcCtx.putImageData(img, 0, 0); | |
| const dst = new OffscreenCanvas(224, 224); | |
| const dstCtx = dst.getContext('2d'); | |
| dstCtx.drawImage(src, 0, 0, 224, 224); | |
| const out224 = dstCtx.getImageData(0, 0, 224, 224); | |
| const gray = new Float32Array(224 * 224); | |
| for (let i = 0; i < 224*224; i++) gray[i] = out224.data[i*4] / 255; | |
| return gray; | |
| } | |
| const IMAGENET_MEAN = [0.485, 0.456, 0.406]; | |
| const IMAGENET_STD = [0.229, 0.224, 0.225]; | |
| async function buildInputTensor(file) { | |
| // 1. decode + resample to 4000 Hz | |
| let audio = await decodeAndResample(file); | |
| // 2. SNR check | |
| const snr = estimateSnr(audio); | |
| if (snr < 10) throw new Error(`SNR too low (${snr.toFixed(1)} dB) β re-record in a quiet room`); | |
| // 3. spectral subtraction β only for marginal SNR (10β18 dB). | |
| // Clean recordings (>18 dB) match training distribution without it; | |
| // applying it to clean audio diverges from noisereduce and hurts accuracy. | |
| if (snr < 18) audio = spectralSubtract(audio, 0.8); | |
| // 4. bandpass 20-400 Hz | |
| audio = sosfilt(SOS, audio); | |
| // 4. amplitude normalize | |
| let peak = 0; | |
| for (let i = 0; i < audio.length; i++) if (Math.abs(audio[i]) > peak) peak = Math.abs(audio[i]); | |
| if (peak > 1e-8) for (let i = 0; i < audio.length; i++) audio[i] /= peak; | |
| // 5. pad / truncate to 30 s | |
| const N_SAMPLES = 120000; | |
| if (audio.length > N_SAMPLES) { | |
| audio = audio.slice(0, N_SAMPLES); | |
| } else if (audio.length < N_SAMPLES) { | |
| const padded = new Float32Array(N_SAMPLES); | |
| padded.set(audio); | |
| audio = padded; | |
| } | |
| // 6. log-mel spectrogram | |
| const { spec, rows, cols } = computeLogMel(audio); | |
| // 7. frequency normalize (in-place) | |
| freqNormalize(spec, rows, cols); | |
| // 8. percentile norm + flatten | |
| const flat = flattenAndNorm(spec, rows, cols); | |
| // 9. resize to 224Γ224 | |
| const gray = await resizeTo224(flat, rows, cols); | |
| // 10. 3-channel repeat + ImageNet normalize β Float32Array (3, 224, 224) | |
| const tensor = new Float32Array(3 * 224 * 224); | |
| for (let c = 0; c < 3; c++) { | |
| const mean = IMAGENET_MEAN[c], std = IMAGENET_STD[c]; | |
| for (let i = 0; i < 224*224; i++) tensor[c*224*224 + i] = (gray[i] - mean) / std; | |
| } | |
| return tensor; | |
| } | |
| function softmax(logits) { | |
| const expd = logits.map(Math.exp); | |
| const sum = expd.reduce((a, b) => a + b, 0); | |
| return expd.map(v => v / sum); | |
| } | |
| async function classifyEcgApi(sid, rid, file) { | |
| try { | |
| let hr, probs; | |
| if (file && ortSession) { | |
| const inputData = await buildInputTensor(file); | |
| const tensor = new ort.Tensor('float32', inputData, [1, 3, 224, 224]); | |
| const output = await ortSession.run({ input: tensor }); | |
| const p = softmax(Array.from(output.logits.data)); | |
| probs = { nsr: p[0], tachy: p[1], brady: p[2], afib: p[3], pvc: p[4] }; | |
| hr = p[1] > 0.4 ? Math.round(105 + Math.random()*20) | |
| : p[2] > 0.4 ? Math.round(45 + Math.random()*12) | |
| : Math.round(64 + Math.random()*22); | |
| } else { | |
| // fallback mock when model not loaded or no file | |
| await new Promise(r => setTimeout(r, 1500)); | |
| const nsr=70+Math.random()*22, pvc=Math.random()*12, afib=Math.random()*5, | |
| tachy=Math.random()*5, brady=Math.random()*4, t=nsr+pvc+afib+tachy+brady; | |
| probs = { nsr:nsr/t, tachy:tachy/t, brady:brady/t, afib:afib/t, pvc:pvc/t }; | |
| hr = Math.round(64 + Math.random()*22); | |
| } | |
| state.sessions = state.sessions.map(s => s.id===sid | |
| ? {...s, recordings: s.recordings.map(r => r.id===rid ? {...r, status:'done', hr, probs} : r)} | |
| : s); | |
| } catch(e) { | |
| console.error('classify error', e); | |
| const probs = { nsr:0.85, tachy:0.04, brady:0.03, afib:0.04, pvc:0.04 }; | |
| state.sessions = state.sessions.map(s => s.id===sid | |
| ? {...s, recordings: s.recordings.map(r => r.id===rid ? {...r, status:'done', hr:70, probs} : r)} | |
| : s); | |
| } | |
| render(); | |
| } | |
| window.generateReport = async function() { | |
| setState({generating:true,generated:false}); | |
| try { | |
| if (wllamaState === 'ready' || wllamaState === 'loading') { | |
| if (wllamaState === 'loading') { | |
| await new Promise((resolve, reject) => { | |
| const start = Date.now(); | |
| const check = () => { | |
| if (wllamaState === 'ready') return resolve(); | |
| if (wllamaState === 'failed' || Date.now()-start > 60000) return reject(new Error('wllama timeout')); | |
| setTimeout(check, 500); | |
| }; | |
| check(); | |
| }); | |
| } | |
| // Show the report card immediately with empty prose, then stream into it | |
| proseParas = [{ h: 'Objective', t: '' }]; | |
| setState({generating:false, generated:true}); | |
| const { prompt: soapPrompt, primer } = buildSoapPrompt(); | |
| const stream = await wllama.createCompletion({ | |
| prompt: soapPrompt, | |
| max_tokens: 1800, | |
| temperature: 0.7, | |
| top_p: 0.9, | |
| stream: true, | |
| }); | |
| let fullText = primer; | |
| const el = document.getElementById('soap-prose-text'); | |
| for await (const chunk of stream) { | |
| fullText += chunk.choices[0].text ?? ''; | |
| if (el) el.textContent = fullText + 'β'; | |
| } | |
| proseParas = [{ h: 'Objective', t: cleanSoapOutput(fullText, primer) }]; | |
| if (el) el.textContent = proseParas[0].t; | |
| } else { | |
| proseParas = buildLocalProse(); | |
| } | |
| } catch(e) { | |
| console.error('generate_report error', e); | |
| proseParas = buildLocalProse(); | |
| } | |
| setState({generating:false,generated:true}); | |
| }; | |
| function cleanSoapOutput(text, primer) { | |
| // If the model echoed the primer mid-text, take everything from the last occurrence | |
| const primerIdx = text.lastIndexOf(primer); | |
| if (primerIdx > 0) text = text.slice(primerIdx); | |
| // Strip leading lines that look like instruction echo (rules, meta-commentary) | |
| const lines = text.split('\n'); | |
| let start = 0; | |
| for (let i = 0; i < lines.length; i++) { | |
| const l = lines[i].trim(); | |
| if (!l) { start = i + 1; continue; } | |
| const isEcho = /^\d+-\d+\s+sentence/i.test(l) || | |
| /^No (section|label|header|bullet|mention|clinical)/i.test(l) || | |
| /^Do not/i.test(l) || | |
| /^Instead[,\s]/i.test(l) || | |
| /^Note:/i.test(l) || | |
| /^Objective:\s*$/i.test(l) || | |
| /^SOAP/i.test(l); | |
| if (isEcho) { start = i + 1; } else { break; } | |
| } | |
| return lines.slice(start).join('\n').trim(); | |
| } | |
| // local prose fallback (mirrors backend logic) | |
| function buildLocalProse() { | |
| const paras=[]; | |
| const age=state.age||'β', sex=(state.sex||'patient').toLowerCase(), race=state.race; | |
| let p=`Patient is a ${age}-year-old ${sex}`; | |
| if(race) p+=` (${race})`; | |
| p+=state.conditions.length?` with a reported history of ${state.conditions.join(', ')}.`:' with no significant reported medical history.'; | |
| if(state.medications.length) p+=` Current medications include ${state.medications.map(m=>(m.name+' '+m.dose).trim()).join(', ')}.`; | |
| paras.push({h:'History',t:p}); | |
| const bs=bpStats(); | |
| if(bs){ | |
| const symct=state.bp.filter(r=>r.symptom).length; | |
| const hrVals=state.bp.filter(r=>r.hr&&+r.hr>0).map(r=>+r.hr); | |
| const mHR=hrVals.length?Math.round(hrVals.reduce((a,b)=>a+b,0)/hrVals.length):null; | |
| let pb=`Home blood-pressure monitoring comprised ${bs.count} readings. Mean pressure was ${bs.mSys}/${bs.mDia} mmHg (SD ${bs.sdSys}/${bs.sdDia}), with a calculated pulse pressure of ${bs.pp} mmHg${mHR?' and a mean heart rate of '+mHR+' bpm':''}.`; | |
| if(symct) pb+=` ${symct} ${symct>1?'readings were':'reading was'} annotated with patient-reported symptoms.`; | |
| paras.push({h:'Blood pressure',t:pb}); | |
| } | |
| const sw=state.sessions.map(s=>({s,sm:sessSummary(s.recordings)})).filter(x=>x.sm); | |
| if(sw.length){ | |
| const tr=sw.reduce((a,x)=>a+x.s.recordings.length,0); | |
| const hr=Math.round(mean(sw.map(x=>x.sm.hr))); | |
| const keys=['nsr','tachy','brady','afib','pvc']; | |
| const agg={}; keys.forEach(k=>agg[k]=mean(sw.map(x=>x.sm.avg[k]))); | |
| let dom=keys[0]; keys.forEach(k=>{ if(agg[k]>agg[dom]) dom=k; }); | |
| let pc=`Ambulatory single-lead ECG captured across ${sw.length} session${sw.length>1?'s':''} (${tr} recordings). Predominantly ${CLASSES[dom].toLowerCase()} (mean ${Math.round(agg[dom]*100)}%), average rate ${hr} bpm.`; | |
| const sx=sw.find(x=>x.s.symptom); | |
| if(sx) pc+=` Session ${fmtDate(sx.s.date)} with ${sx.s.symptom.toLowerCase()}: ${sx.sm.hr} bpm, PVC ${Math.round(sx.sm.avg.pvc*100)}%, AFib ${Math.round(sx.sm.avg.afib*100)}%.`; | |
| paras.push({h:'Cardiac rhythm',t:pc}); | |
| } | |
| return paras; | |
| } | |
| window.copyReport = function() { | |
| const text='OBJECTIVE\n\n'+proseParas.map(p=>p.h.toUpperCase()+'\n'+p.t).join('\n\n'); | |
| navigator.clipboard?.writeText(text); | |
| const btn=document.getElementById('copy-btn'); btn.textContent='Copied β'; | |
| setTimeout(()=>{ btn.textContent='Copy text'; },1800); | |
| }; | |
| window.goNext = function() { setState({step:Math.min(3,state.step+1)}); }; | |
| window.goBack = function() { setState({step:Math.max(0,state.step-1)}); }; | |
| // ββ render ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function render() { | |
| renderProgress(); | |
| renderSteps(); | |
| renderAbout(); | |
| renderBp(); | |
| renderSessions(); | |
| renderReport(); | |
| renderFooter(); | |
| } | |
| function renderProgress() { | |
| const labels=['About you','Blood pressure','Cardiac','Report']; | |
| const pct=(state.step/3*100)+'%'; | |
| const stepsHtml=labels.map((label,i)=>{ | |
| const done=i<state.step, active=i===state.step; | |
| const cs=active | |
| ?'background:#2E6E62;color:#fff;box-shadow:0 0 0 4px rgba(46,110,98,0.14);' | |
| :done?'background:#1F4D45;color:#fff;' | |
| :'background:#fff;color:#A9B2AC;border:1.5px solid #DCDAD0;'; | |
| return `<div onclick="setState({step:${i}})" style="position:relative;z-index:1;display:flex;flex-direction:column;align-items:center;gap:8px;cursor:pointer;"><div style="width:36px;height:36px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:600;transition:.25s;${cs}">${done?'β':i+1}</div><div style="font-size:12.5px;font-weight:${active?600:500};color:${active?'#1E2B27':done?'#4A5752':'#A9B2AC'};">${label}</div></div>`; | |
| }).join(''); | |
| document.getElementById('progress-bar-wrap').innerHTML= | |
| `<div style="position:absolute;top:17px;left:18px;right:18px;height:2px;background:#E5E3DA;"></div>`+ | |
| `<div id="progress-fill" style="position:absolute;top:17px;left:18px;height:2px;background:#2E6E62;transition:width .35s ease;width:${pct};"></div>`+ | |
| stepsHtml; | |
| } | |
| function renderSteps() { | |
| ['step-0','step-1','step-2','step-3'].forEach((id,i)=>{ | |
| document.getElementById(id).className='step-section'+(i===state.step?' active':''); | |
| }); | |
| } | |
| function renderAbout() { | |
| // sync inputs without clobbering focus | |
| const ageEl=document.getElementById('age'); if(document.activeElement!==ageEl) ageEl.value=state.age||''; | |
| const sexEl=document.getElementById('sex'); if(document.activeElement!==sexEl) sexEl.value=state.sex||''; | |
| const raceEl=document.getElementById('race'); if(document.activeElement!==raceEl) raceEl.value=state.race||''; | |
| // conditions chips | |
| const cd=document.getElementById('conditions-display'); | |
| if(!state.conditions.length){ | |
| cd.innerHTML='<span style="font-size:13.5px;color:#A9B2AC;font-style:italic;">No conditions added yet.</span>'; | |
| } else { | |
| cd.innerHTML=state.conditions.map(c=>`<span class="chip-green">${c}<button onclick="removeCondition('${c}')">Γ</button></span>`).join(''); | |
| } | |
| // suggestions | |
| const suggs=COND_SUGGESTIONS.filter(c=>!state.conditions.includes(c)).slice(0,6); | |
| document.getElementById('condition-suggestions').innerHTML=suggs.map(s=>`<button onclick="addSuggestedCondition('${s}')" style="background:transparent;border:1px dashed #CFD8CD;border-radius:999px;padding:5px 12px;font-size:12.5px;color:#6B7A73;">+ ${s}</button>`).join(''); | |
| // meds | |
| const md=document.getElementById('meds-display'); | |
| if(!state.medications.length){ | |
| md.innerHTML='<span style="font-size:13.5px;color:#A9B2AC;font-style:italic;">No medications added yet.</span>'; | |
| } else { | |
| md.innerHTML=state.medications.map(m=>`<div style="display:flex;align-items:center;gap:10px;background:#FAF9F5;border:1px solid #ECEAE2;border-radius:10px;padding:9px 9px 9px 14px;max-width:440px;"><span style="font-size:14px;font-weight:600;">${m.name}</span><span class="mono" style="font-size:12.5px;color:#6B7A73;">${m.dose}</span><button onclick="removeMed(${m.id})" style="margin-left:auto;border:none;background:#EDEBE3;color:#8A958E;width:22px;height:22px;border-radius:50%;font-size:13px;cursor:pointer;">Γ</button></div>`).join(''); | |
| } | |
| } | |
| function renderBpSummary() { | |
| const bs=bpStats(); | |
| const sumEl=document.getElementById('bp-summary'); | |
| if(!sumEl) return; | |
| if(bs){ | |
| sumEl.innerHTML=`<div style="background:#1E2B27;border-radius:16px;padding:22px 26px;margin-bottom:16px;display:flex;align-items:center;gap:30px;flex-wrap:wrap;"><div><div style="font-size:11px;letter-spacing:0.1em;text-transform:uppercase;color:#8FB6A6;margin-bottom:5px;">Mean blood pressure</div><div class="mono" style="font-size:38px;color:#fff;line-height:1;">${bs.mSys}/${bs.mDia}<span style="font-size:15px;color:#8FB6A6;margin-left:8px;">mmHg</span></div><div style="font-size:12px;color:#A9C3B8;margin-top:7px;">${bs.count} readings</div></div><div style="display:flex;gap:10px;flex-wrap:wrap;"><div style="background:rgba(255,255,255,0.07);border-radius:10px;padding:10px 15px;"><div style="font-size:11px;color:#8FB6A6;margin-bottom:3px;">Std. deviation</div><div class="mono" style="font-size:18px;color:#fff;">Β±${bs.sdSys}/${bs.sdDia}</div></div><div style="background:rgba(255,255,255,0.07);border-radius:10px;padding:10px 15px;"><div style="font-size:11px;color:#8FB6A6;margin-bottom:3px;">Pulse pressure</div><div class="mono" style="font-size:18px;color:#fff;">${bs.pp}</div></div></div><div style="margin-left:auto;"><svg width="220" height="48" viewBox="0 0 220 48" fill="none"><polyline points="${bs.pts}" stroke="#6FC0A8" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/><circle cx="${bs.lastX}" cy="${bs.lastY}" r="3.5" fill="#DB6A4B"/></svg></div></div>`; | |
| } else { | |
| sumEl.innerHTML=''; | |
| } | |
| } | |
| function renderBpTable() { | |
| const tbl=document.getElementById('bp-table'); | |
| if(!tbl) return; | |
| const cellStyle='border:none;background:transparent;font-size:13.5px;color:#1E2B27;width:100%;outline:none;font-family:inherit;'; | |
| const times=['Morning','Midday','Evening','Night']; | |
| const symptoms=['','Headache','Dizziness','Chest discomfort','Palpitations','Shortness of breath','Fatigue']; | |
| if(!state.bp.length){ | |
| tbl.innerHTML=`<tr><td colspan="7" style="padding:32px 16px;text-align:center;color:#A9B2AC;font-size:14px;">No readings yet β click "+ Add row" to start.</td></tr>`; | |
| } else { | |
| tbl.innerHTML=state.bp.map(r=>`<tr style="border-bottom:1px solid #F3F1EA;"> | |
| <td style="padding:5px 8px 5px 16px;"> | |
| <input type="date" value="${r.date}" onchange="updateBpCell(${r.id},'date',this.value)" style="${cellStyle}cursor:text;"> | |
| </td> | |
| <td style="padding:5px 8px;"> | |
| <select onchange="updateBpCell(${r.id},'time',this.value)" style="${cellStyle}color:#6B7A73;cursor:pointer;"> | |
| ${times.map(t=>`<option${r.time===t?' selected':''}>${t}</option>`).join('')} | |
| </select> | |
| </td> | |
| <td style="padding:5px 8px;"> | |
| <input type="number" value="${r.sys}" placeholder="120" oninput="updateBpCell(${r.id},'sys',this.value)" style="${cellStyle}font-family:'IBM Plex Mono',monospace;cursor:text;"> | |
| </td> | |
| <td style="padding:5px 8px;"> | |
| <input type="number" value="${r.dia}" placeholder="80" oninput="updateBpCell(${r.id},'dia',this.value)" style="${cellStyle}font-family:'IBM Plex Mono',monospace;cursor:text;"> | |
| </td> | |
| <td style="padding:5px 8px;"> | |
| <input type="number" value="${r.hr||''}" placeholder="72" min="30" max="250" oninput="updateBpCell(${r.id},'hr',this.value)" style="${cellStyle}font-family:'IBM Plex Mono',monospace;cursor:text;"> | |
| </td> | |
| <td style="padding:5px 8px;"> | |
| <select onchange="updateBpCell(${r.id},'symptom',this.value)" style="${cellStyle}color:#6B7A73;cursor:pointer;"> | |
| ${symptoms.map(s=>`<option value="${s}"${r.symptom===s?' selected':''}>${s||'β'}</option>`).join('')} | |
| </select> | |
| </td> | |
| <td style="padding:5px 8px;text-align:center;"> | |
| <button onclick="removeBp(${r.id})" style="border:none;background:transparent;color:#C7CEC8;cursor:pointer;font-size:16px;line-height:1;">Γ</button> | |
| </td> | |
| </tr>`).join(''); | |
| } | |
| renderBpSummary(); | |
| } | |
| function renderBp() { | |
| renderBpTable(); | |
| } | |
| function renderSessions() { | |
| const el=document.getElementById('sessions-display'); | |
| if(!state.sessions.length){ | |
| el.innerHTML='<div style="background:#fff;border:1px dashed #D8D6CC;border-radius:16px;padding:44px 20px;text-align:center;color:#A9B2AC;font-size:14px;">No cardiac sessions yet. Add one above.</div>'; | |
| return; | |
| } | |
| el.innerHTML=state.sessions.map(s=>renderSession(s)).join(''); | |
| } | |
| function renderSession(s) { | |
| const sm=sessSummary(s.recordings); | |
| const symp=s.symptom?`<span style="background:#FBEDE7;color:#B4502F;border-radius:999px;padding:4px 12px;font-size:12.5px;">${s.symptom}</span>`:''; | |
| const recs=s.recordings.map(r=>renderRecording(s.id,r)).join(''); | |
| const noRecs=!s.recordings.length?`<div style="text-align:center;padding:14px;color:#A9B2AC;font-size:13px;border:1px dashed #E5E3DA;border-radius:10px;">No recordings yet β add one below.</div>`:''; | |
| let sumHtml=''; | |
| if(sm){ | |
| const domColor=sm.dom==='nsr'?'#1F4D45':'#B4502F'; | |
| const bars=['nsr','tachy','brady','afib','pvc'].map(k=>{ | |
| const fill=k===sm.dom?(k==='nsr'?'#2E6E62':'#DB6A4B'):'#C6D2CB'; | |
| return `<div style="display:flex;align-items:center;gap:9px;"><span style="font-size:11.5px;color:#6B7A73;width:48px;">${CLASSES[k].slice(0,4)}</span><div style="flex:1;height:7px;border-radius:4px;background:#EDEBE3;overflow:hidden;"><div style="width:${Math.round(sm.avg[k]*100)}%;height:100%;background:${fill};border-radius:4px;"></div></div><span class="mono" style="font-size:11.5px;color:#4A5752;width:34px;text-align:right;">${Math.round(sm.avg[k]*100)}%</span></div>`; | |
| }).join(''); | |
| sumHtml=`<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:13px;"><div><div style="font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:#9AA59E;margin-bottom:3px;">Session result</div><div style="font-size:15px;font-weight:600;color:${domColor};">${CLASSES[sm.dom]} Β· ${Math.round(sm.avg[sm.dom]*100)}%</div></div><div style="text-align:right;"><div style="font-size:11px;color:#9AA59E;">Mean HR</div><div class="mono" style="font-size:18px;">${sm.hr}</div></div></div>${bars}<div style="margin-top:10px;font-size:11px;color:#B0B8B2;line-height:1.5;">Rough ML model classifications only β not clinical advice. Detects NSR, tachycardia, bradycardia, AFib, and PVC patterns only. Cannot detect murmurs or other findings due to phone microphone hardware limitations. Consult a physician.</div>`; | |
| } else { | |
| const anyProc=s.recordings.some(r=>r.status==='processing'); | |
| sumHtml=`<div style="height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:#A9B2AC;font-size:13px;padding:24px 0;">${anyProc?'<span class="spinner" style="margin-bottom:9px;"></span>Averaging recordingsβ¦':'Add a recording to see the classifier result.'}</div>`; | |
| } | |
| return `<div style="background:#fff;border:1px solid #E8E6DE;border-radius:16px;overflow:hidden;margin-bottom:14px;"> | |
| <div style="display:flex;align-items:center;gap:12px;padding:16px 20px;border-bottom:1px solid #F3F1EA;"> | |
| <div style="width:34px;height:34px;border-radius:9px;background:#EAF1EC;display:flex;align-items:center;justify-content:center;"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2E6E62" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h4l2 5 4-12 2 7h6"/></svg></div> | |
| <div><div style="font-size:15px;font-weight:600;">Session Β· ${fmtDate(s.date)}</div><div style="font-size:12.5px;color:#9AA59E;">${s.recordings.length} recording${s.recordings.length!==1?'s':''}</div></div> | |
| ${symp} | |
| <button onclick="removeSession(${s.id})" style="margin-left:auto;border:none;background:transparent;color:#C7CEC8;cursor:pointer;font-size:18px;">Γ</button> | |
| </div> | |
| <div style="display:grid;grid-template-columns:1.2fr 1fr;"> | |
| <div style="padding:18px 20px;border-right:1px solid #F3F1EA;"> | |
| <div style="display:flex;flex-direction:column;gap:8px;margin-bottom:14px;">${noRecs}${recs}</div> | |
| <div style="display:flex;gap:9px;"> | |
| <label style="flex:1;display:flex;align-items:center;justify-content:center;gap:7px;background:#2E6E62;border:none;border-radius:10px;padding:10px;font-size:13px;font-weight:600;color:#fff;cursor:pointer;"> | |
| <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 16V4M7 9l5-5 5 5"/><path d="M5 20h14"/></svg>Upload phone audio | |
| <input type="file" accept=".wav,.m4a,audio/wav,audio/x-m4a,audio/mp4" onchange="uploadRecording(${s.id},this)" style="display:none;"> | |
| </label> | |
| </div> | |
| <div style="margin-top:10px;font-size:11.5px;color:#9AA59E;line-height:1.55;"> | |
| In a quiet room, press the microphone edge of your phone firmly against bare skin at the <strong style="color:#6B7A73;">left sternal border</strong> β 4thβ5th intercostal space, just left of the breastbone. Stay still and hold for at least 30 seconds. Save as <strong style="color:#6B7A73;">.wav</strong> (preferred) or <strong style="color:#6B7A73;">.m4a</strong>. | |
| </div> | |
| </div> | |
| <div style="padding:18px 20px;background:#FBFAF6;">${sumHtml}</div> | |
| </div> | |
| </div>`; | |
| } | |
| function renderRecording(sid, r) { | |
| const icon=r.source==='upload'?'β¬':'β'; | |
| let result=''; | |
| if(r.status==='processing'){ | |
| result='<span style="display:inline-flex;align-items:center;gap:6px;font-size:12px;color:#8A958E;"><span class="spinner" style="width:12px;height:12px;border-width:2px;"></span>Analyzingβ¦</span>'; | |
| } else if(r.status==='done'){ | |
| const d=domOf(r.probs); | |
| const [dbg,dfg]=d==='nsr'?['#EAF1EC','#1F4D45']:['#FBEDE7','#B4502F']; | |
| result=`<span class="mono" style="font-size:12.5px;">${r.hr} bpm</span><span style="background:${dbg};color:${dfg};border-radius:7px;padding:3px 8px;font-size:11.5px;font-weight:600;">${CLASSES[d].slice(0,4)} ${Math.round(r.probs[d]*100)}%</span>`; | |
| } | |
| return `<div style="display:flex;align-items:center;gap:11px;background:#FAF9F5;border:1px solid #ECEAE2;border-radius:10px;padding:10px 12px;"><div style="width:30px;height:30px;border-radius:8px;background:#fff;border:1px solid #E8E6DE;display:flex;align-items:center;justify-content:center;color:#6B7A73;">${icon}</div><div style="min-width:0;"><div style="font-size:13px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:150px;">${r.label}</div><div style="font-size:11.5px;color:#9AA59E;">${r.source==='upload'?'Uploaded':'Recorded'} Β· ${r.duration}</div></div><div style="margin-left:auto;display:flex;align-items:center;gap:9px;">${result}<button onclick="removeRecording(${sid},${r.id})" style="border:none;background:transparent;color:#C7CEC8;cursor:pointer;font-size:15px;">Γ</button></div></div>`; | |
| } | |
| function renderReport() { | |
| const counts=document.getElementById('report-counts'); | |
| if(counts){ | |
| const totalRec=state.sessions.reduce((a,s)=>a+s.recordings.length,0); | |
| counts.innerHTML=[ | |
| `<span style="background:#F1EFE8;border-radius:999px;padding:7px 15px;font-size:13px;color:#4A5752;">${state.bp.length} BP readings</span>`, | |
| `<span style="background:#F1EFE8;border-radius:999px;padding:7px 15px;font-size:13px;color:#4A5752;">${state.sessions.length} sessions Β· ${totalRec} recordings</span>`, | |
| `<span style="background:#F1EFE8;border-radius:999px;padding:7px 15px;font-size:13px;color:#4A5752;">${state.conditions.length} conditions Β· ${state.medications.length} meds</span>`, | |
| ].join(''); | |
| } | |
| document.getElementById('report-prompt').style.display=(!state.generating&&!state.generated)?'block':'none'; | |
| document.getElementById('report-generating').style.display=state.generating?'block':'none'; | |
| document.getElementById('report-generated').style.display=state.generated?'block':'none'; | |
| if(!state.generated||!proseParas.length) return; | |
| const bs=bpStats(); | |
| const age=state.age||'β', sex=state.sex, race=state.race; | |
| // structured panel | |
| const condChips=state.conditions.map(c=>`<span style="background:#EAF1EC;border:1px solid #D7E4DA;border-radius:999px;padding:3px 11px;font-size:12px;color:#1F4D45;margin:2px;display:inline-block;">${c}</span>`).join(''); | |
| const medChips=state.medications.map(m=>`<span style="background:#FAF4EF;border:1px solid #ECD9CE;border-radius:999px;padding:3px 11px;font-size:12px;color:#A05A3C;margin:2px;display:inline-block;">${m.name} ${m.dose}</span>`).join(''); | |
| const patientLine=`${age}-year-old ${sex?sex.toLowerCase():'patient'}${race?' Β· '+race:''}`; | |
| let bpSection=''; | |
| if(bs){ | |
| bpSection=`<div style="border-top:1px solid #F0EEE7;padding-top:16px;margin-bottom:18px;"><div style="font-size:13px;font-weight:600;color:#2E6E62;margin-bottom:10px;">Blood pressure</div><div style="display:flex;align-items:center;gap:16px;"><div><span class="mono" style="font-size:26px;">${bs.mSys}/${bs.mDia}</span><span style="font-size:12px;color:#9AA59E;margin-left:6px;">mean</span></div><svg width="120" height="36" viewBox="0 0 220 48" fill="none" style="margin-left:auto;"><polyline points="${bs.pts}" stroke="#2E6E62" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/><circle cx="${bs.lastX}" cy="${bs.lastY}" r="4" fill="#DB6A4B"/></svg></div><div style="display:flex;gap:7px;margin-top:11px;flex-wrap:wrap;"><span style="background:#EAF1EC;color:#1F4D45;border-radius:8px;padding:4px 10px;font-size:12px;font-weight:600;">SD Β±${bs.sdSys}/${bs.sdDia}</span><span style="background:#EAF1EC;color:#1F4D45;border-radius:8px;padding:4px 10px;font-size:12px;font-weight:600;">PP ${bs.pp}</span><span style="background:#F1EFE8;color:#6B7A73;border-radius:8px;padding:4px 10px;font-size:12px;font-weight:600;">${bs.count} readings</span></div></div>`; | |
| } | |
| const sw=state.sessions.map(s=>({s,sm:sessSummary(s.recordings)})).filter(x=>x.sm); | |
| let sessSection=''; | |
| if(sw.length){ | |
| const sessItems=sw.map(({s,sm})=>{ | |
| const dc=sm.dom==='nsr'?'#1F4D45':'#B4502F'; const db=sm.dom==='nsr'?'#EAF1EC':'#FBEDE7'; | |
| return `<div style="background:#FBFAF6;border:1px solid #F0EEE7;border-radius:11px;padding:12px 14px;"><div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;"><span style="font-size:13px;font-weight:600;">${fmtDate(s.date)}${s.symptom?`<span style="color:#B4502F;font-weight:400;"> Β· ${s.symptom}</span>`:''}</span><span class="mono" style="font-size:13px;color:#4A5752;">${sm.hr} bpm</span></div><div style="display:flex;align-items:center;gap:8px;"><span style="background:${db};color:${dc};border-radius:7px;padding:3px 9px;font-size:12px;font-weight:600;">${CLASSES[sm.dom]} ${Math.round(sm.avg[sm.dom]*100)}%</span><span style="font-size:12px;color:#9AA59E;">${s.recordings.length} recording${s.recordings.length!==1?'s':''}</span></div></div>`; | |
| }).join(''); | |
| sessSection=`<div style="border-top:1px solid #F0EEE7;padding-top:16px;"><div style="font-size:13px;font-weight:600;color:#2E6E62;margin-bottom:11px;">Cardiac rhythm</div><div style="display:flex;flex-direction:column;gap:11px;">${sessItems}</div></div>`; | |
| } | |
| const structured=`<div style="background:#fff;border:1px solid #E8E6DE;border-radius:16px;padding:22px 24px;"><div style="font-size:11.5px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:#9AA59E;margin-bottom:16px;">Structured readings</div><div style="font-size:13px;font-weight:600;color:#2E6E62;margin-bottom:8px;">Patient</div><div style="font-size:14px;color:#2A332F;line-height:1.6;">${patientLine}</div><div style="margin-top:9px;">${condChips}${medChips}</div>${bpSection}${sessSection}</div>`; | |
| // BP readings table | |
| const TH='padding:9px 14px;font-size:11px;font-weight:700;letter-spacing:0.06em;text-transform:uppercase;color:#9AA59E;text-align:left;border-bottom:2px solid #F0EEE7;white-space:nowrap;'; | |
| const THR=TH+'text-align:right;'; | |
| const TD='padding:9px 14px;font-size:13px;color:#2A332F;border-bottom:1px solid #F8F7F2;'; | |
| const TDM=TD+'font-family:"IBM Plex Mono",monospace;'; | |
| const TDMR=TDM+'text-align:right;'; | |
| let bpTable=''; | |
| const bpValid=state.bp.filter(r=>r.sys&&r.dia&&+r.sys>0); | |
| if(bpValid.length){ | |
| const bpRows=bpValid.map(r=>`<tr><td style="${TDM}">${fmtDate(r.date)}</td><td style="${TD}color:#6B7A73;">${r.time||'β'}</td><td style="${TDMR}">${r.sys}</td><td style="${TDMR}">${r.dia}</td><td style="${TDMR}">${r.hr||'β'}</td><td style="${TD}color:${r.symptom?'#B4502F':'#C7CEC8'};">${r.symptom||'β'}</td></tr>`).join(''); | |
| bpTable=`<div style="background:#fff;border:1px solid #E8E6DE;border-radius:16px;overflow:hidden;margin-top:12px;"><div style="padding:14px 22px;border-bottom:1px solid #F0EEE7;font-size:11.5px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:#9AA59E;">Blood pressure readings</div><div style="overflow-x:auto;"><table style="width:100%;border-collapse:collapse;"><thead><tr><th style="${TH}">Date</th><th style="${TH}">Time</th><th style="${THR}">Sys</th><th style="${THR}">Dia</th><th style="${THR}">HR (bpm)</th><th style="${TH}">Symptom</th></tr></thead><tbody>${bpRows}</tbody></table></div></div>`; | |
| } | |
| // ECG classifier output table | |
| let ecgTable=''; | |
| const sessWithProbs=state.sessions.map(s=>({s,recs:s.recordings.filter(r=>r.status==='done'&&r.probs)})).filter(x=>x.recs.length); | |
| if(sessWithProbs.length){ | |
| const KEYS=['nsr','tachy','brady','afib','pvc']; | |
| const ABR={nsr:'NSR',tachy:'Tachy',brady:'Brady',afib:'AFib',pvc:'PVC'}; | |
| let bodyRows=''; | |
| sessWithProbs.forEach(({s,recs},gi)=>{ | |
| const sl=fmtDate(s.date)+(s.symptom?' Β· '+s.symptom:''); | |
| bodyRows+=`<tr><td colspan="7" style="padding:8px 14px 4px;font-size:11.5px;font-weight:700;color:#2E6E62;background:#FAFAF7;${gi>0?'border-top:2px solid #F0EEE7;':''}">${sl}</td></tr>`; | |
| recs.forEach(r=>{ | |
| const dom=domOf(r.probs); | |
| const pCols=KEYS.map(k=>{ | |
| const pct=Math.round((r.probs[k]||0)*100); | |
| return `<td style="${TDMR}${k===dom?'font-weight:700;color:#1F4D45;':''}">${pct}%</td>`; | |
| }).join(''); | |
| bodyRows+=`<tr><td style="${TD}">${r.label}</td><td style="${TDMR}">${r.hr||'β'}</td>${pCols}</tr>`; | |
| }); | |
| }); | |
| const hCols=KEYS.map(k=>`<th style="${THR}">${ABR[k]}</th>`).join(''); | |
| ecgTable=`<div style="background:#fff;border:1px solid #E8E6DE;border-radius:16px;overflow:hidden;margin-top:12px;"><div style="padding:14px 22px;border-bottom:1px solid #F0EEE7;"><div style="font-size:11.5px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:#9AA59E;">ECG classifier output</div><div style="font-size:11px;color:#C7CEC8;margin-top:3px;">Rough ML model classifications β detects NSR, tachycardia, bradycardia, AFib, and PVC only. Cannot detect murmurs or other findings due to phone microphone hardware constraints. Not clinical advice.</div></div><div style="overflow-x:auto;"><table style="width:100%;border-collapse:collapse;"><thead><tr><th style="${TH}">Recording</th><th style="${THR}">HR</th>${hCols}</tr></thead><tbody>${bodyRows}</tbody></table></div></div>`; | |
| } | |
| const proseItems=proseParas.map((p,i)=>`<div style="margin-bottom:18px;"><div style="font-size:11px;font-weight:700;letter-spacing:0.09em;text-transform:uppercase;color:#2E6E62;margin-bottom:6px;">${p.h}</div><p ${i===0?'id="soap-prose-text"':''} style="margin:0;font-size:15px;line-height:1.68;color:#2A332F;">${p.t}</p></div>`).join(''); | |
| const reportDate=new Date().toLocaleDateString('en-US',{month:'long',day:'numeric',year:'numeric'}); | |
| const prose=`<div style="background:#fff;border:1px solid #E8E6DE;border-radius:16px;overflow:hidden;"><div style="background:#1E2B27;padding:18px 26px;display:flex;align-items:center;gap:12px;"><div class="serif" style="font-size:17px;color:#fff;">Objective</div><span style="background:rgba(143,182,166,0.22);color:#9FD3BE;border-radius:999px;padding:3px 10px;font-size:11px;font-weight:600;letter-spacing:0.03em;">AI-GENERATED DRAFT</span><span style="margin-left:auto;font-size:12px;color:#8FB6A6;">${reportDate}</span></div><div style="padding:26px 30px;">${proseItems}<div style="border-top:1px solid #F0EEE7;padding-top:14px;margin-top:6px;font-size:12px;color:#A9B2AC;line-height:1.5;">This summary was generated from patient-entered home monitoring data and is not a medical record. Automated cardiac rhythm classification is decision-support only, has not been FDA-evaluated, and may be inaccurate. It is not a substitute for professional medical advice, diagnosis, or treatment. All findings require physician review before any clinical action is taken. In an emergency, call 911 immediately.</div></div></div>`; | |
| document.getElementById('report-content').innerHTML=structured+bpTable+ecgTable+prose; | |
| } | |
| function renderFooter() { | |
| const back=document.getElementById('footer-back'); | |
| const next=document.getElementById('footer-next'); | |
| const startover=document.getElementById('footer-startover'); | |
| const counter=document.getElementById('footer-counter'); | |
| back.style.display=state.step>0?'block':'none'; | |
| startover.style.display=(state.step===3&&state.generated)?'block':'none'; | |
| next.style.display=state.step<3?'inline-block':'none'; | |
| next.textContent=state.step===2?'Continue to report β':'Continue β'; | |
| counter.textContent=`Step ${state.step+1} of 4`; | |
| } | |
| // ββ model banner βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function setBanner(text, pct /* 0-100 | null */, done = false) { | |
| const banner = document.getElementById('model-banner'); | |
| const fill = document.getElementById('model-bar-fill'); | |
| const pctEl = document.getElementById('model-pct'); | |
| const textEl = document.getElementById('model-banner-text'); | |
| const dot = document.getElementById('model-dot'); | |
| if (done) { | |
| textEl.textContent = 'Model ready'; | |
| dot.style.background = '#6FC0A8'; | |
| fill.style.width = '100%'; | |
| pctEl.textContent = '100%'; | |
| setTimeout(() => banner.classList.add('hidden'), 2000); | |
| return; | |
| } | |
| textEl.textContent = text; | |
| if (pct !== null) { | |
| fill.style.width = pct + '%'; | |
| pctEl.textContent = Math.round(pct) + '%'; | |
| } | |
| } | |
| // ββ init ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function init() { | |
| render(); // UI is immediately usable | |
| // fetch model with byte-level progress | |
| try { | |
| setBanner('Downloading modelβ¦', 0); | |
| const res = await fetch('/heartline.onnx'); | |
| const total = parseInt(res.headers.get('content-length') || '0'); | |
| const reader = res.body.getReader(); | |
| const chunks = []; | |
| let loaded = 0; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| chunks.push(value); | |
| loaded += value.length; | |
| if (total) setBanner('Downloading modelβ¦', (loaded / total) * 90); | |
| } | |
| const buf = new Uint8Array(loaded); | |
| let offset = 0; | |
| for (const c of chunks) { buf.set(c, offset); offset += c.length; } | |
| setBanner('Initializingβ¦', 92); | |
| ortSession = await ort.InferenceSession.create(buf.buffer, { | |
| executionProviders: ['webgpu', 'wasm'], | |
| }); | |
| setBanner('', 100, true); | |
| console.log('ORT session ready'); | |
| initWllama(); // start downloading SOAP model in background | |
| } catch(e) { | |
| console.warn('Model load failed:', e.message); | |
| document.getElementById('model-banner-text').textContent = 'Model unavailable β using demo mode'; | |
| document.getElementById('model-dot').style.background = '#DB6A4B'; | |
| document.getElementById('model-bar-track').style.display = 'none'; | |
| document.getElementById('model-pct').style.display = 'none'; | |
| } | |
| } | |
| init(); | |
| </script> | |
| </body> | |
| </html> | |