├── apps ├── __init__.py ├── music │ ├── img │ │ ├── icon.png │ │ ├── album.png │ │ ├── sc_icon.png │ │ └── background.png │ ├── __main__.py │ ├── __init__.py │ └── music.kv ├── phone │ ├── img │ │ ├── icon.png │ │ └── sc_icon.png │ ├── __main__.py │ ├── __init__.py │ └── phone.kv ├── test │ ├── img │ │ └── icon.png │ ├── __main__.py │ └── __init__.py ├── navigation │ ├── img │ │ ├── icon.png │ │ └── sc_icon.png │ ├── navigation.kv │ ├── __main__.py │ └── __init__.py └── climate_control │ ├── img │ ├── icon.png │ └── sc_icon.png │ ├── __main__.py │ ├── __init__.py │ ├── climate_207.kv │ └── climate.kv ├── kivy_garden └── mapview │ ├── _version.py │ ├── icons │ ├── marker.png │ └── cluster.png │ ├── constants.py │ ├── __init__.py │ ├── types.py │ ├── utils.py │ ├── mbtsource.py │ ├── downloader.py │ ├── source.py │ ├── geojson.py │ ├── clustered_marker_layer.py │ └── view.py ├── android.txt ├── .github └── preview.gif ├── data ├── icons │ ├── apps.png │ ├── music.png │ ├── phone.png │ ├── climate.png │ ├── navigation.png │ ├── apps_pressed.png │ └── app_icons │ │ ├── gps_icon.png │ │ ├── clocks_icon.png │ │ ├── files_icon.png │ │ ├── paint_icon.png │ │ ├── photos_icon.png │ │ ├── test_icon.png │ │ ├── maintenance_icon.png │ │ ├── stopwatch_icon.png │ │ └── launch_color_icon.png └── wallpapers │ ├── black.png │ ├── darkgreyplain.png │ └── blackgreenmaterial.png ├── config.yml ├── Pipfile ├── apps.kv ├── main.kv ├── .gitignore ├── README.md ├── main.py ├── widgets.py └── LICENSE /apps/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /kivy_garden/mapview/_version.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.0.5" 2 | -------------------------------------------------------------------------------- /android.txt: -------------------------------------------------------------------------------- 1 | title=HeadUnit 2 | author=openleo 3 | orientation=landscape 4 | -------------------------------------------------------------------------------- /.github/preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prototux/headunit-ui/HEAD/.github/preview.gif -------------------------------------------------------------------------------- /data/icons/apps.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prototux/headunit-ui/HEAD/data/icons/apps.png -------------------------------------------------------------------------------- /data/icons/music.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prototux/headunit-ui/HEAD/data/icons/music.png -------------------------------------------------------------------------------- /data/icons/phone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prototux/headunit-ui/HEAD/data/icons/phone.png -------------------------------------------------------------------------------- /apps/music/img/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prototux/headunit-ui/HEAD/apps/music/img/icon.png -------------------------------------------------------------------------------- /apps/phone/img/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prototux/headunit-ui/HEAD/apps/phone/img/icon.png -------------------------------------------------------------------------------- /apps/test/img/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prototux/headunit-ui/HEAD/apps/test/img/icon.png -------------------------------------------------------------------------------- /data/icons/climate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prototux/headunit-ui/HEAD/data/icons/climate.png -------------------------------------------------------------------------------- /apps/music/img/album.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prototux/headunit-ui/HEAD/apps/music/img/album.png -------------------------------------------------------------------------------- /apps/music/img/sc_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prototux/headunit-ui/HEAD/apps/music/img/sc_icon.png -------------------------------------------------------------------------------- /apps/phone/img/sc_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prototux/headunit-ui/HEAD/apps/phone/img/sc_icon.png -------------------------------------------------------------------------------- /data/icons/navigation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prototux/headunit-ui/HEAD/data/icons/navigation.png -------------------------------------------------------------------------------- /data/wallpapers/black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prototux/headunit-ui/HEAD/data/wallpapers/black.png -------------------------------------------------------------------------------- /apps/navigation/img/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prototux/headunit-ui/HEAD/apps/navigation/img/icon.png -------------------------------------------------------------------------------- /data/icons/apps_pressed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prototux/headunit-ui/HEAD/data/icons/apps_pressed.png -------------------------------------------------------------------------------- /apps/music/img/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prototux/headunit-ui/HEAD/apps/music/img/background.png -------------------------------------------------------------------------------- /apps/navigation/img/sc_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prototux/headunit-ui/HEAD/apps/navigation/img/sc_icon.png -------------------------------------------------------------------------------- /apps/climate_control/img/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prototux/headunit-ui/HEAD/apps/climate_control/img/icon.png -------------------------------------------------------------------------------- /data/icons/app_icons/gps_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prototux/headunit-ui/HEAD/data/icons/app_icons/gps_icon.png -------------------------------------------------------------------------------- /data/wallpapers/darkgreyplain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prototux/headunit-ui/HEAD/data/wallpapers/darkgreyplain.png -------------------------------------------------------------------------------- /apps/climate_control/img/sc_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prototux/headunit-ui/HEAD/apps/climate_control/img/sc_icon.png -------------------------------------------------------------------------------- /data/icons/app_icons/clocks_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prototux/headunit-ui/HEAD/data/icons/app_icons/clocks_icon.png -------------------------------------------------------------------------------- /data/icons/app_icons/files_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prototux/headunit-ui/HEAD/data/icons/app_icons/files_icon.png -------------------------------------------------------------------------------- /data/icons/app_icons/paint_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prototux/headunit-ui/HEAD/data/icons/app_icons/paint_icon.png -------------------------------------------------------------------------------- /data/icons/app_icons/photos_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prototux/headunit-ui/HEAD/data/icons/app_icons/photos_icon.png -------------------------------------------------------------------------------- /data/icons/app_icons/test_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prototux/headunit-ui/HEAD/data/icons/app_icons/test_icon.png -------------------------------------------------------------------------------- /kivy_garden/mapview/icons/marker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prototux/headunit-ui/HEAD/kivy_garden/mapview/icons/marker.png -------------------------------------------------------------------------------- /data/wallpapers/blackgreenmaterial.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prototux/headunit-ui/HEAD/data/wallpapers/blackgreenmaterial.png -------------------------------------------------------------------------------- /kivy_garden/mapview/icons/cluster.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prototux/headunit-ui/HEAD/kivy_garden/mapview/icons/cluster.png -------------------------------------------------------------------------------- /data/icons/app_icons/maintenance_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prototux/headunit-ui/HEAD/data/icons/app_icons/maintenance_icon.png -------------------------------------------------------------------------------- /data/icons/app_icons/stopwatch_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prototux/headunit-ui/HEAD/data/icons/app_icons/stopwatch_icon.png -------------------------------------------------------------------------------- /data/icons/app_icons/launch_color_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prototux/headunit-ui/HEAD/data/icons/app_icons/launch_color_icon.png -------------------------------------------------------------------------------- /kivy_garden/mapview/constants.py: -------------------------------------------------------------------------------- 1 | MIN_LATITUDE = -90.0 2 | MAX_LATITUDE = 90.0 3 | MIN_LONGITUDE = -180.0 4 | MAX_LONGITUDE = 180.0 5 | CACHE_DIR = "cache" 6 | -------------------------------------------------------------------------------- /apps/navigation/navigation.kv: -------------------------------------------------------------------------------- 1 | #:import MapView kivy_garden.mapview.MapView 2 | FloatLayout: 3 | size_hint: 1, 1 4 | # Main layout 5 | MapView: 6 | id: map 7 | map_source: 'osm' 8 | zoom: 16 9 | -------------------------------------------------------------------------------- /config.yml: -------------------------------------------------------------------------------- 1 | apps: 2 | - music 3 | - climate_control 4 | - navigation 5 | - phone 6 | - test 7 | 8 | shortcuts: 9 | - music 10 | - climate_control 11 | - navigation 12 | - phone 13 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | 8 | [packages] 9 | kivy = "*" 10 | 11 | [requires] 12 | python_version = "3.8" 13 | -------------------------------------------------------------------------------- /apps.kv: -------------------------------------------------------------------------------- 1 | : 2 | GridLayout: 3 | id: apps_grid 4 | cols: 5 5 | rows: 4 6 | padding: 30 7 | spacing: 50, 30 8 | size_hint: 1, 1 9 | canvas.before: 10 | Color: 11 | rgb: 0,0,0 12 | Rectangle: 13 | pos: self.pos 14 | size: self.size 15 | -------------------------------------------------------------------------------- /apps/test/__main__.py: -------------------------------------------------------------------------------- 1 | import app 2 | from kivy.app import App 3 | from kivy.uix.screenmanager import ScreenManager 4 | 5 | global sub_app 6 | 7 | class SubApp(App): 8 | def build(self): 9 | sm = ScreenManager(id='sm') 10 | sm.add_widget(sub_app()) 11 | return sm 12 | 13 | if __name__ =='__main__': 14 | sub_app = getattr(app, '{}App'.format(app._app_name_)) 15 | SubApp().run() 16 | -------------------------------------------------------------------------------- /apps/music/__main__.py: -------------------------------------------------------------------------------- 1 | import __init__ as app 2 | from kivy.app import App 3 | from kivy.uix.screenmanager import ScreenManager 4 | 5 | global sub_app 6 | 7 | class SubApp(App): 8 | def build(self): 9 | sm = ScreenManager(id='sm') 10 | sm.add_widget(sub_app()) 11 | return sm 12 | 13 | if __name__ =='__main__': 14 | sub_app = getattr(app, '{}App'.format(app._app_name_)) 15 | SubApp().run() 16 | -------------------------------------------------------------------------------- /apps/phone/__main__.py: -------------------------------------------------------------------------------- 1 | import __init__ as app 2 | from kivy.app import App 3 | from kivy.uix.screenmanager import ScreenManager 4 | 5 | global sub_app 6 | 7 | class SubApp(App): 8 | def build(self): 9 | sm = ScreenManager(id='sm') 10 | sm.add_widget(sub_app()) 11 | return sm 12 | 13 | if __name__ =='__main__': 14 | sub_app = getattr(app, '{}App'.format(app._app_name_)) 15 | SubApp().run() 16 | -------------------------------------------------------------------------------- /apps/navigation/__main__.py: -------------------------------------------------------------------------------- 1 | import __init__ as app 2 | from kivy.app import App 3 | from kivy.uix.screenmanager import ScreenManager 4 | 5 | global sub_app 6 | 7 | class SubApp(App): 8 | def build(self): 9 | sm = ScreenManager(id='sm') 10 | sm.add_widget(sub_app()) 11 | return sm 12 | 13 | if __name__ =='__main__': 14 | sub_app = getattr(app, '{}App'.format(app._app_name_)) 15 | SubApp().run() 16 | -------------------------------------------------------------------------------- /apps/climate_control/__main__.py: -------------------------------------------------------------------------------- 1 | import __init__ as app 2 | from kivy.app import App 3 | from kivy.uix.screenmanager import ScreenManager 4 | 5 | global sub_app 6 | 7 | class SubApp(App): 8 | def build(self): 9 | sm = ScreenManager(id='sm') 10 | sm.add_widget(sub_app()) 11 | return sm 12 | 13 | if __name__ =='__main__': 14 | sub_app = getattr(app, '{}App'.format(app._app_name_)) 15 | SubApp().run() 16 | -------------------------------------------------------------------------------- /apps/phone/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from kivy.app import App 3 | from kivy.lang import Builder 4 | from kivy.uix.label import Label 5 | from kivy.uix.screenmanager import Screen 6 | 7 | _app_name_ = 'phone' 8 | 9 | class phoneApp(Screen): 10 | def __init__(self, **kwargs): 11 | super(phoneApp, self).__init__(**kwargs) 12 | self.name = _app_name_ 13 | self.root = App.get_running_app().root 14 | self.dir = dirname = os.path.dirname(__file__) 15 | 16 | layout = Builder.load_file("{}/phone.kv".format(self.dir)) 17 | self.add_widget(layout) 18 | -------------------------------------------------------------------------------- /kivy_garden/mapview/__init__.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | MapView 4 | ======= 5 | 6 | MapView is a Kivy widget that display maps. 7 | """ 8 | from kivy_garden.mapview.source import MapSource 9 | from kivy_garden.mapview.types import Bbox, Coordinate 10 | from kivy_garden.mapview.view import ( 11 | MapLayer, 12 | MapMarker, 13 | MapMarkerPopup, 14 | MapView, 15 | MarkerMapLayer, 16 | ) 17 | 18 | __all__ = [ 19 | "Coordinate", 20 | "Bbox", 21 | "MapView", 22 | "MapSource", 23 | "MapMarker", 24 | "MapLayer", 25 | "MarkerMapLayer", 26 | "MapMarkerPopup", 27 | ] 28 | -------------------------------------------------------------------------------- /apps/climate_control/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from kivy.app import App 3 | from kivy.lang import Builder 4 | from kivy.uix.label import Label 5 | from kivy.uix.screenmanager import Screen 6 | 7 | _app_name_ = 'climate_control' 8 | 9 | class climate_controlApp(Screen): 10 | def __init__(self, **kwargs): 11 | super(climate_controlApp, self).__init__(**kwargs) 12 | self.name = _app_name_ 13 | self.root = App.get_running_app().root 14 | self.dir = dirname = os.path.dirname(__file__) 15 | 16 | layout = Builder.load_file("{}/climate.kv".format(self.dir)) 17 | self.add_widget(layout) 18 | -------------------------------------------------------------------------------- /kivy_garden/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 | 10 | class Bbox(tuple): 11 | def collide(self, *args): 12 | if isinstance(args[0], Coordinate): 13 | coord = args[0] 14 | lat = coord.lat 15 | lon = coord.lon 16 | else: 17 | lat, lon = args 18 | lat1, lon1, lat2, lon2 = self[:] 19 | 20 | if lat1 < lat2: 21 | in_lat = lat1 <= lat <= lat2 22 | else: 23 | in_lat = lat2 <= lat <= lat2 24 | if lon1 < lon2: 25 | in_lon = lon1 <= lon <= lon2 26 | else: 27 | in_lon = lon2 <= lon <= lon2 28 | 29 | return in_lat and in_lon 30 | -------------------------------------------------------------------------------- /apps/music/__init__.py: -------------------------------------------------------------------------------- 1 | from kivy.app import App 2 | from kivy.lang import Builder 3 | from kivy.uix.label import Label 4 | from kivy.uix.button import Button 5 | from kivy.uix.screenmanager import Screen 6 | import os 7 | 8 | _app_name_ = 'music' 9 | 10 | class musicApp(Screen): 11 | def __init__(self, **kwargs): 12 | super(musicApp, self).__init__(**kwargs) 13 | self.name = _app_name_ 14 | self.root = App.get_running_app().root 15 | self.dir = dirname = os.path.dirname(__file__) 16 | layout = Builder.load_file("{}/music.kv".format(self.dir)) 17 | layout.ids['background'].source = '{}/img/background.png'.format(self.dir) 18 | 19 | layout.ids['album_art'].source = '{}/img/album.png'.format(self.dir) 20 | 21 | 22 | for i in range(1, 100): 23 | playlist = Button(text='Playlist {}'.format(i), size_hint_y=None) 24 | layout.ids['playlists_list'].add_widget(playlist) 25 | self.add_widget(layout) 26 | -------------------------------------------------------------------------------- /main.kv: -------------------------------------------------------------------------------- 1 | BoxLayout: 2 | orientation: 'vertical' 3 | 4 | ScreenManager: 5 | id: sm 6 | size_hint: 1, 1 7 | 8 | MainScreen: 9 | name: "main" 10 | 11 | AppsScreen: 12 | name: "apps" 13 | GridLayout: 14 | id: apps_grid 15 | cols: 5 16 | rows: 4 17 | padding: 30 18 | spacing: 50, 30 19 | size_hint: 1, 1 20 | canvas.before: 21 | Color: 22 | rgb: 0,0,0 23 | Rectangle: 24 | pos: self.pos 25 | size: self.size 26 | 27 | BoxLayout: 28 | id: bar_icons 29 | size_hint: 1, 0.2 30 | orientation: 'horizontal' 31 | canvas.before: 32 | Color: 33 | rgb: 0.12,0.12,0.12 34 | Rectangle: 35 | pos: self.pos 36 | size: self.size 37 | -------------------------------------------------------------------------------- /apps/phone/phone.kv: -------------------------------------------------------------------------------- 1 | #:import DampedScrollEffect kivy.effects.dampedscroll.DampedScrollEffect 2 | #:import Button kivy.uix.button.Button 3 | FloatLayout: 4 | size_hint: 1, 1 5 | # Main layout 6 | BoxLayout: 7 | orientation: 'horizontal' 8 | 9 | BoxLayout: 10 | orientation: 'vertical' 11 | 12 | Label: 13 | text: '0102030405' 14 | size_hint_y: 0.15 15 | GridLayout: 16 | cols: 3 17 | Button: 18 | text: '1' 19 | Button: 20 | text: '2' 21 | Button: 22 | text: '3' 23 | Button: 24 | text: '4' 25 | Button: 26 | text: '5' 27 | Button: 28 | text: '6' 29 | Button: 30 | text: '7' 31 | Button: 32 | text: '8' 33 | Button: 34 | text: '9' 35 | Button: 36 | text: '*' 37 | Button: 38 | text: '0' 39 | Button: 40 | text: '#' 41 | 42 | ScrollView: 43 | do_scroll_x: False 44 | do_scroll_y: True 45 | effect_cls: DampedScrollEffect 46 | 47 | GridLayout: 48 | cols: 1 49 | size_hint_y: None 50 | height: self.minimum_height 51 | 52 | on_parent: 53 | for i in range(100): self.add_widget(Button(text='contact {}'.format(str(i)), size_hint_y=None)) 54 | -------------------------------------------------------------------------------- /apps/test/__init__.py: -------------------------------------------------------------------------------- 1 | from kivy.app import App 2 | from kivy.lang import Builder 3 | from kivy.uix.label import Label 4 | 5 | 6 | from kivy.uix.screenmanager import Screen 7 | 8 | from kivy.core.image import Image as CoreImage 9 | from kivy.uix.widget import Widget 10 | from kivy.graphics.texture import Texture 11 | from kivy.graphics import Rectangle 12 | from kivy.clock import Clock 13 | from PIL import Image, ImageDraw, ImageFont, ImageGrab 14 | from io import BytesIO 15 | import threading 16 | import sched, time 17 | from functools import partial 18 | 19 | _app_name_ = 'test' 20 | 21 | class testApp(Screen): 22 | def update_screen(self, *args): 23 | frame = ImageGrab.grab().transpose(method=Image.FLIP_TOP_BOTTOM).tobytes() 24 | self.texture.blit_buffer(frame, colorfmt='rgb', bufferfmt='ubyte') 25 | 26 | def __init__(self, **kwargs): 27 | super(testApp, self).__init__(**kwargs) 28 | self.name = 'test' 29 | App.get_running_app().root 30 | 31 | # Try something 32 | self.texture = Texture.create(size=(1920*2, 1080*2)) 33 | self.screen = Widget() 34 | self.add_widget(self.screen) 35 | with self.screen.canvas: 36 | Rectangle(texture=self.texture, pos=self.screen.pos, size=(1000, 450)) 37 | 38 | def on_enter(self): 39 | Clock.schedule_interval(self.update_screen, 0.0001) 40 | -------------------------------------------------------------------------------- /apps/navigation/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sched, time 3 | import threading 4 | from kivy.app import App 5 | from kivy.lang import Builder 6 | from kivy.uix.label import Label 7 | from kivy.uix.screenmanager import Screen 8 | from kivy_garden.mapview import MapMarker, MapLayer 9 | 10 | _app_name_ = 'navigation' 11 | 12 | class navigationApp(Screen): 13 | def __init__(self, **kwargs): 14 | super(navigationApp, self).__init__(**kwargs) 15 | self.name = _app_name_ 16 | self.root = App.get_running_app().root 17 | self.dir = dirname = os.path.dirname(__file__) 18 | 19 | self.sched = sched.scheduler(time.time, time.sleep) 20 | self.sched.enter(1, 10, self.update_coord) 21 | 22 | layout = Builder.load_file("{}/navigation.kv".format(self.dir)) 23 | self.map = layout.ids.map 24 | self.add_widget(layout) 25 | 26 | def update_coord(self): 27 | #print("UPDATE!") 28 | self.car.lat += 0.001 29 | self.map.center_on(self.map.lat+0.001, self.map.lon) 30 | self.sched.enter(1, 10, self.update_coord) 31 | 32 | 33 | def on_enter(self): 34 | # Setup map 35 | self.map.center_on(48.856614,2.3522219) 36 | self.car = MapMarker(lat=48.856614, lon=2.3522219) 37 | self.map.add_marker(self.car) 38 | 39 | # Run auto-update 40 | self.thread = threading.Thread(target=self.sched.run) 41 | self.thread.start() 42 | 43 | def on_stop(self): 44 | if self.hasattr('thread'): 45 | self.thread.stop() 46 | -------------------------------------------------------------------------------- /apps/climate_control/climate_207.kv: -------------------------------------------------------------------------------- 1 | FloatLayout: 2 | size_hint: 1, 1 3 | # Main layout 4 | BoxLayout: 5 | orientation: 'horizontal' 6 | 7 | # Left seat control 8 | BoxLayout: 9 | orientation: 'vertical' 10 | size_hint: 0.4, 1 11 | Button: 12 | text: 'Temp +1' 13 | Label: 14 | text: '25c' 15 | Button: 16 | text: 'Temp -1' 17 | 18 | # Common controls 19 | BoxLayout: 20 | orientation: 'vertical' 21 | 22 | BoxLayout: 23 | orientation: 'horizontal' 24 | ToggleButton: 25 | text: 'ON' 26 | ToggleButton: 27 | text: 'D' 28 | ToggleButton: 29 | text: 'M' 30 | ToggleButton: 31 | text: 'U' 32 | ToggleButton: 33 | text: 'D+M' 34 | ToggleButton: 35 | text: 'D+U' 36 | 37 | BoxLayout: 38 | orientation: 'horizontal' 39 | ToggleButton: 40 | text: 'Front defrost' 41 | ToggleButton: 42 | text: 'Rear defrost' 43 | ToggleButton: 44 | text: 'AC' 45 | ToggleButton: 46 | text: 'Recycling' 47 | 48 | BoxLayout: 49 | orientation: 'horizontal' 50 | ToggleButton: 51 | text: 'Auto' 52 | ToggleButton: 53 | text: 'F1' 54 | ToggleButton: 55 | text: 'F2' 56 | ToggleButton: 57 | text: 'F3' 58 | ToggleButton: 59 | text: 'F4' 60 | ToggleButton: 61 | text: 'F5' 62 | ToggleButton: 63 | text: 'F6' 64 | ToggleButton: 65 | text: 'F7' 66 | ToggleButton: 67 | text: 'F8' 68 | 69 | # Right seat control 70 | BoxLayout: 71 | orientation: 'vertical' 72 | size_hint: 0.4, 1 73 | Button: 74 | text: 'Temp +1' 75 | Label: 76 | text: '25c' 77 | Button: 78 | text: 'Temp -1' 79 | -------------------------------------------------------------------------------- /kivy_garden/mapview/utils.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | 3 | __all__ = ["clamp", "haversine", "get_zoom_for_radius"] 4 | 5 | from math import asin, cos, pi, radians, sin, sqrt 6 | 7 | from kivy.core.window import Window 8 | from kivy.metrics import dp 9 | 10 | 11 | def clamp(x, minimum, maximum): 12 | return max(minimum, min(x, maximum)) 13 | 14 | 15 | def haversine(lon1, lat1, lon2, lat2): 16 | """ 17 | Calculate the great circle distance between two points 18 | on the earth (specified in decimal degrees) 19 | 20 | Taken from: http://stackoverflow.com/questions/4913349/haversine-formula-in-python-bearing-and-distance-between-two-gps-points 21 | """ 22 | # convert decimal degrees to radians 23 | lon1, lat1, lon2, lat2 = map(radians, [lon1, lat1, lon2, lat2]) 24 | # haversine formula 25 | dlon = lon2 - lon1 26 | dlat = lat2 - lat1 27 | a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2 28 | 29 | c = 2 * asin(sqrt(a)) 30 | km = 6367 * c 31 | return km 32 | 33 | 34 | def get_zoom_for_radius(radius_km, lat=None, tile_size=256.0): 35 | """See: https://wiki.openstreetmap.org/wiki/Zoom_levels""" 36 | radius = radius_km * 1000.0 37 | if lat is None: 38 | lat = 0.0 # Do not compensate for the latitude 39 | 40 | # Calculate the equatorial circumference based on the WGS-84 radius 41 | earth_circumference = 2.0 * pi * 6378137.0 * cos(lat * pi / 180.0) 42 | 43 | # Check how many tiles that are currently in view 44 | nr_tiles_shown = min(Window.size) / dp(tile_size) 45 | 46 | # Keep zooming in until we find a zoom level where the circle can fit inside the screen 47 | zoom = 1 48 | while earth_circumference / (2 << (zoom - 1)) * nr_tiles_shown > 2 * radius: 49 | zoom += 1 50 | return zoom - 1 # Go one zoom level back 51 | -------------------------------------------------------------------------------- /apps/music/music.kv: -------------------------------------------------------------------------------- 1 | FloatLayout: 2 | size_hint: 1, 1 3 | Image: 4 | id: background 5 | source: 'img/background.png' 6 | center_x: self.center_x 7 | center_y: self.center_y 8 | allow_stretch: True 9 | keep_ratio: False 10 | 11 | BoxLayout: 12 | orientation: 'vertical' 13 | 14 | ScreenManager: 15 | id: music_sm 16 | Screen: 17 | name: 'music_infos' 18 | BoxLayout: 19 | orientation: 'horizontal' 20 | 21 | Image: 22 | id: album_art 23 | source: 'img/album.png' 24 | 25 | BoxLayout: 26 | orientation: 'vertical' 27 | 28 | Label: 29 | text: 'Track name' 30 | font_size: 40 31 | halign: 'left' 32 | 33 | Label: 34 | text: 'Track artist' 35 | font_size: 30 36 | 37 | Label: 38 | text: 'Playlist name' 39 | font_size: 20 40 | 41 | Screen: 42 | name: 'playlists' 43 | ScrollView: 44 | do_scroll_x: False 45 | do_scroll_y: True 46 | 47 | GridLayout: 48 | id: playlists_list 49 | cols: 1 50 | size_hint_y: None 51 | spacing: 10 52 | padding: 10 53 | height: self.minimum_height 54 | 55 | 56 | AnchorLayout: 57 | anchor_x: 'center' 58 | size_hint: 1, .4 59 | BoxLayout: 60 | orientation: 'horizontal' 61 | size_hint: .8, .6 62 | 63 | Button: 64 | text: 'music' 65 | on_press: music_sm.current = 'playlists' if music_sm.current == 'music_infos' else 'music_infos' 66 | on_press: music_sm.transition.direction = 'right' if music_sm.current == 'music_infos' else 'left' 67 | 68 | Button: 69 | text: 'prev' 70 | Button: 71 | text: 'p/p' 72 | Button: 73 | text: 'next' 74 | 75 | BoxLayout: 76 | orientation: 'vertical' 77 | Button: 78 | text: 'vol up' 79 | Button: 80 | text: 'vol down' 81 | 82 | 83 | -------------------------------------------------------------------------------- /apps/climate_control/climate.kv: -------------------------------------------------------------------------------- 1 | FloatLayout: 2 | size_hint: 1, 1 3 | # Main layout 4 | BoxLayout: 5 | orientation: 'vertical' 6 | 7 | BoxLayout 8 | orientation: 'horizontal' 9 | size_hint: 1, 0.2 10 | #ToggleButton: 11 | # text: 'AutoL' 12 | ToggleButton: 13 | text: 'Front defrost' 14 | ToggleButton: 15 | text: 'Rear defrost' 16 | ToggleButton: 17 | text: 'Auto' 18 | ToggleButton: 19 | text: 'AC' 20 | ToggleButton: 21 | text: 'Recycling' 22 | #ToggleButton: 23 | # text: 'AutoR' 24 | 25 | 26 | BoxLayout: 27 | orientation: 'horizontal' 28 | 29 | # Left seat control 30 | BoxLayout: 31 | orientation: 'vertical' 32 | size_hint: 0.4, 1 33 | Button: 34 | text: 'Temp +1' 35 | Label: 36 | text: '25c' 37 | Button: 38 | text: 'Temp -1' 39 | 40 | BoxLayout: 41 | orientation: 'vertical' 42 | size_hint: 0.4, 1 43 | ToggleButton: 44 | text: 'U' 45 | ToggleButton: 46 | text: 'M' 47 | ToggleButton: 48 | text: 'D' 49 | 50 | # Common controls 51 | BoxLayout: 52 | orientation: 'vertical' 53 | 54 | #BoxLayout: 55 | # orientation: 'horizontal' 56 | # ToggleButton: 57 | # text: 'D' 58 | # ToggleButton: 59 | # text: 'M' 60 | # ToggleButton: 61 | # text: 'U' 62 | 63 | AnchorLayout: 64 | size_hint: 1, 1 65 | BoxLayout: 66 | orientation: 'horizontal' 67 | size_hint: 0.9, 0.2 68 | ToggleButton: 69 | text: 'F1' 70 | ToggleButton: 71 | text: 'F2' 72 | ToggleButton: 73 | text: 'F3' 74 | ToggleButton: 75 | text: 'F4' 76 | ToggleButton: 77 | text: 'F5' 78 | ToggleButton: 79 | text: 'F6' 80 | ToggleButton: 81 | text: 'F7' 82 | ToggleButton: 83 | text: 'F8' 84 | 85 | # Right seat control 86 | BoxLayout: 87 | orientation: 'vertical' 88 | size_hint: 0.4,1 89 | ToggleButton: 90 | text: 'U' 91 | ToggleButton: 92 | text: 'M' 93 | ToggleButton: 94 | text: 'D' 95 | BoxLayout: 96 | orientation: 'vertical' 97 | size_hint: 0.4, 1 98 | Button: 99 | text: 'Temp +1' 100 | Label: 101 | text: '25c' 102 | Button: 103 | text: 'Temp -1' 104 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Map cache 2 | cache/ 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | pip-wheel-metadata/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HeadUnit UI (Come discuss about it on our [Discord](https://discord.gg/RNkHucE7jN)!) 2 | 3 | ## Intro 4 | 5 | This is the UI part of a headunit project from OpenLeo, based on python and kivy, it looks like this (low framerate only due to recording in gif): 6 | 7 | ![headunit preview image](.github/preview.gif?raw=true) 8 | 9 | ## Design (and integration in the whole head unit project) 10 | 11 | This should be launched last, and ideally (not there yet), it should only interface to the other components of the headunit system, not directly to CAN/VAN and devices. A watchdog system (with the UI sending a heartbeat to the OS) would be ideal, as this could then be used to reload the UI should a crash happen. 12 | 13 | The UI is based on a core that is basically the home screen + the app bar + launching apps and giving them context (and ideally, custom widgets to have a coherent style across all apps). All the actual features are in "apps", which are actually python modules. 14 | 15 | 16 | ## Apps design rules 17 | 18 | * You can test your apps without launching the whole UI by running the app as a module (eg. with it's `__main__.py`) 19 | * You should avoid multiple menus and submenus, keep it simple, it is used while driving! 20 | * You should only interface with the car and devices thru what the headunit and UI provides. some apps may require direct connection (for example a diagnostics app that would need direct connection to CAN), but if you happens to connect directly to something, it is more likely that the actual need would be to add a component to the head unit OS instead. 21 | * Dependencies should be self contained, if possible. 22 | * Two icons are needed: `icon.png` for the menu and `sc_icon.png` for the shortcut in app bar 23 | 24 | ## FAQ 25 | 26 | * **Why using python and kivy for an embedded project?** Because it was the best compromise between being easy to work with, without licencing nightmare, and still being able to work directly on a framebuffer (kivy being SDL2 based, you can run it without X11 or Wayland). 27 | * **Why only the UI?** Because it makes the whole head unit more reliable, if the UI crashes, it wouldn't lose CAN frames or the music being played, and the embedded linux can then detect and reload the UI only, which should make it safer as it wouldn't distract as much while driving. 28 | * **Is this PSA/Leo only?** Actually, the whole head unit design is made so it should be possible to port it for any other car architecture, even if these aren't the focus of OpenLeo. 29 | * 30 | 31 | ## TODO 32 | 33 | * Give the apps context so they can interface with the car (can bus for telematics, climate control, and the like) 34 | * Also give context for the other components of the head unit (music playback, navigation, bluetooth...) 35 | * Have a basic (eg. turn by turn symbols) navigation 36 | * Have working apps... 37 | * Add custom widgets that follow a common OpenLeo design guiderules set 38 | * Inject constant infos to apps (car make, model, version, etc) 39 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import kivy 2 | from kivy.app import App 3 | from kivy.lang import Builder 4 | from kivy.properties import NumericProperty, StringProperty, BooleanProperty, ListProperty, ObjectProperty 5 | from kivy.clock import Clock 6 | 7 | from kivy.uix.label import Label 8 | from kivy.uix.image import Image 9 | from kivy.uix.screenmanager import ScreenManager, Screen, NoTransition, SlideTransition 10 | import importlib 11 | from functools import partial 12 | import yaml 13 | import sys 14 | 15 | # Custom widgets 16 | from widgets import AppsScreen, ImageButton, BarIcon, AppsTrayIcon 17 | 18 | class Apps: 19 | def __init__(self): 20 | self.apps = {} 21 | 22 | def add_app(self, name, dt=None): 23 | if name in self.apps: 24 | print('An app already exists with the name {}, aborting'.format(name)) 25 | return 26 | 27 | try: 28 | spec = importlib.util.spec_from_file_location(name, './apps/{}/__init__.py'.format(name)) 29 | mod = importlib.util.module_from_spec(spec) 30 | spec.loader.exec_module(mod) 31 | instance = getattr(mod, '{}App'.format(name))() 32 | except: 33 | mod = importlib.import_module('apps.{}'.format(name), package=__name__) 34 | instance = getattr(mod, '{}App'.format(name))() 35 | 36 | App.get_running_app().root.ids['sm'].add_widget(instance) 37 | App.get_running_app().root.ids['apps_grid'].add_widget(ImageButton(app=name)) 38 | self.apps[name] = instance 39 | 40 | def add_shortcut(self, name, dt=None): 41 | App.get_running_app().root.ids['bar_icons'].add_widget(BarIcon(name=name)) 42 | 43 | def stop(self): 44 | for name, instance in self.apps.items(): 45 | if 'on_stop' in dir(instance): 46 | print(f'Stopping {name}') 47 | try: 48 | instance.on_stop() 49 | except: 50 | e = sys.exc_info()[0] 51 | print(f'Error while stopping {name}: {e}') 52 | else: 53 | print(f'Ignoring stopping {name}') 54 | 55 | 56 | class MainScreen(Screen): 57 | def __init__(self, **kwargs): 58 | super(MainScreen, self).__init__(**kwargs) 59 | 60 | # Add Wallpaper 61 | self.wallpaper = Image(allow_stretch=True, keep_ratio=False) 62 | self.wallpaper.source = 'data/wallpapers/blackgreenmaterial.png' 63 | self.add_widget(self.wallpaper) 64 | 65 | class MainApp(App): 66 | def add_apps_icon(self, dt=None): 67 | self.root.ids['bar_icons'].add_widget(AppsTrayIcon()) 68 | 69 | def build(self): 70 | self.layout = Builder.load_file("main.kv") 71 | 72 | # Load apps 73 | for app in config['apps']: 74 | Clock.schedule_once(partial(apps.add_app, app)) 75 | 76 | # Add shortcuts icons and then the apps tray icon 77 | for app in config['shortcuts']: 78 | Clock.schedule_once(partial(apps.add_shortcut, app)) 79 | Clock.schedule_once(self.add_apps_icon) 80 | 81 | # Finalize build 82 | return self.layout 83 | 84 | def on_stop(self): 85 | global apps 86 | apps.stop() 87 | 88 | if __name__ =='__main__': 89 | global apps 90 | apps = Apps() 91 | 92 | global config 93 | with open('config.yml', 'r') as cs: 94 | config = yaml.safe_load(cs) 95 | 96 | main = MainApp() 97 | main.run() 98 | -------------------------------------------------------------------------------- /widgets.py: -------------------------------------------------------------------------------- 1 | from kivy.uix.behaviors import ButtonBehavior 2 | from kivy.uix.label import Label 3 | from kivy.uix.image import Image 4 | from kivy.uix.relativelayout import RelativeLayout 5 | from kivy.app import App 6 | from kivy.uix.screenmanager import Screen 7 | 8 | class AppsScreen(Screen): 9 | pass 10 | 11 | class ImageButton(ButtonBehavior, RelativeLayout): 12 | def __init__(self, app=None, **kwargs): 13 | super(ImageButton, self).__init__(**kwargs) 14 | 15 | self.app = app 16 | self.root = App.get_running_app().root 17 | 18 | # App icon 19 | icon = Image(source='apps/{}/img/icon.png'.format(app)) 20 | icon.size_hint = (0.5, 0.5) 21 | icon.pos_hint = {'center_x': 0.5, 'center_y': 0.7} 22 | self.add_widget(icon) 23 | 24 | # App name 25 | name = Label(text=app) 26 | name.font_size = 20 27 | name.pos_hint = {'center_x': 0.5, 'center_y': 0.42} 28 | self.add_widget(name) 29 | self.background_color = (0.9, 0.9, 0.9, 0.0) 30 | 31 | def on_press(self): 32 | self.root.ids['sm'].current = self.app 33 | print ('pressed: {}'.format(self.app)) 34 | 35 | class BarIcon(ButtonBehavior, Image): 36 | icon_size = (0.5, 0.5) 37 | icon_pos = {'center_x': 0.5, 'center_y': 0.5} 38 | 39 | def __init__(self, name=None, **kwargs): 40 | super(BarIcon, self).__init__(**kwargs) 41 | self.name = name 42 | self.source = 'apps/{}/img/sc_icon.png'.format(self.name) 43 | self.size_hint = self.icon_size 44 | self.pos_hint = self.icon_pos 45 | 46 | def on_press(self): 47 | if self.name == 'apps': 48 | if self.parent.parent.ids['sm'].current == 'apps': 49 | self.parent.parent.ids['sm'].transition.direction = 'down' 50 | self.parent.parent.ids['sm'].current = 'main' 51 | else: 52 | self.parent.parent.ids['sm'].transition.direction = 'up' 53 | self.parent.parent.ids['sm'].current = 'apps' 54 | self.source = 'data/icons/apps_pressed.png'.format(self.name) 55 | else: 56 | #self.size_hint = (0.6, 0.6) 57 | if self.parent.parent.ids['sm'].current == 'main': 58 | self.parent.parent.ids['sm'].transition.direction = 'up' 59 | else: 60 | self.parent.parent.ids['sm'].transition.direction = 'right' 61 | self.parent.parent.ids['sm'].current = self.name 62 | 63 | def on_release(self): 64 | if self.name == 'apps': 65 | self.source = 'apps/{}/img/sc_icon.png'.format(self.name) 66 | #else: 67 | # self.size_hint = (0.5, 0.5) 68 | 69 | class AppsTrayIcon(BarIcon): 70 | def __init__(self, **kwargs): 71 | super(AppsTrayIcon, self).__init__(**kwargs) 72 | self.source = 'data/icons/apps.png' 73 | self.size_hint = self.icon_size 74 | self.pos_hint = self.icon_pos 75 | 76 | def on_press(self): 77 | if self.parent.parent.ids['sm'].current == 'apps': 78 | self.parent.parent.ids['sm'].transition.direction = 'down' 79 | self.parent.parent.ids['sm'].current = 'main' 80 | else: 81 | self.parent.parent.ids['sm'].transition.direction = 'up' 82 | self.parent.parent.ids['sm'].current = 'apps' 83 | self.source = 'data/icons/apps_pressed.png'.format(self.name) 84 | 85 | def on_release(self): 86 | self.source = 'data/icons/apps.png' 87 | -------------------------------------------------------------------------------- /kivy_garden/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 | import io 14 | import sqlite3 15 | import threading 16 | 17 | from kivy.core.image import Image as CoreImage 18 | from kivy.core.image import ImageLoader 19 | 20 | from kivy_garden.mapview.downloader import Downloader 21 | from kivy_garden.mapview.source import MapSource 22 | 23 | 24 | class MBTilesMapSource(MapSource): 25 | def __init__(self, filename, **kwargs): 26 | super().__init__(**kwargs) 27 | self.filename = filename 28 | self.db = sqlite3.connect(filename) 29 | 30 | # read metadata 31 | c = self.db.cursor() 32 | metadata = dict(c.execute("SELECT * FROM metadata")) 33 | if metadata["format"] == "pbf": 34 | raise ValueError("Only raster maps are supported, not vector maps.") 35 | self.min_zoom = int(metadata["minzoom"]) 36 | self.max_zoom = int(metadata["maxzoom"]) 37 | self.attribution = metadata.get("attribution", "") 38 | self.bounds = bounds = None 39 | cx = cy = 0.0 40 | cz = 5 41 | if "bounds" in metadata: 42 | self.bounds = bounds = map(float, metadata["bounds"].split(",")) 43 | if "center" in metadata: 44 | cx, cy, cz = map(float, metadata["center"].split(",")) 45 | elif self.bounds: 46 | cx = (bounds[2] + bounds[0]) / 2.0 47 | cy = (bounds[3] + bounds[1]) / 2.0 48 | cz = self.min_zoom 49 | self.default_lon = cx 50 | self.default_lat = cy 51 | self.default_zoom = int(cz) 52 | self.projection = metadata.get("projection", "") 53 | self.is_xy = self.projection == "xy" 54 | 55 | def fill_tile(self, tile): 56 | if tile.state == "done": 57 | return 58 | Downloader.instance(self.cache_dir).submit(self._load_tile, tile) 59 | 60 | def _load_tile(self, tile): 61 | # global db context cannot be shared across threads. 62 | ctx = threading.local() 63 | if not hasattr(ctx, "db"): 64 | ctx.db = sqlite3.connect(self.filename) 65 | 66 | # get the right tile 67 | c = ctx.db.cursor() 68 | c.execute( 69 | ( 70 | "SELECT tile_data FROM tiles WHERE " 71 | "zoom_level=? AND tile_column=? AND tile_row=?" 72 | ), 73 | (tile.zoom, tile.tile_x, tile.tile_y), 74 | ) 75 | row = c.fetchone() 76 | if not row: 77 | tile.state = "done" 78 | return 79 | 80 | # no-file loading 81 | try: 82 | data = io.BytesIO(row[0]) 83 | except Exception: 84 | # android issue, "buffer" does not have the buffer interface 85 | # ie row[0] buffer is not compatible with BytesIO on Android?? 86 | data = io.BytesIO(bytes(row[0])) 87 | im = CoreImage( 88 | data, 89 | ext='png', 90 | filename="{}.{}.{}.png".format(tile.zoom, tile.tile_x, tile.tile_y), 91 | ) 92 | 93 | if im is None: 94 | tile.state = "done" 95 | return 96 | 97 | return self._load_tile_done, (tile, im,) 98 | 99 | def _load_tile_done(self, tile, im): 100 | tile.texture = im.texture 101 | tile.state = "need-animation" 102 | 103 | def get_x(self, zoom, lon): 104 | if self.is_xy: 105 | return lon 106 | return super().get_x(zoom, lon) 107 | 108 | def get_y(self, zoom, lat): 109 | if self.is_xy: 110 | return lat 111 | return super().get_y(zoom, lat) 112 | 113 | def get_lon(self, zoom, x): 114 | if self.is_xy: 115 | return x 116 | return super().get_lon(zoom, x) 117 | 118 | def get_lat(self, zoom, y): 119 | if self.is_xy: 120 | return y 121 | return super().get_lat(zoom, y) 122 | -------------------------------------------------------------------------------- /kivy_garden/mapview/downloader.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | 3 | __all__ = ["Downloader"] 4 | 5 | import logging 6 | import traceback 7 | from concurrent.futures import ThreadPoolExecutor, TimeoutError, as_completed 8 | from os import environ, makedirs 9 | from os.path import exists, join 10 | from random import choice 11 | from time import time 12 | 13 | import requests 14 | from kivy.clock import Clock 15 | from kivy.logger import LOG_LEVELS, Logger 16 | 17 | from kivy_garden.mapview.constants import CACHE_DIR 18 | 19 | if "MAPVIEW_DEBUG_DOWNLOADER" in environ: 20 | Logger.setLevel(LOG_LEVELS['debug']) 21 | 22 | # user agent is needed because since may 2019 OSM gives me a 429 or 403 server error 23 | # I tried it with a simpler one (just Mozilla/5.0) this also gets rejected 24 | USER_AGENT = 'Kivy-garden.mapview' 25 | 26 | 27 | class Downloader: 28 | _instance = None 29 | MAX_WORKERS = 5 30 | CAP_TIME = 0.064 # 15 FPS 31 | 32 | @staticmethod 33 | def instance(cache_dir=None): 34 | if Downloader._instance is None: 35 | if not cache_dir: 36 | cache_dir = CACHE_DIR 37 | Downloader._instance = Downloader(cache_dir=cache_dir) 38 | return Downloader._instance 39 | 40 | def __init__(self, max_workers=None, cap_time=None, **kwargs): 41 | self.cache_dir = kwargs.get('cache_dir', CACHE_DIR) 42 | if max_workers is None: 43 | max_workers = Downloader.MAX_WORKERS 44 | if cap_time is None: 45 | cap_time = Downloader.CAP_TIME 46 | self.is_paused = False 47 | self.cap_time = cap_time 48 | self.executor = ThreadPoolExecutor(max_workers=max_workers) 49 | self._futures = [] 50 | Clock.schedule_interval(self._check_executor, 1 / 60.0) 51 | if not exists(self.cache_dir): 52 | makedirs(self.cache_dir) 53 | 54 | def submit(self, f, *args, **kwargs): 55 | future = self.executor.submit(f, *args, **kwargs) 56 | self._futures.append(future) 57 | 58 | def download_tile(self, tile): 59 | Logger.debug( 60 | "Downloader: queue(tile) zoom={} x={} y={}".format( 61 | tile.zoom, tile.tile_x, tile.tile_y 62 | ) 63 | ) 64 | future = self.executor.submit(self._load_tile, tile) 65 | self._futures.append(future) 66 | 67 | def download(self, url, callback, **kwargs): 68 | Logger.debug("Downloader: queue(url) {}".format(url)) 69 | future = self.executor.submit(self._download_url, url, callback, kwargs) 70 | self._futures.append(future) 71 | 72 | def _download_url(self, url, callback, kwargs): 73 | Logger.debug("Downloader: download(url) {}".format(url)) 74 | response = requests.get(url, **kwargs) 75 | response.raise_for_status() 76 | return callback, (url, response) 77 | 78 | def _load_tile(self, tile): 79 | if tile.state == "done": 80 | return 81 | cache_fn = tile.cache_fn 82 | if exists(cache_fn): 83 | Logger.debug("Downloader: use cache {}".format(cache_fn)) 84 | return tile.set_source, (cache_fn,) 85 | tile_y = tile.map_source.get_row_count(tile.zoom) - tile.tile_y - 1 86 | uri = tile.map_source.url.format( 87 | z=tile.zoom, x=tile.tile_x, y=tile_y, s=choice(tile.map_source.subdomains) 88 | ) 89 | Logger.debug("Downloader: download(tile) {}".format(uri)) 90 | response = requests.get(uri, headers={'User-agent': USER_AGENT}, timeout=5) 91 | try: 92 | response.raise_for_status() 93 | data = response.content 94 | with open(cache_fn, "wb") as fd: 95 | fd.write(data) 96 | Logger.debug("Downloaded {} bytes: {}".format(len(data), uri)) 97 | return tile.set_source, (cache_fn,) 98 | except Exception as e: 99 | print("Downloader error: {!r}".format(e)) 100 | 101 | def _check_executor(self, dt): 102 | start = time() 103 | try: 104 | for future in as_completed(self._futures[:], 0): 105 | self._futures.remove(future) 106 | try: 107 | result = future.result() 108 | except Exception: 109 | traceback.print_exc() 110 | # make an error tile? 111 | continue 112 | if result is None: 113 | continue 114 | callback, args = result 115 | callback(*args) 116 | 117 | # capped executor in time, in order to prevent too much 118 | # slowiness. 119 | # seems to works quite great with big zoom-in/out 120 | if time() - start > self.cap_time: 121 | break 122 | except TimeoutError: 123 | pass 124 | -------------------------------------------------------------------------------- /kivy_garden/mapview/source.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | 3 | __all__ = ["MapSource"] 4 | 5 | import hashlib 6 | from math import atan, ceil, cos, exp, log, pi, tan 7 | 8 | from kivy.metrics import dp 9 | 10 | from kivy_garden.mapview.constants import ( 11 | CACHE_DIR, 12 | MAX_LATITUDE, 13 | MAX_LONGITUDE, 14 | MIN_LATITUDE, 15 | MIN_LONGITUDE, 16 | ) 17 | from kivy_garden.mapview.downloader import Downloader 18 | from kivy_garden.mapview.utils import clamp 19 | 20 | 21 | class MapSource: 22 | """Base class for implementing a map source / provider 23 | """ 24 | 25 | attribution_osm = 'Maps & Data © [i][ref=http://www.osm.org/copyright]OpenStreetMap contributors[/ref][/i]' 26 | attribution_thunderforest = 'Maps © [i][ref=http://www.thunderforest.com]Thunderforest[/ref][/i], Data © [i][ref=http://www.osm.org/copyright]OpenStreetMap contributors[/ref][/i]' 27 | 28 | # list of available providers 29 | # cache_key: (is_overlay, minzoom, maxzoom, url, attribution) 30 | providers = { 31 | "osm": ( 32 | 0, 33 | 0, 34 | 19, 35 | "http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", 36 | attribution_osm, 37 | ), 38 | "osm-hot": ( 39 | 0, 40 | 0, 41 | 19, 42 | "http://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png", 43 | "", 44 | ), 45 | "osm-de": ( 46 | 0, 47 | 0, 48 | 18, 49 | "http://{s}.tile.openstreetmap.de/tiles/osmde/{z}/{x}/{y}.png", 50 | "Tiles @ OSM DE", 51 | ), 52 | "osm-fr": ( 53 | 0, 54 | 0, 55 | 20, 56 | "http://{s}.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png", 57 | "Tiles @ OSM France", 58 | ), 59 | "cyclemap": ( 60 | 0, 61 | 0, 62 | 17, 63 | "http://{s}.tile.opencyclemap.org/cycle/{z}/{x}/{y}.png", 64 | "Tiles @ Andy Allan", 65 | ), 66 | "thunderforest-cycle": ( 67 | 0, 68 | 0, 69 | 19, 70 | "http://{s}.tile.thunderforest.com/cycle/{z}/{x}/{y}.png", 71 | attribution_thunderforest, 72 | ), 73 | "thunderforest-transport": ( 74 | 0, 75 | 0, 76 | 19, 77 | "http://{s}.tile.thunderforest.com/transport/{z}/{x}/{y}.png", 78 | attribution_thunderforest, 79 | ), 80 | "thunderforest-landscape": ( 81 | 0, 82 | 0, 83 | 19, 84 | "http://{s}.tile.thunderforest.com/landscape/{z}/{x}/{y}.png", 85 | attribution_thunderforest, 86 | ), 87 | "thunderforest-outdoors": ( 88 | 0, 89 | 0, 90 | 19, 91 | "http://{s}.tile.thunderforest.com/outdoors/{z}/{x}/{y}.png", 92 | attribution_thunderforest, 93 | ), 94 | # no longer available 95 | # "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"}), 96 | # "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"}), 97 | # more to add with 98 | # https://github.com/leaflet-extras/leaflet-providers/blob/master/leaflet-providers.js 99 | # not working ? 100 | # "openseamap": (0, 0, 19, "http://tiles.openseamap.org/seamark/{z}/{x}/{y}.png", 101 | # "Map data @ OpenSeaMap contributors"), 102 | } 103 | 104 | def __init__( 105 | self, 106 | url="http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", 107 | cache_key=None, 108 | min_zoom=0, 109 | max_zoom=19, 110 | tile_size=256, 111 | image_ext="png", 112 | attribution="© OpenStreetMap contributors", 113 | subdomains="abc", 114 | **kwargs 115 | ): 116 | if cache_key is None: 117 | # possible cache hit, but very unlikely 118 | cache_key = hashlib.sha224(url.encode("utf8")).hexdigest()[:10] 119 | self.url = url 120 | self.cache_key = cache_key 121 | self.min_zoom = min_zoom 122 | self.max_zoom = max_zoom 123 | self.tile_size = tile_size 124 | self.image_ext = image_ext 125 | self.attribution = attribution 126 | self.subdomains = subdomains 127 | self.cache_fmt = "{cache_key}_{zoom}_{tile_x}_{tile_y}.{image_ext}" 128 | self.dp_tile_size = min(dp(self.tile_size), self.tile_size * 2) 129 | self.default_lat = self.default_lon = self.default_zoom = None 130 | self.bounds = None 131 | self.cache_dir = kwargs.get('cache_dir', CACHE_DIR) 132 | 133 | @staticmethod 134 | def from_provider(key, **kwargs): 135 | provider = MapSource.providers[key] 136 | cache_dir = kwargs.get('cache_dir', CACHE_DIR) 137 | options = {} 138 | is_overlay, min_zoom, max_zoom, url, attribution = provider[:5] 139 | if len(provider) > 5: 140 | options = provider[5] 141 | return MapSource( 142 | cache_key=key, 143 | min_zoom=min_zoom, 144 | max_zoom=max_zoom, 145 | url=url, 146 | cache_dir=cache_dir, 147 | attribution=attribution, 148 | **options 149 | ) 150 | 151 | def get_x(self, zoom, lon): 152 | """Get the x position on the map using this map source's projection 153 | (0, 0) is located at the top left. 154 | """ 155 | lon = clamp(lon, MIN_LONGITUDE, MAX_LONGITUDE) 156 | return ((lon + 180.0) / 360.0 * pow(2.0, zoom)) * self.dp_tile_size 157 | 158 | def get_y(self, zoom, lat): 159 | """Get the y position on the map using this map source's projection 160 | (0, 0) is located at the top left. 161 | """ 162 | lat = clamp(-lat, MIN_LATITUDE, MAX_LATITUDE) 163 | lat = lat * pi / 180.0 164 | return ( 165 | (1.0 - log(tan(lat) + 1.0 / cos(lat)) / pi) / 2.0 * pow(2.0, zoom) 166 | ) * self.dp_tile_size 167 | 168 | def get_lon(self, zoom, x): 169 | """Get the longitude to the x position in the map source's projection 170 | """ 171 | dx = x / float(self.dp_tile_size) 172 | lon = dx / pow(2.0, zoom) * 360.0 - 180.0 173 | return clamp(lon, MIN_LONGITUDE, MAX_LONGITUDE) 174 | 175 | def get_lat(self, zoom, y): 176 | """Get the latitude to the y position in the map source's projection 177 | """ 178 | dy = y / float(self.dp_tile_size) 179 | n = pi - 2 * pi * dy / pow(2.0, zoom) 180 | lat = -180.0 / pi * atan(0.5 * (exp(n) - exp(-n))) 181 | return clamp(lat, MIN_LATITUDE, MAX_LATITUDE) 182 | 183 | def get_row_count(self, zoom): 184 | """Get the number of tiles in a row at this zoom level 185 | """ 186 | if zoom == 0: 187 | return 1 188 | return 2 << (zoom - 1) 189 | 190 | def get_col_count(self, zoom): 191 | """Get the number of tiles in a col at this zoom level 192 | """ 193 | if zoom == 0: 194 | return 1 195 | return 2 << (zoom - 1) 196 | 197 | def get_min_zoom(self): 198 | """Return the minimum zoom of this source 199 | """ 200 | return self.min_zoom 201 | 202 | def get_max_zoom(self): 203 | """Return the maximum zoom of this source 204 | """ 205 | return self.max_zoom 206 | 207 | def fill_tile(self, tile): 208 | """Add this tile to load within the downloader 209 | """ 210 | if tile.state == "done": 211 | return 212 | Downloader.instance(cache_dir=self.cache_dir).download_tile(tile) 213 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /kivy_garden/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 | 24 | from kivy.graphics import ( 25 | Canvas, 26 | Color, 27 | Line, 28 | MatrixInstruction, 29 | Mesh, 30 | PopMatrix, 31 | PushMatrix, 32 | Scale, 33 | Translate, 34 | ) 35 | from kivy.graphics.tesselator import TYPE_POLYGONS, WINDING_ODD, Tesselator 36 | from kivy.metrics import dp 37 | from kivy.properties import ObjectProperty, StringProperty 38 | from kivy.utils import get_color_from_hex 39 | 40 | from kivy_garden.mapview.constants import CACHE_DIR 41 | from kivy_garden.mapview.downloader import Downloader 42 | from kivy_garden.mapview.view import MapLayer 43 | 44 | COLORS = { 45 | 'aliceblue': '#f0f8ff', 46 | 'antiquewhite': '#faebd7', 47 | 'aqua': '#00ffff', 48 | 'aquamarine': '#7fffd4', 49 | 'azure': '#f0ffff', 50 | 'beige': '#f5f5dc', 51 | 'bisque': '#ffe4c4', 52 | 'black': '#000000', 53 | 'blanchedalmond': '#ffebcd', 54 | 'blue': '#0000ff', 55 | 'blueviolet': '#8a2be2', 56 | 'brown': '#a52a2a', 57 | 'burlywood': '#deb887', 58 | 'cadetblue': '#5f9ea0', 59 | 'chartreuse': '#7fff00', 60 | 'chocolate': '#d2691e', 61 | 'coral': '#ff7f50', 62 | 'cornflowerblue': '#6495ed', 63 | 'cornsilk': '#fff8dc', 64 | 'crimson': '#dc143c', 65 | 'cyan': '#00ffff', 66 | 'darkblue': '#00008b', 67 | 'darkcyan': '#008b8b', 68 | 'darkgoldenrod': '#b8860b', 69 | 'darkgray': '#a9a9a9', 70 | 'darkgrey': '#a9a9a9', 71 | 'darkgreen': '#006400', 72 | 'darkkhaki': '#bdb76b', 73 | 'darkmagenta': '#8b008b', 74 | 'darkolivegreen': '#556b2f', 75 | 'darkorange': '#ff8c00', 76 | 'darkorchid': '#9932cc', 77 | 'darkred': '#8b0000', 78 | 'darksalmon': '#e9967a', 79 | 'darkseagreen': '#8fbc8f', 80 | 'darkslateblue': '#483d8b', 81 | 'darkslategray': '#2f4f4f', 82 | 'darkslategrey': '#2f4f4f', 83 | 'darkturquoise': '#00ced1', 84 | 'darkviolet': '#9400d3', 85 | 'deeppink': '#ff1493', 86 | 'deepskyblue': '#00bfff', 87 | 'dimgray': '#696969', 88 | 'dimgrey': '#696969', 89 | 'dodgerblue': '#1e90ff', 90 | 'firebrick': '#b22222', 91 | 'floralwhite': '#fffaf0', 92 | 'forestgreen': '#228b22', 93 | 'fuchsia': '#ff00ff', 94 | 'gainsboro': '#dcdcdc', 95 | 'ghostwhite': '#f8f8ff', 96 | 'gold': '#ffd700', 97 | 'goldenrod': '#daa520', 98 | 'gray': '#808080', 99 | 'grey': '#808080', 100 | 'green': '#008000', 101 | 'greenyellow': '#adff2f', 102 | 'honeydew': '#f0fff0', 103 | 'hotpink': '#ff69b4', 104 | 'indianred': '#cd5c5c', 105 | 'indigo': '#4b0082', 106 | 'ivory': '#fffff0', 107 | 'khaki': '#f0e68c', 108 | 'lavender': '#e6e6fa', 109 | 'lavenderblush': '#fff0f5', 110 | 'lawngreen': '#7cfc00', 111 | 'lemonchiffon': '#fffacd', 112 | 'lightblue': '#add8e6', 113 | 'lightcoral': '#f08080', 114 | 'lightcyan': '#e0ffff', 115 | 'lightgoldenrodyellow': '#fafad2', 116 | 'lightgray': '#d3d3d3', 117 | 'lightgrey': '#d3d3d3', 118 | 'lightgreen': '#90ee90', 119 | 'lightpink': '#ffb6c1', 120 | 'lightsalmon': '#ffa07a', 121 | 'lightseagreen': '#20b2aa', 122 | 'lightskyblue': '#87cefa', 123 | 'lightslategray': '#778899', 124 | 'lightslategrey': '#778899', 125 | 'lightsteelblue': '#b0c4de', 126 | 'lightyellow': '#ffffe0', 127 | 'lime': '#00ff00', 128 | 'limegreen': '#32cd32', 129 | 'linen': '#faf0e6', 130 | 'magenta': '#ff00ff', 131 | 'maroon': '#800000', 132 | 'mediumaquamarine': '#66cdaa', 133 | 'mediumblue': '#0000cd', 134 | 'mediumorchid': '#ba55d3', 135 | 'mediumpurple': '#9370d8', 136 | 'mediumseagreen': '#3cb371', 137 | 'mediumslateblue': '#7b68ee', 138 | 'mediumspringgreen': '#00fa9a', 139 | 'mediumturquoise': '#48d1cc', 140 | 'mediumvioletred': '#c71585', 141 | 'midnightblue': '#191970', 142 | 'mintcream': '#f5fffa', 143 | 'mistyrose': '#ffe4e1', 144 | 'moccasin': '#ffe4b5', 145 | 'navajowhite': '#ffdead', 146 | 'navy': '#000080', 147 | 'oldlace': '#fdf5e6', 148 | 'olive': '#808000', 149 | 'olivedrab': '#6b8e23', 150 | 'orange': '#ffa500', 151 | 'orangered': '#ff4500', 152 | 'orchid': '#da70d6', 153 | 'palegoldenrod': '#eee8aa', 154 | 'palegreen': '#98fb98', 155 | 'paleturquoise': '#afeeee', 156 | 'palevioletred': '#d87093', 157 | 'papayawhip': '#ffefd5', 158 | 'peachpuff': '#ffdab9', 159 | 'peru': '#cd853f', 160 | 'pink': '#ffc0cb', 161 | 'plum': '#dda0dd', 162 | 'powderblue': '#b0e0e6', 163 | 'purple': '#800080', 164 | 'red': '#ff0000', 165 | 'rosybrown': '#bc8f8f', 166 | 'royalblue': '#4169e1', 167 | 'saddlebrown': '#8b4513', 168 | 'salmon': '#fa8072', 169 | 'sandybrown': '#f4a460', 170 | 'seagreen': '#2e8b57', 171 | 'seashell': '#fff5ee', 172 | 'sienna': '#a0522d', 173 | 'silver': '#c0c0c0', 174 | 'skyblue': '#87ceeb', 175 | 'slateblue': '#6a5acd', 176 | 'slategray': '#708090', 177 | 'slategrey': '#708090', 178 | 'snow': '#fffafa', 179 | 'springgreen': '#00ff7f', 180 | 'steelblue': '#4682b4', 181 | 'tan': '#d2b48c', 182 | 'teal': '#008080', 183 | 'thistle': '#d8bfd8', 184 | 'tomato': '#ff6347', 185 | 'turquoise': '#40e0d0', 186 | 'violet': '#ee82ee', 187 | 'wheat': '#f5deb3', 188 | 'white': '#ffffff', 189 | 'whitesmoke': '#f5f5f5', 190 | 'yellow': '#ffff00', 191 | 'yellowgreen': '#9acd32', 192 | } 193 | 194 | 195 | def flatten(lst): 196 | return [item for sublist in lst for item in sublist] 197 | 198 | 199 | class GeoJsonMapLayer(MapLayer): 200 | 201 | source = StringProperty() 202 | geojson = ObjectProperty() 203 | cache_dir = StringProperty(CACHE_DIR) 204 | 205 | def __init__(self, **kwargs): 206 | self.first_time = True 207 | self.initial_zoom = None 208 | super().__init__(**kwargs) 209 | with self.canvas: 210 | self.canvas_polygon = Canvas() 211 | self.canvas_line = Canvas() 212 | with self.canvas_polygon.before: 213 | PushMatrix() 214 | self.g_matrix = MatrixInstruction() 215 | self.g_scale = Scale() 216 | self.g_translate = Translate() 217 | with self.canvas_polygon: 218 | self.g_canvas_polygon = Canvas() 219 | with self.canvas_polygon.after: 220 | PopMatrix() 221 | 222 | def reposition(self): 223 | vx, vy = self.parent.delta_x, self.parent.delta_y 224 | pzoom = self.parent.zoom 225 | zoom = self.initial_zoom 226 | if zoom is None: 227 | self.initial_zoom = zoom = pzoom 228 | if zoom != pzoom: 229 | diff = 2 ** (pzoom - zoom) 230 | vx /= diff 231 | vy /= diff 232 | self.g_scale.x = self.g_scale.y = diff 233 | else: 234 | self.g_scale.x = self.g_scale.y = 1.0 235 | self.g_translate.xy = vx, vy 236 | self.g_matrix.matrix = self.parent._scatter.transform 237 | 238 | if self.geojson: 239 | update = not self.first_time 240 | self.on_geojson(self, self.geojson, update=update) 241 | self.first_time = False 242 | 243 | def traverse_feature(self, func, part=None): 244 | """Traverse the whole geojson and call the func with every element 245 | found. 246 | """ 247 | if part is None: 248 | part = self.geojson 249 | if not part: 250 | return 251 | tp = part["type"] 252 | if tp == "FeatureCollection": 253 | for feature in part["features"]: 254 | func(feature) 255 | elif tp == "Feature": 256 | func(part) 257 | 258 | @property 259 | def bounds(self): 260 | # return the min lon, max lon, min lat, max lat 261 | bounds = [float("inf"), float("-inf"), float("inf"), float("-inf")] 262 | 263 | def _submit_coordinate(coord): 264 | lon, lat = coord 265 | bounds[0] = min(bounds[0], lon) 266 | bounds[1] = max(bounds[1], lon) 267 | bounds[2] = min(bounds[2], lat) 268 | bounds[3] = max(bounds[3], lat) 269 | 270 | def _get_bounds(feature): 271 | geometry = feature["geometry"] 272 | tp = geometry["type"] 273 | if tp == "Point": 274 | _submit_coordinate(geometry["coordinates"]) 275 | elif tp == "Polygon": 276 | for coordinate in geometry["coordinates"][0]: 277 | _submit_coordinate(coordinate) 278 | elif tp == "MultiPolygon": 279 | for polygon in geometry["coordinates"]: 280 | for coordinate in polygon[0]: 281 | _submit_coordinate(coordinate) 282 | 283 | self.traverse_feature(_get_bounds) 284 | return bounds 285 | 286 | @property 287 | def center(self): 288 | min_lon, max_lon, min_lat, max_lat = self.bounds 289 | cx = (max_lon - min_lon) / 2.0 290 | cy = (max_lat - min_lat) / 2.0 291 | return min_lon + cx, min_lat + cy 292 | 293 | def on_geojson(self, instance, geojson, update=False): 294 | if self.parent is None: 295 | return 296 | if not update: 297 | self.g_canvas_polygon.clear() 298 | self._geojson_part(geojson, geotype="Polygon") 299 | self.canvas_line.clear() 300 | self._geojson_part(geojson, geotype="LineString") 301 | 302 | def on_source(self, instance, value): 303 | if value.startswith(("http://", "https://")): 304 | Downloader.instance(cache_dir=self.cache_dir).download( 305 | value, self._load_geojson_url 306 | ) 307 | else: 308 | with open(value, "rb") as fd: 309 | geojson = json.load(fd) 310 | self.geojson = geojson 311 | 312 | def _load_geojson_url(self, url, response): 313 | self.geojson = response.json() 314 | 315 | def _geojson_part(self, part, geotype=None): 316 | tp = part["type"] 317 | if tp == "FeatureCollection": 318 | for feature in part["features"]: 319 | if geotype and feature["geometry"]["type"] != geotype: 320 | continue 321 | self._geojson_part_f(feature) 322 | elif tp == "Feature": 323 | if geotype and part["geometry"]["type"] == geotype: 324 | self._geojson_part_f(part) 325 | else: 326 | # unhandled geojson part 327 | pass 328 | 329 | def _geojson_part_f(self, feature): 330 | properties = feature["properties"] 331 | geometry = feature["geometry"] 332 | graphics = self._geojson_part_geometry(geometry, properties) 333 | for g in graphics: 334 | tp = geometry["type"] 335 | if tp == "Polygon": 336 | self.g_canvas_polygon.add(g) 337 | else: 338 | self.canvas_line.add(g) 339 | 340 | def _geojson_part_geometry(self, geometry, properties): 341 | tp = geometry["type"] 342 | graphics = [] 343 | if tp == "Polygon": 344 | tess = Tesselator() 345 | for c in geometry["coordinates"]: 346 | xy = list(self._lonlat_to_xy(c)) 347 | xy = flatten(xy) 348 | tess.add_contour(xy) 349 | 350 | tess.tesselate(WINDING_ODD, TYPE_POLYGONS) 351 | 352 | color = self._get_color_from(properties.get("color", "FF000088")) 353 | graphics.append(Color(*color)) 354 | for vertices, indices in tess.meshes: 355 | graphics.append( 356 | Mesh(vertices=vertices, indices=indices, mode="triangle_fan") 357 | ) 358 | 359 | elif tp == "LineString": 360 | stroke = get_color_from_hex(properties.get("stroke", "#ffffff")) 361 | stroke_width = dp(properties.get("stroke-width")) 362 | xy = list(self._lonlat_to_xy(geometry["coordinates"])) 363 | xy = flatten(xy) 364 | graphics.append(Color(*stroke)) 365 | graphics.append(Line(points=xy, width=stroke_width)) 366 | 367 | return graphics 368 | 369 | def _lonlat_to_xy(self, lonlats): 370 | view = self.parent 371 | zoom = view.zoom 372 | for lon, lat in lonlats: 373 | p = view.get_window_xy_from(lat, lon, zoom) 374 | p = p[0] - self.parent.delta_x, p[1] - self.parent.delta_y 375 | p = self.parent._scatter.to_local(*p) 376 | yield p 377 | 378 | def _get_color_from(self, value): 379 | color = COLORS.get(value.lower(), value) 380 | color = get_color_from_hex(color) 381 | return color 382 | -------------------------------------------------------------------------------- /kivy_garden/mapview/clustered_marker_layer.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | Layer that support point clustering 4 | =================================== 5 | """ 6 | 7 | from math import atan, exp, floor, log, pi, sin, sqrt 8 | from os.path import dirname, join 9 | 10 | from kivy.lang import Builder 11 | from kivy.metrics import dp 12 | from kivy.properties import ( 13 | ListProperty, 14 | NumericProperty, 15 | ObjectProperty, 16 | StringProperty, 17 | ) 18 | 19 | from kivy_garden.mapview.view import MapLayer, MapMarker 20 | 21 | Builder.load_string( 22 | """ 23 | : 24 | size_hint: None, None 25 | source: root.source 26 | size: list(map(dp, self.texture_size)) 27 | allow_stretch: True 28 | 29 | Label: 30 | color: root.text_color 31 | pos: root.pos 32 | size: root.size 33 | text: "{}".format(root.num_points) 34 | font_size: dp(18) 35 | """ 36 | ) 37 | 38 | 39 | # longitude/latitude to spherical mercator in [0..1] range 40 | def lngX(lng): 41 | return lng / 360.0 + 0.5 42 | 43 | 44 | def latY(lat): 45 | if lat == 90: 46 | return 0 47 | if lat == -90: 48 | return 1 49 | s = sin(lat * pi / 180.0) 50 | y = 0.5 - 0.25 * log((1 + s) / (1 - s)) / pi 51 | return min(1, max(0, y)) 52 | 53 | 54 | # spherical mercator to longitude/latitude 55 | def xLng(x): 56 | return (x - 0.5) * 360 57 | 58 | 59 | def yLat(y): 60 | y2 = (180 - y * 360) * pi / 180 61 | return 360 * atan(exp(y2)) / pi - 90 62 | 63 | 64 | class KDBush: 65 | """ 66 | kdbush implementation from: 67 | https://github.com/mourner/kdbush/blob/master/src/kdbush.js 68 | """ 69 | 70 | def __init__(self, points, node_size=64): 71 | self.points = points 72 | self.node_size = node_size 73 | 74 | self.ids = ids = [0] * len(points) 75 | self.coords = coords = [0] * len(points) * 2 76 | for i, point in enumerate(points): 77 | ids[i] = i 78 | coords[2 * i] = point.x 79 | coords[2 * i + 1] = point.y 80 | 81 | self._sort(ids, coords, node_size, 0, len(ids) - 1, 0) 82 | 83 | def range(self, min_x, min_y, max_x, max_y): 84 | return self._range( 85 | self.ids, self.coords, min_x, min_y, max_x, max_y, self.node_size 86 | ) 87 | 88 | def within(self, x, y, r): 89 | return self._within(self.ids, self.coords, x, y, r, self.node_size) 90 | 91 | def _sort(self, ids, coords, node_size, left, right, depth): 92 | if right - left <= node_size: 93 | return 94 | m = int(floor((left + right) / 2.0)) 95 | self._select(ids, coords, m, left, right, depth % 2) 96 | self._sort(ids, coords, node_size, left, m - 1, depth + 1) 97 | self._sort(ids, coords, node_size, m + 1, right, depth + 1) 98 | 99 | def _select(self, ids, coords, k, left, right, inc): 100 | swap_item = self._swap_item 101 | while right > left: 102 | if (right - left) > 600: 103 | n = float(right - left + 1) 104 | m = k - left + 1 105 | z = log(n) 106 | s = 0.5 + exp(2 * z / 3.0) 107 | sd = 0.5 * sqrt(z * s * (n - s) / n) * (-1 if (m - n / 2.0) < 0 else 1) 108 | new_left = max(left, int(floor(k - m * s / n + sd))) 109 | new_right = min(right, int(floor(k + (n - m) * s / n + sd))) 110 | self._select(ids, coords, k, new_left, new_right, inc) 111 | 112 | t = coords[2 * k + inc] 113 | i = left 114 | j = right 115 | 116 | swap_item(ids, coords, left, k) 117 | if coords[2 * right + inc] > t: 118 | swap_item(ids, coords, left, right) 119 | 120 | while i < j: 121 | swap_item(ids, coords, i, j) 122 | i += 1 123 | j -= 1 124 | while coords[2 * i + inc] < t: 125 | i += 1 126 | while coords[2 * j + inc] > t: 127 | j -= 1 128 | 129 | if coords[2 * left + inc] == t: 130 | swap_item(ids, coords, left, j) 131 | else: 132 | j += 1 133 | swap_item(ids, coords, j, right) 134 | 135 | if j <= k: 136 | left = j + 1 137 | if k <= j: 138 | right = j - 1 139 | 140 | def _swap_item(self, ids, coords, i, j): 141 | swap = self._swap 142 | swap(ids, i, j) 143 | swap(coords, 2 * i, 2 * j) 144 | swap(coords, 2 * i + 1, 2 * j + 1) 145 | 146 | def _swap(self, arr, i, j): 147 | tmp = arr[i] 148 | arr[i] = arr[j] 149 | arr[j] = tmp 150 | 151 | def _range(self, ids, coords, min_x, min_y, max_x, max_y, node_size): 152 | stack = [0, len(ids) - 1, 0] 153 | result = [] 154 | x = y = 0 155 | 156 | while stack: 157 | axis = stack.pop() 158 | right = stack.pop() 159 | left = stack.pop() 160 | 161 | if right - left <= node_size: 162 | for i in range(left, right + 1): 163 | x = coords[2 * i] 164 | y = coords[2 * i + 1] 165 | if x >= min_x and x <= max_x and y >= min_y and y <= max_y: 166 | result.append(ids[i]) 167 | continue 168 | 169 | m = int(floor((left + right) / 2.0)) 170 | 171 | x = coords[2 * m] 172 | y = coords[2 * m + 1] 173 | 174 | if x >= min_x and x <= max_x and y >= min_y and y <= max_y: 175 | result.append(ids[m]) 176 | 177 | nextAxis = (axis + 1) % 2 178 | 179 | if min_x <= x if axis == 0 else min_y <= y: 180 | stack.append(left) 181 | stack.append(m - 1) 182 | stack.append(nextAxis) 183 | if max_x >= x if axis == 0 else max_y >= y: 184 | stack.append(m + 1) 185 | stack.append(right) 186 | stack.append(nextAxis) 187 | 188 | return result 189 | 190 | def _within(self, ids, coords, qx, qy, r, node_size): 191 | sq_dist = self._sq_dist 192 | stack = [0, len(ids) - 1, 0] 193 | result = [] 194 | r2 = r * r 195 | 196 | while stack: 197 | axis = stack.pop() 198 | right = stack.pop() 199 | left = stack.pop() 200 | 201 | if right - left <= node_size: 202 | for i in range(left, right + 1): 203 | if sq_dist(coords[2 * i], coords[2 * i + 1], qx, qy) <= r2: 204 | result.append(ids[i]) 205 | continue 206 | 207 | m = int(floor((left + right) / 2.0)) 208 | 209 | x = coords[2 * m] 210 | y = coords[2 * m + 1] 211 | 212 | if sq_dist(x, y, qx, qy) <= r2: 213 | result.append(ids[m]) 214 | 215 | nextAxis = (axis + 1) % 2 216 | 217 | if (qx - r <= x) if axis == 0 else (qy - r <= y): 218 | stack.append(left) 219 | stack.append(m - 1) 220 | stack.append(nextAxis) 221 | if (qx + r >= x) if axis == 0 else (qy + r >= y): 222 | stack.append(m + 1) 223 | stack.append(right) 224 | stack.append(nextAxis) 225 | 226 | return result 227 | 228 | def _sq_dist(self, ax, ay, bx, by): 229 | dx = ax - bx 230 | dy = ay - by 231 | return dx * dx + dy * dy 232 | 233 | 234 | class Cluster: 235 | def __init__(self, x, y, num_points, id, props): 236 | self.x = x 237 | self.y = y 238 | self.num_points = num_points 239 | self.zoom = float("inf") 240 | self.id = id 241 | self.props = props 242 | self.parent_id = None 243 | self.widget = None 244 | 245 | # preprocess lon/lat 246 | self.lon = xLng(x) 247 | self.lat = yLat(y) 248 | 249 | 250 | class Marker: 251 | def __init__(self, lon, lat, cls=MapMarker, options=None): 252 | self.lon = lon 253 | self.lat = lat 254 | self.cls = cls 255 | self.options = options 256 | 257 | # preprocess x/y from lon/lat 258 | self.x = lngX(lon) 259 | self.y = latY(lat) 260 | 261 | # cluster information 262 | self.id = None 263 | self.zoom = float("inf") 264 | self.parent_id = None 265 | self.widget = None 266 | 267 | def __repr__(self): 268 | return "".format( 269 | self.lon, self.lat, self.source 270 | ) 271 | 272 | 273 | class SuperCluster: 274 | """Port of supercluster from mapbox in pure python 275 | """ 276 | 277 | def __init__(self, min_zoom=0, max_zoom=16, radius=40, extent=512, node_size=64): 278 | self.min_zoom = min_zoom 279 | self.max_zoom = max_zoom 280 | self.radius = radius 281 | self.extent = extent 282 | self.node_size = node_size 283 | 284 | def load(self, points): 285 | """Load an array of markers. 286 | Once loaded, the index is immutable. 287 | """ 288 | from time import time 289 | 290 | self.trees = {} 291 | self.points = points 292 | 293 | for index, point in enumerate(points): 294 | point.id = index 295 | 296 | clusters = points 297 | for z in range(self.max_zoom, self.min_zoom - 1, -1): 298 | start = time() 299 | print("build tree", z) 300 | self.trees[z + 1] = KDBush(clusters, self.node_size) 301 | print("kdbush", (time() - start) * 1000) 302 | start = time() 303 | clusters = self._cluster(clusters, z) 304 | print(len(clusters)) 305 | print("clustering", (time() - start) * 1000) 306 | self.trees[self.min_zoom] = KDBush(clusters, self.node_size) 307 | 308 | def get_clusters(self, bbox, zoom): 309 | """For the given bbox [westLng, southLat, eastLng, northLat], and 310 | integer zoom, returns an array of clusters and markers 311 | """ 312 | tree = self.trees[self._limit_zoom(zoom)] 313 | ids = tree.range(lngX(bbox[0]), latY(bbox[3]), lngX(bbox[2]), latY(bbox[1])) 314 | clusters = [] 315 | for i in range(len(ids)): 316 | c = tree.points[ids[i]] 317 | if isinstance(c, Cluster): 318 | clusters.append(c) 319 | else: 320 | clusters.append(self.points[c.id]) 321 | return clusters 322 | 323 | def _limit_zoom(self, z): 324 | return max(self.min_zoom, min(self.max_zoom + 1, z)) 325 | 326 | def _cluster(self, points, zoom): 327 | clusters = [] 328 | c_append = clusters.append 329 | trees = self.trees 330 | r = self.radius / float(self.extent * pow(2, zoom)) 331 | 332 | # loop through each point 333 | for i in range(len(points)): 334 | p = points[i] 335 | # if we've already visited the point at this zoom level, skip it 336 | if p.zoom <= zoom: 337 | continue 338 | p.zoom = zoom 339 | 340 | # find all nearby points 341 | tree = trees[zoom + 1] 342 | neighbor_ids = tree.within(p.x, p.y, r) 343 | 344 | num_points = 1 345 | if isinstance(p, Cluster): 346 | num_points = p.num_points 347 | wx = p.x * num_points 348 | wy = p.y * num_points 349 | 350 | props = None 351 | 352 | for j in range(len(neighbor_ids)): 353 | b = tree.points[neighbor_ids[j]] 354 | # filter out neighbors that are too far or already processed 355 | if zoom < b.zoom: 356 | num_points2 = 1 357 | if isinstance(b, Cluster): 358 | num_points2 = b.num_points 359 | # save the zoom (so it doesn't get processed twice) 360 | b.zoom = zoom 361 | # accumulate coordinates for calculating weighted center 362 | wx += b.x * num_points2 363 | wy += b.y * num_points2 364 | num_points += num_points2 365 | b.parent_id = i 366 | 367 | if num_points == 1: 368 | c_append(p) 369 | else: 370 | p.parent_id = i 371 | c_append( 372 | Cluster(wx / num_points, wy / num_points, num_points, i, props) 373 | ) 374 | return clusters 375 | 376 | 377 | class ClusterMapMarker(MapMarker): 378 | source = StringProperty(join(dirname(__file__), "icons", "cluster.png")) 379 | cluster = ObjectProperty() 380 | num_points = NumericProperty() 381 | text_color = ListProperty([0.1, 0.1, 0.1, 1]) 382 | 383 | def on_cluster(self, instance, cluster): 384 | self.num_points = cluster.num_points 385 | 386 | def on_touch_down(self, touch): 387 | return False 388 | 389 | 390 | class ClusteredMarkerLayer(MapLayer): 391 | cluster_cls = ObjectProperty(ClusterMapMarker) 392 | cluster_min_zoom = NumericProperty(0) 393 | cluster_max_zoom = NumericProperty(16) 394 | cluster_radius = NumericProperty("40dp") 395 | cluster_extent = NumericProperty(512) 396 | cluster_node_size = NumericProperty(64) 397 | 398 | def __init__(self, **kwargs): 399 | self.cluster = None 400 | self.cluster_markers = [] 401 | super().__init__(**kwargs) 402 | 403 | def add_marker(self, lon, lat, cls=MapMarker, options=None): 404 | if options is None: 405 | options = {} 406 | marker = Marker(lon, lat, cls, options) 407 | self.cluster_markers.append(marker) 408 | return marker 409 | 410 | def remove_marker(self, marker): 411 | self.cluster_markers.remove(marker) 412 | 413 | def reposition(self): 414 | if self.cluster is None: 415 | self.build_cluster() 416 | margin = dp(48) 417 | mapview = self.parent 418 | set_marker_position = self.set_marker_position 419 | bbox = mapview.get_bbox(margin) 420 | bbox = (bbox[1], bbox[0], bbox[3], bbox[2]) 421 | self.clear_widgets() 422 | for point in self.cluster.get_clusters(bbox, mapview.zoom): 423 | widget = point.widget 424 | if widget is None: 425 | widget = self.create_widget_for(point) 426 | set_marker_position(mapview, widget) 427 | self.add_widget(widget) 428 | 429 | def build_cluster(self): 430 | self.cluster = SuperCluster( 431 | min_zoom=self.cluster_min_zoom, 432 | max_zoom=self.cluster_max_zoom, 433 | radius=self.cluster_radius, 434 | extent=self.cluster_extent, 435 | node_size=self.cluster_node_size, 436 | ) 437 | self.cluster.load(self.cluster_markers) 438 | 439 | def create_widget_for(self, point): 440 | if isinstance(point, Marker): 441 | point.widget = point.cls(lon=point.lon, lat=point.lat, **point.options) 442 | elif isinstance(point, Cluster): 443 | point.widget = self.cluster_cls(lon=point.lon, lat=point.lat, cluster=point) 444 | return point.widget 445 | 446 | def set_marker_position(self, mapview, marker): 447 | x, y = mapview.get_window_xy_from(marker.lat, marker.lon, mapview.zoom) 448 | marker.x = int(x - marker.width * marker.anchor_x) 449 | marker.y = int(y - marker.height * marker.anchor_y) 450 | -------------------------------------------------------------------------------- /kivy_garden/mapview/view.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | 3 | __all__ = ["MapView", "MapMarker", "MapMarkerPopup", "MapLayer", "MarkerMapLayer"] 4 | 5 | import webbrowser 6 | from itertools import takewhile 7 | from math import ceil 8 | from os.path import dirname, join 9 | 10 | from kivy.clock import Clock 11 | from kivy.compat import string_types 12 | from kivy.graphics import Canvas, Color, Rectangle 13 | from kivy.graphics.transformation import Matrix 14 | from kivy.lang import Builder 15 | from kivy.metrics import dp 16 | from kivy.properties import ( 17 | AliasProperty, 18 | BooleanProperty, 19 | ListProperty, 20 | NumericProperty, 21 | ObjectProperty, 22 | StringProperty, 23 | ) 24 | from kivy.uix.behaviors import ButtonBehavior 25 | from kivy.uix.image import Image 26 | from kivy.uix.label import Label 27 | from kivy.uix.scatter import Scatter 28 | from kivy.uix.widget import Widget 29 | 30 | from kivy_garden.mapview import Bbox, Coordinate 31 | from kivy_garden.mapview.constants import ( 32 | CACHE_DIR, 33 | MAX_LATITUDE, 34 | MAX_LONGITUDE, 35 | MIN_LATITUDE, 36 | MIN_LONGITUDE, 37 | ) 38 | from kivy_garden.mapview.source import MapSource 39 | from kivy_garden.mapview.utils import clamp 40 | 41 | Builder.load_string( 42 | """ 43 | : 44 | size_hint: None, None 45 | source: root.source 46 | size: list(map(dp, self.texture_size)) 47 | allow_stretch: True 48 | 49 | : 50 | canvas.before: 51 | StencilPush 52 | Rectangle: 53 | pos: self.pos 54 | size: self.size 55 | StencilUse 56 | Color: 57 | rgba: self.background_color 58 | Rectangle: 59 | pos: self.pos 60 | size: self.size 61 | canvas.after: 62 | StencilUnUse 63 | Rectangle: 64 | pos: self.pos 65 | size: self.size 66 | StencilPop 67 | 68 | ClickableLabel: 69 | text: root.map_source.attribution if hasattr(root.map_source, "attribution") else "" 70 | size_hint: None, None 71 | size: self.texture_size[0] + sp(8), self.texture_size[1] + sp(4) 72 | font_size: "10sp" 73 | right: [root.right, self.center][0] 74 | color: 0, 0, 0, 1 75 | markup: True 76 | canvas.before: 77 | Color: 78 | rgba: .8, .8, .8, .8 79 | Rectangle: 80 | pos: self.pos 81 | size: self.size 82 | 83 | 84 | : 85 | auto_bring_to_front: False 86 | do_rotation: False 87 | scale_min: 0.2 88 | scale_max: 3. 89 | 90 | : 91 | RelativeLayout: 92 | id: placeholder 93 | y: root.top 94 | center_x: root.center_x 95 | size: root.popup_size 96 | 97 | """ 98 | ) 99 | 100 | 101 | class ClickableLabel(Label): 102 | def on_ref_press(self, *args): 103 | webbrowser.open(str(args[0]), new=2) 104 | 105 | 106 | class Tile(Rectangle): 107 | def __init__(self, *args, **kwargs): 108 | super().__init__(*args, **kwargs) 109 | self.cache_dir = kwargs.get('cache_dir', CACHE_DIR) 110 | 111 | @property 112 | def cache_fn(self): 113 | map_source = self.map_source 114 | fn = map_source.cache_fmt.format( 115 | image_ext=map_source.image_ext, 116 | cache_key=map_source.cache_key, 117 | **self.__dict__ 118 | ) 119 | return join(self.cache_dir, fn) 120 | 121 | def set_source(self, cache_fn): 122 | self.source = cache_fn 123 | self.state = "need-animation" 124 | 125 | 126 | class MapMarker(ButtonBehavior, Image): 127 | """A marker on a map, that must be used on a :class:`MapMarker` 128 | """ 129 | 130 | anchor_x = NumericProperty(0.5) 131 | """Anchor of the marker on the X axis. Defaults to 0.5, mean the anchor will 132 | be at the X center of the image. 133 | """ 134 | 135 | anchor_y = NumericProperty(0) 136 | """Anchor of the marker on the Y axis. Defaults to 0, mean the anchor will 137 | be at the Y bottom of the image. 138 | """ 139 | 140 | lat = NumericProperty(0) 141 | """Latitude of the marker 142 | """ 143 | 144 | lon = NumericProperty(0) 145 | """Longitude of the marker 146 | """ 147 | 148 | source = StringProperty(join(dirname(__file__), "icons", "marker.png")) 149 | """Source of the marker, defaults to our own marker.png 150 | """ 151 | 152 | # (internal) reference to its layer 153 | _layer = None 154 | 155 | def __init__(self, **kwargs): 156 | super(MapMarker, self).__init__(**kwargs) 157 | self.texture_update() 158 | 159 | def detach(self): 160 | if self._layer: 161 | self._layer.remove_widget(self) 162 | self._layer = None 163 | 164 | 165 | class MapMarkerPopup(MapMarker): 166 | is_open = BooleanProperty(False) 167 | placeholder = ObjectProperty(None) 168 | popup_size = ListProperty([100, 100]) 169 | 170 | def add_widget(self, widget): 171 | if not self.placeholder: 172 | self.placeholder = widget 173 | if self.is_open: 174 | super().add_widget(self.placeholder) 175 | else: 176 | self.placeholder.add_widget(widget) 177 | 178 | def remove_widget(self, widget): 179 | if widget is not self.placeholder: 180 | self.placeholder.remove_widget(widget) 181 | else: 182 | super().remove_widget(widget) 183 | 184 | def on_is_open(self, *args): 185 | self.refresh_open_status() 186 | 187 | def on_release(self, *args): 188 | self.is_open = not self.is_open 189 | 190 | def refresh_open_status(self): 191 | if not self.is_open and self.placeholder.parent: 192 | super().remove_widget(self.placeholder) 193 | elif self.is_open and not self.placeholder.parent: 194 | super().add_widget(self.placeholder) 195 | 196 | 197 | class MapLayer(Widget): 198 | """A map layer, that is repositionned everytime the :class:`MapView` is 199 | moved. 200 | """ 201 | 202 | viewport_x = NumericProperty(0) 203 | viewport_y = NumericProperty(0) 204 | 205 | def reposition(self): 206 | """Function called when :class:`MapView` is moved. You must recalculate 207 | the position of your children. 208 | """ 209 | pass 210 | 211 | def unload(self): 212 | """Called when the view want to completly unload the layer. 213 | """ 214 | pass 215 | 216 | 217 | class MarkerMapLayer(MapLayer): 218 | """A map layer for :class:`MapMarker` 219 | """ 220 | 221 | order_marker_by_latitude = BooleanProperty(True) 222 | 223 | def __init__(self, **kwargs): 224 | self.markers = [] 225 | super().__init__(**kwargs) 226 | 227 | def insert_marker(self, marker, **kwargs): 228 | if self.order_marker_by_latitude: 229 | before = list( 230 | takewhile(lambda i_m: i_m[1].lat < marker.lat, enumerate(self.children)) 231 | ) 232 | if before: 233 | kwargs['index'] = before[-1][0] + 1 234 | 235 | super().add_widget(marker, **kwargs) 236 | 237 | def add_widget(self, marker): 238 | marker._layer = self 239 | self.markers.append(marker) 240 | self.insert_marker(marker) 241 | 242 | def remove_widget(self, marker): 243 | marker._layer = None 244 | if marker in self.markers: 245 | self.markers.remove(marker) 246 | super().remove_widget(marker) 247 | 248 | def reposition(self): 249 | if not self.markers: 250 | return 251 | mapview = self.parent 252 | set_marker_position = self.set_marker_position 253 | bbox = None 254 | # reposition the markers depending the latitude 255 | markers = sorted(self.markers, key=lambda x: -x.lat) 256 | margin = max((max(marker.size) for marker in markers)) 257 | bbox = mapview.get_bbox(margin) 258 | for marker in markers: 259 | if bbox.collide(marker.lat, marker.lon): 260 | set_marker_position(mapview, marker) 261 | if not marker.parent: 262 | self.insert_marker(marker) 263 | else: 264 | super().remove_widget(marker) 265 | 266 | def set_marker_position(self, mapview, marker): 267 | x, y = mapview.get_window_xy_from(marker.lat, marker.lon, mapview.zoom) 268 | marker.x = int(x - marker.width * marker.anchor_x) 269 | marker.y = int(y - marker.height * marker.anchor_y) 270 | 271 | def unload(self): 272 | self.clear_widgets() 273 | del self.markers[:] 274 | 275 | 276 | class MapViewScatter(Scatter): 277 | # internal 278 | def on_transform(self, *args): 279 | super().on_transform(*args) 280 | self.parent.on_transform(self.transform) 281 | 282 | def collide_point(self, x, y): 283 | return True 284 | 285 | 286 | class MapView(Widget): 287 | """MapView is the widget that control the map displaying, navigation, and 288 | layers management. 289 | """ 290 | 291 | lon = NumericProperty() 292 | """Longitude at the center of the widget 293 | """ 294 | 295 | lat = NumericProperty() 296 | """Latitude at the center of the widget 297 | """ 298 | 299 | zoom = NumericProperty(0) 300 | """Zoom of the widget. Must be between :meth:`MapSource.get_min_zoom` and 301 | :meth:`MapSource.get_max_zoom`. Default to 0. 302 | """ 303 | 304 | map_source = ObjectProperty(MapSource()) 305 | """Provider of the map, default to a empty :class:`MapSource`. 306 | """ 307 | 308 | double_tap_zoom = BooleanProperty(False) 309 | """If True, this will activate the double-tap to zoom. 310 | """ 311 | 312 | pause_on_action = BooleanProperty(True) 313 | """Pause any map loading / tiles loading when an action is done. 314 | This allow better performance on mobile, but can be safely deactivated on 315 | desktop. 316 | """ 317 | 318 | snap_to_zoom = BooleanProperty(True) 319 | """When the user initiate a zoom, it will snap to the closest zoom for 320 | better graphics. The map can be blur if the map is scaled between 2 zoom. 321 | Default to True, even if it doesn't fully working yet. 322 | """ 323 | 324 | animation_duration = NumericProperty(100) 325 | """Duration to animate Tiles alpha from 0 to 1 when it's ready to show. 326 | Default to 100 as 100ms. Use 0 to deactivate. 327 | """ 328 | 329 | delta_x = NumericProperty(0) 330 | delta_y = NumericProperty(0) 331 | background_color = ListProperty([181 / 255.0, 208 / 255.0, 208 / 255.0, 1]) 332 | cache_dir = StringProperty(CACHE_DIR) 333 | _zoom = NumericProperty(0) 334 | _pause = BooleanProperty(False) 335 | _scale = 1.0 336 | _disabled_count = 0 337 | 338 | __events__ = ["on_map_relocated"] 339 | 340 | # Public API 341 | 342 | @property 343 | def viewport_pos(self): 344 | vx, vy = self._scatter.to_local(self.x, self.y) 345 | return vx - self.delta_x, vy - self.delta_y 346 | 347 | @property 348 | def scale(self): 349 | if self._invalid_scale: 350 | self._invalid_scale = False 351 | self._scale = self._scatter.scale 352 | return self._scale 353 | 354 | def get_bbox(self, margin=0): 355 | """Returns the bounding box from the bottom/left (lat1, lon1) to 356 | top/right (lat2, lon2). 357 | """ 358 | x1, y1 = self.to_local(0 - margin, 0 - margin) 359 | x2, y2 = self.to_local((self.width + margin), (self.height + margin)) 360 | c1 = self.get_latlon_at(x1, y1) 361 | c2 = self.get_latlon_at(x2, y2) 362 | return Bbox((c1.lat, c1.lon, c2.lat, c2.lon)) 363 | 364 | bbox = AliasProperty(get_bbox, None, bind=["lat", "lon", "_zoom"]) 365 | 366 | def unload(self): 367 | """Unload the view and all the layers. 368 | It also cancel all the remaining downloads. 369 | """ 370 | self.remove_all_tiles() 371 | 372 | def get_window_xy_from(self, lat, lon, zoom): 373 | """Returns the x/y position in the widget absolute coordinates 374 | from a lat/lon""" 375 | scale = self.scale 376 | vx, vy = self.viewport_pos 377 | ms = self.map_source 378 | x = ms.get_x(zoom, lon) - vx 379 | y = ms.get_y(zoom, lat) - vy 380 | x *= scale 381 | y *= scale 382 | x = x + self.pos[0] 383 | y = y + self.pos[1] 384 | return x, y 385 | 386 | def center_on(self, *args): 387 | """Center the map on the coordinate :class:`Coordinate`, or a (lat, lon) 388 | """ 389 | map_source = self.map_source 390 | zoom = self._zoom 391 | 392 | if len(args) == 1 and isinstance(args[0], Coordinate): 393 | coord = args[0] 394 | lat = coord.lat 395 | lon = coord.lon 396 | elif len(args) == 2: 397 | lat, lon = args 398 | else: 399 | raise Exception("Invalid argument for center_on") 400 | lon = clamp(lon, MIN_LONGITUDE, MAX_LONGITUDE) 401 | lat = clamp(lat, MIN_LATITUDE, MAX_LATITUDE) 402 | scale = self._scatter.scale 403 | x = map_source.get_x(zoom, lon) - self.center_x / scale 404 | y = map_source.get_y(zoom, lat) - self.center_y / scale 405 | self.delta_x = -x 406 | self.delta_y = -y 407 | self.lon = lon 408 | self.lat = lat 409 | self._scatter.pos = 0, 0 410 | self.trigger_update(True) 411 | 412 | def set_zoom_at(self, zoom, x, y, scale=None): 413 | """Sets the zoom level, leaving the (x, y) at the exact same point 414 | in the view. 415 | """ 416 | zoom = clamp( 417 | zoom, self.map_source.get_min_zoom(), self.map_source.get_max_zoom() 418 | ) 419 | if int(zoom) == int(self._zoom): 420 | if scale is None: 421 | return 422 | elif scale == self.scale: 423 | return 424 | scale = scale or 1.0 425 | 426 | # first, rescale the scatter 427 | scatter = self._scatter 428 | scale = clamp(scale, scatter.scale_min, scatter.scale_max) 429 | rescale = scale * 1.0 / scatter.scale 430 | scatter.apply_transform( 431 | Matrix().scale(rescale, rescale, rescale), 432 | post_multiply=True, 433 | anchor=scatter.to_local(x, y), 434 | ) 435 | 436 | # adjust position if the zoom changed 437 | c1 = self.map_source.get_col_count(self._zoom) 438 | c2 = self.map_source.get_col_count(zoom) 439 | if c1 != c2: 440 | f = float(c2) / float(c1) 441 | self.delta_x = scatter.x + self.delta_x * f 442 | self.delta_y = scatter.y + self.delta_y * f 443 | # back to 0 every time 444 | scatter.apply_transform( 445 | Matrix().translate(-scatter.x, -scatter.y, 0), post_multiply=True 446 | ) 447 | 448 | # avoid triggering zoom changes. 449 | self._zoom = zoom 450 | self.zoom = self._zoom 451 | 452 | def on_zoom(self, instance, zoom): 453 | if zoom == self._zoom: 454 | return 455 | x = self.map_source.get_x(zoom, self.lon) - self.delta_x 456 | y = self.map_source.get_y(zoom, self.lat) - self.delta_y 457 | self.set_zoom_at(zoom, x, y) 458 | self.center_on(self.lat, self.lon) 459 | 460 | def get_latlon_at(self, x, y, zoom=None): 461 | """Return the current :class:`Coordinate` within the (x, y) widget 462 | coordinate. 463 | """ 464 | if zoom is None: 465 | zoom = self._zoom 466 | vx, vy = self.viewport_pos 467 | scale = self._scale 468 | return Coordinate( 469 | lat=self.map_source.get_lat(zoom, y / scale + vy), 470 | lon=self.map_source.get_lon(zoom, x / scale + vx), 471 | ) 472 | 473 | def add_marker(self, marker, layer=None): 474 | """Add a marker into the layer. If layer is None, it will be added in 475 | the default marker layer. If there is no default marker layer, a new 476 | one will be automatically created 477 | """ 478 | if layer is None: 479 | if not self._default_marker_layer: 480 | layer = MarkerMapLayer() 481 | self.add_layer(layer) 482 | else: 483 | layer = self._default_marker_layer 484 | layer.add_widget(marker) 485 | layer.set_marker_position(self, marker) 486 | 487 | def remove_marker(self, marker): 488 | """Remove a marker from its layer 489 | """ 490 | marker.detach() 491 | 492 | def add_layer(self, layer, mode="window"): 493 | """Add a new layer to update at the same time the base tile layer. 494 | mode can be either "scatter" or "window". If "scatter", it means the 495 | layer will be within the scatter transformation. It's perfect if you 496 | want to display path / shape, but not for text. 497 | If "window", it will have no transformation. You need to position the 498 | widget yourself: think as Z-sprite / billboard. 499 | Defaults to "window". 500 | """ 501 | assert mode in ("scatter", "window") 502 | if self._default_marker_layer is None and isinstance(layer, MarkerMapLayer): 503 | self._default_marker_layer = layer 504 | self._layers.append(layer) 505 | c = self.canvas 506 | if mode == "scatter": 507 | self.canvas = self.canvas_layers 508 | else: 509 | self.canvas = self.canvas_layers_out 510 | layer.canvas_parent = self.canvas 511 | super().add_widget(layer) 512 | self.canvas = c 513 | 514 | def remove_layer(self, layer): 515 | """Remove the layer 516 | """ 517 | c = self.canvas 518 | self._layers.remove(layer) 519 | self.canvas = layer.canvas_parent 520 | super().remove_widget(layer) 521 | self.canvas = c 522 | 523 | def sync_to(self, other): 524 | """Reflect the lat/lon/zoom of the other MapView to the current one. 525 | """ 526 | if self._zoom != other._zoom: 527 | self.set_zoom_at(other._zoom, *self.center) 528 | self.center_on(other.get_latlon_at(*self.center)) 529 | 530 | # Private API 531 | 532 | def __init__(self, **kwargs): 533 | from kivy.base import EventLoop 534 | 535 | EventLoop.ensure_window() 536 | self._invalid_scale = True 537 | self._tiles = [] 538 | self._tiles_bg = [] 539 | self._tilemap = {} 540 | self._layers = [] 541 | self._default_marker_layer = None 542 | self._need_redraw_all = False 543 | self._transform_lock = False 544 | self.trigger_update(True) 545 | self.canvas = Canvas() 546 | self._scatter = MapViewScatter() 547 | self.add_widget(self._scatter) 548 | with self._scatter.canvas: 549 | self.canvas_map = Canvas() 550 | self.canvas_layers = Canvas() 551 | with self.canvas: 552 | self.canvas_layers_out = Canvas() 553 | self._scale_target_anim = False 554 | self._scale_target = 1.0 555 | self._touch_count = 0 556 | self.map_source.cache_dir = self.cache_dir 557 | Clock.schedule_interval(self._animate_color, 1 / 60.0) 558 | self.lat = kwargs.get("lat", self.lat) 559 | self.lon = kwargs.get("lon", self.lon) 560 | super().__init__(**kwargs) 561 | 562 | def _animate_color(self, dt): 563 | # fast path 564 | d = self.animation_duration 565 | if d == 0: 566 | for tile in self._tiles: 567 | if tile.state == "need-animation": 568 | tile.g_color.a = 1.0 569 | tile.state = "animated" 570 | for tile in self._tiles_bg: 571 | if tile.state == "need-animation": 572 | tile.g_color.a = 1.0 573 | tile.state = "animated" 574 | else: 575 | d = d / 1000.0 576 | for tile in self._tiles: 577 | if tile.state != "need-animation": 578 | continue 579 | tile.g_color.a += dt / d 580 | if tile.g_color.a >= 1: 581 | tile.state = "animated" 582 | for tile in self._tiles_bg: 583 | if tile.state != "need-animation": 584 | continue 585 | tile.g_color.a += dt / d 586 | if tile.g_color.a >= 1: 587 | tile.state = "animated" 588 | 589 | def add_widget(self, widget): 590 | if isinstance(widget, MapMarker): 591 | self.add_marker(widget) 592 | elif isinstance(widget, MapLayer): 593 | self.add_layer(widget) 594 | else: 595 | super().add_widget(widget) 596 | 597 | def remove_widget(self, widget): 598 | if isinstance(widget, MapMarker): 599 | self.remove_marker(widget) 600 | elif isinstance(widget, MapLayer): 601 | self.remove_layer(widget) 602 | else: 603 | super().remove_widget(widget) 604 | 605 | def on_map_relocated(self, zoom, coord): 606 | pass 607 | 608 | def animated_diff_scale_at(self, d, x, y): 609 | self._scale_target_time = 1.0 610 | self._scale_target_pos = x, y 611 | if self._scale_target_anim is False: 612 | self._scale_target_anim = True 613 | self._scale_target = d 614 | else: 615 | self._scale_target += d 616 | Clock.unschedule(self._animate_scale) 617 | Clock.schedule_interval(self._animate_scale, 1 / 60.0) 618 | 619 | def _animate_scale(self, dt): 620 | diff = self._scale_target / 3.0 621 | if abs(diff) < 0.01: 622 | diff = self._scale_target 623 | self._scale_target = 0 624 | else: 625 | self._scale_target -= diff 626 | self._scale_target_time -= dt 627 | self.diff_scale_at(diff, *self._scale_target_pos) 628 | ret = self._scale_target != 0 629 | if not ret: 630 | self._pause = False 631 | return ret 632 | 633 | def diff_scale_at(self, d, x, y): 634 | scatter = self._scatter 635 | scale = scatter.scale * (2 ** d) 636 | self.scale_at(scale, x, y) 637 | 638 | def scale_at(self, scale, x, y): 639 | scatter = self._scatter 640 | scale = clamp(scale, scatter.scale_min, scatter.scale_max) 641 | rescale = scale * 1.0 / scatter.scale 642 | scatter.apply_transform( 643 | Matrix().scale(rescale, rescale, rescale), 644 | post_multiply=True, 645 | anchor=scatter.to_local(x, y), 646 | ) 647 | 648 | def on_touch_down(self, touch): 649 | if not self.collide_point(*touch.pos): 650 | return 651 | if self.pause_on_action: 652 | self._pause = True 653 | if "button" in touch.profile and touch.button in ("scrolldown", "scrollup"): 654 | d = 1 if touch.button == "scrollup" else -1 655 | self.animated_diff_scale_at(d, *touch.pos) 656 | return True 657 | elif touch.is_double_tap and self.double_tap_zoom: 658 | self.animated_diff_scale_at(1, *touch.pos) 659 | return True 660 | touch.grab(self) 661 | self._touch_count += 1 662 | if self._touch_count == 1: 663 | self._touch_zoom = (self.zoom, self._scale) 664 | return super().on_touch_down(touch) 665 | 666 | def on_touch_up(self, touch): 667 | if touch.grab_current == self: 668 | touch.ungrab(self) 669 | self._touch_count -= 1 670 | if self._touch_count == 0: 671 | # animate to the closest zoom 672 | zoom, scale = self._touch_zoom 673 | cur_zoom = self.zoom 674 | cur_scale = self._scale 675 | if cur_zoom < zoom or cur_scale < scale: 676 | self.animated_diff_scale_at(1.0 - cur_scale, *touch.pos) 677 | elif cur_zoom > zoom or cur_scale > scale: 678 | self.animated_diff_scale_at(2.0 - cur_scale, *touch.pos) 679 | self._pause = False 680 | return True 681 | return super().on_touch_up(touch) 682 | 683 | def on_transform(self, *args): 684 | self._invalid_scale = True 685 | if self._transform_lock: 686 | return 687 | self._transform_lock = True 688 | # recalculate viewport 689 | map_source = self.map_source 690 | zoom = self._zoom 691 | scatter = self._scatter 692 | scale = scatter.scale 693 | if scale >= 2.0: 694 | zoom += 1 695 | scale /= 2.0 696 | elif scale < 1: 697 | zoom -= 1 698 | scale *= 2.0 699 | zoom = clamp(zoom, map_source.min_zoom, map_source.max_zoom) 700 | if zoom != self._zoom: 701 | self.set_zoom_at(zoom, scatter.x, scatter.y, scale=scale) 702 | self.trigger_update(True) 703 | else: 704 | if zoom == map_source.min_zoom and scatter.scale < 1.0: 705 | scatter.scale = 1.0 706 | self.trigger_update(True) 707 | else: 708 | self.trigger_update(False) 709 | 710 | if map_source.bounds: 711 | self._apply_bounds() 712 | self._transform_lock = False 713 | self._scale = self._scatter.scale 714 | 715 | def _apply_bounds(self): 716 | # if the map_source have any constraints, apply them here. 717 | map_source = self.map_source 718 | zoom = self._zoom 719 | min_lon, min_lat, max_lon, max_lat = map_source.bounds 720 | xmin = map_source.get_x(zoom, min_lon) 721 | xmax = map_source.get_x(zoom, max_lon) 722 | ymin = map_source.get_y(zoom, min_lat) 723 | ymax = map_source.get_y(zoom, max_lat) 724 | 725 | dx = self.delta_x 726 | dy = self.delta_y 727 | oxmin, oymin = self._scatter.to_local(self.x, self.y) 728 | oxmax, oymax = self._scatter.to_local(self.right, self.top) 729 | s = self._scale 730 | cxmin = oxmin - dx 731 | if cxmin < xmin: 732 | self._scatter.x += (cxmin - xmin) * s 733 | cymin = oymin - dy 734 | if cymin < ymin: 735 | self._scatter.y += (cymin - ymin) * s 736 | cxmax = oxmax - dx 737 | if cxmax > xmax: 738 | self._scatter.x -= (xmax - cxmax) * s 739 | cymax = oymax - dy 740 | if cymax > ymax: 741 | self._scatter.y -= (ymax - cymax) * s 742 | 743 | def on__pause(self, instance, value): 744 | if not value: 745 | self.trigger_update(True) 746 | 747 | def trigger_update(self, full): 748 | self._need_redraw_full = full or self._need_redraw_full 749 | Clock.unschedule(self.do_update) 750 | Clock.schedule_once(self.do_update, -1) 751 | 752 | def do_update(self, dt): 753 | zoom = self._zoom 754 | scale = self._scale 755 | self.lon = self.map_source.get_lon( 756 | zoom, (self.center_x - self._scatter.x) / scale - self.delta_x 757 | ) 758 | self.lat = self.map_source.get_lat( 759 | zoom, (self.center_y - self._scatter.y) / scale - self.delta_y 760 | ) 761 | self.dispatch("on_map_relocated", zoom, Coordinate(self.lon, self.lat)) 762 | for layer in self._layers: 763 | layer.reposition() 764 | 765 | if self._need_redraw_full: 766 | self._need_redraw_full = False 767 | self.move_tiles_to_background() 768 | self.load_visible_tiles() 769 | else: 770 | self.load_visible_tiles() 771 | 772 | def bbox_for_zoom(self, vx, vy, w, h, zoom): 773 | # return a tile-bbox for the zoom 774 | map_source = self.map_source 775 | size = map_source.dp_tile_size 776 | scale = self._scale 777 | 778 | max_x_end = map_source.get_col_count(zoom) 779 | max_y_end = map_source.get_row_count(zoom) 780 | 781 | x_count = int(ceil(w / scale / float(size))) + 1 782 | y_count = int(ceil(h / scale / float(size))) + 1 783 | 784 | tile_x_first = int(clamp(vx / float(size), 0, max_x_end)) 785 | tile_y_first = int(clamp(vy / float(size), 0, max_y_end)) 786 | tile_x_last = tile_x_first + x_count 787 | tile_y_last = tile_y_first + y_count 788 | tile_x_last = int(clamp(tile_x_last, tile_x_first, max_x_end)) 789 | tile_y_last = int(clamp(tile_y_last, tile_y_first, max_y_end)) 790 | 791 | x_count = tile_x_last - tile_x_first 792 | y_count = tile_y_last - tile_y_first 793 | return (tile_x_first, tile_y_first, tile_x_last, tile_y_last, x_count, y_count) 794 | 795 | def load_visible_tiles(self): 796 | map_source = self.map_source 797 | vx, vy = self.viewport_pos 798 | zoom = self._zoom 799 | dirs = [0, 1, 0, -1, 0] 800 | bbox_for_zoom = self.bbox_for_zoom 801 | size = map_source.dp_tile_size 802 | 803 | ( 804 | tile_x_first, 805 | tile_y_first, 806 | tile_x_last, 807 | tile_y_last, 808 | x_count, 809 | y_count, 810 | ) = bbox_for_zoom(vx, vy, self.width, self.height, zoom) 811 | 812 | # Adjust tiles behind us 813 | for tile in self._tiles_bg[:]: 814 | tile_x = tile.tile_x 815 | tile_y = tile.tile_y 816 | 817 | f = 2 ** (zoom - tile.zoom) 818 | w = self.width / f 819 | h = self.height / f 820 | ( 821 | btile_x_first, 822 | btile_y_first, 823 | btile_x_last, 824 | btile_y_last, 825 | _, 826 | _, 827 | ) = bbox_for_zoom(vx / f, vy / f, w, h, tile.zoom) 828 | 829 | if ( 830 | tile_x < btile_x_first 831 | or tile_x >= btile_x_last 832 | or tile_y < btile_y_first 833 | or tile_y >= btile_y_last 834 | ): 835 | tile.state = "done" 836 | self._tiles_bg.remove(tile) 837 | self.canvas_map.before.remove(tile.g_color) 838 | self.canvas_map.before.remove(tile) 839 | continue 840 | 841 | tsize = size * f 842 | tile.size = tsize, tsize 843 | tile.pos = (tile_x * tsize + self.delta_x, tile_y * tsize + self.delta_y) 844 | 845 | # Get rid of old tiles first 846 | for tile in self._tiles[:]: 847 | tile_x = tile.tile_x 848 | tile_y = tile.tile_y 849 | 850 | if ( 851 | tile_x < tile_x_first 852 | or tile_x >= tile_x_last 853 | or tile_y < tile_y_first 854 | or tile_y >= tile_y_last 855 | ): 856 | tile.state = "done" 857 | self.tile_map_set(tile_x, tile_y, False) 858 | self._tiles.remove(tile) 859 | self.canvas_map.remove(tile) 860 | self.canvas_map.remove(tile.g_color) 861 | else: 862 | tile.size = (size, size) 863 | tile.pos = (tile_x * size + self.delta_x, tile_y * size + self.delta_y) 864 | 865 | # Load new tiles if needed 866 | x = tile_x_first + x_count // 2 - 1 867 | y = tile_y_first + y_count // 2 - 1 868 | arm_max = max(x_count, y_count) + 2 869 | arm_size = 1 870 | turn = 0 871 | while arm_size < arm_max: 872 | for i in range(arm_size): 873 | if ( 874 | not self.tile_in_tile_map(x, y) 875 | and y >= tile_y_first 876 | and y < tile_y_last 877 | and x >= tile_x_first 878 | and x < tile_x_last 879 | ): 880 | self.load_tile(x, y, size, zoom) 881 | 882 | x += dirs[turn % 4 + 1] 883 | y += dirs[turn % 4] 884 | 885 | if turn % 2 == 1: 886 | arm_size += 1 887 | 888 | turn += 1 889 | 890 | def load_tile(self, x, y, size, zoom): 891 | if self.tile_in_tile_map(x, y) or zoom != self._zoom: 892 | return 893 | self.load_tile_for_source(self.map_source, 1.0, size, x, y, zoom) 894 | # XXX do overlay support 895 | self.tile_map_set(x, y, True) 896 | 897 | def load_tile_for_source(self, map_source, opacity, size, x, y, zoom): 898 | tile = Tile(size=(size, size), cache_dir=self.cache_dir) 899 | tile.g_color = Color(1, 1, 1, 0) 900 | tile.tile_x = x 901 | tile.tile_y = y 902 | tile.zoom = zoom 903 | tile.pos = (x * size + self.delta_x, y * size + self.delta_y) 904 | tile.map_source = map_source 905 | tile.state = "loading" 906 | if not self._pause: 907 | map_source.fill_tile(tile) 908 | self.canvas_map.add(tile.g_color) 909 | self.canvas_map.add(tile) 910 | self._tiles.append(tile) 911 | 912 | def move_tiles_to_background(self): 913 | # remove all the tiles of the main map to the background map 914 | # retain only the one who are on the current zoom level 915 | # for all the tile in the background, stop the download if not yet started. 916 | zoom = self._zoom 917 | tiles = self._tiles 918 | btiles = self._tiles_bg 919 | canvas_map = self.canvas_map 920 | tile_size = self.map_source.tile_size 921 | 922 | # move all tiles to background 923 | while tiles: 924 | tile = tiles.pop() 925 | if tile.state == "loading": 926 | tile.state = "done" 927 | continue 928 | btiles.append(tile) 929 | 930 | # clear the canvas 931 | canvas_map.clear() 932 | canvas_map.before.clear() 933 | self._tilemap = {} 934 | 935 | # unsure if it's really needed, i personnally didn't get issues right now 936 | # btiles.sort(key=lambda z: -z.zoom) 937 | 938 | # add all the btiles into the back canvas. 939 | # except for the tiles that are owned by the current zoom level 940 | for tile in btiles[:]: 941 | if tile.zoom == zoom: 942 | btiles.remove(tile) 943 | tiles.append(tile) 944 | tile.size = tile_size, tile_size 945 | canvas_map.add(tile.g_color) 946 | canvas_map.add(tile) 947 | self.tile_map_set(tile.tile_x, tile.tile_y, True) 948 | continue 949 | canvas_map.before.add(tile.g_color) 950 | canvas_map.before.add(tile) 951 | 952 | def remove_all_tiles(self): 953 | # clear the map of all tiles. 954 | self.canvas_map.clear() 955 | self.canvas_map.before.clear() 956 | for tile in self._tiles: 957 | tile.state = "done" 958 | del self._tiles[:] 959 | del self._tiles_bg[:] 960 | self._tilemap = {} 961 | 962 | def tile_map_set(self, tile_x, tile_y, value): 963 | key = tile_y * self.map_source.get_col_count(self._zoom) + tile_x 964 | if value: 965 | self._tilemap[key] = value 966 | else: 967 | self._tilemap.pop(key, None) 968 | 969 | def tile_in_tile_map(self, tile_x, tile_y): 970 | key = tile_y * self.map_source.get_col_count(self._zoom) + tile_x 971 | return key in self._tilemap 972 | 973 | def on_size(self, instance, size): 974 | for layer in self._layers: 975 | layer.size = size 976 | self.center_on(self.lat, self.lon) 977 | self.trigger_update(True) 978 | 979 | def on_pos(self, instance, pos): 980 | self.center_on(self.lat, self.lon) 981 | self.trigger_update(True) 982 | 983 | def on_map_source(self, instance, source): 984 | if isinstance(source, string_types): 985 | self.map_source = MapSource.from_provider(source) 986 | elif isinstance(source, (tuple, list)): 987 | cache_key, min_zoom, max_zoom, url, attribution, options = source 988 | self.map_source = MapSource( 989 | url=url, 990 | cache_key=cache_key, 991 | min_zoom=min_zoom, 992 | max_zoom=max_zoom, 993 | attribution=attribution, 994 | cache_dir=self.cache_dir, 995 | **options 996 | ) 997 | elif isinstance(source, MapSource): 998 | self.map_source = source 999 | else: 1000 | raise Exception("Invalid map source provider") 1001 | self.zoom = clamp(self.zoom, self.map_source.min_zoom, self.map_source.max_zoom) 1002 | self.remove_all_tiles() 1003 | self.trigger_update(True) 1004 | --------------------------------------------------------------------------------