Added mapview module

This commit is contained in:
Mark Qvist 2023-10-19 15:01:17 +02:00
parent 4a6dfa4a47
commit 404649f805
13 changed files with 2407 additions and 0 deletions

27
sbapp/mapview/__init__.py Normal file
View File

@ -0,0 +1,27 @@
# coding=utf-8
"""
MapView
=======
MapView is a Kivy widget that display maps.
"""
from mapview.source import MapSource
from mapview.types import Bbox, Coordinate
from mapview.view import (
MapLayer,
MapMarker,
MapMarkerPopup,
MapView,
MarkerMapLayer,
)
__all__ = [
"Coordinate",
"Bbox",
"MapView",
"MapSource",
"MapMarker",
"MapLayer",
"MarkerMapLayer",
"MapMarkerPopup",
]

View File

@ -0,0 +1 @@
__version__ = "1.0.6"

View File

@ -0,0 +1,449 @@
# coding=utf-8
"""
Layer that support point clustering
===================================
"""
from math import atan, exp, floor, log, pi, sin, sqrt
from os.path import dirname, join
from kivy.lang import Builder
from kivy.metrics import dp
from kivy.properties import (
ListProperty,
NumericProperty,
ObjectProperty,
StringProperty,
)
from mapview.view import MapLayer, MapMarker
Builder.load_string(
"""
<ClusterMapMarker>:
size_hint: None, None
source: root.source
size: list(map(dp, self.texture_size))
allow_stretch: True
Label:
color: root.text_color
pos: root.pos
size: root.size
text: "{}".format(root.num_points)
font_size: dp(18)
"""
)
# longitude/latitude to spherical mercator in [0..1] range
def lngX(lng):
return lng / 360.0 + 0.5
def latY(lat):
if lat == 90:
return 0
if lat == -90:
return 1
s = sin(lat * pi / 180.0)
y = 0.5 - 0.25 * log((1 + s) / (1 - s)) / pi
return min(1, max(0, y))
# spherical mercator to longitude/latitude
def xLng(x):
return (x - 0.5) * 360
def yLat(y):
y2 = (180 - y * 360) * pi / 180
return 360 * atan(exp(y2)) / pi - 90
class KDBush:
"""
kdbush implementation from:
https://github.com/mourner/kdbush/blob/master/src/kdbush.js
"""
def __init__(self, points, node_size=64):
self.points = points
self.node_size = node_size
self.ids = ids = [0] * len(points)
self.coords = coords = [0] * len(points) * 2
for i, point in enumerate(points):
ids[i] = i
coords[2 * i] = point.x
coords[2 * i + 1] = point.y
self._sort(ids, coords, node_size, 0, len(ids) - 1, 0)
def range(self, min_x, min_y, max_x, max_y):
return self._range(
self.ids, self.coords, min_x, min_y, max_x, max_y, self.node_size
)
def within(self, x, y, r):
return self._within(self.ids, self.coords, x, y, r, self.node_size)
def _sort(self, ids, coords, node_size, left, right, depth):
if right - left <= node_size:
return
m = int(floor((left + right) / 2.0))
self._select(ids, coords, m, left, right, depth % 2)
self._sort(ids, coords, node_size, left, m - 1, depth + 1)
self._sort(ids, coords, node_size, m + 1, right, depth + 1)
def _select(self, ids, coords, k, left, right, inc):
swap_item = self._swap_item
while right > left:
if (right - left) > 600:
n = float(right - left + 1)
m = k - left + 1
z = log(n)
s = 0.5 + exp(2 * z / 3.0)
sd = 0.5 * sqrt(z * s * (n - s) / n) * (-1 if (m - n / 2.0) < 0 else 1)
new_left = max(left, int(floor(k - m * s / n + sd)))
new_right = min(right, int(floor(k + (n - m) * s / n + sd)))
self._select(ids, coords, k, new_left, new_right, inc)
t = coords[2 * k + inc]
i = left
j = right
swap_item(ids, coords, left, k)
if coords[2 * right + inc] > t:
swap_item(ids, coords, left, right)
while i < j:
swap_item(ids, coords, i, j)
i += 1
j -= 1
while coords[2 * i + inc] < t:
i += 1
while coords[2 * j + inc] > t:
j -= 1
if coords[2 * left + inc] == t:
swap_item(ids, coords, left, j)
else:
j += 1
swap_item(ids, coords, j, right)
if j <= k:
left = j + 1
if k <= j:
right = j - 1
def _swap_item(self, ids, coords, i, j):
swap = self._swap
swap(ids, i, j)
swap(coords, 2 * i, 2 * j)
swap(coords, 2 * i + 1, 2 * j + 1)
def _swap(self, arr, i, j):
tmp = arr[i]
arr[i] = arr[j]
arr[j] = tmp
def _range(self, ids, coords, min_x, min_y, max_x, max_y, node_size):
stack = [0, len(ids) - 1, 0]
result = []
x = y = 0
while stack:
axis = stack.pop()
right = stack.pop()
left = stack.pop()
if right - left <= node_size:
for i in range(left, right + 1):
x = coords[2 * i]
y = coords[2 * i + 1]
if x >= min_x and x <= max_x and y >= min_y and y <= max_y:
result.append(ids[i])
continue
m = int(floor((left + right) / 2.0))
x = coords[2 * m]
y = coords[2 * m + 1]
if x >= min_x and x <= max_x and y >= min_y and y <= max_y:
result.append(ids[m])
nextAxis = (axis + 1) % 2
if min_x <= x if axis == 0 else min_y <= y:
stack.append(left)
stack.append(m - 1)
stack.append(nextAxis)
if max_x >= x if axis == 0 else max_y >= y:
stack.append(m + 1)
stack.append(right)
stack.append(nextAxis)
return result
def _within(self, ids, coords, qx, qy, r, node_size):
sq_dist = self._sq_dist
stack = [0, len(ids) - 1, 0]
result = []
r2 = r * r
while stack:
axis = stack.pop()
right = stack.pop()
left = stack.pop()
if right - left <= node_size:
for i in range(left, right + 1):
if sq_dist(coords[2 * i], coords[2 * i + 1], qx, qy) <= r2:
result.append(ids[i])
continue
m = int(floor((left + right) / 2.0))
x = coords[2 * m]
y = coords[2 * m + 1]
if sq_dist(x, y, qx, qy) <= r2:
result.append(ids[m])
nextAxis = (axis + 1) % 2
if (qx - r <= x) if axis == 0 else (qy - r <= y):
stack.append(left)
stack.append(m - 1)
stack.append(nextAxis)
if (qx + r >= x) if axis == 0 else (qy + r >= y):
stack.append(m + 1)
stack.append(right)
stack.append(nextAxis)
return result
def _sq_dist(self, ax, ay, bx, by):
dx = ax - bx
dy = ay - by
return dx * dx + dy * dy
class Cluster:
def __init__(self, x, y, num_points, id, props):
self.x = x
self.y = y
self.num_points = num_points
self.zoom = float("inf")
self.id = id
self.props = props
self.parent_id = None
self.widget = None
# preprocess lon/lat
self.lon = xLng(x)
self.lat = yLat(y)
class Marker:
def __init__(self, lon, lat, cls=MapMarker, options=None):
self.lon = lon
self.lat = lat
self.cls = cls
self.options = options
# preprocess x/y from lon/lat
self.x = lngX(lon)
self.y = latY(lat)
# cluster information
self.id = None
self.zoom = float("inf")
self.parent_id = None
self.widget = None
def __repr__(self):
return "<Marker lon={} lat={} source={}>".format(
self.lon, self.lat, self.source
)
class SuperCluster:
"""Port of supercluster from mapbox in pure python
"""
def __init__(self, min_zoom=0, max_zoom=16, radius=40, extent=512, node_size=64):
self.min_zoom = min_zoom
self.max_zoom = max_zoom
self.radius = radius
self.extent = extent
self.node_size = node_size
def load(self, points):
"""Load an array of markers.
Once loaded, the index is immutable.
"""
from time import time
self.trees = {}
self.points = points
for index, point in enumerate(points):
point.id = index
clusters = points
for z in range(self.max_zoom, self.min_zoom - 1, -1):
start = time()
print("build tree", z)
self.trees[z + 1] = KDBush(clusters, self.node_size)
print("kdbush", (time() - start) * 1000)
start = time()
clusters = self._cluster(clusters, z)
print(len(clusters))
print("clustering", (time() - start) * 1000)
self.trees[self.min_zoom] = KDBush(clusters, self.node_size)
def get_clusters(self, bbox, zoom):
"""For the given bbox [westLng, southLat, eastLng, northLat], and
integer zoom, returns an array of clusters and markers
"""
tree = self.trees[self._limit_zoom(zoom)]
ids = tree.range(lngX(bbox[0]), latY(bbox[3]), lngX(bbox[2]), latY(bbox[1]))
clusters = []
for i in range(len(ids)):
c = tree.points[ids[i]]
if isinstance(c, Cluster):
clusters.append(c)
else:
clusters.append(self.points[c.id])
return clusters
def _limit_zoom(self, z):
return max(self.min_zoom, min(self.max_zoom + 1, z))
def _cluster(self, points, zoom):
clusters = []
c_append = clusters.append
trees = self.trees
r = self.radius / float(self.extent * pow(2, zoom))
# loop through each point
for i in range(len(points)):
p = points[i]
# if we've already visited the point at this zoom level, skip it
if p.zoom <= zoom:
continue
p.zoom = zoom
# find all nearby points
tree = trees[zoom + 1]
neighbor_ids = tree.within(p.x, p.y, r)
num_points = 1
if isinstance(p, Cluster):
num_points = p.num_points
wx = p.x * num_points
wy = p.y * num_points
props = None
for j in range(len(neighbor_ids)):
b = tree.points[neighbor_ids[j]]
# filter out neighbors that are too far or already processed
if zoom < b.zoom:
num_points2 = 1
if isinstance(b, Cluster):
num_points2 = b.num_points
# save the zoom (so it doesn't get processed twice)
b.zoom = zoom
# accumulate coordinates for calculating weighted center
wx += b.x * num_points2
wy += b.y * num_points2
num_points += num_points2
b.parent_id = i
if num_points == 1:
c_append(p)
else:
p.parent_id = i
c_append(
Cluster(wx / num_points, wy / num_points, num_points, i, props)
)
return clusters
class ClusterMapMarker(MapMarker):
source = StringProperty(join(dirname(__file__), "icons", "cluster.png"))
cluster = ObjectProperty()
num_points = NumericProperty()
text_color = ListProperty([0.1, 0.1, 0.1, 1])
def on_cluster(self, instance, cluster):
self.num_points = cluster.num_points
def on_touch_down(self, touch):
return False
class ClusteredMarkerLayer(MapLayer):
cluster_cls = ObjectProperty(ClusterMapMarker)
cluster_min_zoom = NumericProperty(0)
cluster_max_zoom = NumericProperty(16)
cluster_radius = NumericProperty("40dp")
cluster_extent = NumericProperty(512)
cluster_node_size = NumericProperty(64)
def __init__(self, **kwargs):
self.cluster = None
self.cluster_markers = []
super().__init__(**kwargs)
def add_marker(self, lon, lat, cls=MapMarker, options=None):
if options is None:
options = {}
marker = Marker(lon, lat, cls, options)
self.cluster_markers.append(marker)
return marker
def remove_marker(self, marker):
self.cluster_markers.remove(marker)
def reposition(self):
if self.cluster is None:
self.build_cluster()
margin = dp(48)
mapview = self.parent
set_marker_position = self.set_marker_position
bbox = mapview.get_bbox(margin)
bbox = (bbox[1], bbox[0], bbox[3], bbox[2])
self.clear_widgets()
for point in self.cluster.get_clusters(bbox, mapview.zoom):
widget = point.widget
if widget is None:
widget = self.create_widget_for(point)
set_marker_position(mapview, widget)
self.add_widget(widget)
def build_cluster(self):
self.cluster = SuperCluster(
min_zoom=self.cluster_min_zoom,
max_zoom=self.cluster_max_zoom,
radius=self.cluster_radius,
extent=self.cluster_extent,
node_size=self.cluster_node_size,
)
self.cluster.load(self.cluster_markers)
def create_widget_for(self, point):
if isinstance(point, Marker):
point.widget = point.cls(lon=point.lon, lat=point.lat, **point.options)
elif isinstance(point, Cluster):
point.widget = self.cluster_cls(lon=point.lon, lat=point.lat, cluster=point)
return point.widget
def set_marker_position(self, mapview, marker):
x, y = mapview.get_window_xy_from(marker.lat, marker.lon, mapview.zoom)
marker.x = int(x - marker.width * marker.anchor_x)
marker.y = int(y - marker.height * marker.anchor_y)

View File

@ -0,0 +1,5 @@
MIN_LATITUDE = -90.0
MAX_LATITUDE = 90.0
MIN_LONGITUDE = -180.0
MAX_LONGITUDE = 180.0
CACHE_DIR = "cache"

123
sbapp/mapview/downloader.py Normal file
View File

@ -0,0 +1,123 @@
# coding=utf-8
__all__ = ["Downloader"]
import logging
import traceback
from concurrent.futures import ThreadPoolExecutor, TimeoutError, as_completed
from os import environ, makedirs
from os.path import exists, join
from random import choice
from time import time
import requests
from kivy.clock import Clock
from kivy.logger import LOG_LEVELS, Logger
from mapview.constants import CACHE_DIR
if "MAPVIEW_DEBUG_DOWNLOADER" in environ:
Logger.setLevel(LOG_LEVELS['debug'])
# user agent is needed because since may 2019 OSM gives me a 429 or 403 server error
# I tried it with a simpler one (just Mozilla/5.0) this also gets rejected
USER_AGENT = 'Kivy-garden.mapview'
class Downloader:
_instance = None
MAX_WORKERS = 5
CAP_TIME = 0.064 # 15 FPS
@staticmethod
def instance(cache_dir=None):
if Downloader._instance is None:
if not cache_dir:
cache_dir = CACHE_DIR
Downloader._instance = Downloader(cache_dir=cache_dir)
return Downloader._instance
def __init__(self, max_workers=None, cap_time=None, **kwargs):
self.cache_dir = kwargs.get('cache_dir', CACHE_DIR)
if max_workers is None:
max_workers = Downloader.MAX_WORKERS
if cap_time is None:
cap_time = Downloader.CAP_TIME
self.is_paused = False
self.cap_time = cap_time
self.executor = ThreadPoolExecutor(max_workers=max_workers)
self._futures = []
Clock.schedule_interval(self._check_executor, 1 / 60.0)
if not exists(self.cache_dir):
makedirs(self.cache_dir)
def submit(self, f, *args, **kwargs):
future = self.executor.submit(f, *args, **kwargs)
self._futures.append(future)
def download_tile(self, tile):
Logger.debug(
"Downloader: queue(tile) zoom={} x={} y={}".format(
tile.zoom, tile.tile_x, tile.tile_y
)
)
future = self.executor.submit(self._load_tile, tile)
self._futures.append(future)
def download(self, url, callback, **kwargs):
Logger.debug("Downloader: queue(url) {}".format(url))
future = self.executor.submit(self._download_url, url, callback, kwargs)
self._futures.append(future)
def _download_url(self, url, callback, kwargs):
Logger.debug("Downloader: download(url) {}".format(url))
response = requests.get(url, **kwargs)
response.raise_for_status()
return callback, (url, response)
def _load_tile(self, tile):
if tile.state == "done":
return
cache_fn = tile.cache_fn
if exists(cache_fn):
Logger.debug("Downloader: use cache {}".format(cache_fn))
return tile.set_source, (cache_fn,)
tile_y = tile.map_source.get_row_count(tile.zoom) - tile.tile_y - 1
uri = tile.map_source.url.format(
z=tile.zoom, x=tile.tile_x, y=tile_y, s=choice(tile.map_source.subdomains)
)
Logger.debug("Downloader: download(tile) {}".format(uri))
response = requests.get(uri, headers={'User-agent': USER_AGENT}, timeout=5)
try:
response.raise_for_status()
data = response.content
with open(cache_fn, "wb") as fd:
fd.write(data)
Logger.debug("Downloaded {} bytes: {}".format(len(data), uri))
return tile.set_source, (cache_fn,)
except Exception as e:
print("Downloader error: {!r}".format(e))
def _check_executor(self, dt):
start = time()
try:
for future in as_completed(self._futures[:], 0):
self._futures.remove(future)
try:
result = future.result()
except Exception:
traceback.print_exc()
# make an error tile?
continue
if result is None:
continue
callback, args = result
callback(*args)
# capped executor in time, in order to prevent too much
# slowiness.
# seems to works quite great with big zoom-in/out
if time() - start > self.cap_time:
break
except TimeoutError:
pass

387
sbapp/mapview/geojson.py Normal file
View File

@ -0,0 +1,387 @@
# 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

121
sbapp/mapview/mbtsource.py Normal file
View File

@ -0,0 +1,121 @@
# coding=utf-8
"""
MBTiles provider for MapView
============================
This provider is based on .mbfiles from MapBox.
See: http://mbtiles.org/
"""
__all__ = ["MBTilesMapSource"]
import io
import sqlite3
import threading
from kivy.core.image import Image as CoreImage
from kivy.core.image import ImageLoader
from mapview.downloader import Downloader
from mapview.source import MapSource
class MBTilesMapSource(MapSource):
def __init__(self, filename, **kwargs):
super().__init__(**kwargs)
self.filename = filename
self.db = sqlite3.connect(filename)
# read metadata
c = self.db.cursor()
metadata = dict(c.execute("SELECT * FROM metadata"))
if metadata["format"] == "pbf":
raise ValueError("Only raster maps are supported, not vector maps.")
self.min_zoom = int(metadata["minzoom"])
self.max_zoom = int(metadata["maxzoom"])
self.attribution = metadata.get("attribution", "")
self.bounds = bounds = None
cx = cy = 0.0
cz = 5
if "bounds" in metadata:
self.bounds = bounds = tuple(map(float, metadata["bounds"].split(",")))
if "center" in metadata:
cx, cy, cz = tuple(map(float, metadata["center"].split(",")))
elif self.bounds:
cx = (bounds[2] + bounds[0]) / 2.0
cy = (bounds[3] + bounds[1]) / 2.0
cz = self.min_zoom
self.default_lon = cx
self.default_lat = cy
self.default_zoom = int(cz)
self.projection = metadata.get("projection", "")
self.is_xy = self.projection == "xy"
def fill_tile(self, tile):
if tile.state == "done":
return
Downloader.instance(self.cache_dir).submit(self._load_tile, tile)
def _load_tile(self, tile):
# global db context cannot be shared across threads.
ctx = threading.local()
if not hasattr(ctx, "db"):
ctx.db = sqlite3.connect(self.filename)
# get the right tile
c = ctx.db.cursor()
c.execute(
(
"SELECT tile_data FROM tiles WHERE "
"zoom_level=? AND tile_column=? AND tile_row=?"
),
(tile.zoom, tile.tile_x, tile.tile_y),
)
row = c.fetchone()
if not row:
tile.state = "done"
return
# no-file loading
try:
data = io.BytesIO(row[0])
except Exception:
# android issue, "buffer" does not have the buffer interface
# ie row[0] buffer is not compatible with BytesIO on Android??
data = io.BytesIO(bytes(row[0]))
im = CoreImage(
data,
ext='png',
filename="{}.{}.{}.png".format(tile.zoom, tile.tile_x, tile.tile_y),
)
if im is None:
tile.state = "done"
return
return self._load_tile_done, (tile, im,)
def _load_tile_done(self, tile, im):
tile.texture = im.texture
tile.state = "need-animation"
def get_x(self, zoom, lon):
if self.is_xy:
return lon
return super().get_x(zoom, lon)
def get_y(self, zoom, lat):
if self.is_xy:
return lat
return super().get_y(zoom, lat)
def get_lon(self, zoom, x):
if self.is_xy:
return x
return super().get_lon(zoom, x)
def get_lat(self, zoom, y):
if self.is_xy:
return y
return super().get_lat(zoom, y)

212
sbapp/mapview/source.py Normal file
View File

@ -0,0 +1,212 @@
# coding=utf-8
__all__ = ["MapSource"]
import hashlib
from math import atan, ceil, cos, exp, log, pi, tan
from kivy.metrics import dp
from mapview.constants import (
CACHE_DIR,
MAX_LATITUDE,
MAX_LONGITUDE,
MIN_LATITUDE,
MIN_LONGITUDE,
)
from mapview.downloader import Downloader
from mapview.utils import clamp
class MapSource:
"""Base class for implementing a map source / provider
"""
attribution_osm = 'Maps & Data © [i][ref=http://www.osm.org/copyright]OpenStreetMap contributors[/ref][/i]'
attribution_thunderforest = 'Maps © [i][ref=http://www.thunderforest.com]Thunderforest[/ref][/i], Data © [i][ref=http://www.osm.org/copyright]OpenStreetMap contributors[/ref][/i]'
# list of available providers
# cache_key: (is_overlay, minzoom, maxzoom, url, attribution)
providers = {
"osm": (
0,
0,
19,
"http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
attribution_osm,
),
"osm-hot": (
0,
0,
19,
"http://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png",
"",
),
"osm-de": (
0,
0,
18,
"http://{s}.tile.openstreetmap.de/tiles/osmde/{z}/{x}/{y}.png",
"Tiles @ OSM DE",
),
"osm-fr": (
0,
0,
20,
"http://{s}.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png",
"Tiles @ OSM France",
),
"cyclemap": (
0,
0,
17,
"http://{s}.tile.opencyclemap.org/cycle/{z}/{x}/{y}.png",
"Tiles @ Andy Allan",
),
"thunderforest-cycle": (
0,
0,
19,
"http://{s}.tile.thunderforest.com/cycle/{z}/{x}/{y}.png",
attribution_thunderforest,
),
"thunderforest-transport": (
0,
0,
19,
"http://{s}.tile.thunderforest.com/transport/{z}/{x}/{y}.png",
attribution_thunderforest,
),
"thunderforest-landscape": (
0,
0,
19,
"http://{s}.tile.thunderforest.com/landscape/{z}/{x}/{y}.png",
attribution_thunderforest,
),
"thunderforest-outdoors": (
0,
0,
19,
"http://{s}.tile.thunderforest.com/outdoors/{z}/{x}/{y}.png",
attribution_thunderforest,
),
# no longer available
# "mapquest-osm": (0, 0, 19, "http://otile{s}.mqcdn.com/tiles/1.0.0/map/{z}/{x}/{y}.jpeg", "Tiles Courtesy of Mapquest", {"subdomains": "1234", "image_ext": "jpeg"}),
# "mapquest-aerial": (0, 0, 19, "http://oatile{s}.mqcdn.com/tiles/1.0.0/sat/{z}/{x}/{y}.jpeg", "Tiles Courtesy of Mapquest", {"subdomains": "1234", "image_ext": "jpeg"}),
# more to add with
# https://github.com/leaflet-extras/leaflet-providers/blob/master/leaflet-providers.js
# not working ?
# "openseamap": (0, 0, 19, "http://tiles.openseamap.org/seamark/{z}/{x}/{y}.png",
# "Map data @ OpenSeaMap contributors"),
}
def __init__(
self,
url="http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
cache_key=None,
min_zoom=0,
max_zoom=19,
tile_size=256,
image_ext="png",
attribution="© OpenStreetMap contributors",
subdomains="abc",
**kwargs
):
if cache_key is None:
# possible cache hit, but very unlikely
cache_key = hashlib.sha224(url.encode("utf8")).hexdigest()[:10]
self.url = url
self.cache_key = cache_key
self.min_zoom = min_zoom
self.max_zoom = max_zoom
self.tile_size = tile_size
self.image_ext = image_ext
self.attribution = attribution
self.subdomains = subdomains
self.cache_fmt = "{cache_key}_{zoom}_{tile_x}_{tile_y}.{image_ext}"
self.dp_tile_size = min(dp(self.tile_size), self.tile_size * 2)
self.default_lat = self.default_lon = self.default_zoom = None
self.bounds = None
self.cache_dir = kwargs.get('cache_dir', CACHE_DIR)
@staticmethod
def from_provider(key, **kwargs):
provider = MapSource.providers[key]
cache_dir = kwargs.get('cache_dir', CACHE_DIR)
options = {}
is_overlay, min_zoom, max_zoom, url, attribution = provider[:5]
if len(provider) > 5:
options = provider[5]
return MapSource(
cache_key=key,
min_zoom=min_zoom,
max_zoom=max_zoom,
url=url,
cache_dir=cache_dir,
attribution=attribution,
**options
)
def get_x(self, zoom, lon):
"""Get the x position on the map using this map source's projection
(0, 0) is located at the top left.
"""
lon = clamp(lon, MIN_LONGITUDE, MAX_LONGITUDE)
return ((lon + 180.0) / 360.0 * pow(2.0, zoom)) * self.dp_tile_size
def get_y(self, zoom, lat):
"""Get the y position on the map using this map source's projection
(0, 0) is located at the top left.
"""
lat = clamp(-lat, MIN_LATITUDE, MAX_LATITUDE)
lat = lat * pi / 180.0
return (
(1.0 - log(tan(lat) + 1.0 / cos(lat)) / pi) / 2.0 * pow(2.0, zoom)
) * self.dp_tile_size
def get_lon(self, zoom, x):
"""Get the longitude to the x position in the map source's projection
"""
dx = x / float(self.dp_tile_size)
lon = dx / pow(2.0, zoom) * 360.0 - 180.0
return clamp(lon, MIN_LONGITUDE, MAX_LONGITUDE)
def get_lat(self, zoom, y):
"""Get the latitude to the y position in the map source's projection
"""
dy = y / float(self.dp_tile_size)
n = pi - 2 * pi * dy / pow(2.0, zoom)
lat = -180.0 / pi * atan(0.5 * (exp(n) - exp(-n)))
return clamp(lat, MIN_LATITUDE, MAX_LATITUDE)
def get_row_count(self, zoom):
"""Get the number of tiles in a row at this zoom level
"""
if zoom == 0:
return 1
return 2 << (zoom - 1)
def get_col_count(self, zoom):
"""Get the number of tiles in a col at this zoom level
"""
if zoom == 0:
return 1
return 2 << (zoom - 1)
def get_min_zoom(self):
"""Return the minimum zoom of this source
"""
return self.min_zoom
def get_max_zoom(self):
"""Return the maximum zoom of this source
"""
return self.max_zoom
def fill_tile(self, tile):
"""Add this tile to load within the downloader
"""
if tile.state == "done":
return
Downloader.instance(cache_dir=self.cache_dir).download_tile(tile)

29
sbapp/mapview/types.py Normal file
View File

@ -0,0 +1,29 @@
# coding=utf-8
__all__ = ["Coordinate", "Bbox"]
from collections import namedtuple
Coordinate = namedtuple("Coordinate", ["lat", "lon"])
class Bbox(tuple):
def collide(self, *args):
if isinstance(args[0], Coordinate):
coord = args[0]
lat = coord.lat
lon = coord.lon
else:
lat, lon = args
lat1, lon1, lat2, lon2 = self[:]
if lat1 < lat2:
in_lat = lat1 <= lat <= lat2
else:
in_lat = lat2 <= lat <= lat2
if lon1 < lon2:
in_lon = lon1 <= lon <= lon2
else:
in_lon = lon2 <= lon <= lon2
return in_lat and in_lon

50
sbapp/mapview/utils.py Normal file
View File

@ -0,0 +1,50 @@
# coding=utf-8
__all__ = ["clamp", "haversine", "get_zoom_for_radius"]
from math import asin, cos, pi, radians, sin, sqrt
from kivy.core.window import Window
from kivy.metrics import dp
def clamp(x, minimum, maximum):
return max(minimum, min(x, maximum))
def haversine(lon1, lat1, lon2, lat2):
"""
Calculate the great circle distance between two points
on the earth (specified in decimal degrees)
Taken from: http://stackoverflow.com/questions/4913349/haversine-formula-in-python-bearing-and-distance-between-two-gps-points
"""
# convert decimal degrees to radians
lon1, lat1, lon2, lat2 = map(radians, [lon1, lat1, lon2, lat2])
# haversine formula
dlon = lon2 - lon1
dlat = lat2 - lat1
a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2
c = 2 * asin(sqrt(a))
km = 6367 * c
return km
def get_zoom_for_radius(radius_km, lat=None, tile_size=256.0):
"""See: https://wiki.openstreetmap.org/wiki/Zoom_levels"""
radius = radius_km * 1000.0
if lat is None:
lat = 0.0 # Do not compensate for the latitude
# Calculate the equatorial circumference based on the WGS-84 radius
earth_circumference = 2.0 * pi * 6378137.0 * cos(lat * pi / 180.0)
# Check how many tiles that are currently in view
nr_tiles_shown = min(Window.size) / dp(tile_size)
# Keep zooming in until we find a zoom level where the circle can fit inside the screen
zoom = 1
while earth_circumference / (2 << (zoom - 1)) * nr_tiles_shown > 2 * radius:
zoom += 1
return zoom - 1 # Go one zoom level back

1003
sbapp/mapview/view.py Normal file

File diff suppressed because it is too large Load Diff