Squash of recent dev work: return_session login, backend switcher, line detail timeline, overview strips, node platform/country/ISP info with flags and icons, build info (version, tag, time) behind site.show_build, and the acrylic display theme.
259 lines
11 KiB
TypeScript
259 lines
11 KiB
TypeScript
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<HTMLDivElement>(null)
|
|
const drag = useRef<Drag | null>(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<number | null>(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 (
|
|
<div className="minimap">
|
|
<div ref={box} className="track" onPointerDown={down} onPointerMove={(e) => { 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}>
|
|
<LoadingBar active={loading} />
|
|
<svg viewBox="0 0 1000 60" preserveAspectRatio="none" aria-hidden="true">
|
|
{covered.map((c) => (
|
|
<rect key={c.x} className="has" x={c.x} y="0" width={c.w} height="60" />
|
|
))}
|
|
<path d={path} />
|
|
</svg>
|
|
{ticks
|
|
.filter((x) => x.midnight)
|
|
.map((x) => (
|
|
<div key={x.frac} className="midnight" style={{ left: `${x.frac * 100}%` }} />
|
|
))}
|
|
{sweep?.changed ? <div className={`sweep ${sweep.grow ? 'grow' : 'cut'}`} style={{ left: `${(sweep.lo / domainMin) * 100}%`, width: `${((sweep.hi - sweep.lo) / domainMin) * 100}%` }} /> : null}
|
|
<div className="sel" style={{ left: `${(from / domainMin) * 100}%`, width: `${(length / domainMin) * 100}%` }}>
|
|
<i className={`h l ${sweep?.changed && target?.side === 'l' ? `will ${sweep.grow ? 'grow' : 'cut'}` : ''}`} />
|
|
<i className={`h r ${sweep?.changed && target?.side === 'r' ? `will ${sweep.grow ? 'grow' : 'cut'}` : ''}`} />
|
|
</div>
|
|
{hover !== null && !live ? (
|
|
<>
|
|
<div className="guide" style={{ left: `${(hover / domainMin) * 100}%` }} />
|
|
<div className="guide-label num" style={{ left: `${Math.min(88, Math.max(0, (hover / domainMin) * 100 - 6))}%` }}>
|
|
{dateOf(domainStart + hover * MIN)} {clockOf(domainStart + hover * MIN)}
|
|
</div>
|
|
</>
|
|
) : null}
|
|
<div className="ticks">
|
|
{ticks.map((x, i) => (
|
|
<button
|
|
key={x.hour}
|
|
type="button"
|
|
className={`tick ${x.label ? '' : 'minor'} ${x.midnight ? 'date' : ''}`}
|
|
style={{ left: `${x.frac * 100}%`, transform: i === 0 ? 'none' : i === ticks.length - 1 ? 'translateX(-100%)' : undefined }}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
onPointerMove={(e) => {
|
|
// 停在刻度上:按这个钟点预览(不是鼠标所在的像素位置)
|
|
e.stopPropagation()
|
|
if (x.hour * 60 < domainMin) setHover(x.hour * 60)
|
|
}}
|
|
onClick={() => jumpTo(x.hour)}
|
|
title={`离 ${clockOf(domainStart + x.hour * 3_600_000)} 近的端点移到这里`}
|
|
tabIndex={-1}
|
|
disabled={x.hour * 60 >= domainMin}
|
|
>
|
|
{x.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="readout num">
|
|
{fmtSpan(domainStart + from * MIN, domainStart + to * MIN)}
|
|
<span className="faint"> · {length >= 60 ? `${Math.floor(length / 60)} 小时${length % 60 ? ` ${length % 60} 分` : ''}` : `${length} 分钟`}</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|