diff --git a/.nx/version-plans/version-plan-1778163738042.md b/.nx/version-plans/version-plan-1778163738042.md new file mode 100644 index 000000000..63d1ccf92 --- /dev/null +++ b/.nx/version-plans/version-plan-1778163738042.md @@ -0,0 +1,5 @@ +--- +'@ledgerhq/lumen-ui-react-visualization': patch +--- + +feat(Scrubber): Introduce Scrubber component for chart interactions diff --git a/libs/ui-react-visualization/src/lib/Components/Axis/XAxis/XAxis.test.tsx b/libs/ui-react-visualization/src/lib/Components/Axis/XAxis/XAxis.test.tsx index 62e814d94..69b4d2e9e 100644 --- a/libs/ui-react-visualization/src/lib/Components/Axis/XAxis/XAxis.test.tsx +++ b/libs/ui-react-visualization/src/lib/Components/Axis/XAxis/XAxis.test.tsx @@ -68,26 +68,25 @@ describe('XAxis', () => { ticks: [1, 3], }); const axis = getByTestId('x-axis'); - // 2 grid lines + 1 axis line + 2 tick marks = 5 expect(axis.querySelectorAll('line')).toHaveLength(5); }); - it('uses dashed grid lines by default', () => { + it('uses solid grid lines by default', () => { const { getByTestId } = renderXAxis({ showGrid: true, ticks: [2] }); const axis = getByTestId('x-axis'); const line = axis.querySelector('line'); - expect(line?.getAttribute('stroke-dasharray')).toBe('3 3'); + expect(line?.getAttribute('stroke-dasharray')).toBeNull(); }); - it('uses solid grid lines when gridLineStyle is solid', () => { + it('uses dashed grid lines when gridLineStyle is dashed', () => { const { getByTestId } = renderXAxis({ showGrid: true, - gridLineStyle: 'solid', + gridLineStyle: 'dashed', ticks: [2], }); const axis = getByTestId('x-axis'); const line = axis.querySelector('line'); - expect(line?.getAttribute('stroke-dasharray')).toBeNull(); + expect(line?.getAttribute('stroke-dasharray')).toBe('3 3'); }); it('applies tickLabelFormatter', () => { diff --git a/libs/ui-react-visualization/src/lib/Components/Axis/XAxis/XAxis.tsx b/libs/ui-react-visualization/src/lib/Components/Axis/XAxis/XAxis.tsx index 58b75a60e..fc745f871 100644 --- a/libs/ui-react-visualization/src/lib/Components/Axis/XAxis/XAxis.tsx +++ b/libs/ui-react-visualization/src/lib/Components/Axis/XAxis/XAxis.tsx @@ -14,7 +14,7 @@ const TICK_LABEL_OFFSET = 6; export const DEFAULT_AXIS_HEIGHT = 28; export function XAxis({ - gridLineStyle = 'dashed', + gridLineStyle = 'solid', position = 'bottom', showGrid = false, showLine = false, @@ -56,7 +56,9 @@ export function XAxis({ y1={drawingArea.y} x2={tick.position} y2={drawingArea.y + drawingArea.height} - style={{ stroke: cssVar('var(--border-muted-subtle)') }} + style={{ + stroke: cssVar('var(--border-muted-subtle-transparent)'), + }} strokeWidth={cssVar('var(--stroke-1)')} strokeDasharray={gridLineStyle === 'dashed' ? '3 3' : undefined} /> diff --git a/libs/ui-react-visualization/src/lib/Components/Axis/YAxis/YAxis.test.tsx b/libs/ui-react-visualization/src/lib/Components/Axis/YAxis/YAxis.test.tsx index e86cf72f0..471269987 100644 --- a/libs/ui-react-visualization/src/lib/Components/Axis/YAxis/YAxis.test.tsx +++ b/libs/ui-react-visualization/src/lib/Components/Axis/YAxis/YAxis.test.tsx @@ -71,26 +71,25 @@ describe('YAxis', () => { ticks: [20, 40], }); const axis = getByTestId('y-axis'); - // 2 grid lines + 1 axis line + 2 tick marks = 5 expect(axis.querySelectorAll('line')).toHaveLength(5); }); - it('uses dashed grid lines by default', () => { + it('uses solid grid lines by default', () => { const { getByTestId } = renderYAxis({ showGrid: true, ticks: [30] }); const axis = getByTestId('y-axis'); const line = axis.querySelector('line'); - expect(line?.getAttribute('stroke-dasharray')).toBe('3 3'); + expect(line?.getAttribute('stroke-dasharray')).toBeNull(); }); - it('uses solid grid lines when gridLineStyle is solid', () => { + it('uses dashed grid lines when gridLineStyle is dashed', () => { const { getByTestId } = renderYAxis({ showGrid: true, - gridLineStyle: 'solid', + gridLineStyle: 'dashed', ticks: [30], }); const axis = getByTestId('y-axis'); const line = axis.querySelector('line'); - expect(line?.getAttribute('stroke-dasharray')).toBeNull(); + expect(line?.getAttribute('stroke-dasharray')).toBe('3 3'); }); it('applies tickLabelFormatter', () => { diff --git a/libs/ui-react-visualization/src/lib/Components/Axis/YAxis/YAxis.tsx b/libs/ui-react-visualization/src/lib/Components/Axis/YAxis/YAxis.tsx index 82dad8760..fda4c3405 100644 --- a/libs/ui-react-visualization/src/lib/Components/Axis/YAxis/YAxis.tsx +++ b/libs/ui-react-visualization/src/lib/Components/Axis/YAxis/YAxis.tsx @@ -14,7 +14,7 @@ const TICK_LABEL_OFFSET = 6; export const DEFAULT_AXIS_WIDTH = 40; export function YAxis({ - gridLineStyle = 'dashed', + gridLineStyle = 'solid', position = 'start', showGrid = false, showLine = false, @@ -56,7 +56,9 @@ export function YAxis({ y1={tick.position} x2={drawingArea.x + drawingArea.width} y2={tick.position} - style={{ stroke: cssVar('var(--border-muted-subtle)') }} + style={{ + stroke: cssVar('var(--border-muted-subtle-transparent)'), + }} strokeWidth={cssVar('var(--stroke-1)')} strokeDasharray={gridLineStyle === 'dashed' ? '3 3' : undefined} /> diff --git a/libs/ui-react-visualization/src/lib/Components/CartesianChart/CartesianChart.tsx b/libs/ui-react-visualization/src/lib/Components/CartesianChart/CartesianChart.tsx index 2e2d198cb..128841996 100644 --- a/libs/ui-react-visualization/src/lib/Components/CartesianChart/CartesianChart.tsx +++ b/libs/ui-react-visualization/src/lib/Components/CartesianChart/CartesianChart.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ScrubberProvider } from '../Scrubber/ScrubberProvider'; import { CartesianChartProvider, useBuildChartContext } from './context'; import type { CartesianChartProps } from './types'; import { @@ -18,9 +19,12 @@ export function CartesianChart({ inset, axisPadding, ariaLabel = 'Chart', + enableScrubbing = false, + onScrubberPositionChange, children, }: CartesianChartProps) { const containerRef = useRef(null); + const svgRef = useRef(null); const [measuredWidth, setMeasuredWidth] = useState( typeof width === 'number' ? width : undefined, ); @@ -69,15 +73,31 @@ export function CartesianChart({ const svgContent = ( - {children} + {enableScrubbing ? ( + + {children} + + ) : ( + children + )} ); diff --git a/libs/ui-react-visualization/src/lib/Components/CartesianChart/types.ts b/libs/ui-react-visualization/src/lib/Components/CartesianChart/types.ts index 654aedcff..3cdfe5d5f 100644 --- a/libs/ui-react-visualization/src/lib/Components/CartesianChart/types.ts +++ b/libs/ui-react-visualization/src/lib/Components/CartesianChart/types.ts @@ -48,4 +48,15 @@ export type CartesianChartProps = { * SVG content rendered inside the chart's context provider. */ children?: ReactNode; + /** + * Enables scrubbing (hover/touch/keyboard) interactions on the chart. + * When true, the SVG becomes focusable and captures pointer/keyboard events. + * @default false + */ + enableScrubbing?: boolean; + /** + * Callback fired whenever the scrubber moves to a new data index or is cleared. + * Receives `undefined` when the scrubber leaves the chart. + */ + onScrubberPositionChange?: (index: number | undefined) => void; }; diff --git a/libs/ui-react-visualization/src/lib/Components/Line/Line.tsx b/libs/ui-react-visualization/src/lib/Components/Line/Line.tsx index 9b4218cd9..a0beb1db4 100644 --- a/libs/ui-react-visualization/src/lib/Components/Line/Line.tsx +++ b/libs/ui-react-visualization/src/lib/Components/Line/Line.tsx @@ -7,7 +7,7 @@ import { useCartesianChartContext } from '../CartesianChart/context'; import type { LineProps } from './types'; import { toScaledPoints, buildLinePath, buildAreaPath } from './utils'; -const AREA_GRADIENT_OPACITY = 0.2; +const AREA_GRADIENT_OPACITY = 0.15; export function Line({ seriesId, diff --git a/libs/ui-react-visualization/src/lib/Components/LineChart/LineChart.tsx b/libs/ui-react-visualization/src/lib/Components/LineChart/LineChart.tsx index 887799f5f..75aa8ae64 100644 --- a/libs/ui-react-visualization/src/lib/Components/LineChart/LineChart.tsx +++ b/libs/ui-react-visualization/src/lib/Components/LineChart/LineChart.tsx @@ -19,6 +19,8 @@ export function LineChart({ width = '100%', height = 160, inset, + enableScrubbing, + onScrubberPositionChange, children, }: LineChartProps) { const { @@ -78,6 +80,8 @@ export function LineChart({ height={height} inset={inset} axisPadding={axisPadding} + enableScrubbing={enableScrubbing} + onScrubberPositionChange={onScrubberPositionChange} > {showXAxis && } {showYAxis && } diff --git a/libs/ui-react-visualization/src/lib/Components/LineChart/types.ts b/libs/ui-react-visualization/src/lib/Components/LineChart/types.ts index 0f7ec6ddc..d0d8c9e0d 100644 --- a/libs/ui-react-visualization/src/lib/Components/LineChart/types.ts +++ b/libs/ui-react-visualization/src/lib/Components/LineChart/types.ts @@ -61,4 +61,15 @@ export type LineChartProps = { * Additional SVG content rendered inside the chart after lines and axes. */ children?: ReactNode; + /** + * Enables scrubbing (hover/touch/keyboard) interactions on the chart. + * When true, add a `` as a child to visualise the interaction. + * @default false + */ + enableScrubbing?: boolean; + /** + * Callback fired whenever the scrubber moves to a new data index or is cleared. + * Receives `undefined` when the scrubber leaves the chart. + */ + onScrubberPositionChange?: (index: number | undefined) => void; }; diff --git a/libs/ui-react-visualization/src/lib/Components/Scrubber/Scrubber.stories.tsx b/libs/ui-react-visualization/src/lib/Components/Scrubber/Scrubber.stories.tsx new file mode 100644 index 000000000..fd8a726dd --- /dev/null +++ b/libs/ui-react-visualization/src/lib/Components/Scrubber/Scrubber.stories.tsx @@ -0,0 +1,409 @@ +import { cssVar } from '@ledgerhq/lumen-design-core'; +import { + Button, + SegmentedControl, + SegmentedControlButton, +} from '@ledgerhq/lumen-ui-react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useEffect, useMemo, useState } from 'react'; + +import { StoryDecorator } from '../../../../.storybook/StoryDecorator'; +import { LineChart } from '../LineChart'; + +import { Point } from '../Point/Point'; +import { Scrubber } from './Scrubber'; +import type { ScrubberProps } from './types'; + +const meta = { + component: Scrubber, + title: 'Visualization/Scrubber', + tags: ['experimental'], + decorators: [ + (Story, context) => ( + // TODO: remove the stale decorator + +
+ +
+
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const dates = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +]; + +const singleSeries = [ + { + id: 'prices', + stroke: '#7B61FF', + data: [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20], + }, +]; + +const multiSeries = [ + { + id: 'lineA', + label: 'Line A', + stroke: '#7B61FF', + data: [5, 15, 10, 90, 85, 70, 30, 25, 25, 40, 60, 80], + }, + { + id: 'lineB', + label: 'Line B', + stroke: '#44D7B6', + data: [90, 85, 70, 25, 23, 40, 45, 40, 50, 30, 20, 10], + }, +]; + +export const Base: Story = { + render: (args: ScrubberProps) => ( +
+
+

Single Series

+

This is a single-series chart with a line.

+
+ ({ + min: bounds.min * 0.8, + max: bounds.max * 1.2, + }), + }} + enableScrubbing + > + + + + + + +
+ ), + args: {}, +}; + +export const MultiSeries: Story = { + render: (args: ScrubberProps) => ( +
+
+

Multi Series

+

+ This is a multi-series chart with two lines. +

+
+ ({ + min: bounds.min * 0.5, + max: bounds.max * 1.2, + }), + tickLabelFormatter: (v) => `$${v}`, + }} + > + dates[i] ?? ''} showBeacons /> + +
+ ), + args: {}, +}; + +const TIMELINES = { + '1D': { days: 1, dateFormat: { hour: 'numeric', minute: '2-digit' } }, + '1W': { days: 7, dateFormat: { weekday: 'short', hour: 'numeric' } }, + '1M': { days: 30, dateFormat: { month: 'short', day: 'numeric' } }, + '1Y': { + days: 365, + dateFormat: { month: 'short', day: 'numeric', year: 'numeric' }, + }, +} as const; + +type TimelineKey = keyof typeof TIMELINES; + +const buildCoingeckoUrl = (days: number): string => + `https://api.coingecko.com/api/v3/coins/bitcoin/market_chart?vs_currency=usd&days=${days}`; + +const formatBtcPrice = (v: number | string): string => { + const n = typeof v === 'string' ? parseFloat(v) : v; + return `$${n.toLocaleString('en-US', { maximumFractionDigits: 0 })}`; +}; + +function BitcoinChartStory(props: ScrubberProps) { + const [chartData, setChartData] = useState<{ + prices: number[]; + dates: string[]; + } | null>(null); + const [error, setError] = useState(null); + const [scrubberIndex, setScrubberIndex] = useState(); + const [showPoints, setShowPoints] = useState(true); + const [timeline, setTimeline] = useState('1Y'); + + useEffect(() => { + setChartData(null); + setError(null); + setScrubberIndex(undefined); + + const { days, dateFormat } = TIMELINES[timeline]; + + fetch(buildCoingeckoUrl(days)) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }) + .then((json: { prices: [number, number][] }) => { + setChartData({ + prices: json.prices.map(([, price]) => Math.round(price)), + dates: json.prices.map(([ts]) => + new Date(ts).toLocaleDateString( + 'en-US', + dateFormat as Intl.DateTimeFormatOptions, + ), + ), + }); + }) + .catch((err: Error) => setError(err.message)); + }, [timeline]); + + const extrema = useMemo(() => { + if (!chartData) return { highIndex: 0, lowIndex: 0, localExtrema: [] }; + + let hi = 0; + let lo = 0; + for (let i = 1; i < chartData.prices.length; i++) { + if (chartData.prices[i] > chartData.prices[hi]) hi = i; + if (chartData.prices[i] < chartData.prices[lo]) lo = i; + } + + const window = 10; + const candidates: { index: number; type: 'peak' | 'trough' }[] = []; + for (let i = window; i < chartData.prices.length - window; i++) { + const slice = chartData.prices.slice(i - window, i + window + 1); + const val = chartData.prices[i]; + if (val === Math.max(...slice) && i !== hi) { + candidates.push({ index: i, type: 'peak' }); + } else if (val === Math.min(...slice) && i !== lo) { + candidates.push({ index: i, type: 'trough' }); + } + } + + const minSpacing = 15; + const spaced: typeof candidates = []; + for (const c of candidates) { + if (spaced.every((s) => Math.abs(s.index - c.index) >= minSpacing)) { + spaced.push(c); + } + } + + return { highIndex: hi, lowIndex: lo, localExtrema: spaced.slice(0, 20) }; + }, [chartData]); + + const { highIndex, lowIndex, localExtrema } = extrema; + + const yTicks = useMemo((): number[] => { + if (!chartData) return []; + const min = Math.min(...chartData.prices); + const max = Math.max(...chartData.prices); + const mid = Math.round((min + max) / 2); + return [min, mid, max]; + }, [chartData]); + + if (error) return
Failed to fetch BTC data: {error}
; + if (!chartData) return
Loading Bitcoin price data…
; + + const series = [ + { + id: 'btc', + stroke: cssVar('var(--border-success)'), + data: chartData.prices, + }, + ]; + const displayIndex = scrubberIndex ?? chartData.prices.length - 1; + + return ( +
+
+

Bitcoin Price

+

+ {chartData.dates[displayIndex]} —{' '} + {formatBtcPrice(chartData.prices[displayIndex])} +

+ + setTimeline(v as TimelineKey)} + tabLayout='fit' + > + {Object.keys(TIMELINES).map((key) => ( + + {key} + + ))} + + +
+ + + + +
+
+
+ + + {showPoints && ( + <> + + + {localExtrema.map((e) => ( + + ))} + + )} + +
+
+ ); +} + +export const BitcoinChart: Story = { + render: (args: ScrubberProps) => , + args: {}, +}; + +export const WithAxes: Story = { + render: (args: ScrubberProps) => ( + ({ + min: bounds.min * 0.5, + max: bounds.max * 1.2, + }), + tickLabelFormatter: (v) => `$${v}`, + }} + > + + + + ), + args: {}, +}; diff --git a/libs/ui-react-visualization/src/lib/Components/Scrubber/Scrubber.test.tsx b/libs/ui-react-visualization/src/lib/Components/Scrubber/Scrubber.test.tsx new file mode 100644 index 000000000..5a26f003e --- /dev/null +++ b/libs/ui-react-visualization/src/lib/Components/Scrubber/Scrubber.test.tsx @@ -0,0 +1,182 @@ +import { render, act, fireEvent } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { CartesianChart } from '../CartesianChart'; +import { Scrubber } from './Scrubber'; + +const sampleSeries = [ + { id: 's1', stroke: '#7B61FF', data: [10, 20, 30, 40, 50] }, + { id: 's2', stroke: '#44D7B6', data: [50, 40, 30, 20, 10] }, +]; + +const renderScrubber = ({ + scrubberProps = {}, + children, +}: { + scrubberProps?: React.ComponentProps; + children?: ReactNode; +} = {}) => { + const result = render( + + + {children} + , + ); + + // Mock getBoundingClientRect on the SVG + const svg = result.getByTestId('chart-svg'); + vi.spyOn(svg, 'getBoundingClientRect').mockReturnValue({ + left: 0, + top: 0, + right: 400, + bottom: 200, + width: 400, + height: 200, + x: 0, + y: 0, + toJSON: () => ({}), + }); + + return result; +}; + +const activateScrubber = (svg: Element, clientX = 200) => { + act(() => { + fireEvent.mouseMove(svg, { clientX, clientY: 100 }); + }); +}; + +describe('Scrubber', () => { + it('renders nothing when no scrubber position is active', () => { + const { queryByTestId } = renderScrubber(); + expect(queryByTestId('scrubber')).toBeNull(); + }); + + it('renders the scrubber group on mousemove', () => { + const { getByTestId } = renderScrubber(); + activateScrubber(getByTestId('chart-svg')); + expect(getByTestId('scrubber')).toBeTruthy(); + }); + + it('renders the reference line by default', () => { + const { getByTestId } = renderScrubber(); + activateScrubber(getByTestId('chart-svg')); + expect(getByTestId('scrubber-line')).toBeTruthy(); + }); + + it('hides the reference line when hideLine is true', () => { + const { getByTestId, queryByTestId } = renderScrubber({ + scrubberProps: { hideLine: true }, + }); + activateScrubber(getByTestId('chart-svg')); + expect(queryByTestId('scrubber-line')).toBeNull(); + }); + + it('renders the overlay by default', () => { + const { getByTestId } = renderScrubber(); + activateScrubber(getByTestId('chart-svg')); + expect(getByTestId('scrubber-overlay')).toBeTruthy(); + }); + + it('hides the overlay when hideOverlay is true', () => { + const { getByTestId, queryByTestId } = renderScrubber({ + scrubberProps: { hideOverlay: true }, + }); + activateScrubber(getByTestId('chart-svg')); + expect(queryByTestId('scrubber-overlay')).toBeNull(); + }); + + it('does not render beacons by default', () => { + const { getByTestId, queryAllByTestId } = renderScrubber(); + activateScrubber(getByTestId('chart-svg')); + expect(queryAllByTestId(/scrubber-beacon-/)).toHaveLength(0); + }); + + it('renders one beacon per series when showBeacons is true', () => { + const { getByTestId, getAllByTestId } = renderScrubber({ + scrubberProps: { showBeacons: true }, + }); + activateScrubber(getByTestId('chart-svg')); + expect(getAllByTestId(/scrubber-beacon-/)).toHaveLength(2); + }); + + it('renders the label when a label function is provided', () => { + const { getByTestId, getByText } = renderScrubber({ + scrubberProps: { label: (i: number) => `Index ${i}` }, + }); + activateScrubber(getByTestId('chart-svg')); + expect(getByTestId('scrubber-label')).toBeTruthy(); + expect(getByText(/^Index \d+$/)).toBeTruthy(); + }); + + it('does not render a label when no label function is provided', () => { + const { getByTestId, queryByTestId } = renderScrubber(); + activateScrubber(getByTestId('chart-svg')); + expect(queryByTestId('scrubber-label')).toBeNull(); + }); + + it('hides the scrubber group on mouseleave', () => { + const { getByTestId, queryByTestId } = renderScrubber(); + const svg = getByTestId('chart-svg'); + activateScrubber(svg); + expect(getByTestId('scrubber')).toBeTruthy(); + + act(() => { + fireEvent.mouseLeave(svg); + }); + expect(queryByTestId('scrubber')).toBeNull(); + }); + + it('renders line at a valid x position within drawing area', () => { + const { getByTestId } = renderScrubber(); + activateScrubber(getByTestId('chart-svg')); + const line = getByTestId('scrubber-line'); + const x1 = parseFloat(line.getAttribute('x1') ?? '0'); + expect(x1).toBeGreaterThanOrEqual(0); + expect(x1).toBeLessThanOrEqual(400); + }); + + it('skips beacon for a series with null at the scrubbed index', () => { + const seriesWithNull = [ + { id: 's1', stroke: '#7B61FF', data: [10, 20, 30, 40, 50] }, + { id: 's2', stroke: '#44D7B6', data: [50, null, 30, 20, 10] }, + ]; + + const result = render( + + + , + ); + + const svg = result.getByTestId('chart-svg'); + vi.spyOn(svg, 'getBoundingClientRect').mockReturnValue({ + left: 0, + top: 0, + right: 400, + bottom: 200, + width: 400, + height: 200, + x: 0, + y: 0, + toJSON: () => ({}), + }); + + act(() => { + fireEvent.mouseMove(svg, { clientX: 100, clientY: 100 }); + }); + + const beacons = result.queryAllByTestId(/scrubber-beacon-/); + expect(beacons.length).toBeLessThanOrEqual(1); + }); +}); diff --git a/libs/ui-react-visualization/src/lib/Components/Scrubber/Scrubber.tsx b/libs/ui-react-visualization/src/lib/Components/Scrubber/Scrubber.tsx new file mode 100644 index 000000000..221064a8b --- /dev/null +++ b/libs/ui-react-visualization/src/lib/Components/Scrubber/Scrubber.tsx @@ -0,0 +1,176 @@ +import { cssVar } from '@ledgerhq/lumen-design-core'; +import { useId, useMemo } from 'react'; + +import { useCartesianChartContext } from '../CartesianChart/context'; +import { useScrubberContext } from './context'; +import type { ScrubberProps } from './types'; +import { + BEACON_RADIUS, + BEACON_STROKE_WIDTH, + LABEL_OFFSET_Y, + OVERLAY_LINE_INSET, + OVERLAY_OFFSET, + OVERLAY_OPACITY, + LINE_GRADIENT_EDGE_OPACITY, + resolvePixelX, + resolvePixelY, +} from './utils'; + +/** + * Renders the scrubber visuals: vertical reference line, future-data overlay + * rect, per-series beacon dots, and an optional formatted label above the line. + * + * Must be used as a child of `LineChart` (or `CartesianChart`) with + * `enableScrubbing` enabled. Renders nothing when no scrubber position is active. + * + * @example + * ```tsx + * + * data[i].date} /> + * + * ``` + */ +export function Scrubber({ + label, + hideLine = false, + hideOverlay = false, + showBeacons = false, +}: Readonly) { + const lineGradientId = useId(); + const { scrubberPosition } = useScrubberContext(); + const { + getXScale, + getXAxisConfig, + getYScale, + drawingArea, + series, + seriesMap, + } = useCartesianChartContext(); + + const pixelX = useMemo(() => { + if (scrubberPosition === undefined) return undefined; + return resolvePixelX(scrubberPosition, getXScale, getXAxisConfig()); + }, [scrubberPosition, getXScale, getXAxisConfig]); + + const beacons = useMemo(() => { + if (scrubberPosition === undefined || !showBeacons) return []; + return series + .map((s) => { + const seriesData = seriesMap.get(s.id)?.data; + const pixelY = resolvePixelY(scrubberPosition, seriesData, getYScale); + if (pixelY === undefined) return null; + return { id: s.id, stroke: s.stroke, pixelY }; + }) + .filter( + (b): b is { id: string; stroke: string; pixelY: number } => b !== null, + ); + }, [scrubberPosition, showBeacons, series, seriesMap, getYScale]); + + const resolvedLabel = useMemo(() => { + if (scrubberPosition === undefined || !label) return undefined; + return label(scrubberPosition); + }, [scrubberPosition, label]); + + if (scrubberPosition === undefined || pixelX === undefined) { + return null; + } + + const { + x: drawX, + y: drawY, + width: drawWidth, + height: drawHeight, + } = drawingArea; + + const overlayX = pixelX + OVERLAY_LINE_INSET; + const overlayY = drawY - OVERLAY_OFFSET; + const overlayWidth = Math.max( + 0, + drawX + drawWidth - pixelX - OVERLAY_LINE_INSET + OVERLAY_OFFSET, + ); + const overlayHeight = drawHeight + OVERLAY_OFFSET * 2; + + return ( + + {!hideLine && ( + <> + + + + + + + + + + + )} + + {!hideOverlay && ( + + )} + + {resolvedLabel !== undefined && ( + + {resolvedLabel} + + )} + + {showBeacons && + beacons.map((beacon) => ( + + ))} + + ); +} diff --git a/libs/ui-react-visualization/src/lib/Components/Scrubber/ScrubberProvider.test.tsx b/libs/ui-react-visualization/src/lib/Components/Scrubber/ScrubberProvider.test.tsx new file mode 100644 index 000000000..37091f86b --- /dev/null +++ b/libs/ui-react-visualization/src/lib/Components/Scrubber/ScrubberProvider.test.tsx @@ -0,0 +1,398 @@ +import { fireEvent, render, act } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { CartesianChart } from '../CartesianChart'; +import { useScrubberContext } from './context'; + +const sampleSeries = [{ id: 's1', stroke: '#000', data: [10, 20, 30, 40, 50] }]; + +const boundingRect: DOMRect = { + left: 0, + top: 0, + right: 400, + bottom: 200, + width: 400, + height: 200, + x: 0, + y: 0, + toJSON: () => ({}), +}; + +/** + * Renders a CartesianChart with scrubbing enabled. A child component reads the + * scrubber context and exposes the current position via a test-id attribute so + * assertions can inspect it without coupling to visual output. + */ +function ScrubberPositionDisplay() { + const { scrubberPosition } = useScrubberContext(); + return ( + + ); +} + +function ScrubberContextSetter({ index }: { index: number }) { + const { onScrubberPositionChange } = useScrubberContext(); + return ( + onScrubberPositionChange(index)} + /> + ); +} + +const renderWithScrubbing = ({ + onScrubberPositionChange, + children, + series = sampleSeries, +}: { + onScrubberPositionChange?: (index: number | undefined) => void; + children?: ReactNode; + series?: typeof sampleSeries; +} = {}) => { + const result = render( + + + {children} + , + ); + const svg = result.getByTestId('chart-svg'); + vi.spyOn(svg, 'getBoundingClientRect').mockReturnValue(boundingRect); + return result; +}; + +const activateScrubber = (svg: Element, clientX = 200): void => { + act(() => { + fireEvent.mouseMove(svg, { clientX, clientY: 100 }); + }); +}; + +describe('ScrubberProvider', () => { + it('renders children without error', () => { + const { getByTestId } = renderWithScrubbing(); + expect(getByTestId('scrubber-position')).toBeTruthy(); + }); + + it('starts with no scrubber position (undefined)', () => { + const { getByTestId } = renderWithScrubbing(); + expect(getByTestId('scrubber-position').dataset.value).toBe('undefined'); + }); + + it('updates position on mousemove', () => { + const onChange = vi.fn(); + const { getByTestId } = renderWithScrubbing({ + onScrubberPositionChange: onChange, + }); + const svg = getByTestId('chart-svg'); + + activateScrubber(svg); + + expect(onChange).toHaveBeenCalled(); + const calledWith = onChange.mock.calls[0][0]; + expect(typeof calledWith).toBe('number'); + }); + + it('clears position on mouseleave', () => { + const onChange = vi.fn(); + const { getByTestId } = renderWithScrubbing({ + onScrubberPositionChange: onChange, + }); + const svg = getByTestId('chart-svg'); + + activateScrubber(svg, 100); + act(() => { + fireEvent.mouseLeave(svg); + }); + + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]; + expect(lastCall[0]).toBeUndefined(); + }); + + it('does not update position when enableScrubbing is false', () => { + const onChange = vi.fn(); + const { getByTestId } = render( + , + ); + const svg = getByTestId('chart-svg'); + + // No ScrubberProvider is mounted, so no listeners → onChange never called + act(() => { + fireEvent.mouseMove(svg, { clientX: 200, clientY: 100 }); + }); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('updates position on ArrowRight key', () => { + const onChange = vi.fn(); + const { getByTestId } = renderWithScrubbing({ + onScrubberPositionChange: onChange, + }); + const svg = getByTestId('chart-svg'); + + activateScrubber(svg, 0); + const initialIndex = onChange.mock.calls[0]?.[0] ?? 0; + + act(() => { + fireEvent.keyDown(svg, { key: 'ArrowRight' }); + }); + + const lastIndex = onChange.mock.calls[onChange.mock.calls.length - 1][0]; + expect(lastIndex).toBeGreaterThan(initialIndex); + }); + + it('decrements position on ArrowLeft key', () => { + const onChange = vi.fn(); + const { getByTestId } = renderWithScrubbing({ + onScrubberPositionChange: onChange, + }); + const svg = getByTestId('chart-svg'); + + activateScrubber(svg, 200); + const initialIndex = onChange.mock.calls[0]?.[0] as number; + + act(() => { + fireEvent.keyDown(svg, { key: 'ArrowLeft' }); + }); + + const lastIndex = onChange.mock.calls[onChange.mock.calls.length - 1][0]; + expect(lastIndex).toBeLessThan(initialIndex); + }); + + it('jumps to first index on Home key', () => { + const onChange = vi.fn(); + const { getByTestId } = renderWithScrubbing({ + onScrubberPositionChange: onChange, + }); + const svg = getByTestId('chart-svg'); + + activateScrubber(svg, 200); + + act(() => { + fireEvent.keyDown(svg, { key: 'Home' }); + }); + + const lastIndex = onChange.mock.calls[onChange.mock.calls.length - 1][0]; + expect(lastIndex).toBe(0); + }); + + it('jumps to last index on End key', () => { + const onChange = vi.fn(); + const { getByTestId } = renderWithScrubbing({ + onScrubberPositionChange: onChange, + }); + const svg = getByTestId('chart-svg'); + + activateScrubber(svg, 0); + + act(() => { + fireEvent.keyDown(svg, { key: 'End' }); + }); + + const lastIndex = onChange.mock.calls[onChange.mock.calls.length - 1][0]; + expect(lastIndex).toBe(sampleSeries[0].data.length - 1); + }); + + it('uses a larger step with Shift key', () => { + const onChange = vi.fn(); + const { getByTestId } = renderWithScrubbing({ + onScrubberPositionChange: onChange, + }); + const svg = getByTestId('chart-svg'); + + act(() => { + fireEvent.keyDown(svg, { key: 'Home' }); + }); + + onChange.mockClear(); + act(() => { + fireEvent.keyDown(svg, { key: 'ArrowRight', shiftKey: true }); + }); + + const index = onChange.mock.calls[0]?.[0] as number; + expect(index).toBeGreaterThanOrEqual(1); + }); + + it('ignores unrelated keys', () => { + const onChange = vi.fn(); + const { getByTestId } = renderWithScrubbing({ + onScrubberPositionChange: onChange, + }); + const svg = getByTestId('chart-svg'); + + activateScrubber(svg); + onChange.mockClear(); + + act(() => { + fireEvent.keyDown(svg, { key: 'a' }); + }); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('clears position on Escape key', () => { + const onChange = vi.fn(); + const { getByTestId } = renderWithScrubbing({ + onScrubberPositionChange: onChange, + }); + const svg = getByTestId('chart-svg'); + + activateScrubber(svg); + act(() => { + fireEvent.keyDown(svg, { key: 'Escape' }); + }); + + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]; + expect(lastCall[0]).toBeUndefined(); + }); + + it('makes the SVG focusable when enableScrubbing is true', () => { + const { getByTestId } = renderWithScrubbing(); + const svg = getByTestId('chart-svg'); + expect(svg.getAttribute('tabindex')).toBe('0'); + }); + + it('does not set tabIndex when enableScrubbing is false', () => { + const { getByTestId } = render( + , + ); + const svg = getByTestId('chart-svg'); + expect(svg.getAttribute('tabindex')).toBeNull(); + }); + + it('updates position on touchstart', () => { + const onChange = vi.fn(); + const { getByTestId } = renderWithScrubbing({ + onScrubberPositionChange: onChange, + }); + const svg = getByTestId('chart-svg'); + + act(() => { + fireEvent.touchStart(svg, { + touches: [{ clientX: 200, clientY: 100 }], + }); + }); + + expect(onChange).toHaveBeenCalled(); + expect(typeof onChange.mock.calls[0][0]).toBe('number'); + }); + + it('updates position on touchmove', () => { + const onChange = vi.fn(); + const { getByTestId } = renderWithScrubbing({ + onScrubberPositionChange: onChange, + }); + const svg = getByTestId('chart-svg'); + + act(() => { + fireEvent.touchStart(svg, { + touches: [{ clientX: 100, clientY: 100 }], + }); + }); + + onChange.mockClear(); + + act(() => { + fireEvent.touchMove(svg, { + touches: [{ clientX: 300, clientY: 100 }], + }); + }); + + expect(onChange).toHaveBeenCalled(); + }); + + it('clears position on touchend', () => { + const onChange = vi.fn(); + const { getByTestId } = renderWithScrubbing({ + onScrubberPositionChange: onChange, + }); + const svg = getByTestId('chart-svg'); + + act(() => { + fireEvent.touchStart(svg, { + touches: [{ clientX: 200, clientY: 100 }], + }); + }); + act(() => { + fireEvent.touchEnd(svg); + }); + + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]; + expect(lastCall[0]).toBeUndefined(); + }); + + it('clears position on blur', () => { + const onChange = vi.fn(); + const { getByTestId } = renderWithScrubbing({ + onScrubberPositionChange: onChange, + }); + const svg = getByTestId('chart-svg'); + + activateScrubber(svg); + act(() => { + fireEvent.blur(svg); + }); + + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]; + expect(lastCall[0]).toBeUndefined(); + }); + + it('clamps out-of-range index set via context', () => { + const onChange = vi.fn(); + const { getByTestId } = renderWithScrubbing({ + onScrubberPositionChange: onChange, + children: , + }); + + act(() => { + fireEvent.click(getByTestId('context-setter')); + }); + + const calledWith = onChange.mock.calls[0][0]; + expect(calledWith).toBe(sampleSeries[0].data.length - 1); + }); + + it('fires external callback when child updates position via context', () => { + const onChange = vi.fn(); + const { getByTestId } = renderWithScrubbing({ + onScrubberPositionChange: onChange, + children: , + }); + + act(() => { + fireEvent.click(getByTestId('context-setter')); + }); + + expect(onChange).toHaveBeenCalledWith(2); + }); + + it('does not fire keyboard handler when dataLength is 0', () => { + const onChange = vi.fn(); + const { getByTestId } = renderWithScrubbing({ + onScrubberPositionChange: onChange, + series: [{ id: 's1', stroke: '#000', data: [] }], + }); + const svg = getByTestId('chart-svg'); + + act(() => { + fireEvent.keyDown(svg, { key: 'ArrowRight' }); + }); + + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/ui-react-visualization/src/lib/Components/Scrubber/ScrubberProvider.tsx b/libs/ui-react-visualization/src/lib/Components/Scrubber/ScrubberProvider.tsx new file mode 100644 index 000000000..ffa11889f --- /dev/null +++ b/libs/ui-react-visualization/src/lib/Components/Scrubber/ScrubberProvider.tsx @@ -0,0 +1,190 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { useCartesianChartContext } from '../CartesianChart/context'; +import { ScrubberContextProvider } from './context'; +import type { ScrubberContextValue, ScrubberProviderProps } from './types'; +import { getDataIndexFromPosition } from './utils'; + +export function ScrubberProvider({ + children, + svgRef, + enableScrubbing, + onScrubberPositionChange, +}: Readonly) { + const { getXScale, getXAxisConfig, dataLength } = useCartesianChartContext(); + const [scrubberPosition, setScrubberPosition] = useState( + undefined, + ); + const scrubberPositionRef = useRef(scrubberPosition); + scrubberPositionRef.current = scrubberPosition; + + const setScrubberPositionAndNotify = useCallback( + (index: number | undefined) => { + const clamped = + index === undefined + ? undefined + : Math.max(0, Math.min(index, dataLength - 1)); + setScrubberPosition(clamped); + onScrubberPositionChange?.(clamped); + }, + [dataLength, onScrubberPositionChange], + ); + + const updatePosition = useCallback( + (pixelX: number) => { + const scale = getXScale(); + if (!scale || !enableScrubbing || dataLength <= 0) return; + + const index = getDataIndexFromPosition( + pixelX, + scale, + getXAxisConfig(), + dataLength, + ); + + if (index !== scrubberPositionRef.current) { + setScrubberPositionAndNotify(index); + } + }, + [ + enableScrubbing, + getXScale, + getXAxisConfig, + dataLength, + setScrubberPositionAndNotify, + ], + ); + + const clearPosition = useCallback(() => { + if (!enableScrubbing) return; + setScrubberPositionAndNotify(undefined); + }, [enableScrubbing, setScrubberPositionAndNotify]); + + const handleMouseMove = useCallback( + (event: MouseEvent) => { + const target = event.currentTarget as SVGSVGElement; + const rect = target.getBoundingClientRect(); + updatePosition(event.clientX - rect.left); + }, + [updatePosition], + ); + + const handleTouchStart = useCallback( + (event: TouchEvent) => { + if (!enableScrubbing || !event.touches.length) return; + const touch = event.touches[0]; + const target = event.currentTarget as SVGSVGElement; + const rect = target.getBoundingClientRect(); + updatePosition(touch.clientX - rect.left); + }, + [enableScrubbing, updatePosition], + ); + + const handleTouchMove = useCallback( + (event: TouchEvent) => { + if (!event.touches.length) return; + event.preventDefault(); + const touch = event.touches[0]; + const target = event.currentTarget as SVGSVGElement; + const rect = target.getBoundingClientRect(); + updatePosition(touch.clientX - rect.left); + }, + [updatePosition], + ); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (!enableScrubbing || dataLength <= 0) return; + + const maxIndex = dataLength - 1; + const current = scrubberPositionRef.current ?? maxIndex; + const step = event.shiftKey + ? Math.min(10, Math.max(1, Math.floor(maxIndex * 0.1))) + : 1; + + let next: number | undefined; + + switch (event.key) { + case 'ArrowLeft': + event.preventDefault(); + next = Math.max(0, current - step); + break; + case 'ArrowRight': + event.preventDefault(); + next = Math.min(maxIndex, current + step); + break; + case 'Home': + event.preventDefault(); + next = 0; + break; + case 'End': + event.preventDefault(); + next = maxIndex; + break; + case 'Escape': + event.preventDefault(); + next = undefined; + break; + default: + return; + } + + setScrubberPositionAndNotify(next); + }, + [enableScrubbing, dataLength, setScrubberPositionAndNotify], + ); + + const handleBlur = useCallback(() => { + if (!enableScrubbing || scrubberPositionRef.current === undefined) return; + clearPosition(); + }, [enableScrubbing, clearPosition]); + + useEffect(() => { + const svg = svgRef.current; + if (!svg || !enableScrubbing) return; + + svg.addEventListener('mousemove', handleMouseMove); + svg.addEventListener('mouseleave', clearPosition); + svg.addEventListener('touchstart', handleTouchStart, { passive: false }); + svg.addEventListener('touchmove', handleTouchMove, { passive: false }); + svg.addEventListener('touchend', clearPosition); + svg.addEventListener('touchcancel', clearPosition); + svg.addEventListener('keydown', handleKeyDown); + svg.addEventListener('blur', handleBlur); + + return () => { + svg.removeEventListener('mousemove', handleMouseMove); + svg.removeEventListener('mouseleave', clearPosition); + svg.removeEventListener('touchstart', handleTouchStart); + svg.removeEventListener('touchmove', handleTouchMove); + svg.removeEventListener('touchend', clearPosition); + svg.removeEventListener('touchcancel', clearPosition); + svg.removeEventListener('keydown', handleKeyDown); + svg.removeEventListener('blur', handleBlur); + }; + }, [ + svgRef, + enableScrubbing, + handleMouseMove, + clearPosition, + handleTouchStart, + handleTouchMove, + handleKeyDown, + handleBlur, + ]); + + const contextValue: ScrubberContextValue = useMemo( + () => ({ + enableScrubbing, + scrubberPosition, + onScrubberPositionChange: setScrubberPositionAndNotify, + }), + [enableScrubbing, scrubberPosition, setScrubberPositionAndNotify], + ); + + return ( + + {children} + + ); +} diff --git a/libs/ui-react-visualization/src/lib/Components/Scrubber/context/index.ts b/libs/ui-react-visualization/src/lib/Components/Scrubber/context/index.ts new file mode 100644 index 000000000..ec56f76ec --- /dev/null +++ b/libs/ui-react-visualization/src/lib/Components/Scrubber/context/index.ts @@ -0,0 +1 @@ +export { ScrubberContextProvider, useScrubberContext } from './scrubberContext'; diff --git a/libs/ui-react-visualization/src/lib/Components/Scrubber/context/scrubberContext.ts b/libs/ui-react-visualization/src/lib/Components/Scrubber/context/scrubberContext.ts new file mode 100644 index 000000000..6e6c7b1e2 --- /dev/null +++ b/libs/ui-react-visualization/src/lib/Components/Scrubber/context/scrubberContext.ts @@ -0,0 +1,14 @@ +import { createSafeContext } from '@ledgerhq/lumen-utils-shared'; + +import type { ScrubberContextValue } from '../types'; + +const [ScrubberContextProvider, _useScrubberSafeContext] = + createSafeContext('Scrubber'); + +export const useScrubberContext = (): ScrubberContextValue => + _useScrubberSafeContext({ + consumerName: 'useScrubberContext', + contextRequired: true, + }); + +export { ScrubberContextProvider }; diff --git a/libs/ui-react-visualization/src/lib/Components/Scrubber/index.ts b/libs/ui-react-visualization/src/lib/Components/Scrubber/index.ts new file mode 100644 index 000000000..725071f8f --- /dev/null +++ b/libs/ui-react-visualization/src/lib/Components/Scrubber/index.ts @@ -0,0 +1,3 @@ +export { Scrubber } from './Scrubber'; +export { useScrubberContext } from './context'; +export type { ScrubberProps, ScrubberContextValue } from './types'; diff --git a/libs/ui-react-visualization/src/lib/Components/Scrubber/types.ts b/libs/ui-react-visualization/src/lib/Components/Scrubber/types.ts new file mode 100644 index 000000000..c0d08dc4d --- /dev/null +++ b/libs/ui-react-visualization/src/lib/Components/Scrubber/types.ts @@ -0,0 +1,55 @@ +import type { ReactNode, RefObject } from 'react'; + +export type ScrubberContextValue = { + /** + * Whether scrubbing interactions are enabled. + */ + enableScrubbing: boolean; + /** + * The current data index of the scrubber, or undefined when idle. + */ + scrubberPosition: number | undefined; + /** + * Callback to update the scrubber position. + */ + onScrubberPositionChange: (index: number | undefined) => void; +}; + +export type ScrubberProviderProps = { + children: ReactNode; + /** + * Ref to the root SVG element where event listeners will be attached. + */ + svgRef: RefObject; + /** + * Whether scrubbing is enabled. + */ + enableScrubbing: boolean; + /** + * Optional external callback fired whenever the scrubber position changes. + */ + onScrubberPositionChange?: (index: number | undefined) => void; +}; + +export type ScrubberProps = { + /** + * Formats a label string shown above the reference line for a given data index. + * When omitted, no label is rendered. + */ + label?: (dataIndex: number) => string; + /** + * Hides the vertical reference line. + * @default false + */ + hideLine?: boolean; + /** + * Hides the semi-transparent overlay that dims data after the scrubber position. + * @default false + */ + hideOverlay?: boolean; + /** + * Shows the beacon dots on each series at the scrubbed data index. + * @default false + */ + showBeacons?: boolean; +}; diff --git a/libs/ui-react-visualization/src/lib/Components/Scrubber/utils.test.ts b/libs/ui-react-visualization/src/lib/Components/Scrubber/utils.test.ts new file mode 100644 index 000000000..5cb4fb856 --- /dev/null +++ b/libs/ui-react-visualization/src/lib/Components/Scrubber/utils.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + getCategoricalScale, + getNumericScale, +} from '../../utils/scales/scales'; +import { + getDataIndexFromPosition, + resolvePixelX, + resolvePixelY, +} from './utils'; + +describe('getDataIndexFromPosition', () => { + describe('with a categorical (band) scale', () => { + const scale = getCategoricalScale({ + domain: { min: 0, max: 3 }, + range: { min: 0, max: 400 }, + padding: 0, + }); + + it('returns 0 for a position at the center of the first band', () => { + const bandwidth = scale.bandwidth(); + const center0 = (scale(0) ?? 0) + bandwidth / 2; + expect(getDataIndexFromPosition(center0, scale, undefined, 4)).toBe(0); + }); + + it('returns 1 for a position at the center of the second band', () => { + const bandwidth = scale.bandwidth(); + const center1 = (scale(1) ?? 0) + bandwidth / 2; + expect(getDataIndexFromPosition(center1, scale, undefined, 4)).toBe(1); + }); + + it('returns the nearest band for a position between bands', () => { + expect(getDataIndexFromPosition(190, scale, undefined, 4)).toBe(1); + expect(getDataIndexFromPosition(210, scale, undefined, 4)).toBe(2); + }); + + it('returns 3 (last) for a position beyond the last band', () => { + expect(getDataIndexFromPosition(500, scale, undefined, 4)).toBe(3); + }); + }); + + describe('with a numeric (linear) scale and no axisData', () => { + const scale = getNumericScale({ + scaleType: 'linear', + domain: { min: 0, max: 4 }, + range: { min: 0, max: 400 }, + }); + + it('returns 0 for pixel 0', () => { + expect(getDataIndexFromPosition(0, scale, undefined, 5)).toBe(0); + }); + + it('returns 2 for pixel 200 (midpoint)', () => { + expect(getDataIndexFromPosition(200, scale, undefined, 5)).toBe(2); + }); + + it('clamps to 0 for negative positions', () => { + expect(getDataIndexFromPosition(-50, scale, undefined, 5)).toBe(0); + }); + + it('clamps to dataLength-1 for positions beyond range', () => { + expect(getDataIndexFromPosition(600, scale, undefined, 5)).toBe(4); + }); + }); + + describe('with a numeric scale and numeric axisData', () => { + const scale = getNumericScale({ + scaleType: 'linear', + domain: { min: 10, max: 30 }, + range: { min: 0, max: 200 }, + }); + const axisConfig = { data: [10, 20, 30] as number[] }; + + it('returns 0 for pixel closest to first data point', () => { + expect(getDataIndexFromPosition(10, scale, axisConfig, 3)).toBe(0); + }); + + it('returns 1 for pixel closest to middle data point', () => { + expect(getDataIndexFromPosition(100, scale, axisConfig, 3)).toBe(1); + }); + + it('returns 2 for pixel closest to last data point', () => { + expect(getDataIndexFromPosition(195, scale, axisConfig, 3)).toBe(2); + }); + }); +}); + +describe('resolvePixelY', () => { + const numericScale = getNumericScale({ + scaleType: 'linear', + domain: { min: 0, max: 100 }, + range: { min: 0, max: 200 }, + }); + + const getYScale = vi.fn(() => numericScale); + + it('returns a pixel value for a valid data point', () => { + const result = resolvePixelY(1, [10, 50, 90], getYScale); + expect(typeof result).toBe('number'); + }); + + it('returns undefined when getYScale returns undefined', () => { + const noScale = vi.fn(() => undefined); + expect(resolvePixelY(0, [10], noScale)).toBeUndefined(); + }); + + it('returns undefined when seriesData is undefined', () => { + expect(resolvePixelY(0, undefined, getYScale)).toBeUndefined(); + }); + + it('returns undefined when the value at dataIndex is null', () => { + expect(resolvePixelY(1, [10, null, 30], getYScale)).toBeUndefined(); + }); + + it('returns undefined when the value at dataIndex is out of bounds', () => { + expect(resolvePixelY(5, [10, 20], getYScale)).toBeUndefined(); + }); +}); + +describe('resolvePixelX', () => { + const numericScale = getNumericScale({ + scaleType: 'linear', + domain: { min: 0, max: 4 }, + range: { min: 0, max: 400 }, + }); + + it('returns a pixel value when a scale is available', () => { + const getXScale = vi.fn(() => numericScale); + const result = resolvePixelX(2, getXScale); + expect(typeof result).toBe('number'); + }); + + it('returns undefined when getXScale returns undefined', () => { + const noScale = vi.fn(() => undefined); + expect(resolvePixelX(0, noScale)).toBeUndefined(); + }); + + it('uses numeric axisConfig.data values instead of the index', () => { + const scale = getNumericScale({ + scaleType: 'linear', + domain: { min: 10, max: 30 }, + range: { min: 0, max: 200 }, + }); + const getXScale = vi.fn(() => scale); + const axisConfig = { data: [10, 20, 30] as number[] }; + + const atIndex = resolvePixelX(1, getXScale); + const atAxisValue = resolvePixelX(1, getXScale, axisConfig); + + expect(atAxisValue).not.toBe(atIndex); + expect(atAxisValue).toBe(scale(20)); + }); + + it('falls back to index when axisConfig.data is strings', () => { + const getXScale = vi.fn(() => numericScale); + const axisConfig = { data: ['a', 'b', 'c'] as string[] }; + + expect(resolvePixelX(2, getXScale, axisConfig)).toBe( + resolvePixelX(2, getXScale), + ); + }); +}); diff --git a/libs/ui-react-visualization/src/lib/Components/Scrubber/utils.ts b/libs/ui-react-visualization/src/lib/Components/Scrubber/utils.ts new file mode 100644 index 000000000..6990bc7cd --- /dev/null +++ b/libs/ui-react-visualization/src/lib/Components/Scrubber/utils.ts @@ -0,0 +1,123 @@ +import { + getPointOnScale, + isCategoricalScale, + isNumericScale, +} from '../../utils/scales/scales'; +import type { AxisConfigProps, ChartScaleFunction } from '../../utils/types'; +import type { useCartesianChartContext } from '../CartesianChart/context'; + +export const BEACON_RADIUS = 5; +export const BEACON_STROKE_WIDTH = 2; +export const LABEL_OFFSET_Y = 12; +export const OVERLAY_OFFSET = 2; +export const OVERLAY_LINE_INSET = 0.5; +export const OVERLAY_OPACITY = 0.8; +export const LINE_GRADIENT_EDGE_OPACITY = 0.1; + +const isNumberArray = (arr: string[] | number[]): arr is number[] => + typeof arr[0] === 'number'; + +/** + * Returns the index of the item whose pixel position is closest to `pixelX`. + * `getPixelPosition` maps each index to its pixel coordinate (or undefined if + * the value cannot be projected). + */ +const findClosestIndex = ( + length: number, + pixelX: number, + getPixelPosition: (index: number) => number | undefined, +): number => { + let closestIndex = 0; + let closestDistance = Infinity; + + for (let i = 0; i < length; i++) { + const pos = getPixelPosition(i); + if (pos === undefined) continue; + + const distance = Math.abs(pixelX - pos); + if (distance < closestDistance) { + closestDistance = distance; + closestIndex = i; + } + } + + return closestIndex; +}; + +/** + * Converts a pixel position along the x-axis into the nearest data index. + * + * For band (categorical) scales, finds the band whose center is closest to + * `pixelX`. For numeric scales, uses `scale.invert()` and rounds to the + * nearest integer, clamped to the valid index range. + */ +export const getDataIndexFromPosition = ( + pixelX: number, + scale: ChartScaleFunction, + axisConfig: Partial | undefined, + dataLength: number, +): number => { + if (isCategoricalScale(scale)) { + const domain = scale.domain(); + const bandwidth = scale.bandwidth(); + return findClosestIndex(domain.length, pixelX, (i) => { + const pos = scale(domain[i]); + return pos === undefined ? undefined : pos + bandwidth / 2; + }); + } + + if (isNumericScale(scale)) { + const axisData = axisConfig?.data; + + if (axisData && axisData.length > 0 && isNumberArray(axisData)) { + return findClosestIndex(axisData.length, pixelX, (i) => + scale(axisData[i]), + ); + } + + const inverted = scale.invert(pixelX); + return Math.max( + 0, + Math.min(Math.round(inverted as number), dataLength - 1), + ); + } + + return 0; +}; + +/** + * Resolves the pixel y-coordinate for a given series data point at a data index. + * Returns undefined when the value is null/missing or the scale is unavailable. + */ +export const resolvePixelY = ( + dataIndex: number, + seriesData: (number | null)[] | undefined, + getYScale: ReturnType['getYScale'], +): number | undefined => { + const yScale = getYScale(); + if (!yScale || !isNumericScale(yScale)) return undefined; + if (!seriesData) return undefined; + + const value = seriesData[dataIndex]; + if (value === null || value === undefined) return undefined; + + return yScale(value) as number; +}; + +/** + * Resolves the pixel x-coordinate for a given data index using the x-scale. + * When numeric x-axis data is provided, the corresponding axis value is used; + * otherwise the data index is used as the x input. + * Returns undefined when the scale is unavailable or the value cannot be mapped. + */ +export const resolvePixelX = ( + dataIndex: number, + getXScale: ReturnType['getXScale'], + axisConfig?: AxisConfigProps, +): number | undefined => { + const scale = getXScale(); + if (!scale) return undefined; + const axisValue = axisConfig?.data?.[dataIndex]; + const xValue = typeof axisValue === 'number' ? axisValue : dataIndex; + return getPointOnScale(xValue, scale); +}; diff --git a/libs/ui-react-visualization/src/lib/Components/index.ts b/libs/ui-react-visualization/src/lib/Components/index.ts index b351f0978..9f8cbccc6 100644 --- a/libs/ui-react-visualization/src/lib/Components/index.ts +++ b/libs/ui-react-visualization/src/lib/Components/index.ts @@ -1,2 +1,3 @@ export * from './LineChart'; export * from './Point'; +export * from './Scrubber'; diff --git a/libs/ui-react-visualization/src/lib/utils/scales/scales.ts b/libs/ui-react-visualization/src/lib/utils/scales/scales.ts index 218e60c14..bdd80b229 100644 --- a/libs/ui-react-visualization/src/lib/utils/scales/scales.ts +++ b/libs/ui-react-visualization/src/lib/utils/scales/scales.ts @@ -73,6 +73,20 @@ export const isNumericScale = ( return !isCategoricalScale(scale); }; +/** + * Converts a single data-space value to pixel-space on a given scale. + * For band scales the result is centered within the band. + */ +export const getPointOnScale = ( + value: number, + scale: ChartScaleFunction, +): number => { + if (isCategoricalScale(scale)) { + return (scale(value) ?? 0) + scale.bandwidth() / 2; + } + return scale(value) as number; +}; + /** * Projects a single data-space coordinate pair into pixel-space. * Handles centering for categorical (band) scales. @@ -82,12 +96,7 @@ export const projectPoint = ( dataY: number, xScale: ChartScaleFunction, yScale: ChartScaleFunction, -): { x: number; y: number } => { - const x = isCategoricalScale(xScale) - ? (xScale(dataX) ?? 0) + xScale.bandwidth() / 2 - : xScale(dataX); - const y = isCategoricalScale(yScale) - ? (yScale(dataY) ?? 0) + yScale.bandwidth() / 2 - : yScale(dataY); - return { x: x, y }; -}; +): { x: number; y: number } => ({ + x: getPointOnScale(dataX, xScale), + y: getPointOnScale(dataY, yScale), +});