MapcodeCodec.java
/*
* Copyright (C) 2014-2017, Stichting Mapcode Foundation (http://www.mapcode.com)
*
* Licensed 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.
*/
package com.mapcode;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import static com.mapcode.CheckArgs.checkDefined;
import static com.mapcode.CheckArgs.checkNonnull;
import static com.mapcode.Mapcode.getPrecisionFormat;
// ----------------------------------------------------------------------------------------------
// Package private implementation class. For internal use within the mapcode implementation only.
//----------------------------------------------------------------------------------------------
/**
* This class is the external Java interface for encoding and decoding mapcodes.
*/
@SuppressWarnings("MagicNumber")
public final class MapcodeCodec {
// Get direct access to the data model.
private static final DataModel DATA_MODEL = DataModel.getInstance();
private MapcodeCodec() {
// Prevent instantiation.
}
// ------------------------------------------------------------------------------------------
// Encoding latitude, longitude to mapcodes.
// ------------------------------------------------------------------------------------------
/**
* Encode a lat/lon pair to a mapcode with territory information. This produces a non-empty list of mapcode,
* with at the very least 1 mapcodes for the lat/lon, which is the "International" mapcode.
*
* The returned result list will always contain at least 1 mapcode, because every lat/lon pair can be encoded.
*
* The list is ordered in such a way that the last result is the international code. However, you cannot assume
* that the first result is the shortest mapcode. If you want to use the shortest mapcode, use
* {@link #encodeToShortest(double, double, Territory)}.
*
* The international code can be obtained from the list by using: "results.get(results.size() - 1)", or
* you can use {@link #encodeToInternational(double, double)}, which is faster.
*
* @param latDeg Latitude, accepted range: -90..90.
* @param lonDeg Longitude, accepted range: -180..180.
* @return Non-empty, ordered list of mapcode information records, see {@link Mapcode}.
* @throws IllegalArgumentException Thrown if latitude or longitude are out of range.
*/
@Nonnull
public static List<Mapcode> encode(final double latDeg, final double lonDeg)
throws IllegalArgumentException {
return encode(latDeg, lonDeg, null);
}
@Nonnull
public static List<Mapcode> encode(@Nonnull final Point point)
throws IllegalArgumentException {
checkDefined("point", point);
return encode(point.getLatDeg(), point.getLonDeg());
}
/**
* Encode a lat/lon pair to a mapcode with territory information, for a specific territory. This produces a
* potentially empty list of mapcodes (empty if the lat/lon does not fall within the territory for mapcodes).
*
* The returned result list will always contain at least 1 mapcode, because every lat/lon pair can be encoded.
*
* The list is ordered in such a way that the last result is the international code. However, you cannot assume
* that the first result is the shortest mapcode. If you want to use the shortest mapcode, use
* {@link #encodeToShortest(double, double, Territory)}.
*
* @param latDeg Latitude, accepted range: -90..90 (limited to this range if outside).
* @param lonDeg Longitude, accepted range: -180..180 (wrapped to this range if outside).
* @param restrictToTerritory Try to encode only within this territory, see {@link Territory}. May be null.
* @return List of mapcode information records, see {@link Mapcode}. This list is empty if no
* Mapcode can be generated for this territory matching the lat/lon.
* @throws IllegalArgumentException Thrown if latitude or longitude are out of range.
*/
@Nonnull
public static List<Mapcode> encode(final double latDeg, final double lonDeg,
@Nullable final Territory restrictToTerritory)
throws IllegalArgumentException {
final List<Mapcode> results = Encoder.encode(latDeg, lonDeg, restrictToTerritory, false);
assert results != null;
return results;
}
@Nonnull
public static List<Mapcode> encode(@Nonnull final Point point,
@Nullable final Territory restrictToTerritory)
throws IllegalArgumentException {
checkNonnull("point", point);
return encode(point.getLatDeg(), point.getLonDeg(), restrictToTerritory);
}
/**
* Encode a lat/lon pair to a list of mapcodes, like {@link #encode(double, double)}.
* The result list is limited to those mapcodes that belong to the provided ISO 3166 country code, 2 characters.
* For example, if you wish to restrict the list to Mexican mapcodes, use "MX". This would
* produce a result list of mapcodes with territories that start with "MX-" (note that a
* mapcode that starts with "MEX" is not returned in that case.)
*
* @param latDeg Latitude, accepted range: -90..90.
* @param lonDeg Longitude, accepted range: -180..180.
* @param countryISO2 ISO 3166 country code, 2 characters.
* @return Possibly empty, ordered list of mapcode information records, see {@link Mapcode}.
* @throws IllegalArgumentException Thrown if latitude or longitude are out of range, or if the ISO code is invalid.
*/
@Nonnull
public static List<Mapcode> encodeRestrictToCountryISO2(final double latDeg, final double lonDeg,
@Nonnull final String countryISO2)
throws IllegalArgumentException {
checkNonnull("countryISO2", countryISO2);
final String countryISO3 = Territory.fromCountryISO2(countryISO2).toString();
final String prefix = countryISO2.toUpperCase() + '-';
final List<Mapcode> mapcodes = encode(latDeg, lonDeg);
final List<Mapcode> filtered = new ArrayList<Mapcode>();
for (final Mapcode mapcode : mapcodes) {
if (mapcode.getTerritory().toString().startsWith(prefix)) {
// If the mapcode starts with the ISO 2 code, it's OK.
filtered.add(mapcode);
} else if (mapcode.getTerritory().toString().equals(countryISO3)) {
// Otherwise, if it's the correct country ISO 3 code, it's also OK.
filtered.add(mapcode);
}
}
return filtered;
}
@Nonnull
public static List<Mapcode> encodeRestrictToCountryISO2(@Nonnull final Point point,
@Nonnull final String countryISO2)
throws IllegalArgumentException {
checkNonnull("point", point);
return encodeRestrictToCountryISO2(point.getLatDeg(), point.getLonDeg(), countryISO2);
}
/**
* Encode a lat/lon pair to a list of mapcodes, like {@link #encode(double, double)}.
* The result list is limited to those mapcodes that belong to the provided ISO 3166 country code, 3 characters.
* For example, if you wish to restrict the list to Mexican mapcodes, use "MEX". This would
* produce a result list of mapcodes with territories that start with "MEX" (note that
* mapcode that starts with "MX-" are not returned in that case.)
*
* @param latDeg Latitude, accepted range: -90..90.
* @param lonDeg Longitude, accepted range: -180..180.
* @param countryISO3 ISO 3166 country code, 3 characters.
* @return Possibly empty, ordered list of mapcode information records, see {@link Mapcode}.
* @throws IllegalArgumentException Thrown if latitude or longitude are out of range, or if the ISO code is invalid.
*/
@Nonnull
public static List<Mapcode> encodeRestrictToCountryISO3(final double latDeg, final double lonDeg,
@Nonnull final String countryISO3)
throws IllegalArgumentException {
checkNonnull("countryISO3", countryISO3);
return encodeRestrictToCountryISO2(latDeg, lonDeg, Territory.getCountryISO2FromISO3(countryISO3));
}
@Nonnull
public static List<Mapcode> encodeRestrictToCountryISO3(@Nonnull final Point point,
@Nonnull final String countryISO3)
throws IllegalArgumentException {
checkNonnull("point", point);
return encodeRestrictToCountryISO3(point.getLatDeg(), point.getLonDeg(), countryISO3);
}
/**
* Encode a lat/lon pair to its shortest mapcode with territory information.
*
* @param latDeg Latitude, accepted range: -90..90.
* @param lonDeg Longitude, accepted range: -180..180.
* @param restrictToTerritory Try to encode only within this territory, see {@link Territory}. Cannot be null.
* @return Shortest mapcode, see {@link Mapcode}.
* @throws IllegalArgumentException Thrown if latitude or longitude are out of range.
* @throws UnknownMapcodeException Thrown if no mapcode was found for the lat/lon matching the territory.
*/
@Nonnull
public static Mapcode encodeToShortest(final double latDeg, final double lonDeg,
@Nonnull final Territory restrictToTerritory)
throws IllegalArgumentException, UnknownMapcodeException {
checkNonnull("restrictToTerritory", restrictToTerritory);
// Call mapcode encoder.
@Nonnull final List<Mapcode> results =
Encoder.encode(latDeg, lonDeg, restrictToTerritory, /* Stop with one result: */ true);
assert results != null;
assert results.size() <= 1;
if (results.isEmpty()) {
throw new UnknownMapcodeException("No Mapcode for lat=" + latDeg + ", lon=" + lonDeg +
", territory=" + restrictToTerritory);
}
return results.get(0);
}
@Nonnull
public static Mapcode encodeToShortest(@Nonnull final Point point,
@Nonnull final Territory restrictToTerritory)
throws IllegalArgumentException, UnknownMapcodeException {
checkDefined("point", point);
return encodeToShortest(point.getLatDeg(), point.getLonDeg(), restrictToTerritory);
}
/**
* Encode a lat/lon pair to its unambiguous, international mapcode.
*
* @param latDeg Latitude, accepted range: -90..90.
* @param lonDeg Longitude, accepted range: -180..180.
* @return International unambiguous mapcode (always exists), see {@link Mapcode}.
* @throws IllegalArgumentException Thrown if latitude or longitude are out of range.
*/
@Nonnull
public static Mapcode encodeToInternational(final double latDeg, final double lonDeg)
throws IllegalArgumentException {
// Call mapcode encoder.
@Nonnull final List<Mapcode> results = encode(latDeg, lonDeg, Territory.AAA);
assert results != null;
assert results.size() >= 1;
return results.get(results.size() - 1);
}
@Nonnull
public static Mapcode encodeToInternational(@Nonnull final Point point)
throws IllegalArgumentException {
checkDefined("point", point);
return encodeToInternational(point.getLatDeg(), point.getLonDeg());
}
// ------------------------------------------------------------------------------------------
// Decoding mapcodes back to latitude, longitude.
// ------------------------------------------------------------------------------------------
//
/**
* Decode a mapcode to a Point. The decoding process may fail for local mapcodes,
* because no territory context is supplied (world-wide).
*
* The accepted format is:
* {mapcode}
* {territory-code} {mapcode}
*
* @param mapcode Mapcode.
* @return Point corresponding to mapcode.
* @throws UnknownMapcodeException Thrown if the mapcode has the correct syntax,
* but cannot be decoded into a point.
* @throws UnknownPrecisionFormatException Thrown if the precision format is incorrect.
* @throws IllegalArgumentException Thrown if arguments are null, or if the syntax of the mapcode is incorrect.
*/
@Nonnull
public static Point decode(@Nonnull final String mapcode)
throws UnknownMapcodeException, IllegalArgumentException, UnknownPrecisionFormatException {
return decode(mapcode, Territory.AAA);
}
/**
* Decode a mapcode to a Point. A reference territory is supplied for disambiguation (only used if applicable).
*
* The accepted format is:
* {mapcode}
* {territory-code} {mapcode}
*
* Note that if a territory-code is supplied in the string, it takes preferences over the parameter.
*
* @param mapcode Mapcode.
* @param defaultTerritoryContext Default territory context for disambiguation purposes. May be null.
* @return Point corresponding to mapcode. Latitude range: -90..90, longitude range: -180..180.
* @throws UnknownMapcodeException Thrown if the mapcode has the right syntax, but cannot be decoded into a point.
* @throws UnknownPrecisionFormatException Thrown if the precision format is incorrect.
* @throws IllegalArgumentException Thrown if arguments are null, or if the syntax of the mapcode is incorrect.
*/
@Nonnull
public static Point decode(@Nonnull final String mapcode, @Nullable final Territory defaultTerritoryContext)
throws UnknownMapcodeException, IllegalArgumentException, UnknownPrecisionFormatException {
checkNonnull("mapcode", mapcode);
final MapcodeZone mapcodeZone = decodeToMapcodeZone(mapcode, defaultTerritoryContext);
if (mapcodeZone.isEmpty()) {
throw new UnknownMapcodeException("Unknown mapcode, mapcode=" + mapcode + ", territoryContext=" + defaultTerritoryContext);
}
return mapcodeZone.getCenter();
}
/**
* Decode a mapcode to a Rectangle, which defines the valid zone for a mapcode. The boundaries of the
* mapcode zone are inclusive for the South and West borders and exclusive for the North and East borders.
* This is essentially the same call as a 'decode', except it returns a rectangle, rather than its center point.
*
* @param mapcode Mapcode.
* @return Rectangle Mapcode zone. South/West borders are inclusive, North/East borders exclusive.
* @throws UnknownMapcodeException Thrown if the mapcode has the correct syntax,
* but cannot be decoded into a point.
* @throws UnknownPrecisionFormatException Thrown if the precision format is incorrect.
* @throws IllegalArgumentException Thrown if arguments are null, or if the syntax of the mapcode is incorrect.
*/
@Nonnull
public static Rectangle decodeToRectangle(@Nonnull final String mapcode)
throws UnknownMapcodeException, IllegalArgumentException, UnknownPrecisionFormatException {
return decodeToRectangle(mapcode, Territory.AAA);
}
/**
* Decode a mapcode to a Rectangle, which defines the valid zone for a mapcode. The boundaries of the
* mapcode zone are inclusive for the South and West borders and exclusive for the North and East borders.
* This is essentially the same call as a 'decode', except it returns a rectangle, rather than its center point.
*
* @param mapcode Mapcode.
* @param defaultTerritoryContext Default territory context for disambiguation purposes. May be null.
* @return Rectangle Mapcode zone. South/West borders are inclusive, North/East borders exclusive.
* @throws UnknownMapcodeException Thrown if the mapcode has the correct syntax,
* but cannot be decoded into a point.
* @throws UnknownPrecisionFormatException Thrown if the precision format is incorrect.
* @throws IllegalArgumentException Thrown if arguments are null, or if the syntax of the mapcode is incorrect.
*/
@Nonnull
public static Rectangle decodeToRectangle(@Nonnull final String mapcode, @Nullable final Territory defaultTerritoryContext)
throws UnknownMapcodeException, IllegalArgumentException, UnknownPrecisionFormatException {
checkNonnull("mapcode", mapcode);
final MapcodeZone mapcodeZone = decodeToMapcodeZone(mapcode, defaultTerritoryContext);
final Point southWest = Point.fromLatLonFractions(mapcodeZone.getLatFractionMin(), mapcodeZone.getLonFractionMin());
final Point northEast = Point.fromLatLonFractions(mapcodeZone.getLatFractionMax(), mapcodeZone.getLonFractionMax());
final Rectangle rectangle = new Rectangle(southWest, northEast);
assert rectangle.isDefined();
return rectangle;
}
/**
* Is coordinate near multiple territory borders?
*
* @param point Latitude/Longitude in degrees.
* @param territory Territory.
* @return true Iff the coordinate is near more than one territory border (and thus encode(decode(M)) may not produce M).
*/
public static boolean isNearMultipleBorders(@Nonnull final Point point, @Nonnull final Territory territory) {
checkDefined("point", point);
if (territory != Territory.AAA) {
final int territoryNumber = territory.getNumber();
if (territory.getParentTerritory() != null) {
// There is a parent! check its borders as well...
if (isNearMultipleBorders(point, territory.getParentTerritory())) {
return true;
}
}
int nrFound = 0;
final int fromTerritoryRecord = DATA_MODEL.getDataFirstRecord(territoryNumber);
final int uptoTerritoryRecord = DATA_MODEL.getDataLastRecord(territoryNumber);
for (int territoryRecord = uptoTerritoryRecord; territoryRecord >= fromTerritoryRecord; territoryRecord--) {
if (!Data.isRestricted(territoryRecord)) {
final Boundary boundary = Boundary.createBoundaryForTerritoryRecord(territoryRecord);
final int xdiv8 = Common.xDivider(boundary.getLatMicroDegMin(), boundary.getLatMicroDegMax()) / 4;
if (boundary.extendBoundary(60, xdiv8).containsPoint(point)) {
if (!boundary.extendBoundary(-60, -xdiv8).containsPoint(point)) {
nrFound++;
if (nrFound > 1) {
return true;
}
}
}
}
}
}
return false;
}
@Nonnull
private static MapcodeZone decodeToMapcodeZone(@Nonnull final String mapcode, @Nullable final Territory defaultTerritoryContext)
throws UnknownMapcodeException, IllegalArgumentException {
checkNonnull("mapcode", mapcode);
String mapcodeClean = Mapcode.convertStringToPlainAscii(mapcode.trim()).toUpperCase();
// Determine territory from mapcode.
final Territory territory;
final Matcher matcherTerritory = Mapcode.PATTERN_TERRITORY.matcher(mapcodeClean);
if (matcherTerritory.find()) {
// Use the territory code from the string.
final String territoryName = mapcodeClean.substring(matcherTerritory.start(), matcherTerritory.end()).trim();
try {
territory = Territory.fromString(territoryName);
} catch (final UnknownTerritoryException ignored) {
throw new UnknownMapcodeException("Wrong territory code: " + territoryName);
}
// Cut off the territory part.
mapcodeClean = mapcodeClean.substring(matcherTerritory.end()).trim();
} else {
// No territory code was supplied in the string, use specified territory context parameter.
territory = (defaultTerritoryContext != null) ? defaultTerritoryContext : Territory.AAA;
}
// Throws an exception if the format is incorrect.
getPrecisionFormat(mapcodeClean);
return Decoder.decodeToMapcodeZone(mapcodeClean, territory);
}
}