packages feed

linden-0: client/src/Linden.js

var x = require('diablo')
var classNames = require('classnames')
var clamp = require('clamp')
var inlineStyle = require('inline-style')

var ImgCache = require('./ImgCache')
var walkTree = require('./walkTree')

var PI2 = Math.PI / 2
var TWOPI = 2 * Math.PI
var DEG = PI2 / 90

function sin(angle) {
  return Math.sin(angle * DEG)
}

function cos(angle) {
  return Math.cos(angle * DEG)
}

function mod(v, m) {
  return (v % m + m) % m
}

function rotBounds(a, ox, oy, x1, y1, x2, y2) {
  var rx1
  var ry1
  var rx2
  var ry2
  var am = mod(a, 360)
  if (am <= 90) {
    rx1 = x1
    ry1 = y2
    rx2 = x2
    ry2 = y1
  } else if (am <= 180) {
    rx1 = x2
    ry1 = y1
    rx2 = x1
    ry2 = y2
  } else if (am <= 270) {
    rx1 = x2
    ry1 = y2
    rx2 = x1
    ry2 = y1
  } else {
    rx1 = x1
    ry1 = y2
    rx2 = x2
    ry2 = y1
  }
  rx1 -= ox
  ry1 -= oy
  rx2 -= ox
  ry2 -= oy
  var newBounds = {
    x1: ox + rx1 * cos(am) - ry1 * sin(am),
    y1: oy + rx1 * sin(am) + ry1 * cos(am),
    x2: ox + rx2 * cos(am) - ry2 * sin(am),
    y2: oy + rx2 * sin(am) + ry2 * cos(am),
  }

  // FIXME: not sure why these are transposed :(
  if (newBounds.x2 < newBounds.x1) {
    var swapx = newBounds.x2
    newBounds.x2 = newBounds.x1
    newBounds.x1 = swapx
  }
  if (newBounds.y2 < newBounds.y1) {
    var swapy = newBounds.y2
    newBounds.y2 = newBounds.y1
    newBounds.y1 = swapy
  }

  return newBounds
}

function rgba(r, g, b, a) {
  return 'rgba(' + 100 * r + '%, ' + 100 * g + '%, ' + 100 * b + '%, ' + a + ')'
}

function tempToRGB(t) {
  if (t < 0.32) {
    return [1, 0.5 + 0.5 * t / 0.32, 0.5]
  }
  if (t < 0.64) {
    return [1, 1, 0.5 + 0.5 * (t - 0.32) / 0.32]
  }
  return [0.75 + 0.25 * (1 - t) / 0.36, 0.75 + 0.25 * (1 - t) / 0.36, 1]
}

function walkPositions(l, state, visit) {
  // parent cache
  var pc = {
    x2: 0,
    y2: 0,
    scale: 1,
    angle: 0,
  }
  walkTree(l, function visitPosition(node, descend) {
    var nodeState = state[node.id]
    var c = node.cache
    if (!c) {
      c = node.cache = {}  // eslint-disable-line no-param-reassign
    }
    c.scale = pc.scale * node.scale
    c.angle = pc.angle + nodeState.angle
    var dx = node.attach[0] - node.root[0]
    var dy = node.attach[1] - node.root[1]
    c.x1 = pc.x2
    c.y1 = pc.y2
    c.x2 = c.x1 + c.scale * (dx * cos(c.angle) + dy * sin(c.angle))
    c.y2 = c.y1 + c.scale * (-dx * sin(c.angle) + dy * cos(c.angle))

    var origPC = pc
    pc = c
    visit(node, descend)
    pc = origPC
  })
}

function calcBox(node) {
  var c = node.cache
  var bx1 = c.x1 - node.root[0]
  var by1 = c.y1 - node.root[1]
  return rotBounds(
    -c.angle,
    bx1 + node.root[0],
    by1 + node.root[1],
    bx1,
    by1,
    bx1 + c.scale * c.width,
    by1 + c.scale * c.height
  )
}

function renderLinden(l, state, lights, opts) {
  var w = opts.width
  var h = opts.height
  var debug = opts.debug
  var urlPrefix = opts.urlPrefix
  var imgPrefix = ''
  var imgCache = opts.imgCache
  var ctx = opts.el.getContext('2d')
  var density = Number(opts.el.getAttribute('data-density'))

  if (density > 1) {
    imgPrefix = '2x-'
  }

  var imgs = []
  walkTree(l, function visitImg(node, descend) {
    if (node.img) {
      imgs.push(urlPrefix + imgPrefix + node.img)
    }
    descend()
  })

  function getState(node, prop) {
    return state[node.id][prop]
  }

  imgCache.getAll(imgs, function withImgs(loadedImgs) {
    ctx.save()
    ctx.scale(density, -density)
    ctx.translate(0, -h)
    ctx.clearRect(0, 0, w, h)

    var circledNode
    walkPositions(l, state, function visitDraw(node, descend) {
      var c = node.cache
      ctx.save()
      ctx.rotate(getState(node, 'angle') * -DEG)
      ctx.scale(node.scale, node.scale)
      if (state._selected) {
        if (node.id === state._selected.id) {
          ctx.globalAlpha = 0.5
        }
        if (node.id === state._selected.circled) {
          circledNode = node
        }
      }
      if (node.img) {
        var imgUrl = urlPrefix + imgPrefix + node.img
        var img = loadedImgs[imgUrl]
        if (img) {
          ctx.save()
          ctx.scale(1 / density, -1 / density)
          ctx.translate(0, -img.height)
          ctx.drawImage(img, -node.root[0] * density, node.root[1] * density)
          ctx.restore()
          c.width = img.width / density
          c.height = img.height / density
        }
      }
      ctx.translate(node.attach[0] - node.root[0], node.attach[1] - node.root[1])
      descend()
      ctx.restore()
    })

    // lights
    var mixCanvas = document.createElement('canvas')
    mixCanvas.width = opts.el.width
    mixCanvas.height = opts.el.height
    var lctx = mixCanvas.getContext('2d')
    lctx.scale(density, density)
    var lightRadius = Math.sqrt(Math.pow(w, 2) + Math.pow(h, 2))
    function drawLights(callback) {
      for (var i = 0; i < lights.length; i++) {
        var light = lights[i]
        lctx.save()
        lctx.beginPath()
        lctx.moveTo(light.pos[0], light.pos[1])
        var x1 = light.pos[0] + lightRadius * cos(light.pointAngle - light.beamAngle / 2)
        var y1 = light.pos[1] + lightRadius * sin(light.pointAngle - light.beamAngle / 2)
        lctx.lineTo(x1, y1)
        lctx.arc(light.pos[0], light.pos[1], lightRadius,
          (light.pointAngle - light.beamAngle / 2) * DEG,
          (light.pointAngle + light.beamAngle / 2) * DEG
        )
        lctx.lineTo(light.pos[0], light.pos[1])
        callback(light)
        lctx.restore()
      }
    }
    drawLights(function drawLightFill(light) {
      var grad = lctx.createRadialGradient(
        light.pos[0], light.pos[1], 0,
        light.pos[0], light.pos[1], lightRadius
      )
      var color = tempToRGB(light.temp)
      grad.addColorStop(0, rgba(color[0], color[1], color[2], 1))
      grad.addColorStop(1, rgba(color[0], color[1], color[2], 0))
      lctx.fillStyle = grad
      lctx.globalCompositeOperation = 'lighten'
      lctx.fill()
    })
    drawLights(function drawLightStroke(light) {
      lctx.globalAlpha = 0.4
      lctx.strokeStyle = 'black'
      lctx.stroke()
    })
    ctx.save()
    ctx.scale(1 / density, 1 / density)
    ctx.globalCompositeOperation = 'source-atop'
    ctx.globalAlpha = 0.65
    ctx.drawImage(mixCanvas, 0, 0)
    ctx.globalAlpha = 0.25
    ctx.globalCompositeOperation = 'destination-over'
    ctx.drawImage(mixCanvas, 0, 0)
    ctx.restore()

    if (circledNode) {
      var c = circledNode.cache
      ctx.save()
      ctx.beginPath()
      var circledBox = calcBox(circledNode)
      var cx = circledBox.x1 + (circledBox.x2 - circledBox.x1) / 2
      var cy = circledBox.y1 + (circledBox.y2 - circledBox.y1) / 2
      var d = Math.sqrt(Math.pow(circledBox.x2 - circledBox.x1, 2) + Math.pow(circledBox.y2 - circledBox.y1, 2))
      ctx.arc(cx, cy, d / 2 + 6, 0, TWOPI)
      ctx.strokeStyle = 'red'
      ctx.lineWidth = 2
      ctx.globalAlpha = 0.9
      ctx.stroke()
      ctx.restore()
    }

    if (debug) {
      walkTree(l, function visitDrawDebug(node, descend) {
        ctx.save()
        var c2 = node.cache
        ctx.beginPath()
        ctx.globalAlpha = 0.75
        ctx.moveTo(c2.x1, c2.y1)
        ctx.lineTo(c2.x2, c2.y2)
        ctx.strokeStyle = node.proxyFor ? 'lightgreen' : 'magenta'
        ctx.lineWidth = 1
        ctx.stroke()
        descend()
        ctx.restore()
        if (c2.width) {
          var box = calcBox(node)
          ctx.beginPath()
          ctx.rect(box.x1, box.y1, (box.x2 - box.x1), (box.y2 - box.y1))
          ctx.strokeStyle = 'red'
          ctx.stroke()
        }
      })
    }

    ctx.restore()

    if (opts.onFinish) {
      opts.onFinish()
    }
  })
}

x.component('Handle', {
  componentDidMount: function componentDidMount() {
    this._handleDragMove = this.handleDragMove.bind(this)
    this._handleDragUp = this.handleDragUp.bind(this)

    // ontouchstart attribute doesn't seem to work :(
    this.getDOMNode().firstChild.addEventListener('touchstart', this.handleDragDown.bind(this))
  },

  _stopListening: function _stopListening() {
    window.removeEventListener('mousemove', this._handleDragMove, false)
    window.removeEventListener('mouseup', this._handleDragUp, false)
    window.removeEventListener('touchmove', this._handleDragMove, false)
    window.removeEventListener('touchend', this._handleDragUp, false)
  },

  componentWillUnmount: function componentWillUnmount() {
    this._stopListening()
  },

  _normalizeEvent: function _normalizeEvent(ev) {
    if (ev.touches && ev.touches[0]) {
      ev.clientX = ev.touches[0].clientX  // eslint-disable-line no-param-reassign
      ev.clientY = ev.touches[0].clientY  // eslint-disable-line no-param-reassign
    }
  },

  handleDragDown: function handleDragDown(ev) {
    if (this.props.disabled) {
      return
    }
    document.documentElement.style.cursor = 'move'
    window.addEventListener('mousemove', this._handleDragMove, false)
    window.addEventListener('mouseup', this._handleDragUp, false)
    window.addEventListener('touchmove', this._handleDragMove, false)
    window.addEventListener('touchend', this._handleDragUp, false)
    if (this.props.onGrab) {
      this._normalizeEvent(ev)
      this.props.onGrab(ev)
    }
    ev.preventDefault()
  },

  handleDragUp: function handleDragUp(ev) {
    document.documentElement.style.cursor = null
    this._stopListening()
    if (this.props.onRelease) {
      this._normalizeEvent(ev)
      this.props.onRelease(ev)
    }
    ev.preventDefault()
  },

  handleDragMove: function handleDragMove(ev) {
    if (this.props.onDrag) {
      this._normalizeEvent(ev)
      this.props.onDrag(ev)
    }
    ev.preventDefault()
  },

  render: function render() {
    // FIXME: diablo issue: undefined / null gets stringified
    return x`<div
      className=${classNames('handle', this.props.className)}
      style=${this.props.style || ''}
      onMouseDown=${this.handleDragDown.bind(this)}
    />`
  },
})

x.component('Light', {
  handleRotate: function handleRotate(ev) {
    // TODO: key?
    var box = this.getDOMNode().firstChild.getBoundingClientRect()
    var px = ev.clientX - box.left
    var py = ev.clientY - box.top
    var angle = mod(-Math.atan2(py, px) / DEG, 360)
    var distance = Math.sqrt(Math.pow(px, 2) + Math.pow(py, 2))
    this.props.onRotate(angle, clamp(0, 95 - (distance - 42) * 3, 95))
  },

  handleRotateColor: function handleRotateColor(ev) {
    var box = this.getDOMNode().firstChild.getBoundingClientRect()
    var px = ev.clientX - box.left
    var py = ev.clientY - box.top
    var angle = clamp(90, mod(-Math.atan2(py, px) / DEG - this.props.pointAngle, 360), 270) - 90
    this.props.onChangeColor(angle / 180)
  },

  render: function render() {
    var style = inlineStyle({
      left: this.props.x + 'px',
      top: this.props.y + 'px',
      transform: `rotate(${-this.props.pointAngle - 90}deg)`,
    })

    var handles = ''
    if (this.props.selected) {
      var d = 42 + clamp(0, (95 - this.props.beamAngle) / 3, 95)
      var rotHandlePosStyle = inlineStyle({
        position: 'absolute',
        top: d + 'px',
      })

      var colorAngle = 180 * this.props.temp
      var color = tempToRGB(this.props.temp)
      var colorHandlePosStyle = inlineStyle({
        position: 'absolute',
        left: 39 * cos(-colorAngle) + 'px',
        top: 39 * sin(-colorAngle) + 'px',
      })
      var colorHandleStyle = inlineStyle({
        backgroundColor: rgba(color[0], color[1], color[2], 1),
      })

      handles = x`
        <div>
          <div style=${rotHandlePosStyle}>
            <Handle
              className="rotate"
              disabled=${this.props.disabled}
              onDrag=${this.handleRotate.bind(this)}
              onRelease=${this.props.onSave}
            ></Handle>
          </div>
          <div style=${colorHandlePosStyle}>
            <Handle
              className="color"
              style=${colorHandleStyle}
              disabled=${this.props.disabled}
              onDrag=${this.handleRotateColor.bind(this)}
              onRelease=${this.props.onSave}
            ></Handle>
          </div>
        </div>
      `
    }

    return x`
      <div
        className=${classNames('light', this.props.selected && 'selected')}
        style=${style}
        data-light-id=${this.props.id}
      >
        <div>
          <Handle
            className="position"
            disabled=${this.props.disabled}
            onGrab=${this.props.onInteract}
            onDrag=${this.props.onMove}
            onRelease=${this.props.onSave}
          ></Handle>
        </div>
        ${handles}
      </div>
    `
  },
})

function Linden(opts) {
  this.el = opts.el
  this.opts = opts
  this.imgCache = new ImgCache()
  this._queued = false
  this._rendering = false
  this._afterNextRender = null
  this.resetTree()
  this.renderUI()
}
Linden.prototype = {
  STIFFNESS: 0.25,
  FRICTION: 0.1,
  MAX_LIGHTS: 3,
  FPS: 15,

  resetTree: function resetTree() {
    this.l = null
    this.lights = []
    this.state = { _selected: null }
  },

  renderUI: function renderUI() {
    var tools = ''
    if (!this.opts.readOnly && !this.opts.loading) {
      tools = x`
        <div className="tools">
          <button
            className="add-light"
            disabled=${this.lights.length === this.MAX_LIGHTS}
            title="Create new light"
            onClick=${this.handleCreateLight.bind(this)}
          >+</button>
          <button
            className="remove"
            disabled=${!this.state._selected}
            title="Remove selected (delete key)"
            onClick=${this.handleRemoveSelection.bind(this)}
          >x</button>
        </div>
      `
    }

    var style = inlineStyle({
      width: this.opts.width + 'px',
      height: this.opts.height + 'px',
    })

    var canvasMultiplier = window.devicePixelRatio > 1 ? 2 : 1
    var canvasStyle = inlineStyle({
      transformOrigin: '0 0',
      transform: `scale(${1 / canvasMultiplier}, ${1 / canvasMultiplier})`,
    })

    var className = classNames('frame', this.opts.loading && 'loading')
    x.render(x`
      <div
        onMouseDown=${this.handleMouseDown.bind(this)}
        onKeyDown=${this.handleKeyDown.bind(this)}
        style=${style}
        className=${className}
        title=${this.opts.title}
        tabIndex="1"
      >
        ${tools}
        ${this.opts.loading ? x`<div><div className="spinner"></div><div className="spinner-text">loading</div>` : ''}
        <div className="content">
          <canvas
            data-density=${canvasMultiplier}
            width=${this.opts.width * canvasMultiplier}
            height=${this.opts.height * canvasMultiplier}
            style=${canvasStyle}
            onMouseDown=${this.handleCanvasMouseDown.bind(this)}
          ></canvas>
          ${this.lights.map(function renderLight(light) {
            var selected = Boolean(
              this.state._selected &&
              this.state._selected.kind === 'light' &&
              this.state._selected.id === light.id
            )
            return x`<Light
              id=${light.id}
              disabled=${this.opts.readOnly}
              selected=${selected}
              x=${light.pos[0]}
              y=${this.opts.height - light.pos[1]}
              temp=${light.temp}
              pointAngle=${light.pointAngle}
              beamAngle=${light.beamAngle}
              onInteract=${this.handleLightInteract.bind(this, light.id)}
              onMove=${this.handleLightMove.bind(this, light.id)}
              onRotate=${this.handleLightRotate.bind(this, light.id)}
              onChangeColor=${this.handleLightChangeColor.bind(this, light.id)}
              onSave=${this.saveLight.bind(this, light.id)}
            />`
          }.bind(this))}
        </div>
      </div>
    `, this.el)
  },

  update: function update(data) {
    this.l = data.tree

    var nodeCount = 0
    var nodeTypeCount = {}
    walkTree(this.l, function visitInitState(node, descend) {
      nodeCount++
      var nodeType = node.img || 'proxy'
      nodeTypeCount[nodeType] = nodeTypeCount[nodeType] || {count: 0}
      nodeTypeCount[nodeType].count++

      var nodeState = this.state[node.id]
      if (!nodeState) {
        nodeState = this.state[node.id] = {
          angle: node.angle,
          av: 0,
        }
      }
      nodeState.node = node
      descend()
    }.bind(this))

    this.lights = data.lights || []

    var startTime = Date.now()

    this._afterNextRender = function afterRender() {
      this.opts.loading = false
      this.renderUI()
      this._afterNextRender = null

      if (this.opts.debug) {
        var duration = Date.now() - startTime
        console.debug('updated. rendered', nodeCount, 'nodes in', duration, 'ms', '| supply:', data.supply)
        console.table(nodeTypeCount)
      }
    }.bind(this)

    this.renderCanvas()
  },

  addWind: function addWind(angle, magnitude, spread) {
    walkTree(this.l, function visitWind(node, descend) {
      if (!node.rigid && node.cache) {
        var nodeState = this.state[node.id]
        var alignment = sin(angle - node.cache.angle)
        var spreadMag = (1 - spread) + Math.random() * spread
        nodeState.av += node.waviness * spreadMag * (1 / Math.sqrt(node.cache.scale)) * alignment * magnitude
      }
      descend()
    }.bind(this))

    this.queueStep()
  },

  queueStep: function queueStep() {
    if (!this._queued) {
      // note that actual FPS is below this.FPS since we don't adapt sleep time
      setTimeout(this.step.bind(this), 1000 / this.FPS)
      this._queued = Date.now()
    }
  },

  step: function step() {
    if (this.opts.beforeStep) {
      this.opts.beforeStep()
    }

    walkTree(this.l, function visitStep(node, descend) {
      var nodeState = this.state[node.id]
      nodeState.av += (1 - node.waviness) * node.scale * this.STIFFNESS * (node.angle - nodeState.angle)
      nodeState.av += -nodeState.av * this.FRICTION
      nodeState.angle += nodeState.av
      descend()
    }.bind(this))

    this.renderCanvas()

    this._queued = false
    this.queueStep()
  },

  renderCanvas: function renderCanvas() {
    if (this._rendering || !this.l) {
      return
    }
    this._rendering = true
    renderLinden(this.l, this.state, this.lights, {
      width: this.opts.width,
      height: this.opts.height,
      el: this.el.querySelector('canvas'),
      urlPrefix: this.opts.urlPrefix,
      imgCache: this.imgCache,
      debug: this.opts.debug,
      onFinish: function onRenderFinish() {
        if (this._afterNextRender) {
          this._afterNextRender()
        }
        this._rendering = false
      }.bind(this),
    })
  },

  handleMouseDown: function handleMouseDown() {
    this.el.querySelector('.frame').focus()
  },

  _deselect: function _deselect() {
    this.state._selected = null
    this.renderCanvas()
    this.renderUI()
  },

  handleCanvasMouseDown: function handleCanvasMouseDown(ev) {
    if (this.opts.readOnly) {
      return
    }
    this._deselect()

    var box = this.el.getBoundingClientRect()
    var x0 = ev.clientX - box.left
    var y0 = this.opts.height - (ev.clientY - box.top)
    var hit = null
    var hitArea = Number.MAX_VALUE
    walkTree(this.l, function visitCheckProximity(node, descend) {
      var c = node.cache
      if (c && c.width && !node.immutable) {
        var box = calcBox(node)
        if (box.x1 <= x0 && box.x2 >= x0 &&
            box.y1 <= y0 && box.y2 >= y0) {
          var area = (box.x2 - box.x1) * (box.y2 - box.y1)
          if (area < hitArea) {
            hit = node
            hitArea = area
          }
        }
      }
      descend()
    })

    if (hit) {
      var selectedId = hit.proxyFor || hit.id
      this.state._selected = { kind: 'node', id: selectedId, circled: hit.id }
    }
    this.renderCanvas()
    this.renderUI()
  },

  handleKeyDown: function handleKeyDown(ev) {
    if (ev.which === 46 || ev.which === 8) { // backspace or delete
      this.handleRemoveSelection()
      ev.preventDefault()
    }
  },

  handleLightInteract: function handleLightInteract(id) {
    if (this.opts.readOnly) {
      return
    }
    this.state._selected = { kind: 'light', id: id }
    this.renderCanvas()
    this.renderUI()
  },

  handleLightMove: function handleLightMove(id, ev) {
    var box = this.el.getBoundingClientRect()
    var px = clamp(0, ev.clientX - box.left, this.opts.width)
    var py = clamp(0, this.opts.height - (ev.clientY - box.top), this.opts.height)
    this.setLight(id, {
      pos: [px, py],
    })
  },

  handleLightRotate: function handleLightRotate(id, pointAngle, beamAngle) {
    this.setLight(id, {
      pointAngle: pointAngle,
      beamAngle: beamAngle,
    })
  },

  handleLightChangeColor: function handleLightChangeColor(id, temp) {
    this.setLight(id, {
      temp: temp,
    })
  },

  setLight: function setLight(id, data) {
    this.opts.onLightChanging(id)
    var idx = this.lights.findIndex(function matchLight(light) { return light.id === id })
    if (idx === -1) {
      return
    }
    Object.assign(this.lights[idx], data)
    this.renderCanvas()
    this.renderUI()
  },

  saveLight: function saveLight(id) {
    var idx = this.lights.findIndex(function matchLight(light) { return light.id === id })
    if (idx === -1) {
      return
    }
    this.opts.onSetLight(id, this.lights[idx])
  },

  handleCreateLight: function handleCreateLight() {
    var lx = this.opts.width * Math.random()
    var ly = (this.opts.height + this.opts.height * Math.random()) / 2
    var tx = this.opts.width / 2
    var ty = 0
    this.opts.onSetLight(null, {
      pos: [lx, ly],
      pointAngle: Math.atan2(ty - ly, tx - lx) / DEG,
      beamAngle: 45 + 45 * Math.random(),
      temp: 0.5,
    })
  },

  handleRemoveSelection: function handleRemoveSelection() {
    if (!this.state._selected) {
      return
    }

    if (this.state._selected.kind === 'node') {
      this.opts.onPruneNode(this.state._selected.id)
    } else if (this.state._selected.kind === 'light') {
      this.opts.onRemoveLight(this.state._selected.id)
    }

    this._deselect()
  },
}

module.exports = Linden