denolfe
2/16/2016 - 11:04 PM

Rickshaw Graph is a drop in replacement for Dashing's graph

Rickshaw Graph is a drop in replacement for Dashing's graph

// ----------------------------------------------------------------------------
// Sass declarations
// ----------------------------------------------------------------------------
$background-color:  #dc5945;

$title-color:       rgba(255, 255, 255, 0.7);
$moreinfo-color:    rgba(255, 255, 255, 0.5);
$tick-color:        rgba(0, 0, 0, 0.4);


// ----------------------------------------------------------------------------
// Widget-graph styles
// ----------------------------------------------------------------------------
.widget-rickshawgraph {

  background-color: $background-color;
  position: relative;

  .rickshaw_graph {
    position: absolute;
    left: 0px;
    top: 0px;
  }

  svg {
    position: absolute;
    left: 0px;
    top: 0px;
  }

  .title, .value {
    position: relative;
    z-index: 99;
  }

  .title {
    color: $title-color;
  }

  .more-info {
    color: $moreinfo-color;
    font-weight: 600;
    font-size: 20px;
    margin-top: 0;
    z-index: 99;
  }

  .x_tick {
    position: absolute;
    bottom: 0;
    .title {
      font-size: 20px;
      color: $tick-color;
      opacity: 0.5;
      padding-bottom: 3px;
    }
  }

  .y_ticks {
    font-size: 20px;
    fill: $tick-color;
    text {
      opacity: 0.5;
    }
  }

  .domain {
    display: none;
  }

  .rickshaw_legend {
    position: absolute;
    left: 0px;
    bottom: 0px;
    white-space: nowrap;
    overflow-x: hidden;
    font-size: 15px;
    height: 20px;

    ul {
      margin: 0;
      padding: 0;
      list-style-type: none;
      text-align: center;
    }

    ul li {
      display: inline;
    }

    .swatch {
      display: inline-block;
      width: 14px;
      height: 14px;
      margin-left: 5px;
    }

    .label {
      display: inline-block;
      margin-left: 5px;
    }
  }

}
<h1 class="title" data-bind="title"></h1>

<h2 class="value" data-bind="current | prepend prefix"></h2>

<p class="more-info" data-bind="moreinfo"></p>
# Rickshawgraph v0.1.2

class Dashing.Rickshawgraph extends Dashing.Widget

  DIVISORS = [
    {number: 100000000000000000000000,  label: 'Y'},
    {number: 100000000000000000000,     label: 'Z'},
    {number: 100000000000000000,        label: 'E'},
    {number: 1000000000000000,          label: 'P'},
    {number: 1000000000000,             label: 'T'},
    {number: 1000000000,                label: 'G'},
    {number: 1000000,                   label: 'M'},
    {number: 1000,                      label: 'K'}
  ]

  # Take a long number like "2356352" and turn it into "2.4M"
#  formatNumber = (number) ->
#    for divisior in DIVISORS
#      if number > divisior.number
#        number = "#{Math.round(number / (divisior.number/10))/10}#{divisior.label}"
#        break
#
#    return number

  formatNumber = (x) ->
    x.toString().replace /\B(?=(\d{3})+(?!\d))/g, ','

  getRenderer: () -> return @get('renderer') or @get('graphtype') or 'area'

  # Retrieve the `current` value of the graph.
  @accessor 'current', ->
    answer = null
    console.log 'value: ' + answer
    # Return the value supplied if there is one.
    if @get('displayedValue') != null and @get('displayedValue') != undefined
      answer = @get('displayedValue')

    if answer == null
      console.log 'answer is null'
# Compute a value to return based on the summaryMethod
      series = @_parseData {points: @get('points'), series: @get('series')}
      if !(series?.length > 0)
# No data in series
        console.log 'no data in series'
        answer = ''

      else
        switch @get('summaryMethod')
          when "sum"
            console.log 'sumMethod: sum'
            answer = 0
            answer += (point?.y or 0) for point in s.data for s in series

          when "sumLast"
            console.log 'sumMethod: sumLast'
            answer = 0
            answer += s.data[s.data.length - 1].y or 0 for s in series

          when "highest"
            console.log 'sumMethod: highest'
            answer = 0
            if @get('unstack') or (@getRenderer() is "line")
              answer = Math.max(answer, (point?.y or 0)) for point in s.data for s in series
            else
# Compute the sum of values at each point along the graph
              for index in [0...series[0].data.length]
                value = 0
                for s in series
                  value += s.data[index]?.y or 0
                answer = Math.max(answer, value)

          when "none"
            console.log 'sumMethod: none'
            answer = ''

          else
            console.log 'only 1 series'
# Otherwise if there's only one series, pick the most recent value from the series.
            if series.length == 1 and series[0].data?.length > 0
              data = series[0].data
              answer = data[data.length - 1].y
            else if series.length > 1 and series[0].data?.length > 0
              data = series[0].data
              answer = data[data.length - 1].y
            else
# Otherwise just return nothing.
              console.log 'return nothing'
              answer = ''

        answer = formatNumber answer

    return answer


  ready: ->
    @assignedColors = @get('colors').split(':') if @get('colors')
    @strokeColors = @get('strokeColors').split(':') if @get('strokeColors')

    @graph = @_createGraph()
    @graph.render()

  clear: ->
# Remove the old graph/legend if there is one.
    $node = $(@node)
    $node.find('.rickshaw_graph').remove()
    if @$legendDiv
      @$legendDiv.remove()
      @$legendDiv = null

# Handle new data from Dashing.
  onData: (data) ->
    series = @_parseData data

    if @graph
# Remove the existing graph if the number of series has changed or any names have changed.
      needClear = false
      needClear |= (series.length != @graph.series.length)
      if @get("legend") then for subseries, index in series
        needClear |= @graph.series[index]?.name != series[index]?.name

      if needClear then @graph = @_createGraph()

      # Copy over the new graph data
      for subseries, index in series
        @graph.series[index] = subseries

      @graph.render()

# Create a new Rickshaw graph.
  _createGraph: ->
    $node = $(@node)
    $container = $node.parent()

    @clear()

    # Gross hacks. Let's fix this.
    width  = (Dashing.widget_base_dimensions[0] * $container.data("sizex")) + Dashing.widget_margins[0] * 2 * (($container.data("sizex") ? 1) - 1)
    height = (Dashing.widget_base_dimensions[1] * $container.data("sizey")) + Dashing.widget_margins[1] * 2 * (($container.data("sizey") ? 1) - 1)

    if @get("legend")
# Shave 20px off the bottom of the graph for the legend
      height -= 20

    $graph = $("<div style='height: #{height}px;'></div>")
    $node.append $graph
    series = @_parseData {points: @get('points'), series: @get('series')}

    graphOptions = {
      element:  $graph.get(0),
      renderer: @getRenderer(),
      width:    width,
      height:   height,
      series:   series
    }

    if !!@get('stroke') then graphOptions.stroke = true
    if @get('min') != null then graphOptions.min = @get('min')
    if @get('max') != null then graphOptions.max = @get('max')

    try
      graph = new Rickshaw.Graph graphOptions
    catch err
      nullsFound = false
      if err.toString() is "x and y properties of points should be numbers instead of number and object"
# This will happen with older versions of Rickshaw that don't support nulls in the data set.
        for s in series
          for point in s.data
            if point.y is null
              nullsFound = true
              point.y = 0

      if nullsFound
# Try to create the graph again now that we've patched up the data.
        graph = new Rickshaw.Graph graphOptions
        if !@rickshawVersionWarning
          console.log "#{@get 'id'} - Nulls were found in your data, but Rickshaw didn't like" +
              " them.  Consider upgrading your rickshaw to 1.4.3 or higher."
          @rickshawVersionWarning = true
      else
# No nulls were found - this is some other problem, so just re-throw the exception.
        throw err

    graph.renderer.unstack = !!@get('unstack')

    xAxisOptions =  {
      graph: graph
      tickFormat: (x) ->
        if x == 0
          ''
        else
          30 - x
    }
    if Rickshaw.Fixtures.Time.Local
      xAxisOptions.timeFixture = new Rickshaw.Fixtures.Time.Local()

    x_axis = new Rickshaw.Graph.Axis.X xAxisOptions
    y_axis = new Rickshaw.Graph.Axis.Y(graph: graph, tickFormat: Rickshaw.Fixtures.Number.formatKMBT)

    if @get("legend")
# Add a legend
      @$legendDiv = $("<div style='width: #{width}px;'></div>")
      $node.append(@$legendDiv)
      legend = new Rickshaw.Graph.Legend {
        graph: graph
        element: @$legendDiv.get(0)
      }

    return graph

# Parse a {series, points} object with new data from Dashing.
#
  _parseData: (data) ->
    series = []

    # Figure out what kind of data we've been passed
    if data.series
      dataSeries = if isString(data.series) then JSON.parse data.series else data.series
      for subseries, index in dataSeries
        try
          series.push @_parseSeries subseries
        catch err
          console.log "Error while parsing series: #{err}"

    else if data.points
      points = data.points
      if isString(points) then points = JSON.parse points

      if points[0]? and !points[0].x?
# Not already in Rickshaw format; assume graphite data
        points = graphiteDataToRickshaw(points)

      series.push {data: points}

    if series.length is 0
# No data - create a dummy series to keep Rickshaw happy
      series.push {data: [{x:0, y:0}]}

    @_updateColors(series)

    # Fix any missing data in the series.
    if Rickshaw.Series.fill then Rickshaw.Series.fill(series, null)

    return series

# Parse a series of data from an array passed to `_parseData()`.
# This accepts both Graphite and Rickshaw style data sets.
  _parseSeries: (series) ->
    if series?.datapoints?
# This is a Graphite series
      answer = {
        name: series.target
        data: graphiteDataToRickshaw series.datapoints
        color: series.color
        stroke: series.stroke
      }
    else if series?.data?
# Rickshaw data.  Need to clone, otherwise we could end up with multiple graphs sharing
# the same data, and Rickshaw really doesn't like that.
      answer = {
        name:   series.name
        data:   series.data
        color:  series.color
        stroke: series.stroke
      }
    else if !series
      throw new Error("No data received for #{@get 'id'}")
    else
      throw new Error("Unknown data for #{@get 'id'}.  series: #{series}")

    answer.data.sort (a,b) -> a.x - b.x

    return answer

# Update the color assignments for a series.  This will assign colors to any data that
# doesn't have a color already.
  _updateColors: (series) ->
# If no colors were provided, or of there aren't enough colors, then generate a set of
# colors to use.
    if !@defaultColors or @defaultColors?.length != series.length
      @defaultColors = computeDefaultColors @, @node, series

    for subseries, index in series
# Preferentially pick supplied colors instead of defaults, but don't overwrite a color
# if one was supplied with the data.
      subseries.color ?= @assignedColors?[index] or @defaultColors[index]
      subseries.stroke ?= @strokeColors?[index] or "#000"

  # Convert a collection of Graphite data points into data that Rickshaw will understand.
  graphiteDataToRickshaw = (datapoints) ->
    answer = []
    for datapoint in datapoints
# Need to convert potential nulls from Graphite into a real number for Rickshaw.
      answer.push {x: datapoint[1], y: (datapoint[0] or 0)}
    answer

  # Compute a pleasing set of default colors.  This works by starting with the background color,
  # and picking colors of intermediate luminance between the background and white (or the
  # background and black, for light colored backgrounds.)  We use the brightest color for the
  # first series, because then multiple series will appear to blend in to the background.
  computeDefaultColors = (self, node, series) ->
    defaultColors = []

    # Use a neutral color if we can't get the background-color for some reason.
    backgroundColor = parseColor($(node).css('background-color')) or [50, 50, 50, 1.0]
    hsl = rgbToHsl backgroundColor

    alpha = if self.get('defaultAlpha')? then self.get('defaultAlpha') else 1

    if self.get('colorScheme') in ['rainbow', 'near-rainbow']
      saturation = (interpolate hsl[1], 1.0, 3)[1]
      luminance = if (hsl[2] < 0.6) then 0.7 else 0.3

      hueOffset = 0
      if self.get('colorScheme') is 'rainbow'
# Note the first and last values in `hues` will both have the same hue as the background,
# hence the + 2.
        hues = interpolate hsl[0], hsl[0] + 1, (series.length + 2)
        hueOffset = 1
      else
        hues = interpolate hsl[0] - 0.25, hsl[0] + 0.25, series.length
      for hue, index in hues
        if hue > 1 then hues[index] -= 1
        if hue < 0 then hues[index] += 1

      for index in [0...series.length]
        defaultColors[index] = rgbToColor hslToRgb([hues[index + hueOffset], saturation, luminance, alpha])

    else
      hue = if self.get('colorScheme') is "compliment" then hsl[0] + 0.5 else hsl[0]
      if hsl[0] > 1 then hsl[0] -= 1

      saturation = hsl[1]
      saturationSource = if (saturation < 0.6) then 0.7 else 0.3
      saturations = interpolate saturationSource, saturation, (series.length + 1)

      luminance = hsl[2]
      luminanceSource = if (luminance < 0.6) then 0.9 else 0.1
      luminances = interpolate luminanceSource, luminance, (series.length + 1)

      for index in [0...series.length]
        defaultColors[index] = rgbToColor hslToRgb([hue, saturations[index], luminances[index], alpha])

    return defaultColors



# Helper functions
# ================
isString = (obj) ->
  return toString.call(obj) is "[object String]"

# Parse a `rgb(x,y,z)` or `rgba(x,y,z,a)` string.
parseRgbaColor = (colorString) ->
  match = /^rgb\(\s*([\d]+)\s*,\s*([\d]+)\s*,\s*([\d]+)\s*\)/.exec(colorString)
  if match
    return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3]), 1.0]

  match = /^rgba\(\s*([\d]+)\s*,\s*([\d]+)\s*,\s*([\d]+)\s*,\s*([\d]+)\s*\)/.exec(colorString)
  if match
    return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3]), parseInt(match[4])]

  return null

# Parse a color string as RGBA
parseColor = (colorString) ->
  answer = null

  # Try to use the browser to parse the color for us.
  div = document.createElement('div')
  div.style.color = colorString
  if div.style.color
    answer = parseRgbaColor div.style.color

  if !answer
    match = /^#([\da-fA-F]{2})([\da-fA-F]{2})([\da-fA-F]{2})/.exec(colorString)
    if match then answer = [parseInt(match[1], 16), parseInt(match[2], 16), parseInt(match[3], 16), 1.0]

  if !answer
    match = /^#([\da-fA-F])([\da-fA-F])([\da-fA-F])/.exec(colorString)
    if match then answer = [parseInt(match[1], 16) * 0x11, parseInt(match[2], 16) * 0x11, parseInt(match[3], 16) * 0x11, 1.0]

  if !answer then answer = parseRgbaColor colorString

  return answer

# Convert an RGB or RGBA color to a CSS color.
rgbToColor = (rgb) ->
  if (!3 of rgb) or (rgb[3] == 1.0)
    return "rgb(#{rgb[0]},#{rgb[1]},#{rgb[2]})"
  else
    return "rgba(#{rgb[0]},#{rgb[1]},#{rgb[2]},#{rgb[3]})"

# Returns an array of size `steps`, where the first value is `source`, the last value is `dest`,
# and the intervening values are interpolated.  If steps < 2, then returns `[dest]`.
#
interpolate = (source, dest, steps) ->
  if steps < 2
    answer =[dest]
  else
    stepSize = (dest - source) / (steps - 1)
    answer = (num for num in [source..dest] by stepSize)
    # Rounding errors can cause us to drop the last value
    if answer.length < steps then answer.push dest

  return answer

# Adapted from http://axonflux.com/handy-rgb-to-hsl-and-rgb-to-hsv-color-model-c
#
# Converts an RGBA color value to HSLA. Conversion formula
# adapted from http://en.wikipedia.org/wiki/HSL_color_space.
# Assumes r, g, and b are contained in the set [0, 255] and
# a in [0, 1].  Returns h, s, l, a in the set [0, 1].
#
# Returns the HSLA representation as an array.
rgbToHsl = (rgba) ->
  [r,g,b,a] = rgba
  r /= 255
  g /= 255
  b /= 255
  max = Math.max(r, g, b)
  min = Math.min(r, g, b)
  l = (max + min) / 2

  if max == min
    h = s = 0 # achromatic
  else
    d = max - min
    s = if l > 0.5 then d / (2 - max - min) else d / (max + min)
    switch max
      when r then h = (g - b) / d + (g < b ? 6 : 0)
      when g then h = (b - r) / d + 2
      when b then h = (r - g) / d + 4
    h /= 6;

  return [h, s, l, a]

# Adapted from http://axonflux.com/handy-rgb-to-hsl-and-rgb-to-hsv-color-model-c
#
# Converts an HSLA color value to RGBA. Conversion formula
# adapted from http://en.wikipedia.org/wiki/HSL_color_space.
# Assumes h, s, l, and a are contained in the set [0, 1] and
# returns r, g, and b in the set [0, 255] and a in [0, 1].
#
# Retunrs the RGBA representation as an array.
hslToRgb = (hsla) ->
  [h,s,l,a] = hsla
  if s is 0
    r = g = b = l # achromatic
  else
    hue2rgb = (p, q, t) ->
      if(t < 0)   then t += 1
      if(t > 1)   then t -= 1
      if(t < 1/6) then return p + (q - p) * 6 * t
      if(t < 1/2) then return q
      if(t < 2/3) then return p + (q - p) * (2/3 - t) * 6
      return p

    q = if l < 0.5 then l * (1 + s) else l + s - l * s
    p = 2 * l - q;
    r = hue2rgb(p, q, h + 1/3)
    g = hue2rgb(p, q, h)
    b = hue2rgb(p, q, h - 1/3)

  return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255), a]

Graphing Widget

The graphing widget shows graphs using the Rickshaw graphing library. The names of data fields should be (vaguely) familiar if you've used Rickshaw before.

It's recommended that you replace the /assets/javascripts/rickshaw.min.js from your dashboard with the latest from here.

Supported HTML data fields

  • data-title: Title to display.
  • data-displayed-value: If provided, then the value to display overtop of the graph. If not provided, then the most recent value will be used if there is only one series.
  • data-renderer: Any valid Rickshaw renderer, including 'area', 'line', 'bar', 'scatterplot'.
  • data-stroke: If "true", then area graphs will be drawn with a stroke.
  • data-unstack: If "true", then area and bar graphs will be "unstacked".
  • data-colors: A ":" separated list of colors to use for each plot. If there are fewer colors provided than there are series to graph, then pleasing colors will be automatically chosen. (e.g.: data-colors="#ccc:#ddd:#eee")
  • data-stroke-colors: A ":" separated list of colors to use for strokes.
  • data-color-scheme: One of "rainbow", "near-rainbow", "compliment", "default". Controls how default colors are assigned.
  • data-default-alpha: Alpha for default colors.
  • data-legend: If "true", then a legend will be added to your graph.
  • data-min and data-max: Set the highest and lowest values of the y-axis.
  • data-summary-method determines how the value shown in the graph is computed. If data-displayed-value is set, this is ignored. Otherwise this should be one of:
    • "last" - Default - If there is only one series, show the most recent value from that series.
    • "sum" - Sum of all values across all series.
    • "sumLast" - Sum of last values across all series.
    • "highest" - For stacked graphs, the highest single data point based on the sum of all series. For unstacked graphs, the highest single data point of any series.

Passing Data

Data can be provided in a number of formats. Data can be passed as a series of points:

points = [{x:1, y: 4}, {x:2, y:27}, {x:3, y:6}]
send_event('convergence', points: points)

Note that the x values are interpreted as unix timestamps. Data can also be passed as full-on Rickshaw-style series:

series = [
    {
        name: "Convergence",
        data: [{x:1, y: 4}, {x:2, y:27}, {x:3, y:6}]
    },
    {
        name: "Divergence",
        data: [{x:1, y: 5}, {x:2, y:2}, {x:3, y:9}]
    }
]
send_event('convergence', series: series)

You can even provide colors and strokes here, which will override the values defined in the HTML. Or data can be passed as Graphite-style data:

graphite = [
  {
    target: "stats_counts.http.ok",
    datapoints: [[10, 1378449600], [40, 1378452000], [53, 1378454400], [63, 1378456800], [27, 1378459200]]
  },
  {
    target: "stats_counts.http.err",
    datapoints: [[0, 1378449600], [4, 1378452000], [nil, 1378454400], [3, 1378456800], [0, 1378459200]]
  }
]
send_event('http', series: graphite)

You can even send data as JSON strings, straight from Graphite:

require "rest-client"
SCHEDULER.every '10s', :first_in => 0 do
    target = "aliasSub(summarize(stats_counts.http.*%2C%2720min%27)%2C%27%5E.*http.(%5Cw*).*%27%2C%27%5C1%27)"
    url = "http://graphteserver.local:8000/render?format=json&target=#{target}&from=today"
    graphite_json_data = RestClient.get url
    send_event 'http_counts', { series: graphite_json_data }
end