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 { // 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 allNodes; List nodesToRender; bool isLimitActive = false; 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); } 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.longitude.abs() <= 180; }).toList(); // Apply rendering limit to prevent UI lag if (validNodes.length > maxNodes) { nodesToRender = validNodes.take(maxNodes).toList(); isLimitActive = true; debugPrint('[MapDataManager] Node limit active: rendering ${nodesToRender.length} of ${validNodes.length} devices'); } else { nodesToRender = validNodes; isLimitActive = false; } } else { // Below minimum zoom - don't render any nodes allNodes = []; nodesToRender = []; isLimitActive = false; } // Notify parent if limit state changed (for button disabling) if (isLimitActive != _lastNodeLimitState) { _lastNodeLimitState = isLimitActive; // 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 ? allNodes.where((node) { return (node.coord.latitude != 0 || node.coord.longitude != 0) && node.coord.latitude.abs() <= 90 && node.coord.longitude.abs() <= 180; }).length : 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 allNodes; final List nodesToRender; final bool isLimitActive; final int validNodesCount; const MapDataResult({ required this.allNodes, required this.nodesToRender, required this.isLimitActive, required this.validNodesCount, }); }