org.elasticsearch.common.geo.GeoUtils.java Source code

Java tutorial

Introduction

Here is the source code for org.elasticsearch.common.geo.GeoUtils.java

Source

/*
 * 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.
 */

package org.elasticsearch.common.geo;

import org.apache.commons.lang3.tuple.Pair;
import org.apache.lucene.spatial.prefix.tree.GeohashPrefixTree;
import org.apache.lucene.spatial.prefix.tree.QuadPrefixTree;
import org.apache.lucene.util.SloppyMath;
import org.elasticsearch.ElasticsearchParseException;
import org.elasticsearch.common.unit.DistanceUnit;
import org.elasticsearch.common.xcontent.XContentParser;
import org.elasticsearch.common.xcontent.XContentParser.Token;
import org.elasticsearch.index.mapper.geo.GeoPointFieldMapper;

import java.io.IOException;

/**
 */
public class GeoUtils {

    public static final String LATITUDE = GeoPointFieldMapper.Names.LAT;
    public static final String LONGITUDE = GeoPointFieldMapper.Names.LON;
    public static final String GEOHASH = GeoPointFieldMapper.Names.GEOHASH;

    public static final double DATELINE = 180.0D;

    /** Earth ellipsoid major axis defined by WGS 84 in meters */
    public static final double EARTH_SEMI_MAJOR_AXIS = 6378137.0; // meters (WGS 84)

    /** Earth ellipsoid minor axis defined by WGS 84 in meters */
    public static final double EARTH_SEMI_MINOR_AXIS = 6356752.314245; // meters (WGS 84)

    /** Earth mean radius defined by WGS 84 in meters */
    public static final double EARTH_MEAN_RADIUS = 6371008.7714D; // meters (WGS 84)

    /** Earth axis ratio defined by WGS 84 (0.996647189335) */
    public static final double EARTH_AXIS_RATIO = EARTH_SEMI_MINOR_AXIS / EARTH_SEMI_MAJOR_AXIS;

    /** Earth ellipsoid equator length in meters */
    public static final double EARTH_EQUATOR = 2 * Math.PI * EARTH_SEMI_MAJOR_AXIS;

    /** Earth ellipsoid polar distance in meters */
    public static final double EARTH_POLAR_DISTANCE = Math.PI * EARTH_SEMI_MINOR_AXIS;

    /**
     * Return an approximate value of the diameter of the earth (in meters) at the given latitude (in radians).
     */
    public static double earthDiameter(double latitude) {
        // SloppyMath impl returns a result in kilometers
        return SloppyMath.earthDiameter(latitude) * 1000;
    }

    /**
     * Calculate the width (in meters) of geohash cells at a specific level 
     * @param level geohash level must be greater or equal to zero 
     * @return the width of cells at level in meters  
     */
    public static double geoHashCellWidth(int level) {
        assert level >= 0;
        // Geohash cells are split into 32 cells at each level. the grid
        // alternates at each level between a 8x4 and a 4x8 grid 
        return EARTH_EQUATOR / (1L << ((((level + 1) / 2) * 3) + ((level / 2) * 2)));
    }

    /**
     * Calculate the width (in meters) of quadtree cells at a specific level 
     * @param level quadtree level must be greater or equal to zero 
     * @return the width of cells at level in meters  
     */
    public static double quadTreeCellWidth(int level) {
        assert level >= 0;
        return EARTH_EQUATOR / (1L << level);
    }

    /**
     * Calculate the height (in meters) of geohash cells at a specific level 
     * @param level geohash level must be greater or equal to zero 
     * @return the height of cells at level in meters  
     */
    public static double geoHashCellHeight(int level) {
        assert level >= 0;
        // Geohash cells are split into 32 cells at each level. the grid
        // alternates at each level between a 8x4 and a 4x8 grid 
        return EARTH_POLAR_DISTANCE / (1L << ((((level + 1) / 2) * 2) + ((level / 2) * 3)));
    }

    /**
     * Calculate the height (in meters) of quadtree cells at a specific level 
     * @param level quadtree level must be greater or equal to zero 
     * @return the height of cells at level in meters  
     */
    public static double quadTreeCellHeight(int level) {
        assert level >= 0;
        return EARTH_POLAR_DISTANCE / (1L << level);
    }

    /**
     * Calculate the size (in meters) of geohash cells at a specific level 
     * @param level geohash level must be greater or equal to zero 
     * @return the size of cells at level in meters  
     */
    public static double geoHashCellSize(int level) {
        assert level >= 0;
        final double w = geoHashCellWidth(level);
        final double h = geoHashCellHeight(level);
        return Math.sqrt(w * w + h * h);
    }

    /**
     * Calculate the size (in meters) of quadtree cells at a specific level 
     * @param level quadtree level must be greater or equal to zero 
     * @return the size of cells at level in meters  
     */
    public static double quadTreeCellSize(int level) {
        assert level >= 0;
        return Math.sqrt(EARTH_POLAR_DISTANCE * EARTH_POLAR_DISTANCE + EARTH_EQUATOR * EARTH_EQUATOR)
                / (1L << level);
    }

    /**
     * Calculate the number of levels needed for a specific precision. Quadtree
     * cells will not exceed the specified size (diagonal) of the precision.
     * @param meters Maximum size of cells in meters (must greater than zero)
     * @return levels need to achieve precision  
     */
    public static int quadTreeLevelsForPrecision(double meters) {
        assert meters >= 0;
        if (meters == 0) {
            return QuadPrefixTree.MAX_LEVELS_POSSIBLE;
        } else {
            final double ratio = 1 + (EARTH_POLAR_DISTANCE / EARTH_EQUATOR); // cell ratio
            final double width = Math.sqrt((meters * meters) / (ratio * ratio)); // convert to cell width
            final long part = Math.round(Math.ceil(EARTH_EQUATOR / width));
            final int level = Long.SIZE - Long.numberOfLeadingZeros(part) - 1; // (log_2)
            return (part <= (1l << level)) ? level : (level + 1); // adjust level
        }
    }

    /**
     * Calculate the number of levels needed for a specific precision. QuadTree
     * cells will not exceed the specified size (diagonal) of the precision.
     * @param distance Maximum size of cells as unit string (must greater or equal to zero)
     * @return levels need to achieve precision  
     */
    public static int quadTreeLevelsForPrecision(String distance) {
        return quadTreeLevelsForPrecision(DistanceUnit.METERS.parse(distance, DistanceUnit.DEFAULT));
    }

    /**
     * Calculate the number of levels needed for a specific precision. GeoHash
     * cells will not exceed the specified size (diagonal) of the precision.
     * @param meters Maximum size of cells in meters (must greater or equal to zero)
     * @return levels need to achieve precision  
     */
    public static int geoHashLevelsForPrecision(double meters) {
        assert meters >= 0;

        if (meters == 0) {
            return GeohashPrefixTree.getMaxLevelsPossible();
        } else {
            final double ratio = 1 + (EARTH_POLAR_DISTANCE / EARTH_EQUATOR); // cell ratio
            final double width = Math.sqrt((meters * meters) / (ratio * ratio)); // convert to cell width
            final double part = Math.ceil(EARTH_EQUATOR / width);
            if (part == 1)
                return 1;
            final int bits = (int) Math.round(Math.ceil(Math.log(part) / Math.log(2)));
            final int full = bits / 5; // number of 5 bit subdivisions    
            final int left = bits - full * 5; // bit representing the last level
            final int even = full + (left > 0 ? 1 : 0); // number of even levels
            final int odd = full + (left > 3 ? 1 : 0); // number of odd levels
            return even + odd;
        }
    }

    /**
     * Calculate the number of levels needed for a specific precision. GeoHash
     * cells will not exceed the specified size (diagonal) of the precision.
     * @param distance Maximum size of cells as unit string (must greater or equal to zero)
     * @return levels need to achieve precision  
     */
    public static int geoHashLevelsForPrecision(String distance) {
        return geoHashLevelsForPrecision(DistanceUnit.METERS.parse(distance, DistanceUnit.DEFAULT));
    }

    /**
     * Normalize longitude to lie within the -180 (exclusive) to 180 (inclusive) range.
     *
     * @param lon Longitude to normalize
     * @return The normalized longitude.
     */
    public static double normalizeLon(double lon) {
        return centeredModulus(lon, 360);
    }

    /**
     * Normalize latitude to lie within the -90 to 90 (both inclusive) range.
     * <p/>
     * Note: You should not normalize longitude and latitude separately,
     * because when normalizing latitude it may be necessary to
     * add a shift of 180&deg; in the longitude.
     * For this purpose, you should call the
     * {@link #normalizePoint(GeoPoint)} function.
     *
     * @param lat Latitude to normalize
     * @return The normalized latitude.
     * @see #normalizePoint(GeoPoint)
     */
    public static double normalizeLat(double lat) {
        lat = centeredModulus(lat, 360);
        if (lat < -90) {
            lat = -180 - lat;
        } else if (lat > 90) {
            lat = 180 - lat;
        }
        return lat;
    }

    /**
     * Normalize the geo {@code Point} for its coordinates to lie within their
     * respective normalized ranges.
     * <p/>
     * Note: A shift of 180&deg; is applied in the longitude if necessary,
     * in order to normalize properly the latitude.
     *
     * @param point The point to normalize in-place.
     */
    public static void normalizePoint(GeoPoint point) {
        normalizePoint(point, true, true);
    }

    /**
     * Normalize the geo {@code Point} for the given coordinates to lie within
     * their respective normalized ranges.
     * <p/>
     * You can control which coordinate gets normalized with the two flags.
     * <p/>
     * Note: A shift of 180&deg; is applied in the longitude if necessary,
     * in order to normalize properly the latitude.
     * If normalizing latitude but not longitude, it is assumed that
     * the longitude is in the form x+k*360, with x in ]-180;180],
     * and k is meaningful to the application.
     * Therefore x will be adjusted while keeping k preserved.
     *
     * @param point   The point to normalize in-place.
     * @param normLat Whether to normalize latitude or leave it as is.
     * @param normLon Whether to normalize longitude.
     */
    public static void normalizePoint(GeoPoint point, boolean normLat, boolean normLon) {
        double lat = point.lat();
        double lon = point.lon();

        normLat = normLat && (lat > 90 || lat <= -90);
        normLon = normLon && (lon > 180 || lon <= -180);

        if (normLat) {
            lat = centeredModulus(lat, 360);
            boolean shift = true;
            if (lat < -90) {
                lat = -180 - lat;
            } else if (lat > 90) {
                lat = 180 - lat;
            } else {
                // No need to shift the longitude, and the latitude is normalized
                shift = false;
            }
            if (shift) {
                if (normLon) {
                    lon += 180;
                } else {
                    // Longitude won't be normalized,
                    // keep it in the form x+k*360 (with x in ]-180;180])
                    // by only changing x, assuming k is meaningful for the user application.
                    lon += normalizeLon(lon) > 0 ? -180 : 180;
                }
            }
        }
        if (normLon) {
            lon = centeredModulus(lon, 360);
        }
        point.reset(lat, lon);
    }

    private static double centeredModulus(double dividend, double divisor) {
        double rtn = dividend % divisor;
        if (rtn <= 0) {
            rtn += divisor;
        }
        if (rtn > divisor / 2) {
            rtn -= divisor;
        }
        return rtn;
    }

    /**
     * Parse a {@link GeoPoint} with a {@link XContentParser}:
     * 
     * @param parser {@link XContentParser} to parse the value from
     * @return new {@link GeoPoint} parsed from the parse
     * 
     * @throws IOException
     * @throws org.elasticsearch.ElasticsearchParseException
     */
    public static GeoPoint parseGeoPoint(XContentParser parser) throws IOException, ElasticsearchParseException {
        return parseGeoPoint(parser, new GeoPoint());
    }

    /**
     * Parse a {@link GeoPoint} with a {@link XContentParser}. A geopoint has one of the following forms:
     * 
     * <ul>
     *     <li>Object: <pre>{&quot;lat&quot;: <i>&lt;latitude&gt;</i>, &quot;lon&quot;: <i>&lt;longitude&gt;</i>}</pre></li>
     *     <li>String: <pre>&quot;<i>&lt;latitude&gt;</i>,<i>&lt;longitude&gt;</i>&quot;</pre></li>
     *     <li>Geohash: <pre>&quot;<i>&lt;geohash&gt;</i>&quot;</pre></li>
     *     <li>Array: <pre>[<i>&lt;longitude&gt;</i>,<i>&lt;latitude&gt;</i>]</pre></li>
     * </ul>
     * 
     * @param parser {@link XContentParser} to parse the value from
     * @param point A {@link GeoPoint} that will be reset by the values parsed
     * @return new {@link GeoPoint} parsed from the parse
     * 
     * @throws IOException
     * @throws org.elasticsearch.ElasticsearchParseException
     */
    public static GeoPoint parseGeoPoint(XContentParser parser, GeoPoint point)
            throws IOException, ElasticsearchParseException {
        double lat = Double.NaN;
        double lon = Double.NaN;
        String geohash = null;

        if (parser.currentToken() == Token.START_OBJECT) {
            while (parser.nextToken() != Token.END_OBJECT) {
                if (parser.currentToken() == Token.FIELD_NAME) {
                    String field = parser.text();
                    if (LATITUDE.equals(field)) {
                        parser.nextToken();
                        switch (parser.currentToken()) {
                        case VALUE_NUMBER:
                        case VALUE_STRING:
                            lat = parser.doubleValue(true);
                            break;
                        default:
                            throw new ElasticsearchParseException("latitude must be a number");
                        }
                    } else if (LONGITUDE.equals(field)) {
                        parser.nextToken();
                        switch (parser.currentToken()) {
                        case VALUE_NUMBER:
                        case VALUE_STRING:
                            lon = parser.doubleValue(true);
                            break;
                        default:
                            throw new ElasticsearchParseException("longitude must be a number");
                        }
                    } else if (GEOHASH.equals(field)) {
                        if (parser.nextToken() == Token.VALUE_STRING) {
                            geohash = parser.text();
                        } else {
                            throw new ElasticsearchParseException("geohash must be a string");
                        }
                    } else {
                        throw new ElasticsearchParseException("field must be either '" + LATITUDE + "', '"
                                + LONGITUDE + "' or '" + GEOHASH + "'");
                    }
                } else {
                    throw new ElasticsearchParseException("Token '" + parser.currentToken() + "' not allowed");
                }
            }

            if (geohash != null) {
                if (!Double.isNaN(lat) || !Double.isNaN(lon)) {
                    throw new ElasticsearchParseException("field must be either lat/lon or geohash");
                } else {
                    return point.resetFromGeoHash(geohash);
                }
            } else if (Double.isNaN(lat)) {
                throw new ElasticsearchParseException("field [" + LATITUDE + "] missing");
            } else if (Double.isNaN(lon)) {
                throw new ElasticsearchParseException("field [" + LONGITUDE + "] missing");
            } else {
                return point.reset(lat, lon);
            }

        } else if (parser.currentToken() == Token.START_ARRAY) {
            int element = 0;
            while (parser.nextToken() != Token.END_ARRAY) {
                if (parser.currentToken() == Token.VALUE_NUMBER) {
                    element++;
                    if (element == 1) {
                        lon = parser.doubleValue();
                    } else if (element == 2) {
                        lat = parser.doubleValue();
                    } else {
                        throw new ElasticsearchParseException("only two values allowed");
                    }
                } else {
                    throw new ElasticsearchParseException("Numeric value expected");
                }
            }
            return point.reset(lat, lon);
        } else if (parser.currentToken() == Token.VALUE_STRING) {
            String data = parser.text();
            int comma = data.indexOf(',');
            if (comma > 0) {
                lat = Double.parseDouble(data.substring(0, comma).trim());
                lon = Double.parseDouble(data.substring(comma + 1).trim());
                return point.reset(lat, lon);
            } else {
                return point.resetFromGeoHash(data);
            }
        } else {
            throw new ElasticsearchParseException("geo_point expected");
        }
    }

    public static boolean correctPolyAmbiguity(GeoPoint[] points, boolean handedness) {
        return correctPolyAmbiguity(points, handedness, computePolyOrientation(points), 0, points.length, false);
    }

    public static boolean correctPolyAmbiguity(GeoPoint[] points, boolean handedness, boolean orientation,
            int component, int length, boolean shellCorrected) {
        // OGC requires shell as ccw (Right-Handedness) and holes as cw (Left-Handedness)
        // since GeoJSON doesn't specify (and doesn't need to) GEO core will assume OGC standards
        // thus if orientation is computed as cw, the logic will translate points across dateline
        // and convert to a right handed system

        // compute the bounding box and calculate range
        Pair<Pair, Pair> range = GeoUtils.computeBBox(points, length);
        final double rng = (Double) range.getLeft().getRight() - (Double) range.getLeft().getLeft();
        // translate the points if the following is true
        //   1.  shell orientation is cw and range is greater than a hemisphere (180 degrees) but not spanning 2 hemispheres
        //       (translation would result in a collapsed poly)
        //   2.  the shell of the candidate hole has been translated (to preserve the coordinate system)
        boolean incorrectOrientation = component == 0 && handedness != orientation;
        boolean translated = ((incorrectOrientation && (rng > DATELINE && rng != 360.0))
                || (shellCorrected && component != 0));
        if (translated) {
            for (GeoPoint c : points) {
                if (c.x < 0.0) {
                    c.x += 360.0;
                }
            }
        }
        return translated;
    }

    public static boolean computePolyOrientation(GeoPoint[] points) {
        return computePolyOrientation(points, points.length);
    }

    public static boolean computePolyOrientation(GeoPoint[] points, int length) {
        // calculate the direction of the points:
        // find the point at the top of the set and check its
        // neighbors orientation. So direction is equivalent
        // to clockwise/counterclockwise
        final int top = computePolyOrigin(points, length);
        final int prev = ((top + length - 1) % length);
        final int next = ((top + 1) % length);
        return (points[prev].x > points[next].x);
    }

    private static final int computePolyOrigin(GeoPoint[] points, int length) {
        int top = 0;
        // we start at 1 here since top points to 0
        for (int i = 1; i < length; i++) {
            if (points[i].y < points[top].y) {
                top = i;
            } else if (points[i].y == points[top].y) {
                if (points[i].x < points[top].x) {
                    top = i;
                }
            }
        }
        return top;
    }

    public static final Pair computeBBox(GeoPoint[] points) {
        return computeBBox(points, 0);
    }

    public static final Pair computeBBox(GeoPoint[] points, int length) {
        double minX = points[0].x;
        double maxX = points[0].x;
        double minY = points[0].y;
        double maxY = points[0].y;
        // compute the bounding coordinates (@todo: cleanup brute force)
        for (int i = 1; i < length; ++i) {
            if (points[i].x < minX) {
                minX = points[i].x;
            }
            if (points[i].x > maxX) {
                maxX = points[i].x;
            }
            if (points[i].y < minY) {
                minY = points[i].y;
            }
            if (points[i].y > maxY) {
                maxY = points[i].y;
            }
        }
        // return a pair of ranges on the X and Y axis, respectively
        return Pair.of(Pair.of(minX, maxX), Pair.of(minY, maxY));
    }

    public static GeoPoint convertToGreatCircle(GeoPoint point) {
        return convertToGreatCircle(point.y, point.x);
    }

    public static GeoPoint convertToGreatCircle(double lat, double lon) {
        GeoPoint p = new GeoPoint(lat, lon);
        // convert the point to standard lat/lon bounds
        normalizePoint(p);

        if (p.x < 0.0D) {
            p.x += 360.0D;
        }

        if (p.y < 0.0D) {
            p.y += 180.0D;
        }
        return p;
    }

    private GeoUtils() {
    }
}