from statistics import mean import geopandas from shapely.geometry import LineString import numpy as np import pandas as pd from packaging.version import Version _MAP_KWARGS = [ "location", "prefer_canvas", "no_touch", "disable_3d", "png_enabled", "zoom_control", "crs", "zoom_start", "left", "top", "position", "min_zoom", "max_zoom", "min_lat", "max_lat", "min_lon", "max_lon", "max_bounds", ] def _explore( df, column=None, cmap=None, color=None, m=None, tiles="OpenStreetMap", attr=None, tooltip=True, popup=False, highlight=True, categorical=False, legend=True, scheme=None, k=5, vmin=None, vmax=None, width="100%", height="100%", categories=None, classification_kwds=None, control_scale=True, marker_type=None, marker_kwds={}, style_kwds={}, highlight_kwds={}, missing_kwds={}, tooltip_kwds={}, popup_kwds={}, legend_kwds={}, map_kwds={}, **kwargs, ): """Interactive map based on GeoPandas and folium/leaflet.js Generate an interactive leaflet map based on :class:`~geopandas.GeoDataFrame` Parameters ---------- column : str, np.array, pd.Series (default None) The name of the dataframe column, :class:`numpy.array`, or :class:`pandas.Series` to be plotted. If :class:`numpy.array` or :class:`pandas.Series` are used then it must have same length as dataframe. cmap : str, matplotlib.Colormap, branca.colormap or function (default None) The name of a colormap recognized by ``matplotlib``, a list-like of colors, :class:`matplotlib.colors.Colormap`, a :class:`branca.colormap.ColorMap` or function that returns a named color or hex based on the column value, e.g.:: def my_colormap(value): # scalar value defined in 'column' if value > 1: return "green" return "red" color : str, array-like (default None) Named color or a list-like of colors (named or hex). m : folium.Map (default None) Existing map instance on which to draw the plot. tiles : str, xyzservices.TileProvider (default 'OpenStreetMap Mapnik') Map tileset to use. Can choose from the list supported by folium, query a :class:`xyzservices.TileProvider` by a name from ``xyzservices.providers``, pass :class:`xyzservices.TileProvider` object or pass custom XYZ URL. The current list of built-in providers (when ``xyzservices`` is not available): ``["OpenStreetMap", "Stamen Terrain", “Stamen Toner", “Stamen Watercolor" "CartoDB positron", “CartoDB dark_matter"]`` You can pass a custom tileset to Folium by passing a Leaflet-style URL to the tiles parameter: ``http://{s}.yourtiles.com/{z}/{x}/{y}.png``. Be sure to check their terms and conditions and to provide attribution with the ``attr`` keyword. attr : str (default None) Map tile attribution; only required if passing custom tile URL. tooltip : bool, str, int, list (default True) Display GeoDataFrame attributes when hovering over the object. ``True`` includes all columns. ``False`` removes tooltip. Pass string or list of strings to specify a column(s). Integer specifies first n columns to be included. Defaults to ``True``. popup : bool, str, int, list (default False) Input GeoDataFrame attributes for object displayed when clicking. ``True`` includes all columns. ``False`` removes popup. Pass string or list of strings to specify a column(s). Integer specifies first n columns to be included. Defaults to ``False``. highlight : bool (default True) Enable highlight functionality when hovering over a geometry. categorical : bool (default False) If ``False``, ``cmap`` will reflect numerical values of the column being plotted. For non-numerical columns, this will be set to True. legend : bool (default True) Plot a legend in choropleth plots. Ignored if no ``column`` is given. scheme : str (default None) Name of a choropleth classification scheme (requires ``mapclassify`` >= 2.4.0). A :func:`mapclassify.classify` will be used under the hood. Supported are all schemes provided by ``mapclassify`` (e.g. ``'BoxPlot'``, ``'EqualInterval'``, ``'FisherJenks'``, ``'FisherJenksSampled'``, ``'HeadTailBreaks'``, ``'JenksCaspall'``, ``'JenksCaspallForced'``, ``'JenksCaspallSampled'``, ``'MaxP'``, ``'MaximumBreaks'``, ``'NaturalBreaks'``, ``'Quantiles'``, ``'Percentiles'``, ``'StdMean'``, ``'UserDefined'``). Arguments can be passed in ``classification_kwds``. k : int (default 5) Number of classes vmin : None or float (default None) Minimum value of ``cmap``. If ``None``, the minimum data value in the column to be plotted is used. vmax : None or float (default None) Maximum value of ``cmap``. If ``None``, the maximum data value in the column to be plotted is used. width : pixel int or percentage string (default: '100%') Width of the folium :class:`~folium.folium.Map`. If the argument m is given explicitly, width is ignored. height : pixel int or percentage string (default: '100%') Height of the folium :class:`~folium.folium.Map`. If the argument m is given explicitly, height is ignored. categories : list-like Ordered list-like object of categories to be used for categorical plot. classification_kwds : dict (default None) Keyword arguments to pass to mapclassify control_scale : bool, (default True) Whether to add a control scale on the map. marker_type : str, folium.Circle, folium.CircleMarker, folium.Marker (default None) Allowed string options are ('marker', 'circle', 'circle_marker'). Defaults to folium.CircleMarker. marker_kwds: dict (default {}) Additional keywords to be passed to the selected ``marker_type``, e.g.: radius : float (default 2 for ``circle_marker`` and 50 for ``circle``)) Radius of the circle, in meters (for ``circle``) or pixels (for ``circle_marker``). fill : bool (default True) Whether to fill the ``circle`` or ``circle_marker`` with color. icon : folium.map.Icon the :class:`folium.map.Icon` object to use to render the marker. draggable : bool (default False) Set to True to be able to drag the marker around the map. style_kwds : dict (default {}) Additional style to be passed to folium ``style_function``: stroke : bool (default True) Whether to draw stroke along the path. Set it to ``False`` to disable borders on polygons or circles. color : str Stroke color weight : int Stroke width in pixels opacity : float (default 1.0) Stroke opacity fill : boolean (default True) Whether to fill the path with color. Set it to ``False`` to disable filling on polygons or circles. fillColor : str Fill color. Defaults to the value of the color option fillOpacity : float (default 0.5) Fill opacity. style_function : callable Function mapping a GeoJson Feature to a style ``dict``. * Style properties :func:`folium.vector_layers.path_options` * GeoJson features :class:`GeoDataFrame.__geo_interface__` e.g.:: lambda x: {"color":"red" if x["properties"]["gdp_md_est"]<10**6 else "blue"} Plus all supported by :func:`folium.vector_layers.path_options`. See the documentation of :class:`folium.features.GeoJson` for details. highlight_kwds : dict (default {}) Style to be passed to folium highlight_function. Uses the same keywords as ``style_kwds``. When empty, defaults to ``{"fillOpacity": 0.75}``. tooltip_kwds : dict (default {}) Additional keywords to be passed to :class:`folium.features.GeoJsonTooltip`, e.g. ``aliases``, ``labels``, or ``sticky``. popup_kwds : dict (default {}) Additional keywords to be passed to :class:`folium.features.GeoJsonPopup`, e.g. ``aliases`` or ``labels``. legend_kwds : dict (default {}) Additional keywords to be passed to the legend. Currently supported customisation: caption : string Custom caption of the legend. Defaults to the column name. Additional accepted keywords when ``scheme`` is specified: colorbar : bool (default True) An option to control the style of the legend. If True, continuous colorbar will be used. If False, categorical legend will be used for bins. scale : bool (default True) Scale bins along the colorbar axis according to the bin edges (True) or use the equal length for each bin (False) fmt : string (default "{:.2f}") A formatting specification for the bin edges of the classes in the legend. For example, to have no decimals: ``{"fmt": "{:.0f}"}``. Applies if ``colorbar=False``. labels : list-like A list of legend labels to override the auto-generated labels. Needs to have the same number of elements as the number of classes (`k`). Applies if ``colorbar=False``. interval : boolean (default False) An option to control brackets from mapclassify legend. If True, open/closed interval brackets are shown in the legend. Applies if ``colorbar=False``. max_labels : int, default 10 Maximum number of colorbar tick labels (requires branca>=0.5.0) map_kwds : dict (default {}) Additional keywords to be passed to folium :class:`~folium.folium.Map`, e.g. ``dragging``, or ``scrollWheelZoom``. **kwargs : dict Additional options to be passed on to the folium object. Returns ------- m : folium.folium.Map folium :class:`~folium.folium.Map` instance Examples -------- >>> df = geopandas.read_file(geopandas.datasets.get_path("naturalearth_lowres")) >>> df.head(2) # doctest: +SKIP pop_est continent name iso_a3 \ gdp_md_est geometry 0 920938 Oceania Fiji FJI 8374.0 MULTIPOLY\ GON (((180.00000 -16.06713, 180.00000... 1 53950935 Africa Tanzania TZA 150600.0 POLYGON (\ (33.90371 -0.95000, 34.07262 -1.05982... >>> df.explore("pop_est", cmap="Blues") # doctest: +SKIP """ def _colormap_helper(_cmap, n_resample=None, idx=None): """Helper for MPL deprecation - GH#2596""" if not n_resample: return cm.get_cmap(_cmap) else: if MPL_361: return cm.get_cmap(_cmap).resampled(n_resample)(idx) else: return cm.get_cmap(_cmap, n_resample)(idx) try: import branca as bc import folium import matplotlib import matplotlib.colors as colors import matplotlib.pyplot as plt from mapclassify import classify # isolate MPL version - GH#2596 MPL_361 = Version(matplotlib.__version__) >= Version("3.6.1") if MPL_361: from matplotlib import colormaps as cm else: import matplotlib.cm as cm except (ImportError, ModuleNotFoundError): raise ImportError( "The 'folium', 'matplotlib' and 'mapclassify' packages are required for " "'explore()'. You can install them using " "'conda install -c conda-forge folium matplotlib mapclassify' " "or 'pip install folium matplotlib mapclassify'." ) # xyservices is an optional dependency try: import xyzservices HAS_XYZSERVICES = True except (ImportError, ModuleNotFoundError): HAS_XYZSERVICES = False gdf = df.copy() # convert LinearRing to LineString rings_mask = df.geom_type == "LinearRing" if rings_mask.any(): gdf.geometry[rings_mask] = gdf.geometry[rings_mask].apply( lambda g: LineString(g) ) if gdf.crs is None: kwargs["crs"] = "Simple" tiles = None elif not gdf.crs.equals(4326): gdf = gdf.to_crs(4326) # create folium.Map object if m is None: # Get bounds to specify location and map extent bounds = gdf.total_bounds location = kwargs.pop("location", None) if location is None: x = mean([bounds[0], bounds[2]]) y = mean([bounds[1], bounds[3]]) location = (y, x) if "zoom_start" in kwargs.keys(): fit = False else: fit = True else: fit = False # get a subset of kwargs to be passed to folium.Map for i in _MAP_KWARGS: if i in map_kwds: raise ValueError( f"'{i}' cannot be specified in 'map_kwds'. " f"Use the '{i}={map_kwds[i]}' argument instead." ) map_kwds = { **map_kwds, **{i: kwargs[i] for i in kwargs.keys() if i in _MAP_KWARGS}, } if HAS_XYZSERVICES: # match provider name string to xyzservices.TileProvider if isinstance(tiles, str): try: tiles = xyzservices.providers.query_name(tiles) except ValueError: pass if isinstance(tiles, xyzservices.TileProvider): attr = attr if attr else tiles.html_attribution map_kwds["min_zoom"] = tiles.get("min_zoom", 0) map_kwds["max_zoom"] = tiles.get("max_zoom", 18) tiles = tiles.build_url(scale_factor="{r}") m = folium.Map( location=location, control_scale=control_scale, tiles=tiles, attr=attr, width=width, height=height, **map_kwds, ) # fit bounds to get a proper zoom level if fit: m.fit_bounds([[bounds[1], bounds[0]], [bounds[3], bounds[2]]]) for map_kwd in _MAP_KWARGS: kwargs.pop(map_kwd, None) nan_idx = None if column is not None: if pd.api.types.is_list_like(column): if len(column) != gdf.shape[0]: raise ValueError( "The GeoDataFrame and given column have different number of rows." ) else: column_name = "__plottable_column" gdf[column_name] = column column = column_name elif pd.api.types.is_categorical_dtype(gdf[column]): if categories is not None: raise ValueError( "Cannot specify 'categories' when column has categorical dtype" ) categorical = True elif ( pd.api.types.is_object_dtype(gdf[column]) or pd.api.types.is_bool_dtype(gdf[column]) or pd.api.types.is_string_dtype(gdf[column]) or categories ): categorical = True nan_idx = pd.isna(gdf[column]) if categorical: cat = pd.Categorical(gdf[column][~nan_idx], categories=categories) N = len(cat.categories) cmap = cmap if cmap else "tab20" # colormap exists in matplotlib if cmap in plt.colormaps(): color = np.apply_along_axis( colors.to_hex, 1, _colormap_helper(cmap, n_resample=N, idx=cat.codes), ) legend_colors = np.apply_along_axis( colors.to_hex, 1, _colormap_helper(cmap, n_resample=N, idx=range(N)) ) # colormap is matplotlib.Colormap elif isinstance(cmap, colors.Colormap): color = np.apply_along_axis(colors.to_hex, 1, cmap(cat.codes)) legend_colors = np.apply_along_axis(colors.to_hex, 1, cmap(range(N))) # custom list of colors elif pd.api.types.is_list_like(cmap): if N > len(cmap): cmap = cmap * (N // len(cmap) + 1) color = np.take(cmap, cat.codes) legend_colors = np.take(cmap, range(N)) else: raise ValueError( "'cmap' is invalid. For categorical plots, pass either valid " "named matplotlib colormap or a list-like of colors." ) elif callable(cmap): # List of colors based on Branca colormaps or self-defined functions color = list(map(lambda x: cmap(x), df[column])) else: vmin = gdf[column].min() if vmin is None else vmin vmax = gdf[column].max() if vmax is None else vmax # get bins if scheme is not None: if classification_kwds is None: classification_kwds = {} if "k" not in classification_kwds: classification_kwds["k"] = k binning = classify( np.asarray(gdf[column][~nan_idx]), scheme, **classification_kwds ) color = np.apply_along_axis( colors.to_hex, 1, _colormap_helper(cmap, n_resample=k, idx=binning.yb), ) else: bins = np.linspace(vmin, vmax, 257)[1:] binning = classify( np.asarray(gdf[column][~nan_idx]), "UserDefined", bins=bins ) color = np.apply_along_axis( colors.to_hex, 1, _colormap_helper(cmap, n_resample=256, idx=binning.yb), ) # set default style if "fillOpacity" not in style_kwds: style_kwds["fillOpacity"] = 0.5 if "weight" not in style_kwds: style_kwds["weight"] = 2 if "style_function" in style_kwds: style_kwds_function = style_kwds["style_function"] if not callable(style_kwds_function): raise ValueError("'style_function' has to be a callable") style_kwds.pop("style_function") else: def _no_style(x): return {} style_kwds_function = _no_style # specify color if color is not None: if ( isinstance(color, str) and isinstance(gdf, geopandas.GeoDataFrame) and color in gdf.columns ): # use existing column def _style_color(x): base_style = { "fillColor": x["properties"][color], **style_kwds, } return { **base_style, **style_kwds_function(x), } style_function = _style_color else: # assign new column if isinstance(gdf, geopandas.GeoSeries): gdf = geopandas.GeoDataFrame(geometry=gdf) if nan_idx is not None and nan_idx.any(): nan_color = missing_kwds.pop("color", None) gdf["__folium_color"] = nan_color gdf.loc[~nan_idx, "__folium_color"] = color else: gdf["__folium_color"] = color stroke_color = style_kwds.pop("color", None) if not stroke_color: def _style_column(x): base_style = { "fillColor": x["properties"]["__folium_color"], "color": x["properties"]["__folium_color"], **style_kwds, } return { **base_style, **style_kwds_function(x), } style_function = _style_column else: def _style_stroke(x): base_style = { "fillColor": x["properties"]["__folium_color"], "color": stroke_color, **style_kwds, } return { **base_style, **style_kwds_function(x), } style_function = _style_stroke else: # use folium default def _style_default(x): return {**style_kwds, **style_kwds_function(x)} style_function = _style_default if highlight: if "fillOpacity" not in highlight_kwds: highlight_kwds["fillOpacity"] = 0.75 def _style_highlight(x): return {**highlight_kwds} highlight_function = _style_highlight else: highlight_function = None # define default for points if marker_type is None: marker_type = "circle_marker" marker = marker_type if isinstance(marker_type, str): if marker_type == "marker": marker = folium.Marker(**marker_kwds) elif marker_type == "circle": marker = folium.Circle(**marker_kwds) elif marker_type == "circle_marker": marker_kwds["radius"] = marker_kwds.get("radius", 2) marker_kwds["fill"] = marker_kwds.get("fill", True) marker = folium.CircleMarker(**marker_kwds) else: raise ValueError( "Only 'marker', 'circle', and 'circle_marker' are " "supported as marker values" ) # remove additional geometries if isinstance(gdf, geopandas.GeoDataFrame): non_active_geoms = [ name for name, val in (gdf.dtypes == "geometry").items() if val and name != gdf.geometry.name ] gdf = gdf.drop(columns=non_active_geoms) # prepare tooltip and popup if isinstance(gdf, geopandas.GeoDataFrame): # add named index to the tooltip if gdf.index.name is not None: gdf = gdf.reset_index() # specify fields to show in the tooltip tooltip = _tooltip_popup("tooltip", tooltip, gdf, **tooltip_kwds) popup = _tooltip_popup("popup", popup, gdf, **popup_kwds) else: tooltip = None popup = None # add dataframe to map folium.GeoJson( gdf.__geo_interface__, tooltip=tooltip, popup=popup, marker=marker, style_function=style_function, highlight_function=highlight_function, **kwargs, ).add_to(m) if legend: # NOTE: overlaps will be resolved in branca #88 caption = column if not column == "__plottable_column" else "" caption = legend_kwds.pop("caption", caption) if categorical: categories = cat.categories.to_list() legend_colors = legend_colors.tolist() if nan_idx.any() and nan_color: categories.append(missing_kwds.pop("label", "NaN")) legend_colors.append(nan_color) _categorical_legend(m, caption, categories, legend_colors) elif column is not None: cbar = legend_kwds.pop("colorbar", True) colormap_kwds = {} if "max_labels" in legend_kwds: colormap_kwds["max_labels"] = legend_kwds.pop("max_labels") if scheme: cb_colors = np.apply_along_axis( colors.to_hex, 1, _colormap_helper(cmap, n_resample=binning.k, idx=range(binning.k)), ) if cbar: if legend_kwds.pop("scale", True): index = [vmin] + binning.bins.tolist() else: index = None colorbar = bc.colormap.StepColormap( cb_colors, vmin=vmin, vmax=vmax, caption=caption, index=index, **colormap_kwds, ) else: fmt = legend_kwds.pop("fmt", "{:.2f}") if "labels" in legend_kwds: categories = legend_kwds["labels"] else: categories = binning.get_legend_classes(fmt) show_interval = legend_kwds.pop("interval", False) if not show_interval: categories = [c[1:-1] for c in categories] if nan_idx.any() and nan_color: categories.append(missing_kwds.pop("label", "NaN")) cb_colors = np.append(cb_colors, nan_color) _categorical_legend(m, caption, categories, cb_colors) else: if isinstance(cmap, bc.colormap.ColorMap): colorbar = cmap else: mp_cmap = _colormap_helper(cmap) cb_colors = np.apply_along_axis( colors.to_hex, 1, mp_cmap(range(mp_cmap.N)) ) # linear legend if mp_cmap.N > 20: colorbar = bc.colormap.LinearColormap( cb_colors, vmin=vmin, vmax=vmax, caption=caption, **colormap_kwds, ) # steps else: colorbar = bc.colormap.StepColormap( cb_colors, vmin=vmin, vmax=vmax, caption=caption, **colormap_kwds, ) if cbar: if nan_idx.any() and nan_color: _categorical_legend( m, "", [missing_kwds.pop("label", "NaN")], [nan_color] ) m.add_child(colorbar) return m def _tooltip_popup(type, fields, gdf, **kwds): """get tooltip or popup""" import folium # specify fields to show in the tooltip if fields is False or fields is None or fields == 0: return None else: if fields is True: fields = gdf.columns.drop(gdf.geometry.name).to_list() elif isinstance(fields, int): fields = gdf.columns.drop(gdf.geometry.name).to_list()[:fields] elif isinstance(fields, str): fields = [fields] for field in ["__plottable_column", "__folium_color"]: if field in fields: fields.remove(field) # Cast fields to str fields = list(map(str, fields)) if type == "tooltip": return folium.GeoJsonTooltip(fields, **kwds) elif type == "popup": return folium.GeoJsonPopup(fields, **kwds) def _categorical_legend(m, title, categories, colors): """ Add categorical legend to a map The implementation is using the code originally written by Michel Metran (@michelmetran) and released on GitHub (https://github.com/michelmetran/package_folium) under MIT license. Copyright (c) 2020 Michel Metran Parameters ---------- m : folium.Map Existing map instance on which to draw the plot title : str title of the legend (e.g. column name) categories : list-like list of categories colors : list-like list of colors (in the same order as categories) """ # Header to Add head = """ {% macro header(this, kwargs) %} {% endmacro %} """ import branca as bc # Add CSS (on Header) macro = bc.element.MacroElement() macro._template = bc.element.Template(head) m.get_root().add_child(macro) body = f"""