mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-20 10:00:58 +00:00
b9eda09da9
npm install was failing with ERESOLVE: the lockfile pinned storybook 10.5.7 and vitest 4.1.10 as peers while package.json asked for ^10.5.9 and ^4.1.11, and npm would not move either. Neither npm update, a targeted install, nor --package-lock-only broke the cycle, so node_modules and package-lock.json were regenerated from scratch (601 packages, 0 vulnerabilities). oxlint 1.79.0 then promoted five React Compiler rules into the correctness category, flagging 101 pre-existing sites. 1.78.0 exits 0 on the same tree, so nothing in our code changed - the rule set grew. They are fixed rather than suppressed: - refs (31): latest-value ref writes moved out of render into an effect. onlineClientsRef turned out to be write-only and is gone; expireDiffRef and trafficDiffRef were replaced by reading the values directly. - set-state-in-effect (55): reset-on-open modals now adjust state during render; where an effect mixed a synchronous reset with an async fetch, the reset moved to render and the effect kept only the request. useMediaQuery became useSyncExternalStore. - preserve-manual-memoization (11): optional-chained deps the compiler cannot match, hoisted to locals or dropped where the memo wrapped a string concat. - purity (3): Date.now() in render replaced by a state-backed clock, which also refreshes the expiry tag every 60s instead of freezing it until the next unrelated re-render. - immutability (1): applyClientStatsEvent merged websocket traffic into DBInbound rows in place; it now rebuilds only the rows it touches. Two things fell out of that. clientCount is derived with useMemo instead of an imperative rebuildClientCount() called from five sites, which also fixes a staleness bug where changing the expiry or traffic threshold left the counts alone until some later rebuild. statsVersion existed only to force a re-render after an in-place mutation, is meaningless now that rows are replaced, and nothing read it, so it is removed. Also adds a lint:fix script - oxlint --fix was previously only reachable through the lint-staged hook.
612 lines
19 KiB
TypeScript
612 lines
19 KiB
TypeScript
import { useEffect, useMemo, useRef } from 'react';
|
|
import uPlot from 'uplot';
|
|
import 'uplot/dist/uPlot.min.css';
|
|
import './Sparkline.css';
|
|
|
|
export interface SparklineReferenceLine {
|
|
y: number;
|
|
label?: string;
|
|
color?: string;
|
|
dash?: string;
|
|
}
|
|
|
|
export interface SparklineExtrema {
|
|
show?: boolean;
|
|
formatter?: (v: number) => string;
|
|
minColor?: string;
|
|
maxColor?: string;
|
|
}
|
|
|
|
const DEFAULT_STROKE = '#008771';
|
|
const DEFAULT_STROKE2 = '#722ed1';
|
|
const DEFAULT_STROKE3 = '#a0d911';
|
|
const DEFAULT_MIN_COLOR = '#52c41a';
|
|
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 {
|
|
data: number[];
|
|
data2?: number[];
|
|
data3?: number[];
|
|
stroke2?: string;
|
|
stroke3?: string;
|
|
name1?: string;
|
|
name2?: string;
|
|
name3?: string;
|
|
labels?: (string | number)[];
|
|
height?: number;
|
|
stroke?: string;
|
|
strokeWidth?: number;
|
|
maxPoints?: number;
|
|
showGrid?: boolean;
|
|
fillOpacity?: number;
|
|
showMarker?: boolean;
|
|
markerRadius?: number;
|
|
showAxes?: boolean;
|
|
yTickStep?: number;
|
|
tickCountX?: number;
|
|
showTooltip?: boolean;
|
|
showLegend?: boolean;
|
|
valueMin?: number;
|
|
valueMax?: number | null;
|
|
yFormatter?: (v: number) => string;
|
|
tooltipFormatter?: ((v: number) => string) | null;
|
|
tooltipLabelFormatter?: ((label: string) => string) | null;
|
|
referenceLines?: SparklineReferenceLine[];
|
|
extrema?: SparklineExtrema;
|
|
}
|
|
|
|
interface ChartPoint {
|
|
index: number;
|
|
value: number;
|
|
value2: number;
|
|
value3: number;
|
|
label: string;
|
|
}
|
|
|
|
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(color: string, alpha: number): string {
|
|
const trimmed = color.trim();
|
|
const fn = trimmed.match(/^rgba?\(([^)]+)\)$/i);
|
|
if (fn) {
|
|
const parts = fn[1]
|
|
.split(/[,/]\s*|\s+/)
|
|
.filter(Boolean)
|
|
.map(Number);
|
|
if (parts.length >= 3 && parts.slice(0, 3).every((n) => Number.isFinite(n))) {
|
|
const baseAlpha = parts.length > 3 && Number.isFinite(parts[3]) ? parts[3] : 1;
|
|
return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${baseAlpha * alpha})`;
|
|
}
|
|
return trimmed;
|
|
}
|
|
let h = trimmed;
|
|
if (h.startsWith('#')) h = h.slice(1);
|
|
if (h.length === 3)
|
|
h = h
|
|
.split('')
|
|
.map((c) => c + c)
|
|
.join('');
|
|
if (h.length !== 6) return trimmed;
|
|
const int = Number.parseInt(h, 16);
|
|
if (Number.isNaN(int)) return trimmed;
|
|
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,
|
|
showLegend = true,
|
|
valueMin = 0,
|
|
valueMax = 100,
|
|
yFormatter = (v: number) => `${Math.round(v)}%`,
|
|
tooltipFormatter = null,
|
|
tooltipLabelFormatter = null,
|
|
referenceLines,
|
|
extrema,
|
|
} = props;
|
|
|
|
const hasSeries2 = data2.length > 0;
|
|
const hasSeries3 = data3.length > 0;
|
|
const multiSeries = hasSeries2 || hasSeries3;
|
|
|
|
const points = useMemo<ChartPoint[]>(() => {
|
|
const n = Math.min(data.length, maxPoints);
|
|
if (n === 0) return [];
|
|
const sliceStart = data.length - n;
|
|
const labelStart = Math.max(0, labels.length - n);
|
|
const slice2Start = data2.length - n;
|
|
const slice3Start = data3.length - n;
|
|
return data.slice(sliceStart).map((value, i) => ({
|
|
index: i,
|
|
value: Number(value) || 0,
|
|
value2: data2.length ? Number(data2[slice2Start + i]) || 0 : 0,
|
|
value3: data3.length ? Number(data3[slice3Start + i]) || 0 : 0,
|
|
label: String(labels[labelStart + i] ?? i + 1),
|
|
}));
|
|
}, [data, data2, data3, labels, maxPoints]);
|
|
|
|
const yDomain = useMemo<[number, number]>(() => {
|
|
if (valueMax != null) return [valueMin, valueMax];
|
|
let max = valueMin;
|
|
for (const p of points) {
|
|
if (Number.isFinite(p.value) && p.value > max) max = p.value;
|
|
if (hasSeries2 && Number.isFinite(p.value2) && p.value2 > max) max = p.value2;
|
|
if (hasSeries3 && Number.isFinite(p.value3) && p.value3 > max) max = p.value3;
|
|
}
|
|
if (max <= valueMin) max = valueMin + 1;
|
|
return [valueMin, max * 1.1];
|
|
}, [points, valueMin, valueMax, hasSeries2, hasSeries3]);
|
|
|
|
const yTicks = useMemo<number[] | undefined>(() => {
|
|
if (!showAxes) return undefined;
|
|
const [min, max] = yDomain;
|
|
if (valueMax === 100 && valueMin === 0 && yTickStep > 0) {
|
|
const out: number[] = [];
|
|
for (let v = min; v <= max; v += yTickStep) out.push(v);
|
|
return out;
|
|
}
|
|
const n = 5;
|
|
return Array.from({ length: n }, (_, i) => min + ((max - min) * i) / (n - 1));
|
|
}, [showAxes, yDomain, valueMin, valueMax, yTickStep]);
|
|
|
|
const xTickIndexes = useMemo<number[] | undefined>(() => {
|
|
if (!showAxes || points.length === 0) return undefined;
|
|
const m = Math.max(2, tickCountX);
|
|
return Array.from({ length: m }, (_, i) => Math.round((i * (points.length - 1)) / (m - 1)));
|
|
}, [showAxes, tickCountX, points.length]);
|
|
|
|
const extremaPoints = useMemo<ExtremaResult | null>(() => {
|
|
if (!extrema?.show || multiSeries || points.length < 2) return null;
|
|
let minIdx = 0;
|
|
let maxIdx = 0;
|
|
for (let i = 1; i < points.length; i++) {
|
|
if (points[i].value < points[minIdx].value) minIdx = i;
|
|
if (points[i].value > points[maxIdx].value) maxIdx = i;
|
|
}
|
|
if (minIdx === maxIdx) return null;
|
|
return { min: points[minIdx], max: points[maxIdx], minIdx, maxIdx };
|
|
}, [points, extrema?.show, multiSeries]);
|
|
|
|
const legendItems = useMemo(
|
|
() =>
|
|
[
|
|
{ name: name1, color: stroke },
|
|
{ name: name2, color: stroke2 },
|
|
{ name: name3, color: stroke3 },
|
|
].filter((s, i) => s.name && (i === 0 ? multiSeries : i === 1 ? hasSeries2 : hasSeries3)),
|
|
[name1, name2, name3, stroke, stroke2, stroke3, multiSeries, hasSeries2, hasSeries3],
|
|
);
|
|
|
|
const fmtExtrema = extrema?.formatter ?? yFormatter;
|
|
const minColor = extrema?.minColor ?? DEFAULT_MIN_COLOR;
|
|
const maxColor = extrema?.maxColor ?? DEFAULT_MAX_COLOR;
|
|
|
|
const ariaSummary = useMemo(() => {
|
|
if (points.length === 0) return name1 ?? '';
|
|
const last = points[points.length - 1];
|
|
const parts: string[] = [];
|
|
parts.push(name1 ? `${name1}: ${yFormatter(last.value)}` : yFormatter(last.value));
|
|
if (hasSeries2 && name2) parts.push(`${name2}: ${yFormatter(last.value2)}`);
|
|
if (hasSeries3 && name3) parts.push(`${name3}: ${yFormatter(last.value3)}`);
|
|
return parts.join(', ');
|
|
}, [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);
|
|
const viewRef = useRef<SparklineView>({ points, yDomain, yTicks, xTickIndexes, extremaPoints });
|
|
useEffect(() => {
|
|
cfgRef.current = cfg;
|
|
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(() => {
|
|
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 (
|
|
<div
|
|
className="sparkline-container"
|
|
role={ariaSummary ? 'img' : undefined}
|
|
aria-label={ariaSummary || undefined}
|
|
>
|
|
{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>
|
|
)}
|
|
{showLegend && legendItems.length > 0 && (
|
|
<div className="sparkline-legend" aria-hidden="true">
|
|
{legendItems.map((s) => (
|
|
<span key={s.name} className="extrema-item" style={{ color: s.color }}>
|
|
● {s.name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div ref={containerRef} className="sparkline-plot" style={{ height }} />
|
|
</div>
|
|
);
|
|
}
|