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