Exemplo n.º 1
0
 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, **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)
Exemplo n.º 2
0
 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, **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)
Exemplo n.º 3
0
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., 208 / 255., 208 / 255., 1])
    cache_dir = StringProperty(CACHE_DIR)
    _zoom = NumericProperty(0)
    _pause = BooleanProperty(False)
    _scale = 1.
    _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.

        # 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(MapView, self).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(MapView, self).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.
        self._touch_count = 0
        self.map_source.cache_dir = self.cache_dir
        Clock.schedule_interval(self._animate_color, 1 / 60.)
        self.lat = kwargs.get("lat", self.lat)
        self.lon = kwargs.get("lon", self.lon)
        super(MapView, self).__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.
                    tile.state = "animated"
            for tile in self._tiles_bg:
                if tile.state == "need-animation":
                    tile.g_color.a = 1.
                    tile.state = "animated"
        else:
            d = d / 1000.
            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(MapView, self).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(MapView, self).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.
        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.)

    def _animate_scale(self, dt):
        diff = self._scale_target / 3.
        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 == "scrollup" 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(MapView, self).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. - cur_scale, *touch.pos)
                elif cur_zoom > zoom or cur_scale > scale:
                    self.animated_diff_scale_at(2. - 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 scale >= 2.01:
            zoom += 1
            scale /= 2.
        elif scale < 0.99:
            zoom -= 1
            scale *= 2.
        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.:
                scatter.scale = 1.
                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)

        # print "Range {},{} to {},{}".format(
        #    tile_x_first, tile_y_first,
        #    tile_x_last, tile_y_last)

        # 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., 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)