solaris-js
Version:
A reusable component for interactive visualization of the Solar System
524 lines (388 loc) • 16.2 kB
text/coffeescript
###
# Adapted from https://github.com/mrdoob/three.js/blob/master/examples/js/controls/OrbitControls.js
#
# This set of controls performs orbiting, dollying (zooming), and panning.
# Unlike TrackballControls, it maintains the "up" direction object.up (+Y by default).
#
# Orbit - left mouse / touch: one finger move
# Zoom - middle mouse, or mousewheel / touch: two finger spread or squish
# Pan - right mouse, or arrow keys / touch: three finger swipe
###
import * as THREE from 'three'
Controls = (object, domElement, options) ->
##################
# public variables
##################
@object = object
@domElement = domElement ? document
@enabled = true # Set to false to disable this control
# "target" sets the location of focus, where the object orbits around
@target = new THREE.Vector3
# How far you can dolly in and out (PerspectiveCamera only)
@minDistance = options?.minDistance ? 0
@maxDistance = options?.maxDistance ? Infinity
# How far you can zoom in and out (OrthographicCamera only)
@minZoom = 0
@maxZoom = Infinity
# How far you can orbit vertically, upper and lower limits.
# Range is 0 to Math.PI radians.
@minPolarAngle = 0 # radians
@maxPolarAngle = Math.PI # radians
# How far you can orbit horizontally, upper and lower limits.
# If set, must be a sub-interval of the interval [ - Math.PI, Math.PI ].
@minAzimuthAngle = -Infinity # radians
@maxAzimuthAngle = Infinity # radians
# Set to true to enable damping (inertia)
# If damping is enabled, you must call controls.update() in your animation loop
@enableDamping = false
@dampingFactor = 0.25
# This option actually enables dollying in and out; left as "zoom" for backwards compatibility.
@enableZoom = true # Set to false to disable zooming
@zoomSpeed = 1.0
@enableRotate = true # Set to false to disable rotating
@rotateSpeed = 1.0
@enablePan = true # Set to false to disable panning
@keyPanSpeed = 7.0 # pixels moved per arrow key push
@enableKeys = true # Set to false to disable use of the keys
@keys = {LEFT: 37, UP: 38, RIGHT: 39, BOTTOM: 40}
@mouseButtons = {ORBIT: THREE.MOUSE.LEFT, ZOOM: THREE.MOUSE.MIDDLE, PAN: THREE.MOUSE.RIGHT}
# for reset
@target0 = @target.clone()
@position0 = @object.position.clone()
@zoom0 = @object.zoom
###################
# private variables
###################
changeEvent = {type: 'change'}
startEvent = {type: 'start'}
endEvent = {type: 'end'}
STATE =
NONE: -1, ROTATE: 0, DOLLY: 1, PAN: 2
TOUCH_ROTATE: 3, TOUCH_DOLLY: 4, TOUCH_PAN: 5
state = STATE.NONE
EPS = 0.000001
# current position in spherical coordinates
spherical = new THREE.Spherical
sphericalDelta = new THREE.Spherical
scale = 1
panOffset = new THREE.Vector3
zoomChanged = false
rotateStart = new THREE.Vector2
rotateEnd = new THREE.Vector2
rotateDelta = new THREE.Vector2
panStart = new THREE.Vector2
panEnd = new THREE.Vector2
panDelta = new THREE.Vector2
dollyStart = new THREE.Vector2
dollyEnd = new THREE.Vector2
dollyDelta = new THREE.Vector2
updateState =
offset: new THREE.Vector3
lastPosition: new THREE.Vector3
lastQuaternion: new THREE.Quaternion
quat: new THREE.Quaternion().setFromUnitVectors object.up, new THREE.Vector3(0, 1, 0)
updateState.quatInverse = updateState.quat.clone().inverse()
panState =
left: new THREE.Vector3
up: new THREE.Vector3
offset: new THREE.Vector3
####################
# public methods
####################
@getPolarAngle = -> spherical.phi
@getAzimuthalAngle = -> spherical.theta
@saveState = =>
@target0.copy @target
@position0.copy @object.position
@zoom0 = @object.zoom
@reset = =>
@target.copy @target0
@object.position.copy @position0
@object.zoom = @zoom0
@object.updateProjectionMatrix()
@dispatchEvent changeEvent
@update()
state = STATE.NONE
# this method is exposed, but perhaps it would be better if we can make it private...
@update = =>
{offset, quat, quatInverse, lastPosition, lastQuaternion} = updateState
position = @object.position
offset.copy(position).sub @target
# rotate offset to "y-axis-is-up" space
offset.applyQuaternion quat
# angle from z-axis around y-axis
spherical.setFromVector3 offset
spherical.theta += sphericalDelta.theta
spherical.phi += sphericalDelta.phi
# restrict theta to be between desired limits
spherical.theta = Math.max(@minAzimuthAngle, Math.min(@maxAzimuthAngle, spherical.theta))
# restrict phi to be between desired limits
spherical.phi = Math.max(@minPolarAngle, Math.min(@maxPolarAngle, spherical.phi))
spherical.makeSafe()
spherical.radius *= scale
# restrict radius to be between desired limits
spherical.radius = Math.max(@minDistance, Math.min(@maxDistance, spherical.radius))
# move target to panned location
@target.add panOffset
offset.setFromSpherical spherical
# rotate offset back to "camera-up-vector-is-up" space
offset.applyQuaternion quatInverse
position.copy(@target).add offset
@object.lookAt @target
if @enableDamping
sphericalDelta.theta *= 1 - @dampingFactor
sphericalDelta.phi *= 1 - @dampingFactor
else
sphericalDelta.set 0, 0, 0
scale = 1
panOffset.set 0, 0, 0
# update condition is:
# min(camera displacement, camera rotation in radians)^2 > EPS
# using small-angle approximation cos(x/2) = 1 - x^2 / 8
if zoomChanged or
lastPosition.distanceToSquared(@object.position) > EPS or
8 * (1 - lastQuaternion.dot(@object.quaternion)) > EPS
@dispatchEvent changeEvent
lastPosition.copy @object.position
lastQuaternion.copy @object.quaternion
zoomChanged = false
return true
false
@dispose = =>
@domElement.removeEventListener 'contextmenu', onContextMenu, false
@domElement.removeEventListener 'mousedown', onMouseDown, false
@domElement.removeEventListener 'wheel', onMouseWheel, false
@domElement.removeEventListener 'touchstart', onTouchStart, false
@domElement.removeEventListener 'touchend', onTouchEnd, false
@domElement.removeEventListener 'touchmove', onTouchMove, false
document.removeEventListener 'mousemove', onMouseMove, false
document.removeEventListener 'mouseup', onMouseUp, false
window.removeEventListener 'keydown', onKeyDown, false
#################
# private methods
#################
getZoomScale = => 0.95 ** @zoomSpeed
rotateLeft = (angle) -> sphericalDelta.theta -= angle
rotateUp = (angle) -> sphericalDelta.phi -= angle
panLeft = (distance, objectMatrix) ->
v = panState.left
v.setFromMatrixColumn objectMatrix, 0 # get X column of objectMatrix
v.multiplyScalar -distance
panOffset.add v
panUp = (distance, objectMatrix) ->
v = panState.up
v.setFromMatrixColumn objectMatrix, 1 # get Y column of objectMatrix
v.multiplyScalar distance
panOffset.add v
# deltaX and deltaY are in pixels; right and down are positive
pan = (deltaX, deltaY) =>
offset = panState.offset
element = if @domElement is document then @domElement.body else @domElement
if @object instanceof THREE.PerspectiveCamera
position = @object.position
offset.copy(position).sub @target
targetDistance = offset.length()
# half of the fov is center to top of screen
targetDistance *= Math.tan(@object.fov / 2 * Math.PI / 180.0)
# we actually don't use screenWidth, since perspective camera is fixed to screen height
panLeft(2 * deltaX * targetDistance / element.clientHeight, @object.matrix)
panUp(2 * deltaY * targetDistance / element.clientHeight, @object.matrix)
else if @object instanceof THREE.OrthographicCamera
panLeft(deltaX * (@object.right - @object.left) / @object.zoom / element.clientWidth, @object.matrix)
panUp(deltaY * (@object.top - @object.bottom) / @object.zoom / element.clientHeight, @object.matrix)
dollyIn = (dollyScale) =>
if @object instanceof THREE.PerspectiveCamera then scale /= dollyScale
else if @object instanceof THREE.OrthographicCamera
@object.zoom = Math.max(@minZoom, Math.min(@maxZoom, @object.zoom * dollyScale))
@object.updateProjectionMatrix()
zoomChanged = true
dollyOut = (dollyScale) =>
if @object instanceof THREE.PerspectiveCamera then scale *= dollyScale
else if @object instanceof THREE.OrthographicCamera
@object.zoom = Math.max(@minZoom, Math.min(@maxZoom, @object.zoom / dollyScale))
@object.updateProjectionMatrix()
zoomChanged = true
# event callbacks - update the object state
handleMouseDownRotate = (event) -> rotateStart.set event.clientX, event.clientY
handleMouseDownDolly = (event) -> dollyStart.set event.clientX, event.clientY
handleMouseDownPan = (event) -> panStart.set event.clientX, event.clientY
handleMouseMoveRotate = (event) =>
rotateEnd.set event.clientX, event.clientY
rotateDelta.subVectors rotateEnd, rotateStart
element = if @domElement is document then @domElement.body else @domElement
# rotating across whole screen goes 360 degrees around
rotateLeft 2 * Math.PI * rotateDelta.x / element.clientWidth * @rotateSpeed
# rotating up and down along whole screen attempts to go 360, but limited to 180
rotateUp 2 * Math.PI * rotateDelta.y / element.clientHeight * @rotateSpeed
rotateStart.copy rotateEnd
@update()
handleMouseMoveDolly = (event) =>
dollyEnd.set event.clientX, event.clientY
dollyDelta.subVectors dollyEnd, dollyStart
if dollyDelta.y > 0 then dollyIn getZoomScale()
else if dollyDelta.y < 0 then dollyOut getZoomScale()
dollyStart.copy(dollyEnd)
@update()
handleMouseMovePan = (event) =>
panEnd.set event.clientX, event.clientY
panDelta.subVectors panEnd, panStart
pan panDelta.x, panDelta.y
panStart.copy panEnd
@update()
handleMouseWheel = (event) =>
if event.deltaY < 0 then dollyOut getZoomScale()
else if event.deltaY > 0 then dollyIn getZoomScale()
@update()
handleKeyDown = (event) =>
{UP, BOTTOM, LEFT, RIGHT} = @keys
switch event.keyCode
when UP then pan 0, @keyPanSpeed
when BOTTOM then pan 0, -@keyPanSpeed
when LEFT then pan @keyPanSpeed, 0
when RIGHT then pan -@keyPanSpeed, 0
@update() if event.keyCode in [UP, BOTTOM, LEFT, RIGHT]
handleTouchStartRotate = (event) ->
rotateStart.set event.touches[0].pageX, event.touches[0].pageY
handleTouchStartDolly = (event) ->
dx = event.touches[0].pageX - (event.touches[1].pageX)
dy = event.touches[0].pageY - (event.touches[1].pageY)
distance = Math.sqrt(dx * dx + dy * dy)
dollyStart.set 0, distance
handleTouchStartPan = (event) ->
panStart.set event.touches[0].pageX, event.touches[0].pageY
handleTouchMoveRotate = (event) =>
rotateEnd.set event.touches[0].pageX, event.touches[0].pageY
rotateDelta.subVectors rotateEnd, rotateStart
element = if @domElement is document then @domElement.body else @domElement
# rotating across whole screen goes 360 degrees around
rotateLeft 2 * Math.PI * rotateDelta.x / element.clientWidth * @rotateSpeed
# rotating up and down along whole screen attempts to go 360, but limited to 180
rotateUp 2 * Math.PI * rotateDelta.y / element.clientHeight * @rotateSpeed
rotateStart.copy rotateEnd
@update()
handleTouchMoveDolly = (event) =>
dx = event.touches[0].pageX - (event.touches[1].pageX)
dy = event.touches[0].pageY - (event.touches[1].pageY)
distance = Math.sqrt(dx * dx + dy * dy)
dollyEnd.set 0, distance
dollyDelta.subVectors dollyEnd, dollyStart
if dollyDelta.y > 0 then dollyOut getZoomScale()
else if dollyDelta.y < 0 then dollyIn getZoomScale()
dollyStart.copy dollyEnd
@update()
handleTouchMovePan = (event) =>
panEnd.set event.touches[0].pageX, event.touches[0].pageY
panDelta.subVectors panEnd, panStart
pan panDelta.x, panDelta.y
panStart.copy panEnd
@update()
# event handlers - FSM: listen for events and reset state
onMouseDown = (event) =>
return unless @enabled
event.preventDefault()
switch event.button
when @mouseButtons.ORBIT
return unless @enableRotate
handleMouseDownRotate event
state = STATE.ROTATE
when @mouseButtons.ZOOM
return unless @enableZoom
handleMouseDownDolly event
state = STATE.DOLLY
when @mouseButtons.PAN
return unless @enablePan
handleMouseDownPan event
state = STATE.PAN
if state isnt STATE.NONE
document.addEventListener 'mousemove', onMouseMove, false
document.addEventListener 'mouseup', onMouseUp, false
@dispatchEvent startEvent
onMouseMove = (event) =>
return unless @enabled
event.preventDefault()
switch state
when STATE.ROTATE
return unless @enableRotate
handleMouseMoveRotate event
when STATE.DOLLY
return unless @enableZoom
handleMouseMoveDolly event
when STATE.PAN
return unless @enablePan
handleMouseMovePan event
onMouseUp = (event) =>
return unless @enabled
document.removeEventListener 'mousemove', onMouseMove, false
document.removeEventListener 'mouseup', onMouseUp, false
@dispatchEvent endEvent
state = STATE.NONE
onMouseWheel = (event) =>
return unless @enabled and @enableZoom and state in [STATE.NONE, STATE.ROTATE]
event.preventDefault()
event.stopPropagation()
handleMouseWheel event
@dispatchEvent startEvent
# not sure why these are here...
@dispatchEvent endEvent
onKeyDown = (event) =>
return unless @enabled and @enableKeys and @enablePan
handleKeyDown event
onTouchStart = (event) =>
return unless @enabled
switch event.touches.length
when 1 # one-fingered touch: rotate
return unless @enableRotate
handleTouchStartRotate event
state = STATE.TOUCH_ROTATE
when 2 # two-fingered touch: dolly
return unless @enableZoom
handleTouchStartDolly event
state = STATE.TOUCH_DOLLY
when 3 # three-fingered touch: pan
return unless @enablePan
handleTouchStartPan event
state = STATE.TOUCH_PAN
else
state = STATE.NONE
if state isnt STATE.NONE
@dispatchEvent startEvent
onTouchMove = (event) =>
return unless @enabled
event.preventDefault()
event.stopPropagation()
switch event.touches.length
when 1 # one-fingered touch: rotate
return unless @enableRotate
return unless state is STATE.TOUCH_ROTATE
handleTouchMoveRotate event
when 2 # two-fingered touch: dolly
return unless @enableZoom
return unless state is STATE.TOUCH_DOLLY
handleTouchMoveDolly event
when 3 # three-fingered touch: pan
return unless @enablePan
return unless state is STATE.TOUCH_PAN
handleTouchMovePan event
else
state = STATE.NONE
onTouchEnd = (event) =>
return unless @enabled
@dispatchEvent endEvent
state = STATE.NONE
onContextMenu = (event) =>
return unless @enabled
event.preventDefault()
init = =>
@domElement.addEventListener 'contextmenu', onContextMenu, false
@domElement.addEventListener 'mousedown', onMouseDown, false
@domElement.addEventListener 'wheel', onMouseWheel, false
@domElement.addEventListener 'touchstart', onTouchStart, false
@domElement.addEventListener 'touchend', onTouchEnd, false
@domElement.addEventListener 'touchmove', onTouchMove, false
window.addEventListener 'keydown', onKeyDown, false
@update() # force an update at start
init()
this
Controls.prototype = Object.create(THREE.EventDispatcher.prototype)
Controls::constructor = Controls
export default Controls