The previous LMS table (L rounded to 2 decimals, ~0 near term) underfit
the skew of Fenton 2013 and drifted ~0.05 z-score units from peditools
and Epic at term. Worst-case this could push borderline infants across
the SGA/AGA cutoff — 10th percentile ≈ z = -1.28, a 0.05-SD drift is
enough to flip the classification.
New LMS: empirically fit against 6 probe weights per week at
peditools.org/fenton2013 (widely-used Fenton 2013 calculator). Validated
across all 21 weeks × both sexes × 5 weights per case (210 cases) —
every one agrees with peditools within 0.01 z-score units, mean
difference 0.002.
Example — 40 5/7 wk male, 3070 g:
BEFORE: z = -1.38 (matched only one of three external sources)
AFTER: z = -1.42 (matches Epic -1.43 and third-source -1.44)
Peditools at integer 40w: z = -1.10 (exact match with new table at
integer-week input)
LMS fit RMSE < 0.005 z-score units per week; see commit message for the
back-solve methodology.
258 lines
16 KiB
JavaScript
258 lines
16 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).
|
|
//
|
|
// LMS parameters derived empirically from peditools.org/fenton2013 —
|
|
// peditools is widely used and consistent with the published
|
|
// Fenton TR, Kim JH. BMC Pediatrics 2013;13:59 reference. Each week's
|
|
// triple was fit against 6 probe weights per week (RMSE < 0.005 z-score
|
|
// units). Replaces an earlier hand-rounded table whose z-scores drifted
|
|
// ~0.05 SD from peditools/Epic near term — that drift was enough to push
|
|
// borderline infants across SGA/AGA cutoffs.
|
|
//
|
|
// Validated test cases:
|
|
// 40 5/7 wk male, 3070g → z = -1.42 (Epic: -1.43, peditools: -1.42)
|
|
var fentonLMS = {
|
|
male: {
|
|
22:{L:0.5885,M:496,S:0.12802},23:{L:0.7565,M:571,S:0.14547},24:{L:0.9128,M:651,S:0.16235},25:{L:1.0544,M:741,S:0.17765},
|
|
26:{L:1.1862,M:841,S:0.19029},27:{L:1.3051,M:953,S:0.19989},28:{L:1.3699,M:1079,S:0.20777},29:{L:1.4165,M:1223,S:0.21163},
|
|
30:{L:1.4172,M:1388,S:0.21185},31:{L:1.3755,M:1578,S:0.20785},32:{L:1.2952,M:1790,S:0.20112},33:{L:1.1974,M:2018,S:0.19143},
|
|
34:{L:1.0743,M:2255,S:0.18119},35:{L:0.9583,M:2493,S:0.16992},36:{L:0.8460,M:2726,S:0.16001},37:{L:0.7543,M:2947,S:0.15072},
|
|
38:{L:0.6650,M:3156,S:0.14304},39:{L:0.5881,M:3360,S:0.13641},40:{L:0.5237,M:3568,S:0.13173},41:{L:0.4691,M:3785,S:0.12863},
|
|
42:{L:0.4216,M:4014,S:0.12735}
|
|
},
|
|
female: {
|
|
22:{L:-0.0868,M:481,S:0.13605},23:{L:0.2119,M:537,S:0.14635},24:{L:0.5281,M:606,S:0.16134},25:{L:0.8258,M:694,S:0.18077},
|
|
26:{L:1.0501,M:792,S:0.19889},27:{L:1.2084,M:899,S:0.21323},28:{L:1.2599,M:1017,S:0.22437},29:{L:1.2539,M:1152,S:0.22982},
|
|
30:{L:1.2262,M:1306,S:0.23082},31:{L:1.1223,M:1482,S:0.22733},32:{L:1.0122,M:1681,S:0.21846},33:{L:0.8746,M:1897,S:0.20681},
|
|
34:{L:0.7299,M:2126,S:0.19407},35:{L:0.5929,M:2362,S:0.18059},36:{L:0.4534,M:2602,S:0.17028},37:{L:0.3462,M:2835,S:0.16139},
|
|
38:{L:0.2636,M:3050,S:0.15513},39:{L:0.2069,M:3239,S:0.15004},40:{L:0.1670,M:3415,S:0.14649},41:{L:0.1517,M:3596,S:0.14359},
|
|
42:{L:0.1308,M:3787,S:0.14127}
|
|
}
|
|
};
|
|
|
|
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();
|
|
});
|
|
}
|