1 """Winterrodeln maps can be represented in the GeoJSON format - this is referred to as wrgeojson.
2 This module contains related helper functions.
4 Recommended/used Python packages
6 * To work with a wrgeojson map, it is recommended but not necessary
7 to use the Python [geojson](https://github.com/jazzband/geojson) package.
8 This is especially useful as it provides type annotations for the GeoJSON types.
9 * For coordinate transformation, the [pyproj](https://pyproj4.github.io/pyproj/stable/) package is recommended.
10 * For math with geometric shapes, [shapely](https://github.com/shapely/shapely) is recommended.
13 The definition is kept in sync with the definition of wrmap, available at
14 git+ssh://gitolite@git.toastfreeware.priv.at/philipp/winterrodeln/mediawiki_extensions/wrmap.git
17 A Winterrodeln map is represented as `feature collection`.
18 Feature collections must not have a JSON name "properties",
19 but accidentally this name was used to store map related Winterrodeln specific
21 Those properties were:
23 * 'lat': float: latitude of map-center, optional.
24 * 'lon': float: longitude of map-center, optional.
25 * 'zoom': int: zoom level of the map (google zoom levels). optional.
26 * 'width': int: width of the map in pixel. optional (100% if omitted)
27 * 'height': int: height of the map in pixel. optional.
28 * 'under_construction': bool: whether the map is unfinished, optional.
29 * 'simplified': bool: whether the map was simplified (usually with simplify_ways()), optional.
31 The new maps will stick to the GeoJSON definition https://datatracker.ietf.org/doc/html/rfc7946
32 and use an optional foreign member object with the name "wr_properties", where the following optional names
35 * 'lat': float: latitude of map-center, optional and deprecated in favor of vbox.
36 * 'lon': float: longitude of map-center, optional and deprecated in favor of vbox.
37 * 'zoom': int: zoom level of the map (google zoom levels), optional and deprecated in favor of vbox.
38 * 'width': int: width of the map in pixel, optional and deprecated in favor of vbox (100% if omitted).
39 * 'height': int: height of the map in pixel, optional and deprecated in favor of vbox.
40 * 'vbox': array of 4 floats, optional. Same meaning as bbox but contains the map region that should be shown by default.
41 * 'under_construction': bool: whether the map is unfinished, optional. Default: False
42 * 'simplified': bool: whether the map was simplified (usually with simplify_ways()), optional.
43 Currently all maps are converted to be not simplified anymore so in the future this name will be deprecated.
46 For each feature inside the feature collection the following rules apply:
48 * The supported feature geometries are of type Point or LineString.
49 * Each feature has a property called type with a string value.
50 * The type of each Point feature is one of
59 * The type of each LineString feature is one of
71 "type": "FeatureCollection",
75 "geometry": {"type": "Point", "coordinates": [11.42134, 47.302911]},
79 "wiki": "Rumer Alm (Gasthaus)"
84 "geometry": {"type": "Point", "coordinates": [11.448077, 47.288761]},
86 "type": "haltestelle",
87 "name": "Rum Sanatorium"
95 [11.435297, 47.297118],
96 [11.434738, 47.297678],
97 [11.434973, 47.299145]
107 "type": "LineString",
109 [11.420065, 47.29844],
110 [11.418975, 47.298389],
111 [11.417893, 47.29784]
115 "type": "alternative"
121 "type": "LineString",
123 [11.421153, 47.302928],
124 [11.418656, 47.303768],
125 [11.439608, 47.293187],
126 [11.441071, 47.291241]
143 Recipies for working with GeoJSON:
149 with open('wrgeojson.geojson') as fp:
153 Distance between two points:
155 from pyproj import Geod # https://pyproj4.github.io/pyproj/stable/
156 from shapely.geometry import Point, LineString
158 geod = Geod(ellps="WGS84")
159 p1 = Point(11.404997, 47.283445)
160 p2 = Point(11.405752, 47.284616)
161 line_string = LineString([p1, p2])
167 from pyproj import CRS, Transformer
168 crs_from = CRS.from_proj4("+proj=latlon")
169 crs_to = CRS.from_proj4('+proj=merc +lat_ts=47.3')
170 transformer = Transformer.from_crs(crs_from, crs_to, always_xy=True)
171 p1t = transformer.transform(*list(*p1.coords))
172 p2t = transformer.transform(*list(*p2.coords))
174 math.sqrt((p2t[0]-p1t[0])**2 + (p2t[1]-p1t[1])**2) # 142.12323235553566
179 p1s = shapely.geometry.shape(p1)
183 Distance/similarity between two lines:
184 shapely.hausdorff_distance might be used.
187 from collections import Counter
188 from copy import deepcopy
189 from enum import Enum
190 from typing import Tuple, List
194 import shapely.geometry
195 from geojson import FeatureCollection, Feature, Point, GeoJSON
196 from pyproj import CRS, Transformer
199 class WrPointFeatureType(Enum):
200 gastronomy = 'gasthaus'
201 bus_stop = 'haltestelle'
202 parking = 'parkplatz'
209 class WrLineStringFeatureType(Enum):
211 alternative = 'alternative'
218 def line_point_dist(lon_lat_1: Tuple[float, ...], lon_lat_2: Tuple[float, ...],
219 lon_lat: Tuple[float, ...]) -> float:
220 """Returns the distance in meter between the line defined by
222 and the point defined by lon_lat."""
223 # Do the calculation in m
224 earth_radius = 6367500. # earth radius in m
225 deg_to_rad = math.pi / 180.
226 lat_to_meter = deg_to_rad * earth_radius
228 if lon_lat_1 == lon_lat_2:
229 dx = (lon_lat[0] - lon_lat_1[0]) * math.cos(lon_lat_1[0]) * lat_to_meter
230 dy = (lon_lat[1] - lon_lat_1[1]) * lat_to_meter
231 return math.sqrt(dx ** 2 + dy ** 2)
233 # d = lon_lat_2 - lon_lat_1
234 # e = lon_lat - lon_lat_1
236 mean_lat_rad = (lon_lat_1[1] + lon_lat_2[1] + lon_lat[1]) / 3. * deg_to_rad
238 dx = (lon_lat_2[0] - lon_lat_1[0]) * math.cos(mean_lat_rad) * lat_to_meter
239 dy = (lon_lat_2[1] - lon_lat_1[1]) * lat_to_meter
240 ex = (lon_lat[0] - lon_lat_1[0]) * math.cos(mean_lat_rad) * lat_to_meter
241 ey = (lon_lat[1] - lon_lat_1[1]) * lat_to_meter
243 ds = dx ** 2 + dy ** 2
244 es = ex ** 2 + ey ** 2
245 xs = es - (dx * ex + dy * ey) ** 2 / ds
247 return 0. # due to rounding errors xs might be e.g. -1e-23
251 def simplify_way(lon_lat_list: List[Tuple[float, ...]], max_dist: float):
252 """Simplify lon_lat_list by removing points in-place. The introduced error must not exceed max_dist (meter)."""
253 dist_list = [] # dist_list[i] is the distance between
254 # the line lon_lat_list[i] <-> lon_lat_list[i+2]
255 # and the point lon_lat_list[i+1].
256 # len(dist_list) == len(lon_lat_list)-2
258 # Initialize dist_list
259 for i in range(len(lon_lat_list) - 2):
260 dist_list.append(line_point_dist(lon_lat_list[i], lon_lat_list[i + 2], lon_lat_list[i + 1]))
262 while len(dist_list) > 0:
263 min_index = min(range(len(dist_list)), key=dist_list.__getitem__) # same as index(min(dist_list))
264 if dist_list[min_index] > max_dist:
266 del lon_lat_list[min_index + 1]
267 del dist_list[min_index]
270 dist_list[min_index - 1] = line_point_dist(lon_lat_list[min_index - 1], lon_lat_list[min_index + 1],
271 lon_lat_list[min_index],)
272 if min_index < len(dist_list):
273 dist_list[min_index] = line_point_dist(lon_lat_list[min_index], lon_lat_list[min_index + 2],
274 lon_lat_list[min_index + 1])
277 DEFAULT_MAX_DIST_ERROR_M = 15.
280 def simplify_ways(wrgeojson: FeatureCollection, max_dist: float = DEFAULT_MAX_DIST_ERROR_M):
281 ways = [feature for feature in wrgeojson['features'] if feature["geometry"]["type"] == "LineString"]
283 simplify_way(way['geometry']['coordinates'], max_dist)
286 def get_geometry_center(wrgeojson: GeoJSON) -> Point:
287 return Point(get_geometry_envelope(wrgeojson).centroid.coords[0])
290 def get_geometry_points(wrgeojson: GeoJSON) -> shapely.geometry.LineString:
291 return shapely.geometry.LineString(c[:2] for c in geojson.coords(wrgeojson))
294 def get_geometry_envelope(wrgeojson: GeoJSON) -> shapely.geometry.Polygon:
295 return get_geometry_points(wrgeojson).envelope
298 def get_geometry_bbox(wrgeojson: GeoJSON) -> Tuple[float, float, float, float]:
299 return get_geometry_envelope(wrgeojson).bounds
302 def sort_features(wrgeojson: FeatureCollection) -> FeatureCollection:
303 """Sort the features of the sledruns in preferred order of rendering"""
304 def sort_key(feature: Feature) -> Tuple[int, int]:
305 geometry_type = feature['geometry']['type']
306 feature_type = feature['properties'].get('type')
307 if geometry_type == 'LineString':
308 # left types are drawn first
309 order = [None, 'linie', 'anfahrt', 'gehweg', 'alternative', 'rodelbahn', 'lift']
310 return 1, order.index(feature_type)
312 order = ['gasthaus', 'haltestelle', 'parkplatz', 'warnung', 'punkt'] # the left types are coming first
313 return 0, order.index(feature_type)
314 wrgeojson.features = sorted(wrgeojson.features, key=sort_key)
318 def join_wrgeojson_ways(wrgeojson: FeatureCollection, preserve_crossing=False):
319 """Unite ways of the same type of sled runs."""
320 # Get crossing (lon, lat) tuples as list.
321 # Crossings are positions (lon, lat) that occur at least three way-edges.
322 ways = [feature for feature in wrgeojson['features'] if feature["geometry"]["type"] == "LineString"]
323 coord_count = Counter(geojson.utils.coords(ways))
324 crossing_list = [coord for coord, count in coord_count.items() if count >= 3]
326 i = 0 # index within ways
328 # search for ways to combine (they have to have the same type)
329 if len(ways[i]['geometry']['coordinates']) == 0:
335 if ways[i]['properties']['type'] != ways[j]['properties']['type'] \
336 or len(ways[j]['geometry']['coordinates']) == 0:
339 lon_lat = ways[i]['geometry']['coordinates'][-1]
340 if not preserve_crossing or lon_lat not in crossing_list:
342 if lon_lat == ways[j]['geometry']['coordinates'][0]:
344 ways[i]['geometry']['coordinates'].extend(ways[j]['geometry']['coordinates'][1:])
346 elif lon_lat == ways[j]['geometry']['coordinates'][-1]:
347 # Append reversed way
348 ways[i]['geometry']['coordinates'].extend(ways[j]['geometry']['coordinates'][-2::-1])
351 lon_lat = ways[i]['geometry']['coordinates'][0]
352 if lon_lat == ways[j]['geometry']['coordinates'][-1]:
354 ways[i]['geometry']['coordinates'] = ways[j]['geometry']['coordinates'][:-1] + \
355 ways[i]['geometry']['coordinates']
357 elif lon_lat == ways[j]['geometry']['coordinates'][0]:
358 # Prepend reversed way
359 ways[i]['geometry']['coordinates'] = ways[j]['geometry']['coordinates'][:0:-1] + \
360 ways[i]['geometry']['coordinates']
363 ways[j]['geometry']['coordinates'].clear()
366 wrgeojson['features'] = [f for f in wrgeojson['features']
367 if f['geometry']['type'] != 'LineString' or len(f['geometry']['coordinates']) > 0]
370 class LanToToMetricTransformer:
371 def __init__(self, latitude: float):
372 crs_from = CRS.from_proj4("+proj=latlon")
373 crs_to = CRS.from_proj4(f'+proj=merc +lat_ts={latitude:.3f}')
374 self.transformer = Transformer.from_crs(crs_from, crs_to, always_xy=True)
376 def tuple_transformer(self, lon_lat: Tuple[float, float]) -> Tuple[float, float]:
377 return self.transformer.transform(lon_lat[0], lon_lat[1])
379 def geojson_lon_lat_to_metric(self, gj: GeoJSON) ->GeoJSON:
381 geojson.utils.map_tuples(self.tuple_transformer, gj)
385 def geojson_lon_lat_to_metric(gj: GeoJSON) -> GeoJSON:
386 """Converts the coordinates of GeoJSON FeatureCollection as well as other GeoJSON objects to a metric
387 coordinate system."""
388 center = get_geometry_center(gj)
389 transformer = LanToToMetricTransformer(center.coordinates[1])
390 return transformer.geojson_lon_lat_to_metric(gj)