UNPKG

itowns

Version:

A JS/WebGL framework for 3D geospatial data visualization

1,178 lines (1,104 loc) 48.9 kB
import * as THREE from 'three'; import AnimationPlayer from "../Core/AnimationPlayer.js"; import { Coordinates, ellipsoidSizes } from '@itowns/geographic'; import CameraUtils from "../Utils/CameraUtils.js"; import StateControl from "./StateControl.js"; import { VIEW_EVENTS } from "../Core/View.js"; // private members const EPS = 0.000001; const direction = { up: new THREE.Vector2(0, 1), bottom: new THREE.Vector2(0, -1), left: new THREE.Vector2(1, 0), right: new THREE.Vector2(-1, 0) }; // Orbit const rotateStart = new THREE.Vector2(); const rotateEnd = new THREE.Vector2(); const rotateDelta = new THREE.Vector2(); const spherical = new THREE.Spherical(1.0, 0.01, 0); const sphericalDelta = new THREE.Spherical(1.0, 0, 0); let orbitScale = 1.0; // Pan const panStart = new THREE.Vector2(); const panEnd = new THREE.Vector2(); const panDelta = new THREE.Vector2(); const panOffset = new THREE.Vector3(); // Dolly const dollyStart = new THREE.Vector2(); const dollyEnd = new THREE.Vector2(); const dollyDelta = new THREE.Vector2(); let dollyScale; // Globe move const moveAroundGlobe = new THREE.Quaternion(); const cameraTarget = new THREE.Object3D(); const coordCameraTarget = new Coordinates('EPSG:4978'); cameraTarget.matrixWorldInverse = new THREE.Matrix4(); const xyz = new Coordinates('EPSG:4978', 0, 0, 0); const c = new Coordinates('EPSG:4326', 0, 0, 0); // Position object on globe function positionObject(newPosition, object) { xyz.setFromVector3(newPosition).as('EPSG:4326', c); object.position.copy(newPosition); object.lookAt(c.geodesicNormal.add(newPosition)); object.rotateX(Math.PI * 0.5); object.updateMatrixWorld(true); } // Save the last time of mouse move for damping let lastTimeMouseMove = 0; // Animations and damping let enableAnimation = true; const dampingFactorDefault = 0.25; const dampingMove = new THREE.Quaternion(0, 0, 0, 1); const durationDampingMove = 120; const durationDampingOrbital = 60; // Pan Move const panVector = new THREE.Vector3(); // Save last transformation const lastPosition = new THREE.Vector3(); const lastQuaternion = new THREE.Quaternion(); // Tangent sphere to ellipsoid const pickSphere = new THREE.Sphere(); const pickingPoint = new THREE.Vector3(); // Sphere intersection const intersection = new THREE.Vector3(); // Set to true to enable target helper const enableTargetHelper = false; const helpers = {}; /** * Globe control pan event. Fires after camera pan * @event GlobeControls#pan-changed * @property target {GlobeControls} dispatched on controls * @property type {string} orientation-changed */ /** * Globe control orientation event. Fires when camera's orientation change * @event GlobeControls#orientation-changed * @property new {object} * @property new.tilt {number} the new value of the tilt of the camera * @property new.heading {number} the new value of the heading of the camera * @property previous {object} * @property previous.tilt {number} the previous value of the tilt of the camera * @property previous.heading {number} the previous value of the heading of the camera * @property target {GlobeControls} dispatched on controls * @property type {string} orientation-changed */ /** * Globe control range event. Fires when camera's range to target change * @event GlobeControls#range-changed * @property new {number} the new value of the range * @property previous {number} the previous value of the range * @property target {GlobeControls} dispatched on controls * @property type {string} range-changed */ /** * Globe control camera's target event. Fires when camera's target change * @event GlobeControls#camera-target-changed * @property new {object} * @property new {Coordinates} the new camera's target coordinates * @property previous {Coordinates} the previous camera's target coordinates * @property target {GlobeControls} dispatched on controls * @property type {string} camera-target-changed */ /** * globe controls events * @property PAN_CHANGED {string} Fires after camera pan * @property ORIENTATION_CHANGED {string} Fires when camera's orientation change * @property RANGE_CHANGED {string} Fires when camera's range to target change * @property CAMERA_TARGET_CHANGED {string} Fires when camera's target change */ export const CONTROL_EVENTS = { PAN_CHANGED: 'pan-changed', ORIENTATION_CHANGED: 'orientation-changed', RANGE_CHANGED: 'range-changed', CAMERA_TARGET_CHANGED: 'camera-target-changed' }; const quaterPano = new THREE.Quaternion(); const quaterAxis = new THREE.Quaternion(); const axisX = new THREE.Vector3(1, 0, 0); let minDistanceZ = Infinity; const lastNormalizedIntersection = new THREE.Vector3(); const normalizedIntersection = new THREE.Vector3(); const raycaster = new THREE.Raycaster(); const targetPosition = new THREE.Vector3(); const pickedPosition = new THREE.Vector3(); const sphereCamera = new THREE.Sphere(); let previous; /** * GlobeControls is a camera controller * * @class GlobeControls * @param {GlobeView} view the view where the control will be used * @param {CameraTransformOptions|Extent} placement the {@link CameraTransformOptions} to apply to view's camera * or the extent it must display at initialisation, see {@link CameraTransformOptions} in {@link CameraUtils}. * @param {object} [options] An object with one or more configuration properties. Any property of GlobeControls * can be passed in this object. * @property {number} zoomFactor The factor the scale is multiplied by when dollying (zooming) in or * divided by when dollying out. Default is 1.1. * @property {number} rotateSpeed Speed camera rotation in orbit and panoramic mode. Default is 0.25. * @property {number} minDistance Minimum distance between ground and camera in meters (Perspective Camera only). * Default is 250. * @property {number} maxDistance Maximum distance between ground and camera in meters * (Perspective Camera only). Default is ellipsoid radius * 8. * @property {number} minZoom How far you can zoom in, in meters (Orthographic Camera only). Default is 0. * @property {number} maxZoom How far you can zoom out, in meters (Orthographic Camera only). Default * is Infinity. * @property {number} keyPanSpeed Number of pixels moved per push on array key. Default is 7. * @property {number} minPolarAngle Minimum vertical orbit angle (in degrees). Default is 0.5. * @property {number} maxPolarAngle Maximum vertical orbit angle (in degrees). Default is 86. * @property {number} minAzimuthAngle Minimum horizontal orbit angle (in degrees). If modified, * should be in [-180,0]. Default is -Infinity. * @property {number} maxAzimuthAngle Maximum horizontal orbit angle (in degrees). If modified, * should be in [0,180]. Default is Infinity. * @property {boolean} handleCollision Handle collision between camera and ground or not, i.e. whether * you can zoom underground or not. Default is true. * @property {boolean} enableDamping Enable damping or not (simulates the lag that a real camera * operator introduces while operating a heavy physical camera). Default is true. * @property {boolean} dampingMoveFactor the damping move factor. Default is 0.25. * @property {StateControl~State} stateControl redefining which controls state is triggered by the keyboard/mouse * event (For example, rewrite the PAN movement to be triggered with the 'left' mouseButton instead of 'right'). */ class GlobeControls extends THREE.EventDispatcher { constructor(view, placement) { let options = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; super(); this.player = new AnimationPlayer(); this.view = view; this.camera = view.camera3D; // State control this.states = new StateControl(this.view, options.stateControl); // this.enabled property has moved to StateControl Object.defineProperty(this, 'enabled', { get: () => this.states.enabled, set: value => { console.warn('GlobeControls.enabled property is deprecated. Use StateControl.enabled instead ' + '- which you can access with GlobeControls.states.enabled.'); this.states.enabled = value; } }); // These options actually enables dollying in and out; left as "zoom" for // backwards compatibility if (options.zoomSpeed) { console.warn('Controls zoomSpeed parameter is deprecated. Use zoomFactor instead.'); options.zoomFactor = options.zoomFactor || options.zoomSpeed; } this.zoomFactor = options.zoomFactor || 1.1; // Limits to how far you can dolly in and out ( PerspectiveCamera only ) this.minDistance = options.minDistance || 250; this.maxDistance = options.maxDistance || ellipsoidSizes.x * 8.0; // Limits to how far you can zoom in and out ( OrthographicCamera only ) this.minZoom = options.minZoom || 0; this.maxZoom = options.maxZoom || Infinity; // Set to true to disable this control this.rotateSpeed = options.rotateSpeed || 0.25; // Set to true to disable this control this.keyPanSpeed = options.keyPanSpeed || 7.0; // pixels moved per arrow key push // How far you can orbit vertically, upper and lower limits. // Range is 0 to Math.PI radians. // TODO Warning minPolarAngle = 0.01 -> it isn't possible to be perpendicular on Globe this.minPolarAngle = THREE.MathUtils.degToRad(options.minPolarAngle ?? 0.5); this.maxPolarAngle = THREE.MathUtils.degToRad(options.minPolarAngle ?? 86); // How far you can orbit horizontally, upper and lower limits. // If set, must be a sub-interval of the interval [ - Math.PI, Math.PI ]. this.minAzimuthAngle = options.minAzimuthAngle ? THREE.MathUtils.degToRad(options.minAzimuthAngle) : -Infinity; // radians this.maxAzimuthAngle = options.maxAzimuthAngle ? THREE.MathUtils.degToRad(options.maxAzimuthAngle) : Infinity; // radians // Set collision options this.handleCollision = typeof options.handleCollision !== 'undefined' ? options.handleCollision : true; this.minDistanceCollision = 60; // this.enableKeys property has moved to StateControl Object.defineProperty(this, 'enableKeys', { get: () => this.states.enableKeys, set: value => { console.warn('GlobeControls.enableKeys property is deprecated. Use StateControl.enableKeys instead ' + '- which you can access with GlobeControls.states.enableKeys.'); this.states.enableKeys = value; } }); // Enable Damping this.enableDamping = options.enableDamping !== false; this.dampingMoveFactor = options.dampingMoveFactor != undefined ? options.dampingMoveFactor : dampingFactorDefault; this.startEvent = { type: 'start' }; this.endEvent = { type: 'end' }; // Update helper this.updateHelper = enableTargetHelper ? (position, helper) => { positionObject(position, helper); view.notifyChange(this.camera); } : function () {}; this._onEndingMove = null; this._onTravel = this.travel.bind(this); this._onTouchStart = this.onTouchStart.bind(this); this._onTouchEnd = this.onTouchEnd.bind(this); this._onTouchMove = this.onTouchMove.bind(this); this._onStateChange = this.onStateChange.bind(this); this._onRotation = this.handleRotation.bind(this); this._onDrag = this.handleDrag.bind(this); this._onDolly = this.handleDolly.bind(this); this._onPan = this.handlePan.bind(this); this._onPanoramic = this.handlePanoramic.bind(this); this._onZoom = this.handleZoom.bind(this); this.states.addEventListener('state-changed', this._onStateChange, false); this.states.addEventListener(this.states.ORBIT._event, this._onRotation, false); this.states.addEventListener(this.states.MOVE_GLOBE._event, this._onDrag, false); this.states.addEventListener(this.states.DOLLY._event, this._onDolly, false); this.states.addEventListener(this.states.PAN._event, this._onPan, false); this.states.addEventListener(this.states.PANORAMIC._event, this._onPanoramic, false); this.states.addEventListener('zoom', this._onZoom, false); this.view.domElement.addEventListener('touchstart', this._onTouchStart, false); this.view.domElement.addEventListener('touchend', this._onTouchEnd, false); this.view.domElement.addEventListener('touchmove', this._onTouchMove, false); this.states.addEventListener(this.states.TRAVEL_IN._event, this._onTravel, false); this.states.addEventListener(this.states.TRAVEL_OUT._event, this._onTravel, false); view.scene.add(cameraTarget); if (enableTargetHelper) { cameraTarget.add(helpers.target); view.scene.add(helpers.picking); } if (placement.isExtent) { placement.center().as('EPSG:4978', xyz); } else { placement.coord.as('EPSG:4978', xyz); placement.tilt = placement.tilt || 89.5; placement.heading = placement.heading || 0; } positionObject(xyz, cameraTarget); this.lookAtCoordinate(placement, false); coordCameraTarget.crs = this.view.referenceCrs; } get zoomInScale() { return this.zoomFactor; } get zoomOutScale() { return 1 / this.zoomFactor; } get isPaused() { // TODO : also check if CameraUtils is performing an animation return this.states.currentState === this.states.NONE && !this.player.isPlaying(); } onEndingMove(current) { if (this._onEndingMove) { this.player.removeEventListener('animation-stopped', this._onEndingMove); this._onEndingMove = null; } this.handlingEvent(current); } rotateLeft() { let angle = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0; sphericalDelta.theta -= angle; } rotateUp() { let angle = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0; sphericalDelta.phi -= angle; } // pass in distance in world space to move left panLeft(distance) { const te = this.camera.matrix.elements; // get X column of matrix panOffset.fromArray(te); panOffset.multiplyScalar(-distance); panVector.add(panOffset); } // pass in distance in world space to move up panUp(distance) { const te = this.camera.matrix.elements; // get Y column of matrix panOffset.fromArray(te, 4); panOffset.multiplyScalar(distance); panVector.add(panOffset); } // pass in x,y of change desired in pixel space, // right and down are positive mouseToPan(deltaX, deltaY) { const gfx = this.view.mainLoop.gfxEngine; if (this.camera.isPerspectiveCamera) { let targetDistance = this.camera.position.distanceTo(this.getCameraTargetPosition()); // half of the fov is center to top of screen targetDistance *= 2 * Math.tan(THREE.MathUtils.degToRad(this.camera.fov * 0.5)); // we actually don't use screenWidth, since perspective camera is fixed to screen height this.panLeft(deltaX * targetDistance / gfx.width * this.camera.aspect); this.panUp(deltaY * targetDistance / gfx.height); } else if (this.camera.isOrthographicCamera) { // orthographic this.panLeft(deltaX * (this.camera.right - this.camera.left) / gfx.width); this.panUp(deltaY * (this.camera.top - this.camera.bottom) / gfx.height); } } // For Mobile dolly(delta) { if (delta === 0) { return; } dollyScale = delta > 0 ? this.zoomInScale : this.zoomOutScale; if (this.camera.isPerspectiveCamera) { orbitScale /= dollyScale; } else if (this.camera.isOrthographicCamera) { this.camera.zoom = THREE.MathUtils.clamp(this.camera.zoom * dollyScale, this.minZoom, this.maxZoom); this.camera.updateProjectionMatrix(); this.view.notifyChange(this.camera); } } getMinDistanceCameraBoundingSphereObbsUp(tile) { if (tile.level > 10 && tile.children.length == 1 && tile.geometry) { const obb = tile.obb; sphereCamera.center.copy(this.camera.position); sphereCamera.radius = this.minDistanceCollision; if (obb.isSphereAboveXYBox(sphereCamera)) { minDistanceZ = Math.min(sphereCamera.center.z - obb.box3D.max.z, minDistanceZ); } } } update() { let state = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : this.states.currentState; // We compute distance between camera's bounding sphere and geometry's obb up face minDistanceZ = Infinity; if (this.handleCollision) { // We check distance to the ground/surface geometry // add minDistanceZ between camera's bounding and tiles's oriented bounding box (up face only) // Depending on the distance of the camera with obbs, we add a slowdown or constrain to the movement. // this constraint or deceleration is suitable for two types of movement MOVE_GLOBE and ORBIT. // This constraint or deceleration inversely proportional to the camera/obb distance if (this.view.tileLayer) { for (const tile of this.view.tileLayer.level0Nodes) { tile.traverse(this.getMinDistanceCameraBoundingSphereObbsUp.bind(this)); } } } switch (state) { // MOVE_GLOBE Rotate globe with mouse case this.states.MOVE_GLOBE: if (minDistanceZ < 0) { cameraTarget.translateY(-minDistanceZ); this.camera.position.setLength(this.camera.position.length() - minDistanceZ); } else if (minDistanceZ < this.minDistanceCollision) { const translate = this.minDistanceCollision * (1.0 - minDistanceZ / this.minDistanceCollision); cameraTarget.translateY(translate); this.camera.position.setLength(this.camera.position.length() + translate); } lastNormalizedIntersection.copy(normalizedIntersection).applyQuaternion(moveAroundGlobe); cameraTarget.position.applyQuaternion(moveAroundGlobe); this.camera.position.applyQuaternion(moveAroundGlobe); break; // PAN Move camera in projection plan case this.states.PAN: this.camera.position.add(panVector); cameraTarget.position.add(panVector); break; // PANORAMIC Move target camera case this.states.PANORAMIC: { this.camera.worldToLocal(cameraTarget.position); const normal = this.camera.position.clone().normalize().applyQuaternion(this.camera.quaternion.clone().invert()); quaterPano.setFromAxisAngle(normal, sphericalDelta.theta).multiply(quaterAxis.setFromAxisAngle(axisX, sphericalDelta.phi)); cameraTarget.position.applyQuaternion(quaterPano); this.camera.localToWorld(cameraTarget.position); break; } // ZOOM/ORBIT Move Camera around the target camera default: { // get camera position in local space of target this.camera.position.applyMatrix4(cameraTarget.matrixWorldInverse); // angle from z-axis around y-axis if (sphericalDelta.theta || sphericalDelta.phi) { spherical.setFromVector3(this.camera.position); } // far underground const dynamicRadius = spherical.radius * Math.sin(this.minPolarAngle); const slowdownLimit = dynamicRadius * 8; const contraryLimit = dynamicRadius * 2; const minContraintPhi = -0.01; if (this.handleCollision) { if (minDistanceZ < slowdownLimit && minDistanceZ > contraryLimit && sphericalDelta.phi > 0) { // slowdown zone : slowdown sphericalDelta.phi const slowdownZone = slowdownLimit - contraryLimit; // the deeper the camera is in this zone, the bigger the factor is const slowdownFactor = 1 - (slowdownZone - (minDistanceZ - contraryLimit)) / slowdownZone; // apply slowdown factor on tilt mouvement sphericalDelta.phi *= slowdownFactor * slowdownFactor; } else if (minDistanceZ < contraryLimit && minDistanceZ > -contraryLimit && sphericalDelta.phi > minContraintPhi) { // contraint zone : contraint sphericalDelta.phi // calculation of the angle of rotation which allows to leave this zone let contraryPhi = -Math.asin((contraryLimit - minDistanceZ) * 0.25 / spherical.radius); // clamp contraryPhi to make a less brutal exit contraryPhi = THREE.MathUtils.clamp(contraryPhi, minContraintPhi, 0); // the deeper the camera is in this zone, the bigger the factor is const contraryFactor = 1 - (contraryLimit - minDistanceZ) / (2 * contraryLimit); sphericalDelta.phi = THREE.MathUtils.lerp(sphericalDelta.phi, contraryPhi, contraryFactor); minDistanceZ -= Math.sin(sphericalDelta.phi) * spherical.radius; } } spherical.theta += sphericalDelta.theta; spherical.phi += sphericalDelta.phi; // restrict spherical.theta to be between desired limits spherical.theta = Math.max(this.minAzimuthAngle, Math.min(this.maxAzimuthAngle, spherical.theta)); // restrict spherical.phi to be between desired limits spherical.phi = Math.max(this.minPolarAngle, Math.min(this.maxPolarAngle, spherical.phi)); spherical.radius = this.camera.position.length() * orbitScale; // restrict spherical.phi to be betwee EPS and PI-EPS spherical.makeSafe(); // restrict radius to be between desired limits spherical.radius = Math.max(this.minDistance, Math.min(this.maxDistance, spherical.radius)); this.camera.position.setFromSpherical(spherical); // if camera is underground, so move up camera if (minDistanceZ < 0) { this.camera.position.y -= minDistanceZ; spherical.setFromVector3(this.camera.position); sphericalDelta.phi = 0; } cameraTarget.localToWorld(this.camera.position); } } this.camera.up.copy(cameraTarget.position).normalize(); this.camera.lookAt(cameraTarget.position); if (!this.enableDamping) { sphericalDelta.theta = 0; sphericalDelta.phi = 0; moveAroundGlobe.set(0, 0, 0, 1); } else { sphericalDelta.theta *= 1 - dampingFactorDefault; sphericalDelta.phi *= 1 - dampingFactorDefault; moveAroundGlobe.slerp(dampingMove, this.dampingMoveFactor * 0.2); } orbitScale = 1; panVector.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 (lastPosition.distanceToSquared(this.camera.position) > EPS || 8 * (1 - lastQuaternion.dot(this.camera.quaternion)) > EPS) { this.view.notifyChange(this.camera); lastPosition.copy(this.camera.position); lastQuaternion.copy(this.camera.quaternion); } // Launch animationdamping if mouse stops these movements if (this.enableDamping && state === this.states.ORBIT && this.player.isStopped() && (sphericalDelta.theta > EPS || sphericalDelta.phi > EPS)) { this.player.setCallback(() => { this.update(this.states.ORBIT); }); this.player.playLater(durationDampingOrbital, 2); } this.view.dispatchEvent({ type: VIEW_EVENTS.CAMERA_MOVED, coord: coordCameraTarget.setFromVector3(cameraTarget.position), range: spherical.radius, heading: -THREE.MathUtils.radToDeg(spherical.theta), tilt: 90 - THREE.MathUtils.radToDeg(spherical.phi) }); } onStateChange(event) { // If the state changed to NONE, end the movement associated to the previous state. if (this.states.currentState === this.states.NONE) { this.handleEndMovement(event); return; } // Stop CameraUtils ongoing animations, which can for instance be triggered with `this.travel` or // `this.lookAtCoordinate` methods. CameraUtils.stop(this.view, this.camera); // Dispatch events which specify if changes occurred in camera transform options. this.onEndingMove(); // Stop eventual damping movement. this.player.stop(); // Update camera transform options. this.updateTarget(); previous = CameraUtils.getTransformCameraLookingAtTarget(this.view, this.camera, pickedPosition); // Initialize rotation and panoramic movements. rotateStart.copy(event.viewCoords); // Initialize drag movement. if (this.view.getPickingPositionFromDepth(event.viewCoords, pickingPoint)) { pickSphere.radius = pickingPoint.length(); lastNormalizedIntersection.copy(pickingPoint).normalize(); this.updateHelper(pickingPoint, helpers.picking); } // Initialize dolly movement. dollyStart.copy(event.viewCoords); this.view.getPickingPositionFromDepth(event.viewCoords, pickedPosition); // mouse position // Initialize pan movement. panStart.copy(event.viewCoords); } handleRotation(event) { // Stop player if needed. Player can be playing while moving mouse in the case of rotation. This is due to the // fact that a damping move can occur while rotating (without the need of releasing the mouse button) this.player.stop(); this.handlePanoramic(event); } handleDrag(event) { const normalized = this.view.viewToNormalizedCoords(event.viewCoords); // An updateMatrixWorld on the camera prevents camera jittering when moving globe on a zoomed out view, with // devtools open in web browser. this.camera.updateMatrixWorld(); raycaster.setFromCamera(normalized, this.camera); // If there's intersection then move globe else we stop the move if (raycaster.ray.intersectSphere(pickSphere, intersection)) { normalizedIntersection.copy(intersection).normalize(); moveAroundGlobe.setFromUnitVectors(normalizedIntersection, lastNormalizedIntersection); lastTimeMouseMove = Date.now(); this.update(); } else { this.states.onPointerUp(); } } handleDolly(event) { dollyEnd.copy(event.viewCoords); dollyDelta.subVectors(dollyEnd, dollyStart); dollyStart.copy(dollyEnd); event.delta = dollyDelta.y; if (event.delta != 0) { this.handleZoom(event); } } handlePan(event) { if (event.viewCoords) { panEnd.copy(event.viewCoords); panDelta.subVectors(panEnd, panStart); panStart.copy(panEnd); } else if (event.direction) { panDelta.copy(direction[event.direction]).multiplyScalar(this.keyPanSpeed); } this.mouseToPan(panDelta.x, panDelta.y); this.update(this.states.PAN); } handlePanoramic(event) { rotateEnd.copy(event.viewCoords); rotateDelta.subVectors(rotateEnd, rotateStart); const gfx = this.view.mainLoop.gfxEngine; sphericalDelta.theta -= 2 * Math.PI * rotateDelta.x / gfx.width * this.rotateSpeed; // rotating up and down along whole screen attempts to go 360, but limited to 180 sphericalDelta.phi -= 2 * Math.PI * rotateDelta.y / gfx.height * this.rotateSpeed; rotateStart.copy(rotateEnd); this.update(); } handleEndMovement() { let event = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; this.dispatchEvent(this.endEvent); this.player.stop(); // Launch damping movement for : // * this.states.ORBIT // * this.states.MOVE_GLOBE if (this.enableDamping) { if (event.previous === this.states.ORBIT && (sphericalDelta.theta > EPS || sphericalDelta.phi > EPS)) { this.player.setCallback(() => { this.update(this.states.ORBIT); }); this.player.play(durationDampingOrbital); this._onEndingMove = () => this.onEndingMove(); this.player.addEventListener('animation-stopped', this._onEndingMove); } else if (event.previous === this.states.MOVE_GLOBE && Date.now() - lastTimeMouseMove < 50) { this.player.setCallback(() => { this.update(this.states.MOVE_GLOBE); }); // animation since mouse up event occurs less than 50ms after the last mouse move this.player.play(durationDampingMove); this._onEndingMove = () => this.onEndingMove(); this.player.addEventListener('animation-stopped', this._onEndingMove); } else { this.onEndingMove(); } } else { this.onEndingMove(); } } updateTarget() { // Check if the middle of the screen is on the globe (to prevent having a dark-screen bug if outside the globe) if (this.view.getPickingPositionFromDepth(null, pickedPosition)) { // Update camera's target position const distance = !isNaN(pickedPosition.x) ? this.camera.position.distanceTo(pickedPosition) : 100; targetPosition.set(0, 0, -distance); this.camera.localToWorld(targetPosition); // set new camera target on globe positionObject(targetPosition, cameraTarget); cameraTarget.matrixWorldInverse.copy(cameraTarget.matrixWorld).invert(); targetPosition.copy(this.camera.position); targetPosition.applyMatrix4(cameraTarget.matrixWorldInverse); spherical.setFromVector3(targetPosition); } } handlingEvent(current) { current = current || CameraUtils.getTransformCameraLookingAtTarget(this.view, this.camera); const diff = CameraUtils.getDiffParams(previous, current); if (diff) { if (diff.range) { this.dispatchEvent({ type: CONTROL_EVENTS.RANGE_CHANGED, previous: diff.range.previous, new: diff.range.new }); } if (diff.coord) { this.dispatchEvent({ type: CONTROL_EVENTS.CAMERA_TARGET_CHANGED, previous: diff.coord.previous, new: diff.coord.new }); } if (diff.tilt || diff.heading) { const event = { type: CONTROL_EVENTS.ORIENTATION_CHANGED }; if (diff.tilt) { event.previous = { tilt: diff.tilt.previous }; event.new = { tilt: diff.tilt.new }; } if (diff.heading) { event.previous = event.previous || {}; event.new = event.new || {}; event.new.heading = diff.heading.new; event.previous.heading = diff.heading.previous; } this.dispatchEvent(event); } } } travel(event) { this.player.stop(); const point = this.view.getPickingPositionFromDepth(event.viewCoords); const range = this.getRange(point); if (point && range > this.minDistance) { return this.lookAtCoordinate({ coord: new Coordinates('EPSG:4978').setFromVector3(point), range: range * (event.direction === 'out' ? 1 / 0.6 : 0.6), time: 1500 }); } } handleZoom(event) { this.player.stop(); CameraUtils.stop(this.view, this.camera); const zoomScale = event.delta > 0 ? this.zoomInScale : this.zoomOutScale; let point = event.type === 'dolly' ? pickedPosition : this.view.getPickingPositionFromDepth(event.viewCoords); // get cursor position let range = this.getRange(); range *= zoomScale; if (point && range > this.minDistance && range < this.maxDistance) { // check if the zoom is in the allowed interval const camPos = xyz.setFromVector3(cameraTarget.position).as('EPSG:4326', c).toVector3(); point = xyz.setFromVector3(point).as('EPSG:4326', c).toVector3(); if (camPos.x * point.x < 0) { // Correct rotation at 180th meridian by using 0 <= longitude <=360 for interpolation purpose if (camPos.x - point.x > 180) { point.x += 360; } else if (point.x - camPos.x > 180) { camPos.x += 360; } } point.lerp( // point interpol between mouse cursor and cam pos camPos, zoomScale // interpol factor ); point = c.setFromVector3(point).as('EPSG:4978', xyz); return this.lookAtCoordinate({ // update view to the interpolate point coord: point, range }, false); } } onTouchStart(event) { // CameraUtils.stop(view); this.player.stop(); // TODO : this.states.enabled check should be removed when moving touch events management to StateControl if (this.states.enabled === false) { return; } this.state = this.states.touchToState(event.touches.length); this.updateTarget(); if (this.state !== this.states.NONE) { switch (this.state) { case this.states.MOVE_GLOBE: { const coords = this.view.eventToViewCoords(event); if (this.view.getPickingPositionFromDepth(coords, pickingPoint)) { pickSphere.radius = pickingPoint.length(); lastNormalizedIntersection.copy(pickingPoint).normalize(); this.updateHelper(pickingPoint, helpers.picking); } else { this.state = this.states.NONE; } break; } case this.states.ORBIT: case this.states.DOLLY: { const x = event.touches[0].pageX; const y = event.touches[0].pageY; const dx = x - event.touches[1].pageX; const dy = y - event.touches[1].pageY; const distance = Math.sqrt(dx * dx + dy * dy); dollyStart.set(0, distance); rotateStart.set(x, y); break; } case this.states.PAN: panStart.set(event.touches[0].pageX, event.touches[0].pageY); break; default: } this.dispatchEvent(this.startEvent); } } onTouchMove(event) { if (this.player.isPlaying()) { this.player.stop(); } // TODO : this.states.enabled check should be removed when moving touch events management to StateControl if (this.states.enabled === false) { return; } event.preventDefault(); event.stopPropagation(); switch (event.touches.length) { case this.states.MOVE_GLOBE.finger: { const coords = this.view.eventToViewCoords(event); const normalized = this.view.viewToNormalizedCoords(coords); // An updateMatrixWorld on the camera prevents camera jittering when moving globe on a zoomed out view, with // devtools open in web browser. this.camera.updateMatrixWorld(); raycaster.setFromCamera(normalized, this.camera); // If there's intersection then move globe else we stop the move if (raycaster.ray.intersectSphere(pickSphere, intersection)) { normalizedIntersection.copy(intersection).normalize(); moveAroundGlobe.setFromUnitVectors(normalizedIntersection, lastNormalizedIntersection); lastTimeMouseMove = Date.now(); } else { this.onTouchEnd(); } break; } case this.states.ORBIT.finger: case this.states.DOLLY.finger: { const gfx = this.view.mainLoop.gfxEngine; rotateEnd.set(event.touches[0].pageX, event.touches[0].pageY); rotateDelta.subVectors(rotateEnd, rotateStart); // rotating across whole screen goes 360 degrees around this.rotateLeft(2 * Math.PI * rotateDelta.x / gfx.width * this.rotateSpeed); // rotating up and down along whole screen attempts to go 360, but limited to 180 this.rotateUp(2 * Math.PI * rotateDelta.y / gfx.height * this.rotateSpeed); rotateStart.copy(rotateEnd); const dx = event.touches[0].pageX - event.touches[1].pageX; const dy = event.touches[0].pageY - event.touches[1].pageY; const distance = Math.sqrt(dx * dx + dy * dy); dollyEnd.set(0, distance); dollyDelta.subVectors(dollyEnd, dollyStart); this.dolly(dollyDelta.y); dollyStart.copy(dollyEnd); break; } case this.states.PAN.finger: panEnd.set(event.touches[0].pageX, event.touches[0].pageY); panDelta.subVectors(panEnd, panStart); this.mouseToPan(panDelta.x, panDelta.y); panStart.copy(panEnd); break; default: this.state = this.states.NONE; } if (this.state !== this.states.NONE) { this.update(this.state); } } onTouchEnd() { this.handleEndMovement({ previous: this.state }); this.state = this.states.NONE; } dispose() { this.view.domElement.removeEventListener('touchstart', this._onTouchStart, false); this.view.domElement.removeEventListener('touchend', this._onTouchEnd, false); this.view.domElement.removeEventListener('touchmove', this._onTouchMove, false); this.states.dispose(); this.states.removeEventListener('state-changed', this._onStateChange, false); this.states.removeEventListener(this.states.ORBIT._event, this._onRotation, false); this.states.removeEventListener(this.states.MOVE_GLOBE._event, this._onDrag, false); this.states.removeEventListener(this.states.DOLLY._event, this._onDolly, false); this.states.removeEventListener(this.states.PAN._event, this._onPan, false); this.states.removeEventListener(this.states.PANORAMIC._event, this._onPanoramic, false); this.states.removeEventListener('zoom', this._onZoom, false); this.states.removeEventListener(this.states.TRAVEL_IN._event, this._onTravel, false); this.states.removeEventListener(this.states.TRAVEL_OUT._event, this._onTravel, false); this.dispatchEvent({ type: 'dispose' }); } /** * Changes the tilt of the current camera, in degrees. * @param {number} tilt * @param {boolean} isAnimated * @return {Promise<void>} */ setTilt(tilt, isAnimated) { return this.lookAtCoordinate({ tilt }, isAnimated); } /** * Changes the heading of the current camera, in degrees. * @param {number} heading * @param {boolean} isAnimated * @return {Promise<void>} */ setHeading(heading, isAnimated) { return this.lookAtCoordinate({ heading }, isAnimated); } /** * Sets the "range": the distance in meters between the camera and the current central point on the screen. * @param {number} range * @param {boolean} isAnimated * @return {Promise<void>} */ setRange(range, isAnimated) { return this.lookAtCoordinate({ range }, isAnimated); } /** * Returns the {@linkcode Coordinates} of the globe point targeted by the camera in EPSG:4978 projection. See {@linkcode Coordinates} for conversion * @return {THREE.Vector3} position */ getCameraTargetPosition() { return cameraTarget.position; } /** * Returns the "range": the distance in meters between the camera and the current central point on the screen. * @param {THREE.Vector3} [position] - The position to consider as picked on * the ground. * @return {number} number */ getRange(position) { return CameraUtils.getTransformCameraLookingAtTarget(this.view, this.camera, position).range; } /** * Returns the tilt of the current camera in degrees. * @param {THREE.Vector3} [position] - The position to consider as picked on * the ground. * @return {number} The angle of the rotation in degrees. */ getTilt(position) { return CameraUtils.getTransformCameraLookingAtTarget(this.view, this.camera, position).tilt; } /** * Returns the heading of the current camera in degrees. * @param {THREE.Vector3} [position] - The position to consider as picked on * the ground. * @return {number} The angle of the rotation in degrees. */ getHeading(position) { return CameraUtils.getTransformCameraLookingAtTarget(this.view, this.camera, position).heading; } /** * Displaces the central point to a specific amount of pixels from its current position. * The view flies to the desired coordinate, i.e.is not teleported instantly. Note : The results can be strange in some cases, if ever possible, when e.g.the camera looks horizontally or if the displaced center would not pick the ground once displaced. * @param {vector} pVector The vector * @return {Promise} */ pan(pVector) { this.mouseToPan(pVector.x, pVector.y); this.update(this.states.PAN); return Promise.resolve(); } /** * Returns the orientation angles of the current camera, in degrees. * @return {Array<number>} */ getCameraOrientation() { this.view.getPickingPositionFromDepth(null, pickedPosition); return [this.getTilt(pickedPosition), this.getHeading(pickedPosition)]; } /** * Returns the camera location projected on the ground in lat,lon. See {@linkcode Coordinates} for conversion. * @return {Coordinates} position */ getCameraCoordinate() { return new Coordinates('EPSG:4978').setFromVector3(this.camera.position).as('EPSG:4326'); } /** * Returns the {@linkcode Coordinates} of the central point on screen in lat,lon. See {@linkcode Coordinates} for conversion. * @return {Coordinates} coordinate */ getLookAtCoordinate() { return CameraUtils.getTransformCameraLookingAtTarget(this.view, this.camera).coord; } /** * Sets the animation enabled. * @param {boolean} enable enable */ setAnimationEnabled(enable) { enableAnimation = enable; } /** * Determines if animation enabled. * @return {boolean} True if animation enabled, False otherwise. */ isAnimationEnabled() { return enableAnimation; } /** * Returns the actual zoom. The zoom will always be between the [getMinZoom(), getMaxZoom()]. * @return {number} The zoom . */ getZoom() { return this.view.tileLayer.computeTileZoomFromDistanceCamera(this.getRange(), this.view.camera); } /** * Sets the current zoom, which is an index in the logical scales predefined for the application. * The higher the zoom, the closer to the ground. * The zoom is always in the [getMinZoom(), getMaxZoom()] range. * @param {number} zoom The zoom * @param {boolean} isAnimated Indicates if animated * @return {Promise} */ setZoom(zoom, isAnimated) { return this.lookAtCoordinate({ zoom }, isAnimated); } /** * Return the current zoom scale at the central point of the view. * This function compute the scale of a map * @param {number} pitch Screen pitch, in millimeters ; 0.28 by default * @return {number} The zoom scale. * * @deprecated Use View#getScale instead. */ getScale(pitch) { console.warn('Deprecated, use View#getScale instead.'); return this.view.getScale(pitch); } /** * To convert the projection in meters on the globe of a number of pixels of screen * @param {number} pixels count pixels to project * @param {number} pixelPitch Screen pixel pitch, in millimeters (default = 0.28 mm / standard pixel size of 0.28 millimeters as defined by the OGC) * @return {number} projection in meters on globe * * @deprecated Use `View#getPixelsToMeters` instead. */ pixelsToMeters(pixels) { let pixelPitch = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0.28; console.warn('Deprecated use View#getPixelsToMeters instead.'); const scaled = this.getScale(pixelPitch); return pixels * pixelPitch / scaled / 1000; } /** * To convert the projection a number of horizontal pixels of screen to longitude degree WGS84 on the globe * @param {number} pixels count pixels to project * @param {number} pixelPitch Screen pixel pitch, in millimeters (default = 0.28 mm / standard pixel size of 0.28 millimeters as defined by the OGC) * @return {number} projection in degree on globe * * @deprecated Use `View#getPixelsToMeters` and `GlobeControls#metersToDegrees` * instead. */ pixelsToDegrees(pixels) { let pixelPitch = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0.28; console.warn('Deprecated, use View#getPixelsToMeters and GlobeControls#getMetersToDegrees instead.'); const chord = this.pixelsToMeters(pixels, pixelPitch); return THREE.MathUtils.radToDeg(2 * Math.asin(chord / (2 * ellipsoidSizes.x))); } /** * Projection on screen in pixels of length in meter on globe * @param {number} value Length in meter on globe * @param {number} pixelPitch Screen pixel pitch, in millimeters (default = 0.28 mm / standard pixel size of 0.28 millimeters as defined by the OGC) * @return {number} projection in pixels on screen * * @deprecated Use `View#getMetersToPixels` instead. */ metersToPixels(value) { let pixelPitch = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0.28; console.warn('Deprecated, use View#getMetersToPixels instead.'); const scaled = this.getScale(pixelPitch); pixelPitch /= 1000; return value * scaled / pixelPitch; } /** * Changes the zoom of the central point of screen so that screen acts as a map with a specified scale. * The view flies to the desired zoom scale; * @param {number} scale The scale * @param {number} pitch The pitch * @param {boolean} isAnimated Indicates if animated * @return {Promise} */ setScale(scale, pitch, isAnimated) { return this.lookAtCoordinate({ scale, pitch }, isAnimated); } /** * Changes the center of the scene on screen to the specified in lat, lon. See {@linkcode Coordinates} for conversion. * This function allows to change the central position, the zoom, the range, the scale and the camera orientation at the same time. * The zoom has to be between the [getMinZoom(), getMaxZoom()]. * Zoom parameter is ignored if range is set * The tilt's interval is between 4 and 89.5 degree * * @param {CameraUtils~CameraTransformOptions|Extent} [params] - camera transformation to apply * @param {number} [params.zoom] - zoom * @param {number} [params.scale] - scale * @param {boolean} [isAnimated] - Indicates if animated * @return {Promise} A promise that resolves when transformation is complete */ lookAtCoordinate() { let params = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; let isAnimated = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : this.isAnimationEnabled(); this.player.stop(); if (!params.isExtent) { if (params.zoom) { params.range = this.view.tileLayer.computeDistanceCameraFromTileZoom(params.zoom, this.view.camera); } else if (params.scale) { params.range = this.view.getScaleFromDistance(params.pitch, params.scale); if (params.range < this.minDistance || params.range > this.maxDistance) { // eslint-disable-next-line no-console console.warn(`This scale ${params.scale} can not be reached`); params.range = THREE.MathUtils.clamp(params.range, this.minDistance, this.maxDistance); } } if (params.tilt !== undefined) { const minTilt = 90 - THREE.MathUtils.radToDeg(this.maxPolarAngle); const maxTilt = 90 - THREE.MathUtils.radToDeg(this.minPolarAngle); if (params.tilt < minTilt || params.tilt > maxTilt) { params.tilt = THREE.MathUtils.clamp(params.tilt, minTilt, maxTilt); // eslint-disable-next-line no-console console.warn('Tilt was clamped to ', params.tilt, ` the interval is between ${minTilt} and ${maxTilt} degree`); } } } previous = CameraUtils.getTransformCameraLookingAtTarget(this.view, this.camera); if (isAnimated) { params.callback = r => cameraTarget.position.copy(r.targetWorldPosition); this.dispatchEvent({ type: 'animation-started' }); return CameraUtils.animateCameraToLookAtTarget(this.view, this.camera, params).then(result => { this.dispatchEvent({ type: 'animation-ended' }); this.handlingEvent(result); return result; }); } else { return CameraUtils.transformCameraToLookAtTarget(this.view, this.camera, params).then(result => { cameraTarget.position.copy(result.targetWorldPosition); this.handlingEvent(result); return result; }); } } /** * Pick a position on the globe at the given position in lat,lon. See {@linkcode Coordinates} for conversion. * @param {Vector2} windowCoords - window coordinates * @param {number=} y - The y-position inside the Globe element. * @return {Coordinates} position */ pickGeoPosition(windowCoords) { const pickedPosition = this.view.getPickingPositionFromDepth(windowCoords); if (!pickedPosition) { return; } return new Coordinates('EPSG:4978').setFromVector3(pickedPosition).as('EPSG:4326'); } } export default GlobeControls;