From 18075e6b66ec288b41e885f02c243982c69f2c50 Mon Sep 17 00:00:00 2001 From: bowenliang123 Date: Wed, 21 Oct 2026 10:27:51 +0800 Subject: [PATCH] fix(overview): track pointer hover locally on the ribbon The ribbon rendered its cursor and tip from the parent's hoveredSeq, which contextView throttles with a 140ms trailing update and a transition so pointer sweeps cannot re-render the whole view; during a sweep the readout froze at the first record and jumped once after the pointer rested. Keep the pointer's record in local state, as the bars face already does, and relay it upstream unchanged: the readout now follows every record change while the browser preview keeps its throttle. hoveredSeq's only producer is this pointer, so the local record wins while the pointer is inside; leaving drops it. --- src/client/components/trendOverview.tsx | 17 +++++++++++++---- tests/client/components/trendOverview.spec.ts | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/client/components/trendOverview.tsx b/src/client/components/trendOverview.tsx index cb036db..7351c86 100644 --- a/src/client/components/trendOverview.tsx +++ b/src/client/components/trendOverview.tsx @@ -12,7 +12,7 @@ * are illegible at ribbon scale) and Adaptive (the whole log IS the visible window). */ -import { Fragment, useLayoutEffect, useMemo, useRef, type ReactElement } from 'react' +import { Fragment, useCallback, useLayoutEffect, useMemo, useRef, useState, type ReactElement } from 'react' import type { Category, ContextEventRecord, RequestRecord } from '../../shared/types' import { CATS, CAT_COLOR } from '../categories' import { RISE_CLASS, TURN_FILLS, preprocessTrend, trendMaxima, turnStepsOf, trendTipRows } from './trendChart' @@ -193,6 +193,15 @@ export function makeTrendOverview(kit: ViewKit): (props: TrendOverviewProps) => const surfaceRef = useRef(null) const wrapRef = useRef(null) const lastIdxRef = useRef(null) + // Pointer hover is local to the ribbon (the bars' idiom): the tip and cursor must track the pointer, while the + // parent's preview follows a throttle/transition and would otherwise freeze a sweep at its first record. The + // pointer is hoveredSeq's only producer, so its record wins while inside; leaving drops the local override. + const [localHover, setLocalHover] = useState(null) + const hoverSeq = localHover ?? props.hoveredSeq + const handleHover = useCallback((seq: number | null): void => { + setLocalHover(seq) + props.onHover(seq) + }, [props.onHover]) if (n === 0) return null // The stretched-unit x scale: one unit per record; a single record closes its bands across the full width. const u = n > 1 ? n - 1 : 1 @@ -242,19 +251,19 @@ export function makeTrendOverview(kit: ViewKit): (props: TrendOverviewProps) => const idx = pick(e) if (lastIdxRef.current !== idx) { lastIdxRef.current = idx - props.onHover(requests[idx].seq) + handleHover(requests[idx].seq) } } const onLeave = (): void => { lastIdxRef.current = null - props.onHover(null) + handleHover(null) } const onClick = (e: { clientX: number; currentTarget: HTMLElement }): void => { const seq = requests[pick(e)].seq props.onSelect(props.selectedSeq === seq ? null : seq) } - const hoveredIdx = props.hoveredSeq !== null ? requests.findIndex(r => r.seq === props.hoveredSeq) : -1 + const hoveredIdx = hoverSeq !== null ? requests.findIndex(r => r.seq === hoverSeq) : -1 const selectedIdx = props.selectedSeq !== null ? requests.findIndex(r => r.seq === props.selectedSeq) : -1 const stepsOf = useMemo(() => turnStepsOf(props.requests), [props.requests]) // Re-position the tip after every commit (it mounts on hover changes); reads batch before paint — the diff --git a/tests/client/components/trendOverview.spec.ts b/tests/client/components/trendOverview.spec.ts index cc212fc..f2e028d 100644 --- a/tests/client/components/trendOverview.spec.ts +++ b/tests/client/components/trendOverview.spec.ts @@ -350,6 +350,22 @@ describe('TrendOverview pointer interaction', () => { await m.unmount() }) + test('the pointer tracks locally without waiting for the parent hover', async () => { + const m = await mount(h(TrendOverview, propsOf(reqs))) + const surface = query(m.container, '.lc-chart') + stubSurfaceRect(surface) + // HoveredSeq never changes here: the local state alone must paint the cursor and the tip. + await moveX(surface, 200) + assert.equal(query(m.container, '.lc-fit-cursor').style.left, '50%') + assert.equal(query(query(m.container, '.lc-chart-tip'), 'span').textContent, kit.t('tip.step', { t: 1, s: 2, n: 5 })) + await moveX(surface, 120) + assert.equal(query(m.container, '.lc-fit-cursor').style.left, '25%') + await unhover(surface) + assert.equal(queryAll(m.container, '.lc-fit-cursor').length, 0) + assert.equal(queryAll(m.container, '.lc-chart-tip').length, 0) + await m.unmount() + }) + test('a zero-width rect picks the first record', async () => { const spies = { hover: [] as (number | null)[] } const m = await mount(h(TrendOverview, propsOf(reqs, { onHover: (s) => { spies.hover.push(s) } })))