LocationUtils.java

/*
 * Copyright (C) 2018 Alberto Irurueta Carro (alberto@irurueta.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.irurueta.navigation.utils;

import com.irurueta.navigation.geodesic.Geodesic;
import com.irurueta.units.Distance;
import com.irurueta.units.DistanceUnit;

import java.text.DecimalFormat;
import java.util.StringTokenizer;

/**
 * Location utility class based on Android's SDK Location class.
 */
public class LocationUtils {
    /**
     * Constant used to specify formatting of a latitude or longitude
     * in the form "[+-]DDD.DDDDD where D indicates degrees.
     */
    public static final int FORMAT_DEGREES = 0;

    /**
     * Constant used to specify formatting of a latitude or longitude
     * in the form "[+-]DDD:MM.MMMMM" where D indicates degrees and
     * M indicates minutes of arc (1 minute = 1/60th of a degree).
     */
    public static final int FORMAT_MINUTES = 1;

    /**
     * Constant used to specify formatting of a latitude or longitude
     * in the form "DDD:MM:SS.SSSSS" where D indicates degrees, M
     * indicates minutes of arc, and S indicates seconds of arc (1
     * minute = 1/60th of a degree, 1 second = 1/3600th of a degree).
     */
    public static final int FORMAT_SECONDS = 2;

    /**
     * Constructor.
     * Prevents public instantiation.
     */
    private LocationUtils() {
    }

    /**
     * Converts a coordinate to a String representation. The outputType
     * may be one of FORMAT_DEGREES, FORMAT_MINUTES, or FORMAT_SECONDS.
     * The coordinate must be a valid double between -180.0 and 180.0.
     * This conversion is performed in a method that is dependent on the
     * default locale, and so is not guaranteed to round-trip with
     * {@link #convert(String)}.
     *
     * @param coordinate coordinate to be converted.
     * @param outputType output format.
     * @return converted coordinate.
     * @throws IllegalArgumentException if coordinate is less than
     *                                  -180.0, greater than 180.0, or is not a number.
     * @throws IllegalArgumentException if outputType is not one of
     *                                  FORMAT_DEGREES, FORMAT_MINUTES, or FORMAT_SECONDS.
     */
    public static String convert(double coordinate, final int outputType) {
        if (coordinate < -180.0 || coordinate > 180.0 || Double.isNaN(coordinate)) {
            throw new IllegalArgumentException();
        }
        if ((outputType != FORMAT_DEGREES) && (outputType != FORMAT_MINUTES) && (outputType != FORMAT_SECONDS)) {
            throw new IllegalArgumentException();
        }

        final var sb = new StringBuilder();

        // Handle negative values
        if (coordinate < 0) {
            sb.append('-');
            coordinate = -coordinate;
        }

        final var df = new DecimalFormat("###.#####");
        if (outputType == FORMAT_MINUTES || outputType == FORMAT_SECONDS) {
            final var degrees = (int) Math.floor(coordinate);
            sb.append(degrees);
            sb.append(':');
            coordinate -= degrees;
            coordinate *= 60.0;
            if (outputType == FORMAT_SECONDS) {
                final var minutes = (int) Math.floor(coordinate);
                sb.append(minutes);
                sb.append(':');
                coordinate -= minutes;
                coordinate *= 60.0;
            }
        }
        sb.append(df.format(coordinate));
        return sb.toString();
    }

    /**
     * Converts a String in one of the formats described by
     * FORMAT_DEGREES, FORMAT_MINUTES, or FORMAT_SECONDS into a
     * double. This conversion is performed in a locale agnostic
     * method, and so is not guaranteed to round-trip with
     * {@link #convert(double, int)}.
     *
     * @param coordinate coordinate to be parsed.
     * @return parsed value.
     * @throws NullPointerException     if coordinate is null
     * @throws IllegalArgumentException if the coordinate is not
     *                                  in one of the valid formats.
     */
    public static double convert(String coordinate) {
        // IllegalArgumentException if bad syntax
        if (coordinate == null) {
            throw new NullPointerException();
        }

        var negative = false;
        if (coordinate.charAt(0) == '-') {
            coordinate = coordinate.substring(1);
            negative = true;
        }

        final var st = new StringTokenizer(coordinate, ":");
        final var tokens = st.countTokens();
        if (tokens < 1) {
            throw new IllegalArgumentException();
        }
        try {
            final var degrees = st.nextToken();
            double val;
            if (tokens == 1) {
                val = Double.parseDouble(degrees);
                return negative ? -val : val;
            }

            final var minutes = st.nextToken();
            final var deg = Integer.parseInt(degrees);
            double min;
            var sec = 0.0;
            var secPresent = false;

            if (st.hasMoreTokens()) {
                min = Integer.parseInt(minutes);
                final var seconds = st.nextToken();
                sec = Double.parseDouble(seconds);
                secPresent = true;
            } else {
                min = Double.parseDouble(minutes);
            }

            final var isNegative180 = negative && (deg == 180) && (min == 0) && (sec == 0);

            // deg must be in [0, 179] except for the case of -180 degrees
            if ((deg < 0.0) || (deg > 179 && !isNegative180)) {
                throw new IllegalArgumentException();
            }

            // min must be in [0, 59] if seconds are present, otherwise [0.0, 60.0)
            if (min < 0 || min >= 60 || (secPresent && (min > 59))) {
                throw new IllegalArgumentException();
            }

            // sec must be in [0.0, 60.0)
            if (sec < 0 || sec >= 60) {
                throw new IllegalArgumentException();
            }

            val = deg * 3600.0 + min * 60.0 + sec;
            val /= 3600.0;
            return negative ? -val : val;
        } catch (final NumberFormatException nfe) {
            throw new IllegalArgumentException();
        }
    }

    /**
     * Computes the approximate distance in meters between two locations, and the initial and final bearings of the
     * shortest path between them. Distance and bearing are defined using the WGS84 ellipsoid.
     *
     * @param startLatitude  the starting latitude.
     * @param startLongitude the starting longitude.
     * @param endLatitude    the ending latitude.
     * @param endLongitude   the ending longitude.
     * @param results        instance containing results.
     */
    public static void distanceAndBearing(
            final double startLatitude, final double startLongitude, final double endLatitude,
            final double endLongitude, final BearingDistance results) {
        //noinspection all
        final var data = Geodesic.WGS84.inverse(startLatitude, startLongitude, endLatitude, endLongitude);
        results.startLatitude = data.getLat1();
        results.startLongitude = data.getLon1();
        results.endLatitude = data.getLat2();
        results.endLongitude = data.getLon2();
        results.distance = data.getS12();
        results.initialBearing = data.getAzi1();
        results.finalBearing = data.getAzi2();
    }

    /**
     * Computes the approximate distance in meters between two locations, and the initial and final bearings of the
     * shortest path between them. Distance and bearing are defined using the WGS84 ellipsoid.
     *
     * @param startLatitude  the starting latitude.
     * @param startLongitude the starting longitude.
     * @param endLatitude    the ending latitude.
     * @param endLongitude   the ending longitude.
     * @return bearing and distance results.
     */
    public static BearingDistance distanceAndBearing(
            final double startLatitude, final double startLongitude, final double endLatitude,
            final double endLongitude) {
        final var results = new BearingDistance();
        distanceAndBearing(startLatitude, startLongitude, endLatitude, endLongitude, results);
        return results;
    }

    /**
     * Computes the approximate distance in meters between two locations, and the initial and final bearings of the
     * shortest path between them. Distance and bearing are defined using the WGS84 ellipsoid.
     *
     * @param startLatitude  the starting latitude.
     * @param startLongitude the starting longitude.
     * @param endLatitude    the ending latitude.
     * @param endLongitude   the ending longitude.
     * @param results        array containing results. First element will contain distance. Second element will contain
     *                       initial bearing (optional). Third element will contain ending bearing (optional).
     * @throws IllegalArgumentException if results does not have at least 1 element.
     */
    public static void distanceAndBearing(
            final double startLatitude, final double startLongitude, final double endLatitude,
            final double endLongitude, final double[] results) {
        if (results.length == 0) {
            throw new IllegalArgumentException();
        }
        //noinspection all
        final var data = Geodesic.WGS84.inverse(startLatitude, startLongitude, endLatitude, endLongitude);
        results[0] = data.getS12();
        if (results.length > 1) {
            results[1] = data.getAzi1();
            if (results.length > 2) {
                results[2] = data.getAzi2();
            }
        }
    }

    /**
     * Computes the approximate distance in meters between two locations.
     *
     * @param startLatitude  the starting latitude.
     * @param startLongitude the starting longitude.
     * @param endLatitude    the ending latitude.
     * @param endLongitude   the ending longitude.
     * @return distance in meters between two locations.
     */
    public static double distanceBetweenMeters(
            final double startLatitude, final double startLongitude, final double endLatitude,
            final double endLongitude) {
        //noinspection all
        return Geodesic.WGS84.inverse(startLatitude, startLongitude, endLatitude, endLongitude).getS12();
    }

    /**
     * Computes the approximate distance between two locations.
     *
     * @param startLatitude  the starting latitude.
     * @param startLongitude the starting longitude.
     * @param endLatitude    the ending latitude.
     * @param endLongitude   the ending longitude.
     * @return distance between two locations.
     */
    public static Distance distanceBetween(
            final double startLatitude, final double startLongitude, final double endLatitude,
            final double endLongitude) {
        return new Distance(distanceBetweenMeters(startLatitude, startLongitude, endLatitude, endLongitude),
                DistanceUnit.METER);
    }

    /**
     * Computes the approximate distance between two locations.
     *
     * @param startLatitude  the starting latitude.
     * @param startLongitude the starting longitude.
     * @param endLatitude    the ending latitude.
     * @param endLongitude   the ending longitude.
     * @param result         instance where distance between two locations is stored.
     * @return provided result instance.
     */
    public static Distance distanceBetween(
            final double startLatitude, final double startLongitude, final double endLatitude,
            final double endLongitude, final Distance result) {
        result.setValue(distanceBetweenMeters(startLatitude, startLongitude, endLatitude, endLongitude));
        result.setUnit(DistanceUnit.METER);
        return result;
    }


    /**
     * Contains distance and bearing.
     */
    public static class BearingDistance {
        /**
         * Starting latitude (degrees).
         */
        private double startLatitude;

        /**
         * Starting longitude (degrees).
         */
        private double startLongitude;

        /**
         * Ending latitude (degrees).
         */
        private double endLatitude;

        /**
         * Ending longitude (degrees).
         */
        private double endLongitude;

        /**
         * Distance (meters).
         */
        private double distance = 0.0f;

        /**
         * Initial bearing (degrees).
         */
        private double initialBearing = 0.0f;

        /**
         * Final bearing (degrees).
         */
        private double finalBearing = 0.0f;

        /**
         * Gets starting latitude expressed in degrees.
         *
         * @return starting latitude (degrees).
         */
        public double getStartLatitude() {
            return startLatitude;
        }

        /**
         * Gets starting longitude expressed in degrees.
         *
         * @return starting longitude (degrees).
         */
        public double getStartLongitude() {
            return startLongitude;
        }

        /**
         * Gets ending latitude expressed in degrees.
         *
         * @return ending latitude (degrees).
         */
        public double getEndLatitude() {
            return endLatitude;
        }

        /**
         * Gets ending longitude expressed in degrees.
         *
         * @return ending longitude (degrees).
         */
        public double getEndLongitude() {
            return endLongitude;
        }

        /**
         * Gets distance expressed in meters.
         *
         * @return distance (meters).
         */
        public double getDistanceMeters() {
            return distance;
        }

        /**
         * Gets distance.
         *
         * @return distance.
         */
        public Distance getDistance() {
            return new Distance(distance, DistanceUnit.METER);
        }

        /**
         * Gets distance.
         *
         * @param result instance where result value is stored in meters.
         * @return provided distance instance.
         */
        public Distance getDistance(final Distance result) {
            result.setValue(distance);
            result.setUnit(DistanceUnit.METER);
            return result;
        }

        /**
         * Gets initial bearing/azimuth expressed in degrees.
         *
         * @return initial bearing/azimuth (degrees).
         */
        public double getInitialBearing() {
            return initialBearing;
        }

        /**
         * Gets final bearing/azimuth expressed in degrees.
         *
         * @return final bearing/azimuth (degrees).
         */
        public double getFinalBearing() {
            return finalBearing;
        }
    }
}