Skip to content

Instantly share code, notes, and snippets.

@williaster
Last active May 22, 2017 19:54
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save williaster/16ca8595b60fb971942b4381536afe65 to your computer and use it in GitHub Desktop.
Save williaster/16ca8595b60fb971942b4381536afe65 to your computer and use it in GitHub Desktop.
Potential reusable chart API for `@data-ui/xychart`
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';
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';
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])),
});
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;
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