Files
MoShiro/web/display/src/Minimap.tsx
T
justme 14b0de3544 feat: node info, build info and acrylic web theme
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.
2026-10-06 21:24:25 +08:00

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>
)
}