From adc9360a7e99b28afecd26248a7d2341e29c02cb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 12:44:59 +0000 Subject: [PATCH 01/15] =?UTF-8?q?feat(frontend):=20price-chart=20intro=20s?= =?UTF-8?q?weep=20(line=20+=20crosshair=20draw=20left=E2=86=92right)=20+?= =?UTF-8?q?=20remove=20tooltip=20price=20box?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/app/globals.css | 37 ++++++++- frontend/components/PriceHistoryChart.tsx | 99 +++++++++++++++++------ 2 files changed, 110 insertions(+), 26 deletions(-) diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 2de083ad0..90bc0b2d7 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -405,6 +405,33 @@ code { animation: gauge-sweep 800ms cubic-bezier(0.22, 1, 0.36, 1) both; } +/* Price-chart intro sweep — the line + area + crosshair are revealed + * left→right by animating a clip-path inset from "fully clipped on the right" + * (inset 0 100% 0 0 → nothing visible) to "fully revealed" (inset 0 0 0 0). + * The ease-out curve (fast→slow, decelerating into the latest point on the + * right) matches the gauge-sweep family. One clip wrapper reveals EVERYTHING + * inside the SVG at once — line, gradient fill, and the parked crosshair — + * so they appear to "draw in" together rather than animating separately. + * Plays on (a) the chart first scrolling into view on the detail page and + * (b) every 1D-5Y period change, by re-adding the class via a key remount of + * the wrapper. transform/clip + opacity only (Motion Rule 1); the chart + * renders at its FINAL state with no class (SSR / no-JS / reduced-motion all + * show the full chart immediately). 1100ms — the longest beat after the gauge, + * justified because it IS the page's primary data visual. */ +@keyframes chart-sweep { + from { + clip-path: inset(0 100% 0 0); + -webkit-clip-path: inset(0 100% 0 0); + } + to { + clip-path: inset(0 0 0 0); + -webkit-clip-path: inset(0 0 0 0); + } +} +.chart-sweep { + animation: chart-sweep 1100ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + /* Hover lift — table rows / cards rise a hair on hover. Kept tiny * (1px) + fast so it reads as "alive" not "bouncy"; pairs with the * existing slate hover-bg. transform ONLY (Motion Rule 1) — no @@ -428,9 +455,17 @@ code { .animate-rise-in, .animate-chip-pop, .animate-flag-pulse, - .gauge-sweep { + .gauge-sweep, + .chart-sweep { animation: none !important; } + /* The sweep clips the chart while running; reduced-motion skips the + * animation, so force the clip OFF too or the chart would stay hidden + * at the keyframe's `from` (inset 0 100% — fully clipped). */ + .chart-sweep { + clip-path: none !important; + -webkit-clip-path: none !important; + } .hover-lift { transition: none !important; } diff --git a/frontend/components/PriceHistoryChart.tsx b/frontend/components/PriceHistoryChart.tsx index 7e9ee4d56..98349c6da 100644 --- a/frontend/components/PriceHistoryChart.tsx +++ b/frontend/components/PriceHistoryChart.tsx @@ -66,12 +66,56 @@ export function PriceHistoryChart({ // remount is how we re-assert it on those events. const [restKey, setRestKey] = useState(0); const [layoutKey, setLayoutKey] = useState(0); + // `sweepKey` keys the clip-path intro-sweep wrapper: bumping it remounts the + // wrapper, which re-adds the `chart-sweep` class → the line + crosshair + // re-reveal left→right (ease-out, fast→slow into the latest point). Bumped on + // (a) the chart first scrolling into view (IntersectionObserver below) and + // (b) every period change (the period effect below). + const [sweepKey, setSweepKey] = useState(0); + // Gate so the scroll-into-view sweep fires only the FIRST time the chart + // enters the viewport (not on every scroll up/down past it). + const hasSwept = useRef(false); // Chart wrapper — observed for WIDTH changes to re-park the crosshair (Bug B). const wrapperRef = useRef(null); const { resolvedTheme } = useTheme(); useEffect(() => setMounted(true), []); + // Fire the intro sweep the first time the chart scrolls into view on the + // detail page ("เลื่อนลงมาเห็นกราฟแบบเต็ม"). IntersectionObserver so the + // animation starts when the user actually sees it, not on mount (the chart is + // below the hero fold). One-shot via hasSwept. Browser-only; deps re-attach + // once the chart wrapper exists (after loading/error/data resolve). + useEffect(() => { + const el = wrapperRef.current; + if (!el || typeof IntersectionObserver === 'undefined') return; + if (hasSwept.current) return; + const io = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting && !hasSwept.current) { + hasSwept.current = true; + setSweepKey((k) => k + 1); + io.disconnect(); + } + }, + { threshold: 0.35 }, // most of the chart visible before it draws in + ); + io.observe(el); + return () => io.disconnect(); + }, [loading, error, data]); + + // Re-run the sweep on every period change (1D-5Y). The period state drives + // chartData, so a new window's line draws in left→right too. Skipped on the + // very first render (the scroll-into-view observer owns the first sweep). + const firstPeriodRender = useRef(true); + useEffect(() => { + if (firstPeriodRender.current) { + firstPeriodRender.current = false; + return; + } + setSweepKey((k) => k + 1); + }, [period]); + // Crosshair re-park on layout change (rotation / window resize / sidebar // expand-collapse) is handled by the ResizeObserver effect just below the // chartData memo — a width-driven detector that subsumes the old @@ -236,7 +280,6 @@ export function PriceHistoryChart({ if (!mon || Number.isNaN(day)) return raw; return `${mon} ${day}, ${year}`; }; - const fmtTooltip = (v: number) => `$${v.toFixed(2)}`; const fmtPrice = (v: number) => `$${v.toFixed(2)}`; // PR 4f post-spot-check: compute a y-axis domain anchored on the @@ -323,27 +366,12 @@ export function PriceHistoryChart({ const trendStroke = isPositive ? '#10b981' : '#e11d48'; // emerald-500 / rose-600 const trendFillId = `priceFill-${ticker}-${isPositive ? 'up' : 'down'}`; - // Dark-mode-aware tooltip surface. The pre-mount default is light - // to match the `color-scheme: light` initial value in globals.css - // (avoids hydration flicker). Without these explicit colors the - // Recharts default tooltip stays white-bg in dark mode AND the date - // label inherits the body's `rgb(226 232 240)` cascade → unreadable - // light-text-on-white. Shadow per LedgerCraft Elevation spec - // (overlays + dropdowns are the only surfaces that get a shadow). + // Dark-mode flag — drives the crosshair cursor + active-dot colors (the + // price tooltip box was removed per user request; only the crosshair line + + // point remain). The pre-mount default is light to match the + // `color-scheme: light` initial value in globals.css (avoids hydration + // flicker). const isDark = mounted && resolvedTheme === 'dark'; - const tooltipContentStyle = { - fontSize: '0.75rem', - borderRadius: '0.25rem', - border: isDark ? '1px solid #334155' : '1px solid #e2e8f0', - backgroundColor: isDark ? '#0f172a' : '#ffffff', - boxShadow: - '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)', - }; - const tooltipLabelStyle = { - color: isDark ? '#f1f5f9' : '#0f172a', - fontWeight: 600, - marginBottom: '2px', - }; return (
@@ -527,6 +555,12 @@ export function PriceHistoryChart({ if (e.pointerType !== 'touch') setRestKey((k) => k + 1); }} > + {/* Intro-sweep wrapper — keyed by sweepKey so a bump remounts it and + re-adds the `chart-sweep` class, replaying the left→right clip-path + reveal. h-full so the clip covers the whole chart canvas. The + ResponsiveContainer + AreaChart live INSIDE so the line, fill, and + parked crosshair are all revealed by the one clip together. */} +
+ {/* Tooltip kept ONLY for its crosshair `cursor` (the vertical line + + the active-point dot Recharts draws at the hovered/parked index). + The price BOX is removed per user request — `content={() => null}` + renders no popup, so the chart shows just the crosshair line + tracking the finger/pointer with no obscuring panel. The dot at + the active point is the Area's activeDot, set below. */} [fmtTooltip(v), 'Close']} - labelFormatter={formatTooltipLabel} - contentStyle={tooltipContentStyle} - labelStyle={tooltipLabelStyle} + content={() => null} + cursor={{ + stroke: isDark ? '#64748b' : '#94a3b8', + strokeWidth: 1, + strokeDasharray: '3 3', + }} defaultIndex={chartData.length - 1} isAnimationActive={false} /> @@ -561,6 +603,12 @@ export function PriceHistoryChart({ strokeWidth={2} fill={`url(#${trendFillId})`} dot={false} + activeDot={{ + r: 4, + fill: trendStroke, + stroke: isDark ? '#0f172a' : '#ffffff', + strokeWidth: 2, + }} isAnimationActive={false} /> {fairInRange && ( @@ -582,6 +630,7 @@ export function PriceHistoryChart({ )} +
); From 72c7dfde812ae0cfcfdb9bc37c4e518e90f2d931 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 12:50:36 +0000 Subject: [PATCH 02/15] fix(frontend): chart-sweep clip ends at -8px right so the flush-right crosshair dot isn't half-clipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/app/globals.css | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 90bc0b2d7..c5c627f3f 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -420,15 +420,21 @@ code { * justified because it IS the page's primary data visual. */ @keyframes chart-sweep { from { - clip-path: inset(0 100% 0 0); - -webkit-clip-path: inset(0 100% 0 0); + clip-path: inset(0 100% 0 -8px); + -webkit-clip-path: inset(0 100% 0 -8px); } to { - clip-path: inset(0 0 0 0); - -webkit-clip-path: inset(0 0 0 0); + clip-path: inset(0 -8px 0 -8px); + -webkit-clip-path: inset(0 -8px 0 -8px); } } .chart-sweep { + /* The RIGHT inset ends at -8px (not 0) so the flush-right parked crosshair + * dot — which half-overflows the surface via `[&_.recharts-surface]: + * overflow-visible` (PR #322) — is NOT re-clipped at the box edge when the + * sweep finishes. A plain `inset(0 0 0 0)` clip cuts that dot in half (clip + * respects the box edge, ignoring overflow-visible). The -8px left inset + * keeps the reveal symmetric so the leftmost point isn't clipped either. */ animation: chart-sweep 1100ms cubic-bezier(0.22, 1, 0.36, 1) both; } From 4cdd9902dafb251986cae6bf911bc9245b121464 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 13:01:37 +0000 Subject: [PATCH 03/15] fix(frontend): gate chart-sweep to a single play (no double-sweep on above-fold) + motion-rule notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/app/globals.css | 13 +++++++++---- frontend/components/PriceHistoryChart.tsx | 20 ++++++++++++++++++-- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/frontend/app/globals.css b/frontend/app/globals.css index c5c627f3f..d51db07c6 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -414,10 +414,15 @@ code { * so they appear to "draw in" together rather than animating separately. * Plays on (a) the chart first scrolling into view on the detail page and * (b) every 1D-5Y period change, by re-adding the class via a key remount of - * the wrapper. transform/clip + opacity only (Motion Rule 1); the chart - * renders at its FINAL state with no class (SSR / no-JS / reduced-motion all - * show the full chart immediately). 1100ms — the longest beat after the gauge, - * justified because it IS the page's primary data visual. */ + * the wrapper. Motion Rule 1 says "transform + opacity only" — clip-path is a + * documented EXTENSION (same posture as the gauge's stroke-dashoffset, which is + * also neither transform nor opacity): clip-path:inset() causes NO layout + * reflow and is GPU-compositable on Chrome 90+/Safari 16+, so it honors the + * rule's intent (no reflow, compositor-friendly) the way the literal + * transform/opacity pair does. The chart renders at its FINAL state with no + * class (SSR / no-JS / reduced-motion all show the full chart immediately). + * 1100ms — the longest beat after the gauge, justified because it IS the page's + * primary data visual. */ @keyframes chart-sweep { from { clip-path: inset(0 100% 0 -8px); diff --git a/frontend/components/PriceHistoryChart.tsx b/frontend/components/PriceHistoryChart.tsx index 98349c6da..31ff36427 100644 --- a/frontend/components/PriceHistoryChart.tsx +++ b/frontend/components/PriceHistoryChart.tsx @@ -107,6 +107,11 @@ export function PriceHistoryChart({ // Re-run the sweep on every period change (1D-5Y). The period state drives // chartData, so a new window's line draws in left→right too. Skipped on the // very first render (the scroll-into-view observer owns the first sweep). + // Motion Rule 2 ("no re-firing on in-page interaction") gray zone: a period + // switch REPLACES the entire data series (a new line arriving), not a + // re-stagger of existing content — the case Rule 2 actually targets — so + // re-sweeping the new series reads as "new data drawing in", which is the + // intended affordance (user-requested 2026-05-30). const firstPeriodRender = useRef(true); useEffect(() => { if (firstPeriodRender.current) { @@ -559,8 +564,19 @@ export function PriceHistoryChart({ re-adds the `chart-sweep` class, replaying the left→right clip-path reveal. h-full so the clip covers the whole chart canvas. The ResponsiveContainer + AreaChart live INSIDE so the line, fill, and - parked crosshair are all revealed by the one clip together. */} -
+ parked crosshair are all revealed by the one clip together. + The class is GATED on sweepKey > 0: the initial key=0 mount renders + WITHOUT the class (no animation) so the sweep plays exactly ONCE, + driven by the IntersectionObserver when the chart scrolls into view + (sweepKey 0→1) — never a double-play from the static class animating + on mount AND the observer firing (the gauge `usePlayOnMount` gate, + adapted: here the IO is the play trigger, so sweepKey itself is the + client-side gate). Period changes bump it further (1→2→…), each a + fresh sweep. Reduced-motion still no-ops via the globals.css guard. */} +
0 ? ' chart-sweep' : ''}`} + > Date: Sat, 30 May 2026 13:47:49 +0000 Subject: [PATCH 04/15] =?UTF-8?q?fix(frontend):=20crosshair=20rides=20line?= =?UTF-8?q?=20L=E2=86=92R=20+=20bolder=20color=20+=20X-axis=20stays=20put?= =?UTF-8?q?=20(replace=20clip-sweep)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/app/globals.css | 58 ++---- frontend/components/PriceHistoryChart.tsx | 221 ++++++++++++++++++---- 2 files changed, 194 insertions(+), 85 deletions(-) diff --git a/frontend/app/globals.css b/frontend/app/globals.css index d51db07c6..b43891803 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -405,43 +405,17 @@ code { animation: gauge-sweep 800ms cubic-bezier(0.22, 1, 0.36, 1) both; } -/* Price-chart intro sweep — the line + area + crosshair are revealed - * left→right by animating a clip-path inset from "fully clipped on the right" - * (inset 0 100% 0 0 → nothing visible) to "fully revealed" (inset 0 0 0 0). - * The ease-out curve (fast→slow, decelerating into the latest point on the - * right) matches the gauge-sweep family. One clip wrapper reveals EVERYTHING - * inside the SVG at once — line, gradient fill, and the parked crosshair — - * so they appear to "draw in" together rather than animating separately. - * Plays on (a) the chart first scrolling into view on the detail page and - * (b) every 1D-5Y period change, by re-adding the class via a key remount of - * the wrapper. Motion Rule 1 says "transform + opacity only" — clip-path is a - * documented EXTENSION (same posture as the gauge's stroke-dashoffset, which is - * also neither transform nor opacity): clip-path:inset() causes NO layout - * reflow and is GPU-compositable on Chrome 90+/Safari 16+, so it honors the - * rule's intent (no reflow, compositor-friendly) the way the literal - * transform/opacity pair does. The chart renders at its FINAL state with no - * class (SSR / no-JS / reduced-motion all show the full chart immediately). - * 1100ms — the longest beat after the gauge, justified because it IS the page's - * primary data visual. */ -@keyframes chart-sweep { - from { - clip-path: inset(0 100% 0 -8px); - -webkit-clip-path: inset(0 100% 0 -8px); - } - to { - clip-path: inset(0 -8px 0 -8px); - -webkit-clip-path: inset(0 -8px 0 -8px); - } -} -.chart-sweep { - /* The RIGHT inset ends at -8px (not 0) so the flush-right parked crosshair - * dot — which half-overflows the surface via `[&_.recharts-surface]: - * overflow-visible` (PR #322) — is NOT re-clipped at the box edge when the - * sweep finishes. A plain `inset(0 0 0 0)` clip cuts that dot in half (clip - * respects the box edge, ignoring overflow-visible). The -8px left inset - * keeps the reveal symmetric so the leftmost point isn't clipped either. */ - animation: chart-sweep 1100ms cubic-bezier(0.22, 1, 0.36, 1) both; -} +/* Price-chart intro draw — the line + fill are revealed left→right by + * Recharts' OWN area-draw animation (the component sets `isAnimationActive` + + * ease-out on a sweep remount), and a self-drawn crosshair overlay rides the + * curve left→right in lockstep (rAF in PriceHistoryChart.tsx). This replaced an + * earlier clip-path-on-the-wrapper approach, which also revealed the X-axis + + * date labels — the user wanted those to stay PUT. Recharts animates only the + * `.recharts-area` layer (a sibling of `.recharts-xAxis`), so the axis no + * longer moves. No CSS keyframe is needed here anymore; the animation is + * JS-driven and reduced-motion is handled in the component (skip the rAF + pass + * isAnimationActive=false), so SSR / no-JS / reduced-motion show the full chart + * immediately. */ /* Hover lift — table rows / cards rise a hair on hover. Kept tiny * (1px) + fast so it reads as "alive" not "bouncy"; pairs with the @@ -466,17 +440,9 @@ code { .animate-rise-in, .animate-chip-pop, .animate-flag-pulse, - .gauge-sweep, - .chart-sweep { + .gauge-sweep { animation: none !important; } - /* The sweep clips the chart while running; reduced-motion skips the - * animation, so force the clip OFF too or the chart would stay hidden - * at the keyframe's `from` (inset 0 100% — fully clipped). */ - .chart-sweep { - clip-path: none !important; - -webkit-clip-path: none !important; - } .hover-lift { transition: none !important; } diff --git a/frontend/components/PriceHistoryChart.tsx b/frontend/components/PriceHistoryChart.tsx index 31ff36427..1d97f2841 100644 --- a/frontend/components/PriceHistoryChart.tsx +++ b/frontend/components/PriceHistoryChart.tsx @@ -66,15 +66,26 @@ export function PriceHistoryChart({ // remount is how we re-assert it on those events. const [restKey, setRestKey] = useState(0); const [layoutKey, setLayoutKey] = useState(0); - // `sweepKey` keys the clip-path intro-sweep wrapper: bumping it remounts the - // wrapper, which re-adds the `chart-sweep` class → the line + crosshair - // re-reveal left→right (ease-out, fast→slow into the latest point). Bumped on - // (a) the chart first scrolling into view (IntersectionObserver below) and - // (b) every period change (the period effect below). + // `sweepKey` keys the chart on an intro replay (remounts so + // Recharts re-runs its left→right area-draw animation); `playDraw` is the + // gate that says "this remount should ANIMATE" (true only for a sweep — IO + // scroll-in or period change — NOT for the rest/resize re-park remounts, which + // must stay instant). Bumped together by the sweep triggers below. const [sweepKey, setSweepKey] = useState(0); + const [playDraw, setPlayDraw] = useState(false); // Gate so the scroll-into-view sweep fires only the FIRST time the chart // enters the viewport (not on every scroll up/down past it). const hasSwept = useRef(false); + // Self-drawn intro crosshair overlay (a vertical line + a dot that RIDES the + // price curve), animated left→right by rAF in sync with the area draw. During + // the sweep the Recharts cursor + activeDot are suppressed (playDraw) so only + // this animated crosshair shows; at the end it fades and Recharts' parked + // cursor/dot take over at the latest point. Refs let the rAF mutate the SVG + // attributes directly (no per-frame React re-render). + const overlaySvgRef = useRef(null); + const overlayLineRef = useRef(null); + const overlayDotRef = useRef(null); + const drawRafRef = useRef(null); // Chart wrapper — observed for WIDTH changes to re-park the crosshair (Bug B). const wrapperRef = useRef(null); const { resolvedTheme } = useTheme(); @@ -94,6 +105,7 @@ export function PriceHistoryChart({ (entries) => { if (entries[0]?.isIntersecting && !hasSwept.current) { hasSwept.current = true; + setPlayDraw(true); setSweepKey((k) => k + 1); io.disconnect(); } @@ -118,9 +130,95 @@ export function PriceHistoryChart({ firstPeriodRender.current = false; return; } + setPlayDraw(true); setSweepKey((k) => k + 1); }, [period]); + // Drive the intro crosshair left→right in sync with the area-draw animation. + // Runs on each sweep (sweepKey bump while playDraw is true). Each frame: + // 1. ease-out progress p (fast→slow) over DRAW_MS + // 2. target x = p · surfaceWidth + // 3. find the price-curve point at that x via getPointAtLength binary + // search → the dot RIDES the line; the vertical line spans full height + // 4. write x/y straight onto the SVG line+dot refs (no React re-render) + // When p reaches 1 the overlay fades and playDraw flips false, handing the + // rest-state crosshair back to Recharts' parked cursor + activeDot at the + // latest point. Reduced-motion: skip the rAF, leave playDraw false so the + // Recharts cursor shows immediately (the area also renders un-animated). + const DRAW_MS = 1100; + useEffect(() => { + if (!playDraw) return; + if (typeof window === 'undefined') return; + const reduce = + window.matchMedia && + window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (reduce) { + setPlayDraw(false); + return; + } + const wrap = wrapperRef.current; + const svg = overlaySvgRef.current; + const line = overlayLineRef.current; + const dot = overlayDotRef.current; + if (!wrap || !svg || !line || !dot) { + setPlayDraw(false); + return; + } + // Recharts renders async after the remount; poll briefly for the curve. + let t0 = 0; + let startedAt = performance.now(); + const easeOut = (t: number) => 1 - Math.pow(1 - t, 3); // fast→slow + const tick = (now: number) => { + const curve = wrap.querySelector( + '.recharts-area-curve', + ) as SVGPathElement | null; + const surf = wrap.querySelector('.recharts-surface') as SVGSVGElement | null; + if (!curve || !surf) { + // curve not painted yet — wait up to ~400ms, then bail to Recharts + if (now - startedAt > 400) { + setPlayDraw(false); + return; + } + drawRafRef.current = requestAnimationFrame(tick); + return; + } + if (t0 === 0) t0 = now; // start the clock when the curve first exists + const w = surf.getBoundingClientRect().width || 1; + const h = surf.getBoundingClientRect().height || 1; + const p = Math.min(1, (now - t0) / DRAW_MS); + const x = easeOut(p) * w; + // binary-search the curve length whose point.x ≈ target x + const L = curve.getTotalLength(); + let lo = 0; + let hi = L; + for (let i = 0; i < 18; i += 1) { + const mid = (lo + hi) / 2; + if (curve.getPointAtLength(mid).x < x) lo = mid; + else hi = mid; + } + const pt = curve.getPointAtLength((lo + hi) / 2); + line.setAttribute('x1', String(x)); + line.setAttribute('x2', String(x)); + line.setAttribute('y1', '0'); + line.setAttribute('y2', String(h)); + dot.setAttribute('cx', String(x)); + dot.setAttribute('cy', String(pt.y)); + svg.style.opacity = '1'; + if (p < 1) { + drawRafRef.current = requestAnimationFrame(tick); + } else { + // hand off to Recharts' parked cursor/dot, then hide the overlay + svg.style.opacity = '0'; + setPlayDraw(false); + } + }; + drawRafRef.current = requestAnimationFrame(tick); + return () => { + if (drawRafRef.current !== null) cancelAnimationFrame(drawRafRef.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sweepKey, playDraw]); + // Crosshair re-park on layout change (rotation / window resize / sidebar // expand-collapse) is handled by the ResizeObserver effect just below the // chartData memo — a width-driven detector that subsumes the old @@ -522,7 +620,7 @@ export function PriceHistoryChart({ layout viewport from ever growing. */}
{ // A tap WITHOUT a drag must move the crosshair to the tap point. @@ -560,26 +658,16 @@ export function PriceHistoryChart({ if (e.pointerType !== 'touch') setRestKey((k) => k + 1); }} > - {/* Intro-sweep wrapper — keyed by sweepKey so a bump remounts it and - re-adds the `chart-sweep` class, replaying the left→right clip-path - reveal. h-full so the clip covers the whole chart canvas. The - ResponsiveContainer + AreaChart live INSIDE so the line, fill, and - parked crosshair are all revealed by the one clip together. - The class is GATED on sweepKey > 0: the initial key=0 mount renders - WITHOUT the class (no animation) so the sweep plays exactly ONCE, - driven by the IntersectionObserver when the chart scrolls into view - (sweepKey 0→1) — never a double-play from the static class animating - on mount AND the observer firing (the gauge `usePlayOnMount` gate, - adapted: here the IO is the play trigger, so sweepKey itself is the - client-side gate). Period changes bump it further (1→2→…), each a - fresh sweep. Reduced-motion still no-ops via the globals.css guard. */} -
0 ? ' chart-sweep' : ''}`} - > + {/* The intro is now driven by (a) Recharts' OWN left→right area-draw + animation (animated only on a sweep remount via `playDraw`), which + reveals the line + fill WITHOUT touching the X-axis or its date + labels (the axis is a sibling layer, so it stays put — user request), + and (b) a self-drawn crosshair overlay (below) that rides the curve + left→right in lockstep. `sweepKey` remounts the chart to replay the + draw; the remount is keyed together with restKey/layoutKey. */} @@ -600,15 +688,24 @@ export function PriceHistoryChart({ the active-point dot Recharts draws at the hovered/parked index). The price BOX is removed per user request — `content={() => null}` renders no popup, so the chart shows just the crosshair line - tracking the finger/pointer with no obscuring panel. The dot at - the active point is the Area's activeDot, set below. */} + tracking the finger/pointer with no obscuring panel. The cursor + is suppressed (transparent) WHILE the intro draw plays so only + the self-drawn animated crosshair shows; once the draw ends + (playDraw → false) this parked cursor takes over at the latest + point. Bolder color than before (slate-600/300 + 1.5px solid) so + it reads clearly against both the line and the slate page bg + (user: "crosshair กลืนกับกราฟและพื้นหลัง"). */} null} - cursor={{ - stroke: isDark ? '#64748b' : '#94a3b8', - strokeWidth: 1, - strokeDasharray: '3 3', - }} + cursor={ + playDraw + ? false + : { + stroke: isDark ? '#cbd5e1' : '#475569', + strokeWidth: 1.5, + strokeDasharray: '4 3', + } + } defaultIndex={chartData.length - 1} isAnimationActive={false} /> @@ -619,13 +716,22 @@ export function PriceHistoryChart({ strokeWidth={2} fill={`url(#${trendFillId})`} dot={false} - activeDot={{ - r: 4, - fill: trendStroke, - stroke: isDark ? '#0f172a' : '#ffffff', - strokeWidth: 2, - }} - isAnimationActive={false} + activeDot={ + playDraw + ? false + : { + r: 4, + fill: trendStroke, + stroke: isDark ? '#0f172a' : '#ffffff', + strokeWidth: 2, + } + } + // Animate the left→right area draw ONLY on a sweep remount + // (playDraw). The rest/resize re-park remounts keep it instant so + // the crosshair re-park doesn't visibly redraw the whole line. + isAnimationActive={playDraw} + animationDuration={DRAW_MS} + animationEasing="ease-out" /> {fairInRange && ( -
+ + {/* Self-drawn intro crosshair overlay — absolutely positioned ON TOP of + the chart, spanning the same box. The rAF effect above animates the + line (full-height vertical) + the dot (riding the price curve) from + x=0 → right edge in ease-out lockstep with the area draw, then sets + opacity 0 and hands the rest-state crosshair back to Recharts. It is + opacity 0 + pointer-events-none at rest so it never blocks scrubbing. + Colors match the bolder Recharts cursor (slate-600/300) so the + animated and parked crosshairs look identical. preserveAspectRatio + none + a viewBox synced to the surface px size would over-engineer + this — instead the rAF writes RAW px coords (the SVG has no viewBox, + so user units == px), matching the surface 1:1 since margins are 0. */} +
); From 5aef58db48ad7a3f855b924393a1f383b3b97722 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 13:55:53 +0000 Subject: [PATCH 05/15] fix(frontend): null drawRafRef after draw completes (frontend-design-reviewer WARN) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/components/PriceHistoryChart.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/components/PriceHistoryChart.tsx b/frontend/components/PriceHistoryChart.tsx index 1d97f2841..890dd7d72 100644 --- a/frontend/components/PriceHistoryChart.tsx +++ b/frontend/components/PriceHistoryChart.tsx @@ -209,6 +209,7 @@ export function PriceHistoryChart({ } else { // hand off to Recharts' parked cursor/dot, then hide the overlay svg.style.opacity = '0'; + drawRafRef.current = null; // no pending frame — clear the stale id setPlayDraw(false); } }; From 8a4667a660bf474b6f03de81df8d3bf97ee64fa1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 14:50:00 +0000 Subject: [PATCH 06/15] =?UTF-8?q?fix(frontend):=20unify=20chart=20intro=20?= =?UTF-8?q?into=20ONE=20rAF=20=E2=80=94=20sync=20line+crosshair,=20kill=20?= =?UTF-8?q?stutter,=20robust=20scroll-in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/components/PriceHistoryChart.tsx | 195 +++++++++++++++------- 1 file changed, 133 insertions(+), 62 deletions(-) diff --git a/frontend/components/PriceHistoryChart.tsx b/frontend/components/PriceHistoryChart.tsx index 890dd7d72..57f880f56 100644 --- a/frontend/components/PriceHistoryChart.tsx +++ b/frontend/components/PriceHistoryChart.tsx @@ -92,28 +92,52 @@ export function PriceHistoryChart({ useEffect(() => setMounted(true), []); - // Fire the intro sweep the first time the chart scrolls into view on the - // detail page ("เลื่อนลงมาเห็นกราฟแบบเต็ม"). IntersectionObserver so the - // animation starts when the user actually sees it, not on mount (the chart is - // below the hero fold). One-shot via hasSwept. Browser-only; deps re-attach - // once the chart wrapper exists (after loading/error/data resolve). + // Fire the intro draw the first time the chart is visible on the detail page + // ("เลื่อนลงมาเห็นกราฟแบบเต็ม"). IntersectionObserver so it starts when the + // user sees it. One-shot via hasSwept. Two robustness fixes after the + // "เล่นเฉพาะตอนเปลี่ยน period" report: (1) a LOW threshold (0.1) + rootMargin + // so the draw fires as soon as a sliver enters — a tall-screen layout where + // the chart is already partly visible at mount, OR a slow scrub past it, + // would miss a 0.35 gate; (2) a deferred re-check (rAF) so if the chart is + // ALREADY in view at mount (short hero / large viewport), the draw still + // fires (IO does emit an initial callback for an already-intersecting target, + // but we also guard with an explicit getBoundingClientRect check in case the + // very first callback raced the layout). Browser-only; deps re-attach once + // the wrapper exists (after loading/error/data resolve). useEffect(() => { const el = wrapperRef.current; if (!el || typeof IntersectionObserver === 'undefined') return; if (hasSwept.current) return; + const fire = () => { + if (hasSwept.current) return; + hasSwept.current = true; + setPlayDraw(true); + setSweepKey((k) => k + 1); + }; const io = new IntersectionObserver( (entries) => { - if (entries[0]?.isIntersecting && !hasSwept.current) { - hasSwept.current = true; - setPlayDraw(true); - setSweepKey((k) => k + 1); + if (entries[0]?.isIntersecting) { + fire(); io.disconnect(); } }, - { threshold: 0.35 }, // most of the chart visible before it draws in + { threshold: 0.1, rootMargin: '0px 0px -10% 0px' }, ); io.observe(el); - return () => io.disconnect(); + // Fallback: if the chart already occupies the viewport at mount (the IO + // initial callback can race the first paint), fire on the next frame. + const raf = requestAnimationFrame(() => { + const r = el.getBoundingClientRect(); + const vh = window.innerHeight || 0; + if (r.top < vh * 0.9 && r.bottom > 0) { + fire(); + io.disconnect(); + } + }); + return () => { + io.disconnect(); + cancelAnimationFrame(raf); + }; }, [loading, error, data]); // Re-run the sweep on every period change (1D-5Y). The period state drives @@ -134,88 +158,135 @@ export function PriceHistoryChart({ setSweepKey((k) => k + 1); }, [period]); - // Drive the intro crosshair left→right in sync with the area-draw animation. - // Runs on each sweep (sweepKey bump while playDraw is true). Each frame: - // 1. ease-out progress p (fast→slow) over DRAW_MS - // 2. target x = p · surfaceWidth - // 3. find the price-curve point at that x via getPointAtLength binary - // search → the dot RIDES the line; the vertical line spans full height - // 4. write x/y straight onto the SVG line+dot refs (no React re-render) - // When p reaches 1 the overlay fades and playDraw flips false, handing the - // rest-state crosshair back to Recharts' parked cursor + activeDot at the - // latest point. Reduced-motion: skip the rAF, leave playDraw false so the - // Recharts cursor shows immediately (the area also renders un-animated). - const DRAW_MS = 1100; + // Drive the WHOLE intro from ONE rAF so the line reveal and the crosshair + // share a single eased progress → they reach the right edge at the exact same + // frame (the prior version let Recharts animate the area on its own timeline + // while a separate rAF moved the crosshair, so they desynced — "ถึงขวาสุดไม่ + // พร้อมกัน" — and Recharts' per-frame React re-render made it stutter). Here + // Recharts' own area animation is OFF (static path), and each frame we: + // 1. ease progress p (fast→slow), x = ease(p)·width + // 2. reveal the price line+fill by clipping ONLY `.recharts-area` to [0,x] + // (a sibling of `.recharts-xAxis`, so the axis + date labels stay PUT) + // 3. place the crosshair line + dot at x; the dot y comes from a ONE-TIME + // precomputed x→y table off the (now static) curve, so per-frame cost is + // an array lookup, not a path walk → smooth + // At p=1 the area clip is cleared (full line, flush-right dot via overflow- + // visible) and the overlay fades, handing the rest crosshair to Recharts. + const DRAW_MS = 850; useEffect(() => { if (!playDraw) return; - if (typeof window === 'undefined') return; - const reduce = - window.matchMedia && - window.matchMedia('(prefers-reduced-motion: reduce)').matches; - if (reduce) { + if (typeof window === 'undefined') { setPlayDraw(false); return; } + const reduce = + window.matchMedia && + window.matchMedia('(prefers-reduced-motion: reduce)').matches; const wrap = wrapperRef.current; const svg = overlaySvgRef.current; const line = overlayLineRef.current; const dot = overlayDotRef.current; - if (!wrap || !svg || !line || !dot) { + if (reduce || !wrap || !svg || !line || !dot) { setPlayDraw(false); return; } - // Recharts renders async after the remount; poll briefly for the curve. - let t0 = 0; - let startedAt = performance.now(); + + const clearAreaClip = () => { + const a = wrap.querySelector('.recharts-area') as SVGGElement | null; + if (a) a.style.clipPath = ''; + }; + const easeOut = (t: number) => 1 - Math.pow(1 - t, 3); // fast→slow - const tick = (now: number) => { + const N = 120; // x→y samples across the curve (precomputed once) + let ys: number[] = []; + let area: SVGGElement | null = null; + let w = 1; + let h = 1; + let t0 = 0; + const startedAt = performance.now(); + + // One-time prep: measure + sample the STATIC curve. Returns false until + // Recharts has painted the path (we poll a few frames for it). + const prep = (): boolean => { const curve = wrap.querySelector( '.recharts-area-curve', ) as SVGPathElement | null; - const surf = wrap.querySelector('.recharts-surface') as SVGSVGElement | null; - if (!curve || !surf) { - // curve not painted yet — wait up to ~400ms, then bail to Recharts - if (now - startedAt > 400) { + area = wrap.querySelector('.recharts-area') as SVGGElement | null; + const surf = wrap.querySelector( + '.recharts-surface', + ) as SVGSVGElement | null; + if (!curve || !area || !surf) return false; + const rect = surf.getBoundingClientRect(); + w = rect.width || 1; + h = rect.height || 1; + const L = curve.getTotalLength(); + if (L === 0) return false; + ys = new Array(N + 1); + for (let i = 0; i <= N; i += 1) { + const tx = (i / N) * w; + let lo = 0; + let hi = L; + for (let k = 0; k < 16; k += 1) { + const mid = (lo + hi) / 2; + if (curve.getPointAtLength(mid).x < tx) lo = mid; + else hi = mid; + } + ys[i] = curve.getPointAtLength((lo + hi) / 2).y; + } + // hide the line/fill immediately so it reveals from x=0 (no full-flash). + // -20px vertical insets keep the stroke top/bottom from being clipped. + area.style.clipPath = 'inset(-20px 100% -20px 0)'; + return true; + }; + + const tick = (now: number) => { + if (t0 === 0) { + if (now - startedAt > 500) { + // curve never painted — bail to the static chart + clearAreaClip(); setPlayDraw(false); return; } - drawRafRef.current = requestAnimationFrame(tick); - return; + if (!prep()) { + drawRafRef.current = requestAnimationFrame(tick); + return; + } + t0 = now; } - if (t0 === 0) t0 = now; // start the clock when the curve first exists - const w = surf.getBoundingClientRect().width || 1; - const h = surf.getBoundingClientRect().height || 1; const p = Math.min(1, (now - t0) / DRAW_MS); - const x = easeOut(p) * w; - // binary-search the curve length whose point.x ≈ target x - const L = curve.getTotalLength(); - let lo = 0; - let hi = L; - for (let i = 0; i < 18; i += 1) { - const mid = (lo + hi) / 2; - if (curve.getPointAtLength(mid).x < x) lo = mid; - else hi = mid; - } - const pt = curve.getPointAtLength((lo + hi) / 2); + const e = easeOut(p); + const x = e * w; + // reveal the line+fill up to x (clip the part to the RIGHT of x) + if (area) area.style.clipPath = `inset(-20px ${Math.max(0, w - x)}px -20px 0)`; + // crosshair dot y — interpolate the precomputed table at x + const fi = e * N; + const i0 = Math.min(N - 1, Math.floor(fi)); + const frac = fi - i0; + const y = ys[i0] + (ys[i0 + 1] - ys[i0]) * frac; line.setAttribute('x1', String(x)); line.setAttribute('x2', String(x)); line.setAttribute('y1', '0'); line.setAttribute('y2', String(h)); dot.setAttribute('cx', String(x)); - dot.setAttribute('cy', String(pt.y)); + dot.setAttribute('cy', String(y)); svg.style.opacity = '1'; if (p < 1) { drawRafRef.current = requestAnimationFrame(tick); } else { - // hand off to Recharts' parked cursor/dot, then hide the overlay + // full reveal: drop the clip (overflow-visible flush-right dot shows), + // hide the overlay, hand the rest crosshair to Recharts' parked cursor. + clearAreaClip(); svg.style.opacity = '0'; - drawRafRef.current = null; // no pending frame — clear the stale id + drawRafRef.current = null; setPlayDraw(false); } }; drawRafRef.current = requestAnimationFrame(tick); return () => { if (drawRafRef.current !== null) cancelAnimationFrame(drawRafRef.current); + // if interrupted mid-draw (e.g. a period switch), never leave the line + // stuck hidden — clear the clip on the (old) area. + clearAreaClip(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [sweepKey, playDraw]); @@ -727,12 +798,12 @@ export function PriceHistoryChart({ strokeWidth: 2, } } - // Animate the left→right area draw ONLY on a sweep remount - // (playDraw). The rest/resize re-park remounts keep it instant so - // the crosshair re-park doesn't visibly redraw the whole line. - isAnimationActive={playDraw} - animationDuration={DRAW_MS} - animationEasing="ease-out" + // Recharts' own draw animation is OFF — the unified intro rAF + // (above) reveals the line by clipping `.recharts-area`, in + // lockstep with the crosshair, so the two never desync. Keeping + // Recharts static also avoids its per-frame React re-render + // (the stutter source). + isAnimationActive={false} /> {fairInRange && ( Date: Sat, 30 May 2026 15:17:35 +0000 Subject: [PATCH 07/15] =?UTF-8?q?perf(frontend):=20kill=20chart=20freeze?= =?UTF-8?q?=20=E2=80=94=20drop=20getPointAtLength=20(O(path)=C2=B72057/swe?= =?UTF-8?q?ep)=20+=20downsample=205Y=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/components/PriceHistoryChart.tsx | 126 ++++++++++++++++------ 1 file changed, 93 insertions(+), 33 deletions(-) diff --git a/frontend/components/PriceHistoryChart.tsx b/frontend/components/PriceHistoryChart.tsx index 57f880f56..8d69f9710 100644 --- a/frontend/components/PriceHistoryChart.tsx +++ b/frontend/components/PriceHistoryChart.tsx @@ -167,12 +167,20 @@ export function PriceHistoryChart({ // 1. ease progress p (fast→slow), x = ease(p)·width // 2. reveal the price line+fill by clipping ONLY `.recharts-area` to [0,x] // (a sibling of `.recharts-xAxis`, so the axis + date labels stay PUT) - // 3. place the crosshair line + dot at x; the dot y comes from a ONE-TIME - // precomputed x→y table off the (now static) curve, so per-frame cost is - // an array lookup, not a path walk → smooth + // 3. place the crosshair line + dot at x; the dot y is computed by PURE MATH + // from chartData close values + the y-domain (NOT getPointAtLength — see + // the perf note below), so per-frame cost is O(1) → smooth even on a 5Y + // / 1260-point series on a throttled phone. // At p=1 the area clip is cleared (full line, flush-right dot via overflow- // visible) and the overlay fades, handing the rest crosshair to Recharts. - const DRAW_MS = 850; + // + // PERF (2026-05-30 "app ค้างหนัก" fix): the previous version sampled the dot y + // by walking the SVG path with getPointAtLength (121 samples × 16 binary-search + // iters ≈ 2057 calls per sweep). getPointAtLength is O(path length) PER CALL — + // on a 5Y curve (56 KB `d` string) that was ~47 s of main-thread work under a + // 4-6× CPU throttle → the whole page froze. Computing y from the in-memory + // close[] array instead is O(1) per point: zero path walks, zero freeze. + const DRAW_MS = 650; useEffect(() => { if (!playDraw) return; if (typeof window === 'undefined') { @@ -186,7 +194,7 @@ export function PriceHistoryChart({ const svg = overlaySvgRef.current; const line = overlayLineRef.current; const dot = overlayDotRef.current; - if (reduce || !wrap || !svg || !line || !dot) { + if (reduce || !wrap || !svg || !line || !dot || chartData.length < 2) { setPlayDraw(false); return; } @@ -196,43 +204,65 @@ export function PriceHistoryChart({ if (a) a.style.clipPath = ''; }; + const yLo = (yDomain as [number, number])[0]; + const yHi = (yDomain as [number, number])[1]; + const ySpan = yHi - yLo || 1; const easeOut = (t: number) => 1 - Math.pow(1 - t, 3); // fast→slow - const N = 120; // x→y samples across the curve (precomputed once) - let ys: number[] = []; + const closes = chartData.map((d) => d.close); + const nSeg = closes.length - 1; // x maps [0,nSeg] across the plot width let area: SVGGElement | null = null; let w = 1; let h = 1; + // Real plot-area vertical extent (top y + height), read ONCE from the + // curve's bounding box — NOT assumed from margin. Recharts' plot area is + // inset from the surface by the X-axis height (~51 px) at the bottom + an + // auto top pad (~29 px), so a naive [marginTop, surfaceHeight] map put the + // crosshair dot ~27 px off the line. getBBox is O(1) (no path walk like + // getPointAtLength) so it's cheap to read once. closeToY maps a price + // through the SAME linear y-scale Recharts uses over [plotTop, plotTop+plotH]. + let plotTop = 0; + let plotH = 1; + const closeToY = (close: number) => + plotTop + (1 - (close - yLo) / ySpan) * plotH; let t0 = 0; const startedAt = performance.now(); - // One-time prep: measure + sample the STATIC curve. Returns false until - // Recharts has painted the path (we poll a few frames for it). + // One-time prep: grab the area node + surface size + the real plot extent + // from the curve bbox. Returns false until Recharts has painted the curve. const prep = (): boolean => { - const curve = wrap.querySelector( - '.recharts-area-curve', - ) as SVGPathElement | null; area = wrap.querySelector('.recharts-area') as SVGGElement | null; const surf = wrap.querySelector( '.recharts-surface', ) as SVGSVGElement | null; - if (!curve || !area || !surf) return false; + const curve = wrap.querySelector( + '.recharts-area-curve', + ) as SVGPathElement | null; + if (!area || !surf || !curve) return false; const rect = surf.getBoundingClientRect(); w = rect.width || 1; h = rect.height || 1; - const L = curve.getTotalLength(); - if (L === 0) return false; - ys = new Array(N + 1); - for (let i = 0; i <= N; i += 1) { - const tx = (i / N) * w; - let lo = 0; - let hi = L; - for (let k = 0; k < 16; k += 1) { - const mid = (lo + hi) / 2; - if (curve.getPointAtLength(mid).x < tx) lo = mid; - else hi = mid; - } - ys[i] = curve.getPointAtLength((lo + hi) / 2).y; + let bb: DOMRect; + try { + bb = curve.getBBox(); + } catch { + return false; // not laid out yet } + if (bb.height === 0 || bb.width === 0) return false; + // The curve bbox spans the visible price range, but the y-DOMAIN is padded + // ±10% beyond [min,max] (see yDomain), so the plot area is taller than the + // bbox. Recover the full plot extent: the bbox top/bottom correspond to the + // domain's data-max/data-min, which sit 10%/(120%) in from the padded + // domain edges. plotH = bbox.h / (dataSpan/paddedSpan). Simpler + robust: + // derive px-per-price from the bbox (price min→max over bbox.height) and + // extend to the padded domain. + const dataMax = Math.max(...closes); + const dataMin = Math.min(...closes); + const dataSpan = dataMax - dataMin || 1; + const pxPerPrice = bb.height / dataSpan; // px per $ on the real plot + plotH = ySpan * pxPerPrice; // full padded-domain height in px + // bbox.y is where dataMax sits; that price is (yHi - dataMax) below the + // padded-domain top → plotTop = bbox.y - (yHi - dataMax)*pxPerPrice. + plotTop = bb.y - (yHi - dataMax) * pxPerPrice; // hide the line/fill immediately so it reveals from x=0 (no full-flash). // -20px vertical insets keep the stroke top/bottom from being clipped. area.style.clipPath = 'inset(-20px 100% -20px 0)'; @@ -242,7 +272,7 @@ export function PriceHistoryChart({ const tick = (now: number) => { if (t0 === 0) { if (now - startedAt > 500) { - // curve never painted — bail to the static chart + // area never painted — bail to the static chart clearAreaClip(); setPlayDraw(false); return; @@ -258,11 +288,13 @@ export function PriceHistoryChart({ const x = e * w; // reveal the line+fill up to x (clip the part to the RIGHT of x) if (area) area.style.clipPath = `inset(-20px ${Math.max(0, w - x)}px -20px 0)`; - // crosshair dot y — interpolate the precomputed table at x - const fi = e * N; - const i0 = Math.min(N - 1, Math.floor(fi)); - const frac = fi - i0; - const y = ys[i0] + (ys[i0 + 1] - ys[i0]) * frac; + // crosshair dot y — interpolate the close[] array at the swept fraction, + // then map through the y-scale. plot area height = h - marginTop. + const fseg = e * nSeg; + const s0 = Math.min(nSeg - 1, Math.floor(fseg)); + const frac = fseg - s0; + const close = closes[s0] + (closes[s0 + 1] - closes[s0]) * frac; + const y = closeToY(close); line.setAttribute('x1', String(x)); line.setAttribute('x2', String(x)); line.setAttribute('y1', '0'); @@ -341,7 +373,15 @@ export function PriceHistoryChart({ }, [data]); const chartData = useMemo( - () => sliceByPeriod(fullChartData, period), + // Downsample after slicing so the path Recharts builds stays small. A 5Y + // window is ~1260 daily points → a ~56 KB SVG `d` string, whose ONE-TIME + // render is a ~380 ms main-thread task on a throttled phone (the "เปิดหน้า + // ค้างหนัก" report). At a ~360-700 px chart width there are far more points + // than pixels, so capping to MAX_POINTS (evenly, always keeping the last + // point so the latest price + flush-right crosshair are exact) is visually + // lossless but cuts the path ~5× → no freeze. Shorter windows (≤ MAX_POINTS) + // pass through untouched. + () => downsample(sliceByPeriod(fullChartData, period), 260), [fullChartData, period], ); @@ -883,6 +923,26 @@ const PERIOD_LABEL: Record = { // array down to the visible window for the selected period. 1D / 5D // / 5Y are deferred (selector disables them) so they never reach // here, but the function returns the full series as a safe fallback. +// Cap a point series to at most `maxPoints` by even stride sampling, ALWAYS +// keeping the first and last points (so the visible price range + the latest +// price / flush-right crosshair stay exact). Series already ≤ maxPoints pass +// through unchanged. Purely for render cost — at typical chart widths there are +// far more daily points than pixels, so the dropped points are sub-pixel. +export function downsample( + points: ChartPoint[], + maxPoints: number, +): ChartPoint[] { + const n = points.length; + if (n <= maxPoints || maxPoints < 2) return points; + const out: ChartPoint[] = []; + const stride = (n - 1) / (maxPoints - 1); + for (let i = 0; i < maxPoints - 1; i += 1) { + out.push(points[Math.round(i * stride)]); + } + out.push(points[n - 1]); // exact last point + return out; +} + export function sliceByPeriod( points: ChartPoint[], period: TimePeriod, From c9fbde5017d22481b26c54cf94c41320084a1dbd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 15:36:48 +0000 Subject: [PATCH 08/15] test(frontend): add downsample regression guard (frontend-design-reviewer + test-engineer) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/components/downsample.test.mjs | 135 ++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 frontend/components/downsample.test.mjs diff --git a/frontend/components/downsample.test.mjs b/frontend/components/downsample.test.mjs new file mode 100644 index 000000000..268e6f479 --- /dev/null +++ b/frontend/components/downsample.test.mjs @@ -0,0 +1,135 @@ +/** + * Regression guard for the `downsample` pure helper exported from + * PriceHistoryChart.tsx. + * + * Run with: node frontend/components/downsample.test.mjs + * + * No test framework required. The logic below is a verbatim JS + * transcription of the TypeScript source (types stripped; behaviour + * identical). When PriceHistoryChart.tsx changes the implementation + * this file must be kept in sync — the guard in the CI "Frontend + * (build)" job does NOT run this script automatically because the + * project has no frontend unit-test harness yet. See AGENTS.md + * §Testing and the frontend-design-reviewer escalation note that + * created this file for the rationale. + * + * NOTE TO FUTURE AUTHORS: if vitest is ever added to package.json + * (separate PR), delete this file and replace it with a proper + * frontend/components/PriceHistoryChart.test.ts that imports + * `downsample` directly from the TypeScript source. + */ + +// ── verbatim transcription of downsample from PriceHistoryChart.tsx ────────── + +/** + * @param {{ date: string; close: number }[]} points + * @param {number} maxPoints + * @returns {{ date: string; close: number }[]} + */ +function downsample(points, maxPoints) { + const n = points.length; + if (n <= maxPoints || maxPoints < 2) return points; + const out = []; + const stride = (n - 1) / (maxPoints - 1); + for (let i = 0; i < maxPoints - 1; i += 1) { + out.push(points[Math.round(i * stride)]); + } + out.push(points[n - 1]); // exact last point + return out; +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +/** Build a strictly-increasing series of n ChartPoints. */ +function series(n) { + return Array.from({ length: n }, (_, i) => ({ + date: `2024-01-${String(i + 1).padStart(2, "0")}`, + close: i + 1, + })); +} + +let passed = 0; +let failed = 0; + +function assert(label, condition) { + if (condition) { + console.log(` PASS ${label}`); + passed += 1; + } else { + console.error(` FAIL ${label}`); + failed += 1; + } +} + +// ── test cases ──────────────────────────────────────────────────────────────── + +console.log("downsample regression guard\n"); + +// Case 1 — n <= maxPoints → identity (same reference) +{ + const pts = series(5); + const out = downsample(pts, 10); + assert("n <= maxPoints returns same reference", out === pts); +} + +// Case 2 — maxPoints < 2 → identity (same reference) +{ + const pts = series(100); + assert("maxPoints=1 returns same reference", downsample(pts, 1) === pts); + assert("maxPoints=0 returns same reference", downsample(pts, 0) === pts); +} + +// Case 3 — n=261, maxPoints=260 → length 260, first and last preserved +{ + const pts = series(261); + const out = downsample(pts, 260); + assert("n=261 maxPoints=260 → length 260", out.length === 260); + assert("n=261 maxPoints=260 → first point preserved", out[0] === pts[0]); + assert("n=261 maxPoints=260 → last point is pts[260]", out[259] === pts[260]); +} + +// Case 4 — n=1260, maxPoints=260 → length 260, first and last preserved +{ + const pts = series(1260); + const out = downsample(pts, 260); + assert("n=1260 maxPoints=260 → length 260", out.length === 260); + assert("n=1260 maxPoints=260 → first point preserved", out[0] === pts[0]); + assert("n=1260 maxPoints=260 → last point is pts[1259]", out[259] === pts[1259]); +} + +// Case 5 — maxPoints=2, n=100 → length 2, [input[0], input[99]] +{ + const pts = series(100); + const out = downsample(pts, 2); + assert("maxPoints=2 n=100 → length 2", out.length === 2); + assert("maxPoints=2 n=100 → first is pts[0]", out[0] === pts[0]); + assert("maxPoints=2 n=100 → last is pts[99]", out[1] === pts[99]); +} + +// Case 6 — no duplicate of the last point for a strictly-increasing series +{ + const pts = series(1260); + const out = downsample(pts, 260); + // For a distinct series the penultimate and final output points must differ + assert( + "no duplicate last point (penultimate.close !== last.close)", + out[out.length - 2].close !== out[out.length - 1].close, + ); +} + +// Case 7 — all output points are members of the input (no fabricated points) +{ + const pts = series(500); + const inputSet = new Set(pts); + const out = downsample(pts, 260); + const allInInput = out.every((p) => inputSet.has(p)); + assert("all output points are references from the input array", allInInput); +} + +// ── summary ─────────────────────────────────────────────────────────────────── + +console.log(`\n${passed + failed} assertions — ${passed} passed, ${failed} failed`); + +if (failed > 0) { + process.exit(1); +} From 2f0bee07e8aeee1ea210df040c7df3a9578d354b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 15:50:49 +0000 Subject: [PATCH 09/15] fix(frontend): chart draw only on period change (not on enter/refresh) + crosshair line within plot area MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/components/PriceHistoryChart.tsx | 74 ++++------------------- 1 file changed, 13 insertions(+), 61 deletions(-) diff --git a/frontend/components/PriceHistoryChart.tsx b/frontend/components/PriceHistoryChart.tsx index 8d69f9710..d35a054cf 100644 --- a/frontend/components/PriceHistoryChart.tsx +++ b/frontend/components/PriceHistoryChart.tsx @@ -73,9 +73,6 @@ export function PriceHistoryChart({ // must stay instant). Bumped together by the sweep triggers below. const [sweepKey, setSweepKey] = useState(0); const [playDraw, setPlayDraw] = useState(false); - // Gate so the scroll-into-view sweep fires only the FIRST time the chart - // enters the viewport (not on every scroll up/down past it). - const hasSwept = useRef(false); // Self-drawn intro crosshair overlay (a vertical line + a dot that RIDES the // price curve), animated left→right by rAF in sync with the area draw. During // the sweep the Recharts cursor + activeDot are suppressed (playDraw) so only @@ -92,62 +89,13 @@ export function PriceHistoryChart({ useEffect(() => setMounted(true), []); - // Fire the intro draw the first time the chart is visible on the detail page - // ("เลื่อนลงมาเห็นกราฟแบบเต็ม"). IntersectionObserver so it starts when the - // user sees it. One-shot via hasSwept. Two robustness fixes after the - // "เล่นเฉพาะตอนเปลี่ยน period" report: (1) a LOW threshold (0.1) + rootMargin - // so the draw fires as soon as a sliver enters — a tall-screen layout where - // the chart is already partly visible at mount, OR a slow scrub past it, - // would miss a 0.35 gate; (2) a deferred re-check (rAF) so if the chart is - // ALREADY in view at mount (short hero / large viewport), the draw still - // fires (IO does emit an initial callback for an already-intersecting target, - // but we also guard with an explicit getBoundingClientRect check in case the - // very first callback raced the layout). Browser-only; deps re-attach once - // the wrapper exists (after loading/error/data resolve). - useEffect(() => { - const el = wrapperRef.current; - if (!el || typeof IntersectionObserver === 'undefined') return; - if (hasSwept.current) return; - const fire = () => { - if (hasSwept.current) return; - hasSwept.current = true; - setPlayDraw(true); - setSweepKey((k) => k + 1); - }; - const io = new IntersectionObserver( - (entries) => { - if (entries[0]?.isIntersecting) { - fire(); - io.disconnect(); - } - }, - { threshold: 0.1, rootMargin: '0px 0px -10% 0px' }, - ); - io.observe(el); - // Fallback: if the chart already occupies the viewport at mount (the IO - // initial callback can race the first paint), fire on the next frame. - const raf = requestAnimationFrame(() => { - const r = el.getBoundingClientRect(); - const vh = window.innerHeight || 0; - if (r.top < vh * 0.9 && r.bottom > 0) { - fire(); - io.disconnect(); - } - }); - return () => { - io.disconnect(); - cancelAnimationFrame(raf); - }; - }, [loading, error, data]); - - // Re-run the sweep on every period change (1D-5Y). The period state drives - // chartData, so a new window's line draws in left→right too. Skipped on the - // very first render (the scroll-into-view observer owns the first sweep). - // Motion Rule 2 ("no re-firing on in-page interaction") gray zone: a period - // switch REPLACES the entire data series (a new line arriving), not a - // re-stagger of existing content — the case Rule 2 actually targets — so - // re-sweeping the new series reads as "new data drawing in", which is the - // intended affordance (user-requested 2026-05-30). + // Replay the draw ONLY on a period change (1D-5Y) — NOT on entering the detail + // page or on refresh (user 2026-05-30: "ไม่ต้องเล่น animation ตอนเข้าหน้า / + // ตอนรีเฟรช ... ให้เล่นเฉพาะตอนเปลี่ยน 1D-5Y"). `firstPeriodRender` skips the + // initial mount (and any refresh, which is a fresh mount), so the chart paints + // statically on arrival; only an explicit period switch sets playDraw + bumps + // sweepKey to run the sweep. (No IntersectionObserver / scroll-into-view + // trigger anymore — that was the on-enter behavior the user asked to drop.) const firstPeriodRender = useRef(true); useEffect(() => { if (firstPeriodRender.current) { @@ -295,10 +243,14 @@ export function PriceHistoryChart({ const frac = fseg - s0; const close = closes[s0] + (closes[s0 + 1] - closes[s0]) * frac; const y = closeToY(close); + // Span the crosshair line over the PLOT AREA only ([plotTop, plotTop+plotH]), + // NOT the full surface — the surface includes ~29px top pad + the ~51px + // X-axis band below, so y1=0/y2=h overflowed the line above the chart top + // and below into the date axis (user 2026-05-30 "เส้นล้นออกด้านบนและล่าง"). line.setAttribute('x1', String(x)); line.setAttribute('x2', String(x)); - line.setAttribute('y1', '0'); - line.setAttribute('y2', String(h)); + line.setAttribute('y1', String(plotTop)); + line.setAttribute('y2', String(plotTop + plotH)); dot.setAttribute('cx', String(x)); dot.setAttribute('cy', String(y)); svg.style.opacity = '1'; From 9f73657255a23c0f68c466a32e441ef092fc6f08 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 15:53:46 +0000 Subject: [PATCH 10/15] =?UTF-8?q?docs(frontend):=20fix=20stale=20sweepKey?= =?UTF-8?q?=20comment=20(IO=20trigger=20removed)=20=E2=80=94=20frontend-de?= =?UTF-8?q?sign-reviewer=20WARN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 2f0bee07 — the sweep now fires ONLY on a 1D-5Y period change. Comment-only; no behavior change. https://claude.ai/code/session_0144kHrCYNaamMPH57b7xdM7 --- frontend/components/PriceHistoryChart.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/components/PriceHistoryChart.tsx b/frontend/components/PriceHistoryChart.tsx index d35a054cf..1f6fcaf75 100644 --- a/frontend/components/PriceHistoryChart.tsx +++ b/frontend/components/PriceHistoryChart.tsx @@ -68,9 +68,9 @@ export function PriceHistoryChart({ const [layoutKey, setLayoutKey] = useState(0); // `sweepKey` keys the chart on an intro replay (remounts so // Recharts re-runs its left→right area-draw animation); `playDraw` is the - // gate that says "this remount should ANIMATE" (true only for a sweep — IO - // scroll-in or period change — NOT for the rest/resize re-park remounts, which - // must stay instant). Bumped together by the sweep triggers below. + // gate that says "this remount should ANIMATE" (true only for a sweep — a + // 1D-5Y period change — NOT for the rest/resize re-park remounts, which must + // stay instant). Bumped together by the period effect below. const [sweepKey, setSweepKey] = useState(0); const [playDraw, setPlayDraw] = useState(false); // Self-drawn intro crosshair overlay (a vertical line + a dot that RIDES the From d71f458d33546b363f0702f9737d6d46f9cd0f51 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 16:17:46 +0000 Subject: [PATCH 11/15] =?UTF-8?q?feat(frontend):=20headline=20(price/chang?= =?UTF-8?q?e/date)=20tracks=20the=20crosshair=20=E2=80=94=20on=20scrub=20A?= =?UTF-8?q?ND=20during=20the=20draw?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/components/PriceHistoryChart.tsx | 225 +++++++++++++++++----- 1 file changed, 182 insertions(+), 43 deletions(-) diff --git a/frontend/components/PriceHistoryChart.tsx b/frontend/components/PriceHistoryChart.tsx index 1f6fcaf75..11d0b7919 100644 --- a/frontend/components/PriceHistoryChart.tsx +++ b/frontend/components/PriceHistoryChart.tsx @@ -57,6 +57,23 @@ export function PriceHistoryChart({ const [loading, setLoading] = useState(true); const [period, setPeriod] = useState('1Y'); const [mounted, setMounted] = useState(false); + // Index the HEADLINE (price / change / date) reflects. null = the latest + // point (rest state). Set by a scrub (Recharts onMouseMove → activeTooltipIndex) + // so the headline tracks the crosshair the user drags; cleared on pointer + // leave / release so it snaps back to latest. During the intro ANIMATION the + // headline is instead written imperatively (refs below) frame-by-frame — NOT + // via this state — so the 60fps sweep never triggers a React re-render (that + // was the "ค้างหนัก" trap). React re-render on animation end restores the + // latest value (= the last animated frame). + const [hoverIndex, setHoverIndex] = useState(null); + // Headline span refs — the rAF writes price/change/date text straight onto + // these during the animation (imperative, zero re-render). + const priceRef = useRef(null); + const changeAbsRef = useRef(null); + const changePctRef = useRef(null); + const changeArrowRef = useRef(null); + const changeRowRef = useRef(null); + const asOfRef = useRef(null); // Remount keys: bumping either forces to remount, which // re-runs Recharts' componentDidMount → displayDefaultTooltip(defaultIndex), // re-parking the crosshair + tooltip at the latest date. `restKey` bumps @@ -158,6 +175,39 @@ export function PriceHistoryChart({ const easeOut = (t: number) => 1 - Math.pow(1 - t, 3); // fast→slow const closes = chartData.map((d) => d.close); const nSeg = closes.length - 1; // x maps [0,nSeg] across the plot width + const firstClose = closes[0]; + // Nearest data index for an eased progress e∈[0,1] (for the date label, + // which is discrete per point — the price itself is interpolated). + const idxAt = (e: number) => Math.round(e * nSeg); + // Imperative headline writer — used ONLY by the rAF (scrub uses hoverIndex + // state + JSX). Mirrors the JSX formatting exactly so the handoff to React + // on animation end is seamless. Guards each ref (the row may be absent when + // periodChange is null, e.g. <2 points — but then the draw effect's early + // chartData.length<2 bail prevents this from running anyway). + const writeHeadline = (price: number, isoDate: string) => { + const abs = price - firstClose; + const pct = firstClose > 0 ? (abs / firstClose) * 100 : 0; + const positive = abs >= 0; + const sign = positive ? '+' : ''; + if (priceRef.current) priceRef.current.textContent = `$${price.toFixed(2)}`; + if (changeAbsRef.current) + changeAbsRef.current.textContent = `${sign}${abs.toFixed(2)}`; + if (changePctRef.current) + changePctRef.current.textContent = `(${sign}${pct.toFixed(2)}%)`; + if (changeArrowRef.current) + changeArrowRef.current.textContent = positive ? '↑' : '↓'; + if (changeRowRef.current) { + // swap the up/down color class pair on the row + const up = ['text-emerald-700', 'dark:text-emerald-300']; + const down = ['text-rose-600', 'dark:text-rose-400']; + const add = positive ? up : down; + const rm = positive ? down : up; + changeRowRef.current.classList.remove(...rm); + changeRowRef.current.classList.add(...add); + } + if (asOfRef.current) + asOfRef.current.textContent = fmtDateLabel(isoDate); + }; let area: SVGGElement | null = null; let w = 1; let h = 1; @@ -254,6 +304,14 @@ export function PriceHistoryChart({ dot.setAttribute('cx', String(x)); dot.setAttribute('cy', String(y)); svg.style.opacity = '1'; + // Drive the HEADLINE (price / change / date) to the swept point too, so it + // counts up alongside the crosshair. IMPERATIVE writes (refs, no setState) + // — a per-frame setState here would re-render Recharts 60×/s and re-freeze + // the page (the "ค้างหนัก" trap). The change is measured from the window's + // first close (same convention as the static headline). On the final frame + // the end-of-draw setPlayDraw(false) re-render restores the same latest + // values via JSX, so there's no visible snap. + writeHeadline(close, chartData[idxAt(e)].date); if (p < 1) { drawRafRef.current = requestAnimationFrame(tick); } else { @@ -349,6 +407,29 @@ export function PriceHistoryChart({ return { abs, pct, positive: abs >= 0 }; }, [chartData]); + // Headline values at an arbitrary point index — the price, the change vs the + // window's FIRST close (Google-Finance convention: the change is always + // measured from the window start, not from the previous point), the change %, + // direction, and the date. Used to render the headline at the crosshair + // position both on scrub (via hoverIndex state) and during the animation (via + // the imperative writer below). Pure — no side effects. + const headlineAt = (idx: number) => { + const n = chartData.length; + if (n === 0) return null; + const i = Math.max(0, Math.min(n - 1, idx)); + const price = chartData[i].close; + const first = chartData[0].close; + const abs = price - first; + const pct = first > 0 ? (abs / first) * 100 : 0; + return { + price, + abs, + pct, + positive: abs >= 0, + date: chartData[i].date, + }; + }; + // Re-park the crosshair at the latest date after any WIDTH change settles — // device rotation, window resize, OR the left sidebar expanding/collapsing // (which reflows the main-content width). A width change makes @@ -437,16 +518,10 @@ export function PriceHistoryChart({ const yy = raw.slice(2, 4); return `${MONTH_ABBR[monthIdx] ?? raw.slice(5, 7)} ${yy}`; }; - // Tooltip label — "Mon DD, YYYY" reads more cleanly than the raw - // ISO date and stays consistent with the X-axis month-abbr style. - const formatTooltipLabel = (raw: string) => { - const monthIdx = Number(raw.slice(5, 7)) - 1; - const day = Number(raw.slice(8, 10)); - const year = raw.slice(0, 4); - const mon = MONTH_ABBR[monthIdx]; - if (!mon || Number.isNaN(day)) return raw; - return `${mon} ${day}, ${year}`; - }; + // Tooltip / headline date label — "Mon DD, YYYY". Delegates to the + // module-level fmtDateLabel so the rAF headline writer (which is outside this + // render scope) formats dates identically. + const formatTooltipLabel = (raw: string) => fmtDateLabel(raw); const fmtPrice = (v: number) => `$${v.toFixed(2)}`; // PR 4f post-spot-check: compute a y-axis domain anchored on the @@ -547,39 +622,63 @@ export function PriceHistoryChart({ absolute + percent move on a second row beneath it. Mobile viewports were squeezing both onto a single line, leaving the change indicator clipped against the edge. */} - {chartData.length > 0 && ( -
-
- - ${chartData[chartData.length - 1].close.toFixed(2)} - - - USD - -
- {periodChange && ( -
- - {isPositive ? '+' : ''} - {periodChange.abs.toFixed(2)} - - - ({isPositive ? '+' : ''} - {periodChange.pct.toFixed(2)}%) + {chartData.length > 0 && (() => { + // Headline reflects the crosshair: the scrubbed point (hoverIndex) when + // dragging, else the latest. During the animation the rAF overwrites + // these spans imperatively (hoverIndex stays null), then the end-of-draw + // re-render restores the latest. The change row is colored by direction; + // when scrubbing we show the date (PERIOD_LABEL is only meaningful for + // the full window = latest), else the period label. + const lastIdx = chartData.length - 1; + const di = hoverIndex ?? lastIdx; + const hv = headlineAt(di) ?? { + price: chartData[lastIdx].close, + abs: 0, + pct: 0, + positive: true, + date: chartData[lastIdx].date, + }; + const scrubbing = hoverIndex !== null; + const upCls = 'text-emerald-700 dark:text-emerald-300'; + const downCls = 'text-rose-600 dark:text-rose-400'; + return ( +
+
+ + ${hv.price.toFixed(2)} - {isPositive ? '↑' : '↓'} - - {PERIOD_LABEL[period]} + + USD
- )} -
- as of {formatTooltipLabel(chartData[chartData.length - 1].date)} + {periodChange && ( +
+ + {hv.positive ? '+' : ''} + {hv.abs.toFixed(2)} + + + ({hv.positive ? '+' : ''} + {hv.pct.toFixed(2)}%) + + {hv.positive ? '↑' : '↓'} + + {PERIOD_LABEL[period]} + +
+ )} +
+ as of {formatTooltipLabel(hv.date)} +
-
- )} + ); + })()} {/* Reference price chips — always shown below the price headline as the canonical fair-value + target number read (the in-chart @@ -715,11 +814,23 @@ export function PriceHistoryChart({ Object.defineProperty(ev, 'pageY', { get: () => pageY }); surface.dispatchEvent(ev); }} - onPointerUp={() => setRestKey((k) => k + 1)} - onClick={() => setRestKey((k) => k + 1)} - onPointerCancel={() => setRestKey((k) => k + 1)} + onPointerUp={() => { + setHoverIndex(null); + setRestKey((k) => k + 1); + }} + onClick={() => { + setHoverIndex(null); + setRestKey((k) => k + 1); + }} + onPointerCancel={() => { + setHoverIndex(null); + setRestKey((k) => k + 1); + }} onPointerLeave={(e) => { - if (e.pointerType !== 'touch') setRestKey((k) => k + 1); + if (e.pointerType !== 'touch') { + setHoverIndex(null); + setRestKey((k) => k + 1); + } }} > {/* The intro is now driven by (a) Recharts' OWN left→right area-draw @@ -734,6 +845,18 @@ export function PriceHistoryChart({ key={`${period}-${restKey}-${layoutKey}-${sweepKey}`} data={chartData} margin={{ top: 8, right: 0, left: 0, bottom: 0 }} + onMouseMove={(state: { activeTooltipIndex?: number | null }) => { + // Track the scrubbed point so the headline (price/change/date) + // follows the crosshair the user drags. Ignored during the intro + // animation — the rAF owns the headline then (and the Recharts + // cursor is suppressed). activeTooltipIndex is null when the + // pointer is off the plot. + if (playDraw) return; + const idx = state?.activeTooltipIndex; + setHoverIndex( + typeof idx === 'number' && idx >= 0 ? idx : null, + ); + }} > @@ -859,6 +982,22 @@ export function PriceHistoryChart({ ); } +// Module-level date formatter: "YYYY-MM-DD" → "Mon DD, YYYY". Shared by the +// in-render formatTooltipLabel AND the rAF headline writer (which runs outside +// render scope), so both format the crosshair date identically. +const _MONTH_ABBR = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', +]; +export function fmtDateLabel(raw: string): string { + const monthIdx = Number(raw.slice(5, 7)) - 1; + const day = Number(raw.slice(8, 10)); + const year = raw.slice(0, 4); + const mon = _MONTH_ABBR[monthIdx]; + if (!mon || Number.isNaN(day)) return raw; + return `${mon} ${day}, ${year}`; +} + // Plain-English period labels for the change indicator. Matches the // Google Finance phrasing the user referenced as the desired design. const PERIOD_LABEL: Record = { From 89962b5083b84f34fa208f129edb00d9fa86402f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 16:30:44 +0000 Subject: [PATCH 12/15] fix(frontend): guard mid-draw re-park + hide period label while scrubbing (frontend-design-reviewer WARNs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 && {PERIOD_LABEL[period]}`); 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 --- frontend/components/PriceHistoryChart.tsx | 27 +++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/frontend/components/PriceHistoryChart.tsx b/frontend/components/PriceHistoryChart.tsx index 11d0b7919..e68b69261 100644 --- a/frontend/components/PriceHistoryChart.tsx +++ b/frontend/components/PriceHistoryChart.tsx @@ -90,6 +90,11 @@ export function PriceHistoryChart({ // stay instant). Bumped together by the period effect below. const [sweepKey, setSweepKey] = useState(0); const [playDraw, setPlayDraw] = useState(false); + // Mirror of playDraw readable from callbacks that close over a stale render + // (the ResizeObserver's debounced timer): lets them skip work WHILE the intro + // draw is running without re-subscribing on every playDraw flip. + const playDrawRef = useRef(false); + playDrawRef.current = playDraw; // Self-drawn intro crosshair overlay (a vertical line + a dot that RIDES the // price curve), animated left→right by rAF in sync with the area draw. During // the sweep the Recharts cursor + activeDot are suppressed (playDraw) so only @@ -453,7 +458,15 @@ export function PriceHistoryChart({ if (Math.abs(w - lastWidth) < 1) return; // height-only change → ignore lastWidth = w; clearTimeout(t); - t = setTimeout(() => setLayoutKey((k) => k + 1), 300); + t = setTimeout(() => { + // Don't bump layoutKey WHILE the intro draw is running — a re-park + // remount mid-draw would reconcile the headline spans back to latest for + // ~1 frame (fighting the rAF's imperative writes → a flicker). The draw + // is ≤650ms; the re-park can wait for it to finish (the next genuine + // resize, or the rest state, re-parks correctly anyway). + if (playDrawRef.current) return; + setLayoutKey((k) => k + 1); + }, 300); }); ro.observe(el); return () => { @@ -668,9 +681,15 @@ export function PriceHistoryChart({ {hv.pct.toFixed(2)}%)
{hv.positive ? '↑' : '↓'} - - {PERIOD_LABEL[period]} - + {/* Hide the period label ("past year" etc.) WHILE scrubbing — at + a scrubbed point the change is measured from the window start + to THAT point, so "past year" would mislabel it. At rest / + during the animation it correctly describes the full window. */} + {!scrubbing && ( + + {PERIOD_LABEL[period]} + + )}
)}
From b4b8896c7147a584823cbf6502e1778480e4ea21 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 17:56:19 +0000 Subject: [PATCH 13/15] =?UTF-8?q?fix(frontend):=20stop=20chart=20crash=20o?= =?UTF-8?q?n=20scrub/period-switch=20=E2=80=94=20single-text-node=20spans?= =?UTF-8?q?=20+=20no=20imperative=20classList?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/components/PriceHistoryChart.tsx | 41 +++++++++++++++-------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/frontend/components/PriceHistoryChart.tsx b/frontend/components/PriceHistoryChart.tsx index e68b69261..957c73a45 100644 --- a/frontend/components/PriceHistoryChart.tsx +++ b/frontend/components/PriceHistoryChart.tsx @@ -201,15 +201,16 @@ export function PriceHistoryChart({ changePctRef.current.textContent = `(${sign}${pct.toFixed(2)}%)`; if (changeArrowRef.current) changeArrowRef.current.textContent = positive ? '↑' : '↓'; - if (changeRowRef.current) { - // swap the up/down color class pair on the row - const up = ['text-emerald-700', 'dark:text-emerald-300']; - const down = ['text-rose-600', 'dark:text-rose-400']; - const add = positive ? up : down; - const rm = positive ? down : up; - changeRowRef.current.classList.remove(...rm); - changeRowRef.current.classList.add(...add); - } + // NB: the change-row COLOR is intentionally NOT touched here. React owns + // the row's className (the `hv.positive ? upCls : downCls` template), and + // those classes carry an OKLCH `!important` override in globals.css — an + // imperative classList swap would both fight React's className reconcile + // (stale/doubled class for a frame) and lose to the !important rule. During + // the draw hoverIndex is null, so React already paints the row in the + // period's overall direction; the animated NUMBERS count up within that + // steady color (Google-Finance behavior — the headline color doesn't flip + // per frame). The arrow textContent above is on its own single-node span, + // so it's safe to drive imperatively. if (asOfRef.current) asOfRef.current.textContent = fmtDateLabel(isoDate); }; @@ -657,11 +658,25 @@ export function PriceHistoryChart({ return (
+ {/* Each imperatively-written span (priceRef / changeAbsRef / + changePctRef / changeArrowRef / asOfRef) MUST hold exactly ONE + text node — a single template-literal child. The rAF does + `ref.textContent = …` every frame; textContent replaces ALL + children with one text node. If the JSX gave the span MULTIPLE + children (e.g. literal "$" + an expression = two text nodes), + the imperative write collapses them to one, but React's vdom + still believes there are two — so the next re-render (a scrub + setHoverIndex) reconciles against a detached text node: the + scrub silently stops updating ("เลื่อนได้บ้างไม่ได้บ้าง") and a + structural reconcile (insertBefore/removeChild on the missing + node) throws "app error". One text node per written span keeps + React's vdom and the live DOM in agreement (2026-05-30 crash + fix). */} - ${hv.price.toFixed(2)} + {`$${hv.price.toFixed(2)}`} USD @@ -673,12 +688,10 @@ export function PriceHistoryChart({ className={`flex flex-wrap items-baseline gap-1.5 text-sm ${hv.positive ? upCls : downCls}`} > - {hv.positive ? '+' : ''} - {hv.abs.toFixed(2)} + {`${hv.positive ? '+' : ''}${hv.abs.toFixed(2)}`} - ({hv.positive ? '+' : ''} - {hv.pct.toFixed(2)}%) + {`(${hv.positive ? '+' : ''}${hv.pct.toFixed(2)}%)`} {hv.positive ? '↑' : '↓'} {/* Hide the period label ("past year" etc.) WHILE scrubbing — at From 53da768d33dcd7a957d803e7b001e5f8f63ef049 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 18:25:51 +0000 Subject: [PATCH 14/15] =?UTF-8?q?fix(frontend):=20crosshair=20follows=20th?= =?UTF-8?q?e=20scrub=20=E2=80=94=20own=20overlay=20cursor=20instead=20of?= =?UTF-8?q?=20Recharts'=20(which=20snapped=20to=20defaultIndex)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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: - `` + `` — 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 --- frontend/components/PriceHistoryChart.tsx | 146 ++++++++++++++++------ 1 file changed, 109 insertions(+), 37 deletions(-) diff --git a/frontend/components/PriceHistoryChart.tsx b/frontend/components/PriceHistoryChart.tsx index 957c73a45..242518ead 100644 --- a/frontend/components/PriceHistoryChart.tsx +++ b/frontend/components/PriceHistoryChart.tsx @@ -321,10 +321,12 @@ export function PriceHistoryChart({ if (p < 1) { drawRafRef.current = requestAnimationFrame(tick); } else { - // full reveal: drop the clip (overflow-visible flush-right dot shows), - // hide the overlay, hand the rest crosshair to Recharts' parked cursor. + // full reveal: drop the clip (overflow-visible flush-right dot shows). + // LEAVE the overlay visible at the latest point — it's now the permanent + // crosshair (the scrub effect maintains it at rest / on hover). The last + // tick already positioned line+dot at the latest point, so this is a + // seamless handoff (no hide-then-reshow flash). clearAreaClip(); - svg.style.opacity = '0'; drawRafRef.current = null; setPlayDraw(false); } @@ -339,10 +341,6 @@ export function PriceHistoryChart({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [sweepKey, playDraw]); - // Crosshair re-park on layout change (rotation / window resize / sidebar - // expand-collapse) is handled by the ResizeObserver effect just below the - // chartData memo — a width-driven detector that subsumes the old - // orientation-only matchMedia listener. See that effect for the rationale. useEffect(() => { let cancelled = false; @@ -480,6 +478,55 @@ export function PriceHistoryChart({ // and a period change needn't disconnect/re-observe. }, [loading, error, data]); + // Scrub crosshair — we draw our OWN crosshair (the overlay SVG) at hoverIndex + // instead of relying on Recharts' built-in `cursor`. Why: every scrub fires + // setHoverIndex → a parent re-render → Recharts re-applies `defaultIndex` + // (latest point) and snaps its cursor back to the right edge, so the built-in + // cursor can't track a dragged finger across re-renders (the "crosshair ดีด + // กลับขวา / คาอยู่ขวา / มาตอนหยุดลาก" 2026-05-30 bug). The headline already + // tracks via hoverIndex; this positions the matching vertical line + on-curve + // dot at the same index, so the visible crosshair and the numbers move + // together. hoverIndex null = REST → park at the latest point. Skipped while + // the intro draw owns the overlay (playDraw). Imperative (refs) — no re-render. + useEffect(() => { + const svg = overlaySvgRef.current; + const line = overlayLineRef.current; + const dot = overlayDotRef.current; + const wrap = wrapperRef.current; + if (!svg || !line || !dot || !wrap) return; + if (playDraw) return; // the draw rAF is driving the overlay + if (chartData.length < 2) { + svg.style.opacity = '0'; + return; + } + // Padded y-domain + closes, same formula as the render `yDomain`. + const closesArr = chartData.map((d) => d.close); + let lo = closesArr[0]; + let hi = closesArr[0]; + for (const c of closesArr) { + if (c < lo) lo = c; + if (c > hi) hi = c; + } + const pad = (hi - lo || hi || 1) * 0.1; + const geom = measurePlot(wrap, lo - pad, hi + pad, closesArr); + if (!geom) { + svg.style.opacity = '0'; + return; + } + const rawIdx = hoverIndex ?? chartData.length - 1; + const i = Math.max(0, Math.min(chartData.length - 1, rawIdx)); + const x = (i / (chartData.length - 1)) * geom.w; + const y = geom.closeToY(chartData[i].close); + line.setAttribute('x1', String(x)); + line.setAttribute('x2', String(x)); + line.setAttribute('y1', String(geom.plotTop)); + line.setAttribute('y2', String(geom.plotTop + geom.plotH)); + dot.setAttribute('cx', String(x)); + dot.setAttribute('cy', String(y)); + svg.style.opacity = '1'; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hoverIndex, chartData, playDraw, mounted, resolvedTheme, restKey, layoutKey, sweepKey]); + if (loading) { // Skeleton placeholder — shimmer blocks roughly match the layout // shipped after load (current-price headline + change indicator + @@ -903,28 +950,17 @@ export function PriceHistoryChart({ minTickGap={32} /> - {/* Tooltip kept ONLY for its crosshair `cursor` (the vertical line + - the active-point dot Recharts draws at the hovered/parked index). - The price BOX is removed per user request — `content={() => null}` - renders no popup, so the chart shows just the crosshair line - tracking the finger/pointer with no obscuring panel. The cursor - is suppressed (transparent) WHILE the intro draw plays so only - the self-drawn animated crosshair shows; once the draw ends - (playDraw → false) this parked cursor takes over at the latest - point. Bolder color than before (slate-600/300 + 1.5px solid) so - it reads clearly against both the line and the slate page bg - (user: "crosshair กลืนกับกราฟและพื้นหลัง"). */} + {/* Tooltip is kept ONLY to harvest the scrubbed index via + onMouseMove (state.activeTooltipIndex). Its visual cursor is + DISABLED (cursor={false}) — we draw the crosshair ourselves with + the overlay SVG so it tracks the dragged point across re-renders. + Recharts' built-in cursor can't: every setHoverIndex re-render + re-applies defaultIndex and snaps the built-in cursor back to the + right edge (the "ดีดกลับขวา / คาอยู่ขวา" bug). content={()=>null} + keeps the price popup box off. */} null} - cursor={ - playDraw - ? false - : { - stroke: isDark ? '#cbd5e1' : '#475569', - strokeWidth: 1.5, - strokeDasharray: '4 3', - } - } + cursor={false} defaultIndex={chartData.length - 1} isAnimationActive={false} /> @@ -935,16 +971,11 @@ export function PriceHistoryChart({ strokeWidth={2} fill={`url(#${trendFillId})`} dot={false} - activeDot={ - playDraw - ? false - : { - r: 4, - fill: trendStroke, - stroke: isDark ? '#0f172a' : '#ffffff', - strokeWidth: 2, - } - } + // activeDot DISABLED — the on-curve dot is now part of our own + // overlay crosshair (driven by hoverIndex), so Recharts' activeDot + // (which also follows the defaultIndex-reset index) is redundant + + // would double-draw / fight the scrub. + activeDot={false} // Recharts' own draw animation is OFF — the unified intro rAF // (above) reveals the line by clipping `.recharts-area`, in // lockstep with the crosshair, so the two never desync. Keeping @@ -1021,6 +1052,47 @@ const _MONTH_ABBR = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', ]; +// Plot geometry read off the rendered Recharts curve — shared by the intro-draw +// rAF AND the scrub-crosshair effect so both position the overlay identically. +// Returns the surface width + the linear price→y mapping over the REAL plot area +// (top + height derived from the curve bbox, not the naive [marginTop, height]). +// null until Recharts has painted the curve (or on a degenerate flat series). +export type PlotGeom = { + w: number; + plotTop: number; + plotH: number; + closeToY: (close: number) => number; +}; +export function measurePlot( + wrap: HTMLElement, + yLo: number, + yHi: number, + closes: number[], +): PlotGeom | null { + const surf = wrap.querySelector('.recharts-surface') as SVGSVGElement | null; + const curve = wrap.querySelector('.recharts-area-curve') as SVGPathElement | null; + if (!surf || !curve || closes.length === 0) return null; + const rect = surf.getBoundingClientRect(); + const w = rect.width || 1; + let bb: DOMRect; + try { + bb = curve.getBBox(); + } catch { + return null; + } + if (bb.height === 0 || bb.width === 0) return null; + const ySpan = yHi - yLo || 1; + const dataMax = Math.max(...closes); + const dataMin = Math.min(...closes); + const dataSpan = dataMax - dataMin || 1; + const pxPerPrice = bb.height / dataSpan; + const plotH = ySpan * pxPerPrice; + const plotTop = bb.y - (yHi - dataMax) * pxPerPrice; + const closeToY = (close: number) => + plotTop + (1 - (close - yLo) / ySpan) * plotH; + return { w, plotTop, plotH, closeToY }; +} + export function fmtDateLabel(raw: string): string { const monthIdx = Number(raw.slice(5, 7)) - 1; const day = Number(raw.slice(8, 10)); From a81aedabbfc9ccc08be4732cc9eaed71b4a4fd51 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 18:42:39 +0000 Subject: [PATCH 15/15] fix(frontend): overlay overflow:visible + clear hoverIndex on period change + show rest crosshair at load (reviewer FAILs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- frontend/components/PriceHistoryChart.tsx | 53 ++++++++++++++++------- 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/frontend/components/PriceHistoryChart.tsx b/frontend/components/PriceHistoryChart.tsx index 242518ead..fbbd7aebe 100644 --- a/frontend/components/PriceHistoryChart.tsx +++ b/frontend/components/PriceHistoryChart.tsx @@ -124,6 +124,11 @@ export function PriceHistoryChart({ firstPeriodRender.current = false; return; } + // Clear the scrub index from the OLD period — otherwise hoverIndex stays + // non-null on the new window (scrubbing=true), which suppresses the period + // label ("past year") permanently after a switch + parks the crosshair at a + // clamped stale index until the next hover (frontend-design-reviewer 4b). + setHoverIndex(null); setPlayDraw(true); setSweepKey((k) => k + 1); }, [period]); @@ -508,22 +513,33 @@ export function PriceHistoryChart({ if (c > hi) hi = c; } const pad = (hi - lo || hi || 1) * 0.1; - const geom = measurePlot(wrap, lo - pad, hi + pad, closesArr); - if (!geom) { - svg.style.opacity = '0'; - return; - } - const rawIdx = hoverIndex ?? chartData.length - 1; - const i = Math.max(0, Math.min(chartData.length - 1, rawIdx)); - const x = (i / (chartData.length - 1)) * geom.w; - const y = geom.closeToY(chartData[i].close); - line.setAttribute('x1', String(x)); - line.setAttribute('x2', String(x)); - line.setAttribute('y1', String(geom.plotTop)); - line.setAttribute('y2', String(geom.plotTop + geom.plotH)); - dot.setAttribute('cx', String(x)); - dot.setAttribute('cy', String(y)); - svg.style.opacity = '1'; + let raf = 0; + const place = (tries: number) => { + const geom = measurePlot(wrap, lo - pad, hi + pad, closesArr); + if (!geom) { + // On the FIRST mount the curve isn't painted yet, so measurePlot returns + // null and the rest crosshair would never appear until the first hover. + // Retry on the next frame (capped) until Recharts has painted the curve. + if (tries < 20) raf = requestAnimationFrame(() => place(tries + 1)); + else svg.style.opacity = '0'; + return; + } + const rawIdx = hoverIndex ?? chartData.length - 1; + const i = Math.max(0, Math.min(chartData.length - 1, rawIdx)); + const x = (i / (chartData.length - 1)) * geom.w; + const y = geom.closeToY(chartData[i].close); + line.setAttribute('x1', String(x)); + line.setAttribute('x2', String(x)); + line.setAttribute('y1', String(geom.plotTop)); + line.setAttribute('y2', String(geom.plotTop + geom.plotH)); + dot.setAttribute('cx', String(x)); + dot.setAttribute('cy', String(y)); + svg.style.opacity = '1'; + }; + place(0); + return () => { + if (raf) cancelAnimationFrame(raf); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [hoverIndex, chartData, playDraw, mounted, resolvedTheme, restKey, layoutKey, sweepKey]); @@ -1017,6 +1033,11 @@ export function PriceHistoryChart({