Source code for datascience.maps

"""Draw maps using folium."""

__all__ = ['Map', 'Marker', 'Circle', 'Region', 'get_coordinates']


import IPython.display
import itertools
import folium
from folium.plugins import MarkerCluster, BeautifyIcon
import pandas
import numpy as np
import matplotlib as mpl
import pkg_resources
import branca.colormap as cm

import abc
import collections
import collections.abc
import functools
import json
import math
import random
import warnings

from .tables import Table
from .predicates import are

_number = (int, float, np.number)


class _FoliumWrapper(abc.ABC):
    """A map element that can be drawn."""
    _width = 0
    _height = 0
    _folium_map = None

    def draw(self):
        """Draw and cache map."""
        if not self._folium_map:
            self._set_folium_map()
        return self._folium_map

    def as_html(self):
        """Generate HTML to display map."""
        if not self._folium_map:
            self.draw()
        return self._inline_map(self._folium_map, self._width, self._height)

    def show(self):
        """Publish HTML."""
        IPython.display.display(IPython.display.HTML(self.as_html()))

    def _repr_html_(self):
        return self.as_html()

    @staticmethod
    def _inline_map(m, width, height):
        """Returns an embedded iframe of a folium.map."""
        html = m._repr_html_()
        return html

    @abc.abstractmethod
    def _set_folium_map(self):
        """Set the _folium_map attribute to a map."""


[docs] class Map(_FoliumWrapper, collections.abc.Mapping): """A map from IDs to features. Keyword args are forwarded to folium.""" _mapper = folium.Map _default_lat_lon = (37.872, -122.258) _default_zoom = 12 def __init__(self, features=(), ids=(), width=960, height=500, **kwargs): if isinstance(features, np.ndarray): features = list(features) if isinstance(features, collections.abc.Sequence): if len(ids) == len(features): features = dict(zip(ids, features)) else: assert len(ids) == 0 features = dict(enumerate(features)) elif isinstance(features, _MapFeature): features = {0: features} assert isinstance(features, dict), 'Map takes a list or dict of features' tile_style = None if "tiles" in kwargs: tile_style = kwargs.pop("tiles") self._features = features self._attrs = { 'tiles': tile_style if tile_style else 'OpenStreetMap', 'max_zoom': 17, 'min_zoom': 10, } self._width = width self._height = height self._attrs.update(kwargs) self._set_folium_map()
[docs] def copy(self): """ Copies the current Map into a new one and returns it. Note: This only copies rendering attributes. The underlying map is NOT deep-copied. This is as a result of no functionality in Folium. Ref: https://github.com/python-visualization/folium/issues/1207 """ m = Map(features=self._features, width=self._width, height=self._height, **self._attrs) m._folium_map = self._folium_map return m
def __getitem__(self, id): return self._features[id] def __len__(self): return len(self._features) def __iter__(self): return iter(self._features) def _set_folium_map(self): index_map = self._attrs.pop("index_map", None) cluster_labels = self._attrs.pop("cluster_labels", None) self._folium_map = self._create_map() if self._attrs.get("clustered_marker", False): def customize_marker_cluster(color, label): # Returns string for icon_create_function hexcolor = mpl.colors.to_hex(color) return f""" function(cluster) {{ return L.divIcon({{ html: `<div style=' opacity: 0.85; background-color: {hexcolor}; border: solid 2px rgba(66,135,245,1); border-radius: 50%; height: 40px;' onmouseover="document.getElementById('{hexcolor}').style.visibility='visible'" onmouseout="document.getElementById('{hexcolor}').style.visibility='hidden'"> <div id="{hexcolor}" style=' visibility: hidden; font-size: 12px; background-color: white; color: {hexcolor}; text-align: center; padding: 6% 6%; position: absolute; z-index: 1; top: 120%; left: 50%; margin-left: -20px; '>{label}</div> </div>`, iconSize: [40, 40], className: 'dummy' }}); }} """ if index_map is not None: chart_colors = ( (0.0, 30/256, 66/256), (1.0, 200/256, 44/256), (0.0, 150/256, 207/256), (30/256, 100/256, 0.0), (172/256, 60/256, 72/256), ) chart_colors += tuple(tuple((x+0.7)/2 for x in c) for c in chart_colors) colors = list(itertools.islice(itertools.cycle(chart_colors), len(cluster_labels))) marker_cluster = [MarkerCluster(icon_create_function = customize_marker_cluster(colors[i], label)).add_to(self._folium_map) for i, label in enumerate(cluster_labels)] else: marker_cluster = MarkerCluster().add_to(self._folium_map) clustered = True else: clustered = False for i, feature in enumerate(self._features.values()): if isinstance(feature, Circle): feature.draw_on(self._folium_map, self._attrs.get("radius_in_meters", False)) elif clustered and isinstance(feature, Marker): if isinstance(marker_cluster, list): feature.draw_on(marker_cluster[index_map[i]]) else: feature.draw_on(marker_cluster) else: feature.draw_on(self._folium_map) if self._attrs.get("colorbar_scale", None) is not None: colorbar_scale = self._attrs["colorbar_scale"] include_color_scale_outliers = self._attrs.get("include_color_scale_outliers", False) scale_colors = ["#340597", "#7008a5", "#a32494", "#cf5073", "#ee7c4c", "#f69344", "#fcc22d", "#f4e82d", "#f4e82d"] vmin = colorbar_scale.pop(0) vmax = colorbar_scale.pop(-1) colormap = cm.LinearColormap(colors = scale_colors, index = colorbar_scale, caption = "*Legend above may exclude outliers." if not include_color_scale_outliers else "", vmin = colorbar_scale[0], vmax = colorbar_scale[-1]) self._folium_map.add_child(colormap) def _create_map(self): attrs = {'width': self._width, 'height': self._height} attrs.update(self._autozoom()) attrs.update(self._attrs.copy()) # Enforce zoom consistency attrs['max_zoom'] = max(attrs['zoom_start']+2, attrs['max_zoom']) attrs['min_zoom'] = min(attrs['zoom_start']-2, attrs['min_zoom']) return self._mapper(**attrs) def _autozoom(self): """Calculate zoom and location.""" bounds = self._autobounds() attrs = {} midpoint = lambda a, b: (a + b)/2 attrs['location'] = ( midpoint(bounds['min_lat'], bounds['max_lat']), midpoint(bounds['min_lon'], bounds['max_lon']) ) # remove the following with new Folium release # rough approximation, assuming max_zoom is 18 import math lat_diff = bounds['max_lat'] - bounds['min_lat'] lon_diff = bounds['max_lon'] - bounds['min_lon'] area, max_area = lat_diff*lon_diff, 180*360 if area: factor = 1 + max(0, 1 - self._width/1000)/2 + max(0, 1-area**0.5)/2 zoom = math.log(area/max_area)/-factor else: zoom = self._default_zoom zoom = max(1, min(18, round(zoom))) attrs['zoom_start'] = zoom return attrs def _autobounds(self): """Simple calculation for bounds.""" bounds = {} def check(prop, compare, extreme, val): opp = min if compare is max else max bounds.setdefault(prop, val) bounds[prop] = opp(compare(bounds[prop], val), extreme) def bound_check(lat_lon): lat, lon = lat_lon check('max_lat', max, 90, lat) check('min_lat', min, -90, lat) check('max_lon', max, 180, lon) check('min_lon', min, -180, lon) lat_lons = [lat_lon for feature in self._features.values() for lat_lon in feature.lat_lons] if not lat_lons: lat_lons.append(self._default_lat_lon) for lat_lon in lat_lons: bound_check(lat_lon) return bounds @property def features(self): feature_list = [] for key, value in self._features.items(): f = collections.OrderedDict([('id', key), ('feature', value)]) f.update(value.properties) feature_list.append(f) return feature_list
[docs] def format(self, **kwargs): """Apply formatting.""" attrs = self._attrs.copy() attrs.update({'width': self._width, 'height': self._height}) attrs.update(kwargs) return Map(self._features, **attrs)
[docs] def geojson(self): """Render features as a FeatureCollection.""" return { "type": "FeatureCollection", "features": [f.geojson(i) for i, f in self._features.items()] }
[docs] def color(self, values, ids=(), key_on='feature.id', palette='YlOrBr', **kwargs): """Color map features by binning values. values -- a sequence of values or a table of keys and values ids -- an ID for each value; if none are provided, indices are used key_on -- attribute of each feature to match to ids palette -- one of the following color brewer palettes: 'BuGn', 'BuPu', 'GnBu', 'OrRd', 'PuBu', 'PuBuGn', 'PuRd', 'RdPu', 'YlGn', 'YlGnBu', 'YlOrBr', and 'YlOrRd'. Defaults from Folium: threshold_scale: list, default None Data range for D3 threshold scale. Defaults to the following range of quantiles: [0, 0.5, 0.75, 0.85, 0.9], rounded to the nearest order-of-magnitude integer. Ex: 270 rounds to 200, 5600 to 6000. fill_opacity: float, default 0.6 Area fill opacity, range 0-1. line_color: string, default 'black' GeoJSON geopath line color. line_weight: int, default 1 GeoJSON geopath line weight. line_opacity: float, default 1 GeoJSON geopath line opacity, range 0-1. legend_name: string, default None Title for data legend. If not passed, defaults to columns[1]. """ # Set values and ids to both be simple sequences by inspecting values id_name, value_name = 'IDs', 'values' if isinstance(values, collections.abc.Mapping): assert not ids, 'IDs and a map cannot both be used together' if hasattr(values, 'columns') and len(values.columns) == 2: table = values ids, values = table.columns id_name, value_name = table.labels else: dictionary = values ids, values = list(dictionary.keys()), list(dictionary.values()) if len(ids) != len(values): assert len(ids) == 0 # Use indices as IDs ids = list(range(len(values))) m = self._create_map() data = pandas.DataFrame({id_name: ids, value_name: values}) attrs = { 'geo_data': json.dumps(self.geojson()), 'data': data, 'columns': [id_name, value_name], 'key_on': key_on, 'fill_color': palette, } kwargs.update(attrs) folium.Choropleth( **kwargs, name='geojson' ).add_to(m) colored = self.format() colored._folium_map = m return colored
[docs] def overlay(self, feature, color='Blue', opacity=0.6): """ Overlays ``feature`` on the map. Returns a new Map. Args: ``feature``: a ``Table`` of map features, a list of map features, a Map, a Region, or a circle marker map table. The features will be overlayed on the Map with specified ``color``. ``color`` (``str``): Color of feature. Defaults to 'Blue' ``opacity`` (``float``): Opacity of overlain feature. Defaults to 0.6. Returns: A new ``Map`` with the overlain ``feature``. """ result = self.copy() if type(feature) == Table: # if table of features e.g. Table.from_records(taz_map.features) if 'feature' in feature.labels: feature = feature['feature'] # if marker table e.g. table with columns: latitudes,longitudes,popup,color,area else: feature = Circle.map_table(feature) if type(feature) in [list, np.ndarray]: for f in feature: f._attrs['fill_color'] = color f._attrs['fill_opacity'] = opacity f.draw_on(result._folium_map) elif type(feature) == Map: for i in range(len(feature._features)): f = feature._features[i] f._attrs['fill_color'] = color f._attrs['fill_opacity'] = opacity f.draw_on(result._folium_map) elif type(feature) == Region: feature._attrs['fill_color'] = color feature._attrs['fill_opacity'] = opacity feature.draw_on(result._folium_map) return result
[docs] @classmethod def read_geojson(cls, path_or_json_or_string_or_url): """Read a geoJSON string, object, file, or URL. Return a dict of features keyed by ID.""" assert path_or_json_or_string_or_url data = None if isinstance(path_or_json_or_string_or_url, (dict, list)): data = path_or_json_or_string_or_url else: try: data = json.loads(path_or_json_or_string_or_url) except ValueError: pass try: path = path_or_json_or_string_or_url if path.endswith('.gz') or path.endswith('.gzip'): import gzip contents = gzip.open(path, 'r').read().decode('utf-8') else: contents = open(path, 'r').read() data = json.loads(contents) except FileNotFoundError: pass if not data: import urllib.request with urllib.request.urlopen(path_or_json_or_string_or_url) as url: data = json.loads(url.read().decode()) assert data, 'MapData accepts a valid geoJSON object, geoJSON string, path to a geoJSON file, or URL' return cls(cls._read_geojson_features(data))
@staticmethod def _read_geojson_features(data, features=None, prefix=""): """Return a dict of features keyed by ID.""" if features is None: features = collections.OrderedDict() for i, feature in enumerate(data['features']): key = feature.get('id', prefix + str(i)) feature_type = feature['geometry']['type'] if feature_type == 'FeatureCollection': value = Map._read_geojson_features(feature['geometry'], features, prefix + '.' + key) elif feature_type == 'Point': value = Circle._convert_point(feature) elif feature_type in ['Polygon', 'MultiPolygon']: value = Region(feature) else: # TODO Support all http://geojson.org/geojson-spec.html#geometry-objects value = None features[key] = value return features
class _MapFeature(_FoliumWrapper, abc.ABC): """A feature displayed on a map. When displayed alone, a map is created.""" # Default dimensions for displaying the feature in isolation _width = 960 _height = 500 def _set_folium_map(self): """A map containing only the feature.""" m = Map(features=[self], width=self._width, height=self._height) self._folium_map = m.draw() ############# # Interface # ############# @property @abc.abstractmethod def lat_lons(self): """Sequence of lat_lons that describe a map feature (for zooming).""" @property @abc.abstractmethod def _folium_kwargs(self): """kwargs for a call to a map method.""" @abc.abstractmethod def geojson(self, feature_id): """Return GeoJSON.""" @abc.abstractmethod def draw_on(self, folium_map): """Add feature to Folium map object."""
[docs] class Marker(_MapFeature): """A marker displayed with Folium's simple_marker method. popup -- text that pops up when marker is clicked color -- The color of the marker. You can use: [‘red’, ‘blue’, ‘green’, ‘purple’, ‘orange’, ‘darkred’, ’lightred’, ‘beige’, ‘darkblue’, ‘darkgreen’, ‘cadetblue’, ‘darkpurple’, ‘white’, ‘pink’, ‘lightblue’, ‘lightgreen’, ‘gray’, ‘black’, ‘lightgray’] to use standard folium icons. If a hex color code is provided, (color must start with '#'), a folium.plugin.BeautifyIcon will be used instead. Defaults from Folium: marker_icon: string, default 'info-sign' icon from (http://getbootstrap.com/components/) you want on the marker clustered_marker: boolean, default False boolean of whether or not you want the marker clustered with other markers icon_angle: int, default 0 angle of icon popup_width: int, default 300 width of popup The icon can be further customized by by passing in attributes into kwargs by using the attributes listed in `https://python-visualization.github.io/folium/modules.html#folium.map.Icon`. """ def __init__(self, lat, lon, popup='', color='blue', **kwargs): assert isinstance(lat, _number) assert isinstance(lon, _number) self.lat_lon = (lat, lon) self._attrs = { 'popup': popup, 'color': color, **kwargs } # setting default icon to be empty; this is overwritten by .update() # on the next line if 'marker_icon' is present in kwargs self._attrs["marker_icon"] = "sign-blank" self._attrs.update(kwargs) @property def lat_lons(self): return [self.lat_lon]
[docs] def copy(self): """Return a deep copy""" return type(self)(self.lat_lon[0], self.lat_lon[1], **self._attrs)
@property def _folium_kwargs(self): attrs = self._attrs.copy() attrs['location'] = self.lat_lon icon_args = {k: attrs.pop(k) for k in attrs.keys() & {'color', 'marker_icon', 'clustered_marker', 'icon_angle', 'popup_width'}} if 'marker_icon' in icon_args: icon_args['icon'] = icon_args.pop('marker_icon') if 'color' in icon_args and icon_args['color'][0] == '#': # Checks if color provided is a hex code instead; if it is, uses BeautifyIcon to create markers. # If statement does not check to see if color is an empty string. icon_args['background_color'] = icon_args['border_color'] = icon_args.pop('color') if icon_args['background_color'][1] == icon_args['background_color'][3] == icon_args['background_color'][5] == 'f': icon_args['text_color'] = 'gray' else: icon_args['text_color'] = 'white' icon_args['icon_shape'] = 'marker' if 'icon' not in icon_args: icon_args['icon'] = 'circle' attrs['icon'] = BeautifyIcon(**icon_args) else: attrs['icon'] = folium.Icon(**icon_args) return attrs
[docs] def geojson(self, feature_id): """GeoJSON representation of the marker as a point.""" lat, lon = self.lat_lon return { 'type': 'Feature', 'id': feature_id, 'geometry': { 'type': 'Point', 'coordinates': (lon, lat), }, }
[docs] def format(self, **kwargs): """Apply formatting.""" attrs = self._attrs.copy() attrs.update(kwargs) lat, lon = self.lat_lon return type(self)(lat, lon, **attrs)
[docs] def draw_on(self, folium_map): folium.Marker(**self._folium_kwargs).add_to(folium_map)
@classmethod def _convert_point(cls, feature): """Convert a GeoJSON point to a Marker.""" lon, lat = feature['geometry']['coordinates'] popup = feature['properties'].get('name', '') return cls(lat, lon, popup=popup)
[docs] @classmethod def map(cls, latitudes, longitudes, labels=None, colors=None, areas=None, other_attrs=None, clustered_marker=False, **kwargs): """Return markers from columns of coordinates, labels, & colors. The areas column is not applicable to markers, but sets circle areas. Arguments: (TODO) document all options index_map: list of integers, default None (when not applicable) list of indices that maps each marker to a corresponding label at the index in cluster_labels (only applicable when multiple marker clusters are being used) cluster_labels: list of strings, default None (when not applicable) list of labels used for each cluster of markers (only applicable when multiple marker clusters are being used) colorbar_scale: list of floats, default None (when not applicable) list of cutoffs used to indicate where the bins are for each color (only applicable when colorscale gradient is being used) include_color_scale_outliers: boolean, default None (when not applicable) boolean of whether or not outliers are included in the colorscale gradient for markers (only applicable when colorscale gradient is being used) radius_in_meters: boolean, default False boolean of whether or not Circles should have their radii specified in meters, scales with map zoom clustered_marker: boolean, default False boolean of whether or not you want the marker clustered with other markers other_attrs: dictionary of (key) property names to (value) property values, default None A dictionary that list any other attributes that the class Marker/Circle should have """ assert latitudes is not None assert longitudes is not None assert len(latitudes) == len(longitudes) assert areas is None or hasattr(cls, '_has_area'), "A " + cls.__name__ + " has no area" inputs = [latitudes, longitudes] # Variables passed into class Map kwargs instead of markers kwargs map_kwargs = { 'index_map': kwargs.pop("index_map", None), 'cluster_labels': kwargs.pop("cluster_labels", None), 'colorbar_scale': kwargs.pop("colorbar_scale", None), 'include_color_scale_outliers': kwargs.pop("include_color_scale_outliers", None), 'radius_in_meters': kwargs.pop("radius_in_meters", False) } # index_map = kwargs.pop("index_map", None) # cluster_labels = kwargs.pop("cluster_labels", None) # colorbar_scale = kwargs.pop("colorbar_scale", None) # include_color_scale_outliers = kwargs.pop("include_color_scale_outliers", None) # radius_in_meters = kwargs.pop("radius_in_meters", False) if labels is not None: assert len(labels) == len(latitudes) inputs.append(labels) else: inputs.append(("",) * len(latitudes)) if colors is not None: assert len(colors) == len(latitudes) inputs.append(colors) if areas is not None: assert len(areas) == len(latitudes) inputs.append(areas) if other_attrs is not None: other_attrs_processed = [] for i in range(len(latitudes)): other_attrs_processed.append({}) for prop in other_attrs: for i in range(len(other_attrs[prop])): other_attrs_processed[i][prop] = other_attrs[prop][i] for dic in other_attrs_processed: dic.update(kwargs) else: other_attrs_processed = [] if other_attrs_processed: ms = [cls(*args, **other_attrs_processed[row_num]) for row_num, args in enumerate(zip(*inputs))] else: ms = [cls(*args, **kwargs) for row_num, args in enumerate(zip(*inputs))] return Map(ms, clustered_marker=clustered_marker, **map_kwargs)
[docs] @classmethod def map_table(cls, table, clustered_marker=False, include_color_scale_outliers=True, radius_in_meters=False, **kwargs): """Return markers from the columns of a table. The first two columns of the table must be the latitudes and longitudes (in that order), followed by 'labels', 'colors', 'color_scale', 'radius_scale', 'cluster_by', 'area_scale', and/or 'areas' (if applicable) in any order with columns explicitly stating what property they are representing. Args: ``cls``: Type of marker being drawn on the map {Marker, Circle}. ``table``: Table of data to be made into markers. The first two columns of the table must be the latitudes and longitudes (in that order), followed by 'labels', 'colors', 'cluster_by', 'color_scale', 'radius_scale', 'area_scale', and/or 'areas' (if applicable) in any order with columns explicitly stating what property they are representing. Additional columns for marker-specific attributes such as 'marker_icon' for the Marker class can be included as well. ``clustered_marker``: Boolean indicating if markers should be clustered with folium.plugins.MarkerCluster. ``include_color_scale_outliers``: Boolean indicating if outliers should be included in the color scale gradient or not. ``radius_in_meters``: Boolean indicating if circle markers should be drawn to map scale or zoom scale. """ lat, lon, lab, color, areas, colorbar_scale, index_map, cluster_labels, other_attrs = None, None, None, None, None, None, None, None, {} excluded = ["color_scale", "cluster_by", "radius_scale", "area_scale"] for index, col in enumerate(table.labels): this_col = table.column(col) if index == 0: lat = this_col elif index == 1: lon = this_col elif col == "labels": lab = this_col elif col == "colors": color = this_col elif col == "areas": areas = this_col elif col not in excluded: other_attrs[col] = this_col if "cluster_by" in table.labels: clustered_marker = True cluster_column = table.column("cluster_by") cluster_labels = list(set(cluster_column)) index_name = "".join(table.labels) # Ensure column name doesn't already exist in table index_name += " " table = table.with_columns(index_name, np.arange(table.num_rows)) index_map = np.array([-1] * table.num_rows) for i, label in enumerate(cluster_labels): index_map[list(table.where("cluster_by", label).column(index_name))] = i if "radius_scale" in table.labels: radius_column = table.column("radius_scale").astype(float) rmin, rmax = kwargs.get("radius_min", 5), kwargs.get("radius_max", 50) vmin, vmax = radius_column.min(), radius_column.max() scale_fn = lambda v: (v - vmin) / (vmax - vmin) * (rmax - rmin) + rmin radii = scale_fn(radius_column) other_attrs["radius"] = [float(r) for r in radii] if "area_scale" in table.labels: # takes precedence over radius_scale area_column = table.column("area_scale").astype(float) amin, amax = kwargs.get("area_min", 80), kwargs.get("area_max", 8000) vmin, vmax = area_column.min(), area_column.max() scale_fn = lambda v: (v - vmin) / (vmax - vmin) * (amax - amin) + amin areas = scale_fn(area_column) radii = np.sqrt(areas / np.pi) # convert area into radius using A = pi * r^2 other_attrs["radius"] = [float(r) for r in radii] if 'color_scale' in table.labels: vmin = min(table.column("color_scale")) vmax = max(table.column("color_scale")) if include_color_scale_outliers: outlier_min_bound = vmin outlier_max_bound = vmax else: q1 = np.percentile(table.column("color_scale"), 25) q3 = np.percentile(table.column("color_scale"), 75) IQR = q3 - q1 outlier_min_bound = max(vmin, q1 - 1.5 * IQR) outlier_max_bound = min(vmax, q3 + 1.5 * IQR) colorbar_scale = list(np.linspace(outlier_min_bound, outlier_max_bound, 9)) scale_colors = ["#340597", "#7008a5", "#a32494", "#cf5073", "#ee7c4c", "#f69344", "#fcc22d", "#f4e82d", "#f4e82d"] def interpolate_color(colors, cutoffs, datapoint): for i, cutoff in enumerate(cutoffs): if cutoff >= datapoint: return colors[i - 1] if i > 0 else colors[0] return colors[-1] color = [""] * table.num_rows for i, datapoint in enumerate(table.column('color_scale')): color[i] = interpolate_color(scale_colors, colorbar_scale, datapoint) colorbar_scale = [vmin] + colorbar_scale + [vmax] if not other_attrs: other_attrs = None return cls.map( latitudes=lat, longitudes=lon, labels=lab, colors=color, areas=areas, colorbar_scale=colorbar_scale, other_attrs=other_attrs, clustered_marker=clustered_marker, index_map=index_map, include_color_scale_outliers=include_color_scale_outliers, radius_in_meters=radius_in_meters, cluster_labels=cluster_labels, **kwargs )
[docs] class Circle(Marker): """A marker displayed with either Folium's circle_marker or circle methods. The ``circle_marker`` method draws circles that stay the same size regardless of map zoom, whereas the circle method draws circles that have a fixed radius in meters. To toggle between them, use the ``radius_in_meters`` flag in the draw_on function. popup -- text that pops up when marker is clicked color -- fill color area -- pixel-squared area of the circle Defaults from Folium: fill_opacity: float, default 0.6 Circle fill opacity More options can be passed into kwargs by following the attributes listed in `https://leafletjs.com/reference-1.4.0.html#circlemarker` or `https://leafletjs.com/reference-1.4.0.html#circle`. For example, to draw three circles with circle_marker: .. code-block:: python t = Table().with_columns([ 'lat', [37.8, 38, 37.9], 'lon', [-122, -122.1, -121.9], 'label', ['one', 'two', 'three'], 'color', ['red', 'green', 'blue'], 'area', [3000, 4000, 5000], ]) Circle.map_table(t) To draw three circles with the circle methods, replace the last line with: .. code-block:: python Circle.map_table(t, radius_in_meters=True) """ _has_area = True def __init__(self, lat, lon, popup='', color='blue', area=math.pi*(10**2), **kwargs): # Add support for transitioning radius to area radius = (area/math.pi)**0.5 if 'radius' in kwargs: warnings.warn("The 'radius' argument is deprecated. Please use 'area' instead.", FutureWarning) radius = kwargs.pop('radius') super().__init__(lat, lon, popup, color, radius=radius, **kwargs) @property def _folium_kwargs(self): attrs = self._attrs.copy() attrs['location'] = self.lat_lon if 'color' in attrs: attrs['fill_color'] = attrs.pop('color') if 'line_color' in attrs: attrs['color'] = attrs.pop('line_color') else: if 'fill_color' in attrs: attrs['color'] = attrs['fill_color'] return attrs
[docs] def draw_on(self, folium_map, radius_in_meters=False): if radius_in_meters: folium.Circle(**self._folium_kwargs).add_to(folium_map) else: folium.CircleMarker(**self._folium_kwargs).add_to(folium_map)
[docs] class Region(_MapFeature): """A GeoJSON feature displayed with Folium's geo_json method.""" def __init__(self, geojson, **kwargs): assert 'type' in geojson assert geojson['type'] == 'Feature' assert 'geometry' in geojson assert 'type' in geojson['geometry'] assert geojson['geometry']['type'] in ['Polygon', 'MultiPolygon'] self._geojson = geojson self._attrs = kwargs @property def lat_lons(self): """A flat list of (lat, lon) pairs.""" return _lat_lons_from_geojson(self._geojson['geometry']['coordinates']) @property def type(self): """The GEOJSON type of the regions: Polygon or MultiPolygon.""" return self._geojson['geometry']['type'] @property def polygons(self): """Return a list of polygons describing the region. - Each polygon is a list of linear rings, where the first describes the exterior and the rest describe interior holes. - Each linear ring is a list of positions where the last is a repeat of the first. - Each position is a (lat, lon) pair. """ if self.type == 'Polygon': polygons = [self._geojson['geometry']['coordinates']] else: # self.type == "MultiPolygon" polygons = self._geojson['geometry']['coordinates'] return [ [ [_lat_lons_from_geojson(s) for s in ring ] for ring in polygon] for polygon in polygons] @property def properties(self): return self._geojson.get('properties', {})
[docs] def copy(self): """Return a deep copy""" return type(self)(self._geojson.copy(), **self._attrs)
@property def _folium_kwargs(self): attrs = self._attrs.copy() attrs['data'] = json.dumps(self._geojson) return attrs
[docs] def geojson(self, feature_id): """Return GeoJSON with ID substituted.""" if self._geojson.get('id', feature_id) == feature_id: return self._geojson else: geo = self._geojson.copy() geo['id'] = feature_id return geo
[docs] def format(self, **kwargs): """Apply formatting.""" attrs = self._attrs.copy() attrs.update(kwargs) return Region(self._geojson, **attrs)
[docs] def draw_on(self, folium_map): attrs = self._folium_kwargs data = attrs.pop('data') folium.GeoJson( data=data, style_function=lambda x: attrs, name='geojson' ).add_to(folium_map)
def _lat_lons_from_geojson(s): """Return a latitude-longitude pairs from nested GeoJSON coordinates. GeoJSON coordinates are always stored in (longitude, latitude) order. """ if len(s) >= 2 and isinstance(s[0], _number) and isinstance(s[0], _number): lat, lon = s[1], s[0] return [(lat, lon)] else: return [lat_lon for sub in s for lat_lon in _lat_lons_from_geojson(sub)]
[docs] def get_coordinates(table, replace_columns=False, remove_nans=False): """ Adds latitude and longitude coordinates to table based on other location identifiers. Must be in the United States. Takes table with columns "zip code" or "city" and/or "county" and "state" in column names and adds the columns "lat" and "lon". If a county is not found inside the dataset, that row's latitude and longitude coordinates are replaced with np.nans. The 'replace_columns' flag indicates if the "city", "county", "state", and "zip code" columns should be removed afterwards. The 'remove_nans' flag indicates if rows with nan latitudes and longitudes should be removed. Robust to capitalization in city and county names. If a row's location with multiple zip codes is specified, the latitude and longitude pair assigned to the row will correspond to the smallest zip code. Dataset was acquired on July 2, 2020 from https://docs.gaslamp.media/download-zip-code-latitude-longitude-city-state-county-csv. Found in geocode_datasets/geocode_states.csv. Modified column names and made city/county columns all in lowercase. Args: table: A table with counties that need to mapped to coordinates replace_columns: A boolean that indicates if "county", "city", "state", and "zip code" columns should be removed remove_nans: A boolean that indicates if columns with invalid longitudes and latitudes should be removed Returns: Table with latitude and longitude coordinates """ assert "zip code" in table.labels or (("city" in table.labels or "county" in table.labels) and "state" in table.labels) ref = Table.read_table(pkg_resources.resource_filename(__name__, "geodata/geocode_states.csv")) index_name = "".join(table.labels) # Ensures that index can't possibly be one of the preexisting columns index_name += " " table = table.with_columns(index_name, np.arange(table.num_rows)) lat = np.array([np.nan] * table.num_rows) lon = np.array([np.nan] * table.num_rows) unassigned = set(range(table.num_rows)) while len(unassigned) > 0: index = unassigned.pop() row = table.take(index).take(0) if "zip code" in table.labels: select = table.where("zip code", row["zip code"][0]).column(index_name) unassigned -= set(select) try: ref_lat, ref_lon = ref.where("zip", int(row["zip code"][0])).select("lat", "lon").row(0) lat[select] = ref_lat lon[select] = ref_lon except IndexError: pass else: state_select = table.where("state", row["state"][0]).column(index_name) county_select = table.where("county", row["county"][0]).column(index_name) if "county" in table.labels else np.arange(table.num_rows) city_select = table.where("city", row["city"][0]).column(index_name) if "city" in table.labels else np.arange(table.num_rows) select = set.intersection(set(state_select), set(county_select), set(city_select)) unassigned -= select select = list(select) try: matched_ref = ref.where("state", row["state"][0]) if "county" in table.labels: matched_ref = matched_ref.where("county", row["county"][0].lower()) if "city" in table.labels: matched_ref = matched_ref.where("city", row["city"][0].lower()) ref_lat, ref_lon = matched_ref.select("lat", "lon").row(0) lat[select] = ref_lat lon[select] = ref_lon except IndexError: pass table = table.with_columns("lat", lat, "lon", lon) table = table.drop(index_name) if replace_columns: table = table.drop(["county", "city", "zip code", "state"]) if remove_nans: table = table.where("lat", are.below(float("inf"))) # NaNs are not considered to be smaller than infinity return table