diff --git a/light9/web/timeline/timeline-elements.html b/light9/web/timeline/timeline-elements.html
--- a/light9/web/timeline/timeline-elements.html
+++ b/light9/web/timeline/timeline-elements.html
@@ -268,6 +268,7 @@
+
diff --git a/light9/web/timeline/timeline.coffee b/light9/web/timeline/timeline.coffee
--- a/light9/web/timeline/timeline.coffee
+++ b/light9/web/timeline/timeline.coffee
@@ -92,96 +92,6 @@ class Project
if note in selection.selected()
selection.selected(_.without(selection.selected(), note))
-class ViewState
- constructor: () ->
- # caller updates all these observables
- @zoomSpec =
- duration: ko.observable(100) # current song duration
- t1: ko.observable(0)
- t2: ko.observable(100)
- @cursor =
- t: ko.observable(20) # songTime
- @mouse =
- pos: ko.observable($V([0,0]))
- @width = ko.observable(500)
- @audioY = ko.observable(0)
- @audioH = ko.observable(0)
- @zoomedTimeY = ko.observable(0)
- @zoomedTimeH = ko.observable(0)
-
- @fullZoomX = d3.scaleLinear()
- @zoomInX = d3.scaleLinear()
-
- @zoomAnimSec = .1
-
- ko.computed(@maintainZoomLimitsAndScales.bind(@))
-
- setWidth: (w) ->
- @width(w)
- @maintainZoomLimitsAndScales() # before other handlers run
-
- maintainZoomLimitsAndScales: () ->
- log('maintainZoomLimitsAndScales')
- # not for cursor updates
-
- if @zoomSpec.t1() < 0
- @zoomSpec.t1(0)
- if @zoomSpec.duration() and @zoomSpec.t2() > @zoomSpec.duration()
- @zoomSpec.t2(@zoomSpec.duration())
-
- @fullZoomX.domain([0, @zoomSpec.duration()])
- @fullZoomX.range([0, @width()])
-
- @zoomInX.domain([@zoomSpec.t1(), @zoomSpec.t2()])
- @zoomInX.range([0, @width()])
-
- latestMouseTime: ->
- @zoomInX.invert(@mouse.pos().e(1))
-
- onMouseWheel: (deltaY) ->
- zs = @zoomSpec
-
- center = @latestMouseTime()
- left = center - zs.t1()
- right = zs.t2() - center
- scale = Math.pow(1.005, deltaY)
-
- zs.t1(center - left * scale)
- zs.t2(center + right * scale)
- log('view to', ko.toJSON(@))
-
- frameCursor: ->
- zs = @zoomSpec
- visSeconds = zs.t2() - zs.t1()
- margin = visSeconds * .4
- # buggy: really needs t1/t2 to limit their ranges
- if @cursor.t() < zs.t1() or @cursor.t() > zs.t2() - visSeconds * .6
- newCenter = @cursor.t() + margin
- @animatedZoom(newCenter - visSeconds / 2,
- newCenter + visSeconds / 2, @zoomAnimSec)
- frameToEnd: ->
- @animatedZoom(@cursor.t() - 2, @zoomSpec.duration(), @zoomAnimSec)
- frameAll: ->
- @animatedZoom(0, @zoomSpec.duration(), @zoomAnimSec)
- animatedZoom: (newT1, newT2, secs) ->
- fps = 30
- oldT1 = @zoomSpec.t1()
- oldT2 = @zoomSpec.t2()
- lastTime = 0
- for step in [0..secs * fps]
- frac = step / (secs * fps)
- do (frac) =>
- gotoStep = =>
- @zoomSpec.t1((1 - frac) * oldT1 + frac * newT1)
- @zoomSpec.t2((1 - frac) * oldT2 + frac * newT2)
- delay = frac * secs * 1000
- setTimeout(gotoStep, delay)
- lastTime = delay
- setTimeout(=>
- @zoomSpec.t1(newT1)
- @zoomSpec.t2(newT2)
- , lastTime + 10)
-
coffeeElementSetup(class TimelineEditor extends Polymer.mixinBehaviors([Polymer.IronResizableBehavior], Polymer.Element)
@is: 'light9-timeline-editor'
diff --git a/light9/web/timeline/viewstate.coffee b/light9/web/timeline/viewstate.coffee
new file mode 100644
--- /dev/null
+++ b/light9/web/timeline/viewstate.coffee
@@ -0,0 +1,89 @@
+class window.ViewState
+ constructor: () ->
+ # caller updates all these observables
+ @zoomSpec =
+ duration: ko.observable(100) # current song duration
+ t1: ko.observable(0)
+ t2: ko.observable(100)
+ @cursor =
+ t: ko.observable(20) # songTime
+ @mouse =
+ pos: ko.observable($V([0,0]))
+ @width = ko.observable(500)
+ @audioY = ko.observable(0)
+ @audioH = ko.observable(0)
+ @zoomedTimeY = ko.observable(0)
+ @zoomedTimeH = ko.observable(0)
+
+ @fullZoomX = d3.scaleLinear()
+ @zoomInX = d3.scaleLinear()
+
+ @zoomAnimSec = .1
+
+ ko.computed(@maintainZoomLimitsAndScales.bind(@))
+
+ setWidth: (w) ->
+ @width(w)
+ @maintainZoomLimitsAndScales() # before other handlers run
+
+ maintainZoomLimitsAndScales: () ->
+ log('maintainZoomLimitsAndScales')
+ # not for cursor updates
+
+ if @zoomSpec.t1() < 0
+ @zoomSpec.t1(0)
+ if @zoomSpec.duration() and @zoomSpec.t2() > @zoomSpec.duration()
+ @zoomSpec.t2(@zoomSpec.duration())
+
+ @fullZoomX.domain([0, @zoomSpec.duration()])
+ @fullZoomX.range([0, @width()])
+
+ @zoomInX.domain([@zoomSpec.t1(), @zoomSpec.t2()])
+ @zoomInX.range([0, @width()])
+
+ latestMouseTime: ->
+ @zoomInX.invert(@mouse.pos().e(1))
+
+ onMouseWheel: (deltaY) ->
+ zs = @zoomSpec
+
+ center = @latestMouseTime()
+ left = center - zs.t1()
+ right = zs.t2() - center
+ scale = Math.pow(1.005, deltaY)
+
+ zs.t1(center - left * scale)
+ zs.t2(center + right * scale)
+ log('view to', ko.toJSON(@))
+
+ frameCursor: ->
+ zs = @zoomSpec
+ visSeconds = zs.t2() - zs.t1()
+ margin = visSeconds * .4
+ # buggy: really needs t1/t2 to limit their ranges
+ if @cursor.t() < zs.t1() or @cursor.t() > zs.t2() - visSeconds * .6
+ newCenter = @cursor.t() + margin
+ @animatedZoom(newCenter - visSeconds / 2,
+ newCenter + visSeconds / 2, @zoomAnimSec)
+ frameToEnd: ->
+ @animatedZoom(@cursor.t() - 2, @zoomSpec.duration(), @zoomAnimSec)
+ frameAll: ->
+ @animatedZoom(0, @zoomSpec.duration(), @zoomAnimSec)
+ animatedZoom: (newT1, newT2, secs) ->
+ fps = 30
+ oldT1 = @zoomSpec.t1()
+ oldT2 = @zoomSpec.t2()
+ lastTime = 0
+ for step in [0..secs * fps]
+ frac = step / (secs * fps)
+ do (frac) =>
+ gotoStep = =>
+ @zoomSpec.t1((1 - frac) * oldT1 + frac * newT1)
+ @zoomSpec.t2((1 - frac) * oldT2 + frac * newT2)
+ delay = frac * secs * 1000
+ setTimeout(gotoStep, delay)
+ lastTime = delay
+ setTimeout(=>
+ @zoomSpec.t1(newT1)
+ @zoomSpec.t2(newT2)
+ , lastTime + 10)