extract bilirubin risk zone data

This commit is contained in:
Daniel 2026-05-08 03:28:52 +02:00
parent f566455108
commit d8b8f9bdcb
4 changed files with 84 additions and 40 deletions

View file

@ -0,0 +1,9 @@
{
"version": "1.0",
"source": "Bhutani VK et al., Pediatrics 1999;103(1):6-14; cross-checked against AAP 2004 CPG reproduction",
"zones": {
"p95": { "6": 6.0, "12": 7.2, "18": 8.5, "24": 9.6, "30": 11.2, "36": 12.8, "42": 13.8, "48": 14.8, "54": 15.6, "60": 16.2, "66": 16.8, "72": 17.4, "84": 18.0, "96": 18.4, "108": 18.8, "120": 19.0 },
"p75": { "6": 4.5, "12": 5.5, "18": 6.6, "24": 7.8, "30": 9.2, "36": 10.6, "42": 11.6, "48": 12.6, "54": 13.4, "60": 14.0, "66": 14.6, "72": 15.0, "84": 15.4, "96": 15.6, "108": 15.8, "120": 16.0 },
"p40": { "6": 3.0, "12": 4.0, "18": 5.0, "24": 6.2, "30": 7.2, "36": 8.4, "42": 9.2, "48": 10.0, "54": 10.6, "60": 11.2, "66": 11.8, "72": 12.2, "84": 12.6, "96": 12.8, "108": 13.0, "120": 13.2 }
}
}

View file

@ -1,4 +1,5 @@
import { parseAgeMonths, formatParsedAge } from './calculators/age.js';
import { classifyBhutaniRisk, interpolateThreshold as interpolateBiliThreshold, loadBhutaniZones } from './calculators/bilirubin.js';
import { loadEquipmentData, renderEquipmentResult } from './calculators/equipment.js';
import { initImageLightbox } from './calculators/lightbox.js';
import { loadResusMedsData, renderResusMeds } from './calculators/resus.js';
@ -1476,19 +1477,6 @@ initImageLightbox();
var exchangeThresholds37risk = {12:15.7,13:15.9,14:16.0,15:16.1,16:16.2,17:16.4,18:16.5,19:16.6,20:16.7,21:16.8,22:17.0,23:17.1,24:17.2,25:17.3,26:17.4,27:17.5,28:17.7,29:17.8,30:17.9,31:18.0,32:18.1,33:18.2,34:18.3,35:18.4,36:18.5,37:18.6,38:18.7,39:18.8,40:18.9,41:19.0,42:19.1,43:19.2,44:19.3,45:19.4,46:19.5,47:19.6,48:19.7,49:19.8,50:19.9,51:20.0,52:20.1,53:20.1,54:20.2,55:20.3,56:20.4,57:20.5,58:20.6,59:20.7,60:20.7,61:20.8,62:20.9,63:21.0,64:21.1,65:21.1,66:21.2,67:21.3,68:21.4,69:21.4,70:21.5,71:21.6,72:21.7,73:21.7,74:21.8,75:21.9,76:21.9,77:22.0,78:22.1,79:22.1,80:22.2,81:22.3,82:22.3,83:22.4,84:22.5,85:22.5,86:22.6,87:22.6,88:22.7,89:22.8,90:22.8,91:22.9,92:22.9,93:23.0,94:23.0,95:23.1,96:23.1};
var exchangeThresholds38risk = {12:16.3,13:16.4,14:16.5,15:16.6,16:16.7,17:16.9,18:17.0,19:17.1,20:17.2,21:17.3,22:17.4,23:17.6,24:17.7,25:17.8,26:17.9,27:18.0,28:18.1,29:18.2,30:18.3,31:18.4,32:18.5,33:18.7,34:18.8,35:18.9,36:19.0,37:19.1,38:19.2,39:19.3,40:19.4,41:19.5,42:19.6,43:19.7,44:19.8,45:19.9,46:19.9,47:20.0,48:20.1,49:20.2,50:20.3,51:20.4,52:20.5,53:20.6,54:20.7,55:20.8,56:20.8,57:20.9,58:21.0,59:21.1,60:21.2,61:21.3,62:21.3,63:21.4,64:21.5,65:21.6,66:21.7,67:21.7,68:21.8,69:21.9,70:22.0,71:22.0,72:22.1,73:22.2,74:22.2,75:22.3,76:22.4,77:22.5,78:22.5,79:22.6,80:22.7,81:22.7,82:22.8,83:22.8,84:22.9,85:23.0,86:23.0,87:23.1,88:23.1,89:23.2,90:23.3,91:23.3,92:23.4,93:23.4,94:23.5,95:23.5,96:23.5};
function interpolateThreshold(table, hours) {
var keys = Object.keys(table).map(Number).sort(function(a,b){return a-b;});
if (hours <= keys[0]) return table[keys[0]];
if (hours >= keys[keys.length-1]) return table[keys[keys.length-1]];
for (var i = 0; i < keys.length-1; i++) {
if (hours >= keys[i] && hours <= keys[i+1]) {
var t = (hours - keys[i]) / (keys[i+1] - keys[i]);
return table[keys[i]] + t * (table[keys[i+1]] - table[keys[i]]);
}
}
return table[keys[0]];
}
// Bili sub-pill nav
document.querySelectorAll('[data-bili]').forEach(function(pill) {
pill.addEventListener('click', function() {
@ -1529,7 +1517,7 @@ initImageLightbox();
else table = photoThresholds40; // 40+
}
var threshold = interpolateThreshold(table, hours);
var threshold = interpolateBiliThreshold(table, hours);
var aboveThreshold = tsb >= threshold;
// Exchange transfusion threshold — 38+ plateaus for both risk states
@ -1545,7 +1533,7 @@ initImageLightbox();
else if (gaNum === 37) exchangeTable = exchangeThresholds37;
else exchangeTable = exchangeThresholds38;
}
var exchangeThreshold = exchangeTable ? interpolateThreshold(exchangeTable, hours) : null;
var exchangeThreshold = exchangeTable ? interpolateBiliThreshold(exchangeTable, hours) : null;
var aboveExchange = exchangeThreshold && tsb >= exchangeThreshold;
var cl;
@ -1606,49 +1594,38 @@ initImageLightbox();
// 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 bhutaniZones = {
p95: { 6:6.0, 12:7.2, 18:8.5, 24:9.6, 30:11.2, 36:12.8, 42:13.8, 48:14.8,
54:15.6, 60:16.2, 66:16.8, 72:17.4, 84:18.0, 96:18.4, 108:18.8, 120:19.0 },
p75: { 6:4.5, 12:5.5, 18:6.6, 24:7.8, 30:9.2, 36:10.6, 42:11.6, 48:12.6,
54:13.4, 60:14.0, 66:14.6, 72:15.0, 84:15.4, 96:15.6, 108:15.8, 120:16.0 },
p40: { 6:3.0, 12:4.0, 18:5.0, 24:6.2, 30:7.2, 36:8.4, 42:9.2, 48:10.0,
54:10.6, 60:11.2, 66:11.8, 72:12.2, 84:12.6, 96:12.8, 108:13.0, 120:13.2 }
};
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 p95 = interpolateThreshold(bhutaniZones.p95, hours);
var p75 = interpolateThreshold(bhutaniZones.p75, hours);
var p40 = interpolateThreshold(bhutaniZones.p40, hours);
var zone, cl;
if (tsb >= p95) { zone = 'High-Risk Zone'; cl = { color: '#ef4444', bg: '#fee2e2' }; }
else if (tsb >= p75) { zone = 'High-Intermediate Zone'; cl = { color: '#f97316', bg: '#ffedd5' }; }
else if (tsb >= p40) { zone = 'Low-Intermediate Zone'; cl = { color: '#f59e0b', bg: '#fef3c7' }; }
else { zone = 'Low-Risk Zone'; cl = { color: '#10b981', bg: '#d1fae5' }; }
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:' + cl.bg + ';border-color:' + cl.color + ';">' +
'<div style="font-size:18px;font-weight:700;color:' + cl.color + ';">' + zone + '</div>' +
'<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;">' + 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;">' + 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;">' + p40.toFixed(1) + '</div><div class="calc-result-sub">Low-intermediate</div></div>' +
'<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 &ge; 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(bhutaniZones.p95), borderColor: '#ef4444', borderWidth: 2, fill: false, borderDash: [] },
{ label: '75th (High-Intermed)', data: generateThresholdCurve(bhutaniZones.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(bhutaniZones.p40), borderColor: '#f59e0b', borderWidth: 1.5, fill: { target: '-1', above: 'rgba(249,115,22,0.08)' }, borderDash: [3,3] }
{ 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] }
]);
});

View file

@ -0,0 +1,37 @@
var bhutaniCache = null;
export function loadBhutaniZones() {
if (bhutaniCache) return Promise.resolve(bhutaniCache);
return fetch('/data/calculators/bhutani-zones.json', { credentials: 'same-origin' })
.then(function(response) {
if (!response.ok) throw new Error('Failed to load Bhutani zone data');
return response.json();
})
.then(function(data) {
bhutaniCache = data.zones || {};
return bhutaniCache;
});
}
export function interpolateThreshold(table, hours) {
var keys = Object.keys(table).map(Number).sort(function(a,b){return a-b;});
if (hours <= keys[0]) return table[keys[0]];
if (hours >= keys[keys.length-1]) return table[keys[keys.length-1]];
for (var i = 0; i < keys.length-1; i++) {
if (hours >= keys[i] && hours <= keys[i+1]) {
var t = (hours - keys[i]) / (keys[i+1] - keys[i]);
return table[keys[i]] + t * (table[keys[i+1]] - table[keys[i]]);
}
}
return table[keys[0]];
}
export function classifyBhutaniRisk(zones, hours, tsb) {
var p95 = interpolateThreshold(zones.p95, hours);
var p75 = interpolateThreshold(zones.p75, hours);
var p40 = interpolateThreshold(zones.p40, hours);
if (tsb >= p95) return { zone: 'High-Risk Zone', color: '#ef4444', bg: '#fee2e2', p95: p95, p75: p75, p40: p40 };
if (tsb >= p75) return { zone: 'High-Intermediate Zone', color: '#f97316', bg: '#ffedd5', p95: p95, p75: p75, p40: p40 };
if (tsb >= p40) return { zone: 'Low-Intermediate Zone', color: '#f59e0b', bg: '#fef3c7', p95: p95, p75: p75, p40: p40 };
return { zone: 'Low-Risk Zone', color: '#10b981', bg: '#d1fae5', p95: p95, p75: p75, p40: p40 };
}

View file

@ -126,3 +126,24 @@ test('equipment.json entries have complete renderable fields', () => {
});
});
});
test('bhutani-zones.json preserves risk-zone curves', () => {
const raw = fs.readFileSync(path.join(__dirname, '..', 'public', 'data', 'calculators', 'bhutani-zones.json'), 'utf8');
const data = JSON.parse(raw);
assert.equal(data.version, '1.0');
assert.deepEqual(Object.keys(data.zones), ['p95', 'p75', 'p40']);
assert.equal(data.zones.p95['24'], 9.6);
assert.equal(data.zones.p75['48'], 12.6);
assert.equal(data.zones.p40['120'], 13.2);
});
test('bilirubin helpers preserve Bhutani interpolation and classification', async () => {
const bilirubin = await import('../public/js/calculators/bilirubin.js');
const raw = fs.readFileSync(path.join(__dirname, '..', 'public', 'data', 'calculators', 'bhutani-zones.json'), 'utf8');
const zones = JSON.parse(raw).zones;
assert.equal(bilirubin.interpolateThreshold(zones.p95, 21), 9.05);
assert.equal(bilirubin.classifyBhutaniRisk(zones, 24, 10).zone, 'High-Risk Zone');
assert.equal(bilirubin.classifyBhutaniRisk(zones, 24, 8).zone, 'High-Intermediate Zone');
assert.equal(bilirubin.classifyBhutaniRisk(zones, 24, 7).zone, 'Low-Intermediate Zone');
assert.equal(bilirubin.classifyBhutaniRisk(zones, 24, 5).zone, 'Low-Risk Zone');
});