Skip to content

Instantly share code, notes, and snippets.

@chibaye
Last active May 3, 2019 13:59
Show Gist options
  • Save chibaye/ab1e077930825f0c0b091078d773564c to your computer and use it in GitHub Desktop.
Save chibaye/ab1e077930825f0c0b091078d773564c to your computer and use it in GitHub Desktop.
real-time-line-chart
<div id="js-app"></div>

real-time-line-chart

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.

License.

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;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment