diff --git a/README.md b/README.md index 7e657cc2..22f8d2fb 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,10 @@ will download OSM data to `cache/2.284,48.860,2.290,48.865.osm` and render an SV | `-z`, `--zoom` `` | OSM zoom level, default value: 18.0 | | `-c`, `--coordinates` `,` | coordinates of any location within the tile | | `-s`, `--size` `,` | resulting image size | +| `--gpx` `` | path to a GPX file to draw as a track overlay | +| `--track-color` `` | track stroke color (default: #FF0000), default value: `#FF0000` | +| `--track-width` `` | track stroke width in pixels (default: 3.0), default value: 3.0 | +| `--track-opacity` `` | track stroke opacity (default: 0.8), default value: 0.8 | plus [map configuration options](#map-options) @@ -223,6 +227,27 @@ Second, the OSM API only returns data that falls within the requested bounding b You can skip the download entirely by passing your own OSM or Overpass JSON file with the `--input` option. The Overpass step can be disabled with the `--no-overpass` flag. When disabled, Map Machine will attempt to reconstruct water polygons from the partial data by completing boundaries along the bounding box edges. +### GPX track overlay + +The `render` command can draw GPX tracks on top of the map. When a GPX file is provided with `--gpx`, Map Machine parses the track, computes a bounding box around it (with a small padding), downloads the corresponding OSM data, and renders the track as a colored line on top of the map. + +```shell +map-machine render --gpx track.gpx +``` + +You can also combine `--gpx` with an explicit bounding box or other render options: + +```shell +map-machine render --gpx track.gpx \ + --track-color "#0000FF" --track-width 5 --track-opacity 0.6 +``` + +Track style options: + + * `--track-color`: stroke color (default: `#FF0000`), + * `--track-width`: stroke width in pixels (default: 3.0), + * `--track-opacity`: stroke opacity (default: 0.8). + ## Tile generation Command `tile` is used to generate PNG tiles for [slippy maps](https://wiki.openstreetmap.org/wiki/Slippy_Map). To use them, run [Map Machine tile server](#tile-server). diff --git a/doc/moi/readme.moi b/doc/moi/readme.moi index faeb6061..bc6f5e8d 100644 --- a/doc/moi/readme.moi +++ b/doc/moi/readme.moi @@ -328,6 +328,29 @@ with the \c {--input} option. The Overpass step can be disabled with the water polygons from the partial data by completing boundaries along the bounding box edges. +\3 {GPX track overlay} {gpx-track-overlay} + +The \c {render} command can draw GPX tracks on top of the map. When a GPX file +is provided with \c {--gpx}, Map Machine parses the track, computes a bounding +box around it (with a small padding), downloads the corresponding OSM data, and +renders the track as a colored line on top of the map. + +\code {shell} {map-machine render --gpx track.gpx} + +You can also combine \c {--gpx} with an explicit bounding box or other render +options: + +\code {shell} { +map-machine render --gpx track.gpx \\ + --track-color "#0000FF" --track-width 5 --track-opacity 0.6 +} + +Track style options: +\list + {\c {--track-color}: stroke color (default: \c {#FF0000}),} + {\c {--track-width}: stroke width in pixels (default: 3.0),} + {\c {--track-opacity}: stroke opacity (default: 0.8).} + \2 {Tile generation} {tile-generation} Command \c {tile} is used to generate PNG tiles for diff --git a/map_machine/gpx.py b/map_machine/gpx.py new file mode 100644 index 00000000..51e0a848 --- /dev/null +++ b/map_machine/gpx.py @@ -0,0 +1,77 @@ +"""GPX file loading and bounding box computation.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +import gpxpy +import gpxpy.gpx + +from map_machine.geometry.bounding_box import ( + LATITUDE_MAX_DIFFERENCE, + LONGITUDE_MAX_DIFFERENCE, + BoundingBox, +) + +if TYPE_CHECKING: + from pathlib import Path + +__author__ = "Sergey Vartanov" +__email__ = "me@enzet.ru" + +logger: logging.Logger = logging.getLogger(__name__) + +DEFAULT_PADDING: float = 0.005 + + +def load_gpx(path: Path) -> gpxpy.gpx.GPX: + """Parse a GPX file. + + :param path: path to the GPX file + :return: parsed GPX data + """ + with path.open(encoding="utf-8") as gpx_file: + return gpxpy.parse(gpx_file) + + +def get_bounding_box( + gpx: gpxpy.gpx.GPX, padding: float = DEFAULT_PADDING +) -> BoundingBox: + """Compute bounding box from GPX track points with padding. + + :param gpx: parsed GPX data + :param padding: padding in degrees around the track + :return: bounding box covering all track points + :raises ValueError: if the GPX has no track points or the resulting + bounding box exceeds the size limit + """ + bounds = gpx.get_bounds() + if ( + bounds is None + or bounds.min_longitude is None + or bounds.min_latitude is None + or bounds.max_longitude is None + or bounds.max_latitude is None + ): + message = "GPX file contains no track points." + raise ValueError(message) + + left: float = bounds.min_longitude - padding + bottom: float = bounds.min_latitude - padding + right: float = bounds.max_longitude + padding + top: float = bounds.max_latitude + padding + + if ( + right - left > LONGITUDE_MAX_DIFFERENCE + or top - bottom > LATITUDE_MAX_DIFFERENCE + ): + message = ( + f"GPX track bounding box is too large " + f"({right - left:.3f}° × {top - bottom:.3f}°). " + f"Maximum allowed is " + f"{LONGITUDE_MAX_DIFFERENCE}° × {LATITUDE_MAX_DIFFERENCE}°." + ) + raise ValueError(message) + + return BoundingBox(left, bottom, right, top) diff --git a/map_machine/mapper.py b/map_machine/mapper.py index 426a1034..eb2b7987 100644 --- a/map_machine/mapper.py +++ b/map_machine/mapper.py @@ -26,6 +26,7 @@ WaterRelationProcessor, ) from map_machine.geometry.flinger import Flinger, MercatorFlinger +from map_machine.geometry.vector import Polyline from map_machine.map_configuration import LabelMode, MapConfiguration from map_machine.osm.osm_getter import ( NetworkError, @@ -43,6 +44,9 @@ import argparse from collections.abc import Iterable + import gpxpy.gpx + from gpxpy.gpx import GPX + from map_machine.figure import StyledFigure from map_machine.geometry.vector import Segment @@ -352,6 +356,18 @@ def render_map(arguments: argparse.Namespace) -> None: cache_path: Path = Path(arguments.cache) cache_path.mkdir(parents=True, exist_ok=True) + # Parse GPX file if provided. + + gpx_data: GPX | None = None + if getattr(arguments, "gpx", None): + from map_machine.gpx import get_bounding_box, load_gpx # noqa: PLC0415 + + gpx_path: Path = Path(arguments.gpx) + if not gpx_path.is_file(): + fatal(f"No such file: `{gpx_path}`.") + sys.exit(1) + gpx_data = load_gpx(gpx_path) + # Compute bounding box. bounding_box: BoundingBox | None = None @@ -391,6 +407,9 @@ def render_map(arguments: argparse.Namespace) -> None: coordinates, configuration.zoom_level, width, height ) + elif gpx_data is not None: + bounding_box = get_bounding_box(gpx_data) + # Determine files. input_file_names: list[Path] @@ -407,7 +426,9 @@ def render_map(arguments: argparse.Namespace) -> None: logger.fatal(error.message) sys.exit(1) else: - fatal("Specify either --input, or --bounding-box, or --coordinates.") + fatal( + "Specify `--input`, `--bounding-box`, `--coordinates`, or `--gpx`." + ) # Get OpenStreetMap data. @@ -499,8 +520,67 @@ def render_map(arguments: argparse.Namespace) -> None: map_: Map = Map(flinger=flinger, svg=svg, configuration=configuration) map_.draw(constructor) + if gpx_data is not None: + draw_gpx_tracks( + svg, + gpx_data, + flinger, + color=getattr(arguments, "track_color", "#FF0000"), + width=getattr(arguments, "track_width", 3.0), + opacity=getattr(arguments, "track_opacity", 0.8), + ) + logger.info("Writing output SVG to `%s`...", arguments.output_file_name) with Path(arguments.output_file_name).open( "w", encoding="utf-8" ) as output_file: svg.write(output_file) + + +def draw_gpx_tracks( + svg: svgwrite.Drawing, + gpx_data: gpxpy.gpx.GPX, + flinger: Flinger, + color: str, + width: float, + opacity: float, +) -> None: + """Draw GPX tracks on top of the map. + + :param svg: SVG drawing to add track paths to + :param gpx_data: parsed GPX data from gpxpy + :param flinger: coordinate transformer (lat/lon to pixels) + :param color: track stroke color + :param width: track stroke width in pixels + :param opacity: track stroke opacity + """ + segment_count: int = 0 + + for track in gpx_data.tracks: + for segment in track.segments: + if len(segment.points) < 2: + continue + + points: list[np.ndarray] = [ + flinger.fling(np.array((point.latitude, point.longitude))) + for point in segment.points + ] + polyline: Polyline = Polyline(points) + path_commands: str | None = polyline.get_path() + + if path_commands: + path: SVGPath = SVGPath(d=path_commands) + path.update( + { + "fill": "none", + "stroke": color, + "stroke-width": width, + "stroke-opacity": opacity, + "stroke-linecap": "round", + "stroke-linejoin": "round", + } + ) + svg.add(path) + segment_count += 1 + + logger.info("Drew %d track segment(s).", segment_count) diff --git a/map_machine/ui/cli.py b/map_machine/ui/cli.py index bb8eeaf6..fa33bcbd 100644 --- a/map_machine/ui/cli.py +++ b/map_machine/ui/cli.py @@ -422,6 +422,31 @@ def add_render_arguments(parser: argparse.ArgumentParser) -> None: metavar=",", help="resulting image size", ) + parser.add_argument( + "--gpx", + metavar="", + help="path to a GPX file to draw as a track overlay", + ) + parser.add_argument( + "--track-color", + metavar="", + default="#FF0000", + help="track stroke color (default: #FF0000)", + ) + parser.add_argument( + "--track-width", + metavar="", + type=float, + default=3.0, + help="track stroke width in pixels (default: 3.0)", + ) + parser.add_argument( + "--track-opacity", + metavar="", + type=float, + default=0.8, + help="track stroke opacity (default: 0.8)", + ) def add_mapcss_arguments(parser: argparse.ArgumentParser) -> None: diff --git a/pyproject.toml b/pyproject.toml index 70914b0d..b1600275 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ dynamic = ["version"] dependencies = [ "CairoSVG~=2.5.0", "colour~=0.1.5", + "gpxpy>=1.6.0", "numpy~=1.26.0", "Pillow~=12.1.0", "portolan~=1.0.1", diff --git a/tests/data/test_track.gpx b/tests/data/test_track.gpx new file mode 100644 index 00000000..efdadbff --- /dev/null +++ b/tests/data/test_track.gpx @@ -0,0 +1,13 @@ + + + + Test Track + + + + + + + + diff --git a/tests/test_command_line.py b/tests/test_command_line.py index ab92474e..f9599cec 100644 --- a/tests/test_command_line.py +++ b/tests/test_command_line.py @@ -113,8 +113,8 @@ def test_wrong_render_arguments() -> None: """Test `render` command with wrong arguments.""" error_run( ["render", "-z", "17"], - b"CRITICAL Specify either --input, or --bounding-box, or " - b"--coordinates.\n", + b"CRITICAL Specify `--input`, `--bounding-box`, `--coordinates`, " + b"or `--gpx`.\n", ) diff --git a/tests/test_gpx.py b/tests/test_gpx.py new file mode 100644 index 00000000..3c361ab4 --- /dev/null +++ b/tests/test_gpx.py @@ -0,0 +1,66 @@ +"""Test GPX parsing and bounding box computation.""" + +from pathlib import Path + +import pytest +from gpxpy import gpx as gpxpy_gpx + +from map_machine.gpx import get_bounding_box, load_gpx + +__author__ = "Sergey Vartanov" +__email__ = "me@enzet.ru" + +TEST_GPX_PATH: Path = Path("tests/data/test_track.gpx") + + +def test_load_gpx() -> None: + """Test loading a GPX file.""" + gpx = load_gpx(TEST_GPX_PATH) + assert len(gpx.tracks) == 1 + assert gpx.tracks[0].name == "Test Track" + assert len(gpx.tracks[0].segments) == 1 + assert len(gpx.tracks[0].segments[0].points) == 4 + + +def test_bounding_box_from_gpx() -> None: + """Test bounding box computation from GPX data.""" + gpx = load_gpx(TEST_GPX_PATH) + bbox = get_bounding_box(gpx, padding=0.001) + + assert bbox.bottom < 20.0002 + assert bbox.top > 20.0008 + assert bbox.left < 10.0002 + assert bbox.right > 10.0008 + + +def test_bounding_box_padding() -> None: + """Test that padding expands the bounding box.""" + gpx = load_gpx(TEST_GPX_PATH) + bbox_small = get_bounding_box(gpx, padding=0.001) + bbox_large = get_bounding_box(gpx, padding=0.01) + + assert bbox_large.left < bbox_small.left + assert bbox_large.bottom < bbox_small.bottom + assert bbox_large.right > bbox_small.right + assert bbox_large.top > bbox_small.top + + +def test_bounding_box_empty_gpx() -> None: + """Test that empty GPX raises ValueError.""" + gpx = gpxpy_gpx.GPX() + with pytest.raises(ValueError, match="no track points"): + get_bounding_box(gpx) + + +def test_bounding_box_too_large() -> None: + """Test that oversized GPX track raises ValueError.""" + gpx = gpxpy_gpx.GPX() + track = gpxpy_gpx.GPXTrack() + segment = gpxpy_gpx.GPXTrackSegment() + segment.points.append(gpxpy_gpx.GPXTrackPoint(10.0, 10.0)) + segment.points.append(gpxpy_gpx.GPXTrackPoint(11.0, 11.0)) + track.segments.append(segment) + gpx.tracks.append(track) + + with pytest.raises(ValueError, match="too large"): + get_bounding_box(gpx)