diff --git a/sbapp/mapview/__init__.py b/sbapp/mapview/__init__.py new file mode 100644 index 0000000..dcdc24c --- /dev/null +++ b/sbapp/mapview/__init__.py @@ -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", +] diff --git a/sbapp/mapview/_version.py b/sbapp/mapview/_version.py new file mode 100644 index 0000000..382021f --- /dev/null +++ b/sbapp/mapview/_version.py @@ -0,0 +1 @@ +__version__ = "1.0.6" diff --git a/sbapp/mapview/clustered_marker_layer.py b/sbapp/mapview/clustered_marker_layer.py new file mode 100644 index 0000000..c114e58 --- /dev/null +++ b/sbapp/mapview/clustered_marker_layer.py @@ -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( + """ +: + 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 "".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) diff --git a/sbapp/mapview/constants.py b/sbapp/mapview/constants.py new file mode 100644 index 0000000..b6998f8 --- /dev/null +++ b/sbapp/mapview/constants.py @@ -0,0 +1,5 @@ +MIN_LATITUDE = -90.0 +MAX_LATITUDE = 90.0 +MIN_LONGITUDE = -180.0 +MAX_LONGITUDE = 180.0 +CACHE_DIR = "cache" diff --git a/sbapp/mapview/downloader.py b/sbapp/mapview/downloader.py new file mode 100644 index 0000000..a0c25ae --- /dev/null +++ b/sbapp/mapview/downloader.py @@ -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 diff --git a/sbapp/mapview/geojson.py b/sbapp/mapview/geojson.py new file mode 100644 index 0000000..0c5f4c7 --- /dev/null +++ b/sbapp/mapview/geojson.py @@ -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 diff --git a/sbapp/mapview/icons/cluster.png b/sbapp/mapview/icons/cluster.png new file mode 100644 index 0000000..a704756 Binary files /dev/null and b/sbapp/mapview/icons/cluster.png differ diff --git a/sbapp/mapview/icons/marker.png b/sbapp/mapview/icons/marker.png new file mode 100644 index 0000000..2824540 Binary files /dev/null and b/sbapp/mapview/icons/marker.png differ diff --git a/sbapp/mapview/mbtsource.py b/sbapp/mapview/mbtsource.py new file mode 100644 index 0000000..2a2f69c --- /dev/null +++ b/sbapp/mapview/mbtsource.py @@ -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) diff --git a/sbapp/mapview/source.py b/sbapp/mapview/source.py new file mode 100644 index 0000000..3c9b203 --- /dev/null +++ b/sbapp/mapview/source.py @@ -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) diff --git a/sbapp/mapview/types.py b/sbapp/mapview/types.py new file mode 100644 index 0000000..622d8a9 --- /dev/null +++ b/sbapp/mapview/types.py @@ -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 diff --git a/sbapp/mapview/utils.py b/sbapp/mapview/utils.py new file mode 100644 index 0000000..1ecc84f --- /dev/null +++ b/sbapp/mapview/utils.py @@ -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 diff --git a/sbapp/mapview/view.py b/sbapp/mapview/view.py new file mode 100644 index 0000000..e395195 --- /dev/null +++ b/sbapp/mapview/view.py @@ -0,0 +1,1003 @@ +# coding=utf-8 + +__all__ = ["MapView", "MapMarker", "MapMarkerPopup", "MapLayer", "MarkerMapLayer"] + +import webbrowser +from itertools import takewhile +from math import ceil +from os.path import dirname, join + +from kivy.clock import Clock +from kivy.compat import string_types +from kivy.graphics import Canvas, Color, Rectangle +from kivy.graphics.transformation import Matrix +from kivy.lang import Builder +from kivy.metrics import dp +from kivy.properties import ( + AliasProperty, + BooleanProperty, + ListProperty, + NumericProperty, + ObjectProperty, + StringProperty, +) +from kivy.uix.behaviors import ButtonBehavior +from kivy.uix.image import Image +from kivy.uix.label import Label +from kivy.uix.scatter import Scatter +from kivy.uix.widget import Widget + +from mapview import Bbox, Coordinate +from mapview.constants import ( + CACHE_DIR, + MAX_LATITUDE, + MAX_LONGITUDE, + MIN_LATITUDE, + MIN_LONGITUDE, +) +from mapview.source import MapSource +from mapview.utils import clamp + +Builder.load_string( + """ +: + size_hint: None, None + source: root.source + size: list(map(dp, self.texture_size)) + allow_stretch: True + +: + canvas.before: + StencilPush + Rectangle: + pos: self.pos + size: self.size + StencilUse + Color: + rgba: self.background_color + Rectangle: + pos: self.pos + size: self.size + canvas.after: + StencilUnUse + Rectangle: + pos: self.pos + size: self.size + StencilPop + + ClickableLabel: + text: root.map_source.attribution if hasattr(root.map_source, "attribution") else "" + size_hint: None, None + size: self.texture_size[0] + sp(8), self.texture_size[1] + sp(4) + font_size: "10sp" + right: [root.right, self.center][0] + color: 0, 0, 0, 1 + markup: True + canvas.before: + Color: + rgba: .8, .8, .8, .8 + Rectangle: + pos: self.pos + size: self.size + + +: + auto_bring_to_front: False + do_rotation: False + scale_min: 0.2 + scale_max: 3. + +: + RelativeLayout: + id: placeholder + y: root.top + center_x: root.center_x + size: root.popup_size + +""" +) + + +class ClickableLabel(Label): + def on_ref_press(self, *args): + webbrowser.open(str(args[0]), new=2) + + +class Tile(Rectangle): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.cache_dir = kwargs.get('cache_dir', CACHE_DIR) + + @property + def cache_fn(self): + map_source = self.map_source + fn = map_source.cache_fmt.format( + image_ext=map_source.image_ext, + cache_key=map_source.cache_key, + **self.__dict__ + ) + return join(self.cache_dir, fn) + + def set_source(self, cache_fn): + self.source = cache_fn + self.state = "need-animation" + + +class MapMarker(ButtonBehavior, Image): + """A marker on a map, that must be used on a :class:`MapMarker` + """ + + anchor_x = NumericProperty(0.5) + """Anchor of the marker on the X axis. Defaults to 0.5, mean the anchor will + be at the X center of the image. + """ + + anchor_y = NumericProperty(0) + """Anchor of the marker on the Y axis. Defaults to 0, mean the anchor will + be at the Y bottom of the image. + """ + + lat = NumericProperty(0) + """Latitude of the marker + """ + + lon = NumericProperty(0) + """Longitude of the marker + """ + + source = StringProperty(join(dirname(__file__), "icons", "marker.png")) + """Source of the marker, defaults to our own marker.png + """ + + # (internal) reference to its layer + _layer = None + + def __init__(self, **kwargs): + super(MapMarker, self).__init__(**kwargs) + self.texture_update() + + def detach(self): + if self._layer: + self._layer.remove_widget(self) + self._layer = None + + +class MapMarkerPopup(MapMarker): + is_open = BooleanProperty(False) + placeholder = ObjectProperty(None) + popup_size = ListProperty([100, 100]) + + def add_widget(self, widget): + if not self.placeholder: + self.placeholder = widget + if self.is_open: + super().add_widget(self.placeholder) + else: + self.placeholder.add_widget(widget) + + def remove_widget(self, widget): + if widget is not self.placeholder: + self.placeholder.remove_widget(widget) + else: + super().remove_widget(widget) + + def on_is_open(self, *args): + self.refresh_open_status() + + def on_release(self, *args): + self.is_open = not self.is_open + + def refresh_open_status(self): + if not self.is_open and self.placeholder.parent: + super().remove_widget(self.placeholder) + elif self.is_open and not self.placeholder.parent: + super().add_widget(self.placeholder) + + +class MapLayer(Widget): + """A map layer, that is repositionned everytime the :class:`MapView` is + moved. + """ + + viewport_x = NumericProperty(0) + viewport_y = NumericProperty(0) + + def reposition(self): + """Function called when :class:`MapView` is moved. You must recalculate + the position of your children. + """ + pass + + def unload(self): + """Called when the view want to completly unload the layer. + """ + pass + + +class MarkerMapLayer(MapLayer): + """A map layer for :class:`MapMarker` + """ + + order_marker_by_latitude = BooleanProperty(True) + + def __init__(self, **kwargs): + self.markers = [] + super().__init__(**kwargs) + + def insert_marker(self, marker, **kwargs): + if self.order_marker_by_latitude: + before = list( + takewhile(lambda i_m: i_m[1].lat < marker.lat, enumerate(self.children)) + ) + if before: + kwargs['index'] = before[-1][0] + 1 + + super().add_widget(marker, **kwargs) + + def add_widget(self, marker): + marker._layer = self + self.markers.append(marker) + self.insert_marker(marker) + + def remove_widget(self, marker): + marker._layer = None + if marker in self.markers: + self.markers.remove(marker) + super().remove_widget(marker) + + def reposition(self): + if not self.markers: + return + mapview = self.parent + set_marker_position = self.set_marker_position + bbox = None + # reposition the markers depending the latitude + markers = sorted(self.markers, key=lambda x: -x.lat) + margin = max((max(marker.size) for marker in markers)) + bbox = mapview.get_bbox(margin) + for marker in markers: + if bbox.collide(marker.lat, marker.lon): + set_marker_position(mapview, marker) + if not marker.parent: + self.insert_marker(marker) + else: + super().remove_widget(marker) + + 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) + + def unload(self): + self.clear_widgets() + del self.markers[:] + + +class MapViewScatter(Scatter): + # internal + def on_transform(self, *args): + super().on_transform(*args) + self.parent.on_transform(self.transform) + + def collide_point(self, x, y): + return True + + +class MapView(Widget): + """MapView is the widget that control the map displaying, navigation, and + layers management. + """ + + lon = NumericProperty() + """Longitude at the center of the widget + """ + + lat = NumericProperty() + """Latitude at the center of the widget + """ + + zoom = NumericProperty(0) + """Zoom of the widget. Must be between :meth:`MapSource.get_min_zoom` and + :meth:`MapSource.get_max_zoom`. Default to 0. + """ + + map_source = ObjectProperty(MapSource()) + """Provider of the map, default to a empty :class:`MapSource`. + """ + + double_tap_zoom = BooleanProperty(False) + """If True, this will activate the double-tap to zoom. + """ + + pause_on_action = BooleanProperty(True) + """Pause any map loading / tiles loading when an action is done. + This allow better performance on mobile, but can be safely deactivated on + desktop. + """ + + snap_to_zoom = BooleanProperty(True) + """When the user initiate a zoom, it will snap to the closest zoom for + better graphics. The map can be blur if the map is scaled between 2 zoom. + Default to True, even if it doesn't fully working yet. + """ + + animation_duration = NumericProperty(100) + """Duration to animate Tiles alpha from 0 to 1 when it's ready to show. + Default to 100 as 100ms. Use 0 to deactivate. + """ + + delta_x = NumericProperty(0) + delta_y = NumericProperty(0) + background_color = ListProperty([181 / 255.0, 208 / 255.0, 208 / 255.0, 1]) + cache_dir = StringProperty(CACHE_DIR) + _zoom = NumericProperty(0) + _pause = BooleanProperty(False) + _scale = 1.0 + _disabled_count = 0 + + __events__ = ["on_map_relocated"] + + # Public API + + @property + def viewport_pos(self): + vx, vy = self._scatter.to_local(self.x, self.y) + return vx - self.delta_x, vy - self.delta_y + + @property + def scale(self): + if self._invalid_scale: + self._invalid_scale = False + self._scale = self._scatter.scale + return self._scale + + def get_bbox(self, margin=0): + """Returns the bounding box from the bottom/left (lat1, lon1) to + top/right (lat2, lon2). + """ + x1, y1 = self.to_local(0 - margin, 0 - margin) + x2, y2 = self.to_local((self.width + margin), (self.height + margin)) + c1 = self.get_latlon_at(x1, y1) + c2 = self.get_latlon_at(x2, y2) + return Bbox((c1.lat, c1.lon, c2.lat, c2.lon)) + + bbox = AliasProperty(get_bbox, None, bind=["lat", "lon", "_zoom"]) + + def unload(self): + """Unload the view and all the layers. + It also cancel all the remaining downloads. + """ + self.remove_all_tiles() + + def get_window_xy_from(self, lat, lon, zoom): + """Returns the x/y position in the widget absolute coordinates + from a lat/lon""" + scale = self.scale + vx, vy = self.viewport_pos + ms = self.map_source + x = ms.get_x(zoom, lon) - vx + y = ms.get_y(zoom, lat) - vy + x *= scale + y *= scale + x = x + self.pos[0] + y = y + self.pos[1] + return x, y + + def center_on(self, *args): + """Center the map on the coordinate :class:`Coordinate`, or a (lat, lon) + """ + map_source = self.map_source + zoom = self._zoom + + if len(args) == 1 and isinstance(args[0], Coordinate): + coord = args[0] + lat = coord.lat + lon = coord.lon + elif len(args) == 2: + lat, lon = args + else: + raise Exception("Invalid argument for center_on") + lon = clamp(lon, MIN_LONGITUDE, MAX_LONGITUDE) + lat = clamp(lat, MIN_LATITUDE, MAX_LATITUDE) + scale = self._scatter.scale + x = map_source.get_x(zoom, lon) - self.center_x / scale + y = map_source.get_y(zoom, lat) - self.center_y / scale + self.delta_x = -x + self.delta_y = -y + self.lon = lon + self.lat = lat + self._scatter.pos = 0, 0 + self.trigger_update(True) + + def set_zoom_at(self, zoom, x, y, scale=None): + """Sets the zoom level, leaving the (x, y) at the exact same point + in the view. + """ + zoom = clamp( + zoom, self.map_source.get_min_zoom(), self.map_source.get_max_zoom() + ) + if int(zoom) == int(self._zoom): + if scale is None: + return + elif scale == self.scale: + return + scale = scale or 1.0 + + # first, rescale the scatter + scatter = self._scatter + scale = clamp(scale, scatter.scale_min, scatter.scale_max) + rescale = scale * 1.0 / scatter.scale + scatter.apply_transform( + Matrix().scale(rescale, rescale, rescale), + post_multiply=True, + anchor=scatter.to_local(x, y), + ) + + # adjust position if the zoom changed + c1 = self.map_source.get_col_count(self._zoom) + c2 = self.map_source.get_col_count(zoom) + if c1 != c2: + f = float(c2) / float(c1) + self.delta_x = scatter.x + self.delta_x * f + self.delta_y = scatter.y + self.delta_y * f + # back to 0 every time + scatter.apply_transform( + Matrix().translate(-scatter.x, -scatter.y, 0), post_multiply=True + ) + + # avoid triggering zoom changes. + self._zoom = zoom + self.zoom = self._zoom + + def on_zoom(self, instance, zoom): + if zoom == self._zoom: + return + x = self.map_source.get_x(zoom, self.lon) - self.delta_x + y = self.map_source.get_y(zoom, self.lat) - self.delta_y + self.set_zoom_at(zoom, x, y) + self.center_on(self.lat, self.lon) + + def get_latlon_at(self, x, y, zoom=None): + """Return the current :class:`Coordinate` within the (x, y) widget + coordinate. + """ + if zoom is None: + zoom = self._zoom + vx, vy = self.viewport_pos + scale = self._scale + return Coordinate( + lat=self.map_source.get_lat(zoom, y / scale + vy), + lon=self.map_source.get_lon(zoom, x / scale + vx), + ) + + def add_marker(self, marker, layer=None): + """Add a marker into the layer. If layer is None, it will be added in + the default marker layer. If there is no default marker layer, a new + one will be automatically created + """ + if layer is None: + if not self._default_marker_layer: + layer = MarkerMapLayer() + self.add_layer(layer) + else: + layer = self._default_marker_layer + layer.add_widget(marker) + layer.set_marker_position(self, marker) + + def remove_marker(self, marker): + """Remove a marker from its layer + """ + marker.detach() + + def add_layer(self, layer, mode="window"): + """Add a new layer to update at the same time the base tile layer. + mode can be either "scatter" or "window". If "scatter", it means the + layer will be within the scatter transformation. It's perfect if you + want to display path / shape, but not for text. + If "window", it will have no transformation. You need to position the + widget yourself: think as Z-sprite / billboard. + Defaults to "window". + """ + assert mode in ("scatter", "window") + if self._default_marker_layer is None and isinstance(layer, MarkerMapLayer): + self._default_marker_layer = layer + self._layers.append(layer) + c = self.canvas + if mode == "scatter": + self.canvas = self.canvas_layers + else: + self.canvas = self.canvas_layers_out + layer.canvas_parent = self.canvas + super().add_widget(layer) + self.canvas = c + + def remove_layer(self, layer): + """Remove the layer + """ + c = self.canvas + self._layers.remove(layer) + self.canvas = layer.canvas_parent + super().remove_widget(layer) + self.canvas = c + + def sync_to(self, other): + """Reflect the lat/lon/zoom of the other MapView to the current one. + """ + if self._zoom != other._zoom: + self.set_zoom_at(other._zoom, *self.center) + self.center_on(other.get_latlon_at(*self.center)) + + # Private API + + def __init__(self, **kwargs): + from kivy.base import EventLoop + + EventLoop.ensure_window() + self._invalid_scale = True + self._tiles = [] + self._tiles_bg = [] + self._tilemap = {} + self._layers = [] + self._default_marker_layer = None + self._need_redraw_all = False + self._transform_lock = False + self.trigger_update(True) + self.canvas = Canvas() + self._scatter = MapViewScatter() + self.add_widget(self._scatter) + with self._scatter.canvas: + self.canvas_map = Canvas() + self.canvas_layers = Canvas() + with self.canvas: + self.canvas_layers_out = Canvas() + self._scale_target_anim = False + self._scale_target = 1.0 + self._touch_count = 0 + self.map_source.cache_dir = self.cache_dir + Clock.schedule_interval(self._animate_color, 1 / 60.0) + self.lat = kwargs.get("lat", self.lat) + self.lon = kwargs.get("lon", self.lon) + super().__init__(**kwargs) + + def _animate_color(self, dt): + # fast path + d = self.animation_duration + if d == 0: + for tile in self._tiles: + if tile.state == "need-animation": + tile.g_color.a = 1.0 + tile.state = "animated" + for tile in self._tiles_bg: + if tile.state == "need-animation": + tile.g_color.a = 1.0 + tile.state = "animated" + else: + d = d / 1000.0 + for tile in self._tiles: + if tile.state != "need-animation": + continue + tile.g_color.a += dt / d + if tile.g_color.a >= 1: + tile.state = "animated" + for tile in self._tiles_bg: + if tile.state != "need-animation": + continue + tile.g_color.a += dt / d + if tile.g_color.a >= 1: + tile.state = "animated" + + def add_widget(self, widget): + if isinstance(widget, MapMarker): + self.add_marker(widget) + elif isinstance(widget, MapLayer): + self.add_layer(widget) + else: + super().add_widget(widget) + + def remove_widget(self, widget): + if isinstance(widget, MapMarker): + self.remove_marker(widget) + elif isinstance(widget, MapLayer): + self.remove_layer(widget) + else: + super().remove_widget(widget) + + def on_map_relocated(self, zoom, coord): + pass + + def animated_diff_scale_at(self, d, x, y): + self._scale_target_time = 1.0 + self._scale_target_pos = x, y + if self._scale_target_anim is False: + self._scale_target_anim = True + self._scale_target = d + else: + self._scale_target += d + Clock.unschedule(self._animate_scale) + Clock.schedule_interval(self._animate_scale, 1 / 60.0) + + def _animate_scale(self, dt): + diff = self._scale_target / 3.0 + if abs(diff) < 0.01: + diff = self._scale_target + self._scale_target = 0 + else: + self._scale_target -= diff + self._scale_target_time -= dt + self.diff_scale_at(diff, *self._scale_target_pos) + ret = self._scale_target != 0 + if not ret: + self._pause = False + return ret + + def diff_scale_at(self, d, x, y): + scatter = self._scatter + scale = scatter.scale * (2 ** d) + self.scale_at(scale, x, y) + + def scale_at(self, scale, x, y): + scatter = self._scatter + scale = clamp(scale, scatter.scale_min, scatter.scale_max) + rescale = scale * 1.0 / scatter.scale + scatter.apply_transform( + Matrix().scale(rescale, rescale, rescale), + post_multiply=True, + anchor=scatter.to_local(x, y), + ) + + def on_touch_down(self, touch): + if not self.collide_point(*touch.pos): + return + if self.pause_on_action: + self._pause = True + if "button" in touch.profile and touch.button in ("scrolldown", "scrollup"): + d = 1 if touch.button == "scrolldown" else -1 + self.animated_diff_scale_at(d, *touch.pos) + return True + elif touch.is_double_tap and self.double_tap_zoom: + self.animated_diff_scale_at(1, *touch.pos) + return True + touch.grab(self) + self._touch_count += 1 + if self._touch_count == 1: + self._touch_zoom = (self.zoom, self._scale) + return super().on_touch_down(touch) + + def on_touch_up(self, touch): + if touch.grab_current == self: + touch.ungrab(self) + self._touch_count -= 1 + if self._touch_count == 0: + # animate to the closest zoom + zoom, scale = self._touch_zoom + cur_zoom = self.zoom + cur_scale = self._scale + if cur_zoom < zoom or cur_scale < scale: + self.animated_diff_scale_at(1.0 - cur_scale, *touch.pos) + elif cur_zoom > zoom or cur_scale > scale: + self.animated_diff_scale_at(2.0 - cur_scale, *touch.pos) + self._pause = False + return True + return super().on_touch_up(touch) + + def on_transform(self, *args): + self._invalid_scale = True + if self._transform_lock: + return + self._transform_lock = True + # recalculate viewport + map_source = self.map_source + zoom = self._zoom + scatter = self._scatter + scale = scatter.scale + if round(scale, 2) >= 2.0: + zoom += 1 + scale /= 2.0 + elif round(scale, 2) < 1.0: + zoom -= 1 + scale *= 2.0 + zoom = clamp(zoom, map_source.min_zoom, map_source.max_zoom) + if zoom != self._zoom: + self.set_zoom_at(zoom, scatter.x, scatter.y, scale=scale) + self.trigger_update(True) + else: + if zoom == map_source.min_zoom and scatter.scale < 1.0: + scatter.scale = 1.0 + self.trigger_update(True) + else: + self.trigger_update(False) + + if map_source.bounds: + self._apply_bounds() + self._transform_lock = False + self._scale = self._scatter.scale + + def _apply_bounds(self): + # if the map_source have any constraints, apply them here. + map_source = self.map_source + zoom = self._zoom + min_lon, min_lat, max_lon, max_lat = map_source.bounds + xmin = map_source.get_x(zoom, min_lon) + xmax = map_source.get_x(zoom, max_lon) + ymin = map_source.get_y(zoom, min_lat) + ymax = map_source.get_y(zoom, max_lat) + + dx = self.delta_x + dy = self.delta_y + oxmin, oymin = self._scatter.to_local(self.x, self.y) + oxmax, oymax = self._scatter.to_local(self.right, self.top) + s = self._scale + cxmin = oxmin - dx + if cxmin < xmin: + self._scatter.x += (cxmin - xmin) * s + cymin = oymin - dy + if cymin < ymin: + self._scatter.y += (cymin - ymin) * s + cxmax = oxmax - dx + if cxmax > xmax: + self._scatter.x -= (xmax - cxmax) * s + cymax = oymax - dy + if cymax > ymax: + self._scatter.y -= (ymax - cymax) * s + + def on__pause(self, instance, value): + if not value: + self.trigger_update(True) + + def trigger_update(self, full): + self._need_redraw_full = full or self._need_redraw_full + Clock.unschedule(self.do_update) + Clock.schedule_once(self.do_update, -1) + + def do_update(self, dt): + zoom = self._zoom + scale = self._scale + self.lon = self.map_source.get_lon( + zoom, (self.center_x - self._scatter.x) / scale - self.delta_x + ) + self.lat = self.map_source.get_lat( + zoom, (self.center_y - self._scatter.y) / scale - self.delta_y + ) + self.dispatch("on_map_relocated", zoom, Coordinate(self.lon, self.lat)) + for layer in self._layers: + layer.reposition() + + if self._need_redraw_full: + self._need_redraw_full = False + self.move_tiles_to_background() + self.load_visible_tiles() + else: + self.load_visible_tiles() + + def bbox_for_zoom(self, vx, vy, w, h, zoom): + # return a tile-bbox for the zoom + map_source = self.map_source + size = map_source.dp_tile_size + scale = self._scale + + max_x_end = map_source.get_col_count(zoom) + max_y_end = map_source.get_row_count(zoom) + + x_count = int(ceil(w / scale / float(size))) + 1 + y_count = int(ceil(h / scale / float(size))) + 1 + + tile_x_first = int(clamp(vx / float(size), 0, max_x_end)) + tile_y_first = int(clamp(vy / float(size), 0, max_y_end)) + tile_x_last = tile_x_first + x_count + tile_y_last = tile_y_first + y_count + tile_x_last = int(clamp(tile_x_last, tile_x_first, max_x_end)) + tile_y_last = int(clamp(tile_y_last, tile_y_first, max_y_end)) + + x_count = tile_x_last - tile_x_first + y_count = tile_y_last - tile_y_first + return (tile_x_first, tile_y_first, tile_x_last, tile_y_last, x_count, y_count) + + def load_visible_tiles(self): + map_source = self.map_source + vx, vy = self.viewport_pos + zoom = self._zoom + dirs = [0, 1, 0, -1, 0] + bbox_for_zoom = self.bbox_for_zoom + size = map_source.dp_tile_size + + ( + tile_x_first, + tile_y_first, + tile_x_last, + tile_y_last, + x_count, + y_count, + ) = bbox_for_zoom(vx, vy, self.width, self.height, zoom) + + # Adjust tiles behind us + for tile in self._tiles_bg[:]: + tile_x = tile.tile_x + tile_y = tile.tile_y + + f = 2 ** (zoom - tile.zoom) + w = self.width / f + h = self.height / f + ( + btile_x_first, + btile_y_first, + btile_x_last, + btile_y_last, + _, + _, + ) = bbox_for_zoom(vx / f, vy / f, w, h, tile.zoom) + + if ( + tile_x < btile_x_first + or tile_x >= btile_x_last + or tile_y < btile_y_first + or tile_y >= btile_y_last + ): + tile.state = "done" + self._tiles_bg.remove(tile) + self.canvas_map.before.remove(tile.g_color) + self.canvas_map.before.remove(tile) + continue + + tsize = size * f + tile.size = tsize, tsize + tile.pos = (tile_x * tsize + self.delta_x, tile_y * tsize + self.delta_y) + + # Get rid of old tiles first + for tile in self._tiles[:]: + tile_x = tile.tile_x + tile_y = tile.tile_y + + if ( + tile_x < tile_x_first + or tile_x >= tile_x_last + or tile_y < tile_y_first + or tile_y >= tile_y_last + ): + tile.state = "done" + self.tile_map_set(tile_x, tile_y, False) + self._tiles.remove(tile) + self.canvas_map.remove(tile) + self.canvas_map.remove(tile.g_color) + else: + tile.size = (size, size) + tile.pos = (tile_x * size + self.delta_x, tile_y * size + self.delta_y) + + # Load new tiles if needed + x = tile_x_first + x_count // 2 - 1 + y = tile_y_first + y_count // 2 - 1 + arm_max = max(x_count, y_count) + 2 + arm_size = 1 + turn = 0 + while arm_size < arm_max: + for i in range(arm_size): + if ( + not self.tile_in_tile_map(x, y) + and y >= tile_y_first + and y < tile_y_last + and x >= tile_x_first + and x < tile_x_last + ): + self.load_tile(x, y, size, zoom) + + x += dirs[turn % 4 + 1] + y += dirs[turn % 4] + + if turn % 2 == 1: + arm_size += 1 + + turn += 1 + + def load_tile(self, x, y, size, zoom): + if self.tile_in_tile_map(x, y) or zoom != self._zoom: + return + self.load_tile_for_source(self.map_source, 1.0, size, x, y, zoom) + # XXX do overlay support + self.tile_map_set(x, y, True) + + def load_tile_for_source(self, map_source, opacity, size, x, y, zoom): + tile = Tile(size=(size, size), cache_dir=self.cache_dir) + tile.g_color = Color(1, 1, 1, 0) + tile.tile_x = x + tile.tile_y = y + tile.zoom = zoom + tile.pos = (x * size + self.delta_x, y * size + self.delta_y) + tile.map_source = map_source + tile.state = "loading" + if not self._pause: + map_source.fill_tile(tile) + self.canvas_map.add(tile.g_color) + self.canvas_map.add(tile) + self._tiles.append(tile) + + def move_tiles_to_background(self): + # remove all the tiles of the main map to the background map + # retain only the one who are on the current zoom level + # for all the tile in the background, stop the download if not yet started. + zoom = self._zoom + tiles = self._tiles + btiles = self._tiles_bg + canvas_map = self.canvas_map + tile_size = self.map_source.tile_size + + # move all tiles to background + while tiles: + tile = tiles.pop() + if tile.state == "loading": + tile.state = "done" + continue + btiles.append(tile) + + # clear the canvas + canvas_map.clear() + canvas_map.before.clear() + self._tilemap = {} + + # unsure if it's really needed, i personnally didn't get issues right now + # btiles.sort(key=lambda z: -z.zoom) + + # add all the btiles into the back canvas. + # except for the tiles that are owned by the current zoom level + for tile in btiles[:]: + if tile.zoom == zoom: + btiles.remove(tile) + tiles.append(tile) + tile.size = tile_size, tile_size + canvas_map.add(tile.g_color) + canvas_map.add(tile) + self.tile_map_set(tile.tile_x, tile.tile_y, True) + continue + canvas_map.before.add(tile.g_color) + canvas_map.before.add(tile) + + def remove_all_tiles(self): + # clear the map of all tiles. + self.canvas_map.clear() + self.canvas_map.before.clear() + for tile in self._tiles: + tile.state = "done" + del self._tiles[:] + del self._tiles_bg[:] + self._tilemap = {} + + def tile_map_set(self, tile_x, tile_y, value): + key = tile_y * self.map_source.get_col_count(self._zoom) + tile_x + if value: + self._tilemap[key] = value + else: + self._tilemap.pop(key, None) + + def tile_in_tile_map(self, tile_x, tile_y): + key = tile_y * self.map_source.get_col_count(self._zoom) + tile_x + return key in self._tilemap + + def on_size(self, instance, size): + for layer in self._layers: + layer.size = size + self.center_on(self.lat, self.lon) + self.trigger_update(True) + + def on_pos(self, instance, pos): + self.center_on(self.lat, self.lon) + self.trigger_update(True) + + def on_map_source(self, instance, source): + if isinstance(source, string_types): + self.map_source = MapSource.from_provider(source) + elif isinstance(source, (tuple, list)): + cache_key, min_zoom, max_zoom, url, attribution, options = source + self.map_source = MapSource( + url=url, + cache_key=cache_key, + min_zoom=min_zoom, + max_zoom=max_zoom, + attribution=attribution, + cache_dir=self.cache_dir, + **options + ) + elif isinstance(source, MapSource): + self.map_source = source + else: + raise Exception("Invalid map source provider") + self.zoom = clamp(self.zoom, self.map_source.min_zoom, self.map_source.max_zoom) + self.remove_all_tiles() + self.trigger_update(True)