From aa1261da4e581f6b7a2c1ee45c7ea89acc603d70 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 14 Apr 2026 07:06:49 +0200 Subject: [PATCH] Growth charts: fix label ordering + shrink patient dot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Label plugin rewritten: - Collect all percentile-line labels with their native screen Y - Sort top-to-bottom (97th ... 3rd) so order is always correct - Walk the sorted list and enforce 13px min vertical gap by pushing down (never reordering) - Second-pass pull-back if the stack would clip off the chart bottom Prior logic could nudge "10th" past "3rd" because it moved labels independently without respecting their natural screen order. Patient dot: radius 8 → 5 (hover 11 → 7). The 8px ring was dominating the chart; 5px is still clearly visible with the 1.8px white border but no longer obscures adjacent curves. --- public/js/calculators.js | 67 ++++++++++++++++++++++++---------------- 1 file changed, 41 insertions(+), 26 deletions(-) diff --git a/public/js/calculators.js b/public/js/calculators.js index 64cb584..162575f 100644 --- a/public/js/calculators.js +++ b/public/js/calculators.js @@ -816,46 +816,62 @@ var _chartInstances = {}; // Chart.js plugin — draws the percentile label (e.g. "50th") at the - // 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. + // right end of each percentile curve. Labels are sorted by their + // screen Y and spread so adjacent ones never overlap. Preserves the + // natural top-to-bottom order (97th → 3rd visually) so labels never + // get swapped (e.g. "10th" ending up below "3rd"). 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 + var MIN_GAP = 13; + + // Collect all percentile-line labels with their native 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, y: last.y, targetY: last.y }); + }); - 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++; + // Sort top-to-bottom (ascending canvas Y). Highest percentile + // (lowest Y) is index 0. This is the order labels should read. + items.sort(function(a, b) { return a.y - b.y; }); + + // Enforce MIN_GAP between neighbors by pushing each label DOWN + // if it's too close to the previous. Keeps relative order intact. + 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; } - placed.push(targetY); + } - ctx.save(); - ctx.font = font; - var stroke = typeof ds.borderColor === 'string' ? ds.borderColor : '#555'; - // Force solid fill (drop any alpha) so labels pop against faded lines + // Second pass upward: if the bunch collectively pushed past the + // chart bottom, pull it back up. Handles the edge case where + // MIN_GAP stacking would clip off-canvas. + var chartArea = chart.chartArea; + if (chartArea && items.length && items[items.length - 1].targetY > chartArea.bottom - 4) { + var overflow = items[items.length - 1].targetY - (chartArea.bottom - 4); + for (var j = 0; j < items.length; j++) items[j].targetY -= overflow; + } + + 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] + ')'; - - // White halo behind the text for legibility over colored curves + ctx.save(); + ctx.font = font; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.lineWidth = 3; - ctx.strokeStyle = 'rgba(255,255,255,0.9)'; - ctx.strokeText(ds.label, last.x + 5, targetY); + ctx.strokeStyle = 'rgba(255,255,255,0.92)'; + ctx.strokeText(it.label, it.x + 5, it.targetY); ctx.fillStyle = stroke; - ctx.fillText(ds.label, last.x + 5, targetY); + ctx.fillText(it.label, it.x + 5, it.targetY); ctx.restore(); }); } @@ -872,17 +888,16 @@ var datasets = generatePercentileCurves(lmsTable, PERCENTILE_LINES); - // 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. + // 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: 2.5, - pointRadius: 8, - pointHoverRadius: 11, + borderWidth: 1.8, + pointRadius: 5, + pointHoverRadius: 7, pointStyle: 'circle', showLine: false, type: 'scatter',