fix(frontend): price-chart crosshair — kill drag lag + park tooltip at latest on rest/release/rotate#322
Merged
Merged
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Bug (reported on mobile)
On the per-stock price chart (
PriceHistoryChart.tsx, Recharts 2.15.4):Root cause
The
<Tooltip>used Recharts defaults: nodefaultIndex(so no rest state), andisAnimationActiveunset (defaulttrue→ an inlinetransition: transform 400msRecharts puts on the tooltip wrapper makes the box chase the pointer — measured target220pxvs computed186pxmid-animation = the lag). The<Area>already hadisAnimationActive={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.defaultIndexonly on mount, so re-assert it on the other resting events by remounting<AreaChart>viakey={${period}-${restKey}-${layoutKey}}:restKeybumps ononPointerUp/onPointerLeave(snap back after a drag-release / pointer-exit);layoutKeybumps on amatchMedia('(orientation: portrait)')change (re-park after rotate);periodis already in the key.Two refinements over the first design, both verified necessary:
<div>via pointer events (onPointerUp/onPointerLeave) — notonMouseLeave/onTouchEndon the SVG, which can miss touch-release on mobile (the maintainer's case).ResponsiveContainerre-measure madedisplayDefaultTooltipland 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 --noEmitclean ·next buildgreen (506 routes) ·ruff check .clean. Tooltip label per state: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
PriceTimePeriodSelectorhighlights 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 byfrontend-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