Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,10 @@ will download OSM data to `cache/2.284,48.860,2.290,48.865.osm` and render an SV
| <span style="white-space: nowrap;">`-z`</span>, <span style="white-space: nowrap;">`--zoom`</span> `<float>` | OSM zoom level, default value: 18.0 |
| <span style="white-space: nowrap;">`-c`</span>, <span style="white-space: nowrap;">`--coordinates`</span> `<latitude>,<longitude>` | coordinates of any location within the tile |
| <span style="white-space: nowrap;">`-s`</span>, <span style="white-space: nowrap;">`--size`</span> `<width>,<height>` | resulting image size |
| <span style="white-space: nowrap;">`--gpx`</span> `<path>` | path to a GPX file to draw as a track overlay |
| <span style="white-space: nowrap;">`--track-color`</span> `<color>` | track stroke color (default: #FF0000), default value: `#FF0000` |
| <span style="white-space: nowrap;">`--track-width`</span> `<float>` | track stroke width in pixels (default: 3.0), default value: 3.0 |
| <span style="white-space: nowrap;">`--track-opacity`</span> `<float>` | track stroke opacity (default: 0.8), default value: 0.8 |

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

Expand All @@ -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).
Expand Down
23 changes: 23 additions & 0 deletions doc/moi/readme.moi
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 77 additions & 0 deletions map_machine/gpx.py
Original file line number Diff line number Diff line change
@@ -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)
82 changes: 81 additions & 1 deletion map_machine/mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -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.

Expand Down Expand Up @@ -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)
25 changes: 25 additions & 0 deletions map_machine/ui/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,31 @@ def add_render_arguments(parser: argparse.ArgumentParser) -> None:
metavar="<width>,<height>",
help="resulting image size",
)
parser.add_argument(
"--gpx",
metavar="<path>",
help="path to a GPX file to draw as a track overlay",
)
parser.add_argument(
"--track-color",
metavar="<color>",
default="#FF0000",
help="track stroke color (default: #FF0000)",
)
parser.add_argument(
"--track-width",
metavar="<float>",
type=float,
default=3.0,
help="track stroke width in pixels (default: 3.0)",
)
parser.add_argument(
"--track-opacity",
metavar="<float>",
type=float,
default=0.8,
help="track stroke opacity (default: 0.8)",
)


def add_mapcss_arguments(parser: argparse.ArgumentParser) -> None:
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 13 additions & 0 deletions tests/data/test_track.gpx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns="http://www.topografix.com/GPX/1/1" version="1.1"
creator="map-machine-test">
<trk>
<name>Test Track</name>
<trkseg>
<trkpt lat="20.0002" lon="10.0002"/>
<trkpt lat="20.0004" lon="10.0004"/>
<trkpt lat="20.0006" lon="10.0006"/>
<trkpt lat="20.0008" lon="10.0008"/>
</trkseg>
</trk>
</gpx>
4 changes: 2 additions & 2 deletions tests/test_command_line.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)


Expand Down
Loading