From 0a7fd7dd32322e89ef34593cf889f1df346b32bd Mon Sep 17 00:00:00 2001 From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:38:05 +1200 Subject: [PATCH 01/16] Charts: share one rule for a drawable reading Bar and area charts each check a data point's value for validity with slightly different logic scattered inline. Pull the check and the tooltip "No data" formatting into one private module so the upcoming line chart change reuses the same rule instead of a third copy. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GZqouXJVXUJX4ywMC8krzp --- .../area-chart/private/validate-data.ts | 5 ++-- .../charts/src/charts/bar-chart/bar-chart.tsx | 15 ++++------ .../charts/src/charts/private/readings.ts | 23 +++++++++++++++ .../src/charts/private/test/readings.test.ts | 29 +++++++++++++++++++ 4 files changed, 59 insertions(+), 13 deletions(-) create mode 100644 projects/js-packages/charts/src/charts/private/readings.ts create mode 100644 projects/js-packages/charts/src/charts/private/test/readings.test.ts diff --git a/projects/js-packages/charts/src/charts/area-chart/private/validate-data.ts b/projects/js-packages/charts/src/charts/area-chart/private/validate-data.ts index ec03b3f83fd8..b4d07834ba00 100644 --- a/projects/js-packages/charts/src/charts/area-chart/private/validate-data.ts +++ b/projects/js-packages/charts/src/charts/area-chart/private/validate-data.ts @@ -1,4 +1,5 @@ import { __ } from '@wordpress/i18n'; +import { isInvalidReading } from '../../private/readings'; import type { DataPoint, DataPointDate, SeriesData } from '../../../types'; /** @@ -19,9 +20,7 @@ export const validateData = ( data: SeriesData[] ) => { const hasInvalidData = data.some( series => series.data.some( ( point: DataPointDate | DataPoint ) => - isNaN( point.value as number ) || - point.value === null || - point.value === undefined || + isInvalidReading( point.value, { allowMissing: false } ) || ( 'date' in point && point.date && isNaN( point.date.getTime() ) ) ) ); diff --git a/projects/js-packages/charts/src/charts/bar-chart/bar-chart.tsx b/projects/js-packages/charts/src/charts/bar-chart/bar-chart.tsx index f8c698fe6778..2cb9ad933ec4 100644 --- a/projects/js-packages/charts/src/charts/bar-chart/bar-chart.tsx +++ b/projects/js-packages/charts/src/charts/bar-chart/bar-chart.tsx @@ -1,4 +1,3 @@ -import { formatNumber } from '@automattic/number-formatters'; import { PatternLines, PatternCircles, PatternWaves, PatternHexagons } from '@visx/pattern'; import { Axis, BarSeries, BarGroup, Grid, XYChart } from '@visx/xychart'; import { __, sprintf } from '@wordpress/i18n'; @@ -25,6 +24,7 @@ import { attachSubComponents } from '../../utils'; import { useChartChildren } from '../private/chart-composition'; import { ChartInstanceContext } from '../private/chart-instance-context'; import { ChartLayout } from '../private/chart-layout'; +import { formatReading, isInvalidReading } from '../private/readings'; import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state'; import { withResponsive } from '../private/with-responsive'; import plotStyles from '../private/xy-plot/xy-plot.module.scss'; @@ -102,8 +102,7 @@ const validateData = ( data: SeriesData[] ) => { const hasInvalidData = data.some( series => series.data.some( point => - // A null value is a bucket with no reading, which the chart draws as a gap. - ( point.value !== null && isNaN( point.value as number ) ) || + isInvalidReading( point.value, { allowMissing: true } ) || ( ! point.label && ( ! ( 'date' in point && point.date ) || isNaN( point.date.getTime() ) ) ) ) @@ -128,10 +127,6 @@ const renderTooltipRow = ( label: string | undefined, value: string ) => ( ); -// formatNumber( null ) is "0", which would claim a reading of zero for a bucket that has none. -const formatTooltipValue = ( value: number | null | undefined ) => - value == null ? __( 'No data', 'jetpack-charts' ) : formatNumber( value ); - const BarChartInternal: FC< BarChartProps > = ( { data, chartId: providedChartId, @@ -413,10 +408,10 @@ const BarChartInternal: FC< BarChartProps > = ( { return (
{ categoryLabel }
- { renderTooltipRow( primaryKey, formatTooltipValue( nearestDatum.value ) ) } + { renderTooltipRow( primaryKey, formatReading( nearestDatum.value ) ) } { renderTooltipRow( comparisonEntry.series.label, - formatTooltipValue( comparisonDatum.value ) + formatReading( comparisonDatum.value ) ) }
); @@ -425,7 +420,7 @@ const BarChartInternal: FC< BarChartProps > = ( { return (
{ primaryKey }
- { renderTooltipRow( categoryLabel, formatTooltipValue( nearestDatum.value ) ) } + { renderTooltipRow( categoryLabel, formatReading( nearestDatum.value ) ) }
); }, diff --git a/projects/js-packages/charts/src/charts/private/readings.ts b/projects/js-packages/charts/src/charts/private/readings.ts new file mode 100644 index 000000000000..ed6f5a636b52 --- /dev/null +++ b/projects/js-packages/charts/src/charts/private/readings.ts @@ -0,0 +1,23 @@ +import { formatNumber } from '@automattic/number-formatters'; +import { __ } from '@wordpress/i18n'; + +/** + * Whether a data point's value cannot be drawn. + * + * @param value - The point's value. + * @param options - Validation options. + * @param options.allowMissing - Whether `null` is a bucket with no reading rather than a fault. + * @return True when the value is invalid. + */ +export const isInvalidReading = ( value: unknown, { allowMissing }: { allowMissing: boolean } ) => + value === null ? ! allowMissing : isNaN( value as number ); + +/** + * Formats a reading for a tooltip. + * + * @param value - The reading, or null when the bucket has none. + * @return The formatted value. + */ +export const formatReading = ( value: number | null | undefined ) => + // formatNumber( null ) is "0", which would claim a reading of zero for a bucket that has none. + value == null ? __( 'No data', 'jetpack-charts' ) : formatNumber( value ); diff --git a/projects/js-packages/charts/src/charts/private/test/readings.test.ts b/projects/js-packages/charts/src/charts/private/test/readings.test.ts new file mode 100644 index 000000000000..c10ba25e958b --- /dev/null +++ b/projects/js-packages/charts/src/charts/private/test/readings.test.ts @@ -0,0 +1,29 @@ +import { formatReading, isInvalidReading } from '../readings'; + +describe( 'isInvalidReading', () => { + test( 'accepts a number, including zero', () => { + expect( isInvalidReading( 0, { allowMissing: false } ) ).toBe( false ); + expect( isInvalidReading( 42, { allowMissing: false } ) ).toBe( false ); + } ); + + test( 'rejects undefined and NaN whether or not missing readings are allowed', () => { + expect( isInvalidReading( undefined, { allowMissing: true } ) ).toBe( true ); + expect( isInvalidReading( NaN, { allowMissing: true } ) ).toBe( true ); + } ); + + test( 'accepts null only where missing readings are allowed', () => { + expect( isInvalidReading( null, { allowMissing: true } ) ).toBe( false ); + expect( isInvalidReading( null, { allowMissing: false } ) ).toBe( true ); + } ); +} ); + +describe( 'formatReading', () => { + test( 'reads a missing value as No data', () => { + expect( formatReading( null ) ).toBe( 'No data' ); + expect( formatReading( undefined ) ).toBe( 'No data' ); + } ); + + test( 'formats a real zero as zero', () => { + expect( formatReading( 0 ) ).toBe( '0' ); + } ); +} ); From f7c88ae6c398db216b2e0f95bfa4dc3b4f1c7f83 Mon Sep 17 00:00:00 2001 From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:47:16 +1200 Subject: [PATCH 02/16] Charts: let a line chart bucket carry no reading validateData rejected a null value outright. Allowing it lets visx break the line on its own: d3's linear scale maps null to undefined, which already fails the defined check that AreaSeries uses to skip a point. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GZqouXJVXUJX4ywMC8krzp --- .../src/charts/line-chart/line-chart.tsx | 9 ++-- .../line-chart/stories/index.stories.tsx | 5 +- .../line-chart/test/line-chart.test.tsx | 48 ++++++++++++++++--- 3 files changed, 46 insertions(+), 16 deletions(-) diff --git a/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx b/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx index 7618e8c17926..03de1bda7ae4 100644 --- a/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx +++ b/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx @@ -37,6 +37,7 @@ import { useChartChildren } from '../private/chart-composition'; import { ChartInstanceContext, type ChartInstanceRef } from '../private/chart-instance-context'; import { ChartLayout } from '../private/chart-layout'; import { DefaultGlyph } from '../private/default-glyph'; +import { isInvalidReading } from '../private/readings'; import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state'; import { getCurveType } from '../private/time-axis'; import { buildTimeAxisOptions } from '../private/time-axis-options'; @@ -158,19 +159,17 @@ export const renderDefaultTooltip = ( }; const validateData = ( data: SeriesData[] ) => { - if ( ! data?.length ) return 'No data available'; + if ( ! data?.length ) return __( 'No data available', 'jetpack-charts' ); const hasInvalidData = data.some( series => series.data.some( ( point: DataPointDate | DataPoint ) => - isNaN( point.value as number ) || - point.value === null || - point.value === undefined || + isInvalidReading( point.value, { allowMissing: true } ) || ( 'date' in point && point.date && isNaN( point.date.getTime() ) ) ) ); - if ( hasInvalidData ) return 'Invalid data'; + if ( hasInvalidData ) return __( 'Invalid data', 'jetpack-charts' ); return null; }; diff --git a/projects/js-packages/charts/src/charts/line-chart/stories/index.stories.tsx b/projects/js-packages/charts/src/charts/line-chart/stories/index.stories.tsx index 319b10b26bcb..488dd5d64194 100644 --- a/projects/js-packages/charts/src/charts/line-chart/stories/index.stories.tsx +++ b/projects/js-packages/charts/src/charts/line-chart/stories/index.stories.tsx @@ -343,10 +343,7 @@ export const ErrorStates: StoryObj< StoryArgs > = { data={ [ { label: 'Invalid Values', - data: [ - { date: new Date( '2024-01-01' ), value: NaN }, - { date: new Date( '2024-01-02' ), value: null as number | null }, - ], + data: [ { date: new Date( '2024-01-01' ), value: NaN } ], options: {}, }, ] } diff --git a/projects/js-packages/charts/src/charts/line-chart/test/line-chart.test.tsx b/projects/js-packages/charts/src/charts/line-chart/test/line-chart.test.tsx index 035e559569d4..f0f750703f65 100644 --- a/projects/js-packages/charts/src/charts/line-chart/test/line-chart.test.tsx +++ b/projects/js-packages/charts/src/charts/line-chart/test/line-chart.test.tsx @@ -254,18 +254,30 @@ describe( 'LineChart', () => { expect( screen.getByRole( 'grid', { name: /line chart/i } ) ).toBeInTheDocument(); } ); - test( 'handles null or undefined values', () => { + test( 'renders a bucket with no reading instead of failing the whole chart', () => { renderWithTheme( { data: [ { label: 'Series A', data: [ - { date: new Date( '2024-01-01' ), value: null as number | null, label: 'Jan 1' }, - { - date: new Date( '2024-01-02' ), - value: undefined as number | undefined, - label: 'Jan 2', - }, + { date: new Date( '2024-01-01' ), value: null as number | null }, + { date: new Date( '2024-01-02' ), value: 20 }, + ], + }, + ], + } ); + expect( screen.queryByText( /invalid data/i ) ).not.toBeInTheDocument(); + expect( screen.getByRole( 'grid', { name: /line chart/i } ) ).toBeInTheDocument(); + } ); + + test( 'still rejects undefined values', () => { + renderWithTheme( { + data: [ + { + label: 'Series A', + data: [ + { date: new Date( '2024-01-01' ), value: undefined as number | undefined }, + { date: new Date( '2024-01-02' ), value: 20 }, ], }, ], @@ -273,6 +285,28 @@ describe( 'LineChart', () => { expect( screen.getByText( /invalid data/i ) ).toBeInTheDocument(); } ); + test( 'breaks the line at a bucket with no reading', () => { + renderWithTheme( { + data: [ + { + label: 'Series A', + data: [ + { date: new Date( '2024-01-01' ), value: 10 }, + { date: new Date( '2024-01-02' ), value: null as number | null }, + { date: new Date( '2024-01-03' ), value: 20 }, + { date: new Date( '2024-01-04' ), value: 30 }, + ], + }, + ], + } ); + + const grid = screen.getByRole( 'grid', { name: /line chart/i } ); + // eslint-disable-next-line testing-library/no-node-access -- visx's stroke is a ; the axis and grid lines are plain elements sharing that class. + const linePath = grid.querySelector( 'path.visx-line' ); + const gapCount = linePath?.getAttribute( 'd' )?.match( /M/g )?.length; + expect( gapCount ).toBe( 2 ); + } ); + test( 'handles invalid date values', () => { renderWithTheme( { data: [ From 05a0684e0ce03fe55b2178e0fa27617d8ded664e Mon Sep 17 00:00:00 2001 From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:56:38 +1200 Subject: [PATCH 03/16] Charts: say No data in a line tooltip for a bucket with no reading formatNumber( null ) printed 0, claiming a reading the bucket doesn't have. Use formatReading and sort missing readings after numeric ones instead of letting null coerce to 0 in the comparator. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GZqouXJVXUJX4ywMC8krzp --- .../src/charts/line-chart/line-chart.tsx | 21 ++++++++++++------ .../line-chart/test/line-chart.test.tsx | 22 +++++++++++++++++++ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx b/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx index 03de1bda7ae4..d7bb86c0362d 100644 --- a/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx +++ b/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx @@ -1,4 +1,4 @@ -import { formatNumberCompact, formatNumber } from '@automattic/number-formatters'; +import { formatNumberCompact } from '@automattic/number-formatters'; import { LinearGradient } from '@visx/gradient'; import { XYChart, AreaSeries, Grid, Axis, DataContext } from '@visx/xychart'; import { __ } from '@wordpress/i18n'; @@ -37,7 +37,7 @@ import { useChartChildren } from '../private/chart-composition'; import { ChartInstanceContext, type ChartInstanceRef } from '../private/chart-instance-context'; import { ChartLayout } from '../private/chart-layout'; import { DefaultGlyph } from '../private/default-glyph'; -import { isInvalidReading } from '../private/readings'; +import { formatReading, isInvalidReading } from '../private/readings'; import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state'; import { getCurveType } from '../private/time-axis'; import { buildTimeAxisOptions } from '../private/time-axis-options'; @@ -46,7 +46,7 @@ import { useXZoom, ZoomResetButton, ZoomSelectionRect, ZoomClip } from '../priva import plotStyles from '../private/xy-plot/xy-plot.module.scss'; import styles from './line-chart.module.scss'; import { LineChartAnnotation, LineChartAnnotationsOverlay, LineChartGlyph } from './private'; -import type { RenderLineGlyphProps, LineChartProps, TooltipDatum } from './types'; +import type { RenderLineGlyphProps, LineChartProps } from './types'; import type { BucketInfo, DataPoint, @@ -121,12 +121,19 @@ export const renderDefaultTooltip = ( const nearestDatum = tooltipData?.nearestDatum?.datum; if ( ! nearestDatum ) return null; - const tooltipPoints: TooltipDatum[] = Object.entries( tooltipData?.datumByKey || {} ) + const tooltipPoints: { key: string; value: number | null }[] = Object.entries( + tooltipData?.datumByKey || {} + ) .map( ( [ key, { datum } ] ) => ( { key, - value: datum.value as number, + value: datum.value ?? null, } ) ) - .sort( ( a, b ) => b.value - a.value ); + .sort( ( a, b ) => { + if ( a.value === null && b.value === null ) return 0; + if ( a.value === null ) return 1; + if ( b.value === null ) return -1; + return b.value - a.value; + } ); return (
{ point.key }: - { formatNumber( point.value ) } + { formatReading( point.value ) } ) ) } diff --git a/projects/js-packages/charts/src/charts/line-chart/test/line-chart.test.tsx b/projects/js-packages/charts/src/charts/line-chart/test/line-chart.test.tsx index f0f750703f65..a07009bcf744 100644 --- a/projects/js-packages/charts/src/charts/line-chart/test/line-chart.test.tsx +++ b/projects/js-packages/charts/src/charts/line-chart/test/line-chart.test.tsx @@ -270,6 +270,28 @@ describe( 'LineChart', () => { expect( screen.getByRole( 'grid', { name: /line chart/i } ) ).toBeInTheDocument(); } ); + test( 'tooltip reads No data for a bucket with no reading', async () => { + const user = userEvent.setup(); + renderWithTheme( { + data: [ + { + label: 'Series A', + data: [ + { date: new Date( '2024-01-01' ), value: null as number | null }, + { date: new Date( '2024-01-02' ), value: 20 }, + ], + }, + ], + } ); + + screen.getByRole( 'grid', { name: /line chart/i } ).focus(); + await user.keyboard( '{ArrowRight}' ); + + const tooltip = screen.getByTestId( 'chart-tooltip-0' ); + expect( tooltip ).toHaveTextContent( 'No data' ); + expect( tooltip ).not.toHaveTextContent( 'Series A:0' ); + } ); + test( 'still rejects undefined values', () => { renderWithTheme( { data: [ From 5bd91d36d4fc535e727f11a35d67875fce257cbc Mon Sep 17 00:00:00 2001 From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com> Date: Tue, 22 Sep 2026 07:29:10 +1200 Subject: [PATCH 04/16] Charts: put the line's edge glyphs on its first and last real reading d3's linear scale maps a null bucket to undefined, not NaN, and the existing typeof y !== 'number' guard rejects undefined. With a leading or trailing null, LineChartGlyph took data.data[0] or the last point unconditionally, so its start or end glyph silently disappeared instead of sitting on the first or last real reading. Pick the first (find) or last (findLast) point whose scaled y is a finite number, and render nothing when no point has one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GZqouXJVXUJX4ywMC8krzp --- .../line-chart/private/line-chart-glyph.tsx | 21 ++++++++-- .../line-chart/test/line-chart.test.tsx | 38 +++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/projects/js-packages/charts/src/charts/line-chart/private/line-chart-glyph.tsx b/projects/js-packages/charts/src/charts/line-chart/private/line-chart-glyph.tsx index 2b7230aa5487..ce0c5ee44090 100644 --- a/projects/js-packages/charts/src/charts/line-chart/private/line-chart-glyph.tsx +++ b/projects/js-packages/charts/src/charts/line-chart/private/line-chart-glyph.tsx @@ -20,14 +20,29 @@ const LineChartGlyph: FC< LineChartGlyphProps > = ( { const { xScale, yScale } = useContext( DataContext ) || {}; if ( ! xScale || ! yScale ) return null; - if ( data.data.length === 0 ) return null; + // A `null` bucket scales to `undefined`, not `NaN`, so `find`/`findLast` skip it + // to land the edge glyph on the nearest real reading instead of dropping it. + const hasFiniteY = ( datum: ( typeof data.data )[ number ] ) => { + const scaledY = yScale( accessors.yAccessor( datum ) ); + return typeof scaledY === 'number' && Number.isFinite( scaledY ); + }; - const point = position === 'start' ? data.data[ 0 ] : data.data[ data.data.length - 1 ]; + const point = + position === 'start' ? data.data.find( hasFiniteY ) : data.data.findLast( hasFiniteY ); + + if ( ! point ) return null; const x = xScale( accessors.xAccessor( point ) ); const y = yScale( accessors.yAccessor( point ) ); - if ( typeof x !== 'number' || typeof y !== 'number' ) return null; + if ( + typeof x !== 'number' || + typeof y !== 'number' || + ! Number.isFinite( x ) || + ! Number.isFinite( y ) + ) { + return null; + } const size = Math.max( 0, toNumber( glyphStyle?.radius ) ?? 4 ); diff --git a/projects/js-packages/charts/src/charts/line-chart/test/line-chart.test.tsx b/projects/js-packages/charts/src/charts/line-chart/test/line-chart.test.tsx index a07009bcf744..1c8719f27747 100644 --- a/projects/js-packages/charts/src/charts/line-chart/test/line-chart.test.tsx +++ b/projects/js-packages/charts/src/charts/line-chart/test/line-chart.test.tsx @@ -8,7 +8,9 @@ import { GlobalChartsProvider, defaultTheme } from '../../../providers'; import { useGlobalChartsContext } from '../../../providers/chart-context/hooks/use-global-charts-context'; import LineChart, { LineChartUnresponsive } from '../line-chart'; import type { GlobalChartsContextValue } from '../../../providers/chart-context/types'; +import type { DataPointDate } from '../../../types'; import type { ChartInstanceRef } from '../../private/chart-instance-context'; +import type { RenderLineGlyphProps } from '../types'; // Mock useElementSize to return non-zero dimensions in jsdom so charts render const mockRefCallback = jest.fn(); @@ -1098,6 +1100,42 @@ describe( 'LineChart', () => { const endGlyphs = screen.getAllByTestId( /end-glyph/i ); expect( endGlyphs ).toHaveLength( 1 ); } ); + + test( 'puts the start and end glyphs on the first and last real reading', () => { + const recorded: { position?: 'start' | 'end'; value: number | null; y: number }[] = []; + + renderWithTheme( { + withStartGlyphs: true, + withEndGlyphs: true, + renderGlyph: ( props: RenderLineGlyphProps< DataPointDate > ) => { + recorded.push( { + position: props.position, + value: props.datum.value ?? null, + y: props.y, + } ); + return null; + }, + data: [ + { + label: 'Series A', + data: [ + { date: new Date( '2024-01-01' ), value: null as number | null }, + { date: new Date( '2024-01-02' ), value: 10 }, + { date: new Date( '2024-01-03' ), value: 20 }, + { date: new Date( '2024-01-04' ), value: null as number | null }, + ], + }, + ], + } ); + + const start = recorded.find( entry => entry.position === 'start' ); + const end = recorded.find( entry => entry.position === 'end' ); + + expect( start?.value ).toBe( 10 ); + expect( end?.value ).toBe( 20 ); + expect( Number.isFinite( start?.y ) ).toBe( true ); + expect( Number.isFinite( end?.y ) ).toBe( true ); + } ); } ); describe( 'Legend Glyphs', () => { From 788c3e8a465a96b5c0b2928dbad77daf006ee876 Mon Sep 17 00:00:00 2001 From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com> Date: Tue, 22 Sep 2026 07:35:08 +1200 Subject: [PATCH 05/16] Charts: keep a y axis for a line chart window with no readings visx's d3.extent skips null, undefined and NaN, so a window where every visible bucket is null (e.g. entirely before a site launched) left the y scale with no domain. Pin it to [0, 1] in that case, spread before stableYDomain so a hidden series' real range still wins over the empty-domain fallback when rescaleYOnVisibilityChange is false. A caller's own yScale.domain always wins last. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GZqouXJVXUJX4ywMC8krzp --- .../src/charts/line-chart/line-chart.tsx | 24 ++++++- .../line-chart/test/line-chart.test.tsx | 63 +++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx b/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx index d7bb86c0362d..635c1986df8e 100644 --- a/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx +++ b/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx @@ -371,6 +371,18 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >( preventTooltipScroll: tooltipPlacement === 'below-axis', } ); + // visx's d3.extent skips null/undefined/NaN, so a window where every visible bucket + // is null (e.g. entirely before a site launched) leaves the y scale with no domain. + const hasVisibleReading = useMemo( () => { + return dataSorted.some( + series => + isSeriesVisible( series.label ) && + series.data.some( + point => typeof point?.value === 'number' && Number.isFinite( point.value ) + ) + ); + }, [ dataSorted, isSeriesVisible ] ); + const chartOptions = useMemo( () => { return { axis: { @@ -401,11 +413,21 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >( type: 'linear' as const, nice: true, zero: false, + ...( hasVisibleReading ? {} : { domain: [ 0, 1 ] as [ number, number ] } ), ...( stableYDomain ? { domain: stableYDomain } : {} ), ...options?.yScale, }, }; - }, [ options, dataSorted, width, zoom.domain, stableYDomain, formatting, isSeriesVisible ] ); + }, [ + options, + dataSorted, + width, + zoom.domain, + stableYDomain, + hasVisibleReading, + formatting, + isSeriesVisible, + ] ); // Classified from the rendered series, like the axis above: a hidden // hourly line must not leave the tooltip naming an hour the axis dropped. diff --git a/projects/js-packages/charts/src/charts/line-chart/test/line-chart.test.tsx b/projects/js-packages/charts/src/charts/line-chart/test/line-chart.test.tsx index 1c8719f27747..736b24bdade1 100644 --- a/projects/js-packages/charts/src/charts/line-chart/test/line-chart.test.tsx +++ b/projects/js-packages/charts/src/charts/line-chart/test/line-chart.test.tsx @@ -294,6 +294,69 @@ describe( 'LineChart', () => { expect( tooltip ).not.toHaveTextContent( 'Series A:0' ); } ); + test( 'keeps a y axis when every visible bucket has no reading', () => { + renderWithTheme( { + data: [ + { + label: 'Series A', + data: [ + { date: new Date( '2024-01-01' ), value: null as number | null }, + { date: new Date( '2024-01-02' ), value: null as number | null }, + { date: new Date( '2024-01-03' ), value: null as number | null }, + ], + }, + ], + } ); + + expect( screen.queryByText( /invalid data/i ) ).not.toBeInTheDocument(); + + const chart = screen.getByRole( 'grid', { name: /line chart/i } ); + const ticks = within( chart ) + .getAllByText( /^-?[\d.,]+$/ ) + .map( el => el.textContent ?? '' ); + expect( ticks.length ).toBeGreaterThan( 0 ); + ticks.forEach( tick => { + expect( Number.isFinite( Number( tick.replace( /,/g, '' ) ) ) ).toBe( true ); + } ); + } ); + + test( 'pins the value axis to a hidden series with real values, not the empty-domain fallback, when rescaleYOnVisibilityChange is false', () => { + const ref = createRef< ChartInstanceRef >(); + + renderUnwrappedWithTheme( + { + rescaleYOnVisibilityChange: false, + defaultHiddenSeries: [ 'Series A' ], + data: [ + { + label: 'Series A', + data: [ + { date: new Date( '2024-01-01' ), value: 10 }, + { date: new Date( '2024-01-02' ), value: 20 }, + ], + }, + { + label: 'Series B', + data: [ + { date: new Date( '2024-01-01' ), value: null as number | null }, + { date: new Date( '2024-01-02' ), value: null as number | null }, + ], + }, + ], + }, + 'default', + ref + ); + + const domain = ( + ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined + )?.domain(); + + expect( domain ).toBeDefined(); + expect( domain ).not.toEqual( [ 0, 1 ] ); + expect( Math.max( ...( domain ?? [] ) ) ).toBeGreaterThan( 1 ); + } ); + test( 'still rejects undefined values', () => { renderWithTheme( { data: [ From 4c55eb0e9b94000a93fbefdf14e7b1e9ea6e2f76 Mon Sep 17 00:00:00 2001 From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com> Date: Tue, 22 Sep 2026 08:51:38 +1200 Subject: [PATCH 06/16] Charts: document and demonstrate line chart buckets with no reading Adds a BucketsWithNoData story routed through the shared Template so every control still reaches it, documents the null-value behavior in index.docs.mdx and index.api.mdx, and adds the changelog entry. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GZqouXJVXUJX4ywMC8krzp --- ...-chart-cannot-render-a-bucket-with-no-data | 4 +++ .../charts/line-chart/stories/index.api.mdx | 2 +- .../charts/line-chart/stories/index.docs.mdx | 9 +++++- .../line-chart/stories/index.stories.tsx | 32 +++++++++++++++++++ 4 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 projects/js-packages/charts/changelog/charts-284-line-chart-cannot-render-a-bucket-with-no-data diff --git a/projects/js-packages/charts/changelog/charts-284-line-chart-cannot-render-a-bucket-with-no-data b/projects/js-packages/charts/changelog/charts-284-line-chart-cannot-render-a-bucket-with-no-data new file mode 100644 index 000000000000..c767bfe4ae9b --- /dev/null +++ b/projects/js-packages/charts/changelog/charts-284-line-chart-cannot-render-a-bucket-with-no-data @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Line chart: Break the line at a period with no data instead of refusing to render the chart. diff --git a/projects/js-packages/charts/src/charts/line-chart/stories/index.api.mdx b/projects/js-packages/charts/src/charts/line-chart/stories/index.api.mdx index c8fa6a261d85..404799ff6473 100644 --- a/projects/js-packages/charts/src/charts/line-chart/stories/index.api.mdx +++ b/projects/js-packages/charts/src/charts/line-chart/stories/index.api.mdx @@ -117,7 +117,7 @@ The `gradient` option in `SeriesData.options` accepts the following configuratio type DataPointDate = { date?: Date; dateString?: string; // Multiple formats supported - value: number | null; + value: number | null; // null: the bucket has no reading, breaking the line at that point label?: string; }; ``` diff --git a/projects/js-packages/charts/src/charts/line-chart/stories/index.docs.mdx b/projects/js-packages/charts/src/charts/line-chart/stories/index.docs.mdx index 1c911289436d..7b6fbe5ba625 100644 --- a/projects/js-packages/charts/src/charts/line-chart/stories/index.docs.mdx +++ b/projects/js-packages/charts/src/charts/line-chart/stories/index.docs.mdx @@ -437,6 +437,12 @@ Values are automatically formatted as: - **1K and above**: "1k" - **Below 1K**: "1,234" +### Buckets With No Data + +Set a point's `value` to `null` when its bucket has no reading, such as the days before a site launched. The bucket keeps its place on the axis, so the chart still spans the full range, but the line and its gradient fill break at that point instead of connecting across it, and its tooltip reads "No data" rather than 0. The start and end glyphs land on the first and last bucket that has a reading, skipping any null buckets at either end. If every visible series has no reading anywhere in view, the y axis falls back to a `[0, 1]` range. Use `0` only for a real reading of zero. + + + ## Error Handling The chart gracefully handles various error states and edge cases: @@ -446,7 +452,8 @@ The chart gracefully handles various error states and edge cases: **Supported scenarios:** - Empty data arrays - Invalid date values -- Null or NaN numeric values +- NaN numeric values +- Buckets with no reading (null), breaking the line rather than rejected - Single data points - Missing data properties diff --git a/projects/js-packages/charts/src/charts/line-chart/stories/index.stories.tsx b/projects/js-packages/charts/src/charts/line-chart/stories/index.stories.tsx index 488dd5d64194..096f4d101bc1 100644 --- a/projects/js-packages/charts/src/charts/line-chart/stories/index.stories.tsx +++ b/projects/js-packages/charts/src/charts/line-chart/stories/index.stories.tsx @@ -378,6 +378,38 @@ export const ErrorStates: StoryObj< StoryArgs > = { }, }; +const siteLaunchedInApril: SeriesData[] = [ + { + label: 'Subscribers', + data: [ + { date: new Date( 2026, 0, 1 ), value: null }, + { date: new Date( 2026, 1, 1 ), value: null }, + { date: new Date( 2026, 2, 1 ), value: null }, + { date: new Date( 2026, 3, 1 ), value: 12 }, + { date: new Date( 2026, 4, 1 ), value: 31 }, + { date: new Date( 2026, 5, 1 ), value: 58 }, + ], + }, +]; + +export const BucketsWithNoData: StoryObj< StoryArgs > = Template.bind( {} ); +BucketsWithNoData.args = { + ...Default.args, + data: siteLaunchedInApril, +}; +BucketsWithNoData.argTypes = { + // The series-count control swaps in the sample data, which has no gaps to show. + seriesCount: { table: { disable: true } }, +}; +BucketsWithNoData.parameters = { + docs: { + description: { + story: + 'A null value is a bucket with no reading. It keeps its place on the axis so the chart still spans the selected range, but breaks the line and its gradient fill at that point, and its tooltip reads "No data" rather than zero.', + }, + }, +}; + export const WithoutSmoothing: StoryObj< StoryArgs > = Template.bind( {} ); WithoutSmoothing.args = { ...lineChartStoryArgs, From fe9c890263d700f36355cd4809a8dcc1d0b08a33 Mon Sep 17 00:00:00 2001 From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:01:47 +1200 Subject: [PATCH 07/16] Charts: tidy the line chart's no-reading docs, comment and changelog Add the missing changelog entry for the line chart's translated "No data available" and "Invalid data" messages, replace the glyph predicate's disproven undefined-vs-NaN comment with what the code actually does, and reword the no-reading y-axis fallback docs to say no visible series has a reading (not "in view") and to note that a caller's own yScale domain or a pinned rescaleYOnVisibilityChange axis takes priority. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GZqouXJVXUJX4ywMC8krzp --- .../charts/changelog/charts-284-translate-line-chart-errors | 4 ++++ .../charts/src/charts/line-chart/private/line-chart-glyph.tsx | 3 +-- .../charts/src/charts/line-chart/stories/index.docs.mdx | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 projects/js-packages/charts/changelog/charts-284-translate-line-chart-errors diff --git a/projects/js-packages/charts/changelog/charts-284-translate-line-chart-errors b/projects/js-packages/charts/changelog/charts-284-translate-line-chart-errors new file mode 100644 index 000000000000..157533c38213 --- /dev/null +++ b/projects/js-packages/charts/changelog/charts-284-translate-line-chart-errors @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Line chart: Translate the "No data available" and "Invalid data" messages. diff --git a/projects/js-packages/charts/src/charts/line-chart/private/line-chart-glyph.tsx b/projects/js-packages/charts/src/charts/line-chart/private/line-chart-glyph.tsx index ce0c5ee44090..72b839a9b9f1 100644 --- a/projects/js-packages/charts/src/charts/line-chart/private/line-chart-glyph.tsx +++ b/projects/js-packages/charts/src/charts/line-chart/private/line-chart-glyph.tsx @@ -20,8 +20,7 @@ const LineChartGlyph: FC< LineChartGlyphProps > = ( { const { xScale, yScale } = useContext( DataContext ) || {}; if ( ! xScale || ! yScale ) return null; - // A `null` bucket scales to `undefined`, not `NaN`, so `find`/`findLast` skip it - // to land the edge glyph on the nearest real reading instead of dropping it. + // Skip buckets with no reading so the edge glyph lands on the nearest real one. const hasFiniteY = ( datum: ( typeof data.data )[ number ] ) => { const scaledY = yScale( accessors.yAccessor( datum ) ); return typeof scaledY === 'number' && Number.isFinite( scaledY ); diff --git a/projects/js-packages/charts/src/charts/line-chart/stories/index.docs.mdx b/projects/js-packages/charts/src/charts/line-chart/stories/index.docs.mdx index 7b6fbe5ba625..8335e147d8eb 100644 --- a/projects/js-packages/charts/src/charts/line-chart/stories/index.docs.mdx +++ b/projects/js-packages/charts/src/charts/line-chart/stories/index.docs.mdx @@ -439,7 +439,7 @@ Values are automatically formatted as: ### Buckets With No Data -Set a point's `value` to `null` when its bucket has no reading, such as the days before a site launched. The bucket keeps its place on the axis, so the chart still spans the full range, but the line and its gradient fill break at that point instead of connecting across it, and its tooltip reads "No data" rather than 0. The start and end glyphs land on the first and last bucket that has a reading, skipping any null buckets at either end. If every visible series has no reading anywhere in view, the y axis falls back to a `[0, 1]` range. Use `0` only for a real reading of zero. +Set a point's `value` to `null` when its bucket has no reading, such as the days before a site launched. The bucket keeps its place on the axis, so the chart still spans the full range, but the line and its gradient fill break at that point instead of connecting across it, and its tooltip reads "No data" rather than 0. The start and end glyphs land on the first and last bucket that has a reading, skipping any null buckets at either end. If no visible series has a reading, the y axis falls back to a `[0, 1]` range, unless the caller sets `options.yScale.domain` or pins the axis with `rescaleYOnVisibilityChange={ false }`, either of which takes priority. Use `0` only for a real reading of zero. From 4bb85bfd481d5d60b0328a6fb22586022e09323b Mon Sep 17 00:00:00 2001 From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:18:09 +1200 Subject: [PATCH 08/16] Address review: give an empty log-scale axis a positive domain The no-reading fallback used [ 0, 1 ] for every scale, but a log scale cannot map zero, so a caller using type 'log' got a y axis of NaN ticks once every visible bucket was null. It now falls back to [ 1, 10 ] there. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GZqouXJVXUJX4ywMC8krzp --- .../src/charts/line-chart/line-chart.tsx | 6 ++++- .../charts/line-chart/stories/index.docs.mdx | 2 +- .../line-chart/test/line-chart.test.tsx | 27 +++++++++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx b/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx index 635c1986df8e..3d9fd0617eee 100644 --- a/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx +++ b/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx @@ -384,6 +384,10 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >( }, [ dataSorted, isSeriesVisible ] ); const chartOptions = useMemo( () => { + // A log scale cannot reach zero, so its empty axis starts at 1. + const emptyYDomain: [ number, number ] = + options?.yScale?.type === 'log' ? [ 1, 10 ] : [ 0, 1 ]; + return { axis: { x: buildTimeAxisOptions( { @@ -413,7 +417,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >( type: 'linear' as const, nice: true, zero: false, - ...( hasVisibleReading ? {} : { domain: [ 0, 1 ] as [ number, number ] } ), + ...( hasVisibleReading ? {} : { domain: emptyYDomain } ), ...( stableYDomain ? { domain: stableYDomain } : {} ), ...options?.yScale, }, diff --git a/projects/js-packages/charts/src/charts/line-chart/stories/index.docs.mdx b/projects/js-packages/charts/src/charts/line-chart/stories/index.docs.mdx index 8335e147d8eb..414bb8384e67 100644 --- a/projects/js-packages/charts/src/charts/line-chart/stories/index.docs.mdx +++ b/projects/js-packages/charts/src/charts/line-chart/stories/index.docs.mdx @@ -439,7 +439,7 @@ Values are automatically formatted as: ### Buckets With No Data -Set a point's `value` to `null` when its bucket has no reading, such as the days before a site launched. The bucket keeps its place on the axis, so the chart still spans the full range, but the line and its gradient fill break at that point instead of connecting across it, and its tooltip reads "No data" rather than 0. The start and end glyphs land on the first and last bucket that has a reading, skipping any null buckets at either end. If no visible series has a reading, the y axis falls back to a `[0, 1]` range, unless the caller sets `options.yScale.domain` or pins the axis with `rescaleYOnVisibilityChange={ false }`, either of which takes priority. Use `0` only for a real reading of zero. +Set a point's `value` to `null` when its bucket has no reading, such as the days before a site launched. The bucket keeps its place on the axis, so the chart still spans the full range, but the line and its gradient fill break at that point instead of connecting across it, and its tooltip reads "No data" rather than 0. The start and end glyphs land on the first and last bucket that has a reading, skipping any null buckets at either end. If no visible series has a reading, the y axis falls back to a `[0, 1]` range (`[1, 10]` on a log scale), unless the caller sets `options.yScale.domain` or pins the axis with `rescaleYOnVisibilityChange={ false }`, either of which takes priority. Use `0` only for a real reading of zero. diff --git a/projects/js-packages/charts/src/charts/line-chart/test/line-chart.test.tsx b/projects/js-packages/charts/src/charts/line-chart/test/line-chart.test.tsx index 736b24bdade1..953ab8798ae1 100644 --- a/projects/js-packages/charts/src/charts/line-chart/test/line-chart.test.tsx +++ b/projects/js-packages/charts/src/charts/line-chart/test/line-chart.test.tsx @@ -320,6 +320,33 @@ describe( 'LineChart', () => { } ); } ); + test( 'keeps a positive y domain on a log scale when every visible bucket has no reading', () => { + const ref = createRef< ChartInstanceRef >(); + + renderUnwrappedWithTheme( + { + options: { yScale: { type: 'log' } }, + data: [ + { + label: 'Series A', + data: [ + { date: new Date( '2024-01-01' ), value: null as number | null }, + { date: new Date( '2024-01-02' ), value: null as number | null }, + ], + }, + ], + }, + 'default', + ref + ); + + const domain = ( + ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined + )?.domain(); + + expect( domain ).toEqual( [ 1, 10 ] ); + } ); + test( 'pins the value axis to a hidden series with real values, not the empty-domain fallback, when rescaleYOnVisibilityChange is false', () => { const ref = createRef< ChartInstanceRef >(); From 37339fdf0ecc961e32ca992e1396c5159deb84ac Mon Sep 17 00:00:00 2001 From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:39:53 +1200 Subject: [PATCH 09/16] Address review: type the reading check and fix two wordings `isInvalidReading` took `unknown`, which switched off type checking at every call site, so a stray string would have passed validation. It now takes the value type the charts actually hold. The fallback comment said "window", inviting a zoom-filtered scan that would be wrong, and a docs bullet did not parse. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GZqouXJVXUJX4ywMC8krzp --- .../js-packages/charts/src/charts/line-chart/line-chart.tsx | 4 ++-- .../charts/src/charts/line-chart/stories/index.docs.mdx | 2 +- projects/js-packages/charts/src/charts/private/readings.ts | 6 ++++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx b/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx index 3d9fd0617eee..dae82d055278 100644 --- a/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx +++ b/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx @@ -371,8 +371,8 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >( preventTooltipScroll: tooltipPlacement === 'below-axis', } ); - // visx's d3.extent skips null/undefined/NaN, so a window where every visible bucket - // is null (e.g. entirely before a site launched) leaves the y scale with no domain. + // visx's d3.extent skips null/undefined/NaN, so when every visible series is all null + // (e.g. entirely before a site launched) the y scale has no domain. Zoom filters nothing. const hasVisibleReading = useMemo( () => { return dataSorted.some( series => diff --git a/projects/js-packages/charts/src/charts/line-chart/stories/index.docs.mdx b/projects/js-packages/charts/src/charts/line-chart/stories/index.docs.mdx index 414bb8384e67..e667d3bd234d 100644 --- a/projects/js-packages/charts/src/charts/line-chart/stories/index.docs.mdx +++ b/projects/js-packages/charts/src/charts/line-chart/stories/index.docs.mdx @@ -453,7 +453,7 @@ The chart gracefully handles various error states and edge cases: - Empty data arrays - Invalid date values - NaN numeric values -- Buckets with no reading (null), breaking the line rather than rejected +- Buckets with no reading (null), which break the line instead of being rejected - Single data points - Missing data properties diff --git a/projects/js-packages/charts/src/charts/private/readings.ts b/projects/js-packages/charts/src/charts/private/readings.ts index ed6f5a636b52..49264cf166e0 100644 --- a/projects/js-packages/charts/src/charts/private/readings.ts +++ b/projects/js-packages/charts/src/charts/private/readings.ts @@ -9,8 +9,10 @@ import { __ } from '@wordpress/i18n'; * @param options.allowMissing - Whether `null` is a bucket with no reading rather than a fault. * @return True when the value is invalid. */ -export const isInvalidReading = ( value: unknown, { allowMissing }: { allowMissing: boolean } ) => - value === null ? ! allowMissing : isNaN( value as number ); +export const isInvalidReading = ( + value: number | null | undefined, + { allowMissing }: { allowMissing: boolean } +) => ( value === null ? ! allowMissing : value === undefined || isNaN( value ) ); /** * Formats a reading for a tooltip. From 472a8cd79daffc532d441433fcac66c3f4fde4ce Mon Sep 17 00:00:00 2001 From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:25:51 +1200 Subject: [PATCH 10/16] Charts: show a real zero beside the no-data months in the line story Matches the bar chart story: April is now a real zero, so the line starts there at 0 while the months before it draw nothing, which is the distinction between a period with none and a period with no record. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GZqouXJVXUJX4ywMC8krzp --- .../charts/src/charts/line-chart/stories/index.docs.mdx | 2 +- .../src/charts/line-chart/stories/index.stories.tsx | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/projects/js-packages/charts/src/charts/line-chart/stories/index.docs.mdx b/projects/js-packages/charts/src/charts/line-chart/stories/index.docs.mdx index e667d3bd234d..e637039e407c 100644 --- a/projects/js-packages/charts/src/charts/line-chart/stories/index.docs.mdx +++ b/projects/js-packages/charts/src/charts/line-chart/stories/index.docs.mdx @@ -439,7 +439,7 @@ Values are automatically formatted as: ### Buckets With No Data -Set a point's `value` to `null` when its bucket has no reading, such as the days before a site launched. The bucket keeps its place on the axis, so the chart still spans the full range, but the line and its gradient fill break at that point instead of connecting across it, and its tooltip reads "No data" rather than 0. The start and end glyphs land on the first and last bucket that has a reading, skipping any null buckets at either end. If no visible series has a reading, the y axis falls back to a `[0, 1]` range (`[1, 10]` on a log scale), unless the caller sets `options.yScale.domain` or pins the axis with `rescaleYOnVisibilityChange={ false }`, either of which takes priority. Use `0` only for a real reading of zero. +Set a point's `value` to `null` when its bucket has no reading, such as the days before a site launched. The bucket keeps its place on the axis, so the chart still spans the full range, but the line and its gradient fill break at that point instead of connecting across it, and its tooltip reads "No data" rather than 0. The start and end glyphs land on the first and last bucket that has a reading, skipping any null buckets at either end. If no visible series has a reading, the y axis falls back to a `[0, 1]` range (`[1, 10]` on a log scale), unless the caller sets `options.yScale.domain` or pins the axis with `rescaleYOnVisibilityChange={ false }`, either of which takes priority. Use `0` only for a real reading of zero: in the example below April is a real zero, so the line starts there, at 0, while the months before it have nothing. diff --git a/projects/js-packages/charts/src/charts/line-chart/stories/index.stories.tsx b/projects/js-packages/charts/src/charts/line-chart/stories/index.stories.tsx index 096f4d101bc1..01f9fbae52c1 100644 --- a/projects/js-packages/charts/src/charts/line-chart/stories/index.stories.tsx +++ b/projects/js-packages/charts/src/charts/line-chart/stories/index.stories.tsx @@ -385,9 +385,10 @@ const siteLaunchedInApril: SeriesData[] = [ { date: new Date( 2026, 0, 1 ), value: null }, { date: new Date( 2026, 1, 1 ), value: null }, { date: new Date( 2026, 2, 1 ), value: null }, - { date: new Date( 2026, 3, 1 ), value: 12 }, - { date: new Date( 2026, 4, 1 ), value: 31 }, - { date: new Date( 2026, 5, 1 ), value: 58 }, + { date: new Date( 2026, 3, 1 ), value: 0 }, + { date: new Date( 2026, 4, 1 ), value: 12 }, + { date: new Date( 2026, 5, 1 ), value: 31 }, + { date: new Date( 2026, 6, 1 ), value: 58 }, ], }, ]; @@ -405,7 +406,7 @@ BucketsWithNoData.parameters = { docs: { description: { story: - 'A null value is a bucket with no reading. It keeps its place on the axis so the chart still spans the selected range, but breaks the line and its gradient fill at that point, and its tooltip reads "No data" rather than zero.', + 'A null value is a bucket with no reading. It keeps its place on the axis so the chart still spans the selected range, but breaks the line and its gradient fill at that point, and its tooltip reads "No data" rather than zero. April is a real zero, so the line starts there, at 0, rather than in May.', }, }, }; From c63f29278c419d8cb9bde2fcd0f2604df4fbc3f1 Mon Sep 17 00:00:00 2001 From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:44:01 +1200 Subject: [PATCH 11/16] Charts: start a flat line's value axis at zero A line chart scales its value axis to the readings alone, so when every reading is the same the domain collapses to one value and d3 draws the line halfway up the plot. Buckets with no reading make this common: a flat subscriber count after the null months drew its line mid-height beside the same data as full-height bars. Run a flat linear axis from zero to the value, as the bar chart already does. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DxvpmYsGipgDaUwR3zeVe6 --- .../changelog/charts-284-flat-line-value-axis | 4 ++ .../src/charts/line-chart/line-chart.tsx | 57 ++++++++++++++----- .../charts/line-chart/stories/index.docs.mdx | 2 +- .../line-chart/test/line-chart.test.tsx | 57 +++++++++++++++++++ 4 files changed, 106 insertions(+), 14 deletions(-) create mode 100644 projects/js-packages/charts/changelog/charts-284-flat-line-value-axis diff --git a/projects/js-packages/charts/changelog/charts-284-flat-line-value-axis b/projects/js-packages/charts/changelog/charts-284-flat-line-value-axis new file mode 100644 index 000000000000..0371d6a4dec6 --- /dev/null +++ b/projects/js-packages/charts/changelog/charts-284-flat-line-value-axis @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Line chart: Start the value axis at zero for a flat series, so its line is not drawn halfway up the plot. diff --git a/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx b/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx index dae82d055278..a44eb236a775 100644 --- a/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx +++ b/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx @@ -180,6 +180,28 @@ const validateData = ( data: SeriesData[] ) => { return null; }; +// visx derives the y domain from the readings, which fails when they have no range: none at all +// leaves no domain, and a flat line collapses it to one value that d3 draws mid-height. +const getFallbackYDomain = ( + readingExtent: [ number, number ] | undefined, + isLogScale: boolean +): [ number, number ] | undefined => { + // A log scale cannot reach zero, so its empty axis starts at 1. + const emptyDomain: [ number, number ] = isLogScale ? [ 1, 10 ] : [ 0, 1 ]; + + if ( ! readingExtent ) { + return emptyDomain; + } + + const [ min, max ] = readingExtent; + + if ( min !== max || isLogScale ) { + return undefined; + } + + return min === 0 ? emptyDomain : [ Math.min( 0, min ), Math.max( 0, max ) ]; +}; + // Inner component to access DataContext and provide scale data to ref const LineChartScalesRef: FC< { chartRef?: Ref< ChartInstanceRef >; @@ -373,20 +395,29 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >( // visx's d3.extent skips null/undefined/NaN, so when every visible series is all null // (e.g. entirely before a site launched) the y scale has no domain. Zoom filters nothing. - const hasVisibleReading = useMemo( () => { - return dataSorted.some( - series => - isSeriesVisible( series.label ) && - series.data.some( - point => typeof point?.value === 'number' && Number.isFinite( point.value ) - ) - ); + const visibleReadingExtent = useMemo< [ number, number ] | undefined >( () => { + let min = Infinity; + let max = -Infinity; + for ( const series of dataSorted ) { + if ( ! isSeriesVisible( series.label ) ) { + continue; + } + for ( const point of series.data ) { + const value = point?.value; + if ( typeof value === 'number' && Number.isFinite( value ) ) { + min = Math.min( min, value ); + max = Math.max( max, value ); + } + } + } + return min <= max ? [ min, max ] : undefined; }, [ dataSorted, isSeriesVisible ] ); const chartOptions = useMemo( () => { - // A log scale cannot reach zero, so its empty axis starts at 1. - const emptyYDomain: [ number, number ] = - options?.yScale?.type === 'log' ? [ 1, 10 ] : [ 0, 1 ]; + const fallbackYDomain = getFallbackYDomain( + visibleReadingExtent, + options?.yScale?.type === 'log' + ); return { axis: { @@ -417,7 +448,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >( type: 'linear' as const, nice: true, zero: false, - ...( hasVisibleReading ? {} : { domain: emptyYDomain } ), + ...( fallbackYDomain ? { domain: fallbackYDomain } : {} ), ...( stableYDomain ? { domain: stableYDomain } : {} ), ...options?.yScale, }, @@ -428,7 +459,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >( width, zoom.domain, stableYDomain, - hasVisibleReading, + visibleReadingExtent, formatting, isSeriesVisible, ] ); diff --git a/projects/js-packages/charts/src/charts/line-chart/stories/index.docs.mdx b/projects/js-packages/charts/src/charts/line-chart/stories/index.docs.mdx index e637039e407c..00619fe19843 100644 --- a/projects/js-packages/charts/src/charts/line-chart/stories/index.docs.mdx +++ b/projects/js-packages/charts/src/charts/line-chart/stories/index.docs.mdx @@ -439,7 +439,7 @@ Values are automatically formatted as: ### Buckets With No Data -Set a point's `value` to `null` when its bucket has no reading, such as the days before a site launched. The bucket keeps its place on the axis, so the chart still spans the full range, but the line and its gradient fill break at that point instead of connecting across it, and its tooltip reads "No data" rather than 0. The start and end glyphs land on the first and last bucket that has a reading, skipping any null buckets at either end. If no visible series has a reading, the y axis falls back to a `[0, 1]` range (`[1, 10]` on a log scale), unless the caller sets `options.yScale.domain` or pins the axis with `rescaleYOnVisibilityChange={ false }`, either of which takes priority. Use `0` only for a real reading of zero: in the example below April is a real zero, so the line starts there, at 0, while the months before it have nothing. +Set a point's `value` to `null` when its bucket has no reading, such as the days before a site launched. The bucket keeps its place on the axis, so the chart still spans the full range, but the line and its gradient fill break at that point instead of connecting across it, and its tooltip reads "No data" rather than 0. The start and end glyphs land on the first and last bucket that has a reading, skipping any null buckets at either end. If no visible series has a reading, the y axis falls back to a `[0, 1]` range (`[1, 10]` on a log scale). If every reading has the same value, as when a flat count follows the null buckets, a linear y axis runs from 0 to that value, so the line sits at the top of the plot as its bars would. Both fallbacks yield to `options.yScale.domain`, and to an axis pinned with `rescaleYOnVisibilityChange={ false }` when the data across all series has a range. Use `0` only for a real reading of zero: in the example below April is a real zero, so the line starts there, at 0, while the months before it have nothing. diff --git a/projects/js-packages/charts/src/charts/line-chart/test/line-chart.test.tsx b/projects/js-packages/charts/src/charts/line-chart/test/line-chart.test.tsx index 953ab8798ae1..5f79ccbedf55 100644 --- a/projects/js-packages/charts/src/charts/line-chart/test/line-chart.test.tsx +++ b/projects/js-packages/charts/src/charts/line-chart/test/line-chart.test.tsx @@ -347,6 +347,63 @@ describe( 'LineChart', () => { expect( domain ).toEqual( [ 1, 10 ] ); } ); + test.each( [ + [ 'a flat reading beside buckets with no reading', [ null, null, 1, 1 ], [ 0, 1 ] ], + [ 'a flat positive series', [ 5, 5, 5 ], [ 0, 5 ] ], + [ 'a flat negative series', [ -3, -3 ], [ -3, 0 ] ], + [ 'an all-zero series', [ 0, 0 ], [ 0, 1 ] ], + ] )( 'starts the y domain at zero for %s', ( _name, values, expected ) => { + const ref = createRef< ChartInstanceRef >(); + + renderUnwrappedWithTheme( + { + data: [ + { + label: 'Series A', + data: values.map( ( value, index ) => ( { + date: new Date( 2024, 0, index + 1 ), + value: value as number | null, + } ) ), + }, + ], + }, + 'default', + ref + ); + + const domain = ( + ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined + )?.domain(); + + expect( domain ).toEqual( expected ); + } ); + + test( 'keeps the y domain off zero for a series that varies', () => { + const ref = createRef< ChartInstanceRef >(); + + renderUnwrappedWithTheme( + { + data: [ + { + label: 'Series A', + data: [ + { date: new Date( '2024-01-01' ), value: 50 }, + { date: new Date( '2024-01-02' ), value: 60 }, + ], + }, + ], + }, + 'default', + ref + ); + + const domain = ( + ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined + )?.domain(); + + expect( Math.min( ...( domain ?? [] ) ) ).toBeGreaterThan( 0 ); + } ); + test( 'pins the value axis to a hidden series with real values, not the empty-domain fallback, when rescaleYOnVisibilityChange is false', () => { const ref = createRef< ChartInstanceRef >(); From b533e650df763e9ebadb8deac246b74d101d3f86 Mon Sep 17 00:00:00 2001 From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:53:36 +1200 Subject: [PATCH 12/16] Charts: report pointer events on a line chart bucket with no reading visx's nearest search adds the y distance to the x distance, and a null reading scales to NaN, so a pointer over a bucket where no series has a reading fired no pointer event. A drag-to-zoom starting there did nothing, and onPointerDown / onPointerUp never reached a consumer such as a drill-through. Find the nearest datum along x ourselves, preferring a series with a reading, as the bar chart already does for its bands. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GZqouXJVXUJX4ywMC8krzp --- .../src/charts/line-chart/line-chart.tsx | 12 +- .../src/charts/line-chart/private/index.ts | 1 + .../private/nearest-pointer-events.tsx | 139 ++++++++++++++++++ .../line-chart/test/line-chart.test.tsx | 96 +++++++++++- 4 files changed, 241 insertions(+), 7 deletions(-) create mode 100644 projects/js-packages/charts/src/charts/line-chart/private/nearest-pointer-events.tsx diff --git a/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx b/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx index a44eb236a775..c59c3d9f4618 100644 --- a/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx +++ b/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx @@ -45,7 +45,12 @@ import { withResponsive } from '../private/with-responsive'; import { useXZoom, ZoomResetButton, ZoomSelectionRect, ZoomClip } from '../private/x-zoom'; import plotStyles from '../private/xy-plot/xy-plot.module.scss'; import styles from './line-chart.module.scss'; -import { LineChartAnnotation, LineChartAnnotationsOverlay, LineChartGlyph } from './private'; +import { + LineChartAnnotation, + LineChartAnnotationsOverlay, + LineChartGlyph, + NearestPointerEvents, +} from './private'; import type { RenderLineGlyphProps, LineChartProps } from './types'; import type { BucketInfo, @@ -647,12 +652,9 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >( // xScale and yScale could be set in Axis as well, but they are `scale` props there. xScale={ chartOptions.xScale } yScale={ chartOptions.yScale } - onPointerDown={ zoom.handlers.onPointerDown } - onPointerUp={ zoom.handlers.onPointerUp } - onPointerMove={ zoom.handlers.onPointerMove } onPointerOut={ onPointerOut } - pointerEventsDataKey="nearest" > + { /* With every series hidden there is no data to scale against, so the grid and axes are dropped while the empty state stands in — otherwise they render squished at the top. */ } diff --git a/projects/js-packages/charts/src/charts/line-chart/private/index.ts b/projects/js-packages/charts/src/charts/line-chart/private/index.ts index 89c6ef605b45..fae9ef593f85 100644 --- a/projects/js-packages/charts/src/charts/line-chart/private/index.ts +++ b/projects/js-packages/charts/src/charts/line-chart/private/index.ts @@ -2,3 +2,4 @@ export { default as LineChartAnnotationLabelWithPopover } from './line-chart-ann export { default as LineChartAnnotationsOverlay } from './line-chart-annotations-overlay'; export { default as LineChartAnnotation } from './line-chart-annotation'; export { default as LineChartGlyph } from './line-chart-glyph'; +export { NearestPointerEvents } from './nearest-pointer-events'; diff --git a/projects/js-packages/charts/src/charts/line-chart/private/nearest-pointer-events.tsx b/projects/js-packages/charts/src/charts/line-chart/private/nearest-pointer-events.tsx new file mode 100644 index 000000000000..71c4c400c6cd --- /dev/null +++ b/projects/js-packages/charts/src/charts/line-chart/private/nearest-pointer-events.tsx @@ -0,0 +1,139 @@ +import { DataContext, useEventEmitter } from '@visx/xychart'; +import { useCallback, useContext, useMemo } from 'react'; +import type { EventHandlerParams } from '@visx/xychart'; + +type EmittedPointer = Parameters< NonNullable< Parameters< typeof useEventEmitter >[ 1 ] > >[ 0 ]; +type PointerHandler = ( params: EventHandlerParams< object > ) => void; +type Scale = ( value: unknown ) => unknown; +type Candidate = EventHandlerParams< object >; + +/** + * Find the datum nearest a pointer along x, in one series. + * + * @param data - The series' registered data. + * @param xAccessor - Reads a datum's x value. + * @param xScale - The chart's x scale. + * @param x - Pointer x in SVG coordinates. + * @return Index of the nearest datum and its distance, or index -1 when none has a position. + */ +function nearestByX( + data: object[], + xAccessor: ( datum: object ) => unknown, + xScale: Scale, + x: number +): { index: number; distance: number } { + let index = -1; + let distance = Infinity; + data.forEach( ( datum, candidateIndex ) => { + const candidate = Math.abs( Number( xScale( xAccessor( datum ) ) ) - x ); + if ( candidate < distance ) { + distance = candidate; + index = candidateIndex; + } + } ); + return { index, distance }; +} + +/** + * Pick the candidate to report: the closest with a reading, else the closest along x. + * + * @param candidates - One candidate per series; a bucket with no reading has a NaN `distanceY`. + * @return The chosen candidate, or undefined when there are none. + */ +function pickNearest( candidates: Candidate[] ): Candidate | undefined { + const withReading = candidates.filter( candidate => Number.isFinite( candidate.distanceY ) ); + const pool = withReading.length ? withReading : candidates; + const distance = ( candidate: Candidate ) => + withReading.length + ? Math.hypot( candidate.distanceX ?? 0, candidate.distanceY ?? 0 ) + : ( candidate.distanceX ?? 0 ); + return pool.reduce< Candidate | undefined >( + ( best, candidate ) => + ! best || distance( candidate ) < distance( best ) ? candidate : best, + undefined + ); +} + +/** + * Report pointer events at the nearest datum, including a bucket with no reading. + * + * visx's own nearest search measures y distance too, which is NaN for a null value, so a pointer + * over a bucket where no series has a reading fires no event at all. + * + * @param props - Handlers to call. + * @param props.onPointerDown - Receives the nearest datum on pointer down. + * @param props.onPointerMove - Receives the nearest datum on pointer move. + * @param props.onPointerUp - Receives the nearest datum on pointer up. + * @return No visual content. + */ +export function NearestPointerEvents( { + onPointerDown, + onPointerMove, + onPointerUp, +}: { + onPointerDown?: PointerHandler; + onPointerMove?: PointerHandler; + onPointerUp?: PointerHandler; +} ) { + const { xScale, yScale, dataRegistry } = useContext( DataContext ); + + const findNearest = useCallback( + ( params: EmittedPointer ) => { + const point = params?.svgPoint; + if ( ! point || ! xScale || ! yScale || ! dataRegistry ) { + return undefined; + } + const candidates: Candidate[] = []; + for ( const key of dataRegistry.keys() ) { + const entry = dataRegistry.get( key ); + if ( ! entry ) { + continue; + } + const { index, distance } = nearestByX( + entry.data, + entry.xAccessor, + xScale as Scale, + point.x + ); + if ( index < 0 ) { + continue; + } + const datum = entry.data[ index ]; + candidates.push( { + event: params.event, + svgPoint: point, + key, + datum, + index, + distanceX: distance, + distanceY: Math.abs( + Number( ( yScale as Scale )( entry.yAccessor( datum ) ) ) - point.y + ), + } ); + } + return pickNearest( candidates ); + }, + [ xScale, yScale, dataRegistry ] + ); + + const handlers = useMemo( () => { + const report = ( handler?: PointerHandler ) => + handler && + ( ( params: EmittedPointer ) => { + const nearest = findNearest( params ); + if ( nearest ) { + handler( nearest ); + } + } ); + return { + pointerdown: report( onPointerDown ), + pointermove: report( onPointerMove ), + pointerup: report( onPointerUp ), + }; + }, [ findNearest, onPointerDown, onPointerMove, onPointerUp ] ); + + useEventEmitter( 'pointerdown', handlers.pointerdown ); + useEventEmitter( 'pointermove', handlers.pointermove ); + useEventEmitter( 'pointerup', handlers.pointerup ); + return null; +} diff --git a/projects/js-packages/charts/src/charts/line-chart/test/line-chart.test.tsx b/projects/js-packages/charts/src/charts/line-chart/test/line-chart.test.tsx index 5f79ccbedf55..764b4db86da7 100644 --- a/projects/js-packages/charts/src/charts/line-chart/test/line-chart.test.tsx +++ b/projects/js-packages/charts/src/charts/line-chart/test/line-chart.test.tsx @@ -1,6 +1,6 @@ /* eslint-disable react/jsx-no-bind */ -import { render, screen, waitFor, within, act } from '@testing-library/react'; +import { render, screen, waitFor, within, act, fireEvent } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { GlyphDiamond } from '@visx/glyph'; import { createElement, createRef } from 'react'; @@ -8,7 +8,7 @@ import { GlobalChartsProvider, defaultTheme } from '../../../providers'; import { useGlobalChartsContext } from '../../../providers/chart-context/hooks/use-global-charts-context'; import LineChart, { LineChartUnresponsive } from '../line-chart'; import type { GlobalChartsContextValue } from '../../../providers/chart-context/types'; -import type { DataPointDate } from '../../../types'; +import type { DataPointDate, SeriesData } from '../../../types'; import type { ChartInstanceRef } from '../../private/chart-instance-context'; import type { RenderLineGlyphProps } from '../types'; @@ -1954,6 +1954,98 @@ describe( 'LineChart', () => { } ); } ); + describe( 'pointer events', () => { + let screenTransform: PropertyDescriptor | undefined; + + beforeAll( () => { + screenTransform = Object.getOwnPropertyDescriptor( SVGElement.prototype, 'getScreenCTM' ); + // jsdom lacks the SVG transform used by visx to convert pointer coordinates. + Object.defineProperty( SVGElement.prototype, 'getScreenCTM', { + configurable: true, + value: () => null, + } ); + } ); + + afterAll( () => { + if ( screenTransform ) { + Object.defineProperty( SVGElement.prototype, 'getScreenCTM', screenTransform ); + } else { + Reflect.deleteProperty( SVGElement.prototype, 'getScreenCTM' ); + } + } ); + + const pointer = ( type: string, clientX: number, clientY: number ) => { + // visx owns the pointer capture rect and does not expose an attribute prop for it. + // eslint-disable-next-line testing-library/no-node-access + const target = screen.getByRole( 'grid' ).querySelector( 'svg > rect[fill="transparent"]' ); + fireEvent( target, new MouseEvent( type, { bubbles: true, clientX, clientY } ) ); + }; + + const renderWithHandlers = ( data: SeriesData[] ) => { + const handlers = { + onPointerDown: jest.fn(), + onPointerMove: jest.fn(), + onPointerUp: jest.fn(), + }; + mockUseXZoom.mockImplementation( () => ( { ...passthroughZoom(), handlers } ) ); + const ref = createRef< ChartInstanceRef >(); + renderUnwrappedWithTheme( { data }, 'default', ref ); + const xScale = ref.current?.getScales()?.xScale as ( date: Date ) => number; + const yScale = ref.current?.getScales()?.yScale as ( value: number ) => number; + return { handlers, xScale, yScale }; + }; + + const series = ( label: string, values: ( number | null )[] ) => ( { + label, + options: {}, + data: values.map( ( value, index ) => ( { + date: new Date( 2024, 0, index + 1 ), + value, + } ) ), + } ); + + test( 'reports a bucket with no reading on pointer down, move and up', () => { + const data = [ series( 'Series A', [ null, 10, 20 ] ) ]; + const { handlers, xScale } = renderWithHandlers( data ); + const x = xScale( data[ 0 ].data[ 0 ].date ); + + for ( const [ type, handler ] of [ + [ 'pointerdown', handlers.onPointerDown ], + [ 'pointermove', handlers.onPointerMove ], + [ 'pointerup', handlers.onPointerUp ], + ] as const ) { + pointer( type, x, 150 ); + expect( handler ).toHaveBeenCalledTimes( 1 ); + expect( handler ).toHaveBeenLastCalledWith( + expect.objectContaining( { key: 'Series A', index: 0, datum: data[ 0 ].data[ 0 ] } ) + ); + } + } ); + + test( 'prefers a series with a reading over one without at the same bucket', () => { + const data = [ series( 'Series A', [ null, 10 ] ), series( 'Series B', [ 5, 10 ] ) ]; + const { handlers, xScale, yScale } = renderWithHandlers( data ); + + pointer( 'pointerdown', xScale( data[ 0 ].data[ 0 ].date ), yScale( 10 ) ); + + expect( handlers.onPointerDown ).toHaveBeenCalledTimes( 1 ); + expect( handlers.onPointerDown ).toHaveBeenLastCalledWith( + expect.objectContaining( { key: 'Series B', index: 0 } ) + ); + } ); + + test( 'reports the series nearest the pointer when every series has a reading', () => { + const data = [ series( 'Series A', [ 10, 10 ] ), series( 'Series B', [ 20, 20 ] ) ]; + const { handlers, xScale, yScale } = renderWithHandlers( data ); + + pointer( 'pointerdown', xScale( data[ 0 ].data[ 1 ].date ), yScale( 11 ) ); + + expect( handlers.onPointerDown ).toHaveBeenLastCalledWith( + expect.objectContaining( { key: 'Series A', index: 1 } ) + ); + } ); + } ); + // The line is not animated, so it clips only while actually zoomed. test( 'clips the series to the plot when zoomed', () => { mockUseXZoom.mockImplementation( () => ( { From dbb97741c9310a5a26f023891def5a71c5b21bc1 Mon Sep 17 00:00:00 2001 From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:53:53 +1200 Subject: [PATCH 13/16] Address review: share one reading check for the line chart's domains The stable and fallback y domains each spelled out their own finite-number test. One type guard beside the other reading rules keeps them from drifting apart. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GZqouXJVXUJX4ywMC8krzp --- .../charts/src/charts/line-chart/line-chart.tsx | 6 +++--- .../js-packages/charts/src/charts/private/readings.ts | 9 +++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx b/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx index c59c3d9f4618..50feffa62d6e 100644 --- a/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx +++ b/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx @@ -37,7 +37,7 @@ import { useChartChildren } from '../private/chart-composition'; import { ChartInstanceContext, type ChartInstanceRef } from '../private/chart-instance-context'; import { ChartLayout } from '../private/chart-layout'; import { DefaultGlyph } from '../private/default-glyph'; -import { formatReading, isInvalidReading } from '../private/readings'; +import { formatReading, isInvalidReading, isReading } from '../private/readings'; import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state'; import { getCurveType } from '../private/time-axis'; import { buildTimeAxisOptions } from '../private/time-axis-options'; @@ -364,7 +364,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >( for ( const series of dataSorted ) { for ( const point of series.data ?? [] ) { const value = point?.value; - if ( typeof value === 'number' && Number.isFinite( value ) ) { + if ( isReading( value ) ) { min = Math.min( min, value ); max = Math.max( max, value ); } @@ -409,7 +409,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >( } for ( const point of series.data ) { const value = point?.value; - if ( typeof value === 'number' && Number.isFinite( value ) ) { + if ( isReading( value ) ) { min = Math.min( min, value ); max = Math.max( max, value ); } diff --git a/projects/js-packages/charts/src/charts/private/readings.ts b/projects/js-packages/charts/src/charts/private/readings.ts index 49264cf166e0..5e8f1c2e5234 100644 --- a/projects/js-packages/charts/src/charts/private/readings.ts +++ b/projects/js-packages/charts/src/charts/private/readings.ts @@ -1,6 +1,15 @@ import { formatNumber } from '@automattic/number-formatters'; import { __ } from '@wordpress/i18n'; +/** + * Whether a value is a reading a scale can place. + * + * @param value - The point's value. + * @return True for a finite number. + */ +export const isReading = ( value: unknown ): value is number => + typeof value === 'number' && Number.isFinite( value ); + /** * Whether a data point's value cannot be drawn. * From 5000af5a09cec4435a5ebbd01f521f2f3ea71bfb Mon Sep 17 00:00:00 2001 From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:54:10 +1200 Subject: [PATCH 14/16] Address review: let TooltipDatum carry a bucket with no reading The default tooltip had moved to a local row type once a value could be null, leaving the exported TooltipDatum describing rows the chart no longer produces. Widen it and use it again, before a consumer builds on the narrower shape. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GZqouXJVXUJX4ywMC8krzp --- .../js-packages/charts/src/charts/line-chart/line-chart.tsx | 6 ++---- projects/js-packages/charts/src/charts/line-chart/types.ts | 3 ++- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx b/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx index 50feffa62d6e..2747f12419c6 100644 --- a/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx +++ b/projects/js-packages/charts/src/charts/line-chart/line-chart.tsx @@ -51,7 +51,7 @@ import { LineChartGlyph, NearestPointerEvents, } from './private'; -import type { RenderLineGlyphProps, LineChartProps } from './types'; +import type { RenderLineGlyphProps, LineChartProps, TooltipDatum } from './types'; import type { BucketInfo, DataPoint, @@ -126,9 +126,7 @@ export const renderDefaultTooltip = ( const nearestDatum = tooltipData?.nearestDatum?.datum; if ( ! nearestDatum ) return null; - const tooltipPoints: { key: string; value: number | null }[] = Object.entries( - tooltipData?.datumByKey || {} - ) + const tooltipPoints: TooltipDatum[] = Object.entries( tooltipData?.datumByKey || {} ) .map( ( [ key, { datum } ] ) => ( { key, value: datum.value ?? null, diff --git a/projects/js-packages/charts/src/charts/line-chart/types.ts b/projects/js-packages/charts/src/charts/line-chart/types.ts index 987a97fc8e45..f26bdd0c04ec 100644 --- a/projects/js-packages/charts/src/charts/line-chart/types.ts +++ b/projects/js-packages/charts/src/charts/line-chart/types.ts @@ -81,7 +81,8 @@ export interface LineChartProps extends BaseChartProps< SeriesData[] >, SeriesVi export type TooltipDatum = { key: string; - value: number; + /** The series' reading, or null when the bucket has none. */ + value: number | null; }; export type LineChartGlyphProps = { From 8ccdd3879ed1862e333de014ab2a4afcf41b41d9 Mon Sep 17 00:00:00 2001 From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:56:17 +1200 Subject: [PATCH 15/16] Charts: let the no-data line story zoom from a bucket with no reading The story had no zoomable control, so the pointer fix could not be tried where the null buckets are. Document that pointer handlers receive the null datum. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GZqouXJVXUJX4ywMC8krzp --- .../charts/src/charts/line-chart/stories/index.docs.mdx | 2 +- .../charts/src/charts/line-chart/stories/index.stories.tsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/projects/js-packages/charts/src/charts/line-chart/stories/index.docs.mdx b/projects/js-packages/charts/src/charts/line-chart/stories/index.docs.mdx index 00619fe19843..5d37434c9592 100644 --- a/projects/js-packages/charts/src/charts/line-chart/stories/index.docs.mdx +++ b/projects/js-packages/charts/src/charts/line-chart/stories/index.docs.mdx @@ -439,7 +439,7 @@ Values are automatically formatted as: ### Buckets With No Data -Set a point's `value` to `null` when its bucket has no reading, such as the days before a site launched. The bucket keeps its place on the axis, so the chart still spans the full range, but the line and its gradient fill break at that point instead of connecting across it, and its tooltip reads "No data" rather than 0. The start and end glyphs land on the first and last bucket that has a reading, skipping any null buckets at either end. If no visible series has a reading, the y axis falls back to a `[0, 1]` range (`[1, 10]` on a log scale). If every reading has the same value, as when a flat count follows the null buckets, a linear y axis runs from 0 to that value, so the line sits at the top of the plot as its bars would. Both fallbacks yield to `options.yScale.domain`, and to an axis pinned with `rescaleYOnVisibilityChange={ false }` when the data across all series has a range. Use `0` only for a real reading of zero: in the example below April is a real zero, so the line starts there, at 0, while the months before it have nothing. +Set a point's `value` to `null` when its bucket has no reading, such as the days before a site launched. The bucket keeps its place on the axis, so the chart still spans the full range, but the line and its gradient fill break at that point instead of connecting across it, and its tooltip reads "No data" rather than 0. The start and end glyphs land on the first and last bucket that has a reading, skipping any null buckets at either end. Pointer handlers and drag-to-zoom still reach a null bucket, and `onPointerDown` / `onPointerUp` receive its datum with a `null` value. If no visible series has a reading, the y axis falls back to a `[0, 1]` range (`[1, 10]` on a log scale). If every reading has the same value, as when a flat count follows the null buckets, a linear y axis runs from 0 to that value, so the line sits at the top of the plot as its bars would. Both fallbacks yield to `options.yScale.domain`, and to an axis pinned with `rescaleYOnVisibilityChange={ false }` when the data across all series has a range. Use `0` only for a real reading of zero: in the example below April is a real zero, so the line starts there, at 0, while the months before it have nothing. diff --git a/projects/js-packages/charts/src/charts/line-chart/stories/index.stories.tsx b/projects/js-packages/charts/src/charts/line-chart/stories/index.stories.tsx index 01f9fbae52c1..8037dab5e53d 100644 --- a/projects/js-packages/charts/src/charts/line-chart/stories/index.stories.tsx +++ b/projects/js-packages/charts/src/charts/line-chart/stories/index.stories.tsx @@ -401,12 +401,13 @@ BucketsWithNoData.args = { BucketsWithNoData.argTypes = { // The series-count control swaps in the sample data, which has no gaps to show. seriesCount: { table: { disable: true } }, + zoomable: { control: 'boolean' }, }; BucketsWithNoData.parameters = { docs: { description: { story: - 'A null value is a bucket with no reading. It keeps its place on the axis so the chart still spans the selected range, but breaks the line and its gradient fill at that point, and its tooltip reads "No data" rather than zero. April is a real zero, so the line starts there, at 0, rather than in May.', + 'A null value is a bucket with no reading. It keeps its place on the axis so the chart still spans the selected range, but breaks the line and its gradient fill at that point, and its tooltip reads "No data" rather than zero. April is a real zero, so the line starts there, at 0, rather than in May. Pointer events still reach a bucket with no reading, so with `zoomable` a drag can start in January.', }, }, }; From 25ca546ae9cd26e457a037277fd44250db7ac894 Mon Sep 17 00:00:00 2001 From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:38:47 +1200 Subject: [PATCH 16/16] Address review: bisect the pointer lookup and skip unplaceable readings Scanning every point on every pointer move cost O(n) per series where visx bisected; the data is sorted by date, so bisect it the same way. A numeric reading a log scale cannot place scaled to a non-finite y and fell into the no-reading fallback, firing a callback for an invisible point; only a genuine null bucket takes that path now. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GZqouXJVXUJX4ywMC8krzp --- .../private/nearest-pointer-events.tsx | 90 +++++++++++-------- .../line-chart/test/line-chart.test.tsx | 33 ++++++- 2 files changed, 84 insertions(+), 39 deletions(-) diff --git a/projects/js-packages/charts/src/charts/line-chart/private/nearest-pointer-events.tsx b/projects/js-packages/charts/src/charts/line-chart/private/nearest-pointer-events.tsx index 71c4c400c6cd..fa59e790dcb9 100644 --- a/projects/js-packages/charts/src/charts/line-chart/private/nearest-pointer-events.tsx +++ b/projects/js-packages/charts/src/charts/line-chart/private/nearest-pointer-events.tsx @@ -1,16 +1,18 @@ +import { bisector } from '@visx/vendor/d3-array'; import { DataContext, useEventEmitter } from '@visx/xychart'; import { useCallback, useContext, useMemo } from 'react'; import type { EventHandlerParams } from '@visx/xychart'; type EmittedPointer = Parameters< NonNullable< Parameters< typeof useEventEmitter >[ 1 ] > >[ 0 ]; -type PointerHandler = ( params: EventHandlerParams< object > ) => void; -type Scale = ( value: unknown ) => unknown; -type Candidate = EventHandlerParams< object >; +type PointerParams = EventHandlerParams< object >; +type PointerHandler = ( params: PointerParams ) => void; +type Scale = ( ( value: unknown ) => unknown ) & { invert?: ( position: number ) => unknown }; +type Candidate = { params: PointerParams; isMissing: boolean }; /** - * Find the datum nearest a pointer along x, in one series. + * Find the datum nearest a pointer along x, in one series sorted by x. * - * @param data - The series' registered data. + * @param data - The series' registered data, in ascending x order. * @param xAccessor - Reads a datum's x value. * @param xScale - The chart's x scale. * @param x - Pointer x in SVG coordinates. @@ -22,34 +24,46 @@ function nearestByX( xScale: Scale, x: number ): { index: number; distance: number } { - let index = -1; - let distance = Infinity; - data.forEach( ( datum, candidateIndex ) => { - const candidate = Math.abs( Number( xScale( xAccessor( datum ) ) ) - x ); - if ( candidate < distance ) { - distance = candidate; - index = candidateIndex; - } - } ); - return { index, distance }; + const indexes = + typeof xScale.invert === 'function' + ? [ bisector( xAccessor ).left( data, xScale.invert( x ) ) ].flatMap( index => [ + index - 1, + index, + ] ) + : data.map( ( _datum, index ) => index ); + + return indexes + .filter( index => index >= 0 && index < data.length ) + .reduce( + ( best, index ) => { + const distance = Math.abs( Number( xScale( xAccessor( data[ index ] ) ) ) - x ); + return distance < best.distance ? { index, distance } : best; + }, + { index: -1, distance: Infinity } + ); } /** - * Pick the candidate to report: the closest with a reading, else the closest along x. + * Pick the candidate to report: the closest placed reading, else the closest bucket with no reading along x. * - * @param candidates - One candidate per series; a bucket with no reading has a NaN `distanceY`. - * @return The chosen candidate, or undefined when there are none. + * @param candidates - One candidate per series. + * @return The chosen candidate's params, or undefined when there are none. */ -function pickNearest( candidates: Candidate[] ): Candidate | undefined { - const withReading = candidates.filter( candidate => Number.isFinite( candidate.distanceY ) ); - const pool = withReading.length ? withReading : candidates; - const distance = ( candidate: Candidate ) => - withReading.length - ? Math.hypot( candidate.distanceX ?? 0, candidate.distanceY ?? 0 ) - : ( candidate.distanceX ?? 0 ); - return pool.reduce< Candidate | undefined >( - ( best, candidate ) => - ! best || distance( candidate ) < distance( best ) ? candidate : best, +function pickNearest( candidates: Candidate[] ): PointerParams | undefined { + const placed = candidates + .map( ( { params } ) => params ) + .filter( params => Number.isFinite( params.distanceY ) ); + const missing = candidates + .filter( ( { isMissing } ) => isMissing ) + .map( ( { params } ) => params ); + // Same metric as visx's TooltipProvider, so a callback names the datum the tooltip shows. + const distance = ( params: PointerParams ) => + placed.length + ? Math.hypot( params.distanceX ?? 0, params.distanceY ?? 0 ) + : ( params.distanceX ?? 0 ); + + return ( placed.length ? placed : missing ).reduce< PointerParams | undefined >( + ( best, params ) => ( ! best || distance( params ) < distance( best ) ? params : best ), undefined ); } @@ -99,16 +113,18 @@ export function NearestPointerEvents( { continue; } const datum = entry.data[ index ]; + const value = entry.yAccessor( datum ); candidates.push( { - event: params.event, - svgPoint: point, - key, - datum, - index, - distanceX: distance, - distanceY: Math.abs( - Number( ( yScale as Scale )( entry.yAccessor( datum ) ) ) - point.y - ), + params: { + event: params.event, + svgPoint: point, + key, + datum, + index, + distanceX: distance, + distanceY: Math.abs( Number( ( yScale as Scale )( value ) ) - point.y ), + }, + isMissing: value == null, } ); } return pickNearest( candidates ); diff --git a/projects/js-packages/charts/src/charts/line-chart/test/line-chart.test.tsx b/projects/js-packages/charts/src/charts/line-chart/test/line-chart.test.tsx index 764b4db86da7..f5db54f800b2 100644 --- a/projects/js-packages/charts/src/charts/line-chart/test/line-chart.test.tsx +++ b/projects/js-packages/charts/src/charts/line-chart/test/line-chart.test.tsx @@ -1981,7 +1981,7 @@ describe( 'LineChart', () => { fireEvent( target, new MouseEvent( type, { bubbles: true, clientX, clientY } ) ); }; - const renderWithHandlers = ( data: SeriesData[] ) => { + const renderWithHandlers = ( data: SeriesData[], props = {} ) => { const handlers = { onPointerDown: jest.fn(), onPointerMove: jest.fn(), @@ -1989,7 +1989,7 @@ describe( 'LineChart', () => { }; mockUseXZoom.mockImplementation( () => ( { ...passthroughZoom(), handlers } ) ); const ref = createRef< ChartInstanceRef >(); - renderUnwrappedWithTheme( { data }, 'default', ref ); + renderUnwrappedWithTheme( { data, ...props }, 'default', ref ); const xScale = ref.current?.getScales()?.xScale as ( date: Date ) => number; const yScale = ref.current?.getScales()?.yScale as ( value: number ) => number; return { handlers, xScale, yScale }; @@ -2034,6 +2034,35 @@ describe( 'LineChart', () => { ); } ); + test( 'reports nothing for a reading the log scale cannot place', () => { + const data = [ series( 'Series A', [ 0, 10 ] ) ]; + const { handlers, xScale } = renderWithHandlers( data, { + options: { yScale: { type: 'log', domain: [ 1, 100 ] } }, + } ); + + pointer( 'pointerdown', xScale( data[ 0 ].data[ 0 ].date ), 150 ); + + expect( handlers.onPointerDown ).not.toHaveBeenCalled(); + } ); + + test( 'reports the nearest bucket in a long series', () => { + const data = [ + series( + 'Series A', + Array.from( { length: 60 }, ( _, i ) => i ) + ), + ]; + const { handlers, xScale } = renderWithHandlers( data ); + const between = + ( xScale( data[ 0 ].data[ 37 ].date ) * 2 + xScale( data[ 0 ].data[ 38 ].date ) ) / 3; + + pointer( 'pointerdown', between, 150 ); + + expect( handlers.onPointerDown ).toHaveBeenLastCalledWith( + expect.objectContaining( { index: 37, datum: data[ 0 ].data[ 37 ] } ) + ); + } ); + test( 'reports the series nearest the pointer when every series has a reading', () => { const data = [ series( 'Series A', [ 10, 10 ] ), series( 'Series B', [ 20, 20 ] ) ]; const { handlers, xScale, yScale } = renderWithHandlers( data );