# The base class for all charts in Epoch. Defines chart dimensions, keeps a reference
# of the chart's containing elements. And defines core method for handling data and
# drawing.
class Epoch.Chart.Base extends Epoch.Events
defaults =
width: 320
height: 240
dataFormat: null
optionListeners =
'option:width': 'dimensionsChanged'
'option:height': 'dimensionsChanged'
'layer:shown': 'layerChanged'
'layer:hidden': 'layerChanged'
# Creates a new base chart.
# @param [Object] options Options to set for this chart.
# @option options [Integer] width Sets an explicit width for the visualization.
# @option options [Integer] height Sets an explicit height for the visualization.
# @option options [Object, String] dataFormat Specific data format for the chart.
# @option options [Object] model Data model for the chart.
constructor: (@options={}) ->
super()
if @options.model
if @options.model.hasData()?
@setData(@options.model.getData(@options.type, @options.dataFormat))
else
@setData(@options.data or [])
@options.model.on 'data:updated', => @setDataFromModel()
else
@setData(@options.data or [])
if @options.el?
@el = d3.select(@options.el)
@width = @options.width
@height = @options.height
if @el?
@width = @el.width() unless @width?
@height = @el.height() unless @height?
else
@width = defaults.width unless @width?
@height = defaults.height unless @height?
@el = d3.select(document.createElement('DIV'))
.attr('width', @width)
.attr('height', @height)
@onAll optionListeners
# @return [Object] A copy of this charts options.
_getAllOptions: ->
Epoch.Util.defaults({}, @options)
# Chart option accessor.
# @param key Name of the option to fetch. Can be hierarchical, e.g. 'margins.left'
# @return The requested option if found, undefined otherwise.
_getOption: (key) ->
parts = key.split('.')
scope = @options
while parts.length and scope?
subkey = parts.shift()
scope = scope[subkey]
scope
# Chart option mutator.
# @param key Name of the option to fetch. Can be hierarchical, e.g. 'margins.top'
# @param value Value to set for the option.
# @event option:`key` Triggers an option event with the given key being set.
_setOption: (key, value) ->
parts = key.split('.')
scope = @options
while parts.length
subkey = parts.shift()
if parts.length == 0
scope[subkey] = arguments[1]
@trigger "option:#{arguments[0]}"
return
unless scope[subkey]?
scope[subkey] = {}
scope = scope[subkey]
# Sets all options given an object of mixed hierarchical keys and nested objects.
# @param [Object] options Options to set.
# @event option:* Triggers an option event for each key that was set
_setManyOptions: (options, prefix='') ->
for own key, value of options
if Epoch.isObject(value)
@_setManyOptions value, "#{prefix + key}."
else
@_setOption prefix + key, value
# General accessor / mutator for chart options.
#
# @overload option()
# Fetches chart options.
# @return a copy of this chart's options.
#
# @overload option(name)
# Fetches the value the option with the given name.
# @param [String] name Name of the option to fetch. Can be hierarchical, e.g. 'margins.left'
# @return The requested option if found, undefined
otherwise.
#
# @overload option(name, value)
# Sets an option and triggers the associated event.
# @param [String] name Name of the option to fetch. Can be hierarchical, e.g. 'margins.top'
# @param value Value to set for the option.
# @event option:`name` Triggers an option event with the given key being set.
#
# @overload option(options)
# Sets multiple options at once.
# @param [Object] options Options to set for the chart.
# @event option:* Triggers an option event for each key that was set.
option: ->
if arguments.length == 0
@_getAllOptions()
else if arguments.length == 1 and Epoch.isString(arguments[0])
@_getOption arguments[0]
else if arguments.length == 2 and Epoch.isString(arguments[0])
@_setOption arguments[0], arguments[1]
else if arguments.length == 1 and Epoch.isObject(arguments[0])
@_setManyOptions arguments[0]
# Retrieves and sets data from the chart's model
setDataFromModel: ->
prepared = @_prepareData @options.model.getData(@options.type, @options.dataFormat)
@data = @_annotateLayers(prepared)
@draw()
# Set the initial data for the chart.
# @param data Data to initially set for the given chart. The data format can vary
# from chart to chart. The base class assumes that the data provided will be an
# array of layers.
setData: (data, options={}) ->
prepared = @_prepareData (@rawData = @_formatData(data))
@data = @_annotateLayers(prepared)
# Performs post formatted data preparation.
# @param data Data to prepare before setting.
# @return The prepared data.
_prepareData: (data) -> data
# Performs data formatting before setting the charts data
# @param data Data to be formatted.
# @return The chart specific formatted data.
_formatData: (data) ->
Epoch.Data.formatData(data, @options.type, @options.dataFormat)
# Annotates data to add class names, categories, and initial visibility states
_annotateLayers: (data) ->
category = 1
for layer in data
classes = ['layer']
classes.push "category#{category}"
layer.category = category
layer.visible = true
classes.push(Epoch.Util.dasherize layer.label) if layer.label?
layer.className = classes.join(' ')
category++
return data
# Finds a layer in the chart's current data that has the given label or index.
# @param [String, Number] labelOrIndex The label or index of the layer to find.
_findLayer: (labelOrIndex) ->
layer = null
if Epoch.isString(labelOrIndex)
for l in @data
if l.label == labelOrIndex
layer = l
break
else if Epoch.isNumber(labelOrIndex)
index = parseInt(labelOrIndex)
layer = @data[index] unless index < 0 or index >= @data.length
return layer
# Instructs the chart that a data layer should be displayed.
# @param [String, Number] labelOrIndex The label or index of the layer to show.
# @event 'layer:shown' If a layer that was previously hidden now became visible.
showLayer: (labelOrIndex) ->
return unless (layer = @_findLayer labelOrIndex)
return if layer.visible
layer.visible = true
@trigger 'layer:shown'
# Instructs the chart that a data layer should not be displayed.
# @param [String, Number] labelOrIndex The label or index of the layer to hide.
# @event 'layer:hidden' If a layer that was visible was made hidden.
hideLayer: (labelOrIndex) ->
return unless (layer = @_findLayer labelOrIndex)
return unless layer.visible
layer.visible = false
@trigger 'layer:hidden'
# Instructs the chart that a data layer's visibility should be toggled.
# @param [String, Number] labelOrIndex The label or index of the layer to toggle.
# @event 'layer:shown' If the layer was made visible
# @event 'layer:hidden' If the layer was made invisible
toggleLayer: (labelOrIndex) ->
return unless (layer = @_findLayer labelOrIndex)
layer.visible = !layer.visible
if layer.visible
@trigger 'layer:shown'
else
@trigger 'layer:hidden'
# Determines whether or not a data layer is visible.
# @param [String, Number] labelOrIndex The label or index of the layer to toggle.
# @return true
if the layer is visible, false
otherwise.
isLayerVisible: (labelOrIndex) ->
return null unless (layer = @_findLayer labelOrIndex)
layer.visible
# Calculates an array of layers in the charts data that are flagged as visible.
# @return [Array] The chart's visible layers.
getVisibleLayers: ->
return @data.filter((layer) -> layer.visible)
# Updates the chart with new data.
# @param data Data to replace the current data for the chart.
# @param [Boolean] draw Whether or not to redraw the chart after the data has been set.
# Default: true.
update: (data, draw=true) ->
@setData data
@draw() if draw
# Draws the chart. Triggers the 'draw' event, subclasses should call super() after drawing to
# ensure that the event is triggered.
# @abstract Must be overriden in child classes to perform chart specific drawing.
draw: -> @trigger 'draw'
# Determines a resulting scale domain for the y axis given a domain
# @param [Mixed] givenDomain A set domain, a label associated with mutliple
# layers, or null.
# @return The domain for the y scale
_getScaleDomain: (givenDomain) ->
# Explicitly set given domain
if Array.isArray(givenDomain)
return givenDomain
# Check for "labeled" layer ranges
if Epoch.isString(givenDomain)
layers = @getVisibleLayers()
.filter((l) -> l.range == givenDomain)
.map((l) -> l.values)
if layers? && layers.length
values = Epoch.Util.flatten(layers).map((d) -> d.y)
minFn = (memo, curr) -> if curr < memo then curr else memo
maxFn = (memo, curr) -> if curr > memo then curr else memo
return [values.reduce(minFn, values[0]), values.reduce(maxFn, values[0])]
# Find the domain based on chart options
if Array.isArray(@options.range)
@options.range
else if @options.range && Array.isArray(@options.range.left)
@options.range.left
else if @options.range && Array.isArray(@options.range.right)
@options.range.right
else
@extent((d) -> d.y)
# Calculates an extent throughout the layers based on the given comparator.
# @param [Function] cmp Comparator to use for performing the min and max for the extent
# calculation.
# @return [Array] an extent array with the first element as the minimum value in the
# chart's data set and the second element as the maximum.
extent: (cmp) ->
[
d3.min(@getVisibleLayers(), (layer) -> d3.min(layer.values, cmp)),
d3.max(@getVisibleLayers(), (layer) -> d3.max(layer.values, cmp))
]
# Updates the width and height members and container dimensions in response to an
# 'option:width' or 'option:height' event.
dimensionsChanged: ->
@width = @option('width') or @width
@height = @option('height') or @height
@el.width(@width)
@el.height(@height)
# Updates the chart in response to a layer being shown or hidden
layerChanged: ->
@draw()
# Base class for all SVG charts (via d3).
class Epoch.Chart.SVG extends Epoch.Chart.Base
# Initializes the chart and places the rendering SVG in the specified HTML
# containing element.
# @param [Object] options Options for the SVG chart.
# @option options [HTMLElement] el Container element for the chart.
# @option options [Array] data Layered data used to render the chart.
constructor: (@options={}) ->
super(@options)
if @el?
@svg = @el.append('svg')
else
@svg = d3.select(document.createElement('svg'))
@svg.attr
xmlns: 'http://www.w3.org/2000/svg',
width: @width,
height: @height
# Resizes the svg element in response to a 'option:width' or 'option:height' event.
dimensionsChanged: ->
super()
@svg.attr('width', @width).attr('height', @height)
# Base Class for all Canvas based charts.
class Epoch.Chart.Canvas extends Epoch.Chart.Base
# Initializes the chart and places the rendering canvas in the specified
# HTML container element.
# @param [Object] options Options for the SVG chart.
# @option options [HTMLElement] el Container element for the chart.
# @option options [Array] data Layered data used to render the chart.
constructor: (@options={}) ->
super(@options)
if @options.pixelRatio?
@pixelRatio = @options.pixelRatio
else if window.devicePixelRatio?
@pixelRatio = window.devicePixelRatio
else
@pixelRatio = 1
@canvas = d3.select( document.createElement('CANVAS') )
@canvas.style
'width': "#{@width}px"
'height': "#{@height}px"
@canvas.attr
width: @getWidth()
height: @getHeight()
@el.node().appendChild @canvas.node() if @el?
@ctx = Epoch.Util.getContext @canvas.node()
# @return [Number] width of the canvas with respect to the pixel ratio of the display
getWidth: -> @width * @pixelRatio
# @return [Number] height of the canvas with respect to the pixel ratio of the display
getHeight: -> @height * @pixelRatio
# Clears the render canvas.
clear: ->
@ctx.clearRect(0, 0, @getWidth(), @getHeight())
# @return [Object] computed styles for the given selector in the context of this chart.
# @param [String] selector The selector used to compute the styles.
getStyles: (selector) ->
Epoch.QueryCSS.getStyles(selector, @el)
# Resizes the canvas element when the dimensions of the container change
dimensionsChanged: ->
super()
@canvas.style {'width': "#{@width}px", 'height': "#{@height}px"}
@canvas.attr { width: @getWidth(), height: @getHeight() }
# Purges QueryCSS cache and redraws the Canvas based chart.
redraw: ->
Epoch.QueryCSS.purge()
@draw()