1101 lines
62 KiB
JavaScript
1101 lines
62 KiB
JavaScript
import { parseAgeMonths, formatParsedAge } from './calculators/age.js';
|
|
import { calculateAapBilirubinAssessment, classifyBhutaniRisk, loadAapBilirubinThresholds, loadBhutaniZones } from './calculators/bilirubin.js';
|
|
import { calculateBMIAssessment, loadBmiLmsData } from './calculators/bmi.js';
|
|
import { calculateBPAssessment, computeBPPercentile, loadBpReferenceData } from './calculators/bp.js';
|
|
import { calculateBsa, calculateWeightBasedDose } from './calculators/dosing.js';
|
|
import { loadEquipmentData, renderEquipmentResult } from './calculators/equipment.js';
|
|
import { calculateGcs } from './calculators/gcs.js';
|
|
import { calcMidParentalHeight, calcZFromLMS, interpolateLMS, loadGrowthLmsData, normalCDF, renderGrowthResult, valueFromLMS } from './calculators/growth.js';
|
|
import { initImageLightbox } from './calculators/lightbox.js';
|
|
import { loadResusMedsData, renderResusMeds } from './calculators/resus.js';
|
|
import { loadVitalsData, renderVitalsResult } from './calculators/vitals.js';
|
|
|
|
// ============================================================
|
|
// CALCULATORS.JS — Pediatric clinical calculators
|
|
// ============================================================
|
|
|
|
initImageLightbox();
|
|
var _inited = false;
|
|
document.addEventListener('tabChanged', function(e) {
|
|
if (e.detail.tab !== 'calculators' || _inited) return;
|
|
_inited = true;
|
|
initCalculators();
|
|
});
|
|
|
|
// Expose for debugging / other modules
|
|
window._parseAgeMonths = parseAgeMonths;
|
|
|
|
function initCalculators() {
|
|
// ── Top-level pill navigation (scoped to .calc-nav-pill only) ──
|
|
function activateCalcPill(key) {
|
|
var pill = document.querySelector('.calc-nav-pill[data-calc="' + key + '"]');
|
|
if (!pill) return false;
|
|
document.querySelectorAll('.calc-nav-pill').forEach(function(p) { p.classList.remove('active'); });
|
|
document.querySelectorAll('.calc-panel').forEach(function(p) { p.classList.add('hidden'); });
|
|
pill.classList.add('active');
|
|
var panel = document.getElementById('calc-' + key);
|
|
if (panel) panel.classList.remove('hidden');
|
|
return true;
|
|
}
|
|
document.querySelectorAll('.calc-nav-pill').forEach(function(pill) {
|
|
pill.addEventListener('click', function() {
|
|
activateCalcPill(pill.dataset.calc);
|
|
if (window.UIState) window.UIState.set('calc.pill', pill.dataset.calc);
|
|
});
|
|
});
|
|
// Restore the last-selected pill across page reloads / sign-out-sign-in.
|
|
if (window.UIState) {
|
|
var saved = window.UIState.get('calc.pill');
|
|
if (saved) activateCalcPill(saved);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// BP PERCENTILE — BCM/Rosner Quantile Spline Regression
|
|
// AAP 2017 classification with exact percentile computation
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
// BP reference tables load from /data/calculators/bp-reference.json.
|
|
|
|
var classLabels = {
|
|
normal: { text: 'Normal', color: '#10b981', bg: '#d1fae5' },
|
|
elevated: { text: 'Elevated', color: '#f59e0b', bg: '#fef3c7' },
|
|
stage1: { text: 'Stage 1 Hypertension', color: '#f97316', bg: '#ffedd5' },
|
|
stage2: { text: 'Stage 2 Hypertension', color: '#ef4444', bg: '#fee2e2' }
|
|
};
|
|
|
|
var BP_REFERENCE = null;
|
|
loadBpReferenceData()
|
|
.then(function(data) { BP_REFERENCE = data; })
|
|
.catch(function(err) { console.warn('[calculators] BP reference data unavailable:', err && err.message); });
|
|
|
|
document.getElementById('btn-calc-bp').addEventListener('click', function() {
|
|
var age = parseFloat(document.getElementById('bp-age').value);
|
|
var sex = document.getElementById('bp-sex').value;
|
|
var sys = parseFloat(document.getElementById('bp-systolic').value);
|
|
var dia = parseFloat(document.getElementById('bp-diastolic').value);
|
|
var heightCm = parseFloat(document.getElementById('bp-height').value);
|
|
|
|
if (!age || !sex || !sys || !dia || !heightCm) { showToast('Fill in all fields including height', 'error'); return; }
|
|
if (age < 1 || age > 17) { showToast('Age must be 1-17 years', 'error'); return; }
|
|
if (!BP_REFERENCE) { showToast('BP percentile data is still loading.', 'info'); return; }
|
|
|
|
var assessment = calculateBPAssessment(BP_REFERENCE, age, sex, heightCm, sys, dia);
|
|
var htResult = assessment.height;
|
|
var sysResult = assessment.systolic;
|
|
var diaResult = assessment.diastolic;
|
|
var bpClass = assessment.classification;
|
|
|
|
// Get 50th, 90th, 95th predicted values for display
|
|
var sys50 = sysResult.predicted[49].toFixed(0), sys90 = sysResult.predicted[89].toFixed(0), sys95 = sysResult.predicted[94].toFixed(0);
|
|
var dia50 = diaResult.predicted[49].toFixed(0), dia90 = diaResult.predicted[89].toFixed(0), dia95 = diaResult.predicted[94].toFixed(0);
|
|
|
|
var cl = classLabels[bpClass.classification];
|
|
var resultDiv = document.getElementById('bp-result');
|
|
resultDiv.classList.remove('hidden');
|
|
resultDiv.innerHTML =
|
|
'<div class="calc-result-header" style="background:' + cl.bg + ';border-color:' + cl.color + ';">' +
|
|
'<div style="font-size:18px;font-weight:700;color:' + cl.color + ';">' + cl.text + '</div>' +
|
|
'<div style="font-size:13px;color:var(--g600);margin-top:4px;">' + sys + '/' + dia + ' mmHg — ' + age + ' year old ' + sex + ', ' + heightCm + ' cm</div>' +
|
|
'</div>' +
|
|
'<div class="calc-result-grid">' +
|
|
'<div class="calc-result-item">' +
|
|
'<div class="calc-result-label">Systolic</div>' +
|
|
'<div class="calc-result-value">' + sysResult.percentile + '<span style="font-size:12px;">th %ile</span></div>' +
|
|
'<div class="calc-result-sub" style="color:' + classLabels[bpClass.sysClass].color + ';">' + classLabels[bpClass.sysClass].text + '</div>' +
|
|
'</div>' +
|
|
'<div class="calc-result-item">' +
|
|
'<div class="calc-result-label">Diastolic</div>' +
|
|
'<div class="calc-result-value">' + diaResult.percentile + '<span style="font-size:12px;">th %ile</span></div>' +
|
|
'<div class="calc-result-sub" style="color:' + classLabels[bpClass.diaClass].color + ';">' + classLabels[bpClass.diaClass].text + '</div>' +
|
|
'</div>' +
|
|
'<div class="calc-result-item">' +
|
|
'<div class="calc-result-label">Height</div>' +
|
|
'<div class="calc-result-value">' + Math.round(htResult.percentile) + '<span style="font-size:12px;">th %ile</span></div>' +
|
|
'<div class="calc-result-sub">Z = ' + htResult.z.toFixed(2) + '</div>' +
|
|
'</div>' +
|
|
'<div class="calc-result-item">' +
|
|
'<div class="calc-result-label">Reference (50th %ile)</div>' +
|
|
'<div class="calc-result-value" style="font-size:16px;">' + sys50 + '/' + dia50 + '</div>' +
|
|
'<div class="calc-result-sub">90th: ' + sys90 + '/' + dia90 + ' • 95th: ' + sys95 + '/' + dia95 + '</div>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'<p style="font-size:11px;color:var(--g400);margin:12px 0 0;">BCM/Rosner quantile regression percentiles adjusted to patient height. AAP 2017 classification.</p>';
|
|
|
|
// Generate BP curves across ages 1-17 for this sex/height
|
|
renderBPChart(age, sex, heightCm, sys, dia);
|
|
});
|
|
|
|
|
|
function renderBPChart(patientAge, sex, heightCm, patientSys, patientDia) {
|
|
if (typeof Chart === 'undefined') return;
|
|
var container = document.getElementById('bp-chart-container');
|
|
if (!container) return;
|
|
container.classList.remove('hidden');
|
|
if (_chartInstances['bp-chart-canvas']) { _chartInstances['bp-chart-canvas'].destroy(); _chartInstances['bp-chart-canvas'] = null; }
|
|
|
|
// Generate percentile curves across ages 1-17
|
|
var sys50 = [], sys90 = [], sys95 = [], dia50 = [], dia90 = [], dia95 = [];
|
|
for (var a = 1; a <= 17; a++) {
|
|
var sr = computeBPPercentile(BP_REFERENCE, a, sex, heightCm, 'sys', 100);
|
|
var dr = computeBPPercentile(BP_REFERENCE, a, sex, heightCm, 'dia', 60);
|
|
sys50.push({x:a, y:Math.round(sr.predicted[49])}); sys90.push({x:a, y:Math.round(sr.predicted[89])}); sys95.push({x:a, y:Math.round(sr.predicted[94])});
|
|
dia50.push({x:a, y:Math.round(dr.predicted[49])}); dia90.push({x:a, y:Math.round(dr.predicted[89])}); dia95.push({x:a, y:Math.round(dr.predicted[94])});
|
|
}
|
|
|
|
var datasets = [
|
|
{ label: 'SBP 95th', data: sys95, borderColor: 'rgba(239,68,68,0.7)', borderWidth: 2, borderDash: [4,4], pointRadius: 0, fill: false, tension: 0.3 },
|
|
{ label: 'SBP 90th', data: sys90, borderColor: 'rgba(249,115,22,0.6)', borderWidth: 1.5, borderDash: [3,3], pointRadius: 0, fill: false, tension: 0.3 },
|
|
{ label: 'SBP 50th', data: sys50, borderColor: 'rgba(16,185,129,0.8)', borderWidth: 2, pointRadius: 0, fill: false, tension: 0.3 },
|
|
{ label: 'DBP 95th', data: dia95, borderColor: 'rgba(239,68,68,0.4)', borderWidth: 1.5, borderDash: [4,4], pointRadius: 0, fill: false, tension: 0.3 },
|
|
{ label: 'DBP 90th', data: dia90, borderColor: 'rgba(249,115,22,0.3)', borderWidth: 1, borderDash: [3,3], pointRadius: 0, fill: false, tension: 0.3 },
|
|
{ label: 'DBP 50th', data: dia50, borderColor: 'rgba(16,185,129,0.4)', borderWidth: 1.5, pointRadius: 0, fill: false, tension: 0.3 },
|
|
{ label: 'Patient SBP', data: [{x:patientAge, y:patientSys}], backgroundColor: '#2563eb', borderColor: '#1d4ed8', pointRadius: 8, pointHoverRadius: 10, showLine: false, type: 'scatter', order: 0 },
|
|
{ label: 'Patient DBP', data: [{x:patientAge, y:patientDia}], backgroundColor: '#7c3aed', borderColor: '#6d28d9', pointRadius: 8, pointHoverRadius: 10, pointStyle: 'triangle', showLine: false, type: 'scatter', order: 0 }
|
|
];
|
|
|
|
_chartInstances['bp-chart-canvas'] = new Chart(document.getElementById('bp-chart-canvas'), {
|
|
type: 'line',
|
|
data: { datasets: datasets },
|
|
options: {
|
|
responsive: true, maintainAspectRatio: true, aspectRatio: 1.6,
|
|
animation: { duration: 300 },
|
|
scales: {
|
|
x: { type: 'linear', title: { display: true, text: 'Age (years)', font: { size: 12, weight: 'bold' } }, min: 1, max: 17, ticks: { stepSize: 1 }, grid: { color: 'rgba(0,0,0,0.05)' } },
|
|
y: { title: { display: true, text: 'Blood Pressure (mmHg)', font: { size: 12, weight: 'bold' } }, min: 30, grid: { color: 'rgba(0,0,0,0.05)' } }
|
|
},
|
|
plugins: {
|
|
legend: { display: true, position: 'bottom', labels: { font: { size: 10 }, usePointStyle: true } },
|
|
tooltip: {
|
|
callbacks: {
|
|
label: function(ctx) {
|
|
if (ctx.dataset.label.indexOf('Patient') !== -1) return ctx.dataset.label + ': ' + ctx.parsed.y + ' mmHg';
|
|
return ctx.dataset.label + ': ' + ctx.parsed.y + ' mmHg at age ' + ctx.parsed.x;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
document.getElementById('btn-clear-bp').addEventListener('click', function() {
|
|
['bp-age', 'bp-sex', 'bp-height', 'bp-systolic', 'bp-diastolic'].forEach(function(id) {
|
|
var el = document.getElementById(id); if (el) el.value = '';
|
|
});
|
|
document.getElementById('bp-result').classList.add('hidden');
|
|
var bpc = document.getElementById('bp-chart-container'); if (bpc) bpc.classList.add('hidden');
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// BMI PERCENTILE — CDC 2000
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
// CDC BMI-for-age LMS values load from /data/calculators/bmi-lms.json.
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// CHART.JS GROWTH CURVE RENDERING
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
// 7-line reference set: 3, 10, 25, 50, 75, 90, 97.
|
|
// Drops 5th and 95th (they crowded the 3rd/10th and 90th/97th labels
|
|
// on mobile without adding clinical value — 3rd/97th are the
|
|
// abnormal-threshold lines, the inner percentiles give the trend).
|
|
var PERCENTILE_LINES = [
|
|
{ p: 3, z: -1.88079, color: 'rgba(220,38,38,0.9)', dash: [6,4], width: 1.4 }, // red
|
|
{ p: 10, z: -1.28155, color: 'rgba(234,88,12,0.9)', dash: [], width: 1.2 }, // orange
|
|
{ p: 25, z: -0.67449, color: 'rgba(202,138,4,0.85)', dash: [], width: 1.1 }, // amber
|
|
{ p: 50, z: 0, color: 'rgba(22,163,74,0.95)', dash: [], width: 2.4 }, // green (center)
|
|
{ p: 75, z: 0.67449, color: 'rgba(37,99,235,0.85)', dash: [], width: 1.1 }, // blue
|
|
{ p: 90, z: 1.28155, color: 'rgba(124,58,237,0.9)', dash: [], width: 1.2 }, // violet
|
|
{ p: 97, z: 1.88079, color: 'rgba(219,39,119,0.9)', dash: [6,4], width: 1.4 } // pink
|
|
];
|
|
|
|
function generatePercentileCurves(lmsTable, percentileLines) {
|
|
var ages = Object.keys(lmsTable).map(Number).sort(function(a,b){return a-b;});
|
|
var datasets = [];
|
|
percentileLines.forEach(function(pl, idx) {
|
|
var points = [];
|
|
ages.forEach(function(age) {
|
|
var lms = lmsTable[age];
|
|
var val = valueFromLMS(lms.L, lms.M, lms.S, pl.z);
|
|
if (isFinite(val) && val > 0) points.push({ x: age, y: Math.round(val * 100) / 100 });
|
|
});
|
|
datasets.push({
|
|
label: pl.p + 'th',
|
|
data: points,
|
|
borderColor: pl.color,
|
|
borderWidth: pl.width,
|
|
borderDash: pl.dash,
|
|
pointRadius: 0,
|
|
pointHoverRadius: 0,
|
|
fill: false,
|
|
tension: 0.4,
|
|
order: 10
|
|
});
|
|
});
|
|
// Fill bands between symmetric percentile pairs.
|
|
// Indexes: 3=0, 10=1, 25=2, 50=3, 75=4, 90=5, 97=6
|
|
var fills = [
|
|
{ outer: 0, inner: 6, color: 'rgba(220,38,38,0.04)' }, // 3rd-97th (widest)
|
|
{ outer: 1, inner: 5, color: 'rgba(234,88,12,0.04)' }, // 10th-90th
|
|
{ outer: 2, inner: 4, color: 'rgba(163,163,163,0.05)' } // 25th-75th (IQR)
|
|
];
|
|
fills.forEach(function(f) {
|
|
if (datasets[f.outer] && datasets[f.inner]) {
|
|
datasets[f.outer].fill = { target: f.inner, above: f.color, below: f.color };
|
|
}
|
|
});
|
|
return datasets;
|
|
}
|
|
|
|
var _chartInstances = {};
|
|
|
|
// Chart.js plugin — labels each percentile curve at its right edge,
|
|
// spreads labels bidirectionally so top (97/95/90) and bottom (3/5/10)
|
|
// clusters both breathe, draws a thin leader from each label to the
|
|
// actual curve position when nudged, then repaints the patient dot
|
|
// ON TOP so it's never obscured by a label.
|
|
var percentileLabelPlugin = {
|
|
id: 'percentileLabels',
|
|
afterDatasetsDraw: function(chart) {
|
|
var ctx = chart.ctx;
|
|
var font = '700 11px -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif';
|
|
var MIN_GAP = 15;
|
|
|
|
// Collect percentile-line labels with native screen positions
|
|
var items = [];
|
|
chart.data.datasets.forEach(function(ds, i) {
|
|
if (!ds.label || !/^\d+th$/.test(ds.label)) return;
|
|
var meta = chart.getDatasetMeta(i);
|
|
if (!meta || meta.hidden || !meta.data || !meta.data.length) return;
|
|
var last = meta.data[meta.data.length - 1];
|
|
if (!last) return;
|
|
items.push({ label: ds.label, color: ds.borderColor, x: last.x, nativeY: last.y, targetY: last.y });
|
|
});
|
|
// Top to bottom (ascending canvas Y)
|
|
items.sort(function(a, b) { return a.nativeY - b.nativeY; });
|
|
|
|
// Forward pass — push items DOWN when crowded by item above
|
|
for (var k = 1; k < items.length; k++) {
|
|
if (items[k].targetY - items[k - 1].targetY < MIN_GAP) {
|
|
items[k].targetY = items[k - 1].targetY + MIN_GAP;
|
|
}
|
|
}
|
|
// Backward pass — pull items UP when still crowded by item below
|
|
for (var j = items.length - 2; j >= 0; j--) {
|
|
if (items[j + 1].targetY - items[j].targetY < MIN_GAP) {
|
|
items[j].targetY = items[j + 1].targetY - MIN_GAP;
|
|
}
|
|
}
|
|
// Clamp to chart area so nothing clips top/bottom
|
|
var area = chart.chartArea;
|
|
if (area) {
|
|
items.forEach(function(it) {
|
|
if (it.targetY < area.top + 6) it.targetY = area.top + 6;
|
|
if (it.targetY > area.bottom - 6) it.targetY = area.bottom - 6;
|
|
});
|
|
}
|
|
|
|
// Draw leader + label
|
|
items.forEach(function(it) {
|
|
var stroke = typeof it.color === 'string' ? it.color : '#555';
|
|
var m = stroke.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
|
if (m) stroke = 'rgb(' + m[1] + ',' + m[2] + ',' + m[3] + ')';
|
|
ctx.save();
|
|
// Leader — only if the label was nudged away from its native Y
|
|
if (Math.abs(it.targetY - it.nativeY) > 2) {
|
|
ctx.strokeStyle = stroke;
|
|
ctx.globalAlpha = 0.55;
|
|
ctx.lineWidth = 0.8;
|
|
ctx.beginPath();
|
|
ctx.moveTo(it.x, it.nativeY);
|
|
ctx.lineTo(it.x + 4, it.targetY);
|
|
ctx.stroke();
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
// Label with white halo
|
|
ctx.font = font;
|
|
ctx.textAlign = 'left';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.lineWidth = 3;
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.92)';
|
|
ctx.strokeText(it.label, it.x + 5, it.targetY);
|
|
ctx.fillStyle = stroke;
|
|
ctx.fillText(it.label, it.x + 5, it.targetY);
|
|
ctx.restore();
|
|
});
|
|
|
|
// Repaint the patient dot ON TOP of the labels so it's never
|
|
// covered by a nearby percentile label.
|
|
chart.data.datasets.forEach(function(ds, i) {
|
|
if (ds.label !== 'Patient') return;
|
|
var meta = chart.getDatasetMeta(i);
|
|
if (!meta || !meta.data || !meta.data.length) return;
|
|
var pt = meta.data[0];
|
|
if (!pt) return;
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.arc(pt.x, pt.y, 5, 0, Math.PI * 2);
|
|
ctx.fillStyle = '#2563eb';
|
|
ctx.fill();
|
|
ctx.lineWidth = 1.8;
|
|
ctx.strokeStyle = '#ffffff';
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
});
|
|
}
|
|
};
|
|
|
|
function renderGrowthChart(canvasId, containerId, lmsTable, patientX, patientY, xLabel, yLabel, extraDatasets) {
|
|
if (typeof Chart === 'undefined') return; // Chart.js not loaded yet
|
|
|
|
var container = document.getElementById(containerId);
|
|
if (!container) return;
|
|
container.classList.remove('hidden');
|
|
|
|
if (_chartInstances[canvasId]) { _chartInstances[canvasId].destroy(); _chartInstances[canvasId] = null; }
|
|
|
|
var datasets = generatePercentileCurves(lmsTable, PERCENTILE_LINES);
|
|
|
|
// Patient data point — drawn on top of every curve. White ring so
|
|
// it reads cleanly even when it lands directly on a colored line.
|
|
datasets.push({
|
|
label: 'Patient',
|
|
data: [{ x: patientX, y: patientY }],
|
|
backgroundColor: '#2563eb',
|
|
borderColor: '#ffffff',
|
|
borderWidth: 1.8,
|
|
pointRadius: 5,
|
|
pointHoverRadius: 7,
|
|
pointStyle: 'circle',
|
|
showLine: false,
|
|
type: 'scatter',
|
|
order: -1
|
|
});
|
|
|
|
if (extraDatasets) extraDatasets.forEach(function(d) { datasets.push(d); });
|
|
|
|
_chartInstances[canvasId] = new Chart(document.getElementById(canvasId), {
|
|
type: 'line',
|
|
data: { datasets: datasets },
|
|
plugins: [percentileLabelPlugin],
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: true,
|
|
aspectRatio: 1.6,
|
|
animation: { duration: 300 },
|
|
// Leave ~28px of padding on the right so the percentile labels
|
|
// ("50th", "95th", ...) drawn past the last data point are not
|
|
// clipped by the canvas edge.
|
|
layout: { padding: { right: 30 } },
|
|
scales: {
|
|
x: {
|
|
type: 'linear',
|
|
title: { display: true, text: xLabel, font: { size: 12, weight: 'bold' } },
|
|
grid: { color: 'rgba(0,0,0,0.05)' }
|
|
},
|
|
y: {
|
|
title: { display: true, text: yLabel, font: { size: 12, weight: 'bold' } },
|
|
grid: { color: 'rgba(0,0,0,0.05)' }
|
|
}
|
|
},
|
|
plugins: {
|
|
legend: { display: false },
|
|
tooltip: {
|
|
callbacks: {
|
|
label: function(ctx) {
|
|
if (ctx.dataset.label === 'Patient') return 'Patient: ' + ctx.parsed.y.toFixed(1) + ' ' + yLabel.split('(').pop().replace(')', '');
|
|
return ctx.dataset.label + ': ' + ctx.parsed.y.toFixed(1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
var BMI_LMS = null;
|
|
loadBmiLmsData()
|
|
.then(function(data) { BMI_LMS = data; })
|
|
.catch(function(err) { console.warn('[calculators] BMI LMS data unavailable:', err && err.message); });
|
|
|
|
document.getElementById('btn-calc-bmi').addEventListener('click', function() {
|
|
var ageYr = parseFloat(document.getElementById('bmi-age-yr').value) || 0;
|
|
var ageMo = parseInt(document.getElementById('bmi-age-mo').value) || 0;
|
|
var age = ageYr + ageMo / 12;
|
|
var sex = document.getElementById('bmi-sex').value;
|
|
var weight = parseFloat(document.getElementById('bmi-weight').value);
|
|
var height = parseFloat(document.getElementById('bmi-height').value);
|
|
|
|
if (!age || !sex || !weight || !height) { showToast('Fill in all fields', 'error'); return; }
|
|
if (age < 2 || age > 20) { showToast('Age must be 2-20 years', 'error'); return; }
|
|
if (!BMI_LMS) { showToast('BMI percentile data is still loading.', 'info'); return; }
|
|
|
|
var assessment = calculateBMIAssessment(BMI_LMS, sex, age, weight, height);
|
|
var bmi = assessment.bmi;
|
|
var ageMonths = assessment.ageMonths;
|
|
var result = { percentile: assessment.percentile, z: assessment.z };
|
|
var cl = assessment.classification;
|
|
|
|
var extendedInfo = '';
|
|
if (result.percentile >= 85) {
|
|
extendedInfo = '<div class="calc-result-item"><div class="calc-result-label">% of 95th</div><div class="calc-result-value" style="font-size:16px;">' + cl.pctOf95.toFixed(0) + '%</div><div class="calc-result-sub">95th = ' + cl.bmi95.toFixed(1) + ' kg/m²</div></div>';
|
|
}
|
|
|
|
var resultDiv = document.getElementById('bmi-result');
|
|
resultDiv.classList.remove('hidden');
|
|
resultDiv.innerHTML =
|
|
'<div class="calc-result-header" style="background:' + cl.bg + ';border-color:' + cl.color + ';">' +
|
|
'<div style="font-size:18px;font-weight:700;color:' + cl.color + ';">' + cl.text + '</div>' +
|
|
'<div style="font-size:13px;color:var(--g600);margin-top:4px;">BMI ' + bmi.toFixed(1) + ' kg/m² — ' + result.percentile + 'th percentile' + (cl.pctOf95 && result.percentile >= 95 ? ' (' + cl.pctOf95.toFixed(0) + '% of 95th)' : '') + '</div>' +
|
|
'</div>' +
|
|
'<div class="calc-result-grid">' +
|
|
'<div class="calc-result-item"><div class="calc-result-label">BMI</div><div class="calc-result-value">' + bmi.toFixed(1) + '</div><div class="calc-result-sub">kg/m²</div></div>' +
|
|
'<div class="calc-result-item"><div class="calc-result-label">Percentile</div><div class="calc-result-value">' + result.percentile + '<span style="font-size:12px;">th</span></div></div>' +
|
|
'<div class="calc-result-item"><div class="calc-result-label">Z-Score</div><div class="calc-result-value" style="font-size:16px;">' + result.z.toFixed(2) + '</div></div>' +
|
|
extendedInfo +
|
|
'</div>';
|
|
var bmiYears = {}; Object.keys(BMI_LMS[sex]).forEach(function(k) { bmiYears[+(k/12).toFixed(1)] = BMI_LMS[sex][k]; });
|
|
renderGrowthChart('bmi-chart-canvas', 'bmi-chart-container', bmiYears, +(ageMonths/12).toFixed(1), bmi, 'Age (years)', 'BMI (kg/m2)');
|
|
});
|
|
|
|
document.getElementById('btn-clear-bmi').addEventListener('click', function() {
|
|
['bmi-age-yr', 'bmi-sex', 'bmi-weight', 'bmi-height'].forEach(function(id) {
|
|
var el = document.getElementById(id); if (el) el.value = '';
|
|
});
|
|
document.getElementById('bmi-result').classList.add('hidden');
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// BODY SURFACE AREA — Mosteller
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
document.getElementById('btn-calc-bsa').addEventListener('click', function() {
|
|
var weight = parseFloat(document.getElementById('bsa-weight').value);
|
|
var height = parseFloat(document.getElementById('bsa-height').value);
|
|
if (!weight || !height) { showToast('Enter weight and height', 'error'); return; }
|
|
|
|
var bsa = calculateBsa(weight, height);
|
|
var resultDiv = document.getElementById('bsa-result');
|
|
resultDiv.classList.remove('hidden');
|
|
resultDiv.innerHTML =
|
|
'<div class="calc-result-header" style="background:#e0f2fe;border-color:#0ea5e9;">' +
|
|
'<div style="font-size:18px;font-weight:700;color:#0369a1;">BSA: ' + bsa.toFixed(3) + ' m²</div>' +
|
|
'<div style="font-size:13px;color:var(--g600);margin-top:4px;">' + weight + ' kg, ' + height + ' cm (Mosteller formula)</div>' +
|
|
'</div>';
|
|
});
|
|
|
|
document.getElementById('btn-clear-bsa').addEventListener('click', function() {
|
|
['bsa-weight', 'bsa-height'].forEach(function(id) {
|
|
var el = document.getElementById(id); if (el) el.value = '';
|
|
});
|
|
document.getElementById('bsa-result').classList.add('hidden');
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// WEIGHT-BASED DOSING
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
document.getElementById('btn-calc-dose').addEventListener('click', function() {
|
|
var weight = parseFloat(document.getElementById('dose-weight').value);
|
|
var dosePerKg = parseFloat(document.getElementById('dose-per-kg').value);
|
|
var freq = parseInt(document.getElementById('dose-freq').value);
|
|
var maxDose = parseFloat(document.getElementById('dose-max').value) || 0;
|
|
var conc = parseFloat(document.getElementById('dose-conc').value) || 0;
|
|
|
|
if (!weight || !dosePerKg) { showToast('Enter weight and dose', 'error'); return; }
|
|
|
|
var dose = calculateWeightBasedDose(weight, dosePerKg, freq, maxDose, conc);
|
|
var singleDose = dose.singleDose;
|
|
var dailyDose = dose.dailyDose;
|
|
var capped = dose.capped;
|
|
|
|
var rows = '<div class="calc-result-item"><div class="calc-result-label">Single Dose</div><div class="calc-result-value">' + singleDose.toFixed(1) + ' <span style="font-size:12px;">mg</span></div>'
|
|
+ (capped ? '<div class="calc-result-sub" style="color:var(--red);">Capped at max ' + maxDose + ' mg</div>' : '') + '</div>'
|
|
+ '<div class="calc-result-item"><div class="calc-result-label">Daily Total</div><div class="calc-result-value">' + dailyDose.toFixed(1) + ' <span style="font-size:12px;">mg/day</span></div>'
|
|
+ '<div class="calc-result-sub">' + singleDose.toFixed(1) + ' mg x ' + freq + '/day</div></div>';
|
|
|
|
if (conc > 0) {
|
|
var vol = dose.volumeMl;
|
|
rows += '<div class="calc-result-item"><div class="calc-result-label">Volume per Dose</div><div class="calc-result-value">' + vol.toFixed(1) + ' <span style="font-size:12px;">mL</span></div>'
|
|
+ '<div class="calc-result-sub">at ' + conc + ' mg/mL</div></div>';
|
|
}
|
|
|
|
var resultDiv = document.getElementById('dose-result');
|
|
resultDiv.classList.remove('hidden');
|
|
resultDiv.innerHTML =
|
|
'<div class="calc-result-header" style="background:#ede9fe;border-color:#7c3aed;">' +
|
|
'<div style="font-size:18px;font-weight:700;color:#6d28d9;">' + singleDose.toFixed(1) + ' mg per dose</div>' +
|
|
'<div style="font-size:13px;color:var(--g600);margin-top:4px;">' + weight + ' kg x ' + dosePerKg + ' mg/kg' + (capped ? ' (max ' + maxDose + ' mg)' : '') + '</div>' +
|
|
'</div>' +
|
|
'<div class="calc-result-grid">' + rows + '</div>' +
|
|
'<p style="font-size:11px;color:var(--g400);margin:12px 0 0;">Always verify dosing against your formulary and institutional guidelines.</p>';
|
|
});
|
|
|
|
document.getElementById('btn-clear-dose').addEventListener('click', function() {
|
|
['dose-weight', 'dose-per-kg', 'dose-max', 'dose-conc'].forEach(function(id) {
|
|
var el = document.getElementById(id); if (el) el.value = '';
|
|
});
|
|
document.getElementById('dose-freq').value = '1';
|
|
document.getElementById('dose-result').classList.add('hidden');
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// GROWTH CHARTS — WHO 0-2y / CDC 2-20y + Fenton Preterm
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
var currentGrowthType = 'wfa';
|
|
|
|
// WHO/CDC/Fenton growth LMS values load from /data/calculators/growth-lms.json.
|
|
var wfaLMS = null;
|
|
var cdcWfaLMS = null;
|
|
var lfaLMS = null;
|
|
var cdcLfaLMS = null;
|
|
var hcfaLMS = null;
|
|
var wflLMS = null;
|
|
var fentonLMS = null;
|
|
|
|
loadGrowthLmsData()
|
|
.then(function(data) {
|
|
wfaLMS = data.wfaLMS;
|
|
cdcWfaLMS = data.cdcWfaLMS;
|
|
lfaLMS = data.lfaLMS;
|
|
cdcLfaLMS = data.cdcLfaLMS;
|
|
hcfaLMS = data.hcfaLMS;
|
|
wflLMS = data.wflLMS;
|
|
fentonLMS = data.fentonLMS;
|
|
})
|
|
.catch(function(err) { console.warn('[calculators] Growth LMS data unavailable:', err && err.message); });
|
|
|
|
// Growth sub-pill navigation
|
|
document.querySelectorAll('[data-growth]').forEach(function(pill) {
|
|
pill.addEventListener('click', function() {
|
|
document.querySelectorAll('[data-growth]').forEach(function(p) { p.classList.remove('active'); });
|
|
pill.classList.add('active');
|
|
currentGrowthType = pill.dataset.growth;
|
|
var isFenton = currentGrowthType === 'fenton';
|
|
var isHC = currentGrowthType === 'hcfa';
|
|
var isWFL = currentGrowthType === 'wfl';
|
|
document.getElementById('growth-age-field').style.display = (isFenton || isWFL) ? 'none' : '';
|
|
document.getElementById('growth-ga-field').style.display = isFenton ? '' : 'none';
|
|
document.getElementById('growth-hc-field').style.display = isHC ? '' : 'none';
|
|
document.getElementById('growth-weight-field').style.display = isHC ? 'none' : '';
|
|
document.getElementById('growth-length-field').style.display = (currentGrowthType === 'wfa') ? 'none' : (isFenton ? 'none' : '');
|
|
var src = document.getElementById('growth-source-text');
|
|
if (isFenton) src.textContent = 'Fenton 2013 preterm growth charts (22-50 weeks GA). Weight in grams.';
|
|
else if (isHC) src.textContent = 'WHO head circumference-for-age (0-36 months).';
|
|
else if (isWFL) src.textContent = 'WHO weight-for-length (45-110 cm, 0-2 years).';
|
|
else src.textContent = 'WHO growth standards (0-2y) / CDC 2000 (2-20y).';
|
|
document.getElementById('growth-result').classList.add('hidden');
|
|
var chartContainer = document.getElementById('growth-chart-container');
|
|
if (chartContainer) chartContainer.classList.add('hidden');
|
|
if (_chartInstances['growth-chart-canvas']) { _chartInstances['growth-chart-canvas'].destroy(); _chartInstances['growth-chart-canvas'] = null; }
|
|
// Show MPH section only for LFA
|
|
var mphSection = document.getElementById('growth-mph-section');
|
|
if (mphSection) mphSection.style.display = (currentGrowthType === 'lfa') ? '' : 'none';
|
|
});
|
|
});
|
|
|
|
document.getElementById('btn-calc-growth').addEventListener('click', function() {
|
|
var sex = document.getElementById('growth-sex').value;
|
|
if (!sex) { showToast('Select sex', 'error'); return; }
|
|
if (!wfaLMS || !cdcWfaLMS || !lfaLMS || !cdcLfaLMS || !hcfaLMS || !wflLMS || !fentonLMS) {
|
|
showToast('Growth chart data is still loading.', 'info'); return;
|
|
}
|
|
|
|
var resultDiv = document.getElementById('growth-result');
|
|
|
|
if (currentGrowthType === 'fenton') {
|
|
var ga = parseFloat(document.getElementById('growth-ga').value);
|
|
var wt = parseFloat(document.getElementById('growth-weight').value);
|
|
if (!ga || !wt) { showToast('Enter gestational age and weight', 'error'); return; }
|
|
var wtGrams = wt * 1000;
|
|
var lms = interpolateLMS(fentonLMS[sex], ga);
|
|
var z = calcZFromLMS(wtGrams, lms.L, lms.M, lms.S);
|
|
var p = normalCDF(z) * 100;
|
|
resultDiv.classList.remove('hidden');
|
|
resultDiv.innerHTML = renderGrowthResult('Weight at ' + ga + ' weeks GA', wt + ' kg (' + Math.round(wtGrams) + 'g)', '', z, p);
|
|
renderGrowthChart('growth-chart-canvas', 'growth-chart-container', fentonLMS[sex], ga, wtGrams, 'Gestational Age (weeks)', 'Weight (g)');
|
|
return;
|
|
}
|
|
|
|
var yrEl = document.getElementById('growth-age-yr');
|
|
var moEl = document.getElementById('growth-age-mo');
|
|
var dyEl = document.getElementById('growth-age-d');
|
|
var yrRaw = yrEl ? yrEl.value.trim() : '';
|
|
var moRaw = moEl ? moEl.value.trim() : '';
|
|
var dyRaw = dyEl ? dyEl.value.trim() : '';
|
|
// Treat blank fields as 0, but require at least ONE field to have any value.
|
|
// "0 days" (newborn at birth) is valid — explicit zero counts.
|
|
if (yrRaw === '' && moRaw === '' && dyRaw === '') {
|
|
showToast('Enter age (years, months, or days)', 'error'); return;
|
|
}
|
|
var yr = parseFloat(yrRaw) || 0;
|
|
var mo = parseFloat(moRaw) || 0;
|
|
var dy = parseFloat(dyRaw) || 0;
|
|
var age = yr * 12 + mo + dy / 30.4375; // age in months, fractional OK
|
|
if (age < 0) { showToast('Age cannot be negative', 'error'); return; }
|
|
|
|
if (currentGrowthType === 'wfa') {
|
|
var wt = parseFloat(document.getElementById('growth-weight').value);
|
|
if (!wt) { showToast('Enter weight', 'error'); return; }
|
|
// Use WHO for 0-60mo, CDC for >60mo
|
|
var wfaTable = (age > 24) ? cdcWfaLMS[sex] : wfaLMS[sex];
|
|
var wfaChartTable = (age > 24) ? cdcWfaLMS[sex] : wfaLMS[sex];
|
|
var lms = interpolateLMS(wfaTable, age);
|
|
var z = calcZFromLMS(wt, lms.L, lms.M, lms.S);
|
|
var p = normalCDF(z) * 100;
|
|
var src = (age > 24) ? 'CDC' : 'WHO';
|
|
resultDiv.classList.remove('hidden');
|
|
resultDiv.innerHTML = renderGrowthResult('Weight-for-Age (' + age + ' mo, ' + src + ')', wt, 'kg', z, p);
|
|
if (age > 24) {
|
|
var wfaYears = {}; Object.keys(wfaChartTable).forEach(function(k) { wfaYears[+(k/12).toFixed(2)] = wfaChartTable[k]; });
|
|
renderGrowthChart('growth-chart-canvas', 'growth-chart-container', wfaYears, +(age/12).toFixed(2), wt, 'Age (years)', 'Weight (kg)');
|
|
} else {
|
|
renderGrowthChart('growth-chart-canvas', 'growth-chart-container', wfaChartTable, age, wt, 'Age (months)', 'Weight (kg)');
|
|
}
|
|
}
|
|
else if (currentGrowthType === 'lfa') {
|
|
var len = parseFloat(document.getElementById('growth-length').value);
|
|
if (!len) { showToast('Enter length/height', 'error'); return; }
|
|
// Use WHO for 0-60mo, CDC for >60mo
|
|
var lfaTable = (age > 24) ? cdcLfaLMS[sex] : lfaLMS[sex];
|
|
var lms = interpolateLMS(lfaTable, age);
|
|
var z = calcZFromLMS(len, lms.L, lms.M, lms.S);
|
|
var p = normalCDF(z) * 100;
|
|
var src = (age > 24) ? 'CDC' : 'WHO';
|
|
resultDiv.classList.remove('hidden');
|
|
resultDiv.innerHTML = renderGrowthResult('Length/Height-for-Age (' + age + ' mo, ' + src + ')', len, 'cm', z, p);
|
|
// Mid-parental height
|
|
var mphDatasets = [];
|
|
var motherHt = parseFloat((document.getElementById('growth-mother-ht') || {}).value);
|
|
var fatherHt = parseFloat((document.getElementById('growth-father-ht') || {}).value);
|
|
var mph = calcMidParentalHeight(motherHt, fatherHt, sex);
|
|
if (mph) {
|
|
// Calculate what percentile the MPH corresponds to using adult height LMS (~18y = 216mo)
|
|
var adultLms = interpolateLMS(cdcLfaLMS[sex], 216);
|
|
var mphZ = calcZFromLMS(mph.target, adultLms.L, adultLms.M, adultLms.S);
|
|
var mphPctile = (Math.round(normalCDF(mphZ) * 10000) / 100).toFixed(2);
|
|
resultDiv.innerHTML += '<div style="margin-top:8px;padding:8px 12px;background:#ede9fe;border-radius:6px;font-size:13px;color:#6d28d9;"><strong>Mid-Parental Height:</strong> ' + mph.target.toFixed(1) + ' cm (' + mphPctile + 'th percentile) — target range: ' + mph.low.toFixed(1) + ' - ' + mph.high.toFixed(1) + ' cm</div>';
|
|
// MPH coordinates: use years if age > 24mo
|
|
var mphX1 = age > 24 ? 17 : 204;
|
|
var mphX2 = age > 24 ? 20 : 240;
|
|
mphDatasets.push({ label: 'MPH Range', data: [{x:mphX1,y:mph.low},{x:mphX2,y:mph.low}], borderColor:'rgba(139,92,246,0.3)', backgroundColor:'rgba(139,92,246,0.1)', fill:'+1', pointRadius:0, borderWidth:1, tension:0, order:5 });
|
|
mphDatasets.push({ label: 'MPH Upper', data: [{x:mphX1,y:mph.high},{x:mphX2,y:mph.high}], borderColor:'rgba(139,92,246,0.3)', fill:false, pointRadius:0, borderWidth:1, tension:0, order:5 });
|
|
mphDatasets.push({ label: 'MPH ' + mph.target.toFixed(0) + 'cm (' + mphPctile + 'th %ile)', data: [{x:mphX1,y:mph.target},{x:mphX2,y:mph.target}], borderColor:'rgba(139,92,246,0.8)', borderDash:[6,3], fill:false, pointRadius:3, pointHoverRadius:6, borderWidth:2, tension:0, order:5 });
|
|
}
|
|
if (age > 24) {
|
|
var lfaYears = {}; Object.keys(lfaTable).forEach(function(k) { lfaYears[+(k/12).toFixed(2)] = lfaTable[k]; });
|
|
renderGrowthChart('growth-chart-canvas', 'growth-chart-container', lfaYears, +(age/12).toFixed(2), len, 'Age (years)', 'Length/Height (cm)', mphDatasets);
|
|
} else {
|
|
renderGrowthChart('growth-chart-canvas', 'growth-chart-container', lfaTable, age, len, 'Age (months)', 'Length/Height (cm)', mphDatasets);
|
|
}
|
|
}
|
|
else if (currentGrowthType === 'hcfa') {
|
|
var hc = parseFloat(document.getElementById('growth-hc').value);
|
|
if (!hc) { showToast('Enter head circumference', 'error'); return; }
|
|
if (age > 60) { showToast('HC charts available 0-60 months only', 'error'); return; }
|
|
var lms = interpolateLMS(hcfaLMS[sex], age);
|
|
var z = calcZFromLMS(hc, lms.L, lms.M, lms.S);
|
|
var p = normalCDF(z) * 100;
|
|
resultDiv.classList.remove('hidden');
|
|
resultDiv.innerHTML = renderGrowthResult('Head Circumference (' + age + ' mo)', hc, 'cm', z, p);
|
|
if (age > 24) {
|
|
var hcYears = {}; Object.keys(hcfaLMS[sex]).forEach(function(k) { hcYears[+(k/12).toFixed(2)] = hcfaLMS[sex][k]; });
|
|
renderGrowthChart('growth-chart-canvas', 'growth-chart-container', hcYears, +(age/12).toFixed(2), hc, 'Age (years)', 'Head Circumference (cm)');
|
|
} else {
|
|
renderGrowthChart('growth-chart-canvas', 'growth-chart-container', hcfaLMS[sex], age, hc, 'Age (months)', 'Head Circumference (cm)');
|
|
}
|
|
}
|
|
else if (currentGrowthType === 'wfl') {
|
|
var wt = parseFloat(document.getElementById('growth-weight').value);
|
|
var len = parseFloat(document.getElementById('growth-length').value);
|
|
if (!wt || !len) { showToast('Enter weight and length', 'error'); return; }
|
|
// Use WHO Weight-for-Length if length is within 45-110cm range
|
|
if (len >= 45 && len <= 110 && wflLMS[sex]) {
|
|
var lms = interpolateLMS(wflLMS[sex], len);
|
|
var z = calcZFromLMS(wt, lms.L, lms.M, lms.S);
|
|
var p = normalCDF(z) * 100;
|
|
resultDiv.classList.remove('hidden');
|
|
resultDiv.innerHTML = renderGrowthResult('Weight-for-Length (' + wt + 'kg / ' + len + 'cm, WHO)', wt, 'kg', z, p);
|
|
renderGrowthChart('growth-chart-canvas', 'growth-chart-container', wflLMS[sex], len, wt, 'Length (cm)', 'Weight (kg)');
|
|
} else {
|
|
// Fall back to BMI-for-Age for children outside WFL range
|
|
if (!BMI_LMS) { showToast('BMI percentile data is still loading.', 'info'); return; }
|
|
var bmi = wt / Math.pow(len / 100, 2);
|
|
var ageMonths = Math.max(24, Math.min(240, Math.round(age)));
|
|
var lms = interpolateLMS(BMI_LMS[sex], ageMonths);
|
|
var z = calcZFromLMS(bmi, lms.L, lms.M, lms.S);
|
|
var p = normalCDF(z) * 100;
|
|
resultDiv.classList.remove('hidden');
|
|
resultDiv.innerHTML = renderGrowthResult('Weight-for-Length (' + wt + 'kg / ' + len + 'cm, BMI proxy)', 'BMI ' + bmi.toFixed(1), 'kg/m2', z, p);
|
|
}
|
|
}
|
|
});
|
|
|
|
document.getElementById('btn-clear-growth').addEventListener('click', function() {
|
|
['growth-sex', 'growth-age-yr', 'growth-age-mo', 'growth-age-d', 'growth-ga', 'growth-weight', 'growth-length', 'growth-hc'].forEach(function(id) {
|
|
var el = document.getElementById(id); if (el) el.value = '';
|
|
});
|
|
var hint = document.getElementById('growth-age-parsed'); if (hint) hint.textContent = '';
|
|
document.getElementById('growth-result').classList.add('hidden');
|
|
});
|
|
|
|
// Live feedback as the age fields change
|
|
var ageHint = document.getElementById('growth-age-parsed');
|
|
function updateAgeHint() {
|
|
if (!ageHint) return;
|
|
var yrRaw = ((document.getElementById('growth-age-yr') || {}).value || '').trim();
|
|
var moRaw = ((document.getElementById('growth-age-mo') || {}).value || '').trim();
|
|
var dyRaw = ((document.getElementById('growth-age-d') || {}).value || '').trim();
|
|
if (yrRaw === '' && moRaw === '' && dyRaw === '') { ageHint.textContent = ''; return; }
|
|
var total = (parseFloat(yrRaw) || 0) * 12 + (parseFloat(moRaw) || 0) + (parseFloat(dyRaw) || 0) / 30.4375;
|
|
ageHint.style.color = 'var(--g500)';
|
|
ageHint.textContent = formatParsedAge(total);
|
|
}
|
|
['growth-age-yr', 'growth-age-mo', 'growth-age-d'].forEach(function(id) {
|
|
var el = document.getElementById(id);
|
|
if (el) el.addEventListener('input', updateAgeHint);
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// BILIRUBIN — AAP 2022 Phototherapy + Bhutani Nomogram
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
// ── Bilirubin chart helpers ──
|
|
|
|
function generateThresholdCurve(table) {
|
|
var keys = Object.keys(table).map(Number).sort(function(a,b){return a-b;});
|
|
return keys.map(function(h) { return { x: h, y: table[h] }; });
|
|
}
|
|
|
|
function renderBiliChart(patientHours, patientTSB, curveDatasets) {
|
|
if (typeof Chart === 'undefined') return;
|
|
var container = document.getElementById('bili-chart-container');
|
|
if (!container) return;
|
|
container.classList.remove('hidden');
|
|
|
|
if (_chartInstances['bili-chart-canvas']) { _chartInstances['bili-chart-canvas'].destroy(); _chartInstances['bili-chart-canvas'] = null; }
|
|
|
|
var datasets = curveDatasets.map(function(ds) {
|
|
return Object.assign({ pointRadius: 0, pointHoverRadius: 0, tension: 0.3, order: 10 }, ds);
|
|
});
|
|
|
|
// Patient point
|
|
datasets.push({
|
|
label: 'Patient',
|
|
data: [{ x: patientHours, y: patientTSB }],
|
|
backgroundColor: '#2563eb',
|
|
borderColor: '#1d4ed8',
|
|
pointRadius: 8,
|
|
pointHoverRadius: 10,
|
|
showLine: false,
|
|
type: 'scatter',
|
|
order: 0
|
|
});
|
|
|
|
_chartInstances['bili-chart-canvas'] = new Chart(document.getElementById('bili-chart-canvas'), {
|
|
type: 'line',
|
|
data: { datasets: datasets },
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: true,
|
|
aspectRatio: 1.6,
|
|
animation: { duration: 300 },
|
|
scales: {
|
|
x: { type: 'linear', title: { display: true, text: 'Age (hours)', font: { size: 12, weight: 'bold' } }, min: 12, max: 150, grid: { color: 'rgba(0,0,0,0.05)' } },
|
|
y: { title: { display: true, text: 'TSB (mg/dL)', font: { size: 12, weight: 'bold' } }, min: 0, grid: { color: 'rgba(0,0,0,0.05)' } }
|
|
},
|
|
plugins: {
|
|
legend: { display: true, position: 'bottom', labels: { font: { size: 11 }, usePointStyle: true, pointStyle: 'line' } },
|
|
tooltip: {
|
|
callbacks: {
|
|
label: function(ctx) {
|
|
if (ctx.dataset.label === 'Patient') return 'Patient: ' + ctx.parsed.y.toFixed(1) + ' mg/dL at ' + ctx.parsed.x + 'h';
|
|
return ctx.dataset.label + ': ' + ctx.parsed.y.toFixed(1) + ' mg/dL';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// AAP 2022 bilirubin treatment thresholds load from /data/calculators/aap-bilirubin-thresholds.json.
|
|
|
|
// Bili sub-pill nav
|
|
document.querySelectorAll('[data-bili]').forEach(function(pill) {
|
|
pill.addEventListener('click', function() {
|
|
document.querySelectorAll('[data-bili]').forEach(function(p) { p.classList.remove('active'); });
|
|
pill.classList.add('active');
|
|
document.getElementById('bili-aap-panel').style.display = pill.dataset.bili === 'aap' ? '' : 'none';
|
|
document.getElementById('bili-bhutani-panel').style.display = pill.dataset.bili === 'bhutani' ? '' : 'none';
|
|
document.getElementById('bili-result').classList.add('hidden');
|
|
var biliChartC = document.getElementById('bili-chart-container');
|
|
if (biliChartC) biliChartC.classList.add('hidden');
|
|
if (_chartInstances['bili-chart-canvas']) { _chartInstances['bili-chart-canvas'].destroy(); _chartInstances['bili-chart-canvas'] = null; }
|
|
});
|
|
});
|
|
|
|
var AAP_BILI_THRESHOLDS = null;
|
|
loadAapBilirubinThresholds()
|
|
.then(function(data) { AAP_BILI_THRESHOLDS = data; })
|
|
.catch(function(err) { console.warn('[calculators] AAP bilirubin threshold data unavailable:', err && err.message); });
|
|
|
|
document.getElementById('btn-calc-bili-aap').addEventListener('click', function() {
|
|
var ga = document.getElementById('bili-ga').value;
|
|
var hours = parseFloat(document.getElementById('bili-age-hours').value);
|
|
var tsb = parseFloat(document.getElementById('bili-tsb').value);
|
|
var risk = document.getElementById('bili-risk').value;
|
|
if (!ga || isNaN(hours) || isNaN(tsb)) { showToast('Fill in all fields', 'error'); return; }
|
|
if (!AAP_BILI_THRESHOLDS) { showToast('AAP bilirubin threshold data is still loading.', 'info'); return; }
|
|
|
|
var gaNum = parseInt(ga);
|
|
var assessment = calculateAapBilirubinAssessment(AAP_BILI_THRESHOLDS, gaNum, hours, tsb, risk);
|
|
var table = assessment.phototherapyTable;
|
|
var threshold = assessment.phototherapyThreshold;
|
|
var aboveThreshold = assessment.abovePhototherapy;
|
|
var exchangeTable = assessment.exchangeTable;
|
|
var exchangeThreshold = assessment.exchangeThreshold;
|
|
var aboveExchange = assessment.aboveExchange;
|
|
|
|
var cl;
|
|
if (aboveExchange) cl = { text: 'ABOVE EXCHANGE TRANSFUSION THRESHOLD', color: '#7f1d1d', bg: '#fecaca' };
|
|
else if (aboveThreshold) cl = { text: 'Above Phototherapy Threshold', color: '#ef4444', bg: '#fee2e2' };
|
|
else cl = { text: 'Below Phototherapy Threshold', color: '#10b981', bg: '#d1fae5' };
|
|
|
|
var resultDiv = document.getElementById('bili-result');
|
|
resultDiv.classList.remove('hidden');
|
|
resultDiv.innerHTML =
|
|
'<div class="calc-result-header" style="background:' + cl.bg + ';border-color:' + cl.color + ';">' +
|
|
'<div style="font-size:18px;font-weight:700;color:' + cl.color + ';">' + cl.text + '</div>' +
|
|
'<div style="font-size:13px;color:var(--g600);margin-top:4px;">TSB ' + tsb + ' mg/dL at ' + hours + ' hours of life</div>' +
|
|
'</div>' +
|
|
'<div class="calc-result-grid">' +
|
|
'<div class="calc-result-item"><div class="calc-result-label">Patient TSB</div><div class="calc-result-value">' + tsb + '</div><div class="calc-result-sub">mg/dL</div></div>' +
|
|
'<div class="calc-result-item"><div class="calc-result-label">Photo Threshold</div><div class="calc-result-value">' + threshold.toFixed(1) + '</div><div class="calc-result-sub">mg/dL at ' + hours + 'h</div></div>' +
|
|
'<div class="calc-result-item"><div class="calc-result-label">Margin</div><div class="calc-result-value" style="color:' + cl.color + ';">' + (tsb >= threshold ? '+' : '') + (tsb - threshold).toFixed(1) + '</div><div class="calc-result-sub">mg/dL ' + (aboveThreshold ? 'above' : 'below') + ' photo</div></div>' +
|
|
(exchangeThreshold ? '<div class="calc-result-item"><div class="calc-result-label">Exchange Threshold</div><div class="calc-result-value" style="color:#7f1d1d;">' + exchangeThreshold.toFixed(1) + '</div><div class="calc-result-sub">mg/dL' + (aboveExchange ? ' — EXCEEDED' : '') + '</div></div>' : '') +
|
|
'</div>' +
|
|
'<p style="font-size:11px;color:var(--g400);margin:12px 0 0;">GA ' + ga + ' weeks, ' + (risk === 'medium' ? 'with' : 'without') + ' neurotoxicity risk factors. AAP 2022 CPG (exact values from published nomograms). Always use clinical judgment.</p>';
|
|
|
|
// Chart: phototherapy + exchange threshold lines vs patient
|
|
var chartDatasets = [
|
|
{ label: 'Phototherapy Threshold', data: generateThresholdCurve(table), borderColor: '#ef4444', borderWidth: 2, borderDash: [], fill: { target: 'origin', above: 'rgba(239,68,68,0.08)' } }
|
|
];
|
|
if (exchangeTable) {
|
|
chartDatasets.push({ label: 'Exchange Transfusion', data: generateThresholdCurve(exchangeTable), borderColor: '#7f1d1d', borderWidth: 2, borderDash: [6,3], fill: false });
|
|
}
|
|
renderBiliChart(hours, tsb, chartDatasets);
|
|
});
|
|
|
|
document.getElementById('btn-clear-bili-aap').addEventListener('click', function() {
|
|
['bili-ga', 'bili-age-hours', 'bili-tsb', 'bili-risk'].forEach(function(id) {
|
|
var el = document.getElementById(id); if (el) el.value = '';
|
|
});
|
|
document.getElementById('bili-result').classList.add('hidden');
|
|
var bc = document.getElementById('bili-chart-container'); if (bc) bc.classList.add('hidden');
|
|
});
|
|
|
|
// Bhutani 1999 hour-specific risk nomogram — 40th / 75th / 95th
|
|
// percentile TSB boundaries. Values are a pre-digitized transcription
|
|
// of the original Figure 2 (Bhutani VK et al., Pediatrics 1999;103(1):6-14),
|
|
// lifted verbatim from the JS data arrays used by the codingace.net
|
|
// Bhutani calculator:
|
|
// https://codingace.net/health/neonate_bilirubin_bhutani.html
|
|
// Cross-checked against the AAP 2004 CPG reproduction of the same chart:
|
|
// https://staffnet.southernhealth.ca/wp-content/uploads/Bhutani-Nomogram.pdf
|
|
// (Reproduced with AAP permission from Pediatrics 2004;114:297-316)
|
|
// 6-hour granularity through the first 72 h, 12-hour after.
|
|
//
|
|
// Zone semantics (per AAP 2004 reproduction legend):
|
|
// High-risk: TSB >= 95th percentile
|
|
// High-intermediate: 75th <= TSB < 95th
|
|
// Low-intermediate: 40th <= TSB < 75th
|
|
// Low-risk: TSB < 40th
|
|
//
|
|
// Clinical note: this nomogram is for RISK STRATIFICATION only.
|
|
// For phototherapy/exchange decisions use the AAP 2022 tab
|
|
// (which supersedes the 2004 treatment thresholds).
|
|
var BHUTANI_ZONES = null;
|
|
loadBhutaniZones()
|
|
.then(function(data) { BHUTANI_ZONES = data; })
|
|
.catch(function(err) { console.warn('[calculators] Bhutani zone data unavailable:', err && err.message); });
|
|
|
|
document.getElementById('btn-calc-bhutani').addEventListener('click', function() {
|
|
var hours = parseFloat(document.getElementById('bhutani-age').value);
|
|
var tsb = parseFloat(document.getElementById('bhutani-tsb').value);
|
|
if (isNaN(hours) || isNaN(tsb)) { showToast('Fill in all fields', 'error'); return; }
|
|
if (!BHUTANI_ZONES) { showToast('Bhutani zone data is still loading.', 'info'); return; }
|
|
|
|
var riskZone = classifyBhutaniRisk(BHUTANI_ZONES, hours, tsb);
|
|
|
|
var resultDiv = document.getElementById('bili-result');
|
|
resultDiv.classList.remove('hidden');
|
|
resultDiv.innerHTML =
|
|
'<div class="calc-result-header" style="background:' + riskZone.bg + ';border-color:' + riskZone.color + ';">' +
|
|
'<div style="font-size:18px;font-weight:700;color:' + riskZone.color + ';">' + riskZone.zone + '</div>' +
|
|
'<div style="font-size:13px;color:var(--g600);margin-top:4px;">TSB ' + tsb + ' mg/dL at ' + hours + ' hours of life</div>' +
|
|
'</div>' +
|
|
'<div class="calc-result-grid">' +
|
|
'<div class="calc-result-item"><div class="calc-result-label">95th %ile</div><div class="calc-result-value" style="font-size:16px;color:#ef4444;">' + riskZone.p95.toFixed(1) + '</div><div class="calc-result-sub">High-risk</div></div>' +
|
|
'<div class="calc-result-item"><div class="calc-result-label">75th %ile</div><div class="calc-result-value" style="font-size:16px;color:#f97316;">' + riskZone.p75.toFixed(1) + '</div><div class="calc-result-sub">High-intermediate</div></div>' +
|
|
'<div class="calc-result-item"><div class="calc-result-label">40th %ile</div><div class="calc-result-value" style="font-size:16px;color:#f59e0b;">' + riskZone.p40.toFixed(1) + '</div><div class="calc-result-sub">Low-intermediate</div></div>' +
|
|
'</div>' +
|
|
'<p style="font-size:11px;color:var(--g400);margin:12px 0 0;">Bhutani hour-specific risk nomogram for infants ≥ 35 weeks GA. Digitized values from Bhutani et al., Pediatrics 1999 (cross-checked against the AAP 2004 CPG reproduction). For phototherapy decisions use the AAP 2022 tab.</p>';
|
|
|
|
// Chart: Bhutani zone curves with patient
|
|
renderBiliChart(hours, tsb, [
|
|
{ label: '95th (High-Risk)', data: generateThresholdCurve(BHUTANI_ZONES.p95), borderColor: '#ef4444', borderWidth: 2, fill: false, borderDash: [] },
|
|
{ label: '75th (High-Intermed)', data: generateThresholdCurve(BHUTANI_ZONES.p75), borderColor: '#f97316', borderWidth: 1.5, fill: { target: '-1', above: 'rgba(239,68,68,0.08)' }, borderDash: [3,3] },
|
|
{ label: '40th (Low-Intermed)', data: generateThresholdCurve(BHUTANI_ZONES.p40), borderColor: '#f59e0b', borderWidth: 1.5, fill: { target: '-1', above: 'rgba(249,115,22,0.08)' }, borderDash: [3,3] }
|
|
]);
|
|
});
|
|
|
|
document.getElementById('btn-clear-bhutani').addEventListener('click', function() {
|
|
['bhutani-age', 'bhutani-tsb'].forEach(function(id) {
|
|
var el = document.getElementById(id); if (el) el.value = '';
|
|
});
|
|
document.getElementById('bili-result').classList.add('hidden');
|
|
var bc = document.getElementById('bili-chart-container'); if (bc) bc.classList.add('hidden');
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// VITAL SIGNS — Interactive age selector
|
|
// Harriet Lane Handbook 23rd Edition reference values
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
var VITALS_DATA = {};
|
|
loadVitalsData()
|
|
.then(function(data) { VITALS_DATA = data; })
|
|
.catch(function(err) { console.warn('[calculators] Vitals data unavailable:', err && err.message); });
|
|
|
|
// Use event delegation on the vitals panel so the listener works even
|
|
// when the panel is hidden at init time (hidden elements are still in the DOM,
|
|
// but a setTimeout-based approach can race with dynamic content loading).
|
|
var vitalsPanel = document.getElementById('calc-vitals');
|
|
if (vitalsPanel) {
|
|
vitalsPanel.addEventListener('change', function(e) {
|
|
if (e.target.id !== 'vitals-age-select') return;
|
|
var key = e.target.value;
|
|
var resultDiv = document.getElementById('vitals-result');
|
|
if (!key || !VITALS_DATA[key]) { resultDiv.classList.add('hidden'); return; }
|
|
|
|
var v = VITALS_DATA[key];
|
|
resultDiv.classList.remove('hidden');
|
|
resultDiv.innerHTML = renderVitalsResult(v);
|
|
});
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// RESUSCITATION MEDICATIONS — Harriet Lane Handbook / AHA PALS 2020
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
var RESUS_DATA = null;
|
|
loadResusMedsData()
|
|
.then(function(data) { RESUS_DATA = data; })
|
|
.catch(function(err) { console.warn('[calculators] Resus meds data unavailable:', err && err.message); });
|
|
|
|
// Use event delegation for resus panel
|
|
var resusPanel = document.getElementById('calc-resus');
|
|
if (resusPanel) {
|
|
resusPanel.addEventListener('click', function(e) {
|
|
var btn = e.target.closest('#btn-calc-resus, #btn-clear-resus');
|
|
if (!btn) return;
|
|
|
|
if (btn.id === 'btn-clear-resus') {
|
|
var wi = document.getElementById('resus-weight'); if (wi) wi.value = '';
|
|
var rd = document.getElementById('resus-result'); if (rd) { rd.classList.add('hidden'); rd.innerHTML = ''; }
|
|
return;
|
|
}
|
|
|
|
// Calculate
|
|
var weightEl = document.getElementById('resus-weight');
|
|
var resultDiv = document.getElementById('resus-result');
|
|
var w = parseFloat(weightEl.value);
|
|
if (!w || w <= 0) { showToast('Please enter a valid weight.', 'error'); return; }
|
|
|
|
if (!RESUS_DATA) { showToast('Resus medication data is still loading.', 'info'); return; }
|
|
resultDiv.innerHTML = renderResusMeds(RESUS_DATA, w);
|
|
resultDiv.classList.remove('hidden');
|
|
});
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// GLASGOW COMA SCALE
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
// GCS sub-pill navigation
|
|
var gcsPanel = document.getElementById('calc-gcs');
|
|
if (gcsPanel) {
|
|
gcsPanel.addEventListener('click', function(e) {
|
|
var pill = e.target.closest('[data-gcs]');
|
|
if (!pill) return;
|
|
gcsPanel.querySelectorAll('[data-gcs]').forEach(function(p) { p.classList.remove('active'); });
|
|
pill.classList.add('active');
|
|
var isInfant = pill.dataset.gcs === 'infant';
|
|
var childP = document.getElementById('gcs-child-panel');
|
|
var infantP = document.getElementById('gcs-infant-panel');
|
|
if (childP) childP.style.display = isInfant ? 'none' : '';
|
|
if (infantP) infantP.style.display = isInfant ? '' : 'none';
|
|
updateGCS();
|
|
});
|
|
|
|
gcsPanel.addEventListener('change', function(e) {
|
|
if (e.target.tagName === 'SELECT') updateGCS();
|
|
});
|
|
}
|
|
|
|
function updateGCS() {
|
|
var isInfant = document.querySelector('[data-gcs="infant"].active') !== null;
|
|
var prefix = isInfant ? 'gcs-infant' : 'gcs-child';
|
|
var eye = parseInt(document.getElementById(prefix + '-eye').value) || 0;
|
|
var verbal = parseInt(document.getElementById(prefix + '-verbal').value) || 0;
|
|
var motor = parseInt(document.getElementById(prefix + '-motor').value) || 0;
|
|
var score = calculateGcs(eye, verbal, motor);
|
|
|
|
var resultDiv = document.getElementById('gcs-result');
|
|
if (resultDiv) {
|
|
resultDiv.innerHTML =
|
|
'<div class="calc-result-header" style="background:' + score.bg + ';border-color:' + score.color + ';">' +
|
|
'<div style="font-size:24px;font-weight:700;color:' + score.color + ';">GCS: ' + score.total + '/15</div>' +
|
|
'<div style="font-size:14px;color:var(--g600);margin-top:4px;">' + score.severity + '</div>' +
|
|
'</div>' +
|
|
'<div class="calc-result-grid" style="margin-top:12px;">' +
|
|
'<div class="calc-result-item"><div class="calc-result-label">Eye (E)</div><div class="calc-result-value">' + eye + '</div></div>' +
|
|
'<div class="calc-result-item"><div class="calc-result-label">Verbal (V)</div><div class="calc-result-value">' + verbal + '</div></div>' +
|
|
'<div class="calc-result-item"><div class="calc-result-label">Motor (M)</div><div class="calc-result-value">' + motor + '</div></div>' +
|
|
'</div>' +
|
|
'<div style="margin-top:12px;font-size:12px;color:var(--g500);">' +
|
|
'<strong>Interpretation:</strong> 13-15 Mild, 9-12 Moderate, 3-8 Severe/Coma. ' +
|
|
'Intubation typically considered at GCS ≤ 8. ' +
|
|
(isInfant ? 'Using infant-modified verbal and motor scales.' : '') +
|
|
'</div>';
|
|
}
|
|
}
|
|
// Initial GCS render
|
|
setTimeout(updateGCS, 200);
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// EQUIPMENT SIZING — Harriet Lane Handbook
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
var EQUIP_DATA = {};
|
|
loadEquipmentData()
|
|
.then(function(data) { EQUIP_DATA = data; })
|
|
.catch(function(err) { console.warn('[calculators] Equipment data unavailable:', err && err.message); });
|
|
|
|
var equipPanel = document.getElementById('calc-equipment');
|
|
if (equipPanel) {
|
|
equipPanel.addEventListener('change', function(e) {
|
|
if (e.target.id !== 'equip-age-select') return;
|
|
var key = e.target.value;
|
|
var resultDiv = document.getElementById('equip-result');
|
|
if (!key || !EQUIP_DATA[key]) { resultDiv.classList.add('hidden'); return; }
|
|
|
|
var eq = EQUIP_DATA[key];
|
|
resultDiv.classList.remove('hidden');
|
|
resultDiv.innerHTML = renderEquipmentResult(eq);
|
|
});
|
|
}
|
|
}
|