From f838f647adaff43e55044faf3eb9b2f3f7c9491d Mon Sep 17 00:00:00 2001 From: dkhawk <107309+dkhawk@users.noreply.github.com> Date: Mon, 11 Nov 2024 13:36:53 -0700 Subject: [PATCH] feat: port Bounds, Points, MathUtil, SphericalUtil, and Gradient to Kotlin --- library/build.gradle | 1 + .../com/google/maps/android/MathUtil.java | 116 ---- .../java/com/google/maps/android/MathUtil.kt | 150 +++++ .../com/google/maps/android/PolyUtil.java | 580 ---------------- .../java/com/google/maps/android/PolyUtil.kt | 635 ++++++++++++++++++ .../google/maps/android/SphericalUtil.java | 265 -------- .../com/google/maps/android/SphericalUtil.kt | 280 ++++++++ .../com/google/maps/android/StreetViewUtil.kt | 8 +- .../data/{Geometry.java => Geometry.kt} | 13 +- .../com/google/maps/android/data/Point.java | 73 -- .../com/google/maps/android/data/Point.kt | 54 ++ .../android/data/geojson/GeoJsonPoint.java | 75 --- .../maps/android/data/geojson/GeoJsonPoint.kt | 50 ++ .../geometry/{Bounds.java => Bounds.kt} | 52 +- .../android/geometry/{Point.java => Point.kt} | 24 +- .../maps/android/heatmaps/Gradient.java | 193 ------ .../google/maps/android/heatmaps/Gradient.kt | 178 +++++ .../projection/{Point.java => Point.kt} | 14 +- .../SphericalMercatorProjection.java | 46 -- .../projection/SphericalMercatorProjection.kt | 45 ++ .../maps/android/quadtree/PointQuadTree.java | 226 ------- .../maps/android/quadtree/PointQuadTree.kt | 231 +++++++ .../maps/android/ui/RotationLayout.java | 4 +- .../maps/android/ui/SquareTextView.java | 2 +- .../com/google/maps/android/PolyUtilTest.java | 4 +- .../maps/android/SphericalUtilTest.java | 9 +- .../google/maps/android/data/PointTest.java | 7 - .../data/geojson/GeoJsonPointTest.java | 6 +- .../maps/android/geometry/BoundsTest.kt | 48 ++ .../android/quadtree/PointQuadTreeTest.java | 232 ------- .../android/quadtree/PointQuadTreeTest.kt | 189 ++++++ 31 files changed, 1912 insertions(+), 1898 deletions(-) delete mode 100644 library/src/main/java/com/google/maps/android/MathUtil.java create mode 100644 library/src/main/java/com/google/maps/android/MathUtil.kt delete mode 100644 library/src/main/java/com/google/maps/android/PolyUtil.java create mode 100644 library/src/main/java/com/google/maps/android/PolyUtil.kt delete mode 100644 library/src/main/java/com/google/maps/android/SphericalUtil.java create mode 100644 library/src/main/java/com/google/maps/android/SphericalUtil.kt rename library/src/main/java/com/google/maps/android/data/{Geometry.java => Geometry.kt} (82%) delete mode 100644 library/src/main/java/com/google/maps/android/data/Point.java create mode 100644 library/src/main/java/com/google/maps/android/data/Point.kt delete mode 100644 library/src/main/java/com/google/maps/android/data/geojson/GeoJsonPoint.java create mode 100644 library/src/main/java/com/google/maps/android/data/geojson/GeoJsonPoint.kt rename library/src/main/java/com/google/maps/android/geometry/{Bounds.java => Bounds.kt} (50%) rename library/src/main/java/com/google/maps/android/geometry/{Point.java => Point.kt} (59%) delete mode 100644 library/src/main/java/com/google/maps/android/heatmaps/Gradient.java create mode 100644 library/src/main/java/com/google/maps/android/heatmaps/Gradient.kt rename library/src/main/java/com/google/maps/android/projection/{Point.java => Point.kt} (67%) delete mode 100644 library/src/main/java/com/google/maps/android/projection/SphericalMercatorProjection.java create mode 100644 library/src/main/java/com/google/maps/android/projection/SphericalMercatorProjection.kt delete mode 100644 library/src/main/java/com/google/maps/android/quadtree/PointQuadTree.java create mode 100644 library/src/main/java/com/google/maps/android/quadtree/PointQuadTree.kt create mode 100644 library/src/test/java/com/google/maps/android/geometry/BoundsTest.kt delete mode 100644 library/src/test/java/com/google/maps/android/quadtree/PointQuadTreeTest.java create mode 100644 library/src/test/java/com/google/maps/android/quadtree/PointQuadTreeTest.kt diff --git a/library/build.gradle b/library/build.gradle index d8e39ddd5..3e24382bb 100644 --- a/library/build.gradle +++ b/library/build.gradle @@ -76,6 +76,7 @@ dependencies { testImplementation(libs.kxml2) testImplementation(libs.mockk) implementation(libs.kotlin.stdlib.jdk8) + testImplementation(libs.truth) } tasks.register('instrumentTest') { dependsOn connectedCheck } diff --git a/library/src/main/java/com/google/maps/android/MathUtil.java b/library/src/main/java/com/google/maps/android/MathUtil.java deleted file mode 100644 index a7e2c7488..000000000 --- a/library/src/main/java/com/google/maps/android/MathUtil.java +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright 2013 Google Inc. - * - * 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.google.maps.android; - -import static java.lang.Math.*; - -/** - * Utility functions that are used my both PolyUtil and SphericalUtil. - */ -class MathUtil { - /** - * The earth's radius, in meters. - * Mean radius as defined by IUGG. - */ - static final double EARTH_RADIUS = 6371009; - - /** - * Restrict x to the range [low, high]. - */ - static double clamp(double x, double low, double high) { - return x < low ? low : (x > high ? high : x); - } - - /** - * Wraps the given value into the inclusive-exclusive interval between min and max. - * - * @param n The value to wrap. - * @param min The minimum. - * @param max The maximum. - */ - static double wrap(double n, double min, double max) { - return (n >= min && n < max) ? n : (mod(n - min, max - min) + min); - } - - /** - * Returns the non-negative remainder of x / m. - * - * @param x The operand. - * @param m The modulus. - */ - static double mod(double x, double m) { - return ((x % m) + m) % m; - } - - /** - * Returns mercator Y corresponding to latitude. - * See http://en.wikipedia.org/wiki/Mercator_projection . - */ - static double mercator(double lat) { - return log(tan(lat * 0.5 + PI / 4)); - } - - /** - * Returns latitude from mercator Y. - */ - static double inverseMercator(double y) { - return 2 * atan(exp(y)) - PI / 2; - } - - /** - * Returns haversine(angle-in-radians). - * hav(x) == (1 - cos(x)) / 2 == sin(x / 2)^2. - */ - static double hav(double x) { - double sinHalf = sin(x * 0.5); - return sinHalf * sinHalf; - } - - /** - * Computes inverse haversine. Has good numerical stability around 0. - * arcHav(x) == acos(1 - 2 * x) == 2 * asin(sqrt(x)). - * The argument must be in [0, 1], and the result is positive. - */ - static double arcHav(double x) { - return 2 * asin(sqrt(x)); - } - - // Given h==hav(x), returns sin(abs(x)). - static double sinFromHav(double h) { - return 2 * sqrt(h * (1 - h)); - } - - // Returns hav(asin(x)). - static double havFromSin(double x) { - double x2 = x * x; - return x2 / (1 + sqrt(1 - x2)) * .5; - } - - // Returns sin(arcHav(x) + arcHav(y)). - static double sinSumFromHav(double x, double y) { - double a = sqrt(x * (1 - x)); - double b = sqrt(y * (1 - y)); - return 2 * (a + b - 2 * (a * y + b * x)); - } - - /** - * Returns hav() of distance from (lat1, lng1) to (lat2, lng2) on the unit sphere. - */ - static double havDistance(double lat1, double lat2, double dLng) { - return hav(lat1 - lat2) + hav(dLng) * cos(lat1) * cos(lat2); - } -} diff --git a/library/src/main/java/com/google/maps/android/MathUtil.kt b/library/src/main/java/com/google/maps/android/MathUtil.kt new file mode 100644 index 000000000..4b99e1f5f --- /dev/null +++ b/library/src/main/java/com/google/maps/android/MathUtil.kt @@ -0,0 +1,150 @@ +/* + * Copyright 2013 Google Inc. + * + * 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.google.maps.android + +import kotlin.math.PI +import kotlin.math.asin +import kotlin.math.atan +import kotlin.math.cos +import kotlin.math.exp +import kotlin.math.ln +import kotlin.math.sin +import kotlin.math.sqrt +import kotlin.math.tan + +/** + * Utility functions that are used my both PolyUtil and SphericalUtil. + */ +object MathUtil { + /** + * The earth's radius, in meters. + * Mean radius as defined by IUGG. + */ + const val EARTH_RADIUS: Double = 6371009.0 + + /** + * Constant by which to multiply an angular value in degrees to obtain an + * angular value in radians. + */ + private const val DEGREES_TO_RADIANS = PI / 180.0 + + fun Double.toRadians() = this * DEGREES_TO_RADIANS + + /** + * Constant by which to multiply an angular value in degrees to obtain an + * angular value in radians. + */ + private const val RADIANS_TO_DEGREES = 180.0 / PI + + fun Double.toDegrees() = this * RADIANS_TO_DEGREES + + /** + * Restrict x to the range [low, high]. + */ + @JvmStatic + fun clamp(x: Double, low: Double, high: Double): Double { + return if (x < low) low else (if (x > high) high else x) + } + + /** + * Wraps the given value into the inclusive-exclusive interval between min and max. + * + * @param n The value to wrap. + * @param min The minimum. + * @param max The maximum. + */ + @JvmStatic + fun wrap(n: Double, min: Double, max: Double): Double { + return if ((n >= min && n < max)) n else (mod(n - min, max - min) + min) + } + + /** + * Returns the non-negative remainder of x / m. + * + * @param x The operand. + * @param m The modulus. + */ + @JvmStatic + fun mod(x: Double, m: Double): Double { + return ((x % m) + m) % m + } + + /** + * Returns mercator Y corresponding to latitude. + * See http://en.wikipedia.org/wiki/Mercator_projection . + */ + @JvmStatic + fun mercator(lat: Double): Double { + return ln(tan(lat * 0.5 + Math.PI / 4)) + } + + /** + * Returns latitude from mercator Y. + */ + @JvmStatic + fun inverseMercator(y: Double): Double { + return 2 * atan(exp(y)) - Math.PI / 2 + } + + /** + * Returns haversine(angle-in-radians). + * hav(x) == (1 - cos(x)) / 2 == sin(x / 2)^2. + */ + @JvmStatic + fun hav(x: Double): Double { + val sinHalf = sin(x * 0.5) + return sinHalf * sinHalf + } + + /** + * Computes inverse haversine. Has good numerical stability around 0. + * arcHav(x) == acos(1 - 2 * x) == 2 * asin(sqrt(x)). + * The argument must be in [0, 1], and the result is positive. + */ + @JvmStatic + fun arcHav(x: Double): Double { + return 2 * asin(sqrt(x)) + } + + // Given h==hav(x), returns sin(abs(x)). + @JvmStatic + fun sinFromHav(h: Double): Double { + return 2 * sqrt(h * (1 - h)) + } + + // Returns hav(asin(x)). + @JvmStatic + fun havFromSin(x: Double): Double { + val x2 = x * x + return x2 / (1 + sqrt(1 - x2)) * .5 + } + + // Returns sin(arcHav(x) + arcHav(y)). + @JvmStatic + fun sinSumFromHav(x: Double, y: Double): Double { + val a = sqrt(x * (1 - x)) + val b = sqrt(y * (1 - y)) + return 2 * (a + b - 2 * (a * y + b * x)) + } + + /** + * Returns hav() of distance from (lat1, lng1) to (lat2, lng2) on the unit sphere. + */ + @JvmStatic + fun havDistance(lat1: Double, lat2: Double, dLng: Double): Double { + return hav(lat1 - lat2) + hav(dLng) * cos(lat1) * cos(lat2) + } +} diff --git a/library/src/main/java/com/google/maps/android/PolyUtil.java b/library/src/main/java/com/google/maps/android/PolyUtil.java deleted file mode 100644 index 1c32d5a38..000000000 --- a/library/src/main/java/com/google/maps/android/PolyUtil.java +++ /dev/null @@ -1,580 +0,0 @@ -/* - * Copyright 2008, 2013 Google Inc. - * - * 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.google.maps.android; - -import com.google.android.gms.maps.model.LatLng; - -import java.util.ArrayList; -import java.util.List; -import java.util.Stack; - -import static com.google.maps.android.MathUtil.EARTH_RADIUS; -import static com.google.maps.android.MathUtil.clamp; -import static com.google.maps.android.MathUtil.hav; -import static com.google.maps.android.MathUtil.havDistance; -import static com.google.maps.android.MathUtil.havFromSin; -import static com.google.maps.android.MathUtil.inverseMercator; -import static com.google.maps.android.MathUtil.mercator; -import static com.google.maps.android.MathUtil.sinFromHav; -import static com.google.maps.android.MathUtil.sinSumFromHav; -import static com.google.maps.android.MathUtil.wrap; -import static com.google.maps.android.SphericalUtil.computeDistanceBetween; -import static java.lang.Math.PI; -import static java.lang.Math.cos; -import static java.lang.Math.max; -import static java.lang.Math.min; -import static java.lang.Math.sin; -import static java.lang.Math.sqrt; -import static java.lang.Math.tan; -import static java.lang.Math.toRadians; - -public class PolyUtil { - - private PolyUtil() { - } - - /** - * Returns tan(latitude-at-lng3) on the great circle (lat1, lng1) to (lat2, lng2). lng1==0. - * See http://williams.best.vwh.net/avform.htm . - */ - private static double tanLatGC(double lat1, double lat2, double lng2, double lng3) { - return (tan(lat1) * sin(lng2 - lng3) + tan(lat2) * sin(lng3)) / sin(lng2); - } - - /** - * Returns mercator(latitude-at-lng3) on the Rhumb line (lat1, lng1) to (lat2, lng2). lng1==0. - */ - private static double mercatorLatRhumb(double lat1, double lat2, double lng2, double lng3) { - return (mercator(lat1) * (lng2 - lng3) + mercator(lat2) * lng3) / lng2; - } - - /** - * Computes whether the vertical segment (lat3, lng3) to South Pole intersects the segment - * (lat1, lng1) to (lat2, lng2). - * Longitudes are offset by -lng1; the implicit lng1 becomes 0. - */ - private static boolean intersects(double lat1, double lat2, double lng2, - double lat3, double lng3, boolean geodesic) { - // Both ends on the same side of lng3. - if ((lng3 >= 0 && lng3 >= lng2) || (lng3 < 0 && lng3 < lng2)) { - return false; - } - // Point is South Pole. - if (lat3 <= -PI / 2) { - return false; - } - // Any segment end is a pole. - if (lat1 <= -PI / 2 || lat2 <= -PI / 2 || lat1 >= PI / 2 || lat2 >= PI / 2) { - return false; - } - if (lng2 <= -PI) { - return false; - } - double linearLat = (lat1 * (lng2 - lng3) + lat2 * lng3) / lng2; - // Northern hemisphere and point under lat-lng line. - if (lat1 >= 0 && lat2 >= 0 && lat3 < linearLat) { - return false; - } - // Southern hemisphere and point above lat-lng line. - if (lat1 <= 0 && lat2 <= 0 && lat3 >= linearLat) { - return true; - } - // North Pole. - if (lat3 >= PI / 2) { - return true; - } - // Compare lat3 with latitude on the GC/Rhumb segment corresponding to lng3. - // Compare through a strictly-increasing function (tan() or mercator()) as convenient. - return geodesic ? - tan(lat3) >= tanLatGC(lat1, lat2, lng2, lng3) : - mercator(lat3) >= mercatorLatRhumb(lat1, lat2, lng2, lng3); - } - - public static boolean containsLocation(LatLng point, List polygon, boolean geodesic) { - return containsLocation(point.latitude, point.longitude, polygon, geodesic); - } - - /** - * Computes whether the given point lies inside the specified polygon. - * The polygon is always considered closed, regardless of whether the last point equals - * the first or not. - * Inside is defined as not containing the South Pole -- the South Pole is always outside. - * The polygon is formed of great circle segments if geodesic is true, and of rhumb - * (loxodromic) segments otherwise. - */ - public static boolean containsLocation(double latitude, double longitude, List polygon, boolean geodesic) { - final int size = polygon.size(); - if (size == 0) { - return false; - } - double lat3 = toRadians(latitude); - double lng3 = toRadians(longitude); - LatLng prev = polygon.get(size - 1); - double lat1 = toRadians(prev.latitude); - double lng1 = toRadians(prev.longitude); - int nIntersect = 0; - for (LatLng point2 : polygon) { - double dLng3 = wrap(lng3 - lng1, -PI, PI); - // Special case: point equal to vertex is inside. - if (lat3 == lat1 && dLng3 == 0) { - return true; - } - double lat2 = toRadians(point2.latitude); - double lng2 = toRadians(point2.longitude); - // Offset longitudes by -lng1. - if (intersects(lat1, lat2, wrap(lng2 - lng1, -PI, PI), lat3, dLng3, geodesic)) { - ++nIntersect; - } - lat1 = lat2; - lng1 = lng2; - } - return (nIntersect & 1) != 0; - } - - public static final double DEFAULT_TOLERANCE = 0.1; // meters. - - /** - * Computes whether the given point lies on or near the edge of a polygon, within a specified - * tolerance in meters. The polygon edge is composed of great circle segments if geodesic - * is true, and of Rhumb segments otherwise. The polygon edge is implicitly closed -- the - * closing segment between the first point and the last point is included. - */ - public static boolean isLocationOnEdge(LatLng point, List polygon, boolean geodesic, - double tolerance) { - return isLocationOnEdgeOrPath(point, polygon, true, geodesic, tolerance); - } - - /** - * Same as {@link #isLocationOnEdge(LatLng, List, boolean, double)} - * with a default tolerance of 0.1 meters. - */ - public static boolean isLocationOnEdge(LatLng point, List polygon, boolean geodesic) { - return isLocationOnEdge(point, polygon, geodesic, DEFAULT_TOLERANCE); - } - - /** - * Computes whether the given point lies on or near a polyline, within a specified - * tolerance in meters. The polyline is composed of great circle segments if geodesic - * is true, and of Rhumb segments otherwise. The polyline is not closed -- the closing - * segment between the first point and the last point is not included. - */ - public static boolean isLocationOnPath(LatLng point, List polyline, - boolean geodesic, double tolerance) { - return isLocationOnEdgeOrPath(point, polyline, false, geodesic, tolerance); - } - - /** - * Same as {@link #isLocationOnPath(LatLng, List, boolean, double)} - *

- * with a default tolerance of 0.1 meters. - */ - public static boolean isLocationOnPath(LatLng point, List polyline, - boolean geodesic) { - return isLocationOnPath(point, polyline, geodesic, DEFAULT_TOLERANCE); - } - - private static boolean isLocationOnEdgeOrPath(LatLng point, List poly, boolean closed, - boolean geodesic, double toleranceEarth) { - int idx = locationIndexOnEdgeOrPath(point, poly, closed, geodesic, toleranceEarth); - - return (idx >= 0); - } - - /** - * Computes whether (and where) a given point lies on or near a polyline, within a specified tolerance. - * The polyline is not closed -- the closing segment between the first point and the last point is not included. - * - * @param point our needle - * @param poly our haystack - * @param geodesic the polyline is composed of great circle segments if geodesic - * is true, and of Rhumb segments otherwise - * @param tolerance tolerance (in meters) - * @return -1 if point does not lie on or near the polyline. - * 0 if point is between poly[0] and poly[1] (inclusive), - * 1 if between poly[1] and poly[2], - * ..., - * poly.size()-2 if between poly[poly.size() - 2] and poly[poly.size() - 1] - */ - public static int locationIndexOnPath(LatLng point, List poly, - boolean geodesic, double tolerance) { - return locationIndexOnEdgeOrPath(point, poly, false, geodesic, tolerance); - } - - /** - * Same as {@link #locationIndexOnPath(LatLng, List, boolean, double)} - *

- * with a default tolerance of 0.1 meters. - */ - public static int locationIndexOnPath(LatLng point, List polyline, - boolean geodesic) { - return locationIndexOnPath(point, polyline, geodesic, DEFAULT_TOLERANCE); - } - - /** - * Computes whether (and where) a given point lies on or near a polyline, within a specified tolerance. - * If closed, the closing segment between the last and first points of the polyline is not considered. - * - * @param point our needle - * @param poly our haystack - * @param closed whether the polyline should be considered closed by a segment connecting the last point back to the first one - * @param geodesic the polyline is composed of great circle segments if geodesic - * is true, and of Rhumb segments otherwise - * @param toleranceEarth tolerance (in meters) - * @return -1 if point does not lie on or near the polyline. - * 0 if point is between poly[0] and poly[1] (inclusive), - * 1 if between poly[1] and poly[2], - * ..., - * poly.size()-2 if between poly[poly.size() - 2] and poly[poly.size() - 1] - */ - public static int locationIndexOnEdgeOrPath(LatLng point, List poly, boolean closed, - boolean geodesic, double toleranceEarth) { - int size = poly.size(); - if (size == 0) { - return -1; - } - double tolerance = toleranceEarth / EARTH_RADIUS; - double havTolerance = hav(tolerance); - double lat3 = toRadians(point.latitude); - double lng3 = toRadians(point.longitude); - LatLng prev = poly.get(closed ? size - 1 : 0); - double lat1 = toRadians(prev.latitude); - double lng1 = toRadians(prev.longitude); - int idx = 0; - if (geodesic) { - for (LatLng point2 : poly) { - double lat2 = toRadians(point2.latitude); - double lng2 = toRadians(point2.longitude); - if (isOnSegmentGC(lat1, lng1, lat2, lng2, lat3, lng3, havTolerance)) { - return Math.max(0, idx - 1); - } - lat1 = lat2; - lng1 = lng2; - idx++; - } - } else { - // We project the points to mercator space, where the Rhumb segment is a straight line, - // and compute the geodesic distance between point3 and the closest point on the - // segment. This method is an approximation, because it uses "closest" in mercator - // space which is not "closest" on the sphere -- but the error is small because - // "tolerance" is small. - double minAcceptable = lat3 - tolerance; - double maxAcceptable = lat3 + tolerance; - double y1 = mercator(lat1); - double y3 = mercator(lat3); - double[] xTry = new double[3]; - for (LatLng point2 : poly) { - double lat2 = toRadians(point2.latitude); - double y2 = mercator(lat2); - double lng2 = toRadians(point2.longitude); - if (max(lat1, lat2) >= minAcceptable && min(lat1, lat2) <= maxAcceptable) { - // We offset longitudes by -lng1; the implicit x1 is 0. - double x2 = wrap(lng2 - lng1, -PI, PI); - double x3Base = wrap(lng3 - lng1, -PI, PI); - xTry[0] = x3Base; - // Also explore wrapping of x3Base around the world in both directions. - xTry[1] = x3Base + 2 * PI; - xTry[2] = x3Base - 2 * PI; - for (double x3 : xTry) { - double dy = y2 - y1; - double len2 = x2 * x2 + dy * dy; - double t = len2 <= 0 ? 0 : clamp((x3 * x2 + (y3 - y1) * dy) / len2, 0, 1); - double xClosest = t * x2; - double yClosest = y1 + t * dy; - double latClosest = inverseMercator(yClosest); - double havDist = havDistance(lat3, latClosest, x3 - xClosest); - if (havDist < havTolerance) { - return Math.max(0, idx - 1); - } - } - } - lat1 = lat2; - lng1 = lng2; - y1 = y2; - idx++; - } - } - return -1; - } - - /** - * Returns sin(initial bearing from (lat1,lng1) to (lat3,lng3) minus initial bearing - * from (lat1, lng1) to (lat2,lng2)). - */ - private static double sinDeltaBearing(double lat1, double lng1, double lat2, double lng2, - double lat3, double lng3) { - double sinLat1 = sin(lat1); - double cosLat2 = cos(lat2); - double cosLat3 = cos(lat3); - double lat31 = lat3 - lat1; - double lng31 = lng3 - lng1; - double lat21 = lat2 - lat1; - double lng21 = lng2 - lng1; - double a = sin(lng31) * cosLat3; - double c = sin(lng21) * cosLat2; - double b = sin(lat31) + 2 * sinLat1 * cosLat3 * hav(lng31); - double d = sin(lat21) + 2 * sinLat1 * cosLat2 * hav(lng21); - double denom = (a * a + b * b) * (c * c + d * d); - return denom <= 0 ? 1 : (a * d - b * c) / sqrt(denom); - } - - private static boolean isOnSegmentGC(double lat1, double lng1, double lat2, double lng2, - double lat3, double lng3, double havTolerance) { - double havDist13 = havDistance(lat1, lat3, lng1 - lng3); - if (havDist13 <= havTolerance) { - return true; - } - double havDist23 = havDistance(lat2, lat3, lng2 - lng3); - if (havDist23 <= havTolerance) { - return true; - } - double sinBearing = sinDeltaBearing(lat1, lng1, lat2, lng2, lat3, lng3); - double sinDist13 = sinFromHav(havDist13); - double havCrossTrack = havFromSin(sinDist13 * sinBearing); - if (havCrossTrack > havTolerance) { - return false; - } - double havDist12 = havDistance(lat1, lat2, lng1 - lng2); - double term = havDist12 + havCrossTrack * (1 - 2 * havDist12); - if (havDist13 > term || havDist23 > term) { - return false; - } - if (havDist12 < 0.74) { - return true; - } - double cosCrossTrack = 1 - 2 * havCrossTrack; - double havAlongTrack13 = (havDist13 - havCrossTrack) / cosCrossTrack; - double havAlongTrack23 = (havDist23 - havCrossTrack) / cosCrossTrack; - double sinSumAlongTrack = sinSumFromHav(havAlongTrack13, havAlongTrack23); - return sinSumAlongTrack > 0; // Compare with half-circle == PI using sign of sin(). - } - - /** - * Simplifies the given poly (polyline or polygon) using the Douglas-Peucker decimation - * algorithm. Increasing the tolerance will result in fewer points in the simplified polyline - * or polygon. - *

- * When the providing a polygon as input, the first and last point of the list MUST have the - * same latitude and longitude (i.e., the polygon must be closed). If the input polygon is not - * closed, the resulting polygon may not be fully simplified. - *

- * The time complexity of Douglas-Peucker is O(n^2), so take care that you do not call this - * algorithm too frequently in your code. - * - * @param poly polyline or polygon to be simplified. Polygon should be closed (i.e., - * first and last points should have the same latitude and longitude). - * @param tolerance in meters. Increasing the tolerance will result in fewer points in the - * simplified poly. - * @return a simplified poly produced by the Douglas-Peucker algorithm - */ - public static List simplify(List poly, double tolerance) { - final int n = poly.size(); - if (n < 1) { - throw new IllegalArgumentException("Polyline must have at least 1 point"); - } - if (tolerance <= 0) { - throw new IllegalArgumentException("Tolerance must be greater than zero"); - } - - boolean closedPolygon = isClosedPolygon(poly); - LatLng lastPoint = null; - - // Check if the provided poly is a closed polygon - if (closedPolygon) { - // Add a small offset to the last point for Douglas-Peucker on polygons (see #201) - final double OFFSET = 0.00000000001; - lastPoint = poly.get(poly.size() - 1); - // LatLng.latitude and .longitude are immutable, so replace the last point - poly.remove(poly.size() - 1); - poly.add(new LatLng(lastPoint.latitude + OFFSET, lastPoint.longitude + OFFSET)); - } - - int idx; - int maxIdx = 0; - Stack stack = new Stack<>(); - double[] dists = new double[n]; - dists[0] = 1; - dists[n - 1] = 1; - double maxDist; - double dist = 0.0; - int[] current; - - if (n > 2) { - int[] stackVal = new int[]{0, (n - 1)}; - stack.push(stackVal); - while (stack.size() > 0) { - current = stack.pop(); - maxDist = 0; - for (idx = current[0] + 1; idx < current[1]; ++idx) { - dist = distanceToLine(poly.get(idx), poly.get(current[0]), - poly.get(current[1])); - if (dist > maxDist) { - maxDist = dist; - maxIdx = idx; - } - } - if (maxDist > tolerance) { - dists[maxIdx] = maxDist; - int[] stackValCurMax = {current[0], maxIdx}; - stack.push(stackValCurMax); - int[] stackValMaxCur = {maxIdx, current[1]}; - stack.push(stackValMaxCur); - } - } - } - - if (closedPolygon) { - // Replace last point w/ offset with the original last point to re-close the polygon - poly.remove(poly.size() - 1); - poly.add(lastPoint); - } - - // Generate the simplified line - idx = 0; - ArrayList simplifiedLine = new ArrayList<>(); - for (LatLng l : poly) { - if (dists[idx] != 0) { - simplifiedLine.add(l); - } - idx++; - } - - return simplifiedLine; - } - - /** - * Returns true if the provided list of points is a closed polygon (i.e., the first and last - * points are the same), and false if it is not - * - * @param poly polyline or polygon - * @return true if the provided list of points is a closed polygon (i.e., the first and last - * points are the same), and false if it is not - */ - public static boolean isClosedPolygon(List poly) { - LatLng firstPoint = poly.get(0); - LatLng lastPoint = poly.get(poly.size() - 1); - return firstPoint.equals(lastPoint); - } - - /** - * Computes the distance on the sphere between the point p and the line segment start to end. - * - * @param p the point to be measured - * @param start the beginning of the line segment - * @param end the end of the line segment - * @return the distance in meters (assuming spherical earth) - */ - public static double distanceToLine(final LatLng p, final LatLng start, final LatLng end) { - if (start.equals(end)) { - return computeDistanceBetween(end, p); - } - - // Implementation of http://paulbourke.net/geometry/pointlineplane/ or http://geomalgorithms.com/a02-_lines.html - final double s0lat = toRadians(p.latitude); - final double s0lng = toRadians(p.longitude); - final double s1lat = toRadians(start.latitude); - final double s1lng = toRadians(start.longitude); - final double s2lat = toRadians(end.latitude); - final double s2lng = toRadians(end.longitude); - - double lonCorrection = Math.cos(s1lat); - double s2s1lat = s2lat - s1lat; - double s2s1lng = (s2lng - s1lng) * lonCorrection; - final double u = ((s0lat - s1lat) * s2s1lat + (s0lng - s1lng) * lonCorrection * s2s1lng) - / (s2s1lat * s2s1lat + s2s1lng * s2s1lng); - if (u <= 0) { - return computeDistanceBetween(p, start); - } - if (u >= 1) { - return computeDistanceBetween(p, end); - } - LatLng su = new LatLng(start.latitude + u * (end.latitude - start.latitude), start.longitude + u * (end.longitude - start.longitude)); - return computeDistanceBetween(p, su); - } - - /** - * Decodes an encoded path string into a sequence of LatLngs. - */ - public static List decode(final String encodedPath) { - int len = encodedPath.length(); - - // For speed we preallocate to an upper bound on the final length, then - // truncate the array before returning. - final List path = new ArrayList(); - int index = 0; - int lat = 0; - int lng = 0; - - while (index < len) { - int result = 1; - int shift = 0; - int b; - do { - b = encodedPath.charAt(index++) - 63 - 1; - result += b << shift; - shift += 5; - } while (b >= 0x1f); - lat += (result & 1) != 0 ? ~(result >> 1) : (result >> 1); - - result = 1; - shift = 0; - do { - b = encodedPath.charAt(index++) - 63 - 1; - result += b << shift; - shift += 5; - } while (b >= 0x1f); - lng += (result & 1) != 0 ? ~(result >> 1) : (result >> 1); - - path.add(new LatLng(lat * 1e-5, lng * 1e-5)); - } - - return path; - } - - /** - * Encodes a sequence of LatLngs into an encoded path string. - */ - public static String encode(final List path) { - long lastLat = 0; - long lastLng = 0; - - final StringBuffer result = new StringBuffer(); - - for (final LatLng point : path) { - long lat = Math.round(point.latitude * 1e5); - long lng = Math.round(point.longitude * 1e5); - - long dLat = lat - lastLat; - long dLng = lng - lastLng; - - encode(dLat, result); - encode(dLng, result); - - lastLat = lat; - lastLng = lng; - } - return result.toString(); - } - - private static void encode(long v, StringBuffer result) { - v = v < 0 ? ~(v << 1) : v << 1; - while (v >= 0x20) { - result.append(Character.toChars((int) ((0x20 | (v & 0x1f)) + 63))); - v >>= 5; - } - result.append(Character.toChars((int) (v + 63))); - } -} diff --git a/library/src/main/java/com/google/maps/android/PolyUtil.kt b/library/src/main/java/com/google/maps/android/PolyUtil.kt new file mode 100644 index 000000000..bc4c98c3b --- /dev/null +++ b/library/src/main/java/com/google/maps/android/PolyUtil.kt @@ -0,0 +1,635 @@ +/* + * Copyright 2008, 2013 Google Inc. + * + * 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.google.maps.android + +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.MathUtil.EARTH_RADIUS +import com.google.maps.android.MathUtil.clamp +import com.google.maps.android.MathUtil.hav +import com.google.maps.android.MathUtil.havDistance +import com.google.maps.android.MathUtil.havFromSin +import com.google.maps.android.MathUtil.inverseMercator +import com.google.maps.android.MathUtil.mercator +import com.google.maps.android.MathUtil.sinFromHav +import com.google.maps.android.MathUtil.sinSumFromHav +import com.google.maps.android.MathUtil.wrap +import java.util.Stack +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.min +import kotlin.math.sin +import kotlin.math.sqrt +import kotlin.math.tan + +object PolyUtil { + /** + * Returns tan(latitude-at-lng3) on the great circle (lat1, lng1) to (lat2, lng2). lng1==0. + * See http://williams.best.vwh.net/avform.htm . + */ + private fun tanLatGC(lat1: Double, lat2: Double, lng2: Double, lng3: Double): Double { + return (tan(lat1) * sin(lng2 - lng3) + tan(lat2) * sin(lng3)) / sin(lng2) + } + + /** + * Returns mercator(latitude-at-lng3) on the Rhumb line (lat1, lng1) to (lat2, lng2). lng1==0. + */ + private fun mercatorLatRhumb(lat1: Double, lat2: Double, lng2: Double, lng3: Double): Double { + return (mercator(lat1) * (lng2 - lng3) + mercator(lat2) * lng3) / lng2 + } + + /** + * Computes whether the vertical segment (lat3, lng3) to South Pole intersects the segment + * (lat1, lng1) to (lat2, lng2). + * Longitudes are offset by -lng1; the implicit lng1 becomes 0. + */ + private fun intersects( + lat1: Double, lat2: Double, lng2: Double, + lat3: Double, lng3: Double, geodesic: Boolean + ): Boolean { + // Both ends on the same side of lng3. + if ((lng3 >= 0 && lng3 >= lng2) || (lng3 < 0 && lng3 < lng2)) { + return false + } + // Point is South Pole. + if (lat3 <= -Math.PI / 2) { + return false + } + // Any segment end is a pole. + if (lat1 <= -Math.PI / 2 || lat2 <= -Math.PI / 2 || lat1 >= Math.PI / 2 || lat2 >= Math.PI / 2) { + return false + } + if (lng2 <= -Math.PI) { + return false + } + val linearLat = (lat1 * (lng2 - lng3) + lat2 * lng3) / lng2 + // Northern hemisphere and point under lat-lng line. + if (lat1 >= 0 && lat2 >= 0 && lat3 < linearLat) { + return false + } + // Southern hemisphere and point above lat-lng line. + if (lat1 <= 0 && lat2 <= 0 && lat3 >= linearLat) { + return true + } + // North Pole. + if (lat3 >= Math.PI / 2) { + return true + } + // Compare lat3 with latitude on the GC/Rhumb segment corresponding to lng3. + // Compare through a strictly-increasing function (tan() or mercator()) as convenient. + return if (geodesic) tan(lat3) >= tanLatGC( + lat1, + lat2, + lng2, + lng3 + ) else mercator(lat3) >= mercatorLatRhumb(lat1, lat2, lng2, lng3) + } + + @JvmStatic + fun containsLocation(point: LatLng, polygon: List, geodesic: Boolean): Boolean { + return containsLocation(point.latitude, point.longitude, polygon, geodesic) + } + + /** + * Computes whether the given point lies inside the specified polygon. + * The polygon is always considered closed, regardless of whether the last point equals + * the first or not. + * Inside is defined as not containing the South Pole -- the South Pole is always outside. + * The polygon is formed of great circle segments if geodesic is true, and of rhumb + * (loxodromic) segments otherwise. + */ + @JvmStatic + fun containsLocation( + latitude: Double, + longitude: Double, + polygon: List, + geodesic: Boolean + ): Boolean { + val size = polygon.size + if (size == 0) { + return false + } + val lat3 = Math.toRadians(latitude) + val lng3 = Math.toRadians(longitude) + val prev = polygon[size - 1] + var lat1 = Math.toRadians(prev.latitude) + var lng1 = Math.toRadians(prev.longitude) + var nIntersect = 0 + for (point2 in polygon) { + val dLng3 = wrap(lng3 - lng1, -Math.PI, Math.PI) + // Special case: point equal to vertex is inside. + if (lat3 == lat1 && dLng3 == 0.0) { + return true + } + val lat2 = Math.toRadians(point2.latitude) + val lng2 = Math.toRadians(point2.longitude) + // Offset longitudes by -lng1. + if (intersects( + lat1, + lat2, + wrap(lng2 - lng1, -Math.PI, Math.PI), + lat3, + dLng3, + geodesic + ) + ) { + ++nIntersect + } + lat1 = lat2 + lng1 = lng2 + } + return (nIntersect and 1) != 0 + } + + const val DEFAULT_TOLERANCE: Double = 0.1 // meters. + + /** + * Computes whether the given point lies on or near the edge of a polygon, within a specified + * tolerance in meters. The polygon edge is composed of great circle segments if geodesic + * is true, and of Rhumb segments otherwise. The polygon edge is implicitly closed -- the + * closing segment between the first point and the last point is included. + */ + @JvmStatic + fun isLocationOnEdge( + point: LatLng, polygon: List, geodesic: Boolean, + tolerance: Double + ): Boolean { + return isLocationOnEdgeOrPath(point, polygon, true, geodesic, tolerance) + } + + /** + * Same as [.isLocationOnEdge] + * with a default tolerance of 0.1 meters. + */ + @JvmStatic + fun isLocationOnEdge(point: LatLng, polygon: List, geodesic: Boolean): Boolean { + return isLocationOnEdge(point, polygon, geodesic, DEFAULT_TOLERANCE) + } + + /** + * Computes whether the given point lies on or near a polyline, within a specified + * tolerance in meters. The polyline is composed of great circle segments if geodesic + * is true, and of Rhumb segments otherwise. The polyline is not closed -- the closing + * segment between the first point and the last point is not included. + */ + @JvmStatic + fun isLocationOnPath( + point: LatLng, polyline: List, + geodesic: Boolean, tolerance: Double + ): Boolean { + return isLocationOnEdgeOrPath(point, polyline, false, geodesic, tolerance) + } + + /** + * Same as [.isLocationOnPath] + * + * + * with a default tolerance of 0.1 meters. + */ + @JvmStatic + fun isLocationOnPath( + point: LatLng, polyline: List, + geodesic: Boolean + ): Boolean { + return isLocationOnPath(point, polyline, geodesic, DEFAULT_TOLERANCE) + } + + private fun isLocationOnEdgeOrPath( + point: LatLng, poly: List, closed: Boolean, + geodesic: Boolean, toleranceEarth: Double + ): Boolean { + val idx = locationIndexOnEdgeOrPath(point, poly, closed, geodesic, toleranceEarth) + + return (idx >= 0) + } + + /** + * Computes whether (and where) a given point lies on or near a polyline, within a specified tolerance. + * The polyline is not closed -- the closing segment between the first point and the last point is not included. + * + * @param point our needle + * @param poly our haystack + * @param geodesic the polyline is composed of great circle segments if geodesic + * is true, and of Rhumb segments otherwise + * @param tolerance tolerance (in meters) + * @return -1 if point does not lie on or near the polyline. + * 0 if point is between poly[0] and poly[1] (inclusive), + * 1 if between poly[1] and poly[2], + * ..., + * poly.size()-2 if between poly[poly.size() - 2] and poly[poly.size() - 1] + */ + @JvmStatic + fun locationIndexOnPath( + point: LatLng, poly: List, + geodesic: Boolean, tolerance: Double + ): Int { + return locationIndexOnEdgeOrPath(point, poly, false, geodesic, tolerance) + } + + /** + * Same as [.locationIndexOnPath] + * + * + * with a default tolerance of 0.1 meters. + */ + @JvmStatic + fun locationIndexOnPath( + point: LatLng, polyline: List, + geodesic: Boolean + ): Int { + return locationIndexOnPath(point, polyline, geodesic, DEFAULT_TOLERANCE) + } + + /** + * Computes whether (and where) a given point lies on or near a polyline, within a specified tolerance. + * If closed, the closing segment between the last and first points of the polyline is not considered. + * + * @param point our needle + * @param poly our haystack + * @param closed whether the polyline should be considered closed by a segment connecting the last point back to the first one + * @param geodesic the polyline is composed of great circle segments if geodesic + * is true, and of Rhumb segments otherwise + * @param toleranceEarth tolerance (in meters) + * @return -1 if point does not lie on or near the polyline. + * 0 if point is between poly[0] and poly[1] (inclusive), + * 1 if between poly[1] and poly[2], + * ..., + * poly.size()-2 if between poly[poly.size() - 2] and poly[poly.size() - 1] + */ + @JvmStatic + fun locationIndexOnEdgeOrPath( + point: LatLng, poly: List, closed: Boolean, + geodesic: Boolean, toleranceEarth: Double + ): Int { + val size = poly.size + if (size == 0) { + return -1 + } + val tolerance: Double = toleranceEarth / EARTH_RADIUS + val havTolerance = hav(tolerance) + val lat3 = Math.toRadians(point.latitude) + val lng3 = Math.toRadians(point.longitude) + val prev = poly[if (closed) size - 1 else 0] + var lat1 = Math.toRadians(prev.latitude) + var lng1 = Math.toRadians(prev.longitude) + var idx = 0 + if (geodesic) { + for (point2 in poly) { + val lat2 = Math.toRadians(point2.latitude) + val lng2 = Math.toRadians(point2.longitude) + if (isOnSegmentGC(lat1, lng1, lat2, lng2, lat3, lng3, havTolerance)) { + return max(0.0, (idx - 1).toDouble()).toInt() + } + lat1 = lat2 + lng1 = lng2 + idx++ + } + } else { + // We project the points to mercator space, where the Rhumb segment is a straight line, + // and compute the geodesic distance between point3 and the closest point on the + // segment. This method is an approximation, because it uses "closest" in mercator + // space which is not "closest" on the sphere -- but the error is small because + // "tolerance" is small. + val minAcceptable = lat3 - tolerance + val maxAcceptable = lat3 + tolerance + var y1 = mercator(lat1) + val y3 = mercator(lat3) + val xTry = DoubleArray(3) + for (point2 in poly) { + val lat2 = Math.toRadians(point2.latitude) + val y2 = mercator(lat2) + val lng2 = Math.toRadians(point2.longitude) + if (max(lat1, lat2) >= minAcceptable && min(lat1, lat2) <= maxAcceptable) { + // We offset longitudes by -lng1; the implicit x1 is 0. + val x2 = wrap(lng2 - lng1, -Math.PI, Math.PI) + val x3Base = wrap(lng3 - lng1, -Math.PI, Math.PI) + xTry[0] = x3Base + // Also explore wrapping of x3Base around the world in both directions. + xTry[1] = x3Base + 2 * Math.PI + xTry[2] = x3Base - 2 * Math.PI + for (x3 in xTry) { + val dy = y2 - y1 + val len2 = x2 * x2 + dy * dy + val t = if (len2 <= 0) 0.0 else clamp( + (x3 * x2 + (y3 - y1) * dy) / len2, + 0.0, + 1.0 + ) + val xClosest = t * x2 + val yClosest = y1 + t * dy + val latClosest = inverseMercator(yClosest) + val havDist = havDistance(lat3, latClosest, x3 - xClosest) + if (havDist < havTolerance) { + return max(0.0, (idx - 1).toDouble()).toInt() + } + } + } + lat1 = lat2 + lng1 = lng2 + y1 = y2 + idx++ + } + } + return -1 + } + + /** + * Returns sin(initial bearing from (lat1,lng1) to (lat3,lng3) minus initial bearing + * from (lat1, lng1) to (lat2,lng2)). + */ + private fun sinDeltaBearing( + lat1: Double, lng1: Double, lat2: Double, lng2: Double, + lat3: Double, lng3: Double + ): Double { + val sinLat1 = sin(lat1) + val cosLat2 = cos(lat2) + val cosLat3 = cos(lat3) + val lat31 = lat3 - lat1 + val lng31 = lng3 - lng1 + val lat21 = lat2 - lat1 + val lng21 = lng2 - lng1 + val a = sin(lng31) * cosLat3 + val c = sin(lng21) * cosLat2 + val b = sin(lat31) + 2 * sinLat1 * cosLat3 * hav(lng31) + val d = sin(lat21) + 2 * sinLat1 * cosLat2 * hav(lng21) + val denom = (a * a + b * b) * (c * c + d * d) + return if (denom <= 0) 1.0 else (a * d - b * c) / sqrt(denom) + } + + private fun isOnSegmentGC( + lat1: Double, lng1: Double, lat2: Double, lng2: Double, + lat3: Double, lng3: Double, havTolerance: Double + ): Boolean { + val havDist13 = havDistance(lat1, lat3, lng1 - lng3) + if (havDist13 <= havTolerance) { + return true + } + val havDist23 = havDistance(lat2, lat3, lng2 - lng3) + if (havDist23 <= havTolerance) { + return true + } + val sinBearing = sinDeltaBearing(lat1, lng1, lat2, lng2, lat3, lng3) + val sinDist13 = sinFromHav(havDist13) + val havCrossTrack = havFromSin(sinDist13 * sinBearing) + if (havCrossTrack > havTolerance) { + return false + } + val havDist12 = havDistance(lat1, lat2, lng1 - lng2) + val term = havDist12 + havCrossTrack * (1 - 2 * havDist12) + if (havDist13 > term || havDist23 > term) { + return false + } + if (havDist12 < 0.74) { + return true + } + val cosCrossTrack = 1 - 2 * havCrossTrack + val havAlongTrack13 = (havDist13 - havCrossTrack) / cosCrossTrack + val havAlongTrack23 = (havDist23 - havCrossTrack) / cosCrossTrack + val sinSumAlongTrack = sinSumFromHav(havAlongTrack13, havAlongTrack23) + return sinSumAlongTrack > 0 // Compare with half-circle == PI using sign of sin(). + } + + /** + * Simplifies the given poly (polyline or polygon) using the Douglas-Peucker decimation + * algorithm. Increasing the tolerance will result in fewer points in the simplified polyline + * or polygon. + * + * + * When the providing a polygon as input, the first and last point of the list MUST have the + * same latitude and longitude (i.e., the polygon must be closed). If the input polygon is not + * closed, the resulting polygon may not be fully simplified. + * + * + * The time complexity of Douglas-Peucker is O(n^2), so take care that you do not call this + * algorithm too frequently in your code. + * + * @param poly polyline or polygon to be simplified. Polygon should be closed (i.e., + * first and last points should have the same latitude and longitude). + * @param tolerance in meters. Increasing the tolerance will result in fewer points in the + * simplified poly. + * @return a simplified poly produced by the Douglas-Peucker algorithm + */ + @JvmStatic + fun simplify(poly: MutableList, tolerance: Double): List { + val offset = 0.00000000001 + val n = poly.size + require(n >= 1) { "Polyline must have at least 1 point" } + require(!(tolerance <= 0)) { "Tolerance must be greater than zero" } + + val closedPolygon = isClosedPolygon(poly) + var lastPoint = poly.last() + + // Check if the provided poly is a closed polygon + if (closedPolygon) { + // Add a small offset to the last point for Douglas-Peucker on polygons (see #201) + lastPoint = poly[poly.size - 1] + // LatLng.latitude and .longitude are immutable, so replace the last point + poly.removeAt(poly.size - 1) + poly.add(LatLng(lastPoint.latitude + offset, lastPoint.longitude + offset)) + } + + var idx: Int + var maxIdx = 0 + val stack = Stack() + + val dists = DoubleArray(n) + dists[0] = 1.0 + dists[n - 1] = 1.0 + + var maxDist: Double + var dist = 0.0 + var current: IntArray + + if (n > 2) { + val stackVal = intArrayOf(0, (n - 1)) + stack.push(stackVal) + while (stack.size > 0) { + current = stack.pop() + maxDist = 0.0 + idx = current[0] + 1 + while (idx < current[1]) { + dist = distanceToLine( + poly[idx], poly[current[0]], + poly[current[1]] + ) + if (dist > maxDist) { + maxDist = dist + maxIdx = idx + } + ++idx + } + if (maxDist > tolerance) { + dists[maxIdx] = maxDist + val stackValCurMax = intArrayOf(current[0], maxIdx) + stack.push(stackValCurMax) + val stackValMaxCur = intArrayOf(maxIdx, current[1]) + stack.push(stackValMaxCur) + } + } + } + + if (closedPolygon) { + // Replace last point w/ offset with the original last point to re-close the polygon + poly.removeAt(poly.size - 1) + poly.add(lastPoint) + } + + // Generate the simplified line + idx = 0 + val simplifiedLine = ArrayList() + for (l in poly) { + if (dists[idx] != 0.0) { + simplifiedLine.add(l) + } + idx++ + } + + return simplifiedLine + } + + /** + * Returns true if the provided list of points is a closed polygon (i.e., the first and last + * points are the same), and false if it is not + * + * @param poly polyline or polygon + * @return true if the provided list of points is a closed polygon (i.e., the first and last + * points are the same), and false if it is not + */ + @JvmStatic + fun isClosedPolygon(poly: List): Boolean { + val firstPoint = poly[0] + val lastPoint = poly[poly.size - 1] + return firstPoint == lastPoint + } + + /** + * Computes the distance on the sphere between the point p and the line segment start to end. + * + * @param p the point to be measured + * @param start the beginning of the line segment + * @param end the end of the line segment + * @return the distance in meters (assuming spherical earth) + */ + @JvmStatic + fun distanceToLine(p: LatLng, start: LatLng, end: LatLng): Double { + if (start == end) { + return SphericalUtil.computeDistanceBetween(end, p) + } + + // Implementation of http://paulbourke.net/geometry/pointlineplane/ or http://geomalgorithms.com/a02-_lines.html + val s0lat = Math.toRadians(p.latitude) + val s0lng = Math.toRadians(p.longitude) + val s1lat = Math.toRadians(start.latitude) + val s1lng = Math.toRadians(start.longitude) + val s2lat = Math.toRadians(end.latitude) + val s2lng = Math.toRadians(end.longitude) + + val lonCorrection = cos(s1lat) + val s2s1lat = s2lat - s1lat + val s2s1lng = (s2lng - s1lng) * lonCorrection + val u = (((s0lat - s1lat) * s2s1lat + (s0lng - s1lng) * lonCorrection * s2s1lng) + / (s2s1lat * s2s1lat + s2s1lng * s2s1lng)) + if (u <= 0) { + return SphericalUtil.computeDistanceBetween(p, start) + } + if (u >= 1) { + return SphericalUtil.computeDistanceBetween(p, end) + } + val su = LatLng( + start.latitude + u * (end.latitude - start.latitude), + start.longitude + u * (end.longitude - start.longitude) + ) + return SphericalUtil.computeDistanceBetween(p, su) + } + + /** + * Decodes an encoded path string into a sequence of LatLngs. + */ + @JvmStatic + fun decode(encodedPath: String): List { + val len = encodedPath.length + + // For speed we preallocate to an upper bound on the final length, then + // truncate the array before returning. + val path: MutableList = ArrayList() + var index = 0 + var lat = 0 + var lng = 0 + + while (index < len) { + var result = 1 + var shift = 0 + var b: Int + do { + b = encodedPath[index++].code - 63 - 1 + result += b shl shift + shift += 5 + } while (b >= 0x1f) + lat += if ((result and 1) != 0) (result shr 1).inv() else (result shr 1) + + result = 1 + shift = 0 + do { + b = encodedPath[index++].code - 63 - 1 + result += b shl shift + shift += 5 + } while (b >= 0x1f) + lng += if ((result and 1) != 0) (result shr 1).inv() else (result shr 1) + + path.add(LatLng(lat * 1e-5, lng * 1e-5)) + } + + return path + } + + /** + * Encodes a sequence of LatLngs into an encoded path string. + */ + @JvmStatic + fun encode(path: List): String { + var lastLat: Long = 0 + var lastLng: Long = 0 + + return buildString { + for (point in path) { + val lat = Math.round(point.latitude * 1e5) + val lng = Math.round(point.longitude * 1e5) + + val dLat = lat - lastLat + val dLng = lng - lastLng + + dLat.encodeTo(this) + dLng.encodeTo(this) + + lastLat = lat + lastLng = lng + } + } + } +} + +private fun Long.encodeTo(stringBuilder: StringBuilder) { + var value = if (this < 0) { + (this shl 1).inv() + } else { + this shl 1 + } + while (value >= 0x20) { + stringBuilder.append(Character.toChars(((0x20L or (value and 0x1fL)) + 63).toInt())) + value = value shr 5 + } + + stringBuilder.append(Character.toChars((value + 63).toInt())) +} diff --git a/library/src/main/java/com/google/maps/android/SphericalUtil.java b/library/src/main/java/com/google/maps/android/SphericalUtil.java deleted file mode 100644 index 53e03ecda..000000000 --- a/library/src/main/java/com/google/maps/android/SphericalUtil.java +++ /dev/null @@ -1,265 +0,0 @@ -/* - * Copyright 2013 Google Inc. - * - * 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.google.maps.android; - -import com.google.android.gms.maps.model.LatLng; - -import java.util.List; - -import static com.google.maps.android.MathUtil.*; -import static java.lang.Math.*; - -public class SphericalUtil { - - private SphericalUtil() { - } - - /** - * Returns the heading from one LatLng to another LatLng. Headings are - * expressed in degrees clockwise from North within the range [-180,180). - * - * @return The heading in degrees clockwise from north. - */ - public static double computeHeading(LatLng from, LatLng to) { - // http://williams.best.vwh.net/avform.htm#Crs - double fromLat = toRadians(from.latitude); - double fromLng = toRadians(from.longitude); - double toLat = toRadians(to.latitude); - double toLng = toRadians(to.longitude); - double dLng = toLng - fromLng; - double heading = atan2( - sin(dLng) * cos(toLat), - cos(fromLat) * sin(toLat) - sin(fromLat) * cos(toLat) * cos(dLng)); - return wrap(toDegrees(heading), -180, 180); - } - - /** - * Returns the LatLng resulting from moving a distance from an origin - * in the specified heading (expressed in degrees clockwise from north). - * - * @param from The LatLng from which to start. - * @param distance The distance to travel. - * @param heading The heading in degrees clockwise from north. - */ - public static LatLng computeOffset(LatLng from, double distance, double heading) { - distance /= EARTH_RADIUS; - heading = toRadians(heading); - // http://williams.best.vwh.net/avform.htm#LL - double fromLat = toRadians(from.latitude); - double fromLng = toRadians(from.longitude); - double cosDistance = cos(distance); - double sinDistance = sin(distance); - double sinFromLat = sin(fromLat); - double cosFromLat = cos(fromLat); - double sinLat = cosDistance * sinFromLat + sinDistance * cosFromLat * cos(heading); - double dLng = atan2( - sinDistance * cosFromLat * sin(heading), - cosDistance - sinFromLat * sinLat); - return new LatLng(toDegrees(asin(sinLat)), toDegrees(fromLng + dLng)); - } - - /** - * Returns the location of origin when provided with a LatLng destination, - * meters travelled and original heading. Headings are expressed in degrees - * clockwise from North. This function returns null when no solution is - * available. - * - * @param to The destination LatLng. - * @param distance The distance travelled, in meters. - * @param heading The heading in degrees clockwise from north. - */ - public static LatLng computeOffsetOrigin(LatLng to, double distance, double heading) { - heading = toRadians(heading); - distance /= EARTH_RADIUS; - // http://lists.maptools.org/pipermail/proj/2008-October/003939.html - double n1 = cos(distance); - double n2 = sin(distance) * cos(heading); - double n3 = sin(distance) * sin(heading); - double n4 = sin(toRadians(to.latitude)); - // There are two solutions for b. b = n2 * n4 +/- sqrt(), one solution results - // in the latitude outside the [-90, 90] range. We first try one solution and - // back off to the other if we are outside that range. - double n12 = n1 * n1; - double discriminant = n2 * n2 * n12 + n12 * n12 - n12 * n4 * n4; - if (discriminant < 0) { - // No real solution which would make sense in LatLng-space. - return null; - } - double b = n2 * n4 + sqrt(discriminant); - b /= n1 * n1 + n2 * n2; - double a = (n4 - n2 * b) / n1; - double fromLatRadians = atan2(a, b); - if (fromLatRadians < -PI / 2 || fromLatRadians > PI / 2) { - b = n2 * n4 - sqrt(discriminant); - b /= n1 * n1 + n2 * n2; - fromLatRadians = atan2(a, b); - } - if (fromLatRadians < -PI / 2 || fromLatRadians > PI / 2) { - // No solution which would make sense in LatLng-space. - return null; - } - double fromLngRadians = toRadians(to.longitude) - - atan2(n3, n1 * cos(fromLatRadians) - n2 * sin(fromLatRadians)); - return new LatLng(toDegrees(fromLatRadians), toDegrees(fromLngRadians)); - } - - /** - * Returns the LatLng which lies the given fraction of the way between the - * origin LatLng and the destination LatLng. - * - * @param from The LatLng from which to start. - * @param to The LatLng toward which to travel. - * @param fraction A fraction of the distance to travel. - * @return The interpolated LatLng. - */ - public static LatLng interpolate(LatLng from, LatLng to, double fraction) { - // http://en.wikipedia.org/wiki/Slerp - double fromLat = toRadians(from.latitude); - double fromLng = toRadians(from.longitude); - double toLat = toRadians(to.latitude); - double toLng = toRadians(to.longitude); - double cosFromLat = cos(fromLat); - double cosToLat = cos(toLat); - - // Computes Spherical interpolation coefficients. - double angle = computeAngleBetween(from, to); - double sinAngle = sin(angle); - if (sinAngle < 1E-6) { - return new LatLng( - from.latitude + fraction * (to.latitude - from.latitude), - from.longitude + fraction * (to.longitude - from.longitude)); - } - double a = sin((1 - fraction) * angle) / sinAngle; - double b = sin(fraction * angle) / sinAngle; - - // Converts from polar to vector and interpolate. - double x = a * cosFromLat * cos(fromLng) + b * cosToLat * cos(toLng); - double y = a * cosFromLat * sin(fromLng) + b * cosToLat * sin(toLng); - double z = a * sin(fromLat) + b * sin(toLat); - - // Converts interpolated vector back to polar. - double lat = atan2(z, sqrt(x * x + y * y)); - double lng = atan2(y, x); - return new LatLng(toDegrees(lat), toDegrees(lng)); - } - - /** - * Returns distance on the unit sphere; the arguments are in radians. - */ - private static double distanceRadians(double lat1, double lng1, double lat2, double lng2) { - return arcHav(havDistance(lat1, lat2, lng1 - lng2)); - } - - /** - * Returns the angle between two LatLngs, in radians. This is the same as the distance - * on the unit sphere. - */ - static double computeAngleBetween(LatLng from, LatLng to) { - return distanceRadians(toRadians(from.latitude), toRadians(from.longitude), - toRadians(to.latitude), toRadians(to.longitude)); - } - - /** - * Returns the distance between two LatLngs, in meters. - */ - public static double computeDistanceBetween(LatLng from, LatLng to) { - return computeAngleBetween(from, to) * EARTH_RADIUS; - } - - /** - * Returns the length of the given path, in meters, on Earth. - */ - public static double computeLength(List path) { - if (path.size() < 2) { - return 0; - } - double length = 0; - LatLng prev = null; - for (LatLng point : path) { - if (prev != null) { - double prevLat = toRadians(prev.latitude); - double prevLng = toRadians(prev.longitude); - double lat = toRadians(point.latitude); - double lng = toRadians(point.longitude); - length += distanceRadians(prevLat, prevLng, lat, lng); - } - prev = point; - } - return length * EARTH_RADIUS; - } - - /** - * Returns the area of a closed path on Earth. - * - * @param path A closed path. - * @return The path's area in square meters. - */ - public static double computeArea(List path) { - return abs(computeSignedArea(path)); - } - - /** - * Returns the signed area of a closed path on Earth. The sign of the area may be used to - * determine the orientation of the path. - * "inside" is the surface that does not contain the South Pole. - * - * @param path A closed path. - * @return The loop's area in square meters. - */ - public static double computeSignedArea(List path) { - return computeSignedArea(path, EARTH_RADIUS); - } - - /** - * Returns the signed area of a closed path on a sphere of given radius. - * The computed area uses the same units as the radius squared. - * Used by SphericalUtilTest. - */ - static double computeSignedArea(List path, double radius) { - int size = path.size(); - if (size < 3) { - return 0; - } - double total = 0; - LatLng prev = path.get(size - 1); - double prevTanLat = tan((PI / 2 - toRadians(prev.latitude)) / 2); - double prevLng = toRadians(prev.longitude); - // For each edge, accumulate the signed area of the triangle formed by the North Pole - // and that edge ("polar triangle"). - for (LatLng point : path) { - double tanLat = tan((PI / 2 - toRadians(point.latitude)) / 2); - double lng = toRadians(point.longitude); - total += polarTriangleArea(tanLat, lng, prevTanLat, prevLng); - prevTanLat = tanLat; - prevLng = lng; - } - return total * (radius * radius); - } - - /** - * Returns the signed area of a triangle which has North Pole as a vertex. - * Formula derived from "Area of a spherical triangle given two edges and the included angle" - * as per "Spherical Trigonometry" by Todhunter, page 71, section 103, point 2. - * See http://books.google.com/books?id=3uBHAAAAIAAJ&pg=PA71 - * The arguments named "tan" are tan((pi/2 - latitude)/2). - */ - private static double polarTriangleArea(double tan1, double lng1, double tan2, double lng2) { - double deltaLng = lng1 - lng2; - double t = tan1 * tan2; - return 2 * atan2(t * sin(deltaLng), 1 + t * cos(deltaLng)); - } -} diff --git a/library/src/main/java/com/google/maps/android/SphericalUtil.kt b/library/src/main/java/com/google/maps/android/SphericalUtil.kt new file mode 100644 index 000000000..c5ca91af5 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/SphericalUtil.kt @@ -0,0 +1,280 @@ +/* + * Copyright 2013 Google Inc. + * + * 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.google.maps.android + +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.MathUtil.EARTH_RADIUS +import com.google.maps.android.MathUtil.arcHav +import com.google.maps.android.MathUtil.havDistance +import com.google.maps.android.MathUtil.toDegrees +import com.google.maps.android.MathUtil.toRadians +import com.google.maps.android.MathUtil.wrap +import kotlin.math.abs +import kotlin.math.asin +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt +import kotlin.math.tan +import kotlin.math.PI + +object SphericalUtil { + /** + * Returns the heading from one LatLng to another LatLng. Headings are + * expressed in degrees clockwise from North within the range [-180,180). + * + * @return The heading in degrees clockwise from north. + */ + @JvmStatic + fun computeHeading(source: LatLng, destination: LatLng): Double { + // http://williams.best.vwh.net/avform.htm#Crs + val srcRad = source.toLatLngRadians() + val destRad = destination.toLatLngRadians() + + val dLng = destRad.longitude - srcRad.longitude + val heading = atan2( + sin(dLng) * cos(destRad.latitude), + cos(srcRad.latitude) * sin(destRad.latitude) - sin(srcRad.latitude) * cos(destRad.latitude) * cos(dLng) + ) + return wrap(heading.toDegrees(), -180.0, 180.0) + } + + /** + * Returns the LatLng resulting from moving a distance from an origin + * in the specified heading (expressed in degrees clockwise from north). + * + * @param from The LatLng from which to start. + * @param distance The distance to travel. + * @param heading The heading in degrees clockwise from north. + */ + @JvmStatic + fun computeOffset(from: LatLng, distance: Double, heading: Double): LatLng { + val distanceScaled = distance / EARTH_RADIUS + val headingRadians = heading.toRadians() + + // http://williams.best.vwh.net/avform.htm#LL + val (fromLat, fromLng) = from.toLatLngRadians() + + val cosDistance = cos(distanceScaled) + val sinDistance = sin(distanceScaled) + val sinFromLat = sin(fromLat) + val cosFromLat = cos(fromLat) + val sinLat = cosDistance * sinFromLat + sinDistance * cosFromLat * cos(headingRadians) + val dLng = atan2( + sinDistance * cosFromLat * sin(headingRadians), + cosDistance - sinFromLat * sinLat + ) + + return LatLngRadians(asin(sinLat), fromLng + dLng).toLatLng() + } + + /** + * Returns the location of origin when provided with a LatLng destination, + * meters travelled and original heading. Headings are expressed in degrees + * clockwise from North. This function returns null when no solution is + * available. + * + * @param to The destination LatLng. + * @param distance The distance travelled, in meters. + * @param heading The heading in degrees clockwise from north. + */ + @JvmStatic + fun computeOffsetOrigin(to: LatLng, distance: Double, heading: Double): LatLng? { + val distanceScaled = distance / EARTH_RADIUS + val headingRadians = heading.toRadians() + + // http://lists.maptools.org/pipermail/proj/2008-October/003939.html + val n1 = cos(distanceScaled) + val n2 = sin(distanceScaled) * cos(headingRadians) + val n3 = sin(distanceScaled) * sin(headingRadians) + val n4 = sin(Math.toRadians(to.latitude)) + // There are two solutions for b. b = n2 * n4 +/- sqrt(), one solution results + // in the latitude outside the [-90, 90] range. We first try one solution and + // back off to the other if we are outside that range. + val n12 = n1 * n1 + val discriminant = n2 * n2 * n12 + n12 * n12 - n12 * n4 * n4 + if (discriminant < 0) { + // No real solution which would make sense in LatLng-space. + return null + } + var b = n2 * n4 + sqrt(discriminant) + b /= n1 * n1 + n2 * n2 + val a = (n4 - n2 * b) / n1 + var fromLatRadians = atan2(a, b) + if (fromLatRadians < -PI / 2 || fromLatRadians > PI / 2) { + b = n2 * n4 - sqrt(discriminant) + b /= n1 * n1 + n2 * n2 + fromLatRadians = atan2(a, b) + } + if (fromLatRadians < -PI / 2 || fromLatRadians > PI / 2) { + // No solution which would make sense in LatLng-space. + return null + } + val fromLngRadians = Math.toRadians(to.longitude) - + atan2(n3, n1 * cos(fromLatRadians) - n2 * sin(fromLatRadians)) + return LatLng(Math.toDegrees(fromLatRadians), Math.toDegrees(fromLngRadians)) + } + + /** + * Returns the LatLng which lies the given fraction of the way between the + * origin LatLng and the destination LatLng. + * + * @param source The LatLng from which to start. + * @param destination The LatLng toward which to travel. + * @param fraction A fraction of the distance to travel. + * @return The interpolated LatLng. + */ + @JvmStatic + fun interpolate(source: LatLng, destination: LatLng, fraction: Double): LatLng { + // http://en.wikipedia.org/wiki/Slerp + val (sourceLat, sourceLng) = source.toLatLngRadians() + val (destLat, destLng) = destination.toLatLngRadians() + + val cosFromLat = cos(sourceLat) + val cosToLat = cos(destLat) + + // Computes Spherical interpolation coefficients. + val angle = computeAngleBetween(source, destination) + val sinAngle = sin(angle) + if (sinAngle < 1E-6) { + return LatLng( + source.latitude + fraction * (destination.latitude - source.latitude), + source.longitude + fraction * (destination.longitude - source.longitude) + ) + } + val a = sin((1 - fraction) * angle) / sinAngle + val b = sin(fraction * angle) / sinAngle + + // Converts from polar to vector and interpolate. + val x = a * cosFromLat * cos(sourceLng) + b * cosToLat * cos(destLng) + val y = a * cosFromLat * sin(sourceLng) + b * cosToLat * sin(destLng) + val z = a * sin(sourceLat) + b * sin(destLat) + + // Converts interpolated vector back to polar. + val lat = atan2(z, sqrt(x * x + y * y)) + val lng = atan2(y, x) + return LatLng(Math.toDegrees(lat), Math.toDegrees(lng)) + } + + /** + * Returns distance on the unit sphere; the arguments are in radians. + */ + private fun distanceRadians(lat1: Double, lng1: Double, lat2: Double, lng2: Double): Double { + return arcHav(havDistance(lat1, lat2, lng1 - lng2)) + } + + /** + * Returns the angle between two LatLngs, in radians. This is the same as the distance + * on the unit sphere. + */ + @JvmStatic + fun computeAngleBetween(source: LatLng, destination: LatLng) = distanceRadians( + source.latitude.toRadians(), source.longitude.toRadians(), + destination.latitude.toRadians(), destination.longitude.toRadians() + ) + + fun computeAngleBetween(source: LatLngRadians, destination: LatLngRadians) = distanceRadians( + source.latitude, source.longitude, + destination.latitude, destination.longitude + ) + + /** + * Returns the distance between two LatLngs, in meters. + */ + @JvmStatic + fun computeDistanceBetween(from: LatLng, to: LatLng) = + computeAngleBetween(from, to) * EARTH_RADIUS + + /** + * Returns the length of the given path, in meters, on Earth. + */ + @JvmStatic + fun computeLength(path: List): Double { + return path.map { latLng -> + latLng.toLatLngRadians() + }.windowed(2, 1).sumOf { (prev, point) -> + computeAngleBetween(prev, point) + } * EARTH_RADIUS + } + + /** + * Returns the area of a closed path on Earth. + * + * @param path A closed path. + * @return The path's area in square meters. + */ + @JvmStatic + fun computeArea(path: List) = abs(computeSignedArea(path)) + + /** + * Returns the signed area of a closed path on Earth. The sign of the area may be used to + * determine the orientation of the path. + * "inside" is the surface that does not contain the South Pole. + * + * @param path A closed path. + * @return The loop's area in square meters. + */ + @JvmStatic + fun computeSignedArea(path: List) = computeSignedArea(path, EARTH_RADIUS) + + /** + * Returns the signed area of a closed path on a sphere of given radius. + * The computed area uses the same units as the radius squared. + * Used by SphericalUtilTest. + */ + @JvmStatic + fun computeSignedArea(path: List, radius: Double): Double { + val size = path.size + if (size < 3) { + return 0.0 + } + + val points = path.map { it.toLatLngRadians() } + + var total = 0.0 + val prev = points.last() + var prevTanLat = tan((PI / 2 - prev.latitude) / 2) + var prevLng = prev.longitude + // For each edge, accumulate the signed area of the triangle formed by the North Pole + // and that edge ("polar triangle"). + for (point in points) { + val tanLat = tan((PI / 2 - point.latitude) / 2) + total += polarTriangleArea(tanLat, point.longitude, prevTanLat, prevLng) + prevTanLat = tanLat + prevLng = point.longitude + } + return total * (radius * radius) + } + + /** + * Returns the signed area of a triangle which has North Pole as a vertex. + * Formula derived from "Area of a spherical triangle given two edges and the included angle" + * as per "Spherical Trigonometry" by Todhunter, page 71, section 103, point 2. + * See http://books.google.com/books?id=3uBHAAAAIAAJ&pg=PA71 + * The arguments named "tan" are tan((pi/2 - latitude)/2). + */ + private fun polarTriangleArea(tan1: Double, lng1: Double, tan2: Double, lng2: Double): Double { + val deltaLng = lng1 - lng2 + val t = tan1 * tan2 + return 2 * atan2(t * sin(deltaLng), 1 + t * cos(deltaLng)) + } +} + +data class LatLngRadians(val latitude: Double, val longitude: Double) + +private fun LatLng.toLatLngRadians() = LatLngRadians(latitude.toRadians(), longitude.toRadians()) + +private fun LatLngRadians.toLatLng() = LatLng(latitude.toDegrees(), longitude.toDegrees()) \ No newline at end of file diff --git a/library/src/main/java/com/google/maps/android/StreetViewUtil.kt b/library/src/main/java/com/google/maps/android/StreetViewUtil.kt index 38146d47b..9355a7ebb 100644 --- a/library/src/main/java/com/google/maps/android/StreetViewUtil.kt +++ b/library/src/main/java/com/google/maps/android/StreetViewUtil.kt @@ -64,11 +64,9 @@ class StreetViewUtils { val responseCode = connection.responseCode if (responseCode == HttpURLConnection.HTTP_OK) { - val inputStream = connection.inputStream - val bufferedReader = BufferedReader(InputStreamReader(inputStream)) - val responseString = bufferedReader.use { it.readText() } - bufferedReader.close() - inputStream.close() + val responseString = connection.inputStream.use { inputStream -> + BufferedReader(InputStreamReader(inputStream)).use { it.readText() } + } deserializeResponse(responseString).status } else { throw IOException("HTTP Error: $responseCode") diff --git a/library/src/main/java/com/google/maps/android/data/Geometry.java b/library/src/main/java/com/google/maps/android/data/Geometry.kt similarity index 82% rename from library/src/main/java/com/google/maps/android/data/Geometry.java rename to library/src/main/java/com/google/maps/android/data/Geometry.kt index 857f97f98..99692709d 100644 --- a/library/src/main/java/com/google/maps/android/data/Geometry.java +++ b/library/src/main/java/com/google/maps/android/data/Geometry.kt @@ -13,27 +13,24 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -package com.google.maps.android.data; +package com.google.maps.android.data /** * An abstraction that represents a Geometry object * - * @param the type of Geometry object - */ -public interface Geometry { + * @param the type of Geometry object */ +interface Geometry { /** * Gets the type of geometry * * @return type of geometry */ - String getGeometryType(); + fun getGeometryType(): String /** * Gets the stored KML Geometry object * * @return geometry object */ - T getGeometryObject(); - + fun getGeometryObject(): T } diff --git a/library/src/main/java/com/google/maps/android/data/Point.java b/library/src/main/java/com/google/maps/android/data/Point.java deleted file mode 100644 index 6988696cd..000000000 --- a/library/src/main/java/com/google/maps/android/data/Point.java +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright 2023 Google Inc. - * - * 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.google.maps.android.data; - -import com.google.android.gms.maps.model.LatLng; - -import androidx.annotation.NonNull; - -/** - * An abstraction that shares the common properties of - * {@link com.google.maps.android.data.kml.KmlPoint KmlPoint} and - * {@link com.google.maps.android.data.geojson.GeoJsonPoint GeoJsonPoint} - */ -public class Point implements Geometry { - - private final static String GEOMETRY_TYPE = "Point"; - - private final LatLng mCoordinates; - - /** - * Creates a new Point object - * - * @param coordinates coordinates of Point to store - */ - public Point(LatLng coordinates) { - if (coordinates == null) { - throw new IllegalArgumentException("Coordinates cannot be null"); - } - mCoordinates = coordinates; - } - - /** - * Gets the type of geometry - * - * @return type of geometry - */ - public String getGeometryType() { - return GEOMETRY_TYPE; - } - - /** - * Gets the coordinates of the Point - * - * @return coordinates of the Point - */ - public LatLng getGeometryObject() { - return mCoordinates; - } - - @NonNull - @Override - public String toString() { - StringBuilder sb = new StringBuilder(GEOMETRY_TYPE).append("{"); - sb.append("\n coordinates=").append(mCoordinates); - sb.append("\n}\n"); - return sb.toString(); - } - -} diff --git a/library/src/main/java/com/google/maps/android/data/Point.kt b/library/src/main/java/com/google/maps/android/data/Point.kt new file mode 100644 index 000000000..32caf121b --- /dev/null +++ b/library/src/main/java/com/google/maps/android/data/Point.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2024 Google Inc. + * + * 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.google.maps.android.data + +import com.google.android.gms.maps.model.LatLng + +/** + * An abstraction that shares the common properties of + * [KmlPoint][com.google.maps.android.data.kml.KmlPoint] and + * [GeoJsonPoint][com.google.maps.android.data.geojson.GeoJsonPoint] + */ +open class Point(private val coordinates: LatLng) : Geometry { + /** + * Gets the type of geometry + * + * @return type of geometry + */ + override fun getGeometryType(): String { + return GEOMETRY_TYPE + } + + /** + * Gets the coordinates of the Point + * + * @return coordinates of the Point + */ + override fun getGeometryObject(): LatLng { + return coordinates + } + + override fun toString(): String { + val sb = StringBuilder(GEOMETRY_TYPE).append("{") + sb.append("\n coordinates=").append(coordinates) + sb.append("\n}\n") + return sb.toString() + } + + companion object { + private const val GEOMETRY_TYPE = "Point" + } +} diff --git a/library/src/main/java/com/google/maps/android/data/geojson/GeoJsonPoint.java b/library/src/main/java/com/google/maps/android/data/geojson/GeoJsonPoint.java deleted file mode 100644 index 9e306a93c..000000000 --- a/library/src/main/java/com/google/maps/android/data/geojson/GeoJsonPoint.java +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright 2020 Google Inc. - * - * 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.google.maps.android.data.geojson; - -import com.google.android.gms.maps.model.LatLng; -import com.google.maps.android.data.Point; - -/** - * A GeoJsonPoint geometry contains a single {@link com.google.android.gms.maps.model.LatLng}. - */ -public class GeoJsonPoint extends Point { - private final Double mAltitude; - - /** - * Creates a new GeoJsonPoint - * - * @param coordinates coordinates of GeoJsonPoint to store - */ - public GeoJsonPoint(LatLng coordinates) { - this(coordinates, null); - } - - /** - * Creates a new GeoJsonPoint - * - * @param coordinates coordinates of the KmlPoint - * @param altitude altitude of the KmlPoint - */ - public GeoJsonPoint(LatLng coordinates, Double altitude) { - super(coordinates); - - this.mAltitude = altitude; - } - - /** - * Gets the type of geometry. The type of geometry conforms to the GeoJSON 'type' - * specification. - * - * @return type of geometry - */ - public String getType() { - return getGeometryType(); - } - - /** - * Gets the coordinates of the GeoJsonPoint - * - * @return coordinates of the GeoJsonPoint - */ - public LatLng getCoordinates() { - return getGeometryObject(); - } - - /** - * Gets the altitude of the GeoJsonPoint - * - * @return altitude of the GeoJsonPoint - */ - public Double getAltitude() { - return mAltitude; - } -} diff --git a/library/src/main/java/com/google/maps/android/data/geojson/GeoJsonPoint.kt b/library/src/main/java/com/google/maps/android/data/geojson/GeoJsonPoint.kt new file mode 100644 index 000000000..e0c416dee --- /dev/null +++ b/library/src/main/java/com/google/maps/android/data/geojson/GeoJsonPoint.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2020 Google Inc. + * + * 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.google.maps.android.data.geojson + +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.data.Point + +/** + * A GeoJsonPoint geometry contains a single [com.google.android.gms.maps.model.LatLng]. + */ +class GeoJsonPoint +@JvmOverloads +/** + * Creates a new GeoJsonPoint + * + * @param coordinates coordinates of the KmlPoint + * @param altitude altitude of the KmlPoint + */ +constructor( + val coordinates: LatLng, + /** + * Gets the altitude of the GeoJsonPoint + * + * @return altitude of the GeoJsonPoint + */ + val altitude: Double? = null +) : Point(coordinates) { + + val type: String + /** + * Gets the type of geometry. The type of geometry conforms to the GeoJSON 'type' + * specification. + * + * @return type of geometry + */ + get() = getGeometryType() +} diff --git a/library/src/main/java/com/google/maps/android/geometry/Bounds.java b/library/src/main/java/com/google/maps/android/geometry/Bounds.kt similarity index 50% rename from library/src/main/java/com/google/maps/android/geometry/Bounds.java rename to library/src/main/java/com/google/maps/android/geometry/Bounds.kt index dfb06ec8d..4b5544cbf 100755 --- a/library/src/main/java/com/google/maps/android/geometry/Bounds.java +++ b/library/src/main/java/com/google/maps/android/geometry/Bounds.kt @@ -13,49 +13,39 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -package com.google.maps.android.geometry; +package com.google.maps.android.geometry /** * Represents an area in the cartesian plane. */ -public class Bounds { - public final double minX; - public final double minY; - - public final double maxX; - public final double maxY; - - public final double midX; - public final double midY; - - public Bounds(double minX, double maxX, double minY, double maxY) { - this.minX = minX; - this.minY = minY; - this.maxX = maxX; - this.maxY = maxY; - - midX = (minX + maxX) / 2; - midY = (minY + maxY) / 2; - } - - public boolean contains(double x, double y) { - return minX <= x && x <= maxX && minY <= y && y <= maxY; +data class Bounds( + @JvmField val minX: Double, + @JvmField val maxX: Double, + @JvmField val minY: Double, + @JvmField val maxY: Double +) { + @JvmField + val midX: Double = (minX + maxX) / 2 + @JvmField + val midY: Double = (minY + maxY) / 2 + + fun contains(x: Double, y: Double): Boolean { + return minX <= x && x <= maxX && minY <= y && y <= maxY } - public boolean contains(Point point) { - return contains(point.x, point.y); + fun contains(point: Point): Boolean { + return contains(point.x, point.y) } - public boolean intersects(double minX, double maxX, double minY, double maxY) { - return minX < this.maxX && this.minX < maxX && minY < this.maxY && this.minY < maxY; + fun intersects(minX: Double, maxX: Double, minY: Double, maxY: Double): Boolean { + return minX < this.maxX && this.minX < maxX && minY < this.maxY && this.minY < maxY } - public boolean intersects(Bounds bounds) { - return intersects(bounds.minX, bounds.maxX, bounds.minY, bounds.maxY); + fun intersects(bounds: Bounds): Boolean { + return intersects(bounds.minX, bounds.maxX, bounds.minY, bounds.maxY) } - public boolean contains(Bounds bounds) { + fun contains(bounds: Bounds): Boolean { return bounds.minX >= minX && bounds.maxX <= maxX && bounds.minY >= minY && bounds.maxY <= maxY; } } diff --git a/library/src/main/java/com/google/maps/android/geometry/Point.java b/library/src/main/java/com/google/maps/android/geometry/Point.kt similarity index 59% rename from library/src/main/java/com/google/maps/android/geometry/Point.java rename to library/src/main/java/com/google/maps/android/geometry/Point.kt index 88a83d731..478f5074f 100644 --- a/library/src/main/java/com/google/maps/android/geometry/Point.java +++ b/library/src/main/java/com/google/maps/android/geometry/Point.kt @@ -13,26 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +package com.google.maps.android.geometry -package com.google.maps.android.geometry; - -import androidx.annotation.NonNull; - -public class Point { - public final double x; - public final double y; - - public Point(double x, double y) { - this.x = x; - this.y = y; - } - - @NonNull - @Override - public String toString() { - return "Point{" + - "x=" + x + - ", y=" + y + - '}'; - } -} +data class Point(@JvmField val x: Double, @JvmField val y: Double) diff --git a/library/src/main/java/com/google/maps/android/heatmaps/Gradient.java b/library/src/main/java/com/google/maps/android/heatmaps/Gradient.java deleted file mode 100644 index 559b5f0bc..000000000 --- a/library/src/main/java/com/google/maps/android/heatmaps/Gradient.java +++ /dev/null @@ -1,193 +0,0 @@ -/* - * Copyright 2014 Google Inc. - * - * 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.google.maps.android.heatmaps; - -import android.graphics.Color; - -import java.util.HashMap; - -/** - * A class to generate a color map from a given array of colors and the fractions - * that the colors represent by interpolating between their HSV values. - * This color map is to be used in the HeatmapTileProvider. - */ -public class Gradient { - - private class ColorInterval { - private final int color1; - private final int color2; - - /** - * The period over which the color changes from color1 to color2. - * This is given as the number of elements it represents in the colorMap. - */ - private final float duration; - - private ColorInterval(int color1, int color2, float duration) { - this.color1 = color1; - this.color2 = color2; - this.duration = duration; - } - } - - private static final int DEFAULT_COLOR_MAP_SIZE = 1000; - - /** - * Size of a color map for the heatmap - */ - public final int mColorMapSize; - - /** - * The colors to be used in the gradient - */ - public int[] mColors; - - /** - * The starting point for each color, given as a percentage of the maximum intensity - */ - public float[] mStartPoints; - - /** - * Creates a Gradient with the given colors and starting points. - * These are given as parallel arrays. - * - * @param colors The colors to be used in the gradient - * @param startPoints The starting point for each color, given as a percentage of the maximum intensity - * This is given as an array of floats with values in the interval [0,1] - */ - public Gradient(int[] colors, float[] startPoints) { - this(colors, startPoints, DEFAULT_COLOR_MAP_SIZE); - } - - /** - * Creates a Gradient with the given colors and starting points which creates a colorMap of given size. - * The colors and starting points are given as parallel arrays. - * - * @param colors The colors to be used in the gradient - * @param startPoints The starting point for each color, given as a percentage of the maximum intensity - * This is given as an array of floats with values in the interval [0,1] - * @param colorMapSize The size of the colorMap to be generated by the Gradient - */ - public Gradient(int[] colors, float[] startPoints, int colorMapSize) { - if (colors.length != startPoints.length) { - throw new IllegalArgumentException("colors and startPoints should be same length"); - } else if (colors.length == 0) { - throw new IllegalArgumentException("No colors have been defined"); - } - for (int i = 1; i < startPoints.length; i++) { - if (startPoints[i] <= startPoints[i - 1]) { - throw new IllegalArgumentException("startPoints should be in increasing order"); - } - } - mColorMapSize = colorMapSize; - mColors = new int[colors.length]; - mStartPoints = new float[startPoints.length]; - System.arraycopy(colors, 0, mColors, 0, colors.length); - System.arraycopy(startPoints, 0, mStartPoints, 0, startPoints.length); - } - - private HashMap generateColorIntervals() { - HashMap colorIntervals = new HashMap(); - // Create first color if not already created - // The initial color is transparent by default - if (mStartPoints[0] != 0) { - int initialColor = Color.argb( - 0, Color.red(mColors[0]), Color.green(mColors[0]), Color.blue(mColors[0])); - colorIntervals.put(0, new ColorInterval(initialColor, mColors[0], mColorMapSize * mStartPoints[0])); - } - // Generate color intervals - for (int i = 1; i < mColors.length; i++) { - colorIntervals.put(((int) (mColorMapSize * mStartPoints[i - 1])), - new ColorInterval(mColors[i - 1], mColors[i], - (mColorMapSize * (mStartPoints[i] - mStartPoints[i - 1])))); - } - // Extend to a final color - // If color for 100% intensity is not given, the color of highest intensity is used. - if (mStartPoints[mStartPoints.length - 1] != 1) { - int i = mStartPoints.length - 1; - colorIntervals.put(((int) (mColorMapSize * mStartPoints[i])), - new ColorInterval(mColors[i], mColors[i], mColorMapSize * (1 - mStartPoints[i]))); - } - return colorIntervals; - } - - /** - * Generates the color map to use with a provided gradient. - * - * @param opacity Overall opacity of entire image: every individual alpha value will be - * multiplied by this opacity. - * @return the generated color map based on the gradient - */ - int[] generateColorMap(double opacity) { - HashMap colorIntervals = generateColorIntervals(); - int[] colorMap = new int[mColorMapSize]; - ColorInterval interval = colorIntervals.get(0); - int start = 0; - for (int i = 0; i < mColorMapSize; i++) { - if (colorIntervals.containsKey(i)) { - interval = colorIntervals.get(i); - start = i; - } - float ratio = (i - start) / interval.duration; - colorMap[i] = interpolateColor(interval.color1, interval.color2, ratio); - } - if (opacity != 1) { - for (int i = 0; i < mColorMapSize; i++) { - int c = colorMap[i]; - colorMap[i] = Color.argb((int) (Color.alpha(c) * opacity), - Color.red(c), Color.green(c), Color.blue(c)); - } - } - - return colorMap; - } - - /** - * Helper function for creation of color map - * Interpolates between two given colors using their HSV values. - * - * @param color1 First color - * @param color2 Second color - * @param ratio Between 0 to 1. Fraction of the distance between color1 and color2 - * @return Color associated with x2 - */ - static int interpolateColor(int color1, int color2, float ratio) { - - int alpha = (int) ((Color.alpha(color2) - Color.alpha(color1)) * ratio + Color.alpha(color1)); - - float[] hsv1 = new float[3]; - Color.RGBToHSV(Color.red(color1), Color.green(color1), Color.blue(color1), hsv1); - float[] hsv2 = new float[3]; - Color.RGBToHSV(Color.red(color2), Color.green(color2), Color.blue(color2), hsv2); - - // adjust so that the shortest path on the color wheel will be taken - if (hsv1[0] - hsv2[0] > 180) { - hsv2[0] += 360; - } else if (hsv2[0] - hsv1[0] > 180) { - hsv1[0] += 360; - } - - // Interpolate using calculated ratio - float[] result = new float[3]; - for (int i = 0; i < 3; i++) { - result[i] = (hsv2[i] - hsv1[i]) * (ratio) + hsv1[i]; - } - - return Color.HSVToColor(alpha, result); - } - -} diff --git a/library/src/main/java/com/google/maps/android/heatmaps/Gradient.kt b/library/src/main/java/com/google/maps/android/heatmaps/Gradient.kt new file mode 100644 index 000000000..aa4695e77 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/heatmaps/Gradient.kt @@ -0,0 +1,178 @@ +/* + * Copyright 2014 Google Inc. + * + * 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.google.maps.android.heatmaps + +import android.graphics.Color + +/** + * A class to generate a color map from a given array of colors and the fractions + * that the colors represent by interpolating between their HSV values. + * This color map is to be used in the HeatmapTileProvider. + * + * @param mColors The colors to be used in the gradient. + * @param mStartPoints The starting point for each color, given as a percentage of the maximum intensity. + * @param mColorMapSize The size of the color map to be generated by the Gradient. + */ +class Gradient @JvmOverloads constructor( + val mColors: IntArray, + val mStartPoints: FloatArray, + val mColorMapSize: Int = DEFAULT_COLOR_MAP_SIZE +) { + private data class ColorInterval( + val color1: Int, + val color2: Int, + /** + * The period over which the color changes from color1 to color2. + * This is given as the number of elements it represents in the colorMap. + */ + val duration: Float + ) + + /** + * Creates a Gradient with the given colors and starting points which creates a colorMap of given size. + * The colors and starting points are given as parallel arrays. + * + * @param colors The colors to be used in the gradient + * @param startPoints The starting point for each color, given as a percentage of the maximum intensity + * This is given as an array of floats with values in the interval [0,1] + * @param colorMapSize The size of the colorMap to be generated by the Gradient + */ + /** + * Creates a Gradient with the given colors and starting points. + * These are given as parallel arrays. + * + * @param colors The colors to be used in the gradient + * @param startPoints The starting point for each color, given as a percentage of the maximum intensity + * This is given as an array of floats with values in the interval [0,1] + */ + init { + require(mColors.size == mStartPoints.size) { "colors and startPoints should be same length" } + require(mColors.isNotEmpty()) { "No colors have been defined" } + + for (i in 1 until mStartPoints.size) { + require(mStartPoints[i] > mStartPoints[i - 1]) { "startPoints should be in increasing order" } + } + } + + private fun generateColorIntervals(): HashMap { + val colorIntervals = HashMap() + // Create first color if not already created + // The initial color is transparent by default + if (mStartPoints[0] != 0f) { + val initialColor = Color.argb( + 0, Color.red(mColors[0]), Color.green(mColors[0]), Color.blue( + mColors[0] + ) + ) + colorIntervals[0] = + ColorInterval(initialColor, mColors[0], mColorMapSize * mStartPoints[0]) + } + // Generate color intervals + for (i in 1 until mColors.size) { + colorIntervals[(mColorMapSize * mStartPoints[i - 1]).toInt()] = + ColorInterval( + mColors[i - 1], + mColors[i], + mColorMapSize * (mStartPoints[i] - mStartPoints[i - 1]) + ) + } + // Extend to a final color + // If color for 100% intensity is not given, the color of highest intensity is used. + if (mStartPoints[mStartPoints.size - 1] != 1f) { + val i = mStartPoints.size - 1 + colorIntervals[(mColorMapSize * mStartPoints[i]).toInt()] = ColorInterval( + mColors[i], + mColors[i], + mColorMapSize * (1 - mStartPoints[i]) + ) + } + return colorIntervals + } + + /** + * Generates the color map to use with a provided gradient. + * + * @param opacity Overall opacity of entire image: every individual alpha value will be + * multiplied by this opacity. + * @return the generated color map based on the gradient + */ + fun generateColorMap(opacity: Double): IntArray { + val colorIntervals = generateColorIntervals() + val colorMap = IntArray(mColorMapSize) + var interval = colorIntervals[0] + var start = 0 + for (i in 0 until mColorMapSize) { + if (colorIntervals.containsKey(i)) { + interval = colorIntervals[i] + start = i + } + val ratio = (i - start) / interval!!.duration + colorMap[i] = interpolateColor( + interval.color1, interval.color2, ratio + ) + } + if (opacity != 1.0) { + for (i in 0 until mColorMapSize) { + val c = colorMap[i] + colorMap[i] = Color.argb( + (Color.alpha(c) * opacity).toInt(), + Color.red(c), Color.green(c), Color.blue(c) + ) + } + } + + return colorMap + } + + companion object { + private const val DEFAULT_COLOR_MAP_SIZE = 1000 + + /** + * Helper function for creation of color map + * Interpolates between two given colors using their HSV values. + * + * @param color1 First color + * @param color2 Second color + * @param ratio Between 0 to 1. Fraction of the distance between color1 and color2 + * @return Color associated with x2 + */ + @JvmStatic + fun interpolateColor(color1: Int, color2: Int, ratio: Float): Int { + val alpha = + ((Color.alpha(color2) - Color.alpha(color1)) * ratio + Color.alpha(color1)).toInt() + + val hsv1 = FloatArray(3) + Color.RGBToHSV(Color.red(color1), Color.green(color1), Color.blue(color1), hsv1) + val hsv2 = FloatArray(3) + Color.RGBToHSV(Color.red(color2), Color.green(color2), Color.blue(color2), hsv2) + + // adjust so that the shortest path on the color wheel will be taken + if (hsv1[0] - hsv2[0] > 180) { + hsv2[0] += 360f + } else if (hsv2[0] - hsv1[0] > 180) { + hsv1[0] += 360f + } + + // Interpolate using calculated ratio + val result = FloatArray(3) + for (i in 0..2) { + result[i] = (hsv2[i] - hsv1[i]) * (ratio) + hsv1[i] + } + + return Color.HSVToColor(alpha, result) + } + } +} diff --git a/library/src/main/java/com/google/maps/android/projection/Point.java b/library/src/main/java/com/google/maps/android/projection/Point.kt similarity index 67% rename from library/src/main/java/com/google/maps/android/projection/Point.java rename to library/src/main/java/com/google/maps/android/projection/Point.kt index 75ca7fe20..7464f4bfc 100644 --- a/library/src/main/java/com/google/maps/android/projection/Point.java +++ b/library/src/main/java/com/google/maps/android/projection/Point.kt @@ -13,15 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +package com.google.maps.android.projection -package com.google.maps.android.projection; - -/** - * @deprecated since 0.2. Use {@link com.google.maps.android.geometry.Point} instead. - */ -@Deprecated -public class Point extends com.google.maps.android.geometry.Point { - public Point(double x, double y) { - super(x, y); - } -} +@Deprecated("since 0.2. Use {@link com.google.maps.android.geometry.Point} instead.") +typealias Point = com.google.maps.android.geometry.Point diff --git a/library/src/main/java/com/google/maps/android/projection/SphericalMercatorProjection.java b/library/src/main/java/com/google/maps/android/projection/SphericalMercatorProjection.java deleted file mode 100644 index 55271cbea..000000000 --- a/library/src/main/java/com/google/maps/android/projection/SphericalMercatorProjection.java +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2013 Google Inc. - * - * 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.google.maps.android.projection; - -import com.google.android.gms.maps.model.LatLng; - -public class SphericalMercatorProjection { - final double mWorldWidth; - - public SphericalMercatorProjection(final double worldWidth) { - mWorldWidth = worldWidth; - } - - @SuppressWarnings("deprecation") - public Point toPoint(final LatLng latLng) { - final double x = latLng.longitude / 360 + .5; - final double siny = Math.sin(Math.toRadians(latLng.latitude)); - final double y = 0.5 * Math.log((1 + siny) / (1 - siny)) / -(2 * Math.PI) + .5; - - return new Point(x * mWorldWidth, y * mWorldWidth); - } - - public LatLng toLatLng(com.google.maps.android.geometry.Point point) { - final double x = point.x / mWorldWidth - 0.5; - final double lng = x * 360; - - double y = .5 - (point.y / mWorldWidth); - final double lat = 90 - Math.toDegrees(Math.atan(Math.exp(-y * 2 * Math.PI)) * 2); - - return new LatLng(lat, lng); - } -} diff --git a/library/src/main/java/com/google/maps/android/projection/SphericalMercatorProjection.kt b/library/src/main/java/com/google/maps/android/projection/SphericalMercatorProjection.kt new file mode 100644 index 000000000..c68f92462 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/projection/SphericalMercatorProjection.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2013 Google Inc. + * + * 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.google.maps.android.projection + +import com.google.android.gms.maps.model.LatLng +import kotlin.math.atan +import kotlin.math.exp +import kotlin.math.ln +import kotlin.math.sin +import com.google.maps.android.MathUtil.toRadians +import com.google.maps.android.MathUtil.toDegrees + +class SphericalMercatorProjection(private val mWorldWidth: Double) { + @Suppress("deprecation") + fun toPoint(latLng: LatLng): Point { + val x = latLng.longitude / 360 + .5 + val siny = sin(latLng.latitude.toRadians()) + val y = 0.5 * ln((1 + siny) / (1 - siny)) / -(2 * Math.PI) + .5 + + return Point(x * mWorldWidth, y * mWorldWidth) + } + + fun toLatLng(point: com.google.maps.android.geometry.Point): LatLng { + val x = point.x / mWorldWidth - 0.5 + val lng = x * 360 + + val y = .5 - (point.y / mWorldWidth) + val lat = 90 - (atan(exp(-y * 2 * Math.PI)) * 2).toDegrees() + + return LatLng(lat, lng) + } +} diff --git a/library/src/main/java/com/google/maps/android/quadtree/PointQuadTree.java b/library/src/main/java/com/google/maps/android/quadtree/PointQuadTree.java deleted file mode 100644 index b3ee6fb48..000000000 --- a/library/src/main/java/com/google/maps/android/quadtree/PointQuadTree.java +++ /dev/null @@ -1,226 +0,0 @@ -/* - * Copyright 2013 Google Inc. - * - * 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.google.maps.android.quadtree; - -import com.google.maps.android.geometry.Bounds; -import com.google.maps.android.geometry.Point; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Set; - -/** - * A quad tree which tracks items with a Point geometry. - * See http://en.wikipedia.org/wiki/Quadtree for details on the data structure. - * This class is not thread safe. - */ -public class PointQuadTree { - public interface Item { - Point getPoint(); - } - - /** - * The bounds of this quad. - */ - private final Bounds mBounds; - - /** - * The depth of this quad in the tree. - */ - private final int mDepth; - - /** - * Maximum number of elements to store in a quad before splitting. - */ - private final static int MAX_ELEMENTS = 50; - - /** - * The elements inside this quad, if any. - */ - private Set mItems; - - /** - * Maximum depth. - */ - private final static int MAX_DEPTH = 40; - - /** - * Child quads. - */ - private List> mChildren = null; - - /** - * Creates a new quad tree with specified bounds. - * - * @param minX - * @param maxX - * @param minY - * @param maxY - */ - public PointQuadTree(double minX, double maxX, double minY, double maxY) { - this(new Bounds(minX, maxX, minY, maxY)); - } - - public PointQuadTree(Bounds bounds) { - this(bounds, 0); - } - - private PointQuadTree(double minX, double maxX, double minY, double maxY, int depth) { - this(new Bounds(minX, maxX, minY, maxY), depth); - } - - private PointQuadTree(Bounds bounds, int depth) { - mBounds = bounds; - mDepth = depth; - } - - /** - * Insert an item. - */ - public void add(T item) { - Point point = item.getPoint(); - if (this.mBounds.contains(point.x, point.y)) { - insert(point.x, point.y, item); - } - } - - private void insert(double x, double y, T item) { - if (this.mChildren != null) { - if (y < mBounds.midY) { - if (x < mBounds.midX) { // top left - mChildren.get(0).insert(x, y, item); - } else { // top right - mChildren.get(1).insert(x, y, item); - } - } else { - if (x < mBounds.midX) { // bottom left - mChildren.get(2).insert(x, y, item); - } else { - mChildren.get(3).insert(x, y, item); - } - } - return; - } - if (mItems == null) { - mItems = new LinkedHashSet<>(); - } - mItems.add(item); - if (mItems.size() > MAX_ELEMENTS && mDepth < MAX_DEPTH) { - split(); - } - } - - /** - * Split this quad. - */ - private void split() { - mChildren = new ArrayList>(4); - mChildren.add(new PointQuadTree(mBounds.minX, mBounds.midX, mBounds.minY, mBounds.midY, mDepth + 1)); - mChildren.add(new PointQuadTree(mBounds.midX, mBounds.maxX, mBounds.minY, mBounds.midY, mDepth + 1)); - mChildren.add(new PointQuadTree(mBounds.minX, mBounds.midX, mBounds.midY, mBounds.maxY, mDepth + 1)); - mChildren.add(new PointQuadTree(mBounds.midX, mBounds.maxX, mBounds.midY, mBounds.maxY, mDepth + 1)); - - Set items = mItems; - mItems = null; - - for (T item : items) { - // re-insert items into child quads. - insert(item.getPoint().x, item.getPoint().y, item); - } - } - - /** - * Remove the given item from the set. - * - * @return whether the item was removed. - */ - public boolean remove(T item) { - Point point = item.getPoint(); - if (this.mBounds.contains(point.x, point.y)) { - return remove(point.x, point.y, item); - } else { - return false; - } - } - - private boolean remove(double x, double y, T item) { - if (this.mChildren != null) { - if (y < mBounds.midY) { - if (x < mBounds.midX) { // top left - return mChildren.get(0).remove(x, y, item); - } else { // top right - return mChildren.get(1).remove(x, y, item); - } - } else { - if (x < mBounds.midX) { // bottom left - return mChildren.get(2).remove(x, y, item); - } else { - return mChildren.get(3).remove(x, y, item); - } - } - } else { - if (mItems == null) { - return false; - } else { - return mItems.remove(item); - } - } - } - - /** - * Removes all points from the quadTree - */ - public void clear() { - mChildren = null; - if (mItems != null) { - mItems.clear(); - } - } - - /** - * Search for all items within a given bounds. - */ - public Collection search(Bounds searchBounds) { - final List results = new ArrayList(); - search(searchBounds, results); - return results; - } - - private void search(Bounds searchBounds, Collection results) { - if (!mBounds.intersects(searchBounds)) { - return; - } - - if (this.mChildren != null) { - for (PointQuadTree quad : mChildren) { - quad.search(searchBounds, results); - } - } else if (mItems != null) { - if (searchBounds.contains(mBounds)) { - results.addAll(mItems); - } else { - for (T item : mItems) { - if (searchBounds.contains(item.getPoint())) { - results.add(item); - } - } - } - } - } -} diff --git a/library/src/main/java/com/google/maps/android/quadtree/PointQuadTree.kt b/library/src/main/java/com/google/maps/android/quadtree/PointQuadTree.kt new file mode 100644 index 000000000..96ff8376a --- /dev/null +++ b/library/src/main/java/com/google/maps/android/quadtree/PointQuadTree.kt @@ -0,0 +1,231 @@ +/* + * Copyright 2013 Google Inc. + * + * 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.google.maps.android.quadtree + +import com.google.maps.android.geometry.Bounds +import com.google.maps.android.geometry.Point + +/** + * A quad tree which tracks items with a Point geometry. + * See http://en.wikipedia.org/wiki/Quadtree for details on the data structure. + * This class is not thread safe. + */ +class PointQuadTree private constructor( + /** + * The bounds of this quad. + */ + private val mBounds: Bounds, + /** + * The depth of this quad in the tree. + */ + private val mDepth: Int +) { + interface Item { + val point: Point + } + + /** + * The elements inside this quad, if any. + */ + private var mItems: MutableSet = mutableSetOf() + + /** + * Child quadrants. + * + * Note: to be backwards compatible, the SOUTH_EAST quadrant must be first and the NORTH_WEST + * quadrant must be last. + */ + private enum class QUADRANT(val index: Int) { + SOUTH_EAST(0), + SOUTH_WEST(1), + NORTH_EAST(2), + NORTH_WEST(3), + } + + /** + * Child quads. + */ + private var mChildren: List> = emptyList() + + /** + * Creates a new quad tree with specified bounds. + * + * @param minX + * @param maxX + * @param minY + * @param maxY + */ + constructor(minX: Double, maxX: Double, minY: Double, maxY: Double) : this( + Bounds( + minX, + maxX, + minY, + maxY + ) + ) + + constructor(bounds: Bounds) : this(bounds, 0) + + /** + * Insert an item. + */ + fun add(item: T) { + val point = item.point + if (mBounds.contains(point.x, point.y)) { + insert(point.x, point.y, item) + } + } + + private fun applyToMatchingChild( + point: Point, + action: PointQuadTree.(PointQuadTree) -> Unit + ) { + matchingChild(point)?.apply { + action(this) + } + } + + private fun matchingChild(point: Point): PointQuadTree? = matchingChild(point.x, point.y) + + private fun matchingChild(x: Double, y: Double): PointQuadTree? { + return mChildren.firstOrNull { + it.mBounds.contains(x, y) + } + } + + private fun insert(x: Double, y: Double, item: T) { + if (mChildren.isNotEmpty()) { + applyToMatchingChild(item.point) { it.insert(x, y, item) } + } else { + mItems.add(item) + if (mItems.size > MAX_ELEMENTS && mDepth < MAX_DEPTH) { + split() + } + } + } + + /** + * Split this quad. + */ + private fun split() { + require(mChildren.isEmpty()) { "Children already exist" } + + val childrenDepth = mDepth + 1 + + mChildren = buildList { + mBounds.quadrants().forEach { + add(PointQuadTree(it, childrenDepth)) + } + } + + val items = mItems.toList() + mItems.clear() + + for (item in items) { + // re-insert items into child quads. + insert(item.point.x, item.point.y, item) + } + } + + /** + * Remove the given item from the set. + * + * @return whether the item was removed. + */ + fun remove(item: T): Boolean { + return if (mBounds.contains(item.point.x, item.point.y)) { + remove(item.point.x, item.point.y, item) + } else { + false + } + } + + private fun remove(x: Double, y: Double, item: T): Boolean { + return matchingChild(x, y)?.remove(x, y, item) ?: mItems.remove(item) + } + + /** + * Removes all points from the quadTree + */ + fun clear() { + mChildren = emptyList() + mItems.clear() + } + + /** + * Search for all items within a given bounds. + */ + fun search(searchBounds: Bounds): Collection { + return ArrayList().apply { + search(searchBounds, this) + } + } + + private fun search(searchBounds: Bounds, results: MutableCollection) { + if (!mBounds.intersects(searchBounds)) { + return + } + + if (mChildren.isNotEmpty()) { + for (quad in mChildren) { + quad.search(searchBounds, results) + } + } else { + if (searchBounds.contains(mBounds)) { + results.addAll(mItems) + } else { + mItems.filterTo(results) { searchBounds.contains(it.point) } + } + } + } + + companion object { + /** + * Maximum number of elements to store in a quad before splitting. + */ + private const val MAX_ELEMENTS = 50 + + /** + * Maximum depth. + */ + private const val MAX_DEPTH = 40 + } +} + +private fun Bounds.quadrants(): List { + /* + * Note: to be backwards compatible, the southEast quadrant must be first and the northWest + * quadrant must be last. + */ + return buildList { + add(southEast) + add(southWest) + add(northEast) + add(northWest) + } +} + +private val Bounds.northWest: Bounds + get() = Bounds(minX, midX, minY, midY) + +private val Bounds.northEast: Bounds + get() = Bounds(midX, maxX, minY, midY) + +private val Bounds.southWest: Bounds + get() = Bounds(minX, midX, midY, maxY) + +private val Bounds.southEast: Bounds + get() = Bounds(midX, maxX, midY, maxY) \ No newline at end of file diff --git a/library/src/main/java/com/google/maps/android/ui/RotationLayout.java b/library/src/main/java/com/google/maps/android/ui/RotationLayout.java index f5f8537ac..b491146df 100644 --- a/library/src/main/java/com/google/maps/android/ui/RotationLayout.java +++ b/library/src/main/java/com/google/maps/android/ui/RotationLayout.java @@ -21,6 +21,8 @@ import android.util.AttributeSet; import android.widget.FrameLayout; +import androidx.annotation.NonNull; + /** * RotationLayout rotates the contents of the layout by multiples of 90 degrees. *

@@ -60,7 +62,7 @@ public void setViewRotation(int degrees) { @Override - public void dispatchDraw(Canvas canvas) { + public void dispatchDraw(@NonNull Canvas canvas) { if (mRotation == 0) { super.dispatchDraw(canvas); return; diff --git a/library/src/main/java/com/google/maps/android/ui/SquareTextView.java b/library/src/main/java/com/google/maps/android/ui/SquareTextView.java index 20df4a9d2..69e351d13 100644 --- a/library/src/main/java/com/google/maps/android/ui/SquareTextView.java +++ b/library/src/main/java/com/google/maps/android/ui/SquareTextView.java @@ -62,7 +62,7 @@ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { @Override public void draw(Canvas canvas) { - canvas.translate(mOffsetLeft / 2, mOffsetTop / 2); + canvas.translate((float) mOffsetLeft / 2, (float) mOffsetTop / 2); super.draw(canvas); } } diff --git a/library/src/test/java/com/google/maps/android/PolyUtilTest.java b/library/src/test/java/com/google/maps/android/PolyUtilTest.java index cff8c538e..71f0241a5 100644 --- a/library/src/test/java/com/google/maps/android/PolyUtilTest.java +++ b/library/src/test/java/com/google/maps/android/PolyUtilTest.java @@ -475,10 +475,10 @@ public void testIsClosedPolygon() { /** * The following method checks whether {@link PolyUtil#distanceToLine(LatLng, LatLng, LatLng) distanceToLine()} } * is determining the distance between a point and a segment accurately. - * + *

* Currently there are tests for different orders of magnitude (i.e., 1X, 10X, 100X, 1000X), as well as a test * where the segment and the point lie in different hemispheres. - * + *

* If further tests need to be added here, make sure that the distance has been verified with QGIS. * * @see QGIS diff --git a/library/src/test/java/com/google/maps/android/SphericalUtilTest.java b/library/src/test/java/com/google/maps/android/SphericalUtilTest.java index 895bccd25..568983b03 100644 --- a/library/src/test/java/com/google/maps/android/SphericalUtilTest.java +++ b/library/src/test/java/com/google/maps/android/SphericalUtilTest.java @@ -26,9 +26,12 @@ import static com.google.maps.android.MathUtil.EARTH_RADIUS; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; +import javax.annotation.Nullable; + public class SphericalUtilTest { // The vertices of an octahedron, for testing private final LatLng up = new LatLng(90, 0); @@ -41,7 +44,9 @@ public class SphericalUtilTest { /** * Tests for approximate equality. */ - private static void expectLatLngApproxEquals(LatLng actual, LatLng expected) { + private static void expectLatLngApproxEquals(@Nullable LatLng actual, @Nullable LatLng expected) { + assertNotNull(actual); + assertNotNull(expected); assertEquals(actual.latitude, expected.latitude, 1e-6); // Account for the convergence of longitude lines at the poles double cosLat = Math.cos(Math.toRadians(actual.latitude)); @@ -295,7 +300,7 @@ public void testComputeLength() { List latLngs; assertEquals(SphericalUtil.computeLength(Collections.emptyList()), 0, 1e-6); - assertEquals(SphericalUtil.computeLength(Arrays.asList(new LatLng(0, 0))), 0, 1e-6); + assertEquals(SphericalUtil.computeLength(List.of(new LatLng(0, 0))), 0, 1e-6); latLngs = Arrays.asList(new LatLng(0, 0), new LatLng(0.1, 0.1)); assertEquals( diff --git a/library/src/test/java/com/google/maps/android/data/PointTest.java b/library/src/test/java/com/google/maps/android/data/PointTest.java index 61ca6bef9..d097aacba 100644 --- a/library/src/test/java/com/google/maps/android/data/PointTest.java +++ b/library/src/test/java/com/google/maps/android/data/PointTest.java @@ -20,7 +20,6 @@ import org.junit.Test; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.fail; public class PointTest { @Test @@ -33,11 +32,5 @@ public void testGetGeometryType() { public void testGetGeometryObject() { Point p = new Point(new LatLng(0, 50)); assertEquals(new LatLng(0, 50), p.getGeometryObject()); - try { - new Point(null); - fail(); - } catch (IllegalArgumentException e) { - assertEquals("Coordinates cannot be null", e.getMessage()); - } } } diff --git a/library/src/test/java/com/google/maps/android/data/geojson/GeoJsonPointTest.java b/library/src/test/java/com/google/maps/android/data/geojson/GeoJsonPointTest.java index 0cc52c4e7..15097634d 100644 --- a/library/src/test/java/com/google/maps/android/data/geojson/GeoJsonPointTest.java +++ b/library/src/test/java/com/google/maps/android/data/geojson/GeoJsonPointTest.java @@ -15,6 +15,8 @@ */ package com.google.maps.android.data.geojson; +import static com.google.common.truth.Truth.assertThat; + import com.google.android.gms.maps.model.LatLng; import org.junit.Test; @@ -36,8 +38,8 @@ public void testGetCoordinates() { try { new GeoJsonPoint(null); fail(); - } catch (IllegalArgumentException e) { - assertEquals("Coordinates cannot be null", e.getMessage()); + } catch (NullPointerException e) { + assertThat(e.getMessage()).contains("Parameter specified as non-null is null"); } } diff --git a/library/src/test/java/com/google/maps/android/geometry/BoundsTest.kt b/library/src/test/java/com/google/maps/android/geometry/BoundsTest.kt new file mode 100644 index 000000000..11df0902a --- /dev/null +++ b/library/src/test/java/com/google/maps/android/geometry/BoundsTest.kt @@ -0,0 +1,48 @@ +package com.google.maps.android.geometry + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class BoundsTest { + + @Test + fun testConstructorAndProperties() { + val bounds = Bounds(1.0, 3.0, 2.0, 4.0) + assertThat(bounds.minX).isEqualTo(1.0) + assertThat(bounds.minY).isEqualTo(2.0) + assertThat(bounds.maxX).isEqualTo(3.0) + assertThat(bounds.maxY).isEqualTo(4.0) + assertThat(bounds.midX).isEqualTo(2.0) + assertThat(bounds.midY).isEqualTo(3.0) + } + + @Test + fun testContainsPoint() { + val bounds = Bounds(1.0, 3.0, 2.0, 4.0) + assertThat(bounds.contains(2.0, 3.0)).isTrue() // Inside + assertThat(bounds.contains(0.5, 2.5)).isFalse() // Outside + assertThat(bounds.contains(1.0, 2.0)).isTrue() // On boundary + assertThat(bounds.contains(3.0, 4.0)).isTrue() // On boundary + } + + @Test + fun testContainsBounds() { + val bounds = Bounds(1.0, 5.0, 2.0, 6.0) + val insideBounds = Bounds(2.0, 4.0, 3.0, 5.0) + val outsideBounds = Bounds(0.0, 2.0, 1.0, 3.0) + val overlappingBounds = Bounds(4.0, 6.0, 5.0, 7.0) + + assertThat(bounds.contains(insideBounds)).isTrue() + assertThat(bounds.contains(outsideBounds)).isFalse() + assertThat(bounds.contains(overlappingBounds)).isFalse() + } + + @Test + fun testIntersects() { + val bounds = Bounds(1.0, 3.0, 2.0, 4.0) + assertThat(bounds.intersects(0.0, 2.0, 1.0, 3.0)).isTrue() // Overlap + assertThat(bounds.intersects(4.0, 5.0, 5.0, 6.0)).isFalse() // No overlap + assertThat(bounds.intersects(2.0, 4.0, 3.0, 5.0)).isTrue() // Containment is intersection + assertThat(bounds.intersects(1.0, 3.0, 2.0, 4.0)).isTrue() // Exact match + } +} diff --git a/library/src/test/java/com/google/maps/android/quadtree/PointQuadTreeTest.java b/library/src/test/java/com/google/maps/android/quadtree/PointQuadTreeTest.java deleted file mode 100644 index c5f2de5b7..000000000 --- a/library/src/test/java/com/google/maps/android/quadtree/PointQuadTreeTest.java +++ /dev/null @@ -1,232 +0,0 @@ -/* - * Copyright 2014 Google Inc. - * - * 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.google.maps.android.quadtree; - -import com.google.maps.android.geometry.Bounds; -import com.google.maps.android.geometry.Point; - -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; - -import java.util.Collection; -import java.util.Random; - -public class PointQuadTreeTest { - - private PointQuadTree mTree; - - @Before - public void setUp() { - mTree = new PointQuadTree<>(0, 1, 0, 1); - } - - @Test - public void testAddOnePoint() { - Item item = new Item(0, 0); - mTree.add(item); - Collection items = searchAll(); - Assert.assertEquals(1, items.size()); - mTree.clear(); - } - - @Test - public void testEmpty() { - Collection items = searchAll(); - Assert.assertEquals(0, items.size()); - } - - @Test - public void testMultiplePoints() { - boolean response; - Item item1 = new Item(0, 0); - - // Remove item that isn't yet in the QuadTree - response = mTree.remove(item1); - Assert.assertFalse(response); - - mTree.add(item1); - Item item2 = new Item(.1, .1); - mTree.add(item2); - Item item3 = new Item(.2, .2); - mTree.add(item3); - - Collection items = searchAll(); - Assert.assertEquals(3, items.size()); - - Assert.assertTrue(items.contains(item1)); - Assert.assertTrue(items.contains(item2)); - Assert.assertTrue(items.contains(item3)); - - response = mTree.remove(item1); - Assert.assertTrue(response); - response = mTree.remove(item2); - Assert.assertTrue(response); - response = mTree.remove(item3); - Assert.assertTrue(response); - - Assert.assertEquals(0, searchAll().size()); - - // Remove item that is no longer in the QuadTree - response = mTree.remove(item1); - Assert.assertFalse(response); - mTree.clear(); - } - - @Test - public void testSameLocationDifferentPoint() { - mTree.add(new Item(0, 0)); - mTree.add(new Item(0, 0)); - - Assert.assertEquals(2, searchAll().size()); - mTree.clear(); - } - - @Test - public void testClear() { - mTree.add(new Item(.1, .1)); - mTree.add(new Item(.2, .2)); - mTree.add(new Item(.3, .3)); - - mTree.clear(); - Assert.assertEquals(0, searchAll().size()); - } - - @Test - public void testSearch() { - System.gc(); - for (int i = 0; i < 10000; i++) { - mTree.add(new Item(i / 20000.0, i / 20000.0)); - } - - Assert.assertEquals(10000, searchAll().size()); - Assert.assertEquals( - 1, mTree.search(new Bounds((double) 0, 0.00001, (double) 0, 0.00001)).size()); - Assert.assertEquals(0, mTree.search(new Bounds(.7, .8, .7, .8)).size()); - mTree.clear(); - System.gc(); - } - - @Test - public void testFourPoints() { - mTree.add(new Item(0.2, 0.2)); - mTree.add(new Item(0.7, 0.2)); - mTree.add(new Item(0.2, 0.7)); - mTree.add(new Item(0.7, 0.7)); - - Assert.assertEquals(2, mTree.search(new Bounds(0.0, 0.5, 0.0, 1.0)).size()); - mTree.clear(); - } - - /** - * Tests 30,000 items at the same point. Timing results are averaged. - */ - @Test - public void testVeryDeepTree() { - System.gc(); - for (int i = 0; i < 30000; i++) { - mTree.add(new Item(0, 0)); - } - - Assert.assertEquals(30000, searchAll().size()); - Assert.assertEquals(30000, mTree.search(new Bounds(0, .1, 0, .1)).size()); - Assert.assertEquals(0, mTree.search(new Bounds(.1, 1, .1, 1)).size()); - - mTree.clear(); - System.gc(); - } - - /** - * Tests 400,000 points relatively uniformly distributed across the space. Timing results are - * averaged. - */ - @Test - public void testManyPoints() { - System.gc(); - for (double i = 0; i < 200; i++) { - for (double j = 0; j < 2000; j++) { - mTree.add(new Item(i / 200.0, j / 2000.0)); - } - } - - // searching bounds that are exact subtrees of the main quadTree - Assert.assertEquals(400000, searchAll().size()); - Assert.assertEquals(100000, mTree.search(new Bounds(0, .5, 0, .5)).size()); - Assert.assertEquals(100000, mTree.search(new Bounds(.5, 1, 0, .5)).size()); - Assert.assertEquals(25000, mTree.search(new Bounds(0, .25, 0, .25)).size()); - Assert.assertEquals(25000, mTree.search(new Bounds(.75, 1, .75, 1)).size()); - - // searching bounds that do not line up with main quadTree - Assert.assertEquals(399800, mTree.search(new Bounds(0, 0.999, 0, 0.999)).size()); - Assert.assertEquals(4221, mTree.search(new Bounds(0.8, 0.9, 0.8, 0.9)).size()); - Assert.assertEquals(4200, mTree.search(new Bounds(0, 1, 0, 0.01)).size()); - Assert.assertEquals(16441, mTree.search(new Bounds(0.4, 0.6, 0.4, 0.6)).size()); - - // searching bounds that are small / have very exact end points - Assert.assertEquals(1, mTree.search(new Bounds(0, .001, 0, .0001)).size()); - Assert.assertEquals(26617, mTree.search(new Bounds(0.356, 0.574, 0.678, 0.987)).size()); - Assert.assertEquals(44689, mTree.search(new Bounds(0.123, 0.456, 0.456, 0.789)).size()); - Assert.assertEquals(4906, mTree.search(new Bounds(0.111, 0.222, 0.333, 0.444)).size()); - - mTree.clear(); - Assert.assertEquals(0, searchAll().size()); - System.gc(); - } - - /** - * Runs a test with 100,000 points. Timing results are averaged. - */ - @Test - public void testRandomPoints() { - System.gc(); - Random random = new Random(); - for (int i = 0; i < 100000; i++) { - mTree.add(new Item(random.nextDouble(), random.nextDouble())); - } - searchAll(); - - mTree.search(new Bounds(0, 0.5, 0, 0.5)); - mTree.search(new Bounds(0, 0.25, 0, 0.25)); - mTree.search(new Bounds(0, 0.125, 0, 0.125)); - mTree.search(new Bounds(0, 0.999, 0, 0.999)); - mTree.search(new Bounds(0, 1, 0, 0.01)); - mTree.search(new Bounds(0.4, 0.6, 0.4, 0.6)); - mTree.search(new Bounds(0.356, 0.574, 0.678, 0.987)); - mTree.search(new Bounds(0.123, 0.456, 0.456, 0.789)); - mTree.search(new Bounds(0.111, 0.222, 0.333, 0.444)); - - mTree.clear(); - System.gc(); - } - - private Collection searchAll() { - return mTree.search(new Bounds(0, 1, 0, 1)); - } - - private static class Item implements PointQuadTree.Item { - private final Point mPoint; - - private Item(double x, double y) { - this.mPoint = new Point(x, y); - } - - @Override - public Point getPoint() { - return mPoint; - } - } -} diff --git a/library/src/test/java/com/google/maps/android/quadtree/PointQuadTreeTest.kt b/library/src/test/java/com/google/maps/android/quadtree/PointQuadTreeTest.kt new file mode 100644 index 000000000..6a004f739 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/quadtree/PointQuadTreeTest.kt @@ -0,0 +1,189 @@ +/* + * Copyright 2014 Google Inc. + * + * 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.google.maps.android.quadtree + +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.geometry.Bounds +import com.google.maps.android.geometry.Point +import org.junit.Test + +class PointQuadTreeTest { + @Test + fun testAddOnePoint() { + val tree = PointQuadTree(0.0, 1.0, 0.0, 1.0) + + tree.add(Item(0.0, 0.0)) + + with(searchAll(tree)) { + assertThat(this).hasSize(1) + assertThat(this).containsExactly(Item(0.0, 0.0)) + } + } + + @Test + fun testEmpty() { + val tree = PointQuadTree(0.0, 1.0, 0.0, 1.0) + + with(searchAll(tree)) { + assertThat(this).isEmpty() + } + } + + @Test + fun testMultiplePoints() { + val tree = PointQuadTree(0.0, 1.0, 0.0, 1.0) + + val items = buildList { + add(Item(0.0, 0.0)) + add(Item(0.1, 0.1)) + add(Item(0.2, 0.2)) + } + + // Remove item that isn't yet in the QuadTree + assertThat(tree.remove(items.first())).isFalse() + + for (item in items) { + tree.add(item) + } + + with(searchAll(tree)) { + assertThat(this).hasSize(3) + assertThat(this).containsExactlyElementsIn(items) + } + + items.forEach { + assertThat(tree.remove(it)).isTrue() + } + + assertThat(searchAll(tree)).isEmpty() + + // Remove item that is no longer in the QuadTree + assertThat(tree.remove(items.first())).isFalse() + } + + @Test + fun testSameLocationDifferentPoint() { + val tree = PointQuadTree(0.0, 1.0, 0.0, 1.0) + + tree.add(Item(0.0, 0.0, 0)) + tree.add(Item(0.0, 0.0, 1)) + + assertThat(searchAll(tree)).hasSize(2) + } + + @Test + fun testClear() { + val tree = PointQuadTree(0.0, 1.0, 0.0, 1.0) + + tree.add(Item(.1, .1)) + tree.add(Item(.2, .2)) + tree.add(Item(.3, .3)) + + tree.clear() + assertThat(searchAll(tree)).isEmpty() + } + + @Test + fun testSearch() { + val tree = PointQuadTree(0.0, 1.0, 0.0, 1.0) + + for (i in 0..9999) { + tree.add(Item(i / 20_000.0, i / 20_000.0)) + } + + assertThat( + tree.search(Bounds(0.0, 0.00001, 0.0, 0.00001)) + ).hasSize(1) + + assertThat( + tree.search(Bounds(.7, .8, .7, .8)) + ).isEmpty() + } + + @Test + fun testFourPoints() { + val tree = PointQuadTree(0.0, 1.0, 0.0, 1.0) + + tree.add(Item(0.2, 0.2)) + tree.add(Item(0.7, 0.2)) + tree.add(Item(0.2, 0.7)) + tree.add(Item(0.7, 0.7)) + + assertThat(tree.search(Bounds(0.0, 0.5, 0.0, 1.0))).hasSize(2) + } + + /** + * Tests 30,000 items at the same point. Timing results are averaged. + */ + @Test + fun testVeryDeepTree() { + val tree = PointQuadTree(0.0, 1.0, 0.0, 1.0) + + for (i in 0..29999) { + tree.add(Item(0.0, 0.0, i)) + } + + assertThat(searchAll(tree)).hasSize(30000) + assertThat(tree.search(Bounds(0.0, .1, 0.0, .1))).hasSize(30000) + assertThat(tree.search(Bounds(0.1, 1.0, 0.1, 1.0))).isEmpty() + } + + /** + * Tests 400,000 points relatively uniformly distributed across the space. Timing results are + * averaged. + */ + + @Test + fun testManyPoints() { + val tree = PointQuadTree(0.0, 1.0, 0.0, 1.0) + + for (i in 0..< 200) { + for (j in 0..< 2000) { + tree.add(Item(i / 200.0, j / 2000.0)) + } + } + + // Searching bounds that are exact subtrees of the main quadTree + assertThat(searchAll(tree)).hasSize(400000) // Using Truth + assertThat(tree.search(Bounds(0.0, .5, 0.0, .5))).hasSize(100000) + assertThat(tree.search(Bounds(.5, 1.0, 0.0, .5))).hasSize(100000) + assertThat(tree.search(Bounds(0.0, .25, 0.0, .25))).hasSize(25000) + assertThat(tree.search(Bounds(.75, 1.0, .75, 1.0))).hasSize(25000) + + // Searching bounds that do not line up with main quadTree + assertThat(tree.search(Bounds(0.0, 0.999, 0.0, 0.999))).hasSize(399800) + assertThat(tree.search(Bounds(0.8, 0.9, 0.8, 0.9))).hasSize(4221) + assertThat(tree.search(Bounds(0.0, 1.0, 0.0, 0.01))).hasSize(4200) + assertThat(tree.search(Bounds(0.4, 0.6, 0.4, 0.6))).hasSize(16441) + + // Searching bounds that are small / have very exact end points + assertThat(tree.search(Bounds(0.0, .001, 0.0, .0001))).hasSize(1) + assertThat(tree.search(Bounds(0.356, 0.574, 0.678, 0.987))).hasSize(26617) + assertThat(tree.search(Bounds(0.123, 0.456, 0.456, 0.789))).hasSize(44689) + assertThat(tree.search(Bounds(0.111, 0.222, 0.333, 0.444))).hasSize(4906) + + tree.clear() + assertThat(searchAll(tree)).isEmpty() + } + + private fun searchAll(tree: PointQuadTree): Collection { + return tree.search(Bounds(0.0, 1.0, 0.0, 1.0)) + } + + private data class Item(val x: Double, val y: Double, val tag: Any? = null) : PointQuadTree.Item { + override val point: Point = Point(x, y) + } +}