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:
parent
1a176f082a
commit
aa1261da4e
1 changed files with 41 additions and 26 deletions
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue