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:
parent
1d96772028
commit
191a163ef9
1 changed files with 50 additions and 28 deletions
|
|
@ -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); });
|
||||
|
|
|
|||
Loading…
Reference in a new issue