From 059ac92be7dc6f235143a7948e5e75adedcee7db Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 18:35:34 +0000 Subject: [PATCH] feat(frontend): range compare on every price chart, with click-anchor and a pinned result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Range compare (drag across a chart to read the change between two points) was only switched on for the portfolio, net worth, bank balance and forecast charts. The security price charts never had it. - scrub.ts: one gesture state for both readings of "click, then drag". Press-drag-release compares the range in either direction and keeps it on screen. Click-move-click anchors a point, follows the pointer, then pins. A click on a pinned range, Escape, blur or leaving clears it. - ScrubOverlay.tsx: band tinted gain/loss with edge hairlines and value dots, plus a readout with the period and Δ (abs + %) for every series. Before, only series[0] was shown, which on the portfolio hero chart is the dashed "invested" line. The readout stays inside the chart box. - ComposedChart gains `scrubbable` (chart builder price chart). - Turned on for the watchlist chart dialog, market lookup, research compare, chart builder and portfolio forecast. - Clicking a chart no longer draws the browser's default focus outline; keyboard focus keeps the ring. - Shortcut tip (en/nl) describes the click gesture. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018QJtaMokesZfp18PhCAeFD --- .../src/components/charts/AreaChart.tsx | 174 ++++++-------- .../components/charts/CartesianChartFrame.tsx | 9 +- .../src/components/charts/ComposedChart.tsx | 138 ++++++++++-- .../src/components/charts/LineChart.tsx | 159 +++++-------- .../src/components/charts/ScrubOverlay.tsx | 193 ++++++++++++++++ .../charts/__tests__/chartScrub.test.tsx | 191 ++++++++++++++++ .../src/components/charts/keyboardNav.ts | 2 +- apps/frontend/src/components/charts/scrub.ts | 212 ++++++++++++++++++ apps/frontend/src/components/charts/scrub.tsx | 59 ----- .../portfolio/WatchlistChartDialog.tsx | 1 + apps/frontend/src/locales/en.ts | 2 +- apps/frontend/src/locales/nl.ts | 2 +- .../src/pages/research/ChartBuilderPage.tsx | 1 + .../src/pages/research/MarketLookupPage.tsx | 1 + .../pages/research/PortfolioForecastPage.tsx | 1 + .../pages/research/ResearchComparePage.tsx | 1 + docs/architecture/frontend-architecture.md | 6 +- docs/components/charts.md | 32 +-- docs/reference/code-patterns.md | 29 +-- i18n/source/en.json | 2 +- i18n/source/nl.json | 2 +- 21 files changed, 903 insertions(+), 314 deletions(-) create mode 100644 apps/frontend/src/components/charts/ScrubOverlay.tsx create mode 100644 apps/frontend/src/components/charts/__tests__/chartScrub.test.tsx create mode 100644 apps/frontend/src/components/charts/scrub.ts delete mode 100644 apps/frontend/src/components/charts/scrub.tsx diff --git a/apps/frontend/src/components/charts/AreaChart.tsx b/apps/frontend/src/components/charts/AreaChart.tsx index ddec80c81..0fc529471 100644 --- a/apps/frontend/src/components/charts/AreaChart.tsx +++ b/apps/frontend/src/components/charts/AreaChart.tsx @@ -35,12 +35,12 @@ import { } from "./CartesianChartFrame"; import { useChartSync } from "./ChartSyncContext"; import { useChartKeyboardNav } from "./keyboardNav"; -import { formatScrubDelta, useChartScrub } from "./scrub"; +import { ScrubBand, ScrubReadout } from "./ScrubOverlay"; +import { scrubView, useChartScrub, type ScrubSeries } from "./scrub"; import { ChartTooltip, type ChartTooltipDatum } from "./ChartTooltip"; import { CHART_NEUTRAL, getChartColor } from "./palette"; import { ChartEntrance, ChartLayerSvg, ChartReveal } from "./chartEntrance"; import { durations } from "@/lib/motion"; -import { cn } from "@/lib/utils"; import { useLanguage } from "@/stores/hydration/LanguageHydration"; import { useAppSettings } from "@/stores/hydration/AppSettingsHydration"; import { formatDateWithAppSettings } from "@/lib/dateUtils"; @@ -93,7 +93,7 @@ export interface AreaChartProps { readonly ariaLabel?: string | undefined; /** Opt into synced crosshairs with sibling charts sharing this id (needs ChartSyncProvider). */ readonly syncId?: string | undefined; - /** Enable pointer-drag range compare (Δ + %) on the primary series. */ + /** Enable range compare (Δ + % per series): drag across the chart, or click a point and move. */ readonly scrubbable?: boolean | undefined; } @@ -416,7 +416,7 @@ function AreaChartInner({ if (datum === undefined) return; setHoverIndex(idx); publishHover(Number(stableXAccessor(datum))); - if (scrub.scrubbing) scrub.move(idx); + scrub.hover(idx); }, [indexAtClientX, publishHover, stableXAccessor, data, scrub], ); @@ -433,13 +433,14 @@ function AreaChartInner({ if (!scrubbable) return; const idx = indexAtClientX(event); if (idx == null) return; + // Keep receiving moves while the button is held outside the plot. event.currentTarget.setPointerCapture(event.pointerId); - scrub.begin(idx); + scrub.press(idx); }, [scrubbable, indexAtClientX, scrub], ); - const handleUp = useCallback(() => scrub.end(), [scrub]); + const handleUp = useCallback(() => scrub.release(), [scrub]); // Keyboard path (←/→, Home/End, Shift+←/→, Escape) — drives the same // hover/scrub state as the pointer, so tooltip/crosshair render unchanged. @@ -511,6 +512,27 @@ function AreaChartInner({ : 0; const tooltipTop = margin.top; + const titleOf = (datum: Datum) => + tooltipTitle + ? tooltipTitle(datum) + : formatHoverTitle(xAccessor(datum), appSettings.dateFormat); + + const scrubSeries = useMemo[]>( + () => + series.map((s, i) => ({ + key: s.key, + label: s.label, + color: s.color ?? getChartColor(i), + value: s.accessor, + })), + [series], + ); + const scrubbed = scrubbable ? scrubView(scrub, data, scrubSeries) : null; + const formatScrubValue = (v: number, key: string) => + tooltipValueFormat + ? tooltipValueFormat(v, key) + : String(Math.round(v * 100) / 100); + // useId: deterministic per render position (stable snapshots), unique per // chart instance — replaces the old Math.random() suffix. const reactId = useId(); @@ -604,33 +626,34 @@ function AreaChartInner({ })} {/* Scrub range band */} - {scrub.range - ? (() => { - const startDatum = data[scrub.range.startIndex]; - const endDatum = data[scrub.range.endIndex]; - if ( - startDatum === undefined || - endDatum === undefined - ) - return null; - const xA = - xScale(stableXAccessor(startDatum)); - const xB = xScale(stableXAccessor(endDatum)); - return ( - - ); - })() - : null} + {scrubbed ? ( + ({ + key: r.key, + y: yScale(r.start), + color: r.color, + })) + } + endMarkers={ + stacked + ? undefined + : scrubbed.rows.map((r) => ({ + key: r.key, + y: yScale(r.end), + color: r.color, + })) + } + /> + ) : null} {/* Crosshair */} {hoverDatum != null ? ( @@ -702,82 +725,33 @@ function AreaChartInner({ onPointerLeave={handleLeave} onPointerDown={handleDown} onPointerUp={handleUp} - onPointerCancel={handleUp} + onPointerCancel={handleLeave} /> - {scrub.range - ? (() => { - const first = series[0]; - const startDatum = data[scrub.range.startIndex]; - const endDatum = data[scrub.range.endIndex]; - if ( - !first || - startDatum === undefined || - endDatum === undefined - ) - return null; - const a = first.accessor(startDatum); - const b = first.accessor(endDatum); - if ( - a == null || - b == null || - !Number.isFinite(a) || - !Number.isFinite(b) - ) - return null; - const fmt = (v: number) => - tooltipValueFormat - ? tooltipValueFormat(v, first.key) - : String(Math.round(v * 100) / 100); - const xA = - margin.left + - (xScale(stableXAccessor(startDatum))); - const xB = - margin.left + - (xScale(stableXAccessor(endDatum))); - const mid = (xA + xB) / 2; - const rising = b - a > 0; - return ( -
- {formatScrubDelta(a, b, fmt, formatPercent)} -
- ); - })() - : null} + {scrubbed?.isRange ? ( + + ) : null} diff --git a/apps/frontend/src/components/charts/CartesianChartFrame.tsx b/apps/frontend/src/components/charts/CartesianChartFrame.tsx index c868c52c2..6a8abf5d3 100644 --- a/apps/frontend/src/components/charts/CartesianChartFrame.tsx +++ b/apps/frontend/src/components/charts/CartesianChartFrame.tsx @@ -10,6 +10,8 @@ import { useRef, } from "react"; +import { cn } from "@/lib/utils"; + export interface CartesianChartMargin { readonly top: number; readonly right: number; @@ -127,7 +129,12 @@ export function CartesianChartFrame({ }: CartesianChartFrameProps) { return ( value != null && Number.isFinite(value); @@ -65,6 +69,8 @@ export interface ComposedChartProps { readonly tooltipValueFormat?: ((v: number, seriesKey: string) => string) | undefined; readonly ariaLabel?: string | undefined; + /** Enable range compare (Δ + % per series): drag across the chart, or click a point and move. */ + readonly scrubbable?: boolean | undefined; } const MARGIN = { top: 16, right: 64, bottom: 28, left: 72 }; @@ -276,10 +282,13 @@ function Inner({ tooltipTitle, tooltipValueFormat, ariaLabel, + scrubbable = false, width, height, }: ComposedChartProps & { width: number; height: number }) { const { t } = useLanguage(); + const formatPercent = usePercentFormatter(); + const fullEffects = useFullEffects(); const { innerWidth, innerHeight, stableXAccessor, xScale } = useCartesianFrame({ data, @@ -347,13 +356,14 @@ function Inner({ bisectX.center(array, x); }, [stableXAccessor]); const [hoverIdx, setHoverIdx] = useState(null); + const scrub = useChartScrub(); // Cache the overlay rect for the duration of a hover session instead of // calling getBoundingClientRect on every pointermove (forced layout per // frame). Cleared on pointerleave so the next hover re-measures. const overlayRectRef = useRef(null); - const handleMove = useCallback( + const indexAtClientX = useCallback( (event: React.PointerEvent) => { let rect = overlayRectRef.current; if (!rect) { @@ -362,15 +372,39 @@ function Inner({ } const x0 = xScale.invert(event.clientX - rect.left); const idx = bisect(data, x0); - setHoverIdx(idx >= 0 && idx < data.length ? idx : null); + return idx >= 0 && idx < data.length ? idx : null; }, [bisect, data, xScale], ); + const handleMove = useCallback( + (event: React.PointerEvent) => { + const idx = indexAtClientX(event); + setHoverIdx(idx); + if (idx != null) scrub.hover(idx); + }, + [indexAtClientX, scrub], + ); + const handleLeave = useCallback(() => { overlayRectRef.current = null; setHoverIdx(null); - }, []); + scrub.end(); + }, [scrub]); + + const handleDown = useCallback( + (event: React.PointerEvent) => { + if (!scrubbable) return; + const idx = indexAtClientX(event); + if (idx == null) return; + // Keep receiving moves while the button is held outside the plot. + event.currentTarget.setPointerCapture(event.pointerId); + scrub.press(idx); + }, + [scrubbable, indexAtClientX, scrub], + ); + + const handleUp = useCallback(() => scrub.release(), [scrub]); const safeHoverIdx = hoverIdx != null && hoverIdx < data.length ? hoverIdx : null; const keyboardNav = useChartKeyboardNav({ @@ -378,6 +412,7 @@ function Inner({ index: safeHoverIdx, onIndexChange: setHoverIdx, onClear: handleLeave, + scrub: scrubbable ? scrub : undefined, }); const hoverDatum = safeHoverIdx != null ? data[safeHoverIdx] : null; @@ -400,6 +435,44 @@ function Inner({ const cx = (d: Datum) => xScale(stableXAccessor(d)); + const titleOf = (datum: Datum) => { + if (tooltipTitle) return tooltipTitle(datum); + // App date format, not the browser locale, for the fallback title + const x = xAccessor(datum); + return x instanceof Date + ? formatDateWithAppSettings(x, appSettings.dateFormat) + : String(x); + }; + + const scrubSeries = useMemo[]>( + () => + series.map((s, i) => ({ + key: s.key, + label: s.label, + color: s.color ?? getChartColor(i), + value: (d: Datum) => repValue(s, d), + })), + [series], + ); + const scrubbed = scrubbable ? scrubView(scrub, data, scrubSeries) : null; + // Dots on line/area/candle marks, on the axis each series is drawn against. + const scrubMarkers = (which: "start" | "end"): ScrubMarker[] => { + if (!scrubbed) return []; + const markers: ScrubMarker[] = []; + for (const row of scrubbed.rows) { + const s = series.find((x) => x.key === row.key); + if (!s || s.type === "bar") continue; + const scale = + s.axis === "right" && rightScale ? rightScale : leftScale; + markers.push({ + key: row.key, + y: scale(which === "start" ? row.start : row.end), + color: row.color, + }); + } + return markers; + }; + return (
({ barWidth={barWidth} /> + {scrubbed ? ( + + ) : null} + {hoverDatum != null && ( ({ width={innerWidth} height={innerHeight} fill="transparent" + style={ + scrubbable + ? { touchAction: "pan-y", cursor: "crosshair" } + : undefined + } onPointerMove={handleMove} onPointerLeave={handleLeave} + onPointerDown={handleDown} + onPointerUp={handleUp} + onPointerCancel={handleLeave} /> + {scrubbed?.isRange ? ( + + tooltipValueFormat + ? tooltipValueFormat(v, key) + : String(Math.round(v * 100) / 100) + } + formatPercent={formatPercent} + fullEffects={fullEffects} + /> + ) : null} + { - const x = xAccessor(hoverDatum); - return x instanceof Date - ? formatDateWithAppSettings( - x, - appSettings.dateFormat, - ) - : x; - })(), - ) - : undefined - } + title={hoverDatum != null ? titleOf(hoverDatum) : undefined} items={tooltipItems} />
diff --git a/apps/frontend/src/components/charts/LineChart.tsx b/apps/frontend/src/components/charts/LineChart.tsx index 5d3c2ef3b..57cde5a72 100644 --- a/apps/frontend/src/components/charts/LineChart.tsx +++ b/apps/frontend/src/components/charts/LineChart.tsx @@ -19,12 +19,12 @@ import { } from "./CartesianChartFrame"; import { useChartSync } from "./ChartSyncContext"; import { useChartKeyboardNav } from "./keyboardNav"; -import { formatScrubDelta, useChartScrub } from "./scrub"; +import { ScrubBand, ScrubReadout } from "./ScrubOverlay"; +import { scrubView, useChartScrub, type ScrubSeries } from "./scrub"; import { ChartTooltip, type ChartTooltipDatum } from "./ChartTooltip"; import { CHART_NEUTRAL, getChartColor } from "./palette"; import { ChartEntrance, ChartLayerSvg } from "./chartEntrance"; import { durations } from "@/lib/motion"; -import { cn } from "@/lib/utils"; import { useLanguage } from "@/stores/hydration/LanguageHydration"; import { useAppSettings } from "@/stores/hydration/AppSettingsHydration"; import { formatDateWithAppSettings } from "@/lib/dateUtils"; @@ -75,7 +75,7 @@ export interface LineChartProps { readonly ariaLabel?: string | undefined; /** Opt into synced crosshairs with sibling charts sharing this id (needs ChartSyncProvider). */ readonly syncId?: string | undefined; - /** Enable pointer-drag range compare (Δ + %) on the primary series. */ + /** Enable range compare (Δ + % per series): drag across the chart, or click a point and move. */ readonly scrubbable?: boolean | undefined; } @@ -293,7 +293,7 @@ function Inner({ if (datum === undefined) return; setHoverIdx(idx); publishHover(Number(stableXAccessor(datum))); - if (scrub.scrubbing) scrub.move(idx); + scrub.hover(idx); }, [indexAtClientX, publishHover, stableXAccessor, data, scrub], ); @@ -310,13 +310,14 @@ function Inner({ if (!scrubbable) return; const idx = indexAtClientX(event); if (idx == null) return; + // Keep receiving moves while the button is held outside the plot. event.currentTarget.setPointerCapture(event.pointerId); - scrub.begin(idx); + scrub.press(idx); }, [scrubbable, indexAtClientX, scrub], ); - const handleUp = useCallback(() => scrub.end(), [scrub]); + const handleUp = useCallback(() => scrub.release(), [scrub]); // Keyboard path (←/→, Home/End, Shift+←/→, Escape) — drives the same // hover/scrub state as the pointer, so tooltip/crosshair render unchanged. @@ -386,6 +387,27 @@ function Inner({ ? margin.left + (xScale(xAccessor(hoverDatum))) : 0; + const titleOf = (datum: Datum) => + tooltipTitle + ? tooltipTitle(datum) + : formatTitle(xAccessor(datum), appSettings.dateFormat); + + const scrubSeries = useMemo[]>( + () => + series.map((s, i) => ({ + key: s.key, + label: s.label, + color: s.color ?? getChartColor(i), + value: s.accessor, + })), + [series], + ); + const scrubbed = scrubbable ? scrubView(scrub, data, scrubSeries) : null; + const formatScrubValue = (v: number, key: string) => + tooltipValueFormat + ? tooltipValueFormat(v, key) + : String(Math.round(v * 100) / 100); + return (
{/* Grid, under the series layer. */} @@ -561,32 +583,24 @@ function Inner({ /> )} - {scrub.range - ? (() => { - const startDatum = data[scrub.range.startIndex]; - const endDatum = data[scrub.range.endIndex]; - if ( - startDatum === undefined || - endDatum === undefined - ) - return null; - const xA = xScale(xAccessor(startDatum)); - const xB = xScale(xAccessor(endDatum)); - return ( - - ); - })() - : null} + {scrubbed ? ( + ({ + key: r.key, + y: yScale(r.start), + color: r.color, + }))} + endMarkers={scrubbed.rows.map((r) => ({ + key: r.key, + y: yScale(r.end), + color: r.color, + }))} + /> + ) : null} ({ onPointerLeave={handleLeave} onPointerDown={handleDown} onPointerUp={handleUp} - onPointerCancel={handleUp} + onPointerCancel={handleLeave} /> - {scrub.range - ? (() => { - const first = series[0]; - const startDatum = data[scrub.range.startIndex]; - const endDatum = data[scrub.range.endIndex]; - if ( - !first || - startDatum === undefined || - endDatum === undefined - ) - return null; - const a = first.accessor(startDatum); - const b = first.accessor(endDatum); - if ( - a == null || - b == null || - !Number.isFinite(a) || - !Number.isFinite(b) - ) - return null; - const fmt = (v: number) => - tooltipValueFormat - ? tooltipValueFormat(v, first.key) - : String(Math.round(v * 100) / 100); - const xA = - margin.left + (xScale(xAccessor(startDatum))); - const xB = - margin.left + (xScale(xAccessor(endDatum))); - const mid = (xA + xB) / 2; - const rising = b - a > 0; - return ( -
- {formatScrubDelta(a, b, fmt, formatPercent)} -
- ); - })() - : null} + {scrubbed?.isRange ? ( + + ) : null}
diff --git a/apps/frontend/src/components/charts/ScrubOverlay.tsx b/apps/frontend/src/components/charts/ScrubOverlay.tsx new file mode 100644 index 000000000..75cf09890 --- /dev/null +++ b/apps/frontend/src/components/charts/ScrubOverlay.tsx @@ -0,0 +1,193 @@ +/** + * Range-compare overlay for the time-series chart primitives: the band drawn + * inside the plot and the Δ readout above it. State lives in `scrub.ts`. + */ +import type { PercentFormatter } from "@/hooks/useCurrencyFormatter"; +import { cn } from "@/lib/utils"; +import { CHART_NEUTRAL } from "./palette"; +import { formatScrubDelta, type ScrubRow } from "./scrub"; + +const GAIN = "hsl(var(--gain))"; +const LOSS = "hsl(var(--loss))"; + +function trendColor(rows: ReadonlyArray): string { + const first = rows[0]; + if (!first || first.end === first.start) return CHART_NEUTRAL.label; + return first.end > first.start ? GAIN : LOSS; +} + +export interface ScrubMarker { + readonly key: string; + readonly y: number; + readonly color: string; +} + +interface ScrubBandProps { + /** Plot x of the range start and end (equal while only anchored). */ + readonly xStart: number; + readonly xEnd: number; + readonly innerHeight: number; + readonly rows: ReadonlyArray; + /** Value dots at the start and end of the range. */ + readonly startMarkers?: ReadonlyArray | undefined; + readonly endMarkers?: ReadonlyArray | undefined; +} + +/** The selected range inside the plot: a tinted band, edge hairlines and value dots. */ +export function ScrubBand({ + xStart, + xEnd, + innerHeight, + rows, + startMarkers, + endMarkers, +}: ScrubBandProps) { + const color = trendColor(rows); + const left = Math.min(xStart, xEnd); + const width = Math.abs(xEnd - xStart); + const edges = width > 0 ? [xStart, xEnd] : [xStart]; + const markers = width > 0 ? [startMarkers, endMarkers] : [startMarkers]; + return ( + + {width > 0 ? ( + + ) : null} + {edges.map((x, i) => ( + + ))} + {markers.map((set, i) => + set?.map((m) => ( + + )), + )} + + ); +} + +interface ScrubReadoutProps { + /** Chart-box x of the range start and end. */ + readonly xStart: number; + readonly xEnd: number; + /** Chart-box width the readout must stay inside. */ + readonly width: number; + readonly top: number; + readonly startTitle: string; + readonly endTitle: string; + readonly rows: ReadonlyArray; + readonly formatValue: (value: number, seriesKey: string) => string; + readonly formatPercent: PercentFormatter; + readonly fullEffects: boolean; + /** Rows beyond this are summarized as "+N". */ + readonly maxRows?: number | undefined; +} + +/** The Δ readout over the range: period, then change per series. */ +export function ScrubReadout({ + xStart, + xEnd, + width, + top, + startTitle, + endTitle, + rows, + formatValue, + formatPercent, + fullEffects, + maxRows = 4, +}: ScrubReadoutProps) { + if (rows.length === 0) return null; + const mid = (xStart + xEnd) / 2; + // Shift by the same share of the readout's width as `mid` is of the + // chart's, so the readout tracks the range and never leaves the box. + const share = width > 0 ? Math.min(100, Math.max(0, (mid / width) * 100)) : 50; + const shown = rows.slice(0, maxRows); + const hidden = rows.length - shown.length; + const labelled = rows.length > 1; + return ( +
+
+ {startTitle} – {endTitle} +
+ {shown.map((row) => { + const delta = row.end - row.start; + return ( +
+ {labelled ? ( + <> +
+ ); + })} + {hidden > 0 ? ( +
+{hidden}
+ ) : null} +
+ ); +} diff --git a/apps/frontend/src/components/charts/__tests__/chartScrub.test.tsx b/apps/frontend/src/components/charts/__tests__/chartScrub.test.tsx new file mode 100644 index 000000000..f61f4f8ce --- /dev/null +++ b/apps/frontend/src/components/charts/__tests__/chartScrub.test.tsx @@ -0,0 +1,191 @@ +// @vitest-environment jsdom +/** + * Range compare on the time-series chart primitives: drag across a chart in + * either direction, or click a point and move, to read the change between + * two points per series. The range stays after release and clears on a + * click, Escape, or the pointer leaving the chart. + */ +import { describe, expect, it, vi } from "vitest"; +import { fireEvent } from "@testing-library/react"; + +import { renderWithApp } from "@/test/renderWithApp"; +import { AreaChart } from "@/components/charts/AreaChart"; +import { ComposedChart } from "@/components/charts/ComposedChart"; +import { LineChart } from "@/components/charts/LineChart"; + +// ParentSize measures 0×0 in jsdom; give responsive charts a fixed box. +vi.mock("@visx/responsive", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + ParentSize: ({ + children, + }: { + children: (size: { + width: number; + height: number; + }) => React.ReactNode; + }) => children({ width: 600, height: 300 }), + }; +}); + +const DATA = [ + { date: new Date(2025, 0, 1), value: 10, cost: 100 }, + { date: new Date(2025, 1, 1), value: 20, cost: 90 }, + { date: new Date(2025, 2, 1), value: 30, cost: 95 }, + { date: new Date(2025, 3, 1), value: 40, cost: 80 }, +]; +type Datum = (typeof DATA)[number]; +const SERIES = [ + { key: "value", label: "Value", accessor: (d: Datum) => d.value }, + { key: "cost", label: "Cost", accessor: (d: Datum) => d.cost }, +]; +const fmt = (v: number) => `VAL:${v}`; + +// Plot x (jsdom rects sit at 0) of each point: 600 wide, 90 + 24 margins. +const X = [0, 170, 320, 480]; + +function overlayOf(container: HTMLElement): Element { + const overlay = container.querySelector('rect[fill="transparent"]'); + if (!overlay) throw new Error("hover overlay not rendered"); + return overlay; +} + +function readout(container: HTMLElement): string | null { + return ( + container.querySelector('[data-testid="chart-scrub-readout"]') + ?.textContent ?? null + ); +} + +function drag(overlay: Element, from: number, to: number) { + fireEvent.pointerDown(overlay, { clientX: from, pointerId: 1 }); + fireEvent.pointerMove(overlay, { clientX: to, pointerId: 1 }); + fireEvent.pointerUp(overlay, { clientX: to, pointerId: 1 }); +} + +function click(overlay: Element, x: number) { + fireEvent.pointerMove(overlay, { clientX: x, pointerId: 1 }); + fireEvent.pointerDown(overlay, { clientX: x, pointerId: 1 }); + fireEvent.pointerUp(overlay, { clientX: x, pointerId: 1 }); +} + +function renderArea(scrubbable: boolean) { + return renderWithApp( + d.date} + series={SERIES} + width={600} + height={300} + tooltipValueFormat={fmt} + tooltipTitle={(d) => `T${d.value}`} + scrubbable={scrubbable} + />, + ); +} + +describe("chart range compare", () => { + it("reads the same earlier → later change per series in both drag directions and keeps it after release", () => { + const { container } = renderArea(true); + const overlay = overlayOf(container); + + drag(overlay, X[1] ?? 0, X[3] ?? 0); + const rightward = readout(container); + expect(rightward).toContain("T20 – T40"); + expect(rightward).toMatch(/Value.*Δ \+VAL:20/); + expect(rightward).toMatch(/Cost.*Δ −VAL:10/); + + click(overlay, X[0] ?? 0); // dismiss + expect(readout(container)).toBeNull(); + + drag(overlay, X[3] ?? 0, X[1] ?? 0); + expect(readout(container)).toBe(rightward); + }); + + it("a click on a pinned range clears it", () => { + const { container } = renderArea(true); + const overlay = overlayOf(container); + drag(overlay, X[0] ?? 0, X[2] ?? 0); + expect(readout(container)).not.toBeNull(); + fireEvent.pointerMove(overlay, { clientX: X[3] ?? 0 }); + expect(readout(container)).toContain("T10 – T30"); + click(overlay, X[3] ?? 0); + expect(readout(container)).toBeNull(); + expect( + container.querySelector('[data-testid="chart-scrub-band"]'), + ).toBeNull(); + }); + + it("click anchors a point, moving stretches the range, a second click pins it", () => { + const { container } = renderArea(true); + const overlay = overlayOf(container); + click(overlay, X[0] ?? 0); + expect( + container.querySelector('[data-testid="chart-scrub-band"]'), + ).not.toBeNull(); + expect(readout(container)).toBeNull(); + + fireEvent.pointerMove(overlay, { clientX: X[2] ?? 0 }); + expect(readout(container)).toContain("T10 – T30"); + click(overlay, X[3] ?? 0); + fireEvent.pointerMove(overlay, { clientX: X[1] ?? 0 }); + expect(readout(container)).toContain("T10 – T40"); + }); + + it("Escape and leaving the chart clear the range", () => { + const { container } = renderArea(true); + const overlay = overlayOf(container); + const svg = container.querySelector("svg[role]"); + if (!svg) throw new Error("chart svg not rendered"); + + drag(overlay, X[0] ?? 0, X[2] ?? 0); + fireEvent.keyDown(svg, { key: "Escape" }); + expect(readout(container)).toBeNull(); + + drag(overlay, X[0] ?? 0, X[2] ?? 0); + fireEvent.pointerLeave(overlay); + expect(readout(container)).toBeNull(); + }); + + it("does nothing on a chart that is not scrubbable", () => { + const { container } = renderArea(false); + drag(overlayOf(container), X[0] ?? 0, X[3] ?? 0); + expect(readout(container)).toBeNull(); + }); + + it("works on line and composed charts", () => { + const line = renderWithApp( + d.date} + series={SERIES} + tooltipValueFormat={fmt} + scrubbable + />, + ); + drag(overlayOf(line.container), 0, 10_000); + expect(readout(line.container)).toMatch(/Value.*Δ \+VAL:30/); + line.unmount(); + + const composed = renderWithApp( + d.date} + series={[ + { + key: "close", + label: "Close", + type: "line", + accessor: (d) => d.value, + }, + ]} + tooltipValueFormat={fmt} + scrubbable + />, + ); + // Past the last point clamps to it. + drag(overlayOf(composed.container), 0, 10_000); + expect(readout(composed.container)).toMatch(/Δ \+VAL:30/); + }); +}); diff --git a/apps/frontend/src/components/charts/keyboardNav.ts b/apps/frontend/src/components/charts/keyboardNav.ts index 1b22440cc..fd7aa01be 100644 --- a/apps/frontend/src/components/charts/keyboardNav.ts +++ b/apps/frontend/src/components/charts/keyboardNav.ts @@ -75,7 +75,7 @@ export function useChartKeyboardNav({ if (!scrub.scrubbing) scrub.begin(index ?? (key === "ArrowRight" ? 0 : last)); scrub.move(next); } else if (scrub?.scrubbing) { - // Plain navigation ends a keyboard scrub, like pointerup ends a drag. + // Plain navigation ends a scrub (keyboard-extended or pointer-pinned). scrub.end(); } onIndexChange(next); diff --git a/apps/frontend/src/components/charts/scrub.ts b/apps/frontend/src/components/charts/scrub.ts new file mode 100644 index 000000000..c0892f575 --- /dev/null +++ b/apps/frontend/src/components/charts/scrub.ts @@ -0,0 +1,212 @@ +import { useCallback, useReducer } from "react"; +import type { PercentFormatter } from "@/hooks/useCurrencyFormatter"; + +export interface ScrubRange { + startIndex: number; + endIndex: number; +} + +/** + * - `drag`: button held, the head follows the pointer. + * - `follow`: a click anchored a point, the head follows the hover. + * - `pinned`: the range is fixed until the next click, Escape or leave. + */ +type ScrubPhase = "idle" | "drag" | "follow" | "pinned"; + +interface ScrubState { + readonly phase: ScrubPhase; + readonly anchor: number | null; + readonly head: number | null; + /** The current drag started on a pinned range: a plain click dismisses it. */ + readonly fromPinned: boolean; +} + +type ScrubAction = + | { readonly type: "press"; readonly index: number } + | { readonly type: "hover"; readonly index: number } + | { readonly type: "release" } + | { readonly type: "begin"; readonly index: number } + | { readonly type: "move"; readonly index: number } + | { readonly type: "clear" }; + +const IDLE: ScrubState = { + phase: "idle", + anchor: null, + head: null, + fromPinned: false, +}; + +function scrubReducer(state: ScrubState, action: ScrubAction): ScrubState { + switch (action.type) { + case "press": + if (state.phase === "follow") { + // Second click of click-move-click: pin, or cancel on the anchor. + return action.index === state.anchor + ? IDLE + : { ...state, phase: "pinned", head: action.index }; + } + return { + phase: "drag", + anchor: action.index, + head: action.index, + fromPinned: state.phase === "pinned", + }; + case "hover": + return state.phase === "drag" || state.phase === "follow" + ? state.head === action.index + ? state + : { ...state, head: action.index } + : state; + case "release": + if (state.phase !== "drag") return state; + if (state.head !== state.anchor) return { ...state, phase: "pinned" }; + // A click without a drag: dismiss a pinned range, else anchor here. + return state.fromPinned ? IDLE : { ...state, phase: "follow" }; + case "begin": + return { + phase: "drag", + anchor: action.index, + head: action.index, + fromPinned: false, + }; + case "move": + return state.anchor === null ? state : { ...state, head: action.index }; + case "clear": + return state.phase === "idle" ? state : IDLE; + } +} + +/** + * Range-compare state for time-series charts (Apple-Stocks style). The chart + * owns hit-testing (x → index); this hook owns the gesture: + * + * - press, drag, release: compare the dragged range; it stays on screen after + * release until the next click, Escape or the pointer leaving the chart. + * - click, move, click: the first click anchors a point, the range follows + * the pointer, the second click pins it. + * + * Either direction works; `range` is normalized (start ≤ end), so the change + * always reads earlier → later. + */ +export function useChartScrub() { + const [state, dispatch] = useReducer(scrubReducer, IDLE); + + const press = useCallback((index: number) => { + dispatch({ type: "press", index }); + }, []); + const hover = useCallback((index: number) => { + dispatch({ type: "hover", index }); + }, []); + const release = useCallback(() => { + dispatch({ type: "release" }); + }, []); + // Keyboard path (Shift+←/→): anchor, then extend; any other key ends it. + const begin = useCallback((index: number) => { + dispatch({ type: "begin", index }); + }, []); + const move = useCallback((index: number) => { + dispatch({ type: "move", index }); + }, []); + const end = useCallback(() => { + dispatch({ type: "clear" }); + }, []); + + const { anchor, head } = state; + const range: ScrubRange | null = + anchor !== null && head !== null && anchor !== head + ? { + startIndex: Math.min(anchor, head), + endIndex: Math.max(anchor, head), + } + : null; + + return { + scrubbing: anchor !== null, + anchorIndex: anchor, + range, + press, + hover, + release, + begin, + move, + end, + }; +} + +/** Δ readout text: absolute change + percent (percent omitted when start is 0). */ +export function formatScrubDelta( + startValue: number, + endValue: number, + formatValue: (v: number) => string, + formatPercent: PercentFormatter, +): string { + const delta = endValue - startValue; + const sign = delta > 0 ? "+" : delta < 0 ? "−" : "±"; + // Some formatters sign their output themselves (e.g. relative-performance + // "+x %"); strip any leading sign so ours is the only one. + const abs = formatValue(Math.abs(delta)).replace(/^[+\-−±]\s?/, ""); + if (startValue === 0) return `Δ ${sign}${abs}`; + const pct = (delta / Math.abs(startValue)) * 100; + const pctText = `${pct > 0 ? "+" : pct < 0 ? "−" : ""}${formatPercent(Math.abs(pct), { digits: 1 })}`; + return `Δ ${sign}${abs} (${pctText})`; +} + +export interface ScrubSeries { + readonly key: string; + readonly label?: string | undefined; + readonly color: string; + readonly value: (datum: Datum) => number | null | undefined; +} + +export interface ScrubRow { + readonly key: string; + readonly label: string | undefined; + readonly color: string; + readonly start: number; + readonly end: number; +} + +/** Start/end values of every series with a finite value at both ends. */ +export function scrubRows( + series: ReadonlyArray>, + startDatum: Datum, + endDatum: Datum, +): ScrubRow[] { + const rows: ScrubRow[] = []; + for (const s of series) { + const start = s.value(startDatum); + const end = s.value(endDatum); + if (start == null || end == null) continue; + if (!Number.isFinite(start) || !Number.isFinite(end)) continue; + rows.push({ key: s.key, label: s.label, color: s.color, start, end }); + } + return rows; +} + +export interface ScrubView { + readonly startDatum: Datum; + readonly endDatum: Datum; + readonly rows: ScrubRow[]; + /** False while only a point is anchored (start and end are the same datum). */ + readonly isRange: boolean; +} + +/** What to draw for the current scrub: the range, or just the anchored point. */ +export function scrubView( + scrub: { readonly range: ScrubRange | null; readonly anchorIndex: number | null }, + data: ReadonlyArray, + series: ReadonlyArray>, +): ScrubView | null { + const startIndex = scrub.range?.startIndex ?? scrub.anchorIndex; + const endIndex = scrub.range?.endIndex ?? scrub.anchorIndex; + if (startIndex === null || endIndex === null) return null; + const startDatum = data[startIndex]; + const endDatum = data[endIndex]; + if (startDatum === undefined || endDatum === undefined) return null; + return { + startDatum, + endDatum, + rows: scrubRows(series, startDatum, endDatum), + isRange: scrub.range !== null, + }; +} diff --git a/apps/frontend/src/components/charts/scrub.tsx b/apps/frontend/src/components/charts/scrub.tsx deleted file mode 100644 index a432f94cf..000000000 --- a/apps/frontend/src/components/charts/scrub.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { useCallback, useState } from "react"; -import type { PercentFormatter } from "@/hooks/useCurrencyFormatter"; - -export interface ScrubRange { - startIndex: number; - endIndex: number; -} - -/** - * Pointer-drag scrub state for time-series charts (Apple-Stocks style - * range compare). The chart owns hit-testing (x → index); this hook owns - * the drag lifecycle. Range is normalized (start ≤ end). - */ -export function useChartScrub() { - const [anchor, setAnchor] = useState(null); - const [head, setHead] = useState(null); - - const begin = useCallback((index: number) => { - setAnchor(index); - setHead(index); - }, []); - - const move = useCallback((index: number) => { - setHead(index); - }, []); - - const end = useCallback(() => { - setAnchor(null); - setHead(null); - }, []); - - const range: ScrubRange | null = - anchor !== null && head !== null && anchor !== head - ? { - startIndex: Math.min(anchor, head), - endIndex: Math.max(anchor, head), - } - : null; - - return { scrubbing: anchor !== null, range, begin, move, end }; -} - -/** Δ readout text: absolute change + percent (percent omitted when start is 0). */ -export function formatScrubDelta( - startValue: number, - endValue: number, - formatValue: (v: number) => string, - formatPercent: PercentFormatter, -): string { - const delta = endValue - startValue; - const sign = delta > 0 ? "+" : delta < 0 ? "−" : "±"; - // Some formatters sign their output themselves (e.g. relative-performance - // "+x %"); strip any leading sign so ours is the only one. - const abs = formatValue(Math.abs(delta)).replace(/^[+\-−±]\s?/, ""); - if (startValue === 0) return `Δ ${sign}${abs}`; - const pct = (delta / Math.abs(startValue)) * 100; - const pctText = `${pct > 0 ? "+" : pct < 0 ? "−" : ""}${formatPercent(Math.abs(pct), { digits: 1 })}`; - return `Δ ${sign}${abs} (${pctText})`; -} diff --git a/apps/frontend/src/features/portfolio/WatchlistChartDialog.tsx b/apps/frontend/src/features/portfolio/WatchlistChartDialog.tsx index 9df5f5573..2230ce347 100644 --- a/apps/frontend/src/features/portfolio/WatchlistChartDialog.tsx +++ b/apps/frontend/src/features/portfolio/WatchlistChartDialog.tsx @@ -337,6 +337,7 @@ export function WatchlistChartDialog({ /> ) : formattedData.length > 0 ? ( d.time} diff --git a/apps/frontend/src/locales/en.ts b/apps/frontend/src/locales/en.ts index 786f45cb2..9628f81d9 100644 --- a/apps/frontend/src/locales/en.ts +++ b/apps/frontend/src/locales/en.ts @@ -3611,7 +3611,7 @@ const en: Record = { 'settings.tab.backup': 'Backup', 'settings.tab.general': 'General', 'settings.title': 'Settings', - 'shortcuts.chartScrub': 'Tip: drag across a chart to compare a date range, or focus a chart and step through points with ←/→ (Shift+←/→ to compare).', + 'shortcuts.chartScrub': 'Tip: drag across a chart, or click a point and move, to compare a date range; it stays until you click or press Esc. Or focus a chart and step through points with ←/→ (Shift+←/→ to compare).', 'shortcuts.closeDialog': 'Close dialogs and menus', 'shortcuts.cycleSections': 'Switch between sections', 'shortcuts.desktopSection': 'Desktop app menu', diff --git a/apps/frontend/src/locales/nl.ts b/apps/frontend/src/locales/nl.ts index 02d78e152..c2e00440b 100644 --- a/apps/frontend/src/locales/nl.ts +++ b/apps/frontend/src/locales/nl.ts @@ -3611,7 +3611,7 @@ const nl: Record = { 'settings.tab.backup': 'Back-up', 'settings.tab.general': 'Algemeen', 'settings.title': 'Instellingen', - 'shortcuts.chartScrub': 'Tip: sleep over een grafiek om een periode te vergelijken, of focus een grafiek en stap door punten met ←/→ (Shift+←/→ om te vergelijken).', + 'shortcuts.chartScrub': 'Tip: sleep over een grafiek, of klik op een punt en beweeg, om een periode te vergelijken; dat blijft staan tot je klikt of op Esc drukt. Of focus een grafiek en stap door punten met ←/→ (Shift+←/→ om te vergelijken).', 'shortcuts.closeDialog': 'Dialoogvensters en menu\'s sluiten', 'shortcuts.cycleSections': 'Tussen secties wisselen', 'shortcuts.desktopSection': 'Bureaubladmenu', diff --git a/apps/frontend/src/pages/research/ChartBuilderPage.tsx b/apps/frontend/src/pages/research/ChartBuilderPage.tsx index 614ca145a..5447cd9cd 100644 --- a/apps/frontend/src/pages/research/ChartBuilderPage.tsx +++ b/apps/frontend/src/pages/research/ChartBuilderPage.tsx @@ -938,6 +938,7 @@ export default function ChartBuilderPage() { /> ) : rows.length > 0 ? ( + scrubbable data={rows} xAccessor={(d) => new Date(d.time)} xIsDate diff --git a/apps/frontend/src/pages/research/MarketLookupPage.tsx b/apps/frontend/src/pages/research/MarketLookupPage.tsx index 7d029d09a..bf1ec99b8 100644 --- a/apps/frontend/src/pages/research/MarketLookupPage.tsx +++ b/apps/frontend/src/pages/research/MarketLookupPage.tsx @@ -558,6 +558,7 @@ export default function MarketLookupPage() { displayChart.points.length > 0 ? (
new Date(d.time)} xIsDate diff --git a/apps/frontend/src/pages/research/PortfolioForecastPage.tsx b/apps/frontend/src/pages/research/PortfolioForecastPage.tsx index 890f2af20..f4f5a6e10 100644 --- a/apps/frontend/src/pages/research/PortfolioForecastPage.tsx +++ b/apps/frontend/src/pages/research/PortfolioForecastPage.tsx @@ -568,6 +568,7 @@ export default function PortfolioForecastPage() { /> ) : rows.length > 0 ? ( + scrubbable data={rows} xAccessor={(d) => new Date(d.ts)} xIsDate diff --git a/apps/frontend/src/pages/research/ResearchComparePage.tsx b/apps/frontend/src/pages/research/ResearchComparePage.tsx index bea6918fc..120dd5bde 100644 --- a/apps/frontend/src/pages/research/ResearchComparePage.tsx +++ b/apps/frontend/src/pages/research/ResearchComparePage.tsx @@ -561,6 +561,7 @@ export default function ResearchComparePage() { /> ) : chartData.length > 0 ? ( new Date(d.time)} xIsDate diff --git a/docs/architecture/frontend-architecture.md b/docs/architecture/frontend-architecture.md index 36849f462..12e716da3 100644 --- a/docs/architecture/frontend-architecture.md +++ b/docs/architecture/frontend-architecture.md @@ -3,8 +3,8 @@ title: Frontend Architecture type: architecture status: active description: "React frontend architecture, design system, and diagrams with liquid-glass aesthetic, visx charts, Framer Motion, and Zustand store. May 2026 Tailwind v4 migration with unified CSS architecture. June 2026 Liquid Glass v2 — atmosphere layer, saturated blur tiers, CommandPalette, optimistic mutations, route preload. June 2026 Premium v3 — RollingNumber/Money/DeltaPill, chart scrub+sync, ChartSkeleton, PageTitleContext, palette v2, ShortcutsOverlay + go-to sequences, animated tabs, workspace aurora, ShaderAurora behind visual-effects tier model (ADR-075), per-widget dashboard hydration, optimistic create. 2026-06-24: --gain/--loss CSS semantic tokens unified app-wide (tokens.css baseline, skin-v2.css Okabe-Ito overrides); gain/loss Tailwind color utilities added; colorblindGainLoss default OFF/classic." -date: 2026-10-28 -updated: 2026-10-28 +date: 2026-10-30 +updated: 2026-10-30 tags: [architecture, frontend, uml, plantuml, react, phase-4, phase-6, phase-9, liquid-glass, liquid-glass-v2, premium-v3, visx, framer-motion, statistics-refactoring, zustand, state-management, tailwind-v4, css-architecture, command-palette, optimistic-updates, route-preload, chart-scrub, chart-sync, shader-aurora, visual-effects-tiers, auto-adapt-display, fx-reduced, role-based-glass, glass-by-default, june-2026, gain-loss, css-tokens, skin-v2, tailwind-colors] aliases: [frontend architecture, react architecture, frontend design, design system] --- @@ -693,7 +693,7 @@ A second June 2026 batch with 18 items. See [[docs/adr/071-premium-v3-effects-to #### Chart Interactions (new) -- **Scrub-to-compare**: `scrub.tsx` exports `useChartScrub` + `formatScrubDelta`. AreaChart/LineChart accept a `scrubbable` prop. Pointer-drag selects a range, shows an opaque Δ pill (abs + %), and suppresses the tooltip while scrubbing. Moving chart tooltips and delta pills avoid backdrop blur so pointer movement does not re-rasterize a thick glass surface. Enabled on: CashFlowComparisonChart, ForecastInner(+Rolling), BankBalancesWidget, PortfolioPage (value hero and relative performance), NetWorthChart. +- **Scrub-to-compare**: `scrub.ts` exports `useChartScrub` + `formatScrubDelta`; `ScrubOverlay.tsx` draws the band and readout. AreaChart/LineChart/ComposedChart accept a `scrubbable` prop. Drag (either direction) or click-move-click selects a range that stays until a click, Escape or leave, shows a Δ readout (abs + %) per series, and suppresses the tooltip meanwhile. Moving chart tooltips and delta pills avoid backdrop blur so pointer movement does not re-rasterize a thick glass surface. Enabled on every price/value-over-time chart; list in [[docs/components/charts#scrub-to-compare-scrubbable-prop]]. - **Synced crosshairs**: `ChartSyncContext.tsx` exports `ChartSyncProvider` + `useChartSync`. Charts sharing a `syncId` under one provider mirror hover (nearest point, with a domain guard so disjoint timelines don't pin to edges). Dashboard time-series share `syncId="dashboard-timeline"`. `ChartSyncProvider` wraps `DashboardPage`. BarChart (MonthlyTrends) excluded — categorical band scale. - **Sweep reveal**: AreaChart animates a clipPath on mount. diff --git a/docs/components/charts.md b/docs/components/charts.md index ee706f671..65ae0a12a 100644 --- a/docs/components/charts.md +++ b/docs/components/charts.md @@ -2,8 +2,8 @@ title: Chart Primitives type: component status: active -date: 2026-10-27 -updated: 2026-10-27 +date: 2026-10-30 +updated: 2026-10-30 tags: [components, charts, visx, d3, visualization, phase-9, phase-h, accessibility, aria-label, screen-reader, i18n, localization, premium-v3, chart-scrub, chart-sync, chart-skeleton, sweep-reveal, sparkline-scrub, keyboard-navigation, june-2026] description: Low-level chart primitives built on visx + d3, replacing Recharts with design-token-aware styling. 2026-05-29: chartAria.ts generators now accept t()/kindKey for fully localized chart screen-reader summaries across all 7 chart types and both supported languages. June 2026 Premium v3 (ADR-071): scrubbable prop + useChartScrub (scrub-to-compare), syncId prop + ChartSyncContext (synced crosshairs), sweep reveal on AreaChart, ChartSkeleton ghost waveform. V9: Sparkline activeIndex prop (hairline + dot indicator for stat-card scrub). 2026-08-27: keyboardNav.ts provides shared keyboard access to per-point values across all interactive visx primitives and the NetSummaryCard sparkline scrub. 2026-08-23: ChartPeriodSelector uses native toggle-button semantics with aria-pressed instead of incomplete ARIA tab semantics. aliases: [charts, chart-components, visx-charts, charting, visualization] @@ -12,7 +12,9 @@ related_code: - apps/frontend/src/components/charts/CartesianChartFrame.tsx - apps/frontend/src/components/charts/chartAria.ts - apps/frontend/src/components/charts/__tests__/chartAria.test.ts - - apps/frontend/src/components/charts/scrub.tsx + - apps/frontend/src/components/charts/scrub.ts + - apps/frontend/src/components/charts/ScrubOverlay.tsx + - apps/frontend/src/components/charts/__tests__/chartScrub.test.tsx - apps/frontend/src/components/charts/keyboardNav.ts - apps/frontend/src/components/charts/__tests__/chartKeyboardNav.test.tsx - apps/frontend/src/components/charts/ChartSyncContext.tsx @@ -72,7 +74,8 @@ See [[docs/adr/018-visx-d3-chart-migration|ADR-018: visx/d3 Chart Migration]] fo | Module | Exports | Purpose | | ------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| `scrub.tsx` | `useChartScrub`, `formatScrubDelta` | Scrub-to-compare: pointer-drag range, glass Δ pill | +| `scrub.ts` | `useChartScrub`, `formatScrubDelta`, `scrubView` | Range compare state: drag or click-anchor gesture, pinned range, per-series start/end values | +| `ScrubOverlay.tsx` | `ScrubBand`, `ScrubReadout` | Range compare overlay: tinted band with edge hairlines and value dots, Δ readout per series | | `ChartSyncContext.tsx` | `ChartSyncProvider`, `useChartSync` | Synced crosshairs across charts sharing a `syncId` | | `keyboardNav.ts` | `useChartKeyboardNav` | Keyboard access to per-point values: shared ←/→ · Home/End · Shift+←/→ · Escape map (2026-08) | | `CartesianChartFrame.tsx` | frame components and bounds hook | Shared responsive sizing, x-scale construction, bounds, and accessible SVG shell for `AreaChart`, `LineChart`, and `ComposedChart` | @@ -392,7 +395,7 @@ Per-point values are reachable without a pointer. `useChartKeyboardNav` provides - **Tab** focuses the chart (`:focus-visible` shows the app's global focus ring; mouse clicks don't). - **←/→** step one data point, clamped at the ends; from an empty state → starts at the first point, ← starts at the last. Reuses the exact hover state the pointer drives, so the existing `ChartTooltip`/readout renders — no new visual affordance. - **Home/End** jump to the first/last point. -- **Shift+←/→** extend a range-compare scrub on `scrubbable` charts (`LineChart`/`AreaChart`), showing the same glass Δ pill as a pointer drag; a plain arrow ends the scrub (like pointerup). +- **Shift+←/→** extend a range-compare scrub on `scrubbable` charts (`LineChart`/`AreaChart`), showing the same glass Δ pill as a pointer drag; a plain arrow ends the scrub, including a range pinned by the pointer. - **Escape** clears hover + scrub; it is only consumed (`preventDefault`) when there is something to clear, so enclosing dialogs still receive it. Arrows/Home/End are always consumed when handled (else the page would scroll). - **Blur** clears everything — no tooltip lingers on an unfocused chart. @@ -466,17 +469,20 @@ function FlowTab() { ### Scrub-to-Compare (`scrubbable` prop) -**File:** [[apps/frontend/src/components/charts/scrub.tsx]] +**Files:** [[apps/frontend/src/components/charts/scrub.ts]], [[apps/frontend/src/components/charts/ScrubOverlay.tsx]] -`AreaChart` and `LineChart` accept a `scrubbable?: boolean` prop. When enabled: +`AreaChart`, `LineChart` and `ComposedChart` accept a `scrubbable?: boolean` prop. When enabled (2026-10-30 rework): -- A `useChartScrub` hook tracks pointer events via pointer capture (works on desktop and touch). -- While the user drags, a semi-transparent range band rect is drawn over the chart. -- A glass Δ pill shows the absolute change and percentage change across the selected range (`formatScrubDelta`). -- The standard `ChartTooltip` is suppressed during scrubbing. -- Pointer capture ensures the drag works even when the pointer leaves the SVG element. +- **Drag:** press on a point and drag either way. The range always reads earlier → later. It stays on screen after release. +- **Click, move, click:** a click anchors a point (an edge hairline with value dots marks it), the range follows the pointer, and a second click pins it. A second click on the anchor cancels it. +- **Clearing:** a click on a pinned range, Escape, a plain arrow key, blur, or the pointer leaving the chart clears it. +- The band is tinted gain/loss by the first series' change and has hairlines and value dots at both ends. Stacked areas show no dots because the stack offsets them. +- The readout shows the period (tooltip titles of both ends) and `formatScrubDelta` (absolute change and %) for every series with values at both ends. It shows up to 4 rows, then "+N". It stays inside the chart box. +- The standard `ChartTooltip` is suppressed while a range is shown. +- Pointer capture keeps a drag going when the pointer leaves the plot. +- Clicking a chart no longer draws the browser's default focus outline. Keyboard focus still shows the ring (`CartesianChartFrame`). -**Enabled on:** `CashFlowComparisonChart`, `ForecastInner`, `ForecastInnerRolling`, `BankBalancesWidget`, `PortfolioPage` (value hero and relative performance), `NetWorthChart`. +**Enabled on:** `BankBalancesWidget`, `ForecastInner`, `ForecastInnerRolling`, `PortfolioPage` (value hero, by broker and relative performance), `NetWorthChart`, `WatchlistChartDialog` (price), `MarketLookupPage` (price), `ResearchComparePage`, `ChartBuilderPage` (main `ComposedChart`) and `PortfolioForecastPage`. Statistics flow charts (monthly net, category trends, custom charts), the chart builder oscillator and AI chat tool charts stay off. ### Synced Crosshairs (`syncId` prop + `ChartSyncContext`) diff --git a/docs/reference/code-patterns.md b/docs/reference/code-patterns.md index a63709341..eb4ebbb38 100644 --- a/docs/reference/code-patterns.md +++ b/docs/reference/code-patterns.md @@ -2,8 +2,8 @@ title: Code Patterns Reference type: reference status: active -date: 2026-10-28 -updated: 2026-10-28 +date: 2026-10-30 +updated: 2026-10-30 tags: [reference, patterns, conventions, code-style, backend, frontend, delete-responses, http-204, phase-0, phase-1, phase-2, phase-3, phase-4, phase-5, phase-6, phase-9, phase-12, phase-14, phase-q, phase-c, phase-d, motion, liquid-glass, design-system, decimal, money, timezone, openapi, domain-split, import, import-pipeline, concurrency, batching, decimal-enforcement, zustand, slice-selection, typescript, error-handling, type-safety, csv, formula-injection, cwe-1236, csv-record-splitter, csv-parsing, multi-line-fields, date-utilities, immutability, aggregation-optimization, recipient-groups, portfolio-totals, query-parameter-filtering, buildquery, bug-hunt-2026-05-05, bug-hunt-2026-05-06, bug-hunt-2026-05-08, react-keys, stable-keys, mount-guard, memory-leak-prevention, parseLocaleNumber, number-parsing, locale-number, settings-backed-hook, portfolio-tax-classifications, audit-2026-05-11, belgian-tax, freeze-display-pattern, adr-059, dev-observability, devtools, api-inspector, observability, postgres-locking, for-update-group-by, accessibility, a11y, keyboard-operability, aria, onActivateKeyDown, shared-utils, monorepo, workspace, banker-rounding, plural, tc, portfolio-unit-math, premium-v3, optimistic-create, chart-scrub, chart-sync, context-menu, dialog-interplay, radix, role-based-glass, june-2026, skin-v2, feature-flag, css-scoping, unlayered-css, visual-skin, theming, inline-token-constraint, adr-104, wire-casing, snake-case, api-casing, database-naming, enum-discipline, check-constraints, chk-uq-idx] description: Standard code patterns used throughout the Vision project — repositories, routes, hooks, API client, Express setup, error handling, type safety, filter builders, aggregation envelopes, aggregation refresh, trigger-maintained tables, golden fixtures, database fixtures, pure calculation services, atomic multi-step transactions, streaming CSV exports with formula injection prevention, import batch concurrency, motion consumers, surface shells, gradient icon tiles, money utilities, decimal utilities, shared date utilities with input validation and locale support, timezone boundary handling, TypeScript type annotations, type-safe error handling, domain-split API client, Zustand store with useShallow slice selection, immutable PATCH field sanitization, aggregation query optimization with Map-based single-pass accumulation, recipient group resolution via an indexable semi-join (Phase Q; rewritten from the original scalar-subquery OR shape), portfolio totals single-source-of-truth pattern (Phase 14), Belgian Tax freeze/display pattern for engine-drift protection (ADR-059, May 2026), dev-only observability integration pattern (May 2026 devtools: module-level pub-sub event bus with zero-cost tree-shaking in production). May 2026 bug hunt adds React key generation pattern (use UUID instead of index), mount guard pattern (prevent setState after unmount), and documents parseLocaleNumber heuristic with single-comma thousands separator fix. May 2026 a11y pass adds onActivateKeyDown keyboard-activation helper pattern. June 2026: shared-utils cross-workspace package (@vision/shared-utils) consolidates money, slugify, and shared portfolio calculations; banker's rounding is now the canonical roundMoney mode; tc() plural pattern documented. June 2026 (ADR-070): optimistic mutation pattern (snapshot/patch/rollback via setQueriesData); surface shell updated with glass-regular/glass-elevated/opaque-table canonical rules; motion consumer updated for PageTransition re-addition and dialog keyframe animation. June 2026 Premium v3 (ADR-071): optimistic-create pattern (temp negative-id row, server swap, rollback, onSettled invalidate); chart scrub pattern (useChartScrub, pointer capture, glass Δ pill); chart sync pattern (ChartSyncProvider, syncId prop, domain guard). June 2026 Premium v3 V5 (ADR-071): Radix ContextMenu + Dialog interplay pattern — modal={false} prevents body pointer-events race when menu items spawn Dialogs. June 2026 (role-based glass): surface shell canonical rule broadened — glass-regular now applied to ALL content/chart/stat/state cards, including current table/form/callout/dialog-nested Card instances; old ~6-surface-per-viewport limit superseded; an explicit opaque exception uses a plain bordered bg-card container instead of Card. June 2026 (ADR-104): scoped-skin-behind-a-flag pattern — alternative visual skin shipped as UNLAYERED CSS under :root.skin-v2 toggled by VITE_SKIN_V2 booleanEnv flag (default OFF); localStorage runtime override + window.__setSkinV2 dev helper; critical inline-token constraint: applyThemePalette() writes color tokens as inline styles which beat any stylesheet rule. July 2026: wire casing convention — snake_case is the request/response body contract, translated to camelCase at the route edge; ai/savedCharts/crossWorkspace/admin-dbEditor requests plus marketLookup and import-rollback responses are grandfathered camelCase; dual-accept (`x_y ?? xY`) is banned. aliases: [code patterns, coding patterns, conventions, patterns, delete response pattern, 204 no content, delete convention, how to write code, repository pattern, route pattern, hook pattern, error handling, type-safe error handling, type annotations, filter builder, golden fixture, aggregation envelope, calculation services, import concurrency, motion pattern, surface shell pattern, gradient icon pattern, money pattern, decimal pattern, timezone pattern, domain split, openapi, typescript types, csv export, safe csv, formula injection, cwe-1236, date utilities, immutability, aggregation optimization, Map pattern, recipient group filter, recipientGroupId, portfolio totals, single source of truth, parseLocaleNumber, number parsing, locale-aware number parsing, thousands separator, decimal separator, belgian-tax-pattern, freeze-display-pattern, as-filed-calculation, engine-drift-protection, shared-utils, workspace, plural, tc, scoped-skin-behind-a-flag-pattern-adr-104, skin-v2 pattern, visual skin flag, unlayered css pattern, inline token constraint, wire casing convention, snake_case bodies, api casing, camelCase grandfathered routers, database naming, enum discipline, text plus check, constraint naming, index naming, chk prefix, uq prefix, idx prefix] @@ -3437,27 +3437,28 @@ export function useUpdateTransaction() { ### Scrub-to-Compare -`AreaChart` and `LineChart` accept a `scrubbable?: boolean` prop. To add scrub to a new chart: +`AreaChart`, `LineChart` and `ComposedChart` accept a `scrubbable?: boolean` prop. Turn it on for every chart of a price or value over time. To add scrub to a new chart primitive: ```tsx -import { useChartScrub, formatScrubDelta } from "@/components/charts/scrub"; - -// Inside chart component -const { scrubRange, handlePointerDown, handlePointerMove, handlePointerUp } = - useChartScrub(data, xScale); - -// The hook suppresses tooltip while scrubbing -// A glass Δ pill div is rendered over the chart when scrubRange is non-null +import { ScrubBand, ScrubReadout } from "@/components/charts/ScrubOverlay"; +import { scrubView, useChartScrub } from "@/components/charts/scrub"; + +const scrub = useChartScrub(); +// overlay rect: onPointerDown → setPointerCapture + scrub.press(index), +// onPointerMove → scrub.hover(index), onPointerUp → scrub.release(), +// onPointerLeave/onPointerCancel → scrub.end() +const scrubbed = scrubbable ? scrubView(scrub, data, scrubSeries) : null; +// inside the plot group; when scrubbed.isRange ``` Key rules: - Use pointer events (not mouse events) — works on desktop and touch. - Use `setPointerCapture` on pointer-down so drags work even when the pointer leaves the SVG. -- Suppress `ChartTooltip` while `scrubRange !== null`. -- Render the Δ pill with `formatScrubDelta(start, end)` — returns `{ abs, percent }`. +- Suppress `ChartTooltip` while `scrub.range !== null`. +- Pass `scrub` to `useChartKeyboardNav` so Shift+←/→ and Escape drive the same state. -**Enabled on:** CashFlowComparisonChart, ForecastInner, ForecastInnerRolling, BankBalancesWidget, PortfolioPage, NetWorthChart. +**Enabled on:** see [[docs/components/charts#scrub-to-compare-scrubbable-prop]]. ### Synced Crosshairs diff --git a/i18n/source/en.json b/i18n/source/en.json index 1c72a7f44..1ddd518be 100644 --- a/i18n/source/en.json +++ b/i18n/source/en.json @@ -3609,7 +3609,7 @@ "settings.tab.backup": "Backup", "settings.tab.general": "General", "settings.title": "Settings", - "shortcuts.chartScrub": "Tip: drag across a chart to compare a date range, or focus a chart and step through points with ←/→ (Shift+←/→ to compare).", + "shortcuts.chartScrub": "Tip: drag across a chart, or click a point and move, to compare a date range; it stays until you click or press Esc. Or focus a chart and step through points with ←/→ (Shift+←/→ to compare).", "shortcuts.closeDialog": "Close dialogs and menus", "shortcuts.cycleSections": "Switch between sections", "shortcuts.desktopSection": "Desktop app menu", diff --git a/i18n/source/nl.json b/i18n/source/nl.json index df2308f42..6586039da 100644 --- a/i18n/source/nl.json +++ b/i18n/source/nl.json @@ -3609,7 +3609,7 @@ "settings.tab.backup": "Back-up", "settings.tab.general": "Algemeen", "settings.title": "Instellingen", - "shortcuts.chartScrub": "Tip: sleep over een grafiek om een periode te vergelijken, of focus een grafiek en stap door punten met ←/→ (Shift+←/→ om te vergelijken).", + "shortcuts.chartScrub": "Tip: sleep over een grafiek, of klik op een punt en beweeg, om een periode te vergelijken; dat blijft staan tot je klikt of op Esc drukt. Of focus een grafiek en stap door punten met ←/→ (Shift+←/→ om te vergelijken).", "shortcuts.closeDialog": "Dialoogvensters en menu's sluiten", "shortcuts.cycleSections": "Tussen secties wisselen", "shortcuts.desktopSection": "Bureaubladmenu",