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 { 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>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user