diff --git a/lib/utils/location_formats.dart b/lib/utils/location_formats.dart index 8956cf4..919c380 100644 --- a/lib/utils/location_formats.dart +++ b/lib/utils/location_formats.dart @@ -1,3 +1,169 @@ +import 'dart:math' as math; + +enum CoordinateDisplayFormat { decimal, dms, utm } + +String formatCoordinates( + double latitude, + double longitude, + CoordinateDisplayFormat format, +) { + switch (format) { + case CoordinateDisplayFormat.decimal: + return '${latitude.toStringAsFixed(5)}, ${longitude.toStringAsFixed(5)}'; + case CoordinateDisplayFormat.dms: + return '${formatDmsCoordinate(latitude, true)} ${formatDmsCoordinate(longitude, false)}'; + case CoordinateDisplayFormat.utm: + return formatUtm(latitude, longitude); + } +} + +String formatDmsCoordinate(double degrees, bool isLatitude) { + final direction = isLatitude + ? (degrees >= 0 ? 'N' : 'S') + : (degrees >= 0 ? 'E' : 'W'); + + final absolute = degrees.abs(); + final deg = absolute.floor(); + final minDecimal = (absolute - deg) * 60; + final min = minDecimal.floor(); + final sec = (minDecimal - min) * 60; + + return '$deg°${min.toString().padLeft(2, '0')}\'${sec.toStringAsFixed(2).padLeft(5, '0')}"$direction'; +} + +String formatUtm(double latitude, double longitude) { + if (latitude < -80 || latitude > 84) { + return formatCoordinates( + latitude, + longitude, + CoordinateDisplayFormat.decimal, + ); + } + + final zone = _utmZone(latitude, longitude); + final band = _utmLatitudeBand(latitude); + final latRad = _degreesToRadians(latitude); + final lonRad = _degreesToRadians(longitude); + final centralMeridianRad = _degreesToRadians((zone - 1) * 6 - 180 + 3); + + const semiMajorAxis = 6378137.0; + const flattening = 1 / 298.257223563; + const scaleFactor = 0.9996; + final eccentricitySquared = flattening * (2 - flattening); + final secondEccentricitySquared = + eccentricitySquared / (1 - eccentricitySquared); + + final sinLat = math.sin(latRad); + final cosLat = math.cos(latRad); + final tanLat = math.tan(latRad); + final n = semiMajorAxis / + math.sqrt(1 - eccentricitySquared * sinLat * sinLat); + final t = tanLat * tanLat; + final c = secondEccentricitySquared * cosLat * cosLat; + final a = cosLat * (lonRad - centralMeridianRad); + + final meridianArc = semiMajorAxis * + ((1 - + eccentricitySquared / 4 - + 3 * eccentricitySquared * eccentricitySquared / 64 - + 5 * + eccentricitySquared * + eccentricitySquared * + eccentricitySquared / + 256) * + latRad - + (3 * eccentricitySquared / 8 + + 3 * eccentricitySquared * eccentricitySquared / 32 + + 45 * + eccentricitySquared * + eccentricitySquared * + eccentricitySquared / + 1024) * + math.sin(2 * latRad) + + (15 * eccentricitySquared * eccentricitySquared / 256 + + 45 * + eccentricitySquared * + eccentricitySquared * + eccentricitySquared / + 1024) * + math.sin(4 * latRad) - + (35 * + eccentricitySquared * + eccentricitySquared * + eccentricitySquared / + 3072) * + math.sin(6 * latRad)); + + final easting = scaleFactor * + n * + (a + + (1 - t + c) * a * a * a / 6 + + (5 - 18 * t + t * t + 72 * c - 58 * secondEccentricitySquared) * + a * + a * + a * + a * + a / + 120) + + 500000; + var northing = scaleFactor * + (meridianArc + + n * + tanLat * + (a * a / 2 + + (5 - t + 9 * c + 4 * c * c) * a * a * a * a / 24 + + (61 - + 58 * t + + t * t + + 600 * c - + 330 * secondEccentricitySquared) * + a * + a * + a * + a * + a * + a / + 720)); + if (latitude < 0) { + northing += 10000000; + } + + return '$zone$band ${easting.round()}E ${northing.round()}N'; +} + +int _utmZone(double latitude, double longitude) { + var zone = ((longitude + 180) / 6).floor() + 1; + if (longitude == 180) { + zone = 60; + } + if (latitude >= 56 && + latitude < 64 && + longitude >= 3 && + longitude < 12) { + zone = 32; + } + if (latitude >= 72 && latitude < 84) { + if (longitude >= 0 && longitude < 9) { + zone = 31; + } else if (longitude >= 9 && longitude < 21) { + zone = 33; + } else if (longitude >= 21 && longitude < 33) { + zone = 35; + } else if (longitude >= 33 && longitude < 42) { + zone = 37; + } + } + return zone.clamp(1, 60); +} + +String _utmLatitudeBand(double latitude) { + const bands = 'CDEFGHJKLMNPQRSTUVWX'; + final index = ((latitude + 80) / 8).floor().clamp(0, bands.length - 1); + return bands[index]; +} + +double _degreesToRadians(double degrees) => degrees * math.pi / 180; + String formatPlusCode(double lat, double lon) { const base = '23456789CFGHJMPQRVWX'; diff --git a/lib/widgets/map/compass/compass_header.dart b/lib/widgets/map/compass/compass_header.dart index 780bd50..f0e0f70 100644 --- a/lib/widgets/map/compass/compass_header.dart +++ b/lib/widgets/map/compass/compass_header.dart @@ -6,6 +6,7 @@ import 'package:latlong2/latlong.dart'; import '../../../l10n/app_localizations.dart'; import '../../../models/contact.dart'; import '../../../models/sar_marker.dart'; +import '../../../utils/location_formats.dart'; import 'compass_math.dart'; /// Header component for the compass dialog showing compass rose, @@ -563,21 +564,7 @@ class _LocationFormatToggle extends StatefulWidget { } class _LocationFormatToggleState extends State<_LocationFormatToggle> { - bool _showDMS = false; - - String _formatDMS(double degrees, bool isLatitude) { - final direction = isLatitude - ? (degrees >= 0 ? 'N' : 'S') - : (degrees >= 0 ? 'E' : 'W'); - - final absolute = degrees.abs(); - final deg = absolute.floor(); - final minDecimal = (absolute - deg) * 60; - final min = minDecimal.floor(); - final sec = (minDecimal - min) * 60; - - return '$deg°${min.toString().padLeft(2, '0')}\'${sec.toStringAsFixed(2).padLeft(5, '0')}"$direction'; - } + CoordinateDisplayFormat _format = CoordinateDisplayFormat.decimal; @override Widget build(BuildContext context) { @@ -589,20 +576,24 @@ class _LocationFormatToggleState extends State<_LocationFormatToggle> { final l10n = AppLocalizations.of(context)!; final String displayText; - if (_showDMS) { - displayText = - '${_formatDMS(position.latitude, true)} ${_formatDMS(position.longitude, false)}'; - } else { + if (_format == CoordinateDisplayFormat.decimal) { displayText = l10n.latLonFormat( position.latitude.toStringAsFixed(5), position.longitude.toStringAsFixed(5), ); + } else { + displayText = formatCoordinates( + position.latitude, + position.longitude, + _format, + ); } return GestureDetector( onTap: () { setState(() { - _showDMS = !_showDMS; + _format = CoordinateDisplayFormat.values[ + (_format.index + 1) % CoordinateDisplayFormat.values.length]; }); }, behavior: HitTestBehavior.opaque, diff --git a/lib/widgets/map_markers.dart b/lib/widgets/map_markers.dart index f00de1e..519b088 100644 --- a/lib/widgets/map_markers.dart +++ b/lib/widgets/map_markers.dart @@ -4,6 +4,7 @@ import '../models/contact.dart'; import '../models/sar_marker.dart'; import '../models/sar_template.dart'; import '../l10n/app_localizations.dart'; +import '../utils/location_formats.dart'; class MapMarkers { static List createTeamMemberMarkers( @@ -253,7 +254,11 @@ class MapMarkers { if (contact.displayLocation != null) ...[ _InfoRow( 'Location', - '${contact.displayLocation!.latitude.toStringAsFixed(6)}, ${contact.displayLocation!.longitude.toStringAsFixed(6)}', + formatCoordinates( + contact.displayLocation!.latitude, + contact.displayLocation!.longitude, + CoordinateDisplayFormat.utm, + ), ), ], if (contact.telemetry?.batteryMilliVolts != null) @@ -313,7 +318,11 @@ class MapMarkers { children: [ _InfoRow( 'Location', - '${marker.location.latitude.toStringAsFixed(6)}, ${marker.location.longitude.toStringAsFixed(6)}', + formatCoordinates( + marker.location.latitude, + marker.location.longitude, + CoordinateDisplayFormat.utm, + ), ), _InfoRow('Reported', marker.timeAgo), if (marker.senderName != null) diff --git a/test/utils/location_formats_test.dart b/test/utils/location_formats_test.dart new file mode 100644 index 0000000..2c902af --- /dev/null +++ b/test/utils/location_formats_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/utils/location_formats.dart'; + +void main() { + group('formatUtm', () { + test('formats northern hemisphere UTM coordinates', () { + expect(formatUtm(37.7749, -122.4194), '10S 551131E 4180999N'); + }); + + test('formats southern hemisphere UTM coordinates', () { + expect(formatUtm(-33.8688, 151.2093), '56H 334369E 6250948N'); + }); + + test('falls back to decimal outside the UTM latitude range', () { + expect(formatUtm(85, 14.5), '85.00000, 14.50000'); + }); + }); + + group('formatCoordinates', () { + test('formats DMS coordinates', () { + expect( + formatCoordinates( + 46.0569, + 14.5058, + CoordinateDisplayFormat.dms, + ), + '46°03\'24.84"N 14°30\'20.88"E', + ); + }); + }); +}