Sideband/sbapp/mapview/geojson.py
2023-10-19 15:01:17 +02:00

388 lines
11 KiB
Python

# coding=utf-8
"""
Geojson layer
=============
.. note::
Currently experimental and a work in progress, not fully optimized.
Supports:
- html color in properties
- polygon geometry are cached and not redrawed when the parent mapview changes
- linestring are redrawed everymove, it's ugly and slow.
- marker are NOT supported
"""
__all__ = ["GeoJsonMapLayer"]
import json
from kivy.graphics import (
Canvas,
Color,
Line,
MatrixInstruction,
Mesh,
PopMatrix,
PushMatrix,
Scale,
Translate,
)
from kivy.graphics.tesselator import TYPE_POLYGONS, WINDING_ODD, Tesselator
from kivy.metrics import dp
from kivy.properties import ObjectProperty, StringProperty
from kivy.utils import get_color_from_hex
from mapview.constants import CACHE_DIR
from mapview.downloader import Downloader
from mapview.view import MapLayer
COLORS = {
'aliceblue': '#f0f8ff',
'antiquewhite': '#faebd7',
'aqua': '#00ffff',
'aquamarine': '#7fffd4',
'azure': '#f0ffff',
'beige': '#f5f5dc',
'bisque': '#ffe4c4',
'black': '#000000',
'blanchedalmond': '#ffebcd',
'blue': '#0000ff',
'blueviolet': '#8a2be2',
'brown': '#a52a2a',
'burlywood': '#deb887',
'cadetblue': '#5f9ea0',
'chartreuse': '#7fff00',
'chocolate': '#d2691e',
'coral': '#ff7f50',
'cornflowerblue': '#6495ed',
'cornsilk': '#fff8dc',
'crimson': '#dc143c',
'cyan': '#00ffff',
'darkblue': '#00008b',
'darkcyan': '#008b8b',
'darkgoldenrod': '#b8860b',
'darkgray': '#a9a9a9',
'darkgrey': '#a9a9a9',
'darkgreen': '#006400',
'darkkhaki': '#bdb76b',
'darkmagenta': '#8b008b',
'darkolivegreen': '#556b2f',
'darkorange': '#ff8c00',
'darkorchid': '#9932cc',
'darkred': '#8b0000',
'darksalmon': '#e9967a',
'darkseagreen': '#8fbc8f',
'darkslateblue': '#483d8b',
'darkslategray': '#2f4f4f',
'darkslategrey': '#2f4f4f',
'darkturquoise': '#00ced1',
'darkviolet': '#9400d3',
'deeppink': '#ff1493',
'deepskyblue': '#00bfff',
'dimgray': '#696969',
'dimgrey': '#696969',
'dodgerblue': '#1e90ff',
'firebrick': '#b22222',
'floralwhite': '#fffaf0',
'forestgreen': '#228b22',
'fuchsia': '#ff00ff',
'gainsboro': '#dcdcdc',
'ghostwhite': '#f8f8ff',
'gold': '#ffd700',
'goldenrod': '#daa520',
'gray': '#808080',
'grey': '#808080',
'green': '#008000',
'greenyellow': '#adff2f',
'honeydew': '#f0fff0',
'hotpink': '#ff69b4',
'indianred': '#cd5c5c',
'indigo': '#4b0082',
'ivory': '#fffff0',
'khaki': '#f0e68c',
'lavender': '#e6e6fa',
'lavenderblush': '#fff0f5',
'lawngreen': '#7cfc00',
'lemonchiffon': '#fffacd',
'lightblue': '#add8e6',
'lightcoral': '#f08080',
'lightcyan': '#e0ffff',
'lightgoldenrodyellow': '#fafad2',
'lightgray': '#d3d3d3',
'lightgrey': '#d3d3d3',
'lightgreen': '#90ee90',
'lightpink': '#ffb6c1',
'lightsalmon': '#ffa07a',
'lightseagreen': '#20b2aa',
'lightskyblue': '#87cefa',
'lightslategray': '#778899',
'lightslategrey': '#778899',
'lightsteelblue': '#b0c4de',
'lightyellow': '#ffffe0',
'lime': '#00ff00',
'limegreen': '#32cd32',
'linen': '#faf0e6',
'magenta': '#ff00ff',
'maroon': '#800000',
'mediumaquamarine': '#66cdaa',
'mediumblue': '#0000cd',
'mediumorchid': '#ba55d3',
'mediumpurple': '#9370d8',
'mediumseagreen': '#3cb371',
'mediumslateblue': '#7b68ee',
'mediumspringgreen': '#00fa9a',
'mediumturquoise': '#48d1cc',
'mediumvioletred': '#c71585',
'midnightblue': '#191970',
'mintcream': '#f5fffa',
'mistyrose': '#ffe4e1',
'moccasin': '#ffe4b5',
'navajowhite': '#ffdead',
'navy': '#000080',
'oldlace': '#fdf5e6',
'olive': '#808000',
'olivedrab': '#6b8e23',
'orange': '#ffa500',
'orangered': '#ff4500',
'orchid': '#da70d6',
'palegoldenrod': '#eee8aa',
'palegreen': '#98fb98',
'paleturquoise': '#afeeee',
'palevioletred': '#d87093',
'papayawhip': '#ffefd5',
'peachpuff': '#ffdab9',
'peru': '#cd853f',
'pink': '#ffc0cb',
'plum': '#dda0dd',
'powderblue': '#b0e0e6',
'purple': '#800080',
'red': '#ff0000',
'rosybrown': '#bc8f8f',
'royalblue': '#4169e1',
'saddlebrown': '#8b4513',
'salmon': '#fa8072',
'sandybrown': '#f4a460',
'seagreen': '#2e8b57',
'seashell': '#fff5ee',
'sienna': '#a0522d',
'silver': '#c0c0c0',
'skyblue': '#87ceeb',
'slateblue': '#6a5acd',
'slategray': '#708090',
'slategrey': '#708090',
'snow': '#fffafa',
'springgreen': '#00ff7f',
'steelblue': '#4682b4',
'tan': '#d2b48c',
'teal': '#008080',
'thistle': '#d8bfd8',
'tomato': '#ff6347',
'turquoise': '#40e0d0',
'violet': '#ee82ee',
'wheat': '#f5deb3',
'white': '#ffffff',
'whitesmoke': '#f5f5f5',
'yellow': '#ffff00',
'yellowgreen': '#9acd32',
}
def flatten(lst):
return [item for sublist in lst for item in sublist]
class GeoJsonMapLayer(MapLayer):
source = StringProperty()
geojson = ObjectProperty()
cache_dir = StringProperty(CACHE_DIR)
def __init__(self, **kwargs):
self.first_time = True
self.initial_zoom = None
super().__init__(**kwargs)
with self.canvas:
self.canvas_polygon = Canvas()
self.canvas_line = Canvas()
with self.canvas_polygon.before:
PushMatrix()
self.g_matrix = MatrixInstruction()
self.g_scale = Scale()
self.g_translate = Translate()
with self.canvas_polygon:
self.g_canvas_polygon = Canvas()
with self.canvas_polygon.after:
PopMatrix()
def reposition(self):
vx, vy = self.parent.delta_x, self.parent.delta_y
pzoom = self.parent.zoom
zoom = self.initial_zoom
if zoom is None:
self.initial_zoom = zoom = pzoom
if zoom != pzoom:
diff = 2 ** (pzoom - zoom)
vx /= diff
vy /= diff
self.g_scale.x = self.g_scale.y = diff
else:
self.g_scale.x = self.g_scale.y = 1.0
self.g_translate.xy = vx, vy
self.g_matrix.matrix = self.parent._scatter.transform
if self.geojson:
update = not self.first_time
self.on_geojson(self, self.geojson, update=update)
self.first_time = False
def traverse_feature(self, func, part=None):
"""Traverse the whole geojson and call the func with every element
found.
"""
if part is None:
part = self.geojson
if not part:
return
tp = part["type"]
if tp == "FeatureCollection":
for feature in part["features"]:
func(feature)
elif tp == "Feature":
func(part)
@property
def bounds(self):
# return the min lon, max lon, min lat, max lat
bounds = [float("inf"), float("-inf"), float("inf"), float("-inf")]
def _submit_coordinate(coord):
lon, lat = coord
bounds[0] = min(bounds[0], lon)
bounds[1] = max(bounds[1], lon)
bounds[2] = min(bounds[2], lat)
bounds[3] = max(bounds[3], lat)
def _get_bounds(feature):
geometry = feature["geometry"]
tp = geometry["type"]
if tp == "Point":
_submit_coordinate(geometry["coordinates"])
elif tp == "Polygon":
for coordinate in geometry["coordinates"][0]:
_submit_coordinate(coordinate)
elif tp == "MultiPolygon":
for polygon in geometry["coordinates"]:
for coordinate in polygon[0]:
_submit_coordinate(coordinate)
self.traverse_feature(_get_bounds)
return bounds
@property
def center(self):
min_lon, max_lon, min_lat, max_lat = self.bounds
cx = (max_lon - min_lon) / 2.0
cy = (max_lat - min_lat) / 2.0
return min_lon + cx, min_lat + cy
def on_geojson(self, instance, geojson, update=False):
if self.parent is None:
return
if not update:
self.g_canvas_polygon.clear()
self._geojson_part(geojson, geotype="Polygon")
self.canvas_line.clear()
self._geojson_part(geojson, geotype="LineString")
def on_source(self, instance, value):
if value.startswith(("http://", "https://")):
Downloader.instance(cache_dir=self.cache_dir).download(
value, self._load_geojson_url
)
else:
with open(value, "rb") as fd:
geojson = json.load(fd)
self.geojson = geojson
def _load_geojson_url(self, url, response):
self.geojson = response.json()
def _geojson_part(self, part, geotype=None):
tp = part["type"]
if tp == "FeatureCollection":
for feature in part["features"]:
if geotype and feature["geometry"]["type"] != geotype:
continue
self._geojson_part_f(feature)
elif tp == "Feature":
if geotype and part["geometry"]["type"] == geotype:
self._geojson_part_f(part)
else:
# unhandled geojson part
pass
def _geojson_part_f(self, feature):
properties = feature["properties"]
geometry = feature["geometry"]
graphics = self._geojson_part_geometry(geometry, properties)
for g in graphics:
tp = geometry["type"]
if tp == "Polygon":
self.g_canvas_polygon.add(g)
else:
self.canvas_line.add(g)
def _geojson_part_geometry(self, geometry, properties):
tp = geometry["type"]
self.tp = tp
graphics = []
if tp == "Polygon":
tess = Tesselator()
for c in geometry["coordinates"]:
xy = list(self._lonlat_to_xy(c))
xy = flatten(xy)
tess.add_contour(xy)
tess.tesselate(WINDING_ODD, TYPE_POLYGONS)
color = self._get_color_from(properties.get("color", "FF000088"))
graphics.append(Color(*color))
for vertices, indices in tess.meshes:
graphics.append(
Mesh(vertices=vertices, indices=indices, mode="triangle_fan")
)
elif tp == "LineString":
stroke = get_color_from_hex(properties.get("stroke", "#ffffff"))
stroke_width = dp(properties.get("stroke-width"))
xy = list(self._lonlat_to_xy(geometry["coordinates"]))
xy = flatten(xy)
graphics.append(Color(*stroke))
graphics.append(Line(points=xy, width=stroke_width))
return graphics
def _lonlat_to_xy(self, lonlats):
view = self.parent
zoom = view.zoom
for lon, lat in lonlats:
p = view.get_window_xy_from(lat, lon, zoom)
# Make LineString and Polygon works at the same time
if self.tp == "Polygon":
p = p[0] - self.parent.delta_x, p[1] - self.parent.delta_y
p = self.parent._scatter.to_local(*p)
yield p
def _get_color_from(self, value):
color = COLORS.get(value.lower(), value)
color = get_color_from_hex(color)
return color