# 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 : 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): try: self.source = cache_fn self.state = "need-animation" except: pass class CustomMapMarker(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 """ icon_bg = ListProperty() # (internal) reference to its layer _layer = None def __init__(self, **kwargs): if "icon_bg" in kwargs: bg = kwargs["icon_bg"] if len(bg) >= 3: lim = 0.5 lum = (bg[0]+bg[1]+bg[2])/3 if lum >= lim: self.source = join(dirname(__file__), "icons", "marker_light.png") else: self.source = join(dirname(__file__), "icons", "marker_dark.png") super(CustomMapMarker, self).__init__(**kwargs) self.texture_update() def detach(self): if self._layer: self._layer.remove_widget(self) self._layer = None 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) if hasattr(marker, "children"): if marker.children != None and len(marker.children) > 0: c = marker.children[0] c.x = marker.x c.y = marker.y+dp(16) 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 = round(self._scatter.scale, 2) 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 round(cur_scale, 2) < scale: self.animated_diff_scale_at(1.0 - cur_scale, *touch.pos) elif cur_zoom > zoom or round(cur_scale, 2) > scale: self.animated_diff_scale_at(2.0 - cur_scale, *touch.pos) self._pause = False return True return super(MapView, self).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 round(scatter.scale, 2) < 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)