From 242e3a7837c547d5fc50f168415cb623eb88c586 Mon Sep 17 00:00:00 2001 From: butubb <1422726308@qq.com> Date: Wed, 7 Oct 2026 15:26:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(chart):=20=E7=BA=B5=E8=BD=B4=E8=87=AA=200?= =?UTF-8?q?=20=E8=B5=B7=E3=80=81=E5=88=BB=E5=BA=A6=E5=BD=92=E4=BD=8D?= =?UTF-8?q?=E3=80=81=E6=95=B0=E6=8D=AE=E7=82=B9=E6=81=A2=E5=A4=8D=E4=B8=BA?= =?UTF-8?q?=E6=AD=A3=E5=9C=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 纵轴从 0 起,上界取整到好读的数(1/1.2/1.5/2/…)。此前按 [最小值,最大值] 自适应,491→506 这点变化被撑满整个图高、看着像暴涨,且上下两刻度就是 506 和 491 两个几乎一样的数,没有 0 做参照读不出量级 - 刻度线画在 0 / 中值 / 上界,标签贴在各自主线上、放进左侧刻度栏。原来是两个绝对定位的数字浮在图面上,会挤在一起读成一个数 - 数据点恢复为正圆:根因是 preserveAspectRatio="none" 把 600×160 的 viewBox 横向拉满容器,横纵缩放不一致,半径 4 的圆被压成椭圆。改为用 ResizeObserver 量出容器宽度、等比绘图 - 每个数据点都画出来(原先只画末点),点数多时自动缩小半径;悬停热区按点位间距铺满整段,不再固定 12px - 当前值移到标题行,不再浮在图面上遮挡曲线 --- .../src/components/monitor/NoteTrendChart.tsx | 244 +++++++++++------- 1 file changed, 149 insertions(+), 95 deletions(-) diff --git a/webui/src/components/monitor/NoteTrendChart.tsx b/webui/src/components/monitor/NoteTrendChart.tsx index 605ab3b..1dd4be8 100644 --- a/webui/src/components/monitor/NoteTrendChart.tsx +++ b/webui/src/components/monitor/NoteTrendChart.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNoteSeries } from '@/hooks/useMonitor' import { formatCount, formatDateTime } from '@/lib/monitorFormat' @@ -12,13 +12,29 @@ const METRICS: Array<{ key: MetricKey; label: string }> = [ { key: 'share_count', label: '分享' }, ] -const VIEW_W = 600 +/** 图表高度固定;宽度由容器量出来,见下方 attachContainer。 */ const VIEW_H = 160 -const PAD_LEFT = 10 -const PAD_RIGHT = 56 -const PAD_TOP = 18 +const PAD_LEFT = 44 // 左侧刻度栏,要放得下 "1.2万" +const PAD_RIGHT = 16 +const PAD_TOP = 14 const PAD_BOTTOM = 22 +/** + * 把纵轴上界向上取到一个好读的数(1 / 1.2 / 1.5 / 2 / …)。 + * + * 纵轴刻意从 0 起。原先按 [最小值, 最大值] 自适应,于是 491 -> 506 这点变化被撑满 + * 整个图高、看着像暴涨;而且上下两个刻度就是 "506" 和 "491" 两个几乎一样的数, + * 没有 0 做参照,根本读不出量级。 + */ +const NICE_STEPS = [1, 1.2, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10] + +function niceMax(value: number): number { + if (value <= 0) return 1 + const magnitude = 10 ** Math.floor(Math.log10(value)) + const step = NICE_STEPS.find((candidate) => value / magnitude <= candidate) ?? 10 + return step * magnitude +} + interface NoteTrendChartProps { noteId: string taskId: number | null @@ -36,8 +52,25 @@ interface NoteTrendChartProps { export function NoteTrendChart({ noteId, taskId, noteTitle }: NoteTrendChartProps) { const [metric, setMetric] = useState('liked_count') const [hoverIndex, setHoverIndex] = useState(null) + const [width, setWidth] = useState(0) + const observer = useRef(null) const { data: series, isLoading } = useNoteSeries(noteId, taskId) + // 宽度是量出来的,不是拉伸出来的。原先靠 preserveAspectRatio="none" 把 600×160 的 + // viewBox 横向撑满容器 —— 横纵缩放不一致,半径 4 的圆就被压成了椭圆。只有等比绘图, + // 圆才真的是圆。 + const attachContainer = useCallback((node: HTMLDivElement | null) => { + observer.current?.disconnect() + if (!node) return + const measure = () => setWidth(Math.round(node.getBoundingClientRect().width)) + const resizeObserver = new ResizeObserver(measure) + resizeObserver.observe(node) + observer.current = resizeObserver + measure() + }, []) + + useEffect(() => () => observer.current?.disconnect(), []) + // A point with no parsed value is a gap, not a zero. const points = useMemo( () => (series ?? []).filter((point) => point[metric] !== null), @@ -45,40 +78,57 @@ export function NoteTrendChart({ noteId, taskId, noteTitle }: NoteTrendChartProp ) const geometry = useMemo(() => { - if (points.length < 2) return null + if (points.length < 2 || width <= 0) return null const values = points.map((point) => point[metric] as number) - const min = Math.min(...values) - const max = Math.max(...values) - // A flat series would divide by zero; give it a nominal band. - const span = max - min || 1 + const axisMax = niceMax(Math.max(...values)) - const innerW = VIEW_W - PAD_LEFT - PAD_RIGHT + const innerW = Math.max(1, width - PAD_LEFT - PAD_RIGHT) const innerH = VIEW_H - PAD_TOP - PAD_BOTTOM const xy = points.map((point, index) => { const value = point[metric] as number return { x: PAD_LEFT + (index / (points.length - 1)) * innerW, - y: PAD_TOP + innerH - ((value - min) / span) * innerH, + y: PAD_TOP + innerH - (value / axisMax) * innerH, value, point, } }) - return { xy, min, max } - }, [points, metric]) + return { xy, axisMax, innerW, innerH } + }, [points, metric, width]) if (isLoading) { return
加载中…
} + const metricLabel = METRICS.find((option) => option.key === metric)?.label ?? '' + const latest = points.length > 0 ? (points[points.length - 1][metric] as number) : null + + // Fewer points get a bigger dot; a dense series would otherwise turn into a + // string of overlapping beads. + const dotRadius = points.length > 12 ? 2.5 : 4 + const hitWidth = geometry ? Math.max(12, geometry.innerW / Math.max(1, points.length - 1)) : 12 + return (
- - 指标趋势 · {noteTitle || noteId} - +
+ 指标趋势 + + {noteTitle || noteId} + + {latest !== null && ( + + {formatCount(latest)} + {metricLabel} + + )} +
{METRICS.map((option) => (
- {!geometry ? ( + {points.length < 2 ? (

至少需要两轮采集才能画出趋势(当前 {points.length} 个有效数据点)

) : ( -
- setHoverIndex(null)} - > - {/* Recessive solid hairlines - never dashed. */} - {[0, 0.5, 1].map((ratio) => { - const y = PAD_TOP + (VIEW_H - PAD_TOP - PAD_BOTTOM) * ratio - return ( +
+ {geometry && ( + setHoverIndex(null)} + > + {/* 刻度线画在 0 / 中值 / 上界上,标签就贴在对应位置 —— 不再浮在图面上, + 也就不会出现两个数挤在一起读成一个数的情况。 */} + {[0, 0.5, 1].map((ratio) => { + const y = PAD_TOP + geometry.innerH * ratio + return ( + + + + {formatCount(Math.round(geometry.axisMax * (1 - ratio)))} + + + ) + })} + + {hoverIndex !== null && geometry.xy[hoverIndex] && ( - ) - })} + )} - {hoverIndex !== null && geometry.xy[hoverIndex] && ( - `${node.x},${node.y}`).join(' ')} + fill="none" + stroke="rgb(var(--cyber-neon-cyan))" + strokeWidth={2} + strokeLinejoin="round" + strokeLinecap="round" /> - )} - `${node.x},${node.y}`).join(' ')} - fill="none" - stroke="rgb(var(--cyber-neon-cyan))" - strokeWidth={2} - strokeLinejoin="round" - strokeLinecap="round" - vectorEffect="non-scaling-stroke" - /> + {geometry.xy.map((node, index) => ( + + ))} - {/* Only the endpoint is labelled - a number on every point is noise. */} - + {geometry.xy.map((node, index) => ( + setHoverIndex(index)} + /> + ))} + + )} - {geometry.xy.map((node, index) => ( - setHoverIndex(index)} - /> - ))} - - - {/* Axis extremes live in text tokens, never the series colour. */} - - {formatCount(geometry.max)} - - - {formatCount(geometry.min)} - - - {formatCount(geometry.xy[geometry.xy.length - 1].value)} - - - {hoverIndex !== null && geometry.xy[hoverIndex] && ( + {hoverIndex !== null && geometry?.xy[hoverIndex] && (
@@ -202,7 +256,7 @@ export function NoteTrendChart({ noteId, taskId, noteTitle }: NoteTrendChartProp )}

- 共 {points.length} 个数据点,每轮采集记录一次快照 + 共 {points.length} 个数据点,每轮采集记录一次快照 · 纵轴自 0 起

)