From ec88f1428a947441fb3ba3e02909d0740e24e6ab Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Mon, 21 Sep 2026 21:55:49 +0800 Subject: [PATCH] fix(usage): keep trend tooltip visible until the pointer leaves --- src/pages/UsageRecordsPage.tsx | 81 ++++++++++++++++++++-------- src/services/usageTrend.ts | 25 +++++++++ src/styles.css | 1 + tests/usageBackgroundRefresh.test.ts | 11 ++++ tests/usageTrend.test.ts | 11 ++++ 5 files changed, 107 insertions(+), 22 deletions(-) diff --git a/src/pages/UsageRecordsPage.tsx b/src/pages/UsageRecordsPage.tsx index 9e19fe4..1108c89 100644 --- a/src/pages/UsageRecordsPage.tsx +++ b/src/pages/UsageRecordsPage.tsx @@ -28,11 +28,13 @@ import { formatUsageNumber } from '../services/usageNumber'; import { OTHER_TREND_MODEL_KEY, buildUsageTrendSeries, - findTrendPointIndex, + clampTrendRatio, formatTrendAxisLabel, formatTrendRangeLabel, + isClientPointInsideRect, niceCeiling, trendAxisTicks, + trendPointIndexAtRatio, trendTimeAxisTicks, trendTimePosition, stackModelTokens, @@ -1139,7 +1141,7 @@ function UsageTrend({ range?: Pick; }) { const { t, locale } = useI18n(); - const [hoveredIndex, setHoveredIndex] = useState(null); + const [hoveredRatio, setHoveredRatio] = useState(null); const [hiddenModels, setHiddenModels] = useState([]); const plotRef = useRef(null); const [plotWidth, setPlotWidth] = useState(800); @@ -1151,9 +1153,14 @@ function UsageTrend({ const hiddenKeys = useMemo(() => new Set(hiddenModels), [hiddenModels]); useEffect(() => { - setHoveredIndex(null); - setHiddenModels((current) => current.filter((key) => series.models.some((model) => model.key === key))); - }, [series.bucket, series.models, series.points[0]?.hour, series.points[series.points.length - 1]?.hour]); + const available = new Set(series.models.map((model) => model.key)); + setHiddenModels((current) => { + const next = current.filter((key) => available.has(key)); + return next.length === current.length && next.every((key, index) => key === current[index]) + ? current + : next; + }); + }, [series.models]); const count = series.points.length; @@ -1165,6 +1172,23 @@ function UsageTrend({ return () => observer.disconnect(); }, [count > 0]); + useEffect(() => { + if (count === 0) setHoveredRatio(null); + }, [count === 0]); + + useEffect(() => { + if (hoveredRatio == null) return undefined; + const onWindowPointerMove = (event: PointerEvent) => { + if (event.clientX === 0 && event.clientY === 0) return; + const plot = plotRef.current; + if (!plot || !isClientPointInsideRect(event.clientX, event.clientY, plot.getBoundingClientRect())) { + setHoveredRatio(null); + } + }; + window.addEventListener('pointermove', onWindowPointerMove); + return () => window.removeEventListener('pointermove', onWindowPointerMove); + }, [hoveredRatio == null]); + const chart = useMemo(() => { const stacked = series.points.map((point) => stackModelTokens(point, series.models, hiddenKeys)); const maxTokens = niceCeiling(stacked.reduce((max, layers) => Math.max(max, layers[layers.length - 1]?.y1 ?? 0), 1)); @@ -1222,41 +1246,53 @@ function UsageTrend({ const modelLabel = (key: string, fallback: string) => key === OTHER_TREND_MODEL_KEY ? t('usage.trend.other') : fallback; + const hoveredIndex = hoveredRatio == null + ? -1 + : trendPointIndexAtRatio(series.points, chart.start, chart.end, hoveredRatio); + const handlePointerMove = (e: ReactPointerEvent) => { const rect = e.currentTarget.getBoundingClientRect(); if (rect.width <= 0 || count === 0) return; - const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); - const time = new Date(chart.start.getTime() + ratio * (chart.end.getTime() - chart.start.getTime())); - const idx = findTrendPointIndex(series.points, time); - setHoveredIndex(idx); + setHoveredRatio(clampTrendRatio((e.clientX - rect.left) / rect.width)); }; - const handlePointerLeave = () => { - setHoveredIndex(null); + const handlePointerLeave = (e: ReactPointerEvent) => { + const next = e.relatedTarget; + if (next instanceof Node && e.currentTarget.contains(next)) return; + const rect = e.currentTarget.getBoundingClientRect(); + if (isClientPointInsideRect(e.clientX, e.clientY, rect)) return; + if (e.clientX === 0 && e.clientY === 0) return; + setHoveredRatio(null); }; const handleKeyDown = (e: KeyboardEvent) => { if (count === 0) return; if (e.key === 'ArrowLeft') { e.preventDefault(); - setHoveredIndex((prev) => (prev == null || prev <= 0 ? count - 1 : prev - 1)); + const current = hoveredIndex < 0 + ? count - 1 + : (hoveredIndex <= 0 ? count - 1 : hoveredIndex - 1); + setHoveredRatio((chart.bars[current]?.center ?? 0) / 1000); } else if (e.key === 'ArrowRight') { e.preventDefault(); - setHoveredIndex((prev) => (prev == null || prev >= count - 1 ? 0 : prev + 1)); + const current = hoveredIndex < 0 + ? 0 + : (hoveredIndex >= count - 1 ? 0 : hoveredIndex + 1); + setHoveredRatio((chart.bars[current]?.center ?? 0) / 1000); } else if (e.key === 'Home') { e.preventDefault(); - setHoveredIndex(0); + setHoveredRatio((chart.bars[0]?.center ?? 0) / 1000); } else if (e.key === 'End') { e.preventDefault(); - setHoveredIndex(count - 1); + setHoveredRatio((chart.bars[count - 1]?.center ?? 0) / 1000); } else if (e.key === 'Escape') { - setHoveredIndex(null); + setHoveredRatio(null); } }; - const active = hoveredIndex != null && hoveredIndex >= 0 && hoveredIndex < count ? series.points[hoveredIndex] : null; - const activeStacked = hoveredIndex != null && chart.stacked[hoveredIndex] ? chart.stacked[hoveredIndex] : []; - const activeViewboxX = chart.bars[hoveredIndex ?? 0]?.center ?? 0; + const active = hoveredIndex >= 0 && hoveredIndex < count ? series.points[hoveredIndex] : null; + const activeStacked = hoveredIndex >= 0 && chart.stacked[hoveredIndex] ? chart.stacked[hoveredIndex] : []; + const activeViewboxX = hoveredIndex >= 0 ? (chart.bars[hoveredIndex]?.center ?? 0) : 0; const activePercent = activeViewboxX / 10; const activeLayers = [...activeStacked] .filter((l) => l.tokens > 0) @@ -1312,6 +1348,7 @@ function UsageTrend({ aria-label={t('usage.trend.aria')} onPointerMove={handlePointerMove} onPointerLeave={handlePointerLeave} + onPointerCancel={handlePointerLeave} onKeyDown={handleKeyDown} > ( - + {bar.layers.map((layer) => ( ))} - {active && hoveredIndex != null ? ( + {active ? ( - {active && hoveredIndex != null ? ( + {active ? (
62 ? ' is-left' : ' is-right'}`} style={{ left: `${activePercent}%` }} diff --git a/src/services/usageTrend.ts b/src/services/usageTrend.ts index caafb67..7845787 100644 --- a/src/services/usageTrend.ts +++ b/src/services/usageTrend.ts @@ -288,6 +288,31 @@ export function findTrendPointIndex(points: PreparedTrendPoint[], time: Date): n return index < 0 ? points.length - 1 : index; } +export function clampTrendRatio(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.min(1, value)); +} + +export function trendPointIndexAtRatio( + points: PreparedTrendPoint[], + start: Date, + end: Date, + ratio: number, +): number { + if (!points.length) return -1; + const span = Math.max(0, end.getTime() - start.getTime()); + const time = new Date(start.getTime() + clampTrendRatio(ratio) * span); + return findTrendPointIndex(points, time); +} + +export function isClientPointInsideRect( + clientX: number, + clientY: number, + rect: Pick, +): boolean { + return clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom; +} + function sameCalendarDay(left: Date, right: Date): boolean { return ( left.getFullYear() === right.getFullYear() diff --git a/src/styles.css b/src/styles.css index 68e8697..71c75a3 100644 --- a/src/styles.css +++ b/src/styles.css @@ -8638,6 +8638,7 @@ textarea:focus-visible { width: 100%; height: 100%; overflow: visible; + pointer-events: none; } .usage-trend-grid { diff --git a/tests/usageBackgroundRefresh.test.ts b/tests/usageBackgroundRefresh.test.ts index 8e8a2dc..7289699 100644 --- a/tests/usageBackgroundRefresh.test.ts +++ b/tests/usageBackgroundRefresh.test.ts @@ -17,6 +17,17 @@ describe('usage records background refresh', () => { expect(refreshBlock).not.toContain('document.hidden'); }); + it('keeps the trend tooltip while the pointer stays over the plot', () => { + const source = readFileSync( + new URL('../src/pages/UsageRecordsPage.tsx', import.meta.url), + 'utf8', + ); + expect(source).toContain('hoveredRatio'); + expect(source).toContain('isClientPointInsideRect'); + expect(source).toContain('addEventListener(\'pointermove\', onWindowPointerMove)'); + expect(source).not.toContain('setHoveredIndex(null)'); + }); + it('disables WebView background suspension for the desktop window', () => { const config = JSON.parse( readFileSync(new URL('../src-tauri/tauri.conf.json', import.meta.url), 'utf8'), diff --git a/tests/usageTrend.test.ts b/tests/usageTrend.test.ts index fddc8fe..49c0061 100644 --- a/tests/usageTrend.test.ts +++ b/tests/usageTrend.test.ts @@ -3,15 +3,18 @@ import { OTHER_TREND_MODEL_KEY, buildUsageTrendSeries, chooseTrendBucket, + clampTrendRatio, findTrendPointIndex, formatLocalHourKey, formatTrendAxisLabel, formatTrendRangeLabel, + isClientPointInsideRect, niceCeiling, parseLocalHourKey, stackModelTokens, startOfBucket, trendAxisTicks, + trendPointIndexAtRatio, trendTimeAxisTicks, trendTimePosition, type UsageTimelinePoint, @@ -190,6 +193,14 @@ describe('usage trend helpers', () => { expect(findTrendPointIndex(series.points, new Date(2026, 8, 14, 10, 29))).toBe(0); expect(findTrendPointIndex(series.points, new Date(2026, 8, 14, 10, 30))).toBe(1); expect(findTrendPointIndex(series.points, end)).toBe(series.points.length - 1); + expect(trendPointIndexAtRatio(series.points, start, end, 0)).toBe(0); + expect(trendPointIndexAtRatio(series.points, start, end, 12 / 240)).toBe(0); + expect(trendPointIndexAtRatio(series.points, start, end, 14 / 240)).toBe(1); + expect(trendPointIndexAtRatio(series.points, start, end, 1)).toBe(series.points.length - 1); + expect(clampTrendRatio(1.4)).toBe(1); + expect(clampTrendRatio(Number.NaN)).toBe(0); + expect(isClientPointInsideRect(120, 40, { left: 100, right: 200, top: 10, bottom: 80 })).toBe(true); + expect(isClientPointInsideRect(90, 40, { left: 100, right: 200, top: 10, bottom: 80 })).toBe(false); expect(formatTrendRangeLabel(series.points[0], 'zh-CN', '30m')).toContain('10:17-10:30'); });