/* * 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"; import * as THREE from "three"; AFRAME.registerComponent("gps-camera", { _watchPositionId: null, originCoords: null, 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, }, maxDistance: { type: "int", default: 0, }, gpsMinDistance: { type: "number", default: 5, }, 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 this.originCoords = this.currentCoords; this._setPosition(); var loader = document.querySelector(".arjs-loader"); if (loader) { loader.remove(); } window.dispatchEvent(new CustomEvent("gps-camera-origin-coord-set")); } else { this._setPosition(); } }, _setPosition: function () { var position = this.el.getAttribute("position"); // compute position.x var dstCoords = { longitude: this.currentCoords.longitude, latitude: this.originCoords.latitude, }; position.x = this.computeDistanceMeters(this.originCoords, dstCoords); position.x *= this.currentCoords.longitude > this.originCoords.longitude ? 1 : -1; // compute position.z var dstCoords = { longitude: this.originCoords.longitude, latitude: this.currentCoords.latitude, }; position.z = this.computeDistanceMeters(this.originCoords, dstCoords); position.z *= this.currentCoords.latitude > this.originCoords.latitude ? -1 : 1; // update position this.el.setAttribute("position", position); window.dispatchEvent( new CustomEvent("gps-camera-update-position", { detail: { position: this.currentCoords, origin: this.originCoords }, }) ); }, /** * Returns distance in meters between source and destination inputs. * * Calculate distance, bearing and more between Latitude/Longitude points * Details: https://www.movable-type.co.uk/scripts/latlong.html * * @param {Position} src * @param {Position} dest * @param {Boolean} isPlace * * @returns {number} distance | Number.MAX_SAFE_INTEGER */ computeDistanceMeters: function (src, dest, isPlace) { var distance = this._haversineDist(src, dest); // 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 caller if ( isPlace && this.data.minDistance && this.data.minDistance > 0 && distance < this.data.minDistance ) { return Number.MAX_SAFE_INTEGER; } // if function has been called for a place, and if it's too far and a max distance has been set, // return max distance possible - to be handled by the caller if ( isPlace && this.data.maxDistance && this.data.maxDistance > 0 && distance > this.data.maxDistance ) { return Number.MAX_SAFE_INTEGER; } return distance; }, _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; }, /** * 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); }, _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); } }, });