"""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 importlib.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 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 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 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)
    with importlib.resources.files(__package__).joinpath("geodata/geocode_states.csv").open("r", encoding="utf-8") as f:
        ref = Table.read_table(f)
    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