From 0a2e78fd360176ae30b441b8b79f929b6e1bf91f Mon Sep 17 00:00:00 2001 From: dsc Date: Thu, 29 Mar 2012 06:13:15 -0700 Subject: [PATCH] Renames Vis* to Graph*. Adds support for loading graphs off the server. --- lib/graph/graph-display-view.co | 30 ++++ lib/graph/graph-edit-view.co | 319 +++++++++++++++++++++++++++++++++++++++ lib/graph/graph-model.co | 318 ++++++++++++++++++++++++++++++++++++++ lib/graph/index.co | 7 +- lib/main.co | 37 ++--- lib/template/graph-display.jade | 59 +++++++ lib/template/graph-edit.jade | 59 +++++++ lib/template/graph.jade | 59 ------- lib/util/cascade.co | 8 +- lib/vis/index.co | 3 - lib/vis/vis-model.co | 293 ----------------------------------- lib/vis/vis-view.co | 281 ---------------------------------- www/misc/test.co | 19 +-- www/modules.yaml | 14 +- 14 files changed, 821 insertions(+), 685 deletions(-) create mode 100644 lib/graph/graph-display-view.co create mode 100644 lib/graph/graph-edit-view.co create mode 100644 lib/graph/graph-model.co create mode 100644 lib/template/graph-display.jade create mode 100644 lib/template/graph-edit.jade delete mode 100644 lib/template/graph.jade delete mode 100644 lib/vis/index.co delete mode 100644 lib/vis/vis-model.co delete mode 100644 lib/vis/vis-view.co diff --git a/lib/graph/graph-display-view.co b/lib/graph/graph-display-view.co new file mode 100644 index 0000000..8d24a78 --- /dev/null +++ b/lib/graph/graph-display-view.co @@ -0,0 +1,30 @@ +root = do -> this + +_ = require 'kraken/util/underscore' +{ BaseView, +} = require 'kraken/base' +{ ChartType, DEBOUNCE_RENDER, +} = require 'kraken/chart' +{ Graph, +} = require 'kraken/graph/graph-model' + + + +GraphDisplayView = exports.GraphDisplayView = BaseView.extend do # {{{ + ctorName : 'GraphDisplayView' + tagName : 'section' + className : 'graph' + template : require 'kraken/template/graph-display' + + # events : + # 'blur .value' : 'update' + # 'submit .value' : 'update' + + + initialize: -> + @model or= new Graph + BaseView::initialize ... + + + toString: -> "#{@ctorName}(#{@model})" +# }}} diff --git a/lib/graph/graph-edit-view.co b/lib/graph/graph-edit-view.co new file mode 100644 index 0000000..0050f50 --- /dev/null +++ b/lib/graph/graph-edit-view.co @@ -0,0 +1,319 @@ +root = do -> this + +_ = require 'kraken/util/underscore' +{ BaseView, +} = require 'kraken/base' +{ ChartOptionScaffold, DEBOUNCE_RENDER, +} = require 'kraken/chart' +{ Graph, +} = require 'kraken/graph/graph-model' + + + + + +/** + * @class View for a graph visualization encapsulating the editing UI for: + * - Graph metadata, such as name, description, slug + * - Chart options, using ChartOptionScaffold + */ +GraphEditView = exports.GraphEditView = BaseView.extend do # {{{ + FILTER_CHART_OPTIONS : <[ + file labels visibility colors dateWindow ticker timingName xValueParser + axisLabelFormatter xAxisLabelFormatter yAxisLabelFormatter + valueFormatter xValueFormatter yValueFormatter + ]> + __bind__ : <[ + render renderAll resizeViewport + formatter axisFormatter + onReady onSync + onModelChange onScaffoldChange onFirstClickRenderOptionsTab + ]> + __debounce__: <[ render renderAll ]> + ctorName : 'GraphEditView' + tagName : 'section' + className : 'graph graph-edit' + template : require 'kraken/template/graph-edit' + + events: + 'click .redraw-button' : 'render' + 'click .save-button' : 'save' + 'click .load-button' : 'load' + 'keypress form.details input[type="text"]' : 'onKeypress' + 'keypress form.options .value' : 'onKeypress' + 'submit form.details' : 'onDetailsSubmit' + 'submit form.options' : 'onOptionsSubmit' + 'change input[type="checkbox"]' : 'onOptionsSubmit' + + ready: false + + + + initialize : (o={}) -> + @model or= new Graph + BaseView::initialize ... + # console.log "#this.initialize!" + + for name of @__debounce__ + @[name] = _.debounce @[name], DEBOUNCE_RENDER + + # Resize graph on window resize + # Note: can't debounce the method itself, as the debounce wrapper returns undefined + $ root .on 'resize', _.debounce(@resizeViewport, DEBOUNCE_RENDER) + + @id = _.domize 'graph', (@model.get('slug') or @model.id or @model.cid) + + @model + .on 'ready', @onReady + .on 'sync', @onSync + .on 'destroy', @remove, this + .on 'change', @render, this + .on 'change:dataset', @onModelChange + .on 'change:options', @onModelChange + .on 'error', ~> + console.error "#this.error!", arguments + # TODO: UI alert + + # Rerender the options boxes once the tab is visible + @$el.on 'click', '.graph-options-tab', @onFirstClickRenderOptionsTab + + @viewport = @$el.find '.viewport' + + @scaffold = new ChartOptionScaffold + @$el.find '.graph-options-pane' .append @scaffold.el + @scaffold.collection.reset that if o.graph_spec + @scaffold.on 'change', @onScaffoldChange + + @chartOptions @model.getOptions(), {+silent} + @resizeViewport() + # _.delay @onReady, DEBOUNCE_RENDER + + + + + load: -> + console.log "#this.load!" + @model.fetch() + + save: -> + console.log "#this.save!" + id = @model.id or @model.get('slug') + @model.save {id}, {+wait} + + + change: -> + @model.change() + @scaffold.invoke 'change' + this + + chartOptions: (values, opts) -> + # Handle @chartOptions(k, v, opts) + if arguments.length > 1 and typeof values is 'string' + [k, v, opts] = arguments + values = { "#k": v } + + fields = @scaffold.collection + if values + for k, v in values + fields.get(k)?.setValue v, opts + this + else + 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] + delete options[k] + options + + + toTemplateLocals: -> + attrs = _.clone @model.attributes + delete attrs.options + { $, _, op, @model, view:this } import attrs + + + /** + * Resizes chart according to the model's width and height. + * @return { width, height } + */ + resizeViewport: -> + modelW = width = @model.get 'width' + modelH = height = @model.get 'height' + return { width, height } unless @ready + + # Remove old style, as it confuses dygraph after options update + @viewport.attr 'style', '' + label = @$el.find '.graph-label' + + if width is 'auto' + vpWidth = @viewport.innerWidth() + labelW = label.outerWidth() + width = vpWidth - labelW - 10 - (vpWidth - label.position().left - labelW) + width ?= modelW + if height is 'auto' + height = @viewport.innerHeight() + height ?= modelH + + size = { width, height } + @viewport.css size + # console.log 'resizeViewport!', JSON.stringify(size), @viewport + # @chart.resize size if forceRedraw + size + + + # Repopulate UI from Model + renderDetails: -> + form = @$el.find 'form.details' + for k, v in @model.attributes + continue if k is 'options' + txt = @model.serialize v + + el = form.find "input[name=#k]" + if el.attr('type') is 'checkbox' + el.attr 'checked', if v then 'checked' else '' + else + el.val txt + + form.find "textarea[name=#k]" .text txt + this + + + render: -> + return this unless @ready + @renderDetails() + + dataset = @model.get 'dataset' + size = @resizeViewport() + + # XXX: use @model.changedAttributes() to calculate what to update + options = @chartOptions() #import size + options import do + labelsDiv : @$el.find '.graph-label' .0 + # axisLabelFormatter : @axisFormatter + # valueFormatter : @formatter + + # console.log "#this.render!", dataset + _.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. + @chart?.destroy() + @chart = new Dygraph do + @viewport.0 + dataset + options + # unless @chart + # @chart = new Dygraph do + # @viewport.0 + # dataset + # options + # else + # @chart.updateOptions options + # @chart.resize size + + @updateURL() + @trigger 'render', this + this + + renderAll: -> + return this unless @ready + # console.log "#this.renderAll!" + _.invoke @scaffold.subviews, 'render' + @scaffold.render() + @render() + this + + /** + * Update the page URL using HTML5 History API + */ + updateURL: -> + data = @toJSON() + title = @model.get('name', root.document?.title or '') + url = @toURL() + # console.log 'History.pushState', JSON.stringify(data), title, url + History.pushState data, title, url + + + + ### Formatters {{{ + + axisFormatter: (n, granularity, opts, g) -> + @formatter n, opts, g + + formatter: (n, opts, g) -> + return n if n instanceof Date + sigFigs = opts 'sigFigs' + maxW = opts 'maxNumberWidth' + digits = opts 'digitsAfterDecimal' + v = Dygraph.round_ n, digits + # Dygraph.floatFormat n, sigFigs + # console.log n, "->", v, "?= %#{maxW}.#{digits}g (sigFigs=#sigFigs)" + v + + + ### }}} + ### Event Handlers {{{ + + onReady: -> + console.log "(#this via GraphEditView).ready!" + @ready = @scaffold.ready = true + @onSync() + + onSync: -> + return unless @ready + console.info "#this.sync() --> success!" + # TODO: UI alert + # @change() + # @model.change() + @chartOptions @model.getOptions(), {+silent} + @renderAll() + + onModelChange: -> + changes = @model.changedAttributes() + options = @model.getOptions() + # console.log "Graph.changed( options ) ->\n\tchanges: #{JSON.stringify changes}\n\toptions: #{JSON.stringify options}" #"\n\t^opts: #{JSON.stringify _.intersection _.keys(changes), _.keys(options)}" + @chart.updateOptions file:that if changes?.dataset + @chartOptions options, {+silent} if changes?.options + + onScaffoldChange: (scaffold, value, key, field) -> + 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 + @scaffold.render() + + onKeypress: (evt) -> + $(evt.target).submit() if evt.keyCode is 13 + + onDetailsSubmit: -> + console.log "#this.onDetailsSubmit!" + data = _.synthesize do + @$el.find('form.details').serializeArray() + -> [it.name, it.value] + @model.set data + false + + onOptionsSubmit: -> + console.log "#this.onOptionsSubmit!" + @render() + false + + # }}} + + toString: -> "#{@ctorName}(#{@model})" +# }}} + diff --git a/lib/graph/graph-model.co b/lib/graph/graph-model.co new file mode 100644 index 0000000..d60c6cb --- /dev/null +++ b/lib/graph/graph-model.co @@ -0,0 +1,318 @@ +Seq = require 'seq' + +_ = require 'kraken/util/underscore' +Cascade = require 'kraken/util/cascade' +{ ChartType, +} = require 'kraken/chart' +{ BaseModel, 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. + */ +Graph = exports.Graph = BaseModel.extend do # {{{ + ctorName : 'Graph' + IGNORE_OPTIONS : <[ width height timingName ]> + + urlRoot : '/graph' + ready : false + + /** + * The chart type backing this graph. + * @type ChartType + */ + chartType : null + + /** + * List of graph parents. + * @type GraphList + */ + 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' + width : 'auto' + height : 320 + chartType : 'dygraphs' + parents : <[ root ]> + options : {} + } + + url: -> + "#{@urlRoot}/#{@get('slug')}.json" + + + + + constructor : (attributes={}, opts) -> + @on 'ready', ~> console.log "(#this via Graph).ready!" + attributes.options or= {} + @optionCascade = new Cascade attributes.options + BaseModel.call this, attributes, opts + + + initialize : (attributes, opts) -> + @__super__.initialize ... + opts = {+autoLoad} import (opts or {}) + + @constructor.register this + @parents = new GraphList + # TODO: Load on-demand + @chartType = ChartType.lookup @get('chartType') + + # unless @id or @get('id') or @get('slug') + # @set 'slug', "unsaved_graph_#{@cid}" + + @trigger 'init', this + @load() if opts.autoLoad + + + load: (opts={}) -> + return this if @ready and not opts.force + + self = this + @trigger 'load', this + Seq() + .seq_ (next) ~> + if @isNew() + next.ok() + else + console.log "#{this}.fetch()..." + @fetch do + error : (err) ~> + console.error "#{this}.fetch() --> error! #arguments" + next.ok() + success : (model, res) ~> + console.log "#{this}.fetch() --> success!", res + next.ok res + .seq_ (next) ~> + next.ok @get('parents') + .flatten() + .seqMap -> Graph.lookup it, this + .seqEach_ (next, parent) ~> + @parents.add parent + @optionCascade.addLookup parent.get('options') + next.ok() + .seq ~> + @ready = true + @trigger 'ready', this + + 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 + + setter = (@__super__ or BaseModel::).set + + # Merge options in, firing granulated change events + if values.options + # Remove from values to prevent the super call to `set()` from + # replacing the object wholesale. + options = delete values.options + + # ...Unless we don't have one yet. + if not @attributes.options + setter.call this, {options}, {+silent} + + # Now delegate `setOption()` to do the nested merging. + @setOption options, opts + + # Deal with everything else + setter.call this, values, opts + + + + ### Chart Option Accessors ### + + hasOption: (key) -> + @getOption(key) is void + + getOption: (key, def) -> + @optionCascade.get key, def + + setOption: (key, value, opts={}) -> + 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={}) -> + 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) -> + @chartType.isDefault k, @getOption k + + /** + * 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: (opts={}) -> + opts = {+keepDefaults, +keepUnchanged} import opts + # use jQuery's deep-copy implementation -- XXX: Deep-copy no longer necessary thanks to @getOptions() + # json = $.extend true, {}, @attributes + json = _.clone(@attributes) import { options:@getOptions(opts) } + + + toKVPairs: (opts={}) -> + opts = {-keepSlug, -keepDefaults, -keepUnchanged} import opts + + # use jQuery's deep-copy implementation + kvo = @toJSON opts + kvo.parents = JSON.stringify kvo.parents + delete kvo.slug unless opts.keepSlug + + # console.group 'toKVPairs' + # console.log '[IN]', JSON.stringify kvo + for k, v in kvo.options + kvo.options[k] = @serialize v + # console.log '[OUT]', JSON.stringify kvo + # console.groupEnd() + + _.collapseObject kvo + + toKV: (opts) -> + _.toKV @toKVPairs opts + + + /** + * @returns {String} URL identifying this model. + */ + toURL: -> + slug = @get 'slug', '' + slug = "/#slug" if slug + "/graph#slug?#{@toKV { keepSlug: !!slug }}" + + toString: -> "#{@ctorName}(id=#{@id}, cid=#{@cid})" +# }}} + + +GraphList = exports.GraphList = BaseList.extend do # {{{ + ctorName : 'GraphList' + urlRoot : '/graph' + model : Graph + + initialize : -> + BaseList::initialize ... + + toString: -> + modelIds = _.pluck @models, 'id' + .map -> "\"#it\"" + .join ', ' + "#{@ctorName}(#modelIds)" +# }}} + + +/* * * * Visualization Cache for parent-lookup * * * {{{ */ + +GRAPH_CACHE = exports.GRAPH_CACHE = new GraphList + +Graph import do + CACHE : GRAPH_CACHE + + register: (model) -> + console.log "#{@CACHE}.register(#{model.id or model.get('id')})", model + if @CACHE.contains model + @CACHE.remove model, {+silent} + @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 + @register new Cls { id, slug:id } + .on 'ready', -> cb null, it + + + +/* }}} */ diff --git a/lib/graph/index.co b/lib/graph/index.co index 5437dcc..41c4e11 100644 --- a/lib/graph/index.co +++ b/lib/graph/index.co @@ -1,3 +1,4 @@ -# models = require 'kraken/graph/graph-model' -# views = require 'kraken/graph/graph-view' -# exports import models import views +models = require 'kraken/graph/graph-model' +display_views = require 'kraken/graph/graph-display-view' +edit_views = require 'kraken/graph/graph-edit-view' +exports import models import display_views import edit_views diff --git a/lib/main.co b/lib/main.co index 2134b50..dffeb9e 100644 --- a/lib/main.co +++ b/lib/main.co @@ -11,38 +11,30 @@ Backbone = require 'backbone' ChartOption, ChartOptionList, TagSet, ChartOptionView, ChartOptionScaffold, } = require 'kraken/chart' -{ VisView, VisModel, VisList, -} = require 'kraken/vis' +{ Graph, GraphList, GraphEditView, +} = require 'kraken/graph' root = this CHART_OPTIONS_SPEC = [] CHART_DEFAULT_OPTIONS = {} -ROOT_VIS_DATA = {} -ROOT_VIS_OPTIONS = {} # Create the Graph Scaffold main = -> - # opts = root.CHART_DEFAULT_OPTIONS = {} - # for opt of root.CHART_OPTIONS_SPEC - # opts[opt.name] = opt.default - + # Set up Dygraph chart type spec + # TODO: load this on-demand dyglib = new DygraphsChartType CHART_OPTIONS_SPEC - # TODO: create a preset manager - # Remove chart options from data so we don't have to deepcopy - ROOT_VIS_OPTIONS := delete root.ROOT_VIS_DATA.options - # Bind to URL changes History.Adapter.bind window, 'statechange', -> console.log 'StateChange!\n\n', String(root.location), '\n\n' - data = {} # Process URL loc = String root.location + data = {} # If we got querystring args, apply them to the graph if loc.split '?' .1 @@ -52,23 +44,24 @@ main = -> data.options or {} (v, k) -> [ k, dyglib.parseOption(k,v) ] - # Extract slug from URL + # Extract id from URL if match = /\/graph\/(?!view)([^\/?]+)/i.exec loc - data.slug = match[1] + data.id = 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 - model : vis - $ '#content .inner' .append graph.el + # Instantiate model & view + graph = root.graph = new Graph data, {+parse} + view = root.view = new GraphEditView do + graph_spec : root.CHART_OPTIONS_SPEC # FIXME: necessary? + model : graph + + $ '#content .inner' .append view.el # Load data files -Seq([ <[ CHART_OPTIONS_SPEC /schema/dygraph.json ]>, - <[ ROOT_VIS_DATA /presets/root.json ]> +Seq([ <[ CHART_OPTIONS_SPEC /schema/dygraph.json ]> ]) .parEach_ (next, [key, url]) -> jQuery.ajax do diff --git a/lib/template/graph-display.jade b/lib/template/graph-display.jade new file mode 100644 index 0000000..74e403e --- /dev/null +++ b/lib/template/graph-display.jade @@ -0,0 +1,59 @@ +- var id = model.id || model.cid +- var graph_id = view.id +section.graph(id=graph_id) + form.details.form-horizontal + + .name-row.row-fluid.control-group + //- label.name.control-label(for="#{id}_name"): h3 Graph Name + input.span6.name(type='text', id="#{id}_name", name="name", placeholder='Graph Name', value=name) + + .row-fluid + .viewport + .graph-label + + .row-fluid + .graph-settings.tabbable + //- nav.navbar: div.navbar-inner: div.container + nav + ul.nav.subnav.nav-pills + li: h3 Graph + li.active: a(href="#graph-#{graph_id}-info", data-toggle="tab") Info + li: a(href="#graph-#{graph_id}-data", data-toggle="tab") Data + li: a.graph-options-tab(href="#graph-#{graph_id}-options", data-toggle="tab") Options + li.graph-controls + input.redraw-button.btn(type="button", value="Redraw") + input.load-button.btn(type="button", value="Load") + input.save-button.btn-success(type="button", value="Save") + + .tab-content + .graph-info-pane.tab-pane.active(id="graph-#{graph_id}-info") + .row-fluid + .half.control-group + .control-group + label.slug.control-label(for='slug') Slug + .controls + input.span3.slug(type='text', id='slug', name='slug', placeholder='graph_slug', value=slug) + p.help-block The slug uniquely identifies this graph and will be displayed in the URL. + .control-group + label.width.control-label(for='width') Size + .controls + input.span1.width(type='text', id='width', name='width', value=width) + | × + input.span1.height(type='text', id='height', name='height', value=height) + p.help-block Choosing 'auto' will size the graph to the viewport bounds. + .half.control-group + label.desc.control-label(for='desc') Description + .controls + //- textarea.span3.desc(id='desc', name='desc', placeholder='Graph description.') #{desc} + + p.help-block A description of the graph. + + .graph-data-pane.tab-pane(id="graph-#{graph_id}-data") + .row-fluid + label.dataset.control-label(for='dataset') Data Set + .controls + input.span3.dataset(type='text', id='dataset', name='dataset', placeholder='URL to dataset file', value=dataset) + p.help-block This dataset filename will soon be replaced by a friendly UI. + + .graph-options-pane.tab-pane(id="graph-#{graph_id}-options") + diff --git a/lib/template/graph-edit.jade b/lib/template/graph-edit.jade new file mode 100644 index 0000000..7601f63 --- /dev/null +++ b/lib/template/graph-edit.jade @@ -0,0 +1,59 @@ +- var id = model.id || model.cid +- var graph_id = view.id +section.graph.graph-edit(id=graph_id) + form.details.form-horizontal + + .name-row.row-fluid.control-group + //- label.name.control-label(for="#{id}_name"): h3 Graph Name + input.span6.name(type='text', id="#{id}_name", name="name", placeholder='Graph Name', value=name) + + .row-fluid + .viewport + .graph-label + + .row-fluid + .graph-settings.tabbable + //- nav.navbar: div.navbar-inner: div.container + nav + ul.nav.subnav.nav-pills + li: h3 Graph + li.active: a(href="#graph-#{graph_id}-info", data-toggle="tab") Info + li: a(href="#graph-#{graph_id}-data", data-toggle="tab") Data + li: a.graph-options-tab(href="#graph-#{graph_id}-options", data-toggle="tab") Options + li.graph-controls + input.redraw-button.btn(type="button", value="Redraw") + input.load-button.btn(type="button", value="Load") + input.save-button.btn-success(type="button", value="Save") + + .tab-content + .graph-info-pane.tab-pane.active(id="graph-#{graph_id}-info") + .row-fluid + .half.control-group + .control-group + label.slug.control-label(for="#{id}_slug") Slug + .controls + input.span3.slug(type='text', id="#{id}_slug", name='slug', placeholder='graph_slug', value=slug) + p.help-block The slug uniquely identifies this graph and will be displayed in the URL. + .control-group + label.width.control-label(for="#{id}_width") Size + .controls + input.span1.width(type='text', id="#{id}_width", name='width', value=width) + | × + input.span1.height(type='text', id="#{id}_height", name='height', value=height) + p.help-block Choosing 'auto' will size the graph to the viewport bounds. + .half.control-group + label.desc.control-label(for="#{id}_desc") Description + .controls + //- textarea.span3.desc(id='desc', name='desc', placeholder='Graph description.') #{desc} + + p.help-block A description of the graph. + + .graph-data-pane.tab-pane(id="graph-#{graph_id}-data") + .row-fluid + label.dataset.control-label(for="#{id}_dataset") Data Set + .controls + input.span3.dataset(type='text', id="#{id}_dataset", name='dataset', placeholder='URL to dataset file', value=dataset) + p.help-block This dataset filename will soon be replaced by a friendly UI. + + .graph-options-pane.tab-pane(id="graph-#{graph_id}-options") + diff --git a/lib/template/graph.jade b/lib/template/graph.jade deleted file mode 100644 index 74e403e..0000000 --- a/lib/template/graph.jade +++ /dev/null @@ -1,59 +0,0 @@ -- var id = model.id || model.cid -- var graph_id = view.id -section.graph(id=graph_id) - form.details.form-horizontal - - .name-row.row-fluid.control-group - //- label.name.control-label(for="#{id}_name"): h3 Graph Name - input.span6.name(type='text', id="#{id}_name", name="name", placeholder='Graph Name', value=name) - - .row-fluid - .viewport - .graph-label - - .row-fluid - .graph-settings.tabbable - //- nav.navbar: div.navbar-inner: div.container - nav - ul.nav.subnav.nav-pills - li: h3 Graph - li.active: a(href="#graph-#{graph_id}-info", data-toggle="tab") Info - li: a(href="#graph-#{graph_id}-data", data-toggle="tab") Data - li: a.graph-options-tab(href="#graph-#{graph_id}-options", data-toggle="tab") Options - li.graph-controls - input.redraw-button.btn(type="button", value="Redraw") - input.load-button.btn(type="button", value="Load") - input.save-button.btn-success(type="button", value="Save") - - .tab-content - .graph-info-pane.tab-pane.active(id="graph-#{graph_id}-info") - .row-fluid - .half.control-group - .control-group - label.slug.control-label(for='slug') Slug - .controls - input.span3.slug(type='text', id='slug', name='slug', placeholder='graph_slug', value=slug) - p.help-block The slug uniquely identifies this graph and will be displayed in the URL. - .control-group - label.width.control-label(for='width') Size - .controls - input.span1.width(type='text', id='width', name='width', value=width) - | × - input.span1.height(type='text', id='height', name='height', value=height) - p.help-block Choosing 'auto' will size the graph to the viewport bounds. - .half.control-group - label.desc.control-label(for='desc') Description - .controls - //- textarea.span3.desc(id='desc', name='desc', placeholder='Graph description.') #{desc} - - p.help-block A description of the graph. - - .graph-data-pane.tab-pane(id="graph-#{graph_id}-data") - .row-fluid - label.dataset.control-label(for='dataset') Data Set - .controls - input.span3.dataset(type='text', id='dataset', name='dataset', placeholder='URL to dataset file', value=dataset) - p.help-block This dataset filename will soon be replaced by a friendly UI. - - .graph-options-pane.tab-pane(id="graph-#{graph_id}-options") - diff --git a/lib/util/cascade.co b/lib/util/cascade.co index 38847ff..fb1cdb7 100644 --- a/lib/util/cascade.co +++ b/lib/util/cascade.co @@ -188,7 +188,7 @@ class Cascade for o of arguments then @set o this - toObject: -> + toJSON: -> _.extend {}, ...@_lookups.slice().reverse() # XXX: should unique? but then won't map 1:1 to @values()... @@ -227,5 +227,11 @@ class Cascade "#{Cls.displayName or Cls.name}()" +ALIASES = + toJSON : 'toObject' + each : 'forEach' + +for src, dest in ALIASES + Cascade::[dest] = Cascade::[src] module.exports = exports = Cascade diff --git a/lib/vis/index.co b/lib/vis/index.co deleted file mode 100644 index 9975640..0000000 --- a/lib/vis/index.co +++ /dev/null @@ -1,3 +0,0 @@ -models = require 'kraken/vis/vis-model' -views = require 'kraken/vis/vis-view' -exports import models import views diff --git a/lib/vis/vis-model.co b/lib/vis/vis-model.co deleted file mode 100644 index bbd2e7a..0000000 --- a/lib/vis/vis-model.co +++ /dev/null @@ -1,293 +0,0 @@ -Seq = require 'seq' - -_ = require 'kraken/util/underscore' -Cascade = require 'kraken/util/cascade' -{ ChartType, -} = require 'kraken/chart' -{ BaseModel, 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 ]> - - urlRoot : '/graph' - idAttribute : 'slug' - ready : false - - /** - * The chart type backing this graph. - * @type ChartType - */ - chartType : null - - /** - * 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' - width : 'auto' - height : 320 - chartType : 'dygraphs' - parents : <[ root ]> - options : {} - } - - url: -> - "#{@urlRoot}/#{@get('slug')}.json" - - - - - 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 - @chartType = ChartType.lookup @get('chartType') - - # 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) -> - @getOption(key) is void - - getOption: (key, def) -> - @optionCascade.get key, def - - setOption: (key, value, opts={}) -> - 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={}) -> - 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) -> - @chartType.isDefault k, @getOption k - - /** - * 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: (opts={}) -> - opts = {+keepDefaults} import opts - - # use jQuery's deep-copy implementation - json = $.extend true, {}, @attributes - # json = _.clone(@attributes) import { options:_.clone(@attributes.options) } - return json if opts.keepDefaults - - for k, v in json.options - delete json.options[k] if v is void or @isDefaultOption k - json - - - 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 - 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: (opts) -> - _.toKV @toKVPairs opts - - - /** - * @returns {String} URL identifying this model. - */ - toURL: -> - slug = @get 'slug', '' - slug = "/#slug" if slug - "/graph#slug?#{@toKV { keepSlug: !!slug }}" - - 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 - - - -/* }}} */ diff --git a/lib/vis/vis-view.co b/lib/vis/vis-view.co deleted file mode 100644 index 8275daa..0000000 --- a/lib/vis/vis-view.co +++ /dev/null @@ -1,281 +0,0 @@ -root = do -> this - -_ = require 'kraken/util/underscore' -{ BaseView, -} = require 'kraken/base' -{ ChartOptionScaffold, ChartOption, ChartOptionList, DEBOUNCE_RENDER, -} = require 'kraken/chart' -{ VisModel, -} = require 'kraken/vis/vis-model' - - - - - -/** - * View for a graph visualization encapsulating the UI for: - * - Graph metadata, such as name, description, slug - */ -VisView = exports.VisView = BaseView.extend do # {{{ - FILTER_CHART_OPTIONS : <[ - file labels visibility colors dateWindow ticker timingName xValueParser - axisLabelFormatter xAxisLabelFormatter yAxisLabelFormatter - valueFormatter xValueFormatter yValueFormatter - ]> - __bind__ : <[ - render renderAll resizeViewport - formatter axisFormatter - onReady onModelChange onScaffoldChange onFirstClickRenderOptionsTab - ]> - __debounce__: <[ render renderAll ]> - ctorName : 'VisView' - tagName : 'section' - className : 'graph' - template : require 'kraken/template/graph' - - events: - 'click .redraw-button' : 'render' - 'click .save-button' : 'save' - # 'click .load-button' : 'load' - 'keypress form.details input[type="text"]' : 'onKeypress' - 'keypress form.options .value' : 'onKeypress' - 'submit form.details' : 'onDetailsSubmit' - 'submit form.options' : 'onOptionsSubmit' - 'change input[type="checkbox"]' : 'onOptionsSubmit' - - ready: false - - - - initialize : (o={}) -> - @model or= new VisModel - BaseView::initialize ... - # console.log "#this.initialize!" - - for name of @__debounce__ - @[name] = _.debounce @[name], DEBOUNCE_RENDER - - # Resize graph on window resize - # Note: can't debounce the method itself, as the debounce wrapper returns undefined - $ root .on 'resize', _.debounce(@resizeViewport, DEBOUNCE_RENDER) - - @id = _.domize 'graph', (@model.get('slug', @model.id or @model.cid)) - - @model.on 'destroy', @remove, this - @model.on 'change', @render, this - @model.on 'change:dataset', @onModelChange - @model.on 'change:options', @onModelChange - - # Rerender the options boxes once the tab is visible - @$el.on 'click', '.graph-options-tab', @onFirstClickRenderOptionsTab - - @viewport = @$el.find '.viewport' - - @scaffold = new ChartOptionScaffold - @$el.find '.graph-options-pane' .append @scaffold.el - @scaffold.collection.reset that if o.graph_spec - - @scaffold.on 'change', @onScaffoldChange - - options = @model.getOptions() - @chartOptions options, {+silent} - - @resizeViewport() - _.delay @onReady, DEBOUNCE_RENDER - - - change: -> - @model.change() - @scaffold.invoke 'change' - this - - save: -> - console.log "#this.save!" - $.ajax do - url : '/graph/save' - type : 'POST' - data : @toJSON() - success : (response) -> - console.log 'saved!' - error : (err) -> - console.error "error!", arguments - - - chartOptions: (values, opts) -> - # Handle @chartOptions(k, v, opts) - if arguments.length > 1 and typeof values is 'string' - [k, v, opts] = arguments - values = { "#k": v } - - fields = @scaffold.collection - if values - for k, v in values - fields.get(k)?.setValue v, opts - this - else - 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] - delete options[k] - options - - /** - * Resizes chart according to the model's width and height. - * @return { width, height } - */ - resizeViewport: -> - modelW = width = @model.get 'width' - modelH = height = @model.get 'height' - return { width, height } unless @ready - - # Remove old style, as it confuses dygraph after options update - @viewport.attr 'style', '' - label = @$el.find '.graph-label' - - if width is 'auto' - vpWidth = @viewport.innerWidth() - labelW = label.outerWidth() - width = vpWidth - labelW - 10 - (vpWidth - label.position().left - labelW) - width ?= modelW - if height is 'auto' - height = @viewport.innerHeight() - height ?= modelH - - size = { width, height } - @viewport.css size - # console.log 'resizeViewport!', JSON.stringify(size), @viewport - # @chart.resize size if forceRedraw - size - - - - render: -> - return this unless @ready - - dataset = @model.get 'dataset' - size = @resizeViewport() - - # XXX: use @model.changedAttributes() to calculate what to update - options = @chartOptions() #import size - options import do - labelsDiv : @$el.find '.graph-label' .0 - # axisLabelFormatter : @axisFormatter - # valueFormatter : @formatter - - # console.log "#this.render!", dataset - _.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. - @chart?.destroy() - @chart = new Dygraph do - @viewport.0 - dataset - options - # unless @chart - # @chart = new Dygraph do - # @viewport.0 - # dataset - # options - # else - # @chart.updateOptions options - # @chart.resize size - - @updateURL() - this - - renderAll: -> - return this unless @ready - # console.log "#this.renderAll!" - _.invoke @scaffold.subviews, 'render' - @scaffold.render() - @render() - this - - /** - * Update the page URL using HTML5 History API - */ - updateURL: -> - data = @toJSON() - title = @model.get('name', root.document?.title or '') - url = @toURL() - # console.log 'History.pushState', JSON.stringify(data), title, url - History.pushState data, title, url - - - - ### Formatters {{{ - - axisFormatter: (n, granularity, opts, g) -> - @formatter n, opts, g - - formatter: (n, opts, g) -> - return n if n instanceof Date - sigFigs = opts 'sigFigs' - maxW = opts 'maxNumberWidth' - digits = opts 'digitsAfterDecimal' - v = Dygraph.round_ n, digits - # Dygraph.floatFormat n, sigFigs - # console.log n, "->", v, "?= %#{maxW}.#{digits}g (sigFigs=#sigFigs)" - v - - - ### }}} - ### Event Handlers {{{ - - onReady: -> - # console.log "(#this via VisView).ready!" - @ready = @scaffold.ready = true - # @change() - @model.change() - @renderAll() - - onModelChange: -> - changes = @model.changedAttributes() - # console.log 'VisModel.changed( options ) ->', JSON.stringify changes - @chart.updateOptions file:that if changes?.dataset - @chartOptions that, {+silent} if changes?.options - - onScaffoldChange: (scaffold, value, key, field) -> - 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 - @scaffold.render() - - onKeypress: (evt) -> - $(evt.target).submit() if evt.keyCode is 13 - - onDetailsSubmit: -> - console.log "#this.onDetailsSubmit!" - data = _.synthesize do - @$el.find('form.details').serializeArray() - -> [it.name, it.value] - @model.set data - false - - onOptionsSubmit: -> - console.log "#this.onOptionsSubmit!" - @render() - false - - # }}} - - toString: -> "#{@ctorName}(#{@model})" -# }}} - diff --git a/www/misc/test.co b/www/misc/test.co index 5c3033a..a50dccf 100644 --- a/www/misc/test.co +++ b/www/misc/test.co @@ -11,29 +11,19 @@ Backbone = require 'backbone' ChartOption, ChartOptionList, TagSet, ChartOptionView, ChartOptionScaffold, } = require 'kraken/chart' -{ VisView, VisModel, VisList, -} = require 'kraken/vis' +{ GraphEditView, Graph, GraphList, +} = require 'kraken/graph' root = this CHART_OPTIONS_SPEC = [] CHART_DEFAULT_OPTIONS = {} -ROOT_VIS_DATA = {} -ROOT_VIS_OPTIONS = {} # Create the Graph Scaffold main = -> - # opts = root.CHART_DEFAULT_OPTIONS = {} - # for opt of root.CHART_OPTIONS_SPEC - # opts[opt.name] = opt.default - dyglib = new DygraphsChartType CHART_OPTIONS_SPEC - # TODO: create a preset manager - # Remove chart options from data so we don't have to deepcopy - ROOT_VIS_OPTIONS := delete root.ROOT_VIS_DATA.options - # Bind to URL changes History.Adapter.bind window, 'statechange', -> console.log 'StateChange!\n\n', String(root.location), '\n\n' @@ -56,8 +46,8 @@ main = -> if match = /\/graph\/(?!view)([^\/?]+)/i.exec loc data.slug = match[1] - vis = root.vis = new VisModel data, {+parse} - # graph = root.graph = new VisView do + vis = root.vis = new Graph data, {+parse} + # graph = root.graph = new GraphEditView do # graph_spec : root.CHART_OPTIONS_SPEC # model : vis # $ '#content .inner' .append graph.el @@ -66,7 +56,6 @@ main = -> # Load data files Seq([ <[ CHART_OPTIONS_SPEC /schema/dygraph.json ]>, - <[ ROOT_VIS_DATA /presets/root.json ]> ]) .parEach_ (next, [key, url]) -> jQuery.ajax do diff --git a/www/modules.yaml b/www/modules.yaml index d1c2c45..6172dfc 100644 --- a/www/modules.yaml +++ b/www/modules.yaml @@ -70,14 +70,12 @@ dev: - template: - chart-option.jade - chart-scaffold.jade - - graph.jade - # - graph: - # - graph-model - # - graph-view - # - index - - vis: - - vis-model - - vis-view + - graph-edit.jade + - graph-display.jade + - graph: + - graph-model + - graph-edit-view + - graph-display-view - index # - suffix: .js -- 1.7.0.4