org.opensearch.geometry.utils.Geohash Maven / Gradle / Ivy
/*
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*/
/*
* Licensed to Elasticsearch under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/*
* Modifications Copyright OpenSearch Contributors. See
* GitHub history for details.
*/
package org.opensearch.geometry.utils;
import org.opensearch.geometry.Point;
import org.opensearch.geometry.Rectangle;
import java.util.ArrayList;
import java.util.Collection;
/**
* Utilities for converting to/from the GeoHash standard
*
* The geohash long format is represented as lon/lat (x/y) interleaved with the 4 least significant bits
* representing the level (1-12) [xyxy...xyxyllll]
*
* This differs from a morton encoded value which interleaves lat/lon (y/x).
*
* NOTE: this will replace {@code org.opensearch.common.geo.GeoHashUtils}
*/
public class Geohash {
private static final char[] BASE_32 = {
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'b',
'c',
'd',
'e',
'f',
'g',
'h',
'j',
'k',
'm',
'n',
'p',
'q',
'r',
's',
't',
'u',
'v',
'w',
'x',
'y',
'z' };
private static final String BASE_32_STRING = new String(BASE_32);
/** maximum precision for geohash strings */
public static final int PRECISION = 12;
/** number of bits used for quantizing latitude and longitude values */
private static final short BITS = 32;
private static final double LAT_SCALE = (0x1L << (BITS - 1)) / 180.0D;
private static final double LAT_DECODE = 180.0D / (0x1L << BITS);
private static final double LON_SCALE = (0x1L << (BITS - 1)) / 360.0D;
private static final double LON_DECODE = 360.0D / (0x1L << BITS);
private static final short MORTON_OFFSET = (BITS << 1) - (PRECISION * 5);
/** Bit encoded representation of the latitude of north pole */
private static final long MAX_LAT_BITS = (0x1L << (PRECISION * 5 / 2)) - 1;
// Below code is adapted from the spatial4j library (GeohashUtils.java) Apache 2.0 Licensed
private static final double[] precisionToLatHeight, precisionToLonWidth;
static {
precisionToLatHeight = new double[PRECISION + 1];
precisionToLonWidth = new double[PRECISION + 1];
precisionToLatHeight[0] = 90 * 2;
precisionToLonWidth[0] = 180 * 2;
boolean even = false;
for (int i = 1; i <= PRECISION; i++) {
precisionToLatHeight[i] = precisionToLatHeight[i - 1] / (even ? 8 : 4);
precisionToLonWidth[i] = precisionToLonWidth[i - 1] / (even ? 4 : 8);
even = !even;
}
}
// no instance:
private Geohash() {}
/** Returns a {@link Point} instance from a geohash string */
public static Point toPoint(final String geohash) throws IllegalArgumentException {
final long hash = mortonEncode(geohash);
return new Point(decodeLongitude(hash), decodeLatitude(hash));
}
/**
* Computes the bounding box coordinates from a given geohash
*
* @param geohash Geohash of the defined cell
* @return GeoRect rectangle defining the bounding box
*/
public static Rectangle toBoundingBox(final String geohash) {
// bottom left is the coordinate
Point bottomLeft = toPoint(geohash);
int len = Math.min(12, geohash.length());
long ghLong = longEncode(geohash, len);
// shift away the level
ghLong >>>= 4;
// deinterleave
long lon = BitUtil.deinterleave(ghLong >>> 1);
long lat = BitUtil.deinterleave(ghLong);
final int shift = (12 - len) * 5 + 2;
if (lat < MAX_LAT_BITS) {
// add 1 to lat and lon to get topRight
ghLong = BitUtil.interleave((int) (lat + 1), (int) (lon + 1)) << 4 | len;
final long mortonHash = BitUtil.flipFlop((ghLong >>> 4) << shift);
Point topRight = new Point(decodeLongitude(mortonHash), decodeLatitude(mortonHash));
return new Rectangle(bottomLeft.getX(), topRight.getX(), topRight.getY(), bottomLeft.getY());
} else {
// We cannot go north of north pole, so just using 90 degrees instead of calculating it using
// add 1 to lon to get lon of topRight, we are going to use 90 for lat
ghLong = BitUtil.interleave((int) lat, (int) (lon + 1)) << 4 | len;
final long mortonHash = BitUtil.flipFlop((ghLong >>> 4) << shift);
Point topRight = new Point(decodeLongitude(mortonHash), decodeLatitude(mortonHash));
return new Rectangle(bottomLeft.getX(), topRight.getX(), 90D, bottomLeft.getY());
}
}
/** Array of geohashes one level below the baseGeohash. Sorted. */
public static String[] getSubGeohashes(String baseGeohash) {
String[] hashes = new String[BASE_32.length];
for (int i = 0; i < BASE_32.length; i++) {// note: already sorted
char c = BASE_32[i];
hashes[i] = baseGeohash + c;
}
return hashes;
}
/**
* Calculate all neighbors of a given geohash cell.
*
* @param geohash Geohash of the defined cell
* @return geohashes of all neighbor cells
*/
public static Collection extends CharSequence> getNeighbors(String geohash) {
return addNeighborsAtLevel(geohash, geohash.length(), new ArrayList(8));
}
/**
* Add all geohashes of the cells next to a given geohash to a list.
*
* @param geohash Geohash of a specified cell
* @param neighbors list to add the neighbors to
* @return the given list
*/
public static final > E addNeighbors(String geohash, E neighbors) {
return addNeighborsAtLevel(geohash, geohash.length(), neighbors);
}
/**
* Add all geohashes of the cells next to a given geohash to a list.
*
* @param geohash Geohash of a specified cell
* @param level level of the given geohash
* @param neighbors list to add the neighbors to
* @return the given list
*/
public static final > E addNeighborsAtLevel(String geohash, int level, E neighbors) {
String south = getNeighbor(geohash, level, 0, -1);
String north = getNeighbor(geohash, level, 0, +1);
if (north != null) {
neighbors.add(getNeighbor(north, level, -1, 0));
neighbors.add(north);
neighbors.add(getNeighbor(north, level, +1, 0));
}
neighbors.add(getNeighbor(geohash, level, -1, 0));
neighbors.add(getNeighbor(geohash, level, +1, 0));
if (south != null) {
neighbors.add(getNeighbor(south, level, -1, 0));
neighbors.add(south);
neighbors.add(getNeighbor(south, level, +1, 0));
}
return neighbors;
}
/**
* Calculate the geohash of a neighbor of a geohash
*
* @param geohash the geohash of a cell
* @param level level of the geohash
* @param dx delta of the first grid coordinate (must be -1, 0 or +1)
* @param dy delta of the second grid coordinate (must be -1, 0 or +1)
* @return geohash of the defined cell
*/
public static final String getNeighbor(String geohash, int level, int dx, int dy) {
int cell = BASE_32_STRING.indexOf(geohash.charAt(level - 1));
// Decoding the Geohash bit pattern to determine grid coordinates
int x0 = cell & 1; // first bit of x
int y0 = cell & 2; // first bit of y
int x1 = cell & 4; // second bit of x
int y1 = cell & 8; // second bit of y
int x2 = cell & 16; // third bit of x
// combine the bitpattern to grid coordinates.
// note that the semantics of x and y are swapping
// on each level
int x = x0 + (x1 / 2) + (x2 / 4);
int y = (y0 / 2) + (y1 / 4);
if (level == 1) {
// Root cells at north (namely "bcfguvyz") or at
// south (namely "0145hjnp") do not have neighbors
// in north/south direction
if ((dy < 0 && y == 0) || (dy > 0 && y == 3)) {
return null;
} else {
return Character.toString(encodeBase32(x + dx, y + dy));
}
} else {
// define grid coordinates for next level
final int nx = ((level % 2) == 1) ? (x + dx) : (x + dy);
final int ny = ((level % 2) == 1) ? (y + dy) : (y + dx);
// if the defined neighbor has the same parent a the current cell
// encode the cell directly. Otherwise find the cell next to this
// cell recursively. Since encoding wraps around within a cell
// it can be encoded here.
// xLimit and YLimit must always be respectively 7 and 3
// since x and y semantics are swapping on each level.
if (nx >= 0 && nx <= 7 && ny >= 0 && ny <= 3) {
return geohash.substring(0, level - 1) + encodeBase32(nx, ny);
} else {
String neighbor = getNeighbor(geohash, level - 1, dx, dy);
return (neighbor != null) ? neighbor + encodeBase32(nx, ny) : neighbor;
}
}
}
/**
* Encode a string geohash to the geohash based long format (lon/lat interleaved, 4 least significant bits = level)
*/
public static final long longEncode(String hash) {
return longEncode(hash, hash.length());
}
/**
* Encode lon/lat to the geohash based long format (lon/lat interleaved, 4 least significant bits = level)
*/
public static final long longEncode(final double lon, final double lat, final int level) {
// shift to appropriate level
final short msf = (short) (((12 - level) * 5) + (MORTON_OFFSET - 2));
return ((encodeLatLon(lat, lon) >>> msf) << 4) | level;
}
/**
* Encode to a geohash string from full resolution longitude, latitude)
*/
public static final String stringEncode(final double lon, final double lat) {
return stringEncode(lon, lat, 12);
}
/**
* Encode to a level specific geohash string from full resolution longitude, latitude
*/
public static final String stringEncode(final double lon, final double lat, final int level) {
// convert to geohashlong
long interleaved = encodeLatLon(lat, lon);
interleaved >>>= (((PRECISION - level) * 5) + (MORTON_OFFSET - 2));
final long geohash = (interleaved << 4) | level;
return stringEncode(geohash);
}
/**
* Encode to a geohash string from the geohash based long format
*/
public static final String stringEncode(long geoHashLong) {
int level = (int) geoHashLong & 15;
geoHashLong >>>= 4;
char[] chars = new char[level];
do {
chars[--level] = BASE_32[(int) (geoHashLong & 31L)];
geoHashLong >>>= 5;
} while (level > 0);
return new String(chars);
}
/** base32 encode at the given grid coordinate */
private static char encodeBase32(int x, int y) {
return BASE_32[((x & 1) + ((y & 1) * 2) + ((x & 2) * 2) + ((y & 2) * 4) + ((x & 4) * 4)) % 32];
}
/**
* Encode from geohash string to the geohash based long format (lon/lat interleaved, 4 least significant bits = level)
*/
private static long longEncode(final String hash, int length) {
int level = length - 1;
long b;
long l = 0L;
for (char c : hash.toCharArray()) {
b = (long) (BASE_32_STRING.indexOf(c));
l |= (b << (level-- * 5));
if (level < 0) {
// We cannot handle more than 12 levels
break;
}
}
return (l << 4) | length;
}
/**
* Encode to a morton long value from a given geohash string
*/
public static long mortonEncode(final String hash) {
if (hash.isEmpty()) {
throw new IllegalArgumentException("empty geohash");
}
int level = 11;
long b;
long l = 0L;
for (char c : hash.toCharArray()) {
b = (long) (BASE_32_STRING.indexOf(c));
if (b < 0) {
throw new IllegalArgumentException("unsupported symbol [" + c + "] in geohash [" + hash + "]");
}
l |= (b << ((level-- * 5) + (MORTON_OFFSET - 2)));
if (level < 0) {
// We cannot handle more than 12 levels
break;
}
}
return BitUtil.flipFlop(l);
}
/** approximate width of geohash tile for a specific precision in degrees */
public static double lonWidthInDegrees(int precision) {
return precisionToLonWidth[precision];
}
/** approximate height of geohash tile for a specific precision in degrees */
public static double latHeightInDegrees(int precision) {
return precisionToLatHeight[precision];
}
private static long encodeLatLon(final double lat, final double lon) {
// encode lat/lon flipping the sign bit so negative ints sort before positive ints
final int latEnc = encodeLatitude(lat) ^ 0x80000000;
final int lonEnc = encodeLongitude(lon) ^ 0x80000000;
return BitUtil.interleave(latEnc, lonEnc) >>> 2;
}
/** encode latitude to integer */
public static int encodeLatitude(double latitude) {
// the maximum possible value cannot be encoded without overflow
if (latitude == 90.0D) {
latitude = Math.nextDown(latitude);
}
return (int) Math.floor(latitude / LAT_DECODE);
}
/** encode longitude to integer */
public static int encodeLongitude(double longitude) {
// the maximum possible value cannot be encoded without overflow
if (longitude == 180.0D) {
longitude = Math.nextDown(longitude);
}
return (int) Math.floor(longitude / LON_DECODE);
}
/** returns the latitude value from the string based geohash */
public static final double decodeLatitude(final String geohash) {
return decodeLatitude(Geohash.mortonEncode(geohash));
}
/** returns the latitude value from the string based geohash */
public static final double decodeLongitude(final String geohash) {
return decodeLongitude(Geohash.mortonEncode(geohash));
}
/** decode longitude value from morton encoded geo point */
public static double decodeLongitude(final long hash) {
return unscaleLon(BitUtil.deinterleave(hash));
}
/** decode latitude value from morton encoded geo point */
public static double decodeLatitude(final long hash) {
return unscaleLat(BitUtil.deinterleave(hash >>> 1));
}
private static double unscaleLon(final long val) {
return (val / LON_SCALE) - 180;
}
private static double unscaleLat(final long val) {
return (val / LAT_SCALE) - 90;
}
}