Skip to content

Commit 8c714a3

Browse files
committed
Issue #16: support GPX.
1 parent 0860cd4 commit 8c714a3

File tree

9 files changed

+313
-3
lines changed

9 files changed

+313
-3
lines changed

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,10 @@ will download OSM data to `cache/2.284,48.860,2.290,48.865.osm` and render an SV
210210
| <span style="white-space: nowrap;">`-z`</span>, <span style="white-space: nowrap;">`--zoom`</span> `<float>` | OSM zoom level, default value: 18.0 |
211211
| <span style="white-space: nowrap;">`-c`</span>, <span style="white-space: nowrap;">`--coordinates`</span> `<latitude>,<longitude>` | coordinates of any location within the tile |
212212
| <span style="white-space: nowrap;">`-s`</span>, <span style="white-space: nowrap;">`--size`</span> `<width>,<height>` | resulting image size |
213+
| <span style="white-space: nowrap;">`--gpx`</span> `<path>` | path to a GPX file to draw as a track overlay |
214+
| <span style="white-space: nowrap;">`--track-color`</span> `<color>` | track stroke color (default: #FF0000), default value: `#FF0000` |
215+
| <span style="white-space: nowrap;">`--track-width`</span> `<float>` | track stroke width in pixels (default: 3.0), default value: 3.0 |
216+
| <span style="white-space: nowrap;">`--track-opacity`</span> `<float>` | track stroke opacity (default: 0.8), default value: 0.8 |
213217

214218
plus [map configuration options](#map-options)
215219

@@ -223,6 +227,27 @@ Second, the OSM API only returns data that falls within the requested bounding b
223227

224228
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.
225229

230+
### GPX track overlay
231+
232+
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.
233+
234+
```shell
235+
map-machine render --gpx track.gpx
236+
```
237+
238+
You can also combine `--gpx` with an explicit bounding box or other render options:
239+
240+
```shell
241+
map-machine render --gpx track.gpx \
242+
--track-color "#0000FF" --track-width 5 --track-opacity 0.6
243+
```
244+
245+
Track style options:
246+
247+
* `--track-color`: stroke color (default: `#FF0000`),
248+
* `--track-width`: stroke width in pixels (default: 3.0),
249+
* `--track-opacity`: stroke opacity (default: 0.8).
250+
226251
## Tile generation
227252

228253
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).

doc/moi/readme.moi

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,29 @@ with the \c {--input} option. The Overpass step can be disabled with the
328328
water polygons from the partial data by completing boundaries along the bounding
329329
box edges.
330330

331+
\3 {GPX track overlay} {gpx-track-overlay}
332+
333+
The \c {render} command can draw GPX tracks on top of the map. When a GPX file
334+
is provided with \c {--gpx}, Map Machine parses the track, computes a bounding
335+
box around it (with a small padding), downloads the corresponding OSM data, and
336+
renders the track as a colored line on top of the map.
337+
338+
\code {shell} {map-machine render --gpx track.gpx}
339+
340+
You can also combine \c {--gpx} with an explicit bounding box or other render
341+
options:
342+
343+
\code {shell} {
344+
map-machine render --gpx track.gpx \\
345+
--track-color "#0000FF" --track-width 5 --track-opacity 0.6
346+
}
347+
348+
Track style options:
349+
\list
350+
{\c {--track-color}: stroke color (default: \c {#FF0000}),}
351+
{\c {--track-width}: stroke width in pixels (default: 3.0),}
352+
{\c {--track-opacity}: stroke opacity (default: 0.8).}
353+
331354
\2 {Tile generation} {tile-generation}
332355

333356
Command \c {tile} is used to generate PNG tiles for

map_machine/gpx.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""GPX file loading and bounding box computation."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from typing import TYPE_CHECKING
7+
8+
import gpxpy
9+
import gpxpy.gpx
10+
11+
from map_machine.geometry.bounding_box import (
12+
LATITUDE_MAX_DIFFERENCE,
13+
LONGITUDE_MAX_DIFFERENCE,
14+
BoundingBox,
15+
)
16+
17+
if TYPE_CHECKING:
18+
from pathlib import Path
19+
20+
__author__ = "Sergey Vartanov"
21+
__email__ = "me@enzet.ru"
22+
23+
logger: logging.Logger = logging.getLogger(__name__)
24+
25+
DEFAULT_PADDING: float = 0.005
26+
27+
28+
def load_gpx(path: Path) -> gpxpy.gpx.GPX:
29+
"""Parse a GPX file.
30+
31+
:param path: path to the GPX file
32+
:return: parsed GPX data
33+
"""
34+
with path.open(encoding="utf-8") as gpx_file:
35+
return gpxpy.parse(gpx_file)
36+
37+
38+
def get_bounding_box(
39+
gpx: gpxpy.gpx.GPX, padding: float = DEFAULT_PADDING
40+
) -> BoundingBox:
41+
"""Compute bounding box from GPX track points with padding.
42+
43+
:param gpx: parsed GPX data
44+
:param padding: padding in degrees around the track
45+
:return: bounding box covering all track points
46+
:raises ValueError: if the GPX has no track points or the resulting
47+
bounding box exceeds the size limit
48+
"""
49+
bounds = gpx.get_bounds()
50+
if (
51+
bounds is None
52+
or bounds.min_longitude is None
53+
or bounds.min_latitude is None
54+
or bounds.max_longitude is None
55+
or bounds.max_latitude is None
56+
):
57+
message = "GPX file contains no track points."
58+
raise ValueError(message)
59+
60+
left: float = bounds.min_longitude - padding
61+
bottom: float = bounds.min_latitude - padding
62+
right: float = bounds.max_longitude + padding
63+
top: float = bounds.max_latitude + padding
64+
65+
if (
66+
right - left > LONGITUDE_MAX_DIFFERENCE
67+
or top - bottom > LATITUDE_MAX_DIFFERENCE
68+
):
69+
message = (
70+
f"GPX track bounding box is too large "
71+
f"({right - left:.3f}° × {top - bottom:.3f}°). "
72+
f"Maximum allowed is "
73+
f"{LONGITUDE_MAX_DIFFERENCE}° × {LATITUDE_MAX_DIFFERENCE}°."
74+
)
75+
raise ValueError(message)
76+
77+
return BoundingBox(left, bottom, right, top)

map_machine/mapper.py

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
WaterRelationProcessor,
2727
)
2828
from map_machine.geometry.flinger import Flinger, MercatorFlinger
29+
from map_machine.geometry.vector import Polyline
2930
from map_machine.map_configuration import LabelMode, MapConfiguration
3031
from map_machine.osm.osm_getter import (
3132
NetworkError,
@@ -43,6 +44,9 @@
4344
import argparse
4445
from collections.abc import Iterable
4546

47+
import gpxpy.gpx
48+
from gpxpy.gpx import GPX
49+
4650
from map_machine.figure import StyledFigure
4751
from map_machine.geometry.vector import Segment
4852

@@ -352,6 +356,18 @@ def render_map(arguments: argparse.Namespace) -> None:
352356
cache_path: Path = Path(arguments.cache)
353357
cache_path.mkdir(parents=True, exist_ok=True)
354358

359+
# Parse GPX file if provided.
360+
361+
gpx_data: GPX | None = None
362+
if getattr(arguments, "gpx", None):
363+
from map_machine.gpx import get_bounding_box, load_gpx # noqa: PLC0415
364+
365+
gpx_path: Path = Path(arguments.gpx)
366+
if not gpx_path.is_file():
367+
fatal(f"No such file: `{gpx_path}`.")
368+
sys.exit(1)
369+
gpx_data = load_gpx(gpx_path)
370+
355371
# Compute bounding box.
356372

357373
bounding_box: BoundingBox | None = None
@@ -391,6 +407,9 @@ def render_map(arguments: argparse.Namespace) -> None:
391407
coordinates, configuration.zoom_level, width, height
392408
)
393409

410+
elif gpx_data is not None:
411+
bounding_box = get_bounding_box(gpx_data)
412+
394413
# Determine files.
395414

396415
input_file_names: list[Path]
@@ -407,7 +426,9 @@ def render_map(arguments: argparse.Namespace) -> None:
407426
logger.fatal(error.message)
408427
sys.exit(1)
409428
else:
410-
fatal("Specify either --input, or --bounding-box, or --coordinates.")
429+
fatal(
430+
"Specify `--input`, `--bounding-box`, `--coordinates`, or `--gpx`."
431+
)
411432

412433
# Get OpenStreetMap data.
413434

@@ -499,8 +520,67 @@ def render_map(arguments: argparse.Namespace) -> None:
499520
map_: Map = Map(flinger=flinger, svg=svg, configuration=configuration)
500521
map_.draw(constructor)
501522

523+
if gpx_data is not None:
524+
draw_gpx_tracks(
525+
svg,
526+
gpx_data,
527+
flinger,
528+
color=getattr(arguments, "track_color", "#FF0000"),
529+
width=getattr(arguments, "track_width", 3.0),
530+
opacity=getattr(arguments, "track_opacity", 0.8),
531+
)
532+
502533
logger.info("Writing output SVG to `%s`...", arguments.output_file_name)
503534
with Path(arguments.output_file_name).open(
504535
"w", encoding="utf-8"
505536
) as output_file:
506537
svg.write(output_file)
538+
539+
540+
def draw_gpx_tracks(
541+
svg: svgwrite.Drawing,
542+
gpx_data: gpxpy.gpx.GPX,
543+
flinger: Flinger,
544+
color: str,
545+
width: float,
546+
opacity: float,
547+
) -> None:
548+
"""Draw GPX tracks on top of the map.
549+
550+
:param svg: SVG drawing to add track paths to
551+
:param gpx_data: parsed GPX data from gpxpy
552+
:param flinger: coordinate transformer (lat/lon to pixels)
553+
:param color: track stroke color
554+
:param width: track stroke width in pixels
555+
:param opacity: track stroke opacity
556+
"""
557+
segment_count: int = 0
558+
559+
for track in gpx_data.tracks:
560+
for segment in track.segments:
561+
if len(segment.points) < 2:
562+
continue
563+
564+
points: list[np.ndarray] = [
565+
flinger.fling(np.array((point.latitude, point.longitude)))
566+
for point in segment.points
567+
]
568+
polyline: Polyline = Polyline(points)
569+
path_commands: str | None = polyline.get_path()
570+
571+
if path_commands:
572+
path: SVGPath = SVGPath(d=path_commands)
573+
path.update(
574+
{
575+
"fill": "none",
576+
"stroke": color,
577+
"stroke-width": width,
578+
"stroke-opacity": opacity,
579+
"stroke-linecap": "round",
580+
"stroke-linejoin": "round",
581+
}
582+
)
583+
svg.add(path)
584+
segment_count += 1
585+
586+
logger.info("Drew %d track segment(s).", segment_count)

map_machine/ui/cli.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,31 @@ def add_render_arguments(parser: argparse.ArgumentParser) -> None:
422422
metavar="<width>,<height>",
423423
help="resulting image size",
424424
)
425+
parser.add_argument(
426+
"--gpx",
427+
metavar="<path>",
428+
help="path to a GPX file to draw as a track overlay",
429+
)
430+
parser.add_argument(
431+
"--track-color",
432+
metavar="<color>",
433+
default="#FF0000",
434+
help="track stroke color (default: #FF0000)",
435+
)
436+
parser.add_argument(
437+
"--track-width",
438+
metavar="<float>",
439+
type=float,
440+
default=3.0,
441+
help="track stroke width in pixels (default: 3.0)",
442+
)
443+
parser.add_argument(
444+
"--track-opacity",
445+
metavar="<float>",
446+
type=float,
447+
default=0.8,
448+
help="track stroke opacity (default: 0.8)",
449+
)
425450

426451

427452
def add_mapcss_arguments(parser: argparse.ArgumentParser) -> None:

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ dynamic = ["version"]
4040
dependencies = [
4141
"CairoSVG~=2.5.0",
4242
"colour~=0.1.5",
43+
"gpxpy>=1.6.0",
4344
"numpy~=1.26.0",
4445
"Pillow~=12.1.0",
4546
"portolan~=1.0.1",

tests/data/test_track.gpx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<gpx xmlns="http://www.topografix.com/GPX/1/1" version="1.1"
3+
creator="map-machine-test">
4+
<trk>
5+
<name>Test Track</name>
6+
<trkseg>
7+
<trkpt lat="20.0002" lon="10.0002"/>
8+
<trkpt lat="20.0004" lon="10.0004"/>
9+
<trkpt lat="20.0006" lon="10.0006"/>
10+
<trkpt lat="20.0008" lon="10.0008"/>
11+
</trkseg>
12+
</trk>
13+
</gpx>

tests/test_command_line.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,8 @@ def test_wrong_render_arguments() -> None:
113113
"""Test `render` command with wrong arguments."""
114114
error_run(
115115
["render", "-z", "17"],
116-
b"CRITICAL Specify either --input, or --bounding-box, or "
117-
b"--coordinates.\n",
116+
b"CRITICAL Specify `--input`, `--bounding-box`, `--coordinates`, "
117+
b"or `--gpx`.\n",
118118
)
119119

120120

0 commit comments

Comments
 (0)