Last active
May 22, 2017 19:54
-
-
Save williaster/16ca8595b60fb971942b4381536afe65 to your computer and use it in GitHub Desktop.
Potential reusable chart API for `@data-ui/xychart`
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React from 'react'; | |
import PropTypes from 'prop-types'; | |
import { Group } from '@vx/group'; | |
import { Bar } from '@vx/shape'; | |
import { scaleShape } from './propShapes'; | |
const propTypes = { | |
data: PropTypes.arrayOf(PropTypes.shape({ | |
x: PropTypes.instanceOf(moment).isRequired, | |
y: PropTypes.number.isRequired, | |
label: stringOrT, | |
})).isRequired, | |
// to be used for legends, keys, etc | |
label: stringOrT.isRequired, | |
// these will likely be injected by the parent xychart | |
barWidth: PropTypes.number.isRequired, | |
scales: PropTypes.shape({ | |
x: scaleShape.isRequired, | |
y: scaleShape.isRequired, | |
fill: PropTypes.oneOfType([scaleShape, PropTypes.string]), | |
stroke: PropTypes.oneOfType([scaleShape, PropTypes.string]), | |
strokeWidth: PropTypes.oneOfType([scaleShape, PropTypes.number]), | |
}).isRequired, | |
}; | |
const defaultProps = {}; | |
const defaultScales = { | |
fill: colors.default, | |
stroke: colors.white, | |
strokeWidth: 1, | |
}; | |
export default function BarSeries({ | |
barWidth, | |
data, | |
label, | |
scales, | |
}) { | |
const allScales = { ...defaultScales, ...scales }; | |
const { x, y, fill, stroke, strokeWidth } = allScales; | |
const maxHeight = (y.range() || [0])[0]; | |
return ( | |
<Group key={label}> | |
{data.map((d) => { | |
const barHeight = maxHeight - y(d.y); | |
return ( | |
<Bar | |
key={`bar-${x(d.x)}`} | |
x={x(d.x) - (0.5 * barWidth)} | |
y={maxHeight - barHeight} | |
width={barWidth} | |
height={barHeight} | |
fill={typeof fill === 'function' ? fill(d.fill) : fill} | |
stroke={typeof stroke === 'function' ? stroke(d.stroke) : stroke} | |
strokeWidth={typeof strokeWidth === 'function' ? strokeWidth(d.stroke) : strokeWidth} | |
/> | |
); | |
})} | |
</Group> | |
); | |
} | |
BarSeries.propTypes = propTypes; | |
BarSeries.defaultProps = defaultProps; | |
BarSeries.displayName = 'BarSeries'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React from 'react'; | |
import PropTypes from 'prop-types'; | |
import { curveCardinal, curveLinear } from '@vx/curve'; | |
import { GlyphDot } from '@vx/glyph'; | |
import { scaleShape } from './propShapes'; | |
const propTypes = { | |
data: PropTypes.arrayOf(PropTypes.shape({ | |
x: PropTypes.instanceOf(moment).isRequired, | |
y: PropTypes.number.isRequired, | |
label: stringOrT, | |
})).isRequired, | |
interpolation: PropTypes.oneOf(['linear', 'cardinal']), // @todo add more | |
label: stringOrT.isRequired, | |
showPoints: PropTypes.bool, // could also rely on user overlaying be a PointSeries | |
// these will likely be injected by the parent chart | |
scales: PropTypes.shape({ | |
x: scaleShape.isRequired, | |
y: scaleShape.isRequired, | |
stroke: PropTypes.oneOfType([scaleShape, PropTypes.string]), | |
strokeWidth: PropTypes.oneOfType([scaleShape, PropTypes.number]), | |
strokeDasharray: PropTypes.string, | |
}).isRequired, | |
}; | |
const defaultProps = { | |
interpolation: 'cardinal', | |
showPoints: true, | |
}; | |
const defaultScales = { | |
stroke: colors.default, | |
strokeWidth: 3, | |
}; | |
export default function LineSeries({ | |
data, | |
interpolation, | |
label, | |
showPoints, | |
scales, | |
}) { | |
const allScales = { ...defaultScales, ...scales }; | |
const { x, y, stroke, strokeWidth, strokeDasharray } = allScales; | |
return ( | |
<LinePath | |
key={label} | |
data={data} | |
xScale={x} | |
yScale={y} | |
x={d => d.x} | |
y={d => d.y} | |
stroke={stroke} | |
strokeWidth={strokeWidth} | |
strokeDasharray={strokeDasharray} | |
curve={interpolation === 'linear' ? curveLinear : curveCardinal} | |
glyph={showPoints ? (d => ( | |
<GlyphDot | |
cx={x(d.x)} | |
cy={y(d.y)} | |
r={4} | |
fill={stroke} | |
stroke={'#fff'} | |
strokeWidth={2} | |
> | |
{d.label && | |
<text | |
x={x(d.x)} | |
y={y(d.y)} | |
dx={10} | |
fill={colors.label} | |
stroke={'#fff'} | |
strokeWidth={strokeWidth} | |
fontSize={14} | |
> | |
{d.label} | |
</text>} | |
</GlyphDot> | |
)) : null} | |
/> | |
); | |
} | |
LineSeries.propTypes = propTypes; | |
LineSeries.defaultProps = defaultProps; | |
LineSeries.displayName = 'LineSeries'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import PropTypes from 'prop-types'; | |
export const scaleShape = PropTypes.shape({ | |
type: PropTypes.oneOf(['time', 'linear', 'ordinal']).isRequired, | |
// these would override any computation done by xyplot | |
// and would allow specifying colors for scales, etc. | |
range: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])), | |
domain: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])), | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React from 'react'; | |
import PropTypes from 'prop-types'; | |
import { componentWithName } from 'airbnb-prop-types'; | |
import { AxisBottom, AxisRight } from '@vx/axis'; | |
import { extent } from 'd3-array'; | |
import { Grid } from '@vx/grid'; | |
import { Group } from '@vx/group'; | |
import { scaleLinear, scaleTime, scaleOrdinal } from '@vx/scale'; | |
import { scaleShape } from './propShapes'; | |
const scaleTypeToScale = { | |
time: scaleTime, | |
linear: scaleLinear, | |
ordinal: scaleOrdinal, | |
}; | |
const propTypes = { | |
ariaLabel: stringOrT.isRequired, | |
children: componentWithName(/Series$/), | |
width: PropTypes.number.isRequired, | |
height: PropTypes.number.isRequired, | |
margin: PropTypes.shape({ | |
top: PropTypes.number, | |
right: PropTypes.number, | |
bottom: PropTypes.number, | |
left: PropTypes.number, | |
}), | |
scales: PropTypes.shape({ | |
x: scaleShape.isRequired, | |
y: scaleShape.isRequired, | |
fill: scaleShape, | |
stroke: scaleShape, | |
}).isRequired, // key matches data attribute | |
showXGridlines: PropTypes.bool, | |
showYGridlines: PropTypes.bool, | |
xAxisLabel: stringOrT, | |
yAxisLabel: stringOrT.isRequired, | |
}; | |
const defaultProps = { | |
children: null, | |
margin: {}, | |
showXGridlines: false, | |
showYGridlines: false, | |
xAxisLabel: null, | |
}; | |
const DEFAULT_MARGIN = { | |
top: 8, | |
right: 64, | |
bottom: 48, | |
left: 16, | |
}; | |
class XYChart extends React.PureComponent { | |
// @todo: refactor this, if axis is categorical can rely on rangeBands | |
getBarWidth({ innerWidth }) { | |
let barWidth = 0; | |
React.Children.forEach(this.props.children, (Child) => { | |
if ((Child.type.displayName || Child.type.name).match(/bar/i)) { | |
const data = Child.props.data; | |
barWidth = (innerWidth / data.length) - 6; // 2px padding | |
} | |
}); | |
return barWidth; | |
} | |
getDimmensions() { | |
const { margin: inputMargin, width, height } = this.props; | |
const margin = { ...DEFAULT_MARGIN, ...inputMargin }; | |
return { | |
margin, | |
innerHeight: height - margin.top - margin.bottom, | |
innerWidth: width - margin.left - margin.right, | |
}; | |
} | |
getNumTicks() { | |
// @todo: pass as props or use util functions | |
return { x: 5, y: 4 }; | |
} | |
getScales() { | |
const { scales: scaleProp } = this.props; | |
const { innerWidth, innerHeight } = this.getDimmensions(); | |
const allData = this.collectDataFromChildSeries(); | |
const barWidth = this.getBarWidth({ innerWidth, innerHeight }); | |
const scales = {}; | |
Object.entries(scaleProp).forEach(([key, { type, ...rest }]) => { | |
const fullExtent = extent(allData, d => d[key]); | |
let range; | |
if (key === 'x') range = [0 + (barWidth / 2), innerWidth - (barWidth / 2)]; | |
if (key === 'y') range = [innerHeight, 0]; | |
// @todo colors if (key === 'fill') range = [defaultColors] | |
scales[key] = scaleTypeToScale[type]({ | |
domain: fullExtent, | |
range, | |
clamp: true, | |
...rest, | |
}); | |
}); | |
return scales; | |
} | |
collectDataFromChildSeries() { | |
const { children } = this.props; | |
let data = []; | |
React.Children.forEach(children, (child) => { | |
if (child.props && child.props.data) { | |
data = data.concat(child.props.data); | |
} | |
}); | |
return data; | |
} | |
render() { | |
const { | |
ariaLabel, | |
children, | |
height, | |
showXGridlines, | |
showYGridlines, | |
width, | |
xAxisLabel, | |
yAxisLabel, | |
} = this.props; | |
const { margin, innerWidth, innerHeight } = this.getDimmensions(); | |
const scales = this.getScales(); | |
const numTicks = this.getNumTicks(); | |
const barWidth = this.getBarWidth({ innerWidth }); | |
return ( | |
<svg | |
aria-label={ariaLabel} | |
role="img" | |
width={width} | |
height={height} | |
> | |
<Group left={margin.left} top={margin.top}> | |
<Grid | |
xScale={scales.x} | |
yScale={scales.y} | |
width={innerWidth} | |
height={innerHeight} | |
stroke={grid.stroke} | |
strokeWidth={grid.strokeWidth} | |
numTicksRows={showYGridlines ? numTicks.y : 0} | |
numTicksColumns={showXGridlines ? numTicks.x : 0} | |
/> | |
{/* The chart coordinates scales across all child series */} | |
{React.Children.map( | |
children, | |
child => React.cloneElement(child, { | |
scales: { | |
...scales, | |
...child.props.scales, | |
}, | |
barWidth: child.type.displayName === 'BarSeries' ? barWidth : undefined, | |
fill: child.type.displayName === 'BarSeries' ? 'url(#gradient_default_light)' : null, | |
}), | |
)} | |
</Group> | |
{/* @todo make these configurable and style-able (top/right/bottom/left, etc) */} | |
<AxisRight | |
scale={scales.y} | |
top={margin.top} | |
left={width - margin.right} | |
stroke={colors.grid} | |
label={ | |
<text {...axis.label}> | |
{yAxisLabel} | |
</text> | |
} | |
labelOffset={margin.right * 0.8} | |
tickStroke={colors.grid} | |
tickLabelComponent={<text {...axis.yTickLabel} />} | |
numTicks={numTicks.y} | |
/> | |
<AxisBottom | |
top={height - margin.bottom} | |
left={margin.left} | |
scale={scales.x} | |
label={ | |
<text {...axis.label}> | |
{xAxisLabel} | |
</text> | |
} | |
tickStroke={colors.grid} | |
tickLabelComponent={<text {...axis.xTickLabel} />} | |
numTicks={numTicks.x} | |
stroke="#000" | |
strokeWidth={2} | |
axisPadding={barWidth / 2} | |
/> | |
</svg> | |
); | |
} | |
} | |
XYChart.propTypes = propTypes; | |
XYChart.defaultProps = defaultProps; | |
export default XYChart; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const width = 500; | |
const height = 300; | |
const margin = {}; | |
export default ({ XYChart, LineSeries, BarSeries }) => ({ | |
examples: [ | |
{ | |
description: 'bars and lines', | |
example: ( | |
<XYChart | |
width={width} | |
height={height} | |
margin={margin} | |
scales={{ | |
x: { type: 'time' }, | |
y: { type: 'linear' }, | |
}} | |
ariaLabel="Required label" | |
yAxisLabel="y-axis label" | |
> | |
<BarSeries | |
data={data} | |
label="Apple Stock" | |
/> | |
<LineSeries | |
data={data} | |
label="Apple Stock" | |
scales={{ stroke: '#484848' }} | |
/> | |
</XYChart> | |
), | |
}, | |
], | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment