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 { useNoteSeries } from '@/hooks/useMonitor'
import { formatCount, formatDateTime } from '@/lib/monitorFormat' import { formatCount, formatDateTime } from '@/lib/monitorFormat'
@@ -12,13 +12,29 @@ const METRICS: Array<{ key: MetricKey; label: string }> = [
{ key: 'share_count', label: '分享' }, { key: 'share_count', label: '分享' },
] ]
const VIEW_W = 600 /** 图表高度固定;宽度由容器量出来,见下方 attachContainer。 */
const VIEW_H = 160 const VIEW_H = 160
const PAD_LEFT = 10 const PAD_LEFT = 44 // 左侧刻度栏,要放得下 "1.2万"
const PAD_RIGHT = 56 const PAD_RIGHT = 16
const PAD_TOP = 18 const PAD_TOP = 14
const PAD_BOTTOM = 22 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 { interface NoteTrendChartProps {
noteId: string noteId: string
taskId: number | null taskId: number | null
@@ -36,8 +52,25 @@ interface NoteTrendChartProps {
export function NoteTrendChart({ noteId, taskId, noteTitle }: NoteTrendChartProps) { export function NoteTrendChart({ noteId, taskId, noteTitle }: NoteTrendChartProps) {
const [metric, setMetric] = useState<MetricKey>('liked_count') const [metric, setMetric] = useState<MetricKey>('liked_count')
const [hoverIndex, setHoverIndex] = useState<number | null>(null) 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) 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. // A point with no parsed value is a gap, not a zero.
const points = useMemo( const points = useMemo(
() => (series ?? []).filter((point) => point[metric] !== null), () => (series ?? []).filter((point) => point[metric] !== null),
@@ -45,40 +78,57 @@ export function NoteTrendChart({ noteId, taskId, noteTitle }: NoteTrendChartProp
) )
const geometry = useMemo(() => { 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 values = points.map((point) => point[metric] as number)
const min = Math.min(...values) const axisMax = niceMax(Math.max(...values))
const max = Math.max(...values)
// A flat series would divide by zero; give it a nominal band.
const span = max - min || 1
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 innerH = VIEW_H - PAD_TOP - PAD_BOTTOM
const xy = points.map((point, index) => { const xy = points.map((point, index) => {
const value = point[metric] as number const value = point[metric] as number
return { return {
x: PAD_LEFT + (index / (points.length - 1)) * innerW, x: PAD_LEFT + (index / (points.length - 1)) * innerW,
y: PAD_TOP + innerH - ((value - min) / span) * innerH, y: PAD_TOP + innerH - (value / axisMax) * innerH,
value, value,
point, point,
} }
}) })
return { xy, min, max } return { xy, axisMax, innerW, innerH }
}, [points, metric]) }, [points, metric, width])
if (isLoading) { if (isLoading) {
return <div className="p-4 text-[11px] font-mono text-cyber-text-muted">加载中…</div> 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 ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between gap-3 flex-wrap"> <div className="flex items-center justify-between gap-3 flex-wrap">
<span className="font-mono text-xs text-cyber-text-primary"> <div className="flex items-baseline gap-2 min-w-0">
指标趋势 · <span className="text-cyber-text-secondary">{noteTitle || noteId}</span> <span className="font-mono text-xs text-cyber-text-primary flex-shrink-0">指标趋势</span>
</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"> <div className="flex items-center gap-1">
{METRICS.map((option) => ( {METRICS.map((option) => (
<button <button
@@ -96,97 +146,101 @@ export function NoteTrendChart({ noteId, taskId, noteTitle }: NoteTrendChartProp
</div> </div>
</div> </div>
{!geometry ? ( {points.length < 2 ? (
<p className="py-6 text-center text-[11px] font-mono text-cyber-text-muted"> <p className="py-6 text-center text-[11px] font-mono text-cyber-text-muted">
至少需要两轮采集才能画出趋势(当前 {points.length} 个有效数据点) 至少需要两轮采集才能画出趋势(当前 {points.length} 个有效数据点)
</p> </p>
) : ( ) : (
<div className="relative"> <div className="relative" ref={attachContainer}>
<svg {geometry && (
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`} <svg
className="w-full h-40" width={width}
preserveAspectRatio="none" height={VIEW_H}
onMouseLeave={() => setHoverIndex(null)} viewBox={`0 0 ${width} ${VIEW_H}`}
> className="block"
{/* Recessive solid hairlines - never dashed. */} onMouseLeave={() => setHoverIndex(null)}
{[0, 0.5, 1].map((ratio) => { >
const y = PAD_TOP + (VIEW_H - PAD_TOP - PAD_BOTTOM) * ratio {/* 刻度线画在 0 / 中值 / 上界上,标签就贴在对应位置 —— 不再浮在图面上,
return ( 也就不会出现两个数挤在一起读成一个数的情况。 */}
{[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 <line
key={ratio} x1={geometry.xy[hoverIndex].x}
x1={PAD_LEFT} x2={geometry.xy[hoverIndex].x}
x2={VIEW_W - PAD_RIGHT} y1={PAD_TOP}
y1={y} y2={PAD_TOP + geometry.innerH}
y2={y} stroke="rgb(var(--cyber-neon-cyan) / 0.5)"
stroke="rgb(var(--cyber-text-muted) / 0.25)"
strokeWidth={1} strokeWidth={1}
vectorEffect="non-scaling-stroke"
/> />
) )}
})}
{hoverIndex !== null && geometry.xy[hoverIndex] && ( <polyline
<line points={geometry.xy.map((node) => `${node.x},${node.y}`).join(' ')}
x1={geometry.xy[hoverIndex].x} fill="none"
x2={geometry.xy[hoverIndex].x} stroke="rgb(var(--cyber-neon-cyan))"
y1={PAD_TOP} strokeWidth={2}
y2={VIEW_H - PAD_BOTTOM} strokeLinejoin="round"
stroke="rgb(var(--cyber-neon-cyan) / 0.5)" strokeLinecap="round"
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/> />
)}
<polyline {geometry.xy.map((node, index) => (
points={geometry.xy.map((node) => `${node.x},${node.y}`).join(' ')} <circle
fill="none" key={index}
stroke="rgb(var(--cyber-neon-cyan))" cx={node.x}
strokeWidth={2} cy={node.y}
strokeLinejoin="round" r={dotRadius}
strokeLinecap="round" fill="rgb(var(--cyber-neon-cyan))"
vectorEffect="non-scaling-stroke" // 与环境色同色的描边,让重叠的点之间留出间隙。
/> stroke="rgb(var(--cyber-bg-primary))"
strokeWidth={2}
/>
))}
{/* Only the endpoint is labelled - a number on every point is noise. */} {geometry.xy.map((node, index) => (
<circle <rect
cx={geometry.xy[geometry.xy.length - 1].x} key={index}
cy={geometry.xy[geometry.xy.length - 1].y} x={node.x - hitWidth / 2}
r={4} y={PAD_TOP}
fill="rgb(var(--cyber-neon-cyan))" width={hitWidth}
stroke="rgb(var(--cyber-bg-primary))" height={geometry.innerH}
strokeWidth={2} fill="transparent"
vectorEffect="non-scaling-stroke" onMouseEnter={() => setHoverIndex(index)}
/> />
))}
</svg>
)}
{geometry.xy.map((node, index) => ( {hoverIndex !== null && geometry?.xy[hoverIndex] && (
<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] && (
<div <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={{ style={{
left: `${(geometry.xy[hoverIndex].x / VIEW_W) * 100}%`, left: `${(geometry.xy[hoverIndex].x / width) * 100}%`,
transform: 'translateX(-50%)', transform: 'translateX(-50%)',
}} }}
> >
@@ -202,7 +256,7 @@ export function NoteTrendChart({ noteId, taskId, noteTitle }: NoteTrendChartProp
)} )}
<p className="text-[10px] font-mono text-cyber-text-muted"> <p className="text-[10px] font-mono text-cyber-text-muted">
共 {points.length} 个数据点,每轮采集记录一次快照 共 {points.length} 个数据点,每轮采集记录一次快照 · 纵轴自 0 起
</p> </p>
</div> </div>
) )