diff --git a/packages/visx-xychart/src/components/axis/BaseAxis.tsx b/packages/visx-xychart/src/components/axis/BaseAxis.tsx index 9b98af932..b4178c126 100644 --- a/packages/visx-xychart/src/components/axis/BaseAxis.tsx +++ b/packages/visx-xychart/src/components/axis/BaseAxis.tsx @@ -66,7 +66,11 @@ export default function BaseAxis({ ? (width ?? 0) - (margin?.right ?? 0) : 0; - return ( + const scale = (orientation === 'left' || orientation === 'right' ? yScale : xScale) as + | Scale + | undefined; + + return scale ? ( ({ tickStroke={axisStyles?.tickLine?.stroke} {...props} tickLabelProps={tickLabelProps} - scale={(orientation === 'left' || orientation === 'right' ? yScale : xScale) as Scale} + scale={scale} /> - ); + ) : null; } diff --git a/packages/visx-xychart/src/hooks/useDimensions.ts b/packages/visx-xychart/src/hooks/useDimensions.ts index ba79fad14..ebfef3b1f 100644 --- a/packages/visx-xychart/src/hooks/useDimensions.ts +++ b/packages/visx-xychart/src/hooks/useDimensions.ts @@ -1,20 +1,21 @@ import { useCallback, useState } from 'react'; -const INITIAL_DIMENSIONS = { +const DEFAULT_DIMS = { width: 0, height: 0, margin: { top: 0, right: 0, bottom: 0, left: 0 }, }; -export type Dimensions = typeof INITIAL_DIMENSIONS; +export type Dimensions = typeof DEFAULT_DIMS; /** A hook for accessing and setting memoized width, height, and margin chart dimensions. */ export default function useDimensions( - initialDimensions?: Partial, + initialDims?: Partial, ): [Dimensions, (dims: Dimensions) => void] { const [dimensions, privateSetDimensions] = useState({ - ...INITIAL_DIMENSIONS, - ...initialDimensions, + width: initialDims?.width == null ? DEFAULT_DIMS.width : initialDims.width, + height: initialDims?.height == null ? DEFAULT_DIMS.height : initialDims.height, + margin: initialDims?.margin == null ? DEFAULT_DIMS.margin : initialDims.margin, }); // expose a setter with better memoization logic diff --git a/packages/visx-xychart/src/hooks/useScales.ts b/packages/visx-xychart/src/hooks/useScales.ts index d2e83933a..caa1d602e 100644 --- a/packages/visx-xychart/src/hooks/useScales.ts +++ b/packages/visx-xychart/src/hooks/useScales.ts @@ -39,6 +39,9 @@ export default function useScales< [], ); + // d3Extent scale returns NaN domain for empty arrays + if (xValues.length === 0) return undefined; + const xDomain = isDiscreteScale(xScaleConfig) ? xValues : d3Extent(xValues); let xScale = (scaleCanBeZeroed(xScaleConfig) @@ -54,7 +57,7 @@ export default function useScales< ...xScaleConfig, })) as XScale; - // apply any scale updates from the registy + // apply any scale updates from the registry registryEntries.forEach(entry => { if (entry?.xScale) xScale = entry.xScale(xScale); }); @@ -74,6 +77,9 @@ export default function useScales< [], ); + // d3Extent scale returns NaN domain for empty arrays + if (yValues.length === 0) return undefined; + const yDomain = isDiscreteScale(yScaleConfig) ? yValues : d3Extent(yValues); let yScale = (scaleCanBeZeroed(yScaleConfig) @@ -89,7 +95,7 @@ export default function useScales< ...yScaleConfig, })) as YScale; - // apply any scale updates from the registy + // apply any scale updates from the registry registryEntries.forEach(entry => { if (entry?.yScale) yScale = entry.yScale(yScale); }); diff --git a/packages/visx-xychart/src/providers/DataProvider.tsx b/packages/visx-xychart/src/providers/DataProvider.tsx index 89400105b..a3c134a36 100644 --- a/packages/visx-xychart/src/providers/DataProvider.tsx +++ b/packages/visx-xychart/src/providers/DataProvider.tsx @@ -48,20 +48,20 @@ export default function DataProvider< const contextTheme = useContext(ThemeContext); const theme = propsTheme || contextTheme; const [{ width, height, margin }, setDimensions] = useDimensions(initialDimensions); - const innerWidth = width - (margin?.left ?? 0) - (margin?.right ?? 0); - const innerHeight = height - (margin?.top ?? 0) - (margin?.bottom ?? 0); + const innerWidth = Math.max(0, width - margin.left - margin.right); + const innerHeight = Math.max(0, height - margin.top - margin.bottom); type XScale = ScaleConfigToD3Scale; type YScale = ScaleConfigToD3Scale; const dataRegistry = useDataRegistry(); - const { xScale, yScale }: { xScale: XScale; yScale: YScale } = useScales({ + const { xScale, yScale }: { xScale?: XScale; yScale?: YScale } = useScales({ dataRegistry, xScaleConfig, yScaleConfig, - xRange: [margin.left, width - margin.right], - yRange: [height - margin.bottom, margin.top], + xRange: [margin.left, Math.max(0, width - margin.right)], + yRange: [Math.max(0, height - margin.bottom), margin.top], }); const registryKeys = dataRegistry.keys(); @@ -82,7 +82,7 @@ export default function DataProvider< return ( d.stack; /** - * Merges `seriesChildren` `data` by their `stack` value which forms the stack grouping - * (`x` if vertical, `y` if horizontal) and returns `CombinedStackData[]`. + * Merges `seriesChildren` `props.data` by their `stack` value which + * forms the stack grouping (`x` if vertical, `y` if horizontal) + * and returns `CombinedStackData[]`. */ export default function combineBarStackData< XScale extends AxisScale, diff --git a/packages/visx-xychart/src/utils/getScaleBandwidth.ts b/packages/visx-xychart/src/utils/getScaleBandwidth.ts index dd8862ac1..903c3fc8f 100644 --- a/packages/visx-xychart/src/utils/getScaleBandwidth.ts +++ b/packages/visx-xychart/src/utils/getScaleBandwidth.ts @@ -1,7 +1,7 @@ import { AxisScale } from '@visx/axis'; -export default function getScaleBandwidth(scale: Scale) { +export default function getScaleBandwidth(scale?: Scale) { // Broaden type before using 'xxx' in s as typeguard. const s = scale as AxisScale; - return 'bandwidth' in s ? s?.bandwidth() ?? 0 : 0; + return s && 'bandwidth' in s ? s?.bandwidth() ?? 0 : 0; } diff --git a/packages/visx-xychart/test/components/AreaStack.test.tsx b/packages/visx-xychart/test/components/AreaStack.test.tsx new file mode 100644 index 000000000..80aa41da2 --- /dev/null +++ b/packages/visx-xychart/test/components/AreaStack.test.tsx @@ -0,0 +1,180 @@ +import React, { useContext, useEffect } from 'react'; +import { mount } from 'enzyme'; +import { animated } from 'react-spring'; +import { Area, LinePath } from '@visx/shape'; +import { + AreaStack, + AreaSeries, + DataProvider, + DataContext, + useEventEmitter, + AnimatedAreaStack, +} from '../../src'; +import setupTooltipTest from '../mocks/setupTooltipTest'; +import { XYCHART_EVENT_SOURCE } from '../../src/constants'; + +const providerProps = { + initialDimensions: { width: 100, height: 100 }, + xScale: { type: 'linear' }, + yScale: { type: 'linear' }, +} as const; + +const accessors = { + xAccessor: (d: { x?: number }) => d.x, + yAccessor: (d: { y?: number }) => d.y, +}; + +const series1 = { + key: 'area1', + data: [ + { x: 10, y: 5 }, + { x: 7, y: 5 }, + ], + ...accessors, +}; + +const series2 = { + key: 'area2', + data: [ + { x: 10, y: 5 }, + { x: 7, y: 20 }, + ], + ...accessors, +}; + +describe('', () => { + it('should be defined', () => { + expect(AreaSeries).toBeDefined(); + }); + + it('should render Areas', () => { + const wrapper = mount( + + + + + + + + , + ); + // @ts-ignore produces a union type that is too complex to represent.ts(2590) + expect(wrapper.find(Area)).toHaveLength(2); + }); + + it('should render LinePaths if renderLine=true', () => { + const wrapper = mount( + + + + + + + + , + ); + // @ts-ignore produces a union type that is too complex to represent.ts(2590) + expect(wrapper.find(LinePath)).toHaveLength(2); + }); + + it('should render Glyphs if focus/blur handlers are set', () => { + const wrapper = mount( + + + {}}> + + + + , + ); + expect(wrapper.find('circle')).toHaveLength(series1.data.length); + }); + + it('should update scale domain to include stack sums including negative values', () => { + expect.hasAssertions(); + + function Assertion() { + const { yScale } = useContext(DataContext); + if (yScale) { + expect(yScale.domain()).toEqual([-20, 10]); + } + return null; + } + + mount( + + + + + + + + + , + ); + }); + + it('should invoke showTooltip/hideTooltip on pointermove/pointerout', () => { + expect.assertions(2); + + const showTooltip = jest.fn(); + const hideTooltip = jest.fn(); + + const EventEmitter = () => { + const emit = useEventEmitter(); + const { yScale } = useContext(DataContext); + + useEffect(() => { + // checking for yScale ensures stack data is registered and stacks are rendered + if (emit && yScale) { + // @ts-ignore not a React.MouseEvent + emit('pointermove', new MouseEvent('pointermove'), XYCHART_EVENT_SOURCE); + expect(showTooltip).toHaveBeenCalledTimes(2); // one per key + + // @ts-ignore not a React.MouseEvent + emit('pointerout', new MouseEvent('pointerout'), XYCHART_EVENT_SOURCE); + expect(showTooltip).toHaveBeenCalled(); + } + }); + + return null; + }; + + setupTooltipTest( + <> + + + + + + , + { showTooltip, hideTooltip }, + ); + }); +}); + +describe('', () => { + it('should be defined', () => { + expect(AnimatedAreaStack).toBeDefined(); + }); + it('should render an animated.path', () => { + const wrapper = mount( + + + + + + + + , + ); + expect(wrapper.find(animated.path)).toHaveLength(2); + }); +}); diff --git a/packages/visx-xychart/test/hooks/useStackedData.test.tsx b/packages/visx-xychart/test/hooks/useStackedData.test.tsx new file mode 100644 index 000000000..b56d87fdd --- /dev/null +++ b/packages/visx-xychart/test/hooks/useStackedData.test.tsx @@ -0,0 +1,97 @@ +import React, { useContext, useEffect } from 'react'; +import { mount } from 'enzyme'; +import { AreaSeries, DataContext, DataProvider } from '../../src'; +import useStackedData from '../../src/hooks/useStackedData'; + +const seriesAProps = { + dataKey: 'a', + data: [ + { x: 'stack-a', y: 3 }, + { x: 'stack-b', y: 7 }, + { x: 'stack-c', y: -2 }, + ], + xAccessor: (d: { x: string }) => d.x, + yAccessor: (d: { y: number }) => d.y, +}; + +const seriesBProps = { + ...seriesAProps, + dataKey: 'b', + data: [ + { x: 'stack-a', y: 0 }, + { x: 'stack-b', y: 7 }, + { x: 'stack-c', y: 10 }, + ], +}; + +function setup(children: React.ReactElement | React.ReactElement[]) { + return mount( + + {children} + , + ); +} + +describe('useStackedData', () => { + it('should be defined', () => { + expect(useStackedData).toBeDefined(); + }); + + it('should return a data stack', () => { + expect.hasAssertions(); + + const Consumer = ({ children }: { children: React.ReactElement | React.ReactElement[] }) => { + const { stackedData } = useStackedData({ children }); + // stackedData has arrays with data properties set by d3 which jest doesn't like + expect(stackedData.map(series => series.map(([min, max]) => [min, max]))).toMatchObject([ + [ + // series a + [0, 3], + [0, 7], + [-2, 0], + ], + [ + // series b + [0, 0], + [7, 14], + [0, 10], + ], + ]); + return null; + }; + + setup( + + + + , + ); + }); + + it('compute a comprehensive domain based on the total stack value', () => { + expect.hasAssertions(); + + const Consumer = ({ children }: { children: React.ReactElement | React.ReactElement[] }) => { + useStackedData({ children }); + const { dataRegistry, yScale } = useContext(DataContext); + + useEffect(() => { + if (dataRegistry?.get('a') && yScale) { + expect(yScale.domain()).toEqual([-2, 14]); + } + }, [dataRegistry, yScale]); + return null; + }; + + setup( + + + + , + ); + }); +});