diff --git a/public/js/calculators.js b/public/js/calculators.js index 3a8917e..99de526 100644 --- a/public/js/calculators.js +++ b/public/js/calculators.js @@ -758,16 +758,18 @@ return M * Math.pow(1 + L * S * z, 1 / L); } + // Seven reference lines — classic WHO/CDC set minus the near-duplicate + // 5th/95th, which crowd the 3rd/10th and 90th/97th labels on mobile. + // Each percentile gets a distinct solid color; dashed only for the + // outermost bands (3rd / 97th) so they read as "abnormal thresholds". var PERCENTILE_LINES = [ - { p: 3, z: -1.88079, color: 'rgba(239,68,68,0.4)', dash: [4,4], width: 1 }, - { p: 5, z: -1.64485, color: 'rgba(249,115,22,0.4)', dash: [3,3], width: 1 }, - { p: 10, z: -1.28155, color: 'rgba(234,179,8,0.5)', dash: [2,2], width: 1 }, - { p: 25, z: -0.67449, color: 'rgba(163,163,163,0.5)',dash: [2,2], width: 1 }, - { p: 50, z: 0, color: 'rgba(16,185,129,0.9)', dash: [], width: 2.5 }, - { p: 75, z: 0.67449, color: 'rgba(163,163,163,0.5)',dash: [2,2], width: 1 }, - { p: 90, z: 1.28155, color: 'rgba(234,179,8,0.5)', dash: [2,2], width: 1 }, - { p: 95, z: 1.64485, color: 'rgba(249,115,22,0.4)', dash: [3,3], width: 1 }, - { p: 97, z: 1.88079, color: 'rgba(239,68,68,0.4)', dash: [4,4], width: 1 } + { p: 3, z: -1.88079, color: 'rgba(220,38,38,0.85)', dash: [5,4], width: 1.4 }, // red + { p: 10, z: -1.28155, color: 'rgba(234,88,12,0.85)', 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.85)', dash: [], width: 1.2 }, // violet + { p: 97, z: 1.88079, color: 'rgba(219,39,119,0.9)', dash: [5,4], width: 1.4 } // pink ]; function generatePercentileCurves(lmsTable, percentileLines) { @@ -793,13 +795,12 @@ order: 10 }); }); - // Add fill bands between symmetric percentiles (3-97, 5-95, 10-90, 25-75) - // Use the 50th as center reference + // Fill bands between symmetric percentile pairs. Indexes match the + // 7-line PERCENTILE_LINES array above: 3=0, 10=1, 25=2, 50=3, 75=4, 90=5, 97=6 var fills = [ - { outer: 0, inner: 8, color: 'rgba(239,68,68,0.06)' }, // 3rd-97th - { outer: 1, inner: 7, color: 'rgba(249,115,22,0.06)' }, // 5th-95th - { outer: 2, inner: 6, color: 'rgba(234,179,8,0.06)' }, // 10th-90th - { outer: 3, inner: 5, color: 'rgba(163,163,163,0.06)' } // 25th-75th + { outer: 0, inner: 6, color: 'rgba(220,38,38,0.05)' }, // 3rd-97th band (widest) + { outer: 1, inner: 5, color: 'rgba(234,88,12,0.05)' }, // 10th-90th band + { outer: 2, inner: 4, color: 'rgba(163,163,163,0.05)' } // 25th-75th band (IQR) ]; fills.forEach(function(f) { if (datasets[f.outer] && datasets[f.inner]) { @@ -812,28 +813,46 @@ var _chartInstances = {}; // Chart.js plugin — draws the percentile label (e.g. "50th") at the - // right end of each percentile curve so the lines are identifiable - // without a legend. Standard on printed WHO/CDC growth charts. + // right end of each percentile curve. Standard on printed WHO/CDC + // charts. Labels are nudged vertically when they'd overlap so + // adjacent percentiles (e.g. 10th / 25th) stay legible on mobile. 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 placed = []; // track vertical positions to avoid overlap chart.data.datasets.forEach(function(ds, i) { - // Only the percentile line datasets (labels look like "50th") 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; + + var targetY = last.y; + // Nudge label up or down if it overlaps one already placed (12px minimum gap) + var tries = 0; + while (tries < 5 && placed.some(function(y) { return Math.abs(y - targetY) < 12; })) { + targetY += (ds.label === '3rd' || ds.label === '10th' || ds.label === '25th') ? 12 : -12; + tries++; + } + placed.push(targetY); + ctx.save(); - ctx.font = '600 10px -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif'; - ctx.fillStyle = typeof ds.borderColor === 'string' ? ds.borderColor : '#555'; + ctx.font = font; + var stroke = typeof ds.borderColor === 'string' ? ds.borderColor : '#555'; + // Force solid fill (drop any alpha) so labels pop against faded lines + var m = stroke.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); + if (m) stroke = 'rgb(' + m[1] + ',' + m[2] + ',' + m[3] + ')'; + + // White halo behind the text for legibility over colored curves ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; - // Solidify alpha so labels remain readable even when the line is faint - var m = ctx.fillStyle.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); - if (m) ctx.fillStyle = 'rgb(' + m[1] + ',' + m[2] + ',' + m[3] + ')'; - ctx.fillText(ds.label, last.x + 4, last.y); + ctx.lineWidth = 3; + ctx.strokeStyle = 'rgba(255,255,255,0.9)'; + ctx.strokeText(ds.label, last.x + 5, targetY); + ctx.fillStyle = stroke; + ctx.fillText(ds.label, last.x + 5, targetY); ctx.restore(); }); } @@ -850,18 +869,21 @@ var datasets = generatePercentileCurves(lmsTable, PERCENTILE_LINES); - // Patient data point + // Patient data point — drawn last + lowest `order` so it always sits + // on top of every percentile curve and fill band. White border ring + // makes it readable even when it lands directly on a colored line. datasets.push({ label: 'Patient', data: [{ x: patientX, y: patientY }], backgroundColor: '#2563eb', - borderColor: '#1d4ed8', + borderColor: '#ffffff', + borderWidth: 2.5, pointRadius: 8, - pointHoverRadius: 10, + pointHoverRadius: 11, pointStyle: 'circle', showLine: false, type: 'scatter', - order: 0 + order: -1 }); if (extraDatasets) extraDatasets.forEach(function(d) { datasets.push(d); });