import { SphMercProjection } from "./sphmerc-projection.js";
import * as THREE from "three";
/** The main class for the LocAR.js system. */
class LocationBased {
#proj;
#eventHandlers;
#lastCoords;
#gpsMinDistance;
#gpsMinAccuracy;
#watchPositionId;
#initialPosition;
/**
* @param {THREE.Scene} scene - The Three.js scene to use.
* @param {THREE.Camera} camera - The Three.js camera to use. Should usually
* be a THREE.PerspectiveCamera.
* @param {Object} options - Initialisation options for the GPS; see
* setGpsOptions() below.
*/
constructor(scene, camera, options = {}) {
this.scene = scene;
this.camera = camera;
this.#proj = new SphMercProjection();
this.#eventHandlers = {};
this.#lastCoords = null;
this.#gpsMinDistance = 0;
this.#gpsMinAccuracy = 100;
this.#watchPositionId = null;
this.setGpsOptions(options);
this.#initialPosition = null;
}
/**
* Set the projection to use.
* @param {Object} any object which includes a project() method
* taking longitude and latitude as arguments and returning an array
* containing easting and northing.
*/
setProjection(proj) {
this.#proj = proj;
}
/**
* Set the GPS options.
* @param {Object} object containing gpsMinDistance and/or gpsMinAccuracy
* properties. The former specifies the number of metres which the device
* must move to process a new GPS reading, and the latter specifies the
* minimum accuracy, in metres, for a GPS reading to be counted.
*/
setGpsOptions(options = {}) {
if (options.gpsMinDistance !== undefined) {
this.#gpsMinDistance = options.gpsMinDistance;
}
if (options.gpsMinAccuracy !== undefined) {
this.#gpsMinAccuracy = options.gpsMinAccuracy;
}
}
/**
* Start the GPS on a real device
* @return {boolean} code indicating whether the GPS was started successfully.
* GPS errors can be handled by handling the gpserror event.
*/
startGps() {
if (this.#watchPositionId === null) {
this.#watchPositionId = navigator.geolocation.watchPosition(
(position) => {
this.#gpsReceived(position);
},
(error) => {
if (this.#eventHandlers["gpserror"]) {
/**
* GPS error event.
* @event LocationBased#gpserror
* @param {number} code - the Geolocation API numerical error code.
*/
this.#eventHandlers["gpserror"](error.code);
} else {
alert(`GPS error: code ${error.code}`);
}
},
{
enableHighAccuracy: true,
}
);
return true;
}
return false;
}
/**
* Stop the GPS on a real device
* @return {boolean} true if the GPS was stopped, false if it could not be
* stopped (i.e. it was never started).
*/
stopGps() {
if (this.#watchPositionId !== null) {
navigator.geolocation.clearWatch(this.#watchPositionId);
this.#watchPositionId = null;
return true;
}
return false;
}
/**
* Send a fake GPS signal. Useful for testing on a desktop or laptop.
* @param {number} lon - The longitude.
* @param {number} lat - The latitude.
* @param {number} elev - The elevation in metres. (optional, set to null
* for no elevation).
* @param {number} acc - The accuracy of the GPS reading in metres. May be
* ignored if lower than the specified minimum accuracy.
*/
fakeGps(lon, lat, elev = null, acc = 0) {
if (elev !== null) {
this.setElevation(elev);
}
this.#gpsReceived({
coords: {
longitude: lon,
latitude: lat,
accuracy: acc
},
});
}
/**
* Convert longitude and latitude to three.js/WebGL world coordinates.
* Uses the specified projection, and negates the northing (in typical
* projections, northings increase northwards, but in the WebGL coordinate
* system, we face negative z if the camera is at the origin with default
* rotation).
* @param {number} lon - The longitude.
* @param {number} lat - The latitude.
* @return {Array} a two member array containing the WebGL x and z coordinates
*/
lonLatToWorldCoords(lon, lat) {
const projectedPos = this.#proj.project(lon, lat);
if (this.#initialPosition) {
projectedPos[0] -= this.#initialPosition[0];
projectedPos[1] -= this.#initialPosition[1];
} else {
throw "No initial position determined";
}
return [projectedPos[0], -projectedPos[1]];
}
/**
* Add a new AR object at a given latitude, longitude and elevation.
* @param {THREE.Mesh} object the object
* @param {number} lon - the longitude.
* @param {number} lat - the latitude.
* @param {number} elev - the elevation in metres
* (if not specified, 0 is assigned)
* @param {Object} properties - properties describing the object (for example,
* the contents of the GeoJSON properties field).
*/
add(object, lon, lat, elev, properties = { }) {
object.properties = properties;
this.#setWorldPosition(object, lon, lat, elev);
this.scene.add(object);
}
#setWorldPosition(object, lon, lat, elev) {
const worldCoords = this.lonLatToWorldCoords(lon, lat);
if (elev !== undefined) {
object.position.y = elev;
}
[object.position.x, object.position.z] = worldCoords;
}
/**
* Set the elevation (y coordinate) of the camera.
* @param {number} elev - the elevation in metres.
*/
setElevation(elev) {
this.camera.position.y = elev;
}
/**
* Add an event handler.
* Currently-understood events: "gpsupdate" and "gpserror".
* The former fires when a GPS update is received, and is passed the
* standard Geolocation API position object, along with the distance moved
* since the last GPS update in metres.
* The latter fires when a GPS error is generated, and is passed the
* standard Geolocation API numerical error code.
* @param {string} eventName - the event to handle.
* @param {Function} eventHandler - the event handler function.
* @listens LocationBased#gpsupdate
* @listens LocationBased#gpserror
*/
on(eventName, eventHandler) {
this.#eventHandlers[eventName] = eventHandler;
}
#setWorldOrigin(lon, lat) {
this.#initialPosition = this.#proj.project(lon, lat);
}
#gpsReceived(position) {
let distMoved = Number.MAX_VALUE;
console.log(`#gpsReceived(): position ${position.coords.latitude},${position.coords.longitude}, accuracy ${position.coords.accuracy}, accuracy limit ${this.#gpsMinAccuracy}`);
if (position.coords.accuracy <= this.#gpsMinAccuracy) {
if (this.#lastCoords === null) {
this.#lastCoords = {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
};
} else {
distMoved = this.#haversineDist(this.#lastCoords, position.coords);
}
if (distMoved >= this.#gpsMinDistance) {
this.#lastCoords.longitude = position.coords.longitude;
this.#lastCoords.latitude = position.coords.latitude;
if (!this.initialPosition) {
this.#setWorldOrigin(
position.coords.longitude,
position.coords.latitude
);
}
this.#setWorldPosition(
this.camera,
position.coords.longitude,
position.coords.latitude
);
/**
* GPS update event.
* @event LocationBased#gpsupdate
* @param {object} position -the Geolocation API position object.
* @param {number} distMoved - the distance moved in metres since the
* last GPS update.
*/
if (this.#eventHandlers["gpsupdate"]) {
this.#eventHandlers["gpsupdate"](position, distMoved);
}
}
}
}
/**
* Calculate haversine distance between two lat/lon pairs.
*
* Taken from original A-Frame AR.js location-based components
*/
#haversineDist(src, dest) {
const dlongitude = THREE.MathUtils.degToRad(dest.longitude - src.longitude);
const dlatitude = THREE.MathUtils.degToRad(dest.latitude - src.latitude);
const a =
Math.sin(dlatitude / 2) * Math.sin(dlatitude / 2) +
Math.cos(THREE.MathUtils.degToRad(src.latitude)) *
Math.cos(THREE.MathUtils.degToRad(dest.latitude)) *
(Math.sin(dlongitude / 2) * Math.sin(dlongitude / 2));
const angle = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return angle * 6371000;
}
}
export { LocationBased };