From 8b02ffeef1f5a16509c2b66792d64b1b83433d23 Mon Sep 17 00:00:00 2001 From: Chris Williams Date: Tue, 5 Dec 2017 16:47:39 -0800 Subject: [PATCH] [xy-chart] clean up tooltip behavior customization and hooks, clean up utils --- .../01-xy-chart/LineSeriesExample.jsx | 135 ++++++---- packages/demo/examples/01-xy-chart/index.jsx | 86 +++---- packages/shared/src/enhancer/WithTooltip.js | 26 +- packages/xy-chart/src/chart/XYChart.jsx | 237 +++++++----------- packages/xy-chart/src/utils/chartUtils.js | 55 ---- .../src/utils/collectDataFromChildSeries.js | 23 ++ .../src/utils/collectScalesFromProps.js | 58 +++++ .../xy-chart/src/utils/collectVoronoiData.js | 14 ++ .../xy-chart/src/utils/findClosestDatum.js | 1 + .../xy-chart/src/utils/findClosestDatums.js | 36 +++ .../xy-chart/src/utils/getChartDimensions.js | 11 + .../xy-chart/src/utils/getScaleForAccessor.js | 36 +++ .../src/utils/shallowCompareObjectEntries.js | 6 + 13 files changed, 412 insertions(+), 312 deletions(-) create mode 100644 packages/xy-chart/src/utils/collectDataFromChildSeries.js create mode 100644 packages/xy-chart/src/utils/collectScalesFromProps.js create mode 100644 packages/xy-chart/src/utils/collectVoronoiData.js create mode 100644 packages/xy-chart/src/utils/findClosestDatums.js create mode 100644 packages/xy-chart/src/utils/getChartDimensions.js create mode 100644 packages/xy-chart/src/utils/getScaleForAccessor.js create mode 100644 packages/xy-chart/src/utils/shallowCompareObjectEntries.js diff --git a/packages/demo/examples/01-xy-chart/LineSeriesExample.jsx b/packages/demo/examples/01-xy-chart/LineSeriesExample.jsx index 510b8faa..5ded0925 100644 --- a/packages/demo/examples/01-xy-chart/LineSeriesExample.jsx +++ b/packages/demo/examples/01-xy-chart/LineSeriesExample.jsx @@ -1,14 +1,13 @@ import React from 'react'; -import PropTypes from 'prop-types'; import { allColors } from '@data-ui/theme/build/color'; import { Button } from '@data-ui/forms'; import { CrossHair, - XAxis, - YAxis, LineSeries, WithTooltip, + XAxis, + YAxis, } from '@data-ui/xy-chart'; import ResponsiveXYChart, { formatYear } from './ResponsiveXYChart'; @@ -50,7 +49,8 @@ const seriesProps = [ }, ]; -const TOOLTIP_TIMEOUT = 350; +const MARGIN = { left: 8, top: 16 }; +const TOOLTIP_TIMEOUT = 250; const CONTAINER_TRIGGER = 'CONTAINER_TRIGGER'; const VORONOI_TRIGGER = 'VORONOI_TRIGGER'; @@ -61,12 +61,14 @@ class LineSeriesExample extends React.PureComponent { index: 0, programmaticTrigger: false, trigger: CONTAINER_TRIGGER, + stickyTooltip: false, }; - this.ref = this.ref.bind(this); + 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() { @@ -77,24 +79,33 @@ class LineSeriesExample extends React.PureComponent { this.setState(() => ({ trigger: nextTrigger })); } - ref(ref) { - this.chart = ref; + handleClick(args) { + if (this.triggers) { + this.setState(({ stickyTooltip }) => ({ + stickyTooltip: !stickyTooltip, + }), () => { + this.triggers.mousemove(args); + }); + } + } + + eventTriggerRefs(triggers) { + this.triggers = triggers; this.triggerTooltip(); } triggerTooltip() { - if (this.chart && this.state.index < seriesProps[0].data.length) { + if (this.triggers && this.state.index < seriesProps[0].data.length) { if (this.timeout) clearTimeout(this.timeout); - this.setState(({ index, trigger }) => { - this.chart.handleMouseMove({ + this.triggers.mousemove({ datum: seriesProps[2].data[index], series: trigger === VORONOI_TRIGGER ? null : { [seriesProps[0].label]: seriesProps[0].data[index], [seriesProps[1].label]: seriesProps[1].data[index], [seriesProps[2].label]: seriesProps[2].data[index], }, - overrideCoords: trigger === VORONOI_TRIGGER ? null : { + coords: trigger === VORONOI_TRIGGER ? null : { y: 50, }, }); @@ -103,8 +114,8 @@ class LineSeriesExample extends React.PureComponent { return { index: index + 1, programmaticTrigger: true }; }); - } else if (this.chart) { - this.chart.handleMouseLeave(); + } else if (this.triggers) { + this.triggers.mouseleave(); this.timeout = setTimeout(() => { this.setState(() => ({ index: 0, @@ -117,16 +128,16 @@ class LineSeriesExample extends React.PureComponent { restartProgrammaticTooltip() { if (this.timeout) clearTimeout(this.timeout); - if (this.chart) { - this.setState(() => ({ index: 0 }), this.triggerTooltip); + if (this.triggers) { + this.setState(() => ({ stickyTooltip: false, index: 0 }), this.triggerTooltip); } } renderControls(disableMouseEvents) { - const { trigger } = this.state; + const { trigger, stickyTooltip } = this.state; const useVoronoiTrigger = trigger === VORONOI_TRIGGER; - return ( -
+ return ([ +
-
- ); +
, +
+ Click chart for a  + sticky tooltip + +
, + ]); } renderTooltip({ datum, series }) { @@ -161,10 +182,10 @@ class LineSeriesExample extends React.PureComponent { return (
- {formatYear(datum.x)} + {formatYear(datum.x)} {(!series || Object.keys(series).length === 0) &&
- {datum.y.toFixed(2)} + ${datum.y.toFixed(2)}
}
{trigger === CONTAINER_TRIGGER &&
} @@ -181,7 +202,7 @@ class LineSeriesExample extends React.PureComponent { > {`${label} `} - {series[label].y.toFixed(2)} + ${series[label].y.toFixed(2)}
))} @@ -189,45 +210,49 @@ class LineSeriesExample extends React.PureComponent { } render() { - const { trigger } = this.state; + const { trigger, stickyTooltip } = this.state; const useVoronoiTrigger = trigger === VORONOI_TRIGGER; return ( {disableMouseEvents => (
{this.renderControls(disableMouseEvents)} - - - - - {seriesProps.map(props => ( - + {({ onMouseLeave, onMouseMove, tooltipData }) => ( + + + + {seriesProps.map(props => ( + + ))} + - ))} - - + + )}
)} diff --git a/packages/demo/examples/01-xy-chart/index.jsx b/packages/demo/examples/01-xy-chart/index.jsx index 6a7778a5..8113c8c2 100644 --- a/packages/demo/examples/01-xy-chart/index.jsx +++ b/packages/demo/examples/01-xy-chart/index.jsx @@ -31,7 +31,7 @@ import CirclePackWithCallback from './CirclePackWithCallback'; import LineSeriesExample from './LineSeriesExample'; import LinkedXYCharts from './LinkedXYCharts'; import RectPointComponent from './RectPointComponent'; -import ResponsiveXYChart, { parseDate, formatYear, dateFormatter, renderTooltip } from './ResponsiveXYChart'; +import ResponsiveXYChart, { parseDate, formatYear, dateFormatter } from './ResponsiveXYChart'; import StackedAreaExample from './StackedAreaExample'; import ScatterWithHistogram from './ScatterWithHistograms'; import { @@ -118,51 +118,47 @@ export default { {snapToDataX => ( {snapToDataY => ( - - - - - - - - - - + + + + + + + )} )} diff --git a/packages/shared/src/enhancer/WithTooltip.js b/packages/shared/src/enhancer/WithTooltip.js index 260cec1d..6b461e75 100644 --- a/packages/shared/src/enhancer/WithTooltip.js +++ b/packages/shared/src/enhancer/WithTooltip.js @@ -19,8 +19,6 @@ export const propTypes = { className: PropTypes.string, HoverStyles: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), renderTooltip: PropTypes.func, - snapToDataX: PropTypes.bool, - snapToDataY: PropTypes.bool, styles: PropTypes.object, TooltipComponent: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), tooltipProps: PropTypes.object, @@ -43,7 +41,9 @@ const defaultProps = { styles: { display: 'inline-block', position: 'relative' }, TooltipComponent: TooltipWithBounds, tooltipProps: { - opacity: 0.9, + style: { + opacity: 0.9, + }, }, tooltipTimeout: 200, }; @@ -62,29 +62,21 @@ class WithTooltip extends React.PureComponent { } } - handleMouseMove({ event, datum, dataCoords, overrideCoords, ...rest }) { + handleMouseMove({ event, datum, coords, ...rest }) { if (this.tooltipTimeout) { clearTimeout(this.tooltipTimeout); } - let coords = { x: 0, y: 0 }; + let tooltipCoords = { x: 0, y: 0 }; if (event && event.target && event.target.ownerSVGElement) { - coords = localPoint(event.target.ownerSVGElement, event); + tooltipCoords = localPoint(event.target.ownerSVGElement, event); } - if (this.props.snapToDataX && dataCoords && typeof dataCoords.x === 'number') { - coords.x = dataCoords.x; - } - - if (this.props.snapToDataY && dataCoords && typeof dataCoords.y === 'number') { - coords.y = dataCoords.y; - } - - coords = { ...coords, ...overrideCoords }; + tooltipCoords = { ...tooltipCoords, ...coords }; this.props.showTooltip({ - tooltipLeft: coords.x, - tooltipTop: coords.y, + tooltipLeft: tooltipCoords.x, + tooltipTop: tooltipCoords.y, tooltipData: { event, datum, diff --git a/packages/xy-chart/src/chart/XYChart.jsx b/packages/xy-chart/src/chart/XYChart.jsx index 9cebaa1a..1afa66f6 100644 --- a/packages/xy-chart/src/chart/XYChart.jsx +++ b/packages/xy-chart/src/chart/XYChart.jsx @@ -3,36 +3,40 @@ import PropTypes from 'prop-types'; import Grid from '@vx/grid/build/grids/Grid'; import Group from '@vx/group/build/Group'; -import localPoint from '@vx/event/build/localPoint'; import WithTooltip, { withTooltipPropTypes } from '@data-ui/shared/build/enhancer/WithTooltip'; -import findClosestDatum from '../utils/findClosestDatum'; +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, + disableMouseEvents: PropTypes.bool, + eventTrigger: PropTypes.oneOf([CONTAINER_TRIGGER, SERIES_TRIGGER, VORONOI_TRIGGER]), + eventTriggerRefs: PropTypes.func, height: PropTypes.number.isRequired, innerRef: PropTypes.func, margin: PropTypes.shape({ @@ -41,22 +45,23 @@ export const propTypes = { bottom: PropTypes.number, left: PropTypes.number, }), - // @TODO tooltipProps - // tooltipProps: tooltipPropsShape, renderTooltip: PropTypes.func, - xScale: scaleShape.isRequired, - yScale: scaleShape.isRequired, showXGrid: PropTypes.bool, showYGrid: PropTypes.bool, - theme: themeShape, - // @TODO - // eventTrigger: PropTypes.oneOf(['voronoi', 'series', 'container']), - 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, @@ -65,90 +70,24 @@ const defaultProps = { left: 64, }, renderTooltip: null, + showVoronoi: false, showXGrid: false, showYGrid: false, + snapTooltipToDataX: false, + snapTooltipToDataY: false, + styles: null, theme: {}, - useVoronoi: false, - showVoronoi: false, }; // accessors const getX = d => d && d.x; const getY = d => d && d.y; -const xString = d => getX(d).toString(); 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, @@ -168,22 +107,36 @@ class XYChart extends React.PureComponent { // 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.handleContainerMouseMove = this.handleContainerMouseMove.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 - 'height', + let shouldComputeScales = false; + if (this.props.width !== nextProps.width || this.props.height !== nextProps.height) { + 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) { @@ -195,51 +148,44 @@ class XYChart extends React.PureComponent { }; } - handleContainerMouseMove(event) { - const series = {}; - const { xScale, yScale } = this.state; - - // @TODO abstract to helper; error - const gElement = event.target.ownerSVGElement.firstChild; - const { y: mouseY } = localPoint(gElement, event); - let closestDatum; - let minDelta = Infinity; + 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; + } - // collect data from all series that have an x value near this point - React.Children.forEach(this.props.children, (Child, childIndex) => { - const { disableMouseEvents, data, label } = Child.props; - if (isSeries(componentName(Child)) && !disableMouseEvents) { - const datum = findClosestDatum({ - data, - getX: xScale.invert ? getX : d => getX(d) + ((xScale.barWidth || 0) / 2), - xScale, - event, - }); - const key = label || childIndex; - if (datum) { - series[key] = datum; - const deltaY = Math.abs(yScale(getY(datum)) - mouseY); - closestDatum = !closestDatum || deltaY < minDelta ? datum : closestDatum; - minDelta = Math.min(deltaY, minDelta); - } - } + handleContainerEvent(event) { + const { xScale, yScale } = this.state; + const { children } = this.props; + const { closestDatum, series } = findClosestDatums({ + children, + event, + getX, + getY, + xScale, + yScale, }); - if (Object.keys(series).length > 0) { - this.handleMouseMove({ event, datum: closestDatum, series }); + 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) { - const { xScale, yScale, margin } = this.state; - const dataCoords = { - x: xScale(getX(args.datum)) + margin.left, - y: yScale(getY(args.datum)) + margin.top, - }; this.props.onMouseMove({ - dataCoords, ...args, + coords: { + ...this.getDatumCoords(args.datum), + ...args.coords, + }, }); } } @@ -248,6 +194,18 @@ class XYChart extends React.PureComponent { if (this.props.onMouseLeave) this.props.onMouseLeave(); } + handleClick(args) { + if (this.props.onClick) { + this.props.onClick({ + ...args, + coords: { + ...this.getDatumCoords(args.datum), + ...args.coords, + }, + }); + } + } + render() { if (this.props.renderTooltip) { return ( @@ -259,6 +217,7 @@ class XYChart extends React.PureComponent { const { ariaLabel, + eventTrigger, children, showXGrid, showYGrid, @@ -266,10 +225,8 @@ class XYChart extends React.PureComponent { height, width, innerRef, - onClick, tooltipData, showVoronoi, - useVoronoi, } = this.props; const { @@ -327,7 +284,7 @@ class XYChart extends React.PureComponent { yScale, barWidth, onClick: Child.props.onClick - || (Child.props.disableMouseEvents ? undefined : this.handleOnClick), + || (Child.props.disableMouseEvents ? undefined : this.handleClick), onMouseLeave: Child.props.onMouseLeave || (Child.props.disableMouseEvents ? undefined : this.handleMouseLeave), onMouseMove: Child.props.onMouseMove @@ -342,20 +299,20 @@ class XYChart extends React.PureComponent { return Child; })} - {useVoronoi && + {eventTrigger === VORONOI_TRIGGER && } - {!useVoronoi && + {eventTrigger === CONTAINER_TRIGGER && } 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 0b1d4c27..7b8e0169 100644 --- a/packages/xy-chart/src/utils/findClosestDatum.js +++ b/packages/xy-chart/src/utils/findClosestDatum.js @@ -4,6 +4,7 @@ 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: mouseX } = localPoint(gElement, event); diff --git a/packages/xy-chart/src/utils/findClosestDatums.js b/packages/xy-chart/src/utils/findClosestDatums.js new file mode 100644 index 00000000..edcd1b43 --- /dev/null +++ b/packages/xy-chart/src/utils/findClosestDatums.js @@ -0,0 +1,36 @@ +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 }) { + const series = {}; + + const gElement = event.target.ownerSVGElement.firstChild; + const { 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) => { + const { disableMouseEvents, data, label } = Child.props; + if (isSeries(componentName(Child)) && !disableMouseEvents) { + const datum = findClosestDatum({ + data, + getX: xScale.invert ? getX : d => getX(d) + ((xScale.barWidth || 0) / 2), + xScale, + event, + }); + const key = label || childIndex; + if (datum) { + series[key] = datum; + const deltaY = Math.abs(yScale(getY(datum)) - mouseY); + closestDatum = !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..ab20a57b --- /dev/null +++ b/packages/xy-chart/src/utils/getChartDimensions.js @@ -0,0 +1,11 @@ +import { defaultProps } from '../chart/XYChart'; + +export default function getChartDimensions(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, + }; +} 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..a8b94fa8 --- /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.some(k => a[k] !== b[k]); +}