diff --git a/packages/demo/examples/01-xy-chart/LineSeriesExample.jsx b/packages/demo/examples/01-xy-chart/LineSeriesExample.jsx new file mode 100644 index 00000000..dc510b84 --- /dev/null +++ b/packages/demo/examples/01-xy-chart/LineSeriesExample.jsx @@ -0,0 +1,264 @@ +import React from 'react'; +import { allColors } from '@data-ui/theme/build/color'; +import { Button } from '@data-ui/forms'; + +import { + CrossHair, + LineSeries, + WithTooltip, + XAxis, + YAxis, +} from '@data-ui/xy-chart'; + +import ResponsiveXYChart, { formatYear } from './ResponsiveXYChart'; +import { timeSeriesData } from './data'; +import WithToggle from '../shared/WithToggle'; + +const seriesProps = [ + { + seriesKey: 'Stock 1', + key: 'Stock 1', + data: timeSeriesData, + stroke: allColors.grape[9], + showPoints: true, + dashType: 'solid', + }, + { + seriesKey: 'Stock 2', + key: 'Stock 2', + data: timeSeriesData.map(d => ({ + ...d, + y: Math.random() > 0.5 ? d.y * 2 : d.y / 2, + })), + stroke: allColors.grape[7], + strokeDasharray: '6 4', + dashType: 'dashed', + strokeLinecap: 'butt', + }, + { + seriesKey: 'Stock 3', + key: 'Stock 3', + data: timeSeriesData.map(d => ({ + ...d, + y: Math.random() < 0.3 ? d.y * 3 : d.y / 3, + })), + stroke: allColors.grape[4], + strokeDasharray: '2 2', + dashType: 'dotted', + strokeLinecap: 'butt', + }, +]; + +const MARGIN = { left: 8, top: 16 }; +const TOOLTIP_TIMEOUT = 250; +const CONTAINER_TRIGGER = 'CONTAINER_TRIGGER'; +const VORONOI_TRIGGER = 'VORONOI_TRIGGER'; + +class LineSeriesExample extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + index: 0, + programmaticTrigger: false, + trigger: CONTAINER_TRIGGER, + stickyTooltip: false, + }; + this.eventTriggerRefs = this.eventTriggerRefs.bind(this); + this.triggerTooltip = this.triggerTooltip.bind(this); + this.renderTooltip = this.renderTooltip.bind(this); + this.restartProgrammaticTooltip = this.restartProgrammaticTooltip.bind(this); + this.setTrigger = this.setTrigger.bind(this); + this.handleClick = this.handleClick.bind(this); + } + + componentWillUnmount() { + if (this.timeout) clearTimeout(this.timeout); + } + + setTrigger(nextTrigger) { + this.setState(() => ({ trigger: nextTrigger })); + } + + handleClick(args) { + if (this.triggers) { + this.setState(({ stickyTooltip }) => ({ + stickyTooltip: !stickyTooltip, + }), () => { + this.triggers.mousemove(args); + }); + } + } + + eventTriggerRefs(triggers) { + this.triggers = triggers; + this.triggerTooltip(); + } + + triggerTooltip() { + if (this.triggers && this.state.index < seriesProps[0].data.length) { + if (this.timeout) clearTimeout(this.timeout); + this.setState(({ index, trigger }) => { + this.triggers.mousemove({ + datum: seriesProps[2].data[index], + series: trigger === VORONOI_TRIGGER ? null : { + [seriesProps[0].seriesKey]: seriesProps[0].data[index], + [seriesProps[1].seriesKey]: seriesProps[1].data[index], + [seriesProps[2].seriesKey]: seriesProps[2].data[index], + }, + coords: trigger === VORONOI_TRIGGER ? null : { + y: 50, + }, + }); + + this.timeout = setTimeout(this.triggerTooltip, TOOLTIP_TIMEOUT); + + return { index: index + 1, programmaticTrigger: true }; + }); + } else if (this.triggers) { + this.triggers.mouseleave(); + this.timeout = setTimeout(() => { + this.setState(() => ({ + index: 0, + programmaticTrigger: false, + })); + }, TOOLTIP_TIMEOUT); + } + } + + + restartProgrammaticTooltip() { + if (this.timeout) clearTimeout(this.timeout); + if (this.triggers) { + this.setState(() => ({ stickyTooltip: false, index: 0 }), this.triggerTooltip); + } + } + + renderControls(disableMouseEvents) { + const { trigger, stickyTooltip } = this.state; + const useVoronoiTrigger = trigger === VORONOI_TRIGGER; + return ([ +
+ +
+ +
+ +
, +
+ Click chart for a  + sticky tooltip + +
, + ]); + } + + renderTooltip({ datum, series }) { + const { programmaticTrigger, trigger } = this.state; + return ( +
+
+ {formatYear(datum.x)} + {(!series || Object.keys(series).length === 0) && +
+ ${datum.y.toFixed(2)} +
} +
+ {trigger === CONTAINER_TRIGGER &&
} + {seriesProps.map(({ seriesKey, stroke: color, dashType }) => ( + series && series[seriesKey] && +
+ + {`${seriesKey} `} + + ${series[seriesKey].y.toFixed(2)} +
+ ))} +
+ ); + } + + render() { + const { trigger, stickyTooltip } = this.state; + const useVoronoiTrigger = trigger === VORONOI_TRIGGER; + return ( + + {disableMouseEvents => ( +
+ {this.renderControls(disableMouseEvents)} + + {/* Use WithTooltip to intercept mouse events in stickyTooltip state */} + + {({ onMouseLeave, onMouseMove, tooltipData }) => ( + + + + {seriesProps.map(props => ( + + ))} + + + )} + +
+ )} +
+ ); + } +} + +export default LineSeriesExample; diff --git a/packages/demo/examples/01-xy-chart/ResponsiveXYChart.jsx b/packages/demo/examples/01-xy-chart/ResponsiveXYChart.jsx index 13059493..d2b1e87d 100644 --- a/packages/demo/examples/01-xy-chart/ResponsiveXYChart.jsx +++ b/packages/demo/examples/01-xy-chart/ResponsiveXYChart.jsx @@ -10,10 +10,11 @@ import { export const parseDate = timeParse('%Y%m%d'); export const formatDate = timeFormat('%b %d'); +export const formatYear = timeFormat('%Y'); export const dateFormatter = date => formatDate(parseDate(date)); // this is a little messy to handle all cases across series types -function renderTooltip({ datum, seriesKey, color }) { +export function renderTooltip({ datum, seriesKey, color }) { const { x, x0, y, value } = datum; let xVal = x || x0; if (typeof xVal === 'string') { diff --git a/packages/demo/examples/01-xy-chart/ScatterWithHistograms.jsx b/packages/demo/examples/01-xy-chart/ScatterWithHistograms.jsx index 5dbcf991..bd7956af 100644 --- a/packages/demo/examples/01-xy-chart/ScatterWithHistograms.jsx +++ b/packages/demo/examples/01-xy-chart/ScatterWithHistograms.jsx @@ -85,7 +85,7 @@ class ScatterWithHistogram extends React.PureComponent { margin={marginScatter} theme={theme} renderTooltip={renderTooltip} - useVoronoi + eventTrigger="voronoi" showVoronoi={this.state.showVoronoi} > {datasets.map((dataset, i) => ( diff --git a/packages/demo/examples/01-xy-chart/StackedAreaExample.jsx b/packages/demo/examples/01-xy-chart/StackedAreaExample.jsx index 9fd2ad49..14d98ff8 100644 --- a/packages/demo/examples/01-xy-chart/StackedAreaExample.jsx +++ b/packages/demo/examples/01-xy-chart/StackedAreaExample.jsx @@ -51,26 +51,25 @@ export default function StackedAreaExample() { return ( {asPercent => ( -
- ( - - - - )} - fill={({ datum }) => legendScale(datum)} - labelFormat={label => label} - /> - +
+
+ ( + + + + )} + fill={({ datum }) => legendScale(datum)} + labelFormat={label => label} + /> +
{ - const data2 = timeSeriesData.map(d => ({ - ...d, - y: Math.random() > 0.5 ? d.y * 2 : d.y / 2, - })); - return ( - - {useVoronoi => ( - - {showVoronoi => ( - - {disableMouseEvents => ( - - - - - - - - )} - - )} - - )} - - ); - }, + example: () => , }, { description: 'AreaSeries -- closed', components: [AreaSeries], example: () => ( - - - - - - - - + + {snapToDataX => ( + + {snapToDataY => ( + + + + + + + + + )} + + )} + ), }, { @@ -203,6 +175,38 @@ export default { ariaLabel="Required label" xScale={{ type: 'time' }} yScale={{ type: 'linear' }} + eventTrigger="container" + renderTooltip={({ datum, series }) => ( +
+
+ {dateFormatter(datum.x)} + {(!series || Object.keys(series).length === 0) && +
+ {datum.y.toFixed(2)} +
} +
+
+ {temperatureBands.map((_, i) => { + const key = `band-${i}`; + return ( + series && series[key] && +
+ + {`${key} `} + + {series[key].y.toFixed(2)} +
+ ); + })} +
+ )} > {temperatureBands.map((data, i) => ([ , , , ]))}
), @@ -244,7 +251,7 @@ export default { ariaLabel="Required label" xScale={{ type: 'time' }} yScale={{ type: 'linear' }} - useVoronoi + eventTrigger="container" > `$${val}`} /> @@ -263,6 +270,7 @@ export default { data={priceBandData.band} fill="url(#confidence-interval-fill)" strokeWidth={0} + disableMouseEvents /> (d.y >= reference ? d : { ...d, y: reference }))} @@ -279,8 +287,8 @@ export default { showHorizontalLine={false} fullHeight stroke={colors.categories[3]} - circleStroke={colors.categories[3]} - circleFill="transparent" + circleStroke="white" + circleFill="white" /> ), @@ -318,7 +326,7 @@ export default { yScale={{ type: 'linear', nice: true }} showXGrid={false} showYGrid={false} - useVoronoi + eventTrigger="voronoi" showVoronoi={showVoronoi} > @@ -341,6 +349,7 @@ export default { ariaLabel="Required label" xScale={{ type: 'linear', nice: true }} yScale={{ type: 'linear', nice: true }} + eventTrigger="voronoi" > @@ -390,13 +399,16 @@ export default { ), }, { - description: 'Categorical BarSeries', + description: 'Categorical BarSeries With Snapping Tooltip', components: [XYChart, BarSeries, CrossHair], example: () => ( {}, @@ -121,6 +127,7 @@ const defaultProps = { }; function Button({ + active, block, children, onClick, @@ -144,6 +151,7 @@ function Button({ rounded && styles.rounded, round && small ? styles.round_small : (round && styles.round), disabled && styles.disabled, + active && styles.active, )} > ', () => { expect(wrapper.find('#test').length).toBe(1); }); + test.only('it should use the provided `coords` if passed to onMouseMove', () => { + let mouseMove; + const wrapper = mount( + ( +
{children}
+ )} + renderTooltip={() =>
} + > + {({ onMouseMove }) => { + mouseMove = onMouseMove; + return ; + }} + , + ); + + mouseMove({ coords: {} }); + wrapper.update(); + expect(wrapper.find('#tooltip').prop('style').top).toBe(0); + expect(wrapper.find('#tooltip').prop('style').left).toBe(0); + + mouseMove({ coords: { x: 27, y: 13 } }); + wrapper.update(); + expect(wrapper.find('#tooltip').prop('style').top).toBe(13); + expect(wrapper.find('#tooltip').prop('style').left).toBe(27); + }); + test('it should not render a tooltip if renderTooltip returns a falsy value', () => { const renderTooltip = jest.fn(); renderTooltip.mockReturnValue(
); diff --git a/packages/xy-chart/README.md b/packages/xy-chart/README.md index e06cf346..f32d2c34 100644 --- a/packages/xy-chart/README.md +++ b/packages/xy-chart/README.md @@ -33,6 +33,7 @@ import { XYPlot, BarSeries, CrossHair, XAxis, YAxis, LinearGradient } from '@dat
y {datum.y}
)} + snapTooltipToDataX > node`, should return the inner tooltip contents on trigger. -onClick | PropTypes.func | - | `func({ data, datum, event, color [, seriesKey] })`, passed to all child series (or voronoi) -onMouseMove | PropTypes.func | - | `func({ data, datum, event, color })`, passed to all child series (or voronoi). only needed if you are rolling your own tooltips (see below) +innerRef | PropTypes.func | - | Callback ref that is set on the inner `svg` element +margin | PropTypes.shape({ top: PropTypes.number, right: PropTypes.number, bottom: PropTypes.number, left: PropTypes.number }) | { top: 64, right: 64, bottom: 64, left: 64 } | chart margin, leave room for axes and labels! a "complete" margin will be created using the default top/right/bottom/left values meaning that you have to explicitly set each dimension for full control. also note that a value of `0` may clip LineSeries and PointSeries. +onClick | PropTypes.func | - | `func({ datum, event [, coords [, data, [, color [, series [, seriesKey]]]]] })`, passed to all child series (or voronoi) +onMouseMove | PropTypes.func | - | `func({ datum, event [, coords [, data, [, color [, series [, seriesKey]]]]] })`, passed to all child series (or voronoi). only needed if you are rolling your own tooltips (see below) onMouseLeave | PropTypes.func | - | `func()`, passed to all child series (or voronoi). only needed if you are rolling your own tooltips (see below) -xScale | scaleShape.isRequired | - | scale config, see below. -yScale | scaleShape.isRequired | - | scale config, see below. +renderTooltip | PropTypes.func | - | `({ datum, event [, coords [, data, [, color [, series [, seriesKey]]]]] }) => node`, should return the inner tooltip contents on trigger. showXGrid | PropTypes.bool | false | whether to show vertical gridlines showYGrid | PropTypes.bool | false | whether to show vertical gridlines +showVoronoi | PropTypes.bool | false | convenience prop for debugging to view the underlying voronoi if eventTrigger='voronoi' +snapTooltipToDataX | PropTypes.bool | false | whether to pass coords.x in event callbacks, which has the effect of snapping a tooltip to data x values +snapTooltipToDataY | PropTypes.bool | false | whether to pass coords.y in event callbacks, which has the effect of snapping a tooltip to data y values theme | themeShape | false | theme shape, see below -useVoronoi | PropTypes.bool | false | whether to compute and use a voronoi for all datapoints (with x, y values) / mouse interactions -showVoronoi | PropTypes.bool | false | convenience prop for debugging to view the underlying voronoi if used - +width | PropTypes.number.isRequired | - | Required width of the chart (including margin). Check out `withParentSize` in the examples for responsive charts. +xScale | scaleShape.isRequired | - | scale config, see below. +yScale | scaleShape.isRequired | - | scale config, see below. #### Scale config X and y-scales are configured using `xScale` and `yScale` config props which essentially configure d3/vx scales: @@ -83,12 +87,14 @@ X and y-scales are configured using `xScale` and `yScale` config props which ess const scaleConfigShape = PropTypes.shape({ type: PropTypes.oneOf([ 'time', + 'timeUtc', 'linear', 'band', + 'ordinal', ]).isRequired, includeZero: PropTypes.bool, - // these would override any computation done by xyplot, allowing specific ranges or colors + // these would override any computation done by XYChart, allowing specific ranges or colors // see storybook for more examples range: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])), rangeRound: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])), @@ -96,50 +102,7 @@ const scaleConfigShape = PropTypes.shape({ }); ``` -#### Theme -A theme object with the following shape can be passed to `` to style the chart, axes, and series. -See `@data-ui/theme` for an example. - -```javascript -export const themeShape = PropTypes.shape({ - gridStyles: PropTypes.shape({ - stroke: PropTypes.string, - strokeWidth: PropTypes.number, - }), - xAxisStyles: PropTypes.shape({ - stroke: PropTypes.string, - strokeWidth: PropTypes.number, - label: PropTypes.shape({ - bottom: PropTypes.object, - top: PropTypes.object, - }), - }), - yAxisStyles: PropTypes.shape({ - stroke: PropTypes.string, - strokeWidth: PropTypes.number, - label: PropTypes.shape({ - left: PropTypes.object, - right: PropTypes.object, - }), - }) - xTickStyles: PropTypes.shape({ - stroke: PropTypes.string, - tickLength: PropTypes.number, - label: PropTypes.shape({ - bottom: PropTypes.object, - top: PropTypes.object, - }), - }), - yTickStyles: PropTypes.shape({ - stroke: PropTypes.string, - tickLength: PropTypes.number, - label: PropTypes.shape({ - left: PropTypes.object, - right: PropTypes.object, - }), - }), -}); -``` +Entries in scale objects are shallow checked so new objects don't trigger re-renders. ### `` and `` @@ -160,26 +123,25 @@ tickValues | PropTypes.arrayOf( PropTypes.oneOfType([ PropTypes.number, PropType Several types of series types are exported by the package, and can be used in combination. See the storybook source for more proptables for your series of interest. Here is an overview of scale support and data shapes: -Series | supported x scale type | supported y scale types | data shape | voronoi compatible for tooltips? ------------- | ------------- | ------- | ---- | ---- -`` | time, linear | linear | `{ x, y [, y0, y1, fill, stroke] }`* | yes* -`` | time, linear, band | linear | `{ x, y [, fill, stroke] }` | no -`` | time, linear | linear | `{ x, y [, stroke] }` | yes -`` | time, linear | time, linear | `{ x, y [size, fill, stroke, label] }` | yes -`` | time, linear | linear | `{ x, y [, [stackKey(s)]] }`* | no -`` | band | linear | `{ x, y }` (colors controlled with stackFills & stackKeys) | no -`` | band | linear | `{ x, y }` (colors controlled with groupFills & groupKeys) | no -`` | time, linear | y is computed | `{ x [, size] }` | no -`` | time, linear | linear | `{ x0, x1 [, fill, stroke] }` | no -`` | linear, band | band, linear | `{ x (or y), min, max, median, firstQuartile, thirdQuartile, outliers [, fill, stroke] }` | no -`` | linear, band | band, linear | `{ x (or y), binData [, fill, stroke] }` | no +Series | supported x scale type | supported y scale types | data shape | supported `eventTrigger`s | shared tooltip compatible +------------ | ------------- | ------- | ---- | ---- | ---- +`` | `time`, `linear` | `linear` | `{ x, y [, y0, y1, fill, stroke] }`* | `series`, `container`, `voronoi`* | yes +`` | `time`, `linear`, `band` | `linear` | `{ x, y [, fill, stroke] }` | `series`, `container` | yes +`` | `time`, `linear` | `linear` | `{ x, y [, stroke] }` | `series`, `container`, `voronoi` | yes +`` | `time`, `linear` | `time`, `linear` | `{ x, y [size, fill, stroke, label] }` | `series`, `container` (not best for dense data) `voronoi` | yes +`` | `time`, `linear` | `linear` | `{ x, y [, [stackKey(s)]] }`* | `series` | data for all stack keys should be in passed `datum` +`` | `band` | `linear` | `{ x, y }` (colors controlled with stackFills & stackKeys) | `series` | data for all stack keys should be in passed `datum` +`` | `band` | `linear` | `{ x, y }` (colors controlled with groupFills & groupKeys) | `series` | data for all group keys should be in passed `datum` +`` | `time`, `linear` | y is computed | `{ x [, size] }` | `series` | no +`` | `time`, `linear` | `linear` | `{ x0, x1 [, fill, stroke] }` | `series` | no +`` | `linear`, `band` | `band`, `linear` | `{ x (or y), min, max, median, firstQuartile, thirdQuartile, outliers [, fill, stroke] }` | `series` | no +`` | `linear`, `band` | `band`, `linear` | `{ x (or y), binData [, fill, stroke] }` | `series` | no \* The y boundaries of the `` may be specified by either - defined `y0` and `y1` values or - a single `y` value, in which case its lower bound is set to 0 (a "closed" area series) -It is worth noting that voronoi overlays require a defined `y` attribute, so use of voronoi with only `y0` and `y1` values will not work. #### CirclePackSeries @@ -192,25 +154,22 @@ This series implements the Circle packing algorithm described by ` with `` yourself, which accepts props for additional customization: Name | Type | Default | Description ------------ | ------------- | ------- | ---- -children | PropTypes.func or PropTypes.object | - | Child function (to call) or element (to clone) with onMouseMove, onMouseLeave, and tooltipData props/keys +children | PropTypes.func or PropTypes.object | - | Child function (to call) or element (to clone) with `onMouseMove`, `onMouseLeave`, and `tooltipData` props className | PropTypes.string | - | Class name to add to the `
` container wrapper renderTooltip | PropTypes.func.isRequired | - | Renders the _contents_ of the tooltip, signature of `({ event, data, datum, color }) => node`. If this function returns a `falsy` value, a tooltip will not be rendered. styles | PropTypes.object | {} | Styles to add to the `
` container wrapper @@ -219,21 +178,9 @@ tooltipProps | PropTypes.object | - | Props that are passed to `TooltipComponent tooltipTimeout | PropTypes.number | 200 | Timeout in ms for the tooltip to hide upon calling `onMouseLeave` -#### `` -

- -

- -For series components that have "small" mouse areas, such as `PointSeries` and `LineSeries`, you may opt to use an invisible
Voronoi overlay on top of the visualization to increase the target area of interaction sites and improve user experience. To enable this simply set `useVoronoi` to `true` on the `` component and optionally use the convenience prop `showVoronoi` to view or debug it. Note that this will compute a voronoi layout for _all_ data points (with defined `x` and `y` datum values!) across all series. - -#### Note ‼️ -Because of the polygonal shapes generated by the voronoi layout, you probably _don't_ want to use this option if you are e.g., only rendering a `BarSeries` because the bar points represent the tops of the bars and thus polygons for one bar may overlap the rect of another bar (again, you may use `showVoronoi` to debug this). - -

- -

+Note that to correctly position a tooltip, the `` `onMouseMove` function minimally requires an `event` or `coords` object of the form `{ x: Number, y: Number }`. If `coords` is specified it takes precedent over any position computed from the event. See function signatures below for more. -#### `` +##### `` The `` component may be used in combination with tooltips for additional visual feedback (see the storybook for many examples!). Simply pass the component as a child of `` and it will automatically position itself upon tooltip trigger. Compared to a tooltip, this component snaps to actual data points for improved precision. It accepts the following props: Name | Type | Default | Description @@ -252,6 +199,99 @@ stroke | PropTypes.oneOfType([PropTypes.func, PropTypes.string]) | data-ui/theme strokeDasharray | PropTypes.oneOfType([PropTypes.func, PropTypes.string]) | `3, 3` | The stroke-dash-array of both horizontal and vertical lines strokeWidth | PropTypes.oneOfType([PropTypes.func, PropTypes.number]) | 1 | The strokeWidth of both horizontal and vertical lines + +#### Mouse Events & Triggers +`XYChart` has hooks for `mousemove`, `mouseleave`, and `click` events that can be triggered at different levels as specified by the `eventTrigger` prop: + +##### eventTrigger='series' +For the `series` event trigger, `XYChart` will pass along event handlers to its child series unless a series has `disableMouseEvents` set to `true`, and any event handlers defined at the series level will _override_ those defined at the `XYChart` level. Series-level events are triggered by interactions with the series DOM elements themselves. + +##### eventTrigger='container' +For the `container` event trigger, the `XYChart` container will intercept all mouse events and event handlers will be called with all `datum`s nearest the hovered x value. This type of event trigger is useful if you want to implement a shared tooltip. Note that `data` passed to series should be sorted by x-value for this to work correctly. + +##### eventTrigger='voronoi' +

+ +

+ +For series components that have "small" mouse areas, such as `PointSeries` and `LineSeries`, you may opt to use an invisible Voronoi overlay on top of the visualization to increase the target area of interaction sites and improve user experience. To view or debug a voronoi you may set the convenience prop `showVoronoi` to `true`. Note that this will compute a voronoi layout for _all_ data points across _all_ series. + +##### Note ‼️ +It is worth noting that voronoi overlays require a defined `y` attribute, so use of voronoi with only `y0` and `y1` values will not work (this is reflected in the compatibility table above). + +Additionally, because of the polygonal shapes generated by the voronoi layout, you probably _don't_ want to use this option if you are e.g., only rendering a `BarSeries` because the bar points represent the tops of the bars and thus polygons for one bar may overlap the rect of another bar (again, you may use `showVoronoi` to debug this). + +

+ +

+ +#### Functions and Function Signatures + +`XYChart` and all series support `onMouseMove`, `onMouseLeave`, and `onClick` event handlers with the following signatures: + +``` +onMouseMove({ datum, event [, coords [, data, [, color [, series [, seriesKey]]]]] }) +onClick({ datum, event [, coords [, data, [, color [, series [, seriesKey]]]]] }) +onMouseLeave() +``` + +A `seriesKey` is passed when `eventTrigger=series` for ``, ``, or ``. It corresponds to the relevant `stackKey` or `groupKey` that triggered the event. + +`series` is passed when `eventTrigger=container` and represents an object of `datum`s across all series components nearest the current mouse `x`. The _closest_ `datum` across all series components is passed as `datum` in the function signature. Within the `series` object, `datum`s are keyed on the `seriesKey` prop set on the series component itself. **similar to React, if `seriesKey` is not set its index as a child of `XYChart` will be used which is more error prone** + +`coords` is an object of the form `{ x: Number, y: Number }`. `XYChart` passes `x` and `y` only if `snapTooltipToDataX` or `snapTooltipToDataY` are `true`, respectively. + +##### Programmatically triggering tooltips +`XYChart` exposes hooks to manually trigger any of these handlers with the `eventTriggerRefs` prop. Similar to `React` `ref`s, this prop is a callback function that is called by `XYChart` after mounting. The callback receives an object as input, with keys corresponding to the event type names and respective handlers as values: `eventTriggerRefs({ click, mousemove, mouseleave })`. The ref handlers have the same signatures as defined above. + +Note that `snapTooltipToData*` props will still have an effect when events are triggered this way. + + +#### Theme +A theme object with the following shape can be passed to `` to style the chart, axes, and series. +See `@data-ui/theme` for an example. + +```javascript +export const themeShape = PropTypes.shape({ + gridStyles: PropTypes.shape({ + stroke: PropTypes.string, + strokeWidth: PropTypes.number, + }), + xAxisStyles: PropTypes.shape({ + stroke: PropTypes.string, + strokeWidth: PropTypes.number, + label: PropTypes.shape({ + bottom: PropTypes.object, + top: PropTypes.object, + }), + }), + yAxisStyles: PropTypes.shape({ + stroke: PropTypes.string, + strokeWidth: PropTypes.number, + label: PropTypes.shape({ + left: PropTypes.object, + right: PropTypes.object, + }), + }) + xTickStyles: PropTypes.shape({ + stroke: PropTypes.string, + tickLength: PropTypes.number, + label: PropTypes.shape({ + bottom: PropTypes.object, + top: PropTypes.object, + }), + }), + yTickStyles: PropTypes.shape({ + stroke: PropTypes.string, + tickLength: PropTypes.number, + label: PropTypes.shape({ + left: PropTypes.object, + right: PropTypes.object, + }), + }), +}); +``` + More on the way. ### Other diff --git a/packages/xy-chart/src/chart/XYChart.jsx b/packages/xy-chart/src/chart/XYChart.jsx index 5340f631..5481bc8c 100644 --- a/packages/xy-chart/src/chart/XYChart.jsx +++ b/packages/xy-chart/src/chart/XYChart.jsx @@ -5,33 +5,39 @@ import Grid from '@vx/grid/build/grids/Grid'; import Group from '@vx/group/build/Group'; import WithTooltip, { withTooltipPropTypes } from '@data-ui/shared/build/enhancer/WithTooltip'; +import collectVoronoiData from '../utils/collectVoronoiData'; +import findClosestDatums from '../utils/findClosestDatums'; +import shallowCompareObjectEntries from '../utils/shallowCompareObjectEntries'; import Voronoi from './Voronoi'; import { - collectDataFromChildSeries, componentName, isAxis, - isBarSeries, - isCirclePackSeries, isCrossHair, - isDefined, isReferenceLine, isSeries, getChildWithName, - getScaleForAccessor, numTicksForWidth, numTicksForHeight, propOrFallback, } from '../utils/chartUtils'; +import collectScalesFromProps from '../utils/collectScalesFromProps'; +import getChartDimensions from '../utils/getChartDimensions'; import { scaleShape, themeShape } from '../utils/propShapes'; +export const CONTAINER_TRIGGER = 'container'; +export const SERIES_TRIGGER = 'series'; +export const VORONOI_TRIGGER = 'voronoi'; + export const propTypes = { ...withTooltipPropTypes, ariaLabel: PropTypes.string.isRequired, children: PropTypes.node, - width: PropTypes.number.isRequired, + eventTrigger: PropTypes.oneOf([CONTAINER_TRIGGER, SERIES_TRIGGER, VORONOI_TRIGGER]), + eventTriggerRefs: PropTypes.func, height: PropTypes.number.isRequired, + innerRef: PropTypes.func, margin: PropTypes.shape({ top: PropTypes.number, right: PropTypes.number, @@ -39,17 +45,23 @@ export const propTypes = { left: PropTypes.number, }), renderTooltip: PropTypes.func, - xScale: scaleShape.isRequired, - yScale: scaleShape.isRequired, showXGrid: PropTypes.bool, showYGrid: PropTypes.bool, - theme: themeShape, - useVoronoi: PropTypes.bool, showVoronoi: PropTypes.bool, + snapTooltipToDataX: PropTypes.bool, + snapTooltipToDataY: PropTypes.bool, + theme: themeShape, + width: PropTypes.number.isRequired, + xScale: scaleShape.isRequired, + yScale: scaleShape.isRequired, }; -const defaultProps = { +export const defaultProps = { children: null, + disableMouseEvents: false, + eventTrigger: SERIES_TRIGGER, + eventTriggerRefs: null, + innerRef: null, margin: { top: 64, right: 64, @@ -57,92 +69,24 @@ const defaultProps = { left: 64, }, renderTooltip: null, + showVoronoi: false, showXGrid: false, showYGrid: false, + snapTooltipToDataX: false, + snapTooltipToDataY: false, + styles: null, theme: {}, - // @TODO tooltipProps - // voronoi - useVoronoi: false, - showVoronoi: false, }; // accessors -const getX = d => d.x; -const getY = d => d.y; -const xString = d => getX(d).toString(); +const getX = d => d && d.x; +const getY = d => d && d.y; class XYChart extends React.PureComponent { - static collectScalesFromProps(props) { - const { xScale: xScaleObject, yScale: yScaleObject, children } = props; - const { innerWidth, innerHeight } = XYChart.getDimmensions(props); - const { allData } = collectDataFromChildSeries(children); - - const xScale = getScaleForAccessor({ - allData, - minAccessor: d => (typeof d.x0 !== 'undefined' ? d.x0 : d.x), - maxAccessor: d => (typeof d.x1 !== 'undefined' ? d.x1 : d.x), - range: [0, innerWidth], - ...xScaleObject, - }); - - const yScale = getScaleForAccessor({ - allData, - minAccessor: d => (typeof d.y0 !== 'undefined' ? d.y0 : d.y), - maxAccessor: d => (typeof d.y1 !== 'undefined' ? d.y1 : d.y), - range: [innerHeight, 0], - ...yScaleObject, - }); - - React.Children.forEach(children, (Child) => { // Child-specific scales or adjustments here - const name = componentName(Child); - if (isBarSeries(name) && xScaleObject.type !== 'band') { - const dummyBand = getScaleForAccessor({ - allData, - minAccessor: xString, - maxAccessor: xString, - type: 'band', - rangeRound: [0, innerWidth], - paddingOuter: 1, - }); - - const offset = dummyBand.bandwidth() / 2; - xScale.range([offset, innerWidth - offset]); - xScale.barWidth = dummyBand.bandwidth(); - xScale.offset = offset; - } - if (isCirclePackSeries(name)) { - yScale.domain([-innerHeight / 2, innerHeight / 2]); - } - }); - - return { - xScale, - yScale, - }; - } - - static getDimmensions(props) { - const { margin, width, height } = props; - const completeMargin = { ...defaultProps.margin, ...margin }; - return { - margin: completeMargin, - innerHeight: height - completeMargin.top - completeMargin.bottom, - innerWidth: width - completeMargin.left - completeMargin.right, - }; - } - static getStateFromProps(props) { - const { margin, innerWidth, innerHeight } = XYChart.getDimmensions(props); - const { xScale, yScale } = XYChart.collectScalesFromProps(props); - - const voronoiData = React.Children.toArray(props.children).reduce((result, Child) => { - if (isSeries(componentName(Child)) && !Child.props.disableMouseEvents) { - return result.concat( - Child.props.data.filter(d => isDefined(getX(d)) && isDefined(getY(d))), - ); - } - return result; - }, []); + const { margin, innerWidth, innerHeight } = getChartDimensions(props); + const { xScale, yScale } = collectScalesFromProps(props); + const voronoiData = collectVoronoiData({ children: props.children, getX, getY }); return { innerHeight, @@ -161,19 +105,41 @@ class XYChart extends React.PureComponent { // if renderTooltip is passed we return another XYChart wrapped in WithTooltip // therefore we don't want to compute state if the nested chart will do so this.state = props.renderTooltip ? {} : XYChart.getStateFromProps(props); + + this.getDatumCoords = this.getDatumCoords.bind(this); + this.handleClick = this.handleClick.bind(this); + this.handleMouseLeave = this.handleMouseLeave.bind(this); + this.handleMouseMove = this.handleMouseMove.bind(this); + this.handleContainerEvent = this.handleContainerEvent.bind(this); + } + + componentDidMount() { + if (!this.props.renderTooltip && this.props.eventTriggerRefs) { + this.props.eventTriggerRefs({ + mousemove: this.handleMouseMove, + mouseleave: this.handleMouseLeave, + click: this.handleClick, + }); + } } componentWillReceiveProps(nextProps) { - if ([ // recompute scales if any of the following change + let shouldComputeScales = false; + if ([ + 'width', 'height', + 'children', + ].some(prop => this.props[prop] !== nextProps[prop])) { + shouldComputeScales = true; + } + if ([ 'margin', - 'width', 'xScale', 'yScale', - ].some(prop => this.props[prop] !== nextProps[prop])) { - // @TODO update only on children updates that require new scales - this.setState(XYChart.getStateFromProps(nextProps)); + ].some(prop => !shallowCompareObjectEntries(this.props[prop], nextProps[prop]))) { + shouldComputeScales = true; } + if (shouldComputeScales) this.setState(XYChart.getStateFromProps(nextProps)); } getNumTicks(innerWidth, innerHeight) { @@ -185,6 +151,63 @@ class XYChart extends React.PureComponent { }; } + getDatumCoords(datum) { + const { snapTooltipToDataX, snapTooltipToDataY } = this.props; + const { xScale, yScale, margin } = this.state; + const coords = {}; + // tooltip operates in full width/height space so we must account for margins + if (datum && snapTooltipToDataX) coords.x = xScale(getX(datum)) + margin.left; + if (datum && snapTooltipToDataY) coords.y = yScale(getY(datum)) + margin.top; + return coords; + } + + handleContainerEvent(event) { + const { xScale, yScale } = this.state; + const { children } = this.props; + const { closestDatum, series } = findClosestDatums({ + children, + event, + getX, + getY, + xScale, + yScale, + }); + if (closestDatum || Object.keys(series).length > 0) { + event.persist(); + const args = { event, datum: closestDatum, series }; + if (event.type === 'mousemove') this.handleMouseMove(args); + else if (event.type === 'click') this.handleClick(args); + } + } + + handleMouseMove(args) { + if (this.props.onMouseMove) { + this.props.onMouseMove({ + ...args, + coords: { + ...this.getDatumCoords(args.datum), + ...args.coords, + }, + }); + } + } + + handleMouseLeave(args) { + if (this.props.onMouseLeave) this.props.onMouseLeave(args); + } + + handleClick(args) { + if (this.props.onClick) { + this.props.onClick({ + ...args, + coords: { + ...this.getDatumCoords(args.datum), + ...args.coords, + }, + }); + } + } + render() { if (this.props.renderTooltip) { return ( @@ -196,18 +219,16 @@ class XYChart extends React.PureComponent { const { ariaLabel, + eventTrigger, children, showXGrid, showYGrid, theme, height, width, - onClick, - onMouseLeave, - onMouseMove, + innerRef, tooltipData, showVoronoi, - useVoronoi, } = this.props; const { @@ -224,13 +245,13 @@ class XYChart extends React.PureComponent { const { numXTicks, numYTicks } = this.getNumTicks(innerWidth, innerHeight); const barWidth = xScale.barWidth || (xScale.bandwidth && xScale.bandwidth()) || 0; const CrossHairs = []; // ensure these are the top-most layer - return innerWidth > 0 && innerHeight > 0 && ( {(showXGrid || showYGrid) && (numXTicks || numYTicks) && @@ -264,9 +285,12 @@ class XYChart extends React.PureComponent { xScale, yScale, barWidth, - onClick: Child.props.onClick || onClick, - onMouseLeave: Child.props.onMouseLeave || onMouseLeave, - onMouseMove: Child.props.onMouseMove || onMouseMove, + onClick: Child.props.onClick + || (Child.props.disableMouseEvents ? undefined : this.handleClick), + onMouseLeave: Child.props.onMouseLeave + || (Child.props.disableMouseEvents ? undefined : this.handleMouseLeave), + onMouseMove: Child.props.onMouseMove + || (Child.props.disableMouseEvents ? undefined : this.handleMouseMove), }); } else if (isCrossHair(name)) { CrossHairs.push(Child); @@ -277,19 +301,32 @@ class XYChart extends React.PureComponent { return Child; })} - {useVoronoi && + {eventTrigger === VORONOI_TRIGGER && } + {eventTrigger === CONTAINER_TRIGGER && + } + {tooltipData && CrossHairs.length > 0 && CrossHairs.map((CrossHair, i) => ( React.cloneElement(CrossHair, { key: `crosshair-${i}`, // eslint-disable-line react/no-array-index-key diff --git a/packages/xy-chart/src/series/AreaSeries.jsx b/packages/xy-chart/src/series/AreaSeries.jsx index 55ad05ed..ce7a7ec5 100644 --- a/packages/xy-chart/src/series/AreaSeries.jsx +++ b/packages/xy-chart/src/series/AreaSeries.jsx @@ -10,10 +10,11 @@ import interpolatorLookup from '../utils/interpolatorLookup'; import { callOrValue, isDefined } from '../utils/chartUtils'; import findClosestDatum from '../utils/findClosestDatum'; import { areaSeriesDataShape, interpolationShape } from '../utils/propShapes'; +import sharedSeriesProps from '../utils/sharedSeriesProps'; const propTypes = { + ...sharedSeriesProps, data: areaSeriesDataShape.isRequired, - disableMouseEvents: PropTypes.bool, fill: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), fillOpacity: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), interpolation: interpolationShape, @@ -21,16 +22,9 @@ const propTypes = { strokeDasharray: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), strokeWidth: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), strokeLinecap: PropTypes.oneOf(['butt', 'square', 'round', 'inherit']), - onClick: PropTypes.func, - onMouseMove: PropTypes.func, - onMouseLeave: PropTypes.func, - // these will likely be injected by the parent chart - xScale: PropTypes.func, - yScale: PropTypes.func, }; const defaultProps = { - disableMouseEvents: false, interpolation: 'monotoneX', stroke: color.default, strokeWidth: 3, @@ -38,11 +32,6 @@ const defaultProps = { strokeLinecap: 'round', fill: color.default, fillOpacity: 0.3, - xScale: null, - yScale: null, - onClick: null, - onMouseMove: null, - onMouseLeave: null, }; const x = d => d && d.x; diff --git a/packages/xy-chart/src/series/BarSeries.jsx b/packages/xy-chart/src/series/BarSeries.jsx index c87ee023..f5f5e120 100644 --- a/packages/xy-chart/src/series/BarSeries.jsx +++ b/packages/xy-chart/src/series/BarSeries.jsx @@ -7,39 +7,25 @@ import themeColors from '@data-ui/theme/build/color'; import { barSeriesDataShape } from '../utils/propShapes'; import { callOrValue, isDefined } from '../utils/chartUtils'; +import sharedSeriesProps from '../utils/sharedSeriesProps'; const propTypes = { + ...sharedSeriesProps, + barWidth: PropTypes.number, data: barSeriesDataShape.isRequired, - disableMouseEvents: PropTypes.bool, - // overridden by data props fill: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), fillOpacity: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), stroke: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), strokeWidth: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), - - onClick: PropTypes.func, - onMouseMove: PropTypes.func, - onMouseLeave: PropTypes.func, - - // probably injected by the parent xychart - barWidth: PropTypes.number, - xScale: PropTypes.func, - yScale: PropTypes.func, }; const defaultProps = { barWidth: null, - disableMouseEvents: false, fill: themeColors.default, fillOpacity: null, stackBy: null, stroke: '#FFFFFF', strokeWidth: 1, - xScale: null, - yScale: null, - onClick: null, - onMouseMove: null, - onMouseLeave: null, }; const x = d => d.x; diff --git a/packages/xy-chart/src/series/BoxPlotSeries.jsx b/packages/xy-chart/src/series/BoxPlotSeries.jsx index 66d2772a..ee7267a7 100644 --- a/packages/xy-chart/src/series/BoxPlotSeries.jsx +++ b/packages/xy-chart/src/series/BoxPlotSeries.jsx @@ -5,20 +5,18 @@ import BoxPlot from '@vx/stats/build/boxplot/BoxPlot'; import themeColors from '@data-ui/theme/build/color'; import { callOrValue, isDefined } from '../utils/chartUtils'; - import { boxPlotSeriesDataShape } from '../utils/propShapes'; +import sharedSeriesProps from '../utils/sharedSeriesProps'; const propTypes = { + ...sharedSeriesProps, containerEvents: PropTypes.bool, data: boxPlotSeriesDataShape.isRequired, - disableMouseEvents: PropTypes.bool, fill: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + horizontal: PropTypes.bool, stroke: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), strokeWidth: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), fillOpacity: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), - onClick: PropTypes.func, - onMouseMove: PropTypes.func, - onMouseLeave: PropTypes.func, widthRatio: PropTypes.number, containerProps: PropTypes.object, outlierProps: PropTypes.object, @@ -26,11 +24,6 @@ const propTypes = { minProps: PropTypes.object, maxProps: PropTypes.object, medianProps: PropTypes.object, - - // likely be injected by the parent chart - xScale: PropTypes.func, - yScale: PropTypes.func, - horizontal: PropTypes.bool, }; const defaultProps = { @@ -40,8 +33,6 @@ const defaultProps = { strokeWidth: 2, fill: themeColors.default, fillOpacity: 1, - xScale: null, - yScale: null, horizontal: false, widthRatio: 1, containerProps: null, @@ -50,10 +41,6 @@ const defaultProps = { minProps: null, maxProps: null, medianProps: null, - disableMouseEvents: false, - onMouseMove: undefined, - onMouseLeave: undefined, - onClick: undefined, }; const MAX_BOX_WIDTH = 50; diff --git a/packages/xy-chart/src/series/GroupedBarSeries.jsx b/packages/xy-chart/src/series/GroupedBarSeries.jsx index 6d1fc7c0..3dab7b43 100644 --- a/packages/xy-chart/src/series/GroupedBarSeries.jsx +++ b/packages/xy-chart/src/series/GroupedBarSeries.jsx @@ -5,37 +5,25 @@ import BarGroup from '@vx/shape/build/shapes/BarGroup'; import color from '@data-ui/theme/build/color'; import { groupedBarSeriesDataShape } from '../utils/propShapes'; -import { scaleTypeToScale } from '../utils/chartUtils'; +import { scaleTypeToScale } from '../utils/getScaleForAccessor'; +import sharedSeriesProps from '../utils/sharedSeriesProps'; const propTypes = { + ...sharedSeriesProps, data: groupedBarSeriesDataShape.isRequired, - disableMouseEvents: PropTypes.bool, groupKeys: PropTypes.arrayOf(PropTypes.string).isRequired, groupFills: PropTypes.arrayOf(PropTypes.string), stroke: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), strokeWidth: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), groupPadding: PropTypes.number, // see https://github.com/d3/d3-scale#band-scales - onClick: PropTypes.func, - onMouseMove: PropTypes.func, - onMouseLeave: PropTypes.func, - - // these will likely be injected by the parent xychart - xScale: PropTypes.func, - yScale: PropTypes.func, }; const defaultProps = { - disableMouseEvents: false, groupKeys: null, groupFills: color.categories, groupPadding: 0.1, stroke: 'none', strokeWidth: 1, - xScale: null, - yScale: null, - onClick: null, - onMouseMove: null, - onMouseLeave: null, }; const x = d => d.x; diff --git a/packages/xy-chart/src/series/IntervalSeries.jsx b/packages/xy-chart/src/series/IntervalSeries.jsx index 7157997f..41b1840c 100644 --- a/packages/xy-chart/src/series/IntervalSeries.jsx +++ b/packages/xy-chart/src/series/IntervalSeries.jsx @@ -7,35 +7,22 @@ import color from '@data-ui/theme/build/color'; import { intervalSeriesDataShape } from '../utils/propShapes'; import { callOrValue } from '../utils/chartUtils'; +import sharedSeriesProps from '../utils/sharedSeriesProps'; const propTypes = { + ...sharedSeriesProps, data: intervalSeriesDataShape.isRequired, - disableMouseEvents: PropTypes.bool, - // overridden by data props fill: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), fillOpacity: PropTypes.number, stroke: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), strokeWidth: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), - onClick: PropTypes.func, - onMouseMove: PropTypes.func, - onMouseLeave: PropTypes.func, - - // likely be injected by the parent xychart - xScale: PropTypes.func, - yScale: PropTypes.func, }; const defaultProps = { - disableMouseEvents: false, fill: color.default, fillOpacity: 1, stroke: 'none', strokeWidth: 1, - xScale: null, - yScale: null, - onClick: null, - onMouseMove: null, - onMouseLeave: null, }; const x0 = d => d.x0; diff --git a/packages/xy-chart/src/series/LineSeries.jsx b/packages/xy-chart/src/series/LineSeries.jsx index 042c2a48..22191021 100644 --- a/packages/xy-chart/src/series/LineSeries.jsx +++ b/packages/xy-chart/src/series/LineSeries.jsx @@ -9,38 +9,26 @@ import { callOrValue, isDefined } from '../utils/chartUtils'; import findClosestDatum from '../utils/findClosestDatum'; import interpolatorLookup from '../utils/interpolatorLookup'; import { interpolationShape, lineSeriesDataShape } from '../utils/propShapes'; +import sharedSeriesProps from '../utils/sharedSeriesProps'; const propTypes = { + ...sharedSeriesProps, data: lineSeriesDataShape.isRequired, - disableMouseEvents: PropTypes.bool, interpolation: interpolationShape, showPoints: PropTypes.bool, stroke: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), strokeDasharray: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), strokeWidth: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), strokeLinecap: PropTypes.oneOf(['butt', 'square', 'round', 'inherit']), - onClick: PropTypes.func, - onMouseMove: PropTypes.func, - onMouseLeave: PropTypes.func, - - // these will likely be injected by the parent chart - xScale: PropTypes.func, - yScale: PropTypes.func, }; const defaultProps = { - disableMouseEvents: false, interpolation: 'monotoneX', showPoints: false, stroke: color.default, strokeDasharray: null, strokeWidth: 3, strokeLinecap: 'round', - xScale: null, - yScale: null, - onClick: null, - onMouseMove: null, - onMouseLeave: null, }; const x = d => d.x; diff --git a/packages/xy-chart/src/series/PointSeries.jsx b/packages/xy-chart/src/series/PointSeries.jsx index 58518d78..e04c3971 100644 --- a/packages/xy-chart/src/series/PointSeries.jsx +++ b/packages/xy-chart/src/series/PointSeries.jsx @@ -8,6 +8,7 @@ import color from '@data-ui/theme/build/color'; import { callOrValue, isDefined } from '../utils/chartUtils'; import { pointSeriesDataShape } from '../utils/propShapes'; import GlyphDotComponent from '../glyph/GlyphDotComponent'; +import sharedSeriesProps from '../utils/sharedSeriesProps'; export const pointComponentPropTypes = { x: PropTypes.number.isRequired, @@ -26,28 +27,19 @@ export const pointComponentPropTypes = { }; export const propTypes = { + ...sharedSeriesProps, data: pointSeriesDataShape.isRequired, - disableMouseEvents: PropTypes.bool, labelComponent: PropTypes.element, pointComponent: PropTypes.oneOfType([PropTypes.func, PropTypes.element]), - onClick: PropTypes.func, - onMouseMove: PropTypes.func, - onMouseLeave: PropTypes.func, - // attributes on data points will override these fill: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), fillOpacity: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), stroke: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), strokeWidth: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), strokeDasharray: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), size: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), - - // likely be injected by the parent chart - xScale: PropTypes.func, - yScale: PropTypes.func, }; export const defaultProps = { - disableMouseEvents: false, labelComponent: , pointComponent: GlyphDotComponent, size: 4, @@ -56,11 +48,6 @@ export const defaultProps = { stroke: '#FFFFFF', strokeDasharray: null, strokeWidth: 1, - xScale: null, - yScale: null, - onClick: null, - onMouseMove: null, - onMouseLeave: null, }; const noEventsStyles = { pointerEvents: 'none' }; diff --git a/packages/xy-chart/src/series/StackedAreaSeries.jsx b/packages/xy-chart/src/series/StackedAreaSeries.jsx index fd9a3107..b92ef24d 100644 --- a/packages/xy-chart/src/series/StackedAreaSeries.jsx +++ b/packages/xy-chart/src/series/StackedAreaSeries.jsx @@ -9,37 +9,26 @@ import interpolatorLookup from '../utils/interpolatorLookup'; import { callOrValue, isDefined } from '../utils/chartUtils'; import findClosestDatum from '../utils/findClosestDatum'; import { lineSeriesDataShape, interpolationShape } from '../utils/propShapes'; +import sharedSeriesProps from '../utils/sharedSeriesProps'; const propTypes = { + ...sharedSeriesProps, data: lineSeriesDataShape.isRequired, - disableMouseEvents: PropTypes.bool, fillOpacity: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), interpolation: interpolationShape, stackKeys: PropTypes.arrayOf(PropTypes.string).isRequired, stackFills: PropTypes.arrayOf(PropTypes.string), stroke: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), strokeWidth: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), - onClick: PropTypes.func, - onMouseMove: PropTypes.func, - onMouseLeave: PropTypes.func, - // these will likely be injected by the parent chart - xScale: PropTypes.func, - yScale: PropTypes.func, }; const defaultProps = { - disableMouseEvents: false, fill: color.default, fillOpacity: 0.7, interpolation: 'monotoneX', stackFills: color.categories, stroke: '#fff', strokeWidth: 1, - xScale: null, - yScale: null, - onClick: null, - onMouseMove: null, - onMouseLeave: null, }; const x = d => d && d.x; diff --git a/packages/xy-chart/src/series/StackedBarSeries.jsx b/packages/xy-chart/src/series/StackedBarSeries.jsx index b1389957..f54259be 100644 --- a/packages/xy-chart/src/series/StackedBarSeries.jsx +++ b/packages/xy-chart/src/series/StackedBarSeries.jsx @@ -5,34 +5,22 @@ import BarStack from '@vx/shape/build/shapes/BarStack'; import color from '@data-ui/theme/build/color'; import { stackedBarSeriesDataShape } from '../utils/propShapes'; -import { scaleTypeToScale } from '../utils/chartUtils'; +import { scaleTypeToScale } from '../utils/getScaleForAccessor'; +import sharedSeriesProps from '../utils/sharedSeriesProps'; const propTypes = { + ...sharedSeriesProps, data: stackedBarSeriesDataShape.isRequired, - disableMouseEvents: PropTypes.bool, stackKeys: PropTypes.arrayOf(PropTypes.string).isRequired, stackFills: PropTypes.arrayOf(PropTypes.string), stroke: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), strokeWidth: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), - onClick: PropTypes.func, - onMouseMove: PropTypes.func, - onMouseLeave: PropTypes.func, - - // these will likely be injected by the parent xychart - xScale: PropTypes.func, - yScale: PropTypes.func, }; const defaultProps = { - disableMouseEvents: false, stackFills: color.categories, stroke: '#FFFFFF', strokeWidth: 1, - xScale: null, - yScale: null, - onClick: null, - onMouseMove: null, - onMouseLeave: null, }; const x = d => d.x; diff --git a/packages/xy-chart/src/series/ViolinPlotSeries.jsx b/packages/xy-chart/src/series/ViolinPlotSeries.jsx index 938da05a..177400e7 100644 --- a/packages/xy-chart/src/series/ViolinPlotSeries.jsx +++ b/packages/xy-chart/src/series/ViolinPlotSeries.jsx @@ -7,40 +7,24 @@ import themeColors from '@data-ui/theme/build/color'; import { callOrValue } from '../utils/chartUtils'; import { violinPlotSeriesDataShape } from '../utils/propShapes'; - +import sharedSeriesProps from '../utils/sharedSeriesProps'; const propTypes = { + ...sharedSeriesProps, data: violinPlotSeriesDataShape.isRequired, - - // attributes on data points will override these + horizontal: PropTypes.bool, fill: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), stroke: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), strokeWidth: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), - - // likely be injected by the parent chart - xScale: PropTypes.func, - yScale: PropTypes.func, - horizontal: PropTypes.bool, widthRatio: PropTypes.number, - disableMouseEvents: PropTypes.bool, - onMouseMove: PropTypes.func, - onMouseLeave: PropTypes.func, - onClick: PropTypes.func, }; const defaultProps = { - boxWidth: null, stroke: themeColors.darkGray, strokeWidth: 2, fill: themeColors.default, - xScale: null, - yScale: null, horizontal: false, widthRatio: 1, - disableMouseEvents: false, - onMouseMove: undefined, - onMouseLeave: undefined, - onClick: undefined, }; const MAX_BOX_WIDTH = 50; diff --git a/packages/xy-chart/src/utils/chartUtils.js b/packages/xy-chart/src/utils/chartUtils.js index 9779d0fa..e5a76a3d 100644 --- a/packages/xy-chart/src/utils/chartUtils.js +++ b/packages/xy-chart/src/utils/chartUtils.js @@ -1,6 +1,4 @@ import { Children } from 'react'; -import { scaleLinear, scaleTime, scaleUtc, scaleBand, scaleOrdinal } from '@vx/scale'; -import { extent } from 'd3-array'; export function callOrValue(maybeFn, ...args) { if (typeof maybeFn === 'function') { @@ -53,59 +51,6 @@ export function isStackedSeries(name) { return (/stacked/gi).test(name); } -export const scaleTypeToScale = { - time: scaleTime, - timeUtc: scaleUtc, - linear: scaleLinear, - band: scaleBand, - ordinal: scaleOrdinal, -}; - -export function collectDataFromChildSeries(children) { - let allData = []; - const dataByIndex = {}; - const dataBySeriesType = {}; - - Children.forEach(children, (Child, i) => { - if (Child && Child.props && Child.props.data) { - const name = componentName(Child); - const { data } = Child.props; - if (data && isSeries(name)) { - dataByIndex[i] = data; - allData = allData.concat(data); - dataBySeriesType[name] = (dataBySeriesType[name] || []).concat(data); - } - } - }); - return { dataByIndex, allData, dataBySeriesType }; -} - -export function getScaleForAccessor({ - allData, - minAccessor, - maxAccessor, - type, - includeZero = true, - range, - ...rest -}) { - let domain; - if (type === 'band' || type === 'ordinal') { - domain = allData.map(minAccessor); - } - if (type === 'linear' || type === 'time' || type === 'timeUtc') { - const [min, max] = extent([ - ...extent(allData, minAccessor), - ...extent(allData, maxAccessor), - ]); - domain = [ - type === 'linear' && includeZero ? Math.min(0, min) : min, - type === 'linear' && includeZero ? Math.max(0, max) : max, - ]; - } - return scaleTypeToScale[type]({ domain, range, ...rest }); -} - export function numTicksForHeight(height) { if (height <= 300) return 3; if (height <= 600) return 5; diff --git a/packages/xy-chart/src/utils/collectDataFromChildSeries.js b/packages/xy-chart/src/utils/collectDataFromChildSeries.js new file mode 100644 index 00000000..241322f6 --- /dev/null +++ b/packages/xy-chart/src/utils/collectDataFromChildSeries.js @@ -0,0 +1,23 @@ +import { Children } from 'react'; + +import { componentName, isSeries } from './chartUtils'; + +export default function collectDataFromChildSeries(children) { + let allData = []; + const dataByIndex = {}; + const dataBySeriesType = {}; + + Children.forEach(children, (Child, i) => { + if (Child && Child.props && Child.props.data) { + const name = componentName(Child); + const { data } = Child.props; + if (data && isSeries(name)) { + dataByIndex[i] = data; + allData = allData.concat(data); + dataBySeriesType[name] = (dataBySeriesType[name] || []).concat(data); + } + } + }); + + return { dataByIndex, allData, dataBySeriesType }; +} diff --git a/packages/xy-chart/src/utils/collectScalesFromProps.js b/packages/xy-chart/src/utils/collectScalesFromProps.js new file mode 100644 index 00000000..84b126a9 --- /dev/null +++ b/packages/xy-chart/src/utils/collectScalesFromProps.js @@ -0,0 +1,58 @@ +import { Children } from 'react'; + +import collectDataFromChildSeries from './collectDataFromChildSeries'; +import getChartDimensions from './getChartDimensions'; +import getScaleForAccessor from './getScaleForAccessor'; +import { componentName, isBarSeries, isCirclePackSeries } from './chartUtils'; + +const getX = d => d && d.x; +const xString = d => getX(d).toString(); + +export default function collectScalesFromProps(props) { + const { xScale: xScaleObject, yScale: yScaleObject, children } = props; + const { innerWidth, innerHeight } = getChartDimensions(props); + const { allData } = collectDataFromChildSeries(children); + + const xScale = getScaleForAccessor({ + allData, + minAccessor: d => (typeof d.x0 !== 'undefined' ? d.x0 : d.x), + maxAccessor: d => (typeof d.x1 !== 'undefined' ? d.x1 : d.x), + range: [0, innerWidth], + ...xScaleObject, + }); + + const yScale = getScaleForAccessor({ + allData, + minAccessor: d => (typeof d.y0 !== 'undefined' ? d.y0 : d.y), + maxAccessor: d => (typeof d.y1 !== 'undefined' ? d.y1 : d.y), + range: [innerHeight, 0], + ...yScaleObject, + }); + + Children.forEach(children, (Child) => { // Child-specific scales or adjustments here + const name = componentName(Child); + if (isBarSeries(name) && xScaleObject.type !== 'band') { + const dummyBand = getScaleForAccessor({ + allData, + minAccessor: xString, + maxAccessor: xString, + type: 'band', + rangeRound: [0, innerWidth], + paddingOuter: 1, + }); + + const offset = dummyBand.bandwidth() / 2; + xScale.range([offset, innerWidth - offset]); + xScale.barWidth = dummyBand.bandwidth(); + xScale.offset = offset; + } + if (isCirclePackSeries(name)) { + yScale.domain([-innerHeight / 2, innerHeight / 2]); + } + }); + + return { + xScale, + yScale, + }; +} diff --git a/packages/xy-chart/src/utils/collectVoronoiData.js b/packages/xy-chart/src/utils/collectVoronoiData.js new file mode 100644 index 00000000..c4555f82 --- /dev/null +++ b/packages/xy-chart/src/utils/collectVoronoiData.js @@ -0,0 +1,14 @@ +import { Children } from 'react'; + +import { isSeries, isDefined, componentName } from './chartUtils'; + +export default function collectVoronoiData({ children, getX, getY }) { + return Children.toArray(children).reduce((result, Child) => { + if (isSeries(componentName(Child)) && !Child.props.disableMouseEvents) { + return result.concat( + Child.props.data.filter(d => isDefined(getX(d)) && isDefined(getY(d))), + ); + } + return result; + }, []); +} diff --git a/packages/xy-chart/src/utils/findClosestDatum.js b/packages/xy-chart/src/utils/findClosestDatum.js index ce85cd4c..b394b4ca 100644 --- a/packages/xy-chart/src/utils/findClosestDatum.js +++ b/packages/xy-chart/src/utils/findClosestDatum.js @@ -1,16 +1,31 @@ -import { bisector } from 'd3-array'; +import { bisector, bisectLeft as d3BisectLeft } from 'd3-array'; import localPoint from '@vx/event/build/localPoint'; export default function findClosestDatum({ data, getX, xScale, event }) { if (!event || !event.target || !event.target.ownerSVGElement) return null; const bisect = bisector(getX).left; + // if the g element has a transform we need to be in g coords not svg coords const gElement = event.target.ownerSVGElement.firstChild; - const { x } = localPoint(gElement, event); - const dataX = xScale.invert(x); - const index = bisect(data, dataX, 1); - const d0 = data[index - 1]; - const d1 = data[index] || {}; - const d = !d0 || (Math.abs(dataX - getX(d0)) > Math.abs(dataX - getX(d1))) ? d1 : d0; + const { x: mouseX } = localPoint(gElement, event); + + const isOrdinalScale = typeof xScale.invert !== 'function'; + let d; + if (isOrdinalScale) { + // Ordinal scales don't have an invert function so we do it maually + const xDomain = xScale.domain(); + const scaledXValues = xDomain.map(val => xScale(val)); + const index = d3BisectLeft(scaledXValues, mouseX); + const d0 = data[index - 1]; + const d1 = data[index]; + d = d0 || d1; + } else { + const dataX = xScale.invert(mouseX); + const index = bisect(data, dataX, 0); + const d0 = data[index - 1]; + const d1 = data[index] || {}; + d = !d0 || (Math.abs(dataX - getX(d0)) > Math.abs(dataX - getX(d1))) ? d1 : d0; + } + return d; } diff --git a/packages/xy-chart/src/utils/findClosestDatums.js b/packages/xy-chart/src/utils/findClosestDatums.js new file mode 100644 index 00000000..07bd03f9 --- /dev/null +++ b/packages/xy-chart/src/utils/findClosestDatums.js @@ -0,0 +1,49 @@ +import { Children } from 'react'; +import localPoint from '@vx/event/build/localPoint'; + +import findClosestDatum from './findClosestDatum'; +import { componentName, isSeries } from '../utils/chartUtils'; + +export default function findClosestDatums({ + children, + xScale, + yScale, + getX, + getY, + event, + maxXDistancePx = 25, +}) { + if (!event || !event.target || !event.target.ownerSVGElement) return null; + const series = {}; + + const gElement = event.target.ownerSVGElement.firstChild; + const { x: mouseX, y: mouseY } = localPoint(gElement, event); + let closestDatum; + let minDelta = Infinity; + + // collect data from all series that have an x value near this point + Children.forEach(children, (Child, childIndex) => { + if (isSeries(componentName(Child)) && !Child.props.disableMouseEvents) { + const { data, seriesKey } = Child.props; + // @TODO data should be sorted, come up with a way to enforce+cache instead of relying on user + const datum = findClosestDatum({ + data, + getX, + xScale, + event, + }); + + const deltaX = Math.abs(xScale(getX(datum || {})) - mouseX); + + if (datum && deltaX <= maxXDistancePx) { + const key = seriesKey || childIndex; // fall back to child index + series[key] = datum; + const deltaY = Math.abs(yScale(getY(datum)) - mouseY); + closestDatum = deltaY < minDelta ? datum : closestDatum; + minDelta = Math.min(deltaY, minDelta); + } + } + }); + + return { series, closestDatum }; +} diff --git a/packages/xy-chart/src/utils/getChartDimensions.js b/packages/xy-chart/src/utils/getChartDimensions.js new file mode 100644 index 00000000..af14da0a --- /dev/null +++ b/packages/xy-chart/src/utils/getChartDimensions.js @@ -0,0 +1,10 @@ +import { defaultProps } from '../chart/XYChart'; + +export default function getChartDimensions({ margin, width, height }) { + const completeMargin = { ...defaultProps.margin, ...margin }; + return { + margin: completeMargin, + innerHeight: Math.max(0, height - completeMargin.top - completeMargin.bottom), + innerWidth: Math.max(0, width - completeMargin.left - completeMargin.right), + }; +} diff --git a/packages/xy-chart/src/utils/getScaleForAccessor.js b/packages/xy-chart/src/utils/getScaleForAccessor.js new file mode 100644 index 00000000..df8ff1dd --- /dev/null +++ b/packages/xy-chart/src/utils/getScaleForAccessor.js @@ -0,0 +1,36 @@ +import { scaleLinear, scaleTime, scaleUtc, scaleBand, scaleOrdinal } from '@vx/scale'; +import { extent } from 'd3-array'; + +export const scaleTypeToScale = { + time: scaleTime, + timeUtc: scaleUtc, + linear: scaleLinear, + band: scaleBand, + ordinal: scaleOrdinal, +}; + +export default function getScaleForAccessor({ + allData, + minAccessor, + maxAccessor, + type, + includeZero = true, + range, + ...rest +}) { + let domain; + if (type === 'band' || type === 'ordinal') { + domain = allData.map(minAccessor); + } + if (type === 'linear' || type === 'time' || type === 'timeUtc') { + const [min, max] = extent([ + ...extent(allData, minAccessor), + ...extent(allData, maxAccessor), + ]); + domain = [ + type === 'linear' && includeZero ? Math.min(0, min) : min, + type === 'linear' && includeZero ? Math.max(0, max) : max, + ]; + } + return scaleTypeToScale[type]({ domain, range, ...rest }); +} diff --git a/packages/xy-chart/src/utils/shallowCompareObjectEntries.js b/packages/xy-chart/src/utils/shallowCompareObjectEntries.js new file mode 100644 index 00000000..12a6da57 --- /dev/null +++ b/packages/xy-chart/src/utils/shallowCompareObjectEntries.js @@ -0,0 +1,6 @@ +export default function shallowCompareObjectEntries(a, b) { + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) return false; + return keysA.every(k => a[k] === b[k]); +} diff --git a/packages/xy-chart/src/utils/sharedSeriesProps.js b/packages/xy-chart/src/utils/sharedSeriesProps.js new file mode 100644 index 00000000..eb7e7148 --- /dev/null +++ b/packages/xy-chart/src/utils/sharedSeriesProps.js @@ -0,0 +1,11 @@ +import PropTypes from 'prop-types'; + +export default { + disableMouseEvents: PropTypes.bool, + seriesKey: PropTypes.string, + onClick: PropTypes.func, + onMouseMove: PropTypes.func, + onMouseLeave: PropTypes.func, + xScale: PropTypes.func, + yScale: PropTypes.func, +}; diff --git a/packages/xy-chart/test/HorizontalReferenceLine.test.js b/packages/xy-chart/test/annotation/HorizontalReferenceLine.test.js similarity index 97% rename from packages/xy-chart/test/HorizontalReferenceLine.test.js rename to packages/xy-chart/test/annotation/HorizontalReferenceLine.test.js index 0945ee55..1bf6bd56 100644 --- a/packages/xy-chart/test/HorizontalReferenceLine.test.js +++ b/packages/xy-chart/test/annotation/HorizontalReferenceLine.test.js @@ -2,7 +2,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import Line from '@vx/shape/build/shapes/Line'; -import { XYChart, HorizontalReferenceLine } from '../src/'; +import { XYChart, HorizontalReferenceLine } from '../../src/'; describe('', () => { const reference = 12; diff --git a/packages/xy-chart/test/XAxis.test.js b/packages/xy-chart/test/axis/XAxis.test.js similarity index 98% rename from packages/xy-chart/test/XAxis.test.js rename to packages/xy-chart/test/axis/XAxis.test.js index ee5708e2..a2f0c813 100644 --- a/packages/xy-chart/test/XAxis.test.js +++ b/packages/xy-chart/test/axis/XAxis.test.js @@ -2,7 +2,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import AxisBottom from '@vx/axis/build/axis/AxisBottom'; import AxisTop from '@vx/axis/build/axis/AxisTop'; -import { XYChart, XAxis, LineSeries } from '../src/'; +import { XYChart, XAxis, LineSeries } from '../../src/'; describe('', () => { const chartProps = { diff --git a/packages/xy-chart/test/YAxis.test.js b/packages/xy-chart/test/axis/YAxis.test.js similarity index 98% rename from packages/xy-chart/test/YAxis.test.js rename to packages/xy-chart/test/axis/YAxis.test.js index a1561278..256d48b8 100644 --- a/packages/xy-chart/test/YAxis.test.js +++ b/packages/xy-chart/test/axis/YAxis.test.js @@ -2,7 +2,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import AxisLeft from '@vx/axis/build/axis/AxisLeft'; import AxisRight from '@vx/axis/build/axis/AxisRight'; -import { XYChart, YAxis, LineSeries } from '../src/'; +import { XYChart, YAxis, LineSeries } from '../../src/'; describe('', () => { const chartProps = { diff --git a/packages/xy-chart/test/CrossHair.test.js b/packages/xy-chart/test/chart/CrossHair.test.js similarity index 98% rename from packages/xy-chart/test/CrossHair.test.js rename to packages/xy-chart/test/chart/CrossHair.test.js index 4cb71e0c..86590345 100644 --- a/packages/xy-chart/test/CrossHair.test.js +++ b/packages/xy-chart/test/chart/CrossHair.test.js @@ -4,7 +4,7 @@ import { shallow } from 'enzyme'; import Line from '@vx/shape/build/shapes/Line'; import scaleLinear from '@vx/scale/build/scales/linear'; -import CrossHair from '../src/chart/CrossHair'; +import { CrossHair } from '../../src/'; describe('', () => { const props = { diff --git a/packages/xy-chart/test/Voronoi.test.js b/packages/xy-chart/test/chart/Voronoi.test.js similarity index 97% rename from packages/xy-chart/test/Voronoi.test.js rename to packages/xy-chart/test/chart/Voronoi.test.js index 4b268718..acfca2b0 100644 --- a/packages/xy-chart/test/Voronoi.test.js +++ b/packages/xy-chart/test/chart/Voronoi.test.js @@ -3,7 +3,7 @@ import { shallow, mount } from 'enzyme'; import VoronoiPolygon from '@vx/voronoi/build/components/VoronoiPolygon'; -import Voronoi from '../src/chart/Voronoi'; +import Voronoi from '../../src/chart/Voronoi'; describe('', () => { const props = { diff --git a/packages/xy-chart/test/XYChart.test.js b/packages/xy-chart/test/chart/XYChart.test.js similarity index 57% rename from packages/xy-chart/test/XYChart.test.js rename to packages/xy-chart/test/chart/XYChart.test.js index f5e509a7..3cd570e1 100644 --- a/packages/xy-chart/test/XYChart.test.js +++ b/packages/xy-chart/test/chart/XYChart.test.js @@ -1,10 +1,10 @@ import Grid from '@vx/grid/build/grids/Grid'; import Group from '@vx/group/build/Group'; import React from 'react'; -import { shallow } from 'enzyme'; +import { shallow, mount } from 'enzyme'; -import { XYChart, xyChartPropTypes, XAxis, YAxis, LineSeries, WithTooltip } from '../src'; -import Voronoi from '../src/chart/Voronoi'; +import { XYChart, xyChartPropTypes, XAxis, YAxis, LineSeries, WithTooltip } from '../../src'; +import Voronoi from '../../src/chart/Voronoi'; describe('', () => { const mockProps = { @@ -169,9 +169,64 @@ describe('', () => { expect(yScale.domain()).toEqual([-mockData[2].num, 0]); }); - test('it should render a Voronoi if useVoronoi is true', () => { + test('it should call the eventTriggerRefs callback on mount', () => { + expect.assertions(4); + + function eventTriggerRefs(refs) { + expect(refs).toEqual(expect.any(Object)); + expect(refs.click).toEqual(expect.any(Function)); + expect(refs.mousemove).toEqual(expect.any(Function)); + expect(refs.mouseleave).toEqual(expect.any(Function)); + } + + mount(); + }); + + test('it should set the passed innerRef callback on the svg', () => { + expect.assertions(1); + + function innerRef(ref) { + expect(ref.tagName).toBe('svg'); + } + + mount(); + }); + + test('calls to eventTriggerRefs should invoke the corresponding event handlers passed to XYChart', () => { + const onMouseMove = jest.fn(); + const onMouseLeave = jest.fn(); + const onClick = jest.fn(); + + const callbackArgs = { test: 'object' }; + function eventTriggerRefs(refs) { + refs.click(callbackArgs); + refs.mousemove(callbackArgs); + refs.mouseleave(callbackArgs); + } + + mount( + , + ); + + expect(onMouseMove).toHaveBeenCalledTimes(1); + expect(onMouseMove.mock.calls[0][0]).toMatchObject(callbackArgs); + + expect(onMouseLeave).toHaveBeenCalledTimes(1); + expect(onMouseLeave.mock.calls[0][0]).toMatchObject(callbackArgs); + + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick.mock.calls[0][0]).toMatchObject(callbackArgs); + }); + + test('it should render a Voronoi if eventTrigger="voronoi"', () => { const wrapper = shallow( - + ({ ...d, x: d.date, y: d.num }))} @@ -181,4 +236,89 @@ describe('', () => { expect(wrapper.find(Voronoi).length).toBe(1); }); + + test('it should render a rect to intercept events if eventTrigger="container"', () => { + const wrapper = shallow( + + ({ ...d, x: d.date, y: d.num }))} + /> + , + ); + + expect(wrapper.find('rect').length).toBe(1); + }); + + test('it should pass appropriate coords in mouse event handlers if snapTooltipToDataX or snapTooltipToDataY is true', () => { + const onMouseMove = jest.fn(); + const onClick = jest.fn(); + const data = mockData.map(d => ({ x: d.date, y: d.num })); + const callbackArgs = { datum: data[1] }; + + function eventTriggerRefs(refs) { + refs.click(callbackArgs); + refs.mousemove(callbackArgs); + } + + mount( + + + , + ); + + mount( + + + , + ); + + mount( + + + , + ); + + expect(onMouseMove).toHaveBeenCalledTimes(3); + expect(onClick).toHaveBeenCalledTimes(3); + + // first call, no x/y + expect(onMouseMove.mock.calls[0][0].coords.x).toBeUndefined(); + expect(onMouseMove.mock.calls[0][0].coords.y).toBeUndefined(); + expect(onClick.mock.calls[0][0].coords.x).toBeUndefined(); + expect(onClick.mock.calls[0][0].coords.y).toBeUndefined(); + + // second call, just x + expect(onMouseMove.mock.calls[1][0].coords.x).toEqual(expect.any(Number)); + expect(onMouseMove.mock.calls[1][0].coords.y).toBeUndefined(); + expect(onClick.mock.calls[1][0].coords.x).toEqual(expect.any(Number)); + expect(onClick.mock.calls[1][0].coords.y).toBeUndefined(); + + // third call, just y + expect(onMouseMove.mock.calls[2][0].coords.x).toBeUndefined(); + expect(onMouseMove.mock.calls[2][0].coords.y).toEqual(expect.any(Number)); + expect(onClick.mock.calls[2][0].coords.x).toBeUndefined(); + expect(onClick.mock.calls[2][0].coords.y).toEqual(expect.any(Number)); + }); }); diff --git a/packages/xy-chart/test/chartUtils.test.js b/packages/xy-chart/test/chartUtils.test.js deleted file mode 100644 index a70b30ae..00000000 --- a/packages/xy-chart/test/chartUtils.test.js +++ /dev/null @@ -1,164 +0,0 @@ -import React from 'react'; -import { BarSeries, LineSeries } from '../src'; - -import { - callOrValue, - getScaleForAccessor, - collectDataFromChildSeries, - componentName, -} from '../src/utils/chartUtils'; - -describe('collectDataFromChildSeries', () => { - const dummyProps = { xScale: () => {}, yScale: () => {}, label: 'bogus', barWidth: 0 }; - const barData = [{ x: 'bar', y: 123 }]; - const lineData = [{ x: 'line', y: 123 }]; - - const children = [ -
, - , - , - , - null, - ]; - - test('should ignore non-series children', () => { - expect( - collectDataFromChildSeries([,
]).allData, - ).toEqual([]); - }); - - const output = collectDataFromChildSeries(children); - - test('should concatenate all data', () => { - expect(output.allData).toEqual([...barData, ...lineData, ...barData]); - }); - - test('should collect data by Series type', () => { - expect(output.dataBySeriesType).toEqual({ - BarSeries: [...barData, ...barData], - LineSeries: [...lineData], - }); - }); - - test('should collect data by child index', () => { - expect(output.dataByIndex).toEqual({ - 1: barData, - 2: lineData, - 3: barData, - }); - }); -}); - -describe('callOrValue', () => { - test('should return non-functions', () => { - expect(callOrValue(123)).toEqual(123); - expect(callOrValue('123')).toEqual('123'); - expect(callOrValue(['hello'])).toEqual(['hello']); - }); - - test('should call a function', () => { - expect(callOrValue(() => 'abc')).toEqual('abc'); - }); - - test('should pass args to functions', () => { - expect(callOrValue((a, b, c) => `${a}${b}${c}`, 'x', 'y')).toEqual('xyundefined'); - }); -}); - -describe('getScaleForAccessor', () => { - const allData = [ - { date: '2016-01-05', dirtyNum: undefined, num: 124, cat: 'a' }, - { date: '2017-01-05', dirtyNum: -15, num: 500, cat: 'b' }, - { date: '2018-01-05', dirtyNum: 7, num: 50, cat: 'c' }, - { date: '2019-01-05', dirtyNum: null, num: 501, cat: 'z' }, - ]; - - test('should compute date domains', () => { - expect(getScaleForAccessor({ - allData, - minAccessor: d => new Date(d.date), - maxAccessor: d => new Date(d.date), - type: 'time', - range: [0, 100], - }).domain()).toEqual([ - new Date(allData[0].date), - new Date(allData[allData.length - 1].date), - ]); - }); - - test('should compute date strings domains', () => { - expect(getScaleForAccessor({ - allData, - minAccessor: d => d.date, - maxAccessor: d => d.date, - type: 'band', - range: [0, 100], - }).domain()).toEqual(['2016-01-05', '2017-01-05', '2018-01-05', '2019-01-05']); - }); - - test('should compute categorical domains', () => { - expect(getScaleForAccessor({ - allData, - minAccessor: d => d.cat, - maxAccessor: d => d.cat, - type: 'band', - range: [0, 100], - }).domain()).toEqual(['a', 'b', 'c', 'z']); - }); - - test('should compute numeric domains including zero', () => { - expect(getScaleForAccessor({ - allData, - minAccessor: d => d.num, - maxAccessor: d => d.num, - type: 'linear', - range: [0, 100], - }).domain()).toEqual([0, 501]); - }); - - test('should compute numeric domains excluding zero', () => { - expect(getScaleForAccessor({ - allData, - minAccessor: d => d.num, - maxAccessor: d => d.num, - type: 'linear', - range: [0, 100], - includeZero: false, - }).domain()).toEqual([50, 501]); - }); - - test('should compute numeric domains with missing values', () => { - expect(getScaleForAccessor({ - allData, - minAccessor: d => d.dirtyNum, - maxAccessor: d => d.dirtyNum, - type: 'linear', - range: [0, 100], - includeZero: false, - }).domain()).toEqual([-15, 7]); - }); -}); - -describe('componentName', () => { - class Component extends React.Component {} // eslint-disable-line - function SFC() {} - function SFCWithDisplayName() {} - SFCWithDisplayName.displayName = 'SFCWithDisplayName'; - - test('should work with React Components', () => { - expect(componentName()).toBe('Component'); - }); - - test('should work with SFCs', () => { - expect(componentName()).toBe('SFC'); - }); - - test('should work with DisplayName', () => { - expect(componentName()).toBe('SFCWithDisplayName'); - }); - - test('should return empty string for non-components', () => { - expect(componentName(null)).toBe(''); - expect(componentName(SFC)).toBe(''); - }); -}); diff --git a/packages/xy-chart/test/withTheme.test.js b/packages/xy-chart/test/enhancer/withTheme.test.js similarity index 96% rename from packages/xy-chart/test/withTheme.test.js rename to packages/xy-chart/test/enhancer/withTheme.test.js index d986cbcc..9b698b11 100644 --- a/packages/xy-chart/test/withTheme.test.js +++ b/packages/xy-chart/test/enhancer/withTheme.test.js @@ -1,6 +1,6 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { theme as defaultTheme, withTheme } from '../src'; +import { theme as defaultTheme, withTheme } from '../../src'; describe('withTheme', () => { test('it should be a fn', () => { diff --git a/packages/xy-chart/test/AreaSeries.test.js b/packages/xy-chart/test/series/AreaSeries.test.js similarity index 98% rename from packages/xy-chart/test/AreaSeries.test.js rename to packages/xy-chart/test/series/AreaSeries.test.js index 58ea141c..7cb71afc 100644 --- a/packages/xy-chart/test/AreaSeries.test.js +++ b/packages/xy-chart/test/series/AreaSeries.test.js @@ -3,7 +3,7 @@ import { shallow, mount } from 'enzyme'; import Area from '@vx/shape/build/shapes/Area'; import LinePath from '@vx/shape/build/shapes/LinePath'; -import { XYChart, AreaSeries } from '../src/'; +import { XYChart, AreaSeries } from '../../src/'; describe('', () => { const mockProps = { diff --git a/packages/xy-chart/test/BarSeries.test.js b/packages/xy-chart/test/series/BarSeries.test.js similarity index 98% rename from packages/xy-chart/test/BarSeries.test.js rename to packages/xy-chart/test/series/BarSeries.test.js index 016070fa..9cb6b77b 100644 --- a/packages/xy-chart/test/BarSeries.test.js +++ b/packages/xy-chart/test/series/BarSeries.test.js @@ -1,7 +1,7 @@ import Bar from '@vx/shape/build/shapes/Bar'; import React from 'react'; import { shallow, mount } from 'enzyme'; -import { XYChart, BarSeries } from '../src/'; +import { XYChart, BarSeries } from '../../src/'; describe('', () => { const mockProps = { diff --git a/packages/xy-chart/test/BoxPlotSeries.test.js b/packages/xy-chart/test/series/BoxPlotSeries.test.js similarity index 98% rename from packages/xy-chart/test/BoxPlotSeries.test.js rename to packages/xy-chart/test/series/BoxPlotSeries.test.js index eaee7afc..23b3c1ab 100644 --- a/packages/xy-chart/test/BoxPlotSeries.test.js +++ b/packages/xy-chart/test/series/BoxPlotSeries.test.js @@ -2,7 +2,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import BoxPlot from '@vx/stats/build/boxplot/BoxPlot'; -import { XYChart, BoxPlotSeries, computeStats } from '../src/'; +import { XYChart, BoxPlotSeries, computeStats } from '../../src/'; describe('', () => { const mockData = [1, 2, 3, 4, 5, 5, 5, 5, 5, 6, 9, 5, 1]; diff --git a/packages/xy-chart/test/CirclePackSeries.test.js b/packages/xy-chart/test/series/CirclePackSeries.test.js similarity index 98% rename from packages/xy-chart/test/CirclePackSeries.test.js rename to packages/xy-chart/test/series/CirclePackSeries.test.js index 11017fc8..eff6efb9 100644 --- a/packages/xy-chart/test/CirclePackSeries.test.js +++ b/packages/xy-chart/test/series/CirclePackSeries.test.js @@ -1,7 +1,7 @@ import React from 'react'; import { shallow, mount } from 'enzyme'; -import { XYChart, CirclePackSeries, PointSeries } from '../src/'; +import { XYChart, CirclePackSeries, PointSeries } from '../../src/'; describe('', () => { const mockProps = { diff --git a/packages/xy-chart/test/GroupedBarSeries.test.js b/packages/xy-chart/test/series/GroupedBarSeries.test.js similarity index 98% rename from packages/xy-chart/test/GroupedBarSeries.test.js rename to packages/xy-chart/test/series/GroupedBarSeries.test.js index a4d4cc71..22374ada 100644 --- a/packages/xy-chart/test/GroupedBarSeries.test.js +++ b/packages/xy-chart/test/series/GroupedBarSeries.test.js @@ -2,7 +2,7 @@ import Bar from '@vx/shape/build/shapes/Bar'; import BarGroup from '@vx/shape/build/shapes/BarGroup'; import React from 'react'; import { shallow, mount } from 'enzyme'; -import { XYChart, GroupedBarSeries } from '../src/'; +import { XYChart, GroupedBarSeries } from '../../src/'; describe('', () => { const mockProps = { diff --git a/packages/xy-chart/test/IntervalSeries.test.js b/packages/xy-chart/test/series/IntervalSeries.test.js similarity index 98% rename from packages/xy-chart/test/IntervalSeries.test.js rename to packages/xy-chart/test/series/IntervalSeries.test.js index 5aa38495..e4989893 100644 --- a/packages/xy-chart/test/IntervalSeries.test.js +++ b/packages/xy-chart/test/series/IntervalSeries.test.js @@ -2,7 +2,7 @@ import Bar from '@vx/shape/build/shapes/Bar'; import React from 'react'; import { shallow, mount } from 'enzyme'; -import { XYChart, IntervalSeries } from '../src/'; +import { XYChart, IntervalSeries } from '../../src/'; describe('', () => { const mockProps = { diff --git a/packages/xy-chart/test/LineSeries.test.js b/packages/xy-chart/test/series/LineSeries.test.js similarity index 98% rename from packages/xy-chart/test/LineSeries.test.js rename to packages/xy-chart/test/series/LineSeries.test.js index bdce1e8f..82976908 100644 --- a/packages/xy-chart/test/LineSeries.test.js +++ b/packages/xy-chart/test/series/LineSeries.test.js @@ -3,7 +3,7 @@ import LinePath from '@vx/shape/build/shapes/LinePath'; import React from 'react'; import { shallow, mount } from 'enzyme'; -import { XYChart, LineSeries } from '../src/'; +import { XYChart, LineSeries } from '../../src/'; describe('', () => { const mockProps = { diff --git a/packages/xy-chart/test/PointSeries.test.js b/packages/xy-chart/test/series/PointSeries.test.js similarity index 97% rename from packages/xy-chart/test/PointSeries.test.js rename to packages/xy-chart/test/series/PointSeries.test.js index ea1df8ff..48381c16 100644 --- a/packages/xy-chart/test/PointSeries.test.js +++ b/packages/xy-chart/test/series/PointSeries.test.js @@ -1,8 +1,8 @@ import React from 'react'; import { shallow, mount } from 'enzyme'; -import { XYChart, PointSeries } from '../src/'; -import GlyphDotComponent from '../src/glyph/GlyphDotComponent'; +import { XYChart, PointSeries } from '../../src/'; +import GlyphDotComponent from '../../src/glyph/GlyphDotComponent'; describe('', () => { const mockProps = { diff --git a/packages/xy-chart/test/StackedAreaSeries.test.js b/packages/xy-chart/test/series/StackedAreaSeries.test.js similarity index 98% rename from packages/xy-chart/test/StackedAreaSeries.test.js rename to packages/xy-chart/test/series/StackedAreaSeries.test.js index a7a8697f..81f3da32 100644 --- a/packages/xy-chart/test/StackedAreaSeries.test.js +++ b/packages/xy-chart/test/series/StackedAreaSeries.test.js @@ -2,7 +2,7 @@ import React from 'react'; import { shallow, mount } from 'enzyme'; import Stack from '@vx/shape/build/shapes/Stack'; -import { XYChart, StackedAreaSeries } from '../src/'; +import { XYChart, StackedAreaSeries } from '../../src/'; describe('', () => { const mockProps = { diff --git a/packages/xy-chart/test/StackedBarSeries.test.js b/packages/xy-chart/test/series/StackedBarSeries.test.js similarity index 97% rename from packages/xy-chart/test/StackedBarSeries.test.js rename to packages/xy-chart/test/series/StackedBarSeries.test.js index 7b8ef0f7..1a72cffd 100644 --- a/packages/xy-chart/test/StackedBarSeries.test.js +++ b/packages/xy-chart/test/series/StackedBarSeries.test.js @@ -2,9 +2,9 @@ import Bar from '@vx/shape/build/shapes/Bar'; import BarStack from '@vx/shape/build/shapes/BarStack'; import React from 'react'; import { shallow, mount } from 'enzyme'; -import { XYChart, StackedBarSeries } from '../src/'; +import { XYChart, StackedBarSeries } from '../../src/'; -describe('', () => { +describe('', () => { const mockProps = { xScale: { type: 'band' }, yScale: { type: 'linear', includeZero: false }, diff --git a/packages/xy-chart/test/ViolinPlotSeries.test.js b/packages/xy-chart/test/series/ViolinPlotSeries.test.js similarity index 97% rename from packages/xy-chart/test/ViolinPlotSeries.test.js rename to packages/xy-chart/test/series/ViolinPlotSeries.test.js index f673028b..f01f646c 100644 --- a/packages/xy-chart/test/ViolinPlotSeries.test.js +++ b/packages/xy-chart/test/series/ViolinPlotSeries.test.js @@ -2,7 +2,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import ViolinPlot from '@vx/stats/build/violinplot/ViolinPlot'; -import { XYChart, ViolinPlotSeries, computeStats } from '../src/'; +import { XYChart, ViolinPlotSeries, computeStats } from '../../src/'; describe('', () => { const mockData = [1, 2, 3, 4, 5, 5, 5, 5, 5, 6, 9, 5, 1]; diff --git a/packages/xy-chart/test/utils/chartUtils.test.js b/packages/xy-chart/test/utils/chartUtils.test.js new file mode 100644 index 00000000..cd3783f3 --- /dev/null +++ b/packages/xy-chart/test/utils/chartUtils.test.js @@ -0,0 +1,46 @@ +import React from 'react'; + +import { + callOrValue, + componentName, +} from '../../src/utils/chartUtils'; + +describe('callOrValue', () => { + test('should return non-functions', () => { + expect(callOrValue(123)).toEqual(123); + expect(callOrValue('123')).toEqual('123'); + expect(callOrValue(['hello'])).toEqual(['hello']); + }); + + test('should call a function', () => { + expect(callOrValue(() => 'abc')).toEqual('abc'); + }); + + test('should pass args to functions', () => { + expect(callOrValue((a, b, c) => `${a}${b}${c}`, 'x', 'y')).toEqual('xyundefined'); + }); +}); + +describe('componentName', () => { + class Component extends React.Component {} // eslint-disable-line + function SFC() {} + function SFCWithDisplayName() {} + SFCWithDisplayName.displayName = 'SFCWithDisplayName'; + + test('should work with React Components', () => { + expect(componentName()).toBe('Component'); + }); + + test('should work with SFCs', () => { + expect(componentName()).toBe('SFC'); + }); + + test('should work with DisplayName', () => { + expect(componentName()).toBe('SFCWithDisplayName'); + }); + + test('should return empty string for non-components', () => { + expect(componentName(null)).toBe(''); + expect(componentName(SFC)).toBe(''); + }); +}); diff --git a/packages/xy-chart/test/utils/collectDataFromChildSeries.test.js b/packages/xy-chart/test/utils/collectDataFromChildSeries.test.js new file mode 100644 index 00000000..a20b71b3 --- /dev/null +++ b/packages/xy-chart/test/utils/collectDataFromChildSeries.test.js @@ -0,0 +1,45 @@ +import React from 'react'; + +import { BarSeries, LineSeries } from '../../src'; +import collectDataFromChildSeries from '../../src/utils/collectDataFromChildSeries'; + +describe('collectDataFromChildSeries', () => { + const dummyProps = { xScale: () => {}, yScale: () => {}, barWidth: 0 }; + const barData = [{ x: 'bar', y: 123 }]; + const lineData = [{ x: 'line', y: 123 }]; + + const children = [ +
, + , + , + , + null, + ]; + + test('should ignore non-series children', () => { + expect( + collectDataFromChildSeries([,
]).allData, + ).toEqual([]); + }); + + const output = collectDataFromChildSeries(children); + + test('should concatenate all data', () => { + expect(output.allData).toEqual([...barData, ...lineData, ...barData]); + }); + + test('should collect data by Series type', () => { + expect(output.dataBySeriesType).toEqual({ + BarSeries: [...barData, ...barData], + LineSeries: [...lineData], + }); + }); + + test('should collect data by child index', () => { + expect(output.dataByIndex).toEqual({ + 1: barData, + 2: lineData, + 3: barData, + }); + }); +}); diff --git a/packages/xy-chart/test/utils/collectVoronoiData.test.js b/packages/xy-chart/test/utils/collectVoronoiData.test.js new file mode 100644 index 00000000..88a903a2 --- /dev/null +++ b/packages/xy-chart/test/utils/collectVoronoiData.test.js @@ -0,0 +1,66 @@ +import React from 'react'; + +import { BarSeries, LineSeries } from '../../src'; +import collectVoronoiData from '../../src/utils/collectVoronoiData'; + +describe('interpolatorLookup', () => { + const dummyProps = { xScale: () => {}, yScale: () => {}, barWidth: 0 }; + const barData = [{ x: 'bar', y: 123 }]; + const lineData = [{ x: 'line', y: 123 }]; + const nullData = [{ x: null, y: 1 }, { x: 'test', y: null }]; + + const getX = d => d.x; + const getY = d => d.y; + + const children = [ +
, + , + , + , + null, + ]; + + test('it should be defined', () => { + expect(collectVoronoiData).toBeDefined(); + }); + + test('it should return an array', () => { + expect(collectVoronoiData({ children, getX, getY })).toEqual(expect.any(Array)); + }); + + test('it should not include datum from <*Series /> with disableMouseEvents set to true', () => { + expect(collectVoronoiData({ + children: [ + , + , + , + ], + getX, + getY, + }).length).toBe(2); + }); + + test('it should not include datum from non-<*Series /> children', () => { + expect(collectVoronoiData({ + children: [ +
, + , + null, + ], + getX, + getY, + }).length).toBe(1); + }); + + test('it should not include datum with undefined x or y values', () => { + expect(collectVoronoiData({ + children: [ + , + , + , + ], + getX, + getY, + }).length).toBe(0); + }); +}); diff --git a/packages/xy-chart/test/computeCirclePack.test.js b/packages/xy-chart/test/utils/computeCirclePack.test.js similarity index 96% rename from packages/xy-chart/test/computeCirclePack.test.js rename to packages/xy-chart/test/utils/computeCirclePack.test.js index 39306dd7..7c142a1f 100644 --- a/packages/xy-chart/test/computeCirclePack.test.js +++ b/packages/xy-chart/test/utils/computeCirclePack.test.js @@ -1,5 +1,5 @@ import scaleLinear from '@vx/scale/build/scales/linear'; -import computeCirclePack from '../src/utils/computeCirclePack'; +import computeCirclePack from '../../src/utils/computeCirclePack'; describe('computeCirclePack', () => { const mockData = [ diff --git a/packages/xy-chart/test/utils/findClosestDatum.test.js b/packages/xy-chart/test/utils/findClosestDatum.test.js new file mode 100644 index 00000000..92344cd0 --- /dev/null +++ b/packages/xy-chart/test/utils/findClosestDatum.test.js @@ -0,0 +1,81 @@ +import scaleLinear from '@vx/scale/build/scales/linear'; +import scaleBand from '@vx/scale/build/scales/band'; +import findClosestDatum from '../../src/utils/findClosestDatum'; + +describe('findClosestDatum', () => { + beforeAll(() => { + // mock prototype attributes for vx's localPoint + Element.prototype.getBoundingClientRect = jest.fn(() => ({ + width: 10, + height: 10, + top: 0, + left: 0, + bottom: 0, + right: 0, + })); + + Element.prototype.clientLeft = 0; + Element.prototype.clientTop = 0; + }); + + const node = document.createElement('g'); + const event = { + // missing clientX + clientY: 0, + target: { + ownerSVGElement: { + firstChild: node, + }, + }, + }; + + test('it should be defined', () => { + expect(findClosestDatum).toBeDefined(); + }); + + test('it should return the closest datum', () => { + const props = { + data: [{ x: 0 }, { x: 5 }, { x: 10 }], + getX: d => d.x, + xScale: scaleLinear({ domain: [0, 10], range: [0, 10] }), + }; + + expect(findClosestDatum({ + ...props, + event: { ...event, clientX: 1 }, + })).toBe(props.data[0]); + + expect(findClosestDatum({ + ...props, + event: { ...event, clientX: 6 }, + })).toBe(props.data[1]); + + expect(findClosestDatum({ + ...props, + event: { ...event, clientX: 9 }, + })).toBe(props.data[2]); + }); + + test('it should work for ordinal scales', () => { + const props = { + data: [{ x: 'a' }, { x: 'b' }, { x: 'c' }], + getX: d => d.x, + xScale: scaleBand({ domain: ['a', 'b', 'c'], range: [0, 10] }), + }; + + expect(findClosestDatum({ + ...props, + event: { ...event, clientX: 0 }, + })).toBe(props.data[0]); + + expect(findClosestDatum({ + ...props, + event: { ...event, clientX: 5 }, + })).toBe(props.data[1]); + + expect(findClosestDatum({ + ...props, + event: { ...event, clientX: 10 }, + })).toBe(props.data[2]); + }); +}); diff --git a/packages/xy-chart/test/utils/findClosestDatums.test.js b/packages/xy-chart/test/utils/findClosestDatums.test.js new file mode 100644 index 00000000..b713b9d6 --- /dev/null +++ b/packages/xy-chart/test/utils/findClosestDatums.test.js @@ -0,0 +1,122 @@ +import React from 'react'; +import scaleLinear from '@vx/scale/build/scales/linear'; +import scaleBand from '@vx/scale/build/scales/band'; +import findClosestDatums from '../../src/utils/findClosestDatums'; + +import { LineSeries } from '../../src'; + +describe('findClosestDatum', () => { + beforeAll(() => { + // mock prototype attributes for vx's localPoint + Element.prototype.getBoundingClientRect = jest.fn(() => ({ + width: 10, + height: 10, + top: 0, + left: 0, + bottom: 0, + right: 0, + })); + + Element.prototype.clientLeft = 0; + Element.prototype.clientTop = 0; + }); + + const event = { + clientX: 0, + clientY: 0, + target: { + ownerSVGElement: { + firstChild: document.createElement('g'), + }, + }, + }; + + const getX = d => d.x; + const getY = d => d.y; + + test('it should be defined', () => { + expect(findClosestDatums).toBeDefined(); + }); + + test('it should return an object with closestDatum and series', () => { + const data = [ + [{ x: 'a', y: 5 }, { x: 'b', y: 0 }, { x: 'c', y: 8 }], + [{ x: 'a', y: 2 }, { x: 'b', y: 5 }, { x: 'c', y: 9 }], + ]; + + const args = { + event, + getX, + getY, + xScale: scaleBand({ domain: ['a', 'b', 'c'], range: [0, 10] }), + yScale: scaleLinear({ domain: [0, 10], range: [0, 10] }), + children: [ + , + , + ], + }; + + const result = findClosestDatums(args); + + expect(result).toEqual( + expect.objectContaining({ + closestDatum: expect.any(Object), + series: expect.any(Object), + }), + ); + }); + + test('it should return one datum per series and use `seriesKey`s for series keys when possible', () => { + const data = [ + [{ x: 'a', y: 5 }, { x: 'b', y: 0 }, { x: 'c', y: 8 }], + [{ x: 'a', y: 2 }, { x: 'b', y: 5 }, { x: 'c', y: 9 }], + [{ x: 'a', y: 0 }, { x: 'b', y: 0 }, { x: 'c', y: 0 }], + ]; + + const args = { + event, + getX, + getY, + xScale: scaleBand({ domain: ['a', 'b', 'c'], range: [0, 10] }), + yScale: scaleLinear({ domain: [0, 10], range: [0, 10] }), + children: [ + , + , + , + ], + }; + + const result = findClosestDatums(args); + + expect(result.series).toEqual( + expect.objectContaining({ + 'line-1': expect.any(Object), + 'line-2': expect.any(Object), + 2: expect.any(Object), + }), + ); + + expect(data[0].indexOf(result.series['line-1'])).toBeGreaterThan(-1); + expect(data[1].indexOf(result.series['line-2'])).toBeGreaterThan(-1); + expect(data[2].indexOf(result.series[2])).toBeGreaterThan(-1); + }); + + test('it should ignore non-series children and series with disableMouseEvents set to true', () => { + const args = { + event, + getX, + getY, + xScale: scaleBand({ domain: ['a', 'b', 'c'], range: [0, 10] }), + yScale: scaleLinear({ domain: [0, 10], range: [0, 10] }), + children: [ + , + null, +
, + ], + }; + + const result = findClosestDatums(args); + expect(result.closestDatum).toBeUndefined(); + expect(Object.keys(result.series).length).toBe(0); + }); +}); diff --git a/packages/xy-chart/test/utils/getChartDimensions.test.js b/packages/xy-chart/test/utils/getChartDimensions.test.js new file mode 100644 index 00000000..fdfb6a09 --- /dev/null +++ b/packages/xy-chart/test/utils/getChartDimensions.test.js @@ -0,0 +1,30 @@ +import getChartDimensions from '../../src/utils/getChartDimensions'; + +describe('getChartDimensions', () => { + const result = getChartDimensions({ width: 100, height: 100, margin: {} }); + + test('it should be defined', () => { + expect(getChartDimensions).toBeDefined(); + }); + + test('it should return an object with margin, innerHeight, and innerWidth keys', () => { + expect(result).toEqual( + expect.objectContaining({ + innerWidth: expect.any(Number), + innerHeight: expect.any(Number), + margin: expect.any(Object), + }), + ); + }); + + test('it should return a complete margin', () => { + expect(result.margin).toEqual( + expect.objectContaining({ + top: expect.any(Number), + right: expect.any(Number), + bottom: expect.any(Number), + left: expect.any(Number), + }), + ); + }); +}); diff --git a/packages/xy-chart/test/utils/getScaleForAccessor.test.js b/packages/xy-chart/test/utils/getScaleForAccessor.test.js new file mode 100644 index 00000000..f0999975 --- /dev/null +++ b/packages/xy-chart/test/utils/getScaleForAccessor.test.js @@ -0,0 +1,93 @@ +import getScaleForAccessor, { scaleTypeToScale } from '../../src/utils/getScaleForAccessor'; + +describe('scaleTypeToScale', () => { + test('it should be defined', () => { + expect(scaleTypeToScale).toBeDefined(); + }); + + test('it should have time, timeUtc, linear, band, and ordinal entries', () => { + expect(scaleTypeToScale).toEqual( + expect.objectContaining({ + time: expect.any(Function), + timeUtc: expect.any(Function), + linear: expect.any(Function), + band: expect.any(Function), + ordinal: expect.any(Function), + }), + ); + }); +}); + +describe('getScaleForAccessor', () => { + const allData = [ + { date: '2016-01-05', dirtyNum: undefined, num: 124, cat: 'a' }, + { date: '2017-01-05', dirtyNum: -15, num: 500, cat: 'b' }, + { date: '2018-01-05', dirtyNum: 7, num: 50, cat: 'c' }, + { date: '2019-01-05', dirtyNum: null, num: 501, cat: 'z' }, + ]; + + test('should compute date domains', () => { + expect(getScaleForAccessor({ + allData, + minAccessor: d => new Date(d.date), + maxAccessor: d => new Date(d.date), + type: 'time', + range: [0, 100], + }).domain()).toEqual([ + new Date(allData[0].date), + new Date(allData[allData.length - 1].date), + ]); + }); + + test('should compute date strings domains', () => { + expect(getScaleForAccessor({ + allData, + minAccessor: d => d.date, + maxAccessor: d => d.date, + type: 'band', + range: [0, 100], + }).domain()).toEqual(['2016-01-05', '2017-01-05', '2018-01-05', '2019-01-05']); + }); + + test('should compute categorical domains', () => { + expect(getScaleForAccessor({ + allData, + minAccessor: d => d.cat, + maxAccessor: d => d.cat, + type: 'band', + range: [0, 100], + }).domain()).toEqual(['a', 'b', 'c', 'z']); + }); + + test('should compute numeric domains including zero', () => { + expect(getScaleForAccessor({ + allData, + minAccessor: d => d.num, + maxAccessor: d => d.num, + type: 'linear', + range: [0, 100], + }).domain()).toEqual([0, 501]); + }); + + test('should compute numeric domains excluding zero', () => { + expect(getScaleForAccessor({ + allData, + minAccessor: d => d.num, + maxAccessor: d => d.num, + type: 'linear', + range: [0, 100], + includeZero: false, + }).domain()).toEqual([50, 501]); + }); + + test('should compute numeric domains with missing values', () => { + expect(getScaleForAccessor({ + allData, + minAccessor: d => d.dirtyNum, + maxAccessor: d => d.dirtyNum, + type: 'linear', + range: [0, 100], + includeZero: false, + }).domain()).toEqual([-15, 7]); + }); +}); diff --git a/packages/xy-chart/test/utils/interpolatorLookup.test.js b/packages/xy-chart/test/utils/interpolatorLookup.test.js new file mode 100644 index 00000000..8d2690b6 --- /dev/null +++ b/packages/xy-chart/test/utils/interpolatorLookup.test.js @@ -0,0 +1,19 @@ +import interpolatorLookup from '../../src/utils/interpolatorLookup'; + +describe('interpolatorLookup', () => { + test('it should be defined', () => { + expect(interpolatorLookup).toBeDefined(); + }); + + test('it should have linear, cardinal, monotoneX, monotoneY, and natural interpolators', () => { + expect(interpolatorLookup).toEqual( + expect.objectContaining({ + linear: expect.any(Function), + cardinal: expect.any(Function), + monotoneX: expect.any(Function), + monotoneY: expect.any(Function), + natural: expect.any(Function), + }), + ); + }); +}); diff --git a/packages/xy-chart/test/utils/shallowCompareObjectEntries.test.js b/packages/xy-chart/test/utils/shallowCompareObjectEntries.test.js new file mode 100644 index 00000000..31531846 --- /dev/null +++ b/packages/xy-chart/test/utils/shallowCompareObjectEntries.test.js @@ -0,0 +1,25 @@ +import shallowCompareObjectEntries from '../../src/utils/shallowCompareObjectEntries'; + +describe('shallowCompareObjectEntries', () => { + test('it should be defined', () => { + expect(shallowCompareObjectEntries).toBeDefined(); + }); + + test('it should return false if objects have different key counts', () => { + expect(shallowCompareObjectEntries({ a: 'a' }, {})).toBe(false); + }); + + test('it should return false if objects have different keys', () => { + expect(shallowCompareObjectEntries({ a: 'a' }, { b: 'a' })).toBe(false); + expect(shallowCompareObjectEntries({ b: 'a' }, { a: 'a' })).toBe(false); + }); + + test('it should return false if objects have different values', () => { + expect(shallowCompareObjectEntries({ a: 'a' }, { a: 'b' })).toBe(false); + expect(shallowCompareObjectEntries({ b: 'a' }, { b: 'b' })).toBe(false); + }); + + test('it should return true if objects have the same entries', () => { + expect(shallowCompareObjectEntries({ a: 'a', b: 1 }, { a: 'a', b: 1 })).toBe(true); + }); +});