|
|
|
|
@ -15,6 +15,7 @@ import '../models/app_settings.dart';
|
|
|
|
|
import '../models/channel.dart';
|
|
|
|
|
import '../models/contact.dart';
|
|
|
|
|
import '../services/app_settings_service.dart';
|
|
|
|
|
import '../services/path_history_service.dart';
|
|
|
|
|
import '../services/map_marker_service.dart';
|
|
|
|
|
import '../services/map_tile_cache_service.dart';
|
|
|
|
|
import '../utils/contact_search.dart';
|
|
|
|
|
@ -64,6 +65,8 @@ class _MapScreenState extends State<MapScreen> {
|
|
|
|
|
final List<Polyline> _polylines = [];
|
|
|
|
|
bool _legendExpanded = false;
|
|
|
|
|
bool _showNodeLabels = true;
|
|
|
|
|
List<_GuessedLocation> _cachedGuessedLocations = [];
|
|
|
|
|
String _guessedLocationsCacheKey = '';
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void initState() {
|
|
|
|
|
@ -119,8 +122,8 @@ class _MapScreenState extends State<MapScreen> {
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
return Consumer2<MeshCoreConnector, AppSettingsService>(
|
|
|
|
|
builder: (context, connector, settingsService, child) {
|
|
|
|
|
return Consumer3<MeshCoreConnector, AppSettingsService, PathHistoryService>(
|
|
|
|
|
builder: (context, connector, settingsService, pathHistory, child) {
|
|
|
|
|
final tileCache = context.read<MapTileCacheService>();
|
|
|
|
|
final settings = settingsService.settings;
|
|
|
|
|
final contacts = connector.contacts;
|
|
|
|
|
@ -160,6 +163,40 @@ class _MapScreenState extends State<MapScreen> {
|
|
|
|
|
.where((c) => c.hasLocation)
|
|
|
|
|
.toList();
|
|
|
|
|
|
|
|
|
|
// All contacts with a known location — used as anchors regardless of
|
|
|
|
|
// time/key-prefix filters so that repeaters are always available.
|
|
|
|
|
final allContactsWithLocation = contacts
|
|
|
|
|
.where((c) => c.hasLocation)
|
|
|
|
|
.toList();
|
|
|
|
|
|
|
|
|
|
// Compute guessed locations with caching
|
|
|
|
|
final maxRangeKm = _estimateLoRaRangeKm(connector);
|
|
|
|
|
final filteredKeys = filteredByKeyPrefix
|
|
|
|
|
.map((c) => '${c.publicKeyHex}:${c.path.join("-")}')
|
|
|
|
|
.join(',');
|
|
|
|
|
final anchorKeys = allContactsWithLocation
|
|
|
|
|
.map(
|
|
|
|
|
(c) =>
|
|
|
|
|
'${c.publicKeyHex}:${c.latitude}:${c.longitude}:${c.path.isNotEmpty ? c.path.last : ""}',
|
|
|
|
|
)
|
|
|
|
|
.join(',');
|
|
|
|
|
final cacheKey =
|
|
|
|
|
'$filteredKeys|$anchorKeys|${pathHistory.version}:${connector.currentSf}:${connector.currentBwHz}:${connector.currentTxPower}:${settings.mapShowGuessedLocations}';
|
|
|
|
|
if (cacheKey != _guessedLocationsCacheKey) {
|
|
|
|
|
_guessedLocationsCacheKey = cacheKey;
|
|
|
|
|
_cachedGuessedLocations = settings.mapShowGuessedLocations
|
|
|
|
|
? _computeGuessedLocations(
|
|
|
|
|
filteredByKeyPrefix,
|
|
|
|
|
allContactsWithLocation,
|
|
|
|
|
pathHistory,
|
|
|
|
|
maxRangeKm,
|
|
|
|
|
)
|
|
|
|
|
: [];
|
|
|
|
|
}
|
|
|
|
|
final guessedLocations = settings.mapShowGuessedLocations
|
|
|
|
|
? _cachedGuessedLocations
|
|
|
|
|
: <_GuessedLocation>[];
|
|
|
|
|
|
|
|
|
|
_polylines.clear();
|
|
|
|
|
_polylines.addAll(
|
|
|
|
|
_points.length > 1
|
|
|
|
|
@ -430,6 +467,8 @@ class _MapScreenState extends State<MapScreen> {
|
|
|
|
|
size: 34,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
if (!_isBuildingPathTrace)
|
|
|
|
|
...guessedLocations.map(_buildGuessedMarker),
|
|
|
|
|
..._buildMarkers(
|
|
|
|
|
contactsWithLocation,
|
|
|
|
|
settings,
|
|
|
|
|
@ -489,6 +528,7 @@ class _MapScreenState extends State<MapScreen> {
|
|
|
|
|
contactsWithLocation,
|
|
|
|
|
settings,
|
|
|
|
|
sharedMarkers.length,
|
|
|
|
|
guessedLocations.length,
|
|
|
|
|
),
|
|
|
|
|
if (_isBuildingPathTrace) _buildPathTraceOverlay(),
|
|
|
|
|
],
|
|
|
|
|
@ -512,6 +552,200 @@ class _MapScreenState extends State<MapScreen> {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
List<_GuessedLocation> _computeGuessedLocations(
|
|
|
|
|
List<Contact> allContacts,
|
|
|
|
|
List<Contact> withLocation,
|
|
|
|
|
PathHistoryService pathHistory,
|
|
|
|
|
double? maxRangeKm,
|
|
|
|
|
) {
|
|
|
|
|
// Index known-location repeaters by their 1-byte hash.
|
|
|
|
|
// null value = two repeaters share the same hash byte (ambiguous collision).
|
|
|
|
|
final repeaterByHash = <int, Contact?>{};
|
|
|
|
|
for (final c in withLocation) {
|
|
|
|
|
if (c.type == advTypeRepeater) {
|
|
|
|
|
if (repeaterByHash.containsKey(c.publicKey[0])) {
|
|
|
|
|
repeaterByHash[c.publicKey[0]] =
|
|
|
|
|
null; // collision: can't disambiguate
|
|
|
|
|
} else {
|
|
|
|
|
repeaterByHash[c.publicKey[0]] = c;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final result = <_GuessedLocation>[];
|
|
|
|
|
|
|
|
|
|
for (final contact in allContacts) {
|
|
|
|
|
if (contact.hasLocation) continue;
|
|
|
|
|
|
|
|
|
|
final anchorSet = <LatLng>{};
|
|
|
|
|
|
|
|
|
|
// Collect the contact-side (last-hop) repeater from every known path.
|
|
|
|
|
// path = [device-side hop, ..., contact-side hop]
|
|
|
|
|
// Only path.last is actually within radio range of the contact — using
|
|
|
|
|
// earlier bytes would anchor against our own side of the network.
|
|
|
|
|
final pathSets = <List<int>>[
|
|
|
|
|
contact.path.toList(),
|
|
|
|
|
...pathHistory
|
|
|
|
|
.getRecentPaths(contact.publicKeyHex)
|
|
|
|
|
.map((r) => r.pathBytes),
|
|
|
|
|
];
|
|
|
|
|
final lastHopBytes = <int>{};
|
|
|
|
|
for (final pathBytes in pathSets) {
|
|
|
|
|
if (pathBytes.isEmpty) continue;
|
|
|
|
|
final lastHop = pathBytes.last;
|
|
|
|
|
lastHopBytes.add(lastHop);
|
|
|
|
|
final r = repeaterByHash[lastHop];
|
|
|
|
|
if (r != null) anchorSet.add(LatLng(r.latitude!, r.longitude!));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fallback: for any last-hop byte with no GPS repeater, average the
|
|
|
|
|
// positions of contacts with known GPS that share the same last hop.
|
|
|
|
|
// Those contacts are all adjacent to the same unknown repeater, so their
|
|
|
|
|
// centroid is a reasonable proxy for its location.
|
|
|
|
|
for (final byte in lastHopBytes) {
|
|
|
|
|
if (repeaterByHash.containsKey(byte)) continue;
|
|
|
|
|
for (final c in withLocation) {
|
|
|
|
|
if (c.path.isNotEmpty && c.path.last == byte) {
|
|
|
|
|
anchorSet.add(LatLng(c.latitude!, c.longitude!));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Filter anchors that are geometrically inconsistent with radio range.
|
|
|
|
|
// Two anchors more than 2 * maxRange apart cannot both be in direct radio
|
|
|
|
|
// range of the same node, so isolated outliers are removed.
|
|
|
|
|
final anchors = maxRangeKm != null && anchorSet.length > 1
|
|
|
|
|
? _filterConsistentAnchors(anchorSet.toList(), maxRangeKm)
|
|
|
|
|
: anchorSet.toList();
|
|
|
|
|
|
|
|
|
|
if (anchors.isEmpty) continue;
|
|
|
|
|
|
|
|
|
|
final LatLng position;
|
|
|
|
|
if (anchors.length == 1) {
|
|
|
|
|
// Offset single-anchor guesses so they don't overlap the repeater marker.
|
|
|
|
|
// Use the contact's public key byte as a deterministic angle seed.
|
|
|
|
|
const offsetDeg = 0.003; // ~330 m at the equator
|
|
|
|
|
final angle = (contact.publicKey[1] / 255.0) * 2 * pi;
|
|
|
|
|
position = LatLng(
|
|
|
|
|
anchors[0].latitude + offsetDeg * cos(angle),
|
|
|
|
|
anchors[0].longitude + offsetDeg * sin(angle),
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
double lat = 0, lon = 0;
|
|
|
|
|
for (final a in anchors) {
|
|
|
|
|
lat += a.latitude;
|
|
|
|
|
lon += a.longitude;
|
|
|
|
|
}
|
|
|
|
|
position = LatLng(lat / anchors.length, lon / anchors.length);
|
|
|
|
|
}
|
|
|
|
|
result.add(
|
|
|
|
|
_GuessedLocation(
|
|
|
|
|
contact: contact,
|
|
|
|
|
position: position,
|
|
|
|
|
highConfidence: anchors.length >= 2,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Estimates the free-space maximum LoRa range in km from the connected
|
|
|
|
|
/// device's current radio parameters. Returns null if parameters are unknown.
|
|
|
|
|
double? _estimateLoRaRangeKm(MeshCoreConnector connector) {
|
|
|
|
|
final freqHz = connector.currentFreqHz;
|
|
|
|
|
final bwHz = connector.currentBwHz;
|
|
|
|
|
final sf = connector.currentSf;
|
|
|
|
|
final txPower = connector.currentTxPower;
|
|
|
|
|
if (freqHz == null || bwHz == null || sf == null || txPower == null) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
// LoRa receiver sensitivity = thermal noise + NF + required demod SNR
|
|
|
|
|
const noiseFigureDb = 6.0;
|
|
|
|
|
final thermalNoiseDbm = -174.0 + 10 * log(bwHz.toDouble()) / ln10;
|
|
|
|
|
final sensitivityDbm =
|
|
|
|
|
thermalNoiseDbm + noiseFigureDb + _sfToRequiredSnrDb(sf);
|
|
|
|
|
// FSPL at max range equals link budget:
|
|
|
|
|
// FSPL = 20*log10(d_m) + 20*log10(f_hz) - 147.55
|
|
|
|
|
final linkBudgetDb = txPower.toDouble() - sensitivityDbm;
|
|
|
|
|
final exponent =
|
|
|
|
|
(linkBudgetDb + 147.55 - 20 * log(freqHz.toDouble()) / ln10) / 20;
|
|
|
|
|
return pow(10, exponent) / 1000;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
double _sfToRequiredSnrDb(int sf) {
|
|
|
|
|
switch (sf) {
|
|
|
|
|
case 5:
|
|
|
|
|
return -2.5;
|
|
|
|
|
case 6:
|
|
|
|
|
return -5.0;
|
|
|
|
|
case 7:
|
|
|
|
|
return -7.5;
|
|
|
|
|
case 8:
|
|
|
|
|
return -10.0;
|
|
|
|
|
case 9:
|
|
|
|
|
return -12.5;
|
|
|
|
|
case 10:
|
|
|
|
|
return -15.0;
|
|
|
|
|
case 11:
|
|
|
|
|
return -17.5;
|
|
|
|
|
case 12:
|
|
|
|
|
return -20.0;
|
|
|
|
|
default:
|
|
|
|
|
return -10.0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Removes anchors that have no neighbour within 2 * maxRangeKm.
|
|
|
|
|
/// A node cannot be simultaneously in radio range of two points farther apart
|
|
|
|
|
/// than twice the expected maximum range.
|
|
|
|
|
List<LatLng> _filterConsistentAnchors(
|
|
|
|
|
List<LatLng> anchors,
|
|
|
|
|
double maxRangeKm,
|
|
|
|
|
) {
|
|
|
|
|
const distance = Distance();
|
|
|
|
|
final maxDistM = maxRangeKm * 2000;
|
|
|
|
|
return anchors
|
|
|
|
|
.where((a) => anchors.any((b) => b != a && distance(a, b) <= maxDistM))
|
|
|
|
|
.toList();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Marker _buildGuessedMarker(_GuessedLocation guess) {
|
|
|
|
|
final color = _getNodeColor(guess.contact.type);
|
|
|
|
|
return Marker(
|
|
|
|
|
point: guess.position,
|
|
|
|
|
width: 35,
|
|
|
|
|
height: 35,
|
|
|
|
|
child: GestureDetector(
|
|
|
|
|
onTap: () => _showNodeInfo(
|
|
|
|
|
context,
|
|
|
|
|
guess.contact,
|
|
|
|
|
guessedPosition: guess.position,
|
|
|
|
|
),
|
|
|
|
|
child: Container(
|
|
|
|
|
padding: const EdgeInsets.all(4),
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: color.withValues(alpha: guess.highConfidence ? 0.55 : 0.30),
|
|
|
|
|
shape: BoxShape.circle,
|
|
|
|
|
border: Border.all(color: Colors.white, width: 2),
|
|
|
|
|
boxShadow: [
|
|
|
|
|
BoxShadow(
|
|
|
|
|
color: Colors.black.withValues(alpha: 0.3),
|
|
|
|
|
blurRadius: 4,
|
|
|
|
|
offset: const Offset(0, 2),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
child: const Icon(
|
|
|
|
|
Icons.not_listed_location,
|
|
|
|
|
color: Colors.white,
|
|
|
|
|
size: 20,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
List<Marker> _buildMarkers(
|
|
|
|
|
List<Contact> contacts,
|
|
|
|
|
settings, {
|
|
|
|
|
@ -657,6 +891,7 @@ class _MapScreenState extends State<MapScreen> {
|
|
|
|
|
List<Contact> contactsWithLocation,
|
|
|
|
|
settings,
|
|
|
|
|
int markerCount,
|
|
|
|
|
int guessedCount,
|
|
|
|
|
) {
|
|
|
|
|
int nodeCount = 0;
|
|
|
|
|
for (final contact in contactsWithLocation) {
|
|
|
|
|
@ -696,7 +931,12 @@ class _MapScreenState extends State<MapScreen> {
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Text(
|
|
|
|
|
context.l10n.map_nodesCount(nodeCount),
|
|
|
|
|
context.l10n.map_nodesCount(
|
|
|
|
|
nodeCount +
|
|
|
|
|
(settings.mapShowGuessedLocations
|
|
|
|
|
? guessedCount
|
|
|
|
|
: 0),
|
|
|
|
|
),
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
@ -764,6 +1004,12 @@ class _MapScreenState extends State<MapScreen> {
|
|
|
|
|
context.l10n.map_pinPublic,
|
|
|
|
|
Colors.orange,
|
|
|
|
|
),
|
|
|
|
|
if (settings.mapShowGuessedLocations && guessedCount > 0)
|
|
|
|
|
_buildLegendItem(
|
|
|
|
|
Icons.not_listed_location,
|
|
|
|
|
context.l10n.map_guessedLocation,
|
|
|
|
|
Colors.grey,
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
@ -952,7 +1198,11 @@ class _MapScreenState extends State<MapScreen> {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _showNodeInfo(BuildContext context, Contact contact) {
|
|
|
|
|
void _showNodeInfo(
|
|
|
|
|
BuildContext context,
|
|
|
|
|
Contact contact, {
|
|
|
|
|
LatLng? guessedPosition,
|
|
|
|
|
}) {
|
|
|
|
|
showDialog(
|
|
|
|
|
context: context,
|
|
|
|
|
builder: (dialogContext) => AlertDialog(
|
|
|
|
|
@ -972,10 +1222,16 @@ class _MapScreenState extends State<MapScreen> {
|
|
|
|
|
children: [
|
|
|
|
|
_buildInfoRow('Type', contact.typeLabel),
|
|
|
|
|
_buildInfoRow('Path', contact.pathLabel),
|
|
|
|
|
_buildInfoRow(
|
|
|
|
|
'Location',
|
|
|
|
|
'${contact.latitude!.toStringAsFixed(6)}, ${contact.longitude!.toStringAsFixed(6)}',
|
|
|
|
|
),
|
|
|
|
|
if (contact.hasLocation)
|
|
|
|
|
_buildInfoRow(
|
|
|
|
|
'Location',
|
|
|
|
|
'${contact.latitude!.toStringAsFixed(6)}, ${contact.longitude!.toStringAsFixed(6)}',
|
|
|
|
|
)
|
|
|
|
|
else if (guessedPosition != null)
|
|
|
|
|
_buildInfoRow(
|
|
|
|
|
'Est. Location',
|
|
|
|
|
'~${guessedPosition.latitude.toStringAsFixed(6)}, ${guessedPosition.longitude.toStringAsFixed(6)}',
|
|
|
|
|
),
|
|
|
|
|
_buildInfoRow(
|
|
|
|
|
context.l10n.map_lastSeen,
|
|
|
|
|
_formatLastSeen(contact.lastSeen),
|
|
|
|
|
@ -1481,6 +1737,14 @@ class _MapScreenState extends State<MapScreen> {
|
|
|
|
|
},
|
|
|
|
|
contentPadding: EdgeInsets.zero,
|
|
|
|
|
),
|
|
|
|
|
CheckboxListTile(
|
|
|
|
|
title: Text(context.l10n.map_showGuessedLocations),
|
|
|
|
|
value: settings.mapShowGuessedLocations,
|
|
|
|
|
onChanged: (value) {
|
|
|
|
|
service.setMapShowGuessedLocations(value ?? true);
|
|
|
|
|
},
|
|
|
|
|
contentPadding: EdgeInsets.zero,
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
Text(
|
|
|
|
|
context.l10n.map_keyPrefix,
|
|
|
|
|
@ -1744,6 +2008,18 @@ class _MapScreenState extends State<MapScreen> {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _GuessedLocation {
|
|
|
|
|
final Contact contact;
|
|
|
|
|
final LatLng position;
|
|
|
|
|
final bool highConfidence;
|
|
|
|
|
|
|
|
|
|
_GuessedLocation({
|
|
|
|
|
required this.contact,
|
|
|
|
|
required this.position,
|
|
|
|
|
required this.highConfidence,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _MarkerPayload {
|
|
|
|
|
final LatLng position;
|
|
|
|
|
final String label;
|
|
|
|
|
|