mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-31 18:24:10 +00:00
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:
parent
e23599cb18
commit
be5425cbed
2 changed files with 147 additions and 111 deletions
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue