Refactor of ChartType.
authordsc <dsc@wikimedia.org>
Tue, 22 May 2012 11:09:33 +0000 (04:09 -0700)
committerdsc <dsc@wikimedia.org>
Tue, 22 May 2012 11:09:33 +0000 (04:09 -0700)
14 files changed:
docs/internals/chart-type.md
docs/todo.md
lib/base/base-mixin.co
lib/chart/chart-type.co
lib/chart/dygraphs.co
lib/chart/index.co
lib/graph/graph-display-view.co
lib/graph/graph-edit-view.co
lib/graph/graph-model.co
lib/graph/graph-view.co
lib/main-edit.co
lib/server/view-helpers.co
lib/util/event/ready-emitter.co
lib/util/parser.co

index 29f2e12..88bc58d 100644 (file)
@@ -1,5 +1,5 @@
-# ChartType
+# Chart
 
-ChartType is an abstraction on charting libraries to allow them to plug into the GraphKit framework.
+Chart is an abstraction on charting libraries to allow them to plug into the GraphKit framework.
 
 
index baea50c..308bdab 100644 (file)
@@ -96,7 +96,7 @@
 
 
 ### Charting
-- Finish ChartType & Chart wrapper
+- Finish Chart & Chart wrapper
 - Benchmark line on the graph
 - Per-series styles
 - Meta-metrics:
index 29a8581..c6764cb 100644 (file)
@@ -145,34 +145,30 @@ BaseBackboneMixin = exports.BaseBackboneMixin =
 
 /**
  * @class Base mixin class. Extend this to create a new mixin, attaching the
- *  donor methods as you would instance methods. Your constructor must delegate
- *  to its superclass, passing itself as the context.
- * 
- *  Then, to mingle your mixin with another class or object, invoke the mixin
- *  *without* the `new` operator:
+ *  donor methods as you would instance methods.
+ *  
+ *  To mingle your mixin with another class or object:
  *  
  *  class MyMixin extends Mixin
- *      -> return Mixin.call MyMixin, it
  *      foo: -> "foo!"
  *  
  *  # Mix into an object...
- *  o = MyMixin { bar:1 }
+ *  o = MyMixin.mix { bar:1 }
  *  
  *  # Mix into a Coco class...
  *  class Bar
- *      MyMixin this
+ *      MyMixin.mix this
  *      bar : 1
  *  
  */
 class exports.Mixin
     
     /**
-     * Mixes this mixin into If the target is not a class, a new object will be
-     * returned which inherits from the mixin.
-     * @constructor
+     * Mixes this mixin into the target. If `target` is not a class, a new
+     * object will be returned which inherits from the mixin.
      */
-    (target) ->
-        return unless target
+    @mix = (target) ->
+        return that unless target
         
         MixinClass = Mixin
         MixinClass = @constructor   if this instanceof Mixin
@@ -183,8 +179,17 @@ class exports.Mixin
         else
             target = _.clone(MixinClass::) import target
         
-        return target
+        (target.__mixins__ or= []).push MixinClass
+        target
     
+    /**
+     * Coco metaprogramming hook to propagate class properties and methods.
+     */
+    @extended = (SubClass) ->
+        SuperClass = this
+        for own k, v in SuperClass
+            SubClass[k] = v unless SubClass[k]
+        SubClass
 
 
 
index 0831a29..68357c2 100644 (file)
@@ -1,47 +1,16 @@
-{ EventEmitter,
-} = require 'events'
+moment = require 'moment'
+Backbone = require 'backbone'
 
 { _, op,
 } = require 'kraken/util'
+{ ReadyEmitter,
+} = require 'kraken/util/event'
 { Parsers, ParserMixin,
 } = require 'kraken/util/parser'
 
 
 
 /**
- * @class Specification for an option.
- */
-class exports.ChartTypeOption
-    SPEC_KEYS : <[ name type default desc tags examples ]>
-    
-    name     : null
-    type     : 'String'
-    default  : null
-    desc     : ''
-    tags     : null
-    examples : null
-    
-    
-    (@chartType, @spec) ->
-        throw new Error('Each ChartTypeOption requires a name!') unless @spec.name
-        
-        for k of @SPEC_KEYS
-            v = @spec[k]
-            @[k] = v if v?
-        @tags or= []
-        @parseValue = @chartType.getParser @type
-    
-    parseValue : Parsers.parseString
-    
-    isDefault: (v) ->
-        # @default is v
-        _.isEqual @default, v
-    
-    toString: -> "(#{@name}: #{@type})"
-
-
-
-/**
  * Map of known libraries by name.
  * @type Object
  */
@@ -50,88 +19,220 @@ KNOWN_CHART_TYPES = exports.KNOWN_CHART_TYPES = {}
 
 /**
  * @class Abstraction of a chart-type or charting library, encapsulating its
- *  logic and options.
+ *  logic and options. In addition, a `ChartType` also mediates the
+ *  transformation of the domain-specific data types (the model and its view)
+ *  with its specific needs.
+ *  
+ *  `ChartType`s mix in `ParserMixin`: when implementing a `ChartType`, you can
+ *  add or supplement parsers merely by subclassing and overriding the
+ *  corresponding `parseXXX` method (such as `parseArray` or `parseDate`).
+ * 
+ * @extends EventEmitter
+ * @borrows ParserMixin
  */
-class exports.ChartType extends EventEmitter
+class exports.ChartType extends ReadyEmitter
+    
+    ### Class Methods
+    
     /**
-     * Ordered ChartTypeOption objects.
-     * @type ChartTypeOption[]
+     * Register a new chart type.
      */
-    options_ordered : null
+    @register = (Subclass) ->
+        KNOWN_CHART_TYPES[ Subclass::typeName ] = Subclass
+    
+    /**
+     * Look up a `ChartType` by `typeName`.
+     */
+    @lookup = (name) ->
+        name = name.get('chartType') if name instanceof Backbone.Model
+        KNOWN_CHART_TYPES[name]
+    
+    /**
+     * Look up a chart type by name, returning a new instance
+     * with the given model (and, optionally, view).
+     * @returns {ChartType}
+     */
+    @create = (model, view) ->
+        return null unless Type = @lookup model
+        new Type model, view
+    
+    
+    ### Class Properties
+    /*
+     * These are "class properties": each is set on the prototype at the class-level,
+     * and the reference is therefore shared by all instances. It is expected you
+     * will not modify this on the instance-level.
+     */
+    
+    /**
+     * URL for the Chart Spec JSON. Loaded once, the first time an instance of
+     * that class is created.
+     * @type String
+     * @readonly
+     */
+    CHART_SPEC_URL : null
+    
+    /**
+     * Chart-type name.
+     * @type String
+     * @readonly
+     */
+    typeName: null
     
     /**
-     * Map of option name to ChartTypeOption objects.
-     * @type { name:ChartTypeOption, ... }
+     * Map of option name to ChartOption objects.
+     * @type { name:ChartOption, ... }
+     * @readonly
      */
     options : null
     
+    /**
+     * Ordered ChartOption objects.
+     * 
+     * This is a "class-property": it is set on the prototype at the class-level,
+     * and the reference is shared by all instances. It is expected you will not
+     * modify that instance.
+     * 
+     * @type ChartOption[]
+     * @readonly
+     */
+    options_ordered : null
+    
+    /**
+     * Hash of role-names to the selector which, when applied to the view,
+     * returns the correct element.
+     * @type Object
+     */
+    roles :
+        viewport : '.viewport'
+    
+    /**
+     * Whether the ChartType has loaded all its data and is ready.
+     * @type Boolean
+     */
+    ready: false
+    
+    
+    
+    ### Instance properties
+    
+    /**
+     * Model to be rendered as a chart.
+     * @type Backbone.Model
+     */
+    model : null
+    
+    /**
+     * View to render the chart into.
+     * @type Backbone.View
+     */
+    view  : null
+    
+    /**
+     * Last chart rendered by this ChartType.
+     * @private
+     */
+    chart: null
+    
+    
     
     
     /**
      * @constructor
-     * @param {String} name Library name.
-     * @param {Array} options List of options objects, each specifying the
-     *  name, type, default, description (etc) of a chart option.
      */
-    (@name, options) ->
-        @options_ordered = _.map options, (opt) ~> new ChartTypeOption this, opt
-        @options = _.synthesize @options_ordered, -> [it.name, it]
-        ChartType.register this
+    (@model, @view) ->
+        @roles or= {}
+        _.bindAll this, ...@__bind__ # TODO: roll up MRO
+        @loadSpec() unless @ready
+    
+    
+    # Builder Pattern
+    withModel : (@model) -> this
+    withView  : (@view)  -> this
+    
     
     
     /**
-     * @returns {ChartTypeOption} Get an option's spec by name.
+     * Load the corresponding chart specification, which includes
+     * info about valid options, along with their types and defaults.
      */
-    get: (name, def) ->
-        @options[name] or def
+    loadSpec: ->
+        return this if @ready
+        proto = @constructor::
+        jQuery.ajax do
+            url     : @CHART_SPEC_URL
+            success : (spec) ~>
+                proto.options_ordered = spec
+                proto.options = _.synthesize spec, -> [it.name, it]
+                proto.ready = true
+                @emit 'ready', this
+            error: ~> console.error "Error loading #{@typeName} spec! #it"
+        this
+    
     
     /**
-     * @returns {Array} List of values found at the given attr on each
-     *  option spec object.
+     * @returns {ChartOption} Get an option's spec by name.
      */
-    pluck: (attr) ->
-        _.pluck @spec, attr
+    getOption: (name, def) ->
+        @options[name] or def
+    
     
     /**
      * @returns {Object} An object, mapping from option.name to the
      *  result of the supplied function.
      */
     map: (fn, context=this) ->
-        _.synthesize @spec, ~> [it.name, fn.call(context, it, it.name, this)]
+        _.synthesize @options, ~> [it.name, fn.call(context, it, it.name, this)]
     
     
     /**
-     * @returns {Boolean} Whether the supplied value is the same as
-     * the default value for the given key.
+     * @param {String} attr Attribute to look up on each options object.
+     * @returns {Object} Map from name to the value found at the given attr.
      */
-    isDefault: (name, value) ->
-        @get name .isDefault value
+    pluck: (attr) ->
+        @map -> it[attr]
     
     
-    serialize: (v, k) ->
-        # if v!?
-        #     v = ''
-        if _.isBoolean v
-            v =  Number v
-        else if _.isObject v
-            v = JSON.stringify v
-        String v
+    /**
+     * @returns {Boolean} Whether the supplied value is the same as
+     *  the default value for the given key.
+     */
+    isDefault: (name, value) ->
+        _.isEqual @getOption(name).default, value
     
     
-    ### Parsers
+    ### }}}
+    ### Parsers & Serialization {{{
     
     /**
      * When implementing a ChartType, you can add or override parsers
      * merely by subclassing.
+     * @borrows ParserMixin
      */
-    ParserMixin this
+    ParserMixin.mix this
     
+    /**
+     * @returns {Function} Parser for the given option name.
+     */
     getParserFor: (name) ->
-        @getParser @get(name).type
+        @getParser @getOption(name).type
     
+    /**
+     * Parses a single serialized option value into its proper type.
+     * 
+     * @param {String} name Option-name of the value being parsed.
+     * @param {String} value Value to parse.
+     * @returns {*} Parsed value.
+     */
     parseOption: (name, value) ->
         @getParserFor(name)(value)
     
+    /**
+     * Parses options using `parseOption(name, value)`.
+     * 
+     * @param {Object} options Options to parse.
+     * @returns {Object} Parsed options.
+     */
     parseOptions: (options) ->
         out = {}
         for k, v in options
@@ -139,22 +240,174 @@ class exports.ChartType extends EventEmitter
         out
     
     
+    /**
+     * Serializes option-value to a String.
+     * 
+     * @param {*} v Value to serialize.
+     * @param {String} k Option-name of the given value.
+     * @returns {String} The serialized value
+     */
+    serialize: (v, k) ->
+        # if v!?
+        #     v = ''
+        if _.isBoolean v
+            v =  Number v
+        else if _.isObject v
+            v = JSON.stringify v
+        String v
+    
     
-    ### Class Methods
+    ### }}}
+    ### Formatters {{{
     
     /**
-     * Register a new chart type.
+     * Formats a date for display on an axis: `MM/YYYY`
+     * @param {Date} d Date to format.
+     * @returns {String}
      */
-    @register = (chartType) ->
-        KNOWN_CHART_TYPES[chartType.name] = chartType
+    axisDateFormatter: (d) ->
+        moment(d).format 'MM/YYYY'
     
     /**
-     * Look up a chart type by name.
+     * Formats a date for display in the legend: `DD MMM YYYY`
+     * @param {Date} d Date to format.
+     * @returns {String}
      */
-    @lookup = (name) ->
-        KNOWN_CHART_TYPES[name]
+    dateFormatter: (d) ->
+        moment(d).format 'DD MMM YYYY'
+    
+    /**
+     * Formats a number for display, first dividing by the greatest suffix
+     *  of {B = Billions, M = Millions, K = Thousands} that results in a
+     *  absolute value greater than 0, and then rounding to `digits` using
+     *  `result.toFixed(digits)`.
+     * 
+     * @param {Number} n Number to format.
+     * @param {Number} [digits=2] Number of digits after the decimal to always display.
+     * @returns {String} Formatted number.
+     */
+    numberFormatter: (n, digits=2) ->
+        for [suffix, d] of [['B', 1000000000], ['M', 1000000], ['K', 1000], ['', NaN]]
+            break if isNaN d
+            if n >= d
+                n = n / d
+                break
+        s = n.toFixed(digits)
+        parts = s.split '.'
+        whole = _.rchop parts[0], 3 .join ','
+        fraction = '.' + parts.slice(1).join '.'
+        { n, digits, whole, fraction, suffix }
+    
+    
+    ### }}}
+    ### Rendering {{{
+    
+    /**
+     * Finds the element in the view which plays the given role in the chart.
+     * Canonically, all charts have a "viewport" element. Other roles might
+     * include a "legend" element, or several "axis" elements.
+     * 
+     * Default implementation looks up a selector in the `roles` hash, and if
+     * found, queries the view for matching children.
+     * 
+     * @param {String} role Name of the role to look up.
+     * @returns {jQuery|null} $-wrapped DOM element.
+     */
+    getElementsForRole: (role) ->
+        return null unless @view
+        if @roles[role]
+            @view.$ that
+        else
+            null
+    
+    
+    /**
+     * Transform/extract the data for this chart from the model. Default
+     * implementation calls `model.getData()`.
+     * 
+     * @returns {*} Data object for the chart.
+     */
+    getData: ->
+        @model.getData()
+    
     
+    /**
+     * Map from option-name to default value. Note that this reference will be
+     * modified by `.render()`.
+     * 
+     * @returns {Object} Default options.
+     */
+    getDefaultOptions: ->
+        @pluck 'default'
+    
+    
+    /**
+     * Transforms domain data and applies it to the chart library to
+     * render or update the corresponding chart.
+     * 
+     * @returns {Chart}
+     */
+    render: ->
+        data       = @getData()
+        options    = @getDefaultOptions() import @transform @model, @view
+        viewport   = @getElementsForRole 'viewport'
+        @lastChart = @renderChart data, viewport, options, @chart
+    
+    
+    /**
+     * Transforms the domain objects into a hash of derived values using
+     * chart-type-specific keys.
+     * 
+     * @abstract
+     * @returns {Object} The derived data.
+     */
+    transform: ->
+        ...
+    
+    
+    /**
+     * Called to render the chart.
+     * 
+     * @abstract
+     * @returns {Chart}
+     */
+    renderChart: (data, viewport, options, lastChart) ->
+        ...
     
+    
+    ### }}}
+
 
 
 
+# 
+# /**
+#  * @class Specification for an option.
+#  */
+# class exports.ChartOption
+#     SPEC_KEYS : <[ name type default desc tags examples ]>
+#     
+#     name     : null
+#     type     : 'String'
+#     default  : null
+#     desc     : ''
+#     tags     : null
+#     examples : null
+#     
+#     
+#     (@spec) ->
+#         throw new Error('Each ChartOption requires a name!') unless @spec.name
+#         
+#         for k of @SPEC_KEYS
+#             v = @spec[k]
+#             @[k] = v if v?
+#         @tags or= []
+#     
+#     isDefault: (v) ->
+#         # @default is v
+#         _.isEqual @default, v
+#     
+#     toString: -> "(#{@name}: #{@type})"
+# 
+# 
+# 
index bdaada1..1702859 100644 (file)
 _ = require 'kraken/util/underscore'
-{ ChartType, ChartTypeOption,
+{ ChartType,
 } = require 'kraken/chart/chart-type'
 
 
 class exports.DygraphsChartType extends ChartType
-    name : 'dygraphs'
+    __bind__ : <[ dygNumberFormatter dygNumberFormatterHTML ]>
+    CHART_SPEC_URL : '/schema/dygraph.json'
+    typeName : 'dygraphs'
+    ChartType.register this
     
-    (options) ->
-        super 'dygraphs', options
     
-    render: ->
-        ...
+    /**
+     * Hash of role-names to the selector which, when applied to the view,
+     * returns the correct element.
+     * @type Object
+     */
+    roles :
+        viewport : '.viewport'
+        legend   : '.graph-legend'
     
+    
+    
+    /**
+     * @constructor
+     */
+    -> super ...
+    
+    
+    
+    ### Formatters {{{
+    
+    # XXX: Dygraphs-specific
+    makeAxisFormatter: (fmttr) ->
+        (n, granularity, opts, g) -> fmttr n, opts, g
+    
+    # XXX: Dygraphs-specific
+    dygAxisDateFormatter: (n, granularity, opts, g) ->
+        moment(n).format 'MM/YYYY'
+    
+    # XXX: Dygraphs-specific
+    dygDateFormatter: (n, opts, g) ->
+        moment(n).format 'DD MMM YYYY'
+    
+    # XXX: Dygraphs-specific
+    dygNumberFormatter: (n, opts, g) ->
+        digits = if typeof opts('digitsAfterDecimal') is 'number' then that else 2
+        { whole, fraction, suffix } = @numberFormatter n, digits
+        "#whole#fraction#suffix"
+    
+    # XXX: Dygraphs-specific
+    dygNumberFormatterHTML: (n, opts, g) ->
+        digits = if typeof opts('digitsAfterDecimal') is 'number' then that else 2
+        # digits = opts('digitsAfterDecimal') ? 2
+        { whole, fraction, suffix } = @numberFormatter n, digits
+        # coco will trim the whitespace
+        "<span class='value'>
+        <span class='whole'>#whole</span>
+        <span class='fraction'>#fraction</span>
+        <span class='suffix'>#suffix</span>
+        </span>"
+    
+    
+    ### }}}
+ &