x5-geometry
Version:
Geometry and word processing utilities for XNet
269 lines (268 loc) • 10 kB
JavaScript
;
// TypeScript library for X5, XNET's hexagonal mapping system
// Core geometry functions
// Created by Sint Connexa, 18 August 2022
Object.defineProperty(exports, "__esModule", { value: true });
exports.earthR = void 0;
exports.haversine = haversine;
exports.travel = travel;
exports.gpsDiff = gpsDiff;
exports.sigmaTest = sigmaTest;
exports.encodeGPS = encodeGPS;
exports.decodeGPS = decodeGPS;
exports.normalizeLatLon = normalizeLatLon;
exports.validateLatLon = validateLatLon;
exports.vec2add = vec2add;
exports.vec2scale = vec2scale;
exports.latLon2ab = latLon2ab;
exports.ab2latLon = ab2latLon;
exports.grid2latLon = grid2latLon;
exports.ab2grid = ab2grid;
exports.latLon2grid = latLon2grid;
exports.grid2latLonHex = grid2latLonHex;
// Constants
const earthR = 6371e3; // radius of the Earth in meters
exports.earthR = earthR;
// haversine distance between two lat-lon points, value in meters.
// based on example provided by
// https://www.movable-type.co.uk/scripts/latlong.html
function haversine(lat1, lon1, lat2, lon2) {
// coerce arguments to numbers (may be strings)
lat1 = Number(lat1);
lat2 = Number(lat2);
lon1 = Number(lon1);
lon2 = Number(lon2);
const φ1 = lat1 * Math.PI / 180; // φ, λ in radians
const φ2 = lat2 * Math.PI / 180;
const Δφ = (lat2 - lat1) * Math.PI / 180;
const Δλ = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
Math.cos(φ1) * Math.cos(φ2) *
Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const d = earthR * c; // in metres
return d;
}
// given a lat/lon location and a two-dimensional displacement vector
// in meters, compute a new lat/lon. NOTE: accurate only for small
// (less than 100km) Δy if large Δx displacement involved.
function travel(lat1, lon1, Δx, Δy) {
// coerce arguments to numbers (may be strings)
lat1 = Number(lat1);
lon1 = Number(lon1);
Δy = Number(Δy);
Δx = Number(Δx);
const pi = Math.PI;
const cos = Math.cos;
const mlat = (1.0 / ((2.0 * pi / 360.0) * earthR)); //1 meter in one degree latitude
const mlon = mlat / cos(lat1 * (pi / 180.0)); //1 meter in one degree longitude
const lat2 = lat1 + Δy * mlat;
const lon2 = lon1 + Δx * mlon;
return [lat2, lon2];
}
// function to compute the distance between two packed latlong
// coordinate representations. Returns distance in meters
function gpsDiff(packed1, packed2) {
const latlon1 = decodeGPS(packed1);
const latlon2 = decodeGPS(packed2);
const d = haversine(latlon1[0], latlon1[1], latlon2[0], latlon2[1]);
return d;
}
// test GPS coordinates for lying within three sigma (99%) of each
// other. Default σ = 1.5m, 2σ (95%) = 3m, 3σ = 4.5m
// NOTE: 3σ implies that we expect one test out of 100 to fail due to
// error even when the true location has not changed. However, GPS
// measurement error is highly autocorrelated for stationary
// measurements, meaning that the true error-driven failure rate
// should be significantly lower.
function sigmaTest(packed1, packed2, σ = 1.5) {
return gpsDiff(packed1, packed2) < 3 * σ;
}
// Generate an offset hex grid between -lat and +lat, 0-360 long.
// boundaries are toroidal, "wrapping" at +-lat and 0/360 long.
// Grid geometry steps 3/2 unit in X and 1/2 unit in y for each cell.
// __ __
// / \__/2 \__ dir 2, "beta" = [ 3/2 l, 1/2 l ]
// \__/1 \_0/2 \ _
// /0 \_0/1 \_1/ /|
// \_0/0 \_1/1 \ /
// / \_1/0 \__/ %
// \__/ \_2/0 \ %
// / \__/ \_3/ _%/
// \__/ \__/ dir 1, "alpha" = [ 3/2 l, -1/2 l ]
// utility function, take two two-vectors and add them, return result
function vec2add(v1, v2) {
return [v1[0] + v2[0], v1[1] + v2[1]];
}
// utility function, take two two-vectors and subtract them, return result
function vec2sub(v1, v2) {
return [v1[0] - v2[0], v1[1] - v2[1]];
}
// utility function, scale a two-vector by a constant
function vec2scale(v1, a) {
return [v1[0] * a, v1[1] * a];
}
// Helper function to normalize latitude and longitude
function normalizeLatLon(lat, lon, maxLat = 70) {
// Normalize longitude to [-180, 180]
lon = ((lon + 180) % 360) - 180;
// Normalize latitude to [-maxLat, maxLat]
lat = lat > 0 ?
(lat + maxLat) % (2 * maxLat) - maxLat :
(lat - maxLat) % (2 * maxLat) + maxLat;
// Clamp latitude to [-maxLat, maxLat]
lat = Math.max(-maxLat, Math.min(maxLat, lat));
return [lat, lon];
}
// Helper function to validate lat/lon coordinates
function validateLatLon(lat, lon, maxLat = 70) {
if (lat < -maxLat || lat > maxLat) {
throw new Error(`Latitude must be between -${maxLat}° and ${maxLat}°`);
}
if (lon < -180 || lon > 180) {
throw new Error('Longitude must be between -180° and 180°');
}
}
// Helper function to encode GPS coordinates into a packed string format
function encodeGPS(lat, lon, maxLat = 70, step = 0.01, offset = 13034) {
// Validate input coordinates
validateLatLon(lat, lon, maxLat);
// Normalize coordinates
[lat, lon] = normalizeLatLon(lat, lon, maxLat);
// Convert lat/lon to grid coordinates
const grid = latLon2grid(lat, lon, maxLat, step, offset);
// Pack grid coordinates into a single number
const packed = (grid[0] << 16) | grid[1];
// Convert to base-36 for compact string representation
return packed.toString(36);
}
// Helper function to decode GPS coordinates from packed string format
function decodeGPS(packed, maxLat = 70, step = 0.01, offset = 13034) {
// Convert base-36 string back to number
const packedNum = parseInt(packed, 36);
// Unpack grid coordinates
const i = (packedNum >> 16) & 0xFFFF;
const j = packedNum & 0xFFFF;
// Convert grid coordinates to lat/lon
const [lat, lon] = grid2latLon(i, j, maxLat, step, offset);
// Normalize and return coordinates
return normalizeLatLon(lat, lon, maxLat);
}
// move rendered verticies a bit towards the geometric center of a
// polygon, as specified by parameter factor. Primarily intended for
// rendering adjacent polys in a non-overlapping way
function displaceIn(geom, factor = 0.03) {
var sum = [0, 0];
for (var i = 0; i < geom.length; i++) {
sum = vec2add(geom[i], sum);
}
const center = vec2scale(sum, 1.0 / geom.length);
for (var i = 0; i < geom.length; i++) {
const inward = vec2sub(center, geom[i]);
const diff = vec2scale(inward, factor);
geom[i] = vec2add(geom[i], diff);
}
return geom;
}
// transform a latitude/longitude position into coordinates in the
// alpha/beta skew coordinate system
function latLon2ab(lat, lon, maxLat = 70, step = 0.01) {
lat = lat > 0 ? (lat + maxLat) % (2 * maxLat) - maxLat :
(lat - maxLat) % (2 * maxLat) + maxLat; // torroidal latitude
lon = lon > 0 ? (lon + 180) % 360 - 180 :
(lon - 180) % 360 + 180; // torroidal longitude
const s = step / 2;
const b = (lat + lon / 3) / (2 * s);
const a = lon / (3 * s) - b;
return [a, b];
}
// given a coordinate in alpha/beta space, convert to latitude and
// longitude.
function ab2latLon(a, b, maxLat = 70, step = 0.01) {
const alpha = [(3 / 2) * step, (-1 / 2) * step];
const beta = [(3 / 2) * step, (1 / 2) * step];
const x = vec2add(vec2scale(alpha, a), vec2scale(beta, b));
return [(x[1] + maxLat) % (2 * maxLat) - maxLat,
(x[0] + 180) % 360 - 180];
}
// given a grid index in i,j, return the center of that hex
// in lat/long
function grid2latLon(i, j, maxLat = 70, step = 0.01, offset = 13034) {
const a = i - offset;
const b = j - offset;
return ab2latLon(a, b, maxLat, step);
}
// given a coordinate in alpha/beta space, determine which hex index
// it falls into. For the region of the globe from -70 to 70 latitude
// and with a step value of 0.01, the offset added to make the
// smallest index non-negative is 13034
function ab2grid(a, b, offset = 13034) {
const x = b - Math.floor(b);
const y = a - Math.floor(a);
var i, j;
const AB = -0.5 * x - y + 0.5;
if (AB > 0) {
const FA = -2 * x - y + 1;
if (FA > 0) { // REGION I
i = Math.floor(a);
j = Math.floor(b);
}
else { // REGION II
i = Math.floor(a);
j = Math.floor(b) + 1;
}
}
else {
const AH = x - y;
if (AH > 0) {
const GH = -0.5 * x - y + 1;
if (GH > 0) { // REGION II
i = Math.floor(a);
j = Math.floor(b) + 1;
}
else { // REGION III
i = Math.floor(a) + 1;
j = Math.floor(b) + 1;
}
}
else {
const HJ = -2 * x - y + 2;
if (HJ > 0) { // REGION IV
i = Math.floor(a) + 1;
j = Math.floor(b);
}
else { // REGION III
i = Math.floor(a) + 1;
j = Math.floor(b) + 1;
}
}
}
i += offset;
j += offset;
return [i, j];
}
// given a coordinate in lat/long space, find which hex index (i,j) it
// falls into
function latLon2grid(lat, lon, maxLat = 70, step = 0.01, offset = 13034) {
var grid = latLon2ab(lat, lon, maxLat, step);
return ab2grid(grid[0], grid[1], offset);
}
// given a grid i,j index, get the six points that compose the vertices
// of the hex in right-hand order
function grid2latLonHex(i, j, offset = 13034, maxLat = 70, step = 0.01) {
const a = i - offset;
const b = j - offset;
const abHex = [
[a + 1 / 3, b + 1 / 3],
[a - 1 / 3, b + 2 / 3],
[a - 2 / 3, b + 1 / 3],
[a - 1 / 3, b - 1 / 3],
[a + 1 / 3, b - 2 / 3],
[a + 2 / 3, b - 1 / 3]
];
var latLon = [];
for (var i = 0; i < abHex.length; i++) {
latLon.push(ab2latLon(abHex[i][0], abHex[i][1], maxLat, step));
}
return latLon;
}