refactor(sparkline): move min/max readout to a corner badge

On-chart extrema labels were colliding with the Y-axis ticks at the
top, the X-axis timestamps at the bottom, and the chart line itself
when min/max sat near a chart edge. Replace the floating labels with
a single rounded pill in the chart's top-right corner that lists
"▲ max  ▼ min", outside the drawing area. Dots still mark the points
on the line. Also nudge Y tick text 4px left, push X timestamps down
with tickMargin=14, and widen YAxis to 56px so values like "234 KB/s"
don't crowd the chart.
This commit is contained in:
MHSanaei 2026-05-27 18:18:08 +02:00
parent e23599cb18
commit be5425cbed
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
2 changed files with 147 additions and 111 deletions

View file

@ -2,3 +2,33 @@
display: block; display: block;
width: 100%; width: 100%;
} }
.sparkline-container {
position: relative;
width: 100%;
}
.sparkline-extrema {
position: absolute;
top: 2px;
right: 8px;
display: inline-flex;
align-items: center;
gap: 12px;
padding: 2px 8px;
background: color-mix(in srgb, var(--ant-color-bg-elevated) 88%, transparent);
border: 1px solid var(--ant-color-border-secondary);
border-radius: 999px;
font-size: 11px;
font-weight: 600;
line-height: 16px;
pointer-events: none;
z-index: 1;
}
.sparkline-extrema .extrema-item {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}

View file

@ -137,7 +137,7 @@ export default function Sparkline({
if (points[i].value > points[maxIdx].value) maxIdx = i; if (points[i].value > points[maxIdx].value) maxIdx = i;
} }
if (minIdx === maxIdx) return null; if (minIdx === maxIdx) return null;
return { min: points[minIdx], max: points[maxIdx] }; return { min: points[minIdx], max: points[maxIdx], minIdx, maxIdx };
}, [points, extrema?.show]); }, [points, extrema?.show]);
const fmtExtrema = extrema?.formatter ?? yFormatter; const fmtExtrema = extrema?.formatter ?? yFormatter;
@ -145,120 +145,126 @@ export default function Sparkline({
const maxColor = extrema?.maxColor ?? DEFAULT_MAX_COLOR; const maxColor = extrema?.maxColor ?? DEFAULT_MAX_COLOR;
return ( return (
<ResponsiveContainer width="100%" height={height} className="sparkline-svg"> <div className="sparkline-container">
<AreaChart data={points} margin={{ top: 6, right: 6, bottom: showAxes ? 14 : 4, left: showAxes ? 4 : 4 }}> {extremaPoints && (
<defs> <div className="sparkline-extrema" aria-hidden="true">
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1"> <span className="extrema-item" style={{ color: maxColor }}>
<stop offset="0%" stopColor={stroke} stopOpacity={fillOpacity} /> {fmtExtrema(extremaPoints.max.value)}
<stop offset="100%" stopColor={stroke} stopOpacity={0} /> </span>
</linearGradient> <span className="extrema-item" style={{ color: minColor }}>
</defs> {fmtExtrema(extremaPoints.min.value)}
{showGrid && ( </span>
<CartesianGrid stroke="rgba(128, 128, 140, 0.35)" strokeDasharray="3 4" vertical={false} /> </div>
)} )}
<XAxis <ResponsiveContainer width="100%" height={height} className="sparkline-svg">
dataKey="label" <AreaChart
hide={!showAxes} data={points}
tick={{ fontSize: 10, fill: 'var(--ant-color-text-tertiary)' }} margin={{
axisLine={false} top: showAxes ? 14 : 6,
tickLine={false} right: showAxes ? 12 : 6,
interval={0} bottom: showAxes ? 26 : 4,
ticks={xTickIndexes?.map((i) => points[i]?.label).filter(Boolean) as string[] | undefined} left: 4,
/> }}
<YAxis >
domain={yDomain} <defs>
hide={!showAxes} <linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
tick={{ fontSize: 10, fill: 'var(--ant-color-text-tertiary)' }} <stop offset="0%" stopColor={stroke} stopOpacity={fillOpacity} />
axisLine={false} <stop offset="100%" stopColor={stroke} stopOpacity={0} />
tickLine={false} </linearGradient>
tickFormatter={yFormatter} </defs>
ticks={yTicks} {showGrid && (
width={48} <CartesianGrid stroke="rgba(128, 128, 140, 0.35)" strokeDasharray="3 4" vertical={false} />
/> )}
{showTooltip && ( <XAxis
<Tooltip dataKey="label"
cursor={{ stroke: 'var(--ant-color-border)', strokeDasharray: '2 4' }} hide={!showAxes}
contentStyle={{ tick={{ fontSize: 10, fill: 'var(--ant-color-text-tertiary)' }}
background: 'var(--ant-color-bg-elevated)', axisLine={false}
border: '1px solid var(--ant-color-border-secondary)', tickLine={false}
borderRadius: 6, tickMargin={14}
fontSize: 12, interval={0}
padding: '6px 10px', ticks={xTickIndexes?.map((i) => points[i]?.label).filter(Boolean) as string[] | undefined}
boxShadow: '0 4px 14px rgba(0, 0, 0, 0.12)',
}}
labelStyle={{ color: 'var(--ant-color-text-tertiary)', marginBottom: 4, fontSize: 11 }}
itemStyle={{ color: 'var(--ant-color-text)', padding: 0, fontWeight: 500 }}
formatter={(v) => [fmtTooltip(Number(v) || 0), '']}
labelFormatter={(label) => (tooltipLabelFormatter ? tooltipLabelFormatter(String(label)) : String(label))}
separator=""
/> />
)} <YAxis
{referenceLines?.map((rl, idx) => ( domain={yDomain}
<ReferenceLine hide={!showAxes}
key={`ref-${idx}-${rl.y}`} tick={{ fontSize: 10, fill: 'var(--ant-color-text-tertiary)', dx: -4 }}
y={rl.y} axisLine={false}
stroke={rl.color || stroke} tickLine={false}
strokeDasharray={rl.dash || '5 4'} tickMargin={8}
strokeWidth={1.4} tickFormatter={yFormatter}
label={rl.label ? { ticks={yTicks}
value: rl.label, width={56}
position: 'insideTopRight',
fill: rl.color || stroke,
fontSize: 10,
fontWeight: 600,
} : undefined}
ifOverflow="extendDomain"
/> />
))} {showTooltip && (
{extremaPoints && ( <Tooltip
<> cursor={{ stroke: 'var(--ant-color-border)', strokeDasharray: '2 4' }}
<ReferenceDot contentStyle={{
x={extremaPoints.max.label} background: 'var(--ant-color-bg-elevated)',
y={extremaPoints.max.value} border: '1px solid var(--ant-color-border-secondary)',
r={4.5} borderRadius: 6,
fill={maxColor} fontSize: 12,
stroke="var(--ant-color-bg-elevated)" padding: '6px 10px',
strokeWidth={2} boxShadow: '0 4px 14px rgba(0, 0, 0, 0.12)',
label={{
value: `${fmtExtrema(extremaPoints.max.value)}`,
position: 'top',
fontSize: 10.5,
fill: maxColor,
fontWeight: 600,
offset: 8,
}} }}
labelStyle={{ color: 'var(--ant-color-text-tertiary)', marginBottom: 4, fontSize: 11 }}
itemStyle={{ color: 'var(--ant-color-text)', padding: 0, fontWeight: 500 }}
formatter={(v) => [fmtTooltip(Number(v) || 0), '']}
labelFormatter={(label) => (tooltipLabelFormatter ? tooltipLabelFormatter(String(label)) : String(label))}
separator=""
/>
)}
{referenceLines?.map((rl, idx) => (
<ReferenceLine
key={`ref-${idx}-${rl.y}`}
y={rl.y}
stroke={rl.color || stroke}
strokeDasharray={rl.dash || '5 4'}
strokeWidth={1.4}
label={rl.label ? {
value: rl.label,
position: 'insideTopRight',
fill: rl.color || stroke,
fontSize: 10,
fontWeight: 600,
} : undefined}
ifOverflow="extendDomain" ifOverflow="extendDomain"
/> />
<ReferenceDot ))}
x={extremaPoints.min.label} {extremaPoints && (
y={extremaPoints.min.value} <>
r={4.5} <ReferenceDot
fill={minColor} x={extremaPoints.max.label}
stroke="var(--ant-color-bg-elevated)" y={extremaPoints.max.value}
strokeWidth={2} r={4.5}
label={{ fill={maxColor}
value: `${fmtExtrema(extremaPoints.min.value)}`, stroke="var(--ant-color-bg-elevated)"
position: 'bottom', strokeWidth={2}
fontSize: 10.5, ifOverflow="extendDomain"
fill: minColor, />
fontWeight: 600, <ReferenceDot
offset: 8, x={extremaPoints.min.label}
}} y={extremaPoints.min.value}
ifOverflow="extendDomain" r={4.5}
/> fill={minColor}
</> stroke="var(--ant-color-bg-elevated)"
)} strokeWidth={2}
<Area ifOverflow="extendDomain"
type="monotone" />
dataKey="value" </>
stroke={stroke} )}
strokeWidth={strokeWidth} <Area
fill={`url(#${gradId})`} type="monotone"
dot={false} dataKey="value"
activeDot={showMarker ? { r: markerRadius, fill: stroke, strokeWidth: 0 } : false} stroke={stroke}
isAnimationActive={false} strokeWidth={strokeWidth}
/> fill={`url(#${gradId})`}
</AreaChart> dot={false}
</ResponsiveContainer> activeDot={showMarker ? { r: markerRadius, fill: stroke, strokeWidth: 0 } : false}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
); );
} }