Skip to content

Commit 3148633

Browse files
committed
refactor(library): Modernize SphericalUtil with idiomatic Kotlin
This commit refactors the `SphericalUtil` object to use more modern and idiomatic Kotlin patterns, improving code readability and conciseness. Key changes include: - **Functional Approach**: Replaced imperative `for` loops in `computeLength` and `computeSignedArea` with functional constructs like `zipWithNext()` and `sumOf()`. - **Extension Functions**: Introduced helper extension functions for `LatLng` to enable destructuring (`component1`/`component2`) and simplify conversion to radians (`toRadians`). - **Code Cleanup**: Refactored methods like `computeOffset`, `computeOffsetOrigin`, and `interpolate` to use immutable variables (`val`) and the new helper functions. - **New Tests**: Added `SphericalUtilKotlinTest.kt` to provide test coverage for the refactored list-based calculations using Google Truth.
1 parent 1156731 commit 3148633

File tree

2 files changed

+135
-72
lines changed

2 files changed

+135
-72
lines changed

library/src/main/java/com/google/maps/android/SphericalUtil.kt

Lines changed: 80 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,6 @@ import kotlin.math.sin
3232
import kotlin.math.sqrt
3333
import kotlin.math.tan
3434

35-
private fun Double.toRadians() = this * (PI / 180.0)
36-
private fun Double.toDegrees() = this * (180.0 / PI)
37-
3835
object SphericalUtil {
3936
/**
4037
* Returns the heading from one LatLng to another LatLng. Headings are
@@ -51,7 +48,8 @@ object SphericalUtil {
5148

5249
// Breaking the formula down into Y and X components for atan2().
5350
val y = sin(deltaLngRad) * cos(toLatRad)
54-
val x = cos(fromLatRad) * sin(toLatRad) - sin(fromLatRad) * cos(toLatRad) * cos(deltaLngRad)
51+
val x = cos(fromLatRad) * sin(toLatRad) -
52+
sin(fromLatRad) * cos(toLatRad) * cos(deltaLngRad)
5553

5654
val headingRad = atan2(y, x)
5755

@@ -68,23 +66,26 @@ object SphericalUtil {
6866
*/
6967
@JvmStatic
7068
fun computeOffset(from: LatLng, distance: Double, heading: Double): LatLng {
71-
var distance = distance
72-
var heading = heading
73-
distance /= EARTH_RADIUS
74-
heading = Math.toRadians(heading)
75-
// http://williams.best.vwh.net/avform.htm#LL
76-
val fromLat = Math.toRadians(from.latitude)
77-
val fromLng = Math.toRadians(from.longitude)
78-
val cosDistance = cos(distance)
79-
val sinDistance = sin(distance)
80-
val sinFromLat = sin(fromLat)
81-
val cosFromLat = cos(fromLat)
82-
val sinLat = cosDistance * sinFromLat + sinDistance * cosFromLat * cos(heading)
83-
val dLng = atan2(
84-
sinDistance * cosFromLat * sin(heading),
85-
cosDistance - sinFromLat * sinLat
86-
)
87-
return LatLng(Math.toDegrees(asin(sinLat)), Math.toDegrees(fromLng + dLng))
69+
val distanceRad = distance / EARTH_RADIUS
70+
val headingRad = heading.toRadians()
71+
72+
val (fromLatRad, fromLngRad) = from.toRadians()
73+
74+
val cosDistance = cos(distanceRad)
75+
val sinDistance = sin(distanceRad)
76+
val sinFromLat = sin(fromLatRad)
77+
val cosFromLat = cos(fromLatRad)
78+
79+
val sinToLat = cosDistance * sinFromLat + sinDistance * cosFromLat * cos(headingRad)
80+
val toLatRad = asin(sinToLat)
81+
82+
val y = sin(headingRad) * sinDistance * cosFromLat
83+
val x = cosDistance - sinFromLat * sinToLat
84+
val dLngRad = atan2(y, x)
85+
86+
val toLngRad = fromLngRad + dLngRad
87+
88+
return LatLng(toLatRad.toDegrees(), toLngRad.toDegrees())
8889
}
8990

9091
/**
@@ -99,15 +100,13 @@ object SphericalUtil {
99100
*/
100101
@JvmStatic
101102
fun computeOffsetOrigin(to: LatLng, distance: Double, heading: Double): LatLng? {
102-
var distance = distance
103-
var heading = heading
104-
heading = Math.toRadians(heading)
105-
distance /= EARTH_RADIUS
103+
val headingRad = heading.toRadians()
104+
val distanceRad = distance / EARTH_RADIUS
106105
// http://lists.maptools.org/pipermail/proj/2008-October/003939.html
107-
val n1 = cos(distance)
108-
val n2 = sin(distance) * cos(heading)
109-
val n3 = sin(distance) * sin(heading)
110-
val n4 = sin(Math.toRadians(to.latitude))
106+
val n1 = cos(distanceRad)
107+
val n2 = sin(distanceRad) * cos(headingRad)
108+
val n3 = sin(distanceRad) * sin(headingRad)
109+
val n4 = sin(to.latitude.toRadians())
111110
// There are two solutions for b. b = n2 * n4 +/- sqrt(), one solution results
112111
// in the latitude outside the [-90, 90] range. We first try one solution and
113112
// back off to the other if we are outside that range.
@@ -130,9 +129,9 @@ object SphericalUtil {
130129
// No solution which would make sense in LatLng-space.
131130
return null
132131
}
133-
val fromLngRadians = Math.toRadians(to.longitude) -
132+
val fromLngRadians = to.longitude.toRadians() -
134133
atan2(n3, n1 * cos(fromLatRadians) - n2 * sin(fromLatRadians))
135-
return LatLng(Math.toDegrees(fromLatRadians), Math.toDegrees(fromLngRadians))
134+
return LatLng(fromLatRadians.toDegrees(), fromLngRadians.toDegrees())
136135
}
137136

138137
/**
@@ -147,17 +146,17 @@ object SphericalUtil {
147146
@JvmStatic
148147
fun interpolate(from: LatLng, to: LatLng, fraction: Double): LatLng {
149148
// http://en.wikipedia.org/wiki/Slerp
150-
val fromLat = Math.toRadians(from.latitude)
151-
val fromLng = Math.toRadians(from.longitude)
152-
val toLat = Math.toRadians(to.latitude)
153-
val toLng = Math.toRadians(to.longitude)
154-
val cosFromLat = cos(fromLat)
155-
val cosToLat = cos(toLat)
149+
val (fromLatRad, fromLngRad) = from.toRadians()
150+
val (toLatRad, toLngRad) = to.toRadians()
151+
152+
val cosFromLat = cos(fromLatRad)
153+
val cosToLat = cos(toLatRad)
156154

157155
// Computes Spherical interpolation coefficients.
158156
val angle = computeAngleBetween(from, to)
159157
val sinAngle = sin(angle)
160158
if (sinAngle < 1E-6) {
159+
// Fall back to linear interpolation for very small angles.
161160
return LatLng(
162161
from.latitude + fraction * (to.latitude - from.latitude),
163162
from.longitude + fraction * (to.longitude - from.longitude)
@@ -167,14 +166,15 @@ object SphericalUtil {
167166
val b = sin(fraction * angle) / sinAngle
168167

169168
// Converts from polar to vector and interpolate.
170-
val x = a * cosFromLat * cos(fromLng) + b * cosToLat * cos(toLng)
171-
val y = a * cosFromLat * sin(fromLng) + b * cosToLat * sin(toLng)
172-
val z = a * sin(fromLat) + b * sin(toLat)
169+
val x = a * cosFromLat * cos(fromLngRad) + b * cosToLat * cos(toLngRad)
170+
val y = a * cosFromLat * sin(fromLngRad) + b * cosToLat * sin(toLngRad)
171+
val z = a * sin(fromLatRad) + b * sin(toLatRad)
173172

174173
// Converts interpolated vector back to polar.
175-
val lat = atan2(z, sqrt(x * x + y * y))
176-
val lng = atan2(y, x)
177-
return LatLng(Math.toDegrees(lat), Math.toDegrees(lng))
174+
val latRad = atan2(z, sqrt(x * x + y * y))
175+
val lngRad = atan2(y, x)
176+
177+
return LatLng(latRad.toDegrees(), lngRad.toDegrees())
178178
}
179179

180180
/**
@@ -184,7 +184,7 @@ object SphericalUtil {
184184
arcHav(havDistance(lat1, lat2, lng1 - lng2))
185185

186186
/**
187-
* Returns the angle between two LatLngs, in radians. This is the same as the distance
187+
* Returns the angle between two [LatLng]s, in radians. This is the same as the distance
188188
* on the unit sphere.
189189
*/
190190
@JvmStatic
@@ -194,10 +194,11 @@ object SphericalUtil {
194194
)
195195

196196
/**
197-
* Returns the distance between two LatLngs, in meters.
197+
* Returns the distance between two [LatLng]s, in meters.
198198
*/
199199
@JvmStatic
200-
fun computeDistanceBetween(from: LatLng, to: LatLng) = computeAngleBetween(from, to) * EARTH_RADIUS
200+
fun computeDistanceBetween(from: LatLng, to: LatLng) =
201+
computeAngleBetween(from, to) * EARTH_RADIUS
201202

202203
/**
203204
* Returns the length of the given path, in meters, on Earth.
@@ -207,19 +208,16 @@ object SphericalUtil {
207208
if (path.size < 2) {
208209
return 0.0
209210
}
210-
var length = 0.0
211-
var prev: LatLng? = null
212-
for (point in path) {
213-
if (prev != null) {
214-
val prevLat = Math.toRadians(prev.latitude)
215-
val prevLng = Math.toRadians(prev.longitude)
216-
val lat = Math.toRadians(point.latitude)
217-
val lng = Math.toRadians(point.longitude)
218-
length += distanceRadians(prevLat, prevLng, lat, lng)
219-
}
220-
prev = point
211+
212+
// Using zipWithNext() is a more functional and idiomatic way to handle
213+
// adjacent pairs in a collection. We then sum the distances between each pair.
214+
val totalDistance = path.zipWithNext().sumOf { (prev, point) ->
215+
val (prevLatRad, prevLngRad) = prev.toRadians()
216+
val (latRad, lngRad) = point.toRadians()
217+
distanceRadians(prevLatRad, prevLngRad, latRad, lngRad)
221218
}
222-
return length * EARTH_RADIUS
219+
220+
return totalDistance * EARTH_RADIUS
223221
}
224222

225223
/**
@@ -242,31 +240,33 @@ object SphericalUtil {
242240
@JvmStatic
243241
fun computeSignedArea(path: Polygon) = computeSignedArea(path, EARTH_RADIUS)
244242

243+
245244
/**
246245
* Returns the signed area of a closed path on a sphere of given radius.
247246
* The computed area uses the same units as the radius squared.
248247
* Used by SphericalUtilTest.
249248
*/
250249
@JvmStatic
251-
fun computeSignedArea(path: Polygon, radius: Double): Double {
252-
val size = path.size
253-
if (size < 3) {
250+
fun computeSignedArea(path: List<LatLng>, radius: Double): Double {
251+
if (path.size < 3) {
254252
return 0.0
255253
}
256-
var total = 0.0
257-
val prev = path[size - 1]
258-
var prevTanLat = tan((PI / 2 - prev.latitude.toRadians()) / 2)
259-
var prevLng = prev.longitude.toRadians()
254+
255+
// Create a closed path by appending the first point at the end.
256+
val closedPath = path + path.first()
257+
260258
// For each edge, accumulate the signed area of the triangle formed by the North Pole
261259
// and that edge ("polar triangle").
262-
for (point in path) {
260+
// `zipWithNext` creates pairs of consecutive vertices, representing the edges of the polygon.
261+
val totalArea = closedPath.zipWithNext { prev, point ->
262+
val prevTanLat = tan((PI / 2 - prev.latitude.toRadians()) / 2)
263263
val tanLat = tan((PI / 2 - point.latitude.toRadians()) / 2)
264+
val prevLng = prev.longitude.toRadians()
264265
val lng = point.longitude.toRadians()
265-
total += polarTriangleArea(tanLat, lng, prevTanLat, prevLng)
266-
prevTanLat = tanLat
267-
prevLng = lng
268-
}
269-
return total * (radius * radius)
266+
polarTriangleArea(tanLat, lng, prevTanLat, prevLng)
267+
}.sum()
268+
269+
return totalArea * (radius * radius)
270270
}
271271

272272
/**
@@ -281,4 +281,12 @@ object SphericalUtil {
281281
val t = tan1 * tan2
282282
return 2 * atan2(t * sin(deltaLng), 1 + t * cos(deltaLng))
283283
}
284-
}
284+
}
285+
286+
/**
287+
* Helper extension function to convert a LatLng to a pair of radians.
288+
*/
289+
private fun LatLng.toRadians() = Pair(latitude.toRadians(), longitude.toRadians())
290+
291+
private fun Double.toRadians() = this * (PI / 180.0)
292+
private fun Double.toDegrees() = this * (180.0 / PI)
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package com.google.maps.android
2+
3+
import com.google.android.gms.maps.model.LatLng
4+
import com.google.common.truth.Truth.assertThat
5+
import org.junit.Test
6+
import kotlin.math.abs
7+
8+
/**
9+
* Tests for [SphericalUtil] that are written in Kotlin.
10+
*/
11+
class SphericalUtilKotlinTest {
12+
13+
val testPolygon = """
14+
-104.9596325,39.7543772,0
15+
-104.9596969,39.7448581,0
16+
-104.959375,39.7446271,0
17+
-104.959096,39.7443136,0
18+
-104.9588171,39.7440166,0
19+
-104.9581305,39.7439176,0
20+
-104.9409429,39.7438681,0
21+
-104.9408785,39.7543277,0
22+
-104.9596325,39.7543772,0
23+
""".trimIndent().lines().map {
24+
val (lng, lat, _) = it.split(",")
25+
LatLng(lat.toDouble(), lng.toDouble())
26+
}
27+
28+
// The expected length (perimeter) of the test polygon in meters.
29+
private val EXPECTED_LENGTH_METERS = 5474.0
30+
31+
// The expected area of the test polygon in square meters.
32+
private val EXPECTED_AREA_SQ_METERS = 1859748.0
33+
34+
// A tolerance for comparing floating-point numbers.
35+
private val TOLERANCE = 1.0 // 1 meter or 1 sq meter
36+
37+
/**
38+
* Tests the `computeLength` method with the polygon from the KML file.
39+
*/
40+
@Test
41+
fun testComputeLengthWithKmlPolygon() {
42+
val calculatedLength = SphericalUtil.computeLength(testPolygon)
43+
assertThat(calculatedLength).isWithin(TOLERANCE).of(EXPECTED_LENGTH_METERS)
44+
}
45+
46+
/**
47+
* Tests the `computeSignedArea` method with the polygon from the KML file.
48+
* Note: We test the absolute value since computeArea simply wraps computeSignedArea with abs().
49+
*/
50+
@Test
51+
fun testComputeSignedAreaWithKmlPolygon() {
52+
val calculatedSignedArea = SphericalUtil.computeSignedArea(testPolygon)
53+
assertThat(abs(calculatedSignedArea)).isWithin(TOLERANCE).of(EXPECTED_AREA_SQ_METERS)
54+
}
55+
}

0 commit comments

Comments
 (0)