Skip to content

Commit 871da50

Browse files
authored
Added PolyLine Features and updated utils with extra features (#67)
* Update .gitignore with Jetbrain settings file * Add PolyLineLayer * Add extra features to util * Cache SmoothLine and update points only * Update functions in utils to meet standards
1 parent 216ea42 commit 871da50

File tree

4 files changed

+225
-7
lines changed

4 files changed

+225
-7
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,3 +124,6 @@ dmypy.json
124124

125125
# MapView cache
126126
/cache
127+
128+
# Jetbrain IDE settings
129+
.idea

kivy_garden/mapview/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
MapMarkerPopup,
1414
MapView,
1515
MarkerMapLayer,
16+
PolylineLayer
1617
)
1718

1819
__all__ = [
@@ -24,4 +25,5 @@
2425
"MapLayer",
2526
"MarkerMapLayer",
2627
"MapMarkerPopup",
28+
"PolylineLayer"
2729
]

kivy_garden/mapview/utils.py

Lines changed: 182 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
__all__ = ["clamp", "haversine", "get_zoom_for_radius"]
44

5-
from math import asin, cos, pi, radians, sin, sqrt
5+
from contextlib import suppress
6+
from math import asin, cos, pi, radians, sin, sqrt, log, tan
67

78
from kivy.core.window import Window
89
from kivy.metrics import dp
@@ -43,8 +44,185 @@ def get_zoom_for_radius(radius_km, lat=None, tile_size=256.0):
4344
# Check how many tiles that are currently in view
4445
nr_tiles_shown = min(Window.size) / dp(tile_size)
4546

46-
# Keep zooming in until we find a zoom level where the circle can fit inside the screen
47+
# Keep zooming in until we find a zoom level where the circle
48+
# can fit inside the screen
4749
zoom = 1
48-
while earth_circumference / (2 << (zoom - 1)) * nr_tiles_shown > 2 * radius:
49-
zoom += 1
50+
with suppress(OverflowError):
51+
while (
52+
earth_circumference
53+
/ (2 << (zoom - 1)) * nr_tiles_shown
54+
> 2 * radius
55+
):
56+
zoom += 1
5057
return zoom - 1 # Go one zoom level back
58+
59+
60+
def get_bounding_box(locations):
61+
"""
62+
Calculate the minimum and maximum latitude and longitude
63+
from the given set of coordinates to form a bounding box
64+
65+
:Parameters:
66+
`locations`: List of tuples containing latitude and longitude.
67+
"""
68+
min_lat = min(locations, key=lambda x: x[0])[0]
69+
max_lat = max(locations, key=lambda x: x[0])[0]
70+
min_lon = min(locations, key=lambda x: x[1])[1]
71+
max_lon = max(locations, key=lambda x: x[1])[1]
72+
return min_lat, max_lat, min_lon, max_lon
73+
74+
75+
def get_bounding_box_center(locations):
76+
"""
77+
Find the center of this bounding box by averaging the
78+
minimum and maximum latitudes and longitudes
79+
80+
:Parameters:
81+
`locations`: List of tuples containing latitude and longitude.
82+
"""
83+
min_lat, max_lat, min_lon, max_lon = get_bounding_box(locations)
84+
center_lat = (min_lat + max_lat) / 2
85+
center_lon = (min_lon + max_lon) / 2
86+
return center_lat, center_lon
87+
88+
89+
def get_fit_zoom_level(locations, map_width, map_height, tile_size=256):
90+
"""
91+
Calculates the zoom level to fit all locations into the map view.
92+
93+
Determine the zoom level that fits the bounding box within the map view.
94+
This involves calculating the required scale to fit both the width
95+
and height of the bounding box into the viewport.
96+
97+
:Parameters:
98+
`locations`: List of tuples containing latitude and longitude.
99+
`map_width`: Width of the map
100+
`map_height`: Height of the map
101+
102+
:return: Calculated zoom level.
103+
"""
104+
min_lat, max_lat, min_lon, max_lon = get_bounding_box(locations)
105+
106+
# Function to convert latitude to pixel value
107+
def lat_to_pixel(lat, zoom):
108+
return (
109+
tile_size
110+
* (1 - log(tan(radians(lat)) + 1 / cos(radians(lat))) / pi)
111+
/ 2 * (2 ** zoom)
112+
)
113+
114+
# Function to convert longitude to pixel value
115+
def lon_to_pixel(lon, zoom):
116+
return tile_size * (lon + 180) / 360 * (2 ** zoom)
117+
118+
# Determine the best zoom level
119+
zoom = 1
120+
for z in range(1, 21): # Assuming a max zoom level of 20
121+
lat_pixel_range = lat_to_pixel(max_lat, z) - lat_to_pixel(min_lat, z)
122+
lon_pixel_range = lon_to_pixel(max_lon, z) - lon_to_pixel(min_lon, z)
123+
124+
if lat_pixel_range < map_height and lon_pixel_range < map_width:
125+
zoom = z
126+
else:
127+
break
128+
129+
return zoom
130+
131+
132+
def update_map_view(
133+
map_width,
134+
map_height,
135+
lat1,
136+
lon1,
137+
lat2,
138+
lon2,
139+
mapview=None,
140+
polyline_layer=None,
141+
max_zoom=16,
142+
tile_size=256
143+
):
144+
"""
145+
Updates the MapView to ensure that two specified
146+
locations are both visible on the screen, centering the
147+
view between the two locations and adjusting the zoom level
148+
accordingly.
149+
150+
This function calculates the optimal center point and zoom
151+
level for the MapView to display both `(lat1, lon1)` and `(lat2, lon2)`.
152+
It ensures that the map is centered between these two points and
153+
adjusts the zoom level so that both locations remain visible within
154+
the given map dimensions.
155+
156+
The function performs the following steps:
157+
1. Calculates the geographic center between
158+
`(lat1, lon1)` and `(lat2, lon2)`.
159+
2. Determines the appropriate zoom level to fit both locations within
160+
the specified `map_width` and `map_height`.
161+
3. Further adjusts the zoom level based on the distance between the two
162+
locations using the Haversine formula.
163+
4. Centers the map on the calculated center point.
164+
5. Sets the zoom level to the average of the calculated zoom levels, with a
165+
maximum zoom level of 16.
166+
6. Updates the coordinates for the polyline layer to draw a line between
167+
`(lat1, lon1)` and `(lat2, lon2)`.
168+
169+
"""
170+
coordinates = [(lat1, lon1), (lat2, lon2)]
171+
center_lat, center_lon = get_bounding_box_center(coordinates)
172+
z1 = get_fit_zoom_level(
173+
coordinates,
174+
map_width,
175+
map_height,
176+
tile_size
177+
)
178+
z2 = get_zoom_for_radius(haversine(lon1, lat1, lon2, lat2))
179+
zoom_level = int((z1 + z2) / 2)
180+
if mapview:
181+
mapview.center_on(center_lat, center_lon)
182+
mapview.zoom = min(zoom_level, max_zoom)
183+
if polyline_layer:
184+
polyline_layer.coordinates = coordinates
185+
return (center_lat, center_lon), zoom_level
186+
187+
188+
def generate_circle_points(lat, lon, radius, precision=360):
189+
"""
190+
Generates a list of points that form a circle around a
191+
given latitude and longitude.
192+
193+
The function calculates `N` points that form a circle with a
194+
specified radius aroundthe central point defined by the given
195+
latitude (`lat`) and longitude (`lon`).
196+
197+
Args:
198+
lat (float): The latitude of the central point around
199+
which the circle is generated.
200+
lon (float): The longitude of the central point around
201+
which the circle is generated.
202+
radius (float): The radius of the circle in kilometers.
203+
precision (int, float): The precision of the circle
204+
205+
Returns:
206+
list of dict: A list of dictionaries, where each dictionary contains
207+
latitude ('lat') and longitude ('lon') of a point on the circle.
208+
209+
Example:
210+
>>> generate_circle_points(37.7749, -122.4194, 10)
211+
[{'lat': 37.78215, 'lon': -122.4194},
212+
{'lat': 37.78206, 'lon': -122.415}, ...]
213+
"""
214+
215+
# generate points
216+
circlePoints = []
217+
for k in range(precision):
218+
angle = pi * 2 * k / precision
219+
dx = radius * cos(angle)
220+
dy = radius * sin(angle)
221+
point = {
222+
'lat': lon + (180 / pi) * (dy / 6371),
223+
'lon': lat + (180 / pi) * (dx / 6371) / cos(lon * pi / 180)
224+
}
225+
# add to list
226+
circlePoints.append(point)
227+
228+
return circlePoints

kivy_garden/mapview/view.py

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
# coding=utf-8
22

3-
__all__ = ["MapView", "MapMarker", "MapMarkerPopup", "MapLayer", "MarkerMapLayer"]
3+
__all__ = [
4+
"MapView",
5+
"MapMarker",
6+
"MapMarkerPopup",
7+
"MapLayer",
8+
"MarkerMapLayer",
9+
"PolylineLayer"
10+
]
411

512
import webbrowser
613
from itertools import takewhile
@@ -9,17 +16,17 @@
916

1017
from kivy.clock import Clock
1118
from kivy.compat import string_types
12-
from kivy.graphics import Canvas, Color, Rectangle
19+
from kivy.graphics import Canvas, Color, Rectangle, SmoothLine
1320
from kivy.graphics.transformation import Matrix
1421
from kivy.lang import Builder
15-
from kivy.metrics import dp
1622
from kivy.properties import (
1723
AliasProperty,
1824
BooleanProperty,
1925
ListProperty,
2026
NumericProperty,
2127
ObjectProperty,
2228
StringProperty,
29+
ColorProperty,
2330
)
2431
from kivy.uix.behaviors import ButtonBehavior
2532
from kivy.uix.image import Image
@@ -93,6 +100,16 @@
93100
y: root.top
94101
center_x: root.center_x
95102
size: root.popup_size
103+
104+
<PolyLineLayer>:
105+
canvas:
106+
Color:
107+
rgba: self.line_color
108+
SmoothLine:
109+
width: 2
110+
joint: 'round'
111+
cap: 'round'
112+
96113
97114
"""
98115
)
@@ -273,6 +290,24 @@ def unload(self):
273290
del self.markers[:]
274291

275292

293+
class PolylineLayer(MapLayer):
294+
line_color = ColorProperty("red")
295+
coordinates = ListProperty()
296+
297+
def __init__(self, **kwargs):
298+
super().__init__(**kwargs)
299+
self.bind(coordinates=lambda *_: self.reposition())
300+
301+
def reposition(self):
302+
mapview = self.parent
303+
points = []
304+
for lat, lon in self.coordinates:
305+
x, y = mapview.get_window_xy_from(lat, lon, mapview.zoom)
306+
points.extend([x, y])
307+
if points:
308+
self.canvas.children[2].points = points
309+
310+
276311
class MapViewScatter(Scatter):
277312
# internal
278313
def on_transform(self, *args):

0 commit comments

Comments
 (0)