import { useCallback, useEffect, useRef, useState } from 'react' import uPlot, { type AlignedData, type Options } from 'uplot' import 'uplot/dist/uPlot.min.css' export interface ChartSeries { label: string values: (number | null)[] /** CSS 变量名,例如 --chart-1。 */ color: string dash?: number[] fill?: boolean } interface Props { /** 时间轴,Unix 毫秒。 */ t: number[] series: ChartSeries[] height?: number /** Y 轴的单位后缀(ms、%)。 */ unit?: string /** 为真时 Y 轴从 0 开始。 */ zero?: boolean /** 缺口:这些时间段用底色标出。 */ gaps?: { from: number; to: number }[] } function cssVar(name: string): string { return getComputedStyle(document.documentElement).getPropertyValue(name).trim() } function formatValue(v: number | null | undefined, unit: string): string { if (v === null || v === undefined) return '—' const text = Math.abs(v) >= 100 ? v.toFixed(0) : Math.abs(v) >= 10 ? v.toFixed(1) : v.toFixed(2) return `${text}${unit}` } /** * uPlot 的封装: * - 跟随主题(明暗切换时重绘)和容器宽度,空值断线; * - 在图上拖动选一段放大预览,双击或点「还原」恢复;数据刷新时保持放大的区间; * - 鼠标悬停显示这个时刻每条线的值。 */ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gaps = [] }: Props) { const host = useRef(null) const tip = useRef(null) const plot = useRef(null) const zoom = useRef<{ min: number; max: number } | null>(null) const [zoomed, setZoomed] = useState(false) const reset = useCallback(() => { zoom.current = null setZoomed(false) const u = plot.current if (u && u.data[0].length > 0) u.setScale('x', { min: u.data[0][0], max: u.data[0][u.data[0].length - 1] }) }, []) useEffect(() => { const el = host.current if (!el) return const build = () => { plot.current?.destroy() const axis = cssVar('--chart-axis') const grid = cssVar('--chart-grid') const font = '12px ' + (cssVar('--font-sans') || 'sans-serif') const shade = cssVar('--app-warn-container') const options: Options = { width: el.clientWidth || 600, height, legend: { show: false }, cursor: { drag: { x: true, y: false, setScale: false }, points: { size: 6 } }, select: { show: true, left: 0, top: 0, width: 0, height: 0 }, scales: { x: { time: true, range: (_u, min, max) => (zoom.current ? [zoom.current.min, zoom.current.max] : [min, max]) }, y: { range: (_u, min, max) => [zero ? Math.min(0, min) : min, max <= min ? min + 1 : max + (max - min) * 0.08] }, }, axes: [ { stroke: axis, grid: { stroke: grid, width: 1 }, ticks: { stroke: grid }, font }, { stroke: axis, grid: { stroke: grid, width: 1 }, ticks: { show: false }, font, size: 56, values: (_u, ticks) => ticks.map((v) => `${v}${unit}`) }, ], series: [ {}, ...series.map((s) => { const color = cssVar(s.color) return { label: s.label, stroke: color, width: 1.8, dash: s.dash, fill: s.fill ? color + '22' : undefined, spanGaps: false, points: { show: false }, } }), ], hooks: { ready: [ (u) => { u.over.addEventListener('dblclick', reset) }, ], setSelect: [ (u) => { if (u.select.width < 6) return const min = u.posToVal(u.select.left, 'x') const max = u.posToVal(u.select.left + u.select.width, 'x') if (max - min < 5) return zoom.current = { min, max } setZoomed(true) u.setSelect({ left: 0, top: 0, width: 0, height: 0 }, false) u.setScale('x', { min, max }) }, ], setCursor: [ (u) => { const box = tip.current if (!box) return const idx = u.cursor.idx if (idx === null || idx === undefined || (u.cursor.left ?? -1) < 0) { box.style.display = 'none' return } const when = new Date(u.data[0][idx] * 1000) const lines = [when.toLocaleString('zh-CN', { hour12: false, month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })] for (let i = 0; i < series.length; i++) lines.push(`${series[i].label} ${formatValue(u.data[i + 1][idx], unit)}`) box.textContent = lines.join('\n') box.style.display = 'block' const width = box.offsetWidth const left = (u.cursor.left ?? 0) + 14 box.style.left = `${left + width > u.over.clientWidth ? Math.max(0, (u.cursor.left ?? 0) - width - 14) : left}px` box.style.top = '8px' }, ], drawClear: [ (u) => { const { ctx } = u ctx.save() ctx.fillStyle = shade for (const g of gaps) { const x0 = Math.max(u.bbox.left, u.valToPos(g.from / 1000, 'x', true)) const x1 = Math.min(u.bbox.left + u.bbox.width, u.valToPos(g.to / 1000, 'x', true)) if (x1 > x0) ctx.fillRect(x0, u.bbox.top, x1 - x0, u.bbox.height) } ctx.restore() }, ], }, } const data: AlignedData = [t.map((v) => v / 1000), ...series.map((s) => s.values.map((v) => (v === null ? null : v)))] plot.current = new uPlot(options, data, el) // 把提示框放进绘图区,这样它的位置和光标是同一个坐标系 if (tip.current) plot.current.over.appendChild(tip.current) } build() const resize = new ResizeObserver(() => plot.current?.setSize({ width: el.clientWidth, height })) resize.observe(el) const theme = new MutationObserver(build) theme.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }) return () => { resize.disconnect() theme.disconnect() plot.current?.destroy() plot.current = null } }, [t, series, height, unit, zero, gaps, reset]) return (
{series.map((s) => ( {s.label} ))} {zoomed ? ( ) : ( '拖动选择一段放大,双击还原' )}
) }