388 lines
11 KiB
Python
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
|