Source code for akvo.rsr.staticmap

# -*- coding: utf-8 -*-

"""Akvo RSR is covered by the GNU Affero General Public License.

See more details in the license.txt file located at the root folder of the Akvo RSR module.
For additional details on the GNU license please see < http://www.gnu.org/licenses/agpl.html >.
"""

import collections
from django.conf import settings
from math import radians, cos, sin, asin, sqrt, pi, log, tan


Coordinate = collections.namedtuple('Coordinate', ['latitude', 'longitude'])

Size = collections.namedtuple('Size', ['width', 'height'])

# Radius of earth in meters.
EARTH_RADIUS = 6378137


# Adopted from https://github.com/julienr/pymapcluster/blob/master/globalmaptiles.py
[docs]class SimpleMercator(object): def __init__(self, tileSize=256): self.tileSize = tileSize self.initialResolution = 2 * pi * EARTH_RADIUS / self.tileSize self.originShift = 2 * pi * EARTH_RADIUS / 2.0
[docs] def coordinate_to_pixels(self, coordinate, zoom): mx, my = self.coordinate_to_meters(coordinate) return self.meters_to_pixels(mx, my, zoom)
[docs] def coordinate_to_meters(self, coordinate): mx = coordinate.longitude * self.originShift / 180.0 my = log(tan((90 + coordinate.latitude) * pi / 360.0)) / (pi / 180.0) my = my * self.originShift / 180.0 return mx, my
[docs] def meters_to_pixels(self, mx, my, zoom): res = self.resolution(zoom) px = (mx + self.originShift) / res py = (my + self.originShift) / res return px, py
[docs] def resolution(self, zoom): return self.initialResolution / (2**zoom)
# Adopted from https://github.com/julienr/pymapcluster/blob/master/pymapcluster.py
[docs]class MarkerClusterer(object): def __init__(self, mercator=SimpleMercator(), gridsize=50): self.mercator = mercator self.gridsize = gridsize
[docs] def process(self, coordinates, zoom): centers = [] clusters = {} for i, coordinate in enumerate(coordinates): point_pix = self.mercator.coordinate_to_pixels(coordinate, zoom) assigned = False for c in centers: center = coordinates[c] center_pix = self.mercator.coordinate_to_pixels(center, zoom) if self._in_cluster(center_pix, point_pix): clusters[c].append(coordinate) assigned = True break if not assigned: centers.append(i) clusters[i] = [coordinate] return clusters
def _in_cluster(self, center, point): return (point[0] >= center[0] - self.gridsize) \ and (point[0] <= center[0] + self.gridsize) \ and (point[1] >= center[1] - self.gridsize) \ and (point[1] <= center[1] + self.gridsize)
[docs]class MapboxAutoClusteringAdapter(object): URL = "https://api.mapbox.com/styles/v1/mapbox/light-v10/static/{}{}/{}?access_token={}&logo=false" # https://docs.mapbox.com/help/glossary/zoom-level/ MAPBOX_ZOOMLEVEL = { 0: 78271.484, 1: 39135.742, 2: 19567.871, 3: 9783.936, 4: 4891.968, 5: 2445.984, 6: 1222.992, 7: 611.496, 8: 305.748, 9: 152.874, 10: 76.437, 11: 38.218, 12: 19.109, 13: 9.555, 14: 4.777, 15: 2.389, 16: 1.194, 17: 0.597, 18: 0.299, 19: 0.149, 20: 0.075, 21: 0.037, 22: 0.037, } def __init__(self, key, clusterer=MarkerClusterer()): self.key = key self.clusterer = clusterer
[docs] def get_url(self, locations, size=Size(600, 300), zoom=None): zoom_level = self._calculate_zoom_level(locations, size) zoom_level = zoom_level if zoom is None or zoom > zoom_level else zoom clusters = self.clusterer.process(locations, zoom_level) center = _determine_center(locations) markers = self._make_markers(clusters) overlay_str = ','.join([ 'pin-s-{}+1890ff({},{})'.format(v, c.longitude, c.latitude) for (c, v) in markers ]) coi_str = '{},{},{}'.format(center.longitude, center.latitude, zoom_level) size_str = "{}x{}".format(size.width, size.height) return self.URL.format('{}/'.format(overlay_str) if overlay_str else '', coi_str, size_str, self.key)
def _calculate_zoom_level(self, locations, size): if not locations: return 0 location_groups = _group_locations(locations) min_latitude = min(c.latitude for c in location_groups) max_latitude = max(c.latitude for c in location_groups) distance = _calculate_distance(Coordinate(min_latitude, 0), Coordinate(max_latitude, 0)) for zoom in range(8, -1, -1): # Only zoom up to 8 level, bigger than that will be too small for an overview map. # # Mapbox determines the geographical distance covered by an individual # pixel in a map depends on the latitude, so we only need to use the # image height in the calculation. # https://docs.mapbox.com/help/glossary/zoom-level/#zoom-levels-and-geographical-distance level = self.MAPBOX_ZOOMLEVEL[zoom] * size.height if level > (distance + (distance * 0.8)): return zoom return 0 def _make_markers(self, clusters): markers = [] for cluster in clusters.values(): center = _determine_center(cluster) count = len(cluster) # TODO: Mapbox only support alphanumeric label a through z, 0 through 99 (https://docs.mapbox.com/api/maps/#marker) # If a cluster has more than 99 item than it will be maxed out to 99, We can use a custom marker to remove this limitation markers.append((center, count if count < 100 else 99)) return markers
[docs]class MapboxAdapter(object): URL = "https://api.mapbox.com/styles/v1/mapbox/light-v10/static/{}/{}/{}?access_token={}&logo=false" def __init__(self, key): self.key = key
[docs] def get_url(self, locations, size=Size(600, 300), zoom=None): location_groups = _group_locations(locations) overlay_string = self._make_multiple_markers(location_groups)\ if len(locations) > 1\ else self._make_single_marker(locations[0]) center_zoom = self._make_center_and_zoom(location_groups, zoom or 8) size_string = "{}x{}".format(size.width, size.height) return self.URL.format(overlay_string, center_zoom, size_string, self.key)
def _make_multiple_markers(self, location_groups): markers = [ 'pin-s-{}+1890ff({},{})'.format(v, c.longitude, c.latitude) for c, v in sorted(location_groups.items(), key=lambda kv: (kv[1], kv[0])) ] return ','.join(markers) def _make_single_marker(self, location): return 'pin-s+1890ff({},{})'.format(location.longitude, location.latitude) def _make_center_and_zoom(self, location_groups, zoom): if len(location_groups) > 1: return 'auto' location = list(location_groups.keys())[0] return '{},{},{}'.format(location.longitude, location.latitude, zoom)
[docs]class MapquestAdapter(object): URL = "https://www.mapquestapi.com/staticmap/v5/map?key={}&locations={}" def __init__(self, key): self.key = key
[docs] def get_url(self, locations, size=None, zoom=None): location_groups = _group_locations(locations) location_strings = [ '{},{}|marker-{}'.format(c.latitude, c.longitude, v) for c, v in sorted(location_groups.items(), key=lambda kv: (kv[1], kv[0])) ] url = self.URL.format(self.key, "||".join(location_strings)) if size: url = url + "&size={},{}".format(size.width, size.height) if zoom is not None and 0 <= zoom <= 20: url = url + "&zoom={}".format(zoom) return url
def _group_locations(locations): groups = {} for location in locations: if location not in groups: groups[location] = 0 groups[location] += 1 return groups def _determine_center(coordinates): if not coordinates: return Coordinate(0, 0) location_groups = _group_locations(coordinates) min_latitude = min(c.latitude for c in location_groups) max_latitude = max(c.latitude for c in location_groups) min_longitude = min(c.longitude for c in location_groups) max_longitude = max(c.longitude for c in location_groups) latitude_center = (min_latitude + max_latitude) / 2 longitude_center = (min_longitude + max_longitude) / 2 return Coordinate(latitude_center, longitude_center) def _calculate_distance(coordinate1, coordinate2): lon1 = radians(coordinate1.longitude) lon2 = radians(coordinate2.longitude) lat1 = radians(coordinate1.latitude) lat2 = radians(coordinate2.latitude) # Haversine formula a = sin((lat2 - lat1) / 2) ** 2 + cos(lat1) * cos(lat2) * sin((lon2 - lon1) / 2) ** 2 c = 2 * asin(sqrt(a)) return c * EARTH_RADIUS default_adapter = MapboxAutoClusteringAdapter(getattr(settings, 'MAPBOX_KEY', 'NO_API_KEY'))
[docs]def get_staticmap_url(locations, size=None, zoom=None): return default_adapter.get_url(locations, size, zoom)