From a8f387b0da71f4da2ca12c71b77cf0cb0fbe7074 Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Wed, 14 Jan 2026 19:28:08 -0800 Subject: [PATCH 1/3] Fix map centering weirdly When nodes or markers are outside of the main area of interest. --- lib/screens/map_screen.dart | 66 +++++++++++++++++++++++++++++-------- 1 file changed, 52 insertions(+), 14 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index bdc96c8..1a6b527 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; +import 'dart:math'; import '../connector/meshcore_connector.dart'; import '../l10n/l10n.dart'; @@ -70,6 +71,25 @@ class _MapScreenState extends State { }); } + double _standardDeviation(List values) { + if (values.length <= 1) { + return 0.0; + } + + final mean = values.reduce((a, b) => a + b) / values.length; + + double sumSquaredDiff = 0.0; + for (final value in values) { + final diff = value - mean; + sumSquaredDiff += diff * diff; + } + + // Sample standard deviation (n-1) — most appropriate here + final variance = sumSquaredDiff / (values.length - 1); + + return sqrt(variance); + } + @override Widget build(BuildContext context) { return Consumer2( @@ -116,19 +136,37 @@ class _MapScreenState extends State { _isSelectingPoi || highlightPosition != null; if (contactsWithLocation.isNotEmpty || sharedMarkers.isNotEmpty) { - double avgLat = contactsWithLocation - .map((c) => c.latitude!) - .fold(0, (sum, lat) => sum + lat); - double avgLon = contactsWithLocation - .map((c) => c.longitude!) - .fold(0, (sum, lon) => sum + lon); - for (final marker in sharedMarkers) { - avgLat += marker.position.latitude; - avgLon += marker.position.longitude; - } - final total = contactsWithLocation.length + sharedMarkers.length; - if (total > 0) { - center = LatLng(avgLat / total, avgLon / total); + double avgLat = 0.0; + double avgLon = 0.0; + final allPoints = [ + ...contactsWithLocation.map((c) => LatLng(c.latitude!, c.longitude!)), + ...sharedMarkers.map((m) => m.position), + ]; + if (allPoints.length >= 3) { + final latValues = allPoints.map((p) => p.latitude).toList(); + final lonValues = allPoints.map((p) => p.longitude).toList(); + + final latStdDev = _standardDeviation(latValues); + final lonStdDev = _standardDeviation(lonValues); + final filteredLatValues = latValues + .where((lat) => (lat - (latValues.reduce((a, b) => a + b) / latValues.length)).abs() <= latStdDev * 2) + .toList(); + final filteredLonValues = lonValues + .where((lon) => (lon - (lonValues.reduce((a, b) => a + b) / lonValues.length)).abs() <= lonStdDev * 2) + .toList(); + center = LatLng( + filteredLatValues.reduce((a, b) => a + b) / filteredLatValues.length, + filteredLonValues.reduce((a, b) => a + b) / filteredLonValues.length, + ); + } else { + for (final point in allPoints) { + avgLat += point.latitude; + avgLon += point.longitude; + } + center = LatLng( + avgLat / allPoints.length, + avgLon / allPoints.length, + ); } } if (highlightPosition != null) { @@ -169,7 +207,7 @@ class _MapScreenState extends State { mapController: _mapController, options: MapOptions( initialCenter: center, - initialZoom: 13.0, + initialZoom: 10.0, minZoom: 2.0, maxZoom: 18.0, onTap: (_, latLng) { From a6b2756d0df9e83bb47f20cf331735f99b07edbe Mon Sep 17 00:00:00 2001 From: zjs81 Date: Thu, 15 Jan 2026 19:11:13 -0700 Subject: [PATCH 2/3] Ran flutter format on the file --- lib/screens/map_screen.dart | 323 ++++++++++++++++++++++-------------- 1 file changed, 202 insertions(+), 121 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 1a6b527..42ecc76 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -100,10 +100,12 @@ class _MapScreenState extends State { final highlightPosition = widget.highlightPosition; final sharedMarkers = settings.mapShowMarkers ? _collectSharedMarkers(connector) - .where((marker) => - !_hiddenMarkerIds.contains(marker.id) && - !_removedMarkerIds.contains(marker.id)) - .toList() + .where( + (marker) => + !_hiddenMarkerIds.contains(marker.id) && + !_removedMarkerIds.contains(marker.id), + ) + .toList() : <_SharedMarker>[]; // Filter by time @@ -111,16 +113,18 @@ class _MapScreenState extends State { final filteredByTime = settings.mapTimeFilterHours == 0 ? contacts : contacts.where((c) { - final hoursSinceLastSeen = - now.difference(c.lastSeen).inHours; + final hoursSinceLastSeen = now.difference(c.lastSeen).inHours; return hoursSinceLastSeen <= settings.mapTimeFilterHours; }).toList(); // Filter by key prefix final keyPrefix = settings.mapKeyPrefix.trim(); - final filteredByKeyPrefix = (settings.mapKeyPrefixEnabled && keyPrefix.isNotEmpty) + final filteredByKeyPrefix = + (settings.mapKeyPrefixEnabled && keyPrefix.isNotEmpty) ? filteredByTime.where((c) { - return c.publicKeyHex.toLowerCase().startsWith(keyPrefix.toLowerCase()); + return c.publicKeyHex.toLowerCase().startsWith( + keyPrefix.toLowerCase(), + ); }).toList() : filteredByTime; @@ -131,7 +135,8 @@ class _MapScreenState extends State { // Calculate center of all nodes, or default to (0, 0) LatLng center = const LatLng(0, 0); - final hasMapContent = contactsWithLocation.isNotEmpty || + final hasMapContent = + contactsWithLocation.isNotEmpty || sharedMarkers.isNotEmpty || _isSelectingPoi || highlightPosition != null; @@ -139,24 +144,42 @@ class _MapScreenState extends State { double avgLat = 0.0; double avgLon = 0.0; final allPoints = [ - ...contactsWithLocation.map((c) => LatLng(c.latitude!, c.longitude!)), + ...contactsWithLocation.map( + (c) => LatLng(c.latitude!, c.longitude!), + ), ...sharedMarkers.map((m) => m.position), ]; if (allPoints.length >= 3) { final latValues = allPoints.map((p) => p.latitude).toList(); final lonValues = allPoints.map((p) => p.longitude).toList(); - + final latStdDev = _standardDeviation(latValues); final lonStdDev = _standardDeviation(lonValues); final filteredLatValues = latValues - .where((lat) => (lat - (latValues.reduce((a, b) => a + b) / latValues.length)).abs() <= latStdDev * 2) + .where( + (lat) => + (lat - + (latValues.reduce((a, b) => a + b) / + latValues.length)) + .abs() <= + latStdDev * 2, + ) .toList(); final filteredLonValues = lonValues - .where((lon) => (lon - (lonValues.reduce((a, b) => a + b) / lonValues.length)).abs() <= lonStdDev * 2) + .where( + (lon) => + (lon - + (lonValues.reduce((a, b) => a + b) / + lonValues.length)) + .abs() <= + lonStdDev * 2, + ) .toList(); center = LatLng( - filteredLatValues.reduce((a, b) => a + b) / filteredLatValues.length, - filteredLonValues.reduce((a, b) => a + b) / filteredLonValues.length, + filteredLatValues.reduce((a, b) => a + b) / + filteredLatValues.length, + filteredLonValues.reduce((a, b) => a + b) / + filteredLonValues.length, ); } else { for (final point in allPoints) { @@ -194,7 +217,9 @@ class _MapScreenState extends State { tooltip: context.l10n.common_settings, onPressed: () => Navigator.push( context, - MaterialPageRoute(builder: (context) => const SettingsScreen()), + MaterialPageRoute( + builder: (context) => const SettingsScreen(), + ), ), ), ], @@ -272,14 +297,18 @@ class _MapScreenState extends State { ), ], ), - _buildLegend(contactsWithLocation.length, sharedMarkers.length), + _buildLegend( + contactsWithLocation.length, + sharedMarkers.length, + ), ], ), bottomNavigationBar: SafeArea( top: false, child: QuickSwitchBar( selectedIndex: 2, - onDestinationSelected: (index) => _handleQuickSwitch(index, context), + onDestinationSelected: (index) => + _handleQuickSwitch(index, context), ), ), floatingActionButton: FloatingActionButton( @@ -297,27 +326,17 @@ class _MapScreenState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - Icons.location_off, - size: 64, - color: Colors.grey[400], - ), + Icon(Icons.location_off, size: 64, color: Colors.grey[400]), const SizedBox(height: 16), Text( context.l10n.map_noNodesWithLocation, - style: TextStyle( - fontSize: 18, - color: Colors.grey[600], - ), + style: TextStyle(fontSize: 18, color: Colors.grey[600]), ), const SizedBox(height: 8), Text( context.l10n.map_nodesNeedGps, textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - color: Colors.grey[500], - ), + style: TextStyle(fontSize: 14, color: Colors.grey[500]), ), ], ), @@ -331,7 +350,8 @@ class _MapScreenState extends State { if (!contact.hasLocation) continue; // Apply node type filters - if (contact.type == advTypeRepeater && !settings.mapShowRepeaters) continue; + if (contact.type == advTypeRepeater && !settings.mapShowRepeaters) + continue; if (contact.type == advTypeChat && !settings.mapShowChatNodes) continue; if (contact.type != advTypeChat && contact.type != advTypeRepeater && @@ -434,13 +454,37 @@ class _MapScreenState extends State { ), ), const SizedBox(height: 8), - _buildLegendItem(Icons.person, context.l10n.map_chat, Colors.blue), - _buildLegendItem(Icons.router, context.l10n.map_repeater, Colors.green), - _buildLegendItem(Icons.meeting_room, context.l10n.map_room, Colors.purple), - _buildLegendItem(Icons.sensors, context.l10n.map_sensor, Colors.orange), + _buildLegendItem( + Icons.person, + context.l10n.map_chat, + Colors.blue, + ), + _buildLegendItem( + Icons.router, + context.l10n.map_repeater, + Colors.green, + ), + _buildLegendItem( + Icons.meeting_room, + context.l10n.map_room, + Colors.purple, + ), + _buildLegendItem( + Icons.sensors, + context.l10n.map_sensor, + Colors.orange, + ), _buildLegendItem(Icons.flag, context.l10n.map_pinDm, Colors.blue), - _buildLegendItem(Icons.flag, context.l10n.map_pinPrivate, Colors.purple), - _buildLegendItem(Icons.flag, context.l10n.map_pinPublic, Colors.orange), + _buildLegendItem( + Icons.flag, + context.l10n.map_pinPrivate, + Colors.purple, + ), + _buildLegendItem( + Icons.flag, + context.l10n.map_pinPublic, + Colors.orange, + ), ], ), ), @@ -456,10 +500,7 @@ class _MapScreenState extends State { children: [ Icon(icon, size: 16, color: color), const SizedBox(width: 8), - Text( - label, - style: const TextStyle(fontSize: 12), - ), + Text(label, style: const TextStyle(fontSize: 12)), ], ), ); @@ -513,7 +554,9 @@ class _MapScreenState extends State { label: payload.label, flags: payload.flags, fromName: message.senderName, - sourceLabel: channel.name.isEmpty ? 'Channel ${channel.index}' : channel.name, + sourceLabel: channel.name.isEmpty + ? 'Channel ${channel.index}' + : channel.name, isChannel: true, isPublicChannel: isPublic, ), @@ -579,11 +622,7 @@ class _MapScreenState extends State { ), ], ), - child: const Icon( - Icons.flag, - color: Colors.white, - size: 20, - ), + child: const Icon(Icons.flag, color: Colors.white, size: 20), ), ], ), @@ -601,10 +640,8 @@ class _MapScreenState extends State { Navigator.push( context, MaterialPageRoute( - builder: (context) => RepeaterHubScreen( - repeater: repeater, - password: password, - ), + builder: (context) => + RepeaterHubScreen(repeater: repeater, password: password), ), ); }, @@ -622,9 +659,7 @@ class _MapScreenState extends State { context.read().markContactRead(room.publicKeyHex); Navigator.push( context, - MaterialPageRoute( - builder: (context) => ChatScreen(contact: room), - ), + MaterialPageRoute(builder: (context) => ChatScreen(contact: room)), ); }, ), @@ -651,9 +686,14 @@ class _MapScreenState extends State { children: [ _buildInfoRow('Type', contact.typeLabel), _buildInfoRow('Path', contact.pathLabel), - _buildInfoRow('Location', - '${contact.latitude!.toStringAsFixed(6)}, ${contact.longitude!.toStringAsFixed(6)}'), - _buildInfoRow(context.l10n.map_lastSeen, _formatLastSeen(contact.lastSeen)), + _buildInfoRow( + 'Location', + '${contact.latitude!.toStringAsFixed(6)}, ${contact.longitude!.toStringAsFixed(6)}', + ), + _buildInfoRow( + context.l10n.map_lastSeen, + _formatLastSeen(contact.lastSeen), + ), _buildInfoRow('Public Key', contact.publicKeyHex), ], ), @@ -662,7 +702,8 @@ class _MapScreenState extends State { onPressed: () => Navigator.pop(dialogContext), child: Text(context.l10n.common_close), ), - if (contact.type == advTypeChat) // Only show chat button for chat nodes + if (contact.type == + advTypeChat) // Only show chat button for chat nodes TextButton( onPressed: () { Navigator.pop(dialogContext); @@ -675,22 +716,22 @@ class _MapScreenState extends State { }, child: Text(context.l10n.contacts_openChat), ), - if (contact.type == advTypeRepeater) - TextButton( - onPressed: () { - Navigator.pop(dialogContext); - _showRepeaterLogin(context, contact); - }, - child: Text(context.l10n.map_manageRepeater), - ), - if (contact.type == advTypeRoom) - TextButton( - onPressed: () { - Navigator.pop(dialogContext); - _showRoomLogin(context, contact); - }, - child: Text(context.l10n.map_joinRoom), - ), + if (contact.type == advTypeRepeater) + TextButton( + onPressed: () { + Navigator.pop(dialogContext); + _showRepeaterLogin(context, contact); + }, + child: Text(context.l10n.map_manageRepeater), + ), + if (contact.type == advTypeRoom) + TextButton( + onPressed: () { + Navigator.pop(dialogContext); + _showRoomLogin(context, contact); + }, + child: Text(context.l10n.map_joinRoom), + ), ], ), ); @@ -702,17 +743,13 @@ class _MapScreenState extends State { case 0: Navigator.pushReplacement( context, - buildQuickSwitchRoute( - const ContactsScreen(hideBackButton: true), - ), + buildQuickSwitchRoute(const ContactsScreen(hideBackButton: true)), ); break; case 1: Navigator.pushReplacement( context, - buildQuickSwitchRoute( - const ChannelsScreen(hideBackButton: true), - ), + buildQuickSwitchRoute(const ChannelsScreen(hideBackButton: true)), ); break; } @@ -760,7 +797,8 @@ class _MapScreenState extends State { 'Location', '${marker.position.latitude.toStringAsFixed(6)}, ${marker.position.longitude.toStringAsFixed(6)}', ), - if (marker.flags.isNotEmpty) _buildInfoRow(context.l10n.map_flags, marker.flags), + if (marker.flags.isNotEmpty) + _buildInfoRow(context.l10n.map_flags, marker.flags), ], ), actions: [ @@ -810,10 +848,7 @@ class _MapScreenState extends State { ), ), const SizedBox(height: 2), - Text( - value, - style: const TextStyle(fontSize: 14), - ), + Text(value, style: const TextStyle(fontSize: 14)), ], ), ); @@ -898,7 +933,10 @@ class _MapScreenState extends State { ); } - Future _promptForLabel(BuildContext context, String defaultLabel) async { + Future _promptForLabel( + BuildContext context, + String defaultLabel, + ) async { final controller = TextEditingController(text: defaultLabel); return showDialog( context: context, @@ -919,7 +957,10 @@ class _MapScreenState extends State { TextButton( onPressed: () { final label = controller.text.trim().replaceAll('|', '/'); - Navigator.pop(dialogContext, label.isEmpty ? defaultLabel : label); + Navigator.pop( + dialogContext, + label.isEmpty ? defaultLabel : label, + ); }, child: Text(context.l10n.common_continue), ), @@ -951,8 +992,11 @@ class _MapScreenState extends State { return Consumer( builder: (consumerContext, liveConnector, child) { final allContacts = liveConnector.contacts - .where((contact) => - contact.type != advTypeRepeater && contact.type != advTypeRoom) + .where( + (contact) => + contact.type != advTypeRepeater && + contact.type != advTypeRoom, + ) .toList(); return SafeArea( child: SingleChildScrollView( @@ -962,7 +1006,10 @@ class _MapScreenState extends State { children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), - child: Text(context.l10n.map_sendToContact, style: const TextStyle(fontWeight: FontWeight.bold)), + child: Text( + context.l10n.map_sendToContact, + style: const TextStyle(fontWeight: FontWeight.bold), + ), ), Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), @@ -973,7 +1020,10 @@ class _MapScreenState extends State { border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), ), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), ), onChanged: (value) { setSheetState(() { @@ -983,50 +1033,73 @@ class _MapScreenState extends State { ), ), ...allContacts - .where((contact) => - query.isEmpty || matchesContactQuery(contact, query)) + .where( + (contact) => + query.isEmpty || + matchesContactQuery(contact, query), + ) .map((contact) { - return ListTile( - leading: const Icon(Icons.person), - title: Text(contact.name), - onTap: () { - Navigator.pop(sheetContext); - liveConnector.sendMessage(contact, markerText); - }, - ); - }), + return ListTile( + leading: const Icon(Icons.person), + title: Text(contact.name), + onTap: () { + Navigator.pop(sheetContext); + liveConnector.sendMessage(contact, markerText); + }, + ); + }), Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), - child: Text(context.l10n.map_sendToChannel, style: const TextStyle(fontWeight: FontWeight.bold)), + child: Text( + context.l10n.map_sendToChannel, + style: const TextStyle(fontWeight: FontWeight.bold), + ), ), if (liveConnector.isLoadingChannels) const Padding( - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), child: LinearProgressIndicator(), ) - else if (liveConnector.channels.where((c) => !c.isEmpty).isEmpty) + else if (liveConnector.channels + .where((c) => !c.isEmpty) + .isEmpty) Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), child: Text(context.l10n.map_noChannelsAvailable), ) else - ...liveConnector.channels.where((c) => !c.isEmpty).map((channel) { + ...liveConnector.channels.where((c) => !c.isEmpty).map(( + channel, + ) { final isPublic = _isPublicChannel(channel); - final label = channel.name.isEmpty ? 'Channel ${channel.index}' : channel.name; + final label = channel.name.isEmpty + ? 'Channel ${channel.index}' + : channel.name; return ListTile( leading: Icon( isPublic ? Icons.public : Icons.tag, color: isPublic ? Colors.orange : Colors.blue, ), title: Text(label), - subtitle: isPublic ? Text(context.l10n.channels_publicChannel) : null, + subtitle: isPublic + ? Text(context.l10n.channels_publicChannel) + : null, onTap: () async { Navigator.pop(sheetContext); final canSend = isPublic ? await _confirmPublicShare(context, label) : true; if (canSend) { - liveConnector.sendChannelMessage(channel, markerText); + liveConnector.sendChannelMessage( + channel, + markerText, + ); } }, ); @@ -1046,12 +1119,17 @@ class _MapScreenState extends State { return channel.isPublicChannel; } - Future _confirmPublicShare(BuildContext context, String channelLabel) async { + Future _confirmPublicShare( + BuildContext context, + String channelLabel, + ) async { final result = await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: Text(context.l10n.map_publicLocationShare), - content: Text(context.l10n.map_publicLocationShareConfirm(channelLabel)), + content: Text( + context.l10n.map_publicLocationShareConfirm(channelLabel), + ), actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext, false), @@ -1067,7 +1145,10 @@ class _MapScreenState extends State { return result ?? false; } - void _showFilterDialog(BuildContext context, AppSettingsService settingsService) { + void _showFilterDialog( + BuildContext context, + AppSettingsService settingsService, + ) { showDialog( context: context, builder: (dialogContext) => AlertDialog( @@ -1171,10 +1252,7 @@ class _MapScreenState extends State { const SizedBox(height: 8), Text( _getTimeFilterLabel(settings.mapTimeFilterHours), - style: TextStyle( - fontSize: 14, - color: Colors.grey[700], - ), + style: TextStyle(fontSize: 14, color: Colors.grey[700]), ), Slider( value: _hoursToSliderValue(settings.mapTimeFilterHours), @@ -1214,11 +1292,14 @@ class _MapScreenState extends State { if (hours <= 24) { return (hours / 24) * 40; - } else if (hours <= 168) { // 7 days + } else if (hours <= 168) { + // 7 days return 40 + ((hours - 24) / (168 - 24)) * 20; - } else if (hours <= 720) { // 30 days + } else if (hours <= 720) { + // 30 days return 60 + ((hours - 168) / (720 - 168)) * 20; - } else if (hours <= 4380) { // 6 months + } else if (hours <= 4380) { + // 6 months return 80 + ((hours - 720) / (4380 - 720)) * 19; } else { return 100; From 7cc7183e0cee1d4747719ef98297c566964f3eea Mon Sep 17 00:00:00 2001 From: zjs81 Date: Thu, 15 Jan 2026 19:15:42 -0700 Subject: [PATCH 3/3] Refactor map initialization and zoom calculation logic in MapScreen --- lib/screens/map_screen.dart | 94 ++++++++++++++++++++++++++----------- 1 file changed, 66 insertions(+), 28 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 42ecc76..12e4470 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -1,8 +1,9 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; -import 'dart:math'; import '../connector/meshcore_connector.dart'; import '../l10n/l10n.dart'; @@ -48,6 +49,8 @@ class _MapScreenState extends State { final Set _hiddenMarkerIds = {}; Set _removedMarkerIds = {}; bool _isSelectingPoi = false; + bool _hasInitializedMap = false; + bool _removedMarkersLoaded = false; @override void initState() { @@ -68,6 +71,7 @@ class _MapScreenState extends State { if (!mounted) return; setState(() { _removedMarkerIds = ids; + _removedMarkersLoaded = true; }); } @@ -90,6 +94,16 @@ class _MapScreenState extends State { return sqrt(variance); } + // Calculate zoom level based on the spread of points (std deviation in degrees) + double _zoomFromStdDev(double latStdDev, double lonStdDev) { + final maxSpread = max(latStdDev, lonStdDev); + if (maxSpread <= 0) return 13.0; + // Approzimate: each zoom level halves the visible area + // ~0.01 degrees spread -> zoom 13, ~0.1 -> zoom 10, ~1.0 -> zoom 7 + final zoom = 10.0 - log(maxSpread * 10 + 1) / ln10 * 3; + return zoom.clamp(4.0, 15.0); + } + @override Widget build(BuildContext context) { return Consumer2( @@ -133,16 +147,15 @@ class _MapScreenState extends State { .where((c) => c.hasLocation) .toList(); - // Calculate center of all nodes, or default to (0, 0) + // Calculate center and zoom of all nodes, or default to (0, 0) LatLng center = const LatLng(0, 0); + double initialZoom = 10.0; final hasMapContent = contactsWithLocation.isNotEmpty || sharedMarkers.isNotEmpty || _isSelectingPoi || highlightPosition != null; if (contactsWithLocation.isNotEmpty || sharedMarkers.isNotEmpty) { - double avgLat = 0.0; - double avgLon = 0.0; final allPoints = [ ...contactsWithLocation.map( (c) => LatLng(c.latitude!, c.longitude!), @@ -153,35 +166,48 @@ class _MapScreenState extends State { final latValues = allPoints.map((p) => p.latitude).toList(); final lonValues = allPoints.map((p) => p.longitude).toList(); + final meanLat = + latValues.reduce((a, b) => a + b) / latValues.length; + final meanLon = + lonValues.reduce((a, b) => a + b) / lonValues.length; final latStdDev = _standardDeviation(latValues); final lonStdDev = _standardDeviation(lonValues); - final filteredLatValues = latValues + + final filteredPoints = allPoints .where( - (lat) => - (lat - - (latValues.reduce((a, b) => a + b) / - latValues.length)) - .abs() <= - latStdDev * 2, + (p) => + (p.latitude - meanLat).abs() <= latStdDev * 2 && + (p.longitude - meanLon).abs() <= lonStdDev * 2, ) .toList(); - final filteredLonValues = lonValues - .where( - (lon) => - (lon - - (lonValues.reduce((a, b) => a + b) / - lonValues.length)) - .abs() <= - lonStdDev * 2, - ) - .toList(); - center = LatLng( - filteredLatValues.reduce((a, b) => a + b) / - filteredLatValues.length, - filteredLonValues.reduce((a, b) => a + b) / - filteredLonValues.length, - ); + + if (filteredPoints.isNotEmpty) { + final filteredLatValues = filteredPoints + .map((p) => p.latitude) + .toList(); + final filteredLonValues = filteredPoints + .map((p) => p.longitude) + .toList(); + final avgLat = filteredLatValues.reduce((a, b) => a + b); + final avgLon = filteredLonValues.reduce((a, b) => a + b); + center = LatLng( + avgLat / filteredPoints.length, + avgLon / filteredPoints.length, + ); + // Use std deviation of filtered points for zoom + final filteredLatStdDev = _standardDeviation(filteredLatValues); + final filteredLonStdDev = _standardDeviation(filteredLonValues); + initialZoom = _zoomFromStdDev( + filteredLatStdDev, + filteredLonStdDev, + ); + } else { + center = LatLng(meanLat, meanLon); + initialZoom = _zoomFromStdDev(latStdDev, lonStdDev); + } } else { + double avgLat = 0.0; + double avgLon = 0.0; for (final point in allPoints) { avgLat += point.latitude; avgLon += point.longitude; @@ -190,10 +216,22 @@ class _MapScreenState extends State { avgLat / allPoints.length, avgLon / allPoints.length, ); + initialZoom = 12.0; } } if (highlightPosition != null) { center = highlightPosition; + initialZoom = widget.highlightZoom; + } + + // Re center map after removed markers have loaded + if (!_hasInitializedMap && _removedMarkersLoaded && hasMapContent) { + _hasInitializedMap = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _mapController.move(center, initialZoom); + } + }); } final allowBack = !connector.isConnected; @@ -232,7 +270,7 @@ class _MapScreenState extends State { mapController: _mapController, options: MapOptions( initialCenter: center, - initialZoom: 10.0, + initialZoom: initialZoom, minZoom: 2.0, maxZoom: 18.0, onTap: (_, latLng) {