Skip to content

feat(frontend): price-chart intro sweep (line + crosshair draw left→right) + remove tooltip price box#329

Merged
dackclup merged 15 commits into
mainfrom
claude/chart-intro-sweep
May 30, 2026
Merged

feat(frontend): price-chart intro sweep (line + crosshair draw left→right) + remove tooltip price box#329
dackclup merged 15 commits into
mainfrom
claude/chart-intro-sweep

Conversation

@dackclup
Copy link
Copy Markdown
Owner

Per user request (2026-05-30): (1) remove the price tooltip box beside the crosshair (it obscured the chart on hover), and (2) animate the line + crosshair sweeping left→right, ease-out (fast→slow) on two triggers — when the chart first scrolls into view on the detail page, and on every 1D–5Y period change. Frontend-only; zero schema / compute change.

Changes

  • Remove tooltip boxTooltip content={() => null} renders no popup panel; only the crosshair remains (vertical dashed cursor line + an activeDot on the Area at the hovered/parked point). The price line no longer hides behind a box on hover/scrub.
  • Intro sweep — a .chart-sweep wrapper around the ResponsiveContainer animates clip-path: inset(0 100% 0 0)inset(0 0 0 0) via a new @keyframes chart-sweep (cubic-bezier(0.22,1,0.36,1), 1100ms — ease-out, decelerating into the latest point on the right). One clip reveals the whole SVG at once — line, gradient fill, and the parked crosshair — so they draw in together.
  • Triggers — the wrapper is keyed by sweepKey. An IntersectionObserver (threshold 0.35, one-shot via a ref gate) bumps it the first time the chart scrolls into view; a period effect bumps it on every 1D–5Y switch (skipping the first render, which the observer owns).
  • Reduced-motion — the keyframe is in the prefers-reduced-motion guard, AND clip-path is forced to none there, so those users see the full chart immediately (no stuck-clipped state). SSR / no-JS render at the final state too.
  • Cleanup — removed the now-dead tooltipContentStyle / tooltipLabelStyle / fmtTooltip; isDark now drives only the crosshair cursor + activeDot colors.

Verification (Playwright, real chromium, frame-by-frame)

  • scroll-into-view sweep — clips then reveals: 30/30 frames show an inset clip (inset 22% → 9% → 0% right-side), settling to fully revealed ✅
  • tooltip box removed — tooltip wrapper text length = 0 ✅; crosshair line + activeDot present on hover ✅
  • period change 1Y→1M — re-fires the sweep: 20/20 frames re-clip ✅
  • reduced-motionclip-path: none, full chart immediately ✅

tsc --noEmit clean on the edited file; next build → 506 routes. Lockstep via PHASE_STATUS_INFLIGHT.md (per #237).

https://claude.ai/code/session_0144kHrCYNaamMPH57b7xdM7


Generated by Claude Code

…ight) + remove tooltip price box

Per user request (2026-05-30):
1. Remove the price tooltip BOX beside the crosshair — `Tooltip content={() => null}`
   renders no popup panel; only the crosshair stays (vertical dashed cursor line
   + an activeDot on the Area at the hovered/parked point), so the line no longer
   obscures the chart on hover/scrub.
2. Animate the line + crosshair sweeping left→right, ease-out (fast→slow,
   decelerating into the latest point on the right), on TWO triggers:
   (a) the chart first scrolling into view on the detail page, and
   (b) every 1D-5Y period change.

Mechanism — a `.chart-sweep` wrapper around the ResponsiveContainer animates
`clip-path: inset(0 100% 0 0)` → `inset(0 0 0 0)` via a new @Keyframes
(cubic-bezier(0.22,1,0.36,1), 1100ms). One clip reveals the whole SVG at once —
line, gradient fill, and the parked crosshair — so they draw in together. The
wrapper is keyed by `sweepKey`; an IntersectionObserver (threshold 0.35,
one-shot) bumps it on first scroll-into-view, and a period-change effect bumps
it on every 1D-5Y switch. Renders at FINAL state with no class (SSR / no-JS),
and the reduced-motion guard forces `clip-path: none` so those users see the
full chart immediately (no stuck-clipped state).

Removed now-dead tooltip-box styling (tooltipContentStyle / tooltipLabelStyle /
fmtTooltip); isDark now drives only the crosshair cursor + activeDot colors.

Verified (Playwright, real chromium, frame-by-frame): scroll-into-view sweep
clips then reveals (30/30 frames, inset 22%→9%→0%); tooltip box removed (text
len 0) + crosshair line & activeDot present on hover; period change 1Y→1M
re-fires the sweep (20/20 frames re-clip); reduced-motion → clip-path none (full
chart immediately). tsc clean; next build 506 routes.

https://claude.ai/code/session_0144kHrCYNaamMPH57b7xdM7
@vercel
Copy link
Copy Markdown

vercel Bot commented May 30, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
quantrank Ready Ready Preview, Comment May 30, 2026 6:44pm

… crosshair dot isn't half-clipped

Self-caught during empirical right-edge verification: the parked crosshair
activeDot sits at the FLUSH right edge and half-overflows the recharts-surface
via `[&_.recharts-surface]:overflow-visible` (PR #322, so the last point's dot
isn't clipped in half by the SVG viewport). But the new chart-sweep clip-path
ended at `inset(0 0 0 0)` — which clips to the wrapper's box edge and IGNORES
overflow-visible, so it re-cut that dot in half (confirmed via a zoom
screenshot: a half-circle at the right edge). Fix: the keyframe now ends at
`inset(0 -8px 0 -8px)` (negative right inset lets the overflowing dot paint
fully; symmetric -8px left keeps the leftmost point safe too). Re-verified: the
parked dot renders as a full circle, and the sweep still plays left→right on
scroll-into-view + period change, reduced-motion still skips (clip none).

https://claude.ai/code/session_0144kHrCYNaamMPH57b7xdM7
…above-fold) + motion-rule notes

frontend-design-reviewer WARNs (PR #329):
- Double-sweep edge case: the `chart-sweep` class was baked into the static
  className, so the initial key=0 mount animated immediately AND the
  IntersectionObserver then bumped sweepKey→1 for a second play — a visible
  stutter when the chart is above the fold (the below-fold normal case hid it).
  Fix: gate the class on `sweepKey > 0`, so the key=0 mount renders WITHOUT the
  class (no animation) and the sweep plays exactly ONCE, driven by the IO when
  the chart scrolls into view (sweepKey 0→1). Period changes bump further, each
  a fresh single sweep. Adapts the gauge `usePlayOnMount` client-gate idea (here
  the IO is the play trigger, so sweepKey itself is the gate). Verified: an
  above-fold chart now plays exactly 1 sweep (was 2).
- Motion Rule 1 (transform/opacity-only): added a globals.css note that
  clip-path is a documented extension — same posture as the gauge's
  stroke-dashoffset; clip-path:inset() causes no layout reflow and is
  GPU-compositable, honoring the rule's intent.
- Motion Rule 2 (no re-fire on in-page interaction): added a comment that a
  period switch REPLACES the data series (new line arriving) rather than
  re-staggering existing content — the case Rule 2 targets — so the re-sweep is
  the intended "new data drawing in" affordance.

activeDot at rest (crosshair anchor at the latest point): kept per user
direction — it replaces the removed price box as the rest-state price marker.

Verified (Playwright): single-sweep gate (above-fold = exactly 1 play; post-IO
wrapper carries chart-sweep); full suite still 5/5 (scroll sweep clips→reveals,
tooltip box gone + crosshair/dot present, period re-fire, reduced-motion none).
tsc clean; next build 506 routes.

https://claude.ai/code/session_0144kHrCYNaamMPH57b7xdM7
… put (replace clip-sweep)

Three user-reported fixes on the chart intro animation:
1. "crosshair กลืนกับกราฟและพื้นหลัง" — bolder color: slate-600 (light) /
   slate-300 (dark) + 1.5px solid-ish dash (was slate-400/500 1px, too faint).
   Applied to BOTH the Recharts parked cursor and the animated overlay so they
   look identical.
2. "ทำให้ crosshair เลื่อนจากซ้ายไปขวาตามกราฟ" — a self-drawn crosshair overlay
   (absolute SVG line + dot) now rides the price curve left→right in lockstep
   with the line draw, driven by rAF (ease-out, fast→slow). The dot's y is found
   on the real curve via getPointAtLength binary search, so it sits ON the line
   as it travels. At the end the overlay fades (opacity 0) and Recharts' parked
   cursor + activeDot take over at the latest point.
3. "เส้นแกนแนวนอนและวันที่ด้านล่างไม่ต้องเลื่อนตาม" — replaced the
   clip-path-on-the-wrapper sweep (which revealed the WHOLE SVG incl. the X-axis
   + date labels) with Recharts' own `isAnimationActive` area-draw, which
   animates ONLY the `.recharts-area` layer. The `.recharts-xAxis` is a sibling
   layer, so the axis + its date ticks render fully and stay PUT from frame 1.

Mechanism: a sweep remount (sweepKey, bumped by the IntersectionObserver
scroll-in + every period change) sets `playDraw=true`, which (a) turns on the
Recharts area-draw animation for that remount only (rest/resize re-park remounts
stay instant), (b) suppresses the Recharts cursor+activeDot so only the animated
overlay shows, and (c) starts the rAF that drives the overlay L→R. On completion
playDraw flips false. Reduced-motion skips the rAF + passes
isAnimationActive=false → full chart immediately. Removed the now-dead
`.chart-sweep` @Keyframes + reduced-motion clip override from globals.css.

Verified (Playwright, frame-by-frame): X-axis ticks 0px drift during draw
(firstTick [302,302], lastTick [932,932]); overlay line x rides 423→676 L→R with
dot cy tracking the curve (11 distinct y); overlay opacity=1 during draw, =0 at
rest with Recharts parked cursor+dot taking over; + a mid-draw screenshot
confirming the full axis is present while the line is ~13% drawn. tsc clean;
next build 506 routes.

https://claude.ai/code/session_0144kHrCYNaamMPH57b7xdM7
…reviewer WARN)

Cosmetic defensive clarity per the reviewer: at draw completion (p=1) the rAF
loop ends without clearing drawRafRef.current, leaving a stale (already-executed)
frame id in the ref. The next sweep's cleanup then calls cancelAnimationFrame on
that stale id — a spec no-op, not a leak, but cleaner to null it. Set
drawRafRef.current = null in the completion branch. No behavior change.

https://claude.ai/code/session_0144kHrCYNaamMPH57b7xdM7
…kill stutter, robust scroll-in

Three user reports on the intro animation, one root cause: TWO animations ran on
separate timelines — Recharts' own area-draw (isAnimationActive) + a separate
rAF moving the crosshair — so they desynced ("ถึงขวาสุดไม่พร้อมกัน"), and
Recharts' per-frame React re-render during its draw made it stutter ("หน่วงและ
กระตุก").

Fix — drive the WHOLE intro from ONE rAF off a single eased progress:
- Recharts area animation OFF (static path, no per-frame React re-render).
- Each frame reveals the line+fill by clipping ONLY `.recharts-area` to [0,x]
  (a sibling of `.recharts-xAxis`, so the axis + date labels stay PUT) AND
  places the crosshair line+dot at the SAME x → they move and finish in exact
  lockstep (verified maxGap=0px, both reach right edge at rx=688/cx=688).
- The dot's y comes from a ONE-TIME precomputed x→y table sampled off the now-
  static curve (120 samples), so per-frame cost is an array lerp, not an
  18-iteration getPointAtLength path walk → smooth (verified avgDt=16.1ms,
  jank=0 over the draw).
- DRAW_MS 1100 → 850 (snappier per "หน่วง").

Robust scroll-in (complaint #1 "เล่นเฉพาะตอนเปลี่ยน period"): the IO threshold
0.35 could miss on a tall layout / slow scrub; lowered to 0.1 + rootMargin, plus
a one-frame getBoundingClientRect fallback so an already-in-view chart at mount
still fires. Verified: a real wheel scroll brings the chart in and the draw
plays (overlay opacity→1).

Cleanup-safe: if a period switch interrupts mid-draw, the effect cleanup clears
the area clip so the line is never left stuck hidden.

Verified (Playwright): scroll-in fires on real scroll; line+crosshair lockstep
(maxGap 0px) reaching the right edge together; 60fps (16.1ms avg, 0 jank);
X-axis 0px drift during draw. tsc clean; next build 506 routes.

https://claude.ai/code/session_0144kHrCYNaamMPH57b7xdM7
…57/sweep) + downsample 5Y path

User report "เปิดหน้า details ค้างหนัก / กดเปลี่ยน 1D-5Y ก็ค้าง". Root-caused via
6× CPU-throttle profiling (the prior 60fps reading was a desktop-GPU artifact —
no throttle).

Two culprits, both in the iteration-3 draw:

1. **getPointAtLength is O(path-length) PER CALL.** The dot-y sampler walked the
   SVG path 121 samples × 16 binary-search iters ≈ 2057 calls per sweep. On a 5Y
   curve (56 KB `d` string) that was a ~47 s main-thread task under 6× throttle —
   the whole page froze (one long task measured 66 s). Replaced with PURE MATH:
   interpolate the in-memory close[] array at the swept x-fraction and map through
   the y-scale → O(1) per frame, zero path walks. Profiled: getPointAtLength
   calls/sweep 2057 → 0; 5Y sweep wall 47.5 s → 1.9 s (25×). The y-scale is read
   ONCE from the curve's getBBox (O(1), not a path walk) so the crosshair dot sits
   on the line (was 27 px off when I naively assumed [marginTop, surfaceHeight] —
   Recharts insets the plot by the ~51 px X-axis + ~29 px top pad; now derived
   from the real bbox → 6 px, sub-pixel on screen).

2. **5Y fed Recharts ~1260 daily points → a 56 KB path** whose ONE-TIME render is
   a ~380 ms task on a throttled phone. Added `downsample()` — caps the series to
   260 points by even stride (always keeping first + last so the price range +
   latest price + flush-right crosshair stay exact). At 360-700 px chart widths
   there are far more points than pixels, so this is visually lossless. 5Y path
   `d` 56 KB → 12 KB (4.8×); shorter windows (≤260 pts) pass through untouched.

Also: DRAW_MS 850 → 650 (snappier per "หน่วง").

Measured (6× CPU throttle): animation's share of page Total-Blocking-Time is now
~0 ms (identical TBT with animation vs reduced-motion) — the remaining ~1 s TBT
is the detail page's React hydration (ScoreGauge/RankingTable/pillars), which
predates this PR and is a separate perf item, not the animation.

Verified (Playwright): scroll-in fires; line+crosshair lockstep (0 px) reaching
the right edge together; X-axis 0 px drift; dot-on-line ≤6 px (screenshot
confirms it rides the line tip); downsample keeps first+last and caps at 260.
tsc clean; next build 506 routes.

https://claude.ai/code/session_0144kHrCYNaamMPH57b7xdM7
…ewer + test-engineer)

The `downsample` helper (exported from PriceHistoryChart.tsx in the freeze fix)
had no test — frontend-design-reviewer flagged it. The project has NO frontend
test runner (no vitest/jest, no frontend *.test.* files, CI Frontend job runs
only next build), so rather than pull in a framework for one 8-line helper,
test-engineer added a standalone node guard (node is already in the env).

frontend/components/downsample.test.mjs — 14 assertions across 7 cases run via
`node frontend/components/downsample.test.mjs`: identity passthrough (n≤max,
max<2), exact length cap (261→260, 1260→260), first+last-point invariant,
maxPoints=2 edge, no-duplicate-last on a distinct series, no-fabricated-points.
RED phase confirmed against a copy that drops the exact-last push. 14/14 green.

Transcription (not import) because there is no TS test harness; the header
documents the sync obligation + the "delete + replace with a real .test.ts if
vitest is ever added" follow-up. Not CI-wired (no frontend runner to wire into);
it's a manual regression guard. next build still 506 routes (Next doesn't pick
up the .mjs as a route); ruff clean.

https://claude.ai/code/session_0144kHrCYNaamMPH57b7xdM7
…) + crosshair line within plot area

Two user reports (2026-05-30):
1. "เส้น crosshair ล้นออกด้านบนและด้านล่าง" — the overlay crosshair line was
   y1=0..y2=surfaceHeight, but the surface includes a ~8px top pad + the ~50px
   X-axis date band below the plot. So the line overflowed above the chart top
   and down through the date labels. Fixed: span the line over the PLOT AREA
   only — [plotTop, plotTop+plotH], the same geometry already derived in prep()
   from the curve bbox + y-domain. Verified the line now runs [8,258] (the
   Recharts plot clip-rect) instead of [0,288]; screenshot confirms it starts at
   the chart top and ends exactly at the X-axis, no overflow.
2. "ไม่ต้องเล่น animation ตอนเข้าหน้า / ตอนรีเฟรช ... ให้เล่นเฉพาะตอนเปลี่ยน 1D-5Y" —
   removed the IntersectionObserver scroll-into-view trigger entirely. The intro
   now plays ONLY on a period switch: the period effect keeps its
   firstPeriodRender guard so the initial mount (and every refresh, which is a
   fresh mount) paints the chart statically, and only an explicit 1D-5Y button
   press sets playDraw + bumps sweepKey. Removed the now-unused hasSwept ref.

Verified (Playwright): NO draw on page load (overlay stays opacity 0, area never
clipped — chart fully shown on arrival); period change DOES fire the draw;
crosshair line stays within the plot area (no top/bottom overflow). tsc clean;
next build 506 routes.

https://claude.ai/code/session_0144kHrCYNaamMPH57b7xdM7
…ntend-design-reviewer WARN

The sweepKey/playDraw declaration comment still said the sweep fires on "IO
scroll-in or period change", but the IntersectionObserver scroll-in trigger was
removed in 2f0bee0 — the sweep now fires ONLY on a 1D-5Y period change.
Comment-only; no behavior change.

https://claude.ai/code/session_0144kHrCYNaamMPH57b7xdM7
…n scrub AND during the draw

User 2026-05-30: "ทำให้ตัวเลขราคา ราคาที่เปลี่ยนแปลง และวันที่ เปลี่ยนไปตามจุดที่
crosshair อยู่ ทั้งตอนผู้ใช้ลากเองและตอนเล่น animation".

Two drive paths, each chosen to avoid the per-frame-setState freeze that the
earlier getPointAtLength bug taught us:

- SCRUB (user drag): AreaChart `onMouseMove` → `setHoverIndex(activeTooltipIndex)`;
  the headline JSX renders `headlineAt(hoverIndex ?? lastIdx)` — price, change vs
  the window's first close, %, direction color, and date all track the dragged
  point. Cleared to null (→ snaps back to latest) on pointer up / leave / cancel
  (alongside the existing restKey re-park). State is fine here: hover events fire
  at pointer rate and Recharts already re-renders on scrub.

- ANIMATION (the intro draw): the rAF writes the headline spans IMPERATIVELY via
  refs (priceRef/changeAbsRef/changePctRef/changeArrowRef/changeRowRef/asOfRef) —
  `writeHeadline(close, date)` each frame, NO setState — so the 60fps sweep never
  re-renders React (a per-frame setState would re-render Recharts 60×/s and
  re-freeze the page). hoverIndex stays null during the draw (onMouseMove early-
  returns on playDraw). The end-of-draw setPlayDraw(false) re-render restores the
  latest value via JSX = the last animated frame, so no visible snap.

Shared formatting: hoisted a module-level `fmtDateLabel` so the in-render
formatTooltipLabel and the out-of-render rAF writer format the date identically.
The change is always measured from the window's FIRST close (Google-Finance
convention), matching the static headline.

Verified (Playwright): scrub moves price+change+date to the crosshair point
($214.25→$181.57@25%→$186.49@60%, dates Aug 27 2025 / Dec 31 2025); release snaps
back to latest; animation counts the headline through 38 distinct prices across
39 frames ending on latest; mobile 6× throttle shows NO >250ms freeze from the
headline writes (only Recharts' one-time 5Y render). tsc clean; next build 506.

https://claude.ai/code/session_0144kHrCYNaamMPH57b7xdM7
…bing (frontend-design-reviewer WARNs)

Three frontend-design-reviewer WARNs on the headline-tracking commit:

- Items 1+2 (mid-draw reconcile fight): a ResizeObserver re-park (setLayoutKey)
  firing DURING the ~650ms intro draw would reconcile the headline spans back to
  the latest value for ~1 frame, fighting the rAF's imperative writes (a flicker
  + a possible 1-frame doubled color class). Guard: a `playDrawRef` mirror of
  playDraw, checked inside the ResizeObserver's debounced timer — skip the
  layoutKey bump while the draw runs (the draw is ≤650ms; the next genuine resize
  or the rest state re-parks correctly anyway). Closes both WARNs with one guard.
- Item 3 (period label misleading while scrubbing): the `scrubbing` flag was
  computed but unused — PERIOD_LABEL ("past year") stayed visible even when the
  headline showed a scrubbed mid-window point, where the change is measured from
  the window start to THAT point (so "past year" mislabels it). Now hidden while
  scrubbing (`!scrubbing && <span>{PERIOD_LABEL[period]}</span>`); restored at
  rest / during the animation where it correctly describes the full window.
- Item 4 (scrub re-render cost): profiled — a touch-drag scrub across a 5Y
  (260-pt) chart at 6× CPU throttle produced ZERO long tasks (>100ms: 0, >250ms:
  0). setHoverIndex's added re-render is negligible on top of Recharts' existing
  per-move cursor re-render. No action needed.

Verified (Playwright): headline tracking regression suite still 6/6 green
(scrub price+change+date, release snap-back, animation count-up, end-on-latest,
no-freeze); scrub at 6× throttle = 0 long tasks. tsc clean; next build 506.

https://claude.ai/code/session_0144kHrCYNaamMPH57b7xdM7
…node spans + no imperative classList

User-reported: scrubbing the crosshair OR toggling 1D-5Y back and forth threw an
app error, and scrub "เลื่อนได้บ้างไม่ได้บ้าง" (worked intermittently). A/B-tested
the committed build vs the fix — the committed build reproducibly throws
`NotFoundError: Failed to execute 'removeChild' on 'Node': The node to be removed
is not a child of this node` on rapid scrub; the fix shows 0 errors across the
same paths.

ROOT CAUSE: the headline-tracking commit drove price/change/% spans imperatively
with `ref.textContent = …` (per animation frame), but those spans had MULTIPLE
JSX children (e.g. a literal "$" + an expression = two text nodes; "(" + expr +
"%)" = three). `textContent =` collapses all children to ONE text node, but
React's vdom still believes the original multi-node structure exists. The next
re-render (a scrub `setHoverIndex`, or a period change) reconciles against a text
node that's no longer in the DOM → removeChild/insertBefore on a detached node →
crash; when it didn't hard-throw it left the scrub silently not updating
("ได้บ้างไม่ได้บ้าง").

FIX:
- Every imperatively-written span now holds exactly ONE text node — a single
  template-literal child: `{`$${hv.price.toFixed(2)}`}`, `{`${sign}${abs}`}`,
  `{`(${sign}${pct}%)`}`. textContent replacement now matches React's vdom model,
  so re-renders reconcile cleanly. (changeArrowRef / asOfRef were already single-
  node.)
- Dropped the imperative classList color-swap on the change row (another
  React-owns-className-vs-imperative conflict, and it lost to the globals.css
  OKLCH !important anyway). The row color is left to React (correct per-point on
  scrub; steady through the animation while the numbers count up — Google-Finance
  behavior). changeRowRef stays attached but unread.

A/B verified (Playwright): committed build → removeChild crash on rapid scrub;
this build → 0 errors across desktop (scrub enter/leave cycles · hover+period
interleave · rapid scrub ×4) AND mobile touch (scrub · rapid period tap · scrub-
during-anim). Headline tracking regression still 6/6 (scrub price+change+date,
snap-back, animation count-up, no freeze). tsc clean; next build 506 routes.

https://claude.ai/code/session_0144kHrCYNaamMPH57b7xdM7
…ad of Recharts' (which snapped to defaultIndex)

User: the crosshair didn't follow the drag — slow scrub kept snapping it back to
the right edge, fast scrub left it stuck at the right and it only jumped to the
release point when you stopped, even though the price numbers tracked correctly.

ROOT CAUSE: every scrub fires setHoverIndex → a parent re-render → Recharts
re-applies the `<Tooltip defaultIndex={last}>` and snaps its BUILT-IN cursor back
to the latest point on every render. So the built-in cursor literally cannot
track a moving finger across the per-move re-renders (measured: cursorX pinned at
688px/right-edge while mouseX swept 69→619px; on fast-scrub-then-stop the cursor
sat at 688 and only moved to the release point after the renders stopped). The
headline was unaffected because it reads `hoverIndex` directly, not Recharts'
index.

FIX — stop using Recharts' cursor for the crosshair; draw our OWN:
- `<Tooltip cursor={false}>` + `<Area activeDot={false}>` — Recharts no longer
  draws a competing crosshair. The Tooltip stays only to harvest the scrubbed
  index via onMouseMove (state.activeTooltipIndex).
- A new effect positions the existing overlay SVG (the same line+dot the intro
  animation uses) at `hoverIndex` on every hoverIndex/data/theme/remount change:
  x = index/(n-1)·plotW, y = closeToY(close). hoverIndex null = rest → parks at
  the latest point. Skipped while playDraw (the intro rAF owns the overlay). The
  overlay tracks the index we control, so re-renders can't snap it back.
- Extracted the plot geometry (surface width + price→y over the real plot area,
  from the curve bbox) into a shared module-level `measurePlot()` used by both
  the intro rAF and this scrub effect.
- The intro draw now ENDS with the overlay left visible at the latest point
  (was: hide + hand to Recharts' cursor) — seamless handoff to the rest crosshair.

Verified (Playwright): slow scrub cursorX tracks mouseX gap ≤2px (was 619px, 0/9
steps pinned-right now vs 9/9); fast-scrub-then-stop cursor lands at the release
point immediately (241px, was 688); screenshot shows the dashed line + on-curve
dot at the scrubbed point with the matching headline. Crash repro still 0 errors;
headline tracking 6/6; mobile touch 0 errors. tsc clean; next build 506 routes.

https://claude.ai/code/session_0144kHrCYNaamMPH57b7xdM7
…change + show rest crosshair at load (reviewer FAILs)

Two frontend-design-reviewer FAILs on the crosshair-follows-scrub commit, plus a
self-caught follow-on:

- FAIL Item 2 — rest dot half-clipped at the flush-right edge: the overlay <svg>
  had no overflow attr, so the rest crosshair dot at cx=w (r=4) was cut by the
  svg's own viewport. Added `overflow="visible"` (safe — globals.css
  `html,body{overflow-x:clip}` is the document-level backstop).
- FAIL Item 4b — period label vanished after a period switch: hoverIndex kept the
  stale index from the old window, so `scrubbing` stayed true and the
  `{!scrubbing && PERIOD_LABEL}` conditional hid the label permanently. Added
  `setHoverIndex(null)` to the period effect.
- Self-caught (surfaced verifying Item 2): the rest crosshair didn't appear on
  INITIAL load — on first mount measurePlot returns null (Recharts hasn't painted
  the curve yet) and nothing re-triggered the effect until the first hover.
  Wrapped the positioning in a `place(tries)` that retries on rAF (capped 20)
  until the curve is measurable, so the rest crosshair shows from load.

Verified (Playwright): rest crosshair visible at t=0.5s (opacity 1, cx=688 right
edge, overflow visible); period label hides on scrub + reappears ("past 6 months")
after a 1Y→6M switch; cursor still tracks mouse ≤2px; crash repro 0 errors;
headline tracking 6/6. tsc clean; next build 506 routes.

https://claude.ai/code/session_0144kHrCYNaamMPH57b7xdM7
@dackclup dackclup marked this pull request as ready for review May 30, 2026 18:49
@dackclup dackclup merged commit 9ee1b32 into main May 30, 2026
4 checks passed
@dackclup dackclup deleted the claude/chart-intro-sweep branch May 30, 2026 18:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants