changeset 1377:2df0dc79ce76

modernize polymer on index page. move live and timeline to subdirs Ignore-this: 99a836d91ddbc8117abbe04788772409
author Drew Perttula <drewp@bigasterisk.com>
date Wed, 08 Jun 2016 06:02:07 +0000
parents 8b3f7f5656f4
children 64239c6651de
files light9/web/adjustable.coffee light9/web/index.html light9/web/light9-timeline-audio.html light9/web/live.html light9/web/live/index.html light9/web/timeline-elements.html light9/web/timeline.coffee light9/web/timeline.html light9/web/timeline/adjustable.coffee light9/web/timeline/index.html light9/web/timeline/light9-timeline-audio.html light9/web/timeline/timeline-elements.html light9/web/timeline/timeline.coffee makefile
diffstat 14 files changed, 1160 insertions(+), 1159 deletions(-) [+]
line wrap: on
line diff
--- a/light9/web/adjustable.coffee	Wed Jun 08 05:47:49 2016 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,117 +0,0 @@
-log = console.log
-
-class Adjustable
-  # Some value you can edit in the UI, probably by dragging stuff. May
-  # have a <light9-timeline-adjuster> associated. This object does the
-  # layout and positioning.
-  #
-  # The way dragging should work is that you start in the yellow *adj
-  # widget*, wherever it is, but your drag is moving the *target*. The
-  # adj will travel around too, but it may do extra moves to not bump
-  # into stuff or to get out from under your finger.
-
-  constructor: (@config) ->
-    # config has:
-    #   getTarget -> vec2 of current target position
-    #   getSuggestedTargetOffset -> vec2 pixel offset from target
-    #   emptyBox -> true if you want no value display
-
-  getDisplayValue: () ->
-    return '' if @config.emptyBox
-    d3.format(".4g")(@_getValue())
-
-  getCenter: () -> # vec2 of pixels
-    @getTarget().add(@config.getSuggestedTargetOffset())
-
-  getTarget: () -> # vec2 of pixels
-    @config.getTarget()
-            
-  subscribe: (onChange) ->
-    # change could be displayValue or center or target. This likely
-    # calls onChange right away if there's any data yet.
-    throw new Error('not implemented')
-
-  startDrag: () ->
-    # override
-
-  continueDrag: (pos) ->
-    # pos is vec2 of pixels relative to the drag start
-
-    # override
-    
-  endDrag: () ->
-    # override
-
-  _editorCoordinates: () -> # vec2 of mouse relative to <l9-t-editor>
-    ev = d3.event.sourceEvent
-
-    if ev.target.tagName == "LIGHT9-TIMELINE-EDITOR"
-      rootElem = ev.target
-    else
-      rootElem = ev.target.closest('light9-timeline-editor')
-
-    if ev.touches?.length
-      ev = ev.touches[0]
-      
-    # storing root on the object to remember it across calls in case
-    # you drag outside the editor.
-    @root = rootElem.getBoundingClientRect() if rootElem
-    offsetParentPos = $V([ev.pageX - @root.left, ev.pageY - @root.top])
-
-    setMouse(offsetParentPos) # for debugging
-    return offsetParentPos 
-
-class window.AdjustableFloatObservable extends Adjustable
-  constructor: (@config) ->
-    # config also has:
-    #   observable -> ko.observable we will read and write
-    #   getValueForPos(pos) -> what should we set to if the user
-    #                          moves target to this coord?
-
-  _getValue: () ->
-    @config.observable()
-    
-  continueDrag: (pos) ->
-    # pos is vec2 of pixels relative to the drag start.
-
-    epos = @_editorCoordinates()
-    newValue = @config.getValueForPos(epos)
-    @config.observable(newValue)
-
-  subscribe: (onChange) ->
-    ko.computed =>
-      @config.observable()
-      onChange()
-
-class window.AdjustableFloatObject extends Adjustable
-  constructor: (@config) ->
-    # config also has:
-    #   graph
-    #   subj
-    #   pred
-    #   ctx
-    #   getTargetTransform(value) -> getTarget result for value
-    #   getValueForPos
-
-    super(@config)
-    
-  _getValue: () ->
-    # this is a big speedup- callers use _getValue about 4x as much as
-    # the graph changes and graph.floatValue is slow
-    @_currentValue
-
-  getTarget: () ->
-    @config.getTargetTransform(@_getValue())
-    
-  subscribe: (onChange) ->
-    @config.graph.subscribe @config.subj, @config.pred, null, (patch) =>
-      @_currentValue = @config.graph.floatValue(@config.subj, @config.pred)
-      onChange()
-    
-  continueDrag: (pos) ->
-    # pos is vec2 of pixels relative to the drag start
-    
-    newValue = @config.getValueForPos(@_editorCoordinates())
-    @config.graph.patchObject(@config.subj, @config.pred,
-                              @config.graph.LiteralRoundedFloat(newValue),
-                              @config.ctx)
--- a/light9/web/index.html	Wed Jun 08 05:47:49 2016 +0000
+++ b/light9/web/index.html	Wed Jun 08 06:02:07 2016 +0000
@@ -3,49 +3,48 @@
   <head>
     <title></title>
     <meta charset="utf-8" />
+    <script src="/lib/webcomponentsjs/webcomponents-lite.min.js"></script>
     <link rel="stylesheet" href="/style.css">
     <link rel="import" href="/lib/polymer/polymer.html">
   </head>
   <body>
     <dom-module id="service-button-row">
-      <style>
-       div {
-         display: flex;
-         justify-content: space-between;
-         padding: 2px 3px;
-       }
-       div:hover {
-         background: gray;
+      <template>
+        <style>
+         div {
+             display: flex;
+             justify-content: space-between;
+             padding: 2px 3px;
          }
-       .left {
-         display: inline-block;
-         margin-right: 3px;
-       }
-      </style>
-      <template>
+         div:hover {
+             background: gray;
+         }
+         .left {
+             display: inline-block;
+             margin-right: 3px;
+         }
+        </style>
         <div>
-          <span class="left"><a class="big" href="{{withSlash}}">{{name}}</a></span>
+          <span class="left"><a class="big" href="{{name}}/">{{name}}</a></span>
           <span><button on-click="click">window</button></span>
         </div>
       </template>
+      <script>
+       HTMLImports.whenReady(function () {
+           Polymer({
+               is: "service-button-row",
+               properties: {
+                   name: String,
+               },
+               click: function() {
+                   window.open(this.name + '/', '_blank',
+                               'scrollbars=1,resizable=1,titlebar=0,location=0');
+               }
+           });
+       });
+      </script>
     </dom-module>
-    <script>
-     Polymer({
-       is: "service-button-row",
-       properties: {
-         name: String,
-         withSlash: { type: String, computed: 'computeWithSlash(name)' },
-       },
-       computeWithSlash: function (name) {
-         return name + '/';
-       },
-       click: function() {
-         window.open(this.withSlash, '_blank',
-                     'scrollbars=1,resizable=1,titlebar=0,location=0');
-       }
-     });
-    </script>
-
+    
     <h1>light9 home page</h1>
 
     <div style="float: left">
@@ -57,6 +56,8 @@
       <service-button-row name="picamserve"></service-button-row>
       <service-button-row name="vidref"></service-button-row>
       <service-button-row name="."></service-button-row>
+      <service-button-row name="live"></service-button-row>
+      <service-button-row name="timeline"></service-button-row>
     </div>
     
   </body>
--- a/light9/web/light9-timeline-audio.html	Wed Jun 08 05:47:49 2016 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,84 +0,0 @@
-<link rel="import" href="/lib/polymer/polymer.html">
-
-<!-- (potentially-zoomed) spectrogram view -->
-<dom-module id="light9-timeline-audio">
-  <template>
-    <style>
-     :host {
-         display: block;
-         /* shouldn't be seen, but black is correct for 'no
-         audio'. Maybe loading stripes would be better */
-         background: black; 
-     }
-     div {
-         width: 100%;
-         height: 100%;
-         overflow: hidden;
-     }
-     img {
-         height: 100%;
-         position: relative;
-     }
-    </style>
-    <div>
-      <img src="{{imgSrc}}"
-           style="width: {{imgWidth}} ; left: {{imgLeft}}">
-    </div>
-  </template>
-  <script>
-   log = console.log
-   Polymer({
-       is: "light9-timeline-audio",
-       properties: {
-           graph: {type: Object, notify: true},
-           show: {type: String, notify: true},
-           song: {type: String, notify: true},
-           zoom: {type: Object, notify: true},
-           imgSrc: { type: String, notify: true},
-           imgWidth: { computed: '_imgWidth(zoom)' },
-           imgLeft: { computed: '_imgLeft(zoom)' },
-       },
-       observers: [
-           'setImgSrc(graph, show, song)'
-       ],
-       ready: function() {
-           this.zoom = {duration: 0};
-       },       
-       setImgSrc: function() {
-           graph.runHandler(function() {
-               try {
-                   var root = this.graph.stringValue(
-                       this.graph.Uri(this.show),
-                       this.graph.Uri(':spectrogramUrlRoot'));
-               } catch(e) {
-                   return;
-               }
-               
-               try {
-                   var filename = this.graph.stringValue(
-                       this.song, this.graph.Uri(':songFilename'));
-               } catch(e) {
-                   return;
-               }
-               
-               this.imgSrc = root + '/' + filename.replace('.wav', '.png');
-           }.bind(this));
-       },
-       _imgWidth: function(zoom) {
-           if (!zoom.duration) {
-               return "100%";
-           }
-
-           return (100 / ((zoom.t2 - zoom.t1) / zoom.duration)) + "%";
-       },
-       _imgLeft: function(zoom) {
-           if (!zoom.duration) {
-               return "0";
-           }
-
-           var percentPerSec = 100 / (zoom.t2 - zoom.t1);
-           return (-percentPerSec * zoom.t1) + '%';
-       },
-   });
-  </script>
-</dom-module>
--- a/light9/web/live.html	Wed Jun 08 05:47:49 2016 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,165 +0,0 @@
-<!doctype html>
-<html>
-  <head>
-    <title></title>
-    <meta charset="utf-8" />
-    <link rel="stylesheet" href="/style.css">
-    <script src="/lib/webcomponentsjs/webcomponents-lite.min.js"></script>
-    <link rel="import" href="/lib/polymer/polymer.html">
-    <link rel="import" href="/lib/paper-slider/paper-slider.html">
-    <link rel="import" href="/lib/iron-ajax/iron-ajax.html">
-  </head>
-  <body>
-    
-    <dom-module id="light9-collector-client">
-      <template>
-        <iron-ajax url="/collector/attrs" method="PUT" id="put"></iron-ajax>
-        <span>{{status}}</span>
-      </template>
-      <script>
-       HTMLImports.whenReady(function () {
-           Polymer({
-               is: "light9-collector-client",
-               properties: {
-                   status: {type: String, value: 'init'},
-                   clientSession: {value: ""+Date.now()},
-                   self: {type: Object, notify: true}
-               },
-               ready: function() {
-                   this.self = this;
-                   var self = this;
-                   
-                   self.$.put.addEventListener(
-                       'error', function() { self.status = 'err'; });
-                   self.$.put.addEventListener(
-                       'request', function() { self.status = 'send'; });
-                   self.$.put.addEventListener(
-                       'response', function() { self.status = 'ok'; });
-                   // collector gives up on clients after 10sec
-                   setInterval(self.ping.bind(self), 9000);
-                   self.status = 'ready';
-               },               
-               ping: function() {
-                   this.send([]);
-               },
-               send: function(settings) {
-                   this.$.put.body = JSON.stringify({
-                       "settings": settings,
-                       "client": window.location.href,
-                       "clientSession": this.clientSession});
-                   this.$.put.generateRequest();
-               }
-           });
-       });
-      </script>
-    </dom-module>
-       
-    <dom-module id="light9-live-control">
-      <template>
-        <style>
-         paper-slider { width: 100%; }
-        </style>
-
-        <template is="dom-if" if="{{useSlider}}">
-          <paper-slider min="0"
-                        max="{{max}}"
-                        step=".01"
-                        editable
-                        content-type="application/json"
-                        immediate-value="{{immediateSlider}}"></paper-slider>
-        </template>
-        <template is="dom-if" if="{{useColor}}">
-          <input type="color"
-                 id="col"
-                 on-input="onPickedColor"
-                 value="{{pickedColor}}">
-        </template>
-      </template>
-      <script>
-       HTMLImports.whenReady(function () {
-           Polymer({
-               is: "light9-live-control",
-               properties: {
-                   client: {type: Object},
-                   device: {type: String},
-                   attr: {type: String},
-                   max: {type: Number, value: 1},
-                   immediateSlider: {notify: true, observer: "onChange"},
-                   useSlider: {type: Boolean, computed: '_useSlider(attr)'},
-                   useColor: {type: Boolean, computed: '_useColor(attr)'},
-                   pickedColor: {observer: 'onPickedColor'},
-               },
-               ready: function() {
-               },
-               onPickedColor: function(ev) {
-                   this.onChange(ev.target.value);
-               },
-               onChange: function(lev) {
-                   this.client.send([[this.device, this.attr, lev]]);
-               },
-               _useSlider: function(attr) {
-                   return attr != 'http://light9.bigasterisk.com/color';
-               },
-               _useColor: function(attr) {
-                   return attr == 'http://light9.bigasterisk.com/color';
-               },
-           });
-       });
-      </script>
-    </dom-module>
-    
-    <dom-module id="light9-live-controls">
-      <template>
-        <style>
-        </style>
-        <light9-collector-client self="{{client}}"></light9-collector-client>
-        <h1>live</h1>
-
-        <ul>
-          <li>
-            <h2>colorstrip</h2>
-            <light9-live-control
-                client="{{client}}"
-                device="http://light9.bigasterisk.com/device/colorStrip"
-                attr="http://light9.bigasterisk.com/color"
-            ></light9-live-control>
-          </li>
-          <li>
-            <h2>moving</h2>
-            rx <light9-live-control
-                   client="{{client}}"
-                   device="http://light9.bigasterisk.com/device/moving1"
-                   attr="http://light9.bigasterisk.com/rx" max="540"
-               ></light9-live-control>
-            ry <light9-live-control
-                   client="{{client}}"
-                   device="http://light9.bigasterisk.com/device/moving1"
-                   attr="http://light9.bigasterisk.com/ry" max="240"
-               ></light9-live-control>
-            color <light9-live-control
-                      client="{{client}}"
-                      device="http://light9.bigasterisk.com/device/moving1"
-                      attr="http://light9.bigasterisk.com/color"
-                  ></light9-live-control>
-          </li>
-        </ul>
-      </template>
-      <script>
-       HTMLImports.whenReady(function () {
-           Polymer({
-               is: "light9-live-controls",
-               properties: {
-
-               },
-               ready: function() {
-
-               },
-           });
-       });
-      </script>
-    </dom-module>
-    
-    <light9-live-controls></light9-live-controls>    
-    
-  </body>
-</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/live/index.html	Wed Jun 08 06:02:07 2016 +0000
@@ -0,0 +1,165 @@
+<!doctype html>
+<html>
+  <head>
+    <title></title>
+    <meta charset="utf-8" />
+    <link rel="stylesheet" href="/style.css">
+    <script src="/lib/webcomponentsjs/webcomponents-lite.min.js"></script>
+    <link rel="import" href="/lib/polymer/polymer.html">
+    <link rel="import" href="/lib/paper-slider/paper-slider.html">
+    <link rel="import" href="/lib/iron-ajax/iron-ajax.html">
+  </head>
+  <body>
+    
+    <dom-module id="light9-collector-client">
+      <template>
+        <iron-ajax url="/collector/attrs" method="PUT" id="put"></iron-ajax>
+        <span>{{status}}</span>
+      </template>
+      <script>
+       HTMLImports.whenReady(function () {
+           Polymer({
+               is: "light9-collector-client",
+               properties: {
+                   status: {type: String, value: 'init'},
+                   clientSession: {value: ""+Date.now()},
+                   self: {type: Object, notify: true}
+               },
+               ready: function() {
+                   this.self = this;
+                   var self = this;
+                   
+                   self.$.put.addEventListener(
+                       'error', function() { self.status = 'err'; });
+                   self.$.put.addEventListener(
+                       'request', function() { self.status = 'send'; });
+                   self.$.put.addEventListener(
+                       'response', function() { self.status = 'ok'; });
+                   // collector gives up on clients after 10sec
+                   setInterval(self.ping.bind(self), 9000);
+                   self.status = 'ready';
+               },               
+               ping: function() {
+                   this.send([]);
+               },
+               send: function(settings) {
+                   this.$.put.body = JSON.stringify({
+                       "settings": settings,
+                       "client": window.location.href,
+                       "clientSession": this.clientSession});
+                   this.$.put.generateRequest();
+               }
+           });
+       });
+      </script>
+    </dom-module>
+       
+    <dom-module id="light9-live-control">
+      <template>
+        <style>
+         paper-slider { width: 100%; }
+        </style>
+
+        <template is="dom-if" if="{{useSlider}}">
+          <paper-slider min="0"
+                        max="{{max}}"
+                        step=".01"
+                        editable
+                        content-type="application/json"
+                        immediate-value="{{immediateSlider}}"></paper-slider>
+        </template>
+        <template is="dom-if" if="{{useColor}}">
+          <input type="color"
+                 id="col"
+                 on-input="onPickedColor"
+                 value="{{pickedColor}}">
+        </template>
+      </template>
+      <script>
+       HTMLImports.whenReady(function () {
+           Polymer({
+               is: "light9-live-control",
+               properties: {
+                   client: {type: Object},
+                   device: {type: String},
+                   attr: {type: String},
+                   max: {type: Number, value: 1},
+                   immediateSlider: {notify: true, observer: "onChange"},
+                   useSlider: {type: Boolean, computed: '_useSlider(attr)'},
+                   useColor: {type: Boolean, computed: '_useColor(attr)'},
+                   pickedColor: {observer: 'onPickedColor'},
+               },
+               ready: function() {
+               },
+               onPickedColor: function(ev) {
+                   this.onChange(ev.target.value);
+               },
+               onChange: function(lev) {
+                   this.client.send([[this.device, this.attr, lev]]);
+               },
+               _useSlider: function(attr) {
+                   return attr != 'http://light9.bigasterisk.com/color';
+               },
+               _useColor: function(attr) {
+                   return attr == 'http://light9.bigasterisk.com/color';
+               },
+           });
+       });
+      </script>
+    </dom-module>
+    
+    <dom-module id="light9-live-controls">
+      <template>
+        <style>
+        </style>
+        <light9-collector-client self="{{client}}"></light9-collector-client>
+        <h1>live</h1>
+
+        <ul>
+          <li>
+            <h2>colorstrip</h2>
+            <light9-live-control
+                client="{{client}}"
+                device="http://light9.bigasterisk.com/device/colorStrip"
+                attr="http://light9.bigasterisk.com/color"
+            ></light9-live-control>
+          </li>
+          <li>
+            <h2>moving</h2>
+            rx <light9-live-control
+                   client="{{client}}"
+                   device="http://light9.bigasterisk.com/device/moving1"
+                   attr="http://light9.bigasterisk.com/rx" max="540"
+               ></light9-live-control>
+            ry <light9-live-control
+                   client="{{client}}"
+                   device="http://light9.bigasterisk.com/device/moving1"
+                   attr="http://light9.bigasterisk.com/ry" max="240"
+               ></light9-live-control>
+            color <light9-live-control
+                      client="{{client}}"
+                      device="http://light9.bigasterisk.com/device/moving1"
+                      attr="http://light9.bigasterisk.com/color"
+                  ></light9-live-control>
+          </li>
+        </ul>
+      </template>
+      <script>
+       HTMLImports.whenReady(function () {
+           Polymer({
+               is: "light9-live-controls",
+               properties: {
+
+               },
+               ready: function() {
+
+               },
+           });
+       });
+      </script>
+    </dom-module>
+    
+    <light9-live-controls></light9-live-controls>    
+    
+  </body>
+</html>
--- a/light9/web/timeline-elements.html	Wed Jun 08 05:47:49 2016 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,337 +0,0 @@
-<link rel="import" href="/lib/polymer/polymer.html">
-<link rel="import" href="light9-timeline-audio.html">
-<link rel="import" href="/lib/iron-resizable-behavior/iron-resizable-behavior.html">
-<link rel="import" href="/lib/iron-ajax/iron-ajax.html">
-<link rel="import" href="rdfdb-synced-graph.html">
-<link rel="import" href="light9-music.html">
-
-
-<!-- Whole editor- include this on your page.
-     Most coordinates are relative to this element.
-   -->
-<dom-module id="light9-timeline-editor">
-  <template>
-    <style>
-     :host {
-         background: #444;
-         display: flex;
-         flex-direction: column;
-         position: relative;
-         border: 1px solid black;
-         overflow: hidden;
-     }
-     light9-timeline-audio {
-         width: 100%;
-         height: 30px;
-     }
-     light9-timeline-time-zoomed {
-         flex-grow: 1;
-     }
-     light9-timeline-diagram-layer, light9-timeline-adjusters {
-         position: absolute;
-         left: 0; top: 0; right: 0; bottom: 0;
-     }
-    </style>
-    <div>
-      <rdfdb-synced-graph graph="{{graph}}"></rdfdb-synced-graph>
-      <light9-music id="music"
-                    song="{{song}}"
-                    t="{{songTime}}"
-                    playing="{{songPlaying}}"
-                    duration="{{songDuration}}"></light9-music>
-      timeline editor: song [{{song}}] <button>unlink</button>
-      <label><input type="checkbox"> follow player song choice</label>
-    </div>
-    <div>[[debug]]</div>
-    <iron-ajax id="vidrefTime" url="/vidref/time" method="PUT" content-type="application/json"></iron-ajax>
-    <light9-timeline-audio id="audio"
-                           graph="{{graph}}"
-                           show="{{show}}"
-                           song="{{song}}"></light9-timeline-audio>
-    <light9-timeline-time-zoomed id="zoomed"
-                                 graph="{{graph}}"
-                                 song="{{song}}"
-                                 show="{{show}}"
-                                 zoom="{{viewState.zoomSpec}}"
-                                 zoom-in-x="{{zoomInX}}">
-    </light9-timeline-time-zoomed>
-    <light9-timeline-diagram-layer id="dia"></light9-timeline-diagram-layer>
-    <light9-timeline-adjusters id="adjusters"
-                               dia="{{dia}}"
-                               graph="{{graph}}"
-                               song="{{song}}"
-                               parent-adjs="{{adjs}}"
-                               zoom-in-x="{{zoomInX}}">
-    </light9-timeline-adjusters>
-  </template>
- 
-</dom-module>
-
-<!-- the whole section that pans/zooms in time (most of the editor) -->
-<dom-module id="light9-timeline-time-zoomed">
-  <template>
-    <style>
-     :host {
-         display: flex;
-         height: 100%;
-     }
-     div {
-         display: flex;
-         flex-direction: column;
-         height: 100%;
-     }
-     light9-timeline-audio {
-         width: 100%;
-         height: 100px;
-     }
-     light9-timeline-graph-row {
-         flex-grow: 1;
-     }
-    </style>
-    <div>
-      <light9-timeline-time-axis id="time"></light9-timeline-time-axis>
-      <light9-timeline-audio id="audio"
-                             graph="{{graph}}"
-                             song="{{song}}"
-                             show="{{show}}"
-                             zoom="{{zoomFlattened}}">
-      </light9-timeline-audio>
-      <template is="dom-repeat" items="{{rows}}">
-        <light9-timeline-graph-row graph="{{graph}}"
-                                   zoom-in-x="{{zoomInX}}"
-                                   row-index="{{item}}"
-        >
-        </light9-timeline-graph-row>
-      </template>
-    </div>
-  </template>
-  <script>
-   Polymer({
-       is: "light9-timeline-time-zoomed",
-       behaviors: [
-           Polymer.IronResizableBehavior
-       ],
-       properties: {
-           graph: { type: Object, notify: true },
-           song: { type: String, notify: true },
-           zoomInX: { type: Object, notify: true },
-           rows: {value: [0,1,2]},
-           zoom: {type: Object, notify: true, observer: 'onZoom'},
-           zoomFlattened: {type: Object, notify: true}
-       },
-       onZoom: function() {
-           ko.computed(function() {
-               this.zoomFlattened = ko.toJS(this.zoom);
-           }.bind(this));
-       }
-   });
-  </script>
-</dom-module>
-
-
-<!--
-     SVG or canvas that draws these:
-       - background grids
-       - zoom arcs
-       - notes
-       - annotations on notes
-       - connectors between adjusters and their targets
-     
-     This element is not responsible for any hit detection. Things you click (rows,
-     notes, adjusters, etc) are caught on their respective elements. (But is that
-     wrong in the case of notes?)
-   -->
-<dom-module id="light9-timeline-diagram-layer">
-  <template>
-    <style>
-     :host {
-         pointer-events: none;
-     }
-     svg {
-         width: 100%;
-         height: 100%;
-         pointer-events: none;
-     }
-    </style>
-    <svg xmlns="http://www.w3.org/2000/svg"
-         xmlns:svg="http://www.w3.org/2000/svg"
-         xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" >
-      <g id="layer1">
-        <text
-            xml:space="preserve"
-            style="font-size:13px;line-height:125%;font-family:'Verana Sans';-inkscape-font-specification:'Verana Sans';text-align:start;text-anchor:start;fill:#000000;"
-            x="-338.38403"
-            y="631.3988"
-            id="text4290"
-            sodipodi:linespacing="125%" ><tspan sodipodi:role="line" id="tspan4292" x="-338.38403" y="631.3988">spotchase</tspan></text>
-        <g id="timeAxis" transform="translate(0,40)"></g>
-        <g id="mouse"></g>
-        <g id="notes"></g>
-        <g id="connectors"></g>
-        <g id="cursor">
-          <path id="cursor1" style="fill:none; stroke:#ff0303; stroke-width:1.5; stroke-linecap:butt;" />
-          <path id="cursor2" style="fill:#9c0303;" />
-          <path id="cursor3" style="fill:none; stroke:#ff0303; stroke-width:3; stroke-linecap:butt;" />
-        </g>
-      </g>
-    </svg>
-  </template>
-</dom-module>
-
-
-<!-- seconds labels -->
-<dom-module id="light9-timeline-time-axis">
-  <template>
-    <style>
-     div {
-         width: 100%;
-         height: 31px;
-     }
-    </style>
-    <div></div>
-  </template>
-</dom-module>
-
-<!-- one row of notes -->
-<dom-module id="light9-timeline-graph-row">
-  <template>
-    <style>
-     :host {
-         border-top: 1px solid black;
-         display: flex;
-     }
-    </style>
-    <template is="dom-repeat" items="{{noteUris}}">
-      <light9-timeline-note graph="{{graph}}"
-                            uri="{{item}}"
-                            zoom-in-x="{{zoomInX}}">
-      </light9-timeline-note>
-    </template>
-  </template>
-</dom-module>
-
-<!-- One trapezoid note shape in a row.
-     This element has the right Y coords.
-     We compute X coords from the zoom setting.
-     diagram-layer draws the note body. -->
-<dom-module id="light9-timeline-note">
-  <template>
-    <style>
-     :host {
-         display: block;
-         background: green;
-         /* outline: 2px solid red; */
-     }
-    </style>
-    <light9-timeline-note-inline-attrs></light9-timeline-note-inline-attrs>
-  </template>
-</dom-module>
-
-<!-- All the adjusters you can edit or select.
-     This element manages their layout and suppresion.
-     Owns the selection.
-     Maybe includes selecting things that don't even have adjusters.
-     Maybe manages the layout of other labels and text too, to avoid overlaps.
-   -->
-<dom-module id="light9-timeline-adjusters">
-  <template>
-    <style>
-     :host {
-         pointer-events: none; /* restored on the individual adjusters */
-     }
-
-    </style>
-    <template is="dom-repeat" items="{{adjs}}">
-      <light9-timeline-adjuster dia="{{dia}}"
-                                adj="{{item}}"></light9-timeline-adjuster>
-    </template>
-  </template>
-</dom-module>
-
-<!-- Yellow dotted handle that you can adjust to edit something.
-     Knows an attribute to edit and the true screen location, even if
-     parent <light9-timeline-adjusters> has offset us a bit to avoid a
-     text overlap.
-     Draws affordance arrows and a connector line if we're far
-     away from the point that we edit.
-     May grow to a bigger editor when you click or drag.
-   -->
-<dom-module id="light9-timeline-adjuster">
-  <template>
-    <style>
-     /* 
-     #top {outline: 2px solid rgba(255, 0, 0, 0.25);}
-     table {outline: 2px solid rgba(0, 0, 255, 0.19);}
-     */
-     
-     #top {
-         position: absolute;
-         display: inline-block;
-     }
-     table {
-         position: relative;
-         left: -50%;
-         top: -40px; /* percent had no effect */
-         z-index: 2;
-         border-collapse: collapse;
-     }
-     td {
-         text-align: center;
-         font-size: 20px;
-     }
-     span {
-         font-size: 16px;
-         background: rgba(255, 255, 0, 0.5);
-         border: 3px yellow dotted;
-         border-radius: 8px;
-         padding: 5px;
-
-         cursor: ew-resize;
-         -webkit-user-select: none;
-         pointer-events: all;
-     }
-     span.empty {
-         width: 30px; /* todo: supposed to fill the whole visible section*/
-         height: 13px;
-         display: inline-block;
-         background: rgba(0,0,0,0);
-     }
-    </style>
-    <div id="top" style$="left: [[centerStyle.x]]px; top: [[centerStyle.y]]px">
-      <table>
-        <tr><td></td><td style="visibility: hidden">↑</td><td></td></tr>
-        <tr><td>←</td><td><span id="label" class$="[[spanClass]]">[[displayValue]]</span></td><td>→</td></tr>
-        <tr><td></td><td style="visibility: hidden">↓</td><td></td></tr>
-      </table>
-    </div>
-    
-  </template>
-</dom-module>
-
-<!-- sometimes we draw attrs within the shape of a note. -->
-<dom-module id="light9-timeline-note-inline-attrs">
-  <template>
-
-  </template>
-  <script>
-   Polymer({
-       is: "light9-timeline-note-inline-attrs",
-       properties: {
-       }
-   });
-  </script>
-</dom-module>
-
-<script src="/lib/sylvester/sylvester.js"></script>
-<script src="/lib/d3/build/d3.min.js"></script>
-
-<!-- version with https://github.com/RubenVerborgh/N3.js/pull/61 -->
-<script src="/lib/N3.js-pull61/browser/n3-browser.js"></script>
-<!-- master version -->
-<xxscript src="/lib/N3.js/browser/n3-browser.js"></script>
-  
-<script src="/lib/knockout/dist/knockout.debug.js"></script>
-<script src="/lib/shortcut/index.js"></script>
-<script src="/lib/async/dist/async.js"></script>
-<script src="adjustable.js"></script>
-<script src="timeline.js"></script>
--- a/light9/web/timeline.coffee	Wed Jun 08 05:47:49 2016 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,410 +0,0 @@
-log = console.log
-
-Polymer
-  is: 'light9-timeline-editor'
-  behaviors: [ Polymer.IronResizableBehavior ]
-  properties:
-    viewState: { type: Object }
-    debug: {type: String}
-    graph: {type: Object, notify: true}
-    song: {type: String, notify: true}
-    show: {value: 'http://light9.bigasterisk.com/show/dance2016'}
-    songTime: {type: Number, notify: true, observer: '_onSongTime'}
-    songDuration: {type: Number, notify: true, observer: '_onSongDuration'}
-    songPlaying: {type: Boolean, notify: true}
-    fullZoomX: {type: Object, notify: true}
-    zoomInX: {type: Object, notify: true}
-  width: ko.observable(1)
-  listeners:
-    'iron-resize': '_onIronResize'
-  _onIronResize: ->
-    @width(@offsetWidth)
-  _onSongTime: (t) ->
-    @viewState.cursor.t(t)
-  _onSongDuration: (d) ->
-    @viewState.zoomSpec.duration(d)
-
-  ready: ->
-    @viewState =
-      zoomSpec:
-        duration: ko.observable(100)
-        t1: ko.observable(0) # need validation to stay in bounds and not go too close
-        t2: ko.observable(100)
-      cursor:
-        t: ko.observable(20)
-      mouse:
-        pos: ko.observable($V([0,0]))
-    @fullZoomX = d3.scaleLinear()
-    @zoomInX = d3.scaleLinear()
-  attached: ->
-
-    ko.computed(@zoomOrLayoutChanged.bind(@)).extend({rateLimit: 5})
-
-    @adjs = @makeZoomAdjs()
-
-    @trackMouse()
-    @bindKeys()
-    @bindWheelZoom()
-
-  zoomOrLayoutChanged: ->
-    @fullZoomX.domain([0, @viewState.zoomSpec.duration()])
-    @fullZoomX.range([0, @width()])
-
-    # had trouble making notes update when this changes
-    zoomInX = d3.scaleLinear()
-    zoomInX.domain([@viewState.zoomSpec.t1(), @viewState.zoomSpec.t2()])
-    zoomInX.range([0, @width()])
-    @zoomInX = zoomInX
-
-    # todo: these run a lot of work purely for a time change    
-    @$.dia.setTimeAxis(@width(), @$.zoomed.$.audio.offsetTop, @zoomInX)
-    @$.adjusters.updateAllCoords()
-
-    @$.dia.setCursor(@$.audio.offsetTop, @$.audio.offsetHeight,
-                     @$.zoomed.$.time.offsetTop,
-                     @$.zoomed.$.time.offsetHeight,
-                     @fullZoomX, @zoomInX, @viewState.cursor)
-    
-  trackMouse: ->
-    # not just for show- we use the mouse pos sometimes
-    for evName in ['mousemove', 'touchmove']
-      @addEventListener evName, (ev) =>
-        ev.preventDefault()
-
-        # todo: consolidate with _editorCoordinates version
-        if ev.touches?.length
-          ev = ev.touches[0]
-
-        @root = @getBoundingClientRect()
-        @viewState.mouse.pos($V([ev.pageX - @root.left, ev.pageY - @root.top]))
-
-        @$.dia.setMouse(@viewState.mouse.pos())
-        #@sendMouseToVidref()
-
-  sendMouseToVidref: ->
-    now = Date.now()
-    if (!@$.vidrefLastSent? || @$.vidrefLastSent < now - 200) && !@songPlaying
-      @$.vidrefTime.body = {t: @latestMouseTime(), source: 'timeline'}
-      @$.vidrefTime.generateRequest()
-      @$.vidrefLastSent = now
-
-  latestMouseTime: ->
-    @zoomInX.invert(@viewState.mouse.pos().e(1))
-
-  bindWheelZoom: ->
-    @$.zoomed.addEventListener 'mousewheel', (ev) =>
-      zs = @viewState.zoomSpec
-
-      center = @latestMouseTime()
-      left = center - zs.t1()
-      right = zs.t2() - center
-      scale = Math.pow(1.005, ev.deltaY)
-
-      zs.t1(center - left * scale)
-      zs.t2(center + right * scale)
-
-  animatedZoom: (newT1, newT2, secs) ->
-    fps = 30
-    oldT1 = @viewState.zoomSpec.t1()
-    oldT2 = @viewState.zoomSpec.t2()
-    lastTime = 0
-    for step in [0..secs * fps]
-      frac = step / (secs * fps)
-      do (frac) =>
-        gotoStep = =>
-          @viewState.zoomSpec.t1((1 - frac) * oldT1 + frac * newT1)
-          @viewState.zoomSpec.t2((1 - frac) * oldT2 + frac * newT2)
-        delay = frac * secs * 1000
-        setTimeout(gotoStep, delay)
-        lastTime = delay
-    setTimeout(=>
-        @viewState.zoomSpec.t1(newT1)
-        @viewState.zoomSpec.t2(newT2)
-      , lastTime + 10)  
-    
-  bindKeys: ->
-    shortcut.add "Ctrl+P", (ev) =>
-      @$.music.seekPlayOrPause(@latestMouseTime())
-
-    zoomAnimSec = .1
-    shortcut.add "Ctrl+Escape", =>
-      @animatedZoom(0, @viewState.zoomSpec.duration(), zoomAnimSec)
-    shortcut.add "Shift+Escape", =>
-      @animatedZoom(@songTime - 2, @viewState.zoomSpec.duration(), zoomAnimSec)
-    shortcut.add "Escape", =>
-      zs = @viewState.zoomSpec
-      visSeconds = zs.t2() - zs.t1()
-      margin = visSeconds * .4
-      # buggy: really needs t1/t2 to limit their ranges
-      if @songTime < zs.t1() or @songTime > zs.t2() - visSeconds * .6
-        newCenter = @songTime + margin
-        @animatedZoom(newCenter - visSeconds / 2,
-                      newCenter + visSeconds / 2, zoomAnimSec)
-
-  makeZoomAdjs: ->
-    yMid = @$.audio.offsetTop + @$.audio.offsetHeight / 2
-    dur = @viewState.zoomSpec.duration
-    
-    valForPos = (pos) =>
-        x = pos.e(1)
-        t = @fullZoomX.invert(x)
-    left = new AdjustableFloatObservable({
-      observable: @viewState.zoomSpec.t1,
-      getTarget: () =>
-        $V([@fullZoomX(@viewState.zoomSpec.t1()), yMid])
-      getSuggestedTargetOffset: () => $V([-50, 0])
-      getValueForPos: valForPos
-    })
-
-    right = new AdjustableFloatObservable({
-      observable: @viewState.zoomSpec.t2,
-      getTarget: () =>
-        $V([@fullZoomX(@viewState.zoomSpec.t2()), yMid])
-      getSuggestedTargetOffset: () => $V([50, 0])
-      getValueForPos: valForPos
-    })
-
-    panObs = ko.pureComputed({
-        read: () =>
-          (@viewState.zoomSpec.t1() + @viewState.zoomSpec.t2()) / 2
-        write: (value) =>
-          zs = @viewState.zoomSpec
-          span = zs.t2() - zs.t1()
-          zs.t1(value - span / 2)
-          zs.t2(value + span / 2)
-      })
-
-    pan = new AdjustableFloatObservable({
-      observable: panObs
-      emptyBox: true
-      # fullzoom is not right- the sides shouldn't be able to go
-      # offscreen
-      getTarget: () => $V([@fullZoomX(panObs()), yMid])
-      getSuggestedTargetOffset: () => $V([0, 0])
-      getValueForPos: valForPos
-      })
-      
-    return [left, right, pan]
-
-Polymer
-  is: "light9-timeline-time-axis",
-  # for now since it's just one line calling dia,
-  # light9-timeline-editor does our drawing work.
-
-Polymer
-  is: 'light9-timeline-graph-row'
-  behaviors: [ Polymer.IronResizableBehavior ]
-  properties:
-    graph: { type: Object, notify: true }
-    zoomInX: { type: Object, notify: true }
-    noteUris: { type: Array, notify: true }
-    rowIndex: { type: Object, notify: true }
-  observers: [
-    'onGraph(graph)'
-    ]
-  onGraph: ->
-    @graph.runHandler(@update.bind(@))
-  update: ->
-    U = (x) -> @graph.Uri(x)
-
-    @noteUris = []
-    for note in @graph.objects(@song, U(':note'))
-      @push('noteUris', note)
-  
-
-Polymer
-  is: 'light9-timeline-note'
-  behaviors: [ Polymer.IronResizableBehavior ]
-  listeners: 'iron-resize': 'update'
-  properties:
-    graph: { type: Object, notify: true }
-    uri: { type: String, notify: true }
-    zoomInX: { type: Object, notify: true }
-  observers: [
-    'onUri(graph, uri)'
-    'update(graph, uri, zoomInX)'
-    ]
-  ready: ->
-
-  onUri: ->
-    @graph.runHandler(@update.bind(@))
-    
-  update: ->
-    # update our note DOM and SVG elements based on the graph
-    U = (x) -> @graph.Uri(x)
-    try
-      worldPts = [] # (song time, value)
-
-      originTime = @graph.floatValue(@uri, U(':originTime'))
-      for curve in @graph.objects(@uri, U(':curve'))
-        if @graph.uriValue(curve, U(':attr')) == U(':strength')
-          
-          for pt in @graph.objects(curve, U(':point'))
-
-            worldPts.push($V([
-              originTime + @graph.floatValue(pt, U(':time')),
-              @graph.floatValue(pt, U(':value'))
-              ]))
-      worldPts.sort((a,b) -> a.e(1) > b.e(1))
-
-      screenPos = (pt) =>
-        $V([@zoomInX(pt.e(1)), @offsetTop + (1 - pt.e(2)) * @offsetHeight])
-
-      setNote(@uri, (screenPos(pt) for pt in worldPts))
-
-    catch e
-      log("during resize of #{@uri}: #{@e}")
-
-Polymer
-  is: "light9-timeline-adjusters"
-  properties:
-    adjs: { type: Array }, # our computed list
-    parentAdjs: { type: Array }, # incoming requests
-    graph: { type: Object, notify: true }
-    song: { type: String, notify: true }
-    zoomInX: { type: Object, notify: true }
-    dia: { type: Object }
-  observers: [
-    'update(parentAdjs, graph, song, dia)'
-    'onGraph(graph, song)'
-    ]
-  onGraph: (graph, song, zoomInX) ->
-    graph.runHandler(@update.bind(@))
-  update: (parentAdjs, graph, song, dia) ->
-    U = (x) -> @graph.Uri(x)
-    @adjs = (@parentAdjs || []).slice()
-    for note in @graph.objects(@song, U(':note'))
-      @push('adjs', new AdjustableFloatObject({
-        graph: @graph
-        subj: note
-        pred: @graph.Uri(':originTime')
-        ctx: @graph.Uri(@song)
-        getTargetTransform: (value) => $V([@zoomInX(value), 600])
-        getValueForPos: (pos) => @zoomInX.invert(pos.e(1))
-        getSuggestedTargetOffset: () => $V([0, -80])
-      }))
-    
-  updateAllCoords: ->
-    for elem in @querySelectorAll('light9-timeline-adjuster')
-      elem.updateDisplay()
-    
-
-_adjusterSerial = 0
-
-Polymer
-  is: 'light9-timeline-adjuster'
-  properties:
-    adj:
-      type: Object
-      notify: true
-      observer: 'onAdj'
-    target:
-      type: Object
-      notify: true
-    displayValue:
-      type: String
-    centerStyle:
-      type: Object
-    spanClass:
-      type: String
-      value: ''
-
-  onAdj: (adj) ->
-    @adj.subscribe(@updateDisplay.bind(this))
-
-  updateDisplay: () ->
-      @spanClass = if @adj.config.emptyBox then 'empty' else ''
-      @displayValue = @adj.getDisplayValue()
-      center = @adj.getCenter()
-      target = @adj.getTarget()
-      return if isNaN(center.e(1))
-      @centerStyle = {x: center.e(1), y: center.e(2)}
-      @dia?.setAdjusterConnector(@myId, center, target)
-        
-  attached: ->
-    @myId = 'adjuster-' + _adjusterSerial
-    _adjusterSerial += 1
-    
-    drag = d3.drag()
-    sel = d3.select(@$.label)
-    sel.call(drag)
-    drag.subject((d) -> {x: @offsetLeft, y: @offsetTop})
-    drag.container(@offsetParent)
-    drag.on('start', () => @adj?.startDrag())
-    drag.on 'drag', () =>
-      @adj?.continueDrag($V([d3.event.x, d3.event.y]))
-    drag.on('end', () => @adj?.endDrag())
-
-
-svgPathFromPoints = (pts) ->
-  out = ''
-  pts.forEach (p) ->
-    p = p.elements if p.elements # for vec2
-    if out.length == 0
-      out = 'M '
-    else
-      out += 'L '
-    out += '' + p[0] + ',' + p[1] + ' '
-    return
-  out
-
-Polymer
-  is: 'light9-timeline-diagram-layer'
-  properties: {}
-  ready: ->
-    @elemById = {}
-    window.setNote = @setNote.bind(this)
-    window.setMouse = @setMouse.bind(this)
-
-  setTimeAxis: (width, yTop, scale) ->
-    pxPerTick = 50
-    axis = d3.axisTop(scale).ticks(width / pxPerTick)
-    d3.select(@$.timeAxis).attr('transform', 'translate(0,'+yTop+')').call(axis)
-
-  setMouse: (pos) ->
-    elem = @getOrCreateElem('mouse-x', 'mouse', 'path', {style: "fill:none;stroke:#fff;stroke-width:0.5;"})
-    elem.setAttribute('d', svgPathFromPoints([[-9999, pos.e(2)], [9999, pos.e(2)]]))
-    elem = @getOrCreateElem('mouse-y', 'mouse', 'path', {style: "fill:none;stroke:#fff;stroke-width:0.5;"})
-    elem.setAttribute('d', svgPathFromPoints([[pos.e(1), -9999], [pos.e(1), 9999]]))   
-
-  getOrCreateElem: (uri, groupId, tag, attrs) ->
-    elem = @elemById[uri]
-    if !elem
-      elem = @elemById[uri] = document.createElementNS("http://www.w3.org/2000/svg", tag)
-      @$[groupId].appendChild(elem)
-      elem.setAttribute('id', uri)
-      for k,v of attrs
-        elem.setAttribute(k, v)
-    return elem
-    
-  setNote: (uri, curvePts) ->
-    elem = @getOrCreateElem(uri, 'notes', 'path', {style:"fill:#53774b; stroke:#000000; stroke-width:1.5;"})
-    elem.setAttribute('d', svgPathFromPoints(curvePts))
-
-  setCursor: (y1, h1, y2, h2, fullZoomX, zoomInX, cursor) ->
-    @cursorPath =
-      top: @querySelector('#cursor1')
-      mid: @querySelector('#cursor2')
-      bot: @querySelector('#cursor3')
-    return if !@cursorPath.top
-    
-    xZoomedOut = fullZoomX(cursor.t())
-    xZoomedIn = zoomInX(cursor.t())
-    @cursorPath.top.setAttribute 'd', svgPathFromPoints([
-      [xZoomedOut, y1]
-      [xZoomedOut, y1 + h1]
-    ])
-    @cursorPath.mid.setAttribute 'd', svgPathFromPoints([
-      [xZoomedIn + 2, y2 + h2]
-      [xZoomedIn - 2, y2 + h2]
-      [xZoomedOut - 1, y1 + h1]
-      [xZoomedOut + 1, y1 + h1]
-    ]) + ' Z'
-    @cursorPath.bot.setAttribute 'd', svgPathFromPoints([
-      [xZoomedIn, y2 + h2]
-      [xZoomedIn, @offsetParent.offsetHeight]
-    ])
-
-  setAdjusterConnector: (uri, center, target) ->
-    elem = @getOrCreateElem(uri, 'connectors', 'path', {style: "fill:none;stroke:#d4d4d4;stroke-width:0.9282527;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:2.78475821, 2.78475821;stroke-dashoffset:0;"})
-    elem.setAttribute('d', svgPathFromPoints([center, target]))
--- a/light9/web/timeline.html	Wed Jun 08 05:47:49 2016 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,13 +0,0 @@
-<!doctype html>
-<html>
-  <head>
-    <title>timeline</title>
-    <meta charset="utf-8" />
-    <script src="/lib/webcomponentsjs/webcomponents-lite.min.js"></script>
-    <link rel="import" href="timeline-elements.html"
-  </head>
-  <body>
-    <light9-timeline-editor style="width: 100%; height: 600px">
-    </light9-timeline-editor>
-  </body>
-</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/timeline/adjustable.coffee	Wed Jun 08 06:02:07 2016 +0000
@@ -0,0 +1,117 @@
+log = console.log
+
+class Adjustable
+  # Some value you can edit in the UI, probably by dragging stuff. May
+  # have a <light9-timeline-adjuster> associated. This object does the
+  # layout and positioning.
+  #
+  # The way dragging should work is that you start in the yellow *adj
+  # widget*, wherever it is, but your drag is moving the *target*. The
+  # adj will travel around too, but it may do extra moves to not bump
+  # into stuff or to get out from under your finger.
+
+  constructor: (@config) ->
+    # config has:
+    #   getTarget -> vec2 of current target position
+    #   getSuggestedTargetOffset -> vec2 pixel offset from target
+    #   emptyBox -> true if you want no value display
+
+  getDisplayValue: () ->
+    return '' if @config.emptyBox
+    d3.format(".4g")(@_getValue())
+
+  getCenter: () -> # vec2 of pixels
+    @getTarget().add(@config.getSuggestedTargetOffset())
+
+  getTarget: () -> # vec2 of pixels
+    @config.getTarget()
+            
+  subscribe: (onChange) ->
+    # change could be displayValue or center or target. This likely
+    # calls onChange right away if there's any data yet.
+    throw new Error('not implemented')
+
+  startDrag: () ->
+    # override
+
+  continueDrag: (pos) ->
+    # pos is vec2 of pixels relative to the drag start
+
+    # override
+    
+  endDrag: () ->
+    # override
+
+  _editorCoordinates: () -> # vec2 of mouse relative to <l9-t-editor>
+    ev = d3.event.sourceEvent
+
+    if ev.target.tagName == "LIGHT9-TIMELINE-EDITOR"
+      rootElem = ev.target
+    else
+      rootElem = ev.target.closest('light9-timeline-editor')
+
+    if ev.touches?.length
+      ev = ev.touches[0]
+      
+    # storing root on the object to remember it across calls in case
+    # you drag outside the editor.
+    @root = rootElem.getBoundingClientRect() if rootElem
+    offsetParentPos = $V([ev.pageX - @root.left, ev.pageY - @root.top])
+
+    setMouse(offsetParentPos) # for debugging
+    return offsetParentPos 
+
+class window.AdjustableFloatObservable extends Adjustable
+  constructor: (@config) ->
+    # config also has:
+    #   observable -> ko.observable we will read and write
+    #   getValueForPos(pos) -> what should we set to if the user
+    #                          moves target to this coord?
+
+  _getValue: () ->
+    @config.observable()
+    
+  continueDrag: (pos) ->
+    # pos is vec2 of pixels relative to the drag start.
+
+    epos = @_editorCoordinates()
+    newValue = @config.getValueForPos(epos)
+    @config.observable(newValue)
+
+  subscribe: (onChange) ->
+    ko.computed =>
+      @config.observable()
+      onChange()
+
+class window.AdjustableFloatObject extends Adjustable
+  constructor: (@config) ->
+    # config also has:
+    #   graph
+    #   subj
+    #   pred
+    #   ctx
+    #   getTargetTransform(value) -> getTarget result for value
+    #   getValueForPos
+
+    super(@config)
+    
+  _getValue: () ->
+    # this is a big speedup- callers use _getValue about 4x as much as
+    # the graph changes and graph.floatValue is slow
+    @_currentValue
+
+  getTarget: () ->
+    @config.getTargetTransform(@_getValue())
+    
+  subscribe: (onChange) ->
+    @config.graph.subscribe @config.subj, @config.pred, null, (patch) =>
+      @_currentValue = @config.graph.floatValue(@config.subj, @config.pred)
+      onChange()
+    
+  continueDrag: (pos) ->
+    # pos is vec2 of pixels relative to the drag start
+    
+    newValue = @config.getValueForPos(@_editorCoordinates())
+    @config.graph.patchObject(@config.subj, @config.pred,
+                              @config.graph.LiteralRoundedFloat(newValue),
+                              @config.ctx)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/timeline/index.html	Wed Jun 08 06:02:07 2016 +0000
@@ -0,0 +1,13 @@
+<!doctype html>
+<html>
+  <head>
+    <title>timeline</title>
+    <meta charset="utf-8" />
+    <script src="/lib/webcomponentsjs/webcomponents-lite.min.js"></script>
+    <link rel="import" href="timeline-elements.html"
+  </head>
+  <body>
+    <light9-timeline-editor style="width: 100%; height: 600px">
+    </light9-timeline-editor>
+  </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/timeline/light9-timeline-audio.html	Wed Jun 08 06:02:07 2016 +0000
@@ -0,0 +1,84 @@
+<link rel="import" href="/lib/polymer/polymer.html">
+
+<!-- (potentially-zoomed) spectrogram view -->
+<dom-module id="light9-timeline-audio">
+  <template>
+    <style>
+     :host {
+         display: block;
+         /* shouldn't be seen, but black is correct for 'no
+         audio'. Maybe loading stripes would be better */
+         background: black; 
+     }
+     div {
+         width: 100%;
+         height: 100%;
+         overflow: hidden;
+     }
+     img {
+         height: 100%;
+         position: relative;
+     }
+    </style>
+    <div>
+      <img src="{{imgSrc}}"
+           style="width: {{imgWidth}} ; left: {{imgLeft}}">
+    </div>
+  </template>
+  <script>
+   log = console.log
+   Polymer({
+       is: "light9-timeline-audio",
+       properties: {
+           graph: {type: Object, notify: true},
+           show: {type: String, notify: true},
+           song: {type: String, notify: true},
+           zoom: {type: Object, notify: true},
+           imgSrc: { type: String, notify: true},
+           imgWidth: { computed: '_imgWidth(zoom)' },
+           imgLeft: { computed: '_imgLeft(zoom)' },
+       },
+       observers: [
+           'setImgSrc(graph, show, song)'
+       ],
+       ready: function() {
+           this.zoom = {duration: 0};
+       },       
+       setImgSrc: function() {
+           graph.runHandler(function() {
+               try {
+                   var root = this.graph.stringValue(
+                       this.graph.Uri(this.show),
+                       this.graph.Uri(':spectrogramUrlRoot'));
+               } catch(e) {
+                   return;
+               }
+               
+               try {
+                   var filename = this.graph.stringValue(
+                       this.song, this.graph.Uri(':songFilename'));
+               } catch(e) {
+                   return;
+               }
+               
+               this.imgSrc = root + '/' + filename.replace('.wav', '.png');
+           }.bind(this));
+       },
+       _imgWidth: function(zoom) {
+           if (!zoom.duration) {
+               return "100%";
+           }
+
+           return (100 / ((zoom.t2 - zoom.t1) / zoom.duration)) + "%";
+       },
+       _imgLeft: function(zoom) {
+           if (!zoom.duration) {
+               return "0";
+           }
+
+           var percentPerSec = 100 / (zoom.t2 - zoom.t1);
+           return (-percentPerSec * zoom.t1) + '%';
+       },
+   });
+  </script>
+</dom-module>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/timeline/timeline-elements.html	Wed Jun 08 06:02:07 2016 +0000
@@ -0,0 +1,337 @@
+<link rel="import" href="/lib/polymer/polymer.html">
+<link rel="import" href="/lib/iron-resizable-behavior/iron-resizable-behavior.html">
+<link rel="import" href="/lib/iron-ajax/iron-ajax.html">
+<link rel="import" href="light9-timeline-audio.html">
+<link rel="import" href="../rdfdb-synced-graph.html">
+<link rel="import" href="../light9-music.html">
+
+
+<!-- Whole editor- include this on your page.
+     Most coordinates are relative to this element.
+   -->
+<dom-module id="light9-timeline-editor">
+  <template>
+    <style>
+     :host {
+         background: #444;
+         display: flex;
+         flex-direction: column;
+         position: relative;
+         border: 1px solid black;
+         overflow: hidden;
+     }
+     light9-timeline-audio {
+         width: 100%;
+         height: 30px;
+     }
+     light9-timeline-time-zoomed {
+         flex-grow: 1;
+     }
+     light9-timeline-diagram-layer, light9-timeline-adjusters {
+         position: absolute;
+         left: 0; top: 0; right: 0; bottom: 0;
+     }
+    </style>
+    <div>
+      <rdfdb-synced-graph graph="{{graph}}"></rdfdb-synced-graph>
+      <light9-music id="music"
+                    song="{{song}}"
+                    t="{{songTime}}"
+                    playing="{{songPlaying}}"
+                    duration="{{songDuration}}"></light9-music>
+      timeline editor: song [{{song}}] <button>unlink</button>
+      <label><input type="checkbox"> follow player song choice</label>
+    </div>
+    <div>[[debug]]</div>
+    <iron-ajax id="vidrefTime" url="/vidref/time" method="PUT" content-type="application/json"></iron-ajax>
+    <light9-timeline-audio id="audio"
+                           graph="{{graph}}"
+                           show="{{show}}"
+                           song="{{song}}"></light9-timeline-audio>
+    <light9-timeline-time-zoomed id="zoomed"
+                                 graph="{{graph}}"
+                                 song="{{song}}"
+                                 show="{{show}}"
+                                 zoom="{{viewState.zoomSpec}}"
+                                 zoom-in-x="{{zoomInX}}">
+    </light9-timeline-time-zoomed>
+    <light9-timeline-diagram-layer id="dia"></light9-timeline-diagram-layer>
+    <light9-timeline-adjusters id="adjusters"
+                               dia="{{dia}}"
+                               graph="{{graph}}"
+                               song="{{song}}"
+                               parent-adjs="{{adjs}}"
+                               zoom-in-x="{{zoomInX}}">
+    </light9-timeline-adjusters>
+  </template>
+ 
+</dom-module>
+
+<!-- the whole section that pans/zooms in time (most of the editor) -->
+<dom-module id="light9-timeline-time-zoomed">
+  <template>
+    <style>
+     :host {
+         display: flex;
+         height: 100%;
+     }
+     div {
+         display: flex;
+         flex-direction: column;
+         height: 100%;
+     }
+     light9-timeline-audio {
+         width: 100%;
+         height: 100px;
+     }
+     light9-timeline-graph-row {
+         flex-grow: 1;
+     }
+    </style>
+    <div>
+      <light9-timeline-time-axis id="time"></light9-timeline-time-axis>
+      <light9-timeline-audio id="audio"
+                             graph="{{graph}}"
+                             song="{{song}}"
+                             show="{{show}}"
+                             zoom="{{zoomFlattened}}">
+      </light9-timeline-audio>
+      <template is="dom-repeat" items="{{rows}}">
+        <light9-timeline-graph-row graph="{{graph}}"
+                                   zoom-in-x="{{zoomInX}}"
+                                   row-index="{{item}}"
+        >
+        </light9-timeline-graph-row>
+      </template>
+    </div>
+  </template>
+  <script>
+   Polymer({
+       is: "light9-timeline-time-zoomed",
+       behaviors: [
+           Polymer.IronResizableBehavior
+       ],
+       properties: {
+           graph: { type: Object, notify: true },
+           song: { type: String, notify: true },
+           zoomInX: { type: Object, notify: true },
+           rows: {value: [0,1,2]},
+           zoom: {type: Object, notify: true, observer: 'onZoom'},
+           zoomFlattened: {type: Object, notify: true}
+       },
+       onZoom: function() {
+           ko.computed(function() {
+               this.zoomFlattened = ko.toJS(this.zoom);
+           }.bind(this));
+       }
+   });
+  </script>
+</dom-module>
+
+
+<!--
+     SVG or canvas that draws these:
+       - background grids
+       - zoom arcs
+       - notes
+       - annotations on notes
+       - connectors between adjusters and their targets
+     
+     This element is not responsible for any hit detection. Things you click (rows,
+     notes, adjusters, etc) are caught on their respective elements. (But is that
+     wrong in the case of notes?)
+   -->
+<dom-module id="light9-timeline-diagram-layer">
+  <template>
+    <style>
+     :host {
+         pointer-events: none;
+     }
+     svg {
+         width: 100%;
+         height: 100%;
+         pointer-events: none;
+     }
+    </style>
+    <svg xmlns="http://www.w3.org/2000/svg"
+         xmlns:svg="http://www.w3.org/2000/svg"
+         xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" >
+      <g id="layer1">
+        <text
+            xml:space="preserve"
+            style="font-size:13px;line-height:125%;font-family:'Verana Sans';-inkscape-font-specification:'Verana Sans';text-align:start;text-anchor:start;fill:#000000;"
+            x="-338.38403"
+            y="631.3988"
+            id="text4290"
+            sodipodi:linespacing="125%" ><tspan sodipodi:role="line" id="tspan4292" x="-338.38403" y="631.3988">spotchase</tspan></text>
+        <g id="timeAxis" transform="translate(0,40)"></g>
+        <g id="mouse"></g>
+        <g id="notes"></g>
+        <g id="connectors"></g>
+        <g id="cursor">
+          <path id="cursor1" style="fill:none; stroke:#ff0303; stroke-width:1.5; stroke-linecap:butt;" />
+          <path id="cursor2" style="fill:#9c0303;" />
+          <path id="cursor3" style="fill:none; stroke:#ff0303; stroke-width:3; stroke-linecap:butt;" />
+        </g>
+      </g>
+    </svg>
+  </template>
+</dom-module>
+
+
+<!-- seconds labels -->
+<dom-module id="light9-timeline-time-axis">
+  <template>
+    <style>
+     div {
+         width: 100%;
+         height: 31px;
+     }
+    </style>
+    <div></div>
+  </template>
+</dom-module>
+
+<!-- one row of notes -->
+<dom-module id="light9-timeline-graph-row">
+  <template>
+    <style>
+     :host {
+         border-top: 1px solid black;
+         display: flex;
+     }
+    </style>
+    <template is="dom-repeat" items="{{noteUris}}">
+      <light9-timeline-note graph="{{graph}}"
+                            uri="{{item}}"
+                            zoom-in-x="{{zoomInX}}">
+      </light9-timeline-note>
+    </template>
+  </template>
+</dom-module>
+
+<!-- One trapezoid note shape in a row.
+     This element has the right Y coords.
+     We compute X coords from the zoom setting.
+     diagram-layer draws the note body. -->
+<dom-module id="light9-timeline-note">
+  <template>
+    <style>
+     :host {
+         display: block;
+         background: green;
+         /* outline: 2px solid red; */
+     }
+    </style>
+    <light9-timeline-note-inline-attrs></light9-timeline-note-inline-attrs>
+  </template>
+</dom-module>
+
+<!-- All the adjusters you can edit or select.
+     This element manages their layout and suppresion.
+     Owns the selection.
+     Maybe includes selecting things that don't even have adjusters.
+     Maybe manages the layout of other labels and text too, to avoid overlaps.
+   -->
+<dom-module id="light9-timeline-adjusters">
+  <template>
+    <style>
+     :host {
+         pointer-events: none; /* restored on the individual adjusters */
+     }
+
+    </style>
+    <template is="dom-repeat" items="{{adjs}}">
+      <light9-timeline-adjuster dia="{{dia}}"
+                                adj="{{item}}"></light9-timeline-adjuster>
+    </template>
+  </template>
+</dom-module>
+
+<!-- Yellow dotted handle that you can adjust to edit something.
+     Knows an attribute to edit and the true screen location, even if
+     parent <light9-timeline-adjusters> has offset us a bit to avoid a
+     text overlap.
+     Draws affordance arrows and a connector line if we're far
+     away from the point that we edit.
+     May grow to a bigger editor when you click or drag.
+   -->
+<dom-module id="light9-timeline-adjuster">
+  <template>
+    <style>
+     /* 
+     #top {outline: 2px solid rgba(255, 0, 0, 0.25);}
+     table {outline: 2px solid rgba(0, 0, 255, 0.19);}
+     */
+     
+     #top {
+         position: absolute;
+         display: inline-block;
+     }
+     table {
+         position: relative;
+         left: -50%;
+         top: -40px; /* percent had no effect */
+         z-index: 2;
+         border-collapse: collapse;
+     }
+     td {
+         text-align: center;
+         font-size: 20px;
+     }
+     span {
+         font-size: 16px;
+         background: rgba(255, 255, 0, 0.5);
+         border: 3px yellow dotted;
+         border-radius: 8px;
+         padding: 5px;
+
+         cursor: ew-resize;
+         -webkit-user-select: none;
+         pointer-events: all;
+     }
+     span.empty {
+         width: 30px; /* todo: supposed to fill the whole visible section*/
+         height: 13px;
+         display: inline-block;
+         background: rgba(0,0,0,0);
+     }
+    </style>
+    <div id="top" style$="left: [[centerStyle.x]]px; top: [[centerStyle.y]]px">
+      <table>
+        <tr><td></td><td style="visibility: hidden">↑</td><td></td></tr>
+        <tr><td>←</td><td><span id="label" class$="[[spanClass]]">[[displayValue]]</span></td><td>→</td></tr>
+        <tr><td></td><td style="visibility: hidden">↓</td><td></td></tr>
+      </table>
+    </div>
+    
+  </template>
+</dom-module>
+
+<!-- sometimes we draw attrs within the shape of a note. -->
+<dom-module id="light9-timeline-note-inline-attrs">
+  <template>
+
+  </template>
+  <script>
+   Polymer({
+       is: "light9-timeline-note-inline-attrs",
+       properties: {
+       }
+   });
+  </script>
+</dom-module>
+
+<script src="/lib/sylvester/sylvester.js"></script>
+<script src="/lib/d3/build/d3.min.js"></script>
+
+<!-- version with https://github.com/RubenVerborgh/N3.js/pull/61 -->
+<script src="/lib/N3.js-pull61/browser/n3-browser.js"></script>
+<!-- master version -->
+<xxscript src="/lib/N3.js/browser/n3-browser.js"></script>
+  
+<script src="/lib/knockout/dist/knockout.debug.js"></script>
+<script src="/lib/shortcut/index.js"></script>
+<script src="/lib/async/dist/async.js"></script>
+<script src="adjustable.js"></script>
+<script src="timeline.js"></script>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/timeline/timeline.coffee	Wed Jun 08 06:02:07 2016 +0000
@@ -0,0 +1,410 @@
+log = console.log
+
+Polymer
+  is: 'light9-timeline-editor'
+  behaviors: [ Polymer.IronResizableBehavior ]
+  properties:
+    viewState: { type: Object }
+    debug: {type: String}
+    graph: {type: Object, notify: true}
+    song: {type: String, notify: true}
+    show: {value: 'http://light9.bigasterisk.com/show/dance2016'}
+    songTime: {type: Number, notify: true, observer: '_onSongTime'}
+    songDuration: {type: Number, notify: true, observer: '_onSongDuration'}
+    songPlaying: {type: Boolean, notify: true}
+    fullZoomX: {type: Object, notify: true}
+    zoomInX: {type: Object, notify: true}
+  width: ko.observable(1)
+  listeners:
+    'iron-resize': '_onIronResize'
+  _onIronResize: ->
+    @width(@offsetWidth)
+  _onSongTime: (t) ->
+    @viewState.cursor.t(t)
+  _onSongDuration: (d) ->
+    @viewState.zoomSpec.duration(d)
+
+  ready: ->
+    @viewState =
+      zoomSpec:
+        duration: ko.observable(100)
+        t1: ko.observable(0) # need validation to stay in bounds and not go too close
+        t2: ko.observable(100)
+      cursor:
+        t: ko.observable(20)
+      mouse:
+        pos: ko.observable($V([0,0]))
+    @fullZoomX = d3.scaleLinear()
+    @zoomInX = d3.scaleLinear()
+  attached: ->
+
+    ko.computed(@zoomOrLayoutChanged.bind(@)).extend({rateLimit: 5})
+
+    @adjs = @makeZoomAdjs()
+
+    @trackMouse()
+    @bindKeys()
+    @bindWheelZoom()
+
+  zoomOrLayoutChanged: ->
+    @fullZoomX.domain([0, @viewState.zoomSpec.duration()])
+    @fullZoomX.range([0, @width()])
+
+    # had trouble making notes update when this changes
+    zoomInX = d3.scaleLinear()
+    zoomInX.domain([@viewState.zoomSpec.t1(), @viewState.zoomSpec.t2()])
+    zoomInX.range([0, @width()])
+    @zoomInX = zoomInX
+
+    # todo: these run a lot of work purely for a time change    
+    @$.dia.setTimeAxis(@width(), @$.zoomed.$.audio.offsetTop, @zoomInX)
+    @$.adjusters.updateAllCoords()
+
+    @$.dia.setCursor(@$.audio.offsetTop, @$.audio.offsetHeight,
+                     @$.zoomed.$.time.offsetTop,
+                     @$.zoomed.$.time.offsetHeight,
+                     @fullZoomX, @zoomInX, @viewState.cursor)
+    
+  trackMouse: ->
+    # not just for show- we use the mouse pos sometimes
+    for evName in ['mousemove', 'touchmove']
+      @addEventListener evName, (ev) =>
+        ev.preventDefault()
+
+        # todo: consolidate with _editorCoordinates version
+        if ev.touches?.length
+          ev = ev.touches[0]
+
+        @root = @getBoundingClientRect()
+        @viewState.mouse.pos($V([ev.pageX - @root.left, ev.pageY - @root.top]))
+
+        @$.dia.setMouse(@viewState.mouse.pos())
+        #@sendMouseToVidref()
+
+  sendMouseToVidref: ->
+    now = Date.now()
+    if (!@$.vidrefLastSent? || @$.vidrefLastSent < now - 200) && !@songPlaying
+      @$.vidrefTime.body = {t: @latestMouseTime(), source: 'timeline'}
+      @$.vidrefTime.generateRequest()
+      @$.vidrefLastSent = now
+
+  latestMouseTime: ->
+    @zoomInX.invert(@viewState.mouse.pos().e(1))
+
+  bindWheelZoom: ->
+    @$.zoomed.addEventListener 'mousewheel', (ev) =>
+      zs = @viewState.zoomSpec
+
+      center = @latestMouseTime()
+      left = center - zs.t1()
+      right = zs.t2() - center
+      scale = Math.pow(1.005, ev.deltaY)
+
+      zs.t1(center - left * scale)
+      zs.t2(center + right * scale)
+
+  animatedZoom: (newT1, newT2, secs) ->
+    fps = 30
+    oldT1 = @viewState.zoomSpec.t1()
+    oldT2 = @viewState.zoomSpec.t2()
+    lastTime = 0
+    for step in [0..secs * fps]
+      frac = step / (secs * fps)
+      do (frac) =>
+        gotoStep = =>
+          @viewState.zoomSpec.t1((1 - frac) * oldT1 + frac * newT1)
+          @viewState.zoomSpec.t2((1 - frac) * oldT2 + frac * newT2)
+        delay = frac * secs * 1000
+        setTimeout(gotoStep, delay)
+        lastTime = delay
+    setTimeout(=>
+        @viewState.zoomSpec.t1(newT1)
+        @viewState.zoomSpec.t2(newT2)
+      , lastTime + 10)  
+    
+  bindKeys: ->
+    shortcut.add "Ctrl+P", (ev) =>
+      @$.music.seekPlayOrPause(@latestMouseTime())
+
+    zoomAnimSec = .1
+    shortcut.add "Ctrl+Escape", =>
+      @animatedZoom(0, @viewState.zoomSpec.duration(), zoomAnimSec)
+    shortcut.add "Shift+Escape", =>
+      @animatedZoom(@songTime - 2, @viewState.zoomSpec.duration(), zoomAnimSec)
+    shortcut.add "Escape", =>
+      zs = @viewState.zoomSpec
+      visSeconds = zs.t2() - zs.t1()
+      margin = visSeconds * .4
+      # buggy: really needs t1/t2 to limit their ranges
+      if @songTime < zs.t1() or @songTime > zs.t2() - visSeconds * .6
+        newCenter = @songTime + margin
+        @animatedZoom(newCenter - visSeconds / 2,
+                      newCenter + visSeconds / 2, zoomAnimSec)
+
+  makeZoomAdjs: ->
+    yMid = @$.audio.offsetTop + @$.audio.offsetHeight / 2
+    dur = @viewState.zoomSpec.duration
+    
+    valForPos = (pos) =>
+        x = pos.e(1)
+        t = @fullZoomX.invert(x)
+    left = new AdjustableFloatObservable({
+      observable: @viewState.zoomSpec.t1,
+      getTarget: () =>
+        $V([@fullZoomX(@viewState.zoomSpec.t1()), yMid])
+      getSuggestedTargetOffset: () => $V([-50, 0])
+      getValueForPos: valForPos
+    })
+
+    right = new AdjustableFloatObservable({
+      observable: @viewState.zoomSpec.t2,
+      getTarget: () =>
+        $V([@fullZoomX(@viewState.zoomSpec.t2()), yMid])
+      getSuggestedTargetOffset: () => $V([50, 0])
+      getValueForPos: valForPos
+    })
+
+    panObs = ko.pureComputed({
+        read: () =>
+          (@viewState.zoomSpec.t1() + @viewState.zoomSpec.t2()) / 2
+        write: (value) =>
+          zs = @viewState.zoomSpec
+          span = zs.t2() - zs.t1()
+          zs.t1(value - span / 2)
+          zs.t2(value + span / 2)
+      })
+
+    pan = new AdjustableFloatObservable({
+      observable: panObs
+      emptyBox: true
+      # fullzoom is not right- the sides shouldn't be able to go
+      # offscreen
+      getTarget: () => $V([@fullZoomX(panObs()), yMid])
+      getSuggestedTargetOffset: () => $V([0, 0])
+      getValueForPos: valForPos
+      })
+      
+    return [left, right, pan]
+
+Polymer
+  is: "light9-timeline-time-axis",
+  # for now since it's just one line calling dia,
+  # light9-timeline-editor does our drawing work.
+
+Polymer
+  is: 'light9-timeline-graph-row'
+  behaviors: [ Polymer.IronResizableBehavior ]
+  properties:
+    graph: { type: Object, notify: true }
+    zoomInX: { type: Object, notify: true }
+    noteUris: { type: Array, notify: true }
+    rowIndex: { type: Object, notify: true }
+  observers: [
+    'onGraph(graph)'
+    ]
+  onGraph: ->
+    @graph.runHandler(@update.bind(@))
+  update: ->
+    U = (x) -> @graph.Uri(x)
+
+    @noteUris = []
+    for note in @graph.objects(@song, U(':note'))
+      @push('noteUris', note)
+  
+
+Polymer
+  is: 'light9-timeline-note'
+  behaviors: [ Polymer.IronResizableBehavior ]
+  listeners: 'iron-resize': 'update'
+  properties:
+    graph: { type: Object, notify: true }
+    uri: { type: String, notify: true }
+    zoomInX: { type: Object, notify: true }
+  observers: [
+    'onUri(graph, uri)'
+    'update(graph, uri, zoomInX)'
+    ]
+  ready: ->
+
+  onUri: ->
+    @graph.runHandler(@update.bind(@))
+    
+  update: ->
+    # update our note DOM and SVG elements based on the graph
+    U = (x) -> @graph.Uri(x)
+    try
+      worldPts = [] # (song time, value)
+
+      originTime = @graph.floatValue(@uri, U(':originTime'))
+      for curve in @graph.objects(@uri, U(':curve'))
+        if @graph.uriValue(curve, U(':attr')) == U(':strength')
+          
+          for pt in @graph.objects(curve, U(':point'))
+
+            worldPts.push($V([
+              originTime + @graph.floatValue(pt, U(':time')),
+              @graph.floatValue(pt, U(':value'))
+              ]))
+      worldPts.sort((a,b) -> a.e(1) > b.e(1))
+
+      screenPos = (pt) =>
+        $V([@zoomInX(pt.e(1)), @offsetTop + (1 - pt.e(2)) * @offsetHeight])
+
+      setNote(@uri, (screenPos(pt) for pt in worldPts))
+
+    catch e
+      log("during resize of #{@uri}: #{@e}")
+
+Polymer
+  is: "light9-timeline-adjusters"
+  properties:
+    adjs: { type: Array }, # our computed list
+    parentAdjs: { type: Array }, # incoming requests
+    graph: { type: Object, notify: true }
+    song: { type: String, notify: true }
+    zoomInX: { type: Object, notify: true }
+    dia: { type: Object }
+  observers: [
+    'update(parentAdjs, graph, song, dia)'
+    'onGraph(graph, song)'
+    ]
+  onGraph: (graph, song, zoomInX) ->
+    graph.runHandler(@update.bind(@))
+  update: (parentAdjs, graph, song, dia) ->
+    U = (x) -> @graph.Uri(x)
+    @adjs = (@parentAdjs || []).slice()
+    for note in @graph.objects(@song, U(':note'))
+      @push('adjs', new AdjustableFloatObject({
+        graph: @graph
+        subj: note
+        pred: @graph.Uri(':originTime')
+        ctx: @graph.Uri(@song)
+        getTargetTransform: (value) => $V([@zoomInX(value), 600])
+        getValueForPos: (pos) => @zoomInX.invert(pos.e(1))
+        getSuggestedTargetOffset: () => $V([0, -80])
+      }))
+    
+  updateAllCoords: ->
+    for elem in @querySelectorAll('light9-timeline-adjuster')
+      elem.updateDisplay()
+    
+
+_adjusterSerial = 0
+
+Polymer
+  is: 'light9-timeline-adjuster'
+  properties:
+    adj:
+      type: Object
+      notify: true
+      observer: 'onAdj'
+    target:
+      type: Object
+      notify: true
+    displayValue:
+      type: String
+    centerStyle:
+      type: Object
+    spanClass:
+      type: String
+      value: ''
+
+  onAdj: (adj) ->
+    @adj.subscribe(@updateDisplay.bind(this))
+
+  updateDisplay: () ->
+      @spanClass = if @adj.config.emptyBox then 'empty' else ''
+      @displayValue = @adj.getDisplayValue()
+      center = @adj.getCenter()
+      target = @adj.getTarget()
+      return if isNaN(center.e(1))
+      @centerStyle = {x: center.e(1), y: center.e(2)}
+      @dia?.setAdjusterConnector(@myId, center, target)
+        
+  attached: ->
+    @myId = 'adjuster-' + _adjusterSerial
+    _adjusterSerial += 1
+    
+    drag = d3.drag()
+    sel = d3.select(@$.label)
+    sel.call(drag)
+    drag.subject((d) -> {x: @offsetLeft, y: @offsetTop})
+    drag.container(@offsetParent)
+    drag.on('start', () => @adj?.startDrag())
+    drag.on 'drag', () =>
+      @adj?.continueDrag($V([d3.event.x, d3.event.y]))
+    drag.on('end', () => @adj?.endDrag())
+
+
+svgPathFromPoints = (pts) ->
+  out = ''
+  pts.forEach (p) ->
+    p = p.elements if p.elements # for vec2
+    if out.length == 0
+      out = 'M '
+    else
+      out += 'L '
+    out += '' + p[0] + ',' + p[1] + ' '
+    return
+  out
+
+Polymer
+  is: 'light9-timeline-diagram-layer'
+  properties: {}
+  ready: ->
+    @elemById = {}
+    window.setNote = @setNote.bind(this)
+    window.setMouse = @setMouse.bind(this)
+
+  setTimeAxis: (width, yTop, scale) ->
+    pxPerTick = 50
+    axis = d3.axisTop(scale).ticks(width / pxPerTick)
+    d3.select(@$.timeAxis).attr('transform', 'translate(0,'+yTop+')').call(axis)
+
+  setMouse: (pos) ->
+    elem = @getOrCreateElem('mouse-x', 'mouse', 'path', {style: "fill:none;stroke:#fff;stroke-width:0.5;"})
+    elem.setAttribute('d', svgPathFromPoints([[-9999, pos.e(2)], [9999, pos.e(2)]]))
+    elem = @getOrCreateElem('mouse-y', 'mouse', 'path', {style: "fill:none;stroke:#fff;stroke-width:0.5;"})
+    elem.setAttribute('d', svgPathFromPoints([[pos.e(1), -9999], [pos.e(1), 9999]]))   
+
+  getOrCreateElem: (uri, groupId, tag, attrs) ->
+    elem = @elemById[uri]
+    if !elem
+      elem = @elemById[uri] = document.createElementNS("http://www.w3.org/2000/svg", tag)
+      @$[groupId].appendChild(elem)
+      elem.setAttribute('id', uri)
+      for k,v of attrs
+        elem.setAttribute(k, v)
+    return elem
+    
+  setNote: (uri, curvePts) ->
+    elem = @getOrCreateElem(uri, 'notes', 'path', {style:"fill:#53774b; stroke:#000000; stroke-width:1.5;"})
+    elem.setAttribute('d', svgPathFromPoints(curvePts))
+
+  setCursor: (y1, h1, y2, h2, fullZoomX, zoomInX, cursor) ->
+    @cursorPath =
+      top: @querySelector('#cursor1')
+      mid: @querySelector('#cursor2')
+      bot: @querySelector('#cursor3')
+    return if !@cursorPath.top
+    
+    xZoomedOut = fullZoomX(cursor.t())
+    xZoomedIn = zoomInX(cursor.t())
+    @cursorPath.top.setAttribute 'd', svgPathFromPoints([
+      [xZoomedOut, y1]
+      [xZoomedOut, y1 + h1]
+    ])
+    @cursorPath.mid.setAttribute 'd', svgPathFromPoints([
+      [xZoomedIn + 2, y2 + h2]
+      [xZoomedIn - 2, y2 + h2]
+      [xZoomedOut - 1, y1 + h1]
+      [xZoomedOut + 1, y1 + h1]
+    ]) + ' Z'
+    @cursorPath.bot.setAttribute 'd', svgPathFromPoints([
+      [xZoomedIn, y2 + h2]
+      [xZoomedIn, @offsetParent.offsetHeight]
+    ])
+
+  setAdjusterConnector: (uri, center, target) ->
+    elem = @getOrCreateElem(uri, 'connectors', 'path', {style: "fill:none;stroke:#d4d4d4;stroke-width:0.9282527;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:2.78475821, 2.78475821;stroke-dashoffset:0;"})
+    elem.setAttribute('d', svgPathFromPoints([center, target]))
--- a/makefile	Wed Jun 08 05:47:49 2016 +0000
+++ b/makefile	Wed Jun 08 06:02:07 2016 +0000
@@ -98,4 +98,4 @@
 effect_node_setup: create_virtualenv packages binexec install_python_deps
 
 coffee:
-	coffee -cw light9/web/*.coffee
+	coffee -cw light9/web/{.,timeline}/*.coffee