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) } })))