Created
December 16, 2014 17:41
-
-
Save caged/ef793b28ed7e05ebc69d to your computer and use it in GitHub Desktop.
Autoupdating graphite timeseries in d3
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
#= require d3 | |
# Draw timeseries graphs to the screen. Each element can contain a set of | |
# data-* attributes used to configure the graph. The graph should always include | |
# a data-url attribute pointing to an endpoint for time series JSON data. | |
# | |
# Any graph that includes a data-realtime attribute will update automatically. | |
# | |
# Examples: | |
# <div class="js-graph" data-url="/graphite?target=github.unicorn.{browser,api}.cpu_time.mean&from=-1hour" data-realtime></div> | |
# <div class="js-graph" data-url="/graphite?target=haystack-production.apps.github.needle&from-1hour" data-realtime></div> | |
# | |
# Given an HTML element with a set of data-* attributes, extract the data attribute | |
# names and values into an object that can be used by our script | |
# | |
# el - a plain HTMLElement object | |
# | |
# Returns an {Object} | |
extractParamsFromElement = (el) -> | |
params = {} | |
for attr in el.attributes | |
matches = attr.name.match /^data-([a-z0-9+_-]+)/i | |
params[matches[1]] = attr.value if matches | |
params | |
# Draw a timeseries graph into the given container | |
# | |
# container - An HTMLElement used as a container for the resulting graph. This | |
# element should contain a target and a from attribute. | |
# | |
# Returns nothing | |
draw = (container) -> | |
el = d3.select container | |
data = [] | |
margin = top: 20, right: 20, bottom: 30, left: 40 | |
width = parseFloat(el.style('width')) - margin.left - margin.right | |
height = parseFloat(el.style('height')) - margin.top - margin.bottom | |
x = d3.time.scale().range [0, width] | |
y = d3.scale.linear().range [height, 0] | |
xax = d3.svg.axis() | |
.orient('bottom') | |
.scale(x) | |
yax = d3.svg.axis() | |
.orient('left') | |
.tickSize(width) | |
.scale(y) | |
# The data-* parameters extracted from the js-graph element | |
params = extractParamsFromElement el.node() | |
# Is this the first run? During the first run we estabish some configuation | |
# based on the data returned from the server. | |
firstRun = true | |
# The update interval is the time between the first two datapoints returned | |
# in the first run. It's assumed that all other datapoints will be spaced in | |
# the same interval which should be the case. | |
updateInterval = 1000 | |
# The span of time that is drawn on screen. The data window is set on the first | |
# run and used to discard old data that exists outside of the window. | |
dataWindow = 0 | |
# The number of data points returned in the first run payload | |
dataLength = 0 | |
# Path data generated. Graphite's response format is [date, value] which is | |
# represented by d[1] and d[0] respectively. | |
line = d3.svg.line() | |
.x((d) -> x d[1]) | |
.y((d) -> y d[0]) | |
vis = el.append('svg') | |
.attr('width', width + margin.left + margin.right) | |
.attr('height', height + margin.top + margin.bottom) | |
.append('g') | |
.attr('transform', "translate(#{margin.left}, #{margin.top})") | |
vis.append('defs').append('clipPath') | |
.attr('id', 'clip') | |
.append('rect') | |
.attr(width: width - 10, height: height) | |
xaxis = vis.append('g') | |
.attr('class', 'x axis') | |
.attr('transform', "translate(0, #{height})") | |
.call(xax) | |
yaxis = vis.append('g') | |
.attr('class', 'y axis') | |
.attr('transform', "translate(#{width}, 0)") | |
.call(yax) | |
clipgroup = vis.append('g') | |
.attr('clip-path', 'url(#clip)') | |
.attr('class', 'clipped-group') | |
# Be explicit about whether to update in realtime. Any graph marked with | |
# data-realtime will automatically fetch new data | |
# | |
# Returns Boolean | |
isRealTime = -> | |
el.attr('data-realtime') isnt null | |
# We want to be smart about the update interval and how many datapoints that | |
# are drawn on the screen at the same time so we determine that information from | |
# the first response that should contain a full set of data for the desired time | |
# range. Each subsequent request will only request new data from time that has | |
# transpired since the last fetech. | |
# | |
# datapoints - An array of graphite-style [date, val] datapoints returned from the server. | |
# | |
# Returns nothing | |
setConfigFromFirstPayload = (datapoints) -> | |
dataLength = datapoints.length | |
updateInterval = datapoints[1][1] - datapoints[0][1] | |
dataWindow = datapoints[datapoints.length - 1][1] - datapoints[0][1] | |
# Update the graph data url to fetch the last `updateInterval` seconds of data. | |
# | |
# url - A fully qualified url | |
# | |
# Returs a {String} | |
nextURL = (url) -> | |
return url if !isRealTime() or firstRun | |
url.replace(/from=-([0-9a-z]+)&?/i, "from=-#{updateInterval / 1000}s") | |
# Private: Fetch time series data from the url supplied via data-url | |
# on the graph. If an error occurs, the interval will be cleared and | |
# refreshign will be halted. | |
# | |
# Returns nothing | |
fetchData = -> | |
d3.json nextURL(params.url), (err, tsdata) -> | |
if err? | |
clearInterval intervalID | |
return console.log err | |
# TODO: Extract this to some other place | |
if firstRun | |
setConfigFromFirstPayload tsdata[0].datapoints | |
data = tsdata | |
firstRun = false | |
else | |
for series in tsdata | |
target = data.filter (d) -> d.target == series.target | |
if target.length is 1 and target = target[0] | |
lastpoint = target.datapoints[target.datapoints.length - 1] | |
#series.datapoints = series.datapoints.filter (s) -> s[1] is lastpoint[1] | |
target.datapoints = target.datapoints.concat series.datapoints | |
target.datapoints.sort (a, b) -> d3.ascending(a[1], b[1]) | |
clearInterval(intervalID) if !isRealTime() | |
tick() | |
# Private: Refresh the graph with updated data after a successful fetch | |
# | |
# Returns nothing | |
tick = -> | |
now = Date.now() | |
x.domain [now - dataWindow, now] | |
flatdata = data.map((d) -> d.datapoints).reduce((a, b) -> a.concat(b)) | |
yext = d3.extent flatdata, (d) -> d[0] | |
y.domain yext | |
xaxis.transition() | |
.duration(20) | |
.ease('linear') | |
.call xax | |
yaxis.transition() | |
.duration(20) | |
.ease('linear') | |
.call yax | |
path = clipgroup.selectAll('.path') | |
.data(data, (d) -> d.target) | |
path.enter().append('path') | |
.attr('class', (d) -> "path #{d.target.split('.').join(' ')}") | |
.attr('d', (d) -> line d.datapoints) | |
path.transition() | |
.attr('d', (d) -> line d.datapoints) | |
clipgroup.transition() | |
.duration(20) | |
.ease('linear') | |
# TODO discard old data outside of the data window | |
fetchData() | |
intervalID = setInterval fetchData, 5000 | |
# Search for all .js-graph instances in the DOM and draw their respective | |
# data to the screen | |
initialize = -> | |
for el in document.querySelectorAll '.js-graph' | |
draw el | |
document.addEventListener 'DOMContentLoaded', initialize |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment