feat(client): port BMI + Vitals + Resus Meds + Equipment calculators
Nine of the ten Calculator pills now run in React. Only BP Percentile
(Rosner quantile splines, ~3,000 hand-transcribed coefficients across
6 LMS arrays and 4 spline matrices) remains legacy-linked; that port
deserves its own dedicated session with extra care.
shared/clinical/bmi.ts — CDC 2000 BMI-for-age
• bmiLMS table ported byte-for-byte from calculators.js:739
(74 LMS triples = 37 age points × 2 sexes, 24-240 months).
• normalCDF (Abramowitz & Stegun), calcBmiPercentile, classifyBMI
(including the %-of-95th severe-obesity split), and a top-level
computeBmi helper — all verbatim translations of the vanilla math.
• classification labels preserved exactly so existing e2e screenshots
or reporting continue to read the same text ('Class 2 Severe
Obesity', 'Healthy Weight', etc.).
shared/clinical/bmi.test.ts — 12 captured vectors covering:
both sexes, edges (2y + 20y), interpolated-between-keys (13m),
each classification cliff (underweight / healthy / overweight /
obese / severe class 2 / severe class 3), and the age-clamping
branches (<24 mo and >240 mo). All 12 pass at 10-decimal precision
(percentile to 6 places since the vanilla rounds to 2).
scripts/capture-calc-vectors.js — BMI section added
Same pattern as bilirubin / Fenton: the vanilla data + math are
inlined verbatim, the script runs 12 chosen cases, and writes to
e2e/fixtures/calc-vectors.json. Re-run after any upstream change.
client/src/pages/CalculatorPanels.tsx (new)
• BmiPanel — age/sex/weight/height inputs, calls computeBmi,
renders color-coded classification badge with BMI, percentile,
Z, and % of 95th when percentile ≥85.
• VitalsPanel — 8-band age selector (premie → >12 yr).
VITALS_DATA ported verbatim from calculators.js:1703-1831 with
every HR/RR/SBP/DBP/temp/weight/SpO₂ range and clinical notes
preserved entry-for-entry.
• ResusPanel — weight input drives all 14 drugs (Adenosine,
Amiodarone, Atropine, CaCl, Ca-gluconate, Dextrose, Epi,
Hydrocortisone, Insulin, Lidocaine, Mg, Naloxone, NaHCO₃) with
calc() closures ported verbatim from RESUS_MEDS lines 1873-2050.
Category colors + labels preserved.
• EquipmentPanel — 9-band age selector (premie → 16+ yr).
EQUIP_DATA ported verbatim from calculators.js:2173-2228 with
12 equipment sizes per band (BVM, NPA, OPA, blade, ETT, LMA,
Glidescope, IV, CVL, NGT, chest tube, Foley).
client/src/pages/Calculators.tsx
Dispatch wires bmi → BmiPanel, vitals → VitalsPanel, resus →
ResusPanel, equipment → EquipmentPanel. PILLS flags all four as
ported: true. Only bp remains on LegacyPanel.
Backend tsc + client tsc + vite build + 122/122 vitest (19 calc +
70 bili + 21 fenton/neonatal + 12 bmi) all green.
This commit is contained in:
parent
57d6263daf
commit
7371cbf13a
11 changed files with 835 additions and 62 deletions
295
client/src/pages/CalculatorPanels.tsx
Normal file
295
client/src/pages/CalculatorPanels.tsx
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
// ============================================================
|
||||
// CALCULATOR PANELS — BMI / Vitals / Resus / Equipment.
|
||||
// Data ported VERBATIM from public/js/calculators.js:
|
||||
// • VITALS_DATA lines 1703-1831
|
||||
// • RESUS_MEDS lines 1873-2050
|
||||
// • EQUIP_DATA lines 2173-2228
|
||||
// BMI math + LMS table live in shared/clinical/bmi.ts, verified
|
||||
// byte-for-byte by calc-vectors.json (12 BMI cases).
|
||||
// ============================================================
|
||||
|
||||
import { useState } from 'react';
|
||||
import { computeBmi } from '@shared/clinical/bmi';
|
||||
import type { Sex } from '@shared/clinical/fenton';
|
||||
|
||||
const card = 'rounded-lg border border-border bg-card p-5 space-y-3';
|
||||
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring';
|
||||
const label = 'block text-xs font-medium text-muted-foreground';
|
||||
const btnPrimary = 'rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium';
|
||||
const btnGhost = 'rounded-md border border-border bg-background px-4 py-2 text-sm font-medium hover:bg-muted';
|
||||
const errorBox = 'rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-200';
|
||||
|
||||
// ── BMI ─────────────────────────────────────────────────────
|
||||
export function BmiPanel() {
|
||||
const [ageYr, setAgeYr] = useState('');
|
||||
const [ageMo, setAgeMo] = useState('');
|
||||
const [sex, setSex] = useState<Sex>('male');
|
||||
const [weight, setWeight] = useState('');
|
||||
const [height, setHeight] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [result, setResult] = useState<ReturnType<typeof computeBmi> | null>(null);
|
||||
|
||||
function calc() {
|
||||
const yr = Number.parseFloat(ageYr) || 0;
|
||||
const mo = Number.parseInt(ageMo, 10) || 0;
|
||||
const age = yr + mo / 12;
|
||||
const w = Number.parseFloat(weight);
|
||||
const h = Number.parseFloat(height);
|
||||
if (!age || !Number.isFinite(w) || w <= 0 || !Number.isFinite(h) || h <= 0) {
|
||||
setError('Fill in all fields.'); setResult(null); return;
|
||||
}
|
||||
if (age < 2 || age > 20) { setError('Age must be 2-20 years.'); setResult(null); return; }
|
||||
setError('');
|
||||
setResult(computeBmi(w, h, Math.round(age * 12), sex));
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="calc-panel-bmi">
|
||||
<h2 className="text-lg font-semibold">BMI Percentile (CDC 2000)</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="grid grid-cols-2 gap-2 sm:col-span-1">
|
||||
<div><label className={label}>Age (yr)</label><input type="number" min="2" max="20" step="0.1" className={input} value={ageYr} onChange={(e) => setAgeYr(e.target.value)} data-testid="bmi-age-yr" /></div>
|
||||
<div><label className={label}>Months</label><input type="number" min="0" max="11" className={input} value={ageMo} onChange={(e) => setAgeMo(e.target.value)} data-testid="bmi-age-mo" /></div>
|
||||
</div>
|
||||
<div><label className={label}>Sex</label><select className={input} value={sex} onChange={(e) => setSex(e.target.value as Sex)} data-testid="bmi-sex"><option value="male">Male</option><option value="female">Female</option></select></div>
|
||||
<div><label className={label}>Weight (kg)</label><input type="number" min="1" max="200" step="0.1" className={input} value={weight} onChange={(e) => setWeight(e.target.value)} data-testid="bmi-weight" /></div>
|
||||
<div className="sm:col-span-1"><label className={label}>Height (cm)</label><input type="number" min="50" max="220" step="0.1" className={input} value={height} onChange={(e) => setHeight(e.target.value)} data-testid="bmi-height" /></div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={calc} className={btnPrimary} data-testid="calc-bmi-calculate">Calculate</button>
|
||||
<button type="button" onClick={() => { setAgeYr(''); setAgeMo(''); setWeight(''); setHeight(''); setResult(null); setError(''); }} className={btnGhost}>Clear</button>
|
||||
</div>
|
||||
{error && <div className={errorBox}>{error}</div>}
|
||||
{result && (
|
||||
<div
|
||||
className="rounded-lg p-4 space-y-2"
|
||||
style={{ background: result.classification.bg, borderLeft: `4px solid ${result.classification.color}` }}
|
||||
data-testid="calc-bmi-result"
|
||||
>
|
||||
<div className="text-base font-bold" style={{ color: result.classification.color }}>{result.classification.label}</div>
|
||||
<div className="text-sm">BMI {result.bmi.toFixed(1)} kg/m² — {result.percentile}th percentile</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm">
|
||||
<div><span className="text-xs uppercase text-muted-foreground">BMI</span><div className="font-semibold">{result.bmi.toFixed(1)}</div></div>
|
||||
<div><span className="text-xs uppercase text-muted-foreground">Percentile</span><div className="font-semibold">{result.percentile}th</div></div>
|
||||
<div><span className="text-xs uppercase text-muted-foreground">Z-Score</span><div className="font-semibold">{result.z.toFixed(2)}</div></div>
|
||||
{result.percentile >= 85 && <div><span className="text-xs uppercase text-muted-foreground">% of 95th</span><div className="font-semibold">{result.classification.pctOf95.toFixed(0)}%</div></div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground italic">CDC 2000 LMS tables · Kuczmarski et al. Vital Health Stat 11. 2002;(246).</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Vitals ──────────────────────────────────────────────────
|
||||
// Data ported verbatim from calculators.js:1703-1831.
|
||||
interface VitalsEntry {
|
||||
label: string;
|
||||
hr: { awake: string; sleeping: string };
|
||||
rr: string;
|
||||
sbp: string;
|
||||
dbp: string;
|
||||
temp: string;
|
||||
weight: string;
|
||||
spo2: string;
|
||||
notes: string[];
|
||||
}
|
||||
const VITALS_DATA: Record<string, VitalsEntry> = {
|
||||
premie: { label: 'Premie', hr: { awake: '120-170', sleeping: '100-150' }, rr: '40-70', sbp: '55-75', dbp: '35-45', temp: '36.5-37.5', weight: '0.5-2.5 kg', spo2: '88-95% (target)',
|
||||
notes: ['HR and RR are highly variable and depend on gestational age', 'BP increases with gestational age and postnatal age', 'Target SpO2 88-95% to reduce retinopathy of prematurity risk', 'Temperature instability is common — use servo-controlled warmers', 'Bradycardia (<100 bpm) and apnea are common in premature infants'] },
|
||||
'0-3mo': { label: '0-3 Months', hr: { awake: '100-150', sleeping: '85-135' }, rr: '35-55', sbp: '65-85', dbp: '45-55', temp: '36.5-37.5', weight: '2.5-6 kg', spo2: '>95%',
|
||||
notes: ['HR normally increases with crying (up to 180-190 bpm) — this is physiologic', 'Periodic breathing (pauses <10 sec) is normal in neonates', 'Acrocyanosis (blue hands/feet) is normal; central cyanosis is not', 'BP is best measured in the right arm (pre-ductal) in neonates', 'Normal weight loss of 5-7% in first 3-5 days; regain by 10-14 days'] },
|
||||
'3-6mo': { label: '3-6 Months', hr: { awake: '90-120', sleeping: '75-110' }, rr: '30-45', sbp: '70-90', dbp: '50-65', temp: '36.5-37.5', weight: '5-8 kg', spo2: '>95%',
|
||||
notes: ['Expected weight gain: 20-30 g/day (150-200 g/week)', 'HR gradually decreases as vagal tone matures', 'RR >60 at rest may indicate lower respiratory tract disease', 'BP should be measured with appropriate cuff size (width 40% of arm circumference)'] },
|
||||
'6-12mo': { label: '6-12 Months', hr: { awake: '80-120', sleeping: '70-110' }, rr: '25-40', sbp: '80-100', dbp: '55-65', temp: '36.0-37.5', weight: '8-10 kg', spo2: '>95%',
|
||||
notes: ['Expected weight: triple birth weight by 12 months (~10 kg average)', 'Weight gain slows to ~10-15 g/day', 'Sinus arrhythmia (HR varies with breathing) is normal', 'Febrile tachycardia: HR increases ~10 bpm per 1 degree C above 37'] },
|
||||
'1-3yr': { label: '1-3 Years', hr: { awake: '70-110', sleeping: '60-100' }, rr: '20-30', sbp: '90-105', dbp: '55-70', temp: '36.0-37.5', weight: '10-15 kg', spo2: '>95%',
|
||||
notes: ['Expected weight gain: ~200-250 g/month (2-2.5 kg/year)', 'Tachycardia: HR >110 at rest warrants evaluation', 'Tachypnea: RR >30 at rest may indicate respiratory distress', 'BP screening begins at age 3 per AAP 2017 guidelines', 'Estimated weight: 2 x (age in years) + 8'] },
|
||||
'3-6yr': { label: '3-6 Years', hr: { awake: '65-110', sleeping: '55-100' }, rr: '20-25', sbp: '95-110', dbp: '60-75', temp: '36.0-37.5', weight: '14-20 kg', spo2: '>95%',
|
||||
notes: ['Annual BP screening recommended from age 3', 'Normal BP <90th percentile for age, sex, and height', 'Elevated BP: 90th to <95th percentile (or 120/80 if lower)', 'Estimated weight: 2 x (age in years) + 8', 'ETT size (uncuffed): (age/4) + 4'] },
|
||||
'6-12yr': { label: '6-12 Years', hr: { awake: '60-95', sleeping: '50-85' }, rr: '14-22', sbp: '100-120', dbp: '60-75', temp: '36.0-37.5', weight: '20-40 kg', spo2: '>95%',
|
||||
notes: ['Resting HR >95 or <60 warrants evaluation', 'BP should be measured at every clinical encounter', 'Stage 1 HTN: >=95th percentile on 3 separate occasions', 'Estimated weight: 3 x (age in years) + 7', 'ETT size (cuffed): (age/4) + 3.5'] },
|
||||
'>12yr': { label: '>12 Years', hr: { awake: '55-85', sleeping: '45-75' }, rr: '12-18', sbp: '110-135', dbp: '65-85', temp: '36.0-37.5', weight: '40-80 kg', spo2: '>95%',
|
||||
notes: ['Vital signs approach adult values', 'From age 13: use adult BP thresholds (AAP 2017)', 'Normal: <120/<80 mmHg; Elevated: 120-129/<80 mmHg', 'Stage 1 HTN: 130-139/80-89 mmHg; Stage 2 HTN: >=140/>=90 mmHg', 'Orthostatic vitals: measure lying, sitting, standing if dizzy', 'Athletic bradycardia (HR 45-60) may be normal in trained adolescents'] },
|
||||
};
|
||||
const VITALS_ORDER = ['premie', '0-3mo', '3-6mo', '6-12mo', '1-3yr', '3-6yr', '6-12yr', '>12yr'];
|
||||
|
||||
export function VitalsPanel() {
|
||||
const [key, setKey] = useState<string>('1-3yr');
|
||||
const v = VITALS_DATA[key];
|
||||
return (
|
||||
<section className={card} data-testid="calc-panel-vitals">
|
||||
<h2 className="text-lg font-semibold">Vital Signs by Age</h2>
|
||||
<div className="max-w-xs">
|
||||
<label className={label}>Age group</label>
|
||||
<select className={input} value={key} onChange={(e) => setKey(e.target.value)} data-testid="vitals-age-select">
|
||||
{VITALS_ORDER.map((k) => <option key={k} value={k}>{VITALS_DATA[k].label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm" data-testid="vitals-result">
|
||||
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">Heart rate (awake)</div><div className="font-semibold">{v.hr.awake} bpm</div></div>
|
||||
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">Heart rate (sleep)</div><div className="font-semibold">{v.hr.sleeping} bpm</div></div>
|
||||
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">Respiratory rate</div><div className="font-semibold">{v.rr} /min</div></div>
|
||||
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">SpO₂</div><div className="font-semibold">{v.spo2}</div></div>
|
||||
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">SBP</div><div className="font-semibold">{v.sbp} mmHg</div></div>
|
||||
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">DBP</div><div className="font-semibold">{v.dbp} mmHg</div></div>
|
||||
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">Temperature</div><div className="font-semibold">{v.temp} °C</div></div>
|
||||
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">Weight</div><div className="font-semibold">{v.weight}</div></div>
|
||||
</div>
|
||||
<div className="rounded-md bg-blue-50 dark:bg-blue-950/30 p-3 text-xs">
|
||||
<div className="font-semibold mb-1">Clinical notes</div>
|
||||
<ul className="list-disc pl-5 space-y-0.5">{v.notes.map((n, i) => <li key={i}>{n}</li>)}</ul>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground italic">Harriet Lane Handbook 23rd ed · PALS · AAP 2017 BP guidelines.</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Resus Meds ──────────────────────────────────────────────
|
||||
// Data + math ported verbatim from calculators.js:1873-2050.
|
||||
interface ResusResult { dose: string; extra: string; max: string }
|
||||
interface ResusMed { name: string; indication: string; category: 'cardiac' | 'metabolic' | 'reversal'; route: string; calc: (w: number) => ResusResult }
|
||||
const RESUS_MEDS: ResusMed[] = [
|
||||
{ name: 'Adenosine', indication: 'SVT', category: 'cardiac', route: 'IV/IO rapid bolus',
|
||||
calc: (w) => { const d1 = +(w * 0.1).toFixed(2); const d2 = +(w * 0.2).toFixed(2); const d3 = +(w * 0.3).toFixed(2); return { dose: `${d1} mg (0.1 mg/kg)`, extra: `May repeat: ${Math.min(d2, 12)} mg (0.2 mg/kg), then ${Math.min(d3, 12)} mg (0.3 mg/kg)`, max: 'Max first dose 6 mg, max subsequent 12 mg' }; } },
|
||||
{ name: 'Amiodarone', indication: 'VT / VF', category: 'cardiac', route: 'IV/IO',
|
||||
calc: (w) => { const d = +(w * 5).toFixed(1); return { dose: `${Math.min(d, 300)} mg (5 mg/kg)`, extra: 'No pulse: push undiluted. Pulse: over 20-60 min. Subsequent max 150 mg.', max: 'Max first 300 mg, max total 15 mg/kg/24hr or 2200 mg' }; } },
|
||||
{ name: 'Atropine', indication: 'Bradycardia', category: 'cardiac', route: 'IV/IO/IM',
|
||||
calc: (w) => { const d = +(w * 0.02).toFixed(3); const ett = `${(w * 0.04).toFixed(3)}-${(w * 0.06).toFixed(3)}`; return { dose: `${Math.min(d, 0.5)} mg (0.02 mg/kg)`, extra: `ETT dose: ${ett} mg (0.04-0.06 mg/kg)`, max: 'Max single 0.5 mg, max total 1 mg' }; } },
|
||||
{ name: 'Calcium Chloride 10%', indication: 'Hypocalcemia / Hyperkalemia', category: 'metabolic', route: 'IV/IO',
|
||||
calc: (w) => { const d = +(w * 20).toFixed(0); return { dose: `${Math.min(d, 1000)} mg (20 mg/kg)`, extra: 'Give slowly. Central line preferred.', max: 'Max 1 g (1000 mg)' }; } },
|
||||
{ name: 'Calcium Gluconate 10%', indication: 'Hypocalcemia / Hyperkalemia', category: 'metabolic', route: 'IV/IO',
|
||||
calc: (w) => { const d = +(w * 60).toFixed(0); return { dose: `${Math.min(d, 3000)} mg (60 mg/kg)`, extra: 'Give slowly over 10-20 min with cardiac monitoring.', max: 'Max 3 g (3000 mg)' }; } },
|
||||
{ name: 'Dextrose', indication: 'Hypoglycemia', category: 'metabolic', route: 'IV',
|
||||
calc: (w) => {
|
||||
const grams = `${+(w * 0.5).toFixed(1)}-${+(w * 1).toFixed(1)}`;
|
||||
let detail = '';
|
||||
if (w < 5) detail = `D10W: ${(w * 5).toFixed(1)}-${(w * 10).toFixed(1)} mL (5-10 mL/kg)`;
|
||||
else if (w < 45) detail = `D25W: ${(w * 2).toFixed(1)}-${(w * 4).toFixed(1)} mL (2-4 mL/kg)`;
|
||||
else detail = `D50W: ${(w * 1).toFixed(1)}-${(w * 2).toFixed(1)} mL (1-2 mL/kg)`;
|
||||
return { dose: `${grams} g (0.5-1 g/kg)`, extra: detail, max: 'Max 25 g' };
|
||||
} },
|
||||
{ name: 'Epinephrine', indication: 'Pulseless arrest / Anaphylaxis', category: 'cardiac', route: 'IV/IO/IM/ETT',
|
||||
calc: (w) => { const iv = +(w * 0.01).toFixed(3); const ivVol = +(w * 0.1).toFixed(2); const ett = +(w * 0.1).toFixed(2); const im = +(w * 0.01).toFixed(3);
|
||||
return { dose: `${Math.min(iv, 1)} mg IV/IO (0.01 mg/kg of 0.1 mg/mL = ${Math.min(ivVol, 10)} mL) q3-5 min`, extra: `ETT: ${Math.min(ett, 2.5)} mg (0.1 mg/kg of 1 mg/mL). Anaphylaxis IM: ${Math.min(im, 0.5)} mg (0.01 mg/kg)`, max: 'Max IV 1 mg, max ETT 2.5 mg, max IM 0.5 mg' }; } },
|
||||
{ name: 'Hydrocortisone', indication: 'Adrenal crisis', category: 'metabolic', route: 'IV/IM/IO',
|
||||
calc: (w) => { const d = +(w * 2).toFixed(1); return { dose: `${Math.min(d, 100)} mg (2 mg/kg)`, extra: 'Stress dosing for adrenal insufficiency.', max: 'Max 100 mg' }; } },
|
||||
{ name: 'Insulin (Regular)', indication: 'Hyperkalemia', category: 'metabolic', route: 'IV',
|
||||
calc: (w) => { const d = +(w * 0.1).toFixed(2); const dex = +(w * 0.5).toFixed(1); return { dose: `${Math.min(d, 5)} units (0.1 units/kg)`, extra: `Give with ${dex} g/kg dextrose (0.5 g/kg). Monitor glucose closely.`, max: 'Max 5 units' }; } },
|
||||
{ name: 'Lidocaine', indication: 'Antiarrhythmic', category: 'cardiac', route: 'IV/IO',
|
||||
calc: (w) => { const d = +(w * 1).toFixed(1); const ett = `${(w * 2).toFixed(1)}-${(w * 3).toFixed(1)}`; return { dose: `${Math.min(d, 100)} mg (1 mg/kg)`, extra: `ETT: ${ett} mg (2-3 mg/kg). May repeat q5 min.`, max: 'Max 100 mg/dose, max total 3 mg/kg' }; } },
|
||||
{ name: 'Magnesium Sulfate', indication: 'Torsades de Pointes', category: 'cardiac', route: 'IV/IO',
|
||||
calc: (w) => { const d = +(w * 50).toFixed(0); return { dose: `${Math.min(d, 2000)} mg (50 mg/kg)`, extra: 'Give over 10-20 min (faster if pulseless).', max: 'Max 2 g (2000 mg)' }; } },
|
||||
{ name: 'Naloxone', indication: 'Opioid overdose', category: 'reversal', route: 'IV/IO/IM/IN/ETT',
|
||||
calc: (w) => { const partial = `${+(w * 0.001).toFixed(4)}-${+(w * 0.005).toFixed(4)}`; const full = +(w * 0.1).toFixed(3); return { dose: `Partial: ${partial} mg (0.001-0.005 mg/kg)`, extra: `Full reversal: ${Math.min(full, 2)} mg (0.1 mg/kg)`, max: 'Max partial first dose 0.1 mg, max full 2 mg' }; } },
|
||||
{ name: 'Sodium Bicarbonate', indication: 'Metabolic acidosis', category: 'metabolic', route: 'IV/IO',
|
||||
calc: (w) => { const d = +(w * 1).toFixed(1); return { dose: `${Math.min(d, 50)} mEq (1 mEq/kg)`, extra: w < 10 ? 'Dilute to 0.5 mEq/mL (use 4.2% solution) for neonates/small infants.' : 'Use 8.4% solution (1 mEq/mL).', max: 'Max 50 mEq' }; } },
|
||||
];
|
||||
const catColor: Record<ResusMed['category'], string> = { cardiac: '#ef4444', metabolic: '#3b82f6', reversal: '#10b981' };
|
||||
const catLabel: Record<ResusMed['category'], string> = { cardiac: 'Cardiac', metabolic: 'Metabolic', reversal: 'Reversal' };
|
||||
|
||||
export function ResusPanel() {
|
||||
const [weight, setWeight] = useState('');
|
||||
const wt = Number.parseFloat(weight);
|
||||
const valid = Number.isFinite(wt) && wt > 0;
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="calc-panel-resus">
|
||||
<h2 className="text-lg font-semibold">Resus Medications</h2>
|
||||
<div className="max-w-xs">
|
||||
<label className={label}>Weight (kg)</label>
|
||||
<input type="number" min="0.5" max="100" step="0.1" className={input} value={weight} onChange={(e) => setWeight(e.target.value)} data-testid="resus-weight" />
|
||||
</div>
|
||||
{!valid ? <p className="text-xs text-destructive">Enter weight (kg) to see doses.</p> : (
|
||||
<>
|
||||
<div className="text-sm font-semibold">Doses for {wt} kg patient</div>
|
||||
<div className="flex gap-3 flex-wrap text-xs">
|
||||
{(['cardiac', 'metabolic', 'reversal'] as const).map((c) => (
|
||||
<span key={c} className="inline-flex items-center gap-1"><span className="w-2.5 h-2.5 rounded-full" style={{ background: catColor[c] }} />{catLabel[c]}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3" data-testid="resus-result">
|
||||
{RESUS_MEDS.map((med) => {
|
||||
const r = med.calc(wt);
|
||||
const color = catColor[med.category];
|
||||
return (
|
||||
<div key={med.name} className="rounded-lg border bg-card overflow-hidden" style={{ borderColor: color + '55' }}>
|
||||
<div className="px-3 py-2 border-b" style={{ background: color + '10', borderColor: color + '22' }}>
|
||||
<div className="text-sm font-bold" style={{ color }}>{med.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{med.indication}</div>
|
||||
</div>
|
||||
<div className="p-3 text-sm space-y-1">
|
||||
<div><strong>Dose:</strong> {r.dose}</div>
|
||||
<div className="text-xs text-muted-foreground">{r.extra}</div>
|
||||
<div className="text-xs text-muted-foreground"><strong>Max:</strong> {r.max}</div>
|
||||
<div className="text-xs text-muted-foreground"><strong>Route:</strong> {med.route}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="rounded-md bg-amber-50 dark:bg-amber-950/30 p-3 text-xs text-amber-900 dark:text-amber-100">
|
||||
<strong>Disclaimer:</strong> Always verify doses against institutional protocols and current guidelines.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Equipment ───────────────────────────────────────────────
|
||||
// Data ported verbatim from calculators.js:2173-2228.
|
||||
interface EquipEntry {
|
||||
label: string;
|
||||
bvm: string; nasal: string; oral: string; blade: string;
|
||||
ett: string; lma: string; glidescope: string;
|
||||
iv: string; cvl: string; ngt: string; chest: string; foley: string;
|
||||
}
|
||||
const EQUIP_DATA: Record<string, EquipEntry> = {
|
||||
premie: { label: 'Premie (1-3 kg)', bvm: 'Infant', nasal: '12 Fr', oral: 'Infant', blade: 'Miller 0', ett: '2.5-3.0', lma: '1', glidescope: '1', iv: '22-24 ga', cvl: '3 Fr', ngt: '5 Fr', chest: '10-12 Fr', foley: '6 Fr' },
|
||||
newborn: { label: 'Newborn (2-4 kg)', bvm: 'Infant', nasal: '14-16 Fr', oral: 'Small 50 mm', blade: 'Miller 0', ett: '3.0-3.5', lma: '1', glidescope: '1', iv: '22-24 ga', cvl: '3-4 Fr', ngt: '5-8 Fr', chest: '10-12 Fr', foley: '6 Fr' },
|
||||
'6mo': { label: '6 months (6-8 kg)', bvm: 'Infant', nasal: '14-16 Fr', oral: 'Small 60 mm', blade: 'Miller 1', ett: '3.5', lma: '1.5', glidescope: '2', iv: '20-24 ga', cvl: '4 Fr', ngt: '8 Fr', chest: '12-18 Fr', foley: '8 Fr' },
|
||||
'1yr': { label: '1 year (10 kg)', bvm: 'Small child', nasal: '14-18 Fr', oral: 'Small 60 mm', blade: 'Miller 1 / MAC 2', ett: '4.0', lma: '2', glidescope: '2', iv: '20-24 ga', cvl: '4-5 Fr', ngt: '10 Fr', chest: '16-20 Fr', foley: '8 Fr' },
|
||||
'2-3yr': { label: '2-3 years (12-16 kg)', bvm: 'Small child', nasal: '14-18 Fr', oral: 'Small 70 mm', blade: 'Miller 1 / MAC 2', ett: '4.0-4.5', lma: '2', glidescope: '2', iv: '18-22 ga', cvl: '4-5 Fr', ngt: '10-12 Fr', chest: '16-24 Fr', foley: '8 Fr' },
|
||||
'4-6yr': { label: '4-6 years (20-25 kg)', bvm: 'Child', nasal: '16-20 Fr', oral: 'Small 70-80 mm', blade: 'Miller 2 / MAC 2', ett: '4.5-5.0', lma: '2.5', glidescope: '3', iv: '18-22 ga', cvl: '5 Fr', ngt: '12-14 Fr', chest: '20-28 Fr', foley: '8 Fr' },
|
||||
'7-10yr': { label: '7-10 years (25-35 kg)', bvm: 'Child / Small adult', nasal: '18-22 Fr', oral: 'Medium 80-90 mm', blade: 'Miller 2 / MAC 2', ett: '5.5-6.0', lma: '2.5-3', glidescope: '3', iv: '18-22 ga', cvl: '5 Fr', ngt: '12-14 Fr', chest: '20-32 Fr', foley: '8 Fr' },
|
||||
'11-15yr': { label: '11-15 years (40-50 kg)', bvm: 'Adult', nasal: '22-36 Fr', oral: 'Medium 90 mm', blade: 'Miller 2 / MAC 3', ett: '6.0-6.5', lma: '3', glidescope: '3 or 4', iv: '18-20 ga', cvl: '7 Fr', ngt: '14-18 Fr', chest: '28-38 Fr', foley: '10 Fr' },
|
||||
'16yr': { label: '16+ years (>50 kg)', bvm: 'Adult', nasal: '22-36 Fr', oral: 'Medium 90 mm', blade: 'Miller 2 / MAC 3', ett: '7.0-8.0', lma: '4', glidescope: '3 or 4', iv: '18-20 ga', cvl: '7 Fr', ngt: '14-18 Fr', chest: '28-42 Fr', foley: '12 Fr' },
|
||||
};
|
||||
const EQUIP_ORDER = ['premie', 'newborn', '6mo', '1yr', '2-3yr', '4-6yr', '7-10yr', '11-15yr', '16yr'];
|
||||
|
||||
export function EquipmentPanel() {
|
||||
const [key, setKey] = useState('1yr');
|
||||
const e = EQUIP_DATA[key];
|
||||
const rows: Array<[string, string]> = [
|
||||
['BVM', e.bvm],
|
||||
['Nasopharyngeal', e.nasal],
|
||||
['Oropharyngeal', e.oral],
|
||||
['Laryngoscope', e.blade],
|
||||
['ETT', e.ett],
|
||||
['LMA', e.lma],
|
||||
['Glidescope', e.glidescope],
|
||||
['IV', e.iv],
|
||||
['Central line', e.cvl],
|
||||
['NG tube', e.ngt],
|
||||
['Chest tube', e.chest],
|
||||
['Foley', e.foley],
|
||||
];
|
||||
return (
|
||||
<section className={card} data-testid="calc-panel-equipment">
|
||||
<h2 className="text-lg font-semibold">Equipment Sizing</h2>
|
||||
<div className="max-w-xs">
|
||||
<label className={label}>Age / weight band</label>
|
||||
<select className={input} value={key} onChange={(e2) => setKey(e2.target.value)} data-testid="equip-age-select">
|
||||
{EQUIP_ORDER.map((k) => <option key={k} value={k}>{EQUIP_DATA[k].label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm" data-testid="equip-result">
|
||||
{rows.map(([lbl, val]) => (
|
||||
<div key={lbl} className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">{lbl}</div><div className="font-semibold">{val}</div></div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground italic">Harriet Lane Handbook · PALS · Broselow cross-reference.</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -29,6 +29,7 @@ import {
|
|||
} from '@shared/clinical/calculators';
|
||||
import { classifyBhutani, classifyAapBili, type BiliRisk } from '@shared/clinical/bilirubin';
|
||||
import { fentonWeightForAge, classifySizeForAge, type Sex } from '@shared/clinical/fenton';
|
||||
import { BmiPanel, VitalsPanel, ResusPanel, EquipmentPanel } from './CalculatorPanels';
|
||||
|
||||
const card = 'rounded-lg border border-border bg-card p-5 space-y-3';
|
||||
const btnPrimary = 'rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium';
|
||||
|
|
@ -49,15 +50,15 @@ interface Pill {
|
|||
|
||||
const PILLS: Pill[] = [
|
||||
{ id: 'bp', label: 'BP Percentile', summary: 'AAP 2017 age/height/sex-adjusted BP percentiles (Rosner quantile splines).', source: 'AAP 2017 (Flynn) — Rosner splines' },
|
||||
{ id: 'bmi', label: 'BMI Percentile', summary: 'BMI-for-age (CDC 2000 z-score tables).', source: 'CDC 2000 LMS' },
|
||||
{ id: 'bmi', label: 'BMI Percentile', summary: 'BMI-for-age (CDC 2000 z-score tables).', source: 'CDC 2000 LMS', ported: true },
|
||||
{ id: 'growth', label: 'Growth Charts', summary: 'Fenton 2013 preterm weight-for-GA with Z-score + percentile + SGA/AGA/LGA classification.', source: 'Fenton 2013 LMS', ported: true },
|
||||
{ id: 'bili', label: 'Bilirubin', summary: 'AAP 2022 phototherapy + exchange thresholds and Bhutani nomogram risk zones.', source: 'AAP 2022 (Kemper) + Bhutani 1999', ported: true },
|
||||
{ id: 'vitals', label: 'Vital Signs', summary: 'Normal HR / RR / BP ranges by age.', source: 'PALS + AHA reference' },
|
||||
{ id: 'vitals', label: 'Vital Signs', summary: 'Normal HR / RR / BP ranges by age.', source: 'Harriet Lane + PALS + AHA', ported: true },
|
||||
{ id: 'bsa', label: 'Body Surface Area', summary: 'Mosteller body surface area formula.', source: 'Mosteller 1987', ported: true },
|
||||
{ id: 'dose', label: 'Weight-Based Dosing', summary: 'Generic mg/kg dosing with optional max-dose cap and concentration conversion.', source: 'Legacy calculator formula', ported: true },
|
||||
{ id: 'resus', label: 'Resus Meds', summary: 'Code-cart dosing (epinephrine, amiodarone, atropine, etc.).', source: 'PALS' },
|
||||
{ id: 'resus', label: 'Resus Meds', summary: 'Code-cart dosing (epinephrine, amiodarone, atropine, etc.).', source: 'PALS', ported: true },
|
||||
{ id: 'gcs', label: 'GCS', summary: 'Child/adult and infant Glasgow Coma Scale variants.', source: 'Teasdale + pediatric modification', ported: true },
|
||||
{ id: 'equipment', label: 'Equipment', summary: 'ETT size, blade, NG, Foley, suction by age/weight.', source: 'PALS + Broselow cross-reference' },
|
||||
{ id: 'equipment', label: 'Equipment', summary: 'ETT size, blade, NG, Foley, suction by age/weight.', source: 'PALS + Broselow cross-reference', ported: true },
|
||||
];
|
||||
|
||||
function parseOptionalNumber(value: string): number | null {
|
||||
|
|
@ -567,6 +568,10 @@ function ActivePanel({ pill }: { pill: Pill }) {
|
|||
if (pill.id === 'gcs') return <GcsPanel />;
|
||||
if (pill.id === 'bili') return <BiliPanel />;
|
||||
if (pill.id === 'growth') return <GrowthPanel />;
|
||||
if (pill.id === 'bmi') return <BmiPanel />;
|
||||
if (pill.id === 'vitals') return <VitalsPanel />;
|
||||
if (pill.id === 'resus') return <ResusPanel />;
|
||||
if (pill.id === 'equipment') return <EquipmentPanel />;
|
||||
return <LegacyPanel pill={pill} />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"generatedAt": "2026-04-23T23:54:26.721Z",
|
||||
"generatedAt": "2026-04-24T00:03:50.703Z",
|
||||
"source": "public/js/calculators.js + public/js/bedside/neonatal.js",
|
||||
"bhutani": [
|
||||
{
|
||||
|
|
@ -1315,5 +1315,271 @@
|
|||
"expectedWeight": 496
|
||||
}
|
||||
}
|
||||
],
|
||||
"bmi": [
|
||||
{
|
||||
"name": "BMI 2y male healthy",
|
||||
"inputs": {
|
||||
"weightKg": 12,
|
||||
"heightCm": 85,
|
||||
"ageMonths": 24,
|
||||
"sex": "male"
|
||||
},
|
||||
"output": {
|
||||
"bmi": 16.60899653979239,
|
||||
"z": 0.04590054841030556,
|
||||
"percentile": 51.83,
|
||||
"L": -1.982374,
|
||||
"M": 16.5478,
|
||||
"S": 0.080127,
|
||||
"classification": {
|
||||
"label": "Healthy Weight",
|
||||
"pctOf95": 86.14975440063787,
|
||||
"bmi95": 19.279215193757317
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "BMI 2y female underweight",
|
||||
"inputs": {
|
||||
"weightKg": 10,
|
||||
"heightCm": 85,
|
||||
"ageMonths": 24,
|
||||
"sex": "female"
|
||||
},
|
||||
"output": {
|
||||
"bmi": 13.84083044982699,
|
||||
"z": -2.169042945502352,
|
||||
"percentile": 1.5,
|
||||
"L": -1.024497,
|
||||
"M": 16.388,
|
||||
"S": 0.085026,
|
||||
"classification": {
|
||||
"label": "Underweight",
|
||||
"pctOf95": 72.6229727185413,
|
||||
"bmi95": 19.058474104976014
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "BMI 5y male overweight",
|
||||
"inputs": {
|
||||
"weightKg": 22,
|
||||
"heightCm": 110,
|
||||
"ageMonths": 60,
|
||||
"sex": "male"
|
||||
},
|
||||
"output": {
|
||||
"bmi": 18.18181818181818,
|
||||
"z": 1.7619400118187871,
|
||||
"percentile": 96.1,
|
||||
"L": -2.615166,
|
||||
"M": 15.4191,
|
||||
"S": 0.075992,
|
||||
"classification": {
|
||||
"label": "Obese (Class 1)",
|
||||
"pctOf95": 101.3526600484823,
|
||||
"bmi95": 17.939162300348958
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "BMI 5y female obese",
|
||||
"inputs": {
|
||||
"weightKg": 27,
|
||||
"heightCm": 110,
|
||||
"ageMonths": 60,
|
||||
"sex": "female"
|
||||
},
|
||||
"output": {
|
||||
"bmi": 22.314049586776857,
|
||||
"z": 2.5728061038110686,
|
||||
"percentile": 99.5,
|
||||
"L": -3.350078,
|
||||
"M": 15.1519,
|
||||
"S": 0.0843,
|
||||
"classification": {
|
||||
"label": "Class 2 Severe Obesity",
|
||||
"pctOf95": 122.21644077873168,
|
||||
"bmi95": 18.257813306129258
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "BMI 8y male healthy interior",
|
||||
"inputs": {
|
||||
"weightKg": 26,
|
||||
"heightCm": 128,
|
||||
"ageMonths": 96,
|
||||
"sex": "male"
|
||||
},
|
||||
"output": {
|
||||
"bmi": 15.869140625,
|
||||
"z": 0.05328264940184456,
|
||||
"percentile": 52.12,
|
||||
"L": -3.183058,
|
||||
"M": 15.7823,
|
||||
"S": 0.102091,
|
||||
"classification": {
|
||||
"label": "Healthy Weight",
|
||||
"pctOf95": 79.07470043209199,
|
||||
"bmi95": 20.068543463693736
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "BMI 10y female severe obesity",
|
||||
"inputs": {
|
||||
"weightKg": 55,
|
||||
"heightCm": 138,
|
||||
"ageMonths": 120,
|
||||
"sex": "female"
|
||||
},
|
||||
"output": {
|
||||
"bmi": 28.880487292585595,
|
||||
"z": 2.3156540689599736,
|
||||
"percentile": 98.97,
|
||||
"L": -2.171296,
|
||||
"M": 16.8623,
|
||||
"S": 0.137057,
|
||||
"classification": {
|
||||
"label": "Class 2 Severe Obesity",
|
||||
"pctOf95": 125.657690683571,
|
||||
"bmi95": 22.983461764637976
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "BMI 12y male interpolated 13m",
|
||||
"inputs": {
|
||||
"weightKg": 45,
|
||||
"heightCm": 150,
|
||||
"ageMonths": 155,
|
||||
"sex": "male"
|
||||
},
|
||||
"output": {
|
||||
"bmi": 20,
|
||||
"z": 0.5601095425342837,
|
||||
"percentile": 71.23,
|
||||
"L": -2.3388571666666667,
|
||||
"M": 18.416283333333336,
|
||||
"S": 0.13394933333333334,
|
||||
"classification": {
|
||||
"label": "Healthy Weight",
|
||||
"pctOf95": 79.67578522133032,
|
||||
"bmi95": 25.101729395502364
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "BMI 15y female adolescent",
|
||||
"inputs": {
|
||||
"weightKg": 55,
|
||||
"heightCm": 162,
|
||||
"ageMonths": 180,
|
||||
"sex": "female"
|
||||
},
|
||||
"output": {
|
||||
"bmi": 20.957171162932475,
|
||||
"z": 0.31720639335017814,
|
||||
"percentile": 62.45,
|
||||
"L": -2.034893,
|
||||
"M": 19.9306,
|
||||
"S": 0.150512,
|
||||
"classification": {
|
||||
"label": "Healthy Weight",
|
||||
"pctOf95": 74.51433067032951,
|
||||
"bmi95": 28.125021018644542
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "BMI 18y male 20",
|
||||
"inputs": {
|
||||
"weightKg": 75,
|
||||
"heightCm": 178,
|
||||
"ageMonths": 216,
|
||||
"sex": "male"
|
||||
},
|
||||
"output": {
|
||||
"bmi": 23.671253629592222,
|
||||
"z": 0.5483006053710078,
|
||||
"percentile": 70.83,
|
||||
"L": -1.87467,
|
||||
"M": 21.8959,
|
||||
"S": 0.132286,
|
||||
"classification": {
|
||||
"label": "Healthy Weight",
|
||||
"pctOf95": 81.73895442996405,
|
||||
"bmi95": 28.959574776399098
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "BMI 20y female (upper boundary)",
|
||||
"inputs": {
|
||||
"weightKg": 60,
|
||||
"heightCm": 165,
|
||||
"ageMonths": 240,
|
||||
"sex": "female"
|
||||
},
|
||||
"output": {
|
||||
"bmi": 22.03856749311295,
|
||||
"z": 0.09286285862204761,
|
||||
"percentile": 53.7,
|
||||
"L": -2.342797,
|
||||
"M": 21.7219,
|
||||
"S": 0.153241,
|
||||
"classification": {
|
||||
"label": "Healthy Weight",
|
||||
"pctOf95": 69.30198458421499,
|
||||
"bmi95": 31.80077399707353
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "BMI clamped lower (1y)",
|
||||
"inputs": {
|
||||
"weightKg": 10,
|
||||
"heightCm": 75,
|
||||
"ageMonths": 12,
|
||||
"sex": "male"
|
||||
},
|
||||
"output": {
|
||||
"bmi": 17.77777777777778,
|
||||
"z": 0.8341020364361114,
|
||||
"percentile": 79.79,
|
||||
"L": -1.982374,
|
||||
"M": 16.5478,
|
||||
"S": 0.080127,
|
||||
"classification": {
|
||||
"label": "Healthy Weight",
|
||||
"pctOf95": 92.2121445251272,
|
||||
"bmi95": 19.279215193757317
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "BMI clamped upper (25y)",
|
||||
"inputs": {
|
||||
"weightKg": 70,
|
||||
"heightCm": 175,
|
||||
"ageMonths": 300,
|
||||
"sex": "male"
|
||||
},
|
||||
"output": {
|
||||
"bmi": 22.857142857142858,
|
||||
"z": -0.06006050146869193,
|
||||
"percentile": 47.61,
|
||||
"L": -1.843581,
|
||||
"M": 23.0414,
|
||||
"S": 0.134675,
|
||||
"classification": {
|
||||
"label": "Healthy Weight",
|
||||
"pctOf95": 74.61824032278491,
|
||||
"bmi95": 30.632111878097128
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
52
public/app/assets/index-0nCcACOJ.js
Normal file
52
public/app/assets/index-0nCcACOJ.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
public/app/assets/index-CNgx4Nge.css
Normal file
2
public/app/assets/index-CNgx4Nge.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -5,8 +5,8 @@
|
|||
<link rel="icon" type="image/svg+xml" href="/app/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>client</title>
|
||||
<script type="module" crossorigin src="/app/assets/index-DV5ylulF.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/app/assets/index-CLSco3q2.css">
|
||||
<script type="module" crossorigin src="/app/assets/index-0nCcACOJ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/app/assets/index-CNgx4Nge.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -154,6 +154,48 @@ function neonatalAssess(weeks, days, weightGrams, sex) {
|
|||
return { gaDecimal, L: lms.L, M: lms.M, S: lms.S, z, percentile, expectedWeight: Math.round(lms.M) };
|
||||
}
|
||||
|
||||
// ── CDC 2000 BMI LMS (verbatim from calculators.js:739) ──
|
||||
const bmiLMS = {male:{24:{L:-1.982374,M:16.5478,S:0.080127},30:{L:-1.642107,M:16.2497,S:0.075499},36:{L:-1.419991,M:16.0003,S:0.072634},42:{L:-1.438165,M:15.7941,S:0.071495},48:{L:-1.714869,M:15.6282,S:0.071889},54:{L:-2.155348,M:15.5026,S:0.073491},60:{L:-2.615166,M:15.4191,S:0.075992},66:{L:-2.981797,M:15.3795,S:0.079211},72:{L:-3.211705,M:15.3835,S:0.083048},78:{L:-3.314769,M:15.429,S:0.0874},84:{L:-3.323189,M:15.5129,S:0.092131},90:{L:-3.270455,M:15.6317,S:0.097082},96:{L:-3.183058,M:15.7823,S:0.102091},102:{L:-3.079383,M:15.9617,S:0.107013},108:{L:-2.971148,M:16.1671,S:0.111721},114:{L:-2.865311,M:16.3961,S:0.116113},120:{L:-2.765648,M:16.6461,S:0.120112},126:{L:-2.673903,M:16.9151,S:0.123664},132:{L:-2.59056,M:17.2009,S:0.126735},138:{L:-2.51532,M:17.5014,S:0.129309},144:{L:-2.447426,M:17.8146,S:0.131389},150:{L:-2.385858,M:18.1387,S:0.132991},156:{L:-2.329457,M:18.4718,S:0.134141},162:{L:-2.277017,M:18.812,S:0.13488},168:{L:-2.227362,M:19.1576,S:0.135251},174:{L:-2.179426,M:19.5067,S:0.135309},180:{L:-2.132345,M:19.8577,S:0.13511},186:{L:-2.085574,M:20.2086,S:0.134718},192:{L:-2.039015,M:20.5576,S:0.134198},198:{L:-1.99315,M:20.9029,S:0.13362},204:{L:-1.949135,M:21.2425,S:0.133057},210:{L:-1.908831,M:21.5742,S:0.132585},216:{L:-1.87467,M:21.8959,S:0.132286},222:{L:-1.849323,M:22.2054,S:0.132249},228:{L:-1.835138,M:22.5007,S:0.132566},234:{L:-1.833401,M:22.7799,S:0.133339},240:{L:-1.843581,M:23.0414,S:0.134675}},female:{24:{L:-1.024497,M:16.388,S:0.085026},30:{L:-1.534542,M:16.0059,S:0.080932},36:{L:-2.096829,M:15.6992,S:0.078605},42:{L:-2.618733,M:15.4647,S:0.077904},48:{L:-3.018522,M:15.2985,S:0.078713},54:{L:-3.2593,M:15.1961,S:0.080904},60:{L:-3.350078,M:15.1519,S:0.0843},66:{L:-3.325522,M:15.1606,S:0.08868},72:{L:-3.225607,M:15.2169,S:0.093803},78:{L:-3.084291,M:15.3161,S:0.099427},84:{L:-2.926187,M:15.4536,S:0.105325},90:{L:-2.76731,M:15.6252,S:0.111295},96:{L:-2.617192,M:15.827,S:0.117159},102:{L:-2.480952,M:16.0552,S:0.122771},108:{L:-2.360921,M:16.3061,S:0.128014},114:{L:-2.257782,M:16.5763,S:0.132797},120:{L:-2.171296,M:16.8623,S:0.137057},126:{L:-2.100749,M:17.161,S:0.140754},132:{L:-2.045235,M:17.4691,S:0.143868},138:{L:-2.003802,M:17.7836,S:0.146399},144:{L:-1.975521,M:18.1015,S:0.148361},150:{L:-1.95952,M:18.42,S:0.149783},156:{L:-1.954978,M:18.7364,S:0.150705},162:{L:-1.9611,M:19.0481,S:0.151176},168:{L:-1.977074,M:19.3526,S:0.151256},174:{L:-2.002014,M:19.6475,S:0.15101},180:{L:-2.034893,M:19.9306,S:0.150512},186:{L:-2.07446,M:20.1998,S:0.149843},192:{L:-2.119157,M:20.4533,S:0.14909},198:{L:-2.167045,M:20.6891,S:0.148349},204:{L:-2.215738,M:20.9058,S:0.147723},210:{L:-2.262382,M:21.1016,S:0.147323},216:{L:-2.303688,M:21.2753,S:0.147269},222:{L:-2.336038,M:21.4255,S:0.147689},228:{L:-2.355678,M:21.5508,S:0.148724},234:{L:-2.35898,M:21.6501,S:0.150521},240:{L:-2.342797,M:21.7219,S:0.153241}}};
|
||||
|
||||
function calcBmiPercentile(bmi, L, M, S) {
|
||||
let z;
|
||||
if (Math.abs(L) < 0.001) z = Math.log(bmi / M) / S;
|
||||
else z = (Math.pow(bmi / M, L) - 1) / (L * S);
|
||||
const a1 = 0.254829592, a2 = -0.284496736, a3 = 1.421413741;
|
||||
const a4 = -1.453152027, a5 = 1.061405429, p = 0.3275911;
|
||||
const sign = z < 0 ? -1 : 1;
|
||||
const x = Math.abs(z) / Math.sqrt(2);
|
||||
const t = 1 / (1 + p * x);
|
||||
const y = 1 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-x * x);
|
||||
const prob = 0.5 * (1 + sign * y);
|
||||
return { z, percentile: Math.round(prob * 10000) / 100 };
|
||||
}
|
||||
|
||||
function classifyBMI(percentile, bmi, lms) {
|
||||
const p95z = 1.645;
|
||||
const bmi95 = Math.abs(lms.L) < 0.001
|
||||
? lms.M * Math.exp(lms.S * p95z)
|
||||
: lms.M * Math.pow(1 + lms.L * lms.S * p95z, 1 / lms.L);
|
||||
const pctOf95 = (bmi / bmi95) * 100;
|
||||
if (percentile >= 95) {
|
||||
if (pctOf95 >= 140) return { label: 'Class 3 Severe Obesity', pctOf95, bmi95 };
|
||||
if (pctOf95 >= 120) return { label: 'Class 2 Severe Obesity', pctOf95, bmi95 };
|
||||
return { label: 'Obese (Class 1)', pctOf95, bmi95 };
|
||||
}
|
||||
if (percentile >= 85) return { label: 'Overweight', pctOf95, bmi95 };
|
||||
if (percentile >= 5) return { label: 'Healthy Weight', pctOf95, bmi95 };
|
||||
return { label: 'Underweight', pctOf95, bmi95 };
|
||||
}
|
||||
|
||||
function computeBmi(weightKg, heightCm, ageMonths, sex) {
|
||||
const bmi = weightKg / Math.pow(heightCm / 100, 2);
|
||||
const clamped = Math.max(24, Math.min(240, ageMonths));
|
||||
const lms = interpolateLMS(bmiLMS[sex], clamped);
|
||||
const { z, percentile } = calcBmiPercentile(bmi, lms.L, lms.M, lms.S);
|
||||
const classification = classifyBMI(percentile, bmi, lms);
|
||||
return { bmi, z, percentile, L: lms.L, M: lms.M, S: lms.S, classification };
|
||||
}
|
||||
|
||||
function zToPercentile(z) {
|
||||
// Abramowitz & Stegun normal CDF approximation (from lines 2318-2326).
|
||||
const t = 1 / (1 + 0.2316419 * Math.abs(z));
|
||||
|
|
@ -297,6 +339,23 @@ const fentonCases = [
|
|||
{ name: 'Fenton female 36w LGA 3400g', inputs: { sex: 'female', gaWeeks: 36, weightGrams: 3400 } },
|
||||
];
|
||||
|
||||
const bmiCases = [
|
||||
// Edges + interior, both sexes. Cover underweight / healthy / overweight / obese / severe obesity cliffs.
|
||||
{ name: 'BMI 2y male healthy', inputs: { weightKg: 12, heightCm: 85, ageMonths: 24, sex: 'male' } },
|
||||
{ name: 'BMI 2y female underweight', inputs: { weightKg: 10, heightCm: 85, ageMonths: 24, sex: 'female' } },
|
||||
{ name: 'BMI 5y male overweight', inputs: { weightKg: 22, heightCm: 110, ageMonths: 60, sex: 'male' } },
|
||||
{ name: 'BMI 5y female obese', inputs: { weightKg: 27, heightCm: 110, ageMonths: 60, sex: 'female' } },
|
||||
{ name: 'BMI 8y male healthy interior', inputs: { weightKg: 26, heightCm: 128, ageMonths: 96, sex: 'male' } },
|
||||
{ name: 'BMI 10y female severe obesity', inputs: { weightKg: 55, heightCm: 138, ageMonths: 120, sex: 'female' } },
|
||||
{ name: 'BMI 12y male interpolated 13m', inputs: { weightKg: 45, heightCm: 150, ageMonths: 155, sex: 'male' } },
|
||||
{ name: 'BMI 15y female adolescent', inputs: { weightKg: 55, heightCm: 162, ageMonths: 180, sex: 'female' } },
|
||||
{ name: 'BMI 18y male 20', inputs: { weightKg: 75, heightCm: 178, ageMonths: 216, sex: 'male' } },
|
||||
{ name: 'BMI 20y female (upper boundary)', inputs: { weightKg: 60, heightCm: 165, ageMonths: 240, sex: 'female' } },
|
||||
// Boundaries — clamping
|
||||
{ name: 'BMI clamped lower (1y)', inputs: { weightKg: 10, heightCm: 75, ageMonths: 12, sex: 'male' } },
|
||||
{ name: 'BMI clamped upper (25y)', inputs: { weightKg: 70, heightCm: 175, ageMonths: 300, sex: 'male' } },
|
||||
];
|
||||
|
||||
const neonatalCases = [
|
||||
// Validated test case from bedside/neonatal.js comment (40 5/7 wk male 3070g → z = -1.42)
|
||||
{ name: 'Neonatal 40w5d male 3070g (validated against peditools/Epic)',
|
||||
|
|
@ -318,9 +377,10 @@ const vectors = {
|
|||
aap: aapCases.map((c) => ({ ...c, output: classifyAapBili(c.inputs.gaWeeks, c.inputs.hours, c.inputs.tsb, c.inputs.risk) })),
|
||||
fenton: fentonCases.map((c) => ({ ...c, output: fentonWeightForAge(c.inputs.gaWeeks, c.inputs.weightGrams, c.inputs.sex) })),
|
||||
neonatal: neonatalCases.map((c) => ({ ...c, output: neonatalAssess(c.inputs.weeks, c.inputs.days, c.inputs.weightGrams, c.inputs.sex) })),
|
||||
bmi: bmiCases.map((c) => ({ ...c, output: computeBmi(c.inputs.weightKg, c.inputs.heightCm, c.inputs.ageMonths, c.inputs.sex) })),
|
||||
};
|
||||
|
||||
const outPath = path.join(__dirname, '..', 'e2e', 'fixtures', 'calc-vectors.json');
|
||||
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
||||
fs.writeFileSync(outPath, JSON.stringify(vectors, null, 2));
|
||||
console.log(`Wrote ${vectors.bhutani.length} Bhutani, ${vectors.aap.length} AAP, ${vectors.fenton.length} Fenton, ${vectors.neonatal.length} Neonatal cases → ${outPath}`);
|
||||
console.log(`Wrote ${vectors.bhutani.length} Bhutani, ${vectors.aap.length} AAP, ${vectors.fenton.length} Fenton, ${vectors.neonatal.length} Neonatal, ${vectors.bmi.length} BMI cases → ${outPath}`);
|
||||
|
|
|
|||
39
shared/clinical/bmi.test.ts
Normal file
39
shared/clinical/bmi.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// ============================================================
|
||||
// BMI-FOR-AGE parity tests.
|
||||
// ============================================================
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { computeBmi } from './bmi';
|
||||
import type { Sex } from './fenton';
|
||||
|
||||
interface BmiCase {
|
||||
name: string;
|
||||
inputs: { weightKg: number; heightCm: number; ageMonths: number; sex: Sex };
|
||||
output: {
|
||||
bmi: number; z: number; percentile: number;
|
||||
L: number; M: number; S: number;
|
||||
classification: { label: string; pctOf95: number; bmi95: number };
|
||||
};
|
||||
}
|
||||
interface Vectors { bmi: BmiCase[] }
|
||||
|
||||
const vectors: Vectors = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '..', '..', 'e2e', 'fixtures', 'calc-vectors.json'), 'utf8'),
|
||||
);
|
||||
|
||||
describe('CDC 2000 BMI-for-age — parity with vanilla calculators.js', () => {
|
||||
it.each(vectors.bmi)('$name', ({ inputs, output }) => {
|
||||
const actual = computeBmi(inputs.weightKg, inputs.heightCm, inputs.ageMonths, inputs.sex);
|
||||
expect(actual.bmi).toBeCloseTo(output.bmi, 10);
|
||||
expect(actual.z).toBeCloseTo(output.z, 10);
|
||||
expect(actual.percentile).toBeCloseTo(output.percentile, 6);
|
||||
expect(actual.L).toBeCloseTo(output.L, 10);
|
||||
expect(actual.M).toBeCloseTo(output.M, 10);
|
||||
expect(actual.S).toBeCloseTo(output.S, 10);
|
||||
expect(actual.classification.label).toBe(output.classification.label);
|
||||
expect(actual.classification.pctOf95).toBeCloseTo(output.classification.pctOf95, 6);
|
||||
expect(actual.classification.bmi95).toBeCloseTo(output.classification.bmi95, 10);
|
||||
});
|
||||
});
|
||||
108
shared/clinical/bmi.ts
Normal file
108
shared/clinical/bmi.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// ============================================================
|
||||
// BMI-FOR-AGE — CDC 2000 LMS. Table + math + classification
|
||||
// ported VERBATIM from public/js/calculators.js:739-1017.
|
||||
// Vitest parity driven by calc-vectors.json.
|
||||
//
|
||||
// CDC reference:
|
||||
// Kuczmarski RJ, Ogden CL, Guo SS, et al. 2000 CDC Growth
|
||||
// Charts for the United States: methods and development.
|
||||
// Vital Health Stat 11. 2002;(246):1-190.
|
||||
// ============================================================
|
||||
|
||||
import type { Sex, Lms } from './fenton';
|
||||
import { interpolateLMS } from './fenton';
|
||||
|
||||
// 37 age points (24-240 months, 6-mo steps) × 2 sexes. Verbatim from
|
||||
// calculators.js:739. Values are floating-point to 6+ decimals; the
|
||||
// test vectors pin every one.
|
||||
export const bmiLMS: Record<Sex, Record<number, Lms>> = {
|
||||
male: {
|
||||
24: { L: -1.982374, M: 16.5478, S: 0.080127 }, 30: { L: -1.642107, M: 16.2497, S: 0.075499 }, 36: { L: -1.419991, M: 16.0003, S: 0.072634 }, 42: { L: -1.438165, M: 15.7941, S: 0.071495 },
|
||||
48: { L: -1.714869, M: 15.6282, S: 0.071889 }, 54: { L: -2.155348, M: 15.5026, S: 0.073491 }, 60: { L: -2.615166, M: 15.4191, S: 0.075992 }, 66: { L: -2.981797, M: 15.3795, S: 0.079211 },
|
||||
72: { L: -3.211705, M: 15.3835, S: 0.083048 }, 78: { L: -3.314769, M: 15.429, S: 0.0874 }, 84: { L: -3.323189, M: 15.5129, S: 0.092131 }, 90: { L: -3.270455, M: 15.6317, S: 0.097082 },
|
||||
96: { L: -3.183058, M: 15.7823, S: 0.102091 }, 102: { L: -3.079383, M: 15.9617, S: 0.107013 }, 108: { L: -2.971148, M: 16.1671, S: 0.111721 }, 114: { L: -2.865311, M: 16.3961, S: 0.116113 },
|
||||
120: { L: -2.765648, M: 16.6461, S: 0.120112 }, 126: { L: -2.673903, M: 16.9151, S: 0.123664 }, 132: { L: -2.59056, M: 17.2009, S: 0.126735 }, 138: { L: -2.51532, M: 17.5014, S: 0.129309 },
|
||||
144: { L: -2.447426, M: 17.8146, S: 0.131389 }, 150: { L: -2.385858, M: 18.1387, S: 0.132991 }, 156: { L: -2.329457, M: 18.4718, S: 0.134141 }, 162: { L: -2.277017, M: 18.812, S: 0.13488 },
|
||||
168: { L: -2.227362, M: 19.1576, S: 0.135251 }, 174: { L: -2.179426, M: 19.5067, S: 0.135309 }, 180: { L: -2.132345, M: 19.8577, S: 0.13511 }, 186: { L: -2.085574, M: 20.2086, S: 0.134718 },
|
||||
192: { L: -2.039015, M: 20.5576, S: 0.134198 }, 198: { L: -1.99315, M: 20.9029, S: 0.13362 }, 204: { L: -1.949135, M: 21.2425, S: 0.133057 }, 210: { L: -1.908831, M: 21.5742, S: 0.132585 },
|
||||
216: { L: -1.87467, M: 21.8959, S: 0.132286 }, 222: { L: -1.849323, M: 22.2054, S: 0.132249 }, 228: { L: -1.835138, M: 22.5007, S: 0.132566 }, 234: { L: -1.833401, M: 22.7799, S: 0.133339 },
|
||||
240: { L: -1.843581, M: 23.0414, S: 0.134675 },
|
||||
},
|
||||
female: {
|
||||
24: { L: -1.024497, M: 16.388, S: 0.085026 }, 30: { L: -1.534542, M: 16.0059, S: 0.080932 }, 36: { L: -2.096829, M: 15.6992, S: 0.078605 }, 42: { L: -2.618733, M: 15.4647, S: 0.077904 },
|
||||
48: { L: -3.018522, M: 15.2985, S: 0.078713 }, 54: { L: -3.2593, M: 15.1961, S: 0.080904 }, 60: { L: -3.350078, M: 15.1519, S: 0.0843 }, 66: { L: -3.325522, M: 15.1606, S: 0.08868 },
|
||||
72: { L: -3.225607, M: 15.2169, S: 0.093803 }, 78: { L: -3.084291, M: 15.3161, S: 0.099427 }, 84: { L: -2.926187, M: 15.4536, S: 0.105325 }, 90: { L: -2.76731, M: 15.6252, S: 0.111295 },
|
||||
96: { L: -2.617192, M: 15.827, S: 0.117159 }, 102: { L: -2.480952, M: 16.0552, S: 0.122771 }, 108: { L: -2.360921, M: 16.3061, S: 0.128014 }, 114: { L: -2.257782, M: 16.5763, S: 0.132797 },
|
||||
120: { L: -2.171296, M: 16.8623, S: 0.137057 }, 126: { L: -2.100749, M: 17.161, S: 0.140754 }, 132: { L: -2.045235, M: 17.4691, S: 0.143868 }, 138: { L: -2.003802, M: 17.7836, S: 0.146399 },
|
||||
144: { L: -1.975521, M: 18.1015, S: 0.148361 }, 150: { L: -1.95952, M: 18.42, S: 0.149783 }, 156: { L: -1.954978, M: 18.7364, S: 0.150705 }, 162: { L: -1.9611, M: 19.0481, S: 0.151176 },
|
||||
168: { L: -1.977074, M: 19.3526, S: 0.151256 }, 174: { L: -2.002014, M: 19.6475, S: 0.15101 }, 180: { L: -2.034893, M: 19.9306, S: 0.150512 }, 186: { L: -2.07446, M: 20.1998, S: 0.149843 },
|
||||
192: { L: -2.119157, M: 20.4533, S: 0.14909 }, 198: { L: -2.167045, M: 20.6891, S: 0.148349 }, 204: { L: -2.215738, M: 20.9058, S: 0.147723 }, 210: { L: -2.262382, M: 21.1016, S: 0.147323 },
|
||||
216: { L: -2.303688, M: 21.2753, S: 0.147269 }, 222: { L: -2.336038, M: 21.4255, S: 0.147689 }, 228: { L: -2.355678, M: 21.5508, S: 0.148724 }, 234: { L: -2.35898, M: 21.6501, S: 0.150521 },
|
||||
240: { L: -2.342797, M: 21.7219, S: 0.153241 },
|
||||
},
|
||||
};
|
||||
|
||||
// Abramowitz & Stegun erf-form normal CDF (0-1 range). Verbatim from
|
||||
// calculators.js:754-761.
|
||||
export function normalCDF(z: number): number {
|
||||
const a1 = 0.254829592, a2 = -0.284496736, a3 = 1.421413741;
|
||||
const a4 = -1.453152027, a5 = 1.061405429, p = 0.3275911;
|
||||
const sign = z < 0 ? -1 : 1;
|
||||
const x = Math.abs(z) / Math.sqrt(2);
|
||||
const t = 1 / (1 + p * x);
|
||||
const y = 1 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-x * x);
|
||||
return 0.5 * (1 + sign * y);
|
||||
}
|
||||
|
||||
// Verbatim from calculators.js:741-752.
|
||||
export interface BmiZ { z: number; percentile: number }
|
||||
export function calcBmiPercentile(bmi: number, L: number, M: number, S: number): BmiZ {
|
||||
let z: number;
|
||||
if (Math.abs(L) < 0.001) z = Math.log(bmi / M) / S;
|
||||
else z = (Math.pow(bmi / M, L) - 1) / (L * S);
|
||||
const p = normalCDF(z);
|
||||
return { z, percentile: Math.round(p * 10000) / 100 };
|
||||
}
|
||||
|
||||
// Verbatim from calculators.js:1000-1017.
|
||||
export type BmiClassification =
|
||||
| 'Underweight' | 'Healthy Weight' | 'Overweight'
|
||||
| 'Obese (Class 1)' | 'Class 2 Severe Obesity' | 'Class 3 Severe Obesity';
|
||||
export interface BmiClass {
|
||||
label: BmiClassification;
|
||||
color: string;
|
||||
bg: string;
|
||||
pctOf95: number;
|
||||
bmi95: number;
|
||||
}
|
||||
export function classifyBMI(percentile: number, bmi: number, lms: Lms): BmiClass {
|
||||
const p95z = 1.645;
|
||||
const bmi95 = Math.abs(lms.L) < 0.001
|
||||
? lms.M * Math.exp(lms.S * p95z)
|
||||
: lms.M * Math.pow(1 + lms.L * lms.S * p95z, 1 / lms.L);
|
||||
const pctOf95 = (bmi / bmi95) * 100;
|
||||
if (percentile >= 95) {
|
||||
if (pctOf95 >= 140) return { label: 'Class 3 Severe Obesity', color: '#7f1d1d', bg: '#fecaca', pctOf95, bmi95 };
|
||||
if (pctOf95 >= 120) return { label: 'Class 2 Severe Obesity', color: '#dc2626', bg: '#fee2e2', pctOf95, bmi95 };
|
||||
return { label: 'Obese (Class 1)', color: '#ef4444', bg: '#fee2e2', pctOf95, bmi95 };
|
||||
}
|
||||
if (percentile >= 85) return { label: 'Overweight', color: '#f97316', bg: '#ffedd5', pctOf95, bmi95 };
|
||||
if (percentile >= 5) return { label: 'Healthy Weight', color: '#10b981', bg: '#d1fae5', pctOf95, bmi95 };
|
||||
return { label: 'Underweight', color: '#f59e0b', bg: '#fef3c7', pctOf95, bmi95 };
|
||||
}
|
||||
|
||||
// Top-level helper combining interpolate + math + classification.
|
||||
// Mirrors the click-handler flow in calculators.js:1019-1038.
|
||||
export interface BmiResult extends BmiZ {
|
||||
bmi: number;
|
||||
L: number; M: number; S: number;
|
||||
classification: BmiClass;
|
||||
}
|
||||
export function computeBmi(weightKg: number, heightCm: number, ageMonths: number, sex: Sex): BmiResult {
|
||||
const bmi = weightKg / Math.pow(heightCm / 100, 2);
|
||||
const clamped = Math.max(24, Math.min(240, ageMonths));
|
||||
const lms = interpolateLMS(bmiLMS[sex], clamped);
|
||||
const { z, percentile } = calcBmiPercentile(bmi, lms.L, lms.M, lms.S);
|
||||
const classification = classifyBMI(percentile, bmi, lms);
|
||||
return { bmi, z, percentile, L: lms.L, M: lms.M, S: lms.S, classification };
|
||||
}
|
||||
Loading…
Reference in a new issue