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)