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.
206 lines
8.2 KiB
JavaScript
206 lines
8.2 KiB
JavaScript
// ============================================================
|
||
// 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;
|
||
})();
|