Files
deflock-app/lib/services/offline_areas/offline_tile_utils.dart
Doug Borg 2d92214bed Add offline-first tile system with per-provider caching and error retry
- Add ServicePolicy framework with OSM-specific rate limiting and TTL
- Add per-provider disk tile cache (ProviderTileCacheStore) with O(1)
  lookup, oldest-modified eviction, and ETag/304 revalidation
- Rewrite DeflockTileProvider with two paths: common (NetworkTileProvider)
  and offline-first (disk cache -> local tiles -> network with caching)
- Add zoom-aware offline routing so tiles outside offline area zoom ranges
  use the efficient common path instead of the overhead-heavy offline path
- Fix HTTP client lifecycle: dispose() is now a no-op for flutter_map
  widget recycling; shutdown() handles permanent teardown
- Add TileLayerManager with exponential backoff retry (2s->60s cap),
  provider switch detection, and backoff reset
- Guard null provider/tileType in download dialog with localized error
- Fix Nominatim cache key to use normalized viewbox values
- Comprehensive test coverage (1800+ lines across 6 test files)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:34:01 -07:00

100 lines
3.6 KiB
Dart

import 'dart:math';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
/// Utility for tile calculations and lat/lon conversions for OSM offline logic
/// Normalize bounds so south ≤ north, west ≤ east, and degenerate (near-zero)
/// spans are expanded by epsilon. Call this before storing bounds so that
/// `tileInBounds` and [computeTileList] see consistent corner ordering.
LatLngBounds normalizeBounds(LatLngBounds bounds) {
const double epsilon = 1e-7;
var latMin = min(bounds.southWest.latitude, bounds.northEast.latitude);
var latMax = max(bounds.southWest.latitude, bounds.northEast.latitude);
var lonMin = min(bounds.southWest.longitude, bounds.northEast.longitude);
var lonMax = max(bounds.southWest.longitude, bounds.northEast.longitude);
if ((latMax - latMin).abs() < epsilon) {
latMin -= epsilon;
latMax += epsilon;
}
if ((lonMax - lonMin).abs() < epsilon) {
lonMin -= epsilon;
lonMax += epsilon;
}
return LatLngBounds(LatLng(latMin, lonMin), LatLng(latMax, lonMax));
}
Set<List<int>> computeTileList(LatLngBounds bounds, int zMin, int zMax) {
Set<List<int>> tiles = {};
final normalized = normalizeBounds(bounds);
final double latMin = normalized.south;
final double latMax = normalized.north;
final double lonMin = normalized.west;
final double lonMax = normalized.east;
for (int z = zMin; z <= zMax; z++) {
final n = pow(2, z).toInt();
final minTileRaw = latLonToTileRaw(latMin, lonMin, z);
final maxTileRaw = latLonToTileRaw(latMax, lonMax, z);
int minX = min(minTileRaw[0].floor(), maxTileRaw[0].floor()) - 1;
int maxX = max(minTileRaw[0].ceil() - 1, maxTileRaw[0].ceil() - 1) + 1;
int minY = min(minTileRaw[1].floor(), maxTileRaw[1].floor()) - 1;
int maxY = max(minTileRaw[1].ceil() - 1, maxTileRaw[1].ceil() - 1) + 1;
minX = minX.clamp(0, n - 1);
maxX = maxX.clamp(0, n - 1);
minY = minY.clamp(0, n - 1);
maxY = maxY.clamp(0, n - 1);
for (int x = minX; x <= maxX; x++) {
for (int y = minY; y <= maxY; y++) {
tiles.add([z, x, y]);
}
}
}
return tiles;
}
List<double> latLonToTileRaw(double lat, double lon, int zoom) {
final n = pow(2.0, zoom);
final xtile = (lon + 180.0) / 360.0 * n;
final ytile = (1.0 -
log(tan(lat * pi / 180.0) + 1.0 / cos(lat * pi / 180.0)) / pi) / 2.0 * n;
return [xtile, ytile];
}
List<int> latLonToTile(double lat, double lon, int zoom) {
final n = pow(2.0, zoom);
final xtile = ((lon + 180.0) / 360.0 * n).floor();
final ytile = ((1.0 - log(tan(lat * pi / 180.0) + 1.0 / cos(lat * pi / 180.0)) / pi) / 2.0 * n).floor();
return [xtile, ytile];
}
/// Convert tile coordinates back to LatLng bounds
LatLngBounds tileToLatLngBounds(int x, int y, int z) {
final n = pow(2, z);
// Calculate bounds for this tile
final lonWest = x / n * 360.0 - 180.0;
final lonEast = (x + 1) / n * 360.0 - 180.0;
// For latitude, we need to invert the mercator projection
final latNorthRad = atan(sinh(pi * (1 - 2 * y / n)));
final latSouthRad = atan(sinh(pi * (1 - 2 * (y + 1) / n)));
final latNorth = latNorthRad * 180.0 / pi;
final latSouth = latSouthRad * 180.0 / pi;
return LatLngBounds(
LatLng(latSouth, lonWest), // SW corner
LatLng(latNorth, lonEast), // NE corner
);
}
/// Hyperbolic sine function: sinh(x) = (e^x - e^(-x)) / 2
double sinh(double x) {
return (exp(x) - exp(-x)) / 2;
}
LatLngBounds globalWorldBounds() {
// Use slightly shrunken bounds to avoid tile index overflow at extreme coordinates
return LatLngBounds(LatLng(-85.0, -179.9), LatLng(85.0, 179.9));
}