From 5b91c8fef9a035673d70295b7a2b566431d1ff5f Mon Sep 17 00:00:00 2001 From: Florent Tapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:31:56 +0200 Subject: [PATCH] fix: end-of-line label collision + padR increase for clean PNG export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Limit inline labels to top 10 non-excluded lines (legend shows all) - Collision deflection: sort labels by Y, push each down until clear of previous - Increase padR 96→130 so label text never gets clipped by SVG viewport --- src/components/time-series-chart/chart.tsx | 2 +- src/components/time-series-chart/series.tsx | 150 ++++++++++++-------- 2 files changed, 88 insertions(+), 64 deletions(-) diff --git a/src/components/time-series-chart/chart.tsx b/src/components/time-series-chart/chart.tsx index 480613a2..0242bb19 100644 --- a/src/components/time-series-chart/chart.tsx +++ b/src/components/time-series-chart/chart.tsx @@ -38,7 +38,7 @@ export function Chart({ const W = 1000; const H = 360; const padL = 60; - const padR = 96; + const padR = 130; const padT = 16; const padB = 36; const innerW = W - padL - padR; diff --git a/src/components/time-series-chart/series.tsx b/src/components/time-series-chart/series.tsx index afe17328..67e9f386 100644 --- a/src/components/time-series-chart/series.tsx +++ b/src/components/time-series-chart/series.tsx @@ -21,75 +21,99 @@ type SeriesPathsProps = { // now speak for themselves — SeriesPaths renders a natural break wherever // the underlying values are null. +// Max number of end-of-line labels rendered. The legend below shows all +// providers regardless — this cap prevents right-side label clutter when +// dozens of lines share the same Y range. +const MAX_INLINE_LABELS = 10; +// Minimum vertical gap (SVG units) between consecutive placed labels so +// name + value lines don't overlap. +const LABEL_GAP = 27; + export function SeriesPaths({ drawn, unit }: SeriesPathsProps) { + // Limit labels to top-N non-excluded lines (drawn is pre-sorted by value). + const labeledSlugs = new Set( + drawn.filter((d) => !d.excluded).slice(0, MAX_INLINE_LABELS).map((d) => d.slug), + ); + + // Collision deflection: sort labeled lines by their natural Y position, + // then push each label down until it clears the previous one. + const labelY: Record = {}; + const toPlace = drawn + .filter((d) => labeledSlugs.has(d.slug)) + .map((d) => ({ slug: d.slug, natural: d.lastY })) + .sort((a, b) => a.natural - b.natural); + let prevBottom = -Infinity; + for (const item of toPlace) { + const y = Math.max(item.natural, prevBottom); + labelY[item.slug] = y; + prevBottom = y + LABEL_GAP; + } + return ( <> - {drawn.map((d) => ( - - - { + const showLabel = labeledSlugs.has(d.slug); + const ly = labelY[d.slug] ?? d.lastY; + return ( + - {/* Live pulse halo. animated outward */} - - - - - {/* Trailing tail dot */} - - {/* End-of-line label */} - - {d.name} - - - {fmtUnit(d.last, unit)} - - - ))} + + + {/* Live pulse halo */} + + + + + {/* Trailing tail dot */} + + {/* End-of-line label — only for top-N, collision-deflected */} + {showLabel && ( + <> + + {d.name} + + + {fmtUnit(d.last, unit)} + + + )} + + ); + })} ); }