diff --git a/public/data/calculators/bhutani-zones.json b/public/data/calculators/bhutani-zones.json new file mode 100644 index 0000000..f2241a6 --- /dev/null +++ b/public/data/calculators/bhutani-zones.json @@ -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 } + } +} diff --git a/public/js/calculators.js b/public/js/calculators.js index 70d5243..d076e3d 100644 --- a/public/js/calculators.js +++ b/public/js/calculators.js @@ -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 = - '
' + - '
' + zone + '
' + + '
' + + '
' + riskZone.zone + '
' + '
TSB ' + tsb + ' mg/dL at ' + hours + ' hours of life
' + '
' + '
' + - '
95th %ile
' + p95.toFixed(1) + '
High-risk
' + - '
75th %ile
' + p75.toFixed(1) + '
High-intermediate
' + - '
40th %ile
' + p40.toFixed(1) + '
Low-intermediate
' + + '
95th %ile
' + riskZone.p95.toFixed(1) + '
High-risk
' + + '
75th %ile
' + riskZone.p75.toFixed(1) + '
High-intermediate
' + + '
40th %ile
' + riskZone.p40.toFixed(1) + '
Low-intermediate
' + '
' + '

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.

'; // 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] } ]); }); diff --git a/public/js/calculators/bilirubin.js b/public/js/calculators/bilirubin.js new file mode 100644 index 0000000..35f1eb5 --- /dev/null +++ b/public/js/calculators/bilirubin.js @@ -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 }; +} diff --git a/test/calculators-data.test.js b/test/calculators-data.test.js index 61f7171..f1c92a9 100644 --- a/test/calculators-data.test.js +++ b/test/calculators-data.test.js @@ -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'); +});