@automattic/charts 4.2.0 → 4.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/dist/index.cjs +533 -127
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +27 -2
- package/dist/index.d.ts +27 -2
- package/dist/index.js +535 -129
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/charts/area-chart/area-chart.tsx +42 -10
- package/src/charts/area-chart/private/validate-data.ts +2 -3
- package/src/charts/area-chart/test/area-chart.test.tsx +72 -1
- package/src/charts/bar-chart/bar-chart.tsx +135 -96
- package/src/charts/bar-chart/index.ts +1 -1
- package/src/charts/bar-chart/private/band-highlight.tsx +3 -1
- package/src/charts/bar-chart/private/band-tooltip.tsx +1 -1
- package/src/charts/bar-chart/private/classified-bar-series.tsx +85 -0
- package/src/charts/bar-chart/private/comparison-bars-geometry.ts +3 -6
- package/src/charts/bar-chart/private/index.ts +2 -0
- package/src/charts/bar-chart/private/test/band-highlight.test.tsx +28 -2
- package/src/charts/bar-chart/private/test/comparison-bars-geometry.test.ts +2 -3
- package/src/charts/bar-chart/private/test/value-domain.test.ts +100 -0
- package/src/charts/bar-chart/private/use-bar-chart-options.ts +12 -39
- package/src/charts/bar-chart/private/value-domain.ts +90 -0
- package/src/charts/bar-chart/test/bar-chart.test.tsx +614 -10
- package/src/charts/bar-chart/types.ts +43 -0
- package/src/charts/line-chart/line-chart.tsx +148 -35
- package/src/charts/line-chart/private/index.ts +1 -0
- package/src/charts/line-chart/private/line-chart-glyph.tsx +17 -3
- package/src/charts/line-chart/private/nearest-pointer-events.tsx +155 -0
- package/src/charts/line-chart/test/line-chart.test.tsx +604 -9
- package/src/charts/line-chart/types.ts +2 -1
- package/src/charts/private/readings.ts +34 -0
- package/src/charts/private/test/readings.test.ts +29 -0
- package/src/charts/private/test/whole-number-ticks.test.tsx +57 -0
- package/src/charts/private/whole-number-ticks.tsx +79 -0
- package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +124 -0
- package/src/components/legend/hooks/use-chart-legend-items.ts +60 -2
- package/src/components/legend/private/base-legend.tsx +36 -21
- package/src/components/legend/test/legend.test.tsx +84 -0
- package/src/components/legend/types.ts +2 -0
- package/src/components/legend/utils/index.ts +0 -1
- package/src/components/tooltip/accessible-tooltip.tsx +1 -1
- package/src/hooks/test/use-chart-margin.test.tsx +35 -0
- package/src/hooks/use-chart-margin.tsx +7 -2
- package/src/providers/chart-context/global-charts-provider.tsx +1 -1
- package/src/providers/chart-context/test/chart-context.test.tsx +4 -4
- package/src/types.ts +8 -0
- package/src/components/legend/utils/label-transform-factory.ts +0 -24
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { DataContext, GlyphSeries } from '@visx/xychart';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { useCallback, useContext, useMemo } from 'react';
|
|
4
|
+
import { createGroupScale } from './band-scale';
|
|
5
|
+
import { getValueScaleBaseline } from './comparison-bars-geometry';
|
|
6
|
+
import type { BandScale } from './band-scale';
|
|
7
|
+
import type { DataPointDate } from '../../../types';
|
|
8
|
+
import type { BarChartProps } from '../types';
|
|
9
|
+
import type { AxisScale } from '@visx/axis';
|
|
10
|
+
import type { GlyphProps, SeriesProps } from '@visx/xychart';
|
|
11
|
+
|
|
12
|
+
type Props = Pick<
|
|
13
|
+
SeriesProps< AxisScale, AxisScale, DataPointDate >,
|
|
14
|
+
'xAccessor' | 'yAccessor'
|
|
15
|
+
> & {
|
|
16
|
+
dataKey: string;
|
|
17
|
+
data: DataPointDate[];
|
|
18
|
+
colorAccessor: ( datum: DataPointDate ) => string;
|
|
19
|
+
barClassName: NonNullable< BarChartProps[ 'barClassName' ] >;
|
|
20
|
+
primaryKeys: string[];
|
|
21
|
+
groupPadding: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const ClassifiedBarSeries = ( { barClassName, primaryKeys, groupPadding, ...props }: Props ) => {
|
|
25
|
+
const { xScale, yScale, horizontal } = useContext( DataContext );
|
|
26
|
+
const bandScale = ( horizontal ? yScale : xScale ) as BandScale | undefined;
|
|
27
|
+
const valueScale = horizontal ? xScale : yScale;
|
|
28
|
+
const groupScale = useMemo(
|
|
29
|
+
() => createGroupScale( primaryKeys, bandScale?.bandwidth?.() ?? 0, groupPadding ),
|
|
30
|
+
[ primaryKeys, bandScale, groupPadding ]
|
|
31
|
+
);
|
|
32
|
+
const renderGlyph = useCallback(
|
|
33
|
+
( {
|
|
34
|
+
datum,
|
|
35
|
+
x,
|
|
36
|
+
y,
|
|
37
|
+
color,
|
|
38
|
+
onBlur,
|
|
39
|
+
onFocus,
|
|
40
|
+
onPointerMove,
|
|
41
|
+
onPointerOut,
|
|
42
|
+
onPointerUp,
|
|
43
|
+
}: GlyphProps< DataPointDate > ) => {
|
|
44
|
+
if ( ! bandScale?.bandwidth || ! valueScale ) return null;
|
|
45
|
+
const baseline = getValueScaleBaseline(
|
|
46
|
+
valueScale as Parameters< typeof getValueScaleBaseline >[ 0 ]
|
|
47
|
+
);
|
|
48
|
+
const position =
|
|
49
|
+
Number( bandScale( ( horizontal ? props.yAccessor : props.xAccessor )( datum ) ) ) +
|
|
50
|
+
( groupScale( props.dataKey ) ?? 0 );
|
|
51
|
+
const value = horizontal ? x : y;
|
|
52
|
+
return (
|
|
53
|
+
<rect
|
|
54
|
+
className={ clsx( 'visx-bar', barClassName( datum ) ) }
|
|
55
|
+
// visx's own Bars makes a bar focusable on the same condition.
|
|
56
|
+
tabIndex={ onFocus || onBlur ? 0 : undefined }
|
|
57
|
+
onBlur={ onBlur }
|
|
58
|
+
onFocus={ onFocus }
|
|
59
|
+
onPointerMove={ onPointerMove }
|
|
60
|
+
onPointerOut={ onPointerOut }
|
|
61
|
+
onPointerUp={ onPointerUp }
|
|
62
|
+
// Same arithmetic as visx BaseBarGroup, so rounding matches the unclassified bars.
|
|
63
|
+
x={ horizontal ? baseline + Math.min( 0, value - baseline ) : position }
|
|
64
|
+
y={ horizontal ? position : baseline + Math.min( 0, value - baseline ) }
|
|
65
|
+
width={ horizontal ? Math.abs( value - baseline ) : groupScale.bandwidth() }
|
|
66
|
+
height={ horizontal ? groupScale.bandwidth() : Math.abs( value - baseline ) }
|
|
67
|
+
fill={ color }
|
|
68
|
+
/>
|
|
69
|
+
);
|
|
70
|
+
},
|
|
71
|
+
[
|
|
72
|
+
bandScale,
|
|
73
|
+
valueScale,
|
|
74
|
+
horizontal,
|
|
75
|
+
groupScale,
|
|
76
|
+
props.dataKey,
|
|
77
|
+
props.xAccessor,
|
|
78
|
+
props.yAccessor,
|
|
79
|
+
barClassName,
|
|
80
|
+
]
|
|
81
|
+
);
|
|
82
|
+
return <GlyphSeries { ...props } renderGlyph={ renderGlyph } />;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export default ClassifiedBarSeries;
|
|
@@ -3,9 +3,8 @@ export type ComparisonRect = { x: number; y: number; width: number; height: numb
|
|
|
3
3
|
type ValueScale = ( ( v: number ) => number ) & { range: () => unknown[] };
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* sit on the same baseline as primary bars.
|
|
6
|
+
* Mirrors visx's getScaleBaseline so comparison shadows and primary bars
|
|
7
|
+
* share the same baseline, including out-of-domain zero values.
|
|
9
8
|
*
|
|
10
9
|
* @param {ValueScale} scale - The continuous value scale.
|
|
11
10
|
* @return {number} The baseline output position in pixels.
|
|
@@ -20,9 +19,7 @@ export function getValueScaleBaseline( scale: ValueScale ): number {
|
|
|
20
19
|
? Math.min( Math.max( minOutput, maybeZero ), maxOutput )
|
|
21
20
|
: maxOutput;
|
|
22
21
|
}
|
|
23
|
-
return Number.isFinite( maybeZero )
|
|
24
|
-
? Math.min( Math.max( maybeZero, minOutput ), maxOutput )
|
|
25
|
-
: minOutput;
|
|
22
|
+
return Number.isFinite( maybeZero ) ? Math.max( maybeZero, minOutput ) : minOutput;
|
|
26
23
|
}
|
|
27
24
|
|
|
28
25
|
/**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { useBarChartOptions } from './use-bar-chart-options';
|
|
2
2
|
export { TruncatedXTickComponent, TruncatedYTickComponent } from './truncated-tick-component';
|
|
3
|
+
export { default as ClassifiedBarSeries } from './classified-bar-series';
|
|
3
4
|
export { ComparisonBars } from './comparison-bars';
|
|
4
5
|
export type { ComparisonSeriesEntry } from './comparison-bars';
|
|
5
6
|
export {
|
|
@@ -9,5 +10,6 @@ export {
|
|
|
9
10
|
COMPARISON_TICK_GAP_FACTOR,
|
|
10
11
|
} from './comparison-constants';
|
|
11
12
|
export { BASE_BAND_PADDING_INNER } from './use-bar-chart-options';
|
|
13
|
+
export { countRenderedBars, isBarRendered } from './value-domain';
|
|
12
14
|
export { BandHighlight } from './band-highlight';
|
|
13
15
|
export { BandTooltip } from './band-tooltip';
|
|
@@ -7,7 +7,7 @@ import { GlobalChartsProvider } from '../../../../providers';
|
|
|
7
7
|
import { CATALOG_POINTERS } from '../../../../providers/chart-context/private/catalog-pointers';
|
|
8
8
|
import { BarChartUnresponsive } from '../../bar-chart';
|
|
9
9
|
import { BandHighlight } from '../band-highlight';
|
|
10
|
-
import type { BandHighlightSelection } from '../../
|
|
10
|
+
import type { BandHighlightSelection } from '../../types';
|
|
11
11
|
|
|
12
12
|
const xScale = scaleBand( { domain: [ 'Jan', 'Feb' ], range: [ 10, 210 ] } );
|
|
13
13
|
const dataContext = {
|
|
@@ -25,7 +25,7 @@ const Fixture = ( { label = 'Jan', onChange } ) => {
|
|
|
25
25
|
value={
|
|
26
26
|
{
|
|
27
27
|
tooltipOpen: true,
|
|
28
|
-
tooltipData: { nearestDatum: { key: 'scores', datum: { label, value: 50 } } },
|
|
28
|
+
tooltipData: { nearestDatum: { key: 'scores', index: 0, datum: { label, value: 50 } } },
|
|
29
29
|
} as never
|
|
30
30
|
}
|
|
31
31
|
>
|
|
@@ -38,6 +38,20 @@ const Fixture = ( { label = 'Jan', onChange } ) => {
|
|
|
38
38
|
};
|
|
39
39
|
|
|
40
40
|
describe( 'BandHighlight', () => {
|
|
41
|
+
test( 'reports the initial selection and clears it on unmount', () => {
|
|
42
|
+
const onChange = jest.fn();
|
|
43
|
+
const { unmount } = render( <Fixture onChange={ onChange } /> );
|
|
44
|
+
expect( onChange ).toHaveBeenLastCalledWith(
|
|
45
|
+
expect.objectContaining( {
|
|
46
|
+
key: 'scores',
|
|
47
|
+
index: 0,
|
|
48
|
+
datum: { label: 'Jan', value: 50 },
|
|
49
|
+
} )
|
|
50
|
+
);
|
|
51
|
+
unmount();
|
|
52
|
+
expect( onChange ).toHaveBeenLastCalledWith( null );
|
|
53
|
+
} );
|
|
54
|
+
|
|
41
55
|
test( 'uses the catalog surface fill', () => {
|
|
42
56
|
render( <Fixture onChange={ undefined } /> );
|
|
43
57
|
expect( screen.getByTestId( 'bar-chart-band-highlight' ) ).toHaveAttribute(
|
|
@@ -79,6 +93,7 @@ describe( 'BandHighlight', () => {
|
|
|
79
93
|
test.each( [ { withTooltips: false }, { data: [] } ] )(
|
|
80
94
|
'clears the selected band when the highlight unmounts with %j',
|
|
81
95
|
async props => {
|
|
96
|
+
const warn = jest.spyOn( console, 'warn' ).mockImplementation( () => {} );
|
|
82
97
|
const user = userEvent.setup();
|
|
83
98
|
const onChange = jest.fn();
|
|
84
99
|
const chartProps = {
|
|
@@ -103,6 +118,17 @@ describe( 'BandHighlight', () => {
|
|
|
103
118
|
<BarChartUnresponsive { ...chartProps } { ...props } />
|
|
104
119
|
</GlobalChartsProvider>
|
|
105
120
|
);
|
|
121
|
+
rerender(
|
|
122
|
+
<GlobalChartsProvider>
|
|
123
|
+
<BarChartUnresponsive
|
|
124
|
+
{ ...chartProps }
|
|
125
|
+
{ ...props }
|
|
126
|
+
onBandHighlightChange={ jest.fn() }
|
|
127
|
+
/>
|
|
128
|
+
</GlobalChartsProvider>
|
|
129
|
+
);
|
|
130
|
+
expect( warn ).toHaveBeenCalledTimes( 'withTooltips' in props ? 1 : 0 );
|
|
131
|
+
warn.mockRestore();
|
|
106
132
|
expect( onChange ).toHaveBeenLastCalledWith( null );
|
|
107
133
|
}
|
|
108
134
|
);
|
|
@@ -9,12 +9,11 @@ describe( 'getValueScaleBaseline', () => {
|
|
|
9
9
|
expect( getValueScaleBaseline( scale ) ).toBe( 200 );
|
|
10
10
|
} );
|
|
11
11
|
|
|
12
|
-
it( '
|
|
12
|
+
it( 'preserves an out-of-range zero baseline for an ascending scale', () => {
|
|
13
13
|
// horizontal linear scale: range [0, 200], domain [-100, -10] (zero excluded)
|
|
14
14
|
const scale = ( ( v: number ) => ( ( v + 100 ) / 90 ) * 200 ) as never;
|
|
15
15
|
( scale as { range: () => number[] } ).range = () => [ 0, 200 ];
|
|
16
|
-
|
|
17
|
-
expect( getValueScaleBaseline( scale ) ).toBe( 200 );
|
|
16
|
+
expect( getValueScaleBaseline( scale ) ).toBeCloseTo( 222.222222 );
|
|
18
17
|
} );
|
|
19
18
|
} );
|
|
20
19
|
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { getValueScaleDomain } from '../value-domain';
|
|
2
|
+
import type { DataPointDate, SeriesData } from '../../../../types';
|
|
3
|
+
|
|
4
|
+
const series = (
|
|
5
|
+
values: Array< number | null >,
|
|
6
|
+
options?: SeriesData[ 'options' ],
|
|
7
|
+
label = 'Series'
|
|
8
|
+
): SeriesData => ( {
|
|
9
|
+
label,
|
|
10
|
+
data: values.map( ( value, index ) => ( {
|
|
11
|
+
date: new Date( 2026, index, 1 ),
|
|
12
|
+
value,
|
|
13
|
+
} ) ),
|
|
14
|
+
options,
|
|
15
|
+
} );
|
|
16
|
+
|
|
17
|
+
const allRendered = () => true;
|
|
18
|
+
|
|
19
|
+
describe( 'getValueScaleDomain', () => {
|
|
20
|
+
test( 'anchors a varying series at zero', () => {
|
|
21
|
+
expect( getValueScaleDomain( [ series( [ 10, 20, 30 ] ) ], true, allRendered ) ).toEqual( [
|
|
22
|
+
0, 30,
|
|
23
|
+
] );
|
|
24
|
+
} );
|
|
25
|
+
|
|
26
|
+
test( 'spans zero for a varying negative series', () => {
|
|
27
|
+
expect( getValueScaleDomain( [ series( [ -10, -20 ] ) ], true, allRendered ) ).toEqual( [
|
|
28
|
+
-20, 0,
|
|
29
|
+
] );
|
|
30
|
+
} );
|
|
31
|
+
|
|
32
|
+
test( 'leaves a varying series to visx when zero is opted out', () => {
|
|
33
|
+
expect( getValueScaleDomain( [ series( [ 10, 20, 30 ] ) ], false, allRendered ) ).toBeNull();
|
|
34
|
+
} );
|
|
35
|
+
|
|
36
|
+
test( 'anchors a flat series at zero so its bars have height', () => {
|
|
37
|
+
expect( getValueScaleDomain( [ series( [ 500, 500, 500 ] ) ], false, allRendered ) ).toEqual( [
|
|
38
|
+
0, 500,
|
|
39
|
+
] );
|
|
40
|
+
} );
|
|
41
|
+
|
|
42
|
+
test( 'anchors a single-bucket window at zero', () => {
|
|
43
|
+
expect( getValueScaleDomain( [ series( [ 42 ] ) ], false, allRendered ) ).toEqual( [ 0, 42 ] );
|
|
44
|
+
} );
|
|
45
|
+
|
|
46
|
+
test( 'spans zero for a flat negative series', () => {
|
|
47
|
+
expect( getValueScaleDomain( [ series( [ -5, -5 ] ) ], false, allRendered ) ).toEqual( [
|
|
48
|
+
-5, 0,
|
|
49
|
+
] );
|
|
50
|
+
} );
|
|
51
|
+
|
|
52
|
+
test( 'falls back to a unit domain when every value is zero', () => {
|
|
53
|
+
expect( getValueScaleDomain( [ series( [ 0, 0 ] ) ], false, allRendered ) ).toEqual( [ 0, 1 ] );
|
|
54
|
+
} );
|
|
55
|
+
|
|
56
|
+
test( 'falls back to a unit domain when every value is zero in comparison mode', () => {
|
|
57
|
+
const data = [ series( [ 0, 0 ] ), series( [ 0, 0 ], { type: 'comparison' } ) ];
|
|
58
|
+
expect( getValueScaleDomain( data, true, allRendered ) ).toEqual( [ 0, 1 ] );
|
|
59
|
+
} );
|
|
60
|
+
|
|
61
|
+
test( 'falls back to a unit domain when no bucket has a reading', () => {
|
|
62
|
+
expect( getValueScaleDomain( [ series( [ null, null ] ) ], false, allRendered ) ).toEqual( [
|
|
63
|
+
0, 1,
|
|
64
|
+
] );
|
|
65
|
+
} );
|
|
66
|
+
|
|
67
|
+
test( 'ignores buckets with no reading when fitting a varying series', () => {
|
|
68
|
+
expect( getValueScaleDomain( [ series( [ null, 10, 20 ] ) ], false, allRendered ) ).toBeNull();
|
|
69
|
+
} );
|
|
70
|
+
|
|
71
|
+
test( 'keeps zero in the domain across every series in comparison mode', () => {
|
|
72
|
+
const data = [ series( [ 10, 20 ] ), series( [ 5, 40 ], { type: 'comparison' } ) ];
|
|
73
|
+
expect( getValueScaleDomain( data, true, allRendered ) ).toEqual( [ 0, 40 ] );
|
|
74
|
+
} );
|
|
75
|
+
|
|
76
|
+
test( 'anchors a flat series at zero when the varying series beside it is hidden', () => {
|
|
77
|
+
const data = [
|
|
78
|
+
series( [ 500, 500 ], undefined, 'Flat' ),
|
|
79
|
+
series( [ 10, 900 ], undefined, 'Varying' ),
|
|
80
|
+
];
|
|
81
|
+
const isSeriesRendered = ( s: SeriesData ) => s.label !== 'Varying';
|
|
82
|
+
expect( getValueScaleDomain( data, false, isSeriesRendered ) ).toEqual( [ 0, 500 ] );
|
|
83
|
+
} );
|
|
84
|
+
|
|
85
|
+
test( 'anchors the remaining varying series at zero when a sibling is hidden', () => {
|
|
86
|
+
const data = [ series( [ 10, 50 ], undefined, 'A' ), series( [ 15, 25 ], undefined, 'B' ) ];
|
|
87
|
+
const isSeriesRendered = ( s: SeriesData ) => s.label !== 'A';
|
|
88
|
+
expect( getValueScaleDomain( data, true, isSeriesRendered ) ).toEqual( [ 0, 25 ] );
|
|
89
|
+
} );
|
|
90
|
+
|
|
91
|
+
test( 'reads visualValue in preference to value', () => {
|
|
92
|
+
const data: SeriesData[] = [
|
|
93
|
+
{
|
|
94
|
+
label: 'Series',
|
|
95
|
+
data: [ { date: new Date( 2026, 0, 1 ), value: 0, visualValue: 2 } as DataPointDate ],
|
|
96
|
+
},
|
|
97
|
+
];
|
|
98
|
+
expect( getValueScaleDomain( data, false, allRendered ) ).toEqual( [ 0, 2 ] );
|
|
99
|
+
} );
|
|
100
|
+
} );
|
|
@@ -7,7 +7,7 @@ import { getBucketResolution } from '../../../utils/bucket-info';
|
|
|
7
7
|
import { createDateFormatter } from '../../../utils/date-formatting';
|
|
8
8
|
import { getBandTickValues, getFormatter } from '../../private/time-axis';
|
|
9
9
|
import { TruncatedXTickComponent, TruncatedYTickComponent } from './truncated-tick-component';
|
|
10
|
-
import
|
|
10
|
+
import { getBarValue, getValueScaleDomain } from './value-domain';
|
|
11
11
|
import type {
|
|
12
12
|
DataPointDate,
|
|
13
13
|
BaseChartProps,
|
|
@@ -207,6 +207,7 @@ export function useBarChartOptions(
|
|
|
207
207
|
const linearScale = {
|
|
208
208
|
type: 'linear' as const,
|
|
209
209
|
nice: true,
|
|
210
|
+
// XYChart defaults `zero` to true, which would stretch a caller's domain to 0.
|
|
210
211
|
zero: false,
|
|
211
212
|
};
|
|
212
213
|
|
|
@@ -223,12 +224,6 @@ export function useBarChartOptions(
|
|
|
223
224
|
|
|
224
225
|
const bandDomain = timeTickFormatter ? getBandDomain( data, isSeriesRendered ) : null;
|
|
225
226
|
|
|
226
|
-
const valueAccessor = ( d: DataPointDate | EnhancedDataPoint ) => {
|
|
227
|
-
// Use visualValue for bar rendering if available (for zero values), otherwise use value
|
|
228
|
-
const enhancedPoint = d as EnhancedDataPoint;
|
|
229
|
-
return enhancedPoint?.visualValue !== undefined ? enhancedPoint.visualValue : d?.value;
|
|
230
|
-
};
|
|
231
|
-
|
|
232
227
|
return {
|
|
233
228
|
timeAxis: bandDomain &&
|
|
234
229
|
timeTickFormatter && {
|
|
@@ -240,7 +235,7 @@ export function useBarChartOptions(
|
|
|
240
235
|
yTickFormat: valueFormatter,
|
|
241
236
|
tooltipLabelFormatter: tooltipDatumFormatter,
|
|
242
237
|
xAccessor: bucketAccessor,
|
|
243
|
-
yAccessor:
|
|
238
|
+
yAccessor: getBarValue,
|
|
244
239
|
gridVisibility: 'x',
|
|
245
240
|
xScale: bandScale,
|
|
246
241
|
yScale: linearScale,
|
|
@@ -249,7 +244,7 @@ export function useBarChartOptions(
|
|
|
249
244
|
xTickFormat: valueFormatter,
|
|
250
245
|
yTickFormat: labelFormatter,
|
|
251
246
|
tooltipLabelFormatter: tooltipDatumFormatter,
|
|
252
|
-
xAccessor:
|
|
247
|
+
xAccessor: getBarValue,
|
|
253
248
|
yAccessor: bucketAccessor,
|
|
254
249
|
gridVisibility: 'y',
|
|
255
250
|
xScale: linearScale,
|
|
@@ -271,36 +266,14 @@ export function useBarChartOptions(
|
|
|
271
266
|
yScale: baseYScale,
|
|
272
267
|
} = defaultOptions[ orientationKey ];
|
|
273
268
|
|
|
274
|
-
|
|
275
|
-
// a too-narrow domain. Compute an explicit domain spanning all series so comparison
|
|
276
|
-
// shadows aren't clipped. Skip when the user has already provided an explicit domain.
|
|
277
|
-
let valueScaleDomainOverride: { domain?: [ number, number ] } = {};
|
|
269
|
+
const valueScaleOptions = horizontal ? stableOptions.xScale : stableOptions.yScale;
|
|
278
270
|
const hasComparisonSeries = data.some( s => s.options?.type === 'comparison' );
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
series.data.forEach( d => {
|
|
286
|
-
const enhanced = d as { visualValue?: number };
|
|
287
|
-
const v =
|
|
288
|
-
enhanced.visualValue !== undefined ? enhanced.visualValue : ( d.value as number );
|
|
289
|
-
if ( typeof v === 'number' && Number.isFinite( v ) ) {
|
|
290
|
-
allValues.push( v );
|
|
291
|
-
}
|
|
292
|
-
} );
|
|
293
|
-
} );
|
|
294
|
-
if ( allValues.length > 0 ) {
|
|
295
|
-
// Keep zero in the domain so bar length stays proportional to value — a
|
|
296
|
-
// non-zero baseline would exaggerate differences between periods. Math.max
|
|
297
|
-
// keeps zero on the far side too, so charts with negative values still span 0.
|
|
298
|
-
valueScaleDomainOverride = {
|
|
299
|
-
domain: [ Math.min( 0, ...allValues ), Math.max( 0, ...allValues ) ],
|
|
300
|
-
};
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
}
|
|
271
|
+
// Comparison shadows share the primary bars' scale, so they force zero even when the caller opted out.
|
|
272
|
+
const includeZero = hasComparisonSeries || valueScaleOptions?.zero !== false;
|
|
273
|
+
const domain = valueScaleOptions?.domain
|
|
274
|
+
? null
|
|
275
|
+
: getValueScaleDomain( data, includeZero, isSeriesRendered );
|
|
276
|
+
const valueScaleDomainOverride: { domain?: [ number, number ] } = domain ? { domain } : {};
|
|
304
277
|
|
|
305
278
|
const xScale = {
|
|
306
279
|
...baseXScale,
|
|
@@ -367,5 +340,5 @@ export function useBarChartOptions(
|
|
|
367
340
|
labelFormatter: dateAxisTickFormat || defaultTooltipLabelFormatter,
|
|
368
341
|
},
|
|
369
342
|
};
|
|
370
|
-
}, [ defaultOptions, axisConfig, stableOptions, horizontal, data ] );
|
|
343
|
+
}, [ defaultOptions, axisConfig, stableOptions, horizontal, data, isSeriesRendered ] );
|
|
371
344
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { EnhancedDataPoint } from '../../../hooks/use-zero-value-display';
|
|
2
|
+
import type { DataPoint, DataPointDate, SeriesData } from '../../../types';
|
|
3
|
+
|
|
4
|
+
/** Domain for a value axis that has nothing to scale, so its ticks still read 0 to 1. */
|
|
5
|
+
const EMPTY_DOMAIN: [ number, number ] = [ 0, 1 ];
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The value a bar is drawn at, preferring `visualValue` so a zero still shows a sliver.
|
|
9
|
+
*
|
|
10
|
+
* A missing reading is NaN, not null: `ComparisonBars` coerces the value with `Number()`,
|
|
11
|
+
* which turns null into 0 and would draw a comparison shadow at the baseline.
|
|
12
|
+
*
|
|
13
|
+
* @param point - A data point handed to visx.
|
|
14
|
+
* @return The bar value, or NaN when the bucket has no reading.
|
|
15
|
+
*/
|
|
16
|
+
export const getBarValue = ( point: DataPoint | DataPointDate | EnhancedDataPoint ): number => {
|
|
17
|
+
const enhanced = point as EnhancedDataPoint;
|
|
18
|
+
const value = enhanced?.visualValue !== undefined ? enhanced.visualValue : point?.value;
|
|
19
|
+
return value ?? NaN;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Whether visx renders a bar for a point.
|
|
24
|
+
*
|
|
25
|
+
* @param point - A data point handed to visx.
|
|
26
|
+
* @return True when the point has a finite bar value.
|
|
27
|
+
*/
|
|
28
|
+
export const isBarRendered = ( point: DataPoint | DataPointDate | EnhancedDataPoint ): boolean =>
|
|
29
|
+
Number.isFinite( getBarValue( point ) );
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* How many bars visx renders for a list of points.
|
|
33
|
+
*
|
|
34
|
+
* @param points - Data points handed to visx.
|
|
35
|
+
* @return The number of points that get a bar.
|
|
36
|
+
*/
|
|
37
|
+
export const countRenderedBars = (
|
|
38
|
+
points: ReadonlyArray< DataPoint | DataPointDate | EnhancedDataPoint >
|
|
39
|
+
): number => points.filter( isBarRendered ).length;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* An explicit domain for the value scale, or null to let visx fit one to the data.
|
|
43
|
+
*
|
|
44
|
+
* Zero goes into the domain here rather than through the scale's `zero` flag: visx applies
|
|
45
|
+
* `nice` before `zero`, which would leave the axis topping out at a raw maximum.
|
|
46
|
+
*
|
|
47
|
+
* @param data - Every series handed to the chart.
|
|
48
|
+
* @param includeZero - Whether the domain must span zero.
|
|
49
|
+
* @param isSeriesRendered - Whether visx mounts a series, i.e. the legend shows it.
|
|
50
|
+
* @return Value domain, or null when visx should fit its own.
|
|
51
|
+
*/
|
|
52
|
+
export const getValueScaleDomain = (
|
|
53
|
+
data: SeriesData[],
|
|
54
|
+
includeZero: boolean,
|
|
55
|
+
isSeriesRendered: ( series: SeriesData ) => boolean
|
|
56
|
+
): [ number, number ] | null => {
|
|
57
|
+
let min = Infinity;
|
|
58
|
+
let max = -Infinity;
|
|
59
|
+
|
|
60
|
+
// Only hidden series are skipped. Comparison series count too: visx sees just the
|
|
61
|
+
// primary BarSeries and would fit a domain too narrow for the comparison shadows.
|
|
62
|
+
for ( const series of data ) {
|
|
63
|
+
if ( ! isSeriesRendered( series ) ) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
for ( const point of series.data ) {
|
|
67
|
+
const value = getBarValue( point );
|
|
68
|
+
if ( ! Number.isFinite( value ) ) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
min = Math.min( min, value );
|
|
72
|
+
max = Math.max( max, value );
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if ( min === Infinity ) {
|
|
77
|
+
return [ ...EMPTY_DOMAIN ];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// A flat series fitted by visx collapses to zero height, so it is anchored at zero regardless.
|
|
81
|
+
if ( min === max ) {
|
|
82
|
+
return min === 0 ? [ ...EMPTY_DOMAIN ] : [ Math.min( 0, min ), Math.max( 0, max ) ];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if ( includeZero ) {
|
|
86
|
+
return [ Math.min( 0, min ), Math.max( 0, max ) ];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return null;
|
|
90
|
+
};
|