import { useEffect, useMemo, useRef, useState, type PointerEvent } from 'react' import { LoadingBar } from '@shared/ui' import { fmtSpan } from './ranges' const MIN = 60_000 /** 选区最短 5 分钟。 */ const MIN_LEN = 5 const EDGE_PX = 9 interface Props { /** 时间轴的起点(毫秒)。 */ domainStart: number /** 时间轴的长度(分钟):1440 或 2880(连续 48 小时,选区可以跨零点)。 */ domainMin: number t: number[] /** 丢包(缩略图画它)。 */ values: (number | null)[] fromMs: number toMs: number onSelect: (fromMs: number, toMs: number) => void /** 缩略图的数据正在加载。 */ loading?: boolean } type Mode = 'l' | 'r' | 'move' | 'new' interface Drag { mode: Mode anchor: number from: number to: number moved: boolean } const hourOf = (ms: number) => new Date(ms).getHours() const dateOf = (ms: number) => `${new Date(ms).getMonth() + 1}/${new Date(ms).getDate()}` const clockOf = (ms: number) => `${String(new Date(ms).getHours()).padStart(2, '0')}:${String(new Date(ms).getMinutes()).padStart(2, '0')}` /** * 连续 48 小时的缩略图(丢包),上面有一块可拖动的选区: * 拖空白处新建,拖两边调长短,拖中间整体平移;选区可以跨过零点(晚高峰常常是 20:00 到凌晨 02:00)。 * 点一下空白处或者刻度下面的钟点:离那里近的端点贴过去,另一个端点不动;鼠标移上去会标出这个位置的时间。 * 拖动过程中只在这里本地更新,松手才通知外面(外面再去取数),所以不会闪。 */ export function Minimap({ domainStart, domainMin, t, values, fromMs, toMs, onSelect, loading = false }: Props) { const box = useRef(null) const drag = useRef(null) const latest = useRef<[number, number] | null>(null) const [live, setLive] = useState<[number, number] | null>(null) const [held, setHeld] = useState<[number, number] | null>(null) const [hover, setHover] = useState(null) const propFrom = Math.round((fromMs - domainStart) / MIN) const propTo = Math.round((toMs - domainStart) / MIN) // 松手之后到外面的状态更新完成之前,选区停在松手的位置,不跳回去 useEffect(() => { if (held && propFrom === held[0] && propTo === held[1]) setHeld(null) }, [propFrom, propTo, held]) useEffect(() => { if (!held) return const id = window.setTimeout(() => setHeld(null), 4000) return () => window.clearTimeout(id) }, [held]) const shown = live ?? held ?? [propFrom, propTo] const from = Math.max(0, shown[0]) const to = Math.min(domainMin, shown[1]) const path = useMemo(() => { let max = 5 for (const v of values) if (v !== null && v !== undefined && v > max) max = v const points: { x: number; v: number }[] = [] for (let i = 0; i < t.length; i++) { const v = values[i] const x = (t[i] - domainStart) / (domainMin * MIN) if (v !== null && v !== undefined && x >= 0 && x <= 1) points.push({ x, v }) } if (points.length < 2) return '' const first = points[0].x * 1000 const last = points[points.length - 1].x * 1000 return `M${first.toFixed(1)},60 ` + points.map((p) => `L${(p.x * 1000).toFixed(1)},${(58 - (Math.min(p.v, max) / max) * 54).toFixed(1)}`).join(' ') + ` L${last.toFixed(1)},60 Z` }, [t, values, domainStart, domainMin]) // 有数据的时间段:其余(还没有数据的更早时间、中间的缺口、还没到的未来)画成灰色斜纹 const covered = useMemo(() => { const step = t.length > 1 ? t[1] - t[0] : 2 * MIN const segments: { x: number; w: number }[] = [] let start = -1 let last = -1 const flush = () => { if (start >= 0) segments.push({ x: ((start - domainStart) / (domainMin * MIN)) * 1000, w: Math.max(2, ((last + step - start) / (domainMin * MIN)) * 1000) }) start = -1 } for (let i = 0; i < t.length; i++) { if (values[i] === null || values[i] === undefined) continue if (start >= 0 && t[i] - last > step * 2.5) flush() if (start < 0) start = t[i] last = t[i] } flush() return segments }, [t, values, domainStart, domainMin]) // 刻度:每小时一个小短线;24 小时刻度每 2 小时、48 小时刻度每 3 小时标一个钟点;零点标日期 const ticks = useMemo(() => { const labelEvery = domainMin <= 1440 ? 2 : 3 const list: { hour: number; frac: number; label: string | null; midnight: boolean }[] = [] for (let h = 0; h <= domainMin / 60; h += 1) { const ms = domainStart + h * 3_600_000 const hour = hourOf(ms) const labelled = h % labelEvery === 0 || hour === 0 list.push({ hour: h, frac: h / (domainMin / 60), label: !labelled ? null : hour === 0 ? dateOf(ms) : `${hour}时`, midnight: hour === 0 }) } return list }, [domainStart, domainMin]) const minuteAt = (e: PointerEvent) => { const rect = box.current!.getBoundingClientRect() return Math.min(domainMin, Math.max(0, ((e.clientX - rect.left) / rect.width) * domainMin)) } const clamp = (a: number, b: number): [number, number] => { let lo = Math.round(Math.min(a, b)) let hi = Math.round(Math.max(a, b)) if (hi - lo < MIN_LEN) hi = Math.min(domainMin, lo + MIN_LEN) if (hi - lo < MIN_LEN) lo = hi - MIN_LEN return [Math.max(0, lo), Math.min(domainMin, hi)] } const down = (e: PointerEvent) => { if (e.button !== 0) return const rect = box.current!.getBoundingClientRect() const pxPerMin = rect.width / domainMin const at = minuteAt(e) let mode: Mode = 'new' if (Math.abs(at - from) * pxPerMin <= EDGE_PX) mode = 'l' else if (Math.abs(at - to) * pxPerMin <= EDGE_PX) mode = 'r' else if (at > from && at < to) mode = 'move' drag.current = { mode, anchor: at, from, to, moved: false } latest.current = null e.currentTarget.setPointerCapture(e.pointerId) e.preventDefault() } const move = (e: PointerEvent) => { const d = drag.current if (!d) return const at = minuteAt(e) setHover(null) if (!d.moved && Math.abs(at - d.anchor) * (box.current!.getBoundingClientRect().width / domainMin) < 3) return d.moved = true let next: [number, number] if (d.mode === 'new') next = clamp(d.anchor, at) else if (d.mode === 'l') next = clamp(at, d.to) else if (d.mode === 'r') next = clamp(d.from, at) else { const len = d.to - d.from const start = Math.min(domainMin - len, Math.max(0, d.from + (at - d.anchor))) next = [Math.round(start), Math.round(start + len)] } latest.current = next setLive(next) } const up = () => { const d = drag.current const result = latest.current drag.current = null latest.current = null setLive(null) if (d?.moved && result && (result[0] !== d.from || result[1] !== d.to)) { setHeld(result) onSelect(domainStart + result[0] * MIN, domainStart + result[1] * MIN) } else if (d && !d.moved && (d.mode === 'new' || d.mode === 'move')) { // 只是点了一下:离点的位置近的那个端点贴过去,另一个端点不动 snapTo(d.anchor, d.from, d.to) } } /** 哪个端点离 `minute` 近,就把哪个端点移到那里(点在选区外面也一样),另一端不动。 */ const predict = (minute: number, a = from, b = to): { next: [number, number]; side: 'l' | 'r' } => { const at = Math.round(minute) return Math.abs(at - a) <= Math.abs(at - b) ? { next: clamp(at, b), side: 'l' } : { next: clamp(a, at), side: 'r' } } const snapTo = (minute: number, a = from, b = to) => { const { next } = predict(minute, a, b) if (next[0] === a && next[1] === b) return setHeld(next) onSelect(domainStart + next[0] * MIN, domainStart + next[1] * MIN) } // 点下去会动的是哪个端点:从那个端点到目标位置之间染色(扩大是一种颜色,收缩是另一种),那个端点也变色 const target = hover !== null && !live && !drag.current ? predict(hover) : null const sweep = target ? target.side === 'l' ? { lo: Math.min(from, target.next[0]), hi: Math.max(from, target.next[0]), grow: target.next[0] < from, changed: target.next[0] !== from } : { lo: Math.min(to, target.next[1]), hi: Math.max(to, target.next[1]), grow: target.next[1] > to, changed: target.next[1] !== to } : null const jumpTo = (tickHour: number) => snapTo(tickHour * 60) const hoverMove = (e: PointerEvent) => { if (drag.current) return setHover(minuteAt(e)) } const length = to - from return (
{ move(e); hoverMove(e) }} onPointerLeave={() => setHover(null)} onPointerUp={up} onPointerCancel={up} role="slider" aria-label="拖动选择时间段" aria-valuemin={0} aria-valuemax={domainMin} aria-valuenow={from} tabIndex={0}> {ticks .filter((x) => x.midnight) .map((x) => (
))} {sweep?.changed ?
: null}
{hover !== null && !live ? ( <>
{dateOf(domainStart + hover * MIN)} {clockOf(domainStart + hover * MIN)}
) : null}
{ticks.map((x, i) => ( ))}
{fmtSpan(domainStart + from * MIN, domainStart + to * MIN)} · {length >= 60 ? `${Math.floor(length / 60)} 小时${length % 60 ? ` ${length % 60} 分` : ''}` : `${length} 分钟`}
) }