From 6b64209fd3bda238299b3cf9e480cef0b20ddefa Mon Sep 17 00:00:00 2001 From: Chris Williams Date: Wed, 1 Nov 2017 13:59:54 -0700 Subject: [PATCH] [sparkline] add tooltip support, examples, and purecomponent optimizations --- packages/core/package.json | 4 + .../src/components}/CrossHair.jsx | 23 +- packages/core/src/enhancer/WithTooltip.js | 6 +- packages/core/src/index.js | 1 + .../test/components}/CrossHair.test.js | 4 +- .../03-sparkline/BarSeriesExamples.jsx | 57 ++- .../03-sparkline/KitchenSinkExamples.jsx | 477 +++++++++++------- .../03-sparkline/LineSeriesExamples.jsx | 59 ++- .../03-sparkline/PointsAndBandsExamples.jsx | 68 ++- packages/sparkline/package.json | 2 + .../sparkline/src/annotation/BandLine.jsx | 97 ++-- .../annotation/HorizontalReferenceLine.jsx | 93 ++-- .../src/annotation/VerticalReferenceLine.jsx | 113 +++-- packages/sparkline/src/chart/Sparkline.jsx | 62 ++- packages/sparkline/src/index.js | 1 + packages/sparkline/src/series/BarSeries.jsx | 136 ++--- packages/sparkline/src/series/LineSeries.jsx | 120 +++-- packages/sparkline/src/series/PointSeries.jsx | 207 ++++---- .../sparkline/src/utils/findClosestDatum.js | 19 + packages/xy-chart/src/chart/XYChart.jsx | 4 +- packages/xy-chart/src/index.js | 2 +- packages/xy-chart/src/series/BarSeries.jsx | 2 +- .../xy-chart/src/utils/findClosestDatum.js | 2 +- 23 files changed, 952 insertions(+), 607 deletions(-) rename packages/{xy-chart/src/chart => core/src/components}/CrossHair.jsx (86%) rename packages/{xy-chart/test => core/test/components}/CrossHair.test.js (96%) create mode 100644 packages/sparkline/src/utils/findClosestDatum.js diff --git a/packages/core/package.json b/packages/core/package.json index d0af9621..188bc1f0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -47,8 +47,12 @@ "react": "^15.0.0-0 || ^16.0.0-0" }, "dependencies": { + "@data-ui/theme": "0.0.9", "@vx/event": "0.0.143", + "@vx/group": "0.0.143", + "@vx/shape": "0.0.145", "@vx/tooltip": "0.0.143", + "d3-array": "^1.2.1", "prop-types": "^15.5.10" }, "jest": { diff --git a/packages/xy-chart/src/chart/CrossHair.jsx b/packages/core/src/components/CrossHair.jsx similarity index 86% rename from packages/xy-chart/src/chart/CrossHair.jsx rename to packages/core/src/components/CrossHair.jsx index ca44e48b..1a0abf93 100644 --- a/packages/xy-chart/src/chart/CrossHair.jsx +++ b/packages/core/src/components/CrossHair.jsx @@ -2,9 +2,9 @@ import React from 'react'; import PropTypes from 'prop-types'; import { extent } from 'd3-array'; -import { color } from '@data-ui/theme'; -import { Group } from '@vx/group'; -import { Line } from '@vx/shape'; +import color from '@data-ui/theme/build/color'; +import Group from '@vx/group/build/Group'; +import Line from '@vx/shape/build/shapes/Line'; const propTypes = { fullHeight: PropTypes.bool, @@ -24,8 +24,8 @@ const propTypes = { // likely injected by parent left: PropTypes.number, top: PropTypes.number, - xRange: PropTypes.arrayOf(PropTypes.number), - yRange: PropTypes.arrayOf(PropTypes.number), + xScale: PropTypes.func, + yScale: PropTypes.func, }; @@ -49,8 +49,8 @@ const defaultProps = { stroke: color.grays[7], strokeDasharray: '3,3', strokeWidth: 1, - xRange: [0, 0], - yRange: [0, 0], + xScale: null, + yScale: null, }; function CrossHair({ @@ -68,12 +68,13 @@ function CrossHair({ stroke, strokeDasharray, strokeWidth, - xRange, - yRange, + xScale, + yScale, lineStyles, }) { - const [xMin, xMax] = extent(xRange); - const [yMin, yMax] = extent(yRange); + if (!xScale || !yScale) return null; + const [xMin, xMax] = extent(xScale.range()); + const [yMin, yMax] = extent(yScale.range()); return ( {showHorizontalLine && top !== null && diff --git a/packages/core/src/enhancer/WithTooltip.js b/packages/core/src/enhancer/WithTooltip.js index 505aee44..aa22ae1d 100644 --- a/packages/core/src/enhancer/WithTooltip.js +++ b/packages/core/src/enhancer/WithTooltip.js @@ -5,6 +5,8 @@ import localPoint from '@vx/event/build/localPoint'; import withTooltip from '@vx/tooltip/build/enhancers/withTooltip'; import TooltipWithBounds, { withTooltipPropTypes as vxTooltipPropTypes } from '@vx/tooltip/build/tooltips/TooltipWithBounds'; +export { default as Tooltip } from '@vx/tooltip/build/tooltips/Tooltip'; + export const withTooltipPropTypes = { onMouseMove: PropTypes.func, // expects to be called like func({ event, datum }) onMouseLeave: PropTypes.func, // expects to be called like func({ event, datum }) @@ -47,7 +49,9 @@ class WithTooltip extends React.PureComponent { } handleMouseMove({ event, datum, ...rest }) { - if (this.tooltipTimeout) clearTimeout(this.tooltipTimeout); + if (this.tooltipTimeout) { + clearTimeout(this.tooltipTimeout); + } let coords = { x: 0, y: 0 }; if (event && event.target && event.target.ownerSVGElement) { diff --git a/packages/core/src/index.js b/packages/core/src/index.js index 09745575..14f83fb6 100644 --- a/packages/core/src/index.js +++ b/packages/core/src/index.js @@ -1 +1,2 @@ export { default as WithTooltip, withTooltipPropTypes } from './enhancer/WithTooltip'; +export { default as Tooltip } from '@vx/tooltip/build/tooltips/Tooltip'; diff --git a/packages/xy-chart/test/CrossHair.test.js b/packages/core/test/components/CrossHair.test.js similarity index 96% rename from packages/xy-chart/test/CrossHair.test.js rename to packages/core/test/components/CrossHair.test.js index 0c894cfb..ad559973 100644 --- a/packages/xy-chart/test/CrossHair.test.js +++ b/packages/core/test/components/CrossHair.test.js @@ -1,9 +1,9 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { Line } from '@vx/shape'; +import Line from '@vx/shape/build/shapes/Line'; -import CrossHair from '../src/chart/CrossHair'; +import CrossHair from '../src/components/CrossHair'; describe('', () => { const props = { diff --git a/packages/demo/examples/03-sparkline/BarSeriesExamples.jsx b/packages/demo/examples/03-sparkline/BarSeriesExamples.jsx index 5f8c7d08..dc1a9cad 100644 --- a/packages/demo/examples/03-sparkline/BarSeriesExamples.jsx +++ b/packages/demo/examples/03-sparkline/BarSeriesExamples.jsx @@ -8,6 +8,7 @@ import { HorizontalReferenceLine, VerticalReferenceLine, + WithTooltip, PatternLines, LinearGradient, @@ -48,27 +49,41 @@ export default [ - - i + (5 * Math.random()) + (i === 34 ? 5 : 0))} - > - 'max'} - /> - allColors.grape[i === 34 ? 8 : 2]} - fillOpacity={0.7} - renderLabel={(d, i) => (i === 34 ? '🚀' : null)} - /> - - + {(data => ( + + + {({ onMouseMove, onMouseLeave, tooltipData }) => ( + + { + const indexToHighlight = tooltipData ? tooltipData.index : 34; + return allColors.grape[i === indexToHighlight ? 8 : 2]; + }} + fillOpacity={0.7} + renderLabel={(d, i) => { + const indexToHighlight = tooltipData ? tooltipData.index : 34; + return i === indexToHighlight ? '🚀' : null; + }} + /> + (tooltipData ? tooltipData.datum.y.toFixed(2) : 'max')} + /> + + )} + + + ))(range(35).map((_, i) => i + (5 * Math.random()) + (i === 34 ? 5 : 0)))} d.y, }; -const randomData = n => range(n).map(() => (Math.random() * (Math.random() > 0.2 ? 1 : 2))); +const randomData = n => range(n).map((_, i) => ({ + y: Math.random() * (Math.random() > 0.2 ? 1 : 2), + x: `Day ${i + 1}`, +})); + const renderLabel = d => d.toFixed(2); +const renderTooltip = ({ datum }) => ( // eslint-disable-line react/prop-types +
+ {datum.x &&
{datum.x}
} +
{datum.y ? datum.y.toFixed(2) : '--'}
+
+); + export default [ { description: 'Kitchen sink', @@ -46,177 +59,297 @@ export default [ ], example: () => ( - - - allColors.grape[i === 5 ? 8 : 2]} - fillOpacity={0.8} - renderLabel={(d, i) => (i === 5 ? '🤔' : null)} - /> - - - - - - - - - - - - - - - - - - - - - - - - - (i === 0 ? 'left' : 'right')} - /> - - - - - - - - - - - - - - - - - - - - - - - - + {(data => ( + + + {({ onMouseMove, onMouseLeave, tooltipData }) => ( + + { + const indexToHighlight = tooltipData ? tooltipData.index : 5; + return allColors.grape[i === indexToHighlight ? 8 : 2]; + }} + fillOpacity={0.8} + renderLabel={(d, i) => { + const indexToHighlight = tooltipData ? tooltipData.index : 5; + return i === indexToHighlight ? '🤔' : null; + }} + /> + + )} + + + ))(randomData(35))} + + {(data => ( + + + {({ onMouseMove, onMouseLeave, tooltipData }) => ( + + + + + + {tooltipData && [ + , + , + ]} + + )} + + + ))(randomData(25))} + + {(data => ( + + + {({ onMouseMove, onMouseLeave, tooltipData }) => ( + + + + + {tooltipData && [ + , + , + ]} + + )} + + + ))(randomData(25))} + + {(data => ( + + + {({ onMouseMove, onMouseLeave, tooltipData }) => ( + + + + (i === 0 ? 'left' : 'right')} + /> + {tooltipData && [ + , + , + ]} + + )} + + + ))(randomData(25))} + + {(data => ( + + + {({ onMouseMove, onMouseLeave, tooltipData }) => ( + + + + + + + + + {tooltipData && [ + , + , + ]} + + )} + + + ))(randomData(45))} + + {(data => ( + + + {({ onMouseMove, onMouseLeave, tooltipData }) => ( + + + + + + + {tooltipData && [ + , + , + ]} + + )} + + + ))(randomData(35))} ), }, diff --git a/packages/demo/examples/03-sparkline/LineSeriesExamples.jsx b/packages/demo/examples/03-sparkline/LineSeriesExamples.jsx index ab07b375..09f39d75 100644 --- a/packages/demo/examples/03-sparkline/LineSeriesExamples.jsx +++ b/packages/demo/examples/03-sparkline/LineSeriesExamples.jsx @@ -11,6 +11,7 @@ import { BandLine, HorizontalReferenceLine, VerticalReferenceLine, + WithTooltip, PatternLines, LinearGradient, @@ -60,24 +61,46 @@ export default [
- - (Math.random() * (Math.random() > 0.2 ? 1 : 2)))} - > - - - - + {(data => ( + + datum.y.toFixed(2)} > + {({ onMouseMove, onMouseLeave, tooltipData }) => ( + + + + + {tooltipData && [ + , + , + ]} + + )} + + + ))(range(40).map(() => (Math.random() * (Math.random() > 0.2 ? 1 : 2))))} - - - {range(30).map((_, i) => ( - - ))} - - - - + {(data => + ( + datum.y.toFixed(2)}> + {({ onMouseMove, onMouseLeave, tooltipData }) => ( + + {range(30).map((_, i) => ( + + ))} + + + + {tooltipData && [ + , + , + , + ]} + + )} + + ))(randomData(30))} parseFloat(getY(a)) - parseFloat(getY(b))); - const lowerQuartile = yScale(quantile(sortedData, 0.25, getY)); - const upperQuartile = yScale(quantile(sortedData, 0.75, getY)); + let x = 0; + let y = 0; + let width = 0; + let height = 0; + if (band === 'innerquartiles') { + const sortedData = [...data].sort((a, b) => parseFloat(getY(a)) - parseFloat(getY(b))); + const lowerQuartile = yScale(quantile(sortedData, 0.25, getY)); + const upperQuartile = yScale(quantile(sortedData, 0.75, getY)); - y = Math.min(lowerQuartile, upperQuartile); - height = Math.abs(upperQuartile - lowerQuartile); - x = x0; - width = x1 - x0; - } else { - // input points are assumed to be values so we must scale them - const yFrom = typeof band.from.y === 'undefined' ? y0 : yScale(band.from.y); - const yTo = typeof band.to.y === 'undefined' ? y1 : yScale(band.to.y); + y = Math.min(lowerQuartile, upperQuartile); + height = Math.abs(upperQuartile - lowerQuartile); + x = x0; + width = x1 - x0; + } else { + // input points are assumed to be values so we must scale them + const yFrom = typeof band.from.y === 'undefined' ? y0 : yScale(band.from.y); + const yTo = typeof band.to.y === 'undefined' ? y1 : yScale(band.to.y); - y = Math.min(yFrom, yTo); - height = Math.abs(yFrom - yTo); - x = typeof band.from.x === 'undefined' ? x0 : xScale(band.from.x); - width = (typeof band.to.x === 'undefined' ? x1 : xScale(band.to.x)) - x; - } + y = Math.min(yFrom, yTo); + height = Math.abs(yFrom - yTo); + x = typeof band.from.x === 'undefined' ? x0 : xScale(band.from.x); + width = (typeof band.to.x === 'undefined' ? x1 : xScale(band.to.x)) - x; + } - return ( - - ); + return ( + + ); + } } BandLine.propTypes = propTypes; diff --git a/packages/sparkline/src/annotation/HorizontalReferenceLine.jsx b/packages/sparkline/src/annotation/HorizontalReferenceLine.jsx index 201ca7e5..b02d84b4 100644 --- a/packages/sparkline/src/annotation/HorizontalReferenceLine.jsx +++ b/packages/sparkline/src/annotation/HorizontalReferenceLine.jsx @@ -53,54 +53,57 @@ export const defaultProps = { yScale: null, }; -function HorizontalReferenceLine({ - data, - getY, - LabelComponent, - labelOffset, - labelPosition, - reference, - renderLabel, - stroke, - strokeDasharray, - strokeLinecap, - strokeWidth, - xScale, - yScale, -}) { - if (!xScale || !yScale || !getY || !data.length) return null; - const [x0, x1] = xScale.range(); +class HorizontalReferenceLine extends React.PureComponent { + render() { + const { + data, + getY, + LabelComponent, + labelOffset, + labelPosition, + reference, + renderLabel, + stroke, + strokeDasharray, + strokeLinecap, + strokeWidth, + xScale, + yScale, + } = this.props; + if (!xScale || !yScale || !getY || !data.length) return null; + const [x0, x1] = xScale.range(); - let refNumber = reference; - if (reference === 'mean') refNumber = mean(data, getY); - if (reference === 'median') refNumber = median(data, getY); - if (reference === 'max') refNumber = max(data, getY); - if (reference === 'min') refNumber = min(data, getY); + let refNumber = reference; + if (reference === 'mean') refNumber = mean(data, getY); + if (reference === 'median') refNumber = median(data, getY); + if (reference === 'max') refNumber = max(data, getY); + if (reference === 'min') refNumber = min(data, getY); - const scaledRef = yScale(refNumber); - const fromPoint = new Point({ x: x0, y: scaledRef }); - const toPoint = new Point({ x: x1, y: scaledRef }); - const label = renderLabel && renderLabel(refNumber); + const scaledRef = yScale(refNumber); + const fromPoint = new Point({ x: x0, y: scaledRef }); + const toPoint = new Point({ x: x1, y: scaledRef }); + const label = renderLabel && renderLabel(refNumber); - return ( - - - {label && React.cloneElement(LabelComponent, { - x: toPoint.x, - y: toPoint.y, - ...positionLabel(labelPosition, labelOffset), - label, - })} - - ); + return ( + + + {label && React.cloneElement(LabelComponent, { + x: toPoint.x, + y: toPoint.y, + ...positionLabel(labelPosition, labelOffset), + label, + })} + + ); + } } HorizontalReferenceLine.propTypes = propTypes; diff --git a/packages/sparkline/src/annotation/VerticalReferenceLine.jsx b/packages/sparkline/src/annotation/VerticalReferenceLine.jsx index 066e7a26..3e067be1 100644 --- a/packages/sparkline/src/annotation/VerticalReferenceLine.jsx +++ b/packages/sparkline/src/annotation/VerticalReferenceLine.jsx @@ -54,63 +54,66 @@ export const defaultProps = { yScale: null, }; -function VerticalReferenceLine({ - data, - getX, - getY, - LabelComponent, - labelOffset, - labelPosition, - reference, - renderLabel, - stroke, - strokeDasharray, - strokeLinecap, - strokeWidth, - xScale, - yScale, -}) { - if (!xScale || !yScale || !getY || !getX || !data.length) return null; - const [y1, y0] = yScale.range(); - const [yMin, yMax] = yScale.domain(); +class VerticalReferenceLine extends React.PureComponent { + render() { + const { + data, + getX, + getY, + LabelComponent, + labelOffset, + labelPosition, + reference, + renderLabel, + stroke, + strokeDasharray, + strokeLinecap, + strokeWidth, + xScale, + yScale, + } = this.props; + if (!xScale || !yScale || !getY || !getX || !data.length) return null; + const [y1, y0] = yScale.range(); + const [yMin, yMax] = yScale.domain(); - // use a number if passed, else find the index based on the ref type - let index = reference; - if (typeof reference !== 'number') { - index = data.findIndex((d, i) => ( - (reference === 'first' && i === 0) - || (reference === 'last' && i === data.length - 1) - || (reference === 'min' && Math.abs(getY(d) - yMin) < 0.00001) - || (reference === 'max' && Math.abs(getY(d) - yMax) < 0.00001) - )); - } - const datum = data[index]; - // use passed value if no datum, this enables custom x values - const refNumber = datum ? getX(datum) : index; - const scaledRef = xScale(refNumber); - const fromPoint = new Point({ x: scaledRef, y: y1 }); - const toPoint = new Point({ x: scaledRef, y: y0 }); - const label = renderLabel && renderLabel((datum && getY(datum)) || refNumber); + // use a number if passed, else find the index based on the ref type + let index = reference; + if (typeof reference !== 'number') { + index = data.findIndex((d, i) => ( + (reference === 'first' && i === 0) + || (reference === 'last' && i === data.length - 1) + || (reference === 'min' && Math.abs(getY(d) - yMin) < 0.00001) + || (reference === 'max' && Math.abs(getY(d) - yMax) < 0.00001) + )); + } + const datum = data[index]; + // use passed value if no datum, this enables custom x values + const refNumber = datum ? getX(datum) : index; + const scaledRef = xScale(refNumber); + const fromPoint = new Point({ x: scaledRef, y: y1 }); + const toPoint = new Point({ x: scaledRef, y: y0 }); + const label = renderLabel && renderLabel((datum && getY(datum)) || refNumber); - return ( - - - {label && React.cloneElement(LabelComponent, { - x: toPoint.x, - y: toPoint.y, - ...positionLabel(labelPosition, labelOffset), - label, - })} - - ); + return ( + + + {label && React.cloneElement(LabelComponent, { + x: toPoint.x, + y: toPoint.y, + ...positionLabel(labelPosition, labelOffset), + label, + })} + + ); + } } VerticalReferenceLine.propTypes = propTypes; diff --git a/packages/sparkline/src/chart/Sparkline.jsx b/packages/sparkline/src/chart/Sparkline.jsx index ea9c5e78..d0aab70d 100644 --- a/packages/sparkline/src/chart/Sparkline.jsx +++ b/packages/sparkline/src/chart/Sparkline.jsx @@ -6,6 +6,7 @@ import { extent } from 'd3-array'; import Group from '@vx/group/build/Group'; import scaleLinear from '@vx/scale/build/scales/linear'; +import BarSeries from '../series/BarSeries'; import { componentName, isBandLine, isReferenceLine, isSeries } from '../utils/componentIsX'; import isDefined from '../utils/defined'; @@ -23,6 +24,8 @@ const propTypes = { }), max: PropTypes.number, min: PropTypes.number, + onMouseMove: PropTypes.func, + onMouseLeave: PropTypes.func, preserveAspectRatio: PropTypes.string, styles: PropTypes.object, width: PropTypes.number.isRequired, @@ -41,28 +44,41 @@ const defaultProps = { }, max: null, min: null, + onMouseMove: null, + onMouseLeave: null, preserveAspectRatio: null, styles: null, valueAccessor: d => d, viewBox: null, }; -const getX = d => d.x; +const getX = d => d.i; const getY = d => d.y; const parsedDatumThunk = valueAccessor => (d, i) => { const y = valueAccessor(d); - return { x: i, y, id: y, ...d }; + return { i, y, id: y, ...d }; }; class Sparkline extends React.PureComponent { constructor(props) { super(props); + this.getMaxY = this.getMaxY.bind(this); this.state = this.getStateFromProps(props); } componentWillReceiveProps(nextProps) { - this.setState(this.getStateFromProps(nextProps)); + if ([ // recompute scales if any of the following change + 'data', + 'height', + 'margin', + 'max', + 'min', + 'valueAccessor', + 'width', + ].some(prop => this.props[prop] !== nextProps[prop])) { + this.setState(this.getStateFromProps(nextProps)); + } } getStateFromProps(props) { @@ -83,7 +99,7 @@ class Sparkline extends React.PureComponent { domain: [0, data.length - 1], range: [0, innerWidth], }); - const yScale = scaleLinear({ // @TODO introduce props for centering data, etc. + const yScale = scaleLinear({ domain: [ isDefined(min) ? min : yExtent[0], isDefined(max) ? max : yExtent[1], @@ -93,6 +109,10 @@ class Sparkline extends React.PureComponent { return { xScale, yScale, data }; } + getMaxY() { + return Math.max(...this.state.yScale.domain()); + } + getDimmensions(props) { const { margin, width, height } = props || this.props; const completeMargin = { ...defaultProps.margin, ...margin }; @@ -109,12 +129,24 @@ class Sparkline extends React.PureComponent { children, className, height, + onMouseMove, + onMouseLeave, preserveAspectRatio, styles, width, viewBox, } = this.props; + const { data, margin, xScale, yScale } = this.state; + + const seriesProps = { + xScale, + yScale, + data, + getX, + getY, + }; + return ( }
); diff --git a/packages/sparkline/src/index.js b/packages/sparkline/src/index.js index 6c9e8a9b..a6049d7f 100644 --- a/packages/sparkline/src/index.js +++ b/packages/sparkline/src/index.js @@ -8,6 +8,7 @@ export { default as BandLine } from './annotation/BandLine'; export { default as Label } from './annotation/Label'; export { default as HorizontalReferenceLine } from './annotation/HorizontalReferenceLine'; export { default as VerticalReferenceLine } from './annotation/VerticalReferenceLine'; +export { default as WithTooltip, Tooltip, withTooltipPropTypes } from '@data-ui/core/build/enhancer/WithTooltip'; export { LinearGradient } from '@vx/gradient'; export { PatternLines } from '@vx/pattern'; diff --git a/packages/sparkline/src/series/BarSeries.jsx b/packages/sparkline/src/series/BarSeries.jsx index 8cde8436..e7a13847 100644 --- a/packages/sparkline/src/series/BarSeries.jsx +++ b/packages/sparkline/src/series/BarSeries.jsx @@ -19,6 +19,8 @@ export const propTypes = { PropTypes.func, PropTypes.oneOf(['top', 'right', 'bottom', 'left']), ]), + onMouseMove: PropTypes.func, + onMouseLeave: PropTypes.func, renderLabel: PropTypes.func, // (val, i) => node stroke: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), strokeWidth: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), @@ -40,6 +42,8 @@ export const defaultProps = { labelOffset: 8, LabelComponent: