// Educational browser model source snapshot. Not the native oNeura engine or a validated clinical model. // Generated from the inline script in icu.html. No patient decisions. // ============================================================ // ICU SEPSIS SIMULATOR, REAL HEMODYNAMIC MODEL // ============================================================ // Simulates sepsis cascade over 72 hours using physiological equations. // Deterministic: same intervention timing always produces same output. // Deterministic educational approximation; not a calibrated clinical model. (function() { // --- PATIENT CONSTANTS --- const BASELINE = { map: 90, // mmHg hr: 75, // bpm temp: 37.0, // °C spo2: 98, // % cvp: 8, // mmHg co: 5.0, // L/min (cardiac output) svr: 1440, // dyne·s/cm^5 lactate: 0.8, // mmol/L creatinine: 1.0, // mg/dL bilirubin: 0.8, // mg/dL platelets: 250, // ×10^3/µL ph: 7.40, pao2: 95, // mmHg wbc: 7.5, // ×10^3/µL // Derived strokeVolume: 67, // mL (CO / HR * 1000) edv: 120, // mL (preload estimate) // Body weight: 75, // kg bsa: 1.85, // m² // Pathogen model pathogenLoad: 1.0, // normalized 0-1 tnfAlpha: 0.0, // normalized cytokine il6: 0.0, endothelialDys: 0.0, // endothelial dysfunction capillaryLeak: 0.0, // Volume state intravascVol: 5.0, // liters (effective) thirdSpaceLoss: 0, // liters lost to third space fluidBalance: 0, // cumulative net fluid L // Intervention tracking abxGiven: false, abxTime: null, neoActive: false, neoStartTime: null, neoDose: 0, // mcg/kg/min fluidsGiven: 0, // number of fluid boluses crrtActive: false, crrtStartTime: null, // Outcome accumulatedInsult: 0, deathThreshold: 100, }; // --- STATE --- let state = JSON.parse(JSON.stringify(BASELINE)); let currentTime = 24; // hours, start at 24h untreated sepsis for dramatic first impression let playing = false; let playSpeed = 1; // multiplier let playInterval = null; let interventions = []; // {type, time, ...} let historyData = []; // array of snapshots per hour let dead = false; // Precompute entire simulation cache for chart drawing let simCache = []; // {t, map, lactate, sofa, ...} // ============================================================ // PHYSIOLOGY ENGINE // ============================================================ function sigmoid(x, k, x0) { return 1.0 / (1.0 + Math.exp(-k * (x - x0))); } function computePathogenLoad(t, abxGiven, abxTime) { // Pathogen load grows logistically then is reduced by antibiotics let growthRate = 0.08; // per hour let carryingCap = 1.0; let load = 1.0 - 1.0 / (1.0 + 0.001 * Math.exp(growthRate * t)); // More precise: logistic growth load = 1.0 / (1.0 + 9.0 * Math.exp(-0.15 * t)); // reaches ~0.5 at t=15, ~0.95 at t=40 // Antibiotics reduce pathogen load starting 6h after given if (abxGiven && abxTime !== null) { let abxEffectT = t - abxTime; if (abxEffectT > 0) { // Kill rate increases over first 12h of abx let killRate = 0.05 * Math.min(1.0, abxEffectT / 12.0); let reduction = 1.0 - Math.exp(-killRate * abxEffectT); load = load * (1.0 - reduction); } } return Math.max(0, Math.min(1, load)); } function computeCytokines(pathogenLoad, t) { // TNF-alpha peaks early (2-4h after pathogen detection), then declines // IL-6 rises later and sustains let tnf = pathogenLoad * sigmoid(t, 0.5, 4) * (1.0 + 0.3 * Math.exp(-0.05 * t)); let il6 = pathogenLoad * sigmoid(t, 0.3, 8); return { tnf: Math.min(2, tnf), il6: Math.min(2, il6) }; } function computeEndothelialDysfunction(cytokines, t) { // Endothelial dysfunction from cytokine damage let cytokineSignal = 0.5 * cytokines.tnf + 0.5 * cytokines.il6; return Math.min(1.0, cytokineSignal * sigmoid(t, 0.2, 6)); } function computeCapillaryLeak(endothelialDys, t) { // Capillary leak follows endothelial dysfunction with some delay return Math.min(1.0, endothelialDys * sigmoid(t, 0.25, 8)); } function computeVolumeState(capillaryLeak, fluidBoluses, t, fluidTimes) { // Baseline intravascular volume ~5L let baseline = 5.0; // Third space loss from capillary leak let thirdSpaceLoss = capillaryLeak * 2.5; // up to 2.5L lost // Fluid boluses add 2.25L each (30 mL/kg * 75 kg = 2250 mL) let fluidAdded = 0; for (let i = 0; i < fluidBoluses; i++) { let ft = fluidTimes[i] !== undefined ? fluidTimes[i] : 0; // Fluids redistribute over 4h, then third spacing can reclaim let timeSince = t - ft; if (timeSince > 0) { // Initial volume expansion then redistribution let expansion = 2.25 * Math.exp(-0.1 * timeSince); // redistributes over ~10h // After significant capillary leak, added fluid also leaks let leakFraction = capillaryLeak * 0.6; fluidAdded += expansion * (1.0 - leakFraction * Math.min(1, timeSince / 12)); } } let intravasc = baseline - thirdSpaceLoss + fluidAdded; // Illustrative edema signal: fluid and capillary-leak heuristic (not probability) let pulmEdemaRisk = 0; if (fluidBoluses > 0 && capillaryLeak > 0.5 && t > 24) { pulmEdemaRisk = Math.min(1.0, (fluidBoluses * 0.3) * (capillaryLeak - 0.3)); } return { intravasc: Math.max(2, intravasc), thirdSpaceLoss, fluidAdded, pulmEdemaRisk }; } function computeFrankStarling(edv) { // Frank-Starling: stroke volume increases with EDV up to a point // SV = 100 * (1 - exp(-0.015 * EDV)), simplified // Optimal EDV ~ 120-140 mL let sv = 100 * (1.0 - Math.exp(-0.015 * edv)); return Math.max(20, sv); } function computeBaroreflexHR(mapTarget, mapActual, baselineHR) { // Baroreflex: HR increases when MAP drops below target let mapDeficit = mapTarget - mapActual; let hrIncrease = baselineHR * 0.02 * Math.max(0, mapDeficit); // 2% per mmHg deficit // Also increased by inflammatory cytokines return hrIncrease; } function computeSVR(pathogenLoad, endothelialDys, neoActive, neoStartTime, t) { // Baseline SVR ~1440. Sepsis causes profound vasodilation. let baseline = 1440; // Vasodilation from NO release, mediated by cytokines let vasodilation = (pathogenLoad * 0.4 + endothelialDys * 0.5) * 700; let svr = baseline - vasodilation; // Norepinephrine increases SVR if (neoActive && neoStartTime !== null) { let neoT = t - neoStartTime; if (neoT > 0) { // Neo effect ramps up over 30 min let neoEffect = Math.min(1.0, neoT / 0.5); svr += neoEffect * 600; // +600 dyne·s/cm^5 } } return Math.max(400, svr); } function computeQ10Temp(baselineTemp, metabolicRate) { // Q10 effect: every 10°C increase doubles metabolic rate // In sepsis, inflammatory pyrogens raise hypothalamic set point // Temp increase roughly proportional to cytokine signal return baselineTemp + (metabolicRate - 1.0) * 2.0; // simplified } function computeLactate(co, oxygenDemand, t) { // Lactate rises when oxygen delivery (DO2) < oxygen demand // DO2 = CO × CaO2; simplified: lactate inversely proportional to CO when CO low let baseline = 0.8; // Lactate production increases when CO < 4 L/min let lactateProd = 0; if (co < 4.0) { lactateProd = (4.0 - co) * 1.5; // anaerobic metabolism } // Also from cytokine-driven hypermetabolism lactateProd += oxygenDemand * 0.5; // Clearance by liver (impaired in sepsis) let clearance = 0.05 * (1.0 - state._endothelialDys * 0.5); // Accumulate return baseline + lactateProd; } function computeSOFA(pao2, platelets, bilirubin, map, neoActive, gcs, creatinine) { // SOFA score components (0-4 each) // 1. Respiration: PaO2/FiO2 ratio (assume room air FiO2=0.21, then supplemental if needed) let fio2 = 0.21; if (pao2 < 60) fio2 = 0.4; // supplemental O2 if (pao2 < 50) fio2 = 0.6; let pf = pao2 / fio2; let resp = 0; if (pf < 100) resp = 4; else if (pf < 200) resp = 3; else if (pf < 300) resp = 2; else if (pf < 400) resp = 1; // 2. Coagulation: Platelets let coag = 0; if (platelets < 20) coag = 4; else if (platelets < 50) coag = 3; else if (platelets < 100) coag = 2; else if (platelets < 150) coag = 1; // 3. Liver: Bilirubin let liver = 0; if (bilirubin >= 12.0) liver = 4; else if (bilirubin >= 6.0) liver = 3; else if (bilirubin >= 2.0) liver = 2; else if (bilirubin >= 1.2) liver = 1; // 4. Cardiovascular: MAP and vasopressors let cardio = 0; if (neoActive && map < 70) cardio = 4; else if (neoActive) cardio = 3; else if (map < 70) cardio = 3; else if (map < 70) cardio = 2; // dopamine ≤5 or dobutamine else if (map < 75) cardio = 1; // Simplified: use MAP thresholds if (map < 50) cardio = 4; else if (map < 60) cardio = 3; else if (map < 70) cardio = 2; else if (map < 80) cardio = 1; else cardio = 0; // 5. CNS: GCS (simplified, encephalopathy from sepsis) // GCS drops with lactate and hypotension let gcsActual = gcs; let cns = 0; if (gcsActual < 6) cns = 4; else if (gcsActual < 10) cns = 3; else if (gcsActual < 13) cns = 2; else if (gcsActual < 15) cns = 1; // 6. Renal: Creatinine let renal = 0; if (creatinine >= 5.0) renal = 4; else if (creatinine >= 3.5) renal = 3; else if (creatinine >= 2.0) renal = 2; else if (creatinine >= 1.2) renal = 1; return { total: resp + coag + liver + cardio + cns + renal, resp, coag, liver, cardio, cns, renal }; } function computeGCS(lactate, map, t) { // Sepsis-associated encephalopathy // GCS decreases with severity let gcs = 15; // Lactate effect if (lactate > 4) gcs -= 3; else if (lactate > 2.5) gcs -= 1; // MAP effect (hypotension → decreased cerebral perfusion) if (map < 50) gcs -= 4; else if (map < 60) gcs -= 2; else if (map < 70) gcs -= 1; // Time-dependent delirium gcs -= Math.floor(t / 24); return Math.max(3, Math.min(15, gcs)); } function computeSpO2(pao2, pulmEdemaRisk) { // SpO2 from PaO2 using oxyhemoglobin dissociation curve (simplified) let spo2 = 100 * (1.0 / (1.0 + Math.exp(-0.1 * (pao2 - 30)))); // Pulmonary edema reduces SpO2 spo2 -= pulmEdemaRisk * 15; return Math.max(60, Math.min(100, Math.round(spo2))); } // ============================================================ // FULL SIMULATION STEP (for any given time t) // ============================================================ function simulateAtTime(t) { // Count interventions active by time t let fluidBoluses = 0; let fluidTimes = []; let abxGiven = false; let abxTime = null; let neoActive = false; let neoStartTime = null; let crrtActive = false; let crrtStartTime = null; for (let iv of interventions) { if (iv.time <= t) { if (iv.type === 'fluids') { fluidBoluses++; fluidTimes.push(iv.time); } if (iv.type === 'abx') { abxGiven = true; abxTime = iv.time; } if (iv.type === 'neo') { neoActive = true; neoStartTime = iv.time; } if (iv.type === 'crrt') { crrtActive = true; crrtStartTime = iv.time; } } } // 1. Pathogen load let pathogenLoad = computePathogenLoad(t, abxGiven, abxTime); // 2. Cytokines let cytokines = computeCytokines(pathogenLoad, t); // 3. Endothelial dysfunction let endothelialDys = computeEndothelialDysfunction(cytokines, t); // 4. Capillary leak let capillaryLeak = computeCapillaryLeak(endothelialDys, t); // 5. Volume state let vol = computeVolumeState(capillaryLeak, fluidBoluses, t, fluidTimes); // 6. Preload (EDV), reduced by third spacing, increased by fluids let edv = 120 * (vol.intravasc / 5.0); // proportional to intravascular volume // 7. Frank-Starling mechanism → Stroke Volume let sv = computeFrankStarling(edv); // 8. SVR (systemic vascular resistance) let svr = computeSVR(pathogenLoad, endothelialDys, neoActive, neoStartTime, t); // 9. Heart rate, baroreflex + inflammatory tachycardia let tempElevation = cytokines.il6 * 1.5; let temp = BASELINE.temp + tempElevation; // Q10 metabolic scaling: each 1°C increase → ~10% metabolic increase let metabolicRate = 1.0 + (temp - 37.0) * 0.1; // Inflammatory tachycardia (IL-6, TNF mediated) let inflammatoryHR = cytokines.il6 * 30 + cytokines.tnf * 15; // Baroreflex: target MAP 90, increase HR when MAP low let preliminaryCO = sv * BASELINE.hr / 1000; let preliminaryMAP = preliminaryCO * svr / 80; // mmHg conversion factor (CO in L/min, SVR in dyne·s/cm^5: MAP = CO * SVR / 80) let baroHR = computeBaroreflexHR(90, preliminaryMAP, BASELINE.hr); let hr = BASELINE.hr + inflammatoryHR + baroHR; hr = Math.min(160, Math.max(50, hr)); // 10. Cardiac Output = HR × SV / 1000 let co = hr * sv / 1000; // 11. MAP = CO × SVR (conversion: CO in L/min, SVR in dyne·s/cm^5 → MAP in mmHg) // MAP = (CO × SVR) / 80 let map = (co * svr) / 80; // Neo effect already in SVR. But also check MAP target with neo // Actually, let's re-derive with neo in SVR already // MAP = CO * SVR / 80 // 12. CVP, low with hypovolemia, high with fluid overload let cvp = 8 * (vol.intravasc / 5.0); if (neoActive) cvp += 3; // neo increases venous return cvp = Math.max(0, cvp); // 13. Lactate, from tissue hypoperfusion let lactateBase = 0.8; let lactateFromHypoperf = 0; if (map < 70) lactateFromHypoperf += (70 - map) * 0.1; if (co < 3.5) lactateFromHypoperf += (3.5 - co) * 2.0; let lactateFromHypermetab = (metabolicRate - 1.0) * 0.5; let lactate = lactateBase + lactateFromHypoperf + lactateFromHypermetab; // CRRT clears lactate if (crrtActive) { let crrtT = t - crrtStartTime; if (crrtT > 0) { let clearanceRate = 0.15; // fraction cleared per hour lactate = lactate * Math.exp(-clearanceRate * crrtT); lactate = Math.max(0.8, lactate); } } // 14. Organ dysfunction // Renal: Creatinine rises with hypoperfusion (MAP < 65) and cytokine damage let creatBaseline = 1.0; let creatInjury = 0; if (map < 65) creatInjury += (65 - map) * 0.04; // acute kidney injury creatInjury += cytokines.il6 * 0.3 * sigmoid(t, 0.2, 12); let creatinine = creatBaseline + creatInjury; // CRRT clears creatinine if (crrtActive && crrtStartTime !== null) { let crrtT = t - crrtStartTime; if (crrtT > 0) { creatinine = creatinine * Math.exp(-0.1 * crrtT); creatinine = Math.max(1.0, creatinine); } } // Hepatic: Bilirubin let biliBaseline = 0.8; let biliInjury = cytokines.tnf * 0.4 * sigmoid(t, 0.15, 18); let bilirubin = biliBaseline + biliInjury; // Coagulation: Platelets drop with DIC (disseminated intravascular coagulation) let pltBaseline = 250; let pltDrop = (cytokines.tnf + cytokines.il6) * 40 * sigmoid(t, 0.15, 15); let platelets = pltBaseline - pltDrop; // PaO2, affected by pulmonary capillary leak / ARDS let pao2Base = 95; let pao2Drop = capillaryLeak * 30 + vol.pulmEdemaRisk * 20; let pao2 = pao2Base - pao2Drop; // SpO2 let spo2 = computeSpO2(pao2, vol.pulmEdemaRisk); // WBC let wbc = 7.5 + pathogenLoad * 15 - (t > 36 && pathogenLoad > 0.5 ? 10 : 0); wbc = Math.max(1, wbc); // pH, acidosis from lactate and renal failure let ph = 7.40 - (lactate - 0.8) * 0.03 - (creatinine - 1.0) * 0.02; ph = Math.max(6.9, Math.min(7.45, ph)); // 15. GCS let gcs = computeGCS(lactate, map, t); // 16. SOFA score let sofa = computeSOFA(pao2, platelets, bilirubin, map, neoActive, gcs, creatinine); // 17. Accumulated insult (determines survival) // Insult accumulates from tissue hypoperfusion, high lactate, organ failure let insultRate = 0; insultRate += Math.max(0, (2.0 - lactate) * (-1)) * 0.5; // lactate > 2 insultRate += Math.max(0, (65 - map)) * 0.3; // MAP < 65 insultRate += sofa.total * 0.2; // organ failure insultRate += (ph < 7.35 ? (7.35 - ph) * 10 : 0); // acidosis // Early antibiotics reduce insult dramatically let insultReduction = 0; if (abxGiven && abxTime !== null) { let abxEffectT = t - abxTime; if (abxTime <= 3 && abxEffectT > 0) insultReduction = 0.7; else if (abxTime <= 6 && abxEffectT > 0) insultReduction = 0.5; else if (abxTime <= 12 && abxEffectT > 0) insultReduction = 0.25; else if (abxEffectT > 0) insultReduction = 0.1; } insultRate *= (1.0 - insultReduction); return { t, map, hr, temp, spo2, cvp, co, svr, sv, edv, lactate, creatinine, bilirubin, platelets, pao2, wbc, ph, gcs, pathogenLoad, endothelialDys, capillaryLeak, vol, sofa, metabolicRate, insultRate, cytokines, neoActive, abxGiven, crrtActive, fluidBoluses, pulmEdemaRisk: vol.pulmEdemaRisk }; } // ============================================================ // COMPUTE FULL SIMULATION CACHE // ============================================================ function recomputeCache() { simCache = []; let accumulatedInsult = 0; for (let t = 0; t <= 72; t += 0.5) { let s = simulateAtTime(t); accumulatedInsult += s.insultRate * 0.5; // dt = 0.5h s.accumulatedInsult = accumulatedInsult; simCache.push(s); } // Compute survival let finalInsult = simCache[simCache.length - 1].accumulatedInsult; let sofaMax = 0; for (let s of simCache) { if (s.sofa.total > sofaMax) sofaMax = s.sofa.total; } // Survival probability based on accumulated insult and max SOFA // SOFA > 12: mortality > 50%, > 20: > 90% // Early abx bonus let abxBonus = 0; for (let iv of interventions) { if (iv.type === 'abx' && iv.time <= 3) abxBonus = 40; else if (iv.type === 'abx' && iv.time <= 6) abxBonus = 25; else if (iv.type === 'abx' && iv.time <= 12) abxBonus = 10; } let survFromInsult = Math.max(0, 100 - accumulatedInsult + abxBonus); let survFromSOFA = Math.max(0, 100 - sofaMax * 5); let survivalProb = Math.min(survFromInsult, survFromSOFA); survivalProb = Math.max(0, Math.min(100, survivalProb)); state._survivalProb = survivalProb; state._accumulatedInsult = accumulatedInsult; state._maxSOFA = sofaMax; } // ============================================================ // GET STATE AT CURRENT TIME (interpolated from cache) // ============================================================ function getStateNow() { let idx = currentTime / 0.5; let i = Math.floor(idx); if (i >= simCache.length) return simCache[simCache.length - 1]; if (i < 0) return simCache[0]; let frac = idx - i; let a = simCache[i]; let b = simCache[Math.min(i + 1, simCache.length - 1)]; // Interpolate numeric fields let result = {}; for (let key of Object.keys(a)) { if (typeof a[key] === 'number') { result[key] = a[key] + (b[key] - a[key]) * frac; } else if (typeof a[key] === 'object' && a[key] !== null) { result[key] = a[key]; // keep discrete values from nearest } else { result[key] = a[key]; } } return result; } // ============================================================ // RENDERING // ============================================================ // Presentation only: missing or invalid scores must not become normal values. function formatIllustrativeScore(value) { return Number.isFinite(value) ? Math.round(value) + '/100' : 'N/A'; } function render() { let s = getStateNow(); if (!s) return; // Vitals document.getElementById('v-map').textContent = Math.round(s.map); document.getElementById('v-hr').textContent = Math.round(s.hr); document.getElementById('v-spo2').textContent = Math.round(s.spo2); document.getElementById('v-temp').textContent = s.temp.toFixed(1); document.getElementById('v-cvp').textContent = Math.round(s.cvp); // Color vital cards based on severity let mapCard = document.querySelector('.vital-card.map'); mapCard.querySelector('.vital-value').style.color = s.map < 65 ? '#e0795f' : s.map < 75 ? '#d9a85f' : '#f4efe6'; let hrCard = document.querySelector('.vital-card.hr'); hrCard.querySelector('.vital-value').style.color = s.hr > 120 ? '#e0795f' : s.hr > 100 ? '#d9a85f' : '#f4efe6'; let spo2Card = document.querySelector('.vital-card.spo2'); spo2Card.querySelector('.vital-value').style.color = s.spo2 < 90 ? '#e0795f' : s.spo2 < 94 ? '#d9a85f' : '#f4efe6'; // Chart values document.getElementById('cv-map').textContent = Math.round(s.map); document.getElementById('cv-lactate').textContent = s.lactate.toFixed(1); document.getElementById('cv-sofa').textContent = s.sofa.total; // Labs setLabVal('lab-lactate', s.lactate.toFixed(1), s.lactate > 2.0, s.lactate > 4.0); setLabVal('lab-ph', s.ph.toFixed(2), s.ph < 7.35 || s.ph > 7.45, s.ph < 7.2); setLabVal('lab-pao2', Math.round(s.pao2), s.pao2 < 80, s.pao2 < 60); setLabVal('lab-creat', s.creatinine.toFixed(1), s.creatinine > 1.3, s.creatinine > 2.0); setLabVal('lab-bili', s.bilirubin.toFixed(1), s.bilirubin > 1.2, s.bilirubin > 2.0); setLabVal('lab-plt', Math.round(s.platelets), s.platelets < 150, s.platelets < 100); setLabVal('lab-wbc', s.wbc.toFixed(1), s.wbc > 12 || s.wbc < 4, false); // Hemodynamics document.getElementById('lab-co').textContent = s.co.toFixed(1) + ' L/min'; document.getElementById('lab-svr').textContent = Math.round(s.svr) + ' dyn·s/cm⁵'; document.getElementById('lab-sv').textContent = Math.round(s.sv) + ' mL'; document.getElementById('lab-edv').textContent = Math.round(s.edv) + ' mL'; // SOFA document.getElementById('sofa-total').textContent = s.sofa.total; setSofaScore('sofa-resp', s.sofa.resp); setSofaScore('sofa-coag', s.sofa.coag); setSofaScore('sofa-liver', s.sofa.liver); setSofaScore('sofa-cardio', s.sofa.cardio); setSofaScore('sofa-cns', s.sofa.cns); setSofaScore('sofa-renal', s.sofa.renal); // Color SOFA total let sofaEl = document.getElementById('sofa-total'); sofaEl.style.color = s.sofa.total >= 15 ? '#e0795f' : s.sofa.total >= 10 ? '#d5744f' : s.sofa.total >= 5 ? '#d9a85f' : '#f4efe6'; // Simulation info let insult = state._accumulatedInsult || 0; let surv = Number.isFinite(state._survivalProb) ? state._survivalProb : null; document.getElementById('lab-insult').textContent = insult.toFixed(1) + '%'; document.getElementById('lab-survival').textContent = formatIllustrativeScore(surv); document.getElementById('lab-survival').className = 'lab-val' + (surv !== null && surv < 30 ? ' abnormal' : surv !== null && surv < 60 ? ' warning' : ''); // Time display document.getElementById('time-display').textContent = currentTime.toFixed(1) + ' h'; // Draw charts drawChart('chart-map', s.t, 'map', 40, 110, '#8fb079', 65); drawChart('chart-lactate', s.t, 'lactate', 0, 10, '#e0795f', 2.0); drawChart('chart-sofa', s.t, 'sofaTotal', 0, 24, '#6fbcab', null); // Cascade display renderCascade(s); // Outcome renderOutcome(s); // Intervention markers renderMarkers(); // Disable buttons for used interventions updateButtons(); } function setLabVal(id, val, warn, danger) { let el = document.getElementById(id); el.textContent = val; el.className = 'lab-val' + (danger ? ' abnormal' : warn ? ' warning' : ''); } function setSofaScore(id, score) { let el = document.getElementById(id); score = Math.round(score); el.textContent = score; el.className = 'score s' + Math.min(4, score); } function renderCascade(s) { let steps = [ { label: 'Infection established (S. pneumoniae)', active: s.pathogenLoad > 0.1 }, { label: 'TNF-α / IL-6 cytokine release', active: s.cytokines.tnf > 0.2 || s.cytokines.il6 > 0.2 }, { label: 'Endothelial dysfunction', active: s.endothelialDys > 0.2 }, { label: 'Capillary leak → hypovolemia', active: s.capillaryLeak > 0.2 }, { label: '↓ SVR → ↓ MAP (vasodilation)', active: s.svr < 1200 }, { label: '↑ HR (baroreflex + inflammatory)', active: s.hr > 90 }, { label: 'Tissue hypoperfusion → ↑ lactate', active: s.lactate > 2.0 }, { label: 'Renal dysfunction (↑ creatinine)', active: s.creatinine > 1.3 }, { label: 'Hepatic dysfunction (↑ bilirubin)', active: s.bilirubin > 1.2 }, { label: 'Coagulopathy (↓ platelets / DIC)', active: s.platelets < 150 }, ]; let html = ''; for (let step of steps) { html += '
' + step.label + '
'; } document.getElementById('cascade-display').innerHTML = html; // Physio note let note = 't=' + s.t.toFixed(1) + 'h | Pathogen load: ' + (s.pathogenLoad * 100).toFixed(0) + '% | TNF-α: ' + s.cytokines.tnf.toFixed(2) + ' | IL-6: ' + s.cytokines.il6.toFixed(2) + ' | Endoth. dysfx: ' + (s.endothelialDys * 100).toFixed(0) + '%'; if (s.pulmEdemaRisk > 0) note += ' | Illustrative edema signal: ' + (s.pulmEdemaRisk * 100).toFixed(0) + '/100'; document.getElementById('physio-note').textContent = note; } function renderOutcome(s) { let el = document.getElementById('outcome'); if (currentTime >= 72) { el.style.display = ''; let surv = state._survivalProb; if (!Number.isFinite(surv)) { el.className = 'outcome-banner'; el.style.display = 'block'; el.textContent = 'Scenario complete. Illustrative score: N/A. No valid score is available; no patient outcome is predicted.'; } else if (surv >= 50) { el.className = 'outcome-banner alive'; el.textContent = 'Scenario complete. Illustrative score: ' + Math.round(surv) + '/100. This is not a survival probability.'; } else { el.className = 'outcome-banner dead'; el.textContent = 'Scenario complete. Illustrative score: ' + Math.round(surv) + '/100. A model threshold was crossed; no patient outcome is predicted.'; } } else if (s.sofa.total >= 15 && s.map < 55) { el.style.display = ''; el.className = 'outcome-banner critical'; el.textContent = 'Illustrative high-stress model state. MAP ' + Math.round(s.map) + ' mmHg; model SOFA ' + s.sofa.total + '. Not a clinical assessment.'; } else { el.className = 'outcome-banner'; el.style.display = 'none'; } } function renderMarkers() { let container = document.getElementById('intervention-markers'); let html = ''; let width = container.offsetWidth || 600; for (let iv of interventions) { let left = (iv.time / 72) * width; let color = iv.type === 'fluids' ? '#6fbcab' : iv.type === 'neo' ? '#e0795f' : iv.type === 'abx' ? '#8fb079' : '#c2a07f'; html += '
'; } container.innerHTML = html; } function updateButtons() { // Allow multiple fluids, but each only once // Neo can be given once // ABX once // CRRT once let used = {}; for (let iv of interventions) { used[iv.type] = (used[iv.type] || 0) + 1; } document.getElementById('btn-abx').disabled = !!used['abx']; document.getElementById('btn-neo').disabled = !!used['neo']; document.getElementById('btn-crrt').disabled = !!used['crrt']; document.getElementById('btn-fluids').disabled = (used['fluids'] || 0) >= 3; } // ============================================================ // CHART DRAWING (pure canvas) // ============================================================ function drawChart(canvasId, currentTimeVal, field, yMin, yMax, color, threshold) { let canvas = document.getElementById(canvasId); if (!canvas) return; let dpr = window.devicePixelRatio || 1; let rect = canvas.getBoundingClientRect(); canvas.width = rect.width * dpr; canvas.height = 140 * dpr; let ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); let w = rect.width; let h = 140; // Background (transparent so chart-card warm dark shows through) ctx.clearRect(0, 0, w, h); let padL = 36, padR = 10, padT = 10, padB = 24; let plotW = w - padL - padR; let plotH = h - padT - padB; // Grid (warm low-contrast) ctx.strokeStyle = 'rgba(244, 239, 230, 0.08)'; ctx.lineWidth = 0.5; let ySteps = 4; for (let i = 0; i <= ySteps; i++) { let y = padT + plotH * (1 - i / ySteps); ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(padL + plotW, y); ctx.stroke(); // Label ctx.fillStyle = 'rgba(244, 239, 230, 0.5)'; ctx.font = '9px IBM Plex Mono, monospace'; ctx.textAlign = 'right'; let val = yMin + (yMax - yMin) * (i / ySteps); ctx.fillText(val.toFixed(field === 'sofaTotal' ? 0 : 0), padL - 4, y + 3); } // X axis labels ctx.fillStyle = 'rgba(244, 239, 230, 0.5)'; ctx.font = '9px IBM Plex Mono, monospace'; ctx.textAlign = 'center'; for (let hr = 0; hr <= 72; hr += 24) { let x = padL + (hr / 72) * plotW; ctx.fillText(hr + 'h', x, h - 4); } // Threshold line (warm terracotta dashed) if (threshold !== null) { let ty = padT + plotH * (1 - (threshold - yMin) / (yMax - yMin)); ctx.strokeStyle = 'rgba(213, 116, 79, 0.55)'; ctx.lineWidth = 1; ctx.setLineDash([4, 3]); ctx.beginPath(); ctx.moveTo(padL, ty); ctx.lineTo(padL + plotW, ty); ctx.stroke(); ctx.setLineDash([]); } // Data line if (simCache.length > 0) { ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.beginPath(); let started = false; for (let pt of simCache) { if (pt.t > currentTimeVal) break; let val = field === 'sofaTotal' ? pt.sofa.total : pt[field]; let x = padL + (pt.t / 72) * plotW; let y = padT + plotH * (1 - (val - yMin) / (yMax - yMin)); y = Math.max(padT, Math.min(padT + plotH, y)); if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y); } ctx.stroke(); // Fill under let lastPt = null; for (let pt of simCache) { if (pt.t > currentTimeVal) break; lastPt = pt; } if (lastPt) { let lastX = padL + (lastPt.t / 72) * plotW; ctx.lineTo(lastX, padT + plotH); ctx.lineTo(padL, padT + plotH); ctx.closePath(); ctx.fillStyle = color + '22'; ctx.fill(); } // Current point if (lastPt) { let val = field === 'sofaTotal' ? lastPt.sofa.total : lastPt[field]; let cx = padL + (lastPt.t / 72) * plotW; let cy = padT + plotH * (1 - (val - yMin) / (yMax - yMin)); cy = Math.max(padT, Math.min(padT + plotH, cy)); ctx.beginPath(); ctx.arc(cx, cy, 3.5, 0, Math.PI * 2); ctx.fillStyle = color; ctx.fill(); ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.stroke(); } } // Intervention markers on chart for (let iv of interventions) { let x = padL + (iv.time / 72) * plotW; ctx.strokeStyle = iv.type === 'fluids' ? '#6fbcab40' : iv.type === 'neo' ? '#e0795f40' : iv.type === 'abx' ? '#8fb07940' : '#c2a07f40'; ctx.lineWidth = 1; ctx.setLineDash([2, 2]); ctx.beginPath(); ctx.moveTo(x, padT); ctx.lineTo(x, padT + plotH); ctx.stroke(); ctx.setLineDash([]); } // Border ctx.strokeStyle = '#e2e8f0'; ctx.lineWidth = 1; ctx.strokeRect(padL, padT, plotW, plotH); } // ============================================================ // INTERVENTIONS // ============================================================ window.doIntervention = function(type) { // Prevent duplicate (except fluids, max 3) let count = interventions.filter(i => i.type === type).length; if (type !== 'fluids' && count > 0) return; if (type === 'fluids' && count >= 3) return; interventions.push({ type, time: currentTime }); interventions.sort((a, b) => a.time - b.time); // Add to history let labels = { fluids: 'IV Fluids', neo: 'Norepinephrine', abx: 'Antibiotics', crrt: 'CRRT' }; let historyList = document.getElementById('history-list'); if (interventions.length === 1) historyList.innerHTML = ''; let tag = document.createElement('span'); tag.className = 'history-tag ' + type; tag.textContent = labels[type] + ' @ ' + currentTime.toFixed(1) + 'h'; historyList.appendChild(tag); recomputeCache(); render(); }; // ============================================================ // TIMELINE CONTROLS // ============================================================ window.onSlider = function(val) { currentTime = parseFloat(val); render(); }; window.togglePlay = function() { playing = !playing; let btn = document.getElementById('btn-play'); if (playing) { btn.innerHTML = '▮▮ Pause'; btn.classList.add('playing'); startPlayback(); } else { btn.innerHTML = '▶ Play'; btn.classList.remove('playing'); stopPlayback(); } }; function startPlayback() { if (playInterval) clearInterval(playInterval); playInterval = setInterval(function() { currentTime += 0.1 * playSpeed; if (currentTime >= 72) { currentTime = 72; playing = false; let btn = document.getElementById('btn-play'); btn.innerHTML = '▶ Play'; btn.classList.remove('playing'); clearInterval(playInterval); playInterval = null; } document.getElementById('timeline').value = currentTime; render(); }, 100); } function stopPlayback() { if (playInterval) { clearInterval(playInterval); playInterval = null; } } window.cycleSpeed = function() { let speeds = [1, 2, 5, 10]; let idx = speeds.indexOf(playSpeed); playSpeed = speeds[(idx + 1) % speeds.length]; document.getElementById('btn-speed').innerHTML = playSpeed + '×'; if (playing) { stopPlayback(); startPlayback(); } }; window.resetSim = function() { playing = false; stopPlayback(); currentTime = 0; interventions = []; state = JSON.parse(JSON.stringify(BASELINE)); document.getElementById('timeline').value = 0; document.getElementById('btn-play').innerHTML = '▶ Play'; document.getElementById('btn-play').classList.remove('playing'); document.getElementById('history-list').innerHTML = 'No interventions yet'; recomputeCache(); render(); }; // ============================================================ // INIT // ============================================================ recomputeCache(); render(); // Handle resize window.addEventListener('resize', function() { render(); }); })();