fix(usage): keep trend tooltip visible until the pointer leaves

This commit is contained in:
lzt404 committed 2026-09-21 21:58:01 +08:00
1 parent dfb7124796
commit ec88f1428a
5 files changed
+107 -22

No files matched your search

+59 -22
View File
@@ -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<UsageQuery, 'start' | 'end'>;
}) {
const { t, locale } = useI18n();
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [hoveredRatio, setHoveredRatio] = useState<number | null>(null);
const [hiddenModels, setHiddenModels] = useState<string[]>([]);
const plotRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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}
>
<svg
@@ -1335,7 +1372,7 @@ function UsageTrend({
})}
{chart.bars.map((bar, index) => (
<g key={series.points[index].hour} className={`usage-trend-bar${index === hoveredIndex ? ' is-active' : ''}`}>
<g key={series.points[index].hour} className={`usage-trend-bar${active && index === hoveredIndex ? ' is-active' : ''}`}>
{bar.layers.map((layer) => (
<rect
key={layer.key}
@@ -1349,7 +1386,7 @@ function UsageTrend({
</g>
))}
{active && hoveredIndex != null ? (
{active ? (
<g className="usage-trend-active-mark">
<line
x1={activeViewboxX}
@@ -1362,7 +1399,7 @@ function UsageTrend({
) : null}
</svg>
{active && hoveredIndex != null ? (
{active ? (
<div
className={`usage-trend-tooltip${activePercent > 62 ? ' is-left' : ' is-right'}`}
style={{ left: `${activePercent}%` }}
+25
View File
@@ -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<DOMRect, 'left' | 'right' | 'top' | 'bottom'>,
): 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()
+1
View File
@@ -8638,6 +8638,7 @@ textarea:focus-visible {
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
}
.usage-trend-grid {
+11
View File
@@ -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'),
+11
View File
@@ -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');
});