261 lines
11 KiB
TypeScript
261 lines
11 KiB
TypeScript
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
|
|
/** 原始数据的淡线(开了 EWMA 平滑时垫在平滑线后面):画细、画淡,图例和提示框里不出现。 */
|
|
faint?: boolean
|
|
}
|
|
|
|
interface Props {
|
|
/** 时间轴,Unix 毫秒。 */
|
|
t: number[]
|
|
series: ChartSeries[]
|
|
height?: number
|
|
/** Y 轴的单位后缀(ms、%)。 */
|
|
unit?: string
|
|
/** 为真时 Y 轴从 0 开始。 */
|
|
zero?: boolean
|
|
/** 缺口:这些时间段用底色标出。 */
|
|
gaps?: { from: number; to: number }[]
|
|
/** 为真时 `t` 是「从起点算起的毫秒数」而不是 Unix 毫秒:横轴显示 0:00、0:15…,用来把两段时间叠在一起比。 */
|
|
offset?: boolean
|
|
/** 丢包、重传这类「没有才是好」的图:整段都是 0(或没有值)时,在图上盖一句说明,而不是留一条贴着底边的平线。 */
|
|
quiet?: string
|
|
}
|
|
|
|
/** 偏移量(毫秒)→ h:mm:ss / m:ss。 */
|
|
export function fmtOffset(ms: number): string {
|
|
const total = Math.round(ms / 1000)
|
|
const h = Math.floor(total / 3600)
|
|
const m = Math.floor((total % 3600) / 60)
|
|
const sec = total % 60
|
|
const two = (n: number) => String(n).padStart(2, '0')
|
|
return h > 0 ? `${h}:${two(m)}${sec ? ':' + two(sec) : ''}` : `${m}:${two(sec)}`
|
|
}
|
|
|
|
/**
|
|
* 稀疏:有值的点里,前后都是空的(孤立的)占三成以上。每 10 秒才一次的建连放在 5 秒的格子里就是隔一格有一格没有,
|
|
* 每个点都是孤立的,连不成线,图上什么都看不见;这种序列画出点,并把点连起来。
|
|
*/
|
|
function isSparse(values: (number | null)[]): boolean {
|
|
let filled = 0
|
|
let isolated = 0
|
|
for (let i = 0; i < values.length; i++) {
|
|
if (values[i] === null) continue
|
|
filled++
|
|
if ((i === 0 || values[i - 1] === null) && (i === values.length - 1 || values[i + 1] === null)) isolated++
|
|
}
|
|
return filled > 0 && isolated / filled >= 0.3
|
|
}
|
|
|
|
/**
|
|
* Y 轴范围:默认从 0 开始,上面留 8%。百分比的轴不会超过 100%(成功率贴着 100% 时不再出现 100.75% 这种刻度),
|
|
* 一条平线(最大等于最小)也给出可读的范围。
|
|
*/
|
|
function yRange(min: number, max: number, zero: boolean, unit: string): [number, number] {
|
|
const percent = unit === '%'
|
|
const lo = zero ? Math.min(0, min) : percent ? Math.max(0, min - Math.max(1, (max - min) * 0.2)) : min
|
|
let hi = max <= min ? min + (percent ? Math.max(1, Math.abs(min) * 0.01) : 1) : max + (max - min) * 0.08
|
|
if (percent && max <= 100) hi = Math.min(100, hi)
|
|
return [lo, hi > lo ? hi : lo + 1]
|
|
}
|
|
|
|
function cssVar(name: string): string {
|
|
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
|
}
|
|
|
|
/** 毫秒到 1 秒以上换成秒(30000ms → 30s),坐标轴和提示框都不会出现一长串数字。 */
|
|
function withUnit(v: number, unit: string, digits?: number): string {
|
|
if (unit === 'ms' && Math.abs(v) >= 1000) return `${digits === undefined ? +(v / 1000).toFixed(1) : (v / 1000).toFixed(digits)}s`
|
|
return `${v}${unit}`
|
|
}
|
|
|
|
function formatValue(v: number | null | undefined, unit: string): string {
|
|
if (v === null || v === undefined) return '—'
|
|
if (unit === 'ms' && Math.abs(v) >= 1000) return withUnit(v, unit, 2)
|
|
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 = [], offset = false, quiet }: Props) {
|
|
const host = useRef<HTMLDivElement>(null)
|
|
const tip = useRef<HTMLDivElement>(null)
|
|
const plot = useRef<uPlot | null>(null)
|
|
const zoom = useRef<{ min: number; max: number } | null>(null)
|
|
const [zoomed, setZoomed] = useState(false)
|
|
// 数据变了(轮询、实时)只更新数据,不重建图:重建会闪一下,还会丢掉鼠标位置
|
|
const latest = useRef({ t, series, gaps })
|
|
latest.current = { t, series, gaps }
|
|
// 稀疏的序列(例如每分钟只有几次的建连):大部分格子是空的,单独的一个点画成线就看不见,所以画出点并把点连起来
|
|
const sparse = series.map((x) => isSparse(x.values))
|
|
const allQuiet = quiet !== undefined && series.filter((x) => !x.faint).every((x) => x.values.every((v) => v === null || v <= 0))
|
|
const structure = JSON.stringify([series.map((x, i) => [x.label, x.color, x.dash, x.fill, x.faint, sparse[i]]), unit, zero, offset, height])
|
|
const dataOf = (): AlignedData => [latest.current.t.map((v) => v / 1000), ...latest.current.series.map((x) => x.values.map((v) => (v === null ? null : v)))]
|
|
|
|
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: !offset, range: (_u, min, max) => (zoom.current ? [zoom.current.min, zoom.current.max] : [min, max]) },
|
|
y: { range: (_u, min, max) => yRange(min, max, zero, unit) },
|
|
},
|
|
axes: [
|
|
{ stroke: axis, grid: { stroke: grid, width: 1 }, ticks: { stroke: grid }, font, ...(offset ? { values: (_u: uPlot, ticks: number[]) => ticks.map((v) => fmtOffset(v * 1000)) } : {}) },
|
|
{ stroke: axis, grid: { stroke: grid, width: 1 }, ticks: { show: false }, font, size: 56, values: (_u, ticks) => ticks.map((v) => withUnit(v, unit)) },
|
|
],
|
|
series: [
|
|
{},
|
|
...series.map((s) => {
|
|
const color = cssVar(s.color)
|
|
return {
|
|
label: s.label,
|
|
stroke: s.faint ? color + '55' : color,
|
|
width: s.faint ? 1 : 1.8,
|
|
dash: s.dash,
|
|
fill: s.fill ? color + '22' : undefined,
|
|
spanGaps: sparse[series.indexOf(s)],
|
|
points: { show: sparse[series.indexOf(s)], size: 5 },
|
|
}
|
|
}),
|
|
],
|
|
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 = [offset ? `起点后 ${fmtOffset(u.data[0][idx] * 1000)}` : 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 < latest.current.series.length; i++) if (!latest.current.series[i].faint) lines.push(`${latest.current.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()
|
|
// Coverage gaps are context only; keep the warm mask subtle so spikes stay prominent.
|
|
ctx.globalAlpha = 0.16
|
|
ctx.fillStyle = shade
|
|
for (const g of latest.current.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()
|
|
},
|
|
],
|
|
},
|
|
}
|
|
plot.current = new uPlot(options, dataOf(), 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
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [structure, reset])
|
|
|
|
useEffect(() => {
|
|
const u = plot.current
|
|
if (!u) return
|
|
u.setData(dataOf(), !zoom.current)
|
|
u.redraw(false)
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [t, series, gaps])
|
|
|
|
return (
|
|
<div className="chart-wrap">
|
|
<div className="chart-legend">
|
|
{series.filter((s) => !s.faint).map((s) => (
|
|
<span key={s.label}>
|
|
<i style={{ background: `var(${s.color})` }} />
|
|
{s.label}
|
|
</span>
|
|
))}
|
|
<span className="chart-hint">
|
|
{zoomed ? (
|
|
<button type="button" className="btn small text" onClick={reset}>
|
|
还原缩放
|
|
</button>
|
|
) : (
|
|
'拖动选择一段放大,双击还原'
|
|
)}
|
|
</span>
|
|
</div>
|
|
<div className="chart-body">
|
|
<div ref={host} className="chart" />
|
|
{allQuiet ? <div className="chart-quiet">{quiet}</div> : null}
|
|
</div>
|
|
<div ref={tip} className="chart-tip" />
|
|
</div>
|
|
)
|
|
}
|