├── tests ├── __init__.py └── test_mapview.py ├── __init__.py ├── .dockerignore ├── requirements.txt ├── screenshot.png ├── .gitignore ├── mapview ├── icons │ ├── marker.png │ └── cluster.png ├── types.py ├── __init__.py ├── utils.py ├── mbtsource.py ├── downloader.py ├── source.py ├── geojson.py ├── clustered_marker_layer.py └── view.py ├── examples ├── simple_map.py ├── simple_mbtiles.py ├── map_with_marker_popup.py ├── clustered_geojson.py ├── simple_geojson.py ├── map_browser.py └── test_kdbush.py ├── .travis.yml ├── tox.ini ├── Dockerfile ├── LICENSE ├── README.md └── docs ├── Makefile ├── make.bat └── source ├── conf.py └── index.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | 3 | from .mapview import * 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | .git/ 3 | .tox/ 4 | *.pyc 5 | **/__pycache__ 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Kivy-Garden==0.1.1 2 | futures==2.1.6 3 | requests==1.2.3 4 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fitoprincipe/garden.mapview/master/screenshot.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.pyo 3 | *.swp 4 | docs/build/* 5 | cache/* 6 | examples/cache/* 7 | .tox/ 8 | -------------------------------------------------------------------------------- /mapview/icons/marker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fitoprincipe/garden.mapview/master/mapview/icons/marker.png -------------------------------------------------------------------------------- /mapview/icons/cluster.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fitoprincipe/garden.mapview/master/mapview/icons/cluster.png -------------------------------------------------------------------------------- /examples/simple_map.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from kivy.base import runTouchApp 3 | 4 | if __name__ == '__main__' and __package__ is None: 5 | from os import sys, path 6 | sys.path.append(path.dirname(path.dirname(path.abspath(__file__)))) 7 | 8 | from mapview import MapView, MapSource 9 | 10 | kwargs = {} 11 | if len(sys.argv) > 1: 12 | kwargs["map_source"] = MapSource(url=sys.argv[1], attribution="") 13 | 14 | runTouchApp(MapView(**kwargs)) 15 | -------------------------------------------------------------------------------- /tests/test_mapview.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from mapview import MapView 3 | 4 | 5 | class TextInputTest(unittest.TestCase): 6 | 7 | def test_init_simple_map(self): 8 | """ 9 | Makes sure we can initialize a simple MapView object. 10 | """ 11 | kwargs = {} 12 | mapview = MapView(**kwargs) 13 | self.assertEqual(len(mapview.children), 2) 14 | 15 | 16 | if __name__ == '__main__': 17 | import unittest 18 | unittest.main() 19 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | 3 | language: generic 4 | 5 | env: 6 | global: 7 | - DISPLAY=:99.0 8 | 9 | services: 10 | - docker 11 | 12 | before_install: 13 | - sudo apt update -qq > /dev/null 14 | - sudo apt install --yes --no-install-recommends xvfb 15 | 16 | install: 17 | - docker build --tag=mapview-linux . 18 | 19 | before_script: 20 | - sh -e /etc/init.d/xvfb start 21 | 22 | script: 23 | - docker run -e DISPLAY=unix$DISPLAY -v /tmp/.X11-unix:/tmp/.X11-unix mapview-linux /bin/sh -c 'tox' 24 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = pep8,py27 3 | skipsdist = True 4 | # trick to enable pre-installation of Cython 5 | # https://stackoverflow.com/a/50081741/185510 6 | indexserver = 7 | preinstall = https://pypi.python.org/simple 8 | 9 | [testenv] 10 | passenv = DISPLAY XDG_RUNTIME_DIR 11 | basepython = python2.7 12 | deps = 13 | :preinstall: Cython==0.28.2 14 | -r{toxinidir}/requirements.txt 15 | kivy 16 | commands = 17 | python -m unittest discover --top-level-directory=. --start-directory=tests/ 18 | 19 | [testenv:pep8] 20 | deps = flake8 21 | commands = flake8 mapview/ examples/ 22 | 23 | [flake8] 24 | ignore = 25 | E122, E125, E126, E127, E128, E261, E265, E301, E302, E303, E402, E501, 26 | E502, E712, E722, E999, F401, F811, W293, W504 27 | -------------------------------------------------------------------------------- /mapview/types.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | 3 | __all__ = ["Coordinate", "Bbox"] 4 | 5 | from collections import namedtuple 6 | 7 | Coordinate = namedtuple("Coordinate", ["lat", "lon"]) 8 | 9 | class Bbox(tuple): 10 | def collide(self, *args): 11 | if isinstance(args[0], Coordinate): 12 | coord = args[0] 13 | lat = coord.lat 14 | lon = coord.lon 15 | else: 16 | lat, lon = args 17 | lat1, lon1, lat2, lon2 = self[:] 18 | 19 | if lat1 < lat2: 20 | in_lat = lat1 <= lat <= lat2 21 | else: 22 | in_lat = lat2 <= lat <= lat2 23 | if lon1 < lon2: 24 | in_lon = lon1 <= lon <= lon2 25 | else: 26 | in_lon = lon2 <= lon <= lon2 27 | 28 | return in_lat and in_lon 29 | -------------------------------------------------------------------------------- /mapview/__init__.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | MapView 4 | ======= 5 | 6 | .. author:: Mathieu Virbel 7 | 8 | MapView is a Kivy widget that display maps. 9 | """ 10 | 11 | __all__ = ["Coordinate", "Bbox", "MapView", "MapSource", "MapMarker", 12 | "MapLayer", "MarkerMapLayer", "MapMarkerPopup"] 13 | __version__ = "0.2" 14 | 15 | MIN_LATITUDE = -90. 16 | MAX_LATITUDE = 90. 17 | MIN_LONGITUDE = -180. 18 | MAX_LONGITUDE = 180. 19 | CACHE_DIR = "cache" 20 | 21 | try: 22 | # fix if used within garden 23 | import sys 24 | sys.modules['mapview'] = sys.modules['kivy.garden.mapview.mapview'] 25 | del sys 26 | except KeyError: 27 | pass 28 | 29 | from mapview.types import Coordinate, Bbox 30 | from mapview.source import MapSource 31 | from mapview.view import MapView, MapMarker, MapLayer, MarkerMapLayer, \ 32 | MapMarkerPopup 33 | -------------------------------------------------------------------------------- /examples/simple_mbtiles.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | This example demonstrate how to use the MBTilesMapSource provider. 4 | It supports v1.1 version of .mbtiles of Mapbox. 5 | See more at http://mbtiles.org/ 6 | 7 | It currently require a Kivy version that can load data from a buffer. This 8 | is not the case on every platform at 1.8.1, but we're going to fix it. 9 | """ 10 | 11 | import sys 12 | from kivy.base import runTouchApp 13 | 14 | if __name__ == '__main__' and __package__ is None: 15 | from os import sys, path 16 | sys.path.append(path.dirname(path.dirname(path.abspath(__file__)))) 17 | from mapview import MapView 18 | from mapview.mbtsource import MBTilesMapSource 19 | 20 | 21 | source = MBTilesMapSource(sys.argv[1]) 22 | runTouchApp(MapView( 23 | map_source=source, 24 | lat=source.default_lat, 25 | lon=source.default_lon, 26 | zoom=source.default_zoom)) 27 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Docker image for installing dependencies on Linux and running tests. 2 | # Build with: 3 | # docker build --tag=mapview-linux . 4 | # Run with: 5 | # docker run mapview-linux /bin/sh -c 'tox' 6 | # Or for interactive shell: 7 | # docker run -it --rm mapview-linux 8 | FROM ubuntu:18.04 9 | 10 | # configure locale 11 | RUN apt update -qq > /dev/null && apt install --yes --no-install-recommends \ 12 | locales && \ 13 | locale-gen en_US.UTF-8 14 | ENV LANG="en_US.UTF-8" \ 15 | LANGUAGE="en_US.UTF-8" \ 16 | LC_ALL="en_US.UTF-8" 17 | 18 | # install system dependencies 19 | RUN apt update -qq > /dev/null && apt install --yes --no-install-recommends \ 20 | python2.7-minimal libpython2.7-dev virtualenv make lsb-release pkg-config git build-essential \ 21 | sudo libssl-dev tox 22 | 23 | # install kivy system dependencies 24 | # https://kivy.org/docs/installation/installation-linux.html#dependencies-with-sdl2 25 | RUN apt install --yes --no-install-recommends \ 26 | libsdl2-dev libsdl2-image-dev libsdl2-mixer-dev libsdl2-ttf-dev 27 | 28 | WORKDIR /app 29 | COPY . /app 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Mathieu Virbel 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /examples/map_with_marker_popup.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from kivy.base import runTouchApp 3 | from kivy.lang import Builder 4 | 5 | if __name__ == '__main__' and __package__ is None: 6 | from os import sys, path 7 | sys.path.append(path.dirname(path.dirname(path.abspath(__file__)))) 8 | 9 | import mapview 10 | 11 | root = Builder.load_string(""" 12 | #:import sys sys 13 | #:import MapSource mapview.MapSource 14 | MapView: 15 | lat: 50.6394 16 | lon: 3.057 17 | zoom: 13 18 | map_source: MapSource(sys.argv[1], attribution="") if len(sys.argv) > 1 else "osm" 19 | 20 | MapMarkerPopup: 21 | lat: 50.6394 22 | lon: 3.057 23 | popup_size: dp(230), dp(130) 24 | 25 | Bubble: 26 | BoxLayout: 27 | orientation: "horizontal" 28 | padding: "5dp" 29 | AsyncImage: 30 | source: "http://upload.wikimedia.org/wikipedia/commons/9/9d/France-Lille-VieilleBourse-FacadeGrandPlace.jpg" 31 | mipmap: True 32 | Label: 33 | text: "[b]Lille[/b]\\n1 154 861 hab\\n5 759 hab./km2" 34 | markup: True 35 | halign: "center" 36 | 37 | """) 38 | 39 | runTouchApp(root) 40 | -------------------------------------------------------------------------------- /mapview/utils.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | 3 | __all__ = ["clamp"] 4 | 5 | from math import radians, cos, sin, asin, sqrt, log 6 | 7 | 8 | def clamp(x, minimum, maximum): 9 | return max(minimum, min(x, maximum)) 10 | 11 | 12 | def haversine(lon1, lat1, lon2, lat2): 13 | """ 14 | Calculate the great circle distance between two points 15 | on the earth (specified in decimal degrees) 16 | 17 | Taken from: http://stackoverflow.com/questions/4913349/haversine-formula-in-python-bearing-and-distance-between-two-gps-points 18 | """ 19 | # convert decimal degrees to radians 20 | lon1, lat1, lon2, lat2 = map(radians, [lon1, lat1, lon2, lat2]) 21 | # haversine formula 22 | dlon = lon2 - lon1 23 | dlat = lat2 - lat1 24 | a = sin(dlat / 2)**2 + cos(lat1) * cos(lat2) * sin(dlon / 2)**2 25 | 26 | c = 2 * asin(sqrt(a)) 27 | km = 6367 * c 28 | return km 29 | 30 | 31 | def get_zoom_for_radius(radius): 32 | # not super accurate, sorry 33 | radius = radius * 1000 34 | equatorLength = 40075004 35 | widthInPixels = 1024 36 | metersPerPixel = equatorLength / 256 37 | zoomLevel = 1 38 | while metersPerPixel * widthInPixels > radius: 39 | metersPerPixel /= 2 40 | zoomLevel += 1 41 | return zoomLevel - 1 42 | -------------------------------------------------------------------------------- /examples/clustered_geojson.py: -------------------------------------------------------------------------------- 1 | from kivy.base import runTouchApp 2 | import sys 3 | 4 | if __name__ == '__main__' and __package__ is None: 5 | from os import sys, path 6 | sys.path.append(path.dirname(path.dirname(path.abspath(__file__)))) 7 | 8 | from mapview import MapView, MapMarker 9 | from mapview.geojson import GeoJsonMapLayer 10 | from mapview.clustered_marker_layer import ClusteredMarkerLayer 11 | from mapview.utils import haversine, get_zoom_for_radius 12 | 13 | source = sys.argv[1] 14 | 15 | options = {} 16 | layer = GeoJsonMapLayer(source=source) 17 | 18 | # try to auto center the map on the source 19 | lon, lat = layer.center 20 | options["lon"] = lon 21 | options["lat"] = lat 22 | min_lon, max_lon, min_lat, max_lat = layer.bounds 23 | radius = haversine(min_lon, min_lat, max_lon, max_lat) 24 | zoom = get_zoom_for_radius(radius) 25 | options["zoom"] = zoom 26 | 27 | view = MapView(**options) 28 | view.add_layer(layer) 29 | 30 | marker_layer = ClusteredMarkerLayer( 31 | cluster_radius=200 32 | ) 33 | view.add_layer(marker_layer) 34 | 35 | # create marker if they exists 36 | count = 0 37 | 38 | 39 | def create_marker(feature): 40 | global count 41 | geometry = feature["geometry"] 42 | if geometry["type"] != "Point": 43 | return 44 | lon, lat = geometry["coordinates"] 45 | marker_layer.add_marker(lon, lat) 46 | count += 1 47 | 48 | 49 | layer.traverse_feature(create_marker) 50 | if count: 51 | print("Loaded {} markers".format(count)) 52 | 53 | runTouchApp(view) 54 | -------------------------------------------------------------------------------- /examples/simple_geojson.py: -------------------------------------------------------------------------------- 1 | from kivy.base import runTouchApp 2 | import sys 3 | 4 | if __name__ == '__main__' and __package__ is None: 5 | from os import sys, path 6 | sys.path.append(path.dirname(path.dirname(path.abspath(__file__)))) 7 | 8 | from mapview import MapView, MapMarker 9 | from mapview.geojson import GeoJsonMapLayer 10 | from mapview.utils import haversine, get_zoom_for_radius 11 | 12 | if len(sys.argv) > 1: 13 | source = sys.argv[1] 14 | else: 15 | source = "https://storage.googleapis.com/maps-devrel/google.json" 16 | 17 | options = {} 18 | layer = GeoJsonMapLayer(source=source) 19 | 20 | if layer.geojson: 21 | # try to auto center the map on the source 22 | lon, lat = layer.center 23 | options["lon"] = lon 24 | options["lat"] = lat 25 | min_lon, max_lon, min_lat, max_lat = layer.bounds 26 | radius = haversine(min_lon, min_lat, max_lon, max_lat) 27 | zoom = get_zoom_for_radius(radius) 28 | options["zoom"] = zoom 29 | 30 | view = MapView(**options) 31 | view.add_layer(layer) 32 | 33 | if layer.geojson: 34 | # create marker if they exists 35 | count = 0 36 | 37 | def create_marker(feature): 38 | global count 39 | geometry = feature["geometry"] 40 | if geometry["type"] != "Point": 41 | return 42 | lon, lat = geometry["coordinates"] 43 | marker = MapMarker(lon=lon, lat=lat) 44 | view.add_marker(marker) 45 | count += 1 46 | 47 | layer.traverse_feature(create_marker) 48 | if count: 49 | print("Loaded {} markers".format(count)) 50 | 51 | runTouchApp(view) 52 | -------------------------------------------------------------------------------- /examples/map_browser.py: -------------------------------------------------------------------------------- 1 | from kivy.base import runTouchApp 2 | from kivy.lang import Builder 3 | 4 | if __name__ == '__main__' and __package__ is None: 5 | from os import sys, path 6 | sys.path.append(path.dirname(path.dirname(path.abspath(__file__)))) 7 | 8 | root = Builder.load_string(""" 9 | #:import MapSource mapview.MapSource 10 | 11 | : 12 | size_hint_y: None 13 | height: '48dp' 14 | padding: '4dp' 15 | spacing: '4dp' 16 | 17 | canvas: 18 | Color: 19 | rgba: .2, .2, .2, .6 20 | Rectangle: 21 | pos: self.pos 22 | size: self.size 23 | 24 | : 25 | size: self.texture_size 26 | canvas.before: 27 | Color: 28 | rgba: .2, .2, .2, .6 29 | Rectangle: 30 | pos: self.pos 31 | size: self.size 32 | 33 | RelativeLayout: 34 | 35 | MapView: 36 | id: mapview 37 | lat: 50.6394 38 | lon: 3.057 39 | zoom: 8 40 | #size_hint: .5, .5 41 | #pos_hint: {"x": .25, "y": .25} 42 | 43 | #on_map_relocated: mapview2.sync_to(self) 44 | #on_map_relocated: mapview3.sync_to(self) 45 | 46 | MapMarker: 47 | lat: 50.6394 48 | lon: 3.057 49 | 50 | MapMarker 51 | lat: -33.867 52 | lon: 151.206 53 | 54 | Toolbar: 55 | top: root.top 56 | Button: 57 | text: "Move to Lille, France" 58 | on_release: mapview.center_on(50.6394, 3.057) 59 | Button: 60 | text: "Move to Sydney, Autralia" 61 | on_release: mapview.center_on(-33.867, 151.206) 62 | Spinner: 63 | text: "mapnik" 64 | values: MapSource.providers.keys() 65 | on_text: mapview.map_source = self.text 66 | 67 | Toolbar: 68 | Label: 69 | text: "Longitude: {}".format(mapview.lon) 70 | Label: 71 | text: "Latitude: {}".format(mapview.lat) 72 | """) 73 | 74 | runTouchApp(root) 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mapview 2 | 3 | [![Build Status](https://travis-ci.com/kivy-garden/garden.mapview.svg?branch=master)](https://travis-ci.com/kivy-garden/garden.mapview) 4 | 5 | Mapview is a Kivy widget for displaying interactive maps. It has been 6 | designed with lot of inspirations of 7 | [Libchamplain](https://wiki.gnome.org/Projects/libchamplain) and 8 | [Leaflet](http://leafletjs.com/). 9 | 10 | The goal of this widget is to be a replacement of Google Maps widget, 11 | even if this one works very well, it just works on Android with Kivy. 12 | I wanted a map widget that can support custom map, and designed with 13 | the latests state-of-the-art Kivy's methods. 14 | 15 | ![ScreenShot](https://cloud.githubusercontent.com/assets/37904/22764226/925c93ce-ee69-11e6-90ed-88123bfa731f.png) 16 | 17 | ![Marker clustering](https://cloud.githubusercontent.com/assets/37904/22764225/92516f12-ee69-11e6-99d5-6346e302766d.png) 18 | 19 | # Features 20 | 21 | * native multitouch (one for translate, many for translate and zoom) 22 | * asynchronous downloading 23 | * avoided GPU limitation / float precisions issues on tiles coordinates 24 | * marker support 25 | * blazing fast! 26 | * supports Z/X/Y providers by default with `MapSource` 27 | * supports [.mbtiles](http://mbtiles.org) via `MBTilesMapSource` 28 | * supports marker clustering, via `ClusteredMarkerLayer` 29 | 30 | # Requirements 31 | 32 | It requires the `concurrent.futures` and `requests`. If you are on python 2.7, 33 | you can use `futures`: 34 | 35 | ``` 36 | pip install futures requests 37 | ``` 38 | 39 | If you use it on Android / iOS, don't forget to add `openssl` as a requirements, 40 | otherwise you'll have an issue when importing `urllib3` from `requests`. 41 | 42 | # Install 43 | 44 | Install the mapview garden module using the `garden` tool: 45 | 46 | ``` 47 | garden install mapview 48 | ``` 49 | 50 | # Usage 51 | 52 | This widget can be either used within Python or Kv. That's said, not 53 | everything can be done in Kv, to prevent too much computing. 54 | 55 | ```python 56 | from kivy.garden.mapview import MapView 57 | from kivy.app import App 58 | 59 | class MapViewApp(App): 60 | def build(self): 61 | mapview = MapView(zoom=11, lat=50.6394, lon=3.057) 62 | return mapview 63 | 64 | MapViewApp().run() 65 | ``` 66 | 67 | More extensive documentation will come soon. 68 | -------------------------------------------------------------------------------- /examples/test_kdbush.py: -------------------------------------------------------------------------------- 1 | """ 2 | Demonstrate the speed of the KD-tree (KDBush) implementation 3 | 4 | The green circle will indicate the selection zone, the blue rectangle indicate 5 | the selected red dot. 6 | """ 7 | 8 | from kivy.app import App 9 | from kivy.uix.widget import Widget 10 | from kivy.graphics import Color, Rectangle, Ellipse, Canvas 11 | from mapview.clustered_marker_layer import KDBush, Marker 12 | from random import random 13 | 14 | # creating markers 15 | points = [] 16 | for i in range(10000): 17 | points.append(Marker(lon=random() * 360 - 180, lat=random() * 180 - 90)) 18 | 19 | # test kdbush 20 | kdbush = KDBush(points, 64) 21 | 22 | 23 | class TestWidget(Widget): 24 | selection = [] 25 | selection_center = None 26 | radius = 0.1 27 | 28 | canvas_points = None 29 | 30 | def build(self): 31 | radius = self.radius 32 | 33 | if not self.canvas_points: 34 | self.canvas_points = Canvas() 35 | self.canvas.add(self.canvas_points) 36 | with self.canvas_points: 37 | Color(1, 0, 0) 38 | for marker in points: 39 | Rectangle( 40 | pos=(marker.x * 600, marker.y * 600), size=(2, 2)) 41 | 42 | self.canvas.before.clear() 43 | with self.canvas.before: 44 | if self.selection_center: 45 | Color(0, 1, 0, 0.5) 46 | x, y = self.selection_center 47 | r = radius * 600 48 | r2 = r * 2 49 | Ellipse(pos=(x - r, y - r), size=(r2, r2)) 50 | 51 | if self.selection: 52 | Color(0, 0, 1) 53 | for m_id in self.selection: 54 | # x = kdbush.coords[m_id * 2] 55 | # y = kdbush.coords[m_id * 2 + 1] 56 | marker = points[m_id] 57 | x = marker.x 58 | y = marker.y 59 | Rectangle(pos=(x * 600 - 4, y * 600 - 4), size=(8, 8)) 60 | 61 | def on_touch_down(self, touch): 62 | self.select(*touch.pos) 63 | 64 | def on_touch_move(self, touch): 65 | self.select(*touch.pos) 66 | 67 | def select(self, x, y): 68 | self.selection_center = (x, y) 69 | self.selection = kdbush.within(x / 600., y / 600., self.radius) 70 | self.build() 71 | 72 | 73 | class TestApp(App): 74 | def build(self): 75 | self.root = TestWidget() 76 | self.root.build() 77 | 78 | 79 | TestApp().run() 80 | -------------------------------------------------------------------------------- /mapview/mbtsource.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | MBTiles provider for MapView 4 | ============================ 5 | 6 | This provider is based on .mbfiles from MapBox. 7 | See: http://mbtiles.org/ 8 | """ 9 | 10 | __all__ = ["MBTilesMapSource"] 11 | 12 | 13 | from mapview.source import MapSource 14 | from mapview.downloader import Downloader 15 | from kivy.core.image import Image as CoreImage, ImageLoader 16 | import threading 17 | import sqlite3 18 | import io 19 | 20 | 21 | class MBTilesMapSource(MapSource): 22 | def __init__(self, filename, **kwargs): 23 | super(MBTilesMapSource, self).__init__(**kwargs) 24 | self.filename = filename 25 | self.db = sqlite3.connect(filename) 26 | 27 | # read metadata 28 | c = self.db.cursor() 29 | metadata = dict(c.execute("SELECT * FROM metadata")) 30 | if metadata["format"] == "pbf": 31 | raise ValueError("Only raster maps are supported, not vector maps.") 32 | self.min_zoom = int(metadata["minzoom"]) 33 | self.max_zoom = int(metadata["maxzoom"]) 34 | self.attribution = metadata.get("attribution", "") 35 | self.bounds = bounds = None 36 | cx = cy = 0. 37 | cz = 5 38 | if "bounds" in metadata: 39 | self.bounds = bounds = map(float, metadata["bounds"].split(",")) 40 | if "center" in metadata: 41 | cx, cy, cz = map(float, metadata["center"].split(",")) 42 | elif self.bounds: 43 | cx = (bounds[2] + bounds[0]) / 2. 44 | cy = (bounds[3] + bounds[1]) / 2. 45 | cz = self.min_zoom 46 | self.default_lon = cx 47 | self.default_lat = cy 48 | self.default_zoom = int(cz) 49 | self.projection = metadata.get("projection", "") 50 | self.is_xy = (self.projection == "xy") 51 | 52 | def fill_tile(self, tile): 53 | if tile.state == "done": 54 | return 55 | Downloader.instance(self.cache_dir).submit(self._load_tile, tile) 56 | 57 | def _load_tile(self, tile): 58 | # global db context cannot be shared across threads. 59 | ctx = threading.local() 60 | if not hasattr(ctx, "db"): 61 | ctx.db = sqlite3.connect(self.filename) 62 | 63 | # get the right tile 64 | c = ctx.db.cursor() 65 | c.execute( 66 | ("SELECT tile_data FROM tiles WHERE " 67 | "zoom_level=? AND tile_column=? AND tile_row=?"), 68 | (tile.zoom, tile.tile_x, tile.tile_y)) 69 | # print "fetch", tile.zoom, tile.tile_x, tile.tile_y 70 | row = c.fetchone() 71 | if not row: 72 | tile.state = "done" 73 | return 74 | 75 | # no-file loading 76 | try: 77 | data = io.BytesIO(row[0]) 78 | except: 79 | # android issue, "buffer" does not have the buffer interface 80 | # ie row[0] buffer is not compatible with BytesIO on Android?? 81 | data = io.BytesIO(bytes(row[0])) 82 | im = CoreImage(data, ext='png', 83 | filename="{}.{}.{}.png".format(tile.zoom, tile.tile_x, 84 | tile.tile_y)) 85 | 86 | if im is None: 87 | tile.state = "done" 88 | return 89 | 90 | return self._load_tile_done, (tile, im, ) 91 | 92 | def _load_tile_done(self, tile, im): 93 | tile.texture = im.texture 94 | tile.state = "need-animation" 95 | 96 | def get_x(self, zoom, lon): 97 | if self.is_xy: 98 | return lon 99 | return super(MBTilesMapSource, self).get_x(zoom, lon) 100 | 101 | def get_y(self, zoom, lat): 102 | if self.is_xy: 103 | return lat 104 | return super(MBTilesMapSource, self).get_y(zoom, lat) 105 | 106 | def get_lon(self, zoom, x): 107 | if self.is_xy: 108 | return x 109 | return super(MBTilesMapSource, self).get_lon(zoom, x) 110 | 111 | def get_lat(self, zoom, y): 112 | if self.is_xy: 113 | return y 114 | return super(MBTilesMapSource, self).get_lat(zoom, y) 115 | -------------------------------------------------------------------------------- /mapview/downloader.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | 3 | __all__ = ["Downloader"] 4 | 5 | from kivy.clock import Clock 6 | from os.path import join, exists 7 | from os import makedirs, environ 8 | from concurrent.futures import ThreadPoolExecutor, TimeoutError, as_completed 9 | from random import choice 10 | import requests 11 | import traceback 12 | from time import time 13 | from mapview import CACHE_DIR 14 | 15 | 16 | DEBUG = "MAPVIEW_DEBUG_DOWNLOADER" in environ 17 | # user agent is needed because since may 2019 OSM gives me a 429 or 403 server error 18 | # I tried it with a simpler one (just Mozilla/5.0) this also gets rejected 19 | USER_AGENT = 'Kivy-garden.mapview' 20 | 21 | 22 | class Downloader(object): 23 | _instance = None 24 | MAX_WORKERS = 5 25 | CAP_TIME = 0.064 # 15 FPS 26 | 27 | @staticmethod 28 | def instance(cache_dir): 29 | if Downloader._instance is None: 30 | if not cache_dir: 31 | cache_dir = CACHE_DIR 32 | Downloader._instance = Downloader(cache_dir=cache_dir) 33 | return Downloader._instance 34 | 35 | def __init__(self, max_workers=None, cap_time=None, **kwargs): 36 | self.cache_dir = kwargs.get('cache_dir', CACHE_DIR) 37 | if max_workers is None: 38 | max_workers = Downloader.MAX_WORKERS 39 | if cap_time is None: 40 | cap_time = Downloader.CAP_TIME 41 | super(Downloader, self).__init__() 42 | self.is_paused = False 43 | self.cap_time = cap_time 44 | self.executor = ThreadPoolExecutor(max_workers=max_workers) 45 | self._futures = [] 46 | Clock.schedule_interval(self._check_executor, 1 / 60.) 47 | if not exists(self.cache_dir): 48 | makedirs(self.cache_dir) 49 | 50 | def submit(self, f, *args, **kwargs): 51 | future = self.executor.submit(f, *args, **kwargs) 52 | self._futures.append(future) 53 | 54 | def download_tile(self, tile): 55 | if DEBUG: 56 | print("Downloader: queue(tile) zoom={} x={} y={}".format( 57 | tile.zoom, tile.tile_x, tile.tile_y)) 58 | future = self.executor.submit(self._load_tile, tile) 59 | self._futures.append(future) 60 | 61 | def download(self, url, callback, **kwargs): 62 | if DEBUG: 63 | print("Downloader: queue(url) {}".format(url)) 64 | future = self.executor.submit( 65 | self._download_url, url, callback, kwargs) 66 | self._futures.append(future) 67 | 68 | def _download_url(self, url, callback, kwargs): 69 | if DEBUG: 70 | print("Downloader: download(url) {}".format(url)) 71 | r = requests.get(url, **kwargs) 72 | return callback, (url, r, ) 73 | 74 | def _load_tile(self, tile): 75 | if tile.state == "done": 76 | return 77 | cache_fn = tile.cache_fn 78 | if exists(cache_fn): 79 | if DEBUG: 80 | print("Downloader: use cache {}".format(cache_fn)) 81 | return tile.set_source, (cache_fn, ) 82 | tile_y = tile.map_source.get_row_count(tile.zoom) - tile.tile_y - 1 83 | uri = tile.map_source.url.format(z=tile.zoom, x=tile.tile_x, y=tile_y, 84 | s=choice(tile.map_source.subdomains)) 85 | if DEBUG: 86 | print("Downloader: download(tile) {}".format(uri)) 87 | req = requests.get(uri, headers={'User-agent': USER_AGENT},timeout=5) 88 | try: 89 | req.raise_for_status() 90 | data = req.content 91 | with open(cache_fn, "wb") as fd: 92 | fd.write(data) 93 | if DEBUG: 94 | print("Downloaded {} bytes: {}".format(len(data), uri)) 95 | return tile.set_source, (cache_fn, ) 96 | except Exception as e: 97 | print("Downloader error: {!r}".format(e)) 98 | 99 | def _check_executor(self, dt): 100 | start = time() 101 | try: 102 | for future in as_completed(self._futures[:], 0): 103 | self._futures.remove(future) 104 | try: 105 | result = future.result() 106 | except Exception: 107 | traceback.print_exc() 108 | # make an error tile? 109 | continue 110 | if result is None: 111 | continue 112 | callback, args = result 113 | callback(*args) 114 | 115 | # capped executor in time, in order to prevent too much 116 | # slowiness. 117 | # seems to works quite great with big zoom-in/out 118 | if time() - start > self.cap_time: 119 | break 120 | except TimeoutError: 121 | pass 122 | -------------------------------------------------------------------------------- /mapview/source.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | 3 | __all__ = ["MapSource"] 4 | 5 | from kivy.metrics import dp 6 | from math import cos, ceil, log, tan, pi, atan, exp 7 | from mapview import MIN_LONGITUDE, MAX_LONGITUDE, MIN_LATITUDE, MAX_LATITUDE, \ 8 | CACHE_DIR 9 | from mapview.downloader import Downloader 10 | from mapview.utils import clamp 11 | import hashlib 12 | 13 | 14 | class MapSource(object): 15 | """Base class for implementing a map source / provider 16 | """ 17 | 18 | attribution_osm = 'Maps & Data © [i][ref=http://www.osm.org/copyright]OpenStreetMap contributors[/ref][/i]' 19 | attribution_thunderforest = 'Maps © [i][ref=http://www.thunderforest.com]Thunderforest[/ref][/i], Data © [i][ref=http://www.osm.org/copyright]OpenStreetMap contributors[/ref][/i]' 20 | 21 | # list of available providers 22 | # cache_key: (is_overlay, minzoom, maxzoom, url, attribution) 23 | providers = { 24 | "osm": (0, 0, 19, "http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", attribution_osm), 25 | "osm-hot": (0, 0, 19, "http://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png", ""), 26 | "osm-de": (0, 0, 18, "http://{s}.tile.openstreetmap.de/tiles/osmde/{z}/{x}/{y}.png", "Tiles @ OSM DE"), 27 | "osm-fr": (0, 0, 20, "http://{s}.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png", "Tiles @ OSM France"), 28 | "cyclemap": (0, 0, 17, "http://{s}.tile.opencyclemap.org/cycle/{z}/{x}/{y}.png", "Tiles @ Andy Allan"), 29 | "thunderforest-cycle": (0, 0, 19, "http://{s}.tile.thunderforest.com/cycle/{z}/{x}/{y}.png", attribution_thunderforest), 30 | "thunderforest-transport": (0, 0, 19, "http://{s}.tile.thunderforest.com/transport/{z}/{x}/{y}.png", attribution_thunderforest), 31 | "thunderforest-landscape": (0, 0, 19, "http://{s}.tile.thunderforest.com/landscape/{z}/{x}/{y}.png", attribution_thunderforest), 32 | "thunderforest-outdoors": (0, 0, 19, "http://{s}.tile.thunderforest.com/outdoors/{z}/{x}/{y}.png", attribution_thunderforest), 33 | 34 | # no longer available 35 | #"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"}), 36 | #"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"}), 37 | 38 | # more to add with 39 | # https://github.com/leaflet-extras/leaflet-providers/blob/master/leaflet-providers.js 40 | # not working ? 41 | #"openseamap": (0, 0, 19, "http://tiles.openseamap.org/seamark/{z}/{x}/{y}.png", 42 | # "Map data @ OpenSeaMap contributors"), 43 | } 44 | 45 | def __init__(self, 46 | url="http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", 47 | cache_key=None, min_zoom=0, max_zoom=19, tile_size=256, 48 | image_ext="png", 49 | attribution="© OpenStreetMap contributors", 50 | subdomains="abc", **kwargs): 51 | super(MapSource, self).__init__() 52 | if cache_key is None: 53 | # possible cache hit, but very unlikely 54 | cache_key = hashlib.sha224(url.encode("utf8")).hexdigest()[:10] 55 | self.url = url 56 | self.cache_key = cache_key 57 | self.min_zoom = min_zoom 58 | self.max_zoom = max_zoom 59 | self.tile_size = tile_size 60 | self.image_ext = image_ext 61 | self.attribution = attribution 62 | self.subdomains = subdomains 63 | self.cache_fmt = "{cache_key}_{zoom}_{tile_x}_{tile_y}.{image_ext}" 64 | self.dp_tile_size = min(dp(self.tile_size), self.tile_size * 2) 65 | self.default_lat = self.default_lon = self.default_zoom = None 66 | self.bounds = None 67 | self.cache_dir = kwargs.get('cache_dir', CACHE_DIR) 68 | 69 | @staticmethod 70 | def from_provider(key, **kwargs): 71 | provider = MapSource.providers[key] 72 | cache_dir = kwargs.get('cache_dir', CACHE_DIR) 73 | options = {} 74 | is_overlay, min_zoom, max_zoom, url, attribution = provider[:5] 75 | if len(provider) > 5: 76 | options = provider[5] 77 | return MapSource(cache_key=key, min_zoom=min_zoom, 78 | max_zoom=max_zoom, url=url, cache_dir=cache_dir, 79 | attribution=attribution, **options) 80 | 81 | def get_x(self, zoom, lon): 82 | """Get the x position on the map using this map source's projection 83 | (0, 0) is located at the top left. 84 | """ 85 | lon = clamp(lon, MIN_LONGITUDE, MAX_LONGITUDE) 86 | return ((lon + 180.) / 360. * pow(2., zoom)) * self.dp_tile_size 87 | 88 | def get_y(self, zoom, lat): 89 | """Get the y position on the map using this map source's projection 90 | (0, 0) is located at the top left. 91 | """ 92 | lat = clamp(-lat, MIN_LATITUDE, MAX_LATITUDE) 93 | lat = lat * pi / 180. 94 | return ((1.0 - log(tan(lat) + 1.0 / cos(lat)) / pi) / \ 95 | 2. * pow(2., zoom)) * self.dp_tile_size 96 | 97 | def get_lon(self, zoom, x): 98 | """Get the longitude to the x position in the map source's projection 99 | """ 100 | dx = x / float(self.dp_tile_size) 101 | lon = dx / pow(2., zoom) * 360. - 180. 102 | return clamp(lon, MIN_LONGITUDE, MAX_LONGITUDE) 103 | 104 | def get_lat(self, zoom, y): 105 | """Get the latitude to the y position in the map source's projection 106 | """ 107 | dy = y / float(self.dp_tile_size) 108 | n = pi - 2 * pi * dy / pow(2., zoom) 109 | lat = -180. / pi * atan(.5 * (exp(n) - exp(-n))) 110 | return clamp(lat, MIN_LATITUDE, MAX_LATITUDE) 111 | 112 | def get_row_count(self, zoom): 113 | """Get the number of tiles in a row at this zoom level 114 | """ 115 | if zoom == 0: 116 | return 1 117 | return 2 << (zoom - 1) 118 | 119 | def get_col_count(self, zoom): 120 | """Get the number of tiles in a col at this zoom level 121 | """ 122 | if zoom == 0: 123 | return 1 124 | return 2 << (zoom - 1) 125 | 126 | def get_min_zoom(self): 127 | """Return the minimum zoom of this source 128 | """ 129 | return self.min_zoom 130 | 131 | def get_max_zoom(self): 132 | """Return the maximum zoom of this source 133 | """ 134 | return self.max_zoom 135 | 136 | def fill_tile(self, tile): 137 | """Add this tile to load within the downloader 138 | """ 139 | if tile.state == "done": 140 | return 141 | Downloader.instance(cache_dir=self.cache_dir).download_tile(tile) 142 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Mapview.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Mapview.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Mapview" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Mapview" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% source 10 | set I18NSPHINXOPTS=%SPHINXOPTS% source 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | goto end 41 | ) 42 | 43 | if "%1" == "clean" ( 44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 45 | del /q /s %BUILDDIR%\* 46 | goto end 47 | ) 48 | 49 | 50 | %SPHINXBUILD% 2> nul 51 | if errorlevel 9009 ( 52 | echo. 53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 54 | echo.installed, then set the SPHINXBUILD environment variable to point 55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 56 | echo.may add the Sphinx directory to PATH. 57 | echo. 58 | echo.If you don't have Sphinx installed, grab it from 59 | echo.http://sphinx-doc.org/ 60 | exit /b 1 61 | ) 62 | 63 | if "%1" == "html" ( 64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 68 | goto end 69 | ) 70 | 71 | if "%1" == "dirhtml" ( 72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 76 | goto end 77 | ) 78 | 79 | if "%1" == "singlehtml" ( 80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 84 | goto end 85 | ) 86 | 87 | if "%1" == "pickle" ( 88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can process the pickle files. 92 | goto end 93 | ) 94 | 95 | if "%1" == "json" ( 96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 97 | if errorlevel 1 exit /b 1 98 | echo. 99 | echo.Build finished; now you can process the JSON files. 100 | goto end 101 | ) 102 | 103 | if "%1" == "htmlhelp" ( 104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 105 | if errorlevel 1 exit /b 1 106 | echo. 107 | echo.Build finished; now you can run HTML Help Workshop with the ^ 108 | .hhp project file in %BUILDDIR%/htmlhelp. 109 | goto end 110 | ) 111 | 112 | if "%1" == "qthelp" ( 113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 117 | .qhcp project file in %BUILDDIR%/qthelp, like this: 118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Mapview.qhcp 119 | echo.To view the help file: 120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Mapview.ghc 121 | goto end 122 | ) 123 | 124 | if "%1" == "devhelp" ( 125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished. 129 | goto end 130 | ) 131 | 132 | if "%1" == "epub" ( 133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 137 | goto end 138 | ) 139 | 140 | if "%1" == "latex" ( 141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 145 | goto end 146 | ) 147 | 148 | if "%1" == "latexpdf" ( 149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 150 | cd %BUILDDIR%/latex 151 | make all-pdf 152 | cd %BUILDDIR%/.. 153 | echo. 154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 155 | goto end 156 | ) 157 | 158 | if "%1" == "latexpdfja" ( 159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 160 | cd %BUILDDIR%/latex 161 | make all-pdf-ja 162 | cd %BUILDDIR%/.. 163 | echo. 164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 165 | goto end 166 | ) 167 | 168 | if "%1" == "text" ( 169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 170 | if errorlevel 1 exit /b 1 171 | echo. 172 | echo.Build finished. The text files are in %BUILDDIR%/text. 173 | goto end 174 | ) 175 | 176 | if "%1" == "man" ( 177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 178 | if errorlevel 1 exit /b 1 179 | echo. 180 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 181 | goto end 182 | ) 183 | 184 | if "%1" == "texinfo" ( 185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 186 | if errorlevel 1 exit /b 1 187 | echo. 188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 189 | goto end 190 | ) 191 | 192 | if "%1" == "gettext" ( 193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 194 | if errorlevel 1 exit /b 1 195 | echo. 196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 197 | goto end 198 | ) 199 | 200 | if "%1" == "changes" ( 201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 202 | if errorlevel 1 exit /b 1 203 | echo. 204 | echo.The overview file is in %BUILDDIR%/changes. 205 | goto end 206 | ) 207 | 208 | if "%1" == "linkcheck" ( 209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 210 | if errorlevel 1 exit /b 1 211 | echo. 212 | echo.Link check complete; look for any errors in the above output ^ 213 | or in %BUILDDIR%/linkcheck/output.txt. 214 | goto end 215 | ) 216 | 217 | if "%1" == "doctest" ( 218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 219 | if errorlevel 1 exit /b 1 220 | echo. 221 | echo.Testing of doctests in the sources finished, look at the ^ 222 | results in %BUILDDIR%/doctest/output.txt. 223 | goto end 224 | ) 225 | 226 | if "%1" == "xml" ( 227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 228 | if errorlevel 1 exit /b 1 229 | echo. 230 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 231 | goto end 232 | ) 233 | 234 | if "%1" == "pseudoxml" ( 235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 236 | if errorlevel 1 exit /b 1 237 | echo. 238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 239 | goto end 240 | ) 241 | 242 | :end 243 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Mapview documentation build configuration file, created by 4 | # sphinx-quickstart on Mon Aug 25 00:36:08 2014. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | #sys.path.insert(0, os.path.abspath('.')) 20 | 21 | # -- General configuration ----------------------------------------------------- 22 | 23 | # If your documentation needs a minimal Sphinx version, state it here. 24 | #needs_sphinx = '1.0' 25 | 26 | # Add any Sphinx extension module names here, as strings. They can be extensions 27 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 28 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.todo'] 29 | 30 | # Add any paths that contain templates here, relative to this directory. 31 | templates_path = ['_templates'] 32 | 33 | # The suffix of source filenames. 34 | source_suffix = '.rst' 35 | 36 | # The encoding of source files. 37 | #source_encoding = 'utf-8-sig' 38 | 39 | # The master toctree document. 40 | master_doc = 'index' 41 | 42 | # General information about the project. 43 | project = u'Mapview' 44 | copyright = u'2014, Mathieu Virbel' 45 | 46 | # The version info for the project you're documenting, acts as replacement for 47 | # |version| and |release|, also used in various other places throughout the 48 | # built documents. 49 | # 50 | # The short X.Y version. 51 | version = '0.2' 52 | # The full version, including alpha/beta/rc tags. 53 | release = '0.2' 54 | 55 | # The language for content autogenerated by Sphinx. Refer to documentation 56 | # for a list of supported languages. 57 | #language = None 58 | 59 | # There are two options for replacing |today|: either, you set today to some 60 | # non-false value, then it is used: 61 | #today = '' 62 | # Else, today_fmt is used as the format for a strftime call. 63 | #today_fmt = '%B %d, %Y' 64 | 65 | # List of patterns, relative to source directory, that match files and 66 | # directories to ignore when looking for source files. 67 | exclude_patterns = [] 68 | 69 | # The reST default role (used for this markup: `text`) to use for all documents. 70 | #default_role = None 71 | 72 | # If true, '()' will be appended to :func: etc. cross-reference text. 73 | #add_function_parentheses = True 74 | 75 | # If true, the current module name will be prepended to all description 76 | # unit titles (such as .. function::). 77 | #add_module_names = True 78 | 79 | # If true, sectionauthor and moduleauthor directives will be shown in the 80 | # output. They are ignored by default. 81 | #show_authors = False 82 | 83 | # The name of the Pygments (syntax highlighting) style to use. 84 | pygments_style = 'sphinx' 85 | 86 | # A list of ignored prefixes for module index sorting. 87 | #modindex_common_prefix = [] 88 | 89 | # If true, keep warnings as "system message" paragraphs in the built documents. 90 | #keep_warnings = False 91 | 92 | 93 | # -- Options for HTML output --------------------------------------------------- 94 | 95 | # The theme to use for HTML and HTML Help pages. See the documentation for 96 | # a list of builtin themes. 97 | html_theme = 'default' 98 | 99 | # Theme options are theme-specific and customize the look and feel of a theme 100 | # further. For a list of options available for each theme, see the 101 | # documentation. 102 | #html_theme_options = {} 103 | 104 | # Add any paths that contain custom themes here, relative to this directory. 105 | #html_theme_path = [] 106 | 107 | # The name for this set of Sphinx documents. If None, it defaults to 108 | # " v documentation". 109 | #html_title = None 110 | 111 | # A shorter title for the navigation bar. Default is the same as html_title. 112 | #html_short_title = None 113 | 114 | # The name of an image file (relative to this directory) to place at the top 115 | # of the sidebar. 116 | #html_logo = None 117 | 118 | # The name of an image file (within the static path) to use as favicon of the 119 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 120 | # pixels large. 121 | #html_favicon = None 122 | 123 | # Add any paths that contain custom static files (such as style sheets) here, 124 | # relative to this directory. They are copied after the builtin static files, 125 | # so a file named "default.css" will overwrite the builtin "default.css". 126 | html_static_path = ['_static'] 127 | 128 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 129 | # using the given strftime format. 130 | #html_last_updated_fmt = '%b %d, %Y' 131 | 132 | # If true, SmartyPants will be used to convert quotes and dashes to 133 | # typographically correct entities. 134 | #html_use_smartypants = True 135 | 136 | # Custom sidebar templates, maps document names to template names. 137 | #html_sidebars = {} 138 | 139 | # Additional templates that should be rendered to pages, maps page names to 140 | # template names. 141 | #html_additional_pages = {} 142 | 143 | # If false, no module index is generated. 144 | #html_domain_indices = True 145 | 146 | # If false, no index is generated. 147 | #html_use_index = True 148 | 149 | # If true, the index is split into individual pages for each letter. 150 | #html_split_index = False 151 | 152 | # If true, links to the reST sources are added to the pages. 153 | #html_show_sourcelink = True 154 | 155 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 156 | #html_show_sphinx = True 157 | 158 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 159 | #html_show_copyright = True 160 | 161 | # If true, an OpenSearch description file will be output, and all pages will 162 | # contain a tag referring to it. The value of this option must be the 163 | # base URL from which the finished HTML is served. 164 | #html_use_opensearch = '' 165 | 166 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 167 | #html_file_suffix = None 168 | 169 | # Output file base name for HTML help builder. 170 | htmlhelp_basename = 'Mapviewdoc' 171 | 172 | 173 | # -- Options for LaTeX output -------------------------------------------------- 174 | 175 | latex_elements = { 176 | # The paper size ('letterpaper' or 'a4paper'). 177 | #'papersize': 'letterpaper', 178 | 179 | # The font size ('10pt', '11pt' or '12pt'). 180 | #'pointsize': '10pt', 181 | 182 | # Additional stuff for the LaTeX preamble. 183 | #'preamble': '', 184 | } 185 | 186 | # Grouping the document tree into LaTeX files. List of tuples 187 | # (source start file, target name, title, author, documentclass [howto/manual]). 188 | latex_documents = [ 189 | ('index', 'Mapview.tex', u'Mapview Documentation', 190 | u'Mathieu Virbel', 'manual'), 191 | ] 192 | 193 | # The name of an image file (relative to this directory) to place at the top of 194 | # the title page. 195 | #latex_logo = None 196 | 197 | # For "manual" documents, if this is true, then toplevel headings are parts, 198 | # not chapters. 199 | #latex_use_parts = False 200 | 201 | # If true, show page references after internal links. 202 | #latex_show_pagerefs = False 203 | 204 | # If true, show URL addresses after external links. 205 | #latex_show_urls = False 206 | 207 | # Documents to append as an appendix to all manuals. 208 | #latex_appendices = [] 209 | 210 | # If false, no module index is generated. 211 | #latex_domain_indices = True 212 | 213 | 214 | # -- Options for manual page output -------------------------------------------- 215 | 216 | # One entry per manual page. List of tuples 217 | # (source start file, name, description, authors, manual section). 218 | man_pages = [ 219 | ('index', 'mapview', u'Mapview Documentation', 220 | [u'Mathieu Virbel'], 1) 221 | ] 222 | 223 | # If true, show URL addresses after external links. 224 | #man_show_urls = False 225 | 226 | 227 | # -- Options for Texinfo output ------------------------------------------------ 228 | 229 | # Grouping the document tree into Texinfo files. List of tuples 230 | # (source start file, target name, title, author, 231 | # dir menu entry, description, category) 232 | texinfo_documents = [ 233 | ('index', 'Mapview', u'Mapview Documentation', 234 | u'Mathieu Virbel', 'Mapview', 'One line description of project.', 235 | 'Miscellaneous'), 236 | ] 237 | 238 | # Documents to append as an appendix to all manuals. 239 | #texinfo_appendices = [] 240 | 241 | # If false, no module index is generated. 242 | #texinfo_domain_indices = True 243 | 244 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 245 | #texinfo_show_urls = 'footnote' 246 | 247 | # If true, do not generate a @detailmenu in the "Top" node's menu. 248 | #texinfo_no_detailmenu = False 249 | -------------------------------------------------------------------------------- /mapview/geojson.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | Geojson layer 4 | ============= 5 | 6 | .. note:: 7 | 8 | Currently experimental and a work in progress, not fully optimized. 9 | 10 | 11 | Supports: 12 | 13 | - html color in properties 14 | - polygon geometry are cached and not redrawed when the parent mapview changes 15 | - linestring are redrawed everymove, it's ugly and slow. 16 | - marker are NOT supported 17 | 18 | """ 19 | 20 | __all__ = ["GeoJsonMapLayer"] 21 | 22 | import json 23 | from kivy.properties import StringProperty, ObjectProperty 24 | from kivy.graphics import (Canvas, PushMatrix, PopMatrix, MatrixInstruction, 25 | Translate, Scale) 26 | from kivy.graphics import Mesh, Line, Color 27 | from kivy.graphics.tesselator import Tesselator, WINDING_ODD, TYPE_POLYGONS 28 | from kivy.utils import get_color_from_hex 29 | from kivy.metrics import dp 30 | from kivy.utils import get_color_from_hex 31 | from mapview import CACHE_DIR 32 | from mapview.view import MapLayer 33 | from mapview.downloader import Downloader 34 | 35 | COLORS = { 36 | 'aliceblue': '#f0f8ff', 37 | 'antiquewhite': '#faebd7', 38 | 'aqua': '#00ffff', 39 | 'aquamarine': '#7fffd4', 40 | 'azure': '#f0ffff', 41 | 'beige': '#f5f5dc', 42 | 'bisque': '#ffe4c4', 43 | 'black': '#000000', 44 | 'blanchedalmond': '#ffebcd', 45 | 'blue': '#0000ff', 46 | 'blueviolet': '#8a2be2', 47 | 'brown': '#a52a2a', 48 | 'burlywood': '#deb887', 49 | 'cadetblue': '#5f9ea0', 50 | 'chartreuse': '#7fff00', 51 | 'chocolate': '#d2691e', 52 | 'coral': '#ff7f50', 53 | 'cornflowerblue': '#6495ed', 54 | 'cornsilk': '#fff8dc', 55 | 'crimson': '#dc143c', 56 | 'cyan': '#00ffff', 57 | 'darkblue': '#00008b', 58 | 'darkcyan': '#008b8b', 59 | 'darkgoldenrod': '#b8860b', 60 | 'darkgray': '#a9a9a9', 61 | 'darkgrey': '#a9a9a9', 62 | 'darkgreen': '#006400', 63 | 'darkkhaki': '#bdb76b', 64 | 'darkmagenta': '#8b008b', 65 | 'darkolivegreen': '#556b2f', 66 | 'darkorange': '#ff8c00', 67 | 'darkorchid': '#9932cc', 68 | 'darkred': '#8b0000', 69 | 'darksalmon': '#e9967a', 70 | 'darkseagreen': '#8fbc8f', 71 | 'darkslateblue': '#483d8b', 72 | 'darkslategray': '#2f4f4f', 73 | 'darkslategrey': '#2f4f4f', 74 | 'darkturquoise': '#00ced1', 75 | 'darkviolet': '#9400d3', 76 | 'deeppink': '#ff1493', 77 | 'deepskyblue': '#00bfff', 78 | 'dimgray': '#696969', 79 | 'dimgrey': '#696969', 80 | 'dodgerblue': '#1e90ff', 81 | 'firebrick': '#b22222', 82 | 'floralwhite': '#fffaf0', 83 | 'forestgreen': '#228b22', 84 | 'fuchsia': '#ff00ff', 85 | 'gainsboro': '#dcdcdc', 86 | 'ghostwhite': '#f8f8ff', 87 | 'gold': '#ffd700', 88 | 'goldenrod': '#daa520', 89 | 'gray': '#808080', 90 | 'grey': '#808080', 91 | 'green': '#008000', 92 | 'greenyellow': '#adff2f', 93 | 'honeydew': '#f0fff0', 94 | 'hotpink': '#ff69b4', 95 | 'indianred': '#cd5c5c', 96 | 'indigo': '#4b0082', 97 | 'ivory': '#fffff0', 98 | 'khaki': '#f0e68c', 99 | 'lavender': '#e6e6fa', 100 | 'lavenderblush': '#fff0f5', 101 | 'lawngreen': '#7cfc00', 102 | 'lemonchiffon': '#fffacd', 103 | 'lightblue': '#add8e6', 104 | 'lightcoral': '#f08080', 105 | 'lightcyan': '#e0ffff', 106 | 'lightgoldenrodyellow': '#fafad2', 107 | 'lightgray': '#d3d3d3', 108 | 'lightgrey': '#d3d3d3', 109 | 'lightgreen': '#90ee90', 110 | 'lightpink': '#ffb6c1', 111 | 'lightsalmon': '#ffa07a', 112 | 'lightseagreen': '#20b2aa', 113 | 'lightskyblue': '#87cefa', 114 | 'lightslategray': '#778899', 115 | 'lightslategrey': '#778899', 116 | 'lightsteelblue': '#b0c4de', 117 | 'lightyellow': '#ffffe0', 118 | 'lime': '#00ff00', 119 | 'limegreen': '#32cd32', 120 | 'linen': '#faf0e6', 121 | 'magenta': '#ff00ff', 122 | 'maroon': '#800000', 123 | 'mediumaquamarine': '#66cdaa', 124 | 'mediumblue': '#0000cd', 125 | 'mediumorchid': '#ba55d3', 126 | 'mediumpurple': '#9370d8', 127 | 'mediumseagreen': '#3cb371', 128 | 'mediumslateblue': '#7b68ee', 129 | 'mediumspringgreen': '#00fa9a', 130 | 'mediumturquoise': '#48d1cc', 131 | 'mediumvioletred': '#c71585', 132 | 'midnightblue': '#191970', 133 | 'mintcream': '#f5fffa', 134 | 'mistyrose': '#ffe4e1', 135 | 'moccasin': '#ffe4b5', 136 | 'navajowhite': '#ffdead', 137 | 'navy': '#000080', 138 | 'oldlace': '#fdf5e6', 139 | 'olive': '#808000', 140 | 'olivedrab': '#6b8e23', 141 | 'orange': '#ffa500', 142 | 'orangered': '#ff4500', 143 | 'orchid': '#da70d6', 144 | 'palegoldenrod': '#eee8aa', 145 | 'palegreen': '#98fb98', 146 | 'paleturquoise': '#afeeee', 147 | 'palevioletred': '#d87093', 148 | 'papayawhip': '#ffefd5', 149 | 'peachpuff': '#ffdab9', 150 | 'peru': '#cd853f', 151 | 'pink': '#ffc0cb', 152 | 'plum': '#dda0dd', 153 | 'powderblue': '#b0e0e6', 154 | 'purple': '#800080', 155 | 'red': '#ff0000', 156 | 'rosybrown': '#bc8f8f', 157 | 'royalblue': '#4169e1', 158 | 'saddlebrown': '#8b4513', 159 | 'salmon': '#fa8072', 160 | 'sandybrown': '#f4a460', 161 | 'seagreen': '#2e8b57', 162 | 'seashell': '#fff5ee', 163 | 'sienna': '#a0522d', 164 | 'silver': '#c0c0c0', 165 | 'skyblue': '#87ceeb', 166 | 'slateblue': '#6a5acd', 167 | 'slategray': '#708090', 168 | 'slategrey': '#708090', 169 | 'snow': '#fffafa', 170 | 'springgreen': '#00ff7f', 171 | 'steelblue': '#4682b4', 172 | 'tan': '#d2b48c', 173 | 'teal': '#008080', 174 | 'thistle': '#d8bfd8', 175 | 'tomato': '#ff6347', 176 | 'turquoise': '#40e0d0', 177 | 'violet': '#ee82ee', 178 | 'wheat': '#f5deb3', 179 | 'white': '#ffffff', 180 | 'whitesmoke': '#f5f5f5', 181 | 'yellow': '#ffff00', 182 | 'yellowgreen': '#9acd32' 183 | } 184 | 185 | 186 | def flatten(l): 187 | return [item for sublist in l for item in sublist] 188 | 189 | 190 | class GeoJsonMapLayer(MapLayer): 191 | 192 | source = StringProperty() 193 | geojson = ObjectProperty() 194 | cache_dir = StringProperty(CACHE_DIR) 195 | 196 | def __init__(self, **kwargs): 197 | self.first_time = True 198 | self.initial_zoom = None 199 | super(GeoJsonMapLayer, self).__init__(**kwargs) 200 | with self.canvas: 201 | self.canvas_polygon = Canvas() 202 | with self.canvas_polygon.before: 203 | PushMatrix() 204 | self.g_matrix = MatrixInstruction() 205 | self.g_scale = Scale() 206 | self.g_translate = Translate() 207 | with self.canvas_polygon: 208 | self.g_canvas_polygon = Canvas() 209 | with self.canvas_polygon.after: 210 | PopMatrix() 211 | 212 | def reposition(self): 213 | vx, vy = self.parent.delta_x, self.parent.delta_y 214 | pzoom = self.parent.zoom 215 | zoom = self.initial_zoom 216 | if zoom is None: 217 | self.initial_zoom = zoom = pzoom 218 | if zoom != pzoom: 219 | diff = 2**(pzoom - zoom) 220 | vx /= diff 221 | vy /= diff 222 | self.g_scale.x = self.g_scale.y = diff 223 | else: 224 | self.g_scale.x = self.g_scale.y = 1. 225 | self.g_translate.xy = vx, vy 226 | self.g_matrix.matrix = self.parent._scatter.transform 227 | 228 | if self.geojson: 229 | update = not self.first_time 230 | self.on_geojson(self, self.geojson, update=update) 231 | self.first_time = False 232 | 233 | def traverse_feature(self, func, part=None): 234 | """Traverse the whole geojson and call the func with every element 235 | found. 236 | """ 237 | if part is None: 238 | part = self.geojson 239 | if not part: 240 | return 241 | tp = part["type"] 242 | if tp == "FeatureCollection": 243 | for feature in part["features"]: 244 | func(feature) 245 | elif tp == "Feature": 246 | func(part) 247 | 248 | @property 249 | def bounds(self): 250 | # return the min lon, max lon, min lat, max lat 251 | bounds = [float("inf"), float("-inf"), float("inf"), float("-inf")] 252 | 253 | def _submit_coordinate(coord): 254 | lon, lat = coord 255 | bounds[0] = min(bounds[0], lon) 256 | bounds[1] = max(bounds[1], lon) 257 | bounds[2] = min(bounds[2], lat) 258 | bounds[3] = max(bounds[3], lat) 259 | 260 | def _get_bounds(feature): 261 | geometry = feature["geometry"] 262 | tp = geometry["type"] 263 | if tp == "Point": 264 | _submit_coordinate(geometry["coordinates"]) 265 | elif tp == "Polygon": 266 | for coordinate in geometry["coordinates"][0]: 267 | _submit_coordinate(coordinate) 268 | elif tp == "MultiPolygon": 269 | for polygon in geometry["coordinates"]: 270 | for coordinate in polygon[0]: 271 | _submit_coordinate(coordinate) 272 | self.traverse_feature(_get_bounds) 273 | return bounds 274 | 275 | @property 276 | def center(self): 277 | min_lon, max_lon, min_lat, max_lat = self.bounds 278 | cx = (max_lon - min_lon) / 2. 279 | cy = (max_lat - min_lat) / 2. 280 | return min_lon + cx, min_lat + cy 281 | 282 | def on_geojson(self, instance, geojson, update=False): 283 | if self.parent is None: 284 | return 285 | if not update: 286 | # print "Reload geojson (polygon)" 287 | self.g_canvas_polygon.clear() 288 | self._geojson_part(geojson, geotype="Polygon") 289 | # print "Reload geojson (LineString)" 290 | self.canvas_line.clear() 291 | self._geojson_part(geojson, geotype="LineString") 292 | 293 | def on_source(self, instance, value): 294 | if value.startswith("http://") or value.startswith("https://"): 295 | Downloader.instance( 296 | cache_dir=self.cache_dir 297 | ).download(value, self._load_geojson_url) 298 | else: 299 | with open(value, "rb") as fd: 300 | geojson = json.load(fd) 301 | self.geojson = geojson 302 | 303 | def _load_geojson_url(self, url, r): 304 | self.geojson = r.json() 305 | 306 | def _geojson_part(self, part, geotype=None): 307 | tp = part["type"] 308 | if tp == "FeatureCollection": 309 | for feature in part["features"]: 310 | if geotype and feature["geometry"]["type"] != geotype: 311 | continue 312 | self._geojson_part_f(feature) 313 | elif tp == "Feature": 314 | if geotype and part["geometry"]["type"] == geotype: 315 | self._geojson_part_f(part) 316 | else: 317 | # unhandled geojson part 318 | pass 319 | 320 | def _geojson_part_f(self, feature): 321 | properties = feature["properties"] 322 | geometry = feature["geometry"] 323 | graphics = self._geojson_part_geometry(geometry, properties) 324 | for g in graphics: 325 | tp = geometry["type"] 326 | if tp == "Polygon": 327 | self.g_canvas_polygon.add(g) 328 | else: 329 | self.canvas_line.add(g) 330 | 331 | def _geojson_part_geometry(self, geometry, properties): 332 | tp = geometry["type"] 333 | graphics = [] 334 | if tp == "Polygon": 335 | tess = Tesselator() 336 | for c in geometry["coordinates"]: 337 | xy = list(self._lonlat_to_xy(c)) 338 | xy = flatten(xy) 339 | tess.add_contour(xy) 340 | 341 | tess.tesselate(WINDING_ODD, TYPE_POLYGONS) 342 | 343 | color = self._get_color_from(properties.get("color", "FF000088")) 344 | graphics.append(Color(*color)) 345 | for vertices, indices in tess.meshes: 346 | graphics.append( 347 | Mesh( 348 | vertices=vertices, 349 | indices=indices, 350 | mode="triangle_fan")) 351 | 352 | elif tp == "LineString": 353 | stroke = get_color_from_hex(properties.get("stroke", "#ffffff")) 354 | stroke_width = dp(properties.get("stroke-width")) 355 | xy = list(self._lonlat_to_xy(geometry["coordinates"])) 356 | xy = flatten(xy) 357 | graphics.append(Color(*stroke)) 358 | graphics.append(Line(points=xy, width=stroke_width)) 359 | 360 | return graphics 361 | 362 | def _lonlat_to_xy(self, lonlats): 363 | view = self.parent 364 | zoom = view.zoom 365 | for lon, lat in lonlats: 366 | p = view.get_window_xy_from(lat, lon, zoom) 367 | p = p[0] - self.parent.delta_x, p[1] - self.parent.delta_y 368 | p = self.parent._scatter.to_local(*p) 369 | yield p 370 | 371 | def _get_color_from(self, value): 372 | color = COLORS.get(value.lower(), value) 373 | color = get_color_from_hex(color) 374 | return color 375 | -------------------------------------------------------------------------------- /mapview/clustered_marker_layer.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | Layer that support point clustering 4 | =================================== 5 | """ 6 | 7 | from os.path import dirname, join 8 | from math import sin, log, pi, atan, exp, floor, sqrt 9 | from mapview.view import MapLayer, MapMarker 10 | from kivy.lang import Builder 11 | from kivy.metrics import dp 12 | from kivy.properties import (ObjectProperty, NumericProperty, StringProperty, ListProperty) 13 | 14 | 15 | Builder.load_string(""" 16 | : 17 | size_hint: None, None 18 | source: root.source 19 | size: list(map(dp, self.texture_size)) 20 | allow_stretch: True 21 | 22 | Label: 23 | color: root.text_color 24 | pos: root.pos 25 | size: root.size 26 | text: "{}".format(root.num_points) 27 | font_size: dp(18) 28 | """) 29 | 30 | 31 | # longitude/latitude to spherical mercator in [0..1] range 32 | def lngX(lng): 33 | return lng / 360. + 0.5 34 | 35 | 36 | def latY(lat): 37 | if lat == 90: 38 | return 0 39 | if lat == -90: 40 | return 1 41 | s = sin(lat * pi / 180.) 42 | y = (0.5 - 0.25 * log((1 + s) / (1 - s)) / pi) 43 | return min(1, max(0, y)) 44 | 45 | 46 | # spherical mercator to longitude/latitude 47 | def xLng(x): 48 | return (x - 0.5) * 360 49 | 50 | 51 | def yLat(y): 52 | y2 = (180 - y * 360) * pi / 180 53 | return 360 * atan(exp(y2)) / pi - 90 54 | 55 | 56 | class KDBush(object): 57 | # kdbush implementation from https://github.com/mourner/kdbush/blob/master/src/kdbush.js 58 | # 59 | def __init__(self, points, node_size=64): 60 | super(KDBush, self).__init__() 61 | self.points = points 62 | self.node_size = node_size 63 | 64 | self.ids = ids = [0] * len(points) 65 | self.coords = coords = [0] * len(points) * 2 66 | for i, point in enumerate(points): 67 | ids[i] = i 68 | coords[2 * i] = point.x 69 | coords[2 * i + 1] = point.y 70 | 71 | self._sort(ids, coords, node_size, 0, len(ids) - 1, 0) 72 | 73 | def range(self, min_x, min_y, max_x, max_y): 74 | return self._range(self.ids, self.coords, min_x, min_y, max_x, max_y, 75 | self.node_size) 76 | 77 | def within(self, x, y, r): 78 | return self._within(self.ids, self.coords, x, y, r, self.node_size) 79 | 80 | def _sort(self, ids, coords, node_size, left, right, depth): 81 | if right - left <= node_size: 82 | return 83 | m = int(floor((left + right) / 2.)) 84 | self._select(ids, coords, m, left, right, depth % 2) 85 | self._sort(ids, coords, node_size, left, m - 1, depth + 1) 86 | self._sort(ids, coords, node_size, m + 1, right, depth + 1) 87 | 88 | def _select(self, ids, coords, k, left, right, inc): 89 | swap_item = self._swap_item 90 | while right > left: 91 | if (right - left) > 600: 92 | n = float(right - left + 1) 93 | m = k - left + 1 94 | z = log(n) 95 | s = 0.5 + exp(2 * z / 3.) 96 | sd = 0.5 * sqrt(z * s * (n - s) / n) * (-1 97 | if (m - n / 2.) < 0 else 1) 98 | new_left = max(left, int(floor(k - m * s / n + sd))) 99 | new_right = min(right, int(floor(k + (n - m) * s / n + sd))) 100 | self._select(ids, coords, k, new_left, new_right, inc) 101 | 102 | t = coords[2 * k + inc] 103 | i = left 104 | j = right 105 | 106 | swap_item(ids, coords, left, k) 107 | if coords[2 * right + inc] > t: 108 | swap_item(ids, coords, left, right) 109 | 110 | while i < j: 111 | swap_item(ids, coords, i, j) 112 | i += 1 113 | j -= 1 114 | while coords[2 * i + inc] < t: 115 | i += 1 116 | while coords[2 * j + inc] > t: 117 | j -= 1 118 | 119 | if coords[2 * left + inc] == t: 120 | swap_item(ids, coords, left, j) 121 | else: 122 | j += 1 123 | swap_item(ids, coords, j, right) 124 | 125 | if j <= k: 126 | left = j + 1 127 | if k <= j: 128 | right = j - 1 129 | 130 | def _swap_item(self, ids, coords, i, j): 131 | swap = self._swap 132 | swap(ids, i, j) 133 | swap(coords, 2 * i, 2 * j) 134 | swap(coords, 2 * i + 1, 2 * j + 1) 135 | 136 | def _swap(self, arr, i, j): 137 | tmp = arr[i] 138 | arr[i] = arr[j] 139 | arr[j] = tmp 140 | 141 | def _range(self, ids, coords, min_x, min_y, max_x, max_y, node_size): 142 | stack = [0, len(ids) - 1, 0] 143 | result = [] 144 | x = y = 0 145 | 146 | while stack: 147 | axis = stack.pop() 148 | right = stack.pop() 149 | left = stack.pop() 150 | 151 | if right - left <= node_size: 152 | for i in range(left, right + 1): 153 | x = coords[2 * i] 154 | y = coords[2 * i + 1] 155 | if (x >= min_x and x <= max_x and y >= min_y and 156 | y <= max_y): 157 | result.append(ids[i]) 158 | continue 159 | 160 | m = int(floor((left + right) / 2.)) 161 | 162 | x = coords[2 * m] 163 | y = coords[2 * m + 1] 164 | 165 | if (x >= min_x and x <= max_x and y >= min_y and y <= max_y): 166 | result.append(ids[m]) 167 | 168 | nextAxis = (axis + 1) % 2 169 | 170 | if (min_x <= x if axis == 0 else min_y <= y): 171 | stack.append(left) 172 | stack.append(m - 1) 173 | stack.append(nextAxis) 174 | if (max_x >= x if axis == 0 else max_y >= y): 175 | stack.append(m + 1) 176 | stack.append(right) 177 | stack.append(nextAxis) 178 | 179 | return result 180 | 181 | def _within(self, ids, coords, qx, qy, r, node_size): 182 | sq_dist = self._sq_dist 183 | stack = [0, len(ids) - 1, 0] 184 | result = [] 185 | r2 = r * r 186 | 187 | while stack: 188 | axis = stack.pop() 189 | right = stack.pop() 190 | left = stack.pop() 191 | 192 | if right - left <= node_size: 193 | for i in range(left, right + 1): 194 | if sq_dist(coords[2 * i], coords[2 * i + 1], qx, qy) <= r2: 195 | result.append(ids[i]) 196 | continue 197 | 198 | 199 | m = int(floor((left + right) / 2.)) 200 | 201 | x = coords[2 * m] 202 | y = coords[2 * m + 1] 203 | 204 | if sq_dist(x, y, qx, qy) <= r2: 205 | result.append(ids[m]) 206 | 207 | nextAxis = (axis + 1) % 2 208 | 209 | if (qx - r <= x) if axis == 0 else (qy - r <= y): 210 | stack.append(left) 211 | stack.append(m - 1) 212 | stack.append(nextAxis) 213 | if (qx + r >= x) if axis == 0 else (qy + r >= y): 214 | stack.append(m + 1) 215 | stack.append(right) 216 | stack.append(nextAxis) 217 | 218 | return result 219 | 220 | def _sq_dist(self, ax, ay, bx, by): 221 | dx = ax - bx 222 | dy = ay - by 223 | return dx * dx + dy * dy 224 | 225 | 226 | class Cluster(object): 227 | def __init__(self, x, y, num_points, id, props): 228 | super(Cluster, self).__init__() 229 | self.x = x 230 | self.y = y 231 | self.num_points = num_points 232 | self.zoom = float("inf") 233 | self.id = id 234 | self.props = props 235 | self.parent_id = None 236 | self.widget = None 237 | 238 | # preprocess lon/lat 239 | self.lon = xLng(x) 240 | self.lat = yLat(y) 241 | 242 | 243 | class Marker(object): 244 | def __init__(self, lon, lat, cls=MapMarker, options=None): 245 | super(Marker, self).__init__() 246 | self.lon = lon 247 | self.lat = lat 248 | self.cls = cls 249 | self.options = options 250 | 251 | # preprocess x/y from lon/lat 252 | self.x = lngX(lon) 253 | self.y = latY(lat) 254 | 255 | # cluster information 256 | self.id = None 257 | self.zoom = float("inf") 258 | self.parent_id = None 259 | self.widget = None 260 | 261 | def __repr__(self): 262 | return "".format(self.lon, self.lat, 263 | self.source) 264 | 265 | class SuperCluster(object): 266 | """Port of supercluster from mapbox in pure python 267 | """ 268 | 269 | def __init__(self, 270 | min_zoom=0, 271 | max_zoom=16, 272 | radius=40, 273 | extent=512, 274 | node_size=64): 275 | super(SuperCluster, self).__init__() 276 | self.min_zoom = min_zoom 277 | self.max_zoom = max_zoom 278 | self.radius = radius 279 | self.extent = extent 280 | self.node_size = node_size 281 | 282 | def load(self, points): 283 | """Load an array of markers. 284 | Once loaded, the index is immutable. 285 | """ 286 | from time import time 287 | self.trees = {} 288 | self.points = points 289 | 290 | for index, point in enumerate(points): 291 | point.id = index 292 | 293 | clusters = points 294 | for z in range(self.max_zoom, self.min_zoom - 1, -1): 295 | start = time() 296 | print("build tree", z) 297 | self.trees[z + 1] = KDBush(clusters, self.node_size) 298 | print("kdbush", (time() - start) * 1000) 299 | start = time() 300 | clusters = self._cluster(clusters, z) 301 | print(len(clusters)) 302 | print("clustering", (time() - start) * 1000) 303 | self.trees[self.min_zoom] = KDBush(clusters, self.node_size) 304 | 305 | def get_clusters(self, bbox, zoom): 306 | """For the given bbox [westLng, southLat, eastLng, northLat], and 307 | integer zoom, returns an array of clusters and markers 308 | """ 309 | tree = self.trees[self._limit_zoom(zoom)] 310 | ids = tree.range(lngX(bbox[0]), latY(bbox[3]), lngX(bbox[2]), latY(bbox[1])) 311 | clusters = [] 312 | for i in range(len(ids)): 313 | c = tree.points[ids[i]] 314 | if isinstance(c, Cluster): 315 | clusters.append(c) 316 | else: 317 | clusters.append(self.points[c.id]) 318 | return clusters 319 | 320 | def _limit_zoom(self, z): 321 | return max(self.min_zoom, min(self.max_zoom + 1, z)) 322 | 323 | def _cluster(self, points, zoom): 324 | clusters = [] 325 | c_append = clusters.append 326 | trees = self.trees 327 | r = self.radius / float(self.extent * pow(2, zoom)) 328 | 329 | # loop through each point 330 | for i in range(len(points)): 331 | p = points[i] 332 | # if we've already visited the point at this zoom level, skip it 333 | if p.zoom <= zoom: 334 | continue 335 | p.zoom = zoom 336 | 337 | # find all nearby points 338 | tree = trees[zoom + 1] 339 | neighbor_ids = tree.within(p.x, p.y, r) 340 | 341 | num_points = 1 342 | if isinstance(p, Cluster): 343 | num_points = p.num_points 344 | wx = p.x * num_points 345 | wy = p.y * num_points 346 | 347 | props = None 348 | 349 | for j in range(len(neighbor_ids)): 350 | b = tree.points[neighbor_ids[j]] 351 | # filter out neighbors that are too far or already processed 352 | if zoom < b.zoom: 353 | num_points2 = 1 354 | if isinstance(b, Cluster): 355 | num_points2 = b.num_points 356 | b.zoom = zoom # save the zoom (so it doesn't get processed twice) 357 | wx += b.x * num_points2 # accumulate coordinates for calculating weighted center 358 | wy += b.y * num_points2 359 | num_points += num_points2 360 | b.parent_id = i 361 | 362 | if num_points == 1: 363 | c_append(p) 364 | else: 365 | p.parent_id = i 366 | c_append(Cluster(wx / num_points, wy / num_points, num_points, i, props)) 367 | return clusters 368 | 369 | 370 | class ClusterMapMarker(MapMarker): 371 | source = StringProperty(join(dirname(__file__), "icons", "cluster.png")) 372 | cluster = ObjectProperty() 373 | num_points = NumericProperty() 374 | text_color = ListProperty([.1, .1, .1, 1]) 375 | def on_cluster(self, instance, cluster): 376 | self.num_points = cluster.num_points 377 | 378 | def on_touch_down(self, touch): 379 | return False 380 | 381 | 382 | class ClusteredMarkerLayer(MapLayer): 383 | cluster_cls = ObjectProperty(ClusterMapMarker) 384 | cluster_min_zoom = NumericProperty(0) 385 | cluster_max_zoom = NumericProperty(16) 386 | cluster_radius = NumericProperty("40dp") 387 | cluster_extent = NumericProperty(512) 388 | cluster_node_size = NumericProperty(64) 389 | 390 | def __init__(self, **kwargs): 391 | self.cluster = None 392 | self.cluster_markers = [] 393 | super(ClusteredMarkerLayer, self).__init__(**kwargs) 394 | 395 | def add_marker(self, lon, lat, cls=MapMarker, options=None): 396 | if options is None: 397 | options = {} 398 | marker = Marker(lon, lat, cls, options) 399 | self.cluster_markers.append(marker) 400 | return marker 401 | 402 | def remove_marker(self, marker): 403 | self.cluster_markers.remove(marker) 404 | 405 | def reposition(self): 406 | if self.cluster is None: 407 | self.build_cluster() 408 | margin = dp(48) 409 | mapview = self.parent 410 | set_marker_position = self.set_marker_position 411 | bbox = mapview.get_bbox(margin) 412 | bbox = (bbox[1], bbox[0], bbox[3], bbox[2]) 413 | self.clear_widgets() 414 | for point in self.cluster.get_clusters(bbox, mapview.zoom): 415 | widget = point.widget 416 | if widget is None: 417 | widget = self.create_widget_for(point) 418 | set_marker_position(mapview, widget) 419 | self.add_widget(widget) 420 | 421 | def build_cluster(self): 422 | self.cluster = SuperCluster( 423 | min_zoom=self.cluster_min_zoom, 424 | max_zoom=self.cluster_max_zoom, 425 | radius=self.cluster_radius, 426 | extent=self.cluster_extent, 427 | node_size=self.cluster_node_size 428 | ) 429 | self.cluster.load(self.cluster_markers) 430 | 431 | def create_widget_for(self, point): 432 | if isinstance(point, Marker): 433 | point.widget = point.cls(lon=point.lon, lat=point.lat, **point.options) 434 | elif isinstance(point, Cluster): 435 | point.widget = self.cluster_cls(lon=point.lon, lat=point.lat, cluster=point) 436 | return point.widget 437 | 438 | def set_marker_position(self, mapview, marker): 439 | x, y = mapview.get_window_xy_from(marker.lat, marker.lon, mapview.zoom) 440 | marker.x = int(x - marker.width * marker.anchor_x) 441 | marker.y = int(y - marker.height * marker.anchor_y) 442 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. Mapview documentation master file, created by 2 | sphinx-quickstart on Mon Aug 25 00:36:08 2014. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Mapview's documentation! 7 | =================================== 8 | 9 | :class:`MapView` is a Kivy widget specialized into tiles-based map rendering. 10 | 11 | Requirements 12 | ------------ 13 | 14 | MapView is based on: 15 | 16 | - `concurrent.futures `_: 17 | they are natives in Python 3.2. On previous Python 18 | version, you need to use `futures `_. 19 | - `requests `_ 20 | 21 | 22 | Current limitations 23 | ------------------- 24 | 25 | - The API is still moving, it may contain errors. 26 | - Some providers can be slow or timeout. This is not an issue from MapView. 27 | - If a tile is not correctly downloaded or missing from the provider, the error 28 | will be showed on the console, but nothing happen on the map itself. This can 29 | lead to a defect user experience. 30 | - When leaving, `concurrent.futures` are joining all the threads created. It can 31 | stuck the application at a maximum time of 5 seconds (requests timeout). More 32 | if the network is unstable. There is no way to force it yet. 33 | - The cache is not controlable, if the user move the map a lot, it can fill the 34 | disk easily. More control will be given later. 35 | 36 | Usage 37 | ----- 38 | 39 | If you use Kivy garden, you can import the widget like this:: 40 | 41 | from kivy.garden.mapview import MapView, MarkerMap 42 | map = MapView() 43 | 44 | You can customize the default zoom and center the view on Lille by:: 45 | 46 | map = MapView(zoom=9, lon=50.6394, lat=3.057) 47 | 48 | Then, you can create marker and place them on the map. Normally, anything that 49 | goes on a map should go on a :class:`MapLayer`. Hopefully, the :class:`MapView` 50 | give an API for adding marker directly, and creates a :class:`MarkerMapLayer` 51 | if you did'nt created one yet:: 52 | 53 | m1 = MapMarker(lon=50.6394, lat=3.057) # Lille 54 | m2 = MapMarker(lon=-33.867, lat=151.206) # Sydney 55 | map.add_marker(m1) 56 | map.add_marker(m2) 57 | 58 | You can also change the providers by: 59 | 60 | 1. using a provider key:: 61 | 62 | map.map_source = "mapquest-osm" 63 | 64 | 2. using a new MapSource object:: 65 | 66 | source = MapSource(url="http://my-custom-map.source.com/{z}/{x}/{y}.png", 67 | cache_key="my-custom-map", tile_size=512, 68 | image_ext="png", attribution="@ Myself") 69 | map.map_source = source 70 | 71 | API 72 | --- 73 | 74 | .. py:module:: mapview 75 | 76 | .. py:class:: Coordinate(lon, lat) 77 | 78 | Named tuple that represent a geographic coordinate with latitude/longitude 79 | 80 | :param float lon: Longitude 81 | :param float lat: Latitude 82 | 83 | 84 | .. py:class:: MapSource(url, cache_key, min_zoom, max_zoom, tile_size, image_ext, attribution, subdomains) 85 | 86 | Class that represent a map source. All the transformations from X/Y/Z to 87 | longitude, latitude, zoom, and limitations of the providers goes are stored 88 | here. 89 | 90 | :param str url: Tile's url of the providers. 91 | Defaults to `http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png` 92 | :param str cache_key: Key for storing the tiles. Must be unique and not 93 | colliding with another providers, otherwise tiles will not be 94 | downloaded again. 95 | Defaults to "osm" 96 | :param int min_zoom: Minimum zoom value acceptable for this provider. 97 | Defaults to 0. 98 | :param int max_zoom: Maximum zoom value acceptable for this provider. 99 | Defaults to 19. 100 | :param int tile_size: Size of a image tile returned by the provider. 101 | Defaults to 256. 102 | :param str attribution: Attribution for this provider. 103 | Defaults to empty string 104 | :param str subdomains: Domains substitutions for the {s} in the url. 105 | Defaults to "abc" 106 | 107 | .. py:method:: get_x(zoom, lon) 108 | 109 | Get the x position to the longitude in the map source's projection 110 | 111 | :param int zoom: Zoom level to look at 112 | :param float lon: Longitude 113 | :return: X position 114 | :rtype: float 115 | 116 | .. py:method:: get_y(zoom, lat) 117 | 118 | Get the y position to the latitude in the map source's projection 119 | 120 | :param int zoom: Zoom level to look at 121 | :param float lat: Latitude 122 | :return: Y position 123 | :rtype: float 124 | 125 | .. py:method:: get_lon(zoom, x) 126 | 127 | Get the longitude to the x position in the map source's projection 128 | 129 | :param int zoom: Zoom level to look at 130 | :param float x: X position in the map 131 | :return: Longitude 132 | :rtype: float 133 | 134 | .. py:method:: get_lat(zoom, y) 135 | 136 | Get the latitude to the y position in the map source's projection 137 | 138 | :param int zoom: Zoom level to look at 139 | :param float y: Y position in the map 140 | :return: Latitude 141 | :rtype: float 142 | 143 | .. py:method:: get_col_count(zoom) 144 | 145 | Return the number of column for this provider at this zoom level. 146 | 147 | :param int zoom: Zoom level to look at 148 | :return: Number of column 149 | :rtype: int 150 | 151 | .. py:method:: get_row_count(zoom) 152 | 153 | Return the number of row for this provider at this zoom level. 154 | 155 | :param int zoom: Zoom level to look at 156 | :return: Number of rows 157 | :rtype: int 158 | 159 | .. py:method:: get_max_zoom() 160 | 161 | Return the maximum zoom of this source 162 | 163 | :return: Maximum zoom 164 | :rtype: int 165 | 166 | .. py:method:: get_min_zoom() 167 | 168 | Return the minimum zoom of this source 169 | 170 | :return: Minimum zoom 171 | :rtype: int 172 | 173 | 174 | .. py:class:: MapMarker 175 | 176 | A marker on the map, that must be used on a :class:`MapMarker`, or with 177 | :meth:`MapView.add_marker` or with :meth:`MapView.add_widget` 178 | 179 | :Events: 180 | `on_press`: Fired when the MapMarker is pressed 181 | `on_release`: Fired when the MapMarker is release 182 | 183 | .. py:attribute:: anchor_x 184 | 185 | Anchor of the Marker on the X axis. Defaults to 0.5, means the anchor 186 | will be at the X center of the image 187 | 188 | .. py:attribute:: anchor_y 189 | 190 | Anchor of the marker on the Y axis. Defaults to 0, means the anchor 191 | will be at the Y bottom of the image 192 | 193 | .. py:attribute:: lat 194 | 195 | Latitude of the marker 196 | 197 | .. py:attribute:: lon 198 | 199 | Longitude of the marker 200 | 201 | .. py:attribute:: source 202 | 203 | Image source of the marker, defaults to `marker.png` within the mapview 204 | package. 205 | 206 | 207 | .. py:class:: MapView 208 | 209 | MapView is a widget that control the map displaying, navigation and layers 210 | management. 211 | 212 | :Available events: 213 | `on_map_relocated`: called everytime the MapView change location 214 | 215 | .. py:attribute:: lon 216 | 217 | Longitude at the center of the widget, read-only. 218 | 219 | .. py:attribute:: lat 220 | 221 | Latitude at the center of the widget, read-only. 222 | 223 | .. py:attribute:: zoom 224 | 225 | Zoom of the MapView. Must be between :meth:`MapSource.get_min_zoom` and 226 | :meth:`MapSource.get_max_zoom`. Default to 0 227 | 228 | .. py:attribute:: map_source 229 | 230 | Provider of the map, default to an empty :class:`MapSource` 231 | 232 | .. py:attribute:: double_tap_zoom 233 | 234 | If True, this will activate the double-tap to zoom. 235 | 236 | Defaults to False. 237 | 238 | .. py:attribute:: pause_on_action 239 | 240 | Pause on any loading / tiles loading when an action is done. This allow 241 | better performance on mobile, but can be safely deactivated on desktop. 242 | 243 | Defaults to True. 244 | 245 | .. py:attribute:: scale 246 | 247 | Current scale of the internal scatter, read-only. This is usually not 248 | used in user-side unless you're hacking mapview. 249 | 250 | .. py:attribute:: snap_to_zoom 251 | 252 | When the user initiate a zoom, it will snap to the closest zoom for 253 | better graphics. The map can be blur if the map is scaled between 2 254 | zoom. 255 | 256 | Defaults to True, even if it doesn't fully working yet. 257 | 258 | .. py:method:: add_layer(layer) 259 | 260 | Add a new layer to update at the same time than the base tile layer 261 | 262 | :param MapLayer layer: Map layer to add 263 | 264 | .. py:method:: add_marker(marker, layer=None) 265 | 266 | Add a marker into a `layer`. If `layer` is None, it will be added in 267 | the default marker layer. If there is no default marker layer, a new 268 | one will be automatically created. 269 | 270 | :param MapMarker marker: The marker to add 271 | :param MarkerMapLayer layer: The layer to use 272 | 273 | .. py:method:: center_on(lat, lon) 274 | 275 | Center the map on the coordinate (lat, lon) 276 | 277 | :param float lat: Latitude 278 | :param float lon: Longitude 279 | 280 | .. py:method:: get_bbox(margin=0) 281 | 282 | Returns the bounding box from the bottom-left to the top-right. 283 | 284 | :param float margin: Optionnal margin to extend the Bbox bounds 285 | :return: Bounding box 286 | :rtype: :class:`Bbox` 287 | 288 | .. py:method:: get_latlon_at(x, y, zoom=None): 289 | 290 | Return the current coordinate (lat, lon) at the (x, y) widget coordinate 291 | 292 | :param float x: X widget coordinate 293 | :param float y: Y widget coordinate 294 | :return: lat/lon Coordinate 295 | :rtype: :class:`Coordinate` 296 | 297 | .. py:method:: remove_layer(layer) 298 | 299 | Remove a previously added :class:`MapLayer` 300 | 301 | :param MapLayer layer: A map layer 302 | 303 | .. py:method:: remove_marker(marker) 304 | 305 | Remove a previously added :class:`MarkerMap` 306 | 307 | :param MarkerMap marker: The marker 308 | 309 | .. py:method:: set_zoom_at(zoom, x, y, scale=None) 310 | 311 | Sets the zoom level, leaving the (x, y) at the exact same point in the 312 | view. 313 | 314 | :param float zoom: New zoom 315 | :param float x: X coordinate to zoom at 316 | :param float y: Y coordinate to zoom at 317 | :param float scale: (internal) Scale to set on the scatter 318 | 319 | .. py:method:: unload() 320 | 321 | Unload the view and all the layers. It also cancel all the remaining 322 | downloads. The map should not be used after this. 323 | 324 | 325 | .. py:class:: MapLayer 326 | 327 | A map layer. It is repositioned everytime the :class:`MapView` is moved. 328 | 329 | .. py:method:: reposition() 330 | 331 | Function called when the :class:`MapView` is moved. You must recalculate 332 | the position of your children, and handle the visibility. 333 | 334 | .. py:method:: unload() 335 | 336 | Called when the view want to completely unload the layer. 337 | 338 | 339 | .. py:class:: MarkerMapLayer(MapLayer) 340 | 341 | A map layer specialized for handling :class:`MapMarker`. 342 | 343 | 344 | .. py:module:: mapview.mbtsource 345 | 346 | .. py:class:: MBTilesMapSource(MapSource) 347 | 348 | Use a `Mbtiles `_ as a source for a :class:`MapView` 349 | 350 | 351 | .. py:module:: mapview.geojson 352 | 353 | .. py:class:: GeoJsonMapLayer(MapLayer) 354 | 355 | A Geojson :class:`MapLayer`. 356 | 357 | **Experimental**, only Polygon and LineString feature are supported. 358 | Marker are not yet implemented, due to lack of API for wiring Marker 359 | selection back to you. 360 | 361 | .. py:attribute:: source 362 | 363 | A Geojson filename to load, defaults to None. 364 | 365 | .. py:attribute:: geojson 366 | 367 | A dictionary structured as a Geojson. This attribute contain the content 368 | of a :attr:`source` if passed. 369 | 370 | 371 | .. py:module:: mapview.clustered_marker_layer 372 | 373 | .. py:class:: ClusteredMarkerLayer(MapLayer) 374 | 375 | **Experimental** Layout that implement `marker clustering 376 | `_. 377 | It implement its own version of `Super Cluster 378 | `_, based itself on a `KD-tree 379 | `_. 380 | 381 | Aka you can load like 2000 markers without issues. The cluster index is 382 | immutable, so if you add a new marker, it will be rebuild from scratch. 383 | 384 | Please note that the widget creation is done on the fly by the layer, 385 | not by you. 386 | 387 | DONT use `add_widget`, use :meth:`add_marker` 388 | 389 | Example:: 390 | 391 | layer = ClusteredMarkerLayer() 392 | for i in range(2000): 393 | lon = random() * 360 - 180 394 | lat = random() * 180 - 90 395 | layer.add_marker(lon=lon, lat=lat, cls=MapMarker) 396 | 397 | # then you can add the layer to your mapview 398 | mapview = MapView() 399 | mapview.add_widget(layer) 400 | 401 | .. py:attribute:: cluster_cls 402 | 403 | Reference to the class widget for creating a cluster widget. Defaults to 404 | :class:`ClusterMapMarker` 405 | 406 | .. py:attribute:: cluster_min_zoom 407 | 408 | Minimum zoom level at which clusters are generated. Defaults to 0 409 | 410 | .. py:attribute:: cluster_max_zoom 411 | 412 | Maximum zoom level at which clusters are generated. Defaults to 16 413 | 414 | .. py:attribute:: cluster_radius 415 | 416 | Cluster radius, in pixels. Defaults to 40dp 417 | 418 | .. py:attribute:: cluster_extent 419 | 420 | Tile extent. Radius is calculated relative to this value. Defaults 421 | to 512. 422 | 423 | .. py:attribute:: cluster_node_size 424 | 425 | Size of the KD-tree leaf node. Affects performance. Defaults to 64. 426 | 427 | .. py:method:: add_marker(lon, lat, cls=MapMarker, options=None) 428 | 429 | Method to add a marker to the layer. 430 | 431 | :param float lon: Longitude 432 | :param float lat: Latitude 433 | :param object cls: Widget class to use for creating this marker. 434 | Defaults to :class:`MapMarker` 435 | :param dict options: Options to pass to the widget at instanciation. 436 | Defaults to an empty dict. 437 | :return: The instance of a Marker (internal class, not the widget) 438 | 439 | .. py::method:: build_cluster() 440 | 441 | Method to call for building the cluster. It is done automatically at the 442 | first rendering. If you missed it, or need to rebuild after readding 443 | marker, just call this function. 444 | 445 | .. py:class:: ClusterMapMarker(MapMarker) 446 | 447 | Widget created for displaying a Cluster. 448 | 449 | .. py:attribute:: cluster 450 | 451 | Reference to the Cluster used for this widget 452 | 453 | .. py:attribute:: num_points 454 | 455 | Number of marker that the cluster contain. 456 | 457 | .. py:attribute:: text_color 458 | 459 | Color used for the text, defaults to [.1, .1, .1, 1]. 460 | If you want others options, best is to do your own cluster 461 | widget including the label you want (font, size, etc) and customizing 462 | the background color. 463 | 464 | 465 | Indices and tables 466 | ================== 467 | 468 | * :ref:`genindex` 469 | * :ref:`modindex` 470 | * :ref:`search` 471 | -------------------------------------------------------------------------------- /mapview/view.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | 3 | __all__ = ["MapView", "MapMarker", "MapMarkerPopup", "MapLayer", 4 | "MarkerMapLayer"] 5 | 6 | from os.path import join, dirname 7 | from kivy.clock import Clock 8 | from kivy.metrics import dp 9 | from kivy.uix.widget import Widget 10 | from kivy.uix.label import Label 11 | from kivy.uix.image import Image 12 | from kivy.uix.scatter import Scatter 13 | from kivy.uix.behaviors import ButtonBehavior 14 | from kivy.properties import NumericProperty, ObjectProperty, ListProperty, \ 15 | AliasProperty, BooleanProperty, StringProperty 16 | from kivy.graphics import Canvas, Color, Rectangle 17 | from kivy.graphics.transformation import Matrix 18 | from kivy.lang import Builder 19 | from kivy.compat import string_types 20 | from math import ceil 21 | from mapview import MIN_LONGITUDE, MAX_LONGITUDE, MIN_LATITUDE, MAX_LATITUDE, \ 22 | CACHE_DIR, Coordinate, Bbox 23 | from mapview.source import MapSource 24 | from mapview.utils import clamp 25 | from itertools import takewhile 26 | 27 | import webbrowser 28 | 29 | Builder.load_string(""" 30 | : 31 | size_hint: None, None 32 | source: root.source 33 | size: list(map(dp, self.texture_size)) 34 | allow_stretch: True 35 | 36 | : 37 | canvas.before: 38 | StencilPush 39 | Rectangle: 40 | pos: self.pos 41 | size: self.size 42 | StencilUse 43 | Color: 44 | rgba: self.background_color 45 | Rectangle: 46 | pos: self.pos 47 | size: self.size 48 | canvas.after: 49 | StencilUnUse 50 | Rectangle: 51 | pos: self.pos 52 | size: self.size 53 | StencilPop 54 | 55 | ClickableLabel: 56 | text: root.map_source.attribution if hasattr(root.map_source, "attribution") else "" 57 | size_hint: None, None 58 | size: self.texture_size[0] + sp(8), self.texture_size[1] + sp(4) 59 | font_size: "10sp" 60 | right: [root.right, self.center][0] 61 | color: 0, 0, 0, 1 62 | markup: True 63 | canvas.before: 64 | Color: 65 | rgba: .8, .8, .8, .8 66 | Rectangle: 67 | pos: self.pos 68 | size: self.size 69 | 70 | 71 | : 72 | auto_bring_to_front: False 73 | do_rotation: False 74 | scale_min: 0.2 75 | scale_max: 3. 76 | 77 | : 78 | RelativeLayout: 79 | id: placeholder 80 | y: root.top 81 | center_x: root.center_x 82 | size: root.popup_size 83 | 84 | """) 85 | 86 | 87 | class ClickableLabel(Label): 88 | def on_ref_press(self, *args): 89 | webbrowser.open(str(args[0]), new=2) 90 | 91 | 92 | class Tile(Rectangle): 93 | def __init__(self, *args, **kwargs): 94 | super(Tile, self).__init__(*args, **kwargs) 95 | self.cache_dir = kwargs.get('cache_dir', CACHE_DIR) 96 | 97 | @property 98 | def cache_fn(self): 99 | map_source = self.map_source 100 | fn = map_source.cache_fmt.format( 101 | image_ext=map_source.image_ext, 102 | cache_key=map_source.cache_key, 103 | **self.__dict__) 104 | return join(self.cache_dir, fn) 105 | 106 | def set_source(self, cache_fn): 107 | self.source = cache_fn 108 | self.state = "need-animation" 109 | 110 | 111 | class MapMarker(ButtonBehavior, Image): 112 | """A marker on a map, that must be used on a :class:`MapMarker` 113 | """ 114 | 115 | anchor_x = NumericProperty(0.5) 116 | """Anchor of the marker on the X axis. Defaults to 0.5, mean the anchor will 117 | be at the X center of the image. 118 | """ 119 | 120 | anchor_y = NumericProperty(0) 121 | """Anchor of the marker on the Y axis. Defaults to 0, mean the anchor will 122 | be at the Y bottom of the image. 123 | """ 124 | 125 | lat = NumericProperty(0) 126 | """Latitude of the marker 127 | """ 128 | 129 | lon = NumericProperty(0) 130 | """Longitude of the marker 131 | """ 132 | 133 | source = StringProperty(join(dirname(__file__), "icons", "marker.png")) 134 | """Source of the marker, defaults to our own marker.png 135 | """ 136 | 137 | # (internal) reference to its layer 138 | _layer = None 139 | 140 | def detach(self): 141 | if self._layer: 142 | self._layer.remove_widget(self) 143 | self._layer = None 144 | 145 | 146 | class MapMarkerPopup(MapMarker): 147 | is_open = BooleanProperty(False) 148 | placeholder = ObjectProperty(None) 149 | popup_size = ListProperty([100, 100]) 150 | 151 | def add_widget(self, widget): 152 | if not self.placeholder: 153 | self.placeholder = widget 154 | if self.is_open: 155 | super(MapMarkerPopup, self).add_widget(self.placeholder) 156 | else: 157 | self.placeholder.add_widget(widget) 158 | 159 | def remove_widget(self, widget): 160 | if widget is not self.placeholder: 161 | self.placeholder.remove_widget(widget) 162 | else: 163 | super(MapMarkerPopup, self).remove_widget(widget) 164 | 165 | def on_is_open(self, *args): 166 | self.refresh_open_status() 167 | 168 | def on_release(self, *args): 169 | self.is_open = not self.is_open 170 | 171 | def refresh_open_status(self): 172 | if not self.is_open and self.placeholder.parent: 173 | super(MapMarkerPopup, self).remove_widget(self.placeholder) 174 | elif self.is_open and not self.placeholder.parent: 175 | super(MapMarkerPopup, self).add_widget(self.placeholder) 176 | 177 | 178 | class MapLayer(Widget): 179 | """A map layer, that is repositionned everytime the :class:`MapView` is 180 | moved. 181 | """ 182 | viewport_x = NumericProperty(0) 183 | viewport_y = NumericProperty(0) 184 | 185 | def reposition(self): 186 | """Function called when :class:`MapView` is moved. You must recalculate 187 | the position of your children. 188 | """ 189 | pass 190 | 191 | def unload(self): 192 | """Called when the view want to completly unload the layer. 193 | """ 194 | pass 195 | 196 | 197 | class MarkerMapLayer(MapLayer): 198 | """A map layer for :class:`MapMarker` 199 | """ 200 | order_marker_by_latitude = BooleanProperty(True) 201 | 202 | def __init__(self, **kwargs): 203 | self.markers = [] 204 | super(MarkerMapLayer, self).__init__(**kwargs) 205 | 206 | def insert_marker(self, marker, **kwargs): 207 | if self.order_marker_by_latitude: 208 | before = list(takewhile( 209 | lambda i_m: i_m[1].lat < marker.lat, 210 | enumerate(self.children) 211 | )) 212 | if before: 213 | kwargs['index'] = before[-1][0] + 1 214 | 215 | super(MarkerMapLayer, self).add_widget(marker, **kwargs) 216 | 217 | def add_widget(self, marker): 218 | marker._layer = self 219 | self.markers.append(marker) 220 | self.insert_marker(marker) 221 | 222 | def remove_widget(self, marker): 223 | marker._layer = None 224 | if marker in self.markers: 225 | self.markers.remove(marker) 226 | super(MarkerMapLayer, self).remove_widget(marker) 227 | 228 | def reposition(self): 229 | if not self.markers: 230 | return 231 | mapview = self.parent 232 | set_marker_position = self.set_marker_position 233 | bbox = None 234 | # reposition the markers depending the latitude 235 | markers = sorted(self.markers, key=lambda x: -x.lat) 236 | margin = max((max(marker.size) for marker in markers)) 237 | bbox = mapview.get_bbox(margin) 238 | for marker in markers: 239 | if bbox.collide(marker.lat, marker.lon): 240 | set_marker_position(mapview, marker) 241 | if not marker.parent: 242 | self.insert_marker(marker) 243 | else: 244 | super(MarkerMapLayer, self).remove_widget(marker) 245 | 246 | def set_marker_position(self, mapview, marker): 247 | x, y = mapview.get_window_xy_from(marker.lat, marker.lon, mapview.zoom) 248 | marker.x = int(x - marker.width * marker.anchor_x) 249 | marker.y = int(y - marker.height * marker.anchor_y) 250 | 251 | def unload(self): 252 | self.clear_widgets() 253 | del self.markers[:] 254 | 255 | 256 | class MapViewScatter(Scatter): 257 | # internal 258 | def on_transform(self, *args): 259 | super(MapViewScatter, self).on_transform(*args) 260 | self.parent.on_transform(self.transform) 261 | 262 | def collide_point(self, x, y): 263 | # print "collide_point", x, y 264 | return True 265 | 266 | 267 | class MapView(Widget): 268 | """MapView is the widget that control the map displaying, navigation, and 269 | layers management. 270 | """ 271 | 272 | lon = NumericProperty() 273 | """Longitude at the center of the widget 274 | """ 275 | 276 | lat = NumericProperty() 277 | """Latitude at the center of the widget 278 | """ 279 | 280 | zoom = NumericProperty(0) 281 | """Zoom of the widget. Must be between :meth:`MapSource.get_min_zoom` and 282 | :meth:`MapSource.get_max_zoom`. Default to 0. 283 | """ 284 | 285 | map_source = ObjectProperty(MapSource()) 286 | """Provider of the map, default to a empty :class:`MapSource`. 287 | """ 288 | 289 | double_tap_zoom = BooleanProperty(False) 290 | """If True, this will activate the double-tap to zoom. 291 | """ 292 | 293 | pause_on_action = BooleanProperty(True) 294 | """Pause any map loading / tiles loading when an action is done. 295 | This allow better performance on mobile, but can be safely deactivated on 296 | desktop. 297 | """ 298 | 299 | snap_to_zoom = BooleanProperty(True) 300 | """When the user initiate a zoom, it will snap to the closest zoom for 301 | better graphics. The map can be blur if the map is scaled between 2 zoom. 302 | Default to True, even if it doesn't fully working yet. 303 | """ 304 | 305 | animation_duration = NumericProperty(100) 306 | """Duration to animate Tiles alpha from 0 to 1 when it's ready to show. 307 | Default to 100 as 100ms. Use 0 to deactivate. 308 | """ 309 | 310 | delta_x = NumericProperty(0) 311 | delta_y = NumericProperty(0) 312 | background_color = ListProperty([181 / 255., 208 / 255., 208 / 255., 1]) 313 | cache_dir = StringProperty(CACHE_DIR) 314 | _zoom = NumericProperty(0) 315 | _pause = BooleanProperty(False) 316 | _scale = 1. 317 | _disabled_count = 0 318 | 319 | __events__ = ["on_map_relocated"] 320 | 321 | # Public API 322 | 323 | @property 324 | def viewport_pos(self): 325 | vx, vy = self._scatter.to_local(self.x, self.y) 326 | return vx - self.delta_x, vy - self.delta_y 327 | 328 | @property 329 | def scale(self): 330 | if self._invalid_scale: 331 | self._invalid_scale = False 332 | self._scale = self._scatter.scale 333 | return self._scale 334 | 335 | def get_bbox(self, margin=0): 336 | """Returns the bounding box from the bottom/left (lat1, lon1) to 337 | top/right (lat2, lon2). 338 | """ 339 | x1, y1 = self.to_local(0 - margin, 0 - margin) 340 | x2, y2 = self.to_local((self.width + margin), 341 | (self.height + margin)) 342 | c1 = self.get_latlon_at(x1, y1) 343 | c2 = self.get_latlon_at(x2, y2) 344 | return Bbox((c1.lat, c1.lon, c2.lat, c2.lon)) 345 | 346 | bbox = AliasProperty(get_bbox, None, bind=["lat", "lon", "_zoom"]) 347 | 348 | def unload(self): 349 | """Unload the view and all the layers. 350 | It also cancel all the remaining downloads. 351 | """ 352 | self.remove_all_tiles() 353 | 354 | def get_window_xy_from(self, lat, lon, zoom): 355 | """Returns the x/y position in the widget absolute coordinates 356 | from a lat/lon""" 357 | scale = self.scale 358 | vx, vy = self.viewport_pos 359 | ms = self.map_source 360 | x = ms.get_x(zoom, lon) - vx 361 | y = ms.get_y(zoom, lat) - vy 362 | x *= scale 363 | y *= scale 364 | x = x + self.pos[0] 365 | y = y + self.pos[1] 366 | return x, y 367 | 368 | def center_on(self, *args): 369 | """Center the map on the coordinate :class:`Coordinate`, or a (lat, lon) 370 | """ 371 | map_source = self.map_source 372 | zoom = self._zoom 373 | 374 | if len(args) == 1 and isinstance(args[0], Coordinate): 375 | coord = args[0] 376 | lat = coord.lat 377 | lon = coord.lon 378 | elif len(args) == 2: 379 | lat, lon = args 380 | else: 381 | raise Exception("Invalid argument for center_on") 382 | lon = clamp(lon, MIN_LONGITUDE, MAX_LONGITUDE) 383 | lat = clamp(lat, MIN_LATITUDE, MAX_LATITUDE) 384 | scale = self._scatter.scale 385 | x = map_source.get_x(zoom, lon) - self.center_x / scale 386 | y = map_source.get_y(zoom, lat) - self.center_y / scale 387 | self.delta_x = -x 388 | self.delta_y = -y 389 | self.lon = lon 390 | self.lat = lat 391 | self._scatter.pos = 0, 0 392 | self.trigger_update(True) 393 | 394 | def set_zoom_at(self, zoom, x, y, scale=None): 395 | """Sets the zoom level, leaving the (x, y) at the exact same point 396 | in the view. 397 | """ 398 | zoom = clamp(zoom, 399 | self.map_source.get_min_zoom(), 400 | self.map_source.get_max_zoom()) 401 | if int(zoom) == int(self._zoom): 402 | if scale is None: 403 | return 404 | elif scale == self.scale: 405 | return 406 | scale = scale or 1. 407 | 408 | # first, rescale the scatter 409 | scatter = self._scatter 410 | scale = clamp(scale, scatter.scale_min, scatter.scale_max) 411 | rescale = scale * 1.0 / scatter.scale 412 | scatter.apply_transform(Matrix().scale(rescale, rescale, rescale), 413 | post_multiply=True, 414 | anchor=scatter.to_local(x, y)) 415 | 416 | # adjust position if the zoom changed 417 | c1 = self.map_source.get_col_count(self._zoom) 418 | c2 = self.map_source.get_col_count(zoom) 419 | if c1 != c2: 420 | f = float(c2) / float(c1) 421 | self.delta_x = scatter.x + self.delta_x * f 422 | self.delta_y = scatter.y + self.delta_y * f 423 | # back to 0 every time 424 | scatter.apply_transform(Matrix().translate( 425 | -scatter.x, -scatter.y, 0 426 | ), post_multiply=True) 427 | 428 | # avoid triggering zoom changes. 429 | self._zoom = zoom 430 | self.zoom = self._zoom 431 | 432 | def on_zoom(self, instance, zoom): 433 | if zoom == self._zoom: 434 | return 435 | x = self.map_source.get_x(zoom, self.lon) - self.delta_x 436 | y = self.map_source.get_y(zoom, self.lat) - self.delta_y 437 | self.set_zoom_at(zoom, x, y) 438 | self.center_on(self.lat, self.lon) 439 | 440 | def get_latlon_at(self, x, y, zoom=None): 441 | """Return the current :class:`Coordinate` within the (x, y) widget 442 | coordinate. 443 | """ 444 | if zoom is None: 445 | zoom = self._zoom 446 | vx, vy = self.viewport_pos 447 | scale = self._scale 448 | return Coordinate( 449 | lat=self.map_source.get_lat(zoom, y / scale + vy), 450 | lon=self.map_source.get_lon(zoom, x / scale + vx)) 451 | 452 | def add_marker(self, marker, layer=None): 453 | """Add a marker into the layer. If layer is None, it will be added in 454 | the default marker layer. If there is no default marker layer, a new 455 | one will be automatically created 456 | """ 457 | if layer is None: 458 | if not self._default_marker_layer: 459 | layer = MarkerMapLayer() 460 | self.add_layer(layer) 461 | else: 462 | layer = self._default_marker_layer 463 | layer.add_widget(marker) 464 | layer.set_marker_position(self, marker) 465 | 466 | def remove_marker(self, marker): 467 | """Remove a marker from its layer 468 | """ 469 | marker.detach() 470 | 471 | def add_layer(self, layer, mode="window"): 472 | """Add a new layer to update at the same time the base tile layer. 473 | mode can be either "scatter" or "window". If "scatter", it means the 474 | layer will be within the scatter transformation. It's perfect if you 475 | want to display path / shape, but not for text. 476 | If "window", it will have no transformation. You need to position the 477 | widget yourself: think as Z-sprite / billboard. 478 | Defaults to "window". 479 | """ 480 | assert (mode in ("scatter", "window")) 481 | if self._default_marker_layer is None and \ 482 | isinstance(layer, MarkerMapLayer): 483 | self._default_marker_layer = layer 484 | self._layers.append(layer) 485 | c = self.canvas 486 | if mode == "scatter": 487 | self.canvas = self.canvas_layers 488 | else: 489 | self.canvas = self.canvas_layers_out 490 | layer.canvas_parent = self.canvas 491 | super(MapView, self).add_widget(layer) 492 | self.canvas = c 493 | 494 | def remove_layer(self, layer): 495 | """Remove the layer 496 | """ 497 | c = self.canvas 498 | self._layers.remove(layer) 499 | self.canvas = layer.canvas_parent 500 | super(MapView, self).remove_widget(layer) 501 | self.canvas = c 502 | 503 | def sync_to(self, other): 504 | """Reflect the lat/lon/zoom of the other MapView to the current one. 505 | """ 506 | if self._zoom != other._zoom: 507 | self.set_zoom_at(other._zoom, *self.center) 508 | self.center_on(other.get_latlon_at(*self.center)) 509 | 510 | # Private API 511 | 512 | def __init__(self, **kwargs): 513 | from kivy.base import EventLoop 514 | EventLoop.ensure_window() 515 | self._invalid_scale = True 516 | self._tiles = [] 517 | self._tiles_bg = [] 518 | self._tilemap = {} 519 | self._layers = [] 520 | self._default_marker_layer = None 521 | self._need_redraw_all = False 522 | self._transform_lock = False 523 | self.trigger_update(True) 524 | self.canvas = Canvas() 525 | self._scatter = MapViewScatter() 526 | self.add_widget(self._scatter) 527 | with self._scatter.canvas: 528 | self.canvas_map = Canvas() 529 | self.canvas_layers = Canvas() 530 | with self.canvas: 531 | self.canvas_layers_out = Canvas() 532 | self._scale_target_anim = False 533 | self._scale_target = 1. 534 | self._touch_count = 0 535 | self.map_source.cache_dir = self.cache_dir 536 | Clock.schedule_interval(self._animate_color, 1 / 60.) 537 | self.lat = kwargs.get("lat", self.lat) 538 | self.lon = kwargs.get("lon", self.lon) 539 | super(MapView, self).__init__(**kwargs) 540 | 541 | def _animate_color(self, dt): 542 | # fast path 543 | d = self.animation_duration 544 | if d == 0: 545 | for tile in self._tiles: 546 | if tile.state == "need-animation": 547 | tile.g_color.a = 1. 548 | tile.state = "animated" 549 | for tile in self._tiles_bg: 550 | if tile.state == "need-animation": 551 | tile.g_color.a = 1. 552 | tile.state = "animated" 553 | else: 554 | d = d / 1000. 555 | for tile in self._tiles: 556 | if tile.state != "need-animation": 557 | continue 558 | tile.g_color.a += dt / d 559 | if tile.g_color.a >= 1: 560 | tile.state = "animated" 561 | for tile in self._tiles_bg: 562 | if tile.state != "need-animation": 563 | continue 564 | tile.g_color.a += dt / d 565 | if tile.g_color.a >= 1: 566 | tile.state = "animated" 567 | 568 | def add_widget(self, widget): 569 | if isinstance(widget, MapMarker): 570 | self.add_marker(widget) 571 | elif isinstance(widget, MapLayer): 572 | self.add_layer(widget) 573 | else: 574 | super(MapView, self).add_widget(widget) 575 | 576 | def remove_widget(self, widget): 577 | if isinstance(widget, MapMarker): 578 | self.remove_marker(widget) 579 | elif isinstance(widget, MapLayer): 580 | self.remove_layer(widget) 581 | else: 582 | super(MapView, self).remove_widget(widget) 583 | 584 | def on_map_relocated(self, zoom, coord): 585 | pass 586 | 587 | def animated_diff_scale_at(self, d, x, y): 588 | self._scale_target_time = 1. 589 | self._scale_target_pos = x, y 590 | if self._scale_target_anim == False: 591 | self._scale_target_anim = True 592 | self._scale_target = d 593 | else: 594 | self._scale_target += d 595 | Clock.unschedule(self._animate_scale) 596 | Clock.schedule_interval(self._animate_scale, 1 / 60.) 597 | 598 | def _animate_scale(self, dt): 599 | diff = self._scale_target / 3. 600 | if abs(diff) < 0.01: 601 | diff = self._scale_target 602 | self._scale_target = 0 603 | else: 604 | self._scale_target -= diff 605 | self._scale_target_time -= dt 606 | self.diff_scale_at(diff, *self._scale_target_pos) 607 | ret = self._scale_target != 0 608 | if not ret: 609 | self._pause = False 610 | return ret 611 | 612 | def diff_scale_at(self, d, x, y): 613 | scatter = self._scatter 614 | scale = scatter.scale * (2 ** d) 615 | self.scale_at(scale, x, y) 616 | 617 | def scale_at(self, scale, x, y): 618 | scatter = self._scatter 619 | scale = clamp(scale, scatter.scale_min, scatter.scale_max) 620 | rescale = scale * 1.0 / scatter.scale 621 | scatter.apply_transform(Matrix().scale(rescale, rescale, rescale), 622 | post_multiply=True, 623 | anchor=scatter.to_local(x, y)) 624 | 625 | def on_touch_down(self, touch): 626 | if not self.collide_point(*touch.pos): 627 | return 628 | if self.pause_on_action: 629 | self._pause = True 630 | if "button" in touch.profile and touch.button in ( 631 | "scrolldown", "scrollup"): 632 | d = 1 if touch.button == "scrollup" else -1 633 | self.animated_diff_scale_at(d, *touch.pos) 634 | return True 635 | elif touch.is_double_tap and self.double_tap_zoom: 636 | self.animated_diff_scale_at(1, *touch.pos) 637 | return True 638 | touch.grab(self) 639 | self._touch_count += 1 640 | if self._touch_count == 1: 641 | self._touch_zoom = (self.zoom, self._scale) 642 | return super(MapView, self).on_touch_down(touch) 643 | 644 | def on_touch_up(self, touch): 645 | if touch.grab_current == self: 646 | touch.ungrab(self) 647 | self._touch_count -= 1 648 | if self._touch_count == 0: 649 | # animate to the closest zoom 650 | zoom, scale = self._touch_zoom 651 | cur_zoom = self.zoom 652 | cur_scale = self._scale 653 | if cur_zoom < zoom or cur_scale < scale: 654 | self.animated_diff_scale_at(1. - cur_scale, *touch.pos) 655 | elif cur_zoom > zoom or cur_scale > scale: 656 | self.animated_diff_scale_at(2. - cur_scale, *touch.pos) 657 | self._pause = False 658 | return True 659 | return super(MapView, self).on_touch_up(touch) 660 | 661 | def on_transform(self, *args): 662 | self._invalid_scale = True 663 | if self._transform_lock: 664 | return 665 | self._transform_lock = True 666 | # recalculate viewport 667 | map_source = self.map_source 668 | zoom = self._zoom 669 | scatter = self._scatter 670 | scale = scatter.scale 671 | if scale >= 2.: 672 | zoom += 1 673 | scale /= 2. 674 | elif scale < 1: 675 | zoom -= 1 676 | scale *= 2. 677 | zoom = clamp(zoom, map_source.min_zoom, map_source.max_zoom) 678 | if zoom != self._zoom: 679 | self.set_zoom_at(zoom, scatter.x, scatter.y, scale=scale) 680 | self.trigger_update(True) 681 | else: 682 | if zoom == map_source.min_zoom and scatter.scale < 1.: 683 | scatter.scale = 1. 684 | self.trigger_update(True) 685 | else: 686 | self.trigger_update(False) 687 | 688 | if map_source.bounds: 689 | self._apply_bounds() 690 | self._transform_lock = False 691 | self._scale = self._scatter.scale 692 | 693 | def _apply_bounds(self): 694 | # if the map_source have any constraints, apply them here. 695 | map_source = self.map_source 696 | zoom = self._zoom 697 | min_lon, min_lat, max_lon, max_lat = map_source.bounds 698 | xmin = map_source.get_x(zoom, min_lon) 699 | xmax = map_source.get_x(zoom, max_lon) 700 | ymin = map_source.get_y(zoom, min_lat) 701 | ymax = map_source.get_y(zoom, max_lat) 702 | 703 | dx = self.delta_x 704 | dy = self.delta_y 705 | oxmin, oymin = self._scatter.to_local(self.x, self.y) 706 | oxmax, oymax = self._scatter.to_local(self.right, self.top) 707 | s = self._scale 708 | cxmin = (oxmin - dx) 709 | if cxmin < xmin: 710 | self._scatter.x += (cxmin - xmin) * s 711 | cymin = (oymin - dy) 712 | if cymin < ymin: 713 | self._scatter.y += (cymin - ymin) * s 714 | cxmax = (oxmax - dx) 715 | if cxmax > xmax: 716 | self._scatter.x -= (xmax - cxmax) * s 717 | cymax = (oymax - dy) 718 | if cymax > ymax: 719 | self._scatter.y -= (ymax - cymax) * s 720 | 721 | def on__pause(self, instance, value): 722 | if not value: 723 | self.trigger_update(True) 724 | 725 | def trigger_update(self, full): 726 | self._need_redraw_full = full or self._need_redraw_full 727 | Clock.unschedule(self.do_update) 728 | Clock.schedule_once(self.do_update, -1) 729 | 730 | def do_update(self, dt): 731 | zoom = self._zoom 732 | scale = self._scale 733 | self.lon = self.map_source.get_lon(zoom, 734 | ( 735 | self.center_x - self._scatter.x) / scale - self.delta_x) 736 | self.lat = self.map_source.get_lat(zoom, 737 | ( 738 | self.center_y - self._scatter.y) / scale - self.delta_y) 739 | self.dispatch("on_map_relocated", zoom, Coordinate(self.lon, self.lat)) 740 | for layer in self._layers: 741 | layer.reposition() 742 | 743 | if self._need_redraw_full: 744 | self._need_redraw_full = False 745 | self.move_tiles_to_background() 746 | self.load_visible_tiles() 747 | else: 748 | self.load_visible_tiles() 749 | 750 | def bbox_for_zoom(self, vx, vy, w, h, zoom): 751 | # return a tile-bbox for the zoom 752 | map_source = self.map_source 753 | size = map_source.dp_tile_size 754 | scale = self._scale 755 | 756 | max_x_end = map_source.get_col_count(zoom) 757 | max_y_end = map_source.get_row_count(zoom) 758 | 759 | x_count = int(ceil(w / scale / float(size))) + 1 760 | y_count = int(ceil(h / scale / float(size))) + 1 761 | 762 | tile_x_first = int(clamp(vx / float(size), 0, max_x_end)) 763 | tile_y_first = int(clamp(vy / float(size), 0, max_y_end)) 764 | tile_x_last = tile_x_first + x_count 765 | tile_y_last = tile_y_first + y_count 766 | tile_x_last = int(clamp(tile_x_last, tile_x_first, max_x_end)) 767 | tile_y_last = int(clamp(tile_y_last, tile_y_first, max_y_end)) 768 | 769 | x_count = tile_x_last - tile_x_first 770 | y_count = tile_y_last - tile_y_first 771 | return (tile_x_first, tile_y_first, tile_x_last, tile_y_last, 772 | x_count, y_count) 773 | 774 | def load_visible_tiles(self): 775 | map_source = self.map_source 776 | vx, vy = self.viewport_pos 777 | zoom = self._zoom 778 | dirs = [0, 1, 0, -1, 0] 779 | bbox_for_zoom = self.bbox_for_zoom 780 | size = map_source.dp_tile_size 781 | 782 | tile_x_first, tile_y_first, tile_x_last, tile_y_last, \ 783 | x_count, y_count = bbox_for_zoom(vx, vy, self.width, self.height, zoom) 784 | 785 | # print "Range {},{} to {},{}".format( 786 | # tile_x_first, tile_y_first, 787 | # tile_x_last, tile_y_last) 788 | 789 | # Adjust tiles behind us 790 | for tile in self._tiles_bg[:]: 791 | tile_x = tile.tile_x 792 | tile_y = tile.tile_y 793 | 794 | f = 2 ** (zoom - tile.zoom) 795 | w = self.width / f 796 | h = self.height / f 797 | btile_x_first, btile_y_first, btile_x_last, btile_y_last, \ 798 | _, _ = bbox_for_zoom(vx / f, vy / f, w, h, tile.zoom) 799 | 800 | if tile_x < btile_x_first or tile_x >= btile_x_last or \ 801 | tile_y < btile_y_first or tile_y >= btile_y_last: 802 | tile.state = "done" 803 | self._tiles_bg.remove(tile) 804 | self.canvas_map.before.remove(tile.g_color) 805 | self.canvas_map.before.remove(tile) 806 | continue 807 | 808 | tsize = size * f 809 | tile.size = tsize, tsize 810 | tile.pos = ( 811 | tile_x * tsize + self.delta_x, 812 | tile_y * tsize + self.delta_y) 813 | 814 | # Get rid of old tiles first 815 | for tile in self._tiles[:]: 816 | tile_x = tile.tile_x 817 | tile_y = tile.tile_y 818 | 819 | if tile_x < tile_x_first or tile_x >= tile_x_last or \ 820 | tile_y < tile_y_first or tile_y >= tile_y_last: 821 | tile.state = "done" 822 | self.tile_map_set(tile_x, tile_y, False) 823 | self._tiles.remove(tile) 824 | self.canvas_map.remove(tile) 825 | self.canvas_map.remove(tile.g_color) 826 | else: 827 | tile.size = (size, size) 828 | tile.pos = ( 829 | tile_x * size + self.delta_x, tile_y * size + self.delta_y) 830 | 831 | # Load new tiles if needed 832 | x = tile_x_first + x_count // 2 - 1 833 | y = tile_y_first + y_count // 2 - 1 834 | arm_max = max(x_count, y_count) + 2 835 | arm_size = 1 836 | turn = 0 837 | while arm_size < arm_max: 838 | for i in range(arm_size): 839 | if not self.tile_in_tile_map(x, y) and \ 840 | y >= tile_y_first and y < tile_y_last and \ 841 | x >= tile_x_first and x < tile_x_last: 842 | self.load_tile(x, y, size, zoom) 843 | 844 | x += dirs[turn % 4 + 1] 845 | y += dirs[turn % 4] 846 | 847 | if turn % 2 == 1: 848 | arm_size += 1 849 | 850 | turn += 1 851 | 852 | def load_tile(self, x, y, size, zoom): 853 | if self.tile_in_tile_map(x, y) or zoom != self._zoom: 854 | return 855 | self.load_tile_for_source(self.map_source, 1., size, x, y, zoom) 856 | # XXX do overlay support 857 | self.tile_map_set(x, y, True) 858 | 859 | def load_tile_for_source(self, map_source, opacity, size, x, y, zoom): 860 | tile = Tile(size=(size, size), cache_dir=self.cache_dir) 861 | tile.g_color = Color(1, 1, 1, 0) 862 | tile.tile_x = x 863 | tile.tile_y = y 864 | tile.zoom = zoom 865 | tile.pos = (x * size + self.delta_x, y * size + self.delta_y) 866 | tile.map_source = map_source 867 | tile.state = "loading" 868 | if not self._pause: 869 | map_source.fill_tile(tile) 870 | self.canvas_map.add(tile.g_color) 871 | self.canvas_map.add(tile) 872 | self._tiles.append(tile) 873 | 874 | def move_tiles_to_background(self): 875 | # remove all the tiles of the main map to the background map 876 | # retain only the one who are on the current zoom level 877 | # for all the tile in the background, stop the download if not yet started. 878 | zoom = self._zoom 879 | tiles = self._tiles 880 | btiles = self._tiles_bg 881 | canvas_map = self.canvas_map 882 | tile_size = self.map_source.tile_size 883 | 884 | # move all tiles to background 885 | while tiles: 886 | tile = tiles.pop() 887 | if tile.state == "loading": 888 | tile.state = "done" 889 | continue 890 | btiles.append(tile) 891 | 892 | # clear the canvas 893 | canvas_map.clear() 894 | canvas_map.before.clear() 895 | self._tilemap = {} 896 | 897 | # unsure if it's really needed, i personnally didn't get issues right now 898 | # btiles.sort(key=lambda z: -z.zoom) 899 | 900 | # add all the btiles into the back canvas. 901 | # except for the tiles that are owned by the current zoom level 902 | for tile in btiles[:]: 903 | if tile.zoom == zoom: 904 | btiles.remove(tile) 905 | tiles.append(tile) 906 | tile.size = tile_size, tile_size 907 | canvas_map.add(tile.g_color) 908 | canvas_map.add(tile) 909 | self.tile_map_set(tile.tile_x, tile.tile_y, True) 910 | continue 911 | canvas_map.before.add(tile.g_color) 912 | canvas_map.before.add(tile) 913 | 914 | def remove_all_tiles(self): 915 | # clear the map of all tiles. 916 | self.canvas_map.clear() 917 | self.canvas_map.before.clear() 918 | for tile in self._tiles: 919 | tile.state = "done" 920 | del self._tiles[:] 921 | del self._tiles_bg[:] 922 | self._tilemap = {} 923 | 924 | def tile_map_set(self, tile_x, tile_y, value): 925 | key = tile_y * self.map_source.get_col_count(self._zoom) + tile_x 926 | if value: 927 | self._tilemap[key] = value 928 | else: 929 | self._tilemap.pop(key, None) 930 | 931 | def tile_in_tile_map(self, tile_x, tile_y): 932 | key = tile_y * self.map_source.get_col_count(self._zoom) + tile_x 933 | return key in self._tilemap 934 | 935 | def on_size(self, instance, size): 936 | for layer in self._layers: 937 | layer.size = size 938 | self.center_on(self.lat, self.lon) 939 | self.trigger_update(True) 940 | 941 | def on_pos(self, instance, pos): 942 | self.center_on(self.lat, self.lon) 943 | self.trigger_update(True) 944 | 945 | def on_map_source(self, instance, source): 946 | if isinstance(source, string_types): 947 | self.map_source = MapSource.from_provider(source) 948 | elif isinstance(source, (tuple, list)): 949 | cache_key, min_zoom, max_zoom, url, attribution, options = source 950 | self.map_source = MapSource(url=url, cache_key=cache_key, 951 | min_zoom=min_zoom, max_zoom=max_zoom, 952 | attribution=attribution, 953 | cache_dir=self.cache_dir, **options) 954 | elif isinstance(source, MapSource): 955 | self.map_source = source 956 | else: 957 | raise Exception("Invalid map source provider") 958 | self.zoom = clamp(self.zoom, 959 | self.map_source.min_zoom, self.map_source.max_zoom) 960 | self.remove_all_tiles() 961 | self.trigger_update(True) 962 | --------------------------------------------------------------------------------