#!/usr/bin/env python3 """ One-time offline map tile downloader (Phase 3). Downloads raster XYZ tiles from MapTiler Cloud for a bounding box covering your usual driving area and saves them under static/vendor/tiles/{z}/{x}/{y}.png so the web UI's Leaflet map works with zero internet access in the car. This is a DEV-MACHINE-ONLY script. It needs internet + a free MapTiler API key. The output (static PNG tiles) is what actually ships to the Pi - the API key itself is never stored in the repo or deployed anywhere. Get a free key: https://cloud.maptiler.com/account/keys/ (no credit card required for the free tier, good for ~100,000 tile requests/month). Usage: export MAPTILER_KEY=your_key_here python3 scripts/download_map_tiles.py \ --min-lat 40.00 --max-lat 41.00 --min-lon -75.00 --max-lon -74.00 # (bounding box is required - use your own driving area's coordinates, # e.g. from https://boundingbox.klokantech.com/) # optionally override the zoom range too: # --min-zoom 8 --max-zoom 15 Re-running is safe/cheap: already-downloaded tiles are skipped. """ import argparse import math import os import sys import time import urllib.request import urllib.error # zoom 15 gives good street-level detail; zoom 16 roughly quadruples tile # count/quota usage on top of that, so it's opt-in via --max-zoom 16. DEFAULT_MIN_ZOOM = 8 DEFAULT_MAX_ZOOM = 15 MAP_ID = "streets-v2" TILE_SIZE = 256 OUTPUT_DIR = os.path.join( os.path.dirname(os.path.abspath(__file__)), "..", "static", "vendor", "tiles" ) def deg2num(lat, lon, zoom): lat_rad = math.radians(lat) n = 2.0 ** zoom x = int((lon + 180.0) / 360.0 * n) y = int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n) return x, y def iter_tiles(min_lat, max_lat, min_lon, max_lon, min_zoom, max_zoom): for zoom in range(min_zoom, max_zoom + 1): x1, y1 = deg2num(max_lat, min_lon, zoom) x2, y2 = deg2num(min_lat, max_lon, zoom) xlo, xhi = sorted((x1, x2)) ylo, yhi = sorted((y1, y2)) for x in range(xlo, xhi + 1): for y in range(ylo, yhi + 1): yield zoom, x, y def main(): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--min-lat", type=float, required=True, help="Bounding box min latitude (your own driving area)") parser.add_argument("--max-lat", type=float, required=True, help="Bounding box max latitude") parser.add_argument("--min-lon", type=float, required=True, help="Bounding box min longitude") parser.add_argument("--max-lon", type=float, required=True, help="Bounding box max longitude") parser.add_argument("--min-zoom", type=int, default=DEFAULT_MIN_ZOOM) parser.add_argument("--max-zoom", type=int, default=DEFAULT_MAX_ZOOM) parser.add_argument("--api-key", default=os.environ.get("MAPTILER_KEY"), help="MapTiler API key (defaults to MAPTILER_KEY env var)") parser.add_argument("--delay", type=float, default=0.05, help="Seconds to sleep between requests (be nice to the API)") args = parser.parse_args() if not args.api_key: print("ERROR: no MapTiler API key given. Set MAPTILER_KEY env var or pass --api-key.", file=sys.stderr) print("Get a free key at https://cloud.maptiler.com/account/keys/", file=sys.stderr) return 1 tiles = list(iter_tiles(args.min_lat, args.max_lat, args.min_lon, args.max_lon, args.min_zoom, args.max_zoom)) total = len(tiles) print(f"Planning to fetch up to {total} tiles (zoom {args.min_zoom}-{args.max_zoom}) " f"into {os.path.abspath(OUTPUT_DIR)}") downloaded = 0 skipped = 0 failed = 0 for i, (z, x, y) in enumerate(tiles, 1): out_dir = os.path.join(OUTPUT_DIR, str(z), str(x)) out_path = os.path.join(out_dir, f"{y}.png") if os.path.exists(out_path) and os.path.getsize(out_path) > 0: skipped += 1 continue os.makedirs(out_dir, exist_ok=True) url = f"https://api.maptiler.com/maps/{MAP_ID}/{TILE_SIZE}/{z}/{x}/{y}.png?key={args.api_key}" try: with urllib.request.urlopen(url, timeout=15) as resp: data = resp.read() with open(out_path, "wb") as f: f.write(data) downloaded += 1 except urllib.error.HTTPError as e: if e.code == 400: # Out of bounds tile (no data for this x/y/z combo) - skip silently. skipped += 1 else: failed += 1 print(f" [{i}/{total}] FAILED z={z} x={x} y={y}: {e}", file=sys.stderr) except Exception as e: failed += 1 print(f" [{i}/{total}] FAILED z={z} x={x} y={y}: {e}", file=sys.stderr) if i % 200 == 0 or i == total: print(f" [{i}/{total}] downloaded={downloaded} skipped={skipped} failed={failed}") time.sleep(args.delay) print(f"Done. downloaded={downloaded} skipped(existing/out-of-bounds)={skipped} failed={failed}") return 0 if failed == 0 else 2 if __name__ == "__main__": sys.exit(main())