From a8809cadfd8214bd326d47635d2cd2f2f71bd166 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Fri, 4 Dec 2015 17:54:40 +0100 Subject: [PATCH] Animate sparklines by feeding data item by item * Refactored sparklines to be rendered by react * Correct row key for sparklines * Extracted data feed into animated-sparkline * last value is rendered by sparkline now, because it relies on the last value that it is fed, not the lastest availble value --- .../scripts/components/animated-sparkline.js | 127 ++++++++++++++ client/app/scripts/components/sparkline.js | 165 ++++++++---------- 2 files changed, 195 insertions(+), 97 deletions(-) create mode 100644 client/app/scripts/components/animated-sparkline.js diff --git a/client/app/scripts/components/animated-sparkline.js b/client/app/scripts/components/animated-sparkline.js new file mode 100644 index 000000000..456f7105c --- /dev/null +++ b/client/app/scripts/components/animated-sparkline.js @@ -0,0 +1,127 @@ +// Forked from: https://github.com/KyleAMathews/react-sparkline at commit a9d7c5203d8f240938b9f2288287aaf0478df013 +import React from 'react'; +import d3 from 'd3'; +import { OrderedMap } from 'immutable'; + +import Sparkline from './sparkline'; + +const makeOrderedMap = OrderedMap; +const parseDate = d3.time.format.iso.parse; + +export default class AnimatedSparkline extends React.Component { + + constructor(props, context) { + super(props, context); + + this.tickTimer = null; + this.state = { + buffer: makeOrderedMap(), + first: null, + last: null + }; + } + + componentWillMount() { + this.setState(this.updateBuffer(this.props)); + } + + componentWillUnmount() { + clearTimeout(this.tickTimer); + } + + componentWillReceiveProps(nextProps) { + this.setState(this.updateBuffer(nextProps)); + } + + componentDidUpdate() { + // move sliding window one tick + if (!this.tickTimer && this.state.buffer.size > 0) { + this.tick(); + } + } + + updateBuffer(props) { + // merge new samples into buffer + let buffer = this.state.buffer; + const nextSamples = makeOrderedMap(props.data.map(d => [d.date, d.value])); + buffer = buffer.merge(nextSamples); + const state = {}; + + // set first/last marker of sliding window + if (buffer.size > 0) { + const bufferKeys = buffer.keySeq(); + if (this.state.first === null) { + state.first = bufferKeys.first(); + } + if (this.state.last === null) { + state.last = bufferKeys.last(); + } + } + + // remove old values from buffer + const first = this.state.first ? this.state.first : state.first; + state.buffer = buffer.skipWhile((v, d) => d < first); + + return state; + } + + tick() { + if (this.state.last < this.state.buffer.keySeq().last()) { + const dates = this.state.buffer.keySeq(); + let firstIndex = dates.indexOf(this.state.first); + if (firstIndex > -1 && firstIndex < dates.size - 1) { + firstIndex++; + } else { + firstIndex = 0; + } + const first = dates.get(firstIndex); + + let lastIndex = dates.indexOf(this.state.last); + if (lastIndex > -1) { + lastIndex++; + } else { + lastIndex = dates.length - 1; + } + const last = dates.get(lastIndex); + + this.tickTimer = setTimeout(() => { + this.tickTimer = null; + this.setState({first, last}); + }, 900); + } + } + + getGraphData() { + let first = this.state.first; + if (this.props.first && this.props.first < this.state.first) { + // first prop date is way before buffer, keeping it + first = this.props.first; + } + let last = this.state.last; + if (this.props.last && this.props.last > this.state.buffer.keySeq().last()) { + // prop last is after buffer values, need to shift dates + const skip = parseDate(this.props.last) - parseDate(this.state.buffer.keySeq().last()); + last -= skip; + first -= skip; + } + const dateFilter = d => d.date >= first && d.date <= last; + const data = this.state.buffer.map((v, k) => { + return {value: v, date: k}; + }).toIndexedSeq().toJS().filter(dateFilter); + + return {first, last, data}; + } + + render() { + const {data, first, last} = this.getGraphData(); + + return ( + + ); + } + +} + +AnimatedSparkline.propTypes = { + data: React.PropTypes.array.isRequired +}; diff --git a/client/app/scripts/components/sparkline.js b/client/app/scripts/components/sparkline.js index 6831d5654..5b79625ef 100644 --- a/client/app/scripts/components/sparkline.js +++ b/client/app/scripts/components/sparkline.js @@ -1,126 +1,97 @@ // Forked from: https://github.com/KyleAMathews/react-sparkline at commit a9d7c5203d8f240938b9f2288287aaf0478df013 import React from 'react'; -import ReactDOM from 'react-dom'; import d3 from 'd3'; +const parseDate = d3.time.format.iso.parse; + export default class Sparkline extends React.Component { - componentDidMount() { - return this.renderSparkline(); + + constructor(props, context) { + super(props, context); + this.x = d3.scale.linear(); + this.y = d3.scale.linear(); + this.line = d3.svg.line() + .x(d => this.x(d.date)) + .y(d => this.y(d.value)); } - renderSparkline() { - // If the sparkline has already been rendered, remove it. - const el = ReactDOM.findDOMNode(this); - while (el.firstChild) { - el.removeChild(el.firstChild); + getGraphData() { + // data is of shape [{date, value}, ...] and is sorted by date (ASC) + let data = this.props.data; + + // Do nothing if no data or data w/o date are passed in. + if (data.length === 0 || data[0].date === undefined) { + return
; } - const data = this.props.data.slice(); + // adjust scales + this.x.range([2, this.props.width - 2]); + this.y.range([this.props.height - 2, 2]); + this.line.interpolate(this.props.interpolate); - // Do nothing if no data is passed in. - if (data.length === 0) { - return; + // Convert dates into D3 dates + data = data.map(d => { + return { + date: parseDate(d.date), + value: d.value + }; + }); + + // determine date range + let firstDate = this.props.first ? parseDate(this.props.first) : data[0].date; + let lastDate = this.props.last ? parseDate(this.props.last) : data[data.length - 1].date; + // if last prop is after last value, we need to add that difference as + // padding before first value to right-align sparkline + const skip = lastDate - data[data.length - 1].date; + if (skip > 0) { + firstDate -= skip; + lastDate -= skip; } + this.x.domain([firstDate, lastDate]); - const x = d3.scale.linear().range([2, this.props.width - 2]); - const y = d3.scale.linear().range([this.props.height - 2, 2]); + // determine value range + const minValue = this.props.min !== undefined ? this.props.min : d3.min(data, d => d.value); + const maxValue = this.props.max !== undefined ? Math.max(this.props.max, d3.max(data, d => d.value)) : d3.max(data, d => d.value); + this.y.domain([minValue, maxValue]); - // react-sparkline allows you to pass in two types of data. - // Data tied to dates and linear data. We need to change our line and x/y - // functions depending on the type of data. + const lastValue = data[data.length - 1].value; + const lastX = this.x(lastDate); + const lastY = this.y(lastValue); + const title = 'Last ' + d3.round((lastDate - firstDate) / 1000) + ' seconds, ' + + data.length + ' samples, min: ' + d3.round(d3.min(data, d => d.value), 2) + + ', max: ' + d3.round(d3.max(data, d => d.value), 2) + + ', mean: ' + d3.round(d3.mean(data, d => d.value), 2); - // These are objects with a date key - let line; - let lastX; - let lastY; - let title; - if (data[0].date) { - // Convert dates into D3 dates - data.forEach(d => { - d.date = d3.time.format.iso.parse(d.date); - }); - - line = d3.svg.line(). - interpolate(this.props.interpolate). - x(d => x(d.date)). - y(d => y(d.value)); - - const first = this.props.first ? d3.time.format.iso.parse(this.props.first) : d3.min(data, d => d.date); - const last = this.props.last ? d3.time.format.iso.parse(this.props.last) : d3.max(data, d => d.date); - x.domain([first, last]); - - y.domain([ - this.props.min || d3.min(data, d => d.value), - this.props.max || d3.max(data, d => d.value) - ]); - - lastX = x(data[data.length - 1].date); - lastY = y(data[data.length - 1].value); - title = 'Last ' + d3.round((last - first) / 1000) + ' seconds, ' + data.length + ' samples, min: ' + d3.round(d3.min(data, d => d.value), 2) + ', max: ' + d3.round(d3.max(data, d => d.value), 2) + ', mean: ' + d3.round(d3.mean(data, d => d.value), 2); - } else { - line = d3.svg.line(). - interpolate(this.props.interpolate). - x((d, i) => x(i)). - y(d => y(d)); - - x.domain([ - this.props.first || 0, - this.props.last || data.length - ]); - - y.domain([ - this.props.min || d3.min(data), - this.props.max || d3.max(data) - ]); - - lastX = x(data.length - 1); - lastY = y(data[data.length - 1]); - title = data.length + ' samples, min: ' + d3.round(d3.min(data), 2) + ', max: ' + d3.round(d3.max(data), 2) + ', mean: ' + d3.round(d3.mean(data), 2); - } - - d3.select(ReactDOM.findDOMNode(this)).attr('title', title); - - const svg = d3.select(ReactDOM.findDOMNode(this)). - append('svg'). - attr('width', this.props.width). - attr('height', this.props.height). - append('g'); - - svg.append('path'). - datum(data). - attr('class', 'sparkline'). - style('fill', 'none'). - style('stroke', this.props.strokeColor). - style('stroke-width', this.props.strokeWidth). - attr('d', line); - - svg.append('circle'). - attr('class', 'sparkcircle'). - attr('cx', lastX). - attr('cy', lastY). - attr('fill', '#46466a'). - attr('fill-opacity', 0.6). - attr('stroke', 'none'). - attr('r', this.props.circleDiameter); + return {title, lastValue, lastX, lastY, data}; } render() { + const {lastValue, lastX, lastY, title, data} = this.getGraphData(); + return ( -
+
+ + + + + {lastValue} +
); } - componentDidUpdate() { - return this.renderSparkline(); - } } +Sparkline.propTypes = { + data: React.PropTypes.array.isRequired +}; + Sparkline.defaultProps = { width: 80, height: 16, strokeColor: '#7d7da8', strokeWidth: '0.5px', - interpolate: 'basis', - circleDiameter: 1.75, - data: [1, 23, 5, 5, 23, 0, 0, 0, 4, 32, 3, 12, 3, 1, 24, 1, 5, 5, 24, 23] // Some semi-random data. + interpolate: 'none', + circleDiameter: 1.75 };