From a0664d6e15a0f37b4e7677975932b06a5eeccbc4 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 14 Apr 2026 07:10:20 +0200 Subject: [PATCH] Growth charts: bidirectional label spread + leaders + dot-on-top MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Labels at the top (97/95/90) crowd just as much as the bottom trio — prior forward-only pass only spread the bottom. Now: - Forward pass pushes items DOWN when crowded from above - Backward pass pulls items UP when still crowded from below - Min gap bumped 13 → 15 px for breathing room - Labels clamped to stay inside chart top/bottom - Thin leader line drawn from native curve position to the label when the two diverge by more than 2 px, so you can still see which line a nudged label belongs to - Patient dot redrawn on top of all labels at the very end of the plugin so a top-region label can never cover the dot (Chart.js's afterDatasetsDraw fires after dataset rendering, so the native dot was being painted over). --- public/js/calculators.js | 78 ++++++++++++++++++++++++++++------------ 1 file changed, 56 insertions(+), 22 deletions(-) diff --git a/public/js/calculators.js b/public/js/calculators.js index 162575f..920c0b7 100644 --- a/public/js/calculators.js +++ b/public/js/calculators.js @@ -815,19 +815,19 @@ var _chartInstances = {}; - // Chart.js plugin — draws the percentile label (e.g. "50th") at the - // 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"). + // Chart.js plugin — labels each percentile curve at its right edge, + // spreads labels bidirectionally so top (97/95/90) and bottom (3/5/10) + // clusters both breathe, draws a thin leader from each label to the + // actual curve position when nudged, then repaints the patient dot + // ON TOP so it's never obscured by a label. 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 MIN_GAP = 13; + var MIN_GAP = 15; - // Collect all percentile-line labels with their native positions + // Collect percentile-line labels with native screen positions var items = []; chart.data.datasets.forEach(function(ds, i) { if (!ds.label || !/^\d+th$/.test(ds.label)) return; @@ -835,35 +835,50 @@ 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 }); + items.push({ label: ds.label, color: ds.borderColor, x: last.x, nativeY: last.y, targetY: last.y }); }); + // Top to bottom (ascending canvas Y) + items.sort(function(a, b) { return a.nativeY - b.nativeY; }); - // 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. + // Forward pass — push items DOWN when crowded by item above 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; } } - - // 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; + // Backward pass — pull items UP when still crowded by item below + for (var j = items.length - 2; j >= 0; j--) { + if (items[j + 1].targetY - items[j].targetY < MIN_GAP) { + items[j].targetY = items[j + 1].targetY - MIN_GAP; + } + } + // Clamp to chart area so nothing clips top/bottom + var area = chart.chartArea; + if (area) { + items.forEach(function(it) { + if (it.targetY < area.top + 6) it.targetY = area.top + 6; + if (it.targetY > area.bottom - 6) it.targetY = area.bottom - 6; + }); } + // Draw leader + label 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] + ')'; ctx.save(); + // Leader — only if the label was nudged away from its native Y + if (Math.abs(it.targetY - it.nativeY) > 2) { + ctx.strokeStyle = stroke; + ctx.globalAlpha = 0.55; + ctx.lineWidth = 0.8; + ctx.beginPath(); + ctx.moveTo(it.x, it.nativeY); + ctx.lineTo(it.x + 4, it.targetY); + ctx.stroke(); + ctx.globalAlpha = 1; + } + // Label with white halo ctx.font = font; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; @@ -874,6 +889,25 @@ ctx.fillText(it.label, it.x + 5, it.targetY); ctx.restore(); }); + + // Repaint the patient dot ON TOP of the labels so it's never + // covered by a nearby percentile label. + 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(); + ctx.restore(); + }); } };