Przeglądaj źródła

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.
MHSanaei 4 godzin temu
rodzic
commit
be5425cbed

+ 30 - 0
frontend/src/components/Sparkline.css

@@ -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;
+}

+ 115 - 109
frontend/src/components/Sparkline.tsx

@@ -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">
-      <AreaChart data={points} margin={{ top: 6, right: 6, bottom: showAxes ? 14 : 4, left: showAxes ? 4 : 4 }}>
-        <defs>
-          <linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
-            <stop offset="0%" stopColor={stroke} stopOpacity={fillOpacity} />
-            <stop offset="100%" stopColor={stroke} stopOpacity={0} />
-          </linearGradient>
-        </defs>
-        {showGrid && (
-          <CartesianGrid stroke="rgba(128, 128, 140, 0.35)" strokeDasharray="3 4" vertical={false} />
-        )}
-        <XAxis
-          dataKey="label"
-          hide={!showAxes}
-          tick={{ fontSize: 10, fill: 'var(--ant-color-text-tertiary)' }}
-          axisLine={false}
-          tickLine={false}
-          interval={0}
-          ticks={xTickIndexes?.map((i) => points[i]?.label).filter(Boolean) as string[] | undefined}
-        />
-        <YAxis
-          domain={yDomain}
-          hide={!showAxes}
-          tick={{ fontSize: 10, fill: 'var(--ant-color-text-tertiary)' }}
-          axisLine={false}
-          tickLine={false}
-          tickFormatter={yFormatter}
-          ticks={yTicks}
-          width={48}
-        />
-        {showTooltip && (
-          <Tooltip
-            cursor={{ stroke: 'var(--ant-color-border)', strokeDasharray: '2 4' }}
-            contentStyle={{
-              background: 'var(--ant-color-bg-elevated)',
-              border: '1px solid var(--ant-color-border-secondary)',
-              borderRadius: 6,
-              fontSize: 12,
-              padding: '6px 10px',
-              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=""
+    <div className="sparkline-container">
+      {extremaPoints && (
+        <div className="sparkline-extrema" aria-hidden="true">
+          <span className="extrema-item" style={{ color: maxColor }}>
+            ▲ {fmtExtrema(extremaPoints.max.value)}
+          </span>
+          <span className="extrema-item" style={{ color: minColor }}>
+            ▼ {fmtExtrema(extremaPoints.min.value)}
+          </span>
+        </div>
+      )}
+      <ResponsiveContainer width="100%" height={height} className="sparkline-svg">
+        <AreaChart
+          data={points}
+          margin={{
+            top: showAxes ? 14 : 6,
+            right: showAxes ? 12 : 6,
+            bottom: showAxes ? 26 : 4,
+            left: 4,
+          }}
+        >
+          <defs>
+            <linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
+              <stop offset="0%" stopColor={stroke} stopOpacity={fillOpacity} />
+              <stop offset="100%" stopColor={stroke} stopOpacity={0} />
+            </linearGradient>
+          </defs>
+          {showGrid && (
+            <CartesianGrid stroke="rgba(128, 128, 140, 0.35)" strokeDasharray="3 4" vertical={false} />
+          )}
+          <XAxis
+            dataKey="label"
+            hide={!showAxes}
+            tick={{ fontSize: 10, fill: 'var(--ant-color-text-tertiary)' }}
+            axisLine={false}
+            tickLine={false}
+            tickMargin={14}
+            interval={0}
+            ticks={xTickIndexes?.map((i) => points[i]?.label).filter(Boolean) as string[] | undefined}
           />
           />
-        )}
-        {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"
+          <YAxis
+            domain={yDomain}
+            hide={!showAxes}
+            tick={{ fontSize: 10, fill: 'var(--ant-color-text-tertiary)', dx: -4 }}
+            axisLine={false}
+            tickLine={false}
+            tickMargin={8}
+            tickFormatter={yFormatter}
+            ticks={yTicks}
+            width={56}
           />
           />
-        ))}
-        {extremaPoints && (
-          <>
-            <ReferenceDot
-              x={extremaPoints.max.label}
-              y={extremaPoints.max.value}
-              r={4.5}
-              fill={maxColor}
-              stroke="var(--ant-color-bg-elevated)"
-              strokeWidth={2}
-              label={{
-                value: `▲ ${fmtExtrema(extremaPoints.max.value)}`,
-                position: 'top',
-                fontSize: 10.5,
-                fill: maxColor,
-                fontWeight: 600,
-                offset: 8,
+          {showTooltip && (
+            <Tooltip
+              cursor={{ stroke: 'var(--ant-color-border)', strokeDasharray: '2 4' }}
+              contentStyle={{
+                background: 'var(--ant-color-bg-elevated)',
+                border: '1px solid var(--ant-color-border-secondary)',
+                borderRadius: 6,
+                fontSize: 12,
+                padding: '6px 10px',
+                boxShadow: '0 4px 14px rgba(0, 0, 0, 0.12)',
               }}
               }}
-              ifOverflow="extendDomain"
+              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=""
             />
             />
-            <ReferenceDot
-              x={extremaPoints.min.label}
-              y={extremaPoints.min.value}
-              r={4.5}
-              fill={minColor}
-              stroke="var(--ant-color-bg-elevated)"
-              strokeWidth={2}
-              label={{
-                value: `▼ ${fmtExtrema(extremaPoints.min.value)}`,
-                position: 'bottom',
-                fontSize: 10.5,
-                fill: minColor,
+          )}
+          {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,
                 fontWeight: 600,
-                offset: 8,
-              }}
+              } : undefined}
               ifOverflow="extendDomain"
               ifOverflow="extendDomain"
             />
             />
-          </>
-        )}
-        <Area
-          type="monotone"
-          dataKey="value"
-          stroke={stroke}
-          strokeWidth={strokeWidth}
-          fill={`url(#${gradId})`}
-          dot={false}
-          activeDot={showMarker ? { r: markerRadius, fill: stroke, strokeWidth: 0 } : false}
-          isAnimationActive={false}
-        />
-      </AreaChart>
-    </ResponsiveContainer>
+          ))}
+          {extremaPoints && (
+            <>
+              <ReferenceDot
+                x={extremaPoints.max.label}
+                y={extremaPoints.max.value}
+                r={4.5}
+                fill={maxColor}
+                stroke="var(--ant-color-bg-elevated)"
+                strokeWidth={2}
+                ifOverflow="extendDomain"
+              />
+              <ReferenceDot
+                x={extremaPoints.min.label}
+                y={extremaPoints.min.value}
+                r={4.5}
+                fill={minColor}
+                stroke="var(--ant-color-bg-elevated)"
+                strokeWidth={2}
+                ifOverflow="extendDomain"
+              />
+            </>
+          )}
+          <Area
+            type="monotone"
+            dataKey="value"
+            stroke={stroke}
+            strokeWidth={strokeWidth}
+            fill={`url(#${gradId})`}
+            dot={false}
+            activeDot={showMarker ? { r: markerRadius, fill: stroke, strokeWidth: 0 } : false}
+            isAnimationActive={false}
+          />
+        </AreaChart>
+      </ResponsiveContainer>
+    </div>
   );
   );
 }
 }