Changeset - b4633fb9bccf
[Not reviewed]
default
0 2 0
Drew Perttula - 7 years ago 2018-05-07 00:38:56
drewp@bigasterisk.com
extract Project class with the larger graph-only methods
Ignore-this: e0d79e45fb053d8b7e228b6eb5687eba
2 files changed with 96 insertions and 88 deletions:
0 comments (0 inline, 0 general)
light9/web/timeline/timeline-elements.html
Show inline comments
 
<link rel="import" href="/lib/polymer/polymer.html">
 
<link rel="import" href="/lib/iron-resizable-behavior/iron-resizable-behavior.html">
 
<link rel="import" href="/lib/iron-ajax/iron-ajax.html">
 
<link rel="import" href="light9-timeline-audio.html">
 
<link rel="import" href="../rdfdb-synced-graph.html">
 
<link rel="import" href="../light9-music.html">
 
<link rel="import" href="../edit-choice.html">
 
<link rel="import" href="inline-attrs.html">
 

	
 

	
 
<!-- Whole editor- include this on your page.
 
     Most coordinates are relative to this element.
 
   -->
 
<dom-module id="light9-timeline-editor">
 
  <template>
 
    <style>
 
     :host {
 
         background: #444;
 
         display: flex;
 
         flex-direction: column;
 
         position: relative;
 
         border: 1px solid black;
 
         overflow: hidden;
 
     }
 
     light9-timeline-audio {
 
         width: 100%;
 
         height: 30px;
 
     }
 
     light9-timeline-time-zoomed {
 
         flex-grow: 1;
 
     }
 
     #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;
 
     }
 
    </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}}"
 
                                   dia="{{dia}}"
 
                                   set-adjuster="{{setAdjuster}}"
 
                                   song="{{song}}"
 
                                   show="{{show}}"
 
                                   zoom="{{viewState.zoomSpec}}"
 
                                   zoom-in-x="{{zoomInX}}">
 
      </light9-timeline-time-zoomed>
 
      <light9-timeline-diagram-layer id="dia" selection="{{selection}}"></light9-timeline-diagram-layer>
 
      <light9-adjusters-canvas id="adjustersCanvas" set-adjuster="{{setAdjuster}}">
 
      </light9-adjusters-canvas>
 
      <light9-cursor-canvas id="cursorCanvas"></light9-cursor-canvas>
 
    </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%;
 
     }
 
     #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"></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>
 
</dom-module>
 

	
 

	
 
<!--
 
     SVG or canvas that draws these:
 
       - background grids
 
       - zoom arcs
 
       - notes
 
     
 
     This element is not responsible for any hit detection. Things you click (rows,
 
     notes, adjusters, etc) are caught on their respective elements. (But is that
 
     wrong in the case of notes?)
 
   -->
 
<dom-module id="light9-timeline-diagram-layer">
 
  <template>
 
    <style>
 
      :host {
 
      }
 
      svg {
 
      width: 100%;
 
      height: 100%;
 
      }
 
      #notes > path {
 
      
 
      stroke:#000000;
 
      stroke-width:1.5;
 
      }
 
     #notes > path.hover {
 
         stroke-width: 1.5;
 
         stroke: #888;
 
     }
 
     #notes > path.selected {
 
         stroke-width: 5;
 
         stroke: red;
 
     }
 
    </style>
 
    <svg xmlns="http://www.w3.org/2000/svg"
 
         xmlns:svg="http://www.w3.org/2000/svg"
 
         xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" >
 
      <g id="layer1">
 
        <text
light9/web/timeline/timeline.coffee
Show inline comments
 
log = console.log
 
RDF = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'
 
Drawing = window.Drawing
 
ROW_COUNT = 7
 

	
 
class Project
 
  constructor: (@graph) ->
 

	
 
  makeEffect: (uri) ->
 
    U = (x) => @graph.Uri(x)
 
    effect = U(uri + '/effect')
 
    quad = (s, p, o) => {subject: s, predicate: p, object: o, graph: 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: (effect, dropTime, desiredWidthT) ->
 
    U = (x) => @graph.Uri(x)
 
    quad = (s, p, o) => {subject: s, predicate: p, object: o, graph: @song}
 
      
 
    newNote = @graph.nextNumberedResource("#{@song}/n")
 
    newCurve = @graph.nextNumberedResource("#{newNote}c")
 
    points = @graph.nextNumberedResources("#{newCurve}p", 4)
 

	
 
    curveQuads = [
 
        quad(@song, U(':note'), newNote)
 
        quad(newNote, RDF + 'type', U(':Note'))
 
        quad(newNote, U(':originTime'), @graph.LiteralRoundedFloat(dropTime))
 
        quad(newNote, U(':effectClass'), effect)
 
        quad(newNote, U(':curve'), newCurve)
 
        quad(newCurve, 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
 
      v = $V([xOffset + @graph.floatValue(pt, @graph.Uri(':time')),
 
              @graph.floatValue(pt, @graph.Uri(':value'))])
 
      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: [{subject: song, predicate: graph.Uri(':note'), object: note, graph: song}], addQuads: []}
 
    @graph.applyAndSendPatch(patch)
 
    if note in selection.selected()
 
      selection.selected(_.without(selection.selected(), note))
 

	
 
class TimelineEditor extends Polymer.Element
 
  @is: 'light9-timeline-editor'
 
  @behaviors: [ Polymer.IronResizableBehavior ]
 
  @properties:
 
    viewState: { type: Object }
 
    debug: {type: String}
 
    graph: {type: Object, notify: true}
 
    setAdjuster: {type: Function, notify: true}
 
    playerSong: {type: String, notify: true}
 
    followPlayerSong: {type: Boolean, notify: true, value: true}
 
    song: {type: String, notify: true}
 
    show: {value: 'http://light9.bigasterisk.com/show/dance2017'}
 
    songTime: {type: Number, notify: true, observer: '_onSongTime'}
 
    songDuration: {type: Number, notify: true, observer: '_onSongDuration'}
 
    songPlaying: {type: Boolean, notify: true}
 
    fullZoomX: {type: Object, notify: true}
 
    zoomInX: {type: Object, notify: true}
 
    selection: {type: Object, notify: true}
 
  width: ko.observable(1)
 
  @listeners:
 
    'iron-resize': '_onIronResize'
 
  @observers: [
 
    'setSong(playerSong, followPlayerSong)'
 
    ]
 
  _onIronResize: ->
 
    @width(@offsetWidth)
 
  _onSongTime: (t) ->
 
    @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)
 
  setSong: (s) ->
 
    @song = @playerSong if @followPlayerSong
 

	
 
  connectedCallback: ->
 
    super.connectedCallback()
 
    ko.options.deferUpdates = true;
 
    @selection = {hover: ko.observable(null), selected: ko.observable([])}
 

	
 
    window.debug_zoomOrLayoutChangedCount = 0
 
    window.debug_adjUpdateDisplay = 0
 
    
 
    @viewState =
 
      zoomSpec:
 
        duration: ko.observable(100) # current song duration
 
        t1: ko.observable(0) # need validation to stay in bounds and not go too close
 
        t2: ko.observable(100)
 
      cursor:
 
        t: ko.observable(20)
 
      mouse:
 
        pos: ko.observable($V([0,0]))
 
    @fullZoomX = d3.scaleLinear()
 
    @zoomInX = d3.scaleLinear()
 
    #@setAdjuster = @$.adjustersCanvas.setAdjuster.bind(@$.adjustersCanvas)
 
    @setAdjuster = (adjId, makeAdjustable) =>
 
      @$.adjustersCanvas.setAdjuster(adjId, makeAdjustable)
 

	
 
    setInterval(@updateDebugSummary.bind(@), 100)
 

	
 
    #if anchor == loadtest
 
    #  add note and delete it repeatedly
 
    #  disconnect the graph, make many notes, drag a point over many steps, measure lag somewhere
 

	
 
  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,
 
    "
 
    
 
  attached: ->
 
    @dia = @$.dia
 
    ko.computed(@zoomOrLayoutChanged.bind(@))
 
    ko.computed(@songTimeChanged.bind(@))
 

	
 
    @trackMouse()
 
    @bindKeys()
 
    @bindWheelZoom(@dia.querySelector('svg'))
 
    @forwardMouseEventsToAdjustersCanvas()
 

	
 
    @makeZoomAdjs()
 

	
 
    zoomed = @$.zoomed
 
    setupDrop(@$.dia.querySelector('svg'), zoomed.$.rows, @, zoomed.onDrop.bind(zoomed))
 

	
 

	
 
  zoomOrLayoutChanged: ->
 
    log('zoomOrLayoutChanged')
 
    # not for cursor updates
 

	
 
    vs = @viewState
 
    
 
    if vs.zoomSpec.t1() < 0
 
      vs.zoomSpec.t1(0)
 
    if vs.zoomSpec.duration() and vs.zoomSpec.t2() > vs.zoomSpec.duration()
 
      vs.zoomSpec.t2(vs.zoomSpec.duration())
 

	
 
    window.debug_zoomOrLayoutChangedCount++
 
    @fullZoomX.domain([0, vs.zoomSpec.duration()])
 
    @fullZoomX.range([0, @width()])
 

	
 
    # had trouble making notes update when this changes
 
    zoomInX = d3.scaleLinear()
 
    zoomInX.domain([vs.zoomSpec.t1(), vs.zoomSpec.t2()])
 
    zoomInX.range([0, @width()])
 
    @zoomInX = zoomInX
 

	
 
    # todo: these run a lot of work purely for a time change
 
    @dia.setTimeAxis(@width(), @$.zoomed.$.audio.offsetTop, @zoomInX)
 
    @$.adjustersCanvas.updateAllCoords()
 

	
 
    # cursor needs update when layout changes, but I don't want
 
    # zoom/layout to depend on the playback time
 
    setTimeout(@songTimeChanged.bind(@), 1)
 

	
 
  songTimeChanged: ->
 
    @$.cursorCanvas.setCursor(@$.audio.offsetTop, @$.audio.offsetHeight,
 
                              @$.zoomed.$.time.offsetTop,
 
                              @$.zoomed.$.time.offsetHeight,
 
                              @fullZoomX, @zoomInX, @viewState.cursor)
 
    
 
  trackMouse: ->
 
    # not just for show- we use the mouse pos sometimes
 
    for evName in ['mousemove', 'touchmove']
 
      @addEventListener evName, (ev) =>
 
        ev.preventDefault()
 

	
 
        # todo: consolidate with _editorCoordinates version
 
        if ev.touches?.length
 
          ev = ev.touches[0]
 

	
 
        @root = @getBoundingClientRect()
 
        @viewState.mouse.pos($V([ev.pageX - @root.left, ev.pageY - @root.top]))
 

	
 
        @$.cursorCanvas.setMouse(@viewState.mouse.pos())
 
        # 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: @latestMouseTime(), source: 'timeline'}
 
      @$.vidrefTime.generateRequest()
 
      @$.vidrefLastSent = now
 

	
 
  latestMouseTime: ->
 
    @zoomInX.invert(@viewState.mouse.pos().e(1))
 

	
 
  bindWheelZoom: (elem) ->
 
    elem.addEventListener 'mousewheel', (ev) =>
 
      zs = @viewState.zoomSpec
 

	
 
      center = @latestMouseTime()
 
      left = center - zs.t1()
 
      right = zs.t2() - center
 
      scale = Math.pow(1.005, ev.deltaY)
 

	
 
      zs.t1(center - left * scale)
 
      zs.t2(center + right * scale)
 

	
 
  forwardMouseEventsToAdjustersCanvas: ->
 
    ac = @$.adjustersCanvas
 
    @addEventListener('mousedown', ac.onDown.bind(ac))
 
    @addEventListener('mousemove', ac.onMove.bind(ac))
 
    @addEventListener('mouseup', ac.onUp.bind(ac))
 

	
 
  animatedZoom: (newT1, newT2, secs) ->
 
    fps = 30
 
    oldT1 = @viewState.zoomSpec.t1()
 
    oldT2 = @viewState.zoomSpec.t2()
 
    lastTime = 0
 
    for step in [0..secs * fps]
 
      frac = step / (secs * fps)
 
      do (frac) =>
 
        gotoStep = =>
 
          @viewState.zoomSpec.t1((1 - frac) * oldT1 + frac * newT1)
 
          @viewState.zoomSpec.t2((1 - frac) * oldT2 + frac * newT2)
 
        delay = frac * secs * 1000
 
        setTimeout(gotoStep, delay)
 
        lastTime = delay
 
    setTimeout(=>
 
        @viewState.zoomSpec.t1(newT1)
 
        @viewState.zoomSpec.t2(newT2)
 
      , lastTime + 10)  
 
    
 
  bindKeys: ->
 
    shortcut.add "Ctrl+P", (ev) =>
 
      @$.music.seekPlayOrPause(@latestMouseTime())
 

	
 
    zoomAnimSec = .1
 
    shortcut.add "Ctrl+Escape", =>
 
      @animatedZoom(0, @viewState.zoomSpec.duration(), zoomAnimSec)
 
    shortcut.add "Shift+Escape", =>
 
      @animatedZoom(@songTime - 2, @viewState.zoomSpec.duration(), zoomAnimSec)
 
    shortcut.add "Escape", =>
 
      zs = @viewState.zoomSpec
 
      visSeconds = zs.t2() - zs.t1()
 
      margin = visSeconds * .4
 
      # buggy: really needs t1/t2 to limit their ranges
 
      if @songTime < zs.t1() or @songTime > zs.t2() - visSeconds * .6
 
        newCenter = @songTime + margin
 
        @animatedZoom(newCenter - visSeconds / 2,
 
                      newCenter + visSeconds / 2, zoomAnimSec)
 
    shortcut.add "L", =>
 
      @$.adjustersCanvas.updateAllCoords()
 
    shortcut.add 'Delete', =>
 
      for note in @selection.selected()
 
        deleteNote(@graph, @song, note, @selection)
 
        @project.deleteNote(@song, note, @selection)
 

	
 
  makeZoomAdjs: ->
 
    yMid = => @$.audio.offsetTop + @$.audio.offsetHeight / 2
 
    dur = @viewState.zoomSpec.duration
 
    
 
    valForPos = (pos) =>
 
        x = pos.e(1)
 
        t = @fullZoomX.invert(x)
 
    @setAdjuster('zoom-left', => new AdjustableFloatObservable({
 
      observable: @viewState.zoomSpec.t1,
 
      getTarget: () =>
 
        $V([@fullZoomX(@viewState.zoomSpec.t1()), yMid()])
 
      getSuggestedTargetOffset: () => $V([50, 0])
 
      getValueForPos: valForPos
 
    }))
 

	
 
    @setAdjuster('zoom-right', => new AdjustableFloatObservable({
 
      observable: @viewState.zoomSpec.t2,
 
      getTarget: () =>
 
        $V([@fullZoomX(@viewState.zoomSpec.t2()), yMid()])
 
      getSuggestedTargetOffset: () => $V([-50, 0])
 
      getValueForPos: valForPos
 
    }))
 

	
 
    panObs = ko.pureComputed({
 
        read: () =>
 
          (@viewState.zoomSpec.t1() + @viewState.zoomSpec.t2()) / 2
 
        write: (value) =>
 
          zs = @viewState.zoomSpec
 
          span = zs.t2() - zs.t1()
 
          zs.t1(value - span / 2)
 
          zs.t2(value + span / 2)
 
      })
 

	
 
    @setAdjuster('zoom-pan', => new AdjustableFloatObservable({
 
      observable: panObs
 
      emptyBox: true
 
      # fullzoom is not right- the sides shouldn't be able to go
 
      # offscreen
 
      getTarget: () => $V([@fullZoomX(panObs()), yMid()])
 
      getSuggestedTargetOffset: () => $V([0, 0])
 
      getValueForPos: valForPos
 
      }))
 
      
 
customElements.define(TimelineEditor.is, TimelineEditor)
 

	
 
# 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.
 
class TimeZoomed extends Polymer.Element
 
  @is: 'light9-timeline-time-zoomed'
 
  @behaviors: [ Polymer.IronResizableBehavior ]
 
  @properties:
 
    graph: { type: Object, notify: true }
 
    project: { type: Object }
 
    selection: { type: Object, notify: true }
 
    dia: { type: Object, notify: true }
 
    song: { type: String, notify: true }
 
    zoomInX: { type: Object, notify: true }
 
    zoom: { type: Object, notify: true, observer: 'onZoom' } # viewState.zoomSpec
 
    zoomFlattened: { type: Object, notify: true }
 
  @observers: [
 
    'onGraph(graph, dia, setAdjuster, song, zoomInX)'
 
  ]
 
  @listeners: {'iron-resize': 'update'}
 
  update: ->
 
    @renderer.resize(@clientWidth, @clientHeight)
 
    @renderer.render(@stage);
 
    @renderer.render(@stage)
 

	
 
  onZoom: ->
 
    updateZoomFlattened = ->
 
      log('updateZoomFlattened')
 
      @zoomFlattened = ko.toJS(@zoom)
 
    ko.computed(updateZoomFlattened.bind(@))
 
    
 
  connectedCallback: ->
 
     super.connectedCallback()
 

	
 
     @stage = new PIXI.Container()
 
     
 
     @renderer = PIXI.autoDetectRenderer({
 
         backgroundColor: 0xff6060,
 
        autoResize: true,
 
     })
 
     
 
     @$.rows.appendChild(@renderer.view);
 
     graphics = new PIXI.Graphics();
 

	
 
     graphics.beginFill(0xFF3300);
 
     graphics.lineStyle(4, 0xffd900, 1);
 

	
 
     graphics.moveTo(50,50);
 
     graphics.lineTo(250, 50);
 
     graphics.lineTo(100, 100);
 
     graphics.lineTo(50, 50);
 
     graphics.endFill();
 
     
 
     @stage.addChild(graphics);
 
     @renderer.render(@stage);
 

	
 
     # iron-resize should be doing this but it never fires
 
     setInterval(@update.bind(@), 1000)
 
    
 
  onGraph: ->
 
    U = (x) => @graph.Uri(x)
 
    log('assign rows',@song)
 

	
 
    for uri in _.sortBy(@graph.objects(@song, U(':note')), 'uri')
 
      #should only make new ones
 
      child = new Note(@graph, @selection, @dia, uri, @setAdjuster, @song, @zoomInX)
 
      log('note ',uri)
 
    
 
    @rows = []#(new NoteRow(@graph, @dia, @song, @zoomInX, @noteUris, i, @selection) for i in [0...ROW_COUNT])
 

	
 
  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, RDF + 'type', U(':Effect'))
 
      if @graph.contains(effect, RDF + 'type', U(':LightSample'))
 
        effect = @makeEffect(effect)
 
        effect = @project.makeEffect(effect)
 
      else
 
        log("drop #{effect} is not an effect")
 
        return
 

	
 
    dropTime = @zoomInX.invert(pos.e(1))
 
    @makeNewNote(effect, dropTime)
 

	
 
  makeEffect: (uri) ->
 
    U = (x) => @graph.Uri(x)
 
    effect = U(uri + '/effect')
 
    quad = (s, p, o) => {subject: s, predicate: p, object: o, graph: 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: (effect, dropTime) ->
 
    U = (x) => @graph.Uri(x)
 
    quad = (s, p, o) => {subject: s, predicate: p, object: o, graph: @song}
 
      
 
    newNote = @graph.nextNumberedResource("#{@song}/n")
 
    newCurve = @graph.nextNumberedResource("#{newNote}c")
 
    points = @graph.nextNumberedResources("#{newCurve}p", 4)
 

	
 
    curveQuads = [
 
        quad(@song, U(':note'), newNote)
 
        quad(newNote, RDF + 'type', U(':Note'))
 
        quad(newNote, U(':originTime'), @graph.LiteralRoundedFloat(dropTime))
 
        quad(newNote, U(':effectClass'), effect)
 
        quad(newNote, U(':curve'), newCurve)
 
        quad(newCurve, RDF + 'type', U(':Curve'))
 
        quad(newCurve, U(':attr'), U(':strength'))
 
      ]        
 
    pointQuads = []
 

	
 
    desiredWidthX = @offsetWidth * .3
 
    desiredWidthT = @zoomInX.invert(desiredWidthX) - @zoomInX.invert(0)
 
    desiredWidthT = Math.min(desiredWidthT, @zoom.duration() - dropTime)
 
    
 
    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)
 
    @project.makeNewNote(effect, dropTime, desiredWidthT)
 

	
 
customElements.define(TimeZoomed.is, TimeZoomed)
 

	
 
class TimeAxis extends Polymer.Element
 
  @is: "light9-timeline-time-axis",
 
  # for now since it's just one line calling dia,
 
  # light9-timeline-editor does our drawing work.
 

	
 
customElements.define(TimeAxis.is, TimeAxis)
 

	
 
class NoteRow
 
  constructor: (@graph, @dia, @song, @zoomInX, @noteUris, @rowIndex, @selection) ->
 
    @graph.runHandler(@update.bind(@), "row notes #{@rowIndex}")
 

	
 
  observers: [
 
    'observedUpdate(graph, song, rowIndex)'
 
    'onZoom(zoomInX)'
 
    ]
 

	
 

	
 
  observedUpdate: (graph, song, rowIndex) ->
 
    @update() # old behavior
 
    #@graph.runHandler(@update.bind(@), "row notes #{@rowIndex}")
 

	
 
  update: (patch) ->
 
    U = (x) => @graph.Uri(x)
 

	
 
    notesForThisRow = []
 
    i = 0
 
    for n in _.sortBy(@graph.objects(@song, U(':note')), 'uri')
 
      if (i % ROW_COUNT) == @rowIndex
 
        notesForThisRow.push(n)
 
      i++
 

	
 
    for newUri in notesForThisRow
 
      #should only make new ones
 
      child = new Note(@graph, @selection, @dia, newUri, @setAdjuster, @song, @zoomInX)
 

	
 
  onZoom: ->
 
    for e in @children
 
      e.zoomInX = @zoomInX
 

	
 
class Note
 
  constructor: (@graph, @selection, @dia, @uri, @setAdjuster, @song, @zoomInX)->0
 
  is: 'light9-timeline-note'
 
  behaviors: [ Polymer.IronResizableBehavior ]
 
  listeners: 'iron-resize': 'update' #move to parent elem
 
  properties:
 
    graph: { type: Object, notify: true }
 
    selection: { type: Object, notify: true }
 
    dia: { type: Object, notify: true }
 
    uri: { type: String, notify: true }
 
    zoomInX: { type: Object, notify: true }
 
    setAdjuster: {type: Function, notify: true }
 
    inlineRect: { type: Object, notify: true }
 
    song: { type: String, notify: true }
 
  observers: [
 
    'onUri(graph, dia, uri, zoomInX, setAdjuster, song)'
 
    'update(graph, dia, uri, zoomInX, setAdjuster, song)'
 
    ]
 
  ready: ->
 
    @adjusterIds = {} # id : true
 

	
 
  detached: ->
 
    log('detatch', @uri)
 
    @dia.clearNote(@uri)
 
    @isDetached = true
 
    @clearAdjusters()
 

	
 
  clearAdjusters: ->
 
    for i in Object.keys(@adjusterIds)
 
      @setAdjuster(i, 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 == del.predicate == @graph.Uri(':time') and
 
            add.subject == del.subject)
 
          timeEditFor = add.subject
 
          if @worldPts and timeEditFor not in @pointUris
 
            return false
 
    return true
 
            
 
  update: (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()
 

	
 
  _updateDisplay: ->
 
    U = (x) => @graph.Uri(x)
 

	
 
    # @offsetTop causes some CSS layout to run!
 
    yForV = (v) => @offsetTop + (1 - v) * @offsetHeight
 

	
 
    originTime = @graph.floatValue(@uri, U(':originTime'))
 
    effect = @graph.uriValue(@uri, U(':effectClass'))
 
    for curve in @graph.objects(@uri, U(':curve'))
 
      if @graph.uriValue(curve, U(':attr')) == U(':strength')
 

	
 
        [@pointUris, @worldPts] = @_getCurvePoints(curve, originTime)
 
        curveWidthCalc = () => @_curveWidth(@worldPts)
 
    
 
        screenPts = ($V([@zoomInX(pt.e(1)), @offsetTop + (1 - pt.e(2)) * @offsetHeight]) for pt in @worldPts)
 
    
 
        @dia.setNote(@uri, screenPts, effect)
 
        @_updateAdjusters(screenPts, curveWidthCalc, yForV, U(@song))
 
        @_updateInlineAttrs(screenPts)
 
        
 
  _updateAdjusters: (screenPts, curveWidthCalc, yForV, ctx) ->   
 
    if screenPts[screenPts.length - 1].e(1) - screenPts[0].e(1) < 100
 
      @clearAdjusters()
 
    else
 
      @_makeOffsetAdjuster(yForV, curveWidthCalc, ctx)
 
      @_makeCurvePointAdjusters(yForV, @worldPts, ctx)
 
      @_makeFadeAdjusters(yForV, ctx)
 

	
 
  _updateInlineAttrs: (screenPts) ->
 
    leftX = Math.max(2, screenPts[Math.min(1, screenPts.length - 1)].e(1) + 5)
 
    rightX = screenPts[Math.min(2, screenPts.length - 1)].e(1) - 5
 
    if screenPts.length < 3
 
      rightX = leftX + 120
 
    w = 250
 
    h = 110
 
    wasHidden = @inlineRect?.display == 'none'
 
    @inlineRect = {
 
      left: leftX,
 
      top: @offsetTop + @offsetHeight - h - 5,
 
      width: w,
 
      height: h,
 
      display: if rightX - leftX > w then 'block' else 'none'
 
      }
 
    if wasHidden and @inlineRect.display != 'none'
 
      @async =>
 
        @querySelector('light9-timeline-note-inline-attrs')?.displayed()
 
    
 
  _getCurvePoints: (curve, xOffset) ->
 
    worldPts = []
 
    uris = @graph.objects(curve, @graph.Uri(':point'))
 
    for pt in uris
 
      v = $V([xOffset + @graph.floatValue(pt, @graph.Uri(':time')),
 
              @graph.floatValue(pt, @graph.Uri(':value'))])
 
      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
 
    
 
  _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 + '/p' + pointNum
 
    @adjusterIds[adjId] = true
 
    @setAdjuster adjId, =>
 
      adj = new AdjustableFloatObject({
 
        graph: @graph
 
        subj: worldPts[pointNum].uri
 
        pred: U(':time')
 
        ctx: ctx
 
        getTargetPosForValue: (value) =>
 
          $V([@zoomInX(value), yForV(worldPts[pointNum].e(2))])
 
        getValueForPos: (pos) =>
 
          origin = @graph.floatValue(@uri, U(':originTime'))
 
          (@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 + '/offset'
 
    @adjusterIds[adjId] = true
 
    @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([@zoomInX(value + curveWidthCalc() / 2), yForV(.5)])
 
        getValueForPos: (pos) =>
 
          @zoomInX.invert(pos.e(1)) - curveWidthCalc() / 2
 
        getSuggestedTargetOffset: () => $V([-10, 0])
 
      })
 
      adj
 

	
 
  _makeFadeAdjusters: (yForV, ctx) ->
 
    @_makeFadeAdjuster(yForV, ctx, @uri + '/fadeIn', 0, 1, $V([-50, -10]))
 
    n = @worldPts.length
 
    @_makeFadeAdjuster(yForV, ctx, @uri + '/fadeOut', n - 2, n - 1, $V([50, -10]))
 

	
 
  _makeFadeAdjuster: (yForV, ctx, adjId, i0, i1, offset) ->
 
    @adjusterIds[adjId] = true
 
    @setAdjuster adjId, => new AdjustableFade(yForV, i0, i1, @, offset, ctx)
 
    
 
  _suggestedOffset: (pt) ->
 
    if pt.e(2) > .5
 
      $V([0, 30])
 
    else
 
      $V([0, -30])
 
    
 
    
 
deleteNote = (graph, song, note, selection) ->
 
  patch = {delQuads: [{subject: song, predicate: graph.Uri(':note'), object: note, graph: song}], addQuads: []}
 
  graph.applyAndSendPatch(patch)
 
  if note in selection.selected()
 
    selection.selected(_.without(selection.selected(), note))
 
  
 
  
 
class DiagramLayer extends Polymer.Element
 
  # note boxes. 
 
  @is: 'light9-timeline-diagram-layer'
 
  @properties: {
 
    selection: {type: Object, notify: true}
 
  }
 
  connectedCallback: ->
 
    super.connectedCallback()
 
    @elemById = {}
 

	
 
  setTimeAxis: (width, yTop, scale) ->
 
    pxPerTick = 50
 
    axis = d3.axisTop(scale).ticks(width / pxPerTick)
 
    d3.select(@$.timeAxis).attr('transform', 'translate(0,'+yTop+')').call(axis)
 

	
 
  getOrCreateElem: (uri, groupId, tag, attrs, moreBuild) ->
 
    elem = @elemById[uri]
 
    if !elem
 
      elem = @elemById[uri] = document.createElementNS("http://www.w3.org/2000/svg", tag)
 
      @$[groupId].appendChild(elem)
 
      elem.setAttribute('id', uri)
 
      for k,v of attrs
 
        elem.setAttribute(k, v)
 
      if moreBuild
 
        moreBuild(elem)
 
    return elem
 

	
 
  _clearElem: (uri, suffixes) ->
 
    for suff in suffixes
 
      elem = @elemById[uri+suff]
 
      if elem
 
        ko.removeNode(elem)
 
        delete @elemById[uri+suff]
 

	
 
  _anyPointsInView: (pts) ->
 
    for pt in pts
 
      # wrong:
 
      if pt.e(1) > -100 && pt.e(1) < 2500
 
        return true
 
    return false
 
    
 
  setNote: (uri, curvePts, effect) ->
 
    @debounce("setNote #{uri}", () => @_setNoteThrottle(uri, curvePts, effect))
 
    
 
  _setNoteThrottle: (uri, curvePts, effect) ->
 
    areaId = uri + '/area'
 
    if not @_anyPointsInView(curvePts)
 
      @clearNote(uri)
 
      return
 

	
 
    attrs = @_noteAttrs(effect)
 
    elem = @getOrCreateElem areaId, 'notes', 'path', attrs, (elem) =>
 
      @_addNoteListeners(elem, uri)
 
    elem.setAttribute('d', Drawing.svgPathFromPoints(curvePts))
 
    @_updateNotePathClasses(uri, elem)
 

	
 
  _addNoteListeners: (elem, uri) ->
 
    elem.addEventListener 'mouseenter', =>
 
      @selection.hover(uri)
 
    elem.addEventListener 'mousedown', (ev) =>
 
      sel = @selection.selected()
 
      if ev.getModifierState('Control')
 
        if uri in sel
 
          sel = _.without(sel, uri)
 
        else
 
          sel.push(uri)
 
      else
 
        sel = [uri]
 
      @selection.selected(sel)
 
    elem.addEventListener 'mouseleave', =>
 
      @selection.hover(null)
 

	
 
  _noteAttrs: (effect) ->
 
    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
 

	
 
    {style: "fill:hsla(#{hue}, #{sat}%, 58%, 0.313);"}
 

	
 
  clearNote: (uri) ->
 
    @_clearElem(uri, ['/area'])
 

	
 
  _noteInDiagram: (uri) ->
 
    return !!@elemById[uri + '/area']
 

	
 
  _updateNotePathClasses: (uri, elem) ->
 
    ko.computed =>
 
      return if not @_noteInDiagram(uri)
0 comments (0 inline, 0 general)