changeset 2062:d991f7c3485a

WIP rough porting of coffee to ts
author drewp@bigasterisk.com
date Mon, 16 May 2022 01:33:49 -0700
parents a415be4cfac4
children 1c772cb39908
files .vscode/settings.json light9/web/coffee_element.coffee light9/web/timeline/Note.coffee light9/web/timeline/Project.coffee light9/web/timeline/TimeAxis.coffee light9/web/timeline/TimeZoomed.coffee light9/web/timeline/TimelineEditor.coffee light9/web/timeline/adjustable.coffee light9/web/timeline/adjustable.ts light9/web/timeline/adjusters.coffee light9/web/timeline/adjusters.ts light9/web/timeline/brick_layout.coffee light9/web/timeline/brick_layout.ts light9/web/timeline/drawing.ts light9/web/timeline/index.html light9/web/timeline/light9-timeline-audio.html light9/web/timeline/light9-timeline-audio.ts light9/web/timeline/timeline-elements.html light9/web/timeline/timeline-elements.ts light9/web/timeline/timeline.coffee light9/web/timeline/viewstate.coffee light9/web/timeline/viewstate.ts package.json pnpm-lock.yaml
diffstat 24 files changed, 2395 insertions(+), 1633 deletions(-) [+]
line wrap: on
line diff
--- a/.vscode/settings.json	Mon May 16 01:32:57 2022 -0700
+++ b/.vscode/settings.json	Mon May 16 01:33:49 2022 -0700
@@ -1,9 +1,9 @@
 {
-    "python.autoComplete.extraPaths": ["__pypackages__/3.9/lib"],
-    "python.analysis.extraPaths": [
-        "__pypackages__/3.9/lib"
-    ],
-    "python.analysis.diagnosticMode": "workspace",
-    "cmake.configureOnOpen": false
+  "python.autoComplete.extraPaths": ["__pypackages__/3.9/lib"],
+  "python.analysis.extraPaths": ["__pypackages__/3.9/lib"],
+  "python.analysis.diagnosticMode": "workspace",
+  "cmake.configureOnOpen": false,
+  "toTypeScript.fixUnreachableCode": false,
+  "toTypeScript.fixUnusedLabel": false,
+  "toTypeScript.forgottenThisPropertyAccess": false
 }
-  
--- a/light9/web/coffee_element.coffee	Mon May 16 01:32:57 2022 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,15 +0,0 @@
-# Polymer seems to need static getters for 'observers' and
-# 'properties', not just static attributes, though I don't know how it
-# can even tell the difference.
-#
-# This workaround is to use names like '@getter_properties' in the
-# class then register with this function that fixes them.
-#
-# Also see http://coffeescript.org/#unsupported-get-set
-window.coffeeElementSetup = (cls) ->
-  for attr in ['properties', 'observers']
-    val = cls['getter_' + attr]
-    if val?
-      do (val) ->
-        Object.defineProperty(cls, attr, {get: ( -> val)})
-  customElements.define(cls.is, cls)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/timeline/Note.coffee	Mon May 16 01:33:49 2022 -0700
@@ -0,0 +1,285 @@
+log = debug('timeline')
+debug.enable('*')
+
+Drawing = window.Drawing
+ROW_COUNT = 7
+
+# Maintains a pixi object, some adjusters, and inlineattrs corresponding to a note
+# in the graph.
+class Note
+  constructor: (@parentElem, @container, @project, @graph, @selection, @uri, @setAdjuster, @song, @viewState, @brickLayout) ->
+    @adjusterIds = new Set() # id string
+    @updateSoon = _.debounce(@update.bind(@), 30)
+
+  initWatchers: ->
+    @graph.runHandler(@update.bind(@), "note update #{@uri.value}")
+    ko.computed @update.bind(@)
+
+  destroy: ->
+    log('destroy', @uri.value)
+    @isDetached = true
+    @clearAdjusters()
+    @parentElem.updateInlineAttrs(@uri, null)
+
+  clearAdjusters: ->
+    @adjusterIds.forEach (i) =>
+      @setAdjuster(i, null)
+    @adjusterIds.clear()
+
+  getCurvePoints: (subj, curveAttr) ->
+    U = (x) => @graph.Uri(x)
+    originTime = @graph.floatValue(subj, U(':originTime'))
+
+    for curve in @graph.objects(subj, U(':curve'))
+      if @graph.uriValue(curve, U(':attr')).equals(curveAttr)
+        return @project.getCurvePoints(curve, originTime)
+    throw new Error("curve #{@uri.value} has no attr #{curveAttr.value}")
+
+  midPoint: (i0, i1) ->
+    p0 = @worldPts[i0]
+    p1 = @worldPts[i1]
+    p0.x(.5).add(p1.x(.5))
+
+  _planDrawing: ->
+    U = (x) => @graph.Uri(x)
+    [pointUris, worldPts] = @getCurvePoints(@uri, U(':strength'))
+    effect = @graph.uriValue(@uri, U(':effectClass'))
+
+    yForV = @brickLayout.yForVFor(@)
+    dependOn = [@viewState.zoomSpec.t1(),
+                @viewState.zoomSpec.t2(),
+                @viewState.width()]
+    screenPts = (new PIXI.Point(@viewState.zoomInX(pt.e(1)),
+                                yForV(pt.e(2))) for pt in worldPts)
+    return {
+      yForV: yForV
+      worldPts: worldPts
+      screenPts: screenPts
+      effect: effect
+      hover: @uri.equals(@selection.hover())
+      selected: @selection.selected().filter((s) => s.equals(@uri)).length
+    }
+
+  onRowChange: ->
+    @clearAdjusters()
+    @updateSoon()
+
+  redraw: (params) ->
+    # no observable or graph deps in here
+    @container.removeChildren()
+    @graphics = new PIXI.Graphics({nativeLines: false})
+    @graphics.interactive = true
+    @container.addChild(@graphics)
+
+    if params.hover
+      @_traceBorder(params.screenPts, 12, 0x888888)
+    if params.selected
+      @_traceBorder(params.screenPts, 6, 0xff2900)
+
+    shape = new PIXI.Polygon(params.screenPts)
+    @graphics.beginFill(@_noteColor(params.effect), .313)
+    @graphics.drawShape(shape)
+    @graphics.endFill()
+
+    @_traceBorder(params.screenPts, 2, 0xffd900)
+
+    @_addMouseBindings()
+    
+                 
+  update: ->
+    if not @parentElem.isActiveNote(@uri)
+      # stale redraw call
+      return
+
+    if @worldPts
+      @brickLayout.setNoteSpan(@, @worldPts[0].e(1),
+                               @worldPts[@worldPts.length - 1].e(1))
+
+    params = @_planDrawing()
+    @worldPts = params.worldPts
+
+    @redraw(params)
+
+    curveWidthCalc = () => @project.curveWidth(@worldPts)
+    @_updateAdjusters(params.screenPts, @worldPts, curveWidthCalc,
+                      params.yForV, @viewState.zoomInX, @song)
+    @_updateInlineAttrs(params.screenPts, params.yForV)
+    @parentElem.noteDirty()
+
+  _traceBorder: (screenPts, thick, color) ->
+    @graphics.lineStyle(thick, color, 1)
+    @graphics.moveTo(screenPts[0].x, screenPts[0].y)
+    for p in screenPts.slice(1)
+      @graphics.lineTo(p.x, p.y)
+
+  _addMouseBindings: () ->
+    @graphics.on 'mousedown', (ev) =>
+      @_onMouseDown(ev)
+
+    @graphics.on 'mouseover', =>
+      if @selection.hover() and @selection.hover().equals(@uri)
+        # Hovering causes a redraw, which would cause another
+        # mouseover event.
+        return
+      @selection.hover(@uri)
+
+    # mouseout never fires since we rebuild the graphics on mouseover.
+    @graphics.on 'mousemove', (ev) =>
+      if @selection.hover() and @selection.hover().equals(@uri) and ev.target != @graphics
+        @selection.hover(null)
+
+  onUri: ->
+    @graph.runHandler(@update.bind(@), "note updates #{@uri}")
+
+  patchCouldAffectMe: (patch) ->
+    if patch and patch.addQuads # sometimes patch is a polymer-sent value. @update is used as a listener too
+      if patch.addQuads.length == patch.delQuads.length == 1
+        add = patch.addQuads[0]
+        del = patch.delQuads[0]
+        if (add.predicate.equals(del.predicate) and del.predicate.equals(@graph.Uri(':time')) and add.subject.equals(del.subject))
+          timeEditFor = add.subject
+          if @worldPts and timeEditFor not in @pointUris
+            return false
+    return true
+
+  xupdate: (patch) ->
+    # update our note DOM and SVG elements based on the graph
+    if not @patchCouldAffectMe(patch)
+      # as autodep still fires all handlers on all patches, we just
+      # need any single dep to cause another callback. (without this,
+      # we would no longer be registered at all)
+      @graph.subjects(@uri, @uri, @uri)
+      return
+    if @isDetached?
+      return
+
+    @_updateDisplay()
+
+  _updateAdjusters: (screenPts, worldPts, curveWidthCalc, yForV, zoomInX, ctx) ->
+    # todo: allow offset even on more narrow notes
+    if screenPts[screenPts.length - 1].x - screenPts[0].x < 100 or screenPts[0].x > @parentElem.offsetWidth or screenPts[screenPts.length - 1].x < 0
+      @clearAdjusters()
+    else
+      @_makeOffsetAdjuster(yForV, curveWidthCalc, ctx)
+      @_makeCurvePointAdjusters(yForV, worldPts, ctx)
+      @_makeFadeAdjusters(yForV, zoomInX, ctx, worldPts)
+
+  _updateInlineAttrs: (screenPts, yForV) ->
+    w = 280
+
+    leftX = Math.max(2, screenPts[Math.min(1, screenPts.length - 1)].x + 5)
+    rightX = screenPts[Math.min(2, screenPts.length - 1)].x - 5
+    if screenPts.length < 3
+      rightX = leftX + w
+
+    if rightX - leftX < w or rightX < w or leftX > @parentElem.offsetWidth
+      @parentElem.updateInlineAttrs(@uri, null)
+      return
+
+    config = {
+      uri: @uri,
+      left: leftX,
+      top: yForV(1) + 5,
+      width: w,
+      height: yForV(0) - yForV(1) - 15,
+      }
+
+    @parentElem.updateInlineAttrs(@uri, config)
+
+  _makeCurvePointAdjusters: (yForV, worldPts, ctx) ->
+    for pointNum in [0...worldPts.length]
+      @_makePointAdjuster(yForV, worldPts, pointNum, ctx)
+
+  _makePointAdjuster: (yForV, worldPts, pointNum, ctx) ->
+    U = (x) => @graph.Uri(x)
+
+    adjId = @uri.value + '/p' + pointNum
+    @adjusterIds.add(adjId)
+    @setAdjuster adjId, =>
+      adj = new AdjustableFloatObject({
+        graph: @graph
+        subj: worldPts[pointNum].uri
+        pred: U(':time')
+        ctx: ctx
+        getTargetPosForValue: (value) =>
+          $V([@viewState.zoomInX(value), yForV(worldPts[pointNum].e(2))])
+        getValueForPos: (pos) =>
+          origin = @graph.floatValue(@uri, U(':originTime'))
+          (@viewState.zoomInX.invert(pos.e(1)) - origin)
+        getSuggestedTargetOffset: () => @_suggestedOffset(worldPts[pointNum]),
+      })
+      adj._getValue = (=>
+        # note: don't use originTime from the closure- we need the
+        # graph dependency
+        adj._currentValue + @graph.floatValue(@uri, U(':originTime'))
+        )
+      adj
+
+  _makeOffsetAdjuster: (yForV, curveWidthCalc, ctx) ->
+    U = (x) => @graph.Uri(x)
+
+    adjId = @uri.value + '/offset'
+    @adjusterIds.add(adjId)
+    @setAdjuster adjId, =>
+      adj = new AdjustableFloatObject({
+        graph: @graph
+        subj: @uri
+        pred: U(':originTime')
+        ctx: ctx
+        getDisplayValue: (v, dv) => "o=#{dv}"
+        getTargetPosForValue: (value) =>
+          # display bug: should be working from pt[0].t, not from origin
+          $V([@viewState.zoomInX(value + curveWidthCalc() / 2), yForV(.5)])
+        getValueForPos: (pos) =>
+          @viewState.zoomInX.invert(pos.e(1)) - curveWidthCalc() / 2
+        getSuggestedTargetOffset: () => $V([-10, 0])
+      })
+      adj
+
+  _makeFadeAdjusters: (yForV, zoomInX, ctx, worldPts) ->
+    U = (x) => @graph.Uri(x)
+    @_makeFadeAdjuster(yForV, zoomInX, ctx, @uri.value + '/fadeIn', 0, 1, $V([-50, -10]))
+    n = worldPts.length
+    @_makeFadeAdjuster(yForV, zoomInX, ctx, @uri.value + '/fadeOut', n - 2, n - 1, $V([50, -10]))
+
+  _makeFadeAdjuster: (yForV, zoomInX, ctx, adjId, i0, i1, offset) ->
+    @adjusterIds.add(adjId)
+    @setAdjuster adjId, =>
+      new AdjustableFade(yForV, zoomInX, i0, i1, @, offset, ctx)
+
+  _suggestedOffset: (pt) ->
+    if pt.e(2) > .5
+      $V([0, 30])
+    else
+      $V([0, -30])
+
+  _onMouseDown: (ev) ->
+    sel = @selection.selected()
+    if ev.data.originalEvent.ctrlKey
+      if @uri in sel
+        sel = _.without(sel, @uri)
+      else
+        sel.push(@uri)
+    else
+      sel = [@uri]
+    @selection.selected(sel)
+
+  _noteColor: (effect) ->
+    effect = effect.value
+    if effect in ['http://light9.bigasterisk.com/effect/blacklight',
+                  'http://light9.bigasterisk.com/effect/strobewarm']
+      hue = 0
+      sat = 100
+    else
+      hash = 0
+      for i in [(effect.length-10)...effect.length]
+        hash += effect.charCodeAt(i)
+      hue = (hash * 8) % 360
+      sat = 40 + (hash % 20) # don't conceal colorscale too much
+
+    return parseInt(tinycolor.fromRatio({h: hue / 360, s: sat / 100, l: .58}).toHex(), 16)
+
+    #elem = @getOrCreateElem(uri+'/label', 'noteLabels', 'text', {style: "font-size:13px;line-height:125%;font-family:'Verana Sans';text-align:start;text-anchor:start;fill:#000000;"})
+    #elem.setAttribute('x', curvePts[0].e(1)+20)
+    #elem.setAttribute('y', curvePts[0].e(2)-10)
+    #elem.innerHTML = effectLabel
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/timeline/Project.coffee	Mon May 16 01:33:49 2022 -0700
@@ -0,0 +1,92 @@
+log = debug('timeline')
+debug.enable('*')
+
+Drawing = window.Drawing
+ROW_COUNT = 7
+
+class Project
+  constructor: (@graph) ->
+
+  makeEffect: (uri) ->
+    U = (x) => @graph.Uri(x)
+    effect = U(uri.value + '/effect')
+    quad = (s, p, o) => @graph.Quad(s, p, o, effect)
+
+    quads = [
+      quad(effect, U('rdf:type'), U(':Effect')),
+      quad(effect, U(':copiedFrom'), uri),
+      quad(effect, U('rdfs:label'), @graph.Literal(uri.replace(/.*capture\//, ''))),
+      quad(effect, U(':publishAttr'), U(':strength')),
+      ]
+
+    fromSettings = @graph.objects(uri, U(':setting'))
+
+    toSettings = @graph.nextNumberedResources(effect + '_set', fromSettings.length)
+
+    for fs in fromSettings
+      ts = toSettings.pop()
+      # full copies of these since I may have to delete captures
+      quads.push(quad(effect, U(':setting'), ts))
+      quads.push(quad(ts, U(':device'), @graph.uriValue(fs, U(':device'))))
+      quads.push(quad(ts, U(':deviceAttr'), @graph.uriValue(fs, U(':deviceAttr'))))
+      try
+        quads.push(quad(ts, U(':value'), @graph.uriValue(fs, U(':value'))))
+      catch
+        quads.push(quad(ts, U(':scaledValue'), @graph.uriValue(fs, U(':scaledValue'))))
+
+    @graph.applyAndSendPatch({delQuads: [], addQuads: quads})
+    return effect
+
+  makeNewNote: (song, effect, dropTime, desiredWidthT) ->
+    U = (x) => @graph.Uri(x)
+    quad = (s, p, o) => @graph.Quad(s, p, o, song)
+
+    newNote = @graph.nextNumberedResource("#{song.value}/n")
+    newCurve = @graph.nextNumberedResource("#{newNote.value}c")
+    points = @graph.nextNumberedResources("#{newCurve.value}p", 4)
+
+    curveQuads = [
+        quad(song, U(':note'), newNote)
+        quad(newNote, U('rdf:type'), U(':Note'))
+        quad(newNote, U(':originTime'), @graph.LiteralRoundedFloat(dropTime))
+        quad(newNote, U(':effectClass'), effect)
+        quad(newNote, U(':curve'), newCurve)
+        quad(newCurve, U('rdf:type'), U(':Curve'))
+        quad(newCurve, U(':attr'), U(':strength'))
+      ]
+
+    pointQuads = []
+    for i in [0...4]
+      pt = points[i]
+      pointQuads.push(quad(newCurve, U(':point'), pt))
+      pointQuads.push(quad(pt, U(':time'), @graph.LiteralRoundedFloat(i/3 * desiredWidthT)))
+      pointQuads.push(quad(pt, U(':value'), @graph.LiteralRoundedFloat(i == 1 or i == 2)))
+
+    patch = {
+      delQuads: []
+      addQuads: curveQuads.concat(pointQuads)
+    }
+    @graph.applyAndSendPatch(patch)
+
+  getCurvePoints: (curve, xOffset) ->
+    worldPts = []
+    uris = @graph.objects(curve, @graph.Uri(':point'))
+    for pt in uris
+      tm = @graph.floatValue(pt, @graph.Uri(':time'))
+      val = @graph.floatValue(pt, @graph.Uri(':value'))
+      v = $V([xOffset + tm, val])
+      v.uri = pt
+      worldPts.push(v)
+    worldPts.sort((a,b) -> a.e(1) - b.e(1))
+    return [uris, worldPts]
+
+  curveWidth: (worldPts) ->
+    tMin = @graph.floatValue(worldPts[0].uri, @graph.Uri(':time'))
+    tMax = @graph.floatValue(worldPts[3].uri, @graph.Uri(':time'))
+    tMax - tMin
+
+  deleteNote: (song, note, selection) ->
+    patch = {delQuads: [@graph.Quad(song, graph.Uri(':note'), note, song)], addQuads: []}
+    @graph.applyAndSendPatch(patch)
+    if note in selection.selected()
+      selection.selected(_.without(selection.selected(), note))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/timeline/TimeAxis.coffee	Mon May 16 01:33:49 2022 -0700
@@ -0,0 +1,19 @@
+log = debug('timeline')
+debug.enable('*')
+
+Drawing = window.Drawing
+ROW_COUNT = 7
+
+
+
+
+@customElement("light9-timeline-time-axis")
+class TimeAxis extends LitElement
+  @getter_properties:
+    viewState: { type: Object, notify: true, observer: "onViewState" }
+  onViewState: ->
+    ko.computed =>
+      dependOn = [@viewState.zoomSpec.t1(), @viewState.zoomSpec.t2()]
+      pxPerTick = 50
+      axis = d3.axisTop(@viewState.zoomInX).ticks(@viewState.width() / pxPerTick)
+      d3.select(@$.axis).call(axis)
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/timeline/TimeZoomed.coffee	Mon May 16 01:33:49 2022 -0700
@@ -0,0 +1,174 @@
+log = debug('timeline')
+debug.enable('*')
+
+Drawing = window.Drawing
+ROW_COUNT = 7
+
+
+# plan: in here, turn all the notes into simple js objects with all
+# their timing data and whatever's needed for adjusters. From that, do
+# the brick layout. update only changing adjusters.
+@customElement('light9-timeline-time-zoomed')
+class TimeZoomed extends LitElement
+  @getter_properties:
+    graph: { type: Object, notify: true }
+    project: { type: Object }
+    selection: { type: Object, notify: true }
+    song: { type: String, notify: true }
+    viewState: { type: Object, notify: true }
+    inlineAttrConfigs: { type: Array, value: [] } # only for inlineattrs that should be displayed
+    imageSamples: { type: Array, value: [] }
+  @getter_observers: [
+    '_onGraph(graph, setAdjuster, song, viewState, project)',
+    'onZoom(viewState)',
+    '_onViewState(viewState)',
+  ]
+  constructor: ->
+    super()
+    @numRows = 6
+    @noteByUriStr = new Map()
+    @stage = new PIXI.Container()
+    @stage.interactive=true
+
+    @renderer = PIXI.autoDetectRenderer({
+      backgroundColor: 0x606060,
+      antialias: true,
+      forceCanvas: true,
+    })
+    @bg = new PIXI.Container()
+    @stage.addChild(@bg)
+
+    @dirty = _.debounce(@_repaint.bind(@), 10)
+
+  ready: ->
+    super.ready()
+
+    @imageSamples = ['one']
+
+    @addEventListener('iron-resize', @_onResize.bind(@))
+    Polymer.RenderStatus.afterNextRender(this, @_onResize.bind(@))
+
+    @$.rows.appendChild(@renderer.view)
+
+    # This works for display, but pixi hit events didn't correctly
+    # move with the objects, so as a workaround, I extended the top of
+    # the canvas in _onResize.
+    #
+    #ko.computed =>
+    #  @stage.setTransform(0, -(@viewState.rowsY()), 1, 1, 0, 0, 0, 0, 0)
+
+  _onResize: ->
+    @$.rows.firstChild.style.position = 'relative'
+    @$.rows.firstChild.style.top = -@viewState.rowsY() + 'px'
+
+    @renderer.resize(@clientWidth, @clientHeight + @viewState.rowsY())
+
+    @dirty()
+
+  _onGraph: (graph, setAdjuster, song, viewState, project)->
+    return unless @song # polymer will call again
+    @graph.runHandler(@gatherNotes.bind(@), 'zoom notes')
+
+  _onViewState: (viewState) ->
+    @brickLayout = new BrickLayout(@viewState, @numRows)
+
+  noteDirty: -> @dirty()
+    
+  onZoom: ->
+    updateZoomFlattened = ->
+      @zoomFlattened = ko.toJS(@viewState.zoomSpec)
+    ko.computed(updateZoomFlattened.bind(@))
+
+  gatherNotes: ->
+    U = (x) => @graph.Uri(x)
+    return unless @song?
+    songNotes = @graph.objects(U(@song), U(':note'))
+
+    toRemove = new Set(@noteByUriStr.keys())
+    
+    for uri in @graph.sortedUris(songNotes)
+      had = toRemove.delete(uri.value)
+      if not had
+        @_addNote(uri)
+
+    toRemove.forEach @_delNote.bind(@)
+
+    @dirty()
+
+  isActiveNote: (note) -> @noteByUriStr.has(note.value)
+
+  _repaint: ->
+    @_drawGrid()  
+    @renderer.render(@stage)
+
+  _drawGrid: ->
+    # maybe someday this has snappable timing markers too
+    @bg.removeChildren()
+    gfx = new PIXI.Graphics()
+    @bg.addChild(gfx)
+
+    gfx.lineStyle(1, 0x222222, 1)
+    for row in [0...@numRows]
+      y = @brickLayout.rowBottom(row)
+      gfx.moveTo(0, y)
+      gfx.lineTo(@clientWidth, y)
+
+  _addNote: (uri) ->
+    U = (x) => @graph.Uri(x)
+    
+    con = new PIXI.Container()
+    con.interactive=true
+    @stage.addChild(con)
+    
+    note = new Note(@, con, @project, @graph, @selection, uri, @setAdjuster, U(@song), @viewState, @brickLayout)
+    # this must come before the first Note.draw
+    @noteByUriStr.set(uri.value, note)
+    @brickLayout.addNote(note, note.onRowChange.bind(note))
+    note.initWatchers()
+
+  _delNote: (uriStr) ->
+    n = @noteByUriStr.get(uriStr)
+    @brickLayout.delNote(n)
+    @stage.removeChild(n.container)
+    n.destroy()
+    @noteByUriStr.delete(uriStr)
+            
+  onDrop: (effect, pos) ->
+    U = (x) => @graph.Uri(x)
+
+    return unless effect and effect.match(/^http/)
+
+    # we could probably accept some initial overrides right on the
+    # effect uri, maybe as query params
+
+    if not @graph.contains(effect, U('rdf:type'), U(':Effect'))
+      if @graph.contains(effect, U('rdf:type'), U(':LightSample'))
+        effect = @project.makeEffect(effect)
+      else
+        log("drop #{effect} is not an effect")
+        return
+
+    dropTime = @viewState.zoomInX.invert(pos.e(1))
+
+    desiredWidthX = @offsetWidth * .3
+    desiredWidthT = @viewState.zoomInX.invert(desiredWidthX) - @viewState.zoomInX.invert(0)
+    desiredWidthT = Math.min(desiredWidthT, @viewState.zoomSpec.duration() - dropTime)
+    @project.makeNewNote(U(@song), U(effect), dropTime, desiredWidthT)
+
+  updateInlineAttrs: (note, config) ->
+    if not config?
+      index = 0
+      for c in @inlineAttrConfigs
+        if c.uri.equals(note)
+          @splice('inlineAttrConfigs', index)
+          return
+        index += 1
+    else
+      index = 0
+      for c in @inlineAttrConfigs
+        if c.uri.equals(note)
+          @splice('inlineAttrConfigs', index, 1, config)
+          return
+        index += 1
+      @push('inlineAttrConfigs', config)
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/timeline/TimelineEditor.coffee	Mon May 16 01:33:49 2022 -0700
@@ -0,0 +1,194 @@
+log = debug('timeline')
+debug.enable('*')
+
+Drawing = window.Drawing
+ROW_COUNT = 7
+
+
+@customElement('light9-timeline-editor')
+class TimelineEditor extends LitElement
+  @getter_properties:
+    viewState: { type: Object }
+    debug: {type: String}
+    graph: {type: Object, notify: true}
+    project: {type: Object}
+    setAdjuster: {type: Function, notify: true}
+    playerSong: {type: String, notify: true}
+    followPlayerSong: {type: Boolean, notify: true, value: true}
+    song: {type: String, notify: true}
+    show: {type: String, notify: true}
+    songTime: {type: Number, notify: true}
+    songDuration: {type: Number, notify: true}
+    songPlaying: {type: Boolean, notify: true}
+    selection: {type: Object, notify: true}
+  @getter_observers: [
+    '_onSong(playerSong, followPlayerSong)',
+    '_onGraph(graph)',
+    '_onSongDuration(songDuration, viewState)',
+    '_onSongTime(song, playerSong, songTime, viewState)',
+    '_onSetAdjuster(setAdjuster)',
+  ]
+  constructor: ->
+    super()
+    @viewState = new ViewState()
+    window.viewState = @viewState
+
+  ready: ->
+    super.ready()
+    @addEventListener 'mousedown', (ev) => @$.adjustersCanvas.onDown(ev)
+    @addEventListener 'mousemove', (ev) => @$.adjustersCanvas.onMove(ev)
+    @addEventListener 'mouseup', (ev) => @$.adjustersCanvas.onUp(ev)
+
+    ko.options.deferUpdates = true
+
+    @selection = {hover: ko.observable(null), selected: ko.observable([])}
+
+    window.debug_zoomOrLayoutChangedCount = 0
+    window.debug_adjUpdateDisplay = 0
+
+    ko.computed(@zoomOrLayoutChanged.bind(@))
+
+    @trackMouse()
+    @bindKeys()
+    @bindWheelZoom(@)
+
+    setInterval(@updateDebugSummary.bind(@), 100)
+
+    @addEventListener('iron-resize', @_onIronResize.bind(@))
+    Polymer.RenderStatus.afterNextRender(this, @_onIronResize.bind(@))
+
+    Polymer.RenderStatus.afterNextRender this, =>
+      setupDrop(@$.zoomed.$.rows, @$.zoomed.$.rows, @, @$.zoomed.onDrop.bind(@$.zoomed))
+
+  _onIronResize: ->
+    @viewState.setWidth(@offsetWidth)
+    @viewState.coveredByDiagramTop(@$.coveredByDiagram.offsetTop)
+    @viewState.rowsY(@$.zoomed.$.rows.offsetTop) if @$.zoomed?.$?.rows?
+    @viewState.audioY(@$.audio.offsetTop)
+    @viewState.audioH(@$.audio.offsetHeight)
+    if @$.zoomed?.$?.time?
+      @viewState.zoomedTimeY(@$.zoomed.$.time.offsetTop)
+      @viewState.zoomedTimeH(@$.zoomed.$.time.offsetHeight)
+
+  _onSongTime: (song, playerSong, t) ->
+    if song != playerSong
+      @viewState.cursor.t(0)
+      return
+    @viewState.cursor.t(t)
+
+  _onSongDuration: (d) ->
+    d = 700 if d < 1 # bug is that asco isn't giving duration, but 0 makes the scale corrupt
+    @viewState.zoomSpec.duration(d)
+
+  _onSong: (s) ->
+    @song = @playerSong if @followPlayerSong
+
+  _onGraph: (graph) ->
+    @project = new Project(graph)
+    @show = 'http://light9.bigasterisk.com/show/dance2019'
+
+  _onSetAdjuster: () ->
+    @makeZoomAdjs()
+
+  updateDebugSummary: ->
+    elemCount = (tag) -> document.getElementsByTagName(tag).length
+    @debug = "#{window.debug_zoomOrLayoutChangedCount} layout change,
+     #{elemCount('light9-timeline-note')} notes,
+     #{@selection.selected().length} selected
+     #{elemCount('light9-timeline-graph-row')} rows,
+     #{window.debug_adjsCount} adjuster items registered,
+     #{window.debug_adjUpdateDisplay} adjuster updateDisplay calls,
+    "
+
+  zoomOrLayoutChanged: ->
+    vs = @viewState
+    dependOn = [vs.zoomSpec.t1(), vs.zoomSpec.t2(), vs.width()]
+
+    # shouldn't need this- deps should get it
+    @$.zoomed.gatherNotes() if @$.zoomed?.gatherNotes?
+
+    # todo: these run a lot of work purely for a time change
+    if @$.zoomed?.$?.audio?
+      #@dia.setTimeAxis(vs.width(), @$.zoomed.$.audio.offsetTop, vs.zoomInX)
+      @$.adjustersCanvas.updateAllCoords()
+
+  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 = @$.cursorCanvas.getBoundingClientRect()
+        @viewState.mouse.pos($V([ev.pageX - root.left, ev.pageY - root.top]))
+
+        # should be controlled by a checkbox next to follow-player-song-choice
+        @sendMouseToVidref() unless window.location.hash.match(/novidref/)
+
+  sendMouseToVidref: ->
+    now = Date.now()
+    if (!@$.vidrefLastSent? || @$.vidrefLastSent < now - 200) && !@songPlaying
+      @$.vidrefTime.body = {t: @viewState.latestMouseTime(), source: 'timeline', song: @song}
+      @$.vidrefTime.generateRequest()
+      @$.vidrefLastSent = now
+
+  bindWheelZoom: (elem) ->
+    elem.addEventListener 'mousewheel', (ev) =>
+      @viewState.onMouseWheel(ev.deltaY)
+
+  bindKeys: ->
+    shortcut.add "Ctrl+P", (ev) =>
+      @$.music.seekPlayOrPause(@viewState.latestMouseTime())
+    shortcut.add "Ctrl+Escape", => @viewState.frameAll()
+    shortcut.add "Shift+Escape", => @viewState.frameToEnd()
+    shortcut.add "Escape", => @viewState.frameCursor()
+    shortcut.add "L", =>
+      @$.adjustersCanvas.updateAllCoords()
+    shortcut.add 'Delete', =>
+      for note in @selection.selected()
+        @project.deleteNote(@graph.Uri(@song), note, @selection)
+
+  makeZoomAdjs: ->
+    yMid = => @$.audio.offsetTop + @$.audio.offsetHeight / 2
+
+    valForPos = (pos) =>
+      x = pos.e(1)
+      t = @viewState.fullZoomX.invert(x)
+    @setAdjuster('zoom-left', => new AdjustableFloatObservable({
+      observable: @viewState.zoomSpec.t1,
+      getTarget: () =>
+        $V([@viewState.fullZoomX(@viewState.zoomSpec.t1()), yMid()])
+      getSuggestedTargetOffset: () => $V([-50, 10])
+      getValueForPos: valForPos
+    }))
+
+    @setAdjuster('zoom-right', => new AdjustableFloatObservable({
+      observable: @viewState.zoomSpec.t2,
+      getTarget: () =>
+        $V([@viewState.fullZoomX(@viewState.zoomSpec.t2()), yMid()])
+      getSuggestedTargetOffset: () => $V([50, 10])
+      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)
+    })
+
+    @setAdjuster('zoom-pan', => new AdjustableFloatObservable({
+      observable: panObs
+      emptyBox: true
+      # fullzoom is not right- the sides shouldn't be able to go
+      # offscreen
+      getTarget: () => $V([@viewState.fullZoomX(panObs()), yMid()])
+      getSuggestedTargetOffset: () => $V([0, 0])
+      getValueForPos: valForPos
+    }))
--- a/light9/web/timeline/adjustable.coffee	Mon May 16 01:32:57 2022 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,190 +0,0 @@
-log = debug('adjustable')
-
-
-class Adjustable
-  # Some value you can edit in the UI, probably by dragging
-  # stuff. Drawn by light9-adjusters-canvas. 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) ->
-    @ctor2()
-
-  ctor2: () ->
-    # config has:
-    #   getTarget -> vec2 of current target position
-    #   getSuggestedTargetOffset -> vec2 pixel offset from target
-    #   emptyBox -> true if you want no value display
-
-    # updated later by layout algoritm
-    @handle = $V([0, 0])
-
-  getDisplayValue: () ->
-    return '' if @config.emptyBox
-    defaultFormat = d3.format(".4g")(@_getValue())
-    if @config.getDisplayValue?
-      return @config.getDisplayValue(@_getValue(), defaultFormat)
-    defaultFormat
-
-  getSuggestedHandle: () ->
-    @getTarget().add(@config.getSuggestedTargetOffset())
-
-  getHandle: () -> # vec2 of pixels
-    @handle
-
-  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: () ->
-    @initialTarget = @getTarget()
-
-  continueDrag: (pos) ->
-    ## pos is vec2 of pixels relative to the drag start
-    @targetDraggedTo = pos.add(@initialTarget)
-
-  endDrag: () ->
-    # override
-
-  _editorCoordinates: () -> # vec2 of mouse relative to <l9-t-editor>
-    return @targetDraggedTo
-    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])
-
-    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?
-    super()
-    @ctor2()
-
-  _getValue: () ->
-    @config.observable()
-
-  continueDrag: (pos) ->
-    # pos is vec2 of pixels relative to the drag start.
-    super(pos)
-    epos = @_editorCoordinates()
-    newValue = @config.getValueForPos(epos)
-    @config.observable(newValue)
-
-  subscribe: (onChange) ->
-    log('AdjustableFloatObservable subscribe', @config)
-    ko.computed =>
-      @config.observable()
-      onChange()
-
-class window.AdjustableFloatObject extends Adjustable
-  constructor: (@config) ->
-    # config also has:
-    #   graph
-    #   subj
-    #   pred
-    #   ctx
-    #   getTargetPosForValue(value) -> getTarget result for value
-    #   getValueForPos
-    super()
-    @ctor2()
-    if not @config.ctx?
-      throw new Error("missing ctx")
-    # this seems to not fire enough.
-    @config.graph.runHandler(@_syncValue.bind(@),
-                             "adj sync #{@config.subj.value} #{@config.pred.value}")
-
-  _syncValue: () ->
-    @_currentValue = @config.graph.floatValue(@config.subj, @config.pred)
-    @_onChange() if @_onChange
-
-  _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.getTargetPosForValue(@_getValue())
-
-  subscribe: (onChange) ->
-    # only works on one subscription at a time
-    throw new Error('multi subscribe not implemented') if @_onChange
-    @_onChange = onChange
-
-  continueDrag: (pos) ->
-    # pos is vec2 of pixels relative to the drag start
-    super(pos)
-    newValue = @config.getValueForPos(@_editorCoordinates())
-
-    @config.graph.patchObject(@config.subj, @config.pred,
-                              @config.graph.LiteralRoundedFloat(newValue),
-                              @config.ctx)
-    #@_syncValue()
-
-class window.AdjustableFade extends Adjustable
-  constructor: (@yForV, @zoomInX, @i0, @i1, @note, offset, ctx) ->
-    super()
-    @config = {
-      getSuggestedTargetOffset: -> offset
-      getTarget: @getTarget.bind(@)
-      ctx: ctx
-    }
-    @ctor2()
-
-  getTarget: ->
-    mid = @note.midPoint(@i0, @i1)
-    $V([@zoomInX(mid.e(1)), @yForV(mid.e(2))])
-
-  _getValue: ->
-    @note.midPoint(@i0, @i1).e(1)
-
-  continueDrag: (pos) ->
-    # pos is vec2 of pixels relative to the drag start
-    super(pos)
-    graph = @note.graph
-    U = (x) -> graph.Uri(x)
-
-    goalCenterSec = @zoomInX.invert(@initialTarget.e(1) + pos.e(1))
-
-    diamSec = @note.worldPts[@i1].e(1) - @note.worldPts[@i0].e(1)
-    newSec0 = goalCenterSec - diamSec / 2
-    newSec1 = goalCenterSec + diamSec / 2
-
-    originSec = graph.floatValue(@note.uri, U(':originTime'))
-
-    p0 = @_makePatch(graph, @i0, newSec0, originSec, @config.ctx)
-    p1 = @_makePatch(graph, @i1, newSec1, originSec, @config.ctx)
-
-    graph.applyAndSendPatch(@_addPatches(p0, p1))
-
-  _makePatch: (graph, idx, newSec, originSec, ctx) ->
-    graph.getObjectPatch(@note.worldPts[idx].uri,
-                         graph.Uri(':time'),
-                         graph.LiteralRoundedFloat(newSec - originSec), ctx)
-
-  _addPatches: (p0, p1) ->
-    {
-      addQuads: p0.addQuads.concat(p1.addQuads),
-      delQuads: p0.delQuads.concat(p1.delQuads)
-    }
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/timeline/adjustable.ts	Mon May 16 01:33:49 2022 -0700
@@ -0,0 +1,269 @@
+import * as d3 from "d3";
+import { debug } from "debug";
+import * as ko from "knockout";
+const log = debug("adjustable");
+
+interface Config {
+  //   getTarget -> vec2 of current target position
+  getTarget: () => Vector;
+  //   getSuggestedTargetOffset -> vec2 pixel offset from target
+  getSuggestedTargetOffset: () => Vector;
+  //   emptyBox -> true if you want no value display
+  emptyBox: boolean;
+}
+
+export class Adjustable {
+  config: any;
+  handle: any;
+  initialTarget: any;
+  targetDraggedTo: any;
+  root: any;
+  // Some value you can edit in the UI, probably by dragging
+  // stuff. Drawn by light9-adjusters-canvas. 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: any) {
+    this.config = config;
+    this.ctor2();
+  }
+
+  ctor2() {
+    // updated later by layout algoritm
+    return (this.handle = $V([0, 0]));
+  }
+
+  getDisplayValue() {
+    if (this.config.emptyBox) {
+      return "";
+    }
+    const defaultFormat = d3.format(".4g")(this._getValue());
+    if (this.config.getDisplayValue != null) {
+      return this.config.getDisplayValue(this._getValue(), defaultFormat);
+    }
+    return defaultFormat;
+  }
+  _getValue(): any {
+    throw new Error("Method not implemented.");
+  }
+
+  getSuggestedHandle() {
+    return this.getTarget().add(this.config.getSuggestedTargetOffset());
+  }
+
+  getHandle() {
+    // vec2 of pixels
+    return this.handle;
+  }
+
+  getTarget() {
+    // vec2 of pixels
+    return this.config.getTarget();
+  }
+
+  subscribe(onChange: any) {
+    // 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() {
+    return (this.initialTarget = this.getTarget());
+  }
+
+  continueDrag(pos: { add: (arg0: any) => any }) {
+    //# pos is vec2 of pixels relative to the drag start
+    return (this.targetDraggedTo = pos.add(this.initialTarget));
+  }
+
+  endDrag() {}
+  // override
+
+  _editorCoordinates() {
+    // vec2 of mouse relative to <l9-t-editor>
+    let rootElem: { getBoundingClientRect: () => any };
+    return this.targetDraggedTo;
+    // let 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 != null ? ev.touches.length : undefined) {
+    //   ev = ev.touches[0];
+    // }
+
+    // // storing root on the object to remember it across calls in case
+    // // you drag outside the editor.
+    // if (rootElem) {
+    //   this.root = rootElem.getBoundingClientRect();
+    // }
+    // const offsetParentPos = $V([ev.pageX - this.root.left, ev.pageY - this.root.top]);
+
+    // return offsetParentPos;
+  }
+}
+
+class AdjustableFloatObservable extends Adjustable {
+  constructor(config: any) {
+    // 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?
+    this.config = config;
+    super();
+    this.ctor2();
+  }
+
+  _getValue() {
+    return this.config.observable();
+  }
+
+  continueDrag(pos: any) {
+    // pos is vec2 of pixels relative to the drag start.
+    super.continueDrag(pos);
+    const epos = this._editorCoordinates();
+    const newValue = this.config.getValueForPos(epos);
+    return this.config.observable(newValue);
+  }
+
+  subscribe(onChange: () => any) {
+    log("AdjustableFloatObservable subscribe", this.config);
+    return ko.computed(() => {
+      this.config.observable();
+      return onChange();
+    });
+  }
+}
+
+class AdjustableFloatObject extends Adjustable {
+  _currentValue: any;
+  _onChange: any;
+  constructor(config: any) {
+    // config also has:
+    //   graph
+    //   subj
+    //   pred
+    //   ctx
+    //   getTargetPosForValue(value) -> getTarget result for value
+    //   getValueForPos
+    this.config = config;
+    super();
+    this.ctor2();
+    if (this.config.ctx == null) {
+      throw new Error("missing ctx");
+    }
+    // this seems to not fire enough.
+    this.config.graph.runHandler(this._syncValue.bind(this), `adj sync ${this.config.subj.value} ${this.config.pred.value}`);
+  }
+
+  _syncValue() {
+    this._currentValue = this.config.graph.floatValue(this.config.subj, this.config.pred);
+    if (this._onChange) {
+      return this._onChange();
+    }
+  }
+
+  _getValue() {
+    // this is a big speedup- callers use _getValue about 4x as much as
+    // the graph changes and graph.floatValue is slow
+    return this._currentValue;
+  }
+
+  getTarget() {
+    return this.config.getTargetPosForValue(this._getValue());
+  }
+
+  subscribe(onChange: any) {
+    // only works on one subscription at a time
+    if (this._onChange) {
+      throw new Error("multi subscribe not implemented");
+    }
+    return (this._onChange = onChange);
+  }
+
+  continueDrag(pos: any) {
+    // pos is vec2 of pixels relative to the drag start
+    super.continueDrag(pos);
+    const newValue = this.config.getValueForPos(this._editorCoordinates());
+
+    return this.config.graph.patchObject(this.config.subj, this.config.pred, this.config.graph.LiteralRoundedFloat(newValue), this.config.ctx);
+    //@_syncValue()
+  }
+}
+
+class AdjustableFade extends Adjustable {
+  yForV: any;
+  zoomInX: any;
+  i0: any;
+  i1: any;
+  note: any;
+  constructor(yForV: any, zoomInX: any, i0: any, i1: any, note: any, offset: any, ctx: any) {
+    this.yForV = yForV;
+    this.zoomInX = zoomInX;
+    this.i0 = i0;
+    this.i1 = i1;
+    this.note = note;
+    super();
+    this.config = {
+      getSuggestedTargetOffset() {
+        return offset;
+      },
+      getTarget: this.getTarget.bind(this),
+      ctx,
+    };
+    this.ctor2();
+  }
+
+  getTarget() {
+    const mid = this.note.midPoint(this.i0, this.i1);
+    return $V([this.zoomInX(mid.e(1)), this.yForV(mid.e(2))]);
+  }
+
+  _getValue() {
+    return this.note.midPoint(this.i0, this.i1).e(1);
+  }
+
+  continueDrag(pos: { e: (arg0: number) => any }) {
+    // pos is vec2 of pixels relative to the drag start
+    super.continueDrag(pos);
+    const { graph } = this.note;
+    const U = (x: string) => graph.Uri(x);
+
+    const goalCenterSec = this.zoomInX.invert(this.initialTarget.e(1) + pos.e(1));
+
+    const diamSec = this.note.worldPts[this.i1].e(1) - this.note.worldPts[this.i0].e(1);
+    const newSec0 = goalCenterSec - diamSec / 2;
+    const newSec1 = goalCenterSec + diamSec / 2;
+
+    const originSec = graph.floatValue(this.note.uri, U(":originTime"));
+
+    const p0 = this._makePatch(graph, this.i0, newSec0, originSec, this.config.ctx);
+    const p1 = this._makePatch(graph, this.i1, newSec1, originSec, this.config.ctx);
+
+    return graph.applyAndSendPatch(this._addPatches(p0, p1));
+  }
+
+  _makePatch(
+    graph: { getObjectPatch: (arg0: any, arg1: any, arg2: any, arg3: any) => any; Uri: (arg0: string) => any; LiteralRoundedFloat: (arg0: number) => any },
+    idx: string | number,
+    newSec: number,
+    originSec: number,
+    ctx: any
+  ) {
+    return graph.getObjectPatch(this.note.worldPts[idx].uri, graph.Uri(":time"), graph.LiteralRoundedFloat(newSec - originSec), ctx);
+  }
+
+  _addPatches(p0: { addQuads: { concat: (arg0: any) => any }; delQuads: { concat: (arg0: any) => any } }, p1: { addQuads: any; delQuads: any }) {
+    return {
+      addQuads: p0.addQuads.concat(p1.addQuads),
+      delQuads: p0.delQuads.concat(p1.delQuads),
+    };
+  }
+}
--- a/light9/web/timeline/adjusters.coffee	Mon May 16 01:32:57 2022 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,198 +0,0 @@
-log = debug('adjusters')
-Drawing = window.Drawing
-
-maxDist = 60
-
-coffeeElementSetup(class AdjustersCanvas extends Polymer.mixinBehaviors([Polymer.IronResizableBehavior], Polymer.Element)
-  @is: 'light9-adjusters-canvas'
-  @getter_properties:
-    setAdjuster: {type: Function, notify: true }
-  @getter_observers: [
-    'updateAllCoords(adjs)'
-  ]
-  constructor: ->
-    super()
-    @redraw = _.throttle(@_throttledRedraw.bind(@), 30, {leading: false})
-    @adjs = {}
-    @hoveringNear = null
-    
-  ready: ->
-    super.ready()
-    @addEventListener('iron-resize', @resizeUpdate.bind(@))
-    @ctx = @$.canvas.getContext('2d')
-    
-    @redraw()
-    @setAdjuster = @_setAdjuster.bind(@)
-
-    # These don't fire; TimelineEditor calls the handlers for us.
-    @addEventListener('mousedown', @onDown.bind(@))
-    @addEventListener('mousemove', @onMove.bind(@))
-    @addEventListener('mouseup', @onUp.bind(@))
-
-  _mousePos: (ev) ->
-    $V([ev.clientX, ev.clientY - @offsetParent.offsetTop])
-  
-  onDown: (ev) ->
-    if ev.buttons == 1
-      start = @_mousePos(ev)
-      adj = @_adjAtPoint(start)
-      if adj
-        ev.stopPropagation()
-        @currentDrag = {start: start, adj: adj}
-        adj.startDrag()
-
-  onMove: (ev) ->
-    pos = @_mousePos(ev)
-    if @currentDrag
-      @hoveringNear = null
-      @currentDrag.cur = pos
-      @currentDrag.adj.continueDrag(
-        @currentDrag.cur.subtract(@currentDrag.start))
-      @redraw()
-    else
-      near = @_adjAtPoint(pos)
-      if @hoveringNear != near
-        @hoveringNear = near
-        @redraw()
-
-  onUp: (ev) ->
-    return unless @currentDrag
-    @currentDrag.adj.endDrag()
-    @currentDrag = null
-    
-  _setAdjuster: (adjId, makeAdjustable) ->
-    # callers register/unregister the Adjustables they want us to make
-    # adjuster elements for. Caller invents adjId.  makeAdjustable is
-    # a function returning the Adjustable or it is null to clear any
-    # adjusters with this id.
-    if not makeAdjustable?
-      if @adjs[adjId]
-        delete @adjs[adjId]
-    else
-      # this might be able to reuse an existing one a bit
-      adj = makeAdjustable()
-      @adjs[adjId] = adj
-      adj.id = adjId
-
-    @redraw()
-
-    window.debug_adjsCount = Object.keys(@adjs).length
-
-  updateAllCoords: ->
-    @redraw()
-
-  _adjAtPoint: (pt) ->
-    nearest = @qt.find(pt.e(1), pt.e(2))
-    if not nearest? or nearest.distanceFrom(pt) > maxDist
-      return null
-    return nearest?.adj
-
-  resizeUpdate: (ev) ->
-    @$.canvas.width = ev.target.offsetWidth
-    @$.canvas.height = ev.target.offsetHeight
-    @canvasCenter = $V([@$.canvas.width / 2, @$.canvas.height / 2])
-    @redraw()
-
-  _throttledRedraw: () ->
-    return unless @ctx?
-    console.time('adjs redraw')
-    @_layoutCenters()
-    
-    @ctx.clearRect(0, 0, @$.canvas.width, @$.canvas.height)
-
-    for adjId, adj of @adjs
-      ctr = adj.getHandle()
-      target = adj.getTarget()
-      if @_isOffScreen(target)
-        continue
-      @_drawConnector(ctr, target)
-      
-      @_drawAdjuster(adj.getDisplayValue(),
-                     ctr.e(1) - 20, ctr.e(2) - 10,
-                     ctr.e(1) + 20, ctr.e(2) + 10,
-                     adj == @hoveringNear)
-    console.timeEnd('adjs redraw')
-
-  _layoutCenters: ->
-    # push Adjustable centers around to avoid overlaps
-    # Todo: also don't overlap inlineattr boxes
-    # Todo: don't let their connector lines cross each other
-    @qt = d3.quadtree([], ((d)->d.e(1)), ((d)->d.e(2)))
-    @qt.extent([[0,0], [8000,8000]])
-
-    for _, adj of @adjs
-      adj.handle = @_clampOnScreen(adj.getSuggestedHandle())
-
-    numTries = 8
-    for tries in [0...numTries]
-      for _, adj of @adjs
-        current = adj.handle
-        @qt.remove(current)
-        nearest = @qt.find(current.e(1), current.e(2), maxDist)
-        if nearest
-          dist = current.distanceFrom(nearest)
-          if dist < maxDist
-            current = @_stepAway(current, nearest, 1 / numTries)
-            adj.handle = current
-        current.adj = adj
-        @qt.add(current)
-
-      #if -50 < output.e(1) < 20 # mostly for zoom-left
-      #  output.setElements([
-      #    Math.max(20, output.e(1)),
-      #    output.e(2)])
-
-  _stepAway: (current, nearest, dx) ->
-    away = current.subtract(nearest).toUnitVector()
-    toScreenCenter = @canvasCenter.subtract(current).toUnitVector()
-    goalSpacingPx = 20
-    @_clampOnScreen(current.add(away.x(goalSpacingPx * dx)))
-
-  _isOffScreen: (pos) ->
-    pos.e(1) < 0 or pos.e(1) > @$.canvas.width or pos.e(2) < 0 or pos.e(2) > @$.canvas.height
-
-  _clampOnScreen: (pos) ->    
-    marg = 30
-    $V([Math.max(marg, Math.min(@$.canvas.width - marg, pos.e(1))),
-        Math.max(marg, Math.min(@$.canvas.height - marg, pos.e(2)))])
-                        
-  _drawConnector: (ctr, target) ->
-    @ctx.strokeStyle = '#aaa'
-    @ctx.lineWidth = 2
-    @ctx.beginPath()
-    Drawing.line(@ctx, ctr, target)
-    @ctx.stroke()
-    
-  _drawAdjuster: (label, x1, y1, x2, y2, hover) ->
-    radius = 8
-
-    @ctx.shadowColor = 'black'
-    @ctx.shadowBlur = 15
-    @ctx.shadowOffsetX = 5
-    @ctx.shadowOffsetY = 9
-    
-    @ctx.fillStyle = if hover then '#ffff88' else 'rgba(255, 255, 0, 0.5)'
-    @ctx.beginPath()
-    Drawing.roundRect(@ctx, x1, y1, x2, y2, radius)
-    @ctx.fill()
-
-    @ctx.shadowColor = 'rgba(0,0,0,0)'
-        
-    @ctx.strokeStyle = 'yellow'
-    @ctx.lineWidth = 2
-    @ctx.setLineDash([3, 3])
-    @ctx.beginPath()
-    Drawing.roundRect(@ctx, x1, y1, x2, y2, radius)
-    @ctx.stroke()
-    @ctx.setLineDash([])
-
-    @ctx.font = "12px sans"
-    @ctx.fillStyle = '#000'
-    @ctx.fillText(label, x1 + 5, y2 - 5, x2 - x1 - 10)
-
-    # coords from a center that's passed in
-    # # special layout for the thaeter ones with middinh
-    # l/r arrows
-    # mouse arrow cursor upon hover, and accent the hovered adjuster
-    # connector
-)
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/timeline/adjusters.ts	Mon May 16 01:33:49 2022 -0700
@@ -0,0 +1,273 @@
+import { debug } from "debug";
+import { LitElement } from "lit";
+import { customElement } from "lit/decorators.js";
+import { throttle } from "underscore";
+import * as d3 from "d3";
+import { Adjustable } from "./adjustable";
+import * as Drawing from "./drawing";
+// https://www.npmjs.com/package/@types/sylvester Global values: $L, $M, $P, $V, Line, Matrix, Plane, Sylvester, Vector
+const log = debug("adjusters");
+
+const maxDist = 60;
+
+interface Drag {
+  start: Vector;
+  adj: Adjustable;
+  cur?: Vector;
+}
+type QTreeData = Vector & { adj: Adjustable };
+@customElement("light9-adjusters-canvas")
+class AdjustersCanvas extends LitElement {
+  static getter_properties: { setAdjuster: { type: any; notify: boolean } };
+  static getter_observers: {};
+  redraw: any;
+  adjs: { [id: string | number]: Adjustable };
+  hoveringNear: any;
+  ctx: any;
+  $: any;
+  setAdjuster: any;
+  offsetParent: any;
+  currentDrag?: Drag;
+  qt?: d3.Quadtree<QTreeData>;
+  canvasCenter: any;
+  static initClass() {
+    this.getter_properties = { setAdjuster: { type: Function, notify: true } };
+    this.getter_observers = ["updateAllCoords(adjs)"];
+  }
+  constructor() {
+    super();
+    this.redraw = throttle(this._throttledRedraw.bind(this), 30, { leading: false });
+    this.adjs = {};
+    this.hoveringNear = null;
+  }
+
+  ready() {
+    this.addEventListener("iron-resize", this.resizeUpdate.bind(this));
+    this.ctx = this.$.canvas.getContext("2d");
+
+    this.redraw();
+    this.setAdjuster = this._setAdjuster.bind(this);
+
+    // These don't fire; TimelineEditor calls the handlers for us.
+    this.addEventListener("mousedown", this.onDown.bind(this));
+    this.addEventListener("mousemove", this.onMove.bind(this));
+    return this.addEventListener("mouseup", this.onUp.bind(this));
+  }
+  addEventListener(arg0: string, arg1: any) {
+    throw new Error("Method not implemented.");
+  }
+
+  _mousePos(ev: MouseEvent) {
+    return $V([ev.clientX, ev.clientY - this.offsetParent.offsetTop]);
+  }
+
+  onDown(ev: MouseEvent) {
+    if (ev.buttons === 1) {
+      const start = this._mousePos(ev);
+      const adj = this._adjAtPoint(start);
+      if (adj) {
+        ev.stopPropagation();
+        this.currentDrag = { start, adj };
+        return adj.startDrag();
+      }
+    }
+  }
+
+  onMove(ev: MouseEvent) {
+    const pos = this._mousePos(ev);
+    if (this.currentDrag) {
+      this.hoveringNear = null;
+      this.currentDrag.cur = pos;
+      this.currentDrag.adj.continueDrag(this.currentDrag.cur.subtract(this.currentDrag.start));
+      this.redraw();
+    } else {
+      const near = this._adjAtPoint(pos);
+      if (this.hoveringNear !== near) {
+        this.hoveringNear = near;
+        this.redraw();
+      }
+    }
+  }
+
+  onUp(ev: any) {
+    if (!this.currentDrag) {
+      return;
+    }
+    this.currentDrag.adj.endDrag();
+    this.currentDrag = undefined;
+  }
+
+  _setAdjuster(adjId: string | number, makeAdjustable?: () => Adjustable) {
+    // callers register/unregister the Adjustables they want us to make
+    // adjuster elements for. Caller invents adjId.  makeAdjustable is
+    // a function returning the Adjustable or it is undefined to clear any
+    // adjusters with this id.
+    if (makeAdjustable == null) {
+      if (this.adjs[adjId]) {
+        delete this.adjs[adjId];
+      }
+    } else {
+      // this might be able to reuse an existing one a bit
+      const adj = makeAdjustable();
+      this.adjs[adjId] = adj;
+      adj.id = adjId;
+    }
+
+    this.redraw();
+
+    (window as any).debug_adjsCount = Object.keys(this.adjs).length;
+  }
+
+  updateAllCoords() {
+    this.redraw();
+  }
+
+  _adjAtPoint(pt: Vector): Adjustable|undefined {
+    const nearest = this.qt!.find(pt.e(1), pt.e(2));
+    if (nearest == null || nearest.distanceFrom(pt) > maxDist) {
+      return undefined;
+    }
+    return nearest != null ? nearest.adj : undefined;
+  }
+
+  resizeUpdate(ev: { target: { offsetWidth: any; offsetHeight: any } }) {
+    this.$.canvas.width = ev.target.offsetWidth;
+    this.$.canvas.height = ev.target.offsetHeight;
+    this.canvasCenter = $V([this.$.canvas.width / 2, this.$.canvas.height / 2]);
+    return this.redraw();
+  }
+
+  _throttledRedraw() {
+    if (this.ctx == null) {
+      return;
+    }
+    console.time("adjs redraw");
+    this._layoutCenters();
+
+    this.ctx.clearRect(0, 0, this.$.canvas.width, this.$.canvas.height);
+
+    for (let adjId in this.adjs) {
+      const adj = this.adjs[adjId];
+      const ctr = adj.getHandle();
+      const target = adj.getTarget();
+      if (this._isOffScreen(target)) {
+        continue;
+      }
+      this._drawConnector(ctr, target);
+
+      this._drawAdjuster(adj.getDisplayValue(), ctr.e(1) - 20, ctr.e(2) - 10, ctr.e(1) + 20, ctr.e(2) + 10, adj === this.hoveringNear);
+    }
+    return console.timeEnd("adjs redraw");
+  }
+
+  _layoutCenters() {
+    // push Adjustable centers around to avoid overlaps
+    // Todo: also don't overlap inlineattr boxes
+    // Todo: don't let their connector lines cross each other
+    const qt = d3.quadtree<QTreeData>(
+      [],
+      (d: QTreeData) => d.e(1),
+      (d: QTreeData) => d.e(2)
+    );
+    this.qt = qt;
+
+    qt.extent([
+      [0, 0],
+      [8000, 8000],
+    ]);
+
+    let _: string | number, adj: { handle: any; getSuggestedHandle: () => any };
+    for (_ in this.adjs) {
+      adj = this.adjs[_];
+      adj.handle = this._clampOnScreen(adj.getSuggestedHandle());
+    }
+
+    const numTries = 8;
+    for (let tryn = 0; tryn < numTries; tryn++) {
+      for (_ in this.adjs) {
+        adj = this.adjs[_];
+        let current = adj.handle;
+        qt.remove(current);
+        const nearest = qt.find(current.e(1), current.e(2), maxDist);
+        if (nearest) {
+          const dist = current.distanceFrom(nearest);
+          if (dist < maxDist) {
+            current = this._stepAway(current, nearest, 1 / numTries);
+            adj.handle = current;
+          }
+        }
+        current.adj = adj;
+        qt.add(current);
+      }
+    }
+    //if -50 < output.e(1) < 20 # mostly for zoom-left
+    //  output.setElements([
+    //    Math.max(20, output.e(1)),
+    //    output.e(2)])
+  }
+
+
+  _stepAway(
+    current: Vector,
+    nearest: Vector,
+    dx: number
+  ) {
+    const away = current.subtract(nearest).toUnitVector();
+    const toScreenCenter = this.canvasCenter.subtract(current).toUnitVector();
+    const goalSpacingPx = 20;
+    return this._clampOnScreen(current.add(away.x(goalSpacingPx * dx)));
+  }
+
+  _isOffScreen(pos: Vector):boolean {
+    return pos.e(1) < 0 || pos.e(1) > this.$.canvas.width || pos.e(2) < 0 || pos.e(2) > this.$.canvas.height;
+  }
+
+  _clampOnScreen(pos: Vector): Vector {
+    const marg = 30;
+    return $V([Math.max(marg, Math.min(this.$.canvas.width - marg, pos.e(1))), Math.max(marg, Math.min(this.$.canvas.height - marg, pos.e(2)))]);
+  }
+
+  _drawConnector(ctr: Vector, target: Vector) {
+    this.ctx.strokeStyle = "#aaa";
+    this.ctx.lineWidth = 2;
+    this.ctx.beginPath();
+    Drawing.line(this.ctx, ctr, target);
+    this.ctx.stroke();
+  }
+
+  _drawAdjuster(label: any, x1: number, y1: number, x2: number, y2: number, hover: boolean) {
+    const radius = 8;
+
+    this.ctx.shadowColor = "black";
+    this.ctx.shadowBlur = 15;
+    this.ctx.shadowOffsetX = 5;
+    this.ctx.shadowOffsetY = 9;
+
+    this.ctx.fillStyle = hover ? "#ffff88" : "rgba(255, 255, 0, 0.5)";
+    this.ctx.beginPath();
+    Drawing.roundRect(this.ctx, x1, y1, x2, y2, radius);
+    this.ctx.fill();
+
+    this.ctx.shadowColor = "rgba(0,0,0,0)";
+
+    this.ctx.strokeStyle = "yellow";
+    this.ctx.lineWidth = 2;
+    this.ctx.setLineDash([3, 3]);
+    this.ctx.beginPath();
+    Drawing.roundRect(this.ctx, x1, y1, x2, y2, radius);
+    this.ctx.stroke();
+    this.ctx.setLineDash([]);
+
+    this.ctx.font = "12px sans";
+    this.ctx.fillStyle = "#000";
+    this.ctx.fillText(label, x1 + 5, y2 - 5, x2 - x1 - 10);
+
+    // coords from a center that's passed in
+    // # special layout for the thaeter ones with middinh
+    // l/r arrows
+    // mouse arrow cursor upon hover, and accent the hovered adjuster
+    // connector
+  }
+}
+
+
--- a/light9/web/timeline/brick_layout.coffee	Mon May 16 01:32:57 2022 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,53 +0,0 @@
-log = debug('brick')
-
-class window.BrickLayout
-  constructor: (@viewState, @numRows) ->
-    @noteRow = {} # uristr: row, t0, t1, onRowChange
-    
-  addNote: (n, onRowChange) ->
-    @noteRow[n.uri.value] = {row: 0, t0: 0, t1: 0, onRowChange: onRowChange}
-    
-  setNoteSpan: (n, t0, t1) ->
-    @noteRow[n.uri.value].t0 = t0
-    @noteRow[n.uri.value].t1 = t1
-    @_recompute()
-    
-  delNote: (n) ->
-    delete @noteRow[n.uri.value]
-    @_recompute()
-    
-  _recompute: ->
-    for u, row of @noteRow
-      row.prev = row.row
-      row.row = null
-    overlap = (a, b) -> a.t0 < b.t1 and a.t1 > b.t0
-
-    notesByWidth = _.sortBy(
-      ({dur: row.t1 - row.t0 + row.t0 * .0001, uri: u} for u, row of @noteRow),
-      'dur')
-    notesByWidth.reverse()
-
-    for n in notesByWidth
-      blockedRows = new Set()
-      for u, other of @noteRow
-        if other.row != null
-          if overlap(other, @noteRow[n.uri])
-            blockedRows.add(other.row)
-
-      for r in [0 ... @numRows]
-        if not blockedRows.has(r)
-          @noteRow[n.uri].row = r
-          break
-      if @noteRow[n.uri].row == null
-        log("warning: couldn't place #{n.uri}")
-        @noteRow[n.uri].row = 0
-      if @noteRow[n.uri].row != @noteRow[n.uri].prev
-        @noteRow[n.uri].onRowChange()
-          
-  rowBottom: (row) -> @viewState.rowsY() + 20 + 150 * row + 140
-  
-  yForVFor: (n) ->
-    row = @noteRow[n.uri.value].row
-    rowBottom = @rowBottom(row)
-    rowTop = rowBottom - 140
-    (v) => rowBottom + (rowTop - rowBottom) * v      
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/timeline/brick_layout.ts	Mon May 16 01:33:49 2022 -0700
@@ -0,0 +1,95 @@
+import { debug } from "debug";
+import { sortBy } from "underscore";
+import { ViewState } from "viewstate";
+const log = debug("brick");
+
+interface Placement {
+  row?: number;
+  prev?: number;
+  t0: number;
+  t1: number;
+  onRowChange: () => void;
+}
+
+export class BrickLayout {
+  viewState: ViewState;
+  numRows: number;
+  noteRow: { [uri: string]: Placement };
+  constructor(viewState: ViewState, numRows: number) {
+    this.viewState = viewState;
+    this.numRows = numRows;
+    this.noteRow = {}; // uristr: row, t0, t1, onRowChange
+  }
+
+  addNote(n: { uri: { value: string } }, onRowChange: any) {
+    this.noteRow[n.uri.value] = { row: 0, t0: 0, t1: 0, onRowChange };
+  }
+
+  setNoteSpan(n: { uri: { value: string } }, t0: any, t1: any) {
+    this.noteRow[n.uri.value].t0 = t0;
+    this.noteRow[n.uri.value].t1 = t1;
+    this._recompute();
+  }
+
+  delNote(n: { uri: { value: string } }) {
+    delete this.noteRow[n.uri.value];
+    this._recompute();
+  }
+
+  _recompute() {
+    for (let u in this.noteRow) {
+      const row = this.noteRow[u];
+      row.prev = row.row;
+      row.row = undefined;
+    }
+    const overlap = (a: Placement, b: Placement) => a.t0 < b.t1 && a.t1 > b.t0;
+
+    const result = [];
+    for (let u in this.noteRow) {
+      const row = this.noteRow[u];
+      result.push({ dur: row.t1 - row.t0 + row.t0 * 0.0001, uri: u });
+    }
+    const notesByWidth = sortBy(result, "dur");
+    notesByWidth.reverse();
+
+    for (let n of Array.from(notesByWidth)) {
+      const blockedRows = new Set();
+      for (let u in this.noteRow) {
+        const other = this.noteRow[u];
+        if (other.row !== null) {
+          if (overlap(other, this.noteRow[n.uri])) {
+            blockedRows.add(other.row);
+          }
+        }
+      }
+
+      for (let r = 0; r < this.numRows; r++) {
+        if (!blockedRows.has(r)) {
+          this.noteRow[n.uri].row = r;
+          break;
+        }
+      }
+      if (this.noteRow[n.uri].row === null) {
+        log(`warning: couldn't place ${n.uri}`);
+        this.noteRow[n.uri].row = 0;
+      }
+      if (this.noteRow[n.uri].row !== this.noteRow[n.uri].prev) {
+        this.noteRow[n.uri].onRowChange();
+      }
+    }
+  }
+
+  rowBottom(row: number) {
+    return this.viewState.rowsY() + 20 + 150 * row + 140;
+  }
+
+  yForVFor(n: { uri: { value: string } }) {
+    const row = this.noteRow[n.uri.value].row;
+    if (row === undefined) {
+      throw new Error();
+    }
+    const rowBottom = this.rowBottom(row);
+    const rowTop = rowBottom - 140;
+    return (v: number) => rowBottom + (rowTop - rowBottom) * v;
+  }
+}
--- a/light9/web/timeline/drawing.ts	Mon May 16 01:32:57 2022 -0700
+++ b/light9/web/timeline/drawing.ts	Mon May 16 01:33:49 2022 -0700
@@ -1,6 +1,5 @@
-(window as any).Drawing = {};
 
-(window as any).Drawing.svgPathFromPoints = function (pts: { forEach: (arg0: (p: any) => void) => void }) {
+export function svgPathFromPoints(pts: { forEach: (arg0: (p: any) => void) => void }) {
   let out = "";
   pts.forEach(function (p: Number[] | { elements: Number[] }) {
     let x, y;
@@ -20,7 +19,7 @@
   return out;
 };
 
-(window as any).Drawing.line = function (
+export function line(
   ctx: { moveTo: (arg0: any, arg1: any) => void; lineTo: (arg0: any, arg1: any) => any },
   p1: { e: (arg0: number) => any },
   p2: { e: (arg0: number) => any }
@@ -30,7 +29,7 @@
 };
 
 // http://stackoverflow.com/a/4959890
-(window as any).Drawing.roundRect = function (
+export function roundRect(
   ctx: {
     beginPath: () => void;
     moveTo: (arg0: any, arg1: any) => void;
--- a/light9/web/timeline/index.html	Mon May 16 01:32:57 2022 -0700
+++ b/light9/web/timeline/index.html	Mon May 16 01:33:49 2022 -0700
@@ -2,9 +2,8 @@
 <html>
   <head>
     <title>timeline</title>
-    <meta charset="utf-8" />
-    <script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-    <link rel="import" href="timeline-elements.html">
+    <meta charset="utf-8">
+    <script type="module" src="./timeline/timeline-elements.ts"></script>
   </head>
   <body>
     <light9-timeline-editor style="position: absolute; left: 0px; top: 0px; right: 0px; bottom: 0px;">
--- a/light9/web/timeline/light9-timeline-audio.html	Mon May 16 01:32:57 2022 -0700
+++ /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: #202322; 
-     }
-     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>
-   const log = debug('audio');
-   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').replace('.ogg', '.png');
-           }.bind(this), "timeline-audio " + this.song);
-       },
-       _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/light9-timeline-audio.ts	Mon May 16 01:33:49 2022 -0700
@@ -0,0 +1,85 @@
+import { debug } from "debug";
+
+import { css, html, LitElement, TemplateResult } from "lit";
+import { customElement, property } from "lit/decorators.js";
+
+const log = debug("audio");
+
+// (potentially-zoomed) spectrogram view
+@customElement("light9-timeline-audio")
+export class Light9TimelineAudio extends LitElement {
+  render() {
+    return html`
+      <style>
+        :host {
+          display: block;
+          /* shouldn't be seen, but black is correct for 'no
+         audio'. Maybe loading stripes would be better */
+          background: #202322;
+        }
+        div {
+          width: 100%;
+          height: 100%;
+          overflow: hidden;
+        }
+        img {
+          height: 100%;
+          position: relative;
+        }
+      </style>
+      <div>
+        <img src="{{imgSrc}}" style="width: {{imgWidth}} ; left: {{imgLeft}}" />
+      </div>
+    `;
+  }
+  //    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() {
+    this.zoom = { duration: 0 };
+  }
+  setImgSrc() {
+    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").replace(".ogg", ".png");
+      }.bind(this),
+      "timeline-audio " + this.song
+    );
+  }
+  _imgWidth(zoom) {
+    if (!zoom.duration) {
+      return "100%";
+    }
+
+    return 100 / ((zoom.t2 - zoom.t1) / zoom.duration) + "%";
+  }
+  _imgLeft(zoom) {
+    if (!zoom.duration) {
+      return "0";
+    }
+
+    var percentPerSec = 100 / (zoom.t2 - zoom.t1);
+    return -percentPerSec * zoom.t1 + "%";
+  }
+}
--- a/light9/web/timeline/timeline-elements.html	Mon May 16 01:32:57 2022 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,241 +0,0 @@
-<script src="/lib/debug/debug-build.js"></script>
-<script>
- debug.enable('*');
-</script>
-<link rel="import" href="/lib/polymer/polymer.html">
-<link rel="import" href="/lib/polymer/lib/utils/render-status.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">
-<link rel="import" href="../edit-choice.html">
-<link rel="import" href="inline-attrs.html">
-    <script src="/websocket.js"></script>
-<script type="module" src="/light9-vidref-replay.js"></script>
-
-<script type="module" src="/light9-vidref-replay-stack.js"></script>
-
-
-<!-- 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;
-     }
-     #coveredByDiagram {
-         position: relative;
-         display: flex;
-         flex-direction: column;
-         height: 100%;
-     }
-     #dia, #adjusters, #cursorCanvas, #adjustersCanvas {
-         position: absolute;
-         left: 0; top: 0; right: 0; bottom: 0;
-     }
-     #debug {
-         background: white;
-         font-family: monospace;
-         font-size: 125%;
-         height: 15px;
-     }
-     light9-vidref-replay-stack {
-             position: absolute;
-             bottom: 10px;
-             width: 50%;
-             background: gray;
-             box-shadow: 6px 10px 12px #0000006b;
-             display: inline-block;
-     }
-    </style>
-    <div>
-      <rdfdb-synced-graph graph="{{graph}}"></rdfdb-synced-graph>
-      <light9-music id="music"
-                    song="{{playerSong}}"
-                    t="{{songTime}}"
-                    playing="{{songPlaying}}"
-                    duration="{{songDuration}}"></light9-music>
-      timeline editor: song <edit-choice graph="{{graph}}" uri="{{song}}"></edit-choice>
-      <label><input type="checkbox" checked="{{followPlayerSong::change}}" > follow player song choice</label>
-    </div>
-    <div id="debug">[[debug]]</div>
-    <iron-ajax id="vidrefTime" url="/vidref/time" method="PUT" content-type="application/json"></iron-ajax>
-    <div id="coveredByDiagram">
-      <light9-timeline-audio id="audio"
-                             graph="{{graph}}"
-                             show="{{show}}"
-                             song="{{song}}"></light9-timeline-audio>
-      <light9-timeline-time-zoomed id="zoomed"
-                                   graph="{{graph}}"
-                                   project="{{project}}"
-                                   selection="{{selection}}"
-                                   set-adjuster="{{setAdjuster}}"
-                                   song="{{song}}"
-                                   show="{{show}}"
-                                   view-state="{{viewState}}">
-      </light9-timeline-time-zoomed>
-      <light9-adjusters-canvas id="adjustersCanvas" set-adjuster="{{setAdjuster}}">
-      </light9-adjusters-canvas>
-      <light9-cursor-canvas id="cursorCanvas" view-state="{{viewState}}"></light9-cursor-canvas>
-      <light9-vidref-replay-stack size="small"></light9-vidref-replay-stack>
-    </div>
-  </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%;
-         flex-direction: column;
-     }
-     #top {
-     }
-     #rows {
-         height: 100%;
-         overflow: hidden;
-     }
-     #rows.dragging {
-         background: rgba(126, 52, 245, 0.0784);
-     }
-     light9-timeline-time-axis {
-     }
-     light9-timeline-audio {
-         width: 100%;
-         height: 100px;
-     }
-     light9-timeline-graph-row {
-         flex-grow: 1;
-     }
-    </style>
-    <div id="top">
-      <light9-timeline-time-axis id="time" view-state="{{viewState}}"></light9-timeline-time-axis>
-      <light9-timeline-audio id="audio"
-                             graph="{{graph}}"
-                             song="{{song}}"
-                             show="{{show}}"
-                             zoom="{{zoomFlattened}}">
-      </light9-timeline-audio>
-    </div>
-    <div id="rows"></div>
-    <template is="dom-repeat" items="{{imageSamples}}">
-      <img src="/show/dance2019/anim/rainbow1.png">
-    </template>
-    <template is="dom-repeat" items="{{inlineAttrConfigs}}">
-      <light9-timeline-note-inline-attrs graph="{{graph}}"
-                                         project="{{project}}"
-                                         selection="{{selection}}"
-                                         song="{{song}}"
-                                         config="{{item}}">
-      </light9-timeline-note-inline-attrs>
-    </template>
-  </template>
-</dom-module>
-
-<dom-module id="light9-cursor-canvas">
-  <template>
-    <style>
-     #canvas, :host {
-         pointer-events: none;
-     }
-    </style>
-    <canvas id="canvas"></canvas>
-  </template>
-</dom-module>
-      
-<dom-module id="light9-adjusters-canvas">
-  <template>
-    <style>
-     :host {
-         pointer-events: none;
-     }
-    </style>
-    <canvas id="canvas"></canvas>
-  </template>
-</dom-module>
-      
-
-<!-- seconds labels -->
-<dom-module id="light9-timeline-time-axis">
-  <template>
-    <style>
-     :host {
-         display: block;
-     }
-     div {
-         width: 100%;
-         height: 31px;
-     }
-     svg {
-         width: 100%;
-         height: 30px;
-     }
-    </style>
-    <svg id="timeAxis" xmlns="http://www.w3.org/2000/svg">
-      <style>
-       text  {
-           fill: white;
-           color: white;
-           font-size: 135%;
-           font-weight: bold;
-       }
-       
-      </style>
-      <g id="axis" transform="translate(0,30)"></g>    
-    </svg>
-  </template>
-</dom-module>
-
-<!-- All the adjusters you can edit or select. Tells a light9-adjusters-canvas how to draw them. Probabaly doesn't need to be an element.
-     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>
-</dom-module>
-
-
-<script src="/lib/async/dist/async.js"></script>
-<script src="/lib/knockout/dist/knockout.js"></script>
-<script src="/lib/shortcut/index.js"></script>
-<script src="/lib/sylvester/sylvester.js"></script>
-<script src="/lib/underscore/underscore-min.js"></script>
-<script src="/node_modules/d3/dist/d3.min.js"></script>
-<script src="/node_modules/n3/n3-browser.js"></script> 
-<script src="/node_modules/pixi.js/dist/pixi.min.js"></script>
-<script src="/node_modules/tinycolor2/dist/tinycolor-min.js"></script>
-
-<script src="drawing.js"></script>
-<script src="../coffee_element.js"></script>
-<script src="viewstate.js"></script>
-<script src="brick_layout.js"></script>
-<script src="adjustable.js"></script>
-<script src="adjusters.js"></script>
-<script src="timeline.js"></script>
-<script src="cursor_canvas.js"></script>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/timeline/timeline-elements.ts	Mon May 16 01:33:49 2022 -0700
@@ -0,0 +1,248 @@
+console.log("hi tl")
+import { debug } from "debug";
+import { css, html, LitElement, TemplateResult } from "lit";
+import { customElement, property } from "lit/decorators.js";
+export {Light9TimelineAudio} from "../timeline/light9-timeline-audio"
+debug.enable("*");
+/*
+ <link rel="import" href="/lib/polymer/polymer.html">
+<link rel="import" href="/lib/polymer/lib/utils/render-status.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">
+<link rel="import" href="../edit-choice.html">
+<link rel="import" href="inline-attrs.html">
+    <script src="/websocket.js"></script>
+<script type="module" src="/light9-vidref-replay.js"></script>
+
+<script type="module" src="/light9-vidref-replay-stack.js"></script>
+
+*/
+
+// Whole editor- include this on your page.
+// Most coordinates are relative to this element.
+@customElement("light9-timeline-editor")
+export class Light9TimelineEditor extends LitElement {
+  render() {
+    return html`
+    <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;
+     }
+     #coveredByDiagram {
+         position: relative;
+         display: flex;
+         flex-direction: column;
+         height: 100%;
+     }
+     #dia, #adjusters, #cursorCanvas, #adjustersCanvas {
+         position: absolute;
+         left: 0; top: 0; right: 0; bottom: 0;
+     }
+     #debug {
+         background: white;
+         font-family: monospace;
+         font-size: 125%;
+         height: 15px;
+     }
+     light9-vidref-replay-stack {
+             position: absolute;
+             bottom: 10px;
+             width: 50%;
+             background: gray;
+             box-shadow: 6px 10px 12px #0000006b;
+             display: inline-block;
+     }
+    </style>
+    <div>
+      <rdfdb-synced-graph graph="{{graph}}"></rdfdb-synced-graph>
+      <light9-music id="music"
+                    song="{{playerSong}}"
+                    t="{{songTime}}"
+                    playing="{{songPlaying}}"
+                    duration="{{songDuration}}"></light9-music>
+      timeline editor: song <edit-choice graph="{{graph}}" uri="{{song}}"></edit-choice>
+      <label><input type="checkbox" checked="{{followPlayerSong::change}}" > follow player song choice</label>
+    </div>
+    <div id="debug">[[debug]]</div>
+    <iron-ajax id="vidrefTime" url="/vidref/time" method="PUT" content-type="application/json"></iron-ajax>
+    <div id="coveredByDiagram">
+      <light9-timeline-audio id="audio"
+                             graph="{{graph}}"
+                             show="{{show}}"
+                             song="{{song}}"></light9-timeline-audio>
+      <light9-timeline-time-zoomed id="zoomed"
+                                   graph="{{graph}}"
+                                   project="{{project}}"
+                                   selection="{{selection}}"
+                                   set-adjuster="{{setAdjuster}}"
+                                   song="{{song}}"
+                                   show="{{show}}"
+                                   view-state="{{viewState}}">
+      </light9-timeline-time-zoomed>
+      <light9-adjusters-canvas id="adjustersCanvas" set-adjuster="{{setAdjuster}}">
+      </light9-adjusters-canvas>
+      <light9-cursor-canvas id="cursorCanvas" view-state="{{viewState}}"></light9-cursor-canvas>
+      <light9-vidref-replay-stack size="small"></light9-vidref-replay-stack>
+    </div>
+`;
+  }
+}
+
+// the whole section that pans/zooms in time (most of the editor)
+@customElement("light9-timeline-time-zoomed")
+export class Light9TimelineTimeZoomed extends LitElement {
+  render() {
+    return html`
+    <style>
+     :host {
+         display: flex;
+         height: 100%;
+         flex-direction: column;
+     }
+     #top {
+     }
+     #rows {
+         height: 100%;
+         overflow: hidden;
+     }
+     #rows.dragging {
+         background: rgba(126, 52, 245, 0.0784);
+     }
+     light9-timeline-time-axis {
+     }
+     light9-timeline-audio {
+         width: 100%;
+         height: 100px;
+     }
+     light9-timeline-graph-row {
+         flex-grow: 1;
+     }
+    </style>
+    <div id="top">
+      <light9-timeline-time-axis id="time" view-state="{{viewState}}"></light9-timeline-time-axis>
+      <light9-timeline-audio id="audio"
+                             graph="{{graph}}"
+                             song="{{song}}"
+                             show="{{show}}"
+                             zoom="{{zoomFlattened}}">
+      </light9-timeline-audio>
+    </div>
+    <div id="rows"></div>
+    <template is="dom-repeat" items="{{imageSamples}}">
+      <img src="/show/dance2019/anim/rainbow1.png">
+    </template>
+    <template is="dom-repeat" items="{{inlineAttrConfigs}}">
+      <light9-timeline-note-inline-attrs graph="{{graph}}"
+                                         project="{{project}}"
+                                         selection="{{selection}}"
+                                         song="{{song}}"
+                                         config="{{item}}">
+      </light9-timeline-note-inline-attrs>
+    </template>
+`;
+  } 
+}
+
+@customElement("light9-cursor-canvas")
+export class Light9CursorCanvas extends LitElement {
+  render() {
+    return html`
+      <style>
+        #canvas,
+        :host {
+          pointer-events: none;
+        }
+      </style>
+      <canvas id="canvas"></canvas>
+    `;
+  }
+}
+
+@customElement("light9-adjusters-canvas")
+export class Light9AdjustersCanvas extends LitElement {
+  render() {
+    return html`
+      <style>
+        :host {
+          pointer-events: none;
+        }
+      </style>
+      <canvas id="canvas"></canvas>
+    `;
+  }
+}      
+
+// seconds labels
+@customElement("light9-timeline-time-axis")
+export class Light9TimelineTimeAxis extends LitElement {
+  render() {
+    return html`
+      <style>
+        :host {
+          display: block;
+        }
+        div {
+          width: 100%;
+          height: 31px;
+        }
+        svg {
+          width: 100%;
+          height: 30px;
+        }
+      </style>
+      <svg id="timeAxis" xmlns="http://www.w3.org/2000/svg">
+        <style>
+          text {
+            fill: white;
+            color: white;
+            font-size: 135%;
+            font-weight: bold;
+          }
+        </style>
+        <g id="axis" transform="translate(0,30)"></g>
+      </svg>
+    `;
+  }
+}
+
+// All the adjusters you can edit or select. Tells a light9-adjusters-canvas how to draw them. Probabaly doesn't need to be an element.
+//  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.
+@customElement("light9-timeline-adjusters")
+export class Light9TimelineAdjusters extends LitElement {
+  render() {
+    return html`
+      <style>
+        :host {
+          pointer-events: none; /* restored on the individual adjusters */
+        }
+      </style>
+    `;
+  }
+}
+
+/*
+<script src="/lib/async/dist/async.js"></script>
+<script src="/lib/shortcut/index.js"></script>
+<script src="/lib/underscore/underscore-min.js"></script>
+<script src="/node_modules/n3/n3-browser.js"></script> 
+<script src="/node_modules/pixi.js/dist/pixi.min.js"></script>
+<script src="/node_modules/tinycolor2/dist/tinycolor-min.js"></script>
+*/
--- a/light9/web/timeline/timeline.coffee	Mon May 16 01:32:57 2022 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,746 +0,0 @@
-log = debug('timeline')
-debug.enable('*')
-
-Drawing = window.Drawing
-ROW_COUNT = 7
-
-class Project
-  constructor: (@graph) ->
-
-  makeEffect: (uri) ->
-    U = (x) => @graph.Uri(x)
-    effect = U(uri.value + '/effect')
-    quad = (s, p, o) => @graph.Quad(s, p, o, effect)
-
-    quads = [
-      quad(effect, U('rdf:type'), U(':Effect')),
-      quad(effect, U(':copiedFrom'), uri),
-      quad(effect, U('rdfs:label'), @graph.Literal(uri.replace(/.*capture\//, ''))),
-      quad(effect, U(':publishAttr'), U(':strength')),
-      ]
-
-    fromSettings = @graph.objects(uri, U(':setting'))
-
-    toSettings = @graph.nextNumberedResources(effect + '_set', fromSettings.length)
-
-    for fs in fromSettings
-      ts = toSettings.pop()
-      # full copies of these since I may have to delete captures
-      quads.push(quad(effect, U(':setting'), ts))
-      quads.push(quad(ts, U(':device'), @graph.uriValue(fs, U(':device'))))
-      quads.push(quad(ts, U(':deviceAttr'), @graph.uriValue(fs, U(':deviceAttr'))))
-      try
-        quads.push(quad(ts, U(':value'), @graph.uriValue(fs, U(':value'))))
-      catch
-        quads.push(quad(ts, U(':scaledValue'), @graph.uriValue(fs, U(':scaledValue'))))
-
-    @graph.applyAndSendPatch({delQuads: [], addQuads: quads})
-    return effect
-
-  makeNewNote: (song, effect, dropTime, desiredWidthT) ->
-    U = (x) => @graph.Uri(x)
-    quad = (s, p, o) => @graph.Quad(s, p, o, song)
-
-    newNote = @graph.nextNumberedResource("#{song.value}/n")
-    newCurve = @graph.nextNumberedResource("#{newNote.value}c")
-    points = @graph.nextNumberedResources("#{newCurve.value}p", 4)
-
-    curveQuads = [
-        quad(song, U(':note'), newNote)
-        quad(newNote, U('rdf:type'), U(':Note'))
-        quad(newNote, U(':originTime'), @graph.LiteralRoundedFloat(dropTime))
-        quad(newNote, U(':effectClass'), effect)
-        quad(newNote, U(':curve'), newCurve)
-        quad(newCurve, U('rdf:type'), U(':Curve'))
-        quad(newCurve, U(':attr'), U(':strength'))
-      ]
-
-    pointQuads = []
-    for i in [0...4]
-      pt = points[i]
-      pointQuads.push(quad(newCurve, U(':point'), pt))
-      pointQuads.push(quad(pt, U(':time'), @graph.LiteralRoundedFloat(i/3 * desiredWidthT)))
-      pointQuads.push(quad(pt, U(':value'), @graph.LiteralRoundedFloat(i == 1 or i == 2)))
-
-    patch = {
-      delQuads: []
-      addQuads: curveQuads.concat(pointQuads)
-    }
-    @graph.applyAndSendPatch(patch)
-
-  getCurvePoints: (curve, xOffset) ->
-    worldPts = []
-    uris = @graph.objects(curve, @graph.Uri(':point'))
-    for pt in uris
-      tm = @graph.floatValue(pt, @graph.Uri(':time'))
-      val = @graph.floatValue(pt, @graph.Uri(':value'))
-      v = $V([xOffset + tm, val])
-      v.uri = pt
-      worldPts.push(v)
-    worldPts.sort((a,b) -> a.e(1) - b.e(1))
-    return [uris, worldPts]
-
-  curveWidth: (worldPts) ->
-    tMin = @graph.floatValue(worldPts[0].uri, @graph.Uri(':time'))
-    tMax = @graph.floatValue(worldPts[3].uri, @graph.Uri(':time'))
-    tMax - tMin
-
-  deleteNote: (song, note, selection) ->
-    patch = {delQuads: [@graph.Quad(song, graph.Uri(':note'), note, song)], addQuads: []}
-    @graph.applyAndSendPatch(patch)
-    if note in selection.selected()
-      selection.selected(_.without(selection.selected(), note))
-
-
-coffeeElementSetup(class TimelineEditor extends Polymer.mixinBehaviors([Polymer.IronResizableBehavior], Polymer.Element)
-  @is: 'light9-timeline-editor'
-  @getter_properties:
-    viewState: { type: Object }
-    debug: {type: String}
-    graph: {type: Object, notify: true}
-    project: {type: Object}
-    setAdjuster: {type: Function, notify: true}
-    playerSong: {type: String, notify: true}
-    followPlayerSong: {type: Boolean, notify: true, value: true}
-    song: {type: String, notify: true}
-    show: {type: String, notify: true}
-    songTime: {type: Number, notify: true}
-    songDuration: {type: Number, notify: true}
-    songPlaying: {type: Boolean, notify: true}
-    selection: {type: Object, notify: true}
-  @getter_observers: [
-    '_onSong(playerSong, followPlayerSong)',
-    '_onGraph(graph)',
-    '_onSongDuration(songDuration, viewState)',
-    '_onSongTime(song, playerSong, songTime, viewState)',
-    '_onSetAdjuster(setAdjuster)',
-  ]
-  constructor: ->
-    super()
-    @viewState = new ViewState()
-    window.viewState = @viewState
-
-  ready: ->
-    super.ready()
-    @addEventListener 'mousedown', (ev) => @$.adjustersCanvas.onDown(ev)
-    @addEventListener 'mousemove', (ev) => @$.adjustersCanvas.onMove(ev)
-    @addEventListener 'mouseup', (ev) => @$.adjustersCanvas.onUp(ev)
-
-    ko.options.deferUpdates = true
-
-    @selection = {hover: ko.observable(null), selected: ko.observable([])}
-
-    window.debug_zoomOrLayoutChangedCount = 0
-    window.debug_adjUpdateDisplay = 0
-
-    ko.computed(@zoomOrLayoutChanged.bind(@))
-
-    @trackMouse()
-    @bindKeys()
-    @bindWheelZoom(@)
-
-    setInterval(@updateDebugSummary.bind(@), 100)
-
-    @addEventListener('iron-resize', @_onIronResize.bind(@))
-    Polymer.RenderStatus.afterNextRender(this, @_onIronResize.bind(@))
-
-    Polymer.RenderStatus.afterNextRender this, =>
-      setupDrop(@$.zoomed.$.rows, @$.zoomed.$.rows, @, @$.zoomed.onDrop.bind(@$.zoomed))
-
-  _onIronResize: ->
-    @viewState.setWidth(@offsetWidth)
-    @viewState.coveredByDiagramTop(@$.coveredByDiagram.offsetTop)
-    @viewState.rowsY(@$.zoomed.$.rows.offsetTop) if @$.zoomed?.$?.rows?
-    @viewState.audioY(@$.audio.offsetTop)
-    @viewState.audioH(@$.audio.offsetHeight)
-    if @$.zoomed?.$?.time?
-      @viewState.zoomedTimeY(@$.zoomed.$.time.offsetTop)
-      @viewState.zoomedTimeH(@$.zoomed.$.time.offsetHeight)
-
-  _onSongTime: (song, playerSong, t) ->
-    if song != playerSong
-      @viewState.cursor.t(0)
-      return
-    @viewState.cursor.t(t)
-
-  _onSongDuration: (d) ->
-    d = 700 if d < 1 # bug is that asco isn't giving duration, but 0 makes the scale corrupt
-    @viewState.zoomSpec.duration(d)
-
-  _onSong: (s) ->
-    @song = @playerSong if @followPlayerSong
-
-  _onGraph: (graph) ->
-    @project = new Project(graph)
-    @show = 'http://light9.bigasterisk.com/show/dance2019'
-
-  _onSetAdjuster: () ->
-    @makeZoomAdjs()
-
-  updateDebugSummary: ->
-    elemCount = (tag) -> document.getElementsByTagName(tag).length
-    @debug = "#{window.debug_zoomOrLayoutChangedCount} layout change,
-     #{elemCount('light9-timeline-note')} notes,
-     #{@selection.selected().length} selected
-     #{elemCount('light9-timeline-graph-row')} rows,
-     #{window.debug_adjsCount} adjuster items registered,
-     #{window.debug_adjUpdateDisplay} adjuster updateDisplay calls,
-    "
-
-  zoomOrLayoutChanged: ->
-    vs = @viewState
-    dependOn = [vs.zoomSpec.t1(), vs.zoomSpec.t2(), vs.width()]
-
-    # shouldn't need this- deps should get it
-    @$.zoomed.gatherNotes() if @$.zoomed?.gatherNotes?
-
-    # todo: these run a lot of work purely for a time change
-    if @$.zoomed?.$?.audio?
-      #@dia.setTimeAxis(vs.width(), @$.zoomed.$.audio.offsetTop, vs.zoomInX)
-      @$.adjustersCanvas.updateAllCoords()
-
-  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 = @$.cursorCanvas.getBoundingClientRect()
-        @viewState.mouse.pos($V([ev.pageX - root.left, ev.pageY - root.top]))
-
-        # should be controlled by a checkbox next to follow-player-song-choice
-        @sendMouseToVidref() unless window.location.hash.match(/novidref/)
-
-  sendMouseToVidref: ->
-    now = Date.now()
-    if (!@$.vidrefLastSent? || @$.vidrefLastSent < now - 200) && !@songPlaying
-      @$.vidrefTime.body = {t: @viewState.latestMouseTime(), source: 'timeline', song: @song}
-      @$.vidrefTime.generateRequest()
-      @$.vidrefLastSent = now
-
-  bindWheelZoom: (elem) ->
-    elem.addEventListener 'mousewheel', (ev) =>
-      @viewState.onMouseWheel(ev.deltaY)
-
-  bindKeys: ->
-    shortcut.add "Ctrl+P", (ev) =>
-      @$.music.seekPlayOrPause(@viewState.latestMouseTime())
-    shortcut.add "Ctrl+Escape", => @viewState.frameAll()
-    shortcut.add "Shift+Escape", => @viewState.frameToEnd()
-    shortcut.add "Escape", => @viewState.frameCursor()
-    shortcut.add "L", =>
-      @$.adjustersCanvas.updateAllCoords()
-    shortcut.add 'Delete', =>
-      for note in @selection.selected()
-        @project.deleteNote(@graph.Uri(@song), note, @selection)
-
-  makeZoomAdjs: ->
-    yMid = => @$.audio.offsetTop + @$.audio.offsetHeight / 2
-
-    valForPos = (pos) =>
-      x = pos.e(1)
-      t = @viewState.fullZoomX.invert(x)
-    @setAdjuster('zoom-left', => new AdjustableFloatObservable({
-      observable: @viewState.zoomSpec.t1,
-      getTarget: () =>
-        $V([@viewState.fullZoomX(@viewState.zoomSpec.t1()), yMid()])
-      getSuggestedTargetOffset: () => $V([-50, 10])
-      getValueForPos: valForPos
-    }))
-
-    @setAdjuster('zoom-right', => new AdjustableFloatObservable({
-      observable: @viewState.zoomSpec.t2,
-      getTarget: () =>
-        $V([@viewState.fullZoomX(@viewState.zoomSpec.t2()), yMid()])
-      getSuggestedTargetOffset: () => $V([50, 10])
-      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)
-    })
-
-    @setAdjuster('zoom-pan', => new AdjustableFloatObservable({
-      observable: panObs
-      emptyBox: true
-      # fullzoom is not right- the sides shouldn't be able to go
-      # offscreen
-      getTarget: () => $V([@viewState.fullZoomX(panObs()), yMid()])
-      getSuggestedTargetOffset: () => $V([0, 0])
-      getValueForPos: valForPos
-    }))
-)
-
-
-# plan: in here, turn all the notes into simple js objects with all
-# their timing data and whatever's needed for adjusters. From that, do
-# the brick layout. update only changing adjusters.
-coffeeElementSetup(class TimeZoomed extends Polymer.mixinBehaviors([Polymer.IronResizableBehavior], Polymer.Element)
-  @is: 'light9-timeline-time-zoomed'
-  @getter_properties:
-    graph: { type: Object, notify: true }
-    project: { type: Object }
-    selection: { type: Object, notify: true }
-    song: { type: String, notify: true }
-    viewState: { type: Object, notify: true }
-    inlineAttrConfigs: { type: Array, value: [] } # only for inlineattrs that should be displayed
-    imageSamples: { type: Array, value: [] }
-  @getter_observers: [
-    '_onGraph(graph, setAdjuster, song, viewState, project)',
-    'onZoom(viewState)',
-    '_onViewState(viewState)',
-  ]
-  constructor: ->
-    super()
-    @numRows = 6
-    @noteByUriStr = new Map()
-    @stage = new PIXI.Container()
-    @stage.interactive=true
-
-    @renderer = PIXI.autoDetectRenderer({
-      backgroundColor: 0x606060,
-      antialias: true,
-      forceCanvas: true,
-    })
-    @bg = new PIXI.Container()
-    @stage.addChild(@bg)
-
-    @dirty = _.debounce(@_repaint.bind(@), 10)
-
-  ready: ->
-    super.ready()
-
-    @imageSamples = ['one']
-
-    @addEventListener('iron-resize', @_onResize.bind(@))
-    Polymer.RenderStatus.afterNextRender(this, @_onResize.bind(@))
-
-    @$.rows.appendChild(@renderer.view)
-
-    # This works for display, but pixi hit events didn't correctly
-    # move with the objects, so as a workaround, I extended the top of
-    # the canvas in _onResize.
-    #
-    #ko.computed =>
-    #  @stage.setTransform(0, -(@viewState.rowsY()), 1, 1, 0, 0, 0, 0, 0)
-
-  _onResize: ->
-    @$.rows.firstChild.style.position = 'relative'
-    @$.rows.firstChild.style.top = -@viewState.rowsY() + 'px'
-
-    @renderer.resize(@clientWidth, @clientHeight + @viewState.rowsY())
-
-    @dirty()
-
-  _onGraph: (graph, setAdjuster, song, viewState, project)->
-    return unless @song # polymer will call again
-    @graph.runHandler(@gatherNotes.bind(@), 'zoom notes')
-
-  _onViewState: (viewState) ->
-    @brickLayout = new BrickLayout(@viewState, @numRows)
-
-  noteDirty: -> @dirty()
-    
-  onZoom: ->
-    updateZoomFlattened = ->
-      @zoomFlattened = ko.toJS(@viewState.zoomSpec)
-    ko.computed(updateZoomFlattened.bind(@))
-
-  gatherNotes: ->
-    U = (x) => @graph.Uri(x)
-    return unless @song?
-    songNotes = @graph.objects(U(@song), U(':note'))
-
-    toRemove = new Set(@noteByUriStr.keys())
-    
-    for uri in @graph.sortedUris(songNotes)
-      had = toRemove.delete(uri.value)
-      if not had
-        @_addNote(uri)
-
-    toRemove.forEach @_delNote.bind(@)
-
-    @dirty()
-
-  isActiveNote: (note) -> @noteByUriStr.has(note.value)
-
-  _repaint: ->
-    @_drawGrid()  
-    @renderer.render(@stage)
-
-  _drawGrid: ->
-    # maybe someday this has snappable timing markers too
-    @bg.removeChildren()
-    gfx = new PIXI.Graphics()
-    @bg.addChild(gfx)
-
-    gfx.lineStyle(1, 0x222222, 1)
-    for row in [0...@numRows]
-      y = @brickLayout.rowBottom(row)
-      gfx.moveTo(0, y)
-      gfx.lineTo(@clientWidth, y)
-
-  _addNote: (uri) ->
-    U = (x) => @graph.Uri(x)
-    
-    con = new PIXI.Container()
-    con.interactive=true
-    @stage.addChild(con)
-    
-    note = new Note(@, con, @project, @graph, @selection, uri, @setAdjuster, U(@song), @viewState, @brickLayout)
-    # this must come before the first Note.draw
-    @noteByUriStr.set(uri.value, note)
-    @brickLayout.addNote(note, note.onRowChange.bind(note))
-    note.initWatchers()
-
-  _delNote: (uriStr) ->
-    n = @noteByUriStr.get(uriStr)
-    @brickLayout.delNote(n)
-    @stage.removeChild(n.container)
-    n.destroy()
-    @noteByUriStr.delete(uriStr)
-            
-  onDrop: (effect, pos) ->
-    U = (x) => @graph.Uri(x)
-
-    return unless effect and effect.match(/^http/)
-
-    # we could probably accept some initial overrides right on the
-    # effect uri, maybe as query params
-
-    if not @graph.contains(effect, U('rdf:type'), U(':Effect'))
-      if @graph.contains(effect, U('rdf:type'), U(':LightSample'))
-        effect = @project.makeEffect(effect)
-      else
-        log("drop #{effect} is not an effect")
-        return
-
-    dropTime = @viewState.zoomInX.invert(pos.e(1))
-
-    desiredWidthX = @offsetWidth * .3
-    desiredWidthT = @viewState.zoomInX.invert(desiredWidthX) - @viewState.zoomInX.invert(0)
-    desiredWidthT = Math.min(desiredWidthT, @viewState.zoomSpec.duration() - dropTime)
-    @project.makeNewNote(U(@song), U(effect), dropTime, desiredWidthT)
-
-  updateInlineAttrs: (note, config) ->
-    if not config?
-      index = 0
-      for c in @inlineAttrConfigs
-        if c.uri.equals(note)
-          @splice('inlineAttrConfigs', index)
-          return
-        index += 1
-    else
-      index = 0
-      for c in @inlineAttrConfigs
-        if c.uri.equals(note)
-          @splice('inlineAttrConfigs', index, 1, config)
-          return
-        index += 1
-      @push('inlineAttrConfigs', config)
-)
-
-
-
-coffeeElementSetup(class TimeAxis extends Polymer.Element
-  @is: "light9-timeline-time-axis",
-  @getter_properties:
-    viewState: { type: Object, notify: true, observer: "onViewState" }
-  onViewState: ->
-    ko.computed =>
-      dependOn = [@viewState.zoomSpec.t1(), @viewState.zoomSpec.t2()]
-      pxPerTick = 50
-      axis = d3.axisTop(@viewState.zoomInX).ticks(@viewState.width() / pxPerTick)
-      d3.select(@$.axis).call(axis)
-)
-
-
-# Maintains a pixi object, some adjusters, and inlineattrs corresponding to a note
-# in the graph.
-class Note
-  constructor: (@parentElem, @container, @project, @graph, @selection, @uri, @setAdjuster, @song, @viewState, @brickLayout) ->
-    @adjusterIds = new Set() # id string
-    @updateSoon = _.debounce(@update.bind(@), 30)
-
-  initWatchers: ->
-    @graph.runHandler(@update.bind(@), "note update #{@uri.value}")
-    ko.computed @update.bind(@)
-
-  destroy: ->
-    log('destroy', @uri.value)
-    @isDetached = true
-    @clearAdjusters()
-    @parentElem.updateInlineAttrs(@uri, null)
-
-  clearAdjusters: ->
-    @adjusterIds.forEach (i) =>
-      @setAdjuster(i, null)
-    @adjusterIds.clear()
-
-  getCurvePoints: (subj, curveAttr) ->
-    U = (x) => @graph.Uri(x)
-    originTime = @graph.floatValue(subj, U(':originTime'))
-
-    for curve in @graph.objects(subj, U(':curve'))
-      if @graph.uriValue(curve, U(':attr')).equals(curveAttr)
-        return @project.getCurvePoints(curve, originTime)
-    throw new Error("curve #{@uri.value} has no attr #{curveAttr.value}")
-
-  midPoint: (i0, i1) ->
-    p0 = @worldPts[i0]
-    p1 = @worldPts[i1]
-    p0.x(.5).add(p1.x(.5))
-
-  _planDrawing: ->
-    U = (x) => @graph.Uri(x)
-    [pointUris, worldPts] = @getCurvePoints(@uri, U(':strength'))
-    effect = @graph.uriValue(@uri, U(':effectClass'))
-
-    yForV = @brickLayout.yForVFor(@)
-    dependOn = [@viewState.zoomSpec.t1(),
-                @viewState.zoomSpec.t2(),
-                @viewState.width()]
-    screenPts = (new PIXI.Point(@viewState.zoomInX(pt.e(1)),
-                                yForV(pt.e(2))) for pt in worldPts)
-    return {
-      yForV: yForV
-      worldPts: worldPts
-      screenPts: screenPts
-      effect: effect
-      hover: @uri.equals(@selection.hover())
-      selected: @selection.selected().filter((s) => s.equals(@uri)).length
-    }
-
-  onRowChange: ->
-    @clearAdjusters()
-    @updateSoon()
-
-  redraw: (params) ->
-    # no observable or graph deps in here
-    @container.removeChildren()
-    @graphics = new PIXI.Graphics({nativeLines: false})
-    @graphics.interactive = true
-    @container.addChild(@graphics)
-
-    if params.hover
-      @_traceBorder(params.screenPts, 12, 0x888888)
-    if params.selected
-      @_traceBorder(params.screenPts, 6, 0xff2900)
-
-    shape = new PIXI.Polygon(params.screenPts)
-    @graphics.beginFill(@_noteColor(params.effect), .313)
-    @graphics.drawShape(shape)
-    @graphics.endFill()
-
-    @_traceBorder(params.screenPts, 2, 0xffd900)
-
-    @_addMouseBindings()
-    
-                 
-  update: ->
-    if not @parentElem.isActiveNote(@uri)
-      # stale redraw call
-      return
-
-    if @worldPts
-      @brickLayout.setNoteSpan(@, @worldPts[0].e(1),
-                               @worldPts[@worldPts.length - 1].e(1))
-
-    params = @_planDrawing()
-    @worldPts = params.worldPts
-
-    @redraw(params)
-
-    curveWidthCalc = () => @project.curveWidth(@worldPts)
-    @_updateAdjusters(params.screenPts, @worldPts, curveWidthCalc,
-                      params.yForV, @viewState.zoomInX, @song)
-    @_updateInlineAttrs(params.screenPts, params.yForV)
-    @parentElem.noteDirty()
-
-  _traceBorder: (screenPts, thick, color) ->
-    @graphics.lineStyle(thick, color, 1)
-    @graphics.moveTo(screenPts[0].x, screenPts[0].y)
-    for p in screenPts.slice(1)
-      @graphics.lineTo(p.x, p.y)
-
-  _addMouseBindings: () ->
-    @graphics.on 'mousedown', (ev) =>
-      @_onMouseDown(ev)
-
-    @graphics.on 'mouseover', =>
-      if @selection.hover() and @selection.hover().equals(@uri)
-        # Hovering causes a redraw, which would cause another
-        # mouseover event.
-        return
-      @selection.hover(@uri)
-
-    # mouseout never fires since we rebuild the graphics on mouseover.
-    @graphics.on 'mousemove', (ev) =>
-      if @selection.hover() and @selection.hover().equals(@uri) and ev.target != @graphics
-        @selection.hover(null)
-
-  onUri: ->
-    @graph.runHandler(@update.bind(@), "note updates #{@uri}")
-
-  patchCouldAffectMe: (patch) ->
-    if patch and patch.addQuads # sometimes patch is a polymer-sent value. @update is used as a listener too
-      if patch.addQuads.length == patch.delQuads.length == 1
-        add = patch.addQuads[0]
-        del = patch.delQuads[0]
-        if (add.predicate.equals(del.predicate) and del.predicate.equals(@graph.Uri(':time')) and add.subject.equals(del.subject))
-          timeEditFor = add.subject
-          if @worldPts and timeEditFor not in @pointUris
-            return false
-    return true
-
-  xupdate: (patch) ->
-    # update our note DOM and SVG elements based on the graph
-    if not @patchCouldAffectMe(patch)
-      # as autodep still fires all handlers on all patches, we just
-      # need any single dep to cause another callback. (without this,
-      # we would no longer be registered at all)
-      @graph.subjects(@uri, @uri, @uri)
-      return
-    if @isDetached?
-      return
-
-    @_updateDisplay()
-
-  _updateAdjusters: (screenPts, worldPts, curveWidthCalc, yForV, zoomInX, ctx) ->
-    # todo: allow offset even on more narrow notes
-    if screenPts[screenPts.length - 1].x - screenPts[0].x < 100 or screenPts[0].x > @parentElem.offsetWidth or screenPts[screenPts.length - 1].x < 0
-      @clearAdjusters()
-    else
-      @_makeOffsetAdjuster(yForV, curveWidthCalc, ctx)
-      @_makeCurvePointAdjusters(yForV, worldPts, ctx)
-      @_makeFadeAdjusters(yForV, zoomInX, ctx, worldPts)
-
-  _updateInlineAttrs: (screenPts, yForV) ->
-    w = 280
-
-    leftX = Math.max(2, screenPts[Math.min(1, screenPts.length - 1)].x + 5)
-    rightX = screenPts[Math.min(2, screenPts.length - 1)].x - 5
-    if screenPts.length < 3
-      rightX = leftX + w
-
-    if rightX - leftX < w or rightX < w or leftX > @parentElem.offsetWidth
-      @parentElem.updateInlineAttrs(@uri, null)
-      return
-
-    config = {
-      uri: @uri,
-      left: leftX,
-      top: yForV(1) + 5,
-      width: w,
-      height: yForV(0) - yForV(1) - 15,
-      }
-
-    @parentElem.updateInlineAttrs(@uri, config)
-
-  _makeCurvePointAdjusters: (yForV, worldPts, ctx) ->
-    for pointNum in [0...worldPts.length]
-      @_makePointAdjuster(yForV, worldPts, pointNum, ctx)
-
-  _makePointAdjuster: (yForV, worldPts, pointNum, ctx) ->
-    U = (x) => @graph.Uri(x)
-
-    adjId = @uri.value + '/p' + pointNum
-    @adjusterIds.add(adjId)
-    @setAdjuster adjId, =>
-      adj = new AdjustableFloatObject({
-        graph: @graph
-        subj: worldPts[pointNum].uri
-        pred: U(':time')
-        ctx: ctx
-        getTargetPosForValue: (value) =>
-          $V([@viewState.zoomInX(value), yForV(worldPts[pointNum].e(2))])
-        getValueForPos: (pos) =>
-          origin = @graph.floatValue(@uri, U(':originTime'))
-          (@viewState.zoomInX.invert(pos.e(1)) - origin)
-        getSuggestedTargetOffset: () => @_suggestedOffset(worldPts[pointNum]),
-      })
-      adj._getValue = (=>
-        # note: don't use originTime from the closure- we need the
-        # graph dependency
-        adj._currentValue + @graph.floatValue(@uri, U(':originTime'))
-        )
-      adj
-
-  _makeOffsetAdjuster: (yForV, curveWidthCalc, ctx) ->
-    U = (x) => @graph.Uri(x)
-
-    adjId = @uri.value + '/offset'
-    @adjusterIds.add(adjId)
-    @setAdjuster adjId, =>
-      adj = new AdjustableFloatObject({
-        graph: @graph
-        subj: @uri
-        pred: U(':originTime')
-        ctx: ctx
-        getDisplayValue: (v, dv) => "o=#{dv}"
-        getTargetPosForValue: (value) =>
-          # display bug: should be working from pt[0].t, not from origin
-          $V([@viewState.zoomInX(value + curveWidthCalc() / 2), yForV(.5)])
-        getValueForPos: (pos) =>
-          @viewState.zoomInX.invert(pos.e(1)) - curveWidthCalc() / 2
-        getSuggestedTargetOffset: () => $V([-10, 0])
-      })
-      adj
-
-  _makeFadeAdjusters: (yForV, zoomInX, ctx, worldPts) ->
-    U = (x) => @graph.Uri(x)
-    @_makeFadeAdjuster(yForV, zoomInX, ctx, @uri.value + '/fadeIn', 0, 1, $V([-50, -10]))
-    n = worldPts.length
-    @_makeFadeAdjuster(yForV, zoomInX, ctx, @uri.value + '/fadeOut', n - 2, n - 1, $V([50, -10]))
-
-  _makeFadeAdjuster: (yForV, zoomInX, ctx, adjId, i0, i1, offset) ->
-    @adjusterIds.add(adjId)
-    @setAdjuster adjId, =>
-      new AdjustableFade(yForV, zoomInX, i0, i1, @, offset, ctx)
-
-  _suggestedOffset: (pt) ->
-    if pt.e(2) > .5
-      $V([0, 30])
-    else
-      $V([0, -30])
-
-  _onMouseDown: (ev) ->
-    sel = @selection.selected()
-    if ev.data.originalEvent.ctrlKey
-      if @uri in sel
-        sel = _.without(sel, @uri)
-      else
-        sel.push(@uri)
-    else
-      sel = [@uri]
-    @selection.selected(sel)
-
-  _noteColor: (effect) ->
-    effect = effect.value
-    if effect in ['http://light9.bigasterisk.com/effect/blacklight',
-                  'http://light9.bigasterisk.com/effect/strobewarm']
-      hue = 0
-      sat = 100
-    else
-      hash = 0
-      for i in [(effect.length-10)...effect.length]
-        hash += effect.charCodeAt(i)
-      hue = (hash * 8) % 360
-      sat = 40 + (hash % 20) # don't conceal colorscale too much
-
-    return parseInt(tinycolor.fromRatio({h: hue / 360, s: sat / 100, l: .58}).toHex(), 16)
-
-    #elem = @getOrCreateElem(uri+'/label', 'noteLabels', 'text', {style: "font-size:13px;line-height:125%;font-family:'Verana Sans';text-align:start;text-anchor:start;fill:#000000;"})
-    #elem.setAttribute('x', curvePts[0].e(1)+20)
-    #elem.setAttribute('y', curvePts[0].e(2)-10)
-    #elem.innerHTML = effectLabel
--- a/light9/web/timeline/viewstate.coffee	Mon May 16 01:32:57 2022 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,92 +0,0 @@
-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)
-    @coveredByDiagramTop = ko.observable(0) # page coords
-    # all these are relative to #coveredByDiagram:
-    @audioY = ko.observable(0)
-    @audioH = ko.observable(0)
-    @zoomedTimeY = ko.observable(0)
-    @zoomedTimeH = ko.observable(0)
-    @rowsY = 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: () ->
-    # not for cursor updates
-
-    if @zoomSpec.t1() < 0
-      @zoomSpec.t1(0)
-    if @zoomSpec.duration() and @zoomSpec.t2() > @zoomSpec.duration()
-      @zoomSpec.t2(@zoomSpec.duration())
-
-    rightPad = 5 # don't let time adjuster fall off right edge
-    @fullZoomX.domain([0, @zoomSpec.duration()])
-    @fullZoomX.range([0, @width() - rightPad])
-
-    @zoomInX.domain([@zoomSpec.t1(), @zoomSpec.t2()])
-    @zoomInX.range([0, @width() - rightPad])
-    
-  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)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/timeline/viewstate.ts	Mon May 16 01:33:49 2022 -0700
@@ -0,0 +1,128 @@
+import * as ko from "knockout";
+import * as d3 from "d3";
+import debug from "debug";
+
+const log = debug("viewstate");
+export class ViewState {
+  zoomSpec: {
+    duration: ko.Observable<number>; // current song duration
+    t1: ko.Observable<number>;
+    t2: ko.Observable<number>;
+  };
+  cursor: { t: ko.Observable<number> };
+  mouse: { pos: ko.Observable<Vector> };
+  width: ko.Observable<number>;
+  coveredByDiagramTop: ko.Observable<number>;
+  audioY: ko.Observable<number>;
+  audioH: ko.Observable<number>;
+  zoomedTimeY: ko.Observable<number>;
+  zoomedTimeH: ko.Observable<number>;
+  rowsY: ko.Observable<number>;
+  fullZoomX: d3.ScaleLinear<number, number>;
+  zoomInX: d3.ScaleLinear<number, number>;
+  zoomAnimSec: number;
+  constructor() {
+    // caller updates all these observables
+    this.zoomSpec = {
+      duration: ko.observable(100), // current song duration
+      t1: ko.observable(0),
+      t2: ko.observable(100),
+    };
+    this.cursor = { t: ko.observable(20) }; // songTime
+    this.mouse = { pos: ko.observable($V([0, 0])) };
+    this.width = ko.observable(500);
+    this.coveredByDiagramTop = ko.observable(0); // page coords
+    // all these are relative to #coveredByDiagram:
+    this.audioY = ko.observable(0);
+    this.audioH = ko.observable(0);
+    this.zoomedTimeY = ko.observable(0);
+    this.zoomedTimeH = ko.observable(0);
+    this.rowsY = ko.observable(0);
+
+    this.fullZoomX = d3.scaleLinear();
+    this.zoomInX = d3.scaleLinear();
+
+    this.zoomAnimSec = 0.1;
+
+    ko.computed(this.maintainZoomLimitsAndScales.bind(this));
+  }
+
+  setWidth(w: any) {
+    this.width(w);
+    this.maintainZoomLimitsAndScales(); // before other handlers run
+  }
+
+  maintainZoomLimitsAndScales() {
+    // not for cursor updates
+
+    if (this.zoomSpec.t1() < 0) {
+      this.zoomSpec.t1(0);
+    }
+    if (this.zoomSpec.duration() && this.zoomSpec.t2() > this.zoomSpec.duration()) {
+      this.zoomSpec.t2(this.zoomSpec.duration());
+    }
+
+    const rightPad = 5; // don't let time adjuster fall off right edge
+    this.fullZoomX.domain([0, this.zoomSpec.duration()]);
+    this.fullZoomX.range([0, this.width() - rightPad]);
+
+    this.zoomInX.domain([this.zoomSpec.t1(), this.zoomSpec.t2()]);
+    this.zoomInX.range([0, this.width() - rightPad]);
+  }
+
+  latestMouseTime(): number {
+    return this.zoomInX.invert(this.mouse.pos().e(1));
+  }
+
+  onMouseWheel(deltaY: any) {
+    const zs = this.zoomSpec;
+
+    const center = this.latestMouseTime();
+    const left = center - zs.t1();
+    const right = zs.t2() - center;
+    const scale = Math.pow(1.005, deltaY);
+
+    zs.t1(center - left * scale);
+    zs.t2(center + right * scale);
+    log("view to", ko.toJSON(this));
+  }
+
+  frameCursor() {
+    const zs = this.zoomSpec;
+    const visSeconds = zs.t2() - zs.t1();
+    const margin = visSeconds * 0.4;
+    // buggy: really needs t1/t2 to limit their ranges
+    if (this.cursor.t() < zs.t1() || this.cursor.t() > zs.t2() - visSeconds * 0.6) {
+      const newCenter = this.cursor.t() + margin;
+      this.animatedZoom(newCenter - visSeconds / 2, newCenter + visSeconds / 2, this.zoomAnimSec);
+    }
+  }
+  frameToEnd() {
+    this.animatedZoom(this.cursor.t() - 2, this.zoomSpec.duration(), this.zoomAnimSec);
+  }
+  frameAll() {
+    this.animatedZoom(0, this.zoomSpec.duration(), this.zoomAnimSec);
+  }
+  animatedZoom(newT1: number, newT2: number, secs: number) {
+    const fps = 30;
+    const oldT1 = this.zoomSpec.t1();
+    const oldT2 = this.zoomSpec.t2();
+    let lastTime = 0;
+    for (let step = 0; step < secs * fps; step++) {
+      const frac = step / (secs * fps);
+      ((frac) => {
+        const gotoStep = () => {
+          this.zoomSpec.t1((1 - frac) * oldT1 + frac * newT1);
+          return this.zoomSpec.t2((1 - frac) * oldT2 + frac * newT2);
+        };
+        const delay = frac * secs * 1000;
+        setTimeout(gotoStep, delay);
+        lastTime = delay;
+      })(frac);
+    }
+    setTimeout(() => {
+      this.zoomSpec.t1(newT1);
+      return this.zoomSpec.t2(newT2);
+    }, lastTime + 10);
+  }
+}
--- a/package.json	Mon May 16 01:32:57 2022 -0700
+++ b/package.json	Mon May 16 01:33:49 2022 -0700
@@ -9,10 +9,18 @@
     "test": "test"
   },
   "dependencies": {
+    "@types/d3": "^7.1.0",
+    "@types/debug": "^4.1.7",
     "@types/node": "^17.0.31",
+    "@types/sylvester": "^0.1.8",
+    "@types/underscore": "^1.11.4",
+    "d3": "^7.4.4",
     "debug": "^4.3.4",
+    "knockout": "^3.5.1",
     "lit": "^2.2.3",
     "parse-prometheus-text-format": "^1.1.1",
+    "sylvester": "^0.0.21",
+    "underscore": "^1.13.3",
     "vite": "^2.9.1",
     "vite-plugin-rewrite-all": "^0.1.2"
   }
--- a/pnpm-lock.yaml	Mon May 16 01:32:57 2022 -0700
+++ b/pnpm-lock.yaml	Mon May 16 01:33:49 2022 -0700
@@ -1,18 +1,34 @@
 lockfileVersion: 5.3
 
 specifiers:
+  '@types/d3': ^7.1.0
+  '@types/debug': ^4.1.7
   '@types/node': ^17.0.31
+  '@types/sylvester': ^0.1.8
+  '@types/underscore': ^1.11.4
+  d3: ^7.4.4
   debug: ^4.3.4
+  knockout: ^3.5.1
   lit: ^2.2.3
   parse-prometheus-text-format: ^1.1.1
+  sylvester: ^0.0.21
+  underscore: ^1.13.3
   vite: ^2.9.1
   vite-plugin-rewrite-all: ^0.1.2
 
 dependencies:
+  '@types/d3': 7.1.0
+  '@types/debug': 4.1.7
   '@types/node': 17.0.31
+  '@types/sylvester': 0.1.8
+  '@types/underscore': 1.11.4
+  d3: 7.4.4
   debug: 4.3.4
+  knockout: 3.5.1
   lit: 2.2.3
   parse-prometheus-text-format: 1.1.1
+  sylvester: 0.0.21
+  underscore: 1.13.3
   vite: 2.9.1
   vite-plugin-rewrite-all: 0.1.2_vite@2.9.1
 
@@ -22,19 +38,473 @@
     resolution: {integrity: sha512-A2e18XzPMrIh35nhIdE4uoqRzoIpEU5vZYuQN4S3Ee1zkGdYC27DP12pewbw/RLgPHzaE4kx/YqxMzebOpm0dA==}
     dev: false
 
+  /@types/d3-array/3.0.2:
+    resolution: {integrity: sha512-5mjGjz6XOXKOCdTajXTZ/pMsg236RdiwKPrRPWAEf/2S/+PzwY+LLYShUpeysWaMvsdS7LArh6GdUefoxpchsQ==}
+    dev: false
+
+  /@types/d3-axis/3.0.1:
+    resolution: {integrity: sha512-zji/iIbdd49g9WN0aIsGcwcTBUkgLsCSwB+uH+LPVDAiKWENMtI3cJEWt+7/YYwelMoZmbBfzA3qCdrZ2XFNnw==}
+    dependencies:
+      '@types/d3-selection': 3.0.2
+    dev: false
+
+  /@types/d3-brush/3.0.1:
+    resolution: {integrity: sha512-B532DozsiTuQMHu2YChdZU0qsFJSio3Q6jmBYGYNp3gMDzBmuFFgPt9qKA4VYuLZMp4qc6eX7IUFUEsvHiXZAw==}
+    dependencies:
+      '@types/d3-selection': 3.0.2
+    dev: false
+
+  /@types/d3-chord/3.0.1:
+    resolution: {integrity: sha512-eQfcxIHrg7V++W8Qxn6QkqBNBokyhdWSAS73AbkbMzvLQmVVBviknoz2SRS/ZJdIOmhcmmdCRE/NFOm28Z1AMw==}
+    dev: false
+
+  /@types/d3-color/3.0.2:
+    resolution: {integrity: sha512-WVx6zBiz4sWlboCy7TCgjeyHpNjMsoF36yaagny1uXfbadc9f+5BeBf7U+lRmQqY3EHbGQpP8UdW8AC+cywSwQ==}
+    dev: false
+
+  /@types/d3-contour/3.0.1:
+    resolution: {integrity: sha512-C3zfBrhHZvrpAAK3YXqLWVAGo87A4SvJ83Q/zVJ8rFWJdKejUnDYaWZPkA8K84kb2vDA/g90LTQAz7etXcgoQQ==}
+    dependencies:
+      '@types/d3-array': 3.0.2
+      '@types/geojson': 7946.0.8
+    dev: false
+
+  /@types/d3-delaunay/6.0.0:
+    resolution: {integrity: sha512-iGm7ZaGLq11RK3e69VeMM6Oqj2SjKUB9Qhcyd1zIcqn2uE8w9GFB445yCY46NOQO3ByaNyktX1DK+Etz7ZaX+w==}
+    dev: false
+
+  /@types/d3-dispatch/3.0.1:
+    resolution: {integrity: sha512-NhxMn3bAkqhjoxabVJWKryhnZXXYYVQxaBnbANu0O94+O/nX9qSjrA1P1jbAQJxJf+VC72TxDX/YJcKue5bRqw==}
+    dev: false
+
+  /@types/d3-drag/3.0.1:
+    resolution: {integrity: sha512-o1Va7bLwwk6h03+nSM8dpaGEYnoIG19P0lKqlic8Un36ymh9NSkNFX1yiXMKNMx8rJ0Kfnn2eovuFaL6Jvj0zA==}
+    dependencies:
+      '@types/d3-selection': 3.0.2
+    dev: false
+
+  /@types/d3-dsv/3.0.0:
+    resolution: {integrity: sha512-o0/7RlMl9p5n6FQDptuJVMxDf/7EDEv2SYEO/CwdG2tr1hTfUVi0Iavkk2ax+VpaQ/1jVhpnj5rq1nj8vwhn2A==}
+    dev: false
+
+  /@types/d3-ease/3.0.0:
+    resolution: {integrity: sha512-aMo4eaAOijJjA6uU+GIeW018dvy9+oH5Y2VPPzjjfxevvGQ/oRDs+tfYC9b50Q4BygRR8yE2QCLsrT0WtAVseA==}
+    dev: false
+
+  /@types/d3-fetch/3.0.1:
+    resolution: {integrity: sha512-toZJNOwrOIqz7Oh6Q7l2zkaNfXkfR7mFSJvGvlD/Ciq/+SQ39d5gynHJZ/0fjt83ec3WL7+u3ssqIijQtBISsw==}
+    dependencies:
+      '@types/d3-dsv': 3.0.0
+    dev: false
+
+  /@types/d3-force/3.0.3:
+    resolution: {integrity: sha512-z8GteGVfkWJMKsx6hwC3SiTSLspL98VNpmvLpEFJQpZPq6xpA1I8HNBDNSpukfK0Vb0l64zGFhzunLgEAcBWSA==}
+    dev: false
+
+  /@types/d3-format/3.0.1:
+    resolution: {integrity: sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==}
+    dev: false
+
+  /@types/d3-geo/3.0.2:
+    resolution: {integrity: sha512-DbqK7MLYA8LpyHQfv6Klz0426bQEf7bRTvhMy44sNGVyZoWn//B0c+Qbeg8Osi2Obdc9BLLXYAKpyWege2/7LQ==}
+    dependencies:
+      '@types/geojson': 7946.0.8
+    dev: false
+
+  /@types/d3-hierarchy/3.0.2:
+    resolution: {integrity: sha512-+krnrWOZ+aQB6v+E+jEkmkAx9HvsNAD+1LCD0vlBY3t+HwjKnsBFbpVLx6WWzDzCIuiTWdAxXMEnGnVXpB09qQ==}
+    dev: false
+
+  /@types/d3-interpolate/3.0.1:
+    resolution: {integrity: sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==}
+    dependencies:
+      '@types/d3-color': 3.0.2
+    dev: false
+
+  /@types/d3-path/3.0.0:
+    resolution: {integrity: sha512-0g/A+mZXgFkQxN3HniRDbXMN79K3CdTpLsevj+PXiTcb2hVyvkZUBg37StmgCQkaD84cUJ4uaDAWq7UJOQy2Tg==}
+    dev: false
+
+  /@types/d3-polygon/3.0.0:
+    resolution: {integrity: sha512-D49z4DyzTKXM0sGKVqiTDTYr+DHg/uxsiWDAkNrwXYuiZVd9o9wXZIo+YsHkifOiyBkmSWlEngHCQme54/hnHw==}
+    dev: false
+
+  /@types/d3-quadtree/3.0.2:
+    resolution: {integrity: sha512-QNcK8Jguvc8lU+4OfeNx+qnVy7c0VrDJ+CCVFS9srBo2GL9Y18CnIxBdTF3v38flrGy5s1YggcoAiu6s4fLQIw==}
+    dev: false
+
+  /@types/d3-random/3.0.1:
+    resolution: {integrity: sha512-IIE6YTekGczpLYo/HehAy3JGF1ty7+usI97LqraNa8IiDur+L44d0VOjAvFQWJVdZOJHukUJw+ZdZBlgeUsHOQ==}
+    dev: false
+
+  /@types/d3-scale-chromatic/3.0.0:
+    resolution: {integrity: sha512-dsoJGEIShosKVRBZB0Vo3C8nqSDqVGujJU6tPznsBJxNJNwMF8utmS83nvCBKQYPpjCzaaHcrf66iTRpZosLPw==}
+    dev: false
+
+  /@types/d3-scale/4.0.2:
+    resolution: {integrity: sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==}
+    dependencies:
+      '@types/d3-time': 3.0.0
+    dev: false
+
+  /@types/d3-selection/3.0.2:
+    resolution: {integrity: sha512-d29EDd0iUBrRoKhPndhDY6U/PYxOWqgIZwKTooy2UkBfU7TNZNpRho0yLWPxlatQrFWk2mnTu71IZQ4+LRgKlQ==}
+    dev: false
+
+  /@types/d3-shape/3.0.2:
+    resolution: {integrity: sha512-5+ButCmIfNX8id5seZ7jKj3igdcxx+S9IDBiT35fQGTLZUfkFgTv+oBH34xgeoWDKpWcMITSzBILWQtBoN5Piw==}
+    dependencies:
+      '@types/d3-path': 3.0.0
+    dev: false
+
+  /@types/d3-time-format/4.0.0:
+    resolution: {integrity: sha512-yjfBUe6DJBsDin2BMIulhSHmr5qNR5Pxs17+oW4DoVPyVIXZ+m6bs7j1UVKP08Emv6jRmYrYqxYzO63mQxy1rw==}
+    dev: false
+
+  /@types/d3-time/3.0.0:
+    resolution: {integrity: sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==}
+    dev: false
+
+  /@types/d3-timer/3.0.0:
+    resolution: {integrity: sha512-HNB/9GHqu7Fo8AQiugyJbv6ZxYz58wef0esl4Mv828w1ZKpAshw/uFWVDUcIB9KKFeFKoxS3cHY07FFgtTRZ1g==}
+    dev: false
+
+  /@types/d3-transition/3.0.1:
+    resolution: {integrity: sha512-Sv4qEI9uq3bnZwlOANvYK853zvpdKEm1yz9rcc8ZTsxvRklcs9Fx4YFuGA3gXoQN/c/1T6QkVNjhaRO/cWj94g==}
+    dependencies:
+      '@types/d3-selection': 3.0.2
+    dev: false
+
+  /@types/d3-zoom/3.0.1:
+    resolution: {integrity: sha512-7s5L9TjfqIYQmQQEUcpMAcBOahem7TRoSO/+Gkz02GbMVuULiZzjF2BOdw291dbO2aNon4m2OdFsRGaCq2caLQ==}
+    dependencies:
+      '@types/d3-interpolate': 3.0.1
+      '@types/d3-selection': 3.0.2
+    dev: false
+
+  /@types/d3/7.1.0:
+    resolution: {integrity: sha512-gYWvgeGjEl+zmF8c+U1RNIKqe7sfQwIXeLXO5Os72TjDjCEtgpvGBvZ8dXlAuSS1m6B90Y1Uo6Bm36OGR/OtCA==}
+    dependencies:
+      '@types/d3-array': 3.0.2
+      '@types/d3-axis': 3.0.1
+      '@types/d3-brush': 3.0.1
+      '@types/d3-chord': 3.0.1
+      '@types/d3-color': 3.0.2
+      '@types/d3-contour': 3.0.1
+      '@types/d3-delaunay': 6.0.0
+      '@types/d3-dispatch': 3.0.1
+      '@types/d3-drag': 3.0.1
+      '@types/d3-dsv': 3.0.0
+      '@types/d3-ease': 3.0.0
+      '@types/d3-fetch': 3.0.1
+      '@types/d3-force': 3.0.3
+      '@types/d3-format': 3.0.1
+      '@types/d3-geo': 3.0.2
+      '@types/d3-hierarchy': 3.0.2
+      '@types/d3-interpolate': 3.0.1
+      '@types/d3-path': 3.0.0
+      '@types/d3-polygon': 3.0.0
+      '@types/d3-quadtree': 3.0.2
+      '@types/d3-random': 3.0.1
+      '@types/d3-scale': 4.0.2
+      '@types/d3-scale-chromatic': 3.0.0
+      '@types/d3-selection': 3.0.2
+      '@types/d3-shape': 3.0.2
+      '@types/d3-time': 3.0.0
+      '@types/d3-time-format': 4.0.0
+      '@types/d3-timer': 3.0.0
+      '@types/d3-transition': 3.0.1
+      '@types/d3-zoom': 3.0.1
+    dev: false
+
+  /@types/debug/4.1.7:
+    resolution: {integrity: sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==}
+    dependencies:
+      '@types/ms': 0.7.31
+    dev: false
+
+  /@types/geojson/7946.0.8:
+    resolution: {integrity: sha512-1rkryxURpr6aWP7R786/UQOkJ3PcpQiWkAXBmdWc7ryFWqN6a4xfK7BtjXvFBKO9LjQ+MWQSWxYeZX1OApnArA==}
+    dev: false
+
+  /@types/ms/0.7.31:
+    resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==}
+    dev: false
+
   /@types/node/17.0.31:
     resolution: {integrity: sha512-AR0x5HbXGqkEx9CadRH3EBYx/VkiUgZIhP4wvPn/+5KIsgpNoyFaRlVe0Zlx9gRtg8fA06a9tskE2MSN7TcG4Q==}
     dev: false
 
+  /@types/sylvester/0.1.8:
+    resolution: {integrity: sha512-x1bzR4PCxvv1/9iPrbdQ15gWgP8Tp8EPjO4VLjhMijepB44BzJ/XvJavoPViSiHxlBX6NgzRgO0H+qa68lJFGA==}
+    dev: false
+
   /@types/trusted-types/2.0.2:
     resolution: {integrity: sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==}
     dev: false
 
+  /@types/underscore/1.11.4:
+    resolution: {integrity: sha512-uO4CD2ELOjw8tasUrAhvnn2W4A0ZECOvMjCivJr4gA9pGgjv+qxKWY9GLTMVEK8ej85BxQOocUyE7hImmSQYcg==}
+    dev: false
+
+  /commander/7.2.0:
+    resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
+    engines: {node: '>= 10'}
+    dev: false
+
   /connect-history-api-fallback/1.6.0:
     resolution: {integrity: sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg==}
     engines: {node: '>=0.8'}
     dev: false
 
+  /d3-array/3.1.6:
+    resolution: {integrity: sha512-DCbBBNuKOeiR9h04ySRBMW52TFVc91O9wJziuyXw6Ztmy8D3oZbmCkOO3UHKC7ceNJsN2Mavo9+vwV8EAEUXzA==}
+    engines: {node: '>=12'}
+    dependencies:
+      internmap: 2.0.3
+    dev: false
+
+  /d3-axis/3.0.0:
+    resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==}
+    engines: {node: '>=12'}
+    dev: false
+
+  /d3-brush/3.0.0:
+    resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==}
+    engines: {node: '>=12'}
+    dependencies:
+      d3-dispatch: 3.0.1
+      d3-drag: 3.0.0
+      d3-interpolate: 3.0.1
+      d3-selection: 3.0.0
+      d3-transition: 3.0.1_d3-selection@3.0.0
+    dev: false
+
+  /d3-chord/3.0.1:
+    resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==}
+    engines: {node: '>=12'}
+    dependencies:
+      d3-path: 3.0.1
+    dev: false
+
+  /d3-color/3.1.0:
+    resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
+    engines: {node: '>=12'}
+    dev: false
+
+  /d3-contour/3.0.1:
+    resolution: {integrity: sha512-0Oc4D0KyhwhM7ZL0RMnfGycLN7hxHB8CMmwZ3+H26PWAG0ozNuYG5hXSDNgmP1SgJkQMrlG6cP20HoaSbvcJTQ==}
+    engines: {node: '>=12'}
+    dependencies:
+      d3-array: 3.1.6
+    dev: false
+
+  /d3-delaunay/6.0.2:
+    resolution: {integrity: sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==}
+    engines: {node: '>=12'}
+    dependencies:
+      delaunator: 5.0.0
+    dev: false
+
+  /d3-dispatch/3.0.1:
+    resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
+    engines: {node: '>=12'}
+    dev: false
+
+  /d3-drag/3.0.0:
+    resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
+    engines: {node: '>=12'}
+    dependencies:
+      d3-dispatch: 3.0.1
+      d3-selection: 3.0.0
+    dev: false
+
+  /d3-dsv/3.0.1:
+    resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==}
+    engines: {node: '>=12'}
+    hasBin: true
+    dependencies:
+      commander: 7.2.0
+      iconv-lite: 0.6.3
+      rw: 1.3.3
+    dev: false
+
+  /d3-ease/3.0.1:
+    resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
+    engines: {node: '>=12'}
+    dev: false
+
+  /d3-fetch/3.0.1:
+    resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==}
+    engines: {node: '>=12'}
+    dependencies:
+      d3-dsv: 3.0.1
+    dev: false
+
+  /d3-force/3.0.0:
+    resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==}
+    engines: {node: '>=12'}
+    dependencies:
+      d3-dispatch: 3.0.1
+      d3-quadtree: 3.0.1
+      d3-timer: 3.0.1
+    dev: false
+
+  /d3-format/3.1.0:
+    resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==}
+    engines: {node: '>=12'}
+    dev: false
+
+  /d3-geo/3.0.1:
+    resolution: {integrity: sha512-Wt23xBych5tSy9IYAM1FR2rWIBFWa52B/oF/GYe5zbdHrg08FU8+BuI6X4PvTwPDdqdAdq04fuWJpELtsaEjeA==}
+    engines: {node: '>=12'}
+    dependencies:
+      d3-array: 3.1.6
+    dev: false
+
+  /d3-hierarchy/3.1.2:
+    resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==}
+    engines: {node: '>=12'}
+    dev: false
+
+  /d3-interpolate/3.0.1:
+    resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
+    engines: {node: '>=12'}
+    dependencies:
+      d3-color: 3.1.0
+    dev: false
+
+  /d3-path/3.0.1:
+    resolution: {integrity: sha512-gq6gZom9AFZby0YLduxT1qmrp4xpBA1YZr19OI717WIdKE2OM5ETq5qrHLb301IgxhLwcuxvGZVLeeWc/k1I6w==}
+    engines: {node: '>=12'}
+    dev: false
+
+  /d3-polygon/3.0.1:
+    resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==}
+    engines: {node: '>=12'}
+    dev: false
+
+  /d3-quadtree/3.0.1:
+    resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==}
+    engines: {node: '>=12'}
+    dev: false
+
+  /d3-random/3.0.1:
+    resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==}
+    engines: {node: '>=12'}
+    dev: false
+
+  /d3-scale-chromatic/3.0.0:
+    resolution: {integrity: sha512-Lx9thtxAKrO2Pq6OO2Ua474opeziKr279P/TKZsMAhYyNDD3EnCffdbgeSYN5O7m2ByQsxtuP2CSDczNUIZ22g==}
+    engines: {node: '>=12'}
+    dependencies:
+      d3-color: 3.1.0
+      d3-interpolate: 3.0.1
+    dev: false
+
+  /d3-scale/4.0.2:
+    resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
+    engines: {node: '>=12'}
+    dependencies:
+      d3-array: 3.1.6
+      d3-format: 3.1.0
+      d3-interpolate: 3.0.1
+      d3-time: 3.0.0
+      d3-time-format: 4.1.0
+    dev: false
+
+  /d3-selection/3.0.0:
+    resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
+    engines: {node: '>=12'}
+    dev: false
+
+  /d3-shape/3.1.0:
+    resolution: {integrity: sha512-tGDh1Muf8kWjEDT/LswZJ8WF85yDZLvVJpYU9Nq+8+yW1Z5enxrmXOhTArlkaElU+CTn0OTVNli+/i+HP45QEQ==}
+    engines: {node: '>=12'}
+    dependencies:
+      d3-path: 3.0.1
+    dev: false
+
+  /d3-time-format/4.1.0:
+    resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
+    engines: {node: '>=12'}
+    dependencies:
+      d3-time: 3.0.0
+    dev: false
+
+  /d3-time/3.0.0:
+    resolution: {integrity: sha512-zmV3lRnlaLI08y9IMRXSDshQb5Nj77smnfpnd2LrBa/2K281Jijactokeak14QacHs/kKq0AQ121nidNYlarbQ==}
+    engines: {node: '>=12'}
+    dependencies:
+      d3-array: 3.1.6
+    dev: false
+
+  /d3-timer/3.0.1:
+    resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
+    engines: {node: '>=12'}
+    dev: false
+
+  /d3-transition/3.0.1_d3-selection@3.0.0:
+    resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
+    engines: {node: '>=12'}
+    peerDependencies:
+      d3-selection: 2 - 3
+    dependencies:
+      d3-color: 3.1.0
+      d3-dispatch: 3.0.1
+      d3-ease: 3.0.1
+      d3-interpolate: 3.0.1
+      d3-selection: 3.0.0
+      d3-timer: 3.0.1
+    dev: false
+
+  /d3-zoom/3.0.0:
+    resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
+    engines: {node: '>=12'}
+    dependencies:
+      d3-dispatch: 3.0.1
+      d3-drag: 3.0.0
+      d3-interpolate: 3.0.1
+      d3-selection: 3.0.0
+      d3-transition: 3.0.1_d3-selection@3.0.0
+    dev: false
+
+  /d3/7.4.4:
+    resolution: {integrity: sha512-97FE+MYdAlV3R9P74+R3Uar7wUKkIFu89UWMjEaDhiJ9VxKvqaMxauImy8PC2DdBkdM2BxJOIoLxPrcZUyrKoQ==}
+    engines: {node: '>=12'}
+    dependencies:
+      d3-array: 3.1.6
+      d3-axis: 3.0.0
+      d3-brush: 3.0.0
+      d3-chord: 3.0.1
+      d3-color: 3.1.0
+      d3-contour: 3.0.1
+      d3-delaunay: 6.0.2
+      d3-dispatch: 3.0.1
+      d3-drag: 3.0.0
+      d3-dsv: 3.0.1
+      d3-ease: 3.0.1
+      d3-fetch: 3.0.1
+      d3-force: 3.0.0
+      d3-format: 3.1.0
+      d3-geo: 3.0.1
+      d3-hierarchy: 3.1.2
+      d3-interpolate: 3.0.1
+      d3-path: 3.0.1
+      d3-polygon: 3.0.1
+      d3-quadtree: 3.0.1
+      d3-random: 3.0.1
+      d3-scale: 4.0.2
+      d3-scale-chromatic: 3.0.0
+      d3-selection: 3.0.0
+      d3-shape: 3.1.0
+      d3-time: 3.0.0
+      d3-time-format: 4.1.0
+      d3-timer: 3.0.1
+      d3-transition: 3.0.1_d3-selection@3.0.0
+      d3-zoom: 3.0.0
+    dev: false
+
   /debug/4.3.4:
     resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
     engines: {node: '>=6.0'}
@@ -47,6 +517,12 @@
       ms: 2.1.2
     dev: false
 
+  /delaunator/5.0.0:
+    resolution: {integrity: sha512-AyLvtyJdbv/U1GkiS6gUUzclRoAY4Gs75qkMygJJhU75LW4DNuSF2RMzpxs9jw9Oz1BobHjTdkG3zdP55VxAqw==}
+    dependencies:
+      robust-predicates: 3.0.1
+    dev: false
+
   /esbuild-android-64/0.14.34:
     resolution: {integrity: sha512-XfxcfJqmMYsT/LXqrptzFxmaR3GWzXHDLdFNIhm6S00zPaQF1TBBWm+9t0RZ6LRR7iwH57DPjaOeW20vMqI4Yw==}
     engines: {node: '>=12'}
@@ -274,12 +750,28 @@
       function-bind: 1.1.1
     dev: false
 
+  /iconv-lite/0.6.3:
+    resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
+    engines: {node: '>=0.10.0'}
+    dependencies:
+      safer-buffer: 2.1.2
+    dev: false
+
+  /internmap/2.0.3:
+    resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
+    engines: {node: '>=12'}
+    dev: false
+
   /is-core-module/2.8.1:
     resolution: {integrity: sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA==}
     dependencies:
       has: 1.0.3
     dev: false
 
+  /knockout/3.5.1:
+    resolution: {integrity: sha512-wRJ9I4az0QcsH7A4v4l0enUpkS++MBx0BnL/68KaLzJg7x1qmbjSlwEoCNol7KTYZ+pmtI7Eh2J0Nu6/2Z5J/Q==}
+    dev: false
+
   /lit-element/3.2.0:
     resolution: {integrity: sha512-HbE7yt2SnUtg5DCrWt028oaU4D5F4k/1cntAFHTkzY8ZIa8N0Wmu92PxSxucsQSOXlODFrICkQ5x/tEshKi13g==}
     dependencies:
@@ -343,6 +835,10 @@
       supports-preserve-symlinks-flag: 1.0.0
     dev: false
 
+  /robust-predicates/3.0.1:
+    resolution: {integrity: sha512-ndEIpszUHiG4HtDsQLeIuMvRsDnn8c8rYStabochtUeCvfuvNptb5TUbVD68LRAILPX7p9nqQGh4xJgn3EHS/g==}
+    dev: false
+
   /rollup/2.70.1:
     resolution: {integrity: sha512-CRYsI5EuzLbXdxC6RnYhOuRdtz4bhejPMSWjsFLfVM/7w/85n2szZv6yExqUXsBdz5KT8eoubeyDUDjhLHEslA==}
     engines: {node: '>=10.0.0'}
@@ -351,6 +847,14 @@
       fsevents: 2.3.2
     dev: false
 
+  /rw/1.3.3:
+    resolution: {integrity: sha1-P4Yt+pGrdmsUiF700BEkv9oHT7Q=}
+    dev: false
+
+  /safer-buffer/2.1.2:
+    resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+    dev: false
+
   /shallow-equal/1.2.1:
     resolution: {integrity: sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==}
     dev: false
@@ -365,6 +869,15 @@
     engines: {node: '>= 0.4'}
     dev: false
 
+  /sylvester/0.0.21:
+    resolution: {integrity: sha1-KYexzivS84sNzio0OIiEv6RADqc=}
+    engines: {node: '>=0.2.6'}
+    dev: false
+
+  /underscore/1.13.3:
+    resolution: {integrity: sha512-QvjkYpiD+dJJraRA8+dGAU4i7aBbb2s0S3jA45TFOvg2VgqvdCDd/3N6CqA8gluk1W91GLoXg5enMUx560QzuA==}
+    dev: false
+
   /vite-plugin-rewrite-all/0.1.2_vite@2.9.1:
     resolution: {integrity: sha512-hBFuG043kbixgZ/ke9SzKhkO6P8a5ryxD0CmZTe+/Cz17RIKi7uSeNUJy79V4FgavZ7pWVRg0tqVwJ7lP/A2/Q==}
     engines: {node: '>=12.0.0'}