Growth charts: bidirectional label spread + leaders + dot-on-top
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).
This commit is contained in:
parent
b0410c5c62
commit
a0664d6e15
1 changed files with 56 additions and 22 deletions
|
|
@ -815,19 +815,19 @@
|
||||||
|
|
||||||
var _chartInstances = {};
|
var _chartInstances = {};
|
||||||
|
|
||||||
// Chart.js plugin — draws the percentile label (e.g. "50th") at the
|
// Chart.js plugin — labels each percentile curve at its right edge,
|
||||||
// right end of each percentile curve. Labels are sorted by their
|
// spreads labels bidirectionally so top (97/95/90) and bottom (3/5/10)
|
||||||
// screen Y and spread so adjacent ones never overlap. Preserves the
|
// clusters both breathe, draws a thin leader from each label to the
|
||||||
// natural top-to-bottom order (97th → 3rd visually) so labels never
|
// actual curve position when nudged, then repaints the patient dot
|
||||||
// get swapped (e.g. "10th" ending up below "3rd").
|
// ON TOP so it's never obscured by a label.
|
||||||
var percentileLabelPlugin = {
|
var percentileLabelPlugin = {
|
||||||
id: 'percentileLabels',
|
id: 'percentileLabels',
|
||||||
afterDatasetsDraw: function(chart) {
|
afterDatasetsDraw: function(chart) {
|
||||||
var ctx = chart.ctx;
|
var ctx = chart.ctx;
|
||||||
var font = '700 11px -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif';
|
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 = [];
|
var items = [];
|
||||||
chart.data.datasets.forEach(function(ds, i) {
|
chart.data.datasets.forEach(function(ds, i) {
|
||||||
if (!ds.label || !/^\d+th$/.test(ds.label)) return;
|
if (!ds.label || !/^\d+th$/.test(ds.label)) return;
|
||||||
|
|
@ -835,35 +835,50 @@
|
||||||
if (!meta || meta.hidden || !meta.data || !meta.data.length) return;
|
if (!meta || meta.hidden || !meta.data || !meta.data.length) return;
|
||||||
var last = meta.data[meta.data.length - 1];
|
var last = meta.data[meta.data.length - 1];
|
||||||
if (!last) return;
|
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
|
// Forward pass — push items DOWN when crowded by item above
|
||||||
// (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++) {
|
for (var k = 1; k < items.length; k++) {
|
||||||
if (items[k].targetY - items[k - 1].targetY < MIN_GAP) {
|
if (items[k].targetY - items[k - 1].targetY < MIN_GAP) {
|
||||||
items[k].targetY = items[k - 1].targetY + MIN_GAP;
|
items[k].targetY = items[k - 1].targetY + MIN_GAP;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Backward pass — pull items UP when still crowded by item below
|
||||||
// Second pass upward: if the bunch collectively pushed past the
|
for (var j = items.length - 2; j >= 0; j--) {
|
||||||
// chart bottom, pull it back up. Handles the edge case where
|
if (items[j + 1].targetY - items[j].targetY < MIN_GAP) {
|
||||||
// MIN_GAP stacking would clip off-canvas.
|
items[j].targetY = items[j + 1].targetY - MIN_GAP;
|
||||||
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);
|
// Clamp to chart area so nothing clips top/bottom
|
||||||
for (var j = 0; j < items.length; j++) items[j].targetY -= overflow;
|
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) {
|
items.forEach(function(it) {
|
||||||
var stroke = typeof it.color === 'string' ? it.color : '#555';
|
var stroke = typeof it.color === 'string' ? it.color : '#555';
|
||||||
var m = stroke.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
var m = stroke.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
||||||
if (m) stroke = 'rgb(' + m[1] + ',' + m[2] + ',' + m[3] + ')';
|
if (m) stroke = 'rgb(' + m[1] + ',' + m[2] + ',' + m[3] + ')';
|
||||||
ctx.save();
|
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.font = font;
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
|
|
@ -874,6 +889,25 @@
|
||||||
ctx.fillText(it.label, it.x + 5, it.targetY);
|
ctx.fillText(it.label, it.x + 5, it.targetY);
|
||||||
ctx.restore();
|
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();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue