fix(usage): keep trend tooltip visible until the pointer leaves
This commit is contained in:
1 parent
dfb7124796
commit
ec88f1428a
5 files changed
+107
-22
No files matched your search
@@ -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}%` }}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -8638,6 +8638,7 @@ textarea:focus-visible {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.usage-trend-grid {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user