import os import json import time from typing import Any from pathlib import Path try: import sqlite3 except Exception: sqlite3 = None OFFLINE_MBTILES_ENV = "IQPILOT_OFFLINE_MBTILES" OFFLINE_TILE_ROOT_ENV = "IQPILOT_OFFLINE_TILE_ROOT" DEFAULT_OFFLINE_TILE_ROOT = Path("/data/offline_maps/tiles" if Path("/data").exists() else "/tmp/offline_maps/tiles") DEFAULT_OFFLINE_MAP_ROOT = Path("/data/offline_maps" if Path("/data").exists() else "/tmp/offline_maps") SQLITE_ERRORS = (sqlite3.Error,) if sqlite3 is not None else (Exception,) SQLiteConnection = Any def offline_tile_root() -> Path: override = os.getenv(OFFLINE_TILE_ROOT_ENV) return Path(override) if override else DEFAULT_OFFLINE_TILE_ROOT def offline_map_root() -> Path: root = offline_tile_root() if root.name == "tiles": return root.parent if root.name == "xyz": return root.parent.parent if root.parent.name == "tiles" else root.parent return DEFAULT_OFFLINE_MAP_ROOT def _parse_bounds(bounds: str) -> tuple[float, float, float, float] | None: try: min_lon, min_lat, max_lon, max_lat = [float(part) for part in bounds.split(",")] except (ValueError, AttributeError): return None return min_lat, min_lon, max_lat, max_lon def _bounds_contains(bounds: tuple[float, float, float, float], latitude: float, longitude: float) -> bool: min_lat, min_lon, max_lat, max_lon = bounds return min_lat <= latitude <= max_lat and min_lon <= longitude <= max_lon def _bounds_area(bounds: tuple[float, float, float, float]) -> float: min_lat, min_lon, max_lat, max_lon = bounds return max(max_lat - min_lat, 0.0) * max(max_lon - min_lon, 0.0) _region_bounds_cache: dict[Path, tuple[float, float, float, float]] = {} def _load_region_bounds(region_root: Path) -> tuple[float, float, float, float] | None: cached = _region_bounds_cache.get(region_root) if cached is not None: return cached bounds = _load_region_bounds_uncached(region_root) if bounds is not None: if len(_region_bounds_cache) > 64: _region_bounds_cache.clear() _region_bounds_cache[region_root] = bounds return bounds def _load_region_bounds_uncached(region_root: Path) -> tuple[float, float, float, float] | None: manifest_path = region_root / "manifest.json" if manifest_path.exists(): try: manifest = json.loads(manifest_path.read_text()) bounds = manifest.get("mbtiles", {}).get("bounds") parsed = _parse_bounds(bounds) if bounds else None if parsed is not None: return parsed except (OSError, json.JSONDecodeError): pass mbtiles_path = region_root / "tiles" / "offline.mbtiles" if not mbtiles_path.exists(): return None try: conn = open_mbtiles(mbtiles_path) row = conn.execute("SELECT value FROM metadata WHERE name = 'bounds'").fetchone() conn.close() except SQLITE_ERRORS: return None return _parse_bounds(row["value"]) if row is not None else None _REGION_ROOTS_TTL_S = 15.0 _region_roots_cache: tuple[float, Path, tuple[Path, ...]] | None = None def _candidate_region_roots() -> tuple[Path, ...]: global _region_roots_cache root = offline_map_root() now = time.monotonic() if _region_roots_cache is not None: cached_at, cached_root, cached = _region_roots_cache if cached_root == root and now - cached_at < _REGION_ROOTS_TTL_S: return cached candidates: list[Path] = [] if (root / "tiles").exists(): candidates.append(root) regions_root = root / "regions" if regions_root.exists(): for child in sorted(regions_root.iterdir()): if child.is_dir() and (child / "tiles").exists(): candidates.append(child) result = tuple(candidates) _region_roots_cache = (now, root, result) return result def _region_covers_point(region_root: Path, latitude: float, longitude: float) -> bool: mb = region_root / "tiles" / "offline.mbtiles" if not mb.exists(): return True try: conn = open_mbtiles(mb) try: _, max_zoom = mbtiles_zoom_bounds(conn) z = max_zoom if max_zoom is not None else 14 import math n = 2 ** z lat_r = math.radians(max(min(latitude, 85.05112878), -85.05112878)) x = int((longitude + 180.0) / 360.0 * n) y = int((1.0 - math.asinh(math.tan(lat_r)) / math.pi) / 2.0 * n) for dx in (-1, 0, 1): for dy in (-1, 0, 1): if load_raster_tile_blob(conn, z, x + dx, y + dy) is not None: return True return False finally: conn.close() except SQLITE_ERRORS: return True def find_offline_region_root(latitude: float | None = None, longitude: float | None = None) -> Path | None: candidates = _candidate_region_roots() if not candidates: return None if latitude is None or longitude is None: return candidates[0] bounded: list[tuple[float, Path]] = [] for candidate in candidates: bounds = _load_region_bounds(candidate) if bounds is None: continue if _bounds_contains(bounds, latitude, longitude): bounded.append((_bounds_area(bounds), candidate)) if bounded: if len(bounded) == 1: return bounded[0][1] bounded.sort(key=lambda item: item[0]) for _, candidate in bounded: if _region_covers_point(candidate, latitude, longitude): return candidate return bounded[0][1] return candidates[0] def find_offline_mbtiles_path(latitude: float | None = None, longitude: float | None = None, day: bool = False) -> Path | None: explicit = os.getenv(OFFLINE_MBTILES_ENV) if explicit: path = Path(explicit) if day: day_path = path.with_name("offline_day.mbtiles") if day_path.exists(): return day_path return path if path.exists() else None region_root = find_offline_region_root(latitude, longitude) if region_root is None: return None if day: day_preferred = region_root / "tiles" / "offline_day.mbtiles" if day_preferred.exists(): return day_preferred preferred = region_root / "tiles" / "offline.mbtiles" if preferred.exists(): return preferred matches = sorted(p for p in (region_root / "tiles").glob("*.mbtiles") if "_day" not in p.name or day) return matches[0] if matches else None def find_offline_xyz_root(latitude: float | None = None, longitude: float | None = None) -> Path | None: root = offline_tile_root() if not root.exists(): region_root = find_offline_region_root(latitude, longitude) if region_root is None: return None root = region_root / "tiles" if any(child.is_dir() and child.name.isdigit() for child in root.iterdir()): return root xyz_dir = root / "xyz" if xyz_dir.exists() and any(child.is_dir() and child.name.isdigit() for child in xyz_dir.iterdir()): return xyz_dir return None def xyz_to_tms_y(z: int, y: int) -> int: return (2 ** z - 1) - y def open_mbtiles(path: Path) -> SQLiteConnection: if sqlite3 is None: raise RuntimeError("sqlite3 unavailable") conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True, check_same_thread=False) conn.row_factory = sqlite3.Row return conn def mbtiles_is_raster(conn: SQLiteConnection) -> bool: row = conn.execute("SELECT value FROM metadata WHERE name = 'format'").fetchone() if row is None: return False return row["value"] in {"png", "jpg", "jpeg", "webp"} def mbtiles_zoom_bounds(conn: SQLiteConnection) -> tuple[int | None, int | None]: rows = { row["name"]: row["value"] for row in conn.execute("SELECT name, value FROM metadata WHERE name IN ('minzoom', 'maxzoom')") } min_zoom = int(rows["minzoom"]) if "minzoom" in rows else None max_zoom = int(rows["maxzoom"]) if "maxzoom" in rows else None return min_zoom, max_zoom def xyz_zoom_bounds(root: Path) -> tuple[int | None, int | None]: zoom_dirs = sorted( int(child.name) for child in root.iterdir() if child.is_dir() and child.name.isdigit() ) if not zoom_dirs: return None, None return zoom_dirs[0], zoom_dirs[-1] def load_raster_tile_blob(conn: SQLiteConnection, z: int, x: int, y: int) -> bytes | None: row = conn.execute( """ SELECT tile_data FROM tiles WHERE zoom_level = ? AND tile_column = ? AND tile_row = ? """, (z, x, xyz_to_tms_y(z, y)), ).fetchone() return bytes(row["tile_data"]) if row is not None else None def load_raster_xyz_tile_blob(root: Path, z: int, x: int, y: int) -> bytes | None: for suffix in ("png", "webp", "jpg", "jpeg"): for filename in (f"{y}.{suffix}", f"{y}@2x.{suffix}"): tile_path = root / str(z) / str(x) / filename if tile_path.exists(): return tile_path.read_bytes() return None