mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
@@ -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) {
|
String formatPlusCode(double lat, double lon) {
|
||||||
const base = '23456789CFGHJMPQRVWX';
|
const base = '23456789CFGHJMPQRVWX';
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:latlong2/latlong.dart';
|
|||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
import '../../../models/contact.dart';
|
import '../../../models/contact.dart';
|
||||||
import '../../../models/sar_marker.dart';
|
import '../../../models/sar_marker.dart';
|
||||||
|
import '../../../utils/location_formats.dart';
|
||||||
import 'compass_math.dart';
|
import 'compass_math.dart';
|
||||||
|
|
||||||
/// Header component for the compass dialog showing compass rose,
|
/// Header component for the compass dialog showing compass rose,
|
||||||
@@ -563,21 +564,7 @@ class _LocationFormatToggle extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _LocationFormatToggleState extends State<_LocationFormatToggle> {
|
class _LocationFormatToggleState extends State<_LocationFormatToggle> {
|
||||||
bool _showDMS = false;
|
CoordinateDisplayFormat _format = CoordinateDisplayFormat.decimal;
|
||||||
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -589,20 +576,24 @@ class _LocationFormatToggleState extends State<_LocationFormatToggle> {
|
|||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final String displayText;
|
final String displayText;
|
||||||
|
|
||||||
if (_showDMS) {
|
if (_format == CoordinateDisplayFormat.decimal) {
|
||||||
displayText =
|
|
||||||
'${_formatDMS(position.latitude, true)} ${_formatDMS(position.longitude, false)}';
|
|
||||||
} else {
|
|
||||||
displayText = l10n.latLonFormat(
|
displayText = l10n.latLonFormat(
|
||||||
position.latitude.toStringAsFixed(5),
|
position.latitude.toStringAsFixed(5),
|
||||||
position.longitude.toStringAsFixed(5),
|
position.longitude.toStringAsFixed(5),
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
displayText = formatCoordinates(
|
||||||
|
position.latitude,
|
||||||
|
position.longitude,
|
||||||
|
_format,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
_showDMS = !_showDMS;
|
_format = CoordinateDisplayFormat.values[
|
||||||
|
(_format.index + 1) % CoordinateDisplayFormat.values.length];
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import '../models/contact.dart';
|
|||||||
import '../models/sar_marker.dart';
|
import '../models/sar_marker.dart';
|
||||||
import '../models/sar_template.dart';
|
import '../models/sar_template.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../utils/location_formats.dart';
|
||||||
|
|
||||||
class MapMarkers {
|
class MapMarkers {
|
||||||
static List<Marker> createTeamMemberMarkers(
|
static List<Marker> createTeamMemberMarkers(
|
||||||
@@ -253,7 +254,11 @@ class MapMarkers {
|
|||||||
if (contact.displayLocation != null) ...[
|
if (contact.displayLocation != null) ...[
|
||||||
_InfoRow(
|
_InfoRow(
|
||||||
'Location',
|
'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)
|
if (contact.telemetry?.batteryMilliVolts != null)
|
||||||
@@ -313,7 +318,11 @@ class MapMarkers {
|
|||||||
children: [
|
children: [
|
||||||
_InfoRow(
|
_InfoRow(
|
||||||
'Location',
|
'Location',
|
||||||
'${marker.location.latitude.toStringAsFixed(6)}, ${marker.location.longitude.toStringAsFixed(6)}',
|
formatCoordinates(
|
||||||
|
marker.location.latitude,
|
||||||
|
marker.location.longitude,
|
||||||
|
CoordinateDisplayFormat.utm,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_InfoRow('Reported', marker.timeAgo),
|
_InfoRow('Reported', marker.timeAgo),
|
||||||
if (marker.senderName != null)
|
if (marker.senderName != null)
|
||||||
|
|||||||
31
test/utils/location_formats_test.dart
Normal file
31
test/utils/location_formats_test.dart
Normal file
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user