summaryrefslogtreecommitdiffstats
path: root/debian/missing-sources/epoch/src/time/heatmap.coffee
blob: d7f2c509e02e7ad9456f898a1acc79c24d306365 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
# 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 <code>option:buckets</code> event.
  bucketsChanged: -> @redraw()

  # Changes the range of the buckets in response to an <code>option:bucketRange</code> event.
  bucketRangeChanged: ->
    @_transitionRangeAxes()
    @redraw()

  # Changes the opacity function in response to an <code>option:opacity</code> event.
  opacityChanged: ->
    @_setOpacityFunction()
    @redraw()

  # Changes the bucket padding in response to an <code>option:bucketPadding</code> event.
  bucketPaddingChanged: -> @redraw()

  # Changes whether or not to paint zeros in response to an <code>option:paintZeroValues</code> event.
  paintZeroValuesChanged: -> @redraw()

  # Changes whether or not to cut outliers when bucketing in response to an
  # <code>option:cutOutliers</code> event.
  cutOutliersChanged: -> @redraw()

  layerChanged: -> @redraw()

# "Audio... Audio... Audio... Video Disco..." - Justice