mirror of
https://github.com/liberatedsystems/openCom-Companion.git
synced 2024-11-26 07:10:37 +01:00
Added mapview module
This commit is contained in:
parent
4a6dfa4a47
commit
404649f805
27
sbapp/mapview/__init__.py
Normal file
27
sbapp/mapview/__init__.py
Normal 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",
|
||||||
|
]
|
1
sbapp/mapview/_version.py
Normal file
1
sbapp/mapview/_version.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
__version__ = "1.0.6"
|
449
sbapp/mapview/clustered_marker_layer.py
Normal file
449
sbapp/mapview/clustered_marker_layer.py
Normal 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)
|
5
sbapp/mapview/constants.py
Normal file
5
sbapp/mapview/constants.py
Normal 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
123
sbapp/mapview/downloader.py
Normal 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
387
sbapp/mapview/geojson.py
Normal 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
|
BIN
sbapp/mapview/icons/cluster.png
Normal file
BIN
sbapp/mapview/icons/cluster.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 600 B |
BIN
sbapp/mapview/icons/marker.png
Normal file
BIN
sbapp/mapview/icons/marker.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.1 KiB |
121
sbapp/mapview/mbtsource.py
Normal file
121
sbapp/mapview/mbtsource.py
Normal 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
212
sbapp/mapview/source.py
Normal 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
29
sbapp/mapview/types.py
Normal 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
50
sbapp/mapview/utils.py
Normal 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
1003
sbapp/mapview/view.py
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user