@automattic/charts 4.1.1 → 4.3.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 +39 -0
- package/TOKENS.md +1 -1
- package/dist/index.cjs +1335 -348
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +30 -1
- package/dist/index.d.cts +129 -8
- package/dist/index.d.ts +129 -8
- package/dist/index.js +1336 -351
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/charts/area-chart/area-chart.tsx +42 -10
- package/src/charts/area-chart/private/overlays.tsx +1 -2
- 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 +137 -70
- package/src/charts/bar-chart/index.ts +1 -1
- package/src/charts/bar-chart/private/band-highlight.tsx +87 -0
- package/src/charts/bar-chart/private/band-scale.ts +13 -0
- package/src/charts/bar-chart/private/band-tooltip.tsx +138 -0
- package/src/charts/bar-chart/private/comparison-bars.tsx +5 -10
- package/src/charts/bar-chart/private/index.ts +3 -0
- package/src/charts/bar-chart/private/test/band-highlight.test.tsx +135 -0
- package/src/charts/bar-chart/private/test/band-tooltip.test.ts +26 -0
- package/src/charts/bar-chart/private/test/value-domain.test.ts +100 -0
- package/src/charts/bar-chart/private/truncated-tick-component.tsx +1 -1
- package/src/charts/bar-chart/private/use-bar-chart-options.ts +13 -40
- package/src/charts/bar-chart/private/value-domain.ts +90 -0
- package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +11 -23
- package/src/charts/bar-chart/test/bar-chart.test.tsx +1058 -19
- package/src/charts/bar-chart/types.ts +39 -0
- package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -3
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +1 -1
- package/src/charts/conversion-funnel-chart/types.ts +4 -2
- package/src/charts/geo-chart/types.ts +4 -2
- package/src/charts/heatmap-chart/build-calendar-data.ts +5 -11
- package/src/charts/heatmap-chart/build-month-calendar-data.ts +124 -0
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +32 -0
- package/src/charts/heatmap-chart/heatmap-chart.tsx +191 -114
- package/src/charts/heatmap-chart/index.ts +6 -0
- package/src/charts/heatmap-chart/private/civil-day.ts +26 -0
- package/src/charts/heatmap-chart/private/column-groups.ts +71 -0
- package/src/charts/heatmap-chart/private/heatmap-legend.tsx +19 -3
- package/src/charts/heatmap-chart/private/index.ts +2 -0
- package/src/charts/heatmap-chart/private/keyboard-navigation.ts +201 -0
- package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +36 -0
- package/src/charts/heatmap-chart/test/build-month-calendar-data.test.ts +194 -0
- package/src/charts/heatmap-chart/test/column-groups.test.ts +83 -0
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +428 -2
- package/src/charts/heatmap-chart/test/keyboard-navigation.test.ts +25 -0
- package/src/charts/heatmap-chart/test/use-month-calendar-heatmap-data.test.tsx +90 -0
- package/src/charts/heatmap-chart/test/zoned-month-calendar-data.test.ts +23 -0
- package/src/charts/heatmap-chart/types.ts +59 -3
- package/src/charts/heatmap-chart/use-month-calendar-heatmap-data.ts +34 -0
- package/src/charts/leaderboard-chart/types.ts +13 -14
- package/src/charts/line-chart/line-chart.tsx +149 -36
- package/src/charts/line-chart/private/index.ts +1 -0
- package/src/charts/line-chart/private/line-chart-annotation.tsx +1 -1
- 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 +4 -3
- 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/time-axis-options.ts +1 -1
- package/src/charts/private/whole-number-ticks.tsx +79 -0
- package/src/charts/private/with-responsive/with-responsive.tsx +2 -2
- package/src/charts/private/x-zoom/test/x-zoom.test.tsx +1 -3
- package/src/charts/private/x-zoom/x-zoom.tsx +1 -2
- 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 +37 -22
- 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 +128 -41
- package/src/components/tooltip/base-tooltip.module.scss +6 -1
- package/src/components/tooltip/private/bounded-tooltip.tsx +12 -6
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +128 -0
- package/src/components/tooltip/test/bounded-tooltip.test.tsx +17 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +7 -0
- package/src/components/tooltip/xy-chart-tooltip.tsx +9 -10
- package/src/hooks/test/use-chart-margin.test.tsx +35 -0
- package/src/hooks/test/use-element-size.test.tsx +13 -13
- package/src/hooks/use-chart-margin.tsx +7 -2
- package/src/hooks/use-xychart-theme.ts +1 -1
- package/src/index.ts +7 -1
- 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/providers/chart-context/themes.ts +1 -0
- package/src/styles/test/chart-scope.test.ts +1 -1
- package/src/types.ts +14 -13
- package/src/utils/format-metric-value.ts +4 -4
- package/src/utils/get-edge-tick-widths.ts +1 -1
- package/src/utils/get-styles.ts +1 -1
- package/src/visx/types.ts +7 -10
- package/src/components/legend/utils/label-transform-factory.ts +0 -24
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { DataContext, TooltipContext } from '@visx/xychart';
|
|
2
|
+
import { useContext, useEffect, useMemo, useRef } from 'react';
|
|
3
|
+
import { useDeepMemo } from '../../../hooks';
|
|
4
|
+
import { CATALOG_POINTERS } from '../../../providers/chart-context/private/catalog-pointers';
|
|
5
|
+
import type { BandScale } from './band-scale';
|
|
6
|
+
import type { DataPointDate } from '../../../types';
|
|
7
|
+
import type { BandHighlightSelection } from '../types';
|
|
8
|
+
|
|
9
|
+
type Props = {
|
|
10
|
+
visible: boolean;
|
|
11
|
+
horizontal: boolean;
|
|
12
|
+
onChange?: ( selection: BandHighlightSelection | null ) => void;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Render and report the active band using the chart’s registered scales.
|
|
17
|
+
* @param root0 - Highlight options.
|
|
18
|
+
* @param root0.visible - Whether to paint the band.
|
|
19
|
+
* @param root0.horizontal - Whether categories run vertically.
|
|
20
|
+
* @param root0.onChange - Receives selected band geometry.
|
|
21
|
+
* @return The optional band highlight.
|
|
22
|
+
*/
|
|
23
|
+
export function BandHighlight( { visible, horizontal, onChange }: Props ) {
|
|
24
|
+
const { xScale, yScale, dataRegistry, margin, innerWidth, innerHeight } =
|
|
25
|
+
useContext( DataContext );
|
|
26
|
+
const { tooltipOpen, tooltipData } = useContext( TooltipContext );
|
|
27
|
+
const nearest = tooltipData?.nearestDatum;
|
|
28
|
+
const selection = useMemo( () => {
|
|
29
|
+
if ( ! tooltipOpen || ! nearest ) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const scale = ( horizontal ? yScale : xScale ) as BandScale | undefined;
|
|
33
|
+
const entry = dataRegistry?.get( nearest.key );
|
|
34
|
+
const accessor = horizontal ? entry?.yAccessor : entry?.xAccessor;
|
|
35
|
+
if ( ! scale?.bandwidth || ! accessor ) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const position = scale( accessor( nearest.datum ) );
|
|
39
|
+
if ( position === undefined || ! Number.isFinite( position ) ) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
datum: nearest.datum as DataPointDate,
|
|
44
|
+
key: nearest.key,
|
|
45
|
+
index: nearest.index,
|
|
46
|
+
x: horizontal ? ( margin?.left ?? 0 ) : position,
|
|
47
|
+
y: horizontal ? position : ( margin?.top ?? 0 ),
|
|
48
|
+
width: horizontal ? innerWidth : scale.bandwidth(),
|
|
49
|
+
height: horizontal ? scale.bandwidth() : innerHeight,
|
|
50
|
+
};
|
|
51
|
+
}, [
|
|
52
|
+
tooltipOpen,
|
|
53
|
+
nearest,
|
|
54
|
+
horizontal,
|
|
55
|
+
xScale,
|
|
56
|
+
yScale,
|
|
57
|
+
dataRegistry,
|
|
58
|
+
margin,
|
|
59
|
+
innerWidth,
|
|
60
|
+
innerHeight,
|
|
61
|
+
] );
|
|
62
|
+
|
|
63
|
+
const stableSelection = useDeepMemo( selection );
|
|
64
|
+
const onChangeRef = useRef( onChange );
|
|
65
|
+
onChangeRef.current = onChange;
|
|
66
|
+
useEffect( () => {
|
|
67
|
+
onChangeRef.current?.( stableSelection );
|
|
68
|
+
}, [ stableSelection ] );
|
|
69
|
+
|
|
70
|
+
useEffect( () => () => onChangeRef.current?.( null ), [] );
|
|
71
|
+
|
|
72
|
+
if ( ! visible || ! selection ) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const { x, y, width, height } = selection;
|
|
76
|
+
return (
|
|
77
|
+
<rect
|
|
78
|
+
x={ x }
|
|
79
|
+
y={ y }
|
|
80
|
+
width={ width }
|
|
81
|
+
height={ height }
|
|
82
|
+
data-testid="bar-chart-band-highlight"
|
|
83
|
+
pointerEvents="none"
|
|
84
|
+
fill={ CATALOG_POINTERS.surfaceSecondary }
|
|
85
|
+
/>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { scaleBand } from '@visx/scale';
|
|
2
|
+
|
|
3
|
+
export type BandScale = ( ( value: unknown ) => number | undefined ) & { bandwidth: () => number };
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Match the inner slot scale used by visx BarGroup.
|
|
7
|
+
* @param keys - Visible primary series keys.
|
|
8
|
+
* @param bandwidth - Category band width.
|
|
9
|
+
* @param padding - Padding between slots.
|
|
10
|
+
* @return The grouped bar scale.
|
|
11
|
+
*/
|
|
12
|
+
export const createGroupScale = ( keys: string[], bandwidth: number, padding: number ) =>
|
|
13
|
+
scaleBand( { domain: keys, range: [ 0, bandwidth ], padding } );
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { DataContext, TooltipContext, useEventEmitter } from '@visx/xychart';
|
|
2
|
+
import { useCallback, useContext, useMemo } from 'react';
|
|
3
|
+
import { createGroupScale } from './band-scale';
|
|
4
|
+
import type { BandScale } from './band-scale';
|
|
5
|
+
import type { BarChartProps } from '../types';
|
|
6
|
+
|
|
7
|
+
type PointerHandler = NonNullable< Parameters< typeof useEventEmitter >[ 1 ] >;
|
|
8
|
+
type PointerSelection = Parameters< NonNullable< BarChartProps[ 'onPointerUp' ] > >[ 0 ];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Find a category by its painted center, including outer padding and reversed ranges.
|
|
12
|
+
* @param data - Registered series points.
|
|
13
|
+
* @param accessor - Category accessor.
|
|
14
|
+
* @param scale - Rendered category band scale.
|
|
15
|
+
* @param position - Pointer coordinate on the category axis.
|
|
16
|
+
* @return Index of the nearest category, or -1 when none is registered.
|
|
17
|
+
*/
|
|
18
|
+
export function nearestBandIndex< Datum >(
|
|
19
|
+
data: Datum[],
|
|
20
|
+
accessor: ( datum: Datum ) => unknown,
|
|
21
|
+
scale: BandScale,
|
|
22
|
+
position: number
|
|
23
|
+
): number {
|
|
24
|
+
let nearest = -1;
|
|
25
|
+
let distance = Infinity;
|
|
26
|
+
data.forEach( ( datum, index ) => {
|
|
27
|
+
const start = scale( accessor( datum ) );
|
|
28
|
+
if ( start === undefined ) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const candidate = Math.abs( start + scale.bandwidth() / 2 - position );
|
|
32
|
+
if ( candidate < distance ) {
|
|
33
|
+
distance = candidate;
|
|
34
|
+
nearest = index;
|
|
35
|
+
}
|
|
36
|
+
} );
|
|
37
|
+
return nearest;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Correct visx's range-based band inversion, which omits outer padding.
|
|
42
|
+
* @param root0 - Registered series selection.
|
|
43
|
+
* @param root0.keys - Visible series keys.
|
|
44
|
+
* @param root0.groupPadding - Padding between grouped bars.
|
|
45
|
+
* @param root0.withTooltips - Whether pointer events update the tooltip.
|
|
46
|
+
* @param root0.onPointerDown - Receives the corrected pointer-down datum.
|
|
47
|
+
* @param root0.onPointerUp - Receives the corrected pointer-up datum.
|
|
48
|
+
* @return No visual content.
|
|
49
|
+
*/
|
|
50
|
+
export function BandTooltip( {
|
|
51
|
+
keys,
|
|
52
|
+
groupPadding,
|
|
53
|
+
withTooltips,
|
|
54
|
+
onPointerDown,
|
|
55
|
+
onPointerUp,
|
|
56
|
+
}: { keys: string[]; groupPadding: number } & Pick<
|
|
57
|
+
BarChartProps,
|
|
58
|
+
'withTooltips' | 'onPointerDown' | 'onPointerUp'
|
|
59
|
+
> ) {
|
|
60
|
+
const { xScale, yScale, dataRegistry, horizontal } = useContext( DataContext );
|
|
61
|
+
const { showTooltip } = useContext( TooltipContext );
|
|
62
|
+
const scale = ( horizontal ? yScale : xScale ) as BandScale | undefined;
|
|
63
|
+
const bandwidth = scale?.bandwidth?.() ?? 0;
|
|
64
|
+
const groupScale = useMemo(
|
|
65
|
+
() => createGroupScale( keys, bandwidth, groupPadding ),
|
|
66
|
+
[ keys, bandwidth, groupPadding ]
|
|
67
|
+
);
|
|
68
|
+
const getSelections = useCallback(
|
|
69
|
+
( params: Parameters< PointerHandler >[ 0 ] ) => {
|
|
70
|
+
const point = params?.svgPoint;
|
|
71
|
+
if ( ! point || ! scale?.bandwidth ) {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
const selections: PointerSelection[] = [];
|
|
75
|
+
for ( const key of keys ) {
|
|
76
|
+
const entry = dataRegistry?.get( key );
|
|
77
|
+
if ( ! entry ) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const accessor = horizontal ? entry.yAccessor : entry.xAccessor;
|
|
81
|
+
const index = nearestBandIndex(
|
|
82
|
+
entry.data,
|
|
83
|
+
accessor,
|
|
84
|
+
scale,
|
|
85
|
+
horizontal ? point.y : point.x
|
|
86
|
+
);
|
|
87
|
+
if ( index < 0 ) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const datum = entry.data[ index ];
|
|
91
|
+
const start = Number( scale( accessor( datum ) ) ) + Number( groupScale( key ) );
|
|
92
|
+
const end = start + groupScale.step();
|
|
93
|
+
const position = horizontal ? point.y : point.x;
|
|
94
|
+
const distance =
|
|
95
|
+
position >= start && position <= end ? 0 : Math.abs( position - ( start + end ) / 2 );
|
|
96
|
+
selections.push( {
|
|
97
|
+
event: params.event,
|
|
98
|
+
key,
|
|
99
|
+
datum,
|
|
100
|
+
index,
|
|
101
|
+
svgPoint: point,
|
|
102
|
+
distanceX: horizontal ? 0 : distance,
|
|
103
|
+
distanceY: horizontal ? distance : 0,
|
|
104
|
+
} );
|
|
105
|
+
}
|
|
106
|
+
return selections;
|
|
107
|
+
},
|
|
108
|
+
[ scale, dataRegistry, horizontal, keys, groupScale ]
|
|
109
|
+
);
|
|
110
|
+
const handlePointer = useCallback< PointerHandler >(
|
|
111
|
+
params => {
|
|
112
|
+
const selections = getSelections( params );
|
|
113
|
+
if ( withTooltips && params?.event.type !== 'pointerup' ) {
|
|
114
|
+
selections.forEach( showTooltip );
|
|
115
|
+
}
|
|
116
|
+
const callback = params?.event.type === 'pointerdown' ? onPointerDown : onPointerUp;
|
|
117
|
+
if ( params?.event.type === 'pointermove' || ! callback ) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const nearest = selections.reduce(
|
|
121
|
+
( best, selection ) => {
|
|
122
|
+
const distance = ( value: typeof selection ) =>
|
|
123
|
+
Math.hypot( value.distanceX, value.distanceY );
|
|
124
|
+
return ! best || distance( selection ) <= distance( best ) ? selection : best;
|
|
125
|
+
},
|
|
126
|
+
undefined as ( typeof selections )[ number ] | undefined
|
|
127
|
+
);
|
|
128
|
+
if ( nearest ) {
|
|
129
|
+
callback( nearest );
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
[ getSelections, withTooltips, showTooltip, onPointerDown, onPointerUp ]
|
|
133
|
+
);
|
|
134
|
+
useEventEmitter( 'pointermove', withTooltips ? handlePointer : undefined );
|
|
135
|
+
useEventEmitter( 'pointerdown', handlePointer );
|
|
136
|
+
useEventEmitter( 'pointerup', onPointerUp ? handlePointer : undefined );
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { scaleBand } from '@visx/scale';
|
|
2
1
|
import { DataContext } from '@visx/xychart';
|
|
3
2
|
import { useContext } from 'react';
|
|
3
|
+
import { createGroupScale } from './band-scale';
|
|
4
4
|
import { computeComparisonRect, getValueScaleBaseline } from './comparison-bars-geometry';
|
|
5
5
|
import {
|
|
6
6
|
DEFAULT_COMPARISON_OPACITY,
|
|
7
7
|
DEFAULT_COMPARISON_WIDTH_FACTOR,
|
|
8
8
|
} from './comparison-constants';
|
|
9
|
+
import type { BandScale } from './band-scale';
|
|
9
10
|
import type { ElementStyles, GetElementStylesParams } from '../../../providers';
|
|
10
11
|
import type { DataPointDate, SeriesData } from '../../../types';
|
|
11
12
|
import type { FC, ReactNode } from 'react';
|
|
@@ -26,10 +27,8 @@ export type ComparisonSeriesEntry = {
|
|
|
26
27
|
|
|
27
28
|
// Minimal shape we need from visx scales — avoids spreading `any` while
|
|
28
29
|
// remaining compatible with both band and continuous scale return types.
|
|
29
|
-
type AnyScale = ( ( input: unknown ) => number ) &
|
|
30
|
-
bandwidth
|
|
31
|
-
range: () => unknown[];
|
|
32
|
-
};
|
|
30
|
+
type AnyScale = ( ( input: unknown ) => number ) &
|
|
31
|
+
Partial< Pick< BandScale, 'bandwidth' > > & { range: () => unknown[] };
|
|
33
32
|
|
|
34
33
|
// Renders translucent "shadow" bars for comparison series behind their paired primary bars.
|
|
35
34
|
// IMPORTANT: each comparison datum's category key (label or date) must exactly match a key
|
|
@@ -73,11 +72,7 @@ export const ComparisonBars: FC< {
|
|
|
73
72
|
}
|
|
74
73
|
|
|
75
74
|
// Rebuild visx's inner group scale exactly as BarGroup does.
|
|
76
|
-
const groupScale =
|
|
77
|
-
domain: primaryKeys,
|
|
78
|
-
range: [ 0, bandwidth ],
|
|
79
|
-
padding: groupPadding,
|
|
80
|
-
} );
|
|
75
|
+
const groupScale = createGroupScale( primaryKeys, bandwidth, groupPadding );
|
|
81
76
|
const slotThickness = groupScale.bandwidth();
|
|
82
77
|
const baseline = getValueScaleBaseline( valueScale );
|
|
83
78
|
|
|
@@ -9,3 +9,6 @@ export {
|
|
|
9
9
|
COMPARISON_TICK_GAP_FACTOR,
|
|
10
10
|
} from './comparison-constants';
|
|
11
11
|
export { BASE_BAND_PADDING_INNER } from './use-bar-chart-options';
|
|
12
|
+
export { countRenderedBars, isBarRendered } from './value-domain';
|
|
13
|
+
export { BandHighlight } from './band-highlight';
|
|
14
|
+
export { BandTooltip } from './band-tooltip';
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { scaleBand } from '@visx/scale';
|
|
4
|
+
import { DataContext, TooltipContext } from '@visx/xychart';
|
|
5
|
+
import { useState } from 'react';
|
|
6
|
+
import { GlobalChartsProvider } from '../../../../providers';
|
|
7
|
+
import { CATALOG_POINTERS } from '../../../../providers/chart-context/private/catalog-pointers';
|
|
8
|
+
import { BarChartUnresponsive } from '../../bar-chart';
|
|
9
|
+
import { BandHighlight } from '../band-highlight';
|
|
10
|
+
import type { BandHighlightSelection } from '../../types';
|
|
11
|
+
|
|
12
|
+
const xScale = scaleBand( { domain: [ 'Jan', 'Feb' ], range: [ 10, 210 ] } );
|
|
13
|
+
const dataContext = {
|
|
14
|
+
xScale,
|
|
15
|
+
dataRegistry: { get: () => ( { xAccessor: datum => datum.label } ) },
|
|
16
|
+
margin: { top: 20, left: 10 },
|
|
17
|
+
innerWidth: 200,
|
|
18
|
+
innerHeight: 150,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const Fixture = ( { label = 'Jan', onChange } ) => {
|
|
22
|
+
return (
|
|
23
|
+
<DataContext.Provider value={ dataContext as never }>
|
|
24
|
+
<TooltipContext.Provider
|
|
25
|
+
value={
|
|
26
|
+
{
|
|
27
|
+
tooltipOpen: true,
|
|
28
|
+
tooltipData: { nearestDatum: { key: 'scores', index: 0, datum: { label, value: 50 } } },
|
|
29
|
+
} as never
|
|
30
|
+
}
|
|
31
|
+
>
|
|
32
|
+
<svg>
|
|
33
|
+
<BandHighlight visible horizontal={ false } onChange={ onChange } />
|
|
34
|
+
</svg>
|
|
35
|
+
</TooltipContext.Provider>
|
|
36
|
+
</DataContext.Provider>
|
|
37
|
+
);
|
|
38
|
+
};
|
|
39
|
+
|
|
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
|
+
|
|
55
|
+
test( 'uses the catalog surface fill', () => {
|
|
56
|
+
render( <Fixture onChange={ undefined } /> );
|
|
57
|
+
expect( screen.getByTestId( 'bar-chart-band-highlight' ) ).toHaveAttribute(
|
|
58
|
+
'fill',
|
|
59
|
+
CATALOG_POINTERS.surfaceSecondary
|
|
60
|
+
);
|
|
61
|
+
} );
|
|
62
|
+
|
|
63
|
+
test( 'settles inline derived-state callbacks and notifies the latest callback on selection changes', () => {
|
|
64
|
+
const notify = jest.fn();
|
|
65
|
+
const DerivedState = ( { label } ) => {
|
|
66
|
+
const [ band, setBand ] = useState< { x: number } | null >( null );
|
|
67
|
+
return (
|
|
68
|
+
<>
|
|
69
|
+
<output>{ band?.x }</output>
|
|
70
|
+
<Fixture
|
|
71
|
+
label={ label }
|
|
72
|
+
// eslint-disable-next-line react/jsx-no-bind -- An inline callback reproduces parent state updates changing its identity.
|
|
73
|
+
onChange={ ( selection: BandHighlightSelection | null ) => {
|
|
74
|
+
notify( label );
|
|
75
|
+
if ( notify.mock.calls.length > 4 ) {
|
|
76
|
+
throw new Error( 'Highlight notifications did not settle' );
|
|
77
|
+
}
|
|
78
|
+
setBand( selection && { x: selection.x } );
|
|
79
|
+
} }
|
|
80
|
+
/>
|
|
81
|
+
</>
|
|
82
|
+
);
|
|
83
|
+
};
|
|
84
|
+
const { rerender } = render( <DerivedState label="Jan" /> );
|
|
85
|
+
expect( screen.getByRole( 'status' ) ).toHaveTextContent( '10' );
|
|
86
|
+
expect( notify ).toHaveBeenCalledTimes( 1 );
|
|
87
|
+
rerender( <DerivedState label="Feb" /> );
|
|
88
|
+
expect( screen.getByRole( 'status' ) ).toHaveTextContent( '110' );
|
|
89
|
+
expect( notify ).toHaveBeenCalledTimes( 2 );
|
|
90
|
+
expect( notify ).toHaveBeenLastCalledWith( 'Feb' );
|
|
91
|
+
} );
|
|
92
|
+
|
|
93
|
+
test.each( [ { withTooltips: false }, { data: [] } ] )(
|
|
94
|
+
'clears the selected band when the highlight unmounts with %j',
|
|
95
|
+
async props => {
|
|
96
|
+
const warn = jest.spyOn( console, 'warn' ).mockImplementation( () => {} );
|
|
97
|
+
const user = userEvent.setup();
|
|
98
|
+
const onChange = jest.fn();
|
|
99
|
+
const chartProps = {
|
|
100
|
+
width: 500,
|
|
101
|
+
height: 300,
|
|
102
|
+
withTooltips: true,
|
|
103
|
+
onBandHighlightChange: onChange,
|
|
104
|
+
data: [ { label: 'Scores', data: [ { label: 'Jan', value: 50 } ] } ],
|
|
105
|
+
};
|
|
106
|
+
const { rerender } = render(
|
|
107
|
+
<GlobalChartsProvider>
|
|
108
|
+
<BarChartUnresponsive { ...chartProps } />
|
|
109
|
+
</GlobalChartsProvider>
|
|
110
|
+
);
|
|
111
|
+
await user.tab();
|
|
112
|
+
await user.keyboard( '{ArrowRight}' );
|
|
113
|
+
expect( onChange ).toHaveBeenLastCalledWith(
|
|
114
|
+
expect.objectContaining( { datum: expect.objectContaining( { label: 'Jan' } ) } )
|
|
115
|
+
);
|
|
116
|
+
rerender(
|
|
117
|
+
<GlobalChartsProvider>
|
|
118
|
+
<BarChartUnresponsive { ...chartProps } { ...props } />
|
|
119
|
+
</GlobalChartsProvider>
|
|
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();
|
|
132
|
+
expect( onChange ).toHaveBeenLastCalledWith( null );
|
|
133
|
+
}
|
|
134
|
+
);
|
|
135
|
+
} );
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { scaleBand } from '@visx/scale';
|
|
2
|
+
import { nearestBandIndex } from '../band-tooltip';
|
|
3
|
+
|
|
4
|
+
describe( 'nearestBandIndex', () => {
|
|
5
|
+
test.each( [
|
|
6
|
+
[ 25, 688 ],
|
|
7
|
+
[ 688, 25 ],
|
|
8
|
+
] )( 'matches every painted center with outer padding in range %s to %s', ( start, end ) => {
|
|
9
|
+
const data = Array.from( { length: 30 }, ( _, index ) => String( index ) );
|
|
10
|
+
const scale = scaleBand( {
|
|
11
|
+
domain: data,
|
|
12
|
+
range: [ start, end ],
|
|
13
|
+
paddingInner: 0.024,
|
|
14
|
+
paddingOuter: 0.7,
|
|
15
|
+
} );
|
|
16
|
+
data.forEach( ( datum, index ) => {
|
|
17
|
+
expect(
|
|
18
|
+
nearestBandIndex( data, value => value, scale, scale( datum )! + scale.bandwidth() / 2 )
|
|
19
|
+
).toBe( index );
|
|
20
|
+
} );
|
|
21
|
+
} );
|
|
22
|
+
test( 'ignores data outside the band domain', () => {
|
|
23
|
+
const scale = scaleBand( { domain: [ 'recorded' ], range: [ 0, 100 ] } );
|
|
24
|
+
expect( nearestBandIndex( [ 'missing' ], value => value, scale, 50 ) ).toBe( -1 );
|
|
25
|
+
} );
|
|
26
|
+
} );
|
|
@@ -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
|
+
} );
|
|
@@ -11,7 +11,7 @@ import type { FC, CSSProperties } from 'react';
|
|
|
11
11
|
* @return The bandwidth of the scale
|
|
12
12
|
*/
|
|
13
13
|
const getScaleBandwidth = < Scale extends AxisScale >( scale?: Scale ) => {
|
|
14
|
-
return scale && 'bandwidth' in scale ? scale.bandwidth() ?? 0 : 0;
|
|
14
|
+
return scale && 'bandwidth' in scale ? ( scale.bandwidth() ?? 0 ) : 0;
|
|
15
15
|
};
|
|
16
16
|
interface TruncatedTickComponentProps extends TickRendererProps {
|
|
17
17
|
/** Which axis this tick belongs to */
|
|
@@ -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,
|
|
@@ -330,7 +303,7 @@ export function useBarChartOptions(
|
|
|
330
303
|
timeAxis.domain,
|
|
331
304
|
timeAxis.tickFormatter,
|
|
332
305
|
dateAxisOptions.numTicks ?? DEFAULT_NUM_TICKS
|
|
333
|
-
|
|
306
|
+
)
|
|
334
307
|
: null;
|
|
335
308
|
const dateAxisTickValues = bandTickValues ? { tickValues: bandTickValues } : {};
|
|
336
309
|
|
|
@@ -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
|
}
|