From fe401cc04bfccbb04cc645090bbda647469204cb Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Mon, 9 Mar 2026 15:09:37 -0600 Subject: [PATCH] Prioritize closest nodes to viewport center when render limit is active Sort nodes by squared distance from viewport center before applying the render limit, so visible nodes always make the cut instead of arbitrary selection causing gaps that shift as you pan. Also: inject node provider for testability, deduplicate validity filter, and reduce debug log spam to state transitions only. Co-Authored-By: Claude Opus 4.6 --- lib/widgets/map/map_data_manager.dart | 44 +++++-- test/widgets/map_data_manager_test.dart | 164 ++++++++++++++++++++++++ 2 files changed, 195 insertions(+), 13 deletions(-) create mode 100644 test/widgets/map_data_manager_test.dart diff --git a/lib/widgets/map/map_data_manager.dart b/lib/widgets/map/map_data_manager.dart index a74c6da..4ddf057 100644 --- a/lib/widgets/map/map_data_manager.dart +++ b/lib/widgets/map/map_data_manager.dart @@ -10,6 +10,13 @@ import '../../dev_config.dart'; /// Manages data fetching, filtering, and node limit logic for the map. /// Handles profile changes, zoom level restrictions, and node rendering limits. class MapDataManager { + final List Function(LatLngBounds bounds) _getNodesForBounds; + + MapDataManager({ + List Function(LatLngBounds bounds)? getNodesForBounds, + }) : _getNodesForBounds = getNodesForBounds ?? + ((bounds) => NodeProviderWithCache.instance.getCachedNodesForBounds(bounds)); + // Track node limit state for parent notification bool _lastNodeLimitState = false; @@ -51,28 +58,40 @@ class MapDataManager { List allNodes; List nodesToRender; bool isLimitActive = false; - + int validNodesCount = 0; + if (currentZoom >= minZoom) { // Above minimum zoom - get cached nodes with expanded bounds to prevent edge blinking if (mapBounds != null) { final expandedBounds = _expandBounds(mapBounds, kNodeRenderingBoundsExpansion); - allNodes = NodeProviderWithCache.instance.getCachedNodesForBounds(expandedBounds); + allNodes = _getNodesForBounds(expandedBounds); } else { allNodes = []; } - + // Filter out invalid coordinates before applying limit final validNodes = allNodes.where((node) { return (node.coord.latitude != 0 || node.coord.longitude != 0) && - node.coord.latitude.abs() <= 90 && + node.coord.latitude.abs() <= 90 && node.coord.longitude.abs() <= 180; }).toList(); - - // Apply rendering limit to prevent UI lag - if (validNodes.length > maxNodes) { + validNodesCount = validNodes.length; + + // Apply rendering limit to prevent UI lag. + // Sort by distance from viewport center so the most visible nodes + // always make the cut, preventing gaps that shift as you pan. + if (validNodesCount > maxNodes) { + final centerLat = (mapBounds!.north + mapBounds.south) / 2; + final centerLng = (mapBounds.east + mapBounds.west) / 2; + validNodes.sort((a, b) { + final distA = (a.coord.latitude - centerLat) * (a.coord.latitude - centerLat) + + (a.coord.longitude - centerLng) * (a.coord.longitude - centerLng); + final distB = (b.coord.latitude - centerLat) * (b.coord.latitude - centerLat) + + (b.coord.longitude - centerLng) * (b.coord.longitude - centerLng); + return distA.compareTo(distB); + }); nodesToRender = validNodes.take(maxNodes).toList(); isLimitActive = true; - debugPrint('[MapDataManager] Node limit active: rendering ${nodesToRender.length} of ${validNodes.length} devices'); } else { nodesToRender = validNodes; isLimitActive = false; @@ -87,6 +106,9 @@ class MapDataManager { // Notify parent if limit state changed (for button disabling) if (isLimitActive != _lastNodeLimitState) { _lastNodeLimitState = isLimitActive; + if (isLimitActive) { + debugPrint('[MapDataManager] Node limit active: rendering ${nodesToRender.length} of ${allNodes.length} devices'); + } // Schedule callback after build completes to avoid setState during build WidgetsBinding.instance.addPostFrameCallback((_) { onNodeLimitChanged?.call(isLimitActive); @@ -97,11 +119,7 @@ class MapDataManager { allNodes: allNodes, nodesToRender: nodesToRender, isLimitActive: isLimitActive, - validNodesCount: isLimitActive ? allNodes.where((node) { - return (node.coord.latitude != 0 || node.coord.longitude != 0) && - node.coord.latitude.abs() <= 90 && - node.coord.longitude.abs() <= 180; - }).length : 0, + validNodesCount: isLimitActive ? validNodesCount : 0, ); } diff --git a/test/widgets/map_data_manager_test.dart b/test/widgets/map_data_manager_test.dart new file mode 100644 index 0000000..dc4238a --- /dev/null +++ b/test/widgets/map_data_manager_test.dart @@ -0,0 +1,164 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; + +import 'package:deflockapp/models/osm_node.dart'; +import 'package:deflockapp/app_state.dart'; +import 'package:deflockapp/widgets/map/map_data_manager.dart'; + +void main() { + OsmNode nodeAt(int id, double lat, double lng) { + return OsmNode(id: id, coord: LatLng(lat, lng), tags: {'surveillance': 'outdoor'}); + } + + group('Node render prioritization', () { + late MapDataManager dataManager; + late List testNodes; + + setUp(() { + WidgetsFlutterBinding.ensureInitialized(); + testNodes = []; + dataManager = MapDataManager( + getNodesForBounds: (_) => testNodes, + ); + }); + + test('closest nodes to viewport center are kept', () { + final bounds = LatLngBounds(LatLng(38.0, -78.0), LatLng(39.0, -77.0)); + // Center is (38.5, -77.5) + testNodes = [ + nodeAt(1, 38.9, -77.9), // far from center + nodeAt(2, 38.5, -77.5), // at center + nodeAt(3, 38.1, -77.1), // far from center + nodeAt(4, 38.51, -77.49), // very close to center + nodeAt(5, 38.0, -78.0), // corner — farthest + ]; + + final result = dataManager.getNodesForRendering( + currentZoom: 14, + mapBounds: bounds, + uploadMode: UploadMode.production, + maxNodes: 3, + ); + + expect(result.isLimitActive, isTrue); + expect(result.nodesToRender.length, 3); + final ids = result.nodesToRender.map((n) => n.id).toSet(); + expect(ids.contains(2), isTrue, reason: 'Node at center should be kept'); + expect(ids.contains(4), isTrue, reason: 'Node near center should be kept'); + expect(ids.contains(5), isFalse, reason: 'Node at corner should be dropped'); + }); + + test('returns all nodes when under the limit', () { + final bounds = LatLngBounds(LatLng(38.0, -78.0), LatLng(39.0, -77.0)); + testNodes = [ + nodeAt(1, 38.5, -77.5), + nodeAt(2, 38.6, -77.6), + ]; + + final result = dataManager.getNodesForRendering( + currentZoom: 14, + mapBounds: bounds, + uploadMode: UploadMode.production, + maxNodes: 10, + ); + + expect(result.isLimitActive, isFalse); + expect(result.nodesToRender.length, 2); + }); + + test('returns empty when below minimum zoom', () { + final bounds = LatLngBounds(LatLng(38.0, -78.0), LatLng(39.0, -77.0)); + testNodes = [nodeAt(1, 38.5, -77.5)]; + + final result = dataManager.getNodesForRendering( + currentZoom: 5, + mapBounds: bounds, + uploadMode: UploadMode.production, + maxNodes: 10, + ); + + expect(result.nodesToRender, isEmpty); + }); + + test('panning viewport changes which nodes are prioritized', () { + final nodes = [ + nodeAt(1, 38.0, -78.0), // SW + nodeAt(2, 38.5, -77.5), // middle + nodeAt(3, 39.0, -77.0), // NE + ]; + + // Viewport centered near SW + testNodes = List.from(nodes); + final swBounds = LatLngBounds(LatLng(37.5, -78.5), LatLng(38.5, -77.5)); + final swResult = dataManager.getNodesForRendering( + currentZoom: 14, + mapBounds: swBounds, + uploadMode: UploadMode.production, + maxNodes: 1, + ); + expect(swResult.nodesToRender.first.id, 1, + reason: 'SW node closest to SW-centered viewport'); + + // Viewport centered near NE + testNodes = List.from(nodes); + final neBounds = LatLngBounds(LatLng(38.5, -77.5), LatLng(39.5, -76.5)); + final neResult = dataManager.getNodesForRendering( + currentZoom: 14, + mapBounds: neBounds, + uploadMode: UploadMode.production, + maxNodes: 1, + ); + expect(neResult.nodesToRender.first.id, 3, + reason: 'NE node closest to NE-centered viewport'); + }); + + test('order is stable for repeated calls with same viewport', () { + final bounds = LatLngBounds(LatLng(38.0, -78.0), LatLng(39.0, -77.0)); + makeNodes() => [ + nodeAt(1, 38.9, -77.9), + nodeAt(2, 38.5, -77.5), + nodeAt(3, 38.1, -77.1), + nodeAt(4, 38.51, -77.49), + nodeAt(5, 38.0, -78.0), + ]; + + testNodes = makeNodes(); + final result1 = dataManager.getNodesForRendering( + currentZoom: 14, mapBounds: bounds, + uploadMode: UploadMode.production, maxNodes: 3, + ); + + testNodes = makeNodes(); + final result2 = dataManager.getNodesForRendering( + currentZoom: 14, mapBounds: bounds, + uploadMode: UploadMode.production, maxNodes: 3, + ); + + expect( + result1.nodesToRender.map((n) => n.id).toList(), + result2.nodesToRender.map((n) => n.id).toList(), + ); + }); + + test('filters out invalid coordinates before prioritizing', () { + final bounds = LatLngBounds(LatLng(38.0, -78.0), LatLng(39.0, -77.0)); + testNodes = [ + nodeAt(1, 0, 0), // invalid (0,0) + nodeAt(2, 38.5, -77.5), // valid, at center + nodeAt(3, 200, -77.5), // invalid lat + ]; + + final result = dataManager.getNodesForRendering( + currentZoom: 14, + mapBounds: bounds, + uploadMode: UploadMode.production, + maxNodes: 10, + ); + + expect(result.nodesToRender.length, 1); + expect(result.nodesToRender.first.id, 2); + }); + }); +}