fix(chart): 纵轴自 0 起、刻度归位、数据点恢复为正圆
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

- 纵轴从 0 起,上界取整到好读的数(1/1.2/1.5/2/…)。此前按 [最小值,最大值] 自适应,491→506 这点变化被撑满整个图高、看着像暴涨,且上下两刻度就是 506 和 491 两个几乎一样的数,没有 0 做参照读不出量级
- 刻度线画在 0 / 中值 / 上界,标签贴在各自主线上、放进左侧刻度栏。原来是两个绝对定位的数字浮在图面上,会挤在一起读成一个数
- 数据点恢复为正圆:根因是 preserveAspectRatio="none" 把 600×160 的 viewBox 横向拉满容器,横纵缩放不一致,半径 4 的圆被压成椭圆。改为用 ResizeObserver 量出容器宽度、等比绘图
- 每个数据点都画出来(原先只画末点),点数多时自动缩小半径;悬停热区按点位间距铺满整段,不再固定 12px
- 当前值移到标题行,不再浮在图面上遮挡曲线
This commit is contained in:
2026-10-07 15:26:42 +08:00
parent c1068845a9
commit 242e3a7837
+149 -95
View File
@@ -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<MetricKey>('liked_count')
const [hoverIndex, setHoverIndex] = useState<number | null>(null)
const [width, setWidth] = useState(0)
const observer = useRef<ResizeObserver | null>(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 <div className="p-4 text-[11px] font-mono text-cyber-text-muted">加载中…</div>
}
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 (
<div className="space-y-2">
<div className="flex items-center justify-between gap-3 flex-wrap">
<span className="font-mono text-xs text-cyber-text-primary">
指标趋势 · <span className="text-cyber-text-secondary">{noteTitle || noteId}</span>
</span>
<div className="flex items-baseline gap-2 min-w-0">
<span className="font-mono text-xs text-cyber-text-primary flex-shrink-0">指标趋势</span>
<span
className="font-mono text-[11px] text-cyber-text-secondary truncate"
title={noteTitle || noteId}
>
{noteTitle || noteId}
</span>
{latest !== null && (
<span className="font-mono text-xs text-cyber-neon-cyan flex-shrink-0">
{formatCount(latest)}
<span className="text-cyber-text-muted text-[10px] ml-1">{metricLabel}</span>
</span>
)}
</div>
<div className="flex items-center gap-1">
{METRICS.map((option) => (
<button
@@ -96,97 +146,101 @@ export function NoteTrendChart({ noteId, taskId, noteTitle }: NoteTrendChartProp
</div>
</div>
{!geometry ? (
{points.length < 2 ? (
<p className="py-6 text-center text-[11px] font-mono text-cyber-text-muted">
至少需要两轮采集才能画出趋势(当前 {points.length} 个有效数据点)
</p>
) : (
<div className="relative">
<svg
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
className="w-full h-40"
preserveAspectRatio="none"
onMouseLeave={() => 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 (
<div className="relative" ref={attachContainer}>
{geometry && (
<svg
width={width}
height={VIEW_H}
viewBox={`0 0 ${width} ${VIEW_H}`}
className="block"
onMouseLeave={() => setHoverIndex(null)}
>
{/* 刻度线画在 0 / 中值 / 上界上,标签就贴在对应位置 —— 不再浮在图面上,
也就不会出现两个数挤在一起读成一个数的情况。 */}
{[0, 0.5, 1].map((ratio) => {
const y = PAD_TOP + geometry.innerH * ratio
return (
<g key={ratio}>
<line
x1={PAD_LEFT}
x2={width - PAD_RIGHT}
y1={y}
y2={y}
stroke="rgb(var(--cyber-text-muted) / 0.25)"
strokeWidth={1}
/>
<text
x={PAD_LEFT - 8}
y={y}
textAnchor="end"
dominantBaseline="middle"
fontSize={9}
fill="rgb(var(--cyber-text-muted))"
style={{ fontFamily: 'ui-monospace, SFMono-Regular, monospace' }}
>
{formatCount(Math.round(geometry.axisMax * (1 - ratio)))}
</text>
</g>
)
})}
{hoverIndex !== null && geometry.xy[hoverIndex] && (
<line
key={ratio}
x1={PAD_LEFT}
x2={VIEW_W - PAD_RIGHT}
y1={y}
y2={y}
stroke="rgb(var(--cyber-text-muted) / 0.25)"
x1={geometry.xy[hoverIndex].x}
x2={geometry.xy[hoverIndex].x}
y1={PAD_TOP}
y2={PAD_TOP + geometry.innerH}
stroke="rgb(var(--cyber-neon-cyan) / 0.5)"
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
)
})}
)}
{hoverIndex !== null && geometry.xy[hoverIndex] && (
<line
x1={geometry.xy[hoverIndex].x}
x2={geometry.xy[hoverIndex].x}
y1={PAD_TOP}
y2={VIEW_H - PAD_BOTTOM}
stroke="rgb(var(--cyber-neon-cyan) / 0.5)"
strokeWidth={1}
vectorEffect="non-scaling-stroke"
<polyline
points={geometry.xy.map((node) => `${node.x},${node.y}`).join(' ')}
fill="none"
stroke="rgb(var(--cyber-neon-cyan))"
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
/>
)}
<polyline
points={geometry.xy.map((node) => `${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) => (
<circle
key={index}
cx={node.x}
cy={node.y}
r={dotRadius}
fill="rgb(var(--cyber-neon-cyan))"
// 与环境色同色的描边,让重叠的点之间留出间隙。
stroke="rgb(var(--cyber-bg-primary))"
strokeWidth={2}
/>
))}
{/* Only the endpoint is labelled - a number on every point is noise. */}
<circle
cx={geometry.xy[geometry.xy.length - 1].x}
cy={geometry.xy[geometry.xy.length - 1].y}
r={4}
fill="rgb(var(--cyber-neon-cyan))"
stroke="rgb(var(--cyber-bg-primary))"
strokeWidth={2}
vectorEffect="non-scaling-stroke"
/>
{geometry.xy.map((node, index) => (
<rect
key={index}
x={node.x - hitWidth / 2}
y={PAD_TOP}
width={hitWidth}
height={geometry.innerH}
fill="transparent"
onMouseEnter={() => setHoverIndex(index)}
/>
))}
</svg>
)}
{geometry.xy.map((node, index) => (
<rect
key={index}
x={node.x - 6}
y={PAD_TOP}
width={12}
height={VIEW_H - PAD_TOP - PAD_BOTTOM}
fill="transparent"
onMouseEnter={() => setHoverIndex(index)}
/>
))}
</svg>
{/* Axis extremes live in text tokens, never the series colour. */}
<span className="absolute left-0 top-0 text-[9px] font-mono text-cyber-text-muted">
{formatCount(geometry.max)}
</span>
<span className="absolute left-0 bottom-5 text-[9px] font-mono text-cyber-text-muted">
{formatCount(geometry.min)}
</span>
<span className="absolute right-0 top-1/2 -translate-y-1/2 text-[11px] font-mono text-cyber-text-primary">
{formatCount(geometry.xy[geometry.xy.length - 1].value)}
</span>
{hoverIndex !== null && geometry.xy[hoverIndex] && (
{hoverIndex !== null && geometry?.xy[hoverIndex] && (
<div
className="absolute -top-1 px-2 py-1 rounded border border-cyber-border-DEFAULT bg-cyber-bg-elevated text-[10px] font-mono text-cyber-text-primary pointer-events-none whitespace-nowrap"
className="absolute -top-1 px-2 py-1 rounded border border-cyber-border-DEFAULT bg-cyber-bg-elevated text-[10px] font-mono text-cyber-text-primary pointer-events-none whitespace-nowrap z-10"
style={{
left: `${(geometry.xy[hoverIndex].x / VIEW_W) * 100}%`,
left: `${(geometry.xy[hoverIndex].x / width) * 100}%`,
transform: 'translateX(-50%)',
}}
>
@@ -202,7 +256,7 @@ export function NoteTrendChart({ noteId, taskId, noteTitle }: NoteTrendChartProp
)}
<p className="text-[10px] font-mono text-cyber-text-muted">
共 {points.length} 个数据点,每轮采集记录一次快照
共 {points.length} 个数据点,每轮采集记录一次快照 · 纵轴自 0 起
</p>
</div>
)