Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
0a7fd7d
Charts: share one rule for a drawable reading
adamwoodnz Sep 21, 2026
f7c88ae
Charts: let a line chart bucket carry no reading
adamwoodnz Sep 21, 2026
05a0684
Charts: say No data in a line tooltip for a bucket with no reading
adamwoodnz Sep 21, 2026
5bd91d3
Charts: put the line's edge glyphs on its first and last real reading
adamwoodnz Sep 21, 2026
788c3e8
Charts: keep a y axis for a line chart window with no readings
adamwoodnz Sep 21, 2026
4c55eb0
Charts: document and demonstrate line chart buckets with no reading
adamwoodnz Sep 21, 2026
fe9c890
Charts: tidy the line chart's no-reading docs, comment and changelog
adamwoodnz Sep 21, 2026
4bb85bf
Address review: give an empty log-scale axis a positive domain
adamwoodnz Sep 21, 2026
37339fd
Address review: type the reading check and fix two wordings
adamwoodnz Sep 21, 2026
472a8cd
Charts: show a real zero beside the no-data months in the line story
adamwoodnz Sep 21, 2026
c63f292
Charts: start a flat line's value axis at zero
adamwoodnz Sep 22, 2026
b533e65
Charts: report pointer events on a line chart bucket with no reading
adamwoodnz Sep 22, 2026
dbb9774
Address review: share one reading check for the line chart's domains
adamwoodnz Sep 22, 2026
5000af5
Address review: let TooltipDatum carry a bucket with no reading
adamwoodnz Sep 22, 2026
8ccdd38
Charts: let the no-data line story zoom from a bucket with no reading
adamwoodnz Sep 22, 2026
25ca546
Address review: bisect the pointer lookup and skip unplaceable readings
adamwoodnz Sep 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: fixed

Line chart: Translate the "No data available" and "Invalid data" messages.
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { __ } from '@wordpress/i18n';
import { isInvalidReading } from '../../private/readings';
import type { DataPoint, DataPointDate, SeriesData } from '../../../types';

/**
Expand All @@ -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() ) )
)
);
Expand Down
15 changes: 5 additions & 10 deletions projects/js-packages/charts/src/charts/bar-chart/bar-chart.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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() ) ) )
)
Expand All @@ -128,10 +127,6 @@ const renderTooltipRow = ( label: string | undefined, value: string ) => (
</div>
);

// 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,
Expand Down Expand Up @@ -413,10 +408,10 @@ const BarChartInternal: FC< BarChartProps > = ( {
return (
<div className={ styles[ 'bar-chart__tooltip' ] }>
<div className={ styles[ 'bar-chart__tooltip-header' ] }>{ categoryLabel }</div>
{ renderTooltipRow( primaryKey, formatTooltipValue( nearestDatum.value ) ) }
{ renderTooltipRow( primaryKey, formatReading( nearestDatum.value ) ) }
{ renderTooltipRow(
comparisonEntry.series.label,
formatTooltipValue( comparisonDatum.value )
formatReading( comparisonDatum.value )
) }
</div>
);
Expand All @@ -425,7 +420,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
return (
<div className={ styles[ 'bar-chart__tooltip' ] }>
<div className={ styles[ 'bar-chart__tooltip-header' ] }>{ primaryKey }</div>
{ renderTooltipRow( categoryLabel, formatTooltipValue( nearestDatum.value ) ) }
{ renderTooltipRow( categoryLabel, formatReading( nearestDatum.value ) ) }
</div>
);
},
Expand Down
95 changes: 79 additions & 16 deletions projects/js-packages/charts/src/charts/line-chart/line-chart.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -37,14 +37,20 @@ 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, 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';
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, TooltipDatum } from './types';
import type {
BucketInfo,
Expand Down Expand Up @@ -123,9 +129,14 @@ export const renderDefaultTooltip = (
const tooltipPoints: TooltipDatum[] = 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 (
<div
Expand All @@ -149,7 +160,7 @@ export const renderDefaultTooltip = (
>
<span className={ styles[ 'line-chart__tooltip-label' ] }>{ point.key }:</span>
<span className={ styles[ 'line-chart__tooltip-value' ] }>
{ formatNumber( point.value ) }
{ formatReading( point.value ) }
</span>
</Stack>
) ) }
Expand All @@ -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 } ) ||
Comment thread
adamwoodnz marked this conversation as resolved.
( '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 >;
Expand Down Expand Up @@ -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 );
}
Expand Down Expand Up @@ -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( {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"
>
<NearestPointerEvents { ...zoom.handlers } />
{ /* 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. */ }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -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 );

Expand Down
Loading
Loading