From 03e15eea578897c67e27dbcc122d685a3d204e42 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:04:11 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E7=BD=91=E7=BB=9C=E9=80=9F?= =?UTF-8?q?=E7=8E=87=E5=9B=BE=E6=94=B9=E7=94=A8=E5=AF=B9=E6=95=B0=E7=BA=B5?= =?UTF-8?q?=E8=BD=B4=EF=BC=8C=E5=B9=B3=E6=97=B6=E7=9A=84=E6=B5=81=E9=87=8F?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=A2=AB=E7=AA=81=E5=8F=91=E5=8E=8B=E6=88=90?= =?UTF-8?q?=E8=B4=B4=E5=BA=95=E7=9A=84=E7=9B=B4=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 纵轴改为对数,刻度取 1、10、100 KB/MB/GB,最多标 5 个,从顶往下数 - 轴底取窗口里最慢速率所在的档,不低于 1 KB/s:agent 自己上报每个方向约 0.2–0.7 KB/s,空闲的节点落在底上 - 低于 1 KB/s 的值画在底上,提示框仍显示 hub 返回的原值 - `axisTop` 的 1024 进制分支只有网速在用,一并删掉 - 历史请求被拒的注释不再描述 hub 为什么限并发 --- src/components/NodeDetail.tsx | 79 ++++++++++++++++++++--------------- src/lib/format.test.ts | 44 +++++++++---------- src/lib/format.ts | 75 ++++++++++++++++++++------------- 3 files changed, 111 insertions(+), 87 deletions(-) diff --git a/src/components/NodeDetail.tsx b/src/components/NodeDetail.tsx index 35c88ad..7aed908 100644 --- a/src/components/NodeDetail.tsx +++ b/src/components/NodeDetail.tsx @@ -9,8 +9,8 @@ import { Skeleton } from "@/components/ui/skeleton" import { Country, Status } from "@/components/NodeCard" import { api, type Node } from "@/lib/api" import { - axisBytes, axisTop, bytes, clockFor, cycle, despike, quarters, cpuName, FOREVER, money, osName, rate, - tickClock, timeTicks, windows, + axisBytes, axisTop, bytes, clockFor, cycle, despike, quarters, cpuName, FOREVER, money, osName, rate, RATE_FLOOR, + rateAxis, tickClock, timeTicks, windows, } from "@/lib/format" type Point = { @@ -155,9 +155,8 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num const [data, setData] = useState<{ metrics: Point[]; ping: PingPoint[]; probes: Probes; loss?: Loss } | null>(null) // Retained rather than folded into an empty result: a refused request and an // empty window are different answers, and the hub has reason to refuse this one - // -- it caps how many history windows it builds concurrently, since each holds - // the connection the agents report through. Rendered as an empty window, a 503 - // would misdirect the reader. + // -- it caps how many history windows it builds concurrently. Rendered as an + // empty window, a 503 would misdirect the reader. const [failed, setFailed] = useState("") // Where the brush has been dragged, so the axis reticks for the visible span // rather than retaining the whole window's ticks. @@ -222,17 +221,20 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num // The hub answers in seconds; the time axis requires milliseconds. Each rate // also spans from its mean to its peak, which is the band drawn behind the - // line; without a peak the band has no height. - const metricRows = useMemo( - () => - (data?.metrics ?? []).map((m) => ({ - ...m, - ts: m.ts * 1_000, - rx_band: [m.net_rx, m.net_rx_max ?? m.net_rx], - tx_band: [m.net_tx, m.net_tx_max ?? m.net_tx], - })), - [data], - ) + // line; without a peak the band has no height. The rates drawn are lifted to + // the floor of their log axis, which has no zero, while `net_rx` and `net_tx` + // keep the hub's figures for the tooltip. + const metricRows = useMemo(() => { + const lift = (v: number) => Math.max(v, RATE_FLOOR) + return (data?.metrics ?? []).map((m) => ({ + ...m, + ts: m.ts * 1_000, + rx: lift(m.net_rx), + tx: lift(m.net_tx), + rx_band: [lift(m.net_rx), lift(m.net_rx_max ?? m.net_rx)], + tx_band: [lift(m.net_tx), lift(m.net_tx_max ?? m.net_tx)], + })) + }, [data]) // The window's highest rate each way, or null from a hub that sends no peak: // a maximum of the means would be labelled a peak it is not. const peak = useMemo(() => { @@ -242,20 +244,24 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num return { rx: max((m) => m.net_rx_max), tx: max((m) => m.net_tx_max) } }, [data]) - // Axis tops for the two panels with no capacity to measure against. CPU and a + // Axes for the two panels with no capacity to measure against. CPU and a // transfer rate do not express fullness: against a fixed 0-100, a machine // sitting at 0.4% draws as a line along the panel's floor. Memory and disk keep // their totals as tops, where fullness is the entire question. - const tops = useMemo(() => { + const axes = useMemo(() => { const max = (pick: (m: (typeof metricRows)[number]) => number) => metricRows.reduce((hi, m) => Math.max(hi, pick(m)), 0) + // A floor of 4%, or a machine that never exceeds 0.4% would get an axis of + // 0-0.4 and render every scheduler blip as a peak. Capped at 100. + const cpu = axisTop(max((m) => m.cpu), 4, 100) return { - // A floor of 4%, or a machine that never exceeds 0.4% would get an axis of - // 0-0.4 and render every scheduler blip as a peak. Capped at 100. - cpu: axisTop(max((m) => m.cpu), 4, 10, 100), - // Base 1024, so the steps are round in the unit `axisBytes` prints. Fitted - // to the band rather than the line, or the peaks would run off the top. - rate: axisTop(max((m) => Math.max(m.rx_band[1], m.tx_band[1])), 1024, 1024), + cpu: { domain: [0, cpu], ticks: quarters(cpu) }, + // From the slowest rate drawn to the highest peak, so the band stays + // within the panel. + rate: rateAxis( + metricRows.reduce((lo, m) => Math.min(lo, m.rx, m.tx), Infinity), + max((m) => Math.max(m.rx_band[1], m.tx_band[1])), + ), } }, [metricRows]) @@ -583,7 +589,7 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num - + new Date(Number(ts)).toLocaleString("zh-CN")} formatter={(v) => [`${Number(v).toFixed(1)}%`, "CPU"]} @@ -615,8 +621,12 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num - {/* A rate has no total to be a fraction of, so this one climbs the - ladder like CPU rather than pinning to a capacity. + {/* A rate has no total to be a fraction of, and its range spans orders + of magnitude: a week of one node runs from 0.2 KB/s idle to bursts + of 70 MB/s. Fitted to the bursts, a linear axis drew the median + minute less than a pixel above the floor on seven of nine nodes + at the day window, so this one is logarithmic, each tenfold step + the same height. The line is the bucket's mean, so it integrates to the traffic totals, and a 15-second speed test averaged over its minute draws @@ -632,12 +642,15 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num - + new Date(Number(ts)).toLocaleString("zh-CN")} - formatter={(v, name, item) => { - const top = item?.payload?.[item.dataKey === "net_rx" ? "net_rx_max" : "net_tx_max"] - return [top === undefined ? rate(Number(v)) : `均值 ${rate(Number(v))} · 峰值 ${rate(top)}`, name] + // The hub's figures rather than `v`, which is lifted to the + // axis floor. + formatter={(_, name, item) => { + const mean = item?.payload?.[`net_${item.dataKey}`] + const top = item?.payload?.[`net_${item.dataKey}_max`] + return [top === undefined ? rate(mean) : `均值 ${rate(mean)} · 峰值 ${rate(top)}`, name] }} contentStyle={{ fontSize: 12 }} /> @@ -658,8 +671,8 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num legendType="none" /> ))} - - + + diff --git a/src/lib/format.test.ts b/src/lib/format.test.ts index 6f2f333..532b1b6 100644 --- a/src/lib/format.test.ts +++ b/src/lib/format.test.ts @@ -4,8 +4,8 @@ // // Nothing imports it, so the bundle never includes it. import { - axisBytes, axisTop, bytes, cpuName, cycle, daysUntil, despike, osName, pair, quarters, tickClock, timeTicks, - uptime, windows, + axisBytes, axisTop, bytes, cpuName, cycle, daysUntil, despike, osName, pair, quarters, RATE_FLOOR, rateAxis, + tickClock, timeTicks, uptime, windows, } from "./format.ts" let failed = 0 @@ -39,30 +39,26 @@ eq(axisBytes(2 * 1024 ** 3), "2 GB", "整数刻度不写 .0") eq(axisBytes(0), "0 B", "零刻度") // axisTop: the top is derived from a round gridline, so quarters() lands on round -// values in the unit the axis is printed in. -eq(axisTop(0.4, 4, 10, 100), 4, "闲置机器拿到地板值") -eq(axisTop(63, 4, 10, 100), 80, "63% -> 0/20/40/60/80") -eq(axisTop(200, 4, 10, 100), 100, "百分比封顶") -eq(axisTop(25_000_000, 1024, 1024), 32 * 1024 ** 2, "字节轴按 1024 取整") +// values. +eq(axisTop(0.4, 4, 100), 4, "闲置机器拿到地板值") +eq(axisTop(63, 4, 100), 80, "63% -> 0/20/40/60/80") +eq(axisTop(200, 4, 100), 100, "百分比封顶") eq(quarters(32 * 1024 ** 2).map(axisBytes), ["0 B", "8 MB", "16 MB", "24 MB", "32 MB"], "四条网格线都是整值") -// 四条刻度为 step·[1,2,3,4],约束落在第三条:3m 必须能被 axisBytes 精确打印, -// 而它只保留一位小数。2 的幂均满足;半档 384 与 768 不满足,其第三条刻度为 -// 1.125 Ki 与 2.25 Ki,将被打印为 "1.1" 与 "2.3"。这两档恰好覆盖 1–1.5 MB/s -// 与 2–3 MB/s。断言针对标签本身,仅比较轴顶比例无法发现该问题。 -eq(quarters(axisTop(2_621_440, 1024, 1024)).map(axisBytes), - ["0 B", "1 MB", "2 MB", "3 MB", "4 MB"], "峰值 2.5 MB/s 的四条刻度") -eq(quarters(axisTop(1_258_291, 1024, 1024)).map(axisBytes), - ["0 B", "512 KB", "1 MB", "1.5 MB", "2 MB"], "峰值 1.2 MB/s 的四条刻度") -// 轴顶必须取自梯子而非数据本身。十进制梯子配合 1024 进制的 scale 最大仅到 -// 10·1024^k,越过该档后 find 落空,`?? target` 将轴顶回退为 max,网格线随之变为 -// max/4 这类非整值,正是本函数要避免的情形。该回退仅在 [4,40)·1024^k 的窄带内 -// 不出问题,而 40 KB/s–4 MB/s 恰是 VPS 最常见的区间。 -for (const max of [3_000, 300_000, 3_000_000, 300_000_000]) { - const top = axisTop(max, 1024, 1024) - eq(top > max, true, `${max} B/s 的轴顶不能等于数据本身`) - // 上界为 2 而非 1.5:梯子移除半档后,最坏情况是下一档 2 的幂。 - eq(top / max < 2, true, `${max} B/s 的轴顶不能浪费整块面板`) + +// rateAxis: from the rung at or below the slowest rate to the rung at or above +// the highest peak, every label round, at most five of them. +const axis = (low: number, high: number) => { + const { domain, ticks } = rateAxis(low, high) + return [domain.map(axisBytes), ticks.map(axisBytes)] } +eq(axis(209, 70 * 1024 ** 2), [["1 KB", "100 MB"], ["10 KB", "1 MB", "100 MB"]], "空闲 209 B/s 落在底上,突发 70 MB/s") +eq(axis(744, 229 * 1024 ** 2), [["1 KB", "1 GB"], ["1 KB", "100 KB", "10 MB", "1 GB"]], "七档只标四个,从顶往下数") +eq(axis(5 * 1024 ** 2, 80 * 1024 ** 2), [["1 MB", "100 MB"], ["1 MB", "10 MB", "100 MB"]], "底随最慢的速率上移") +eq(axis(1100, 5100), [["1 KB", "10 KB"], ["1 KB", "10 KB"]], "不到一档时也有一档") +eq(axis(1024, 1024), [["1 KB", "10 KB"], ["1 KB", "10 KB"]], "正好落在档上") +eq(axis(0, 0), [["1 KB", "10 KB"], ["1 KB", "10 KB"]], "全是零") +eq(axis(Infinity, 0), [["1 KB", "10 KB"], ["1 KB", "10 KB"]], "空窗口:最小值的初值是 Infinity") +eq(RATE_FLOOR, 1024, "底是 1 KB/s") // timeTicks: round clock values, phased on local midnight rather than the epoch, // and never more than requested. diff --git a/src/lib/format.ts b/src/lib/format.ts index 5326bde..329a8b0 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -221,49 +221,27 @@ function monthTicks(from: number, to: number, count: number): number[] { } /** - * A zero-anchored axis top for a quantity with no capacity to measure against: a - * utilisation percentage, a load average, a transfer rate. + * A zero-anchored axis top for a utilisation percentage, which has no capacity + * to measure against. * * Derived from the gridline rather than the reverse -- the smallest round step * whose fourth multiple clears the data. Chosen the other way, the top is round * while the four gridlines beneath it are not: a ceiling of 75% draws lines at - * 18.75 and 56.25. `base` is 1024 for a quantity written in binary units, so the - * steps are round in the unit it is printed in. + * 18.75 and 56.25. * * `floor` keeps an idle machine looking idle: tracked exactly, a host that never * exceeds 0.4% CPU would get an axis of 0-0.4 and render every scheduler blip as * a peak. */ -export function axisTop(max: number, floor: number, base = 10, cap = Infinity): number { +export function axisTop(max: number, floor: number, cap = Infinity): number { const target = Math.min(cap, Math.max(max, floor)) / 4 - const scale = base ** Math.floor(Math.log(target) / Math.log(base)) - const step = LADDER[base].map((m) => m * scale).find((n) => n >= target) + const scale = 10 ** Math.floor(Math.log10(target)) + const step = LADDER.map((m) => m * scale).find((n) => n >= target) return Math.min(cap, (step ?? target) * 4) } -/** - * Round multipliers, per base. A decade of base 1024 spans 1024, so a decimal - * ladder cannot reach across one: with `scale` at 1024^k the largest step it - * offers is 10 · 1024^k, leaving any target above that unmatched and falling - * through to `top = max` -- no round step and no round gridlines, which is what - * this function exists to provide. That held only for a peak within - * [4, 40) · 1024^k, so a byte axis was more often wrong than right: 40 KB/s to - * 4 MB/s, the ordinary range for a VPS, drew 2.9 MB with quarters at 732.4 KB. - * - * Powers of two for base 1024, with no half-step between them. The four ticks are - * `step · [1, 2, 3, 4]`, so the third constrains the ladder: `3m` must land on a - * label `axisBytes` can print, and it prints one decimal. Every power of two does - * (3 · 512 Ki = 1.5 Mi); the half-steps 384 and 768 do not, their third gridline - * being 1.125 Ki and 2.25 Ki, printed as "1.1" and "2.3" -- one incorrect label - * beneath a top and two gridlines that are correct. Those two rungs would be - * selected for peaks in (1, 1.5] and (2, 3] · 1024^k, that is 1-1.5 MB/s and - * 2-3 MB/s, within the range named above. The cost of dropping them is a top that - * may overshoot the data by 2x rather than 1.5x. - */ -const LADDER: Record = { - 10: [1, 1.5, 2, 2.5, 3, 4, 5, 7.5, 10], - 1024: [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024], -} +/** Round multipliers within a decade. */ +const LADDER = [1, 1.5, 2, 2.5, 3, 4, 5, 7.5, 10] /** * The gridlines for a zero-anchored axis: the top and the three quarters beneath @@ -277,6 +255,43 @@ export function quarters(top: number): number[] { return [0, 0.25, 0.5, 0.75, 1].map((f) => top * f) } +/** + * The rungs of a logarithmic byte axis: 1, 10 and 100 of each binary unit, so + * every gridline prints as a round label -- 100 B, 1 KB, 10 KB, 100 KB, 1 MB. + * Adjacent rungs are 10 apart, or 10.24 across a unit, which draws as even. + */ +const rung = (i: number) => 1024 ** Math.floor(i / 3) * 10 ** (i % 3) + +/** The rung of `RATE_FLOOR`. */ +const FLOOR = 3 + +/** + * The lowest rate drawn, 1 KB/s, since a log axis has no zero. An agent's own + * reports run below it, 0.2 to 0.7 KB/s each way across nine nodes over a week, + * so a node doing nothing else lies along the floor rather than drawing that + * traffic, magnified, as activity. + */ +export const RATE_FLOOR = rung(FLOOR) + +/** + * A logarithmic axis for transfer rates, from the rung at or below `low` to the + * rung at or above `high`, and no lower than `RATE_FLOOR`. + * + * At most five labels, counted down from the top, which is the one that says how + * far the axis reaches: 1 KB/s to 1 GB/s is seven rungs, and on a panel 120 px + * tall a label on each would sit 20 px from the next. + */ +export function rateAxis(low: number, high: number): { domain: [number, number]; ticks: number[] } { + let bottom = FLOOR + while (rung(bottom + 1) <= Math.min(low, high)) bottom++ + let top = bottom + 1 + while (rung(top) < high) top++ + const step = Math.ceil((top - bottom) / 4) + const ticks: number[] = [] + for (let i = top; i >= bottom; i -= step) ticks.unshift(rung(i)) + return { domain: [rung(bottom), rung(top)], ticks } +} + /** * Hampel filter (Hampel 1974; MATLAB ships it as `hampel`). A sample more than * `sigmas` robust deviations from the median of its window is replaced by that From 45a3b5ec57e4a34c8ed1e3ccd6dbee028d7b76f0 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:53:55 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E7=BD=91=E9=80=9F=E8=BD=B4=E6=9C=80?= =?UTF-8?q?=E5=A4=9A=E6=A0=87=206=20=E4=B8=AA=E5=88=BB=E5=BA=A6=EF=BC=8C?= =?UTF-8?q?=E8=BD=B4=E5=BA=95=E6=80=BB=E6=9C=89=E6=A0=87=E7=AD=BE=EF=BC=9B?= =?UTF-8?q?=E7=BA=B5=E8=BD=B4=E5=88=BB=E5=BA=A6=E4=B8=8E=E7=BD=91=E6=A0=BC?= =?UTF-8?q?=E7=BA=BF=E4=B8=8D=E5=86=8D=E8=A2=AB=20recharts=20=E9=9A=90?= =?UTF-8?q?=E8=97=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 峰值 10–100 MB/s 时轴是 1 KB–100 MB,原来只标 10 KB、1 MB、100 MB,空闲的线落在没有标签的底上;现在六档都标,间距 24 px - 四个资源图的纵轴设 interval 0:recharts 把顶端标签往里推 5 px 后会隐藏下面那个刻度,网格线另算一遍还会多丢,CPU、内存、硬盘原来都缺顶端下面那条网格线 - 注释里的反事实改用 would --- src/components/NodeDetail.tsx | 23 ++++++++++++++--------- src/lib/format.test.ts | 8 +++++--- src/lib/format.ts | 10 ++++++---- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/components/NodeDetail.tsx b/src/components/NodeDetail.tsx index 7aed908..35ecb75 100644 --- a/src/components/NodeDetail.tsx +++ b/src/components/NodeDetail.tsx @@ -57,7 +57,12 @@ const SERIES = { dot: false as const, strokeWidth: 1.5, isAnimationActive: false // One width for every stacked panel's value axis. Sized to their own labels -- // 40px under "100%", 68px under "172 MB" -- the four plot areas would be offset by // 28px, placing a CPU spike and the network spike that caused it at different x. -const Y_WIDTH = 68 +// +// Every listed tick is drawn: the lists are spaced to fit, while recharts' own +// collision pass would nudge the top label 5 px inward, drop the tick beneath it, +// and thin the gridlines separately -- the CPU axis would lose its 30% gridline, +// and a rate axis of 1 KB/s to 100 MB/s its 10 MB/s label and two gridlines. +const VALUE_AXIS = { ...AXIS, width: 68, interval: 0 } // The palette is greyscale, so lightness alone is exhausted after two or three // series and the dash pattern carries the rest. @@ -589,7 +594,7 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num - + new Date(Number(ts)).toLocaleString("zh-CN")} formatter={(v) => [`${Number(v).toFixed(1)}%`, "CPU"]} @@ -610,7 +615,7 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num - + new Date(Number(ts)).toLocaleString("zh-CN")} formatter={(v) => bytes(Number(v))} @@ -623,10 +628,10 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num {/* A rate has no total to be a fraction of, and its range spans orders of magnitude: a week of one node runs from 0.2 KB/s idle to bursts - of 70 MB/s. Fitted to the bursts, a linear axis drew the median - minute less than a pixel above the floor on seven of nine nodes - at the day window, so this one is logarithmic, each tenfold step - the same height. + of 70 MB/s. Fitted to the bursts, a linear axis would draw the + median minute less than a pixel above the floor on seven of nine + nodes at the day window, so this one is logarithmic, each tenfold + step the same height. The line is the bucket's mean, so it integrates to the traffic totals, and a 15-second speed test averaged over its minute draws @@ -642,7 +647,7 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num - + new Date(Number(ts)).toLocaleString("zh-CN")} // The hub's figures rather than `v`, which is lifted to the @@ -685,7 +690,7 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num - + new Date(Number(ts)).toLocaleString("zh-CN")} formatter={(v) => bytes(Number(v))} diff --git a/src/lib/format.test.ts b/src/lib/format.test.ts index 532b1b6..ea9f934 100644 --- a/src/lib/format.test.ts +++ b/src/lib/format.test.ts @@ -46,13 +46,15 @@ eq(axisTop(200, 4, 100), 100, "百分比封顶") eq(quarters(32 * 1024 ** 2).map(axisBytes), ["0 B", "8 MB", "16 MB", "24 MB", "32 MB"], "四条网格线都是整值") // rateAxis: from the rung at or below the slowest rate to the rung at or above -// the highest peak, every label round, at most five of them. +// the highest peak, every label round, at most six of them. const axis = (low: number, high: number) => { const { domain, ticks } = rateAxis(low, high) return [domain.map(axisBytes), ticks.map(axisBytes)] } -eq(axis(209, 70 * 1024 ** 2), [["1 KB", "100 MB"], ["10 KB", "1 MB", "100 MB"]], "空闲 209 B/s 落在底上,突发 70 MB/s") -eq(axis(744, 229 * 1024 ** 2), [["1 KB", "1 GB"], ["1 KB", "100 KB", "10 MB", "1 GB"]], "七档只标四个,从顶往下数") +eq(axis(209, 70 * 1024 ** 2), [["1 KB", "100 MB"], ["1 KB", "10 KB", "100 KB", "1 MB", "10 MB", "100 MB"]], + "空闲 209 B/s 落在标了的底上,突发 70 MB/s") +eq(axis(744, 229 * 1024 ** 2), [["1 KB", "1 GB"], ["1 KB", "100 KB", "10 MB", "1 GB"]], "七档隔一档标") +eq(axis(300, 1.5 * 1024 ** 3), [["1 KB", "10 GB"], ["10 KB", "1 MB", "100 MB", "10 GB"]], "八档从顶往下数") eq(axis(5 * 1024 ** 2, 80 * 1024 ** 2), [["1 MB", "100 MB"], ["1 MB", "10 MB", "100 MB"]], "底随最慢的速率上移") eq(axis(1100, 5100), [["1 KB", "10 KB"], ["1 KB", "10 KB"]], "不到一档时也有一档") eq(axis(1024, 1024), [["1 KB", "10 KB"], ["1 KB", "10 KB"]], "正好落在档上") diff --git a/src/lib/format.ts b/src/lib/format.ts index 329a8b0..f453b2a 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -277,16 +277,18 @@ export const RATE_FLOOR = rung(FLOOR) * A logarithmic axis for transfer rates, from the rung at or below `low` to the * rung at or above `high`, and no lower than `RATE_FLOOR`. * - * At most five labels, counted down from the top, which is the one that says how - * far the axis reaches: 1 KB/s to 1 GB/s is seven rungs, and on a panel 120 px - * tall a label on each would sit 20 px from the next. + * At most six labels, counted down from the top, which is the one that says how + * far the axis reaches. Six label every rung from the floor to 100 MB/s, the + * bursts of a gigabit port, so an idle line lies on a labelled gridline; on a + * panel 120 px tall they sit 24 px apart. Wider spans take every other rung: + * 1 KB/s to 1 GB/s is seven, and a label on each would sit 20 px from the next. */ export function rateAxis(low: number, high: number): { domain: [number, number]; ticks: number[] } { let bottom = FLOOR while (rung(bottom + 1) <= Math.min(low, high)) bottom++ let top = bottom + 1 while (rung(top) < high) top++ - const step = Math.ceil((top - bottom) / 4) + const step = Math.ceil((top - bottom) / 5) const ticks: number[] = [] for (let i = top; i >= bottom; i -= step) ticks.unshift(rung(i)) return { domain: [rung(bottom), rung(top)], ticks } From c85bfa51bb3bc63f1eae5687493e47d47d92a4cd Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:54:16 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E5=9B=BE=E8=A1=A8=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E6=A1=86=E4=B8=8D=E5=86=8D=E6=BB=91=E5=8A=A8=EF=BC=8C=E9=BC=A0?= =?UTF-8?q?=E6=A0=87=E4=BB=8E=E5=8F=B3=E8=BE=B9=E7=A7=BB=E5=85=A5=E6=97=B6?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E9=97=AA=E5=87=BA=E6=A8=AA=E5=90=91=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提示框先出现在光标右侧,再用 400 ms 的过渡滑回图表内,滑动期间超出页面最多 140 px。五个提示框都关掉过渡 --- src/components/NodeDetail.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/components/NodeDetail.tsx b/src/components/NodeDetail.tsx index 35ecb75..d09ff1d 100644 --- a/src/components/NodeDetail.tsx +++ b/src/components/NodeDetail.tsx @@ -54,6 +54,11 @@ const AXIS = { stroke: "currentColor", fontSize: 11, tickLine: false, axisLine: // chart across seven hundred points per probe. const SERIES = { dot: false as const, strokeWidth: 1.5, isAnimationActive: false } +// Nor does the tooltip slide. Entering a chart at its right edge, it appears +// beside the cursor first and would glide 400 ms back within the chart, +// overhanging the page by up to 140 px and flashing a horizontal scrollbar. +const TOOLTIP = { isAnimationActive: false } + // One width for every stacked panel's value axis. Sized to their own labels -- // 40px under "100%", 68px under "172 MB" -- the four plot areas would be offset by // 28px, placing a CPU spike and the network spike that caused it at different x. @@ -477,6 +482,7 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num far from it, and zero flattens every wobble. */} new Date(Number(ts)).toLocaleString("zh-CN")} // The line is drawn from what answered, so without this a // bucket that lost most of its packets reads as normal. @@ -596,6 +602,7 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num new Date(Number(ts)).toLocaleString("zh-CN")} formatter={(v) => [`${Number(v).toFixed(1)}%`, "CPU"]} contentStyle={{ fontSize: 12 }} @@ -617,6 +624,7 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num new Date(Number(ts)).toLocaleString("zh-CN")} formatter={(v) => bytes(Number(v))} contentStyle={{ fontSize: 12 }} @@ -649,6 +657,7 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num new Date(Number(ts)).toLocaleString("zh-CN")} // The hub's figures rather than `v`, which is lifted to the // axis floor. @@ -692,6 +701,7 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num new Date(Number(ts)).toLocaleString("zh-CN")} formatter={(v) => bytes(Number(v))} contentStyle={{ fontSize: 12 }}