Skip to content

fix(frontend): price-chart crosshair — kill drag lag + park tooltip at latest on rest/release/rotate#322

Merged
dackclup merged 7 commits into
mainfrom
claude/optimistic-brown-UUcXA
May 29, 2026
Merged

fix(frontend): price-chart crosshair — kill drag lag + park tooltip at latest on rest/release/rotate#322
dackclup merged 7 commits into
mainfrom
claude/optimistic-brown-UUcXA

Conversation

@dackclup
Copy link
Copy Markdown
Owner

Bug (reported on mobile)

On the per-stock price chart (PriceHistoryChart.tsx, Recharts 2.15.4):

  1. Dragging fast → the tooltip box lags behind the finger.
  2. The crosshair line + box never park at the latest date in any resting state — on page open nothing shows; after a drag-release it freezes at the last-touched point then vanishes when the pointer leaves; orientation flips and period changes leave it at an arbitrary point.

Root cause

The <Tooltip> used Recharts defaults: no defaultIndex (so no rest state), and isAnimationActive unset (default true → an inline transition: transform 400ms Recharts puts on the tooltip wrapper makes the box chase the pointer — measured target 220px vs computed 186px mid-animation = the lag). The <Area> already had isAnimationActive={false}; the <Tooltip> did not.

Fix (1 file, +44 / −1)

  • <Tooltip defaultIndex={chartData.length - 1} isAnimationActive={false}> — park at latest on mount + kill the lag.
  • Recharts applies defaultIndex only on mount, so re-assert it on the other resting events by remounting <AreaChart> via key={${period}-${restKey}-${layoutKey}}: restKey bumps on onPointerUp/onPointerLeave (snap back after a drag-release / pointer-exit); layoutKey bumps on a matchMedia('(orientation: portrait)') change (re-park after rotate); period is already in the key.

Two refinements over the first design, both verified necessary:

  1. Rest handlers on the wrapper <div> via pointer events (onPointerUp/onPointerLeave) — not onMouseLeave/onTouchEnd on the SVG, which can miss touch-release on mobile (the maintainer's case).
  2. ~300ms debounce on the orientation bump — remounting during the ResponsiveContainer re-measure made displayDefaultTooltip land on index 0 (verified: landscape parked at the first point "May 28, 2025"). Debouncing so the remount lands after the width settles fixed it.

Verification (local Playwright DOM, NVDA dark mobile 390×844)

tsc --noEmit clean · next build green (506 routes) · ruff check . clean. Tooltip label per state:

State Label OK
On load (no interaction) May 28, 2026 (latest)
During drag @25/50/75% Aug 29 2025 / Dec 3 2025 / Mar 11 2026 ✓ inspection still works
After pointer-up (release) May 28, 2026
After pointer-leave May 28, 2026
Landscape (debounced) May 28, 2026
Back to portrait (debounced) May 28, 2026
After 6M → 1Y May 28, 2026

Inline transition = none in every state (lag gone). Cursor line + tooltip box + active dot all park at the latest point (verified in portrait + landscape screenshots).

No schema / compute / scoring / valuation change. Lockstep via PHASE_STATUS_INFLIGHT.md.

Bonus found, NOT bundled (separate follow-up if wanted): the PriceTimePeriodSelector highlights 1M on initial load while the chart renders the 1Y window (state inits to '1Y') — a selector active-chip mismatch, unrelated to the crosshair ask.

🤖 Reproduced + measured by expert-user-explorer; source-traced + design by frontend-design-reviewer; main agent reconciled + added the pointer-event + debounce refinements after verifying the first design broke the orientation case.

https://claude.ai/code/session_0144kHrCYNaamMPH57b7xdM7


Generated by Claude Code

…t latest in all resting states

The per-stock price chart (PriceHistoryChart.tsx, Recharts 2.15.4) used a bare
<Tooltip>: dragging fast made the tooltip box lag behind the finger (an inline
`transition: transform 400ms` Recharts sets on the wrapper), and the crosshair
never parked at the latest date — on load nothing showed, after a drag-release
it froze at the last-touched point then vanished on pointer-leave, and
orientation/period changes left it at an arbitrary point.

Fix: <Tooltip defaultIndex={chartData.length - 1} isAnimationActive={false}>
(park at latest on mount + kill the lag), plus remount <AreaChart> via
key=`${period}-${restKey}-${layoutKey}` to re-assert the default on the other
resting events — restKey bumps on onPointerUp/onPointerLeave (snap back after a
drag-release/exit), layoutKey bumps on a matchMedia orientation change. Two
refinements verified necessary: rest handlers on the wrapper div via pointer
events (not onMouseLeave/onTouchEnd on the SVG, which can miss touch on mobile);
and a ~300ms debounce on the orientation bump (remounting mid-resize made
displayDefaultTooltip park on index 0).

Verified (Playwright DOM, NVDA mobile): label = latest on load / after release /
after leave / landscape / back-to-portrait / period-change; hover still inspects
any date; transition=none everywhere (lag gone). tsc + next build (506 routes) +
ruff clean. Frontend-only; no schema/compute change.

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

vercel Bot commented May 29, 2026

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

Project Deployment Actions Updated (UTC)
quantrank Ready Ready Preview, Comment May 29, 2026 4:57pm

…e against touch)

Follow-up on the same PR: the park-at-latest handlers introduced a touch
regression — onPointerLeave fired during a touch drag (the browser emits
spurious pointerleave events as the finger crosses child-SVG boundaries; the
wrapper never gets implicit pointer capture because pointerdown lands on a
child), so setRestKey remounted <AreaChart> mid-drag and reset the crosshair to
defaultIndex — the crosshair could never follow the finger.

Fix: guard onPointerLeave to ignore pointerType==='touch' (mouse-leave still
re-parks; touch re-park comes from onPointerUp at finger-lift) + add
touch-action: pan-y on the wrapper (canonical for a scrub chart; preserves
vertical page scroll).

Empirically reconciled two conflicting agent diagnoses (the touch-action /
page-steal theory was refuted by a measured scroll-delta of 0; the pointerleave
remount was confirmed by a recorded 3x pointerleave-during-drag). Verified via
CDP Input.dispatchTouchEvent: touch-drag scrubs Dec 23 2025 -> Sep 19 2025 ->
Jul 15 2025 following the finger, touchEnd re-parks May 28 2026, mouse drag
still works, park-at-latest intact. tsc + next build (506) + ruff clean.

https://claude.ai/code/session_0144kHrCYNaamMPH57b7xdM7
Follow-up on the same PR: drag-scrub + drag-release re-park work, but a single
tap left the crosshair stuck at the tapped point. A tap fires pointerdown ->
pointerup -> click, and the tooltip is set to the tapped point by the
compatibility synthetic-mouse + click that fire AFTER pointerup — so the
onPointerUp re-park fired too early and the tap point re-applied. (A drag moves
far enough that the browser suppresses the synthetic click, so onPointerUp wins
— which is why drag worked and tap didn't.)

Fix: also re-park on onClick. click is the last event of a tap and bubbles to
the wrapper after Recharts sets the tap point, so it wins; drags don't fire
click, so no double work.

Verified via CDP dispatchTouchEvent: pure-tap and micro-tap both re-park to the
latest date, drag still scrubs + release re-parks, mouse drag scrubs + leave
re-parks. tsc + next build (506) + ruff clean.

https://claude.ai/code/session_0144kHrCYNaamMPH57b7xdM7
…argin right 16 -> 0)

The chart's drawn area was flush with the content column on the left but stopped
~16px short on the right, because <AreaChart margin> was left: 0 / right: 16. Set
right: 0 to match left so the line/area + crosshair span the full content width
on both sides.

Measured (Playwright): area-path left/right now equal the container left/right
exactly (leftGap = rightGap = 0); the last x-axis tick "May 26" end-anchors at
the edge so it does not clip; the latest dot sits at the right edge. tsc + next
build (506) + ruff clean.

https://claude.ai/code/session_0144kHrCYNaamMPH57b7xdM7
Two real-device after-effects of the prior chart fixes:

1. The right:0 flush made the latest-point dot + crosshair line clip in half at
   the edge. It is NOT a Recharts clipPath (the dot has no clip ancestor) — it is
   the .recharts-surface SVG's own overflow:hidden (the dot center sits on the
   svg right edge and its right half spilled past the viewBox). Fix: scope
   overflow:visible to this chart's surface via the Tailwind arbitrary variant
   [&_.recharts-surface]:overflow-visible on the wrapper (other charts keep
   default clipping) — keeps margin.right:0 flush AND lets the edge dot/cursor
   render fully into the harmless page gutter.

2. A touch that started on the chart then became a vertical page scroll
   (touch-action:pan-y hands it to the browser) fires pointercancel, not
   pointerup/click — so the crosshair stayed stuck at the touched point after
   scrolling. Fix: add onPointerCancel -> re-park.

Verified (CDP): surface overflow==='visible'; full-width screenshot shows a
complete dot at the edge; touch-scrub then pointercancel re-parks to latest;
regressions intact (park-on-load, tap->latest, drag scrub + release->latest).
tsc + next build (506) + ruff clean.

https://claude.ai/code/session_0144kHrCYNaamMPH57b7xdM7
…right margin instead

The overflow:visible added to un-clip the edge dot at the flush right:0 edge let
SVG content escape the surface and widened the whole page — measured innerWidth
390 -> 449 under mobile emulation (~59px of phantom horizontal scroll when the
crosshair parks at the right edge). This is the flush / full-dot / no-scroll
trilemma: a full dot at a perfectly flush edge must either clip or escape.

Resolution: remove [&_.recharts-surface]:overflow-visible (default clip -> nothing
escapes -> no page scroll) and give the AreaChart margin.right: 8 so the
latest-point dot + crosshair sit just inside the surface, fully visible. Cost:
the line ends ~8px short of the content edge (not pixel-flush). onPointerCancel
(scroll-after-touch re-park) is unrelated and stays.

Verified (clean viewport): innerWidth == docScrollWidth == 390 (hOverflow 0,
nothing past the viewport); dot fully inside (dotRight 370 < containerRight 374);
behaviours intact (park-on-load / drag scrub + release / tap / scrub+pointercancel
all re-park to latest). tsc + next build (506) + ruff clean.

https://claude.ai/code/session_0144kHrCYNaamMPH57b7xdM7
…el overflow-x clip)

Real root cause of "the page expands right after scrubbing the crosshair"
(commits 4-5 fixed the wrong spot — the dot/margin were a red herring). Found
via mobile-emulation measurement (a clean no-isMobile viewport hid it): before/
during scrub innerWidth==390; AFTER release it jumps to 441 and the only element
past the device width is the fixed inset-0 mobile sidebar backdrop (Sidebar.tsx,
always-mounted for its fade) at width 441.

Mechanism: the crosshair re-park remounts <AreaChart> (the key bump) -> a
transient horizontal overflow during the ResponsiveContainer re-measure -> the
mobile layout viewport grows -> the fixed inset-0 backdrop sizes itself to the
widened viewport and, being fixed + full-width, sustains it as phantom
right-side scroll space.

Fix at the document level: html, body { overflow-x: clip } in globals.css. clip
(not hidden) clips the transient so the layout viewport never grows, and does
NOT create a scroll container so it does NOT break the sticky sidebar/header.
With that guard in place the dot/margin tug-of-war dissolves: reverted to
margin.right:0 (line truly flush) + restored [&_.recharts-surface]:overflow-visible
(full edge dot) — both safe now.

Verified (isMobile emulation, the test that exposed it): before / after
scrub+release / after tap all -> innerWidth==scrollWidth==390, zero offenders;
areaRight==containerRight (flush); dot full (dotRight 378); sticky header top==0
after a 600px scroll (sticky intact); park/scrub/tap/pointercancel all re-park.
tsc + next build (506) + ruff clean.

https://claude.ai/code/session_0144kHrCYNaamMPH57b7xdM7
@dackclup dackclup marked this pull request as ready for review May 29, 2026 17:28
@dackclup dackclup merged commit fd04527 into main May 29, 2026
4 checks passed
@dackclup dackclup deleted the claude/optimistic-brown-UUcXA branch May 29, 2026 17:28
dackclup added a commit that referenced this pull request May 29, 2026
…low-x:clip §Gotcha (#323)

Match fair/target reference lines (both #e2e8f0 white in dark / #0f172a in light, strokeWidth 1.5, dash-distinguished, no in-chart labels); always-show fair/target chips below the price headline with signed %-from-current and tabular-nums; current-price as-of date below the change indicator (larger + white to match the price). Plus the overflow-x:clip §Gotcha doc fast-follow from #322. Frontend + docs only; no schema/compute change.
dackclup added a commit that referenced this pull request May 29, 2026
…ap point (#324)

Recharts 2.15 only updates the crosshair on touch-MOVE (handleTouchMove);
handleTouchStart calls handleMouseDown, which never touches the tooltip — so a
tap without a drag left the crosshair parked at the latest date for the whole
press. Add a touch-only onPointerDown that dispatches a synthetic mousemove at
the touch point from inside .recharts-wrapper, driving Recharts' onMouseMove.

getMouseInfo reads pageX (not clientX), and pageX/pageY are not part of
MouseEventInit (a constructed MouseEvent leaves them 0 -> negative chartX ->
Recharts clears the tooltip), so they're set explicitly via Object.defineProperty
to the pointer's page coords. Mouse pointers already hover-track, so the handler
early-returns for non-touch. Release still resets to latest via the existing
#322 remount-on-pointerup/click/pointercancel.

Verified by CDP touch (mobile 414x896): tap@30% -> crosshair jumps to that date
and holds during the press; release -> reset to latest. No regression: drag-scrub
follows, tap-then-scroll resets via pointercancel. Frontend-only; no schema/
compute/scoring/valuation change.

https://claude.ai/code/session_0144kHrCYNaamMPH57b7xdM7

Co-authored-by: Claude <noreply@anthropic.com>
dackclup pushed a commit that referenced this pull request May 30, 2026
… 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
dackclup added a commit that referenced this pull request May 30, 2026
…ight) + remove tooltip price box (#329)

* feat(frontend): price-chart intro sweep (line + crosshair draw left→right) + 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

* fix(frontend): chart-sweep clip ends at -8px right so the flush-right 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

* fix(frontend): gate chart-sweep to a single play (no double-sweep on 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

* fix(frontend): crosshair rides line L→R + bolder color + X-axis stays 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

* fix(frontend): null drawRafRef after draw completes (frontend-design-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

* fix(frontend): unify chart intro into ONE rAF — sync line+crosshair, 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

* perf(frontend): kill chart freeze — drop getPointAtLength (O(path)·2057/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

* test(frontend): add downsample regression guard (frontend-design-reviewer + 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

* fix(frontend): chart draw only on period change (not on enter/refresh) + 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

* docs(frontend): fix stale sweepKey comment (IO trigger removed) — frontend-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

* feat(frontend): headline (price/change/date) tracks the crosshair — on 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

* fix(frontend): guard mid-draw re-park + hide period label while scrubbing (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

* fix(frontend): stop chart crash on scrub/period-switch — single-text-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

* fix(frontend): crosshair follows the scrub — own overlay cursor instead 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

* fix(frontend): overlay overflow:visible + clear hoverIndex on period 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

---------

Co-authored-by: Claude <noreply@anthropic.com>
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