Growth charts: fix label ordering + shrink patient dot

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.
This commit is contained in:
Daniel 2026-04-14 07:06:49 +02:00
parent 1a176f082a
commit aa1261da4e

View file

@ -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',