# Real-time Plot Base Class. Uses an html5 canvas to recreate the basic d3 drawing routines # while simultaneously reducing the load on the viewer's cpu (and, you know, not leaking # memory which ultimately leads to a crashed browser). # # The class also handles the creation of axes and margins common to all time-series plots. # Furthermore it layers the canvas below an SVG element to keep visual consistency when # rendering text, glyphs, etc. class Epoch.Time.Plot extends Epoch.Chart.Canvas defaults = range: null fps: 24 historySize: 120 windowSize: 40 queueSize: 10 axes: ['bottom'] ticks: time: 15 left: 5 right: 5 tickFormats: top: Epoch.Formats.seconds bottom: Epoch.Formats.seconds left: Epoch.Formats.si right: Epoch.Formats.si defaultAxisMargins = top: 25 right: 50 bottom: 25 left: 50 optionListeners = 'option:margins': 'marginsChanged' 'option:margins.top': 'marginsChanged' 'option:margins.right': 'marginsChanged' 'option:margins.bottom': 'marginsChanged' 'option:margins.left': 'marginsChanged' 'option:axes': 'axesChanged' 'option:ticks': 'ticksChanged' 'option:ticks.top': 'ticksChanged' 'option:ticks.right': 'ticksChanged' 'option:ticks.bottom': 'ticksChanged' 'option:ticks.left': 'ticksChanged' 'option:tickFormats': 'tickFormatsChanged' 'option:tickFormats.top': 'tickFormatsChanged' 'option:tickFormats.right': 'tickFormatsChanged' 'option:tickFormats.bottom': 'tickFormatsChanged' 'option:tickFormats.left': 'tickFormatsChanged' # Creates a new real-time plot. # # @param [Object] options Options for the plot. # @option options [Integer] fps Number of frames per second to use when animating # the plot. # @option options [Integer] historySize Maximum number of elements to keep in history # for the plot. # @option options [Integer] windowSize Number of entries to simultaneously display # when rendering the visualization. # @option options [Integer] queueSize Number of elements to queue while not animating # but still recieving elements. In some browsers, intervals will not fire if the # page containing them is not the active tab. By setting a maximum limit to the # number of unprocessed data points we can ensure that the memory footprint of the # page does not get out of hand. # @option options [Object] margins Explicit margins to use for the visualization. Note # that these are optional and will be automatically generated based on which axes are # used for the visualization. Margins are keyed by their position (top, left, bottom # and/or right) and should map to [Integer] values. # @option options [Array] axes Which axes to display when rendering the visualization # (top, left, bottom, and/or right). # @option options [Object] ticks Number of ticks to display on each axis available axes # ares: time, left, and right. The number provided for the left and right axes are in # absolute terms (i.e. there will be exactly that number of ticks). The time ticks # denote how often a tick should be generated (e.g. if 5 is provided then a tick will # be added every fifth time you push a new data entry into the visualization). # @option options [Object] tickFormats Formatting functions for ticks on the given axes. # The avaiable axes are: top, bottom, left, and right. constructor: (@options) -> givenMargins = Epoch.Util.copy(@options.margins) or {} super(@options = Epoch.Util.defaults(@options, defaults)) if @options.model @options.model.on 'data:push', => @pushFromModel() # Queue entering data to get around memory bloat and "non-active" tab issues @_queue = [] # Margins @margins = {} for pos in ['top', 'right', 'bottom', 'left'] @margins[pos] = if @options.margins? and @options.margins[pos]? @options.margins[pos] else if @hasAxis(pos) defaultAxisMargins[pos] else 6 # SVG Overlay @svg = @el.insert('svg', ':first-child') .attr('width', @width) .attr('height', @height) .style('z-index', '1000') # Position the canvas "under" the SVG element if @el.style('position') != 'absolute' and @el.style('position') != 'relative' @el.style('position', 'relative') @canvas.style { position: 'absolute', 'z-index': '999' } @_sizeCanvas() # Animation / Transitions @animation = interval: null active: false delta: => -(@w() / @options.fps), tickDelta: => -( (@w() / @pixelRatio) / @options.fps ) frame: 0, duration: @options.fps # Add SVG Axes @_buildAxes() # Callback used for animation @animationCallback = => @_animate() # Listen for specific option changes @onAll optionListeners # Positions and sizes the canvas based on margins and axes. _sizeCanvas: -> @canvas.attr width: @innerWidth() height: @innerHeight() @canvas.style width: "#{@innerWidth() / @pixelRatio}px" height: "#{@innerHeight() / @pixelRatio}px" top: "#{@margins.top}px" left: "#{@margins.left}px" # Removes any axes found in the SVG and adds both the time and range axes to the plot. _buildAxes: -> @svg.selectAll('.axis').remove() @_prepareTimeAxes() @_prepareRangeAxes() # Works exactly as in Epoch.Chart.Base with the addition of truncating value arrays # to that of the historySize defined in the chart's options. _annotateLayers: (prepared) -> data = [] for own i, layer of prepared copy = Epoch.Util.copy(layer) start = Math.max(0, layer.values.length - @options.historySize) copy.values = layer.values.slice(start) classes = ['layer'] classes.push "category#{(i|0)+1}" classes.push(Epoch.Util.dasherize layer.label) if layer.label? copy.className = classes.join(' ') copy.visible = true data.push copy return data # This method is called to provide a small offset for placement of horizontal ticks. # The value returned will be added to the x value of each tick as they are being # rendered. # # @return [Number] The horizontal offset for the top and bottom axes ticks. _offsetX: -> 0 # Builds time axes (bottom and top) _prepareTimeAxes: -> if @hasAxis('bottom') axis = @bottomAxis = @svg.append('g') .attr('class', "x axis bottom canvas") .attr('transform', "translate(#{@margins.left-1}, #{@innerHeight()/@pixelRatio+@margins.top})") axis.append('path') .attr('class', 'domain') .attr('d', "M0,0H#{@innerWidth()/@pixelRatio+1}") if @hasAxis('top') axis = @topAxis = @svg.append('g') .attr('class', "x axis top canvas") .attr('transform', "translate(#{@margins.left-1}, #{@margins.top})") axis.append('path') .attr('class', 'domain') .attr('d', "M0,0H#{@innerWidth()/@pixelRatio+1}") @_resetInitialTimeTicks() # Resets the initial ticks for the time axes. _resetInitialTimeTicks: -> tickInterval = @options.ticks.time @_ticks = [] @_tickTimer = tickInterval @bottomAxis.selectAll('.tick').remove() if @bottomAxis? @topAxis.selectAll('.tick').remove() if @topAxis? for layer in @data continue unless Epoch.isNonEmptyArray(layer.values) [i, k] = [@options.windowSize-1, layer.values.length-1] while i >= 0 and k >= 0 @_pushTick i, layer.values[k].time, false, true i -= tickInterval k -= tickInterval break # Builds the range axes (left and right) _prepareRangeAxes: -> if @hasAxis('left') @svg.append("g") .attr("class", "y axis left") .attr('transform', "translate(#{@margins.left-1}, #{@margins.top})") .call(@leftAxis()) if @hasAxis('right') @svg.append('g') .attr('class', 'y axis right') .attr('transform', "translate(#{@width - @margins.right}, #{@margins.top})") .call(@rightAxis()) # @return [Object] The d3 left axis. leftAxis: -> ticks = @options.ticks.left axis = d3.svg.axis().scale(@ySvgLeft()).orient('left') .tickFormat(@options.tickFormats.left) if ticks == 2 axis.tickValues @extent((d) -> d.y) else axis.ticks(ticks) # @return [Object] The d3 right axis. rightAxis: -> extent = @extent((d) -> d.y) ticks = @options.ticks.right axis = d3.svg.axis().scale(@ySvgRight()).orient('right') .tickFormat(@options.tickFormats.right) if ticks == 2 axis.tickValues @extent((d) -> d.y) else axis.ticks(ticks) # Determines if the visualization is displaying the axis with the given name. # @param [String] name Name of the axis # @return [Boolean] true if the axis was set in the options, false otherwise. hasAxis: (name) -> @options.axes.indexOf(name) > -1 # @return [Number] the width of the visualization area of the plot (full width - margins) innerWidth: -> (@width - (@margins.left + @margins.right)) * @pixelRatio # @return [Number] the height of the visualization area of the plot (full height - margins) innerHeight: -> (@height - (@margins.top + @margins.bottom)) * @pixelRatio # Abstract method for performing any preprocessing before queuing new entries # @param entry [Object] The entry to prepare. # @return [Object] The prepared entry. _prepareEntry: (entry) -> entry # Abstract method for preparing a group of layered entries entering the visualization # @param [Array] layers The layered entries to prepare. # @return [Array] The prepared layers. _prepareLayers: (layers) -> layers # This method will remove the first incoming entry from the visualization's queue # and shift it into the working set (aka window). It then starts the animating the # transition of the element into the visualization. # @event transition:start in the case that animation is actually started. _startTransition: -> return if @animation.active == true or @_queue.length == 0 @trigger 'transition:start' @_shift() @animation.active = true @animation.interval = setInterval(@animationCallback, 1000/@options.fps) # Stops animating and clears the animation interval given there is no more # incoming data to process. Also finalizes tick entering and exiting. # @event transition:end After the transition has completed. _stopTransition: -> return unless @inTransition() # Shift data off the end for layer in @data continue unless layer.values.length > @options.windowSize + 1 layer.values.shift() # Finalize tick transitions [firstTick, lastTick] = [@_ticks[0], @_ticks[@_ticks.length-1]] if lastTick? and lastTick.enter lastTick.enter = false lastTick.opacity = 1 if firstTick? and firstTick.exit @_shiftTick() # Reset the animation frame modulus @animation.frame = 0 # Trigger that we are done transitioning @trigger 'transition:end' # Clear the transition interval unless another entry is already queued if @_queue.length > 0 @_shift() else @animation.active = false clearInterval @animation.interval # Determines if the plot is currently animating a transition. # @return [Boolean] true if the plot is animating, false otherwise. inTransition: -> @animation.active # This method is used by the application programmer to introduce new data into # the timeseries plot. The method queues the incoming data, ensures a fixed size # for the data queue, and finally calls _startTransition method to # begin animating the plot. # @param [Array] layers Layered incoming visualization data. # @event push Triggered after the new data has been pushed into the queue. push: (layers) -> layers = @_prepareLayers(layers) # Handle entry queue maximum size if @_queue.length > @options.queueSize @_queue.splice @options.queueSize, (@_queue.length - @options.queueSize) return false if @_queue.length == @options.queueSize # Push the entry into the queue @_queue.push layers.map((entry) => @_prepareEntry(entry)) @trigger 'push' # Begin the transition unless we are already doing so @_startTransition() unless @inTransition() # Fetches new entry data from the model in response to a 'data:push' event. pushFromModel: -> @push @options.model.getNext(@options.type, @options.dataFormat) # Shift elements off the incoming data queue (see the implementation of # push above). # # If there's data to be shoved into the visualization it will pull it # off the queue and put it into the working dataset. It also calls through # to @_updateTicks to handle horizontal (or "time") axes tick transitions # since we're implementing independent of d3 as well. # # @event before:shift Before an element has been shifted off the queue. # @event after:shift After the element has been shifted off the queue. _shift: -> @trigger 'before:shift' entry = @_queue.shift() layer.values.push(entry[i]) for own i, layer of @data @_updateTicks(entry[0].time) @_transitionRangeAxes() @trigger 'after:shift' # Transitions the left and right axes when the range of the plot has changed. _transitionRangeAxes: -> if @hasAxis('left') @svg.selectAll('.y.axis.left').transition() .duration(500) .ease('linear') .call(@leftAxis()) if @hasAxis('right') @svg.selectAll('.y.axis.right').transition() .duration(500) .ease('linear') .call(@rightAxis()) # Performs the animation for transitioning elements in the visualization. _animate: -> return unless @inTransition() @_stopTransition() if ++@animation.frame == @animation.duration @draw(@animation.frame * @animation.delta()) @_updateTimeAxes() # @param [Array] givenDomain A given domain for the scale # @return [Function] The y scale for the plot y: (givenDomain) -> d3.scale.linear() .domain(@_getScaleDomain(givenDomain)) .range([@innerHeight(), 0]) # @param [Array] givenDomain Optional domain to override default # @return [Function] The y scale for the svg portions of the plot ySvg: (givenDomain) -> d3.scale.linear() .domain(@_getScaleDomain(givenDomain)) .range([@innerHeight() / @pixelRatio, 0]) # @return [Function] The y scale for the svg portion of the plot for the left axis ySvgLeft: -> if @options.range? @ySvg @options.range.left else @ySvg() # @return [Function] The y scale for the svg portion of the plot for the right axis ySvgRight: -> if @options.range? @ySvg @options.range.right else @ySvg() # @return [Number] The width of a single section of the graph pertaining to a data point w: -> @innerWidth() / @options.windowSize # This is called every time we introduce new data (as a result of _shift) # it checks to see if we also need to update the working tick set and # makes the approriate changes for handling tick animation (enter, exit, # and update in the d3 model). # # @param [Integer] newTime Current newest timestamp in the data _updateTicks: (newTime) -> return unless @hasAxis('top') or @hasAxis('bottom') # Incoming ticks unless (++@_tickTimer) % @options.ticks.time @_pushTick(@options.windowSize, newTime, true) # Outgoing ticks return unless @_ticks.length > 0 unless @_ticks[0].x - (@w()/@pixelRatio) >= 0 @_ticks[0].exit = true # Makes and pushes a new tick into the visualization. # # @param bucket Index in the data window where the tick should initially be position # @param time The unix timestamp associated with the tick # @param enter Whether or not the tick should be considered as "newly entering" # Used primarily for performing the tick opacity tween. _pushTick: (bucket, time, enter=false, reverse=false) -> return unless @hasAxis('top') or @hasAxis('bottom') tick = time: time x: bucket*(@w()/@pixelRatio) + @_offsetX() opacity: if enter then 0 else 1 enter: if enter then true else false exit: false if @hasAxis('bottom') g = @bottomAxis.append('g') .attr('class', 'tick major') .attr('transform', "translate(#{tick.x+1},0)") .style('opacity', tick.opacity) g.append('line') .attr('y2', 6) g.append('text') .attr('text-anchor', 'middle') .attr('dy', 19) .text(@options.tickFormats.bottom(tick.time)) tick.bottomEl = g if @hasAxis('top') g = @topAxis.append('g') .attr('class', 'tick major') .attr('transform', "translate(#{tick.x+1},0)") .style('opacity', tick.opacity) g.append('line') .attr('y2', -6) g.append('text') .attr('text-anchor', 'middle') .attr('dy', -10) .text(@options.tickFormats.top(tick.time)) tick.topEl = g if reverse @_ticks.unshift tick else @_ticks.push tick return tick # Shifts a tick that is no longer needed out of the visualization. _shiftTick: -> return unless @_ticks.length > 0 tick = @_ticks.shift() tick.topEl.remove() if tick.topEl? tick.bottomEl.remove() if tick.bottomEl? # This performs animations for the time axes (top and bottom). _updateTimeAxes: -> return unless @hasAxis('top') or @hasAxis('bottom') [dx, dop] = [@animation.tickDelta(), 1 / @options.fps] for tick in @_ticks tick.x += dx if @hasAxis('bottom') tick.bottomEl.attr('transform', "translate(#{tick.x+1},0)") if @hasAxis('top') tick.topEl.attr('transform', "translate(#{tick.x+1},0)") if tick.enter tick.opacity += dop else if tick.exit tick.opacity -= dop if tick.enter or tick.exit tick.bottomEl.style('opacity', tick.opacity) if @hasAxis('bottom') tick.topEl.style('opacity', tick.opacity) if @hasAxis('top') # Draws the visualization in the plot's canvas. # @param delta The current x offset to apply to all elements when rendering. This number # will be 0 when the plot is not animating and negative when it is. # @abstract It does nothing on its own but is provided so that subclasses can # define a custom rendering routine. draw: (delta=0) -> super() dimensionsChanged: -> super() @svg.attr('width', @width).attr('height', @height) @_sizeCanvas() @_buildAxes() @draw(@animation.frame * @animation.delta()) # Updates axes in response to an option:axes event. axesChanged: -> for pos in ['top', 'right', 'bottom', 'left'] continue if @options.margins? and @options.margins[pos]? if @hasAxis(pos) @margins[pos] = defaultAxisMargins[pos] else @margins[pos] = 6 @_sizeCanvas() @_buildAxes() @draw(@animation.frame * @animation.delta()) # Updates ticks in response to an option.ticks.* event. ticksChanged: -> @_resetInitialTimeTicks() @_transitionRangeAxes() @draw(@animation.frame * @animation.delta()) # Updates tick formats in response to an option.tickFormats.* event. tickFormatsChanged: -> @_resetInitialTimeTicks() @_transitionRangeAxes() @draw(@animation.frame * @animation.delta()) # Updates margins in response to an option.margins.* event. marginsChanged: -> return unless @options.margins? for own pos, size of @options.margins unless size? @margins[pos] = 6 else @margins[pos] = size @_sizeCanvas() @draw(@animation.frame * @animation.delta()) layerChanged: -> @_transitionRangeAxes() super() # Base class for all "stacked" plot types (e.g. bar charts, area charts, etc.) # @abstract It does not perform rendering but instead formats the data # so as to ease the process of rendering stacked plots. class Epoch.Time.Stack extends Epoch.Time.Plot # Sets stacking information (y0) for each of the points in each layer _stackLayers: -> return unless (layers = @getVisibleLayers()).length > 0 for i in [0...layers[0].values.length] y0 = 0 for layer in layers layer.values[i].y0 = y0 y0 += layer.values[i].y # Adds stacking information for layers entering the visualization. # @param [Array] layers Layers to stack. _prepareLayers: (layers) -> y0 = 0 for own i, d of layers continue unless @data[i].visible d.y0 = y0 y0 += d.y return layers # Ensures that elements are stacked when setting the initial data. # @param [Array] data Layered data to set for the visualization. setData: (data) -> super(data) @_stackLayers() # Finds the correct extent to use for range axes (left and right). # @return [Array] An extent array with the first element equal to 0 # and the second element equal to the maximum value amongst the # stacked entries. extent: -> [max, layers] = [0, @getVisibleLayers()] return [0, 0] unless layers.length for i in [0...layers[0].values.length] sum = 0 for j in [0...layers.length] sum += layers[j].values[i].y max = sum if sum > max [0, max] layerChanged: -> @_stackLayers() @_prepareLayers(layers) for layers in @_queue super()