Files
Tyler MacDonaldandClaude Opus 4.8 3c11ffe215 Add GPX/KML track support (frame around a track and draw it as a layer)
Implements the long-standing request in #64: read a recorded track and
either frame the map around it, draw it over the map, or both.

- `plot("track.gpx")` frames the map on the track (auto-choosing a radius
  that encloses it, unless one is given) and draws it.
- `plot(query, gpx="track.gpx")` draws the track over a normal query.
- `gpx` accepts a path or a list of paths; GPX and KML are both read.
- `gpx_style` (or `style["gpx"]`) styles the track layer; it defaults to
  GPX_STYLE.

The track is injected as a "gpx" layer, so it rides through the same
projection, transform and drawing pipeline as every other layer (plot_gdf
already renders LineString/MultiLineString). The reader (prettymaps/gpx.py)
uses only the standard library and shapely, so this adds no dependencies.

Supersedes the stale #80, which targeted the pre-1.0 architecture and only
framed the map (it explicitly left drawing the track "for the future").

Tests in tests/test_gpx.py cover the reader (GPX, KML, multi-file, empty)
and the plot() integration (auto-frame, overlay, custom style).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 12:24:36 -03:00

132 lines
4.6 KiB
Python

"""
Read GPS tracks (GPX / KML) into shapely geometries.
Used by :func:`prettymaps.draw.plot` to (a) frame a map around a recorded track
and (b) draw the track as a map layer. See GitHub issue #64.
The parser is intentionally dependency-free (standard-library XML only) and
namespace-agnostic, so it accepts GPX 1.0/1.1 and KML files regardless of the
declared XML namespace.
"""
import math
import xml.etree.ElementTree as ET
from typing import List, Optional, Tuple, Union
from shapely.geometry import LineString, MultiLineString
from shapely.geometry.base import BaseGeometry
# Mean metres-per-degree of latitude (WGS84). Longitude is scaled by cos(lat).
_M_PER_DEG = 111_320.0
__all__ = ["read_track", "is_track_file", "track_center", "track_radius"]
def _localname(tag: str) -> str:
"""Return an element's tag without its XML namespace, lower-cased."""
return tag.rsplit("}", 1)[-1].lower()
def _lines_from_gpx(root: ET.Element) -> List[LineString]:
"""Extract track segments (trk/trkseg) and routes (rte) as LineStrings."""
lines = []
for element in root.iter():
if _localname(element.tag) in ("trkseg", "rte"):
points = []
for point in element:
if _localname(point.tag) in ("trkpt", "rtept"):
try:
points.append((float(point.get("lon")), float(point.get("lat"))))
except (TypeError, ValueError):
continue
if len(points) >= 2:
lines.append(LineString(points))
return lines
def _lines_from_kml(root: ET.Element) -> List[LineString]:
"""Extract <LineString><coordinates> paths ("lon,lat,alt lon,lat,alt ...")."""
lines = []
for element in root.iter():
if _localname(element.tag) != "linestring":
continue
for child in element:
if _localname(child.tag) != "coordinates":
continue
points = []
for token in (child.text or "").split():
parts = token.split(",")
if len(parts) >= 2:
try:
points.append((float(parts[0]), float(parts[1])))
except ValueError:
continue
if len(points) >= 2:
lines.append(LineString(points))
return lines
def read_track(
source: Union[str, List[str], Tuple[str, ...]]
) -> Optional[MultiLineString]:
"""
Read one or more GPX/KML files into a single shapely ``MultiLineString``.
Coordinates are longitude/latitude (EPSG:4326), matching osmnx/prettymaps.
Args:
source: A path to a ``.gpx`` or ``.kml`` file, or a list of such paths.
Returns:
A ``MultiLineString`` of every track segment / route / line found, or
``None`` if the file(s) contain no usable line geometry.
"""
if isinstance(source, (list, tuple)):
geoms: List[LineString] = []
for item in source:
track = read_track(item)
if track is not None:
geoms.extend(track.geoms)
return MultiLineString(geoms) if geoms else None
root = ET.parse(source).getroot()
root_name = _localname(root.tag)
if root_name == "kml":
lines = _lines_from_kml(root)
elif root_name == "gpx":
lines = _lines_from_gpx(root)
else:
# Unknown root: try both readers.
lines = _lines_from_gpx(root) or _lines_from_kml(root)
return MultiLineString(lines) if lines else None
def is_track_file(query: object) -> bool:
"""True if ``query`` is a path to a ``.gpx`` or ``.kml`` file."""
return isinstance(query, str) and query.lower().rsplit(".", 1)[-1] in ("gpx", "kml")
def track_center(geom: BaseGeometry) -> Tuple[float, float]:
"""Return the ``(latitude, longitude)`` centre of a track's bounding box."""
min_lon, min_lat, max_lon, max_lat = geom.bounds
return (min_lat + max_lat) / 2, (min_lon + max_lon) / 2
def track_radius(
geom: BaseGeometry, margin: float = 1.15, min_radius: float = 250.0
) -> float:
"""
A square "radius" (half-side, in metres) that encloses the whole track.
Args:
geom: Track geometry in lon/lat (EPSG:4326).
margin: Multiplier applied so the track doesn't touch the map edge.
min_radius: Floor for very short tracks, so the map isn't absurdly zoomed.
"""
min_lon, min_lat, max_lon, max_lat = geom.bounds
center_lat = (min_lat + max_lat) / 2
width_m = (max_lon - min_lon) * _M_PER_DEG * math.cos(math.radians(center_lat))
height_m = (max_lat - min_lat) * _M_PER_DEG
return max(min_radius, (max(width_m, height_m) / 2) * margin)