package com.google.openlocationcode; import java.math.BigDecimal; import java.util.HashMap; import java.util.Map; /** * Representation of open location code. https://github.com/google/open-location-code The * OpenLocationCode class is a wrapper around String value {@code code}, which guarantees that the * value is a valid Open Location Code. * * @author Jiri Semecky */ public final class OpenLocationCode { private static final BigDecimal BD_0 = new BigDecimal(0); private static final BigDecimal BD_5 = new BigDecimal(5); private static final BigDecimal BD_4 = new BigDecimal(4); private static final BigDecimal BD_20 = new BigDecimal(20); private static final BigDecimal BD_90 = new BigDecimal(90); private static final BigDecimal BD_180 = new BigDecimal(180); private static final double LATITUDE_PRECISION_8_DIGITS = computeLatitudePrecision(8) / 4; private static final double LATITUDE_PRECISION_6_DIGITS = computeLatitudePrecision(6) / 4; private static final double LATITUDE_PRECISION_4_DIGITS = computeLatitudePrecision(4) / 4; private static final char[] ALPHABET = "23456789CFGHJMPQRVWX".toCharArray(); private static final Map<Character, Integer> CHARACTER_TO_INDEX = new HashMap<>(); static { int index = 0; for (char character : ALPHABET) { char lowerCaseCharacter = Character.toLowerCase(character); CHARACTER_TO_INDEX.put(character, index); CHARACTER_TO_INDEX.put(lowerCaseCharacter, index); index++; } } private static final char SEPARATOR = '+'; private static final char SEPARATOR_POSITION = 8; private static final char SUFFIX_PADDING = '0'; /** Class providing information about area covered by Open Location Code. */ public class CodeArea { private final BigDecimal southLatitude; private final BigDecimal westLongitude; private final BigDecimal northLatitude; private final BigDecimal eastLongitude; public CodeArea( BigDecimal southLatitude, BigDecimal westLongitude, BigDecimal northLatitude, BigDecimal eastLongitude) { this.southLatitude = southLatitude; this.westLongitude = westLongitude; this.northLatitude = northLatitude; this.eastLongitude = eastLongitude; } public double getSouthLatitude() { return southLatitude.doubleValue(); } public double getWestLongitude() { return westLongitude.doubleValue(); } public double getLatitudeHeight() { return northLatitude.subtract(southLatitude).doubleValue(); } public double getLongitudeWidth() { return eastLongitude.subtract(westLongitude).doubleValue(); } public double getCenterLatitude() { return southLatitude.add(northLatitude).doubleValue() / 2; } public double getCenterLongitude() { return westLongitude.add(eastLongitude).doubleValue() / 2; } public double getNorthLatitude() { return northLatitude.doubleValue(); } public double getEastLongitude() { return eastLongitude.doubleValue(); } } /** The state of the OpenLocationCode. */ private final String code; /** Creates Open Location Code for the provided code. */ public OpenLocationCode(String code) { if (!isValidCode(code)) { throw new IllegalArgumentException( "The provided code '" + code + "' is not a valid Open Location Code."); } this.code = code.toUpperCase(); } /** Creates Open Location Code from the provided latitude, longitude and desired code length. */ public OpenLocationCode(double latitude, double longitude, int codeLength) throws IllegalArgumentException { if (codeLength < 4 || (codeLength < 10 & codeLength % 2 == 1)) { throw new IllegalArgumentException("Illegal code length " + codeLength); } latitude = clipLatitude(latitude); longitude = normalizeLongitude(longitude); // Latitude 90 needs to be adjusted to be just less, so the returned code can also be decoded. if (latitude == 90) { latitude = latitude - 0.9 * computeLatitudePrecision(codeLength); } StringBuilder codeBuilder = new StringBuilder(); // Ensure the latitude and longitude are within [0, 180] and [0, 360) respectively. /* Note: double type can't be used because of the rounding arithmetic due to floating point * implementation. Eg. "8.95 - 8" can give result 0.9499999999999 instead of 0.95 which * incorrectly classify the points on the border of a cell. */ BigDecimal remainingLongitude = new BigDecimal(longitude + 180); BigDecimal remainingLatitude = new BigDecimal(latitude + 90); // Create up to 10 significant digits from pairs alternating latitude and longitude. int generatedDigits = 0; while (generatedDigits < codeLength) { // Always the integer part of the remaining latitude/longitude will be used for the following // digit. if (generatedDigits == 0) { // First step World division: Map <0..400) to <0..20) for both latitude and longitude. remainingLatitude = remainingLatitude.divide(BD_20); remainingLongitude = remainingLongitude.divide(BD_20); } else if (generatedDigits < 10) { remainingLatitude = remainingLatitude.multiply(BD_20); remainingLongitude = remainingLongitude.multiply(BD_20); } else { remainingLatitude = remainingLatitude.multiply(BD_5); remainingLongitude = remainingLongitude.multiply(BD_4); } int latitudeDigit = remainingLatitude.intValue(); int longitudeDigit = remainingLongitude.intValue(); if (generatedDigits < 10) { codeBuilder.append(ALPHABET[latitudeDigit]); codeBuilder.append(ALPHABET[longitudeDigit]); generatedDigits += 2; } else { codeBuilder.append(ALPHABET[4 * latitudeDigit + longitudeDigit]); generatedDigits += 1; } remainingLatitude = remainingLatitude.subtract(new BigDecimal(latitudeDigit)); remainingLongitude = remainingLongitude.subtract(new BigDecimal(longitudeDigit)); if (generatedDigits == SEPARATOR_POSITION) { codeBuilder.append(SEPARATOR); } } if (generatedDigits < SEPARATOR_POSITION) { for (; generatedDigits < SEPARATOR_POSITION; generatedDigits++) { codeBuilder.append(SUFFIX_PADDING); } codeBuilder.append(SEPARATOR); } this.code = codeBuilder.toString(); } /** Creates Open Location Code with code length 10 from the provided latitude, longitude. */ public OpenLocationCode(double latitude, double longitude) { this(latitude, longitude, 10); } public String getCode() { return code; } /** * Encodes latitude/longitude into 10 digit Open Location Code. This method is equivalent to * creating the OpenLocationCode object and getting the code from it. */ public static String encode(double latitude, double longitude) { return new OpenLocationCode(latitude, longitude).getCode(); } /** * Encodes latitude/longitude into Open Location Code of the provided length. This method is * equivalent to creating the OpenLocationCode object and getting the code from it. */ public static String encode(double latitude, double longitude, int codeLength) { return new OpenLocationCode(latitude, longitude, codeLength).getCode(); } /** * Decodes {@link OpenLocationCode} object into {@link CodeArea} object encapsulating * latitude/longitude bounding box. */ public CodeArea decode() { if (!isFullCode(code)) { throw new IllegalStateException( "Method decode() could only be called on valid full codes, code was " + code + "."); } String decoded = code.replaceAll("[0+]", ""); // Decode the lat/lng pair component. BigDecimal southLatitude = BD_0; BigDecimal westLongitude = BD_0; int digit = 0; double latitudeResolution = 400, longitudeResolution = 400; // Decode pair. while (digit < decoded.length()) { if (digit < 10) { latitudeResolution /= 20; longitudeResolution /= 20; southLatitude = southLatitude.add( new BigDecimal(latitudeResolution * CHARACTER_TO_INDEX.get(decoded.charAt(digit)))); westLongitude = westLongitude.add( new BigDecimal( longitudeResolution * CHARACTER_TO_INDEX.get(decoded.charAt(digit + 1)))); digit += 2; } else { latitudeResolution /= 5; longitudeResolution /= 4; southLatitude = southLatitude.add( new BigDecimal( latitudeResolution * (CHARACTER_TO_INDEX.get(decoded.charAt(digit)) / 4))); westLongitude = westLongitude.add( new BigDecimal( longitudeResolution * (CHARACTER_TO_INDEX.get(decoded.charAt(digit)) % 4))); digit += 1; } } return new CodeArea( southLatitude.subtract(BD_90), westLongitude.subtract(BD_180), southLatitude.subtract(BD_90).add(new BigDecimal(latitudeResolution)), westLongitude.subtract(BD_180).add(new BigDecimal(longitudeResolution))); } /** * Decodes code representing Open Location Code into {@link CodeArea} object encapsulating * latitude/longitude bounding box. * * @param code Open Location Code to be decoded. * @throws IllegalArgumentException if the provided code is not a valid Open Location Code. */ public static CodeArea decode(String code) throws IllegalArgumentException { return new OpenLocationCode(code).decode(); } /** Returns whether this {@link OpenLocationCode} is a full Open Location Code. */ public boolean isFull() { return code.indexOf(SEPARATOR) == SEPARATOR_POSITION; } /** Returns whether the provided Open Location Code is a full Open Location Code. */ public static boolean isFull(String code) throws IllegalArgumentException { return new OpenLocationCode(code).isFull(); } /** Returns whether this {@link OpenLocationCode} is a short Open Location Code. */ public boolean isShort() { return code.indexOf(SEPARATOR) >= 0 && code.indexOf(SEPARATOR) < SEPARATOR_POSITION; } /** Returns whether the provided Open Location Code is a short Open Location Code. */ public static boolean isShort(String code) throws IllegalArgumentException { return new OpenLocationCode(code).isShort(); } /** * Returns whether this {@link OpenLocationCode} is a padded Open Location Code, meaning that it * contains less than 8 valid digits. */ private boolean isPadded() { return code.indexOf(SUFFIX_PADDING) >= 0; } /** * Returns whether the provided Open Location Code is a padded Open Location Code, meaning that it * contains less than 8 valid digits. */ public static boolean isPadded(String code) throws IllegalArgumentException { return new OpenLocationCode(code).isPadded(); } /** * Returns short {@link OpenLocationCode} from the full Open Location Code created by removing * four or six digits, depending on the provided reference point. It removes as many digits as * possible. */ public OpenLocationCode shorten(double referenceLatitude, double referenceLongitude) { if (!isFull()) { throw new IllegalStateException("shorten() method could only be called on a full code."); } if (isPadded()) { throw new IllegalStateException("shorten() method can not be called on a padded code."); } CodeArea codeArea = decode(); double latitudeDiff = Math.abs(referenceLatitude - codeArea.getCenterLatitude()); double longitudeDiff = Math.abs(referenceLongitude - codeArea.getCenterLongitude()); if (latitudeDiff < LATITUDE_PRECISION_8_DIGITS && longitudeDiff < LATITUDE_PRECISION_8_DIGITS) { return new OpenLocationCode(code.substring(8)); } if (latitudeDiff < LATITUDE_PRECISION_6_DIGITS && longitudeDiff < LATITUDE_PRECISION_6_DIGITS) { return new OpenLocationCode(code.substring(6)); } if (latitudeDiff < LATITUDE_PRECISION_4_DIGITS && longitudeDiff < LATITUDE_PRECISION_4_DIGITS) { return new OpenLocationCode(code.substring(4)); } throw new IllegalArgumentException( "Reference location is too far from the Open Location Code center."); } /** * Returns an {@link OpenLocationCode} object representing a full Open Location Code from this * (short) Open Location Code, given the reference location. */ public OpenLocationCode recover(double referenceLatitude, double referenceLongitude) { if (isFull()) { // Note: each code is either full xor short, no other option. return this; } referenceLatitude = clipLatitude(referenceLatitude); referenceLongitude = normalizeLongitude(referenceLongitude); int digitsToRecover = 8 - code.indexOf(SEPARATOR); // The resolution (height and width) of the padded area in degrees. double paddedAreaSize = Math.pow(20, 2 - (digitsToRecover / 2)); // Use the reference location to pad the supplied short code and decode it. String recoveredPrefix = new OpenLocationCode(referenceLatitude, referenceLongitude) .getCode() .substring(0, digitsToRecover); OpenLocationCode recovered = new OpenLocationCode(recoveredPrefix + code); CodeArea recoveredCodeArea = recovered.decode(); double recoveredLatitude = recoveredCodeArea.getCenterLatitude(); double recoveredLongitude = recoveredCodeArea.getCenterLongitude(); // Move the recovered latitude by one resolution up or down if it is too far from the reference. double latitudeDiff = recoveredLatitude - referenceLatitude; if (latitudeDiff > paddedAreaSize / 2) { recoveredLatitude -= paddedAreaSize; } else if (latitudeDiff < -paddedAreaSize / 2) { recoveredLatitude += paddedAreaSize; } // Move the recovered longitude by one resolution up or down if it is too far from the // reference. double longitudeDiff = recoveredCodeArea.getCenterLongitude() - referenceLongitude; if (longitudeDiff > paddedAreaSize / 2) { recoveredLongitude -= paddedAreaSize; } else if (longitudeDiff < -paddedAreaSize / 2) { recoveredLongitude += paddedAreaSize; } return new OpenLocationCode( recoveredLatitude, recoveredLongitude, recovered.getCode().length() - 1); } /** * Returns whether the bounding box specified by the Open Location Code contains provided point. */ public boolean contains(double latitude, double longitude) { CodeArea codeArea = decode(); return codeArea.getSouthLatitude() <= latitude && latitude < codeArea.getNorthLatitude() && codeArea.getWestLongitude() <= longitude && longitude < codeArea.getEastLongitude(); } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } OpenLocationCode that = (OpenLocationCode) o; return hashCode() == that.hashCode(); } @Override public int hashCode() { return code != null ? code.hashCode() : 0; } @Override public String toString() { return getCode(); } // Exposed static helper methods. /** Returns whether the provided string is a valid Open Location code. */ public static boolean isValidCode(String code) { if (code == null || code.length() < 2) { return false; } // There must be exactly one separator. int separatorPosition = code.indexOf(SEPARATOR); if (separatorPosition == -1) { return false; } if (separatorPosition != code.lastIndexOf(SEPARATOR)) { return false; } if (separatorPosition % 2 != 0) { return false; } // Check first two characters: only some values from the alphabet are permitted. if (separatorPosition == 8) { // First latitude character can only have first 9 values. Integer index0 = CHARACTER_TO_INDEX.get(code.charAt(0)); if (index0 == null || index0 > 8) { return false; } // First longitude character can only have first 18 values. Integer index1 = CHARACTER_TO_INDEX.get(code.charAt(1)); if (index1 == null || index1 > 17) { return false; } } // Check the characters before the separator. boolean paddingStarted = false; for (int i = 0; i < separatorPosition; i++) { if (paddingStarted) { // Once padding starts, there must not be anything but padding. if (code.charAt(i) != SUFFIX_PADDING) { return false; } continue; } if (CHARACTER_TO_INDEX.keySet().contains(code.charAt(i))) { continue; } if (SUFFIX_PADDING == code.charAt(i)) { paddingStarted = true; // Padding can start on even character: 2, 4 or 6. if (i != 2 && i != 4 && i != 6) { return false; } continue; } return false; // Illegal character. } // Check the characters after the separator. if (code.length() > separatorPosition + 1) { if (paddingStarted) { return false; } // Only one character after separator is forbidden. if (code.length() == separatorPosition + 2) { return false; } for (int i = separatorPosition + 1; i < code.length(); i++) { if (!CHARACTER_TO_INDEX.keySet().contains(code.charAt(i))) { return false; } } } return true; } /** Returns if the code is a valid full Open Location Code. */ public static boolean isFullCode(String code) { try { return new OpenLocationCode(code).isFull(); } catch (IllegalArgumentException e) { return false; } } /** Returns if the code is a valid short Open Location Code. */ public static boolean isShortCode(String code) { try { return new OpenLocationCode(code).isShort(); } catch (IllegalArgumentException e) { return false; } } // Private static methods. private static double clipLatitude(double latitude) { return Math.min(Math.max(latitude, -90), 90); } private static double normalizeLongitude(double longitude) { if (longitude < -180) { longitude = (longitude % 360) + 360; } if (longitude >= 180) { longitude = (longitude % 360) - 360; } return longitude; } /** * Compute the latitude precision value for a given code length. Lengths <= 10 have the same * precision for latitude and longitude, but lengths > 10 have different precisions due to the * grid method having fewer columns than rows. Copied from the JS implementation. */ private static double computeLatitudePrecision(int codeLength) { if (codeLength <= 10) { return Math.pow(20, Math.floor(codeLength / -2 + 2)); } return Math.pow(20, -3) / Math.pow(5, codeLength - 10); } }