diff --git a/lib/dev_config.dart b/lib/dev_config.dart index fa51f49..adb9e51 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -41,3 +41,7 @@ const int kTileFetchJitter3Ms = 5000; // User download max zoom span (user can download up to kMaxUserDownloadZoomSpan zooms above min) const int kMaxUserDownloadZoomSpan = 7; + +// Download area limits and constants +const int kMaxReasonableTileCount = 10000; +const int kAbsoluteMaxZoom = 19; diff --git a/lib/services/offline_area_service.dart b/lib/services/offline_area_service.dart index 2cc94bb..4ef19a0 100644 --- a/lib/services/offline_area_service.dart +++ b/lib/services/offline_area_service.dart @@ -1,5 +1,6 @@ import 'dart:io'; import 'dart:convert'; +import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:latlong2/latlong.dart'; import 'package:flutter_map/flutter_map.dart' show LatLngBounds; @@ -305,12 +306,15 @@ class OfflineAreaService { } if (!area.isPermanent) { + // Calculate expanded camera bounds that cover the entire tile area at minimum zoom + final cameraBounds = _calculateCameraBounds(bounds, minZoom); final cameras = await MapDataProvider().getAllCamerasForDownload( - bounds: bounds, + bounds: cameraBounds, profiles: AppState.instance.enabledProfiles, ); area.cameras = cameras; await saveCameras(cameras, directory); + debugPrint('Area $id: Downloaded ${cameras.length} cameras from expanded bounds (${cameraBounds.north.toStringAsFixed(6)}, ${cameraBounds.west.toStringAsFixed(6)}) to (${cameraBounds.south.toStringAsFixed(6)}, ${cameraBounds.east.toStringAsFixed(6)})'); } else { area.cameras = []; } @@ -363,4 +367,57 @@ class OfflineAreaService { _areas.remove(area); await saveAreasToDisk(); } + + /// Calculate expanded bounds that cover the entire tile area at minimum zoom + /// This ensures we fetch all cameras that could be relevant for the offline area + LatLngBounds _calculateCameraBounds(LatLngBounds visibleBounds, int minZoom) { + // Get all tiles that cover the visible bounds at minimum zoom + final tiles = computeTileList(visibleBounds, minZoom, minZoom); + if (tiles.isEmpty) return visibleBounds; + + // Find the bounding box of all these tiles + double minLat = 90.0, maxLat = -90.0; + double minLon = 180.0, maxLon = -180.0; + + for (final tile in tiles) { + final z = tile[0]; + final x = tile[1]; + final y = tile[2]; + + // Convert tile coordinates back to lat/lng bounds + final tileBounds = _tileToLatLngBounds(x, y, z); + + minLat = math.min(minLat, tileBounds.south); + maxLat = math.max(maxLat, tileBounds.north); + minLon = math.min(minLon, tileBounds.west); + maxLon = math.max(maxLon, tileBounds.east); + } + + return LatLngBounds( + LatLng(minLat, minLon), + LatLng(maxLat, maxLon), + ); + } + + /// Convert tile coordinates to LatLng bounds + LatLngBounds _tileToLatLngBounds(int x, int y, int z) { + final n = math.pow(2, z); + final lonDeg = x / n * 360.0 - 180.0; + final latRad = math.atan(_sinh(math.pi * (1 - 2 * y / n))); + final latDeg = latRad * 180.0 / math.pi; + + final lonDegNext = (x + 1) / n * 360.0 - 180.0; + final latRadNext = math.atan(_sinh(math.pi * (1 - 2 * (y + 1) / n))); + final latDegNext = latRadNext * 180.0 / math.pi; + + return LatLngBounds( + LatLng(latDegNext, lonDeg), // SW corner + LatLng(latDeg, lonDegNext), // NE corner + ); + } + + /// Hyperbolic sine function: sinh(x) = (e^x - e^(-x)) / 2 + double _sinh(double x) { + return (math.exp(x) - math.exp(-x)) / 2; + } } diff --git a/lib/widgets/download_area_dialog.dart b/lib/widgets/download_area_dialog.dart index 5a30d51..99cba63 100644 --- a/lib/widgets/download_area_dialog.dart +++ b/lib/widgets/download_area_dialog.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:latlong2/latlong.dart'; import 'package:flutter_map/flutter_map.dart'; +import 'dart:math' as math; import '../dev_config.dart'; import '../services/offline_area_service.dart'; @@ -17,6 +18,7 @@ class DownloadAreaDialog extends StatefulWidget { class _DownloadAreaDialogState extends State { double _zoom = 15; int? _minZoom; + int? _maxPossibleZoom; int? _tileCount; double? _mbEstimate; @@ -28,6 +30,7 @@ class _DownloadAreaDialogState extends State { void _recomputeEstimates() { var bounds = widget.controller.camera.visibleBounds; + // If the visible area is nearly zero, nudge the bounds for estimation const double epsilon = 0.0002; final latSpan = (bounds.north - bounds.south).abs(); @@ -48,46 +51,48 @@ class _DownloadAreaDialogState extends State { LatLng(bounds.northEast.latitude, bounds.northEast.longitude + epsilon) ); } - final minZoom = kWorldMaxZoom + 1; // Use world max zoom + 1 for seamless zoom experience + + final minZoom = kWorldMaxZoom + 1; final maxZoom = _zoom.toInt(); + + // Calculate maximum possible zoom based on tile count limit + final maxPossibleZoom = _calculateMaxZoomForTileLimit(bounds, minZoom); + final nTiles = computeTileList(bounds, minZoom, maxZoom).length; final totalMb = (nTiles * kTileEstimateKb) / 1024.0; + setState(() { _minZoom = minZoom; + _maxPossibleZoom = maxPossibleZoom; _tileCount = nTiles; _mbEstimate = totalMb; }); } + + /// Calculate the maximum zoom level that keeps tile count under the limit + int _calculateMaxZoomForTileLimit(LatLngBounds bounds, int minZoom) { + for (int zoom = minZoom; zoom <= kAbsoluteMaxZoom; zoom++) { + final tileCount = computeTileList(bounds, minZoom, zoom).length; + if (tileCount > kMaxReasonableTileCount) { + // Return the previous zoom level that was still under the limit + return math.max(minZoom, zoom - 1); + } + } + return kAbsoluteMaxZoom; + } + + @override Widget build(BuildContext context) { final bounds = widget.controller.camera.visibleBounds; final maxZoom = _zoom.toInt(); - double sliderMin; - double sliderMax; - int sliderDivisions; - double sliderValue; - // Generate slider min/max/divisions with clarity - if (_minZoom != null) { - sliderMin = _minZoom!.toDouble(); - } else { - sliderMin = 12.0; //fallback - } - if (_minZoom != null) { - final candidateMax = _minZoom! + kMaxUserDownloadZoomSpan; - sliderMax = candidateMax > 19 ? 19.0 : candidateMax.toDouble(); - } else { - sliderMax = 19.0; //fallback - } - if (_minZoom != null) { - final candidateMax = _minZoom! + kMaxUserDownloadZoomSpan; - int diff = (candidateMax > 19 ? 19 : candidateMax) - _minZoom!; - sliderDivisions = diff > 0 ? diff : 1; - } else { - sliderDivisions = 7; //fallback - } - sliderValue = _zoom.clamp(sliderMin, sliderMax); - // We recompute estimates when the zoom slider changes + + // Use the calculated max possible zoom instead of fixed span + final sliderMin = _minZoom?.toDouble() ?? 12.0; + final sliderMax = _maxPossibleZoom?.toDouble() ?? 19.0; + final sliderDivisions = math.max(1, (_maxPossibleZoom ?? 19) - (_minZoom ?? 12)); + final sliderValue = _zoom.clamp(sliderMin, sliderMax); return AlertDialog( title: Row( @@ -144,7 +149,47 @@ class _DownloadAreaDialogState extends State { const Text('Min zoom:'), Text('Z$_minZoom'), ], - ) + ), + if (_maxPossibleZoom != null && _tileCount != null) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: _tileCount! > kMaxReasonableTileCount + ? Colors.orange.withOpacity(0.1) + : Colors.green.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Max recommended zoom: Z$_maxPossibleZoom', + style: TextStyle( + fontSize: 12, + color: _tileCount! > kMaxReasonableTileCount + ? Colors.orange[700] + : Colors.green[700], + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + _tileCount! > kMaxReasonableTileCount + ? 'Current selection exceeds ${kMaxReasonableTileCount.toString()} tile limit' + : 'Within ${kMaxReasonableTileCount.toString()} tile limit', + style: TextStyle( + fontSize: 11, + color: _tileCount! > kMaxReasonableTileCount + ? Colors.orange[600] + : Colors.green[600], + ), + ), + ], + ), + ), + ), ], ), ), @@ -159,6 +204,7 @@ class _DownloadAreaDialogState extends State { final id = DateTime.now().toIso8601String().replaceAll(':', '-'); final appDocDir = await OfflineAreaService().getOfflineAreaDir(); final dir = "${appDocDir.path}/$id"; + // Fire and forget: don't await download, so dialog closes immediately // ignore: unawaited_futures OfflineAreaService().downloadArea( @@ -173,7 +219,7 @@ class _DownloadAreaDialogState extends State { Navigator.pop(context); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Download started!'), + content: Text('Download started! Fetching tiles and cameras...'), ), ); } catch (e) {