An animated line chart React component that displays incoming data in real time. Uses d3 for calculations but not for SVG manipulation
i wrote a blog post about this method
A Pen by Francis Chibaye on CodePen.
<div id="js-app"></div> |
An animated line chart React component that displays incoming data in real time. Uses d3 for calculations but not for SVG manipulation
i wrote a blog post about this method
A Pen by Francis Chibaye on CodePen.
let addData; | |
const transitionDuration = 300; | |
setTimeout(() => { | |
const view = [480, 320]; | |
const trbl = [0, 0, 0, 0]; // refactor, i don't understand the semantics of this. Will i ever need trbl[1] or trbl[2] ? | |
ReactDOM.render( | |
<Container {...{view, trbl}} /> | |
, document.getElementById('js-app')); | |
}, 0); | |
let count = 0; | |
function streamDataStep () { | |
const value = Math.random() * 900 + 100; | |
addData(value); | |
} | |
function generateData (size) { | |
const data = []; | |
for (let index = 0; index < size; index++) { | |
const value = Math.random() * 900 + 100; | |
data.push({index, value}); | |
} | |
return data; | |
} | |
function labelFn (value, index) { | |
return value; | |
} | |
class Container extends React.Component { | |
static propTypes = { | |
trbl: React.PropTypes.array.isRequired, | |
view: React.PropTypes.array.isRequired | |
}; | |
constructor (props) { | |
super(props); | |
this.state = { | |
domainXMin: 100, | |
domainXMax: 500, | |
domainYMin: 0, | |
domainYMax: 100, | |
data: generateData(23) | |
}; | |
} | |
componentDidMount () { | |
addData = (value) => { | |
const data = this.state.data.slice(0); | |
const index = data[data.length - 1].index + 1; | |
data.push({index, value}); | |
data.shift(); | |
this.setState({data}); | |
}; | |
streamDataStep(); | |
} | |
buildDataSeries (data, containerView, containerTrbl, horizontalAxisHeight, verticalAxisWidth, xScale, yScale) { | |
const trbl = [ | |
horizontalAxisHeight, | |
verticalAxisWidth, | |
horizontalAxisHeight, | |
verticalAxisWidth | |
]; | |
const view = [ | |
containerView[0] - verticalAxisWidth * 2, | |
containerView[1] - horizontalAxisHeight * 2 | |
]; | |
return ( | |
<AnimatedLineDataSeries {...{data, trbl, view, xScale, yScale}} /> | |
); | |
} | |
buildVerticalAxis (containerView, containerTrbl, horizontalAxisHeight, verticalAxisWidth, scale) { | |
const view = [verticalAxisWidth, containerView[1] - horizontalAxisHeight * 2]; | |
const trbl = [horizontalAxisHeight, 0, 0, 0]; | |
const orientation = VerticalAxis.orientation.LEFT; | |
const tickValues = scale.ticks(); | |
return ( | |
<AnimatedVerticalAxis {...{scale, trbl, view, tickValues, orientation, labelFn}} /> | |
); | |
} | |
buildScale (domainMin, domainMax, range) { | |
return d3.scaleLinear().domain([domainMin, domainMax]).range(range); | |
} | |
buildHorizontalAxis (containerView, containerTrbl, horizontalAxisHeight, verticalAxisWidth, scale) { | |
const view = [containerView[0] - verticalAxisWidth * 2, horizontalAxisHeight]; | |
const trbl = [containerView[1] - horizontalAxisHeight, verticalAxisWidth, 0, verticalAxisWidth]; | |
const orientation = HorizontalAxis.orientation.BOTTOM; | |
const tickValues = scale.ticks(); | |
return ( | |
<AnimatedHorizontalAxis {...{scale, trbl, view, tickValues, orientation, labelFn}} /> | |
); | |
} | |
render () { | |
const {view, trbl} = this.props; | |
const {data} = this.state; | |
const [domainYMin, domainYMax] = d3.extent(data, ({value}) => value); | |
const [domainXMin, domainXMax] = d3.extent(data, ({index}) => index); | |
const horizontalAxisHeight = 30; | |
const verticalAxisWidth = 42; | |
const marginSide = ((view[0] - verticalAxisWidth * 2) / data.length); | |
const xScale = this.buildScale(domainXMin + 1, domainXMax - 2, [0 - marginSide, view[0] - verticalAxisWidth * 2 + marginSide]); | |
const yScale = this.buildScale(domainYMin, domainYMax, [view[1] - horizontalAxisHeight * 2, 0]); | |
return ( | |
<g> | |
{this.buildHorizontalAxis(view, trbl, horizontalAxisHeight, verticalAxisWidth, xScale)} | |
{this.buildVerticalAxis(view, trbl, horizontalAxisHeight, verticalAxisWidth, yScale)} | |
{this.buildDataSeries(data, view, trbl, horizontalAxisHeight, verticalAxisWidth, xScale, yScale)} | |
</g> | |
); | |
} | |
} | |
class HorizontalAxis extends React.Component { | |
static propTypes = { | |
labelFn: React.PropTypes.func.isRequired, | |
orientation: React.PropTypes.string.isRequired, | |
scale: React.PropTypes.func.isRequired, | |
tickValues: React.PropTypes.array.isRequired, | |
trbl: React.PropTypes.array.isRequired, | |
view: React.PropTypes.array.isRequired | |
}; | |
static orientation = { | |
BOTTOM: 'horizontal-axis-bottom', | |
TOP: 'horizontal-axis-top' | |
}; | |
buildTicks (tickValues, scale, labelFn, trbl, view, orientation) { | |
return tickValues.map((tickValue, index) => { | |
const xPos = scale(tickValue); | |
let y2 = view[1]; | |
let y1 = y2 - 5; | |
if (orientation === HorizontalAxis.orientation.BOTTOM) { | |
y1 = 0; | |
y2 = 5; | |
} | |
return ( | |
<g | |
key={index} | |
transform={`translate(${xPos}, 0)`} | |
> | |
<line | |
{...{y1, y2}} | |
stroke={'darkgray'} | |
x1={0} | |
x2={0} | |
/> | |
<text | |
dy={'1.4em'} | |
textAnchor={'middle'} | |
x={0} | |
y={0} | |
>{labelFn(tickValue, index)}</text> | |
</g> | |
); | |
}); | |
} | |
render () { | |
const {scale, view, trbl, labelFn, tickValues, orientation} = this.props; | |
const [width, height] = view; | |
const id = 'clip-path--' + Math.floor(+(new Date) + Math.random() * 0xffffff).toString(36); | |
let y1 = 0; | |
if (orientation === HorizontalAxis.orientation.TOP) { | |
y1 = view[1]; | |
} | |
const y2 = y1; | |
return ( | |
<g> | |
<defs> | |
<clipPath {...{id}}> | |
<rect {...{width, height}}></rect> | |
</clipPath> | |
</defs> | |
<g | |
clipPath={`url(#${id})`} | |
transform={`translate(${trbl[3]}, ${trbl[0]})`} | |
> | |
<line | |
stroke="darkgray" | |
x1={0} | |
y1={0} | |
x2={view[0]} | |
y2={0} | |
/> | |
{this.buildTicks(tickValues, scale, labelFn, trbl, view, orientation)} | |
</g> | |
</g> | |
); | |
} | |
} | |
class VerticalAxis extends React.Component { | |
static propTypes = { | |
labelFn: React.PropTypes.func.isRequired, | |
orientation: React.PropTypes.string.isRequired, | |
scale: React.PropTypes.func.isRequired, | |
tickValues: React.PropTypes.array.isRequired, | |
trbl: React.PropTypes.array.isRequired, | |
view: React.PropTypes.array.isRequired | |
}; | |
static orientation = { | |
LEFT: 'horizontal-axis-left', | |
RIGHT: 'horizontal-axis-right' | |
}; | |
buildTicks (tickValues, scale, labelFn, trbl, view, orientation) { | |
return tickValues.map((tickValue, index) => { | |
const yPos = scale(tickValue); | |
let x2 = view[0]; | |
let x1 = x2 - 5; | |
let anchorPosition = 'end'; | |
let textXPos = x1 - 2; | |
if (orientation === VerticalAxis.orientation.RIGHT) { | |
x1 = 0; | |
x2 = 5; | |
anchorPosition = 'start'; | |
textXPos = x2 + 2; | |
} | |
return ( | |
<g | |
key={index} | |
transform={`translate(0, ${yPos})`} | |
> | |
<line | |
{...{x1, x2}} | |
stroke={'darkgray'} | |
y1={0} | |
y2={0} | |
/> | |
<text | |
dy={3} | |
textAnchor={anchorPosition} | |
x={textXPos} | |
y={0} | |
>{labelFn(tickValue, index)}</text> | |
</g> | |
); | |
}); | |
} | |
render () { | |
const {scale, view, trbl, labelFn, tickValues, orientation} = this.props; | |
let x1 = view[0]; | |
if (orientation === VerticalAxis.orientation.RIGHT) { | |
x1 = 0; | |
} | |
const x2 = x1; | |
return ( | |
<g transform={`translate(${trbl[3]}, ${trbl[0]})`}> | |
<line | |
{...{x1, x2}} | |
stroke="darkgray" | |
y1={0} | |
y2={view[1]} | |
/> | |
{this.buildTicks(tickValues, scale, labelFn, trbl, view, orientation)} | |
</g> | |
); | |
} | |
} | |
const AnimatedAxisWrapper = () => ComposedComponent => class extends React.Component { | |
static propTypes = { | |
scale: React.PropTypes.func.isRequired | |
}; | |
constructor (props) { | |
super(props); | |
const {scale} = this.props; | |
const [domainMin, domainMax] = scale.domain(); | |
this.state = { | |
domainMax, | |
domainMin | |
}; | |
} | |
componentWillReceiveProps (nextProps) { | |
const [nextDomainMin, nextDomainMax] = nextProps.scale.domain(); | |
const [domainMin, domainMax] = this.props.scale.domain(); | |
if (nextDomainMin === domainMin && nextDomainMax === domainMax) { | |
return; | |
} | |
d3.select(this).transition().tween('attr.domain'); // refactor, is this necessary to cancel previous transition? | |
d3.select(this).transition().duration(transitionDuration).ease(d3.easeLinear).tween('attr.domain', () => { | |
const minInterpolator = d3.interpolateNumber(this.state.domainMin, nextDomainMin); | |
const maxInterpolator = d3.interpolateNumber(this.state.domainMax, nextDomainMax); | |
return (t) => { | |
this.setState({ | |
domainMin: minInterpolator(t), | |
domainMax: maxInterpolator(t) | |
}); | |
}; | |
}); | |
} | |
render () { | |
const {props} = this; | |
const {scale} = props; | |
const {domainMin, domainMax} = this.state; | |
const newScale = scale.copy(); | |
newScale.domain([domainMin, domainMax]); | |
const newProps = Object.assign({}, props, {scale: newScale}); | |
return ( | |
<ComposedComponent {...newProps} /> | |
); | |
} | |
} | |
const AnimatedVerticalAxis = AnimatedAxisWrapper()(VerticalAxis); | |
const AnimatedHorizontalAxis = AnimatedAxisWrapper()(HorizontalAxis); | |
class LineDataSeries extends React.Component { | |
static propTypes = { | |
data: React.PropTypes.array.isRequired, | |
trbl: React.PropTypes.array.isRequired, | |
view: React.PropTypes.array.isRequired, | |
xScale: React.PropTypes.func.isRequired, | |
yScale: React.PropTypes.func.isRequired | |
}; | |
buildAreaPlot (data, view, trbl, xScale, yScale, stroke) { | |
const area = d3.line(); | |
area.x(({index}) => xScale(index)); | |
area.y(({value}) => yScale(value)); | |
area.curve(d3.curveBasis); | |
const d = area(data); | |
return ( | |
<path {...{d, stroke, fill: 'none', strokeWidth: 3}} /> | |
); | |
} | |
render () { | |
const {trbl, view, data, xScale, yScale, year} = this.props; | |
const stroke = 'steelblue'; | |
const [width, height] = view; | |
const id = 'clip-path--' + Math.floor(+(new Date) + Math.random() * 0xffffff).toString(36); | |
return ( | |
<g> | |
<defs> | |
<clipPath {...{id}}> | |
<rect {...{width, height}}></rect> | |
</clipPath> | |
</defs> | |
<g | |
clipPath={`url(#${id})`} | |
transform={`translate(${trbl[3]}, ${trbl[0]})`} | |
> | |
{this.buildAreaPlot(data, view, trbl, xScale, yScale, stroke)} | |
</g> | |
</g> | |
); | |
} | |
} | |
const AnimatedDataSeriesWrapper = () => ComposedComponent => class extends React.Component { | |
static propTypes = { | |
xScale: React.PropTypes.func.isRequired, | |
yScale: React.PropTypes.func.isRequired | |
}; | |
constructor (props) { | |
super(props); | |
const {yScale, xScale} = this.props; | |
const [domainXMin, domainXMax] = xScale.domain(); | |
const [domainYMin, domainYMax] = yScale.domain(); | |
this.state = { | |
domainYMin, | |
domainYMax, | |
domainXMin, | |
domainXMax | |
}; | |
} | |
componentWillReceiveProps (nextProps) { | |
const [nextDomainXMin, nextDomainXMax] = nextProps.xScale.domain(); | |
const [domainXMin, domainXMax] = this.props.xScale.domain(); | |
const [nextDomainYMin, nextDomainYMax] = nextProps.yScale.domain(); | |
const [domainYMin, domainYMax] = this.props.yScale.domain(); | |
const domainYUnchanged = nextDomainYMin === domainYMin && nextDomainYMax === domainYMax; | |
const domainXUnchanged = nextDomainXMin === domainXMin && nextDomainXMax === domainXMax; | |
if (domainYUnchanged && domainXUnchanged) { | |
return; | |
} | |
d3.select(this).transition().tween('attr.domain'); // refactor, is this necessary to cancel previous transition? | |
d3.select(this).transition().duration(transitionDuration).ease(d3.easeLinear).tween('attr.domain', () => { | |
const minYInterpolator = d3.interpolateNumber(this.state.domainYMin, nextDomainYMin); | |
const maxYInterpolator = d3.interpolateNumber(this.state.domainYMax, nextDomainYMax); | |
const minXInterpolator = d3.interpolateNumber(this.state.domainXMin, nextDomainXMin); | |
const maxXInterpolator = d3.interpolateNumber(this.state.domainXMax, nextDomainXMax); | |
return (t) => { | |
this.setState({ | |
domainYMin: minYInterpolator(t), | |
domainYMax: maxYInterpolator(t), | |
domainXMin: minXInterpolator(t), | |
domainXMax: maxXInterpolator(t) | |
}); | |
}; | |
}).on('end', streamDataStep); // refactor, this is a hacky way to get smoothy rendering | |
} | |
render () { | |
const {props} = this; | |
const {xScale, yScale} = props; | |
const {domainYMin, domainYMax, domainXMin, domainXMax} = this.state; | |
const newYScale = yScale.copy(); | |
const newXScale = xScale.copy(); | |
newYScale.domain([domainYMin, domainYMax]); | |
newXScale.domain([domainXMin, domainXMax]); | |
const newProps = Object.assign({}, props, {xScale: newXScale, yScale: newYScale}); | |
return ( | |
<ComposedComponent {...newProps} /> | |
); | |
} | |
} | |
const AnimatedLineDataSeries = AnimatedDataSeriesWrapper()(LineDataSeries); | |
const BaseWrapper = () => ComposedComponent => class extends React.Component { | |
static propTypes = { | |
trbl: React.PropTypes.array.isRequired, | |
view: React.PropTypes.array.isRequired | |
}; | |
render () { | |
const {props} = this; | |
const {view, trbl, children} = props; | |
const viewBox = `0 0 ${view[0]} ${view[1]}`; | |
return ( | |
<svg | |
{...{viewBox}} | |
height={view[1]} | |
width={view[0]} | |
> | |
<g transform={`translate(${trbl[0]}, ${trbl[3]})`}> | |
<ComposedComponent {...props} /> | |
</g> | |
</svg> | |
); | |
} | |
} | |
Container = BaseWrapper()(Container); |
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.0.2/react.min.js"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.3.2/react-dom.js"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.1.0/d3.min.js"></script> |
svg { | |
display: block; | |
margin: 40px auto; | |
background-color: lightyellow; | |
text { | |
font-size: 14px; | |
font-family: sans-serif; | |
} | |
} |