Skip to content

Instantly share code, notes, and snippets.

@caged
Created December 16, 2014 17:41
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 caged/ef793b28ed7e05ebc69d to your computer and use it in GitHub Desktop.
Save caged/ef793b28ed7e05ebc69d to your computer and use it in GitHub Desktop.
Autoupdating graphite timeseries in d3
#= 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&amp;from=-1hour" data-realtime></div>
# <div class="js-graph" data-url="/graphite?target=haystack-production.apps.github.needle&amp;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