|
# 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 |
|
|
|
getRenderer: () -> return @get('renderer') or @get('graphtype') or 'area' |
|
|
|
# Retrieve the `current` value of the graph. |
|
@accessor 'current', -> |
|
answer = null |
|
|
|
# Return the value supplied if there is one. |
|
if @get('displayedValue') != null and @get('displayedValue') != undefined |
|
answer = @get('displayedValue') |
|
|
|
if answer == 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 |
|
answer = '' |
|
|
|
else |
|
switch @get('summaryMethod') |
|
when "sum" |
|
answer = 0 |
|
answer += (point?.y or 0) for point in s.data for s in series |
|
|
|
when "sumLast" |
|
answer = 0 |
|
answer += s.data[s.data.length - 1].y or 0 for s in series |
|
|
|
when "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" |
|
answer = '' |
|
|
|
else |
|
# 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 |
|
# Otherwise just 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 |
|
} |
|
if Rickshaw.Fixtures.Time.Local |
|
xAxisOptions.timeFixture = new Rickshaw.Fixtures.Time.Local() |
|
|
|
x_axis = new Rickshaw.Graph.Axis.Time 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] |
Pretty please? Or point to an example git repo with sample jobs posting data in the proper formats to sample dashboards which display said data for the supported data types? It's mildly difficult for me to piece together all the bits, and examples speak volumes as to how the different parts play together.