Graphs now cascade settings, to enable 'Fork this Graph', as well as inheritence...
authordsc <dsc@wikimedia.org>
Thu, 29 Mar 2012 08:38:57 +0000 (01:38 -0700)
committerdsc <dsc@wikimedia.org>
Thu, 29 Mar 2012 08:38:57 +0000 (01:38 -0700)
lib/chart/chart-library.co
lib/main.co
lib/vis/vis-model.co
lib/vis/vis-view.co

index edba6e3..dee4ece 100644 (file)
@@ -4,8 +4,6 @@ op = require 'kraken/util/op'
 {Parsers, ParserMixin}  = require 'kraken/util/parser'
 
 
-KNOWN_LIBRARIES = exports.KNOWN_LIBRARIES = {}
-
 /**
  * @class Specification for an option.
  */
@@ -32,19 +30,24 @@ class exports.ChartOption
     parse : Parsers.parseString
     
     isDefault: (v) ->
-        @default is v
+        # @default is v
+        _.isEqual @default, v
     
     toString: -> "(#{@name}: #{@type})"
 
 
+
+/**
+ * Map of known libraries by name.
+ * @type Object
+ */
+KNOWN_LIBRARIES = exports.KNOWN_LIBRARIES = {}
+
+
 /**
  * @class Abstraction of a charting library, encapsulating its logic and options.
  */
 class exports.ChartLibrary extends EventEmitter
-    
-    @lookupLibrary = (name) ->
-        KNOWN_LIBRARIES[name]
-    
     /**
      * Ordered ChartOption objects.
      * @type ChartOption[]
@@ -68,9 +71,7 @@ class exports.ChartLibrary extends EventEmitter
     (@name, options) ->
         @options_ordered = _.map options, (opt) ~> new ChartOption this, opt
         @options = _.synthesize @options_ordered, -> [it.name, it]
-        
-        # register library
-        KNOWN_LIBRARIES[@name] = this
+        ChartLibrary.register this
     
     
     /**
@@ -80,7 +81,7 @@ class exports.ChartLibrary extends EventEmitter
         @options[name] or def
     
     /**
-     * @returns {Array} List of values found at the given attr on each 
+     * @returns {Array} List of values found at the given attr on each
      *  option spec object.
      */
     pluck: (attr) ->
@@ -112,6 +113,8 @@ class exports.ChartLibrary extends EventEmitter
         String v
     
     
+    ### Parsers
+    
     /**
      * When implementing a ChartLibrary, you can add or override parsers
      * merely by subclassing.
@@ -124,5 +127,29 @@ class exports.ChartLibrary extends EventEmitter
     parseOption: (name, value) ->
         @getParserFor(name)(value)
     
+    parseOptions: (options) ->
+        out = {}
+        for k, v in options
+            out[k] = @parseOption k, v
+        out
+    
+    
+    
+    ### Class Methods
+    
+    /**
+     * Register a new library.
+     */
+    @register = (library) ->
+        KNOWN_LIBRARIES[library.name] = library
+    
+    /**
+     * Look up a library by name.
+     */
+    @lookupLibrary = (name) ->
+        KNOWN_LIBRARIES[name]
+    
+    
+
 
 
index e621416..fe468c1 100644 (file)
@@ -12,7 +12,7 @@ Backbone = require 'backbone'
 { GraphOption, GraphOptionList, GraphOptionView,
   GraphOptionsScaffold, TagSet,
 } = require 'kraken/graph'
-{ VisView, VisModel,
+{ VisView, VisModel, VisList,
 } = require 'kraken/vis'
 
 
@@ -48,6 +48,7 @@ main = ->
     # If we got querystring args, apply them to the graph
     if loc.split '?' .1
         data = _.uncollapseObject _.fromKV that.replace('#', '%23')
+        data.parents = JSON.parse that if data.parents
         data.options = _.synthesize do
             data.options or {}
             (v, k) -> [ k, dyglib.parseOption(k,v) ]
@@ -56,6 +57,7 @@ main = ->
     if match = /\/graph\/(?!view)([^\/?]+)/i.exec loc
         data.slug = match[1]
     
+    # _.dump _.clone(data.options), 'data.options'
     vis   = root.vis   = new VisModel data, {+parse}
     graph = root.graph = new VisView do
         graph_spec : root.CHART_OPTIONS_SPEC
@@ -73,8 +75,8 @@ Seq([   <[ CHART_OPTIONS_SPEC /schema/dygraph.json ]>,
     jQuery.ajax do
         url : url,
         dataType : 'json'
-        success : (data) ->
-            root[key] = data
+        success : (res) ->
+            root[key] = res
             next.ok()
         error : (err) -> console.error err
 .seq ->
index 59365e8..20929f1 100644 (file)
+Seq = require 'seq'
+
 _ = require 'kraken/underscore'
-{ BaseModel, BaseView,
+Cascade = require 'kraken/util/cascade'
+{ ChartLibrary,
+} = require 'kraken/chart'
+{ BaseModel, BaseView, BaseList,
 } = require 'kraken/base'
 
 root = do -> this
 
 
+
 /**
  * Represents a Graph, including its charting options, dataset, annotations, and all
  * other settings for both its content and presentation.
  */
 VisModel = exports.VisModel = BaseModel.extend do # {{{
+    ctorName       : 'VisModel'
     IGNORE_OPTIONS : <[ width height timingName ]>
-    ctorName    : 'VisModel'
-    urlRoot     : '/graph'
-    idAttribute : 'slug'
     
+    urlRoot        : '/graph'
+    idAttribute    : 'slug'
+    ready          : false
     
+    /**
+     * The chart type backing this graph.
+     * @type ChartLibrary
+     */
+    library : null
     
-    initialize : ->
-        BaseModel::initialize ...
-        name = @get 'name'
-        if name and not @get 'slug', @id
-            @set 'slug', _.underscored name
+    /**
+     * List of graph parents.
+     * @type VisList
+     */
+    parents : null
     
+    /**
+     * Cascade of objects for options lookup (includes own options).
+     * @type Cascade
+     * @private
+     */
+    optionCascade : null
     
+    
+    
+    /**
+     * Attribute defaults.
+     */
     defaults: ->
-        ({
+        {
             slug    : ''
             name    : ''
             desc    : ''
             dataset : '/data/non_mobile_pageviews_by.timestamp.language.csv'
-            # presets : []
             width   : 'auto'
             height  : 320
-        } import root.ROOT_VIS_DATA) import { options:_.clone root.ROOT_VIS_OPTIONS }
+            library : 'dygraphs'
+            parents : <[ root ]>
+            options : {}
+        }
     
+    url: ->
+        "#{@urlRoot}/#{@get('slug')}.json"
     
-    parse: (data) ->
-        data = JSON.parse data if typeof data is 'string'
-        for k, v in data
-            data[k] = Number v if _.contains(<[ width height ]>, k) and v is not 'auto'
-        data
     
-    set: (values, opts) ->
-        if arguments.length > 1 and typeof values is 'string'
-            [k, v, opts] = arguments
-            values = { "#k": v }
-        BaseModel::set.call this, @parse(values), opts
+    
+    
+    constructor : (attributes={}, options) ->
+        # @on 'ready', ~> console.log "(#this via VisModel).ready!"
+        attributes.options or= {}
+        @optionCascade = new Cascade attributes.options
+        BaseModel.call this, attributes, options
+    
+    
+    initialize : ->
+        @__super__.initialize ...
+        
+        @parents = new VisList
+        # TODO: Load on-demand
+        @library = ChartLibrary.lookupLibrary @get('library')
+        
+        # unless @id or @get('id') or @get('slug')
+        #     @set 'slug', "unsaved_graph_#{@cid}"
+        
+        @constructor.register this
+        @trigger 'init', this
+        @load()
+    
+    
+    load: (opts={}) ->
+        return this if @ready and not opts.force
+        
+        @trigger 'load', this
+        Seq @get('parents')
+            .seqMap -> VisModel.lookup it, this
+            .seqEach_ (next, parent) ~>
+                @parents.add parent
+                @optionCascade.addLookup parent.get('options')
+                next.ok()
+            .seq ~>
+                @ready = true
+                @trigger 'ready', this
+    
+    
+    ### Accessors
+    
+    get: (key) ->
+        if _.startsWith key, 'options.'
+            @getOption key.slice(8)
+        else
+            (@__super__ or BaseModel::).get.call this, key
+    
+    
+    set: (key, value, opts) ->
+        # Handle @set(values, opts)
+        if _.isObject(key) and key?
+            [values, opts] = [key, value]
+        else
+            values = { "#key": value }
+        values = @parse values
+        
+        if @ready and values.options
+            options = delete values.options
+            @setOption options, opts
+        
+        (@__super__ or BaseModel::).set.call this, values, opts
+    
     
     
     ### Chart Option Accessors ###
     
     hasOption: (key) ->
-        options = @get 'options', {}
-        options[key]?
+        @getOption(key) is void
     
     getOption: (key, def) ->
-        @get('options', {})[key] ? def
+        @optionCascade.get key, def
     
     setOption: (key, value, opts={}) ->
-        options = @get 'options', {}
-        unless _.contains @IGNORE_OPTIONS, key
-            options[key] = value
-            @set 'options', options, opts
-            @trigger "change:options:#key", this, value, key, opts unless opts.silent
+        if _.isObject(key) and key?
+            [values, opts] = [key, value or {}]
+        else
+            values = { "#key": value }
+        
+        _.dump values, "#this.setOption"
+        options = @get('options')
+        changed = false
+        for key, value in values
+            continue if _.contains @IGNORE_OPTIONS, key
+            changed = true
+            _.setNested options, key, value, {+ensure}
+            @trigger "change:options.#key", this, value, key, opts unless opts.silent
+        
+        if changed and not opts.silent
+            @trigger "change:options", this, options, 'options', opts
         this
     
     unsetOption: (key, opts={}) ->
-        options = @get 'options', {}
-        delete options[key]
-        @set 'options', options, opts
-        @trigger "change:options:#key", this, value, key, opts unless opts.silent
+        unless @optionCascade.unset(key) is void or opts.silent
+            @trigger "change:options.#key", this, void, key, opts
+            @trigger "change:options", this, @get('options'), 'options', opts
         this
     
+    getOptions: (opts={}) ->
+        opts = {+keepDefaults, +keepUnchanged} import opts
+        options = @optionCascade.toObject()
+        for k, v in options
+            delete options[k] if v is void or
+                (not opts.keepDefaults  and @isDefaultOption k) or
+                (not opts.keepUnchanged and not @isChangedOption k)
+        options
+    
+    
+    ### Serialization
+    
+    parse: (data) ->
+        data = JSON.parse data if typeof data is 'string'
+        for k, v in data
+            data[k] = Number v if v is not 'auto' and _.contains <[ width height ]>, k
+            # data[k] = JSON.stringify v if k is 'parents'
+        data
+    
+    /**
+     * @returns {Boolean} Whether the value for option `k` is inherited or not.
+     */
+    isOwnOption: (k) ->
+        @optionCascade.isOwnValue k
+    
+    /**
+     * @returns {Boolean} Whether the value for option `k` is the graph default or not.
+     */
+    isDefaultOption: (k) ->
+        @library.isDefault k, @getOption k
     
-    ### URL Serialization
+    /**
+     * Whether the value for option `k` differs from that of its parent graphs.
+     * @returns {Boolean}
+     */
+    isChangedOption: (k) ->
+        @optionCascade.isChangedValue k
+        and not @isDefaultOption k
     
-    toJSON: (options={}) ->
-        options = {+keepDefaults} import options
+    toJSON: (opts={}) ->
+        opts = {+keepDefaults} import opts
         
-        json = _.clone(@attributes) import { options:_.clone(@attributes.options) }
-        return json if options.keepDefaults
+        # use jQuery's deep-copy implementation
+        json = $.extend true, {}, @attributes
+        # json = _.clone(@attributes) import { options:_.clone(@attributes.options) }
+        return json if opts.keepDefaults
         
-        dyglib = ChartLibrary.lookupLibrary 'dygraphs'
-        opts = json.options
-        for k, v in opts
-            delete opts[k] if dyglib.isDefault k, v
+        for k, v in json.options
+            delete json.options[k] if v is void or @isDefaultOption k
         json
     
     
-    toKVPairs: (keepSlug=false) ->
-        kvo = @toJSON()
-        delete kvo.slug unless keepSlug
+    toKVPairs: (opts={}) ->
+        opts = {-keepSlug, -keepDefaults, -keepUnchanged} import opts
+        
+        # use jQuery's deep-copy implementation
+        kvo = $.extend true, {}, @attributes
+        kvo.parents = JSON.stringify kvo.parents
+        delete kvo.slug unless opts.keepSlug
+        
         # console.group 'toKVPairs'
         # console.log '[IN]', JSON.stringify kvo
-        opts = kvo.options = _.clone kvo.options
-        for k, rootVal in root.ROOT_VIS_OPTIONS
-            v = opts[k]
-            # console.log "  [#k] rootVal:", rootVal, "===", v, "?", _.isEqual(rootVal, v) unless _.isEqual(rootVal, v)
-            if v is void or _.isEqual rootVal, v
-                delete opts[k]
-            else
-                opts[k] = @serialize v
+        kvo.options = @getOptions opts
+        for k, v in kvo.options
+            kvo.options[k] = @serialize v
         # console.log '[OUT]', JSON.stringify kvo
         # console.groupEnd()
         _.collapseObject kvo
     
-    toKV: (keepSlug=false) ->
-        _.toKV @toKVPairs keepSlug
+    toKV: (opts) ->
+        _.toKV @toKVPairs opts
     
     
     /**
@@ -116,10 +240,54 @@ VisModel = exports.VisModel = BaseModel.extend do # {{{
     toURL: ->
         slug = @get 'slug', ''
         slug = "/#slug" if slug
-        "/graph#slug?#{@toKV(false)}"
+        "/graph#slug?#{@toKV { keepSlug: !!slug }}"
     
-    toString: -> "#{@ctorName}(id=#{@id})"
+    toString: -> "#{@ctorName}(id=#{@id}, cid=#{@cid})"
+# }}}
 
+
+VisList = exports.VisList = BaseList.extend do # {{{
+    ctorName : 'VisList'
+    urlRoot  : '/graph'
+    model    : VisModel
+    
+    initialize : ->
+        BaseList::initialize ...
+    
+    toString: ->
+        modelIds = _.pluck @models, 'id' 
+            .map -> "\"#it\""
+            .join ', '
+        "#{@ctorName}(#modelIds)"
 # }}}
 
 
+/* * * *  Visualization Cache for parent-lookup  * * * {{{ */
+
+VIS_CACHE = exports.VIS_CACHE = new VisList
+
+VisModel import do
+    CACHE : VIS_CACHE
+    
+    register: (model) ->
+        # console.log "#{@CACHE}.register(#{model.id or model.get('id')})", model
+        unless @CACHE.contains model
+            @CACHE.add model
+        model
+    
+    get: (id) ->
+        @CACHE.get id
+    
+    lookup: (id, cb) ->
+        # console.log "#{@CACHE}.lookup(#id, #{typeof cb})"
+        if @CACHE.get id
+            cb null, that
+        else
+            Cls = this
+            new Cls { id, slug:id } .fetch do
+                success : -> cb null, it
+                error   : cb
+    
+
+
+/* }}} */
index 1927620..7ca9cec 100644 (file)
@@ -20,8 +20,8 @@ _ = require 'kraken/underscore'
  */
 VisView = exports.VisView = BaseView.extend do # {{{
     FILTER_CHART_OPTIONS : <[ 
-        file labels visibility colors dateWindow  ticker timingName xValueParser 
-        axisLabelFormatter xAxisLabelFormatter yAxisLabelFormatter 
+        file labels visibility colors dateWindow ticker timingName xValueParser
+        axisLabelFormatter xAxisLabelFormatter yAxisLabelFormatter
         valueFormatter xValueFormatter yValueFormatter
     ]>
     __bind__  : <[ 
@@ -79,7 +79,7 @@ VisView = exports.VisView = BaseView.extend do # {{{
         
         @scaffold.on 'change', @onScaffoldChange
         
-        options = @model.get 'options', {}
+        options = @model.getOptions()
         @chartOptions options, {+silent}
         
         @resizeViewport()
@@ -115,7 +115,7 @@ VisView = exports.VisView = BaseView.extend do # {{{
                 fields.get(k)?.setValue v, opts
             this
         else
-            options = @model.toJSON({ -keepDefaults })?.options or {}
+            options = @model.getOptions {-keepDefaults, +keepUnchanged}
             for k of @FILTER_CHART_OPTIONS
                 # console.log "filter #k?", not options[k]
                 if k in options and not options[k]
@@ -166,7 +166,7 @@ VisView = exports.VisView = BaseView.extend do # {{{
             # valueFormatter     : @formatter
         
         # console.log "#this.render!", dataset
-        # _.dump options, 'options'
+        _.dump options, 'options'
         
         # Always rerender the chart to sidestep the case where we need to push defaults into
         # dygraphs to reset the current option state.
@@ -189,7 +189,7 @@ VisView = exports.VisView = BaseView.extend do # {{{
     
     renderAll: ->
         return this unless @ready
-        console.log "#this.renderAll!"
+        # console.log "#this.renderAll!"
         _.invoke @scaffold.subviews, 'render'
         @scaffold.render()
         @render()
@@ -227,9 +227,10 @@ VisView = exports.VisView = BaseView.extend do # {{{
     ### Event Handlers {{{
     
     onReady: ->
-        console.log "#this.ready!"
+        # console.log "(#this via VisView).ready!"
         @ready = @scaffold.ready = true
-        @change()
+        # @change()
+        @model.change()
         @renderAll()
     
     onModelChange: ->
@@ -239,8 +240,21 @@ VisView = exports.VisView = BaseView.extend do # {{{
         @chartOptions that, {+silent} if changes?.options
     
     onScaffoldChange: (scaffold, value, key, field) ->
-        # console.log "scaffold.change!", key, value
-        @model.setOption key, value, {+silent} #unless field.isDefault()
+        current = @model.getOption(key)
+        # console.log do
+        #     "scaffold.change! #key:"
+        #     current
+        #     '-->'
+        #     value
+        #     " ( isDefault?"
+        #     (current is void and field.isDefault())
+        #     "isEqual?"
+        #     _.isEqual(value, current)
+        #     ")   --> "
+        #     unless _.isEqual(value, current) or (current is void and field.isDefault()) then 'CHANGE' else 'SQUELCH'
+        
+        unless _.isEqual(value, current) or (current is void and field.isDefault())
+            @model.setOption(key, value, {+silent})
     
     onFirstClickRenderOptionsTab: ->
         @$el.off 'click', '.graph-options-tab', @onFirstClickRenderOptionsTab