Split the Bedside clinical reference section out of calculators.js (~1220 lines) into 20 focused ES modules under public/js/bedside/. Each module owns one clinical topic (cardiac, seizure, sepsis, burns, etc.) and exports an init() wired up by bedside/index.js. Shared helpers live in shared.js and also set window._EM for back-compat with the 4 remaining call sites in calculators.js. Load order: classic defer calculators.js first, then module script bedside/index.js. Handlers bind at DOM-ready; runtime _EM lookups resolve after both are evaluated. Makes bedside content editable per-section, shrinks calculators.js from 4111 to 2891 lines, and keeps the existing Playwright smoke suite (e2e/tests/bedside-smoke.spec.js) as the behavioral contract.
247 lines
15 KiB
JavaScript
247 lines
15 KiB
JavaScript
// ============================================================
|
|
// bedside/neonatal.js
|
|
// NEONATAL ASSESSMENT (Fenton growth) + NRP pathway + Apgar.
|
|
// ============================================================
|
|
|
|
import { S } from './shared.js';
|
|
|
|
// Fenton 2013 LMS data (weight in grams, GA in weeks)
|
|
var fentonLMS = {
|
|
male: {
|
|
22:{L:0.21,M:496,S:0.17},23:{L:0.21,M:575,S:0.17},24:{L:0.21,M:660,S:0.17},25:{L:0.21,M:762,S:0.16},
|
|
26:{L:0.21,M:870,S:0.16},27:{L:0.20,M:993,S:0.15},28:{L:0.20,M:1124,S:0.15},29:{L:0.19,M:1272,S:0.14},
|
|
30:{L:0.18,M:1430,S:0.14},31:{L:0.17,M:1607,S:0.14},32:{L:0.15,M:1795,S:0.14},33:{L:0.13,M:2008,S:0.13},
|
|
34:{L:0.12,M:2230,S:0.13},35:{L:0.10,M:2467,S:0.13},36:{L:0.08,M:2710,S:0.13},37:{L:0.06,M:2948,S:0.12},
|
|
38:{L:0.04,M:3195,S:0.12},39:{L:0.02,M:3380,S:0.12},40:{L:0.01,M:3530,S:0.12},41:{L:0.00,M:3660,S:0.12},
|
|
42:{L:-0.02,M:3820,S:0.12}
|
|
},
|
|
female: {
|
|
22:{L:0.23,M:474,S:0.17},23:{L:0.22,M:538,S:0.17},24:{L:0.22,M:610,S:0.17},25:{L:0.22,M:705,S:0.16},
|
|
26:{L:0.22,M:810,S:0.16},27:{L:0.21,M:920,S:0.15},28:{L:0.21,M:1040,S:0.15},29:{L:0.20,M:1178,S:0.14},
|
|
30:{L:0.19,M:1330,S:0.14},31:{L:0.18,M:1500,S:0.14},32:{L:0.16,M:1680,S:0.14},33:{L:0.14,M:1880,S:0.13},
|
|
34:{L:0.12,M:2090,S:0.13},35:{L:0.10,M:2310,S:0.13},36:{L:0.08,M:2540,S:0.13},37:{L:0.06,M:2766,S:0.12},
|
|
38:{L:0.04,M:3000,S:0.12},39:{L:0.02,M:3180,S:0.12},40:{L:0.01,M:3340,S:0.12},41:{L:0.00,M:3480,S:0.12},
|
|
42:{L:-0.02,M:3630,S:0.12}
|
|
}
|
|
};
|
|
|
|
function interpolateLMS(table, val) {
|
|
var keys = Object.keys(table).map(Number).sort(function(a,b){return a-b;});
|
|
if (val <= keys[0]) return table[keys[0]];
|
|
if (val >= keys[keys.length-1]) return table[keys[keys.length-1]];
|
|
for (var i = 0; i < keys.length - 1; i++) {
|
|
if (val >= keys[i] && val <= keys[i+1]) {
|
|
var t = (val - keys[i]) / (keys[i+1] - keys[i]);
|
|
var a = table[keys[i]], b = table[keys[i+1]];
|
|
return { L: a.L + t*(b.L-a.L), M: a.M + t*(b.M-a.M), S: a.S + t*(b.S-a.S) };
|
|
}
|
|
}
|
|
return table[keys[0]];
|
|
}
|
|
|
|
function calcZ(value, L, M, S) {
|
|
if (Math.abs(L) < 0.001) return Math.log(value / M) / S;
|
|
return (Math.pow(value / M, L) - 1) / (L * S);
|
|
}
|
|
|
|
function zToPercentile(z) {
|
|
// Approximation of the standard normal CDF
|
|
var a1=0.254829592, a2=-0.284496736, a3=1.421413741, a4=-1.453152027, a5=1.061405429, p=0.3275911;
|
|
var sign = z < 0 ? -1 : 1;
|
|
var x = Math.abs(z) / Math.sqrt(2);
|
|
var t = 1 / (1 + p * x);
|
|
var y = 1 - (((((a5*t + a4)*t) + a3)*t + a2)*t + a1)*t * Math.exp(-x*x);
|
|
return Math.round(((1 + sign * y) / 2) * 1000) / 10;
|
|
}
|
|
|
|
function classifyGA(weeks, days) {
|
|
var total = weeks + (days || 0) / 7;
|
|
if (total < 28) return { label: 'Extremely Preterm', color: '#dc2626', icon: 'fa-triangle-exclamation' };
|
|
if (total < 32) return { label: 'Very Preterm', color: '#ea580c', icon: 'fa-triangle-exclamation' };
|
|
if (total < 34) return { label: 'Moderate Preterm', color: '#d97706', icon: 'fa-circle-exclamation' };
|
|
if (total < 37) return { label: 'Late Preterm', color: '#ca8a04', icon: 'fa-circle-info' };
|
|
if (total < 39) return { label: 'Early Term', color: '#2563eb', icon: 'fa-circle-info' };
|
|
if (total < 41) return { label: 'Full Term', color: '#16a34a', icon: 'fa-circle-check' };
|
|
if (total < 42) return { label: 'Late Term', color: '#d97706', icon: 'fa-circle-info' };
|
|
return { label: 'Post Term', color: '#dc2626', icon: 'fa-triangle-exclamation' };
|
|
}
|
|
|
|
function classifyWeight(percentile) {
|
|
if (percentile < 3) return { label: 'Severely SGA', color: '#dc2626', detail: '<3rd percentile' };
|
|
if (percentile < 10) return { label: 'SGA', color: '#ea580c', detail: '<10th percentile' };
|
|
if (percentile > 97) return { label: 'Severely LGA', color: '#dc2626', detail: '>97th percentile' };
|
|
if (percentile > 90) return { label: 'LGA', color: '#ea580c', detail: '>90th percentile' };
|
|
return { label: 'AGA', color: '#16a34a', detail: '10th-90th percentile' };
|
|
}
|
|
|
|
function classifyBirthWeight(grams) {
|
|
if (grams < 1000) return { label: 'Extremely Low Birth Weight (ELBW)', color: '#dc2626' };
|
|
if (grams < 1500) return { label: 'Very Low Birth Weight (VLBW)', color: '#ea580c' };
|
|
if (grams < 2500) return { label: 'Low Birth Weight (LBW)', color: '#d97706' };
|
|
if (grams <= 4000) return { label: 'Normal Birth Weight', color: '#16a34a' };
|
|
return { label: 'Macrosomia (>4000g)', color: '#ea580c' };
|
|
}
|
|
|
|
function neoAssess() {
|
|
var weeks = parseInt(document.getElementById('neo-ga-weeks').value);
|
|
var days = parseInt(document.getElementById('neo-ga-days').value) || 0;
|
|
var weight = parseFloat(document.getElementById('neo-weight').value);
|
|
var sex = document.getElementById('neo-sex').value;
|
|
var resultDiv = document.getElementById('neo-result');
|
|
|
|
if (!weeks || !weight) {
|
|
resultDiv.innerHTML = '<p style="color:var(--red);font-size:13px;">Enter GA and birth weight.</p>';
|
|
return;
|
|
}
|
|
|
|
var gaDecimal = weeks + days / 7;
|
|
var gaClass = classifyGA(weeks, days);
|
|
var bwClass = classifyBirthWeight(weight);
|
|
|
|
// Calculate percentile using Fenton LMS
|
|
var lms = interpolateLMS(fentonLMS[sex], gaDecimal);
|
|
var z = calcZ(weight, lms.L, lms.M, lms.S);
|
|
var percentile = zToPercentile(z);
|
|
var wtClass = classifyWeight(percentile);
|
|
var expectedWeight = Math.round(lms.M);
|
|
|
|
var html = '';
|
|
|
|
// GA Classification
|
|
html += '<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px;">';
|
|
html += '<div style="flex:1;min-width:200px;padding:14px;border-radius:10px;background:' + gaClass.color + '10;border:1.5px solid ' + gaClass.color + '30;">';
|
|
html += '<div style="font-size:12px;color:var(--g500);margin-bottom:4px;">Gestational Age Classification</div>';
|
|
html += '<div style="font-size:18px;font-weight:700;color:' + gaClass.color + ';"><i class="fas ' + gaClass.icon + '"></i> ' + gaClass.label + '</div>';
|
|
html += '<div style="font-size:13px;color:var(--g600);margin-top:4px;">' + weeks + ' weeks ' + days + ' days (' + gaDecimal.toFixed(1) + ' weeks)</div>';
|
|
html += '</div>';
|
|
|
|
// Weight Classification (AGA/SGA/LGA)
|
|
html += '<div style="flex:1;min-width:200px;padding:14px;border-radius:10px;background:' + wtClass.color + '10;border:1.5px solid ' + wtClass.color + '30;">';
|
|
html += '<div style="font-size:12px;color:var(--g500);margin-bottom:4px;">Weight for Gestational Age</div>';
|
|
html += '<div style="font-size:18px;font-weight:700;color:' + wtClass.color + ';">' + wtClass.label + '</div>';
|
|
html += '<div style="font-size:13px;color:var(--g600);margin-top:4px;">' + percentile.toFixed(1) + 'th percentile (' + wtClass.detail + ')</div>';
|
|
html += '</div>';
|
|
html += '</div>';
|
|
|
|
// Birth Weight Category
|
|
html += '<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px;">';
|
|
html += '<div style="flex:1;min-width:200px;padding:14px;border-radius:10px;background:' + bwClass.color + '10;border:1.5px solid ' + bwClass.color + '30;">';
|
|
html += '<div style="font-size:12px;color:var(--g500);margin-bottom:4px;">Birth Weight Category</div>';
|
|
html += '<div style="font-size:18px;font-weight:700;color:' + bwClass.color + ';">' + bwClass.label + '</div>';
|
|
html += '<div style="font-size:13px;color:var(--g600);margin-top:4px;">' + weight + 'g (' + (weight/1000).toFixed(2) + ' kg)</div>';
|
|
html += '</div>';
|
|
|
|
// Stats
|
|
html += '<div style="flex:1;min-width:200px;padding:14px;border-radius:10px;background:var(--g50);border:1.5px solid var(--g200);">';
|
|
html += '<div style="font-size:12px;color:var(--g500);margin-bottom:4px;">Fenton Growth Data (' + (sex === 'male' ? 'Male' : 'Female') + ')</div>';
|
|
html += '<div style="font-size:13px;color:var(--g700);line-height:1.8;">';
|
|
html += '<strong>Expected weight (50th %ile):</strong> ' + expectedWeight + 'g<br>';
|
|
html += '<strong>Z-score:</strong> ' + z.toFixed(2) + '<br>';
|
|
html += '<strong>Percentile:</strong> ' + percentile.toFixed(1) + '%';
|
|
html += '</div></div>';
|
|
html += '</div>';
|
|
|
|
// Reference
|
|
html += '<p style="font-size:11px;color:var(--g400);margin:8px 0 0;">Fenton TR, Kim JH. A systematic review and meta-analysis to revise the Fenton growth chart for preterm infants. BMC Pediatrics 2013;13:59. GA classification per ACOG/AAP definitions.</p>';
|
|
|
|
resultDiv.innerHTML = html;
|
|
}
|
|
|
|
// ---- NRP pathway + drug calc -----------------------------------------
|
|
|
|
function renderPathway() {
|
|
var el = document.getElementById('nrp-pathway-static');
|
|
if (!el || el.dataset.rendered) return;
|
|
var html = '';
|
|
html += S.stepBox('#3b82f6', 'BIRTH — ASSESS (first 30 sec)', 'Term? Tone? Breathing/crying? If <strong>all yes</strong> → routine care with mother. If <strong>any no</strong> → warm, dry, stimulate, position airway, clear airway PRN, evaluate HR & respirations.');
|
|
html += S.arrow;
|
|
html += S.stepBox('#8b5cf6', 'HR < 100 OR apneic/gasping (60 sec)', '<strong>Start PPV</strong> 40-60 breaths/min, room air for term / 21-30% for preterm. Attach SpO2 (right hand) ± ECG. MR SOPA if ineffective: <em>M</em>ask adjust, <em>R</em>eposition, <em>S</em>uction, <em>O</em>pen mouth, <em>P</em>ressure increase, <em>A</em>lternative airway.');
|
|
html += S.arrow;
|
|
html += S.stepBox('#f59e0b', 'HR < 100 after 30 sec effective PPV', 'Reassess ventilation — ensure chest rise. Consider increasing FiO2, intubation, or LMA. Continue PPV.');
|
|
html += S.arrow;
|
|
html += S.stepBox('#ef4444', 'HR < 60 after 30 sec of effective PPV', '<strong>Intubate + Chest compressions</strong> — 3:1 ratio (90 compressions + 30 breaths per minute), FiO2 100%, lower 1/3 sternum, depth 1/3 AP chest.');
|
|
html += S.arrow;
|
|
html += S.stepBox('#dc2626', 'HR < 60 despite compressions + PPV x 60 sec', '<strong>Epinephrine</strong> 1:10,000 (0.1 mg/mL): <br>• <strong>IV/IO:</strong> 0.01-0.03 mg/kg (0.1-0.3 mL/kg) — preferred<br>• <strong>ETT:</strong> 0.05-0.1 mg/kg (0.5-1 mL/kg) — while IV being placed<br>Repeat q3-5 min. If hypovolemia: <strong>NS 10 mL/kg IV/IO</strong> over 5-10 min.');
|
|
html += '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:8px;margin-top:14px;">';
|
|
html += '<div style="padding:10px;background:var(--g50);border-radius:6px;font-size:12px;"><strong>Target SpO2 (preductal):</strong><br>1 min: 60-65%<br>2 min: 65-70%<br>3 min: 70-75%<br>4 min: 75-80%<br>5 min: 80-85%<br>10 min: 85-95%</div>';
|
|
html += '<div style="padding:10px;background:var(--g50);border-radius:6px;font-size:12px;"><strong>Initial ETT size:</strong><br><1 kg / <28 wk: <strong>2.5</strong><br>1-2 kg / 28-34 wk: <strong>3.0</strong><br>2-3 kg / 34-38 wk: <strong>3.5</strong><br>>3 kg / >38 wk: <strong>3.5-4.0</strong></div>';
|
|
html += '<div style="padding:10px;background:var(--g50);border-radius:6px;font-size:12px;"><strong>ETT depth (lip):</strong><br>~ 6 + weight(kg) cm</div>';
|
|
html += '</div>';
|
|
el.innerHTML = html;
|
|
el.dataset.rendered = '1';
|
|
}
|
|
|
|
function calcNRP() {
|
|
var wt = parseFloat(document.getElementById('nrp-weight').value);
|
|
var el = document.getElementById('nrp-result');
|
|
if (!wt) { el.innerHTML = '<p style="color:var(--red);font-size:13px;">Enter weight (kg).</p>'; return; }
|
|
var epiIvLow = Math.round(wt * 0.01 * 100) / 100;
|
|
var epiIvHigh = Math.round(wt * 0.03 * 100) / 100;
|
|
var epiEtLow = Math.round(wt * 0.05 * 100) / 100;
|
|
var epiEtHigh = Math.round(wt * 0.1 * 100) / 100;
|
|
var ns = Math.round(wt * 10);
|
|
var d10 = Math.round(wt * 2 * 10) / 10;
|
|
var html = '<div style="padding:10px 14px;border-radius:8px;background:var(--red)10;border:1.5px solid var(--red);margin-bottom:10px;"><strong style="color:var(--red);">NRP doses — ' + wt + ' kg</strong></div>';
|
|
var pk = function(txt) { return ' <span style="color:var(--g500);font-size:11px;">(' + txt + ')</span>'; };
|
|
html += S.drugTable(
|
|
S.drugRow('Epinephrine 1:10,000', epiIvLow + '-' + epiIvHigh + ' mg, ' + (Math.round(epiIvLow*10)/10) + '-' + (Math.round(epiIvHigh*10)/10) + ' mL' + pk('0.01-0.03 mg/kg = 0.1-0.3 mL/kg'), 'IV / IO', 'Preferred route. Repeat q3-5 min.') +
|
|
S.drugRow('Epinephrine 1:10,000', epiEtLow + '-' + epiEtHigh + ' mg, ' + (Math.round(epiEtLow*10)/10) + '-' + (Math.round(epiEtHigh*10)/10) + ' mL' + pk('0.05-0.1 mg/kg = 0.5-1 mL/kg'), 'ETT', 'While IV being placed.') +
|
|
S.drugRow('Normal saline', ns + ' mL' + pk('10 mL/kg'), 'IV / IO', 'Over 5-10 min for volume. Repeat PRN.') +
|
|
S.drugRow('Dextrose 10%', d10 + ' mL' + pk('2 mL/kg = 0.2 g/kg'), 'IV slow push', 'For documented hypoglycemia. Then D10 infusion 4-6 mg/kg/min.')
|
|
);
|
|
html += '<div style="padding:10px;background:#fef3c7;border-radius:6px;font-size:12px;color:#92400e;"><strong>Concentration note:</strong> NRP uses epinephrine <strong>1:10,000</strong> (0.1 mg/mL). NOT 1:1000 (1 mg/mL) — that is IM for anaphylaxis / older patients.</div>';
|
|
html += S.ref('AHA/AAP Neonatal Resuscitation Program (NRP) 8th edition, 2020.');
|
|
el.innerHTML = html;
|
|
}
|
|
|
|
// ---- Apgar -----------------------------------------------------------
|
|
|
|
function calcApgar() {
|
|
var keys = ['appearance','pulse','grimace','activity','respiration'];
|
|
var total = keys.reduce(function(s, k) {
|
|
return s + (parseInt(document.getElementById('apgar-' + k).value) || 0);
|
|
}, 0);
|
|
var el = document.getElementById('apgar-result');
|
|
var color, severity, guidance;
|
|
if (total >= 7) {
|
|
color = '#10b981'; severity = 'Reassuring';
|
|
guidance = 'Routine newborn care. Continue reassessment. Repeat at 5 min.';
|
|
} else if (total >= 4) {
|
|
color = '#f59e0b'; severity = 'Moderately depressed';
|
|
guidance = 'Stimulate, clear airway, warm. Give O2 if cyanotic. Ventilate with PPV if HR <100 or apneic/gasping. Reassess q30 sec.';
|
|
} else {
|
|
color = '#ef4444'; severity = 'Severely depressed';
|
|
guidance = 'Full NRP pathway — PPV immediately. Intubate if PPV ineffective. Chest compressions if HR <60. Epinephrine and volume per NRP.';
|
|
}
|
|
var html = '<div style="padding:10px 14px;border-radius:8px;background:' + color + '10;border:1.5px solid ' + color + ';">';
|
|
html += '<strong style="color:' + color + ';font-size:16px;">Apgar: ' + total + '/10 — ' + severity + '</strong>';
|
|
html += '<div style="font-size:12px;color:var(--g700);margin-top:6px;line-height:1.5;">' + guidance + '</div></div>';
|
|
html += '<p style="font-size:11px;color:var(--g400);margin-top:8px;font-style:italic;">Apgar is a description of status — <strong>never</strong> delay resuscitation while scoring. Follow NRP algorithm based on HR and breathing. Apgar <7 at 5 min: repeat q5 min up to 20 min.</p>';
|
|
el.innerHTML = html;
|
|
}
|
|
|
|
export function init() {
|
|
// Neonatal assessment
|
|
document.addEventListener('click', function(e) {
|
|
if (e.target.id === 'btn-neo-assess' || e.target.closest('#btn-neo-assess')) neoAssess();
|
|
});
|
|
|
|
// NRP pathway render + calc
|
|
document.addEventListener('DOMContentLoaded', renderPathway);
|
|
document.addEventListener('tabChanged', function() { setTimeout(renderPathway, 100); });
|
|
if (document.readyState !== 'loading') setTimeout(renderPathway, 0);
|
|
|
|
document.addEventListener('click', function(e) {
|
|
if (e.target.id === 'btn-nrp-calc' || e.target.closest('#btn-nrp-calc')) calcNRP();
|
|
// Render NRP pathway when Neonatal sub-pill is clicked (in case component loaded late)
|
|
var np = e.target.closest && e.target.closest('[data-em="neonatal"]');
|
|
if (np) setTimeout(renderPathway, 0);
|
|
var nav = e.target.closest && e.target.closest('[data-calc="bedside"]');
|
|
if (nav) setTimeout(renderPathway, 50);
|
|
});
|
|
|
|
// Apgar
|
|
document.addEventListener('click', function(e) {
|
|
if (e.target.id === 'btn-apgar-calc' || e.target.closest('#btn-apgar-calc')) calcApgar();
|
|
});
|
|
}
|