/** gps-projected-camera * * based on the original gps-camera, modified by nickw 02/04/20 * * Rather than keeping track of position by calculating the distance of * entities or the current location to the original location, this version * makes use of the "Google" Spherical Mercactor projection, aka epsg:3857. * * The original position (lat/lon) is projected into Spherical Mercator and * stored. * * Then, when we receive a new position (lat/lon), this new position is * projected into Spherical Mercator and then its world position calculated * by comparing against the original position. * * The same is also the case for 'entity-places'; when these are added, their * Spherical Mercator coords are calculated (see gps-projected-entity-place). * * Spherical Mercator units are close to, but not exactly, metres, and are * heavily distorted near the poles. Nonetheless they are a good approximation * for many areas of the world and appear not to cause unacceptable distortions * when used as the units for AR apps. * * UPDATES 28/08/20: * * - add gpsMinDistance and gpsTimeInterval properties to control how * frequently GPS updates are processed. Aim is to prevent 'stuttering' * effects when close to AR content due to continuous small changes in * location. */ import * as AFRAME from "aframe"; AFRAME.registerComponent("gps-projected-camera", { _watchPositionId: null, originCoords: null, // original coords now in Spherical Mercator currentCoords: null, lookControls: null, heading: null, schema: { simulateLatitude: { type: "number", default: 0, }, simulateLongitude: { type: "number", default: 0, }, simulateAltitude: { type: "number", default: 0, }, positionMinAccuracy: { type: "int", default: 100, }, alert: { type: "boolean", default: false, }, minDistance: { type: "int", default: 0, }, gpsMinDistance: { type: "number", default: 0, }, gpsTimeInterval: { type: "number", default: 0, }, }, update: function () { if (this.data.simulateLatitude !== 0 && this.data.simulateLongitude !== 0) { var localPosition = Object.assign({}, this.currentCoords || {}); localPosition.longitude = this.data.simulateLongitude; localPosition.latitude = this.data.simulateLatitude; localPosition.altitude = this.data.simulateAltitude; this.currentCoords = localPosition; // re-trigger initialization for new origin this.originCoords = null; this._updatePosition(); } }, init: function () { if ( !this.el.components["arjs-look-controls"] && !this.el.components["look-controls"] ) { return; } this.lastPosition = { latitude: 0, longitude: 0, }; this.loader = document.createElement("DIV"); this.loader.classList.add("arjs-loader"); document.body.appendChild(this.loader); this.onGpsEntityPlaceAdded = this._onGpsEntityPlaceAdded.bind(this); window.addEventListener( "gps-entity-place-added", this.onGpsEntityPlaceAdded ); this.lookControls = this.el.components["arjs-look-controls"] || this.el.components["look-controls"]; // listen to deviceorientation event var eventName = this._getDeviceOrientationEventName(); this._onDeviceOrientation = this._onDeviceOrientation.bind(this); // if Safari if (!!navigator.userAgent.match(/Version\/[\d.]+.*Safari/)) { // iOS 13+ if (typeof DeviceOrientationEvent.requestPermission === "function") { var handler = function () { console.log("Requesting device orientation permissions..."); DeviceOrientationEvent.requestPermission(); document.removeEventListener("touchend", handler); }; document.addEventListener( "touchend", function () { handler(); }, false ); this.el.sceneEl.systems["arjs"]._displayErrorPopup( "After camera permission prompt, please tap the screen to activate geolocation." ); } else { var timeout = setTimeout(function () { this.el.sceneEl.systems["arjs"]._displayErrorPopup( "Please enable device orientation in Settings > Safari > Motion & Orientation Access." ); }, 750); window.addEventListener(eventName, function () { clearTimeout(timeout); }); } } window.addEventListener(eventName, this._onDeviceOrientation, false); }, play: function () { if (this.data.simulateLatitude !== 0 && this.data.simulateLongitude !== 0) { var localPosition = Object.assign({}, this.currentCoords || {}); localPosition.latitude = this.data.simulateLatitude; localPosition.longitude = this.data.simulateLongitude; if (this.data.simulateAltitude !== 0) { localPosition.altitude = this.data.simulateAltitude; } this.currentCoords = localPosition; this._updatePosition(); } else { this._watchPositionId = this._initWatchGPS( function (position) { var localPosition = { latitude: position.coords.latitude, longitude: position.coords.longitude, altitude: position.coords.altitude, accuracy: position.coords.accuracy, altitudeAccuracy: position.coords.altitudeAccuracy, }; if (this.data.simulateAltitude !== 0) { localPosition.altitude = this.data.simulateAltitude; } this.currentCoords = localPosition; var distMoved = this._haversineDist( this.lastPosition, this.currentCoords ); if (distMoved >= this.data.gpsMinDistance || !this.originCoords) { this._updatePosition(); this.lastPosition = { longitude: this.currentCoords.longitude, latitude: this.currentCoords.latitude, }; } }.bind(this) ); } }, tick: function () { if (this.heading === null) { return; } this._updateRotation(); }, pause: function () { if (this._watchPositionId) { navigator.geolocation.clearWatch(this._watchPositionId); } this._watchPositionId = null; }, remove: function () { var eventName = this._getDeviceOrientationEventName(); window.removeEventListener(eventName, this._onDeviceOrientation, false); window.removeEventListener( "gps-entity-place-added", this.onGpsEntityPlaceAdded ); }, /** * Get device orientation event name, depends on browser implementation. * @returns {string} event name */ _getDeviceOrientationEventName: function () { if ("ondeviceorientationabsolute" in window) { var eventName = "deviceorientationabsolute"; } else if ("ondeviceorientation" in window) { var eventName = "deviceorientation"; } else { var eventName = ""; console.error("Compass not supported"); } return eventName; }, /** * Get current user position. * * @param {function} onSuccess * @param {function} onError * @returns {Promise} */ _initWatchGPS: function (onSuccess, onError) { if (!onError) { onError = function (err) { console.warn("ERROR(" + err.code + "): " + err.message); if (err.code === 1) { // User denied GeoLocation, let their know that this.el.sceneEl.systems["arjs"]._displayErrorPopup( "Please activate Geolocation and refresh the page. If it is already active, please check permissions for this website." ); return; } if (err.code === 3) { this.el.sceneEl.systems["arjs"]._displayErrorPopup( "Cannot retrieve GPS position. Signal is absent." ); return; } }; } if ("geolocation" in navigator === false) { onError({ code: 0, message: "Geolocation is not supported by your browser", }); return Promise.resolve(); } // https://developer.mozilla.org/en-US/docs/Web/API/Geolocation/watchPosition return navigator.geolocation.watchPosition(onSuccess, onError, { enableHighAccuracy: true, maximumAge: this.data.gpsTimeInterval, timeout: 27000, }); }, /** * Update user position. * * @returns {void} */ _updatePosition: function () { // don't update if accuracy is not good enough if (this.currentCoords.accuracy > this.data.positionMinAccuracy) { if (this.data.alert && !document.getElementById("alert-popup")) { var popup = document.createElement("div"); popup.innerHTML = "GPS signal is very poor. Try move outdoor or to an area with a better signal."; popup.setAttribute("id", "alert-popup"); document.body.appendChild(popup); } return; } var alertPopup = document.getElementById("alert-popup"); if ( this.currentCoords.accuracy <= this.data.positionMinAccuracy && alertPopup ) { document.body.removeChild(alertPopup); } if (!this.originCoords) { // first camera initialization // Now store originCoords as PROJECTED original lat/lon, so that // we can set the world origin to the original position in "metres" this.originCoords = this._project( this.currentCoords.latitude, this.currentCoords.longitude ); this._setPosition(); var loader = document.querySelector(".arjs-loader"); if (loader) { loader.remove(); } window.dispatchEvent(new CustomEvent("gps-camera-origin-coord-set")); } else { this._setPosition(); } }, /** * Set the current position (in world coords, based on Spherical Mercator) * * @returns {void} */ _setPosition: function () { var position = this.el.getAttribute("position"); var worldCoords = this.latLonToWorld( this.currentCoords.latitude, this.currentCoords.longitude ); position.x = worldCoords[0]; position.z = worldCoords[1]; // update position this.el.setAttribute("position", position); // add the sphmerc position to the event (for testing only) window.dispatchEvent( new CustomEvent("gps-camera-update-position", { detail: { position: this.currentCoords, origin: this.originCoords }, }) ); }, /** * Returns distance in meters between camera and destination input. * * Assume we are using a metre-based projection. Not all 'metre-based' * projections give exact metres, e.g. Spherical Mercator, but it appears * close enough to be used for AR at least in middle temperate * latitudes (40 - 55). It is heavily distorted near the poles, however. * * @param {Position} dest * @param {Boolean} isPlace * * @returns {number} distance | Number.MAX_SAFE_INTEGER */ computeDistanceMeters: function (dest, isPlace) { var src = this.el.getAttribute("position"); var dx = dest.x - src.x; var dz = dest.z - src.z; var distance = Math.sqrt(dx * dx + dz * dz); // if function has been called for a place, and if it's too near and a min distance has been set, // return max distance possible - to be handled by the method caller if ( isPlace && this.data.minDistance && this.data.minDistance > 0 && distance < this.data.minDistance ) { return Number.MAX_SAFE_INTEGER; } return distance; }, /** * Converts latitude/longitude to OpenGL world coordinates. * * First projects lat/lon to absolute Spherical Mercator and then * calculates the world coordinates by comparing the Spherical Mercator * coordinates with the Spherical Mercator coordinates of the origin point. * * @param {Number} lat * @param {Number} lon * * @returns {array} world coordinates */ latLonToWorld: function (lat, lon) { var projected = this._project(lat, lon); // Sign of z needs to be reversed compared to projected coordinates return [ projected[0] - this.originCoords[0], -(projected[1] - this.originCoords[1]), ]; }, /** * Converts latitude/longitude to Spherical Mercator coordinates. * Algorithm is used in several OpenStreetMap-related applications. * * @param {Number} lat * @param {Number} lon * * @returns {array} Spherical Mercator coordinates */ _project: function (lat, lon) { const HALF_EARTH = 20037508.34; // Convert the supplied coords to Spherical Mercator (EPSG:3857), also // known as 'Google Projection', using the algorithm used extensively // in various OpenStreetMap software. var y = Math.log(Math.tan(((90 + lat) * Math.PI) / 360.0)) / (Math.PI / 180.0); return [(lon / 180.0) * HALF_EARTH, (y * HALF_EARTH) / 180.0]; }, /** * Converts Spherical Mercator coordinates to latitude/longitude. * Algorithm is used in several OpenStreetMap-related applications. * * @param {Number} spherical mercator easting * @param {Number} spherical mercator northing * * @returns {object} lon/lat */ _unproject: function (e, n) { const HALF_EARTH = 20037508.34; var yp = (n / HALF_EARTH) * 180.0; return { longitude: (e / HALF_EARTH) * 180.0, latitude: (180.0 / Math.PI) * (2 * Math.atan(Math.exp((yp * Math.PI) / 180.0)) - Math.PI / 2), }; }, /** * Compute compass heading. * * @param {number} alpha * @param {number} beta * @param {number} gamma * * @returns {number} compass heading */ _computeCompassHeading: function (alpha, beta, gamma) { // Convert degrees to radians var alphaRad = alpha * (Math.PI / 180); var betaRad = beta * (Math.PI / 180); var gammaRad = gamma * (Math.PI / 180); // Calculate equation components var cA = Math.cos(alphaRad); var sA = Math.sin(alphaRad); var sB = Math.sin(betaRad); var cG = Math.cos(gammaRad); var sG = Math.sin(gammaRad); // Calculate A, B, C rotation components var rA = -cA * sG - sA * sB * cG; var rB = -sA * sG + cA * sB * cG; // Calculate compass heading var compassHeading = Math.atan(rA / rB); // Convert from half unit circle to whole unit circle if (rB < 0) { compassHeading += Math.PI; } else if (rA < 0) { compassHeading += 2 * Math.PI; } // Convert radians to degrees compassHeading *= 180 / Math.PI; return compassHeading; }, /** * Handler for device orientation event. * * @param {Event} event * @returns {void} */ _onDeviceOrientation: function (event) { if (event.webkitCompassHeading !== undefined) { if (event.webkitCompassAccuracy < 50) { this.heading = event.webkitCompassHeading; } else { console.warn("webkitCompassAccuracy is event.webkitCompassAccuracy"); } } else if (event.alpha !== null) { if (event.absolute === true || event.absolute === undefined) { this.heading = this._computeCompassHeading( event.alpha, event.beta, event.gamma ); } else { console.warn("event.absolute === false"); } } else { console.warn("event.alpha === null"); } }, /** * Update user rotation data. * * @returns {void} */ _updateRotation: function () { var heading = 360 - this.heading; var cameraRotation = this.el.getAttribute("rotation").y; var yawRotation = THREE.Math.radToDeg( this.lookControls.yawObject.rotation.y ); var offset = (heading - (cameraRotation - yawRotation)) % 360; this.lookControls.yawObject.rotation.y = THREE.Math.degToRad(offset); }, /** * Calculate haversine distance between two lat/lon pairs. * * Taken from gps-camera */ _haversineDist: function (src, dest) { var dlongitude = THREE.Math.degToRad(dest.longitude - src.longitude); var dlatitude = THREE.Math.degToRad(dest.latitude - src.latitude); var a = Math.sin(dlatitude / 2) * Math.sin(dlatitude / 2) + Math.cos(THREE.Math.degToRad(src.latitude)) * Math.cos(THREE.Math.degToRad(dest.latitude)) * (Math.sin(dlongitude / 2) * Math.sin(dlongitude / 2)); var angle = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return angle * 6371000; }, _onGpsEntityPlaceAdded: function () { // if places are added after camera initialization is finished if (this.originCoords) { window.dispatchEvent(new CustomEvent("gps-camera-origin-coord-set")); } if (this.loader && this.loader.parentElement) { document.body.removeChild(this.loader); } }, });