Source: navigate/LookAtNavigator.js

/*
 * Copyright 2015-2017 WorldWind Contributors
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
/**
 * @exports LookAtNavigator
 */
define([
        '../geom/Angle',
        '../gesture/DragRecognizer',
        '../geom/Frustum',
        '../gesture/GestureRecognizer',
        '../geom/Line',
        '../geom/Location',
        '../util/Logger',
        '../geom/Matrix',
        '../navigate/Navigator',
        '../gesture/PanRecognizer',
        '../gesture/PinchRecognizer',
        '../geom/Position',
        '../gesture/RotationRecognizer',
        '../gesture/TiltRecognizer',
        '../geom/Vec2',
        '../geom/Vec3',
        '../util/WWMath'
    ],
    function (Angle,
              DragRecognizer,
              Frustum,
              GestureRecognizer,
              Line,
              Location,
              Logger,
              Matrix,
              Navigator,
              PanRecognizer,
              PinchRecognizer,
              Position,
              RotationRecognizer,
              TiltRecognizer,
              Vec2,
              Vec3,
              WWMath) {
        "use strict";

        /**
         * Constructs a look-at navigator.
         * @alias LookAtNavigator
         * @constructor
         * @augments Navigator
         * @classdesc Represents a navigator that enables the user to pan, zoom and tilt the globe.
         * This navigator automatically responds to user-input events and gestures.
         * @param {WorldWindow} worldWindow The WorldWindow to associate with this navigator.
         */
        var LookAtNavigator = function (worldWindow) {
            Navigator.call(this, worldWindow);

            // Prevent the browser's default actions in response to mouse and touch events, which interfere with
            // navigation. Register these event listeners  before any others to ensure that they're called last.
            function preventDefaultListener(event) {
                event.preventDefault();
            }
            worldWindow.addEventListener("mousedown", preventDefaultListener);
            worldWindow.addEventListener("touchstart", preventDefaultListener);
            worldWindow.addEventListener("contextmenu", preventDefaultListener);
            worldWindow.addEventListener("wheel", preventDefaultListener);

            // Prevent the browser's default actions in response to to pointer events, which interfere with navigation.
            // This CSS style property is configured here to ensure that it's set for all applications.
            if (window.PointerEvent) {
                worldWindow.canvas.style.setProperty("touch-action", "none");
            }

            /**
             * The geographic location at the center of the viewport.
             * @type {Location}
             */
            this.lookAtLocation = new Location(30, -110);

            /**
             * The distance from this navigator's eye point to its look-at location.
             * @type {Number}
             * @default 10,000 kilometers
             */
            this.range = 10e6; // TODO: Compute initial range to fit globe in viewport.

            // Development testing only. Set this to false to suppress default navigator limits on 2D globes.
            this.enable2DLimits = true;

            var thisNavigator = this;

            // Intentionally not documented.
            this.primaryDragRecognizer = new DragRecognizer(worldWindow, function (recognizer) {
                thisNavigator.handlePanOrDrag(recognizer);
            });

            // Intentionally not documented.
            this.secondaryDragRecognizer = new DragRecognizer(worldWindow, function (recognizer) {
                thisNavigator.handleSecondaryDrag(recognizer);
            });
            this.secondaryDragRecognizer.button = 2; // secondary mouse button

            // Intentionally not documented.
            this.panRecognizer = new PanRecognizer(worldWindow, function (recognizer) {
                thisNavigator.handlePanOrDrag(recognizer);
            });

            // Intentionally not documented.
            this.pinchRecognizer = new PinchRecognizer(worldWindow, function (recognizer) {
                thisNavigator.handlePinch(recognizer);
            });

            // Intentionally not documented.
            this.rotationRecognizer = new RotationRecognizer(worldWindow, function (recognizer) {
                thisNavigator.handleRotation(recognizer);
            });

            // Intentionally not documented.
            this.tiltRecognizer = new TiltRecognizer(worldWindow, function (recognizer) {
                thisNavigator.handleTilt(recognizer);
            });

            // Register wheel event listeners on the WorldWindow's canvas.
            worldWindow.addEventListener("wheel", function (event) {
                thisNavigator.handleWheelEvent(event);
            });

            // Establish the dependencies between gesture recognizers. The pan, pinch and rotate gesture may recognize
            // simultaneously with each other.
            this.panRecognizer.recognizeSimultaneouslyWith(this.pinchRecognizer);
            this.panRecognizer.recognizeSimultaneouslyWith(this.rotationRecognizer);
            this.pinchRecognizer.recognizeSimultaneouslyWith(this.rotationRecognizer);

            // Since the tilt gesture is a subset of the pan gesture, pan will typically recognize before tilt,
            // effectively suppressing tilt. Establish a dependency between the other touch gestures and tilt to provide
            // tilt an opportunity to recognize.
            this.panRecognizer.requireRecognizerToFail(this.tiltRecognizer);
            this.pinchRecognizer.requireRecognizerToFail(this.tiltRecognizer);
            this.rotationRecognizer.requireRecognizerToFail(this.tiltRecognizer);

            // Intentionally not documented.
            this.beginPoint = new Vec2(0, 0);
            this.lastPoint = new Vec2(0, 0);
            this.beginHeading = 0;
            this.beginTilt = 0;
            this.beginRange = 0;
            this.lastRotation = 0;
        };

        LookAtNavigator.prototype = Object.create(Navigator.prototype);

        // Documented in superclass.
        LookAtNavigator.prototype.currentState = function () {
            this.applyLimits();

            var globe = this.worldWindow.globe,
                lookAtPosition = new Position(this.lookAtLocation.latitude, this.lookAtLocation.longitude, 0),
                modelview = Matrix.fromIdentity();
            modelview.multiplyByLookAtModelview(lookAtPosition, this.range, this.heading, this.tilt, this.roll, globe);

            return this.currentStateForModelview(modelview);
        };

        // Intentionally not documented.
        LookAtNavigator.prototype.handlePanOrDrag = function (recognizer) {
            if (this.worldWindow.globe.is2D()) {
                this.handlePanOrDrag2D(recognizer);
            } else {
                this.handlePanOrDrag3D(recognizer);
            }
        };

        // Intentionally not documented.
        LookAtNavigator.prototype.handlePanOrDrag3D = function (recognizer) {
            var state = recognizer.state,
                tx = recognizer.translationX,
                ty = recognizer.translationY;

            if (state == WorldWind.BEGAN) {
                this.lastPoint.set(0, 0);
            } else if (state == WorldWind.CHANGED) {
                // Convert the translation from screen coordinates to arc degrees. Use this navigator's range as a
                // metric for converting screen pixels to meters, and use the globe's radius for converting from meters
                // to arc degrees.
                var canvas = this.worldWindow.canvas,
                    globe = this.worldWindow.globe,
                    globeRadius = WWMath.max(globe.equatorialRadius, globe.polarRadius),
                    distance = WWMath.max(1, this.range),
                    metersPerPixel = WWMath.perspectivePixelSize(canvas.clientWidth, canvas.clientHeight, distance),
                    forwardMeters = (ty - this.lastPoint[1]) * metersPerPixel,
                    sideMeters = -(tx - this.lastPoint[0]) * metersPerPixel,
                    forwardDegrees = (forwardMeters / globeRadius) * Angle.RADIANS_TO_DEGREES,
                    sideDegrees = (sideMeters / globeRadius) * Angle.RADIANS_TO_DEGREES;

                // Apply the change in latitude and longitude to this navigator, relative to the current heading.
                var sinHeading = Math.sin(this.heading * Angle.DEGREES_TO_RADIANS),
                    cosHeading = Math.cos(this.heading * Angle.DEGREES_TO_RADIANS);

                this.lookAtLocation.latitude += forwardDegrees * cosHeading - sideDegrees * sinHeading;
                this.lookAtLocation.longitude += forwardDegrees * sinHeading + sideDegrees * cosHeading;
                this.lastPoint.set(tx, ty);
                this.applyLimits();
                this.worldWindow.redraw();
            }
        };

        // Intentionally not documented.
        LookAtNavigator.prototype.handlePanOrDrag2D = function (recognizer) {
            var state = recognizer.state,
                x = recognizer.clientX,
                y = recognizer.clientY,
                tx = recognizer.translationX,
                ty = recognizer.translationY;

            if (state == WorldWind.BEGAN) {
                this.beginPoint.set(x, y);
                this.lastPoint.set(x, y);
            } else if (state == WorldWind.CHANGED) {
                var x1 = this.lastPoint[0],
                    y1 = this.lastPoint[1],
                    x2 = this.beginPoint[0] + tx,
                    y2 = this.beginPoint[1] + ty;
                this.lastPoint.set(x2, y2);

                var navState = this.currentState(),
                    globe = this.worldWindow.globe,
                    ray = navState.rayFromScreenPoint(this.worldWindow.canvasCoordinates(x1, y1)),
                    point1 = new Vec3(0, 0, 0),
                    point2 = new Vec3(0, 0, 0),
                    origin = new Vec3(0, 0, 0);
                if (!globe.intersectsLine(ray, point1)) {
                    return;
                }

                ray = navState.rayFromScreenPoint(this.worldWindow.canvasCoordinates(x2, y2));
                if (!globe.intersectsLine(ray, point2)) {
                    return;
                }

                // Transform the original navigator state's modelview matrix to account for the gesture's change.
                var modelview = Matrix.fromIdentity();
                modelview.copy(navState.modelview);
                modelview.multiplyByTranslation(point2[0] - point1[0], point2[1] - point1[1], point2[2] - point1[2]);

                // Compute the globe point at the screen center from the perspective of the transformed navigator state.
                modelview.extractEyePoint(ray.origin);
                modelview.extractForwardVector(ray.direction);
                if (!globe.intersectsLine(ray, origin)) {
                    return;
                }

                // Convert the transformed modelview matrix to a set of navigator properties, then apply those
                // properties to this navigator.
                var params = modelview.extractViewingParameters(origin, this.roll, globe, {});
                this.lookAtLocation.copy(params.origin);
                this.range = params.range;
                this.heading = params.heading;
                this.tilt = params.tilt;
                this.roll = params.roll;
                this.applyLimits();
                this.worldWindow.redraw();
            }
        };

        // Intentionally not documented.
        LookAtNavigator.prototype.handleSecondaryDrag = function (recognizer) {
            var state = recognizer.state,
                tx = recognizer.translationX,
                ty = recognizer.translationY;

            if (state == WorldWind.BEGAN) {
                this.beginHeading = this.heading;
                this.beginTilt = this.tilt;
            } else if (state == WorldWind.CHANGED) {
                // Compute the current translation from screen coordinates to degrees. Use the canvas dimensions as a
                // metric for converting the gesture translation to a fraction of an angle.
                var headingDegrees = 180 * tx / this.worldWindow.canvas.clientWidth,
                    tiltDegrees = 90 * ty / this.worldWindow.canvas.clientHeight;

                // Apply the change in heading and tilt to this navigator's corresponding properties.
                this.heading = this.beginHeading + headingDegrees;
                this.tilt = this.beginTilt + tiltDegrees;
                this.applyLimits();
                this.worldWindow.redraw();
            }
        };

        // Intentionally not documented.
        LookAtNavigator.prototype.handlePinch = function (recognizer) {
            var state = recognizer.state,
                scale = recognizer.scale;

            if (state == WorldWind.BEGAN) {
                this.beginRange = this.range;
            } else if (state == WorldWind.CHANGED) {
                if (scale != 0) {
                    // Apply the change in pinch scale to this navigator's range, relative to the range when the gesture
                    // began.
                    this.range = this.beginRange / scale;
                    this.applyLimits();
                    this.worldWindow.redraw();
                }
            }
        };

        // Intentionally not documented.
        LookAtNavigator.prototype.handleRotation = function (recognizer) {
            var state = recognizer.state,
                rotation = recognizer.rotation;

            if (state == WorldWind.BEGAN) {
                this.lastRotation = 0;
            } else if (state == WorldWind.CHANGED) {
                // Apply the change in gesture rotation to this navigator's current heading. We apply relative to the
                // current heading rather than the heading when the gesture began in order to work simultaneously with
                // pan operations that also modify the current heading.
                this.heading -= rotation - this.lastRotation;
                this.lastRotation = rotation;
                this.applyLimits();
                this.worldWindow.redraw();
            }
        };

        // Intentionally not documented.
        LookAtNavigator.prototype.handleTilt = function (recognizer) {
            var state = recognizer.state,
                ty = recognizer.translationY;

            if (state == WorldWind.BEGAN) {
                this.beginTilt = this.tilt;
            } else if (state == WorldWind.CHANGED) {
                // Compute the gesture translation from screen coordinates to degrees. Use the canvas dimensions as a
                // metric for converting the translation to a fraction of an angle.
                var tiltDegrees = -90 * ty / this.worldWindow.canvas.clientHeight;
                // Apply the change in heading and tilt to this navigator's corresponding properties.
                this.tilt = this.beginTilt + tiltDegrees;
                this.applyLimits();
                this.worldWindow.redraw();
            }
        };

        // Intentionally not documented.
        LookAtNavigator.prototype.handleWheelEvent = function (event) {
            // Normalize the wheel delta based on the wheel delta mode. This produces a roughly consistent delta across
            // browsers and input devices.
            var normalizedDelta;
            if (event.deltaMode == WheelEvent.DOM_DELTA_PIXEL) {
                normalizedDelta = event.deltaY;
            } else if (event.deltaMode == WheelEvent.DOM_DELTA_LINE) {
                normalizedDelta = event.deltaY * 40;
            } else if (event.deltaMode == WheelEvent.DOM_DELTA_PAGE) {
                normalizedDelta = event.deltaY * 400;
            }

            // Compute a zoom scale factor by adding a fraction of the normalized delta to 1. When multiplied by the
            // navigator's range, this has the effect of zooming out or zooming in depending on whether the delta is
            // positive or negative, respectfully.
            var scale = 1 + (normalizedDelta / 1000);

            // Apply the scale to this navigator's properties.
            this.range *= scale;
            this.applyLimits();
            this.worldWindow.redraw();
        };

        // Intentionally not documented.
        LookAtNavigator.prototype.applyLimits = function () {
            // Clamp latitude to between -90 and +90, and normalize longitude to between -180 and +180.
            this.lookAtLocation.latitude = WWMath.clamp(this.lookAtLocation.latitude, -90, 90);
            this.lookAtLocation.longitude = Angle.normalizedDegreesLongitude(this.lookAtLocation.longitude);

            // Clamp range to values greater than 1 in order to prevent degenerating to a first-person navigator when
            // range is zero.
            this.range = WWMath.clamp(this.range, 1, Number.MAX_VALUE);

            // Normalize heading to between -180 and +180.
            this.heading = Angle.normalizedDegrees(this.heading);

            // Clamp tilt to between 0 and +90 to prevent the viewer from going upside down.
            this.tilt = WWMath.clamp(this.tilt, 0, 90);

            // Normalize heading to between -180 and +180.
            this.roll = Angle.normalizedDegrees(this.roll);

            // Apply 2D limits when the globe is 2D.
            if (this.worldWindow.globe.is2D() && this.enable2DLimits) {
                // Clamp range to prevent more than 360 degrees of visible longitude. Assumes a 45 degree horizontal
                // field of view.
                var maxRange = 2 * Math.PI * this.worldWindow.globe.equatorialRadius;
                this.range = WWMath.clamp(this.range, 1, maxRange);

                // Force tilt to 0 when in 2D mode to keep the viewer looking straight down.
                this.tilt = 0;
            }
        };

        return LookAtNavigator;
    });