pediatric-ai-scribe-v3/public/js/calc-math.js
Daniel ea3a3533e6 feat: Bedside clinical reference module + age→weight estimator + dose-math unit tests
Calculators:
- Bedside tab consolidating emergency protocols (Neonatal+Apgar+NRP, Airway/RSI,
  Cardiac Arrest/PALS, Respiratory, O2 & Ventilation, Status Epilepticus,
  Sepsis & Fever with PECARN/Aronson/Rochester/Step-by-Step, Anaphylaxis,
  Procedural Sedation, Agitation, Antiemetics, Antimicrobials, Burns with
  Lund-Browder body-parts TBSA + Parkland, Toxicology, Trauma).
- Global age→weight estimator at top of Calculators tab (APLS + Best Guess).
- Pressure-time waveform SVG teaching graphic for Ventilation.
- Algorithm image lightbox (fullscreen, Esc/tap-to-close).
- Every weight-based dose shows mg/kg inline for clinician verification.
- Drug tables wrapped in overflow-x:auto for mobile.

Infrastructure:
- Pure dose math extracted to public/js/calc-math.js (dual-export Node+browser).
- 36 unit tests in test/calc-math.test.js via node:test (zero new deps).
- "npm test" added to package.json.
2026-04-20 02:49:42 +02:00

206 lines
8.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ============================================================
// calc-math.js — Pure, side-effect-free dose / score math.
// Dual-exported: browser (window._PED_MATH) + Node (module.exports).
// Keep this file free of DOM references so it can be unit-tested
// under Node with `node --test test/`.
// ============================================================
(function() {
function roundTo(v, places) { var m = Math.pow(10, places); return Math.round(v * m) / m; }
// ── Age → Weight ────────────────────────────────────────────
// APLS (Luscombe & Owens 2007 / APLS-UK 2016) and Best Guess
// (Tinning & Acworth 2007). For ages ≥13 yr we use Best Guess
// as primary; otherwise APLS.
function estimateWeightFromAgeMonths(months) {
if (months == null || isNaN(months) || months < 0) return null;
var years = months / 12;
var apls;
if (months < 12) apls = 0.5 * months + 4;
else if (years <= 5) apls = 2 * years + 8;
else apls = 3 * years + 7;
var bg;
if (months < 12) bg = (months + 9) / 2;
else if (years <= 5) bg = 2 * (years + 5);
else bg = 4 * years;
var primary = years < 13 ? apls : bg;
var bandLabel;
if (years < 13) {
if (months < 12) bandLabel = 'APLS 0-12 mo';
else if (years <= 5) bandLabel = 'APLS 1-5 yr';
else bandLabel = 'APLS 6-12 yr';
} else {
bandLabel = 'Best Guess 5-14 yr';
}
return {
weight: Math.max(0.3, roundTo(primary, 1)),
formulaLabel: bandLabel,
all: { apls: roundTo(apls, 1), bestGuess: roundTo(bg, 1) }
};
}
// ── Parkland fluid resuscitation (burn) ─────────────────────
// 4 mL × weight(kg) × %TBSA over 24 h; half in first 8 h.
function parkland(weightKg, tbsaPct) {
if (!weightKg || weightKg <= 0 || !tbsaPct || tbsaPct <= 0) return null;
var total = 4 * weightKg * tbsaPct;
var first8 = total / 2;
var next16 = total - first8;
return {
totalMl: Math.round(total),
first8Ml: Math.round(first8),
next16Ml: Math.round(next16),
rateFirst8: Math.round(first8 / 8),
rateNext16: Math.round(next16 / 16)
};
}
// ── Maintenance fluids (Holliday-Segar 4-2-1 rule) ─────────
function maintenance421(weightKg) {
if (!weightKg || weightKg <= 0) return null;
if (weightKg <= 10) return Math.round(weightKg * 4);
if (weightKg <= 20) return Math.round(40 + (weightKg - 10) * 2);
return Math.round(60 + (weightKg - 20));
}
// ── PRAM score (asthma, 0-12) ───────────────────────────────
function pramScore(spo2, retractions, scalene, airEntry, wheeze) {
var total = [spo2, retractions, scalene, airEntry, wheeze]
.reduce(function(s, v) { return s + (parseInt(v, 10) || 0); }, 0);
var severity = total <= 3 ? 'Mild' : total <= 7 ? 'Moderate' : 'Severe';
return { total: total, severity: severity };
}
// ── Westley croup score (0-17) ──────────────────────────────
function westleyScore(conscious, cyanosis, stridor, airEntry, retractions) {
var total = [conscious, cyanosis, stridor, airEntry, retractions]
.reduce(function(s, v) { return s + (parseInt(v, 10) || 0); }, 0);
var severity;
if (total <= 2) severity = 'Mild';
else if (total <= 5) severity = 'Moderate';
else if (total <= 11) severity = 'Severe';
else severity = 'Impending respiratory failure';
return { total: total, severity: severity };
}
// ── Epinephrine: anaphylaxis IM vs arrest IV ────────────────
// IMPORTANT: different concentrations (1:1,000 vs 1:10,000).
function epiAnaphylaxisIM(weightKg) {
if (!weightKg || weightKg <= 0) return null;
var mg = Math.min(weightKg * 0.01, 0.5);
return { doseMg: roundTo(mg, 2), volumeMl_1in1000: roundTo(mg, 2) };
}
function epiArrestIV(weightKg) {
if (!weightKg || weightKg <= 0) return null;
var mg = Math.min(weightKg * 0.01, 1);
return { doseMg: roundTo(mg, 2), volumeMl_1in10000: roundTo(mg * 10, 2) };
}
// ── NRP epinephrine (neonatal resus) ────────────────────────
// IV/IO 0.01-0.03 mg/kg = 0.1-0.3 mL/kg of 1:10,000.
function nrpEpiIV(weightKg) {
if (!weightKg || weightKg <= 0) return null;
return {
lowMg: roundTo(weightKg * 0.01, 2),
highMg: roundTo(weightKg * 0.03, 2),
lowMl: roundTo(weightKg * 0.1, 2),
highMl: roundTo(weightKg * 0.3, 2)
};
}
// ── RSI drug doses (selected) ───────────────────────────────
function rsiKetamine(weightKg) {
if (!weightKg) return null;
return { lowMg: Math.min(roundTo(weightKg * 1.5, 1), 150), highMg: Math.min(roundTo(weightKg * 2, 1), 200) };
}
function rsiRocuronium(weightKg) {
if (!weightKg) return null;
return { mg: Math.min(roundTo(weightKg * 1.2, 1), 100) };
}
function rsiSuccinylcholine(weightKg) {
if (!weightKg) return null;
var perKg = weightKg < 10 ? 2 : 1.5;
return { mg: Math.min(roundTo(weightKg * perKg, 1), 150), perKg: perKg };
}
// ── Minimum acceptable systolic BP (1-10 yr) ────────────────
function minSBP(years) {
if (years == null || isNaN(years)) return null;
if (years < 1) return 70;
if (years > 10) return 90;
return 70 + 2 * years;
}
// ── ETT (endotracheal tube) sizing by age ───────────────────
function ettSize(ageYears) {
if (ageYears == null || ageYears < 0) return null;
var uncuffed = ageYears / 4 + 4;
var cuffed = ageYears / 4 + 3.5;
return {
uncuffedMm: roundTo(uncuffed, 1),
cuffedMm: roundTo(cuffed, 1),
depthCm: roundTo(uncuffed * 3, 1)
};
}
// ── Lund-Browder regional TBSA by age bracket ───────────────
// Used for burns TBSA calculation. Bracket: 'infant', 'young',
// 'child', 'adol', 'adult' — per 0-1, 1-5, 5-10, 10-15, >15 yr.
var LB_BRACKETS = ['infant', 'young', 'child', 'adol', 'adult'];
var LB = {
head: [18, 13, 11, 9, 7],
neck: [2, 2, 2, 2, 2],
ant_trunk: [13, 13, 13, 13, 13],
post_trunk: [13, 13, 13, 13, 13],
r_buttock: [2.5, 2.5, 2.5, 2.5, 2.5],
l_buttock: [2.5, 2.5, 2.5, 2.5, 2.5],
genital: [1, 1, 1, 1, 1],
r_uparm: [4, 4, 4, 4, 4],
l_uparm: [4, 4, 4, 4, 4],
r_forearm: [3, 3, 3, 3, 3],
l_forearm: [3, 3, 3, 3, 3],
r_hand: [2.5, 2.5, 2.5, 2.5, 2.5],
l_hand: [2.5, 2.5, 2.5, 2.5, 2.5],
r_thigh: [5.5, 8, 8.5, 9, 9.5],
l_thigh: [5.5, 8, 8.5, 9, 9.5],
r_leg: [5, 5.5, 6, 6.5, 7],
l_leg: [5, 5.5, 6, 6.5, 7],
r_foot: [3.5, 3.5, 3.5, 3.5, 3.5],
l_foot: [3.5, 3.5, 3.5, 3.5, 3.5]
};
// involvements: { regionKey: percentOfRegionInvolved (0-100), ... }
function lundBrowderTBSA(ageBracket, involvements) {
var idx = LB_BRACKETS.indexOf(ageBracket);
if (idx < 0) return null;
var total = 0;
Object.keys(LB).forEach(function(key) {
var pct = (involvements && involvements[key]) || 0;
if (pct < 0) pct = 0; if (pct > 100) pct = 100;
total += LB[key][idx] * (pct / 100);
});
return roundTo(total, 1);
}
var API = {
roundTo: roundTo,
estimateWeightFromAgeMonths: estimateWeightFromAgeMonths,
parkland: parkland,
maintenance421: maintenance421,
pramScore: pramScore,
westleyScore: westleyScore,
epiAnaphylaxisIM: epiAnaphylaxisIM,
epiArrestIV: epiArrestIV,
nrpEpiIV: nrpEpiIV,
rsiKetamine: rsiKetamine,
rsiRocuronium: rsiRocuronium,
rsiSuccinylcholine: rsiSuccinylcholine,
minSBP: minSBP,
ettSize: ettSize,
lundBrowderTBSA: lundBrowderTBSA,
LB_BRACKETS: LB_BRACKETS,
LB: LB
};
if (typeof window !== 'undefined') window._PED_MATH = API;
if (typeof module !== 'undefined' && module.exports) module.exports = API;
})();