@automattic/charts 4.1.1 → 4.2.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 +19 -0
- package/TOKENS.md +1 -1
- package/dist/index.cjs +875 -237
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +30 -1
- package/dist/index.d.cts +106 -6
- package/dist/index.d.ts +106 -6
- package/dist/index.js +875 -239
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/charts/area-chart/private/overlays.tsx +1 -2
- package/src/charts/bar-chart/bar-chart.tsx +67 -17
- package/src/charts/bar-chart/index.ts +1 -1
- package/src/charts/bar-chart/private/band-highlight.tsx +85 -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 +2 -0
- package/src/charts/bar-chart/private/test/band-highlight.test.tsx +109 -0
- package/src/charts/bar-chart/private/test/band-tooltip.test.ts +26 -0
- package/src/charts/bar-chart/private/truncated-tick-component.tsx +1 -1
- package/src/charts/bar-chart/private/use-bar-chart-options.ts +1 -1
- package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +11 -23
- package/src/charts/bar-chart/test/bar-chart.test.tsx +565 -1
- 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 +1 -1
- package/src/charts/line-chart/private/line-chart-annotation.tsx +1 -1
- package/src/charts/line-chart/types.ts +2 -2
- package/src/charts/private/time-axis-options.ts +1 -1
- 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/private/base-legend.tsx +1 -1
- package/src/components/tooltip/accessible-tooltip.tsx +127 -40
- 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-element-size.test.tsx +13 -13
- package/src/hooks/use-xychart-theme.ts +1 -1
- package/src/index.ts +7 -1
- package/src/providers/chart-context/themes.ts +1 -0
- package/src/styles/test/chart-scope.test.ts +1 -1
- package/src/types.ts +6 -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@automattic/charts",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.0",
|
|
4
4
|
"description": "Display charts within Automattic products.",
|
|
5
5
|
"homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/js-packages/charts/#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"dependencies": {
|
|
66
66
|
"@automattic/number-formatters": "^1.2.9",
|
|
67
67
|
"@babel/runtime": "7.29.7",
|
|
68
|
-
"@date-fns/tz": "1.
|
|
68
|
+
"@date-fns/tz": "1.5.0",
|
|
69
69
|
"@react-spring/web": "10.1.1",
|
|
70
70
|
"@visx/annotation": "^4.0.0",
|
|
71
71
|
"@visx/axis": "^4.0.0",
|
|
@@ -84,9 +84,9 @@
|
|
|
84
84
|
"@visx/vendor": "^4.0.0",
|
|
85
85
|
"@visx/xychart": "^4.0.0",
|
|
86
86
|
"@wordpress/i18n": "^6.0.0",
|
|
87
|
-
"@wordpress/icons": "^
|
|
88
|
-
"@wordpress/theme": "2.1.1-next.v.
|
|
89
|
-
"@wordpress/ui": "0.
|
|
87
|
+
"@wordpress/icons": "^16.0.0",
|
|
88
|
+
"@wordpress/theme": "2.1.1-next.v.202609031004.0",
|
|
89
|
+
"@wordpress/ui": "0.22.0",
|
|
90
90
|
"clsx": "2.1.1",
|
|
91
91
|
"date-fns": "^4.1.0",
|
|
92
92
|
"deepmerge": "4.3.1",
|
|
@@ -111,9 +111,9 @@
|
|
|
111
111
|
"@types/react-dom": "18.3.7",
|
|
112
112
|
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
|
113
113
|
"@visx/glyph": "4.0.0",
|
|
114
|
-
"@wordpress/components": "40.
|
|
115
|
-
"@wordpress/element": "8.
|
|
116
|
-
"@wordpress/private-apis": "1.
|
|
114
|
+
"@wordpress/components": "40.1.0",
|
|
115
|
+
"@wordpress/element": "8.7.0",
|
|
116
|
+
"@wordpress/private-apis": "1.55.0",
|
|
117
117
|
"babel-jest": "30.5.1",
|
|
118
118
|
"identity-obj-proxy": "^3.0.0",
|
|
119
119
|
"jest": "30.5.1",
|
|
@@ -57,8 +57,7 @@ export const HoverGlyphs: FC< {
|
|
|
57
57
|
const yScale = dataContext?.yScale as ScaleFn | undefined;
|
|
58
58
|
const tooltipOpen = tooltipContext?.tooltipOpen;
|
|
59
59
|
const nearestDatum = tooltipContext?.tooltipData?.nearestDatum?.datum as
|
|
60
|
-
|
|
|
61
|
-
| undefined;
|
|
60
|
+
DataPointDate | undefined;
|
|
62
61
|
|
|
63
62
|
if (
|
|
64
63
|
! tooltipOpen ||
|
|
@@ -31,6 +31,8 @@ import plotStyles from '../private/xy-plot/xy-plot.module.scss';
|
|
|
31
31
|
import styles from './bar-chart.module.scss';
|
|
32
32
|
import {
|
|
33
33
|
useBarChartOptions,
|
|
34
|
+
BandHighlight,
|
|
35
|
+
BandTooltip,
|
|
34
36
|
ComparisonBars,
|
|
35
37
|
DEFAULT_COMPARISON_WIDTH_FACTOR,
|
|
36
38
|
COMPARISON_INNER_GAP,
|
|
@@ -51,15 +53,31 @@ import type { RenderTooltipParams } from '../../visx/types';
|
|
|
51
53
|
import type { ResponsiveConfig } from '../private/with-responsive';
|
|
52
54
|
import type { FC, ReactNode, ComponentType } from 'react';
|
|
53
55
|
|
|
56
|
+
export type BandHighlightSelection = {
|
|
57
|
+
datum: DataPointDate;
|
|
58
|
+
x: number;
|
|
59
|
+
y: number;
|
|
60
|
+
width: number;
|
|
61
|
+
height: number;
|
|
62
|
+
};
|
|
63
|
+
|
|
54
64
|
export interface BarChartProps extends BaseChartProps< SeriesData[] >, SeriesVisibilityProps {
|
|
55
65
|
/**
|
|
56
66
|
* Legend configuration. Supports `collapseGroups` on top of the shared options.
|
|
57
67
|
*/
|
|
58
68
|
legend?: SeriesChartLegendConfig;
|
|
59
69
|
renderTooltip?: ( params: RenderTooltipParams< DataPointDate > ) => ReactNode;
|
|
70
|
+
/** Place the tooltip beside its category without vertical flipping. */
|
|
71
|
+
tooltipPlacement?: 'auto' | 'beside';
|
|
72
|
+
/** Tooltip top anchor in SVG coordinates; negative offsets are supported. */
|
|
73
|
+
tooltipAnchorTop?: number;
|
|
60
74
|
orientation?: 'horizontal' | 'vertical';
|
|
61
75
|
withPatterns?: boolean;
|
|
62
76
|
showZeroValues?: boolean;
|
|
77
|
+
/** Highlight the active band across the plot when tooltips are enabled. */
|
|
78
|
+
withBandHighlight?: boolean;
|
|
79
|
+
/** Receive active band bounds in SVG coordinates, or null when dismissed. */
|
|
80
|
+
onBandHighlightChange?: ( selection: BandHighlightSelection | null ) => void;
|
|
63
81
|
children?: ReactNode;
|
|
64
82
|
}
|
|
65
83
|
|
|
@@ -121,10 +139,14 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
121
139
|
legend = {},
|
|
122
140
|
gridVisibility: gridVisibilityProp,
|
|
123
141
|
renderTooltip,
|
|
142
|
+
tooltipPlacement,
|
|
143
|
+
tooltipAnchorTop,
|
|
124
144
|
options = {},
|
|
125
145
|
orientation = 'vertical',
|
|
126
146
|
withPatterns = false,
|
|
127
147
|
showZeroValues = false,
|
|
148
|
+
withBandHighlight = false,
|
|
149
|
+
onBandHighlightChange,
|
|
128
150
|
defaultHiddenSeries,
|
|
129
151
|
animation,
|
|
130
152
|
children,
|
|
@@ -193,13 +215,6 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
193
215
|
const [ selectedIndex, setSelectedIndex ] = useState< number | undefined >( undefined );
|
|
194
216
|
const [ isNavigating, setIsNavigating ] = useState( false );
|
|
195
217
|
|
|
196
|
-
// Comparison series have no .visx-bar elements; count only primary series so
|
|
197
|
-
// keyboard navigation doesn't cycle phantom indices into comparison-only slots.
|
|
198
|
-
const primarySeriesForNav = dataWithVisibleZeros.filter( s => s.options?.type !== 'comparison' );
|
|
199
|
-
const totalPoints =
|
|
200
|
-
Math.max( 0, ...primarySeriesForNav.map( s => s.data?.length || 0 ) ) *
|
|
201
|
-
primarySeriesForNav.length;
|
|
202
|
-
|
|
203
218
|
// Add visibility information from the shared legend state.
|
|
204
219
|
const seriesWithVisibility = useMemo(
|
|
205
220
|
() =>
|
|
@@ -225,6 +240,9 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
225
240
|
[ seriesWithVisibility ]
|
|
226
241
|
);
|
|
227
242
|
|
|
243
|
+
const totalPoints =
|
|
244
|
+
Math.max( 0, ...primaryEntries.map( e => e.series.data.length ) ) * primaryEntries.length;
|
|
245
|
+
|
|
228
246
|
const primaryKeys = useMemo(
|
|
229
247
|
() => primaryEntries.map( ( { series } ) => series.label ),
|
|
230
248
|
[ primaryEntries ]
|
|
@@ -259,6 +277,8 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
259
277
|
[ primaryEntries, onDatumActivate ]
|
|
260
278
|
);
|
|
261
279
|
|
|
280
|
+
const visibleSeriesKey = useMemo( () => JSON.stringify( primaryKeys ), [ primaryKeys ] );
|
|
281
|
+
|
|
262
282
|
const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown } = useKeyboardNavigation( {
|
|
263
283
|
selectedIndex,
|
|
264
284
|
setSelectedIndex,
|
|
@@ -267,6 +287,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
267
287
|
chartRef,
|
|
268
288
|
totalPoints,
|
|
269
289
|
onActivate: activateSelectedBar,
|
|
290
|
+
visibleSeriesKey,
|
|
270
291
|
} );
|
|
271
292
|
|
|
272
293
|
const comparisonEntries = useMemo( () => {
|
|
@@ -342,10 +363,10 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
342
363
|
}, [ comparisonEntries.length, chartOptions.xScale, chartOptions.yScale, horizontal ] );
|
|
343
364
|
|
|
344
365
|
const getBarBackground = useCallback(
|
|
345
|
-
( index: number ) => () =>
|
|
366
|
+
( index: number ) => ( datum: DataPointDate ) =>
|
|
346
367
|
withPatterns
|
|
347
368
|
? `url(#${ getPatternId( chartId, index ) })`
|
|
348
|
-
: getElementStyles( { data: dataSorted[ index ], index } ).color,
|
|
369
|
+
: ( datum.color ?? getElementStyles( { data: dataSorted[ index ], index } ).color ),
|
|
349
370
|
[ withPatterns, getElementStyles, dataSorted, chartId ]
|
|
350
371
|
);
|
|
351
372
|
|
|
@@ -581,6 +602,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
581
602
|
return (
|
|
582
603
|
<div
|
|
583
604
|
role="grid"
|
|
605
|
+
ref={ chartRef }
|
|
584
606
|
aria-label={ __( 'Bar chart', 'jetpack-charts' ) }
|
|
585
607
|
tabIndex={ 0 }
|
|
586
608
|
onKeyDown={ onChartKeyDown }
|
|
@@ -588,7 +610,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
588
610
|
onBlur={ onChartBlur }
|
|
589
611
|
>
|
|
590
612
|
{ chartHeight > 0 && (
|
|
591
|
-
<div
|
|
613
|
+
<div className={ plotStyles[ 'xy-plot' ] }>
|
|
592
614
|
<XYChart
|
|
593
615
|
theme={ theme }
|
|
594
616
|
width={ width }
|
|
@@ -600,18 +622,34 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
600
622
|
xScale={ xScale }
|
|
601
623
|
yScale={ yScale }
|
|
602
624
|
horizontal={ horizontal }
|
|
603
|
-
onPointerDown={ onPointerDown }
|
|
604
|
-
onPointerUp={ onPointerUp }
|
|
605
625
|
pointerEventsDataKey="nearest"
|
|
606
626
|
>
|
|
607
|
-
{
|
|
608
|
-
<
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
627
|
+
{ withTooltips && ( withBandHighlight || onBandHighlightChange ) && (
|
|
628
|
+
<BandHighlight
|
|
629
|
+
visible={ withBandHighlight }
|
|
630
|
+
horizontal={ horizontal }
|
|
631
|
+
onChange={ onBandHighlightChange }
|
|
612
632
|
/>
|
|
613
633
|
) }
|
|
614
634
|
|
|
635
|
+
{ ! allSeriesHidden && (
|
|
636
|
+
<>
|
|
637
|
+
{ /* Visx forwards tickValues to its grid primitives but omits it from GridProps. */ }
|
|
638
|
+
<Grid
|
|
639
|
+
columns={ gridVisibility.includes( 'y' ) }
|
|
640
|
+
rows={ false }
|
|
641
|
+
numTicks={ 4 }
|
|
642
|
+
{ ...{ tickValues: chartOptions.axis.x.tickValues } }
|
|
643
|
+
/>
|
|
644
|
+
<Grid
|
|
645
|
+
columns={ false }
|
|
646
|
+
rows={ gridVisibility.includes( 'x' ) }
|
|
647
|
+
numTicks={ 4 }
|
|
648
|
+
{ ...{ tickValues: chartOptions.axis.y.tickValues } }
|
|
649
|
+
/>
|
|
650
|
+
</>
|
|
651
|
+
) }
|
|
652
|
+
|
|
615
653
|
{ withPatterns && (
|
|
616
654
|
<>
|
|
617
655
|
<defs data-testid="bar-chart-patterns">
|
|
@@ -673,6 +711,16 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
673
711
|
/>
|
|
674
712
|
) ) }
|
|
675
713
|
</BarGroup>
|
|
714
|
+
{ /* Do not reorder: for one key the last showTooltip wins, so this must run after BarGroup. */ }
|
|
715
|
+
{ ( withTooltips || onPointerDown || onPointerUp ) && (
|
|
716
|
+
<BandTooltip
|
|
717
|
+
keys={ primaryKeys }
|
|
718
|
+
groupPadding={ groupPadding }
|
|
719
|
+
withTooltips={ withTooltips }
|
|
720
|
+
onPointerDown={ onPointerDown }
|
|
721
|
+
onPointerUp={ onPointerUp }
|
|
722
|
+
/>
|
|
723
|
+
) }
|
|
676
724
|
|
|
677
725
|
{ /* With every series hidden there is no data to build the value scale from, so
|
|
678
726
|
visx collapses the domain and the axes render squished at the top. Drop them
|
|
@@ -686,6 +734,8 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
686
734
|
|
|
687
735
|
{ withTooltips && (
|
|
688
736
|
<AccessibleTooltip
|
|
737
|
+
tooltipPlacement={ tooltipPlacement }
|
|
738
|
+
tooltipAnchorTop={ tooltipAnchorTop }
|
|
689
739
|
detectBounds
|
|
690
740
|
snapTooltipToDatumX
|
|
691
741
|
snapTooltipToDatumY
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { default as BarChart, BarChartUnresponsive } from './bar-chart';
|
|
2
|
-
export type { BarChartProps } from './bar-chart';
|
|
2
|
+
export type { BarChartProps, BandHighlightSelection } from './bar-chart';
|
|
@@ -0,0 +1,85 @@
|
|
|
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 '../bar-chart';
|
|
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
|
+
x: horizontal ? ( margin?.left ?? 0 ) : position,
|
|
45
|
+
y: horizontal ? position : ( margin?.top ?? 0 ),
|
|
46
|
+
width: horizontal ? innerWidth : scale.bandwidth(),
|
|
47
|
+
height: horizontal ? scale.bandwidth() : innerHeight,
|
|
48
|
+
};
|
|
49
|
+
}, [
|
|
50
|
+
tooltipOpen,
|
|
51
|
+
nearest,
|
|
52
|
+
horizontal,
|
|
53
|
+
xScale,
|
|
54
|
+
yScale,
|
|
55
|
+
dataRegistry,
|
|
56
|
+
margin,
|
|
57
|
+
innerWidth,
|
|
58
|
+
innerHeight,
|
|
59
|
+
] );
|
|
60
|
+
|
|
61
|
+
const stableSelection = useDeepMemo( selection );
|
|
62
|
+
const onChangeRef = useRef( onChange );
|
|
63
|
+
onChangeRef.current = onChange;
|
|
64
|
+
useEffect( () => {
|
|
65
|
+
onChangeRef.current?.( stableSelection );
|
|
66
|
+
}, [ stableSelection ] );
|
|
67
|
+
|
|
68
|
+
useEffect( () => () => onChangeRef.current?.( null ), [] );
|
|
69
|
+
|
|
70
|
+
if ( ! visible || ! selection ) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
const { x, y, width, height } = selection;
|
|
74
|
+
return (
|
|
75
|
+
<rect
|
|
76
|
+
x={ x }
|
|
77
|
+
y={ y }
|
|
78
|
+
width={ width }
|
|
79
|
+
height={ height }
|
|
80
|
+
data-testid="bar-chart-band-highlight"
|
|
81
|
+
pointerEvents="none"
|
|
82
|
+
fill={ CATALOG_POINTERS.surfaceSecondary }
|
|
83
|
+
/>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -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 '../bar-chart';
|
|
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
|
|
|
@@ -0,0 +1,109 @@
|
|
|
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 '../../bar-chart';
|
|
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', 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( 'uses the catalog surface fill', () => {
|
|
42
|
+
render( <Fixture onChange={ undefined } /> );
|
|
43
|
+
expect( screen.getByTestId( 'bar-chart-band-highlight' ) ).toHaveAttribute(
|
|
44
|
+
'fill',
|
|
45
|
+
CATALOG_POINTERS.surfaceSecondary
|
|
46
|
+
);
|
|
47
|
+
} );
|
|
48
|
+
|
|
49
|
+
test( 'settles inline derived-state callbacks and notifies the latest callback on selection changes', () => {
|
|
50
|
+
const notify = jest.fn();
|
|
51
|
+
const DerivedState = ( { label } ) => {
|
|
52
|
+
const [ band, setBand ] = useState< { x: number } | null >( null );
|
|
53
|
+
return (
|
|
54
|
+
<>
|
|
55
|
+
<output>{ band?.x }</output>
|
|
56
|
+
<Fixture
|
|
57
|
+
label={ label }
|
|
58
|
+
// eslint-disable-next-line react/jsx-no-bind -- An inline callback reproduces parent state updates changing its identity.
|
|
59
|
+
onChange={ ( selection: BandHighlightSelection | null ) => {
|
|
60
|
+
notify( label );
|
|
61
|
+
if ( notify.mock.calls.length > 4 ) {
|
|
62
|
+
throw new Error( 'Highlight notifications did not settle' );
|
|
63
|
+
}
|
|
64
|
+
setBand( selection && { x: selection.x } );
|
|
65
|
+
} }
|
|
66
|
+
/>
|
|
67
|
+
</>
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
const { rerender } = render( <DerivedState label="Jan" /> );
|
|
71
|
+
expect( screen.getByRole( 'status' ) ).toHaveTextContent( '10' );
|
|
72
|
+
expect( notify ).toHaveBeenCalledTimes( 1 );
|
|
73
|
+
rerender( <DerivedState label="Feb" /> );
|
|
74
|
+
expect( screen.getByRole( 'status' ) ).toHaveTextContent( '110' );
|
|
75
|
+
expect( notify ).toHaveBeenCalledTimes( 2 );
|
|
76
|
+
expect( notify ).toHaveBeenLastCalledWith( 'Feb' );
|
|
77
|
+
} );
|
|
78
|
+
|
|
79
|
+
test.each( [ { withTooltips: false }, { data: [] } ] )(
|
|
80
|
+
'clears the selected band when the highlight unmounts with %j',
|
|
81
|
+
async props => {
|
|
82
|
+
const user = userEvent.setup();
|
|
83
|
+
const onChange = jest.fn();
|
|
84
|
+
const chartProps = {
|
|
85
|
+
width: 500,
|
|
86
|
+
height: 300,
|
|
87
|
+
withTooltips: true,
|
|
88
|
+
onBandHighlightChange: onChange,
|
|
89
|
+
data: [ { label: 'Scores', data: [ { label: 'Jan', value: 50 } ] } ],
|
|
90
|
+
};
|
|
91
|
+
const { rerender } = render(
|
|
92
|
+
<GlobalChartsProvider>
|
|
93
|
+
<BarChartUnresponsive { ...chartProps } />
|
|
94
|
+
</GlobalChartsProvider>
|
|
95
|
+
);
|
|
96
|
+
await user.tab();
|
|
97
|
+
await user.keyboard( '{ArrowRight}' );
|
|
98
|
+
expect( onChange ).toHaveBeenLastCalledWith(
|
|
99
|
+
expect.objectContaining( { datum: expect.objectContaining( { label: 'Jan' } ) } )
|
|
100
|
+
);
|
|
101
|
+
rerender(
|
|
102
|
+
<GlobalChartsProvider>
|
|
103
|
+
<BarChartUnresponsive { ...chartProps } { ...props } />
|
|
104
|
+
</GlobalChartsProvider>
|
|
105
|
+
);
|
|
106
|
+
expect( onChange ).toHaveBeenLastCalledWith( null );
|
|
107
|
+
}
|
|
108
|
+
);
|
|
109
|
+
} );
|
|
@@ -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
|
+
} );
|
|
@@ -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 */
|