-
Notifications
You must be signed in to change notification settings - Fork 0
/
gpxtohtml.py
293 lines (234 loc) · 9.12 KB
/
gpxtohtml.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
# -*- coding: utf-8 -*-
from branca.colormap import LinearColormap
from folium.plugins import MarkerCluster
from pathlib import Path
from PIL import Image
import folium
import gpxpy
import io
import numpy as np
import pandas as pd
import yaml
class Tiles:
""" Representation of tiles for map rendering. """
def __init__(self, url, attr) -> None:
self._url = url
self._attribution = attr
@property
def url(self):
""" Provides access to attribute. """
return self._url
@property
def attribution(self):
""" Provides access to attribute. """
return self._attribution
class Waypoint:
""" Representation of a waypoint for tagging the map. """
def __init__(self, name, location, url) -> None:
self._name = name
self._location = location
self._url = url
def isinside(self, bounds, tol = 0.01):
""" Check whether waypoint belongs to rectangular region. """
(lat, lon) = self._location
[(lat_min, lon_min), (lat_max, lon_max)] = bounds
tests = [
(lat >= lat_min - tol),
(lat <= lat_max + tol),
(lon >= lon_min - tol),
(lon <= lon_max + tol)
]
return all(tests)
@property
def name(self):
""" Provides access to attribute. """
return self._name
@property
def location(self):
""" Provides access to attribute. """
return self._location
@property
def url(self):
""" Provides access to attribute. """
return self._url
@property
def link(self):
""" Provides access to attribute. """
return f"<a href=\"{self._url}\" target=\"_blank\">{self._name}</a>"
@classmethod
def get_coordinates(cls, waypoints):
""" Extract coordinates from a list of waypoints. """
def extractor(w):
# Do not insist in creating new objects here:
return w["location"] if isinstance(w, dict) else w.location
return [extractor(w) for w in waypoints]
class GpxToHtml:
""" Convert a GPX track to an embedable HTML file. """
def __init__(self, trace_path, force=False, dump_png=False):
trace_conf = trace.parent / "track.yaml"
trace_imag = trace.parent / "track.png"
trace_html = trace.parent / "index.html"
if trace_html.exists() and not force:
print(f"Delete {trace_html} and run a new workflow")
else:
self._gpx = self.loadgpx(trace_path)
self._gpc = self.loadcnf(trace_conf)
self._tab = self.tracks2df(self._gpx.tracks)
tiles = Tiles(**self._gpc["tiles"])
grid_interval = self._gpc["grid_interval"]
coordinates = self._tab[["latitude", "longitude"]].to_numpy()
elevation = self._tab["elevation"].to_numpy()
bounds = self.get_bounds(coordinates)
location = np.mean(np.array(coordinates), axis=0)
self._map = self.map_at_location(location, tiles)
self._map.fit_bounds(bounds=bounds)
if waypoints := self._gpc.get("waypoints", None):
self.add_waypoints(bounds, waypoints)
self.add_trace(coordinates, elevation)
self.add_gridlines(bounds, grid_interval)
self._map.save(trace_html)
if dump_png:
self.dump_png(trace_imag)
@staticmethod
def loadgpx(tracepath: Path) -> gpxpy.gpx.GPX:
""" Load GPX file from provided path. """
with open(tracepath, encoding="utf-8") as fp:
gpx = gpxpy.parse(fp)
return gpx
@staticmethod
def loadcnf(cnfpath: Path) -> dict:
""" Load configuration file from provided path. """
with open(cnfpath, encoding="utf-8") as fp:
cnf = yaml.safe_load(fp)
return cnf
@staticmethod
def segments2df(track_no: int, segments: list) -> pd.DataFrame:
""" Convert segments to data-frame. """
route_info = []
for segment in segments:
for point in segment.points:
route_info.append({
"track_no": track_no,
"latitude": point.latitude,
"longitude": point.longitude,
"elevation": point.elevation
})
return pd.DataFrame(route_info)
@staticmethod
def tracks2df(paths: list):
""" Convert all paths provided into a single table. """
routes = []
for track_no, track in enumerate(paths):
# TODO: get metadata with something as track_info.append({})
routes.append(GpxToHtml.segments2df(track_no, track.segments))
return pd.concat(routes)
@staticmethod
def get_bounds(coordinates):
""" Get SW-NE boundaries from trace coordinates. """
lat_min = min(coordinates, key=lambda p: p[0])[0]
lon_min = min(coordinates, key=lambda p: p[1])[1]
lat_max = max(coordinates, key=lambda p: p[0])[0]
lon_max = max(coordinates, key=lambda p: p[1])[1]
bounds = [(lat_min, lon_min), (lat_max, lon_max)]
return bounds
@staticmethod
def map_at_location(location, tiles, opts=None):
""" Create map around given location with provided tiles. """
opts = opts if opts is not None else dict(
width = "100%",
height = "100%",
left = "0%",
top = "0%",
min_zoom = 5,
max_zoom = 18,
position = "relative",
crs = "EPSG3857",
control_scale = True,
prefer_canvas = False,
no_touch = True,
disable_3d = False,
png_enabled = True,
zoom_control = True
)
route_map = folium.Map(location = location, tiles = tiles.url,
attr = tiles.attribution, **opts)
return route_map
@staticmethod
def feed_waypoint(cluster, point, bounds):
""" Feed a single waypoint to map. """
wp = Waypoint(**point)
if not wp.isinside(bounds):
return
popup = folium.Popup(wp.link, min_width=100, max_width=300)
folium.Marker(wp.location, popup=popup).add_to(cluster)
def add_trace(self, coordinates, elevation):
""" Create an elevation-colored trace. """
folium.ColorLine(
positions = coordinates,
weight = 6,
colors = elevation,
colormap = LinearColormap(
colors = ["green", "blue", "red"],
vmin = self._gpc["altitude_range"][0],
vmax = self._gpc["altitude_range"][1]
)
).add_to(self._map)
def add_waypoints(self, bounds, waypoints):
""" Add relevant waypoints to map. """
cluster = MarkerCluster().add_to(self._map)
for point in waypoints:
self.feed_waypoint(cluster, point, bounds)
def add_gridlines(self, bounds, interval):
""" Add gridlines to map with given parameters. """
[(lat_min, lon_min), (lat_max, lon_max)] = bounds
lat_lines = np.arange(
lat_min - interval,
lat_max + interval,
interval
)
lon_lines = np.arange(
lon_min - interval,
lon_max + interval,
interval
)
for lat in lat_lines:
folium.PolyLine(
locations = [[lat, -180], [lat, 180]],
color = "black",
weight = 0.3,
dash_array = "1"
).add_to(self._map)
for lon in lon_lines:
folium.PolyLine(
locations = [[-90, lon], [90, lon]],
color = "black",
weight = 0.3,
dash_array = "1"
).add_to(self._map)
def dump_png(self, saveas, time_out=5.0):
""" Dump map as a PNG file using Selenium. """
img_data = self._map._to_png(time_out)
img_data = io.BytesIO(img_data)
img = Image.open(img_data)
img.save(saveas)
OPENSTREETMAPFR = Tiles(
url = "https://{s}.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png",
attr = "© OpenStreetMap France"
)
OPENTOPOMAP = Tiles(
url = "https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png",
attr = "© OpenTopoMap"
)
CYCLOSM = Tiles(
url = "https://{s}.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png",
attr = "© CyclOSM"
)
MTBMAP = Tiles(
url = "http://tile.mtbmap.cz/mtbmap_tiles/{z}/{x}/{y}.png",
attr = "© MtbMap"
)
if __name__ == "__main__":
project = Path(__file__).resolve().parent / "content"
for trace in (project / "media").glob("**/*.gpx"):
print(f"Woking on trace {trace}")
GpxToHtml(trace, force=False)