chore: initial project import
This commit is contained in:
commit
26620707a0
347 files changed
+56172
No files matched your search
@@ -0,0 +1,189 @@
|
||||
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<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 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 (
|
||||
<div className="chart-wrap">
|
||||
<div className="chart-legend">
|
||||
{series.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 ref={host} className="chart" />
|
||||
<div ref={tip} className="chart-tip" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user