Skip to content

Commit 834861b

Browse files
authored
Merge pull request #148 from dougborg/fix/node-render-prioritization
Prioritize closest nodes to viewport center when render limit is active
2 parents de65cec + ba80b88 commit 834861b

2 files changed

Lines changed: 197 additions & 13 deletions

File tree

‎lib/widgets/map/map_data_manager.dart‎

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ import '../../dev_config.dart';
1010
/// Manages data fetching, filtering, and node limit logic for the map.
1111
/// Handles profile changes, zoom level restrictions, and node rendering limits.
1212
class MapDataManager {
13+
final List<OsmNode> Function(LatLngBounds bounds) _getNodesForBounds;
14+
15+
MapDataManager({
16+
List<OsmNode> Function(LatLngBounds bounds)? getNodesForBounds,
17+
}) : _getNodesForBounds = getNodesForBounds ??
18+
((bounds) => NodeProviderWithCache.instance.getCachedNodesForBounds(bounds));
19+
1320
// Track node limit state for parent notification
1421
bool _lastNodeLimitState = false;
1522

@@ -51,28 +58,42 @@ class MapDataManager {
5158
List<OsmNode> allNodes;
5259
List<OsmNode> nodesToRender;
5360
bool isLimitActive = false;
54-
61+
int validNodesCount = 0;
62+
5563
if (currentZoom >= minZoom) {
5664
// Above minimum zoom - get cached nodes with expanded bounds to prevent edge blinking
5765
if (mapBounds != null) {
5866
final expandedBounds = _expandBounds(mapBounds, kNodeRenderingBoundsExpansion);
59-
allNodes = NodeProviderWithCache.instance.getCachedNodesForBounds(expandedBounds);
67+
allNodes = _getNodesForBounds(expandedBounds);
6068
} else {
6169
allNodes = <OsmNode>[];
6270
}
63-
71+
6472
// Filter out invalid coordinates before applying limit
6573
final validNodes = allNodes.where((node) {
6674
return (node.coord.latitude != 0 || node.coord.longitude != 0) &&
67-
node.coord.latitude.abs() <= 90 &&
75+
node.coord.latitude.abs() <= 90 &&
6876
node.coord.longitude.abs() <= 180;
6977
}).toList();
70-
71-
// Apply rendering limit to prevent UI lag
72-
if (validNodes.length > maxNodes) {
78+
validNodesCount = validNodes.length;
79+
80+
// Apply rendering limit to prevent UI lag.
81+
// Sort by distance from viewport center so the most visible nodes
82+
// always make the cut, preventing gaps that shift as you pan.
83+
if (validNodesCount > maxNodes) {
84+
final bounds = mapBounds!;
85+
final centerLat = (bounds.north + bounds.south) / 2;
86+
final centerLng = (bounds.east + bounds.west) / 2;
87+
validNodes.sort((a, b) {
88+
final distA = (a.coord.latitude - centerLat) * (a.coord.latitude - centerLat) +
89+
(a.coord.longitude - centerLng) * (a.coord.longitude - centerLng);
90+
final distB = (b.coord.latitude - centerLat) * (b.coord.latitude - centerLat) +
91+
(b.coord.longitude - centerLng) * (b.coord.longitude - centerLng);
92+
final cmp = distA.compareTo(distB);
93+
return cmp != 0 ? cmp : a.id.compareTo(b.id);
94+
});
7395
nodesToRender = validNodes.take(maxNodes).toList();
7496
isLimitActive = true;
75-
debugPrint('[MapDataManager] Node limit active: rendering ${nodesToRender.length} of ${validNodes.length} devices');
7697
} else {
7798
nodesToRender = validNodes;
7899
isLimitActive = false;
@@ -87,6 +108,9 @@ class MapDataManager {
87108
// Notify parent if limit state changed (for button disabling)
88109
if (isLimitActive != _lastNodeLimitState) {
89110
_lastNodeLimitState = isLimitActive;
111+
if (isLimitActive) {
112+
debugPrint('[MapDataManager] Node limit active: rendering ${nodesToRender.length} of $validNodesCount valid devices');
113+
}
90114
// Schedule callback after build completes to avoid setState during build
91115
WidgetsBinding.instance.addPostFrameCallback((_) {
92116
onNodeLimitChanged?.call(isLimitActive);
@@ -97,11 +121,7 @@ class MapDataManager {
97121
allNodes: allNodes,
98122
nodesToRender: nodesToRender,
99123
isLimitActive: isLimitActive,
100-
validNodesCount: isLimitActive ? allNodes.where((node) {
101-
return (node.coord.latitude != 0 || node.coord.longitude != 0) &&
102-
node.coord.latitude.abs() <= 90 &&
103-
node.coord.longitude.abs() <= 180;
104-
}).length : 0,
124+
validNodesCount: isLimitActive ? validNodesCount : 0,
105125
);
106126
}
107127

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import 'package:flutter/widgets.dart';
2+
import 'package:flutter_test/flutter_test.dart';
3+
import 'package:flutter_map/flutter_map.dart';
4+
import 'package:latlong2/latlong.dart';
5+
6+
import 'package:deflockapp/models/osm_node.dart';
7+
import 'package:deflockapp/app_state.dart';
8+
import 'package:deflockapp/widgets/map/map_data_manager.dart';
9+
10+
void main() {
11+
OsmNode nodeAt(int id, double lat, double lng) {
12+
return OsmNode(id: id, coord: LatLng(lat, lng), tags: {'surveillance': 'outdoor'});
13+
}
14+
15+
group('Node render prioritization', () {
16+
late MapDataManager dataManager;
17+
late List<OsmNode> testNodes;
18+
19+
setUp(() {
20+
WidgetsFlutterBinding.ensureInitialized();
21+
testNodes = [];
22+
dataManager = MapDataManager(
23+
getNodesForBounds: (_) => testNodes,
24+
);
25+
});
26+
27+
test('closest nodes to viewport center are kept', () {
28+
final bounds = LatLngBounds(LatLng(38.0, -78.0), LatLng(39.0, -77.0));
29+
// Center is (38.5, -77.5)
30+
testNodes = [
31+
nodeAt(1, 38.9, -77.9), // far from center
32+
nodeAt(2, 38.5, -77.5), // at center
33+
nodeAt(3, 38.1, -77.1), // far from center
34+
nodeAt(4, 38.51, -77.49), // very close to center
35+
nodeAt(5, 38.0, -78.0), // corner — farthest
36+
];
37+
38+
final result = dataManager.getNodesForRendering(
39+
currentZoom: 14,
40+
mapBounds: bounds,
41+
uploadMode: UploadMode.production,
42+
maxNodes: 3,
43+
);
44+
45+
expect(result.isLimitActive, isTrue);
46+
expect(result.nodesToRender.length, 3);
47+
final ids = result.nodesToRender.map((n) => n.id).toSet();
48+
expect(ids.contains(2), isTrue, reason: 'Node at center should be kept');
49+
expect(ids.contains(4), isTrue, reason: 'Node near center should be kept');
50+
expect(ids.contains(5), isFalse, reason: 'Node at corner should be dropped');
51+
});
52+
53+
test('returns all nodes when under the limit', () {
54+
final bounds = LatLngBounds(LatLng(38.0, -78.0), LatLng(39.0, -77.0));
55+
testNodes = [
56+
nodeAt(1, 38.5, -77.5),
57+
nodeAt(2, 38.6, -77.6),
58+
];
59+
60+
final result = dataManager.getNodesForRendering(
61+
currentZoom: 14,
62+
mapBounds: bounds,
63+
uploadMode: UploadMode.production,
64+
maxNodes: 10,
65+
);
66+
67+
expect(result.isLimitActive, isFalse);
68+
expect(result.nodesToRender.length, 2);
69+
});
70+
71+
test('returns empty when below minimum zoom', () {
72+
final bounds = LatLngBounds(LatLng(38.0, -78.0), LatLng(39.0, -77.0));
73+
testNodes = [nodeAt(1, 38.5, -77.5)];
74+
75+
final result = dataManager.getNodesForRendering(
76+
currentZoom: 5,
77+
mapBounds: bounds,
78+
uploadMode: UploadMode.production,
79+
maxNodes: 10,
80+
);
81+
82+
expect(result.nodesToRender, isEmpty);
83+
});
84+
85+
test('panning viewport changes which nodes are prioritized', () {
86+
final nodes = [
87+
nodeAt(1, 38.0, -78.0), // SW
88+
nodeAt(2, 38.5, -77.5), // middle
89+
nodeAt(3, 39.0, -77.0), // NE
90+
];
91+
92+
// Viewport centered near SW
93+
testNodes = List.from(nodes);
94+
final swBounds = LatLngBounds(LatLng(37.5, -78.5), LatLng(38.5, -77.5));
95+
final swResult = dataManager.getNodesForRendering(
96+
currentZoom: 14,
97+
mapBounds: swBounds,
98+
uploadMode: UploadMode.production,
99+
maxNodes: 1,
100+
);
101+
expect(swResult.nodesToRender.first.id, 1,
102+
reason: 'SW node closest to SW-centered viewport');
103+
104+
// Viewport centered near NE
105+
testNodes = List.from(nodes);
106+
final neBounds = LatLngBounds(LatLng(38.5, -77.5), LatLng(39.5, -76.5));
107+
final neResult = dataManager.getNodesForRendering(
108+
currentZoom: 14,
109+
mapBounds: neBounds,
110+
uploadMode: UploadMode.production,
111+
maxNodes: 1,
112+
);
113+
expect(neResult.nodesToRender.first.id, 3,
114+
reason: 'NE node closest to NE-centered viewport');
115+
});
116+
117+
test('order is stable for repeated calls with same viewport', () {
118+
final bounds = LatLngBounds(LatLng(38.0, -78.0), LatLng(39.0, -77.0));
119+
makeNodes() => [
120+
nodeAt(1, 38.9, -77.9),
121+
nodeAt(2, 38.5, -77.5),
122+
nodeAt(3, 38.1, -77.1),
123+
nodeAt(4, 38.51, -77.49),
124+
nodeAt(5, 38.0, -78.0),
125+
];
126+
127+
testNodes = makeNodes();
128+
final result1 = dataManager.getNodesForRendering(
129+
currentZoom: 14, mapBounds: bounds,
130+
uploadMode: UploadMode.production, maxNodes: 3,
131+
);
132+
133+
testNodes = makeNodes();
134+
final result2 = dataManager.getNodesForRendering(
135+
currentZoom: 14, mapBounds: bounds,
136+
uploadMode: UploadMode.production, maxNodes: 3,
137+
);
138+
139+
expect(
140+
result1.nodesToRender.map((n) => n.id).toList(),
141+
result2.nodesToRender.map((n) => n.id).toList(),
142+
);
143+
});
144+
145+
test('filters out invalid coordinates before prioritizing', () {
146+
final bounds = LatLngBounds(LatLng(38.0, -78.0), LatLng(39.0, -77.0));
147+
testNodes = [
148+
nodeAt(1, 0, 0), // invalid (0,0)
149+
nodeAt(2, 38.5, -77.5), // valid, at center
150+
nodeAt(3, 200, -77.5), // invalid lat
151+
];
152+
153+
final result = dataManager.getNodesForRendering(
154+
currentZoom: 14,
155+
mapBounds: bounds,
156+
uploadMode: UploadMode.production,
157+
maxNodes: 10,
158+
);
159+
160+
expect(result.nodesToRender.length, 1);
161+
expect(result.nodesToRender.first.id, 2);
162+
});
163+
});
164+
}

0 commit comments

Comments
 (0)