fix(charts): collision-aware end-of-line labels when lines converge
Deploy HiHala Dashboard / deploy (push) Successful in 15s
Deploy HiHala Dashboard / deploy (push) Successful in 15s
Replace per-dataset label drawing with a post-pass in afterDatasetsDraw that collects all museum line endpoints, sorts by Y, then pushes overlapping labels apart with a connector line back to the actual data point. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+71
-22
@@ -138,6 +138,8 @@ export const createBaseOptions = (showDataLabels: boolean): any => {
|
|||||||
// Only activates for charts that have datasets marked with _isMuseumLine.
|
// Only activates for charts that have datasets marked with _isMuseumLine.
|
||||||
const trendLinePlugin = {
|
const trendLinePlugin = {
|
||||||
id: 'trendLineOverlay',
|
id: 'trendLineOverlay',
|
||||||
|
|
||||||
|
// ── hover dim ──────────────────────────────────────────────────
|
||||||
beforeDatasetDraw(chart: any, args: any) {
|
beforeDatasetDraw(chart: any, args: any) {
|
||||||
if (!chart.data.datasets.some((ds: any) => ds._isMuseumLine)) return;
|
if (!chart.data.datasets.some((ds: any) => ds._isMuseumLine)) return;
|
||||||
const active = chart.getActiveElements();
|
const active = chart.getActiveElements();
|
||||||
@@ -149,30 +151,77 @@ const trendLinePlugin = {
|
|||||||
},
|
},
|
||||||
afterDatasetDraw(chart: any, args: any) {
|
afterDatasetDraw(chart: any, args: any) {
|
||||||
if (!chart.data.datasets.some((ds: any) => ds._isMuseumLine)) return;
|
if (!chart.data.datasets.some((ds: any) => ds._isMuseumLine)) return;
|
||||||
const ds = chart.data.datasets[args.index] as any;
|
|
||||||
const active = chart.getActiveElements();
|
const active = chart.getActiveElements();
|
||||||
const isDimmed = active.length > 0 && active[0].datasetIndex !== args.index;
|
if (active.length > 0 && active[0].datasetIndex !== args.index) {
|
||||||
if (isDimmed) chart.ctx.restore();
|
chart.ctx.restore();
|
||||||
if (!ds._isMuseumLine) return;
|
}
|
||||||
const meta = chart.getDatasetMeta(args.index);
|
},
|
||||||
if (meta.hidden) return;
|
|
||||||
const vals = ds.data as number[];
|
// ── end-of-line labels with collision resolution ───────────────
|
||||||
let lastIdx = vals.length - 1;
|
afterDatasetsDraw(chart: any) {
|
||||||
while (lastIdx > 0 && !vals[lastIdx]) lastIdx--;
|
const MIN_GAP = 13;
|
||||||
if (!vals[lastIdx]) return;
|
|
||||||
const pt = meta.data[lastIdx] as any;
|
// Collect last-point info for every visible museum line
|
||||||
if (!pt) return;
|
type Entry = { label: string; color: string; x: number; actualY: number; placedY: number };
|
||||||
const name: string = ds.label;
|
const entries: Entry[] = [];
|
||||||
const label = name.length > 22 ? name.slice(0, 20) + '…' : name;
|
chart.data.datasets.forEach((ds: any, i: number) => {
|
||||||
|
if (!ds._isMuseumLine) return;
|
||||||
|
const meta = chart.getDatasetMeta(i);
|
||||||
|
if (meta.hidden) return;
|
||||||
|
const vals = ds.data as number[];
|
||||||
|
let idx = vals.length - 1;
|
||||||
|
while (idx > 0 && !vals[idx]) idx--;
|
||||||
|
if (!vals[idx]) return;
|
||||||
|
const pt = meta.data[idx] as any;
|
||||||
|
if (!pt) return;
|
||||||
|
const name: string = ds.label;
|
||||||
|
entries.push({
|
||||||
|
label: name.length > 22 ? name.slice(0, 20) + '…' : name,
|
||||||
|
color: ds.borderColor,
|
||||||
|
x: pt.x,
|
||||||
|
actualY: pt.y,
|
||||||
|
placedY: pt.y,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (entries.length === 0) return;
|
||||||
|
|
||||||
|
// Sort top → bottom, then push labels apart
|
||||||
|
entries.sort((a, b) => a.actualY - b.actualY);
|
||||||
|
for (let i = 1; i < entries.length; i++) {
|
||||||
|
if (entries[i].placedY - entries[i - 1].placedY < MIN_GAP) {
|
||||||
|
entries[i].placedY = entries[i - 1].placedY + MIN_GAP;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw labels + connectors
|
||||||
const ctx = chart.ctx;
|
const ctx = chart.ctx;
|
||||||
ctx.save();
|
for (const e of entries) {
|
||||||
ctx.font = '600 9px system-ui,-apple-system,sans-serif';
|
ctx.save();
|
||||||
ctx.fillStyle = ds.borderColor;
|
// Small dot at the actual line tip
|
||||||
ctx.textAlign = 'left';
|
ctx.beginPath();
|
||||||
ctx.textBaseline = 'middle';
|
ctx.arc(e.x, e.actualY, 2.5, 0, Math.PI * 2);
|
||||||
ctx.fillText(label, pt.x + 6, pt.y);
|
ctx.fillStyle = e.color;
|
||||||
ctx.restore();
|
ctx.fill();
|
||||||
}
|
// Connector if label was displaced
|
||||||
|
if (Math.abs(e.placedY - e.actualY) > 3) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(e.x + 5, e.actualY);
|
||||||
|
ctx.lineTo(e.x + 7, e.placedY);
|
||||||
|
ctx.strokeStyle = e.color;
|
||||||
|
ctx.globalAlpha = 0.45;
|
||||||
|
ctx.lineWidth = 0.8;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.font = '600 9px system-ui,-apple-system,sans-serif';
|
||||||
|
ctx.fillStyle = e.color;
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(e.label, e.x + 9, e.placedY);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
ChartJS.register(trendLinePlugin);
|
ChartJS.register(trendLinePlugin);
|
||||||
|
|||||||
Reference in New Issue
Block a user