Files
FlightPlot/flight_map/maps.py
T
2026-10-20 14:18:35 +02:00

128 lines
4.8 KiB
Python

import hashlib
import json
import logging
import math
import os
import tempfile
from pathlib import Path
from typing import Any
from urllib.parse import urlencode
from shapely.geometry import LineString
from flight_map.geo import Bounds, Projection
from flight_map.network import request_json
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
LOGGER = logging.getLogger(__name__)
def validate_map_data(data: Any) -> dict[str, Any]:
if not isinstance(data, dict) or not isinstance(data.get("elements"), list):
raise ValueError("Map data must be an Overpass JSON object containing elements.")
if data.get("remark"):
raise ValueError(f"Overpass could not complete the map query: {data['remark']}")
return data
def map_data(
bounds: Bounds, cache: Path, local_file: Path | None = None, *, refresh: bool = False
) -> dict[str, Any]:
if local_file and refresh:
raise ValueError("--refresh-map cannot be combined with --map-data.")
bbox = f"{bounds.south},{bounds.west},{bounds.north},{bounds.east}"
query = (
"[out:json][timeout:60];("
f'way["highway"]({bbox});way["waterway"]({bbox});'
f'way["building"]({bbox});way["natural"="water"]({bbox});'
");out geom;"
)
key = hashlib.sha256((OVERPASS_URL + "\n" + query).encode()).hexdigest()
path = local_file or cache / f"osm-{key}.json"
if path.exists() and (local_file or not refresh):
LOGGER.info("Reading %s map data: %s", "local" if local_file else "cached", path)
with path.open() as stream:
try:
data = json.load(stream)
except (ValueError, UnicodeError) as exc:
raise ValueError(
f"Invalid map JSON in {path}; correct the file or use --refresh-map "
"to replace a damaged cache entry."
) from exc
elif local_file:
raise ValueError(f"Local map data file does not exist: {local_file}")
else:
LOGGER.info("Downloading vector map data from Overpass for %s", bounds.values)
data = request_json(
OVERPASS_URL,
data=urlencode({"data": query}).encode(),
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
validate_map_data(data)
cache.mkdir(parents=True, exist_ok=True)
temporary: Path | None = None
try:
with tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8", dir=cache, suffix=".tmp", delete=False
) as stream:
temporary = Path(stream.name)
json.dump(data, stream)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
finally:
if temporary and temporary.exists():
temporary.unlink()
LOGGER.info("Cached vector map data: %s", path)
return validate_map_data(data)
def map_lines(
data: dict[str, Any], projection: Projection, *, min_building_size: float = 0
) -> list[LineString]:
result = []
for element in data["elements"]:
if not isinstance(element, dict):
raise ValueError("Map data contains a malformed feature.")
if element.get("type") != "way":
continue
tags = element.get("tags", {})
if not isinstance(tags, dict):
raise ValueError("Map feature tags must be an object.")
if tags.get("highway") in {
"footway",
"path",
"steps",
"cycleway",
"bridleway",
"corridor",
"construction",
"proposed",
}:
continue
geometry = element.get("geometry")
if not isinstance(geometry, list) or len(geometry) < 2:
raise ValueError("Map way has no usable geometry; export using 'out geom'.")
try:
positions = [(float(p["lon"]), float(p["lat"])) for p in geometry]
except (KeyError, ValueError, TypeError) as exc:
raise ValueError("Map way contains invalid geographic coordinates.") from exc
if any(
not math.isfinite(lon)
or not math.isfinite(lat)
or not -180 <= lon <= 180
or not -90 <= lat <= 90
for lon, lat in positions
):
raise ValueError("Map way coordinates must be finite and within geographic limits.")
points = [projection.point(lon, lat) for lon, lat in positions]
line = LineString(points)
if "building" in tags:
west, south, east, north = line.bounds
if min(east - west, north - south) < min_building_size:
continue
result.append(line)
if not result:
raise ValueError("No drawable map features were found for this area.")
return result