From 557b169dae2000a6680ee97cc4f314a806666485 Mon Sep 17 00:00:00 2001 From: justme Date: Thu, 22 Oct 2026 10:14:38 +0800 Subject: [PATCH] web: make loss and retransmission charts obvious, draw sparse series as points and keep percent axes within 100% [CI] --- web/display/src/pages/Line.tsx | 10 +++++---- web/shared/charts/TimeChart.tsx | 38 +++++++++++++++++++++++++++------ web/shared/theme/base.css | 17 +++++++++++++++ 3 files changed, 55 insertions(+), 10 deletions(-) diff --git a/web/display/src/pages/Line.tsx b/web/display/src/pages/Line.tsx index afb43cb..05fa62d 100644 --- a/web/display/src/pages/Line.tsx +++ b/web/display/src/pages/Line.tsx @@ -55,6 +55,8 @@ interface ChartDef { group: MetricGroup /** 占满一行。 */ wide?: boolean + /** 没有才是好的指标:整段为 0 时图上显示的说明 */ + quiet?: string } const GROUPS = [ @@ -66,10 +68,10 @@ const GROUPS = [ const CHARTS: ChartDef[] = [ { id: 'rtt', title: '延迟', group: 'quic', unit: 'ms', series: [{ key: 'rtt.p50', label: 'p50', color: '--chart-1' }, { key: 'rtt.p95', label: 'p95', color: '--chart-2' }, { key: 'rtt.p99', label: 'p99', color: '--chart-3' }] }, { id: 'jitter', title: '抖动(p95)', group: 'quic', unit: 'ms', series: [{ key: 'jitter.up.p95', label: '去程', color: '--chart-2' }, { key: 'jitter.down.p95', label: '回程', color: '--chart-1' }] }, - { id: 'loss', title: '丢包', group: 'quic', unit: '%', series: [{ key: 'loss.up', label: '去程', color: '--chart-2' }, { key: 'loss.down', label: '回程', color: '--chart-3' }, { key: 'loss.total', label: '合计', color: '--chart-1', dash: [4, 3] }] }, - { id: 'loss-runs', title: '最长连续丢包', group: 'quic', unit: '包', series: [{ key: 'loss.run.up_max', label: '去程', color: '--chart-2' }, { key: 'loss.run.down_max', label: '回程', color: '--chart-3' }] }, + { id: 'loss', title: '丢包', group: 'quic', unit: '%', fill: true, quiet: '✓ 这段时间没有丢包', series: [{ key: 'loss.up', label: '去程', color: '--chart-2' }, { key: 'loss.down', label: '回程', color: '--chart-3' }, { key: 'loss.total', label: '合计', color: '--chart-1', dash: [4, 3] }] }, + { id: 'loss-runs', title: '最长连续丢包', group: 'quic', unit: '包', fill: true, quiet: '✓ 这段时间没有连续丢包', series: [{ key: 'loss.run.up_max', label: '去程', color: '--chart-2' }, { key: 'loss.run.down_max', label: '回程', color: '--chart-3' }] }, { id: 'tcp', title: '往返时延(RTT)', group: 'tcp-connect', unit: 'ms', series: [{ key: 'tcp.rtt.p50', label: 'p50', color: '--chart-1' }, { key: 'tcp.rtt.p95', label: 'p95', color: '--chart-2' }, { key: 'tcp.rtt.max', label: '最大', color: '--chart-3' }] }, - { id: 'retrans', title: '重传率', group: 'tcp-connect', unit: '%', series: [{ key: 'tcp.retrans.up', label: '去程', color: '--chart-2' }, { key: 'tcp.retrans.down', label: '回程', color: '--chart-3' }, { key: 'tcp.retrans', label: '合计', color: '--chart-1', dash: [4, 3] }] }, + { id: 'retrans', title: '重传率', group: 'tcp-connect', unit: '%', fill: true, quiet: '✓ 这段时间没有重传', series: [{ key: 'tcp.retrans.up', label: '去程', color: '--chart-2' }, { key: 'tcp.retrans.down', label: '回程', color: '--chart-3' }, { key: 'tcp.retrans', label: '合计', color: '--chart-1', dash: [4, 3] }] }, { id: 'connect-time', title: '建连耗时(p95)', group: 'tcp-connect', unit: 'ms', series: [{ key: 'connect.tcp_ms.p95', label: 'TCP 建连', color: '--chart-1' }, { key: 'connect.tls_ms.p95', label: 'TLS 握手', color: '--chart-2' }] }, { id: 'connect-ok', title: '建连成功率', group: 'tcp-connect', unit: '%', zero: false, series: [{ key: 'connect.success', label: '成功率', color: '--chart-3' }] }, { id: 'score', title: '综合评分', group: 'score', wide: true, unit: '', series: [{ key: 'score.reality', label: 'REALITY', color: '--chart-1' }, { key: 'score.hy2', label: 'Hy2', color: '--chart-2' }, { key: 'score.mc', label: 'MC', color: '--chart-4' }, { key: 'score.base', label: '综合', color: '--chart-5', dash: [4, 3] }] }, @@ -177,7 +179,7 @@ export function Line() { ] : raw.map(({ x, values }) => ({ label: x.label, values, color: x.color, dash: x.dash, fill: def.fill })) if (!list.some((x) => x.values.some((v) => v !== null))) return null - return { def, node: } + return { def, node: } }) return { group, cards: cards.filter((c): c is NonNullable => c !== null) } }).filter((g) => g.cards.length > 0) diff --git a/web/shared/charts/TimeChart.tsx b/web/shared/charts/TimeChart.tsx index 0779646..7c84c9e 100644 --- a/web/shared/charts/TimeChart.tsx +++ b/web/shared/charts/TimeChart.tsx @@ -26,6 +26,8 @@ interface Props { gaps?: { from: number; to: number }[] /** 为真时 `t` 是「从起点算起的毫秒数」而不是 Unix 毫秒:横轴显示 0:00、0:15…,用来把两段时间叠在一起比。 */ offset?: boolean + /** 丢包、重传这类「没有才是好」的图:整段都是 0(或没有值)时,在图上盖一句说明,而不是留一条贴着底边的平线。 */ + quiet?: string } /** 偏移量(毫秒)→ h:mm:ss / m:ss。 */ @@ -38,6 +40,24 @@ export function fmtOffset(ms: number): string { return h > 0 ? `${h}:${two(m)}${sec ? ':' + two(sec) : ''}` : `${m}:${two(sec)}` } +/** 有值的格子不到三分之一(而且至少有一个)算稀疏。 */ +function isSparse(values: (number | null)[]): boolean { + const filled = values.filter((v) => v !== null).length + return filled > 0 && filled < values.length / 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() } @@ -61,7 +81,7 @@ function formatValue(v: number | null | undefined, unit: string): string { * - 在图上拖动选一段放大预览,双击或点「还原」恢复;数据刷新时保持放大的区间; * - 鼠标悬停显示这个时刻每条线的值。 */ -export function TimeChart({ t, series, height = 220, unit = '', zero = true, gaps = [], offset = false }: Props) { +export function TimeChart({ t, series, height = 220, unit = '', zero = true, gaps = [], offset = false, quiet }: Props) { const host = useRef(null) const tip = useRef(null) const plot = useRef(null) @@ -70,7 +90,10 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap // 数据变了(轮询、实时)只更新数据,不重建图:重建会闪一下,还会丢掉鼠标位置 const latest = useRef({ t, series, gaps }) latest.current = { t, series, gaps } - const structure = JSON.stringify([series.map((x) => [x.label, x.color, x.dash, x.fill, x.faint]), unit, zero, offset, height]) + // 稀疏的序列(例如每分钟只有几次的建连):大部分格子是空的,单独的一个点画成线就看不见,所以画出点并把点连起来 + 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(() => { @@ -97,7 +120,7 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap 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) => [zero ? Math.min(0, min) : min, max <= min ? min + 1 : max + (max - min) * 0.08] }, + 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)) } : {}) }, @@ -113,8 +136,8 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap width: s.faint ? 1 : 1.8, dash: s.dash, fill: s.fill ? color + '22' : undefined, - spanGaps: false, - points: { show: false }, + spanGaps: sparse[series.indexOf(s)], + points: { show: sparse[series.indexOf(s)], size: 5 }, } }), ], @@ -218,7 +241,10 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap )} -
+
+
+ {allQuiet ?
{quiet}
: null} +
) diff --git a/web/shared/theme/base.css b/web/shared/theme/base.css index 3b69c20..051bf05 100644 --- a/web/shared/theme/base.css +++ b/web/shared/theme/base.css @@ -1088,3 +1088,20 @@ pre.code { align-items: center; gap: 4px; } + +/* 「没有才是好」的图整段为 0 时:盖一句说明,图的坐标轴留着,说明在绘图区中间 */ +.chart-body { + position: relative; +} + +.chart-quiet { + position: absolute; + inset: 0 0 28px 56px; + display: flex; + align-items: center; + justify-content: center; + color: var(--app-ok); + font-size: 14px; + font-weight: 600; + pointer-events: none; +}