# Real-time Heatmap Implementation.
class Epoch.Time.Heatmap extends Epoch.Time.Plot
defaults =
type: 'time.heatmap'
buckets: 10
bucketRange: [0, 100]
opacity: 'linear'
bucketPadding: 2
paintZeroValues: false
cutOutliers: false
# Easy to use "named" color functions
colorFunctions =
root: (value, max) -> Math.pow(value/max, 0.5)
linear: (value, max) -> value / max
quadratic: (value, max) -> Math.pow(value/max, 2)
cubic: (value, max) -> Math.pow(value/max, 3)
quartic: (value, max) -> Math.pow(value/max, 4)
quintic: (value, max) -> Math.pow(value/max, 5)
optionListeners =
'option:buckets': 'bucketsChanged'
'option:bucketRange': 'bucketRangeChanged'
'option:opacity': 'opacityChanged'
'option:bucketPadding': 'bucketPaddingChanged'
'option:paintZeroValues': 'paintZeroValuesChanged'
'option:cutOutliers': 'cutOutliersChanged'
# Creates a new heatmap.
# @param [Object] options Options for the heatmap.
# @option options [Integer] buckets Number of vertical buckets to use when normalizing the
# incoming histogram data for visualization in the heatmap (default: 10).
# @option options [Array] bucketRange A range of acceptable values to be bucketed (default: [0, 100]).
# @option options [String, Function] opacity The opacity coloring function to use when rendering buckets
# in a column. The built-in functions (referenced by string) are: 'root', 'linear', 'quadratic', 'cubic',
# 'quartic', and 'quintic'. A custom function can be supplied given it accepts two parameters (value, max)
# and returns a numeric value from 0 to 1. Default: linear.
# @option options [Number] bucketPadding Amount of padding to apply around buckets (default: 2).
constructor: (@options={}) ->
super(@options = Epoch.Util.defaults(@options, defaults))
@_setOpacityFunction()
@_setupPaintCanvas()
@onAll optionListeners
@draw()
_setOpacityFunction: ->
if Epoch.isString(@options.opacity)
@_opacityFn = colorFunctions[@options.opacity]
Epoch.exception "Unknown coloring function provided '#{@options.opacity}'" unless @_opacityFn?
else if Epoch.isFunction(@options.opacity)
@_opacityFn = @options.opacity
else
Epoch.exception "Unknown type for provided coloring function."
# Prepares initially set data for rendering.
# @param [Array] data Layered histogram data for the visualization.
setData: (data) ->
super(data)
for layer in @data
layer.values = layer.values.map((entry) => @_prepareEntry(entry))
# Distributes the full histogram in the entry into the defined buckets
# for the visualization.
# @param [Object] entry Entry to prepare for visualization.
_getBuckets: (entry) ->
prepared =
time: entry.time
max: 0
buckets: (0 for i in [0...@options.buckets])
# Bucket size = (Range[1] - Range[0]) / number of buckets
bucketSize = (@options.bucketRange[1] - @options.bucketRange[0]) / @options.buckets
for own value, count of entry.histogram
index = parseInt((value - @options.bucketRange[0]) / bucketSize)
# Remove outliers from the preprared buckets if instructed to do so
if @options.cutOutliers and ((index < 0) or (index >= @options.buckets))
continue
# Bound the histogram to the range (aka, handle out of bounds values)
if index < 0
index = 0
else if index >= @options.buckets
index = @options.buckets - 1
prepared.buckets[index] += parseInt count
for i in [0...prepared.buckets.length]
prepared.max = Math.max(prepared.max, prepared.buckets[i])
return prepared
# @return [Function] The y scale for the heatmap.
y: ->
d3.scale.linear()
.domain(@options.bucketRange)
.range([@innerHeight(), 0])
# @return [Function] The y scale for the svg portions of the heatmap.
ySvg: ->
d3.scale.linear()
.domain(@options.bucketRange)
.range([@innerHeight() / @pixelRatio, 0])
# @return [Number] The height to render each bucket in a column (disregards padding).
h: ->
@innerHeight() / @options.buckets
# @return [Number] The offset needed to center ticks at the middle of each column.
_offsetX: ->
0.5 * @w() / @pixelRatio
# Creates the painting canvas which is used to perform all the actual drawing. The contents
# of the canvas are then copied into the actual display canvas and through some image copy
# trickery at the end of a transition the illusion of motion over time is preserved.
#
# Using two canvases in this way allows us to render an incredible number of buckets in the
# visualization and animate them at high frame rates without smashing the cpu.
_setupPaintCanvas: ->
# Size the paint canvas to have a couple extra columns so we can perform smooth transitions
@paintWidth = (@options.windowSize + 1) * @w()
@paintHeight = @height * @pixelRatio
# Create the "memory only" canvas and nab the drawing context
@paint = document.createElement('CANVAS')
@paint.width = @paintWidth
@paint.height = @paintHeight
@p = Epoch.Util.getContext @paint
# Paint the initial data (rendering backwards from just before the fixed paint position)
@redraw()
# Hook into the events to paint the next row after it's been shifted into the data
@on 'after:shift', '_paintEntry'
# At the end of a transition we must reset the paint canvas by shifting the viewable
# buckets to the left (this allows for a fixed cut point and single renders below in @draw)
@on 'transition:end', '_shiftPaintCanvas'
@on 'transition:end', => @draw(@animation.frame * @animation.delta())
# Redraws the entire heatmap for the current data.
redraw: ->
return unless Epoch.isNonEmptyArray(@data) and Epoch.isNonEmptyArray(@data[0].values)
entryIndex = @data[0].values.length
drawColumn = @options.windowSize
# This addresses a strange off-by-one issue when the chart is transitioning
drawColumn++ if @inTransition()
while (--entryIndex >= 0) and (--drawColumn >= 0)
@_paintEntry(entryIndex, drawColumn)
@draw(@animation.frame * @animation.delta())
# Computes the correct color for a given bucket.
# @param [Integer] value Normalized value at the bucket.
# @param [Integer] max Normalized maximum for the column.
# @param [String] color Computed base color for the bucket.
_computeColor: (value, max, color) ->
Epoch.Util.toRGBA(color, @_opacityFn(value, max))
# Paints a single entry column on the paint canvas at the given column.
# @param [Integer] entryIndex Index of the entry to paint.
# @param [Integer] drawColumn Column on the paint canvas to place the visualized entry.
_paintEntry: (entryIndex=null, drawColumn=null) ->
[w, h] = [@w(), @h()]
entryIndex ?= @data[0].values.length - 1
drawColumn ?= @options.windowSize
entries = []
bucketTotals = (0 for i in [0...@options.buckets])
maxTotal = 0
for layer in @getVisibleLayers()
entry = @_getBuckets( layer.values[entryIndex] )
for own bucket, count of entry.buckets
bucketTotals[bucket] += count
maxTotal += entry.max
styles = @getStyles ".#{layer.className.split(' ').join('.')} rect.bucket"
entry.color = styles.fill
entries.push entry
xPos = drawColumn * w
@p.clearRect xPos, 0, w, @paintHeight
j = @options.buckets
for own bucket, sum of bucketTotals
color = @_avgLab(entries, bucket)
max = 0
for entry in entries
max += (entry.buckets[bucket] / sum) * maxTotal
if sum > 0 or @options.paintZeroValues
@p.fillStyle = @_computeColor(sum, max, color)
@p.fillRect xPos, (j-1) * h, w-@options.bucketPadding, h-@options.bucketPadding
j--
# This shifts the image contents of the paint canvas to the left by 1 column width.
# It is called after a transition has ended (yay, slight of hand).
_shiftPaintCanvas: ->
data = @p.getImageData @w(), 0, @paintWidth-@w(), @paintHeight
@p.putImageData data, 0, 0
# Performs an averaging of the colors for muli-layer heatmaps using the lab color space.
# @param [Array] entries The layers for which the colors are to be averaged.
# @param [Number] bucket The bucket in the entries that must be averaged.
# @return [String] The css color code for the average of all the layer colors.
_avgLab: (entries, bucket) ->
[l, a, b, total] = [0, 0, 0, 0]
for entry in entries
continue unless entry.buckets[bucket]?
total += entry.buckets[bucket]
for own i, entry of entries
if entry.buckets[bucket]?
value = entry.buckets[bucket]|0
else
value = 0
ratio = value / total
color = d3.lab(entry.color)
l += ratio * color.l
a += ratio * color.a
b += ratio * color.b
d3.lab(l, a, b).toString()
# Copies the paint canvas onto the display canvas, thus rendering the heatmap.
draw: (delta=0) ->
@clear()
@ctx.drawImage @paint, delta, 0
super()
# Changes the number of buckets in response to an option:buckets
event.
bucketsChanged: -> @redraw()
# Changes the range of the buckets in response to an option:bucketRange
event.
bucketRangeChanged: ->
@_transitionRangeAxes()
@redraw()
# Changes the opacity function in response to an option:opacity
event.
opacityChanged: ->
@_setOpacityFunction()
@redraw()
# Changes the bucket padding in response to an option:bucketPadding
event.
bucketPaddingChanged: -> @redraw()
# Changes whether or not to paint zeros in response to an option:paintZeroValues
event.
paintZeroValuesChanged: -> @redraw()
# Changes whether or not to cut outliers when bucketing in response to an
# option:cutOutliers
event.
cutOutliersChanged: -> @redraw()
layerChanged: -> @redraw()
# "Audio... Audio... Audio... Video Disco..." - Justice