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/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/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/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 (
{ point.key }:
- { formatNumber( point.value ) }
+ { formatReading( point.value ) }
) ) }
@@ -158,22 +169,42 @@ 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;
};
+// 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 >;
@@ -331,7 +362,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 );
}
@@ -365,7 +396,32 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
preventTooltipScroll: tooltipPlacement === 'below-axis',
} );
+ // 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 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 ( isReading( value ) ) {
+ min = Math.min( min, value );
+ max = Math.max( max, value );
+ }
+ }
+ }
+ return min <= max ? [ min, max ] : undefined;
+ }, [ dataSorted, isSeriesVisible ] );
+
const chartOptions = useMemo( () => {
+ const fallbackYDomain = getFallbackYDomain(
+ visibleReadingExtent,
+ options?.yScale?.type === 'log'
+ );
+
return {
axis: {
x: buildTimeAxisOptions( {
@@ -395,11 +451,21 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
type: 'linear' as const,
nice: true,
zero: false,
+ ...( fallbackYDomain ? { domain: fallbackYDomain } : {} ),
...( stableYDomain ? { domain: stableYDomain } : {} ),
...options?.yScale,
},
};
- }, [ options, dataSorted, width, zoom.domain, stableYDomain, formatting, isSeriesVisible ] );
+ }, [
+ options,
+ dataSorted,
+ width,
+ zoom.domain,
+ stableYDomain,
+ visibleReadingExtent,
+ 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.
@@ -584,12 +650,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/line-chart-glyph.tsx b/projects/js-packages/charts/src/charts/line-chart/private/line-chart-glyph.tsx
index 2b7230aa5487..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,14 +20,28 @@ const LineChartGlyph: FC< LineChartGlyphProps > = ( {
const { xScale, yScale } = useContext( DataContext ) || {};
if ( ! xScale || ! yScale ) return null;
- if ( data.data.length === 0 ) return null;
+ // 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 );
+ };
- 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/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..fa59e790dcb9
--- /dev/null
+++ b/projects/js-packages/charts/src/charts/line-chart/private/nearest-pointer-events.tsx
@@ -0,0 +1,155 @@
+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 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 sorted by x.
+ *
+ * @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.
+ * @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 } {
+ 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 placed reading, else the closest bucket with no reading along x.
+ *
+ * @param candidates - One candidate per series.
+ * @return The chosen candidate's params, or undefined when there are none.
+ */
+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
+ );
+}
+
+/**
+ * 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 ];
+ const value = entry.yAccessor( datum );
+ candidates.push( {
+ 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 );
+ },
+ [ 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/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..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
@@ -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. 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.
+
+
+
## 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), which break the line instead of being 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 319b10b26bcb..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
@@ -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: {},
},
] }
@@ -381,6 +378,40 @@ 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: 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 },
+ ],
+ },
+];
+
+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 } },
+ 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. Pointer events still reach a bucket with no reading, so with `zoomable` a drag can start in January.',
+ },
+ },
+};
+
export const WithoutSmoothing: StoryObj< StoryArgs > = Template.bind( {} );
WithoutSmoothing.args = {
...lineChartStoryArgs,
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..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
@@ -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,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, SeriesData } 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();
@@ -254,18 +256,199 @@ 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( '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( '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( '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.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 >();
+
+ 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: [
+ {
+ 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 +456,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: [
@@ -1042,6 +1247,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', () => {
@@ -1713,6 +1954,127 @@ 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[], props = {} ) => {
+ const handlers = {
+ onPointerDown: jest.fn(),
+ onPointerMove: jest.fn(),
+ onPointerUp: jest.fn(),
+ };
+ mockUseXZoom.mockImplementation( () => ( { ...passthroughZoom(), handlers } ) );
+ const ref = createRef< ChartInstanceRef >();
+ 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 };
+ };
+
+ 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 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 );
+
+ 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( () => ( {
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 = {
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..5e8f1c2e5234
--- /dev/null
+++ b/projects/js-packages/charts/src/charts/private/readings.ts
@@ -0,0 +1,34 @@
+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.
+ *
+ * @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: number | null | undefined,
+ { allowMissing }: { allowMissing: boolean }
+) => ( value === null ? ! allowMissing : value === undefined || isNaN( value ) );
+
+/**
+ * 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' );
+ } );
+} );