mirror of
				https://github.com/liberatedsystems/Sideband_CE.git
				synced 2024-09-03 04:13:27 +02: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…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user