Skip to content

Commit 76b2727

Browse files
committed
Refactor coordinate formatting
1 parent b12fbf4 commit 76b2727

File tree

12 files changed

+423
-386
lines changed

12 files changed

+423
-386
lines changed

src/main/kotlin/com/kylecorry/sol/science/geography/CoordinateFormat.kt

Lines changed: 0 additions & 11 deletions
This file was deleted.
Lines changed: 12 additions & 333 deletions
Original file line numberDiff line numberDiff line change
@@ -1,344 +1,23 @@
11
package com.kylecorry.sol.science.geography
22

3-
import com.kylecorry.sol.math.SolMath.power
4-
import com.kylecorry.sol.math.SolMath.roundPlaces
5-
import com.kylecorry.sol.shared.DecimalFormatter
6-
import com.kylecorry.sol.shared.toDoubleCompat
3+
import com.kylecorry.sol.science.geography.formatting.*
74
import com.kylecorry.sol.units.Coordinate
8-
import gov.nasa.worldwind.avlist.AVKey
9-
import gov.nasa.worldwind.geom.Angle
10-
import gov.nasa.worldwind.geom.coords.MGRSCoordinateFormat
11-
import gov.nasa.worldwind.geom.coords.UPSCoord
12-
import gov.nasa.worldwind.geom.coords.UTMCoord
13-
import uk.gov.dstl.geo.osgb.Constants
14-
import uk.gov.dstl.geo.osgb.EastingNorthingConversion
15-
import uk.gov.dstl.geo.osgb.NationalGrid
16-
import uk.gov.dstl.geo.osgb.OSGB36
17-
import java.util.*
18-
import kotlin.math.abs
19-
import kotlin.math.absoluteValue
205

216
object CoordinateFormatter {
22-
fun Coordinate.toDecimalDegrees(precision: Int = 6): String {
23-
val formattedLatitude = DecimalFormatter.format(latitude, precision)
24-
val formattedLongitude = DecimalFormatter.format(longitude, precision)
25-
return "$formattedLatitude°, $formattedLongitude°"
26-
}
27-
28-
fun Coordinate.toDegreeDecimalMinutes(precision: Int = 3): String {
29-
val latDir = if (latitude < 0) "S" else "N"
30-
val lngDir = if (longitude < 0) "W" else "E"
31-
return "${ddmString(latitude, precision)}$latDir ${
32-
ddmString(
33-
longitude,
34-
precision
35-
)
36-
}$lngDir"
37-
}
38-
39-
fun Coordinate.toDegreeMinutesSeconds(precision: Int = 1): String {
40-
val latDir = if (latitude < 0) "S" else "N"
41-
val lngDir = if (longitude < 0) "W" else "E"
42-
return "${dmsString(latitude, precision)}${latDir} ${
43-
dmsString(
44-
longitude,
45-
precision
46-
)
47-
}${lngDir}"
48-
}
49-
50-
fun Coordinate.toUSNG(precision: Int = 5): String {
51-
val mgrs = toMGRS(precision)
52-
if (mgrs.length > 3) {
53-
return mgrs.substring(0, 3) + " " + mgrs.substring(3)
54-
}
55-
return mgrs
56-
}
57-
58-
fun Coordinate.toMGRS(precision: Int = 5): String {
59-
return try {
60-
MGRSCoordinateFormat.getString(latitude, longitude, precision)
61-
} catch (_: Exception) {
62-
"?"
63-
}
64-
}
65-
66-
fun Coordinate.toUTM(precision: Int = 7): String {
67-
try {
68-
val lat = Angle.fromDegreesLatitude(latitude)
69-
val lng = Angle.fromDegreesLongitude(longitude)
70-
val utm = UTMCoord.fromLatLon(lat, lng)
71-
72-
val zone = utm.zone.toString().padStart(2, '0')
73-
74-
val letter =
75-
if (latitude < -72) 'C' else if (latitude < -64) 'D' else if (latitude < -56) 'E' else if (latitude < -48) 'F' else if (latitude < -40) 'G' else if (latitude < -32) 'H' else if (latitude < -24) 'J' else if (latitude < -16) 'K' else if (latitude < -8) 'L' else if (latitude < 0) 'M' else if (latitude < 8) 'N' else if (latitude < 16) 'P' else if (latitude < 24) 'Q' else if (latitude < 32) 'R' else if (latitude < 40) 'S' else if (latitude < 48) 'T' else if (latitude < 56) 'U' else if (latitude < 64) 'V' else if (latitude < 72) 'W' else 'X'
76-
77-
78-
val easting =
79-
roundUTMPrecision(precision, utm.easting.toInt()).toString().padStart(7, '0') + "E"
80-
val northing =
81-
roundUTMPrecision(precision, utm.northing.toInt()).toString().padStart(7, '0') + "N"
82-
83-
return "$zone$letter $easting $northing"
84-
} catch (e: Exception) {
85-
return toUPS(precision)
86-
}
87-
}
88-
89-
// TODO: Support precision
90-
fun Coordinate.toOSGB(precision: Int = 5): String {
91-
try {
92-
val osgb36 = OSGB36.fromWGS84(latitude, longitude)
93-
val en = EastingNorthingConversion.fromLatLon(
94-
osgb36,
95-
Constants.ELLIPSOID_AIRY1830_MAJORAXIS,
96-
Constants.ELLIPSOID_AIRY1830_MINORAXIS,
97-
Constants.NATIONALGRID_N0,
98-
Constants.NATIONALGRID_E0,
99-
Constants.NATIONALGRID_F0,
100-
Constants.NATIONALGRID_LAT0,
101-
Constants.NATIONALGRID_LON0
102-
)
103-
val ng = NationalGrid.toNationalGrid(en)
104-
if (ng != null) {
105-
return ng
106-
}
107-
} catch (e: Exception) {
108-
return "?"
109-
}
110-
return "?"
111-
}
112-
113-
private fun Coordinate.toUPS(precision: Int = 7): String {
114-
try {
115-
val lat = Angle.fromDegreesLatitude(latitude)
116-
val lng = Angle.fromDegreesLongitude(longitude)
117-
val ups = UPSCoord.fromLatLon(lat, lng)
118-
119-
val easting =
120-
roundUTMPrecision(precision, ups.easting.toInt()).toString().padStart(7, '0') + "E"
121-
val northing =
122-
roundUTMPrecision(precision, ups.northing.toInt()).toString().padStart(7, '0') + "N"
123-
124-
val letter = if (isNorthernHemisphere) {
125-
if (latitude == 90.0 || longitude >= 0) {
126-
'Z'
127-
} else {
128-
'Y'
129-
}
130-
} else {
131-
if (latitude == -90.0 || longitude >= 0) {
132-
'B'
133-
} else {
134-
'A'
135-
}
136-
}
137-
138-
return "$letter $easting $northing"
139-
} catch (e: Exception) {
140-
return "?"
141-
}
142-
}
143-
144-
private fun roundUTMPrecision(precision: Int, utmValue: Int): Int {
145-
return (utmValue / power(10.0, 7 - precision)).toInt() * power(10.0, 7 - precision).toInt()
146-
}
147-
148-
private fun ddmString(degrees: Double, precision: Int = 3): String {
149-
val deg = abs(degrees.toInt())
150-
val minutes = abs((degrees % 1) * 60).roundPlaces(precision)
151-
return "$deg°$minutes'"
152-
}
153-
154-
private fun dmsString(degrees: Double, precision: Int = 1): String {
155-
val deg = abs(degrees.toInt())
156-
val minutes = abs((degrees % 1) * 60)
157-
val seconds = abs(((minutes % 1) * 60).roundPlaces(precision))
158-
return "$deg°${minutes.toInt()}'$seconds\""
159-
}
7+
val formats = listOf(
8+
DecimalDegreesCoordinateFormat(),
9+
DegreesDecimalMinutesCoordinateFormat(),
10+
DegreesMinutesSecondsCoordinateFormat(),
11+
UTMCoordinateFormat(),
12+
MGRSCoordinateFormat(),
13+
USNGCoordinateFormat(),
14+
OSGBCoordinateFormat()
15+
)
16016

16117
fun Coordinate.Companion.parse(
16218
location: String,
163-
format: CoordinateFormat? = null
164-
): Coordinate? {
165-
if (format == null) {
166-
for (fmt in CoordinateFormat.values()) {
167-
val parsed = parse(location, fmt)
168-
if (parsed != null) {
169-
return parsed
170-
}
171-
}
172-
return null
173-
}
174-
175-
return when (format) {
176-
CoordinateFormat.DecimalDegrees -> fromDecimalDegrees(location)
177-
CoordinateFormat.DegreesDecimalMinutes -> fromDegreesDecimalMinutes(location)
178-
CoordinateFormat.DegreesMinutesSeconds -> fromDegreesMinutesSeconds(location)
179-
CoordinateFormat.UTM -> fromUTM(location)
180-
CoordinateFormat.MGRS -> fromMGRS(location)
181-
CoordinateFormat.USNG -> fromMGRS(location)
182-
CoordinateFormat.OSGB -> fromOSGB(location)
183-
}
184-
}
185-
186-
private fun fromMGRS(location: String): Coordinate? {
187-
return try {
188-
return MGRSCoordinateFormat.fromString(location)
189-
} catch (e: Exception) {
190-
null
191-
}
192-
}
193-
194-
private fun fromOSGB(location: String): Coordinate? {
195-
return try {
196-
val eastingNorthing = try {
197-
NationalGrid.fromNationalGrid(location)
198-
} catch (e: Exception) {
199-
val split = location.split(",")
200-
val en = split.mapNotNull { it.toDoubleCompat() }.toDoubleArray()
201-
NationalGrid.toNationalGrid(en)
202-
en
203-
}
204-
val latlonOSGB = EastingNorthingConversion.toLatLon(
205-
eastingNorthing,
206-
Constants.ELLIPSOID_AIRY1830_MAJORAXIS,
207-
Constants.ELLIPSOID_AIRY1830_MINORAXIS,
208-
Constants.NATIONALGRID_N0,
209-
Constants.NATIONALGRID_E0,
210-
Constants.NATIONALGRID_F0,
211-
Constants.NATIONALGRID_LAT0,
212-
Constants.NATIONALGRID_LON0
213-
)
214-
val latlonWGS84 = OSGB36.toWGS84(latlonOSGB[0], latlonOSGB[1])
215-
return Coordinate(latlonWGS84[0], latlonWGS84[1])
216-
} catch (e: Exception) {
217-
null
218-
}
219-
}
220-
221-
private fun fromDecimalDegrees(location: String): Coordinate? {
222-
val regex = Regex("^(-?\\d+(?:[.,]\\d+)?)°?[,\\s]+(-?\\d+(?:[.,]\\d+)?)°?\$")
223-
val matches = regex.find(location.trim()) ?: return null
224-
val latitude = matches.groupValues[1].toDoubleCompat() ?: return null
225-
val longitude = matches.groupValues[2].toDoubleCompat() ?: return null
226-
227-
if (isValidLatitude(latitude) && isValidLongitude(longitude)) {
228-
return Coordinate(latitude, longitude)
229-
}
230-
231-
return null
232-
}
233-
234-
private fun fromDegreesDecimalMinutes(location: String): Coordinate? {
235-
val ddmRegex =
236-
Regex("^(\\d+)°\\s*(\\d+(?:[.,]\\d+)?)[′']\\s*([nNsS])[,\\s]+(\\d+)°\\s*(\\d+(?:[.,]\\d+)?)[′']\\s*([wWeE])\$")
237-
val matches = ddmRegex.find(location.trim()) ?: return null
238-
239-
var latitudeDecimal = 0.0
240-
latitudeDecimal += matches.groupValues[1].toDouble()
241-
latitudeDecimal += (matches.groupValues[2].toDoubleCompat() ?: 0.0) / 60
242-
latitudeDecimal *= if (matches.groupValues[3].lowercase(Locale.getDefault()) == "n") 1 else -1
243-
244-
var longitudeDecimal = 0.0
245-
longitudeDecimal += matches.groupValues[4].toDouble()
246-
longitudeDecimal += (matches.groupValues[5].toDoubleCompat() ?: 0.0) / 60
247-
longitudeDecimal *= if (matches.groupValues[6].lowercase(Locale.getDefault()) == "e") 1 else -1
248-
249-
if (isValidLatitude(latitudeDecimal) && isValidLongitude(
250-
longitudeDecimal
251-
)
252-
) {
253-
return Coordinate(latitudeDecimal, longitudeDecimal)
254-
}
255-
256-
return null
257-
}
258-
259-
private fun fromDegreesMinutesSeconds(location: String): Coordinate? {
260-
val dmsRegex =
261-
Regex("^(\\d+)°\\s*(\\d+)[′']\\s*(\\d+(?:[.,]\\d+)?)[″\"]\\s*([nNsS])[,\\s]+(\\d+)°\\s*(\\d+)[′']\\s*(\\d+(?:[.,]\\d+)?)[″\"]\\s*([wWeE])\$")
262-
val matches = dmsRegex.find(location.trim()) ?: return null
263-
264-
var latitudeDecimal = 0.0
265-
latitudeDecimal += matches.groupValues[1].toDouble()
266-
latitudeDecimal += matches.groupValues[2].toDouble() / 60
267-
latitudeDecimal += (matches.groupValues[3].toDoubleCompat() ?: 0.0) / (60 * 60)
268-
latitudeDecimal *= if (matches.groupValues[4].lowercase(Locale.getDefault()) == "n") 1 else -1
269-
270-
var longitudeDecimal = 0.0
271-
longitudeDecimal += matches.groupValues[5].toDouble()
272-
longitudeDecimal += matches.groupValues[6].toDouble() / 60
273-
longitudeDecimal += (matches.groupValues[7].toDoubleCompat() ?: 0.0) / (60 * 60)
274-
longitudeDecimal *= if (matches.groupValues[8].lowercase(Locale.getDefault()) == "e") 1 else -1
275-
276-
if (isValidLatitude(latitudeDecimal) && isValidLongitude(
277-
longitudeDecimal
278-
)
279-
) {
280-
return Coordinate(latitudeDecimal, longitudeDecimal)
281-
}
282-
283-
return null
284-
}
285-
286-
private fun fromUTM(utm: String): Coordinate? {
287-
val regex =
288-
Regex("(\\d*)\\s*([a-z,A-Z^ioIO])\\s*(\\d+(?:[.,]\\d+)?)[\\smMeE]+(\\d+(?:[.,]\\d+)?)[\\smMnN]*")
289-
val matches = regex.find(utm) ?: return null
290-
291-
val zone = matches.groupValues[1].toIntOrNull() ?: 0
292-
val letter = matches.groupValues[2].toCharArray().first()
293-
val easting = matches.groupValues[3].toDoubleCompat() ?: 0.0
294-
val northing = matches.groupValues[4].toDoubleCompat() ?: 0.0
295-
296-
return fromUTM(zone, letter, easting, northing)
297-
}
298-
299-
private fun fromUTM(
300-
zone: Int,
301-
letter: Char,
302-
easting: Double,
303-
northing: Double
19+
formats: List<CoordinateFormat> = CoordinateFormatter.formats
30420
): Coordinate? {
305-
val polarLetters = listOf('A', 'B', 'Y', 'Z')
306-
return try {
307-
if (polarLetters.contains(letter.uppercaseChar())) {
308-
// Get it into the catch block
309-
throw Exception()
310-
}
311-
val latLng = UTMCoord.locationFromUTMCoord(
312-
zone,
313-
if (letter.uppercaseChar() <= 'M') AVKey.SOUTH else AVKey.NORTH,
314-
easting,
315-
northing
316-
)
317-
Coordinate(latLng.latitude.degrees, latLng.longitude.degrees)
318-
} catch (e: Exception) {
319-
val letters = listOf('A', 'B', 'Y', 'Z')
320-
if (zone != 0 || !letters.contains(letter.uppercaseChar())) {
321-
return null
322-
}
323-
try {
324-
val latLng = UPSCoord.fromUPS(
325-
if (letter.uppercaseChar() <= 'M') AVKey.SOUTH else AVKey.NORTH,
326-
easting,
327-
northing
328-
)
329-
Coordinate(latLng.latitude.degrees, latLng.longitude.degrees)
330-
} catch (e2: Exception) {
331-
null
332-
}
333-
}
334-
}
335-
336-
private fun isValidLongitude(longitude: Double): Boolean {
337-
return longitude.absoluteValue <= 180
21+
return formats.firstNotNullOfOrNull { it.parse(location) }
33822
}
339-
340-
private fun isValidLatitude(latitude: Double): Boolean {
341-
return latitude.absoluteValue <= 90
342-
}
343-
34423
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.kylecorry.sol.science.geography.formatting
2+
3+
import com.kylecorry.sol.units.Coordinate
4+
5+
interface CoordinateFormat {
6+
fun toString(coordinate: Coordinate): String
7+
fun parse(text: String): Coordinate?
8+
}

0 commit comments

Comments
 (0)