Files
deflock-app/lib/widgets/map/map_data_manager.dart
Doug Borg fe401cc04b 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 <noreply@anthropic.com>
2026-03-09 15:09:37 -06:00

162 lines
6.0 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../../models/osm_node.dart';
import '../../app_state.dart';
import '../node_provider_with_cache.dart';
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<OsmNode> Function(LatLngBounds bounds) _getNodesForBounds;
MapDataManager({
List<OsmNode> Function(LatLngBounds bounds)? getNodesForBounds,
}) : _getNodesForBounds = getNodesForBounds ??
((bounds) => NodeProviderWithCache.instance.getCachedNodesForBounds(bounds));
// Track node limit state for parent notification
bool _lastNodeLimitState = false;
/// Get minimum zoom level for node fetching based on upload mode
int getMinZoomForNodes(UploadMode uploadMode) {
// OSM API (sandbox mode) needs higher zoom level due to bbox size limits
if (uploadMode == UploadMode.sandbox) {
return kOsmApiMinZoomLevel;
} else {
return kNodeMinZoomLevel;
}
}
/// Expand bounds by the given multiplier, maintaining center point.
/// Used to expand rendering bounds to prevent nodes blinking at screen edges.
LatLngBounds _expandBounds(LatLngBounds bounds, double multiplier) {
final centerLat = (bounds.north + bounds.south) / 2;
final centerLng = (bounds.east + bounds.west) / 2;
final latSpan = (bounds.north - bounds.south) * multiplier / 2;
final lngSpan = (bounds.east - bounds.west) * multiplier / 2;
return LatLngBounds(
LatLng(centerLat - latSpan, centerLng - lngSpan),
LatLng(centerLat + latSpan, centerLng + lngSpan),
);
}
/// Get nodes to render based on current map state
/// Returns a MapDataResult containing all relevant node data and limit state
MapDataResult getNodesForRendering({
required double currentZoom,
required LatLngBounds? mapBounds,
required UploadMode uploadMode,
required int maxNodes,
void Function(bool isLimited)? onNodeLimitChanged,
}) {
final minZoom = getMinZoomForNodes(uploadMode);
List<OsmNode> allNodes;
List<OsmNode> 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 = _getNodesForBounds(expandedBounds);
} else {
allNodes = <OsmNode>[];
}
// 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.longitude.abs() <= 180;
}).toList();
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;
} else {
nodesToRender = validNodes;
isLimitActive = false;
}
} else {
// Below minimum zoom - don't render any nodes
allNodes = <OsmNode>[];
nodesToRender = <OsmNode>[];
isLimitActive = false;
}
// 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);
});
}
return MapDataResult(
allNodes: allNodes,
nodesToRender: nodesToRender,
isLimitActive: isLimitActive,
validNodesCount: isLimitActive ? validNodesCount : 0,
);
}
/// Show zoom warning if user is below minimum zoom level
void showZoomWarningIfNeeded(BuildContext context, double currentZoom, UploadMode uploadMode) {
final minZoom = getMinZoomForNodes(uploadMode);
// Only show warning once per zoom level to avoid spam
if (currentZoom.floor() == (minZoom - 1)) {
final message = uploadMode == UploadMode.sandbox
? 'Zoom to level $minZoom or higher to see nodes in sandbox mode (OSM API bbox limit)'
: 'Zoom to level $minZoom or higher to see surveillance nodes';
// Show a brief snackbar
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
duration: const Duration(seconds: 4),
behavior: SnackBarBehavior.floating,
),
);
}
}
}
/// Result object containing node data and rendering state
class MapDataResult {
final List<OsmNode> allNodes;
final List<OsmNode> nodesToRender;
final bool isLimitActive;
final int validNodesCount;
const MapDataResult({
required this.allNodes,
required this.nodesToRender,
required this.isLimitActive,
required this.validNodesCount,
});
}