|
@@ -1,15 +1,6 @@
|
|
|
-import { useId, useMemo } from 'react';
|
|
|
|
|
-import {
|
|
|
|
|
- Area,
|
|
|
|
|
- AreaChart,
|
|
|
|
|
- CartesianGrid,
|
|
|
|
|
- ReferenceDot,
|
|
|
|
|
- ReferenceLine,
|
|
|
|
|
- ResponsiveContainer,
|
|
|
|
|
- Tooltip,
|
|
|
|
|
- XAxis,
|
|
|
|
|
- YAxis,
|
|
|
|
|
-} from 'recharts';
|
|
|
|
|
|
|
+import { useEffect, useMemo, useRef } from 'react';
|
|
|
|
|
+import uPlot from 'uplot';
|
|
|
|
|
+import 'uplot/dist/uPlot.min.css';
|
|
|
import './Sparkline.css';
|
|
import './Sparkline.css';
|
|
|
|
|
|
|
|
export interface SparklineReferenceLine {
|
|
export interface SparklineReferenceLine {
|
|
@@ -26,8 +17,14 @@ export interface SparklineExtrema {
|
|
|
maxColor?: string;
|
|
maxColor?: string;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+const DEFAULT_STROKE = '#008771';
|
|
|
|
|
+const DEFAULT_STROKE2 = '#722ed1';
|
|
|
|
|
+const DEFAULT_STROKE3 = '#a0d911';
|
|
|
const DEFAULT_MIN_COLOR = '#52c41a';
|
|
const DEFAULT_MIN_COLOR = '#52c41a';
|
|
|
const DEFAULT_MAX_COLOR = '#fa541c';
|
|
const DEFAULT_MAX_COLOR = '#fa541c';
|
|
|
|
|
+const GRID_COLOR = 'rgba(128, 128, 140, 0.35)';
|
|
|
|
|
+const AXIS_FONT = '10px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif';
|
|
|
|
|
+const LABEL_FONT = 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif';
|
|
|
|
|
|
|
|
interface SparklineProps {
|
|
interface SparklineProps {
|
|
|
data: number[];
|
|
data: number[];
|
|
@@ -68,41 +65,79 @@ interface ChartPoint {
|
|
|
label: string;
|
|
label: string;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-export default function Sparkline({
|
|
|
|
|
- data,
|
|
|
|
|
- data2 = [],
|
|
|
|
|
- data3 = [],
|
|
|
|
|
- stroke2 = '#722ed1',
|
|
|
|
|
- stroke3 = '#a0d911',
|
|
|
|
|
- name1,
|
|
|
|
|
- name2,
|
|
|
|
|
- name3,
|
|
|
|
|
- labels = [],
|
|
|
|
|
- height = 80,
|
|
|
|
|
- stroke = '#008771',
|
|
|
|
|
- strokeWidth = 2,
|
|
|
|
|
- maxPoints = 120,
|
|
|
|
|
- showGrid = true,
|
|
|
|
|
- fillOpacity = 0.22,
|
|
|
|
|
- showMarker = true,
|
|
|
|
|
- markerRadius = 3,
|
|
|
|
|
- showAxes = false,
|
|
|
|
|
- yTickStep = 25,
|
|
|
|
|
- tickCountX = 4,
|
|
|
|
|
- showTooltip = false,
|
|
|
|
|
- valueMin = 0,
|
|
|
|
|
- valueMax = 100,
|
|
|
|
|
- yFormatter = (v: number) => `${Math.round(v)}%`,
|
|
|
|
|
- tooltipFormatter = null,
|
|
|
|
|
- tooltipLabelFormatter = null,
|
|
|
|
|
- referenceLines,
|
|
|
|
|
- extrema,
|
|
|
|
|
-}: SparklineProps) {
|
|
|
|
|
- const reactId = useId();
|
|
|
|
|
- const safeId = reactId.replace(/[^a-zA-Z0-9]/g, '');
|
|
|
|
|
- const gradId = `spkGrad-${safeId}`;
|
|
|
|
|
- const gradId2 = `spkGrad2-${safeId}`;
|
|
|
|
|
- const gradId3 = `spkGrad3-${safeId}`;
|
|
|
|
|
|
|
+interface ExtremaResult {
|
|
|
|
|
+ min: ChartPoint;
|
|
|
|
|
+ max: ChartPoint;
|
|
|
|
|
+ minIdx: number;
|
|
|
|
|
+ maxIdx: number;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface SparklineView {
|
|
|
|
|
+ points: ChartPoint[];
|
|
|
|
|
+ yDomain: [number, number];
|
|
|
|
|
+ yTicks: number[] | undefined;
|
|
|
|
|
+ xTickIndexes: number[] | undefined;
|
|
|
|
|
+ extremaPoints: ExtremaResult | null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function hexToRgba(hex: string, alpha: number): string {
|
|
|
|
|
+ let h = hex.trim();
|
|
|
|
|
+ if (h.startsWith('#')) h = h.slice(1);
|
|
|
|
|
+ if (h.length === 3) h = h.split('').map((c) => c + c).join('');
|
|
|
|
|
+ if (h.length !== 6) return hex;
|
|
|
|
|
+ const int = Number.parseInt(h, 16);
|
|
|
|
|
+ if (Number.isNaN(int)) return hex;
|
|
|
|
|
+ const r = (int >> 16) & 255;
|
|
|
|
|
+ const g = (int >> 8) & 255;
|
|
|
|
|
+ const b = int & 255;
|
|
|
|
|
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function cssVar(el: HTMLElement, name: string, fallback: string): string {
|
|
|
|
|
+ const v = getComputedStyle(el).getPropertyValue(name).trim();
|
|
|
|
|
+ return v || fallback;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function parseDash(dash: string, dpr: number): number[] {
|
|
|
|
|
+ return dash.trim().split(/\s+/).map((n) => (Number(n) || 0) * dpr);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function dprOf(u: uPlot): number {
|
|
|
|
|
+ return u.width > 0 ? u.ctx.canvas.width / u.width : (uPlot.pxRatio || 1);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export default function Sparkline(props: SparklineProps) {
|
|
|
|
|
+ const {
|
|
|
|
|
+ data,
|
|
|
|
|
+ data2 = [],
|
|
|
|
|
+ data3 = [],
|
|
|
|
|
+ stroke = DEFAULT_STROKE,
|
|
|
|
|
+ stroke2 = DEFAULT_STROKE2,
|
|
|
|
|
+ stroke3 = DEFAULT_STROKE3,
|
|
|
|
|
+ name1,
|
|
|
|
|
+ name2,
|
|
|
|
|
+ name3,
|
|
|
|
|
+ labels = [],
|
|
|
|
|
+ height = 80,
|
|
|
|
|
+ strokeWidth = 2,
|
|
|
|
|
+ maxPoints = 120,
|
|
|
|
|
+ showGrid = true,
|
|
|
|
|
+ fillOpacity = 0.22,
|
|
|
|
|
+ showMarker = true,
|
|
|
|
|
+ markerRadius = 3,
|
|
|
|
|
+ showAxes = false,
|
|
|
|
|
+ yTickStep = 25,
|
|
|
|
|
+ tickCountX = 4,
|
|
|
|
|
+ showTooltip = false,
|
|
|
|
|
+ valueMin = 0,
|
|
|
|
|
+ valueMax = 100,
|
|
|
|
|
+ yFormatter = (v: number) => `${Math.round(v)}%`,
|
|
|
|
|
+ tooltipFormatter = null,
|
|
|
|
|
+ tooltipLabelFormatter = null,
|
|
|
|
|
+ referenceLines,
|
|
|
|
|
+ extrema,
|
|
|
|
|
+ } = props;
|
|
|
|
|
+
|
|
|
const hasSeries2 = data2.length > 0;
|
|
const hasSeries2 = data2.length > 0;
|
|
|
const hasSeries3 = data3.length > 0;
|
|
const hasSeries3 = data3.length > 0;
|
|
|
const multiSeries = hasSeries2 || hasSeries3;
|
|
const multiSeries = hasSeries2 || hasSeries3;
|
|
@@ -135,7 +170,7 @@ export default function Sparkline({
|
|
|
return [valueMin, max * 1.1];
|
|
return [valueMin, max * 1.1];
|
|
|
}, [points, valueMin, valueMax, hasSeries2, hasSeries3]);
|
|
}, [points, valueMin, valueMax, hasSeries2, hasSeries3]);
|
|
|
|
|
|
|
|
- const yTicks = useMemo(() => {
|
|
|
|
|
|
|
+ const yTicks = useMemo<number[] | undefined>(() => {
|
|
|
if (!showAxes) return undefined;
|
|
if (!showAxes) return undefined;
|
|
|
const [min, max] = yDomain;
|
|
const [min, max] = yDomain;
|
|
|
if (valueMax === 100 && valueMin === 0 && yTickStep > 0) {
|
|
if (valueMax === 100 && valueMin === 0 && yTickStep > 0) {
|
|
@@ -147,15 +182,13 @@ export default function Sparkline({
|
|
|
return Array.from({ length: n }, (_, i) => min + ((max - min) * i) / (n - 1));
|
|
return Array.from({ length: n }, (_, i) => min + ((max - min) * i) / (n - 1));
|
|
|
}, [showAxes, yDomain, valueMin, valueMax, yTickStep]);
|
|
}, [showAxes, yDomain, valueMin, valueMax, yTickStep]);
|
|
|
|
|
|
|
|
- const xTickIndexes = useMemo(() => {
|
|
|
|
|
|
|
+ const xTickIndexes = useMemo<number[] | undefined>(() => {
|
|
|
if (!showAxes || points.length === 0) return undefined;
|
|
if (!showAxes || points.length === 0) return undefined;
|
|
|
const m = Math.max(2, tickCountX);
|
|
const m = Math.max(2, tickCountX);
|
|
|
return Array.from({ length: m }, (_, i) => Math.round((i * (points.length - 1)) / (m - 1)));
|
|
return Array.from({ length: m }, (_, i) => Math.round((i * (points.length - 1)) / (m - 1)));
|
|
|
}, [showAxes, tickCountX, points.length]);
|
|
}, [showAxes, tickCountX, points.length]);
|
|
|
|
|
|
|
|
- const fmtTooltip = tooltipFormatter ?? yFormatter;
|
|
|
|
|
-
|
|
|
|
|
- const extremaPoints = useMemo(() => {
|
|
|
|
|
|
|
+ const extremaPoints = useMemo<ExtremaResult | null>(() => {
|
|
|
if (!extrema?.show || multiSeries || points.length < 2) return null;
|
|
if (!extrema?.show || multiSeries || points.length < 2) return null;
|
|
|
let minIdx = 0;
|
|
let minIdx = 0;
|
|
|
let maxIdx = 0;
|
|
let maxIdx = 0;
|
|
@@ -191,6 +224,340 @@ export default function Sparkline({
|
|
|
return parts.join(', ');
|
|
return parts.join(', ');
|
|
|
}, [points, name1, name2, name3, hasSeries2, hasSeries3, yFormatter]);
|
|
}, [points, name1, name2, name3, hasSeries2, hasSeries3, yFormatter]);
|
|
|
|
|
|
|
|
|
|
+ const cfg = {
|
|
|
|
|
+ stroke,
|
|
|
|
|
+ stroke2,
|
|
|
|
|
+ stroke3,
|
|
|
|
|
+ strokeWidth,
|
|
|
|
|
+ fillOpacity,
|
|
|
|
|
+ markerRadius,
|
|
|
|
|
+ showGrid,
|
|
|
|
|
+ showMarker,
|
|
|
|
|
+ showAxes,
|
|
|
|
|
+ showTooltip,
|
|
|
|
|
+ height,
|
|
|
|
|
+ name1,
|
|
|
|
|
+ name2,
|
|
|
|
|
+ name3,
|
|
|
|
|
+ yFormatter,
|
|
|
|
|
+ tooltipFormatter,
|
|
|
|
|
+ tooltipLabelFormatter,
|
|
|
|
|
+ referenceLines,
|
|
|
|
|
+ extrema,
|
|
|
|
|
+ };
|
|
|
|
|
+ const cfgRef = useRef(cfg);
|
|
|
|
|
+ cfgRef.current = cfg;
|
|
|
|
|
+ const viewRef = useRef<SparklineView>({ points, yDomain, yTicks, xTickIndexes, extremaPoints });
|
|
|
|
|
+ viewRef.current = { points, yDomain, yTicks, xTickIndexes, extremaPoints };
|
|
|
|
|
+
|
|
|
|
|
+ const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
+ const plotRef = useRef<uPlot | null>(null);
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ const container = containerRef.current;
|
|
|
|
|
+ if (!container) return;
|
|
|
|
|
+
|
|
|
|
|
+ let tooltipEl: HTMLDivElement | null = null;
|
|
|
|
|
+
|
|
|
|
|
+ const seriesColor = (idx: number): string => {
|
|
|
|
|
+ const p = cfgRef.current;
|
|
|
|
|
+ if (idx <= 1) return p.stroke ?? DEFAULT_STROKE;
|
|
|
|
|
+ if (idx === 2) return p.stroke2 ?? DEFAULT_STROKE2;
|
|
|
|
|
+ return p.stroke3 ?? DEFAULT_STROKE3;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const gridTicks = (): number[] => {
|
|
|
|
|
+ const yt = viewRef.current.yTicks;
|
|
|
|
|
+ if (yt && yt.length) return yt;
|
|
|
|
|
+ const [mn, mx] = viewRef.current.yDomain;
|
|
|
|
|
+ const n = 4;
|
|
|
|
|
+ return Array.from({ length: n + 1 }, (_, i) => mn + ((mx - mn) * i) / n);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const buildData = (): uPlot.AlignedData => {
|
|
|
|
|
+ const v = viewRef.current;
|
|
|
|
|
+ const xs = v.points.map((_, i) => i);
|
|
|
|
|
+ const series: number[][] = [v.points.map((p) => p.value)];
|
|
|
|
|
+ if (hasSeries2) series.push(v.points.map((p) => p.value2));
|
|
|
|
|
+ if (hasSeries3) series.push(v.points.map((p) => p.value3));
|
|
|
|
|
+ return [xs, ...series];
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const makeSeries = (): uPlot.Series => ({
|
|
|
|
|
+ stroke: (_u, sidx) => seriesColor(sidx),
|
|
|
|
|
+ width: cfgRef.current.strokeWidth ?? 2,
|
|
|
|
|
+ fill: (u, sidx) => {
|
|
|
|
|
+ const { ctx, bbox } = u;
|
|
|
|
|
+ const color = seriesColor(sidx);
|
|
|
|
|
+ const grad = ctx.createLinearGradient(0, bbox.top, 0, bbox.top + bbox.height);
|
|
|
|
|
+ grad.addColorStop(0, hexToRgba(color, cfgRef.current.fillOpacity ?? 0.22));
|
|
|
|
|
+ grad.addColorStop(1, hexToRgba(color, 0));
|
|
|
|
|
+ return grad;
|
|
|
|
|
+ },
|
|
|
|
|
+ paths: uPlot.paths.spline?.(),
|
|
|
|
|
+ points: { show: false },
|
|
|
|
|
+ spanGaps: true,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const series: uPlot.Series[] = [{}, makeSeries()];
|
|
|
|
|
+ if (hasSeries2) series.push(makeSeries());
|
|
|
|
|
+ if (hasSeries3) series.push(makeSeries());
|
|
|
|
|
+
|
|
|
|
|
+ const axisStroke = (u: uPlot) => cssVar(u.root, '--ant-color-text-tertiary', '#8c8c8c');
|
|
|
|
|
+
|
|
|
|
|
+ const axes: uPlot.Axis[] = [
|
|
|
|
|
+ {
|
|
|
|
|
+ show: showAxes,
|
|
|
|
|
+ stroke: axisStroke,
|
|
|
|
|
+ grid: { show: false },
|
|
|
|
|
+ ticks: { show: false },
|
|
|
|
|
+ font: AXIS_FONT,
|
|
|
|
|
+ gap: 6,
|
|
|
|
|
+ size: 28,
|
|
|
|
|
+ splits: () => viewRef.current.xTickIndexes ?? [],
|
|
|
|
|
+ values: (_u, splits) => splits.map((i) => viewRef.current.points[i]?.label ?? ''),
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ show: showAxes,
|
|
|
|
|
+ scale: 'y',
|
|
|
|
|
+ side: 3,
|
|
|
|
|
+ stroke: axisStroke,
|
|
|
|
|
+ grid: { show: false },
|
|
|
|
|
+ ticks: { show: false },
|
|
|
|
|
+ font: AXIS_FONT,
|
|
|
|
|
+ gap: 4,
|
|
|
|
|
+ size: 56,
|
|
|
|
|
+ splits: () => viewRef.current.yTicks ?? [],
|
|
|
|
|
+ values: (_u, splits) =>
|
|
|
|
|
+ splits.map((v) => (cfgRef.current.yFormatter ? cfgRef.current.yFormatter(v) : String(v))),
|
|
|
|
|
+ },
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ const drawGrid = (u: uPlot) => {
|
|
|
|
|
+ if (cfgRef.current.showGrid === false) return;
|
|
|
|
|
+ const { ctx, bbox } = u;
|
|
|
|
|
+ const dpr = dprOf(u);
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.strokeStyle = GRID_COLOR;
|
|
|
|
|
+ ctx.lineWidth = dpr;
|
|
|
|
|
+ ctx.setLineDash([3 * dpr, 4 * dpr]);
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ for (const ty of gridTicks()) {
|
|
|
|
|
+ const py = Math.round(u.valToPos(ty, 'y', true)) + 0.5;
|
|
|
|
|
+ ctx.moveTo(bbox.left, py);
|
|
|
|
|
+ ctx.lineTo(bbox.left + bbox.width, py);
|
|
|
|
|
+ }
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const drawOverlay = (u: uPlot) => {
|
|
|
|
|
+ const p = cfgRef.current;
|
|
|
|
|
+ const v = viewRef.current;
|
|
|
|
|
+ const { ctx, bbox } = u;
|
|
|
|
|
+ const dpr = dprOf(u);
|
|
|
|
|
+ const right = bbox.left + bbox.width;
|
|
|
|
|
+
|
|
|
|
|
+ if (p.referenceLines?.length) {
|
|
|
|
|
+ for (const rl of p.referenceLines) {
|
|
|
|
|
+ const color = rl.color || p.stroke || DEFAULT_STROKE;
|
|
|
|
|
+ const py = Math.round(u.valToPos(rl.y, 'y', true)) + 0.5;
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.strokeStyle = color;
|
|
|
|
|
+ ctx.lineWidth = 1.4 * dpr;
|
|
|
|
|
+ ctx.setLineDash(parseDash(rl.dash ?? '5 4', dpr));
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.moveTo(bbox.left, py);
|
|
|
|
|
+ ctx.lineTo(right, py);
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+ if (rl.label) {
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.fillStyle = color;
|
|
|
|
|
+ ctx.font = `600 ${10 * dpr}px ${LABEL_FONT}`;
|
|
|
|
|
+ ctx.textAlign = 'right';
|
|
|
|
|
+ ctx.textBaseline = 'bottom';
|
|
|
|
|
+ ctx.fillText(rl.label, right - 4 * dpr, py - 3 * dpr);
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const ex = v.extremaPoints;
|
|
|
|
|
+ if (p.extrema?.show && ex) {
|
|
|
|
|
+ const ringColor = cssVar(u.root, '--ant-color-bg-elevated', '#ffffff');
|
|
|
|
|
+ const dot = (value: number, idx: number, color: string) => {
|
|
|
|
|
+ const px = u.valToPos(idx, 'x', true);
|
|
|
|
|
+ const py = u.valToPos(value, 'y', true);
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.arc(px, py, 4.5 * dpr, 0, Math.PI * 2);
|
|
|
|
|
+ ctx.fillStyle = color;
|
|
|
|
|
+ ctx.fill();
|
|
|
|
|
+ ctx.lineWidth = 2 * dpr;
|
|
|
|
|
+ ctx.strokeStyle = ringColor;
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+ };
|
|
|
|
|
+ dot(ex.max.value, ex.maxIdx, p.extrema.maxColor ?? DEFAULT_MAX_COLOR);
|
|
|
|
|
+ dot(ex.min.value, ex.minIdx, p.extrema.minColor ?? DEFAULT_MIN_COLOR);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const updateTooltip = (u: uPlot) => {
|
|
|
|
|
+ if (!tooltipEl) return;
|
|
|
|
|
+ const idx = u.cursor.idx;
|
|
|
|
|
+ const v = viewRef.current;
|
|
|
|
|
+ const p = cfgRef.current;
|
|
|
|
|
+ if (idx == null || idx < 0 || idx >= v.points.length) {
|
|
|
|
|
+ tooltipEl.style.display = 'none';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const pt = v.points[idx];
|
|
|
|
|
+ const fmt = p.tooltipFormatter ?? p.yFormatter ?? ((x: number) => String(x));
|
|
|
|
|
+ const label = p.tooltipLabelFormatter ? p.tooltipLabelFormatter(String(pt.label)) : String(pt.label);
|
|
|
|
|
+ const multi = hasSeries2 || hasSeries3;
|
|
|
|
|
+
|
|
|
|
|
+ tooltipEl.textContent = '';
|
|
|
|
|
+ const labelDiv = document.createElement('div');
|
|
|
|
|
+ labelDiv.className = 'spk-tt-label';
|
|
|
|
|
+ labelDiv.textContent = label;
|
|
|
|
|
+ tooltipEl.appendChild(labelDiv);
|
|
|
|
|
+
|
|
|
|
|
+ const rows = [
|
|
|
|
|
+ { name: p.name1, color: p.stroke ?? DEFAULT_STROKE, val: pt.value, on: true },
|
|
|
|
|
+ { name: p.name2, color: p.stroke2 ?? DEFAULT_STROKE2, val: pt.value2, on: hasSeries2 },
|
|
|
|
|
+ { name: p.name3, color: p.stroke3 ?? DEFAULT_STROKE3, val: pt.value3, on: hasSeries3 },
|
|
|
|
|
+ ];
|
|
|
|
|
+ for (const r of rows) {
|
|
|
|
|
+ if (!r.on) continue;
|
|
|
|
|
+ const row = document.createElement('div');
|
|
|
|
|
+ row.className = 'spk-tt-row';
|
|
|
|
|
+ if (multi) {
|
|
|
|
|
+ const marker = document.createElement('span');
|
|
|
|
|
+ marker.className = 'spk-tt-dot';
|
|
|
|
|
+ marker.style.background = r.color;
|
|
|
|
|
+ row.appendChild(marker);
|
|
|
|
|
+ const nm = document.createElement('span');
|
|
|
|
|
+ nm.className = 'spk-tt-name';
|
|
|
|
|
+ nm.textContent = r.name ?? '';
|
|
|
|
|
+ row.appendChild(nm);
|
|
|
|
|
+ }
|
|
|
|
|
+ const val = document.createElement('span');
|
|
|
|
|
+ val.className = 'spk-tt-val';
|
|
|
|
|
+ val.textContent = fmt(r.val);
|
|
|
|
|
+ row.appendChild(val);
|
|
|
|
|
+ tooltipEl.appendChild(row);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ tooltipEl.style.display = '';
|
|
|
|
|
+ const overW = u.over.clientWidth;
|
|
|
|
|
+ const overH = u.over.clientHeight;
|
|
|
|
|
+ const cx = u.cursor.left ?? 0;
|
|
|
|
|
+ const cy = u.cursor.top ?? 0;
|
|
|
|
|
+ const w = tooltipEl.offsetWidth;
|
|
|
|
|
+ const h = tooltipEl.offsetHeight;
|
|
|
|
|
+ let x = cx + 12;
|
|
|
|
|
+ if (x + w + 8 > overW) x = cx - w - 12;
|
|
|
|
|
+ if (x < 0) x = 4;
|
|
|
|
|
+ let y = cy - h - 12;
|
|
|
|
|
+ if (y < 0) y = Math.min(cy + 12, overH - h - 4);
|
|
|
|
|
+ tooltipEl.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const opts: uPlot.Options = {
|
|
|
|
|
+ width: container.clientWidth || 600,
|
|
|
|
|
+ height,
|
|
|
|
|
+ padding: [8, 8, showAxes ? 0 : 2, showAxes ? 0 : 2],
|
|
|
|
|
+ legend: { show: false },
|
|
|
|
|
+ cursor: {
|
|
|
|
|
+ show: showTooltip,
|
|
|
|
|
+ x: showTooltip,
|
|
|
|
|
+ y: false,
|
|
|
|
|
+ drag: { x: false, y: false, setScale: false },
|
|
|
|
|
+ points: {
|
|
|
|
|
+ show: showMarker,
|
|
|
|
|
+ size: () => (cfgRef.current.markerRadius ?? 3) * 2,
|
|
|
|
|
+ width: 0,
|
|
|
|
|
+ stroke: (_u, sidx) => seriesColor(sidx),
|
|
|
|
|
+ fill: (_u, sidx) => seriesColor(sidx),
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ scales: {
|
|
|
|
|
+ x: {
|
|
|
|
|
+ time: false,
|
|
|
|
|
+ range: (_u, dmin, dmax) => (dmin === dmax ? [dmin - 0.5, dmax + 0.5] : [dmin, dmax]),
|
|
|
|
|
+ },
|
|
|
|
|
+ y: {
|
|
|
|
|
+ range: () => {
|
|
|
|
|
+ const [mn, mx] = viewRef.current.yDomain;
|
|
|
|
|
+ return [mn, mx];
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ series,
|
|
|
|
|
+ axes,
|
|
|
|
|
+ hooks: {
|
|
|
|
|
+ init: [
|
|
|
|
|
+ (u) => {
|
|
|
|
|
+ if (!cfgRef.current.showTooltip) return;
|
|
|
|
|
+ tooltipEl = document.createElement('div');
|
|
|
|
|
+ tooltipEl.className = 'sparkline-tooltip';
|
|
|
|
|
+ tooltipEl.style.display = 'none';
|
|
|
|
|
+ u.over.appendChild(tooltipEl);
|
|
|
|
|
+ },
|
|
|
|
|
+ ],
|
|
|
|
|
+ drawClear: [drawGrid],
|
|
|
|
|
+ draw: [drawOverlay],
|
|
|
|
|
+ setCursor: [updateTooltip],
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const u = new uPlot(opts, buildData(), container);
|
|
|
|
|
+ plotRef.current = u;
|
|
|
|
|
+
|
|
|
|
|
+ const ro = new ResizeObserver(() => {
|
|
|
|
|
+ const w = container.clientWidth;
|
|
|
|
|
+ if (w > 0) u.setSize({ width: w, height });
|
|
|
|
|
+ });
|
|
|
|
|
+ ro.observe(container);
|
|
|
|
|
+
|
|
|
|
|
+ return () => {
|
|
|
|
|
+ ro.disconnect();
|
|
|
|
|
+ u.destroy();
|
|
|
|
|
+ plotRef.current = null;
|
|
|
|
|
+ tooltipEl = null;
|
|
|
|
|
+ };
|
|
|
|
|
+ }, [hasSeries2, hasSeries3, showAxes, showTooltip, showMarker, height]);
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ plotRef.current?.setData(
|
|
|
|
|
+ (() => {
|
|
|
|
|
+ const xs = points.map((_, i) => i);
|
|
|
|
|
+ const s: number[][] = [points.map((p) => p.value)];
|
|
|
|
|
+ if (hasSeries2) s.push(points.map((p) => p.value2));
|
|
|
|
|
+ if (hasSeries3) s.push(points.map((p) => p.value3));
|
|
|
|
|
+ return [xs, ...s] as uPlot.AlignedData;
|
|
|
|
|
+ })(),
|
|
|
|
|
+ );
|
|
|
|
|
+ }, [points, hasSeries2, hasSeries3, valueMin, valueMax]);
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ plotRef.current?.redraw(false);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ const redraw = () => plotRef.current?.redraw(false);
|
|
|
|
|
+ const moBody = new MutationObserver(redraw);
|
|
|
|
|
+ moBody.observe(document.body, { attributes: true, attributeFilter: ['class'] });
|
|
|
|
|
+ const moRoot = new MutationObserver(redraw);
|
|
|
|
|
+ moRoot.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
|
|
|
|
+ return () => {
|
|
|
|
|
+ moBody.disconnect();
|
|
|
|
|
+ moRoot.disconnect();
|
|
|
|
|
+ };
|
|
|
|
|
+ }, []);
|
|
|
|
|
+
|
|
|
return (
|
|
return (
|
|
|
<div className="sparkline-container" role={ariaSummary ? 'img' : undefined} aria-label={ariaSummary || undefined}>
|
|
<div className="sparkline-container" role={ariaSummary ? 'img' : undefined} aria-label={ariaSummary || undefined}>
|
|
|
{extremaPoints && (
|
|
{extremaPoints && (
|
|
@@ -210,150 +577,7 @@ export default function Sparkline({
|
|
|
))}
|
|
))}
|
|
|
</div>
|
|
</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>
|
|
|
|
|
- <linearGradient id={gradId2} x1="0" y1="0" x2="0" y2="1">
|
|
|
|
|
- <stop offset="0%" stopColor={stroke2} stopOpacity={fillOpacity} />
|
|
|
|
|
- <stop offset="100%" stopColor={stroke2} stopOpacity={0} />
|
|
|
|
|
- </linearGradient>
|
|
|
|
|
- <linearGradient id={gradId3} x1="0" y1="0" x2="0" y2="1">
|
|
|
|
|
- <stop offset="0%" stopColor={stroke3} stopOpacity={fillOpacity} />
|
|
|
|
|
- <stop offset="100%" stopColor={stroke3} 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}
|
|
|
|
|
- />
|
|
|
|
|
- <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}
|
|
|
|
|
- />
|
|
|
|
|
- {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, name) => [fmtTooltip(Number(v) || 0), multiSeries && typeof name === 'string' ? name : '']}
|
|
|
|
|
- labelFormatter={(label) => (tooltipLabelFormatter ? tooltipLabelFormatter(String(label)) : String(label))}
|
|
|
|
|
- separator={multiSeries ? ': ' : ''}
|
|
|
|
|
- />
|
|
|
|
|
- )}
|
|
|
|
|
- {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"
|
|
|
|
|
- />
|
|
|
|
|
- ))}
|
|
|
|
|
- {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"
|
|
|
|
|
- name={multiSeries ? name1 : undefined}
|
|
|
|
|
- stroke={stroke}
|
|
|
|
|
- strokeWidth={strokeWidth}
|
|
|
|
|
- fill={`url(#${gradId})`}
|
|
|
|
|
- dot={false}
|
|
|
|
|
- activeDot={showMarker ? { r: markerRadius, fill: stroke, strokeWidth: 0 } : false}
|
|
|
|
|
- isAnimationActive={false}
|
|
|
|
|
- />
|
|
|
|
|
- {hasSeries2 && (
|
|
|
|
|
- <Area
|
|
|
|
|
- type="monotone"
|
|
|
|
|
- dataKey="value2"
|
|
|
|
|
- name={name2}
|
|
|
|
|
- stroke={stroke2}
|
|
|
|
|
- strokeWidth={strokeWidth}
|
|
|
|
|
- fill={`url(#${gradId2})`}
|
|
|
|
|
- dot={false}
|
|
|
|
|
- activeDot={showMarker ? { r: markerRadius, fill: stroke2, strokeWidth: 0 } : false}
|
|
|
|
|
- isAnimationActive={false}
|
|
|
|
|
- />
|
|
|
|
|
- )}
|
|
|
|
|
- {hasSeries3 && (
|
|
|
|
|
- <Area
|
|
|
|
|
- type="monotone"
|
|
|
|
|
- dataKey="value3"
|
|
|
|
|
- name={name3}
|
|
|
|
|
- stroke={stroke3}
|
|
|
|
|
- strokeWidth={strokeWidth}
|
|
|
|
|
- fill={`url(#${gradId3})`}
|
|
|
|
|
- dot={false}
|
|
|
|
|
- activeDot={showMarker ? { r: markerRadius, fill: stroke3, strokeWidth: 0 } : false}
|
|
|
|
|
- isAnimationActive={false}
|
|
|
|
|
- />
|
|
|
|
|
- )}
|
|
|
|
|
- </AreaChart>
|
|
|
|
|
- </ResponsiveContainer>
|
|
|
|
|
|
|
+ <div ref={containerRef} className="sparkline-plot" style={{ height }} />
|
|
|
</div>
|
|
</div>
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|