From 3c7ead2d98cccc276059f1830694b07c1561cac2 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Sun, 9 Apr 2023 10:05:00 -0400 Subject: [PATCH] chore(gdds-geohash): Geohash polish --- .../src/geohash/geohash-functions.md | 92 ++++++++ modules/dggs-geohash/src/geohash/geohash.ts | 107 +++++++++ modules/dggs-geohash/src/geohash/index.ts | 10 + .../src/geohash/latlon-geohash.js | 222 ++++++++++++++++++ 4 files changed, 431 insertions(+) create mode 100644 modules/dggs-geohash/src/geohash/geohash-functions.md create mode 100644 modules/dggs-geohash/src/geohash/geohash.ts create mode 100644 modules/dggs-geohash/src/geohash/index.ts create mode 100644 modules/dggs-geohash/src/geohash/latlon-geohash.js diff --git a/modules/dggs-geohash/src/geohash/geohash-functions.md b/modules/dggs-geohash/src/geohash/geohash-functions.md new file mode 100644 index 00000000..c61ff2f4 --- /dev/null +++ b/modules/dggs-geohash/src/geohash/geohash-functions.md @@ -0,0 +1,92 @@ +# Geohash Functions + +Note: This page should be moved to documentation repo. + +Geohash: Gustavo Niemeyer’s geocoding system. + +### API Reference + +##### geohashIsValid + +Check if a value is a string representing an geohash + +```typescript +geohashIsValid(value: string): boolean; +``` + +- `value` value to test +- Returns false if not a geohash, true means it can be a geohash + +##### geoToGeohash + +Encode latitude/longitude to geohash, either to specified precision or to automatically +evaluated precision. + +```typescript +geoToGeohash(lngLat: number[], precision?: number): string; +geoToGeohash(lng: number, lat: number, precision?: number): string; +``` + +- `lat` - Latitude in degrees. +- `lon` - Longitude in degrees. +- `precision` - Number of characters in resulting geohash. + +- Returns Geohash of supplied latitude/longitude + +```typescript +const geohash = geoToGeohash([0.119, 52.205], 7); // => 'u120fxw' +``` + +##### geohashToGeo + +Decode geohash to latitude/longitude (location is approximate centre of geohash cell, +to reasonable precision). + +```typescript +geohashToGeo(geohash: string): number[]; +``` + +- `geohash` - Geohash string to be converted to latitude/longitude. +- Returns `[lng, lat]` Center of geohashed location. + +```typescript +const latlon = Geohash.decode('u120fxw'); // => { lat: 52.205, lon: 0.1188 } +``` + +##### geohashToBounds + +Returns SW/NE latitude/longitude bounds of specified geohash. + +```typescript +geohashToBounds(geohash: string, closed?: boolean): geojson; +``` + +- `geohash` - Cell that bounds are required of. +- `closed` - Whether first vertex should be duplicated at end + +- Returns Polygon representing bounds of geohash + +##### geohashToNeighbor + +Determines adjacent cell in given direction. + +```typescript +geohashToNeighbor(geohash: string, direction: string): string; +``` + +- `geohash` - Cell to which adjacent cell is required. +- `direction` - Direction from geohash (`'N'`/`'S'`/`'E'`/`'W'`). + +- Returns Geocode of adjacent cell. + +##### geohashToNeighbors + +Returns all 8 adjacent cells to specified geohash. + +```typescript +geohashToNeighbors(geohash: string): string[]; +``` + +- `geohash` - Geohash neighbors are required of. + +- Returns array of 8 adjacent geohashes diff --git a/modules/dggs-geohash/src/geohash/geohash.ts b/modules/dggs-geohash/src/geohash/geohash.ts new file mode 100644 index 00000000..1744f1bb --- /dev/null +++ b/modules/dggs-geohash/src/geohash/geohash.ts @@ -0,0 +1,107 @@ +// Copyright 2022 Foursquare Labs, Inc. All Rights Reserved. + +import Geohash from './latlon-geohash'; + +const BASE32_GEOHASH_REGEX = /^[0-9bcdefghjkmnpqrstuvwxyz]+$/; + +/** + * Check if a value is a string representing an geohash + * @returns false if not a geohash, true means it can be a geohash + */ +export function geohashIsValid(geohash: unknown): boolean { + return typeof geohash === 'string' && BASE32_GEOHASH_REGEX.test(geohash); +} +/** + * Encodes latitude/longitude to geohash, either to specified precision or to automatically + * evaluated precision. + * + * @param lng Longitude in degrees. + * @param lat Latitude in degrees. + * @param precision Number of characters in resulting geohash. + * @returns Geohash of supplied latitude/longitude. + * @throws Invalid geohash. + * + * @example + * const geohash = geoToGeohash([0.119, 52.205], 7); // => 'u120fxw' + */ +export function geoToGeohash(lngLat: [number, number], precision?: number): string; +export function geoToGeohash(lng: number, lat: number, precision?: number): string; +export function geoToGeohash( + lng: number | [number, number], + latOrPrecision?: number, + precision?: number +): string { + if (Array.isArray(lng)) { + return Geohash.encode(lng[1], lng[0], latOrPrecision); + } + return Geohash.encode(Number(latOrPrecision), lng, precision); +} + +/** + * Decode geohash to latitude/longitude (location is approximate centre of geohash cell, + * to reasonable precision). + * + * @param geohash - Geohash string to be converted to latitude/longitude. + * @returns [lng, lat] Center of geohashed location. + * @throws Invalid geohash + */ +export function geohashToGeo(geohash: string): [number, number] { + const latlng = Geohash.decode(geohash); + return [latlng.lon, latlng.lat]; +} + +/** + * Returns SW/NE lng/lat bounds of specified geohash. + * + * @param geohash - Cell that bounds are required of. + * @param closed - Whether first vertex should be duplicated at end + * @returns Polygon representing bounds of geohash, in [lng, lat] order + * @throws Invalid geohash. + */ +export function geohashToBounds(geohash: string): [number, number][] { + const extents = Geohash.bounds(geohash); + return [ + [extents.sw.lon, extents.sw.lat], + [extents.sw.lon, extents.ne.lat], + [extents.ne.lon, extents.ne.lat], + [extents.ne.lon, extents.sw.lat], + // close polygon + [extents.sw.lon, extents.sw.lat] + ]; +} +/** + * Determines adjacent cell in given direction. + * + * @param geohash - Cell to which adjacent cell is required. + * @param direction - Direction from geohash (n/s/e/w). + * @returns Geocode of adjacent cell. + * @throws Invalid geohash. + */ +export function geohashToNeighbor(geohash: string, direction: 'n' | 's' | 'e' | 'w'): string { + return Geohash.adjacent(geohash, direction); +} + +/** + * Returns all 8 adjacent cells to specified geohash. + * + * @param geohash - Geohash neighbors are required of. + * @returns array of 8 adjacent geohashes + * @throws Invalid geohash. + */ +export function geohashToNeighbors(geohash: string): string[] { + const n = geohashToNeighbor(geohash, 'n'); + const e = geohashToNeighbor(geohash, 'e'); + const s = geohashToNeighbor(geohash, 's'); + const w = geohashToNeighbor(geohash, 'w'); + + return [ + n, + geohashToNeighbor(n, 'e'), + e, + geohashToNeighbor(s, 'e'), + s, + geohashToNeighbor(s, 'w'), + w, + geohashToNeighbor(n, 'w') + ]; +} diff --git a/modules/dggs-geohash/src/geohash/index.ts b/modules/dggs-geohash/src/geohash/index.ts new file mode 100644 index 00000000..98b0cc6d --- /dev/null +++ b/modules/dggs-geohash/src/geohash/index.ts @@ -0,0 +1,10 @@ +// Copyright 2022 Foursquare Labs, Inc. All Rights Reserved. + +export { + geohashIsValid, + geoToGeohash, + geohashToGeo, + geohashToBounds, + geohashToNeighbor, + geohashToNeighbors +} from './geohash'; diff --git a/modules/dggs-geohash/src/geohash/latlon-geohash.js b/modules/dggs-geohash/src/geohash/latlon-geohash.js new file mode 100644 index 00000000..bab14d5d --- /dev/null +++ b/modules/dggs-geohash/src/geohash/latlon-geohash.js @@ -0,0 +1,222 @@ +// Copyright 2022 Foursquare Labs, Inc. All Rights Reserved. + +// External code: copied due to issues with importing library from npm + +/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ +/* Geohash encoding/decoding and associated functions (c) Chris Veness 2014-2019 / MIT Licence */ +/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ + +const base32 = '0123456789bcdefghjkmnpqrstuvwxyz'; // (geohash-specific) Base32 map + +/** + * Geohash: Gustavo Niemeyer’s geocoding system. + */ +export default class Geohash { + /** + * Encodes latitude/longitude to geohash, either to specified precision or to automatically + * evaluated precision. + * + * @param {number} lat - Latitude in degrees. + * @param {number} lon - Longitude in degrees. + * @param {number} [precision] - Number of characters in resulting geohash. + * @returns {string} Geohash of supplied latitude/longitude. + * @throws Invalid geohash. + * + * @example + * const geohash = Geohash.encode(52.205, 0.119, 7); // => 'u120fxw' + */ + // eslint-disable-next-line max-statements, complexity + static encode(lat, lon, precision) { + // infer precision? + if (typeof precision === 'undefined') { + // refine geohash until it matches precision of supplied lat/lon + for (let p = 1; p <= 12; p++) { + const hash = Geohash.encode(lat, lon, p); + const posn = Geohash.decode(hash); + if (posn.lat === lat && posn.lon === lon) return hash; + } + precision = 12; // set to maximum + } + + lat = Number(lat); + lon = Number(lon); + precision = Number(precision); + + if (isNaN(lat) || isNaN(lon) || isNaN(precision)) throw new Error('Invalid geohash'); + + let idx = 0; // index into base32 map + let bit = 0; // each char holds 5 bits + let evenBit = true; + let geohash = ''; + + let latMin = -90; + let latMax = 90; + let lonMin = -180; + let lonMax = 180; + + while (geohash.length < precision) { + if (evenBit) { + // bisect E-W longitude + const lonMid = (lonMin + lonMax) / 2; + if (lon >= lonMid) { + idx = idx * 2 + 1; + lonMin = lonMid; + } else { + idx = idx * 2; + lonMax = lonMid; + } + } else { + // bisect N-S latitude + const latMid = (latMin + latMax) / 2; + if (lat >= latMid) { + idx = idx * 2 + 1; + latMin = latMid; + } else { + idx = idx * 2; + latMax = latMid; + } + } + evenBit = !evenBit; + + if (++bit === 5) { + // 5 bits gives us a character: append it and start over + geohash += base32.charAt(idx); + bit = 0; + idx = 0; + } + } + + return geohash; + } + + /** + * Decode geohash to latitude/longitude (location is approximate centre of geohash cell, + * to reasonable precision). + * + * @param {string} geohash - Geohash string to be converted to latitude/longitude. + * @returns {{lat:number, lon:number}} (Center of) geohashed location. + * @throws Invalid geohash. + * + * @example + * const latlon = Geohash.decode('u120fxw'); // => { lat: 52.205, lon: 0.1188 } + */ + static decode(geohash) { + const bounds = Geohash.bounds(geohash); // <-- the hard work + // now just determine the centre of the cell... + + const latMin = bounds.sw.lat; + const lonMin = bounds.sw.lon; + const latMax = bounds.ne.lat; + const lonMax = bounds.ne.lon; + + // cell centre + let lat = (latMin + latMax) / 2; + let lon = (lonMin + lonMax) / 2; + + // round to close to centre without excessive precision: ⌊2-log10(Δ°)⌋ decimal places + lat = Number(lat.toFixed(Math.floor(2 - Math.log(latMax - latMin) / Math.LN10))); + lon = Number(lon.toFixed(Math.floor(2 - Math.log(lonMax - lonMin) / Math.LN10))); + + return {lat, lon}; + } + + /** + * Returns SW/NE latitude/longitude bounds of specified geohash. + * + * @param {string} geohash - Cell that bounds are required of. + * @returns {{sw: {lat: number, lon: number}, ne: {lat: number, lon: number}}} + * @throws Invalid geohash. + */ + static bounds(geohash) { + if (geohash.length === 0) throw new Error('Invalid geohash'); + + geohash = geohash.toLowerCase(); + + let evenBit = true; + let latMin = -90; + let latMax = 90; + let lonMin = -180; + let lonMax = 180; + + for (let i = 0; i < geohash.length; i++) { + const chr = geohash.charAt(i); + const idx = base32.indexOf(chr); + if (idx === -1) throw new Error('Invalid geohash'); + + for (let n = 4; n >= 0; n--) { + const bitN = (idx >> n) & 1; + if (evenBit) { + // longitude + const lonMid = (lonMin + lonMax) / 2; + // eslint-disable-next-line max-depth + if (bitN === 1) { + lonMin = lonMid; + } else { + lonMax = lonMid; + } + } else { + // latitude + const latMid = (latMin + latMax) / 2; + // eslint-disable-next-line max-depth + if (bitN === 1) { + latMin = latMid; + } else { + latMax = latMid; + } + } + evenBit = !evenBit; + } + } + + const bounds = { + sw: {lat: latMin, lon: lonMin}, + ne: {lat: latMax, lon: lonMax} + }; + + return bounds; + } + + /** + * Determines adjacent cell in given direction. + * + * @param geohash - Cell to which adjacent cell is required. + * @param direction - Direction from geohash (N/S/E/W). + * @returns {string} Geocode of adjacent cell. + * @throws Invalid geohash. + */ + static adjacent(geohash, direction) { + // based on github.com/davetroy/geohash-js + + geohash = geohash.toLowerCase(); + direction = direction.toLowerCase(); + + if (geohash.length === 0) throw new Error('Invalid geohash'); + if ('nsew'.indexOf(direction) === -1) throw new Error('Invalid direction'); + + const neighbor = { + n: ['p0r21436x8zb9dcf5h7kjnmqesgutwvy', 'bc01fg45238967deuvhjyznpkmstqrwx'], + s: ['14365h7k9dcfesgujnmqp0r2twvyx8zb', '238967debc01fg45kmstqrwxuvhjyznp'], + e: ['bc01fg45238967deuvhjyznpkmstqrwx', 'p0r21436x8zb9dcf5h7kjnmqesgutwvy'], + w: ['238967debc01fg45kmstqrwxuvhjyznp', '14365h7k9dcfesgujnmqp0r2twvyx8zb'] + }; + const border = { + n: ['prxz', 'bcfguvyz'], + s: ['028b', '0145hjnp'], + e: ['bcfguvyz', 'prxz'], + w: ['0145hjnp', '028b'] + }; + + const lastCh = geohash.slice(-1); // last character of hash + let parent = geohash.slice(0, -1); // hash without last character + + const type = geohash.length % 2; + + // check for edge-cases which don't share common prefix + if (border[direction][type].indexOf(lastCh) !== -1 && parent !== '') { + parent = Geohash.adjacent(parent, direction); + } + + // append letter for direction to parent + return parent + base32.charAt(neighbor[direction][type].indexOf(lastCh)); + } +}