mirror of
https://github.com/markqvist/Sideband.git
synced 2024-10-01 03:15:37 -04: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