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 = {};
|
||||
|
||||
// Chart.js plugin — draws the percentile label (e.g. "50th") at the
|
||||
// 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").
|
||||
// Chart.js plugin — labels each percentile curve at its right edge,
|
||||
// spreads labels bidirectionally so top (97/95/90) and bottom (3/5/10)
|
||||
// clusters both breathe, draws a thin leader from each label to the
|
||||
// actual curve position when nudged, then repaints the patient dot
|
||||
// ON TOP so it's never obscured by a label.
|
||||
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 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 = [];
|
||||
chart.data.datasets.forEach(function(ds, i) {
|
||||
if (!ds.label || !/^\d+th$/.test(ds.label)) return;
|
||||
|
|
@ -835,35 +835,50 @@
|
|||
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 });
|
||||
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
|
||||
// (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.
|
||||
// Forward pass — push items DOWN when crowded by item above
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
// Backward pass — pull items UP when still crowded by item below
|
||||
for (var j = items.length - 2; j >= 0; j--) {
|
||||
if (items[j + 1].targetY - items[j].targetY < MIN_GAP) {
|
||||
items[j].targetY = items[j + 1].targetY - MIN_GAP;
|
||||
}
|
||||
}
|
||||
// Clamp to chart area so nothing clips top/bottom
|
||||
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) {
|
||||
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] + ')';
|
||||
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.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
|
@ -874,6 +889,25 @@
|
|||
ctx.fillText(it.label, it.x + 5, it.targetY);
|
||||
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