fix(chart): 纵轴自 0 起、刻度归位、数据点恢复为正圆
- 纵轴从 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:
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user