|
2 | 2 |
|
3 | 3 | __all__ = ["clamp", "haversine", "get_zoom_for_radius"] |
4 | 4 |
|
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 |
6 | 7 |
|
7 | 8 | from kivy.core.window import Window |
8 | 9 | from kivy.metrics import dp |
@@ -43,8 +44,185 @@ def get_zoom_for_radius(radius_km, lat=None, tile_size=256.0): |
43 | 44 | # Check how many tiles that are currently in view |
44 | 45 | nr_tiles_shown = min(Window.size) / dp(tile_size) |
45 | 46 |
|
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 |
47 | 49 | 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 |
50 | 57 | 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 |
0 commit comments