@automattic/charts 4.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/charts",
3
- "version": "4.3.0",
3
+ "version": "4.4.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": {
@@ -35,6 +35,7 @@ import {
35
35
  useBarChartOptions,
36
36
  BandHighlight,
37
37
  BandTooltip,
38
+ ClassifiedBarSeries,
38
39
  ComparisonBars,
39
40
  DEFAULT_COMPARISON_WIDTH_FACTOR,
40
41
  COMPARISON_INNER_GAP,
@@ -111,6 +112,8 @@ const BarChartInternal: FC< BarChartProps > = ( {
111
112
  renderTooltip,
112
113
  tooltipPlacement,
113
114
  tooltipAnchorTop,
115
+ tooltipStyle,
116
+ barClassName,
114
117
  options = {},
115
118
  orientation = 'vertical',
116
119
  withPatterns = false,
@@ -704,19 +707,37 @@ const BarChartInternal: FC< BarChartProps > = ( {
704
707
  resolveFill={ resolveComparisonFill }
705
708
  />
706
709
 
707
- <BarGroup padding={ groupPadding }>
708
- { primaryEntries.map( ( { series: seriesData, index } ) => (
709
- <BarSeries
710
- key={ seriesData?.label }
711
- dataKey={ seriesData?.label }
712
- data={ seriesData.data as DataPointDate[] }
713
- yAccessor={ chartOptions.accessors.yAccessor }
714
- xAccessor={ chartOptions.accessors.xAccessor }
715
- colorAccessor={ getBarBackground( index ) }
716
- />
717
- ) ) }
718
- </BarGroup>
719
- { /* Do not reorder: for one key the last showTooltip wins, so this must run after BarGroup. */ }
710
+ { barClassName ? (
711
+ <g className="visx-bar-group">
712
+ { primaryEntries.map( ( { series: seriesData, index } ) => (
713
+ <ClassifiedBarSeries
714
+ key={ seriesData.label }
715
+ dataKey={ seriesData.label }
716
+ data={ seriesData.data as DataPointDate[] }
717
+ xAccessor={ chartOptions.accessors.xAccessor }
718
+ yAccessor={ chartOptions.accessors.yAccessor }
719
+ colorAccessor={ getBarBackground( index ) }
720
+ barClassName={ barClassName }
721
+ primaryKeys={ primaryKeys }
722
+ groupPadding={ groupPadding }
723
+ />
724
+ ) ) }
725
+ </g>
726
+ ) : (
727
+ <BarGroup padding={ groupPadding }>
728
+ { primaryEntries.map( ( { series: seriesData, index } ) => (
729
+ <BarSeries
730
+ key={ seriesData?.label }
731
+ dataKey={ seriesData?.label }
732
+ data={ seriesData.data as DataPointDate[] }
733
+ yAccessor={ chartOptions.accessors.yAccessor }
734
+ xAccessor={ chartOptions.accessors.xAccessor }
735
+ colorAccessor={ getBarBackground( index ) }
736
+ />
737
+ ) ) }
738
+ </BarGroup>
739
+ ) }
740
+ { /* Do not reorder: for one key the last showTooltip wins, so this must run after the primary series. */ }
720
741
  { ( withTooltips || onPointerDown || onPointerUp ) && (
721
742
  <BandTooltip
722
743
  keys={ primaryKeys }
@@ -753,6 +774,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
753
774
  <AccessibleTooltip
754
775
  tooltipPlacement={ tooltipPlacement }
755
776
  tooltipAnchorTop={ tooltipAnchorTop }
777
+ style={ tooltipStyle }
756
778
  detectBounds
757
779
  snapTooltipToDatumX
758
780
  snapTooltipToDatumY
@@ -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
- * Output position of a value scale's baseline: zero if in-domain, else the
7
- * nearest range edge. Mirrors visx's getScaleBaseline so comparison shadows
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,12 +9,11 @@ describe( 'getValueScaleBaseline', () => {
9
9
  expect( getValueScaleBaseline( scale ) ).toBe( 200 );
10
10
  } );
11
11
 
12
- it( 'clamps to the end (range max) when 0 is outside an ascending range scale', () => {
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
- // scale(0) = (100/90)*200 ≈ 222 -> clamped to 200
17
- expect( getValueScaleBaseline( scale ) ).toBe( 200 );
16
+ expect( getValueScaleBaseline( scale ) ).toBeCloseTo( 222.222222 );
18
17
  } );
19
18
  } );
20
19
 
@@ -14,7 +14,7 @@ import { steadyTrafficData } from '../../../stories/sample-data';
14
14
  import BarChart, { BarChartUnresponsive } from '../bar-chart';
15
15
  import { useBarChartOptions } from '../private';
16
16
  import type { GlobalChartsContextValue } from '../../../providers/chart-context/types';
17
- import type { SeriesData } from '../../../types';
17
+ import type { DataPointDate, SeriesData } from '../../../types';
18
18
 
19
19
  // Mock useElementSize to return non-zero dimensions in jsdom so charts render
20
20
  const mockRefCallback = jest.fn();
@@ -60,6 +60,70 @@ describe( 'BarChart', () => {
60
60
  );
61
61
  };
62
62
 
63
+ test.each(
64
+ [ 'vertical', 'horizontal' ].flatMap( orientation =>
65
+ [
66
+ [ 10, 20, 30 ],
67
+ [ -20, -10, -30 ],
68
+ [ -20, 10, 30 ],
69
+ ].map( values => ( { orientation, values } ) )
70
+ )
71
+ )(
72
+ 'adds datum classes without changing $orientation bar attributes for $values',
73
+ async ( { orientation, values } ) => {
74
+ const series = {
75
+ ...defaultProps.data[ 0 ],
76
+ data: defaultProps.data[ 0 ].data.map( ( datum, index ) => ( {
77
+ ...datum,
78
+ value: values[ index ],
79
+ } ) ),
80
+ };
81
+ const props = {
82
+ orientation,
83
+ data: [ series, { ...series, label: 'Series B' } ],
84
+ };
85
+ const view = renderWithTheme( props );
86
+ await waitFor( () => expect( getBarRects() ).toHaveLength( 6 ) );
87
+ const attributes = () =>
88
+ Array.from( getBarRects(), bar =>
89
+ [ 'x', 'y', 'width', 'height', 'fill', 'tabindex' ].map( attribute =>
90
+ bar.getAttribute( attribute )
91
+ )
92
+ );
93
+ const original = attributes();
94
+ view.unmount();
95
+ renderWithTheme( {
96
+ ...props,
97
+ barClassName: ( datum: DataPointDate ) =>
98
+ datum.value === values[ 0 ] ? 'first-value' : undefined,
99
+ } );
100
+ await waitFor( () => expect( getBarRects() ).toHaveLength( 6 ) );
101
+ expect( attributes() ).toEqual( original );
102
+ expect( getBarRects()[ 0 ] ).toHaveClass( 'visx-bar', 'first-value' );
103
+ expect( getBarRects()[ 1 ] ).not.toHaveClass( 'first-value' );
104
+ expect( getBarRects()[ 3 ] ).toHaveClass( 'first-value' );
105
+ }
106
+ );
107
+
108
+ test( 'applies tooltip style overrides while keeping keyboard tooltip content', async () => {
109
+ const user = userEvent.setup();
110
+ renderWithTheme( {
111
+ withTooltips: true,
112
+ barClassName: () => 'styled-bar',
113
+ tooltipStyle: { padding: 0, backgroundColor: 'transparent', boxShadow: 'none' },
114
+ } );
115
+ await user.tab();
116
+ await user.keyboard( '{ArrowRight}' );
117
+ const tooltip = await screen.findByRole( 'tooltip' );
118
+ expect( tooltip ).toHaveTextContent( 'Jan 1' );
119
+ expect( screen.getByTestId( 'bounded-tooltip' ) ).toHaveStyle( {
120
+ padding: '0px',
121
+ // jsdom normalizes transparent in computed styles.
122
+ 'background-color': 'rgba(0, 0, 0, 0)',
123
+ 'box-shadow': 'none',
124
+ } );
125
+ } );
126
+
63
127
  describe( 'pointer selection and focus return', () => {
64
128
  const screenTransform = Object.getOwnPropertyDescriptor( SVGElement.prototype, 'getScreenCTM' );
65
129
  beforeAll( () => {
@@ -677,6 +741,71 @@ describe( 'BarChart', () => {
677
741
  } );
678
742
  } );
679
743
 
744
+ describe( 'Grid tick counts', () => {
745
+ const getPositions = ( selector: string, coordinate: string ) => {
746
+ // eslint-disable-next-line testing-library/no-node-access -- See the visx node constraint above.
747
+ const lines = screen.getByRole( 'grid' ).querySelectorAll( selector );
748
+ return Array.from( lines, line => Number( line.getAttribute( coordinate ) ) );
749
+ };
750
+
751
+ test.each( [ 'x', 'y' ] )( 'aligns an explicit tick count with the %s axis', axis => {
752
+ renderWithTheme( {
753
+ data: [
754
+ {
755
+ label: 'Views',
756
+ data: Array.from( { length: 8 }, ( _, index ) => ( {
757
+ label: `Day ${ index + 1 }`,
758
+ value: ( index + 1 ) * 10,
759
+ } ) ),
760
+ },
761
+ ],
762
+ gridVisibility: 'xy',
763
+ options: {
764
+ axis: { [ axis ]: { numTicks: 2, tickFormat: String, axisClassName: 'test-value-axis' } },
765
+ },
766
+ } );
767
+ const grid = getPositions(
768
+ axis === 'x' ? '.visx-columns line' : '.visx-rows line',
769
+ `${ axis }1`
770
+ );
771
+ const ticks = getPositions( '.test-value-axis .visx-axis-tick line', `${ axis }1` );
772
+ expect( ticks.length ).toBeGreaterThan( 0 );
773
+ expect( grid ).toEqual( ticks );
774
+ } );
775
+
776
+ test.each( [ false, true ] )(
777
+ 'preserves the default tick count (horizontal=%s)',
778
+ horizontal => {
779
+ const axis = horizontal ? 'x' : 'y';
780
+ const positionsFor = ( axisOptions: object ) => {
781
+ const { unmount } = renderWithTheme( {
782
+ orientation: horizontal ? 'horizontal' : 'vertical',
783
+ gridVisibility: 'xy',
784
+ options: {
785
+ axis: { [ axis ]: { axisClassName: 'test-value-axis', ...axisOptions } },
786
+ },
787
+ } );
788
+ const positions = {
789
+ grid: getPositions(
790
+ horizontal ? '.visx-columns line' : '.visx-rows line',
791
+ `${ axis }1`
792
+ ),
793
+ ticks: getPositions( '.test-value-axis .visx-axis-tick line', `${ axis }1` ),
794
+ };
795
+ unmount();
796
+ return positions;
797
+ };
798
+
799
+ const omitted = positionsFor( {} );
800
+ const explicit = positionsFor( { numTicks: 4 } );
801
+
802
+ expect( omitted.ticks.length ).toBeGreaterThan( 0 );
803
+ expect( omitted.grid ).toEqual( omitted.ticks );
804
+ expect( omitted.grid ).toEqual( explicit.grid );
805
+ }
806
+ );
807
+ } );
808
+
680
809
  describe( 'Grid Visibility', () => {
681
810
  test( 'renders with different grid visibility options', () => {
682
811
  const { rerender } = renderWithTheme( { gridVisibility: 'x' } );
@@ -6,7 +6,7 @@ import type {
6
6
  SeriesVisibilityProps,
7
7
  } from '../../types';
8
8
  import type { RenderTooltipParams } from '../../visx/types';
9
- import type { ReactNode } from 'react';
9
+ import type { CSSProperties, ReactNode } from 'react';
10
10
 
11
11
  export type BandHighlightSelection = {
12
12
  datum: DataPointDate;
@@ -28,6 +28,10 @@ export interface BarChartProps extends BaseChartProps< SeriesData[] >, SeriesVis
28
28
  tooltipPlacement?: 'auto' | 'beside';
29
29
  /** Tooltip top anchor in SVG coordinates; negative offsets are supported. */
30
30
  tooltipAnchorTop?: number;
31
+ /** Inline styles merged over the tooltip box defaults. */
32
+ tooltipStyle?: CSSProperties;
33
+ /** CSS class for each primary bar, in addition to visx-bar. */
34
+ barClassName?: ( datum: DataPointDate ) => string | undefined;
31
35
  orientation?: 'horizontal' | 'vertical';
32
36
  withPatterns?: boolean;
33
37
  showZeroValues?: boolean;