From e8a2283fae1357b8f0c9f5f643d93c81bc008d94 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 14 Apr 2026 12:17:32 +0200 Subject: [PATCH] Bilirubin chart: BiliTool/PediTools-style visual polish (no data changes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure visual improvements to renderBiliChart. Interpolation, lookup tables, and threshold math are all untouched. AAP chart now has three-zone shading matching BiliTool's convention: - Faint green tint below the phototherapy curve (safe zone) - Amber band between phototherapy and exchange (treatment zone) - Unshaded above the dashed crimson exchange line (danger) Phototherapy line: solid orange 2.4 px; Exchange: dashed crimson. renderBiliChart common improvements: - Right-edge label on each threshold line ("Phototherapy", "Exchange", or "95th (High-Risk)" for Bhutani) with white halo — identifiable without a legend, like PediTools - Legend removed (replaced by the inline labels) - X-axis auto-ranges to fit the actual data with tick every 12 h - Y-axis tick every 5 mg/dL for clean BiliTool-style gridlines - 40 px right padding so labels don't clip - Patient dot shrunk from r=8 to r=5 with a 1.8 px white ring, redrawn on top of every label + its TSB value printed next to it - Bhutani chart inherits all the same improvements without changing its own zone/fill setup --- public/js/calculators.js | 128 +++++++++++++++++++++++++++++++++++---- 1 file changed, 117 insertions(+), 11 deletions(-) diff --git a/public/js/calculators.js b/public/js/calculators.js index 71601fa..7eb69de 100644 --- a/public/js/calculators.js +++ b/public/js/calculators.js @@ -1404,6 +1404,64 @@ return keys.map(function(h) { return { x: h, y: table[h] }; }); } + // Chart.js plugin — labels threshold lines at their right end (like + // BiliTool/PediTools do on their plots) and re-paints the patient dot + // on top so labels never cover it. + var biliLabelPlugin = { + id: 'biliThresholdLabels', + afterDatasetsDraw: function(chart) { + var ctx = chart.ctx; + var font = '700 11px -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif'; + chart.data.datasets.forEach(function(ds, i) { + if (!ds.label || ds.label === 'Patient') 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; + var color = typeof ds.borderColor === 'string' ? ds.borderColor : '#555'; + var m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); + if (m) color = 'rgb(' + m[1] + ',' + m[2] + ',' + m[3] + ')'; + ctx.save(); + ctx.font = font; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.lineWidth = 3; + ctx.strokeStyle = 'rgba(255,255,255,0.92)'; + ctx.strokeText(ds.label, last.x + 5, last.y); + ctx.fillStyle = color; + ctx.fillText(ds.label, last.x + 5, last.y); + ctx.restore(); + }); + // Patient dot on top + 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(); + // Value label next to the dot + ctx.font = '700 12px -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.lineWidth = 3; + ctx.strokeStyle = 'rgba(255,255,255,0.95)'; + var lbl = ds.data[0].y.toFixed(1); + ctx.strokeText(lbl, pt.x + 9, pt.y); + ctx.fillStyle = '#1e40af'; + ctx.fillText(lbl, pt.x + 9, pt.y); + ctx.restore(); + }); + } + }; + function renderBiliChart(patientHours, patientTSB, curveDatasets) { if (typeof Chart === 'undefined') return; var container = document.getElementById('bili-chart-container'); @@ -1416,33 +1474,61 @@ return Object.assign({ pointRadius: 0, pointHoverRadius: 0, tension: 0.3, order: 10 }, ds); }); - // Patient point + // Patient point (native dataset — used for tooltip + legend. The plugin + // re-paints it on top at the end so threshold labels never cover it.) datasets.push({ label: 'Patient', data: [{ x: patientHours, y: patientTSB }], backgroundColor: '#2563eb', - borderColor: '#1d4ed8', - pointRadius: 8, - pointHoverRadius: 10, + borderColor: '#ffffff', + borderWidth: 1.8, + pointRadius: 5, + pointHoverRadius: 7, showLine: false, type: 'scatter', - order: 0 + order: -1 }); + // Derive nice x-axis range from actual data (clamped to a minimum + // visible window so single-point patients still show in context). + var xMax = patientHours + 6; + var xMin = 12; + curveDatasets.forEach(function(ds) { + (ds.data || []).forEach(function(p) { + if (typeof p.x === 'number') xMax = Math.max(xMax, p.x); + }); + }); + xMax = Math.min(Math.max(xMax, 72), 180); + _chartInstances['bili-chart-canvas'] = new Chart(document.getElementById('bili-chart-canvas'), { type: 'line', data: { datasets: datasets }, + plugins: [biliLabelPlugin], options: { responsive: true, maintainAspectRatio: true, aspectRatio: 1.6, animation: { duration: 300 }, + // Extra right padding so the threshold labels drawn past the + // last data point stay inside the canvas. + layout: { padding: { right: 40 } }, 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)' } } + x: { + type: 'linear', + title: { display: true, text: 'Age (hours)', font: { size: 12, weight: 'bold' } }, + min: xMin, max: xMax, + ticks: { stepSize: 12, font: { size: 11 } }, + grid: { color: 'rgba(0,0,0,0.08)' } + }, + y: { + title: { display: true, text: 'TSB (mg/dL)', font: { size: 12, weight: 'bold' } }, + min: 0, + ticks: { stepSize: 5, font: { size: 11 } }, + grid: { color: 'rgba(0,0,0,0.08)' } + } }, plugins: { - legend: { display: true, position: 'bottom', labels: { font: { size: 11 }, usePointStyle: true, pointStyle: 'line' } }, + legend: { display: false }, tooltip: { callbacks: { label: function(ctx) { @@ -1553,12 +1639,32 @@ '' + '

GA ' + ga + ' weeks, ' + (risk === 'medium' ? 'with' : 'without') + ' neurotoxicity risk factors. AAP 2022 CPG (exact values from published nomograms). Always use clinical judgment.

'; - // Chart: phototherapy + exchange threshold lines vs patient + // Chart: clinical BiliTool/PediTools-style layout + // - Green tint below the phototherapy line (safe zone) + // - Amber tint between phototherapy and exchange (treatment zone) + // - Dashed crimson exchange line above (danger threshold) var chartDatasets = [ - { label: 'Phototherapy Threshold', data: generateThresholdCurve(table), borderColor: '#ef4444', borderWidth: 2, borderDash: [], fill: { target: 'origin', above: 'rgba(239,68,68,0.08)' } } + { + label: 'Phototherapy', + data: generateThresholdCurve(table), + borderColor: '#f97316', // orange line + borderWidth: 2.4, + borderDash: [], + fill: 'origin', + backgroundColor: 'rgba(22,163,74,0.07)' // faint green below = "safe" + } ]; if (exchangeTable) { - chartDatasets.push({ label: 'Exchange Transfusion', data: generateThresholdCurve(exchangeTable), borderColor: '#7f1d1d', borderWidth: 2, borderDash: [6,3], fill: false }); + chartDatasets.push({ + label: 'Exchange', + data: generateThresholdCurve(exchangeTable), + borderColor: '#b91c1c', // crimson line + borderWidth: 2.4, + borderDash: [6, 3], + // Fill between this line and the phototherapy line (dataset 0) + fill: { target: 0 }, + backgroundColor: 'rgba(252,211,77,0.22)' // amber band = "treat" + }); } renderBiliChart(hours, tsb, chartDatasets); });