Growth charts: 7 distinct-color percentile lines + readable mobile labels

Reduced reference curves from 9 to 7 (dropped 5th and 95th — they
crowd the 3rd/10th and 90th/97th labels on small screens without
adding clinical value; 3rd and 97th are the US/WHO standard
abnormal thresholds).

Each percentile now gets a distinct hue:
  3rd  red     50th green (bold)    90th violet
  10th orange  75th blue            97th pink
  25th amber                        (3rd/97th dashed)

Label plugin improvements:
  - Bolder 11px font (was 10px)
  - White halo stroke behind text so labels stay legible when the
    line they sit on is also colored
  - Vertical nudge when two labels would overlap — keeps adjacent
    percentiles readable on mobile aspect ratios
  - Solid fill color (strips alpha from borderColor)

Patient dot:
  - Moved to order: -1 (drawn on top of everything, including labels
    and fill bands)
  - White 2.5px border ring so it's visible even when it lands
    exactly on a colored curve
  - Slightly larger hover radius (11 → was 10)
This commit is contained in:
Daniel 2026-04-14 07:01:53 +02:00
parent 1d96772028
commit 191a163ef9

View file

@ -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); });