Files
2024-09-29 01:46:07 -04:00

1976 lines
71 KiB
Python

"""
Leaflet GeoJson and miscellaneous features.
"""
import functools
import json
import operator
import warnings
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple, Union
import numpy as np
import requests
from branca.colormap import ColorMap, LinearColormap, StepColormap
from branca.element import Element, Figure, Html, IFrame, JavascriptLink, MacroElement
from branca.utilities import color_brewer
from jinja2 import Template
from folium.elements import JSCSSMixin
from folium.folium import Map
from folium.map import FeatureGroup, Icon, Layer, Marker, Popup, Tooltip
from folium.utilities import (
TypeJsonValue,
TypeLine,
TypePathOptions,
_parse_size,
camelize,
escape_backticks,
get_bounds,
get_obj_in_upper_tree,
image_to_url,
javascript_identifier_path_to_array_notation,
none_max,
none_min,
parse_options,
validate_locations,
)
from folium.vector_layers import Circle, CircleMarker, PolyLine, path_options
class RegularPolygonMarker(JSCSSMixin, Marker):
"""
Custom markers using the Leaflet Data Vis Framework.
Parameters
----------
location: tuple or list
Latitude and Longitude of Marker (Northing, Easting)
number_of_sides: int, default 4
Number of polygon sides
rotation: int, default 0
Rotation angle in degrees
radius: int, default 15
Marker radius, in pixels
popup: string or Popup, optional
Input text or visualization for object displayed when clicking.
tooltip: str or folium.Tooltip, optional
Display a text when hovering over the object.
**kwargs:
See vector layers path_options for additional arguments.
https://humangeo.github.io/leaflet-dvf/
"""
_template = Template(
"""
{% macro script(this, kwargs) %}
var {{ this.get_name() }} = new L.RegularPolygonMarker(
{{ this.location|tojson }},
{{ this.options|tojson }}
).addTo({{ this._parent.get_name() }});
{% endmacro %}
"""
)
default_js = [
(
"dvf_js",
"https://cdnjs.cloudflare.com/ajax/libs/leaflet-dvf/0.3.0/leaflet-dvf.markers.min.js",
),
]
def __init__(
self,
location: Sequence[float],
number_of_sides: int = 4,
rotation: int = 0,
radius: int = 15,
popup: Union[Popup, str, None] = None,
tooltip: Union[Tooltip, str, None] = None,
**kwargs: TypePathOptions,
):
super().__init__(location, popup=popup, tooltip=tooltip)
self._name = "RegularPolygonMarker"
self.options = path_options(line=False, radius=radius, **kwargs)
self.options.update(
parse_options(
number_of_sides=number_of_sides,
rotation=rotation,
)
)
class Vega(JSCSSMixin, Element):
"""
Creates a Vega chart element.
Parameters
----------
data: JSON-like str or object
The Vega description of the chart.
It can also be any object that has a method `to_json`,
so that you can (for instance) provide a `vincent` chart.
width: int or str, default None
The width of the output element.
If None, either data['width'] (if available) or '100%' will be used.
Ex: 120, '120px', '80%'
height: int or str, default None
The height of the output element.
If None, either data['width'] (if available) or '100%' will be used.
Ex: 120, '120px', '80%'
left: int or str, default '0%'
The horizontal distance of the output with respect to the parent
HTML object. Ex: 120, '120px', '80%'
top: int or str, default '0%'
The vertical distance of the output with respect to the parent
HTML object. Ex: 120, '120px', '80%'
position: str, default 'relative'
The `position` argument that the CSS shall contain.
Ex: 'relative', 'absolute'
"""
_template = Template("")
default_js = [
("d3", "https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.5/d3.min.js"),
("vega", "https://cdnjs.cloudflare.com/ajax/libs/vega/1.4.3/vega.min.js"),
("jquery", "https://code.jquery.com/jquery-3.7.1.min.js"),
]
def __init__(
self,
data: Any,
width: Union[int, str, None] = None,
height: Union[int, str, None] = None,
left: Union[int, str] = "0%",
top: Union[int, str] = "0%",
position: str = "relative",
):
super().__init__()
self._name = "Vega"
self.data = data.to_json() if hasattr(data, "to_json") else data
if isinstance(self.data, str):
self.data = json.loads(self.data)
# Size Parameters.
self.width = _parse_size(
self.data.get("width", "100%") if width is None else width
)
self.height = _parse_size(
self.data.get("height", "100%") if height is None else height
)
self.left = _parse_size(left)
self.top = _parse_size(top)
self.position = position
def render(self, **kwargs) -> None:
"""Renders the HTML representation of the element."""
super().render(**kwargs)
self.json = json.dumps(self.data)
self._parent.html.add_child(
Element(
Template(
"""
<div id="{{this.get_name()}}"></div>
"""
).render(this=self, kwargs=kwargs)
),
name=self.get_name(),
)
self._parent.script.add_child(
Element(
Template(
"""
vega_parse({{this.json}},{{this.get_name()}});
"""
).render(this=self)
),
name=self.get_name(),
)
figure = self.get_root()
assert isinstance(
figure, Figure
), "You cannot render this Element if it is not in a Figure."
figure.header.add_child(
Element(
Template(
"""
<style> #{{this.get_name()}} {
position : {{this.position}};
width : {{this.width[0]}}{{this.width[1]}};
height: {{this.height[0]}}{{this.height[1]}};
left: {{this.left[0]}}{{this.left[1]}};
top: {{this.top[0]}}{{this.top[1]}};
</style>
"""
).render(this=self, **kwargs)
),
name=self.get_name(),
)
figure.script.add_child(
Template(
"""function vega_parse(spec, div) {
vg.parse.spec(spec, function(chart) { chart({el:div}).update(); });}"""
), # noqa
name="vega_parse",
)
class VegaLite(Element):
"""
Creates a Vega-Lite chart element.
Parameters
----------
data: JSON-like str or object
The Vega-Lite description of the chart.
It can also be any object that has a method `to_json`,
so that you can (for instance) provide an `Altair` chart.
width: int or str, default None
The width of the output element.
If None, either data['width'] (if available) or '100%' will be used.
Ex: 120, '120px', '80%'
height: int or str, default None
The height of the output element.
If None, either data['width'] (if available) or '100%' will be used.
Ex: 120, '120px', '80%'
left: int or str, default '0%'
The horizontal distance of the output with respect to the parent
HTML object. Ex: 120, '120px', '80%'
top: int or str, default '0%'
The vertical distance of the output with respect to the parent
HTML object. Ex: 120, '120px', '80%'
position: str, default 'relative'
The `position` argument that the CSS shall contain.
Ex: 'relative', 'absolute'
"""
_template = Template("")
def __init__(
self,
data: Any,
width: Union[int, str, None] = None,
height: Union[int, str, None] = None,
left: Union[int, str] = "0%",
top: Union[int, str] = "0%",
position: str = "relative",
):
super(self.__class__, self).__init__()
self._name = "VegaLite"
self.data = data.to_json() if hasattr(data, "to_json") else data
if isinstance(self.data, str):
self.data = json.loads(self.data)
self.json = json.dumps(self.data)
# Size Parameters.
self.width = _parse_size(
self.data.get("width", "100%") if width is None else width
)
self.height = _parse_size(
self.data.get("height", "100%") if height is None else height
)
self.left = _parse_size(left)
self.top = _parse_size(top)
self.position = position
def render(self, **kwargs) -> None:
"""Renders the HTML representation of the element."""
self._parent.html.add_child(
Element(
Template(
"""
<div id="{{this.get_name()}}"></div>
"""
).render(this=self, kwargs=kwargs)
),
name=self.get_name(),
)
figure = self.get_root()
assert isinstance(
figure, Figure
), "You cannot render this Element if it is not in a Figure."
figure.header.add_child(
Element(
Template(
"""
<style> #{{this.get_name()}} {
position : {{this.position}};
width : {{this.width[0]}}{{this.width[1]}};
height: {{this.height[0]}}{{this.height[1]}};
left: {{this.left[0]}}{{this.left[1]}};
top: {{this.top[0]}}{{this.top[1]}};
</style>
"""
).render(this=self, **kwargs)
),
name=self.get_name(),
)
embed_mapping: Dict[Optional[int], Callable] = {
1: self._embed_vegalite_v1,
2: self._embed_vegalite_v2,
3: self._embed_vegalite_v3,
4: self._embed_vegalite_v4,
5: self._embed_vegalite_v5,
}
# Version 2 is assumed as the default, if no version is given in the schema.
embed_vegalite = embed_mapping.get(
self.vegalite_major_version, self._embed_vegalite_v2
)
embed_vegalite(figure)
@property
def vegalite_major_version(self) -> Optional[int]:
if "$schema" not in self.data:
return None
schema = self.data["$schema"]
return int(schema.split("/")[-1].split(".")[0].lstrip("v"))
def _embed_vegalite_v5(self, figure: Figure) -> None:
self._vega_embed()
figure.header.add_child(
JavascriptLink("https://cdn.jsdelivr.net/npm//vega@5"), name="vega"
)
figure.header.add_child(
JavascriptLink("https://cdn.jsdelivr.net/npm/vega-lite@5"), name="vega-lite"
)
figure.header.add_child(
JavascriptLink("https://cdn.jsdelivr.net/npm/vega-embed@6"),
name="vega-embed",
)
def _embed_vegalite_v4(self, figure: Figure) -> None:
self._vega_embed()
figure.header.add_child(
JavascriptLink("https://cdn.jsdelivr.net/npm//vega@5"), name="vega"
)
figure.header.add_child(
JavascriptLink("https://cdn.jsdelivr.net/npm/vega-lite@4"), name="vega-lite"
)
figure.header.add_child(
JavascriptLink("https://cdn.jsdelivr.net/npm/vega-embed@6"),
name="vega-embed",
)
def _embed_vegalite_v3(self, figure: Figure) -> None:
self._vega_embed()
figure.header.add_child(
JavascriptLink("https://cdn.jsdelivr.net/npm/vega@4"), name="vega"
)
figure.header.add_child(
JavascriptLink("https://cdn.jsdelivr.net/npm/vega-lite@3"), name="vega-lite"
)
figure.header.add_child(
JavascriptLink("https://cdn.jsdelivr.net/npm/vega-embed@3"),
name="vega-embed",
)
def _embed_vegalite_v2(self, figure: Figure) -> None:
self._vega_embed()
figure.header.add_child(
JavascriptLink("https://cdn.jsdelivr.net/npm/vega@3"), name="vega"
)
figure.header.add_child(
JavascriptLink("https://cdn.jsdelivr.net/npm/vega-lite@2"), name="vega-lite"
)
figure.header.add_child(
JavascriptLink("https://cdn.jsdelivr.net/npm/vega-embed@3"),
name="vega-embed",
)
def _vega_embed(self) -> None:
self._parent.script.add_child(
Element(
Template(
"""
vegaEmbed({{this.get_name()}}, {{this.json}})
.then(function(result) {})
.catch(console.error);
"""
).render(this=self)
),
name=self.get_name(),
)
def _embed_vegalite_v1(self, figure: Figure) -> None:
self._parent.script.add_child(
Element(
Template(
"""
var embedSpec = {
mode: "vega-lite",
spec: {{this.json}}
};
vg.embed(
{{this.get_name()}}, embedSpec, function(error, result) {}
);
"""
).render(this=self)
),
name=self.get_name(),
)
figure.header.add_child(
JavascriptLink("https://d3js.org/d3.v3.min.js"), name="d3"
)
figure.header.add_child(
JavascriptLink("https://cdnjs.cloudflare.com/ajax/libs/vega/2.6.5/vega.js"),
name="vega",
) # noqa
figure.header.add_child(
JavascriptLink(
"https://cdnjs.cloudflare.com/ajax/libs/vega-lite/1.3.1/vega-lite.js"
),
name="vega-lite",
) # noqa
figure.header.add_child(
JavascriptLink(
"https://cdnjs.cloudflare.com/ajax/libs/vega-embed/2.2.0/vega-embed.js"
),
name="vega-embed",
) # noqa
class GeoJson(Layer):
"""
Creates a GeoJson object for plotting into a Map.
Parameters
----------
data: file, dict or str.
The GeoJSON data you want to plot.
* If file, then data will be read in the file and fully
embedded in Leaflet's JavaScript.
* If dict, then data will be converted to JSON and embedded
in the JavaScript.
* If str, then data will be passed to the JavaScript as-is.
* If `__geo_interface__` is available, the `__geo_interface__`
dictionary will be serialized to JSON and
reprojected if `to_crs` is available.
style_function: function, default None
Function mapping a GeoJson Feature to a style dict.
highlight_function: function, default None
Function mapping a GeoJson Feature to a style dict for mouse events.
popup_keep_highlighted: bool, default False
Whether to keep the highlighting active while the popup is open
name : string, default None
The name of the Layer, as it will appear in LayerControls
overlay : bool, default True
Adds the layer as an optional overlay (True) or the base layer (False).
control : bool, default True
Whether the Layer will be included in LayerControls
show: bool, default True
Whether the layer will be shown on opening.
smooth_factor: float, default None
How much to simplify the polyline on each zoom level. More means
better performance and smoother look, and less means more accurate
representation. Leaflet defaults to 1.0.
tooltip: GeoJsonTooltip, Tooltip or str, default None
Display a text when hovering over the object. Can utilize the data,
see folium.GeoJsonTooltip for info on how to do that.
popup: GeoJsonPopup, optional
Show a different popup for each feature by passing a GeoJsonPopup object.
marker: Circle, CircleMarker or Marker, optional
If your data contains Point geometry, you can format the markers by passing a Circle,
CircleMarker or Marker object with your wanted options. The `style_function` and
`highlight_function` will also target the marker object you passed.
embed: bool, default True
Whether to embed the data in the html file or not. Note that disabling
embedding is only supported if you provide a file link or URL.
zoom_on_click: bool, default False
Set to True to enable zooming in on a geometry when clicking on it.
**kwargs
Keyword arguments are passed to the geoJson object as extra options.
Examples
--------
>>> # Providing filename that shall be embedded.
>>> GeoJson("foo.json")
>>> # Providing filename that shall not be embedded.
>>> GeoJson("foo.json", embed=False)
>>> # Providing dict.
>>> GeoJson(json.load(open("foo.json")))
>>> # Providing string.
>>> GeoJson(open("foo.json").read())
>>> # Provide a style_function that color all states green but Alabama.
>>> style_function = lambda x: {
... "fillColor": (
... "#0000ff" if x["properties"]["name"] == "Alabama" else "#00ff00"
... )
... }
>>> GeoJson(geojson, style_function=style_function)
"""
_template = Template(
"""
{% macro script(this, kwargs) %}
{%- if this.style %}
function {{ this.get_name() }}_styler(feature) {
switch({{ this.feature_identifier }}) {
{%- for style, ids_list in this.style_map.items() if not style == 'default' %}
{% for id_val in ids_list %}case {{ id_val|tojson }}: {% endfor %}
return {{ style }};
{%- endfor %}
default:
return {{ this.style_map['default'] }};
}
}
{%- endif %}
{%- if this.highlight %}
function {{ this.get_name() }}_highlighter(feature) {
switch({{ this.feature_identifier }}) {
{%- for style, ids_list in this.highlight_map.items() if not style == 'default' %}
{% for id_val in ids_list %}case {{ id_val|tojson }}: {% endfor %}
return {{ style }};
{%- endfor %}
default:
return {{ this.highlight_map['default'] }};
}
}
{%- endif %}
{%- if this.marker %}
function {{ this.get_name() }}_pointToLayer(feature, latlng) {
var opts = {{ this.marker.options | tojson | safe }};
{% if this.marker._name == 'Marker' and this.marker.icon %}
const iconOptions = {{ this.marker.icon.options | tojson | safe }}
const iconRootAlias = L{%- if this.marker.icon._name == "Icon" %}.AwesomeMarkers{%- endif %}
opts.icon = new iconRootAlias.{{ this.marker.icon._name }}(iconOptions)
{% endif %}
{%- if this.style_function %}
let style = {{ this.get_name()}}_styler(feature)
Object.assign({%- if this.marker.icon -%}opts.icon.options{%- else -%} opts {%- endif -%}, style)
{% endif %}
return new L.{{this.marker._name}}(latlng, opts)
}
{%- endif %}
function {{this.get_name()}}_onEachFeature(feature, layer) {
layer.on({
{%- if this.highlight %}
mouseout: function(e) {
if(typeof e.target.setStyle === "function"){
{%- if this.popup_keep_highlighted %}
if (!e.target.isPopupOpen())
{%- endif %}
{{ this.get_name() }}.resetStyle(e.target);
}
},
mouseover: function(e) {
if(typeof e.target.setStyle === "function"){
const highlightStyle = {{ this.get_name() }}_highlighter(e.target.feature)
e.target.setStyle(highlightStyle);
}
},
{%- if this.popup_keep_highlighted %}
popupopen: function(e) {
if(typeof e.target.setStyle === "function"){
const highlightStyle = {{ this.get_name() }}_highlighter(e.target.feature)
e.target.setStyle(highlightStyle);
e.target.bindPopup(e.popup)
}
},
popupclose: function(e) {
if(typeof e.target.setStyle === "function"){
{{ this.get_name() }}.resetStyle(e.target);
e.target.unbindPopup()
}
},
{%- endif %}
{%- endif %}
{%- if this.zoom_on_click %}
click: function(e) {
if (typeof e.target.getBounds === 'function') {
{{ this.parent_map.get_name() }}.fitBounds(e.target.getBounds());
}
else if (typeof e.target.getLatLng === 'function'){
let zoom = {{ this.parent_map.get_name() }}.getZoom()
zoom = zoom > 12 ? zoom : zoom + 1
{{ this.parent_map.get_name() }}.flyTo(e.target.getLatLng(), zoom)
}
}
{%- endif %}
});
};
var {{ this.get_name() }} = L.geoJson(null, {
{%- if this.smooth_factor is not none %}
smoothFactor: {{ this.smooth_factor|tojson }},
{%- endif %}
onEachFeature: {{ this.get_name() }}_onEachFeature,
{% if this.style %}
style: {{ this.get_name() }}_styler,
{%- endif %}
{%- if this.marker %}
pointToLayer: {{ this.get_name() }}_pointToLayer,
{%- endif %}
{%- for key, value in this.options.items() %}
{{ key }}: {{ value|tojson }},
{%- endfor %}
});
function {{ this.get_name() }}_add (data) {
{{ this.get_name() }}
.addData(data);
}
{%- if this.embed %}
{{ this.get_name() }}_add({{ this.data|tojson }});
{%- else %}
$.ajax({{ this.embed_link|tojson }}, {dataType: 'json', async: false})
.done({{ this.get_name() }}_add);
{%- endif %}
{%- if not this.style %}
{{this.get_name()}}.setStyle(function(feature) {return feature.properties.style;});
{%- endif %}
{% endmacro %}
"""
) # noqa
def __init__(
self,
data: Any,
style_function: Optional[Callable] = None,
highlight_function: Optional[Callable] = None,
popup_keep_highlighted: bool = False,
name: Optional[str] = None,
overlay: bool = True,
control: bool = True,
show: bool = True,
smooth_factor: Optional[float] = None,
tooltip: Union[str, Tooltip, "GeoJsonTooltip", None] = None,
embed: bool = True,
popup: Optional["GeoJsonPopup"] = None,
zoom_on_click: bool = False,
marker: Union[Circle, CircleMarker, Marker, None] = None,
**kwargs: TypeJsonValue,
):
super().__init__(name=name, overlay=overlay, control=control, show=show)
self._name = "GeoJson"
self.embed = embed
self.embed_link: Optional[str] = None
self.json = None
self.parent_map = None
self.smooth_factor = smooth_factor
self.style = style_function is not None
self.highlight = highlight_function is not None
self.zoom_on_click = zoom_on_click
if marker:
if not isinstance(marker, (Circle, CircleMarker, Marker)):
raise TypeError(
"Only Marker, Circle, and CircleMarker are supported as GeoJson marker types."
)
if popup_keep_highlighted and popup is None:
raise ValueError(
"A popup is needed to use the popup_keep_highlighted feature"
)
self.popup_keep_highlighted = popup_keep_highlighted
self.marker = marker
self.options = parse_options(**kwargs)
self.data = self.process_data(data)
if self.style or self.highlight:
self.convert_to_feature_collection()
if style_function is not None:
self._validate_function(style_function, "style_function")
self.style_function = style_function
self.style_map: dict = {}
if highlight_function is not None:
self._validate_function(highlight_function, "highlight_function")
self.highlight_function = highlight_function
self.highlight_map: dict = {}
self.feature_identifier = self.find_identifier()
if isinstance(tooltip, (GeoJsonTooltip, Tooltip)):
self.add_child(tooltip)
elif tooltip is not None:
self.add_child(Tooltip(tooltip))
if isinstance(popup, (GeoJsonPopup, Popup)):
self.add_child(popup)
def process_data(self, data: Any) -> dict:
"""Convert an unknown data input into a geojson dictionary."""
if isinstance(data, dict):
self.embed = True
return data
elif isinstance(data, str):
if data.lower().startswith(("http:", "ftp:", "https:")):
if not self.embed:
self.embed_link = data
return self.get_geojson_from_web(data)
elif data.lstrip()[0] in "[{": # This is a GeoJSON inline string
self.embed = True
return json.loads(data)
else: # This is a filename
if not self.embed:
self.embed_link = data
with open(data) as f:
return json.loads(f.read())
elif hasattr(data, "__geo_interface__"):
self.embed = True
if hasattr(data, "to_crs"):
data = data.to_crs("EPSG:4326")
return json.loads(json.dumps(data.__geo_interface__))
else:
raise ValueError(
"Cannot render objects with any missing geometries" f": {data!r}"
)
def get_geojson_from_web(self, url: str) -> dict:
return requests.get(url).json()
def convert_to_feature_collection(self) -> None:
"""Convert data into a FeatureCollection if it is not already."""
if self.data["type"] == "FeatureCollection":
return
if not self.embed:
raise ValueError(
"Data is not a FeatureCollection, but it should be to apply "
"style or highlight. Because `embed=False` it cannot be "
"converted into one.\nEither change your geojson data to a "
"FeatureCollection, set `embed=True` or disable styling."
)
# Catch case when GeoJSON is just a single Feature or a geometry.
if "geometry" not in self.data.keys():
# Catch case when GeoJSON is just a geometry.
self.data = {"type": "Feature", "geometry": self.data}
self.data = {"type": "FeatureCollection", "features": [self.data]}
def _validate_function(self, func: Callable, name: str) -> None:
"""
Tests `self.style_function` and `self.highlight_function` to ensure
they are functions returning dictionaries.
"""
# If for some reason there are no features (e.g., empty API response)
# don't attempt validation
if not self.data["features"]:
return
test_feature = self.data["features"][0]
if not callable(func) or not isinstance(func(test_feature), dict):
raise ValueError(
f"{name} should be a function that accepts items from "
"data['features'] and returns a dictionary."
)
def find_identifier(self) -> str:
"""Find a unique identifier for each feature, create it if needed.
According to the GeoJSON specs a feature:
- MAY have an 'id' field with a string or numerical value.
- MUST have a 'properties' field. The content can be any json object
or even null.
"""
feats = self.data["features"]
# Each feature has an 'id' field with a unique value.
unique_ids = {feat.get("id", None) for feat in feats}
if None not in unique_ids and len(unique_ids) == len(feats):
return "feature.id"
# Each feature has a unique string or int property.
if all(isinstance(feat.get("properties", None), dict) for feat in feats):
for key in feats[0]["properties"]:
unique_values = {
feat["properties"].get(key, None)
for feat in feats
if isinstance(feat["properties"].get(key, None), (str, int))
}
if len(unique_values) == len(feats):
return f"feature.properties.{key}"
# We add an 'id' field with a unique value to the data.
if self.embed:
for i, feature in enumerate(feats):
feature["id"] = str(i)
return "feature.id"
raise ValueError(
"There is no unique identifier for each feature and because "
"`embed=False` it cannot be added. Consider adding an `id` "
"field to your geojson data or set `embed=True`. "
)
def _get_self_bounds(self) -> List[List[Optional[float]]]:
"""
Computes the bounds of the object itself (not including it's children)
in the form [[lat_min, lon_min], [lat_max, lon_max]].
"""
return get_bounds(self.data, lonlat=True)
def render(self, **kwargs) -> None:
self.parent_map = get_obj_in_upper_tree(self, Map)
# Need at least one feature, otherwise style mapping fails
if (self.style or self.highlight) and self.data["features"]:
mapper = GeoJsonStyleMapper(self.data, self.feature_identifier, self)
if self.style:
self.style_map = mapper.get_style_map(self.style_function)
if self.highlight:
self.highlight_map = mapper.get_highlight_map(self.highlight_function)
super().render()
TypeStyleMapping = Dict[str, Union[str, List[Union[str, int]]]]
class GeoJsonStyleMapper:
"""Create dicts that map styling to GeoJson features.
:meta private:
"""
def __init__(
self,
data: dict,
feature_identifier: str,
geojson_obj: GeoJson,
):
self.data = data
self.feature_identifier = feature_identifier
self.geojson_obj = geojson_obj
def get_style_map(self, style_function: Callable) -> TypeStyleMapping:
"""Return a dict that maps style parameters to features."""
return self._create_mapping(style_function, "style")
def get_highlight_map(self, highlight_function: Callable) -> TypeStyleMapping:
"""Return a dict that maps highlight parameters to features."""
return self._create_mapping(highlight_function, "highlight")
def _create_mapping(self, func: Callable, switch: str) -> TypeStyleMapping:
"""Internal function to create the mapping."""
mapping: TypeStyleMapping = {}
for feature in self.data["features"]:
content = func(feature)
if switch == "style":
for key, value in content.items():
if isinstance(value, MacroElement):
# Make sure objects are rendered:
if value._parent is None:
value._parent = self.geojson_obj
value.render()
# Replace objects with their Javascript var names:
content[key] = "{{'" + value.get_name() + "'}}"
key = self._to_key(content)
feature_id = self.get_feature_id(feature)
mapping.setdefault(key, []).append(feature_id) # type: ignore
self._set_default_key(mapping)
return mapping
def get_feature_id(self, feature: dict) -> Union[str, int]:
"""Return a value identifying the feature."""
fields = self.feature_identifier.split(".")[1:]
value = functools.reduce(operator.getitem, fields, feature)
assert isinstance(value, (str, int))
return value
@staticmethod
def _to_key(d: dict) -> str:
"""Convert dict to str and enable Jinja2 template syntax."""
as_str = json.dumps(d, sort_keys=True)
return as_str.replace('"{{', "{{").replace('}}"', "}}")
@staticmethod
def _set_default_key(mapping: TypeStyleMapping) -> None:
"""Replace the field with the most features with a 'default' field."""
key_longest = max(mapping, key=mapping.get) # type: ignore
mapping["default"] = key_longest
del mapping[key_longest]
class TopoJson(JSCSSMixin, Layer):
"""
Creates a TopoJson object for plotting into a Map.
Parameters
----------
data: file, dict or str.
The TopoJSON data you want to plot.
* If file, then data will be read in the file and fully
embedded in Leaflet's JavaScript.
* If dict, then data will be converted to JSON and embedded
in the JavaScript.
* If str, then data will be passed to the JavaScript as-is.
object_path: str
The path of the desired object into the TopoJson structure.
Ex: 'objects.myobject'.
style_function: function, default None
A function mapping a TopoJson geometry to a style dict.
name : string, default None
The name of the Layer, as it will appear in LayerControls
overlay : bool, default False
Adds the layer as an optional overlay (True) or the base layer (False).
control : bool, default True
Whether the Layer will be included in LayerControls.
show: bool, default True
Whether the layer will be shown on opening.
smooth_factor: float, default None
How much to simplify the polyline on each zoom level. More means
better performance and smoother look, and less means more accurate
representation. Leaflet defaults to 1.0.
tooltip: GeoJsonTooltip, Tooltip or str, default None
Display a text when hovering over the object. Can utilize the data,
see folium.GeoJsonTooltip for info on how to do that.
Examples
--------
>>> # Providing file that shall be embedded.
>>> TopoJson(open("foo.json"), "object.myobject")
>>> # Providing filename that shall not be embedded.
>>> TopoJson("foo.json", "object.myobject")
>>> # Providing dict.
>>> TopoJson(json.load(open("foo.json")), "object.myobject")
>>> # Providing string.
>>> TopoJson(open("foo.json").read(), "object.myobject")
>>> # Provide a style_function that color all states green but Alabama.
>>> style_function = lambda x: {
... "fillColor": (
... "#0000ff" if x["properties"]["name"] == "Alabama" else "#00ff00"
... )
... }
>>> TopoJson(topo_json, "object.myobject", style_function=style_function)
"""
_template = Template(
"""
{% macro script(this, kwargs) %}
var {{ this.get_name() }}_data = {{ this.data|tojson }};
var {{ this.get_name() }} = L.geoJson(
topojson.feature(
{{ this.get_name() }}_data,
{{ this.get_name() }}_data{{ this._safe_object_path }}
),
{
{%- if this.smooth_factor is not none %}
smoothFactor: {{ this.smooth_factor|tojson }},
{%- endif %}
}
).addTo({{ this._parent.get_name() }});
{{ this.get_name() }}.setStyle(function(feature) {
return feature.properties.style;
});
{% endmacro %}
"""
) # noqa
default_js = [
(
"topojson",
"https://cdnjs.cloudflare.com/ajax/libs/topojson/1.6.9/topojson.min.js",
),
]
def __init__(
self,
data: Any,
object_path: str,
style_function: Optional[Callable] = None,
name: Optional[str] = None,
overlay: bool = True,
control: bool = True,
show: bool = True,
smooth_factor: Optional[float] = None,
tooltip: Union[str, Tooltip, None] = None,
):
super().__init__(name=name, overlay=overlay, control=control, show=show)
self._name = "TopoJson"
if "read" in dir(data):
self.embed = True
self.data = json.load(data)
elif type(data) is dict:
self.embed = True
self.data = data
else:
self.embed = False
self.data = data
self.object_path = object_path
self._safe_object_path = javascript_identifier_path_to_array_notation(
object_path
)
self.style_function = style_function or (lambda x: {})
self.smooth_factor = smooth_factor
if isinstance(tooltip, (GeoJsonTooltip, Tooltip)):
self.add_child(tooltip)
elif tooltip is not None:
self.add_child(Tooltip(tooltip))
def style_data(self) -> None:
"""Applies self.style_function to each feature of self.data."""
def recursive_get(data, keys):
if len(keys):
return recursive_get(data.get(keys[0]), keys[1:])
else:
return data
geometries = recursive_get(self.data, self.object_path.split("."))[
"geometries"
] # noqa
for feature in geometries:
feature.setdefault("properties", {}).setdefault("style", {}).update(
self.style_function(feature)
) # noqa
def render(self, **kwargs) -> None:
"""Renders the HTML representation of the element."""
self.style_data()
super().render(**kwargs)
def get_bounds(self) -> List[List[float]]:
"""
Computes the bounds of the object itself (not including it's children)
in the form [[lat_min, lon_min], [lat_max, lon_max]]
"""
if not self.embed:
raise ValueError("Cannot compute bounds of non-embedded TopoJSON.")
xmin, xmax, ymin, ymax = None, None, None, None
for arc in self.data["arcs"]:
x, y = 0, 0
for dx, dy in arc:
x += dx
y += dy
xmin = none_min(x, xmin)
xmax = none_max(x, xmax)
ymin = none_min(y, ymin)
ymax = none_max(y, ymax)
return [
[
self.data["transform"]["translate"][1]
+ self.data["transform"]["scale"][1] * ymin, # noqa
self.data["transform"]["translate"][0]
+ self.data["transform"]["scale"][0] * xmin, # noqa
],
[
self.data["transform"]["translate"][1]
+ self.data["transform"]["scale"][1] * ymax, # noqa
self.data["transform"]["translate"][0]
+ self.data["transform"]["scale"][0] * xmax, # noqa
],
]
class GeoJsonDetail(MacroElement):
"""Base class for GeoJsonTooltip and GeoJsonPopup.
:meta private:
"""
base_template = """
function(layer){
let div = L.DomUtil.create('div');
{% if this.fields %}
let handleObject = feature=>typeof(feature)=='object' ? JSON.stringify(feature) : feature;
let fields = {{ this.fields | tojson | safe }};
let aliases = {{ this.aliases | tojson | safe }};
let table = '<table>' +
String(
fields.map(
(v,i)=>
`<tr>{% if this.labels %}
<th>${aliases[i]{% if this.localize %}.toLocaleString(){% endif %}}</th>
{% endif %}
<td>${handleObject(layer.feature.properties[v]){% if this.localize %}.toLocaleString(){% endif %}}</td>
</tr>`).join(''))
+'</table>';
div.innerHTML=table;
{% endif %}
return div
}
"""
def __init__(
self,
fields: Sequence[str],
aliases: Optional[Sequence[str]] = None,
labels: bool = True,
localize: bool = False,
style: Optional[str] = None,
class_name: str = "geojsondetail",
):
super().__init__()
assert isinstance(
fields, (list, tuple)
), "Please pass a list or tuple to fields."
if aliases is not None:
assert isinstance(aliases, (list, tuple))
assert len(fields) == len(
aliases
), "fields and aliases must have the same length."
assert isinstance(labels, bool), "labels requires a boolean value."
assert isinstance(localize, bool), "localize must be bool."
self._name = "GeoJsonDetail"
self.fields = fields
self.aliases = aliases if aliases is not None else fields
self.labels = labels
self.localize = localize
self.class_name = class_name
if style:
assert isinstance(
style, str
), "Pass a valid inline HTML style property string to style."
# noqa outside of type checking.
self.style = style
def warn_for_geometry_collections(self) -> None:
"""Checks for GeoJson GeometryCollection features to warn user about incompatibility."""
geom_collections = [
feature.get("properties") if feature.get("properties") is not None else key
for key, feature in enumerate(self._parent.data["features"])
if feature["geometry"]
and feature["geometry"]["type"] == "GeometryCollection"
]
if any(geom_collections):
warnings.warn(
f"{self._name} is not configured to render for GeoJson GeometryCollection geometries. "
f"Please consider reworking these features: {geom_collections} to MultiPolygon for full functionality.\n"
"https://tools.ietf.org/html/rfc7946#page-9",
UserWarning,
)
def render(self, **kwargs) -> None:
"""Renders the HTML representation of the element."""
figure = self.get_root()
if isinstance(self._parent, GeoJson):
keys = tuple(
self._parent.data["features"][0]["properties"].keys()
if self._parent.data["features"]
else []
)
self.warn_for_geometry_collections()
elif isinstance(self._parent, TopoJson):
obj_name = self._parent.object_path.split(".")[-1]
keys = tuple(
self._parent.data["objects"][obj_name]["geometries"][0][
"properties"
].keys()
)
else:
raise TypeError(
f"You cannot add a {self._name} to anything other than a "
"GeoJson or TopoJson object."
)
keys = tuple(x for x in keys if x not in ("style", "highlight"))
for value in self.fields:
assert (
value in keys
), f"The field {value} is not available in the data. Choose from: {keys}."
figure.header.add_child(
Element(
Template(
"""
<style>
.{{ this.class_name }} {
{{ this.style }}
}
.{{ this.class_name }} table{
margin: auto;
}
.{{ this.class_name }} tr{
text-align: left;
}
.{{ this.class_name }} th{
padding: 2px; padding-right: 8px;
}
</style>
"""
).render(this=self)
),
name=self.get_name() + "tablestyle",
)
super().render()
class GeoJsonTooltip(GeoJsonDetail):
"""
Create a tooltip that uses data from either geojson or topojson.
Parameters
----------
fields: list or tuple.
Labels of GeoJson/TopoJson 'properties' or GeoPandas GeoDataFrame
columns you'd like to display.
aliases: list/tuple of strings, same length/order as fields, default None.
Optional aliases you'd like to display in the tooltip as field name
instead of the keys of `fields`.
labels: bool, default True.
Set to False to disable displaying the field names or aliases.
localize: bool, default False.
This will use JavaScript's .toLocaleString() to format 'clean' values
as strings for the user's location; i.e. 1,000,000.00 comma separators,
float truncation, etc.
Available for most of JavaScript's primitive types (any data you'll
serve into the template).
style: str, default None.
HTML inline style properties like font and colors. Will be applied to
a div with the text in it.
sticky: bool, default True
Whether the tooltip should follow the mouse.
**kwargs: Assorted.
These values will map directly to the Leaflet Options. More info
available here: https://leafletjs.com/reference.html#tooltip
Examples
--------
# Provide fields and aliases, with Style.
>>> GeoJsonTooltip(
... fields=["CNTY_NM", "census-pop-2015", "census-md-income-2015"],
... aliases=["County", "2015 Census Population", "2015 Median Income"],
... localize=True,
... style=(
... "background-color: grey; color: white; font-family:"
... "courier new; font-size: 24px; padding: 10px;"
... ),
... )
# Provide fields, with labels off and fixed tooltip positions.
>>> GeoJsonTooltip(fields=("CNTY_NM",), labels=False, sticky=False)
"""
_template = Template(
"""
{% macro script(this, kwargs) %}
{{ this._parent.get_name() }}.bindTooltip("""
+ GeoJsonDetail.base_template
+ """,{{ this.tooltip_options | tojson | safe }});
{% endmacro %}
"""
)
def __init__(
self,
fields: Sequence[str],
aliases: Optional[Sequence[str]] = None,
labels: bool = True,
localize: bool = False,
style: Optional[str] = None,
class_name: str = "foliumtooltip",
sticky: bool = True,
**kwargs: TypeJsonValue,
):
super().__init__(
fields=fields,
aliases=aliases,
labels=labels,
localize=localize,
style=style,
class_name=class_name,
)
self._name = "GeoJsonTooltip"
kwargs.update({"sticky": sticky, "class_name": class_name})
self.tooltip_options = {camelize(key): kwargs[key] for key in kwargs.keys()}
class GeoJsonPopup(GeoJsonDetail):
"""
Create a popup feature to bind to each element of a GeoJson layer based on
its attributes.
Parameters
----------
fields: list or tuple.
Labels of GeoJson/TopoJson 'properties' or GeoPandas GeoDataFrame
columns you'd like to display.
aliases: list/tuple of strings, same length/order as fields, default None.
Optional aliases you'd like to display in the tooltip as field name
instead of the keys of `fields`.
labels: bool, default True.
Set to False to disable displaying the field names or aliases.
localize: bool, default False.
This will use JavaScript's .toLocaleString() to format 'clean' values
as strings for the user's location; i.e. 1,000,000.00 comma separators,
float truncation, etc.
Available for most of JavaScript's primitive types (any data you'll
serve into the template).
style: str, default None.
HTML inline style properties like font and colors. Will be applied to
a div with the text in it.
Examples
---
gjson = folium.GeoJson(gdf).add_to(m)
folium.features.GeoJsonPopup(fields=['NAME'],
labels=False
).add_to(gjson)
"""
_template = Template(
"""
{% macro script(this, kwargs) %}
{{ this._parent.get_name() }}.bindPopup("""
+ GeoJsonDetail.base_template
+ """,{{ this.popup_options | tojson | safe }});
{% endmacro %}
"""
)
def __init__(
self,
fields: Sequence[str],
aliases: Optional[Sequence[str]] = None,
labels: bool = True,
style: str = "margin: auto;",
class_name: str = "foliumpopup",
localize: bool = True,
**kwargs: TypeJsonValue,
):
super().__init__(
fields=fields,
aliases=aliases,
labels=labels,
localize=localize,
class_name=class_name,
style=style,
)
self._name = "GeoJsonPopup"
kwargs.update({"class_name": self.class_name})
self.popup_options = {camelize(key): value for key, value in kwargs.items()}
class Choropleth(FeatureGroup):
"""Apply a GeoJSON overlay to the map.
Plot a GeoJSON overlay on the base map. There is no requirement
to bind data (passing just a GeoJSON plots a single-color overlay),
but there is a data binding option to map your columnar data to
different feature objects with a color scale.
If data is passed as a Pandas DataFrame, the "columns" and "key-on"
keywords must be included, the first to indicate which DataFrame
columns to use, the second to indicate the layer in the GeoJSON
on which to key the data. The 'columns' keyword does not need to be
passed for a Pandas series.
Colors are generated from color brewer (https://colorbrewer2.org/)
sequential palettes. By default, linear binning is used between
the min and the max of the values. Custom binning can be achieved
with the `bins` parameter.
TopoJSONs can be passed as "geo_data", but the "topojson" keyword must
also be passed with the reference to the topojson objects to convert.
See the topojson.feature method in the TopoJSON API reference:
https://github.com/topojson/topojson/wiki/API-Reference
Parameters
----------
geo_data: string/object
URL, file path, or data (json, dict, geopandas, etc) to your GeoJSON
geometries
data: Pandas DataFrame or Series, default None
Data to bind to the GeoJSON.
columns: tuple with two values, default None
If the data is a Pandas DataFrame, the columns of data to be bound.
Must pass column 1 as the key, and column 2 the values.
key_on: string, default None
Variable in the `geo_data` GeoJSON file to bind the data to. Must
start with 'feature' and be in JavaScript objection notation.
Ex: 'feature.id' or 'feature.properties.statename'.
bins: int or sequence of scalars or str, default 6
If `bins` is an int, it defines the number of equal-width
bins between the min and the max of the values.
If `bins` is a sequence, it directly defines the bin edges.
For more information on this parameter, have a look at
numpy.histogram function.
fill_color: string, optional
Area fill color, defaults to blue. Can pass a hex code, color name,
or if you are binding data, one of the following color brewer palettes:
'BuGn', 'BuPu', 'GnBu', 'OrRd', 'PuBu', 'PuBuGn', 'PuRd', 'RdPu',
'YlGn', 'YlGnBu', 'YlOrBr', and 'YlOrRd'.
nan_fill_color: string, default 'black'
Area fill color for nan or missing values.
Can pass a hex code, color name.
fill_opacity: float, default 0.6
Area fill opacity, range 0-1.
nan_fill_opacity: float, default fill_opacity
Area fill opacity for nan or missing values, 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 empty string
Title for data legend.
topojson: string, default None
If using a TopoJSON, passing "objects.yourfeature" to the topojson
keyword argument will enable conversion to GeoJSON.
smooth_factor: float, default None
How much to simplify the polyline on each zoom level. More means
better performance and smoother look, and less means more accurate
representation. Leaflet defaults to 1.0.
highlight: boolean, default False
Enable highlight functionality when hovering over a GeoJSON area.
use_jenks: bool, default False
Use jenkspy to calculate bins using "natural breaks"
(Fisher-Jenks algorithm). This is useful when your data is unevenly
distributed.
name : string, optional
The name of the layer, as it will appear in LayerControls
overlay : bool, default True
Adds the layer as an optional overlay (True) or the base layer (False).
control : bool, default True
Whether the Layer will be included in LayerControls.
show: bool, default True
Whether the layer will be shown on opening.
Returns
-------
GeoJSON data layer in obj.template_vars
Examples
--------
>>> Choropleth(geo_data="us-states.json", line_color="blue", line_weight=3)
>>> Choropleth(
... geo_data="geo.json",
... data=df,
... columns=["Data 1", "Data 2"],
... key_on="feature.properties.myvalue",
... fill_color="PuBu",
... bins=[0, 20, 30, 40, 50, 60],
... )
>>> Choropleth(geo_data="countries.json", topojson="objects.countries")
>>> Choropleth(
... geo_data="geo.json",
... data=df,
... columns=["Data 1", "Data 2"],
... key_on="feature.properties.myvalue",
... fill_color="PuBu",
... bins=[0, 20, 30, 40, 50, 60],
... highlight=True,
... )
"""
def __init__(
self,
geo_data: Any,
data: Optional[Any] = None,
columns: Optional[Sequence[Any]] = None,
key_on: Optional[str] = None,
bins: Union[int, Sequence[float]] = 6,
fill_color: Optional[str] = None,
nan_fill_color: str = "black",
fill_opacity: float = 0.6,
nan_fill_opacity: Optional[float] = None,
line_color: str = "black",
line_weight: float = 1,
line_opacity: float = 1,
name: Optional[str] = None,
legend_name: str = "",
overlay: bool = True,
control: bool = True,
show: bool = True,
topojson: Optional[str] = None,
smooth_factor: Optional[float] = None,
highlight: bool = False,
use_jenks: bool = False,
**kwargs,
):
super().__init__(name=name, overlay=overlay, control=control, show=show)
self._name = "Choropleth"
fill_color = fill_color or ("blue" if data is None else "Blues")
if data is not None and not color_brewer(fill_color):
raise ValueError(
"Please pass a valid color brewer code to "
"fill_local. See docstring for valid codes."
)
if nan_fill_opacity is None:
nan_fill_opacity = fill_opacity
if "threshold_scale" in kwargs:
if kwargs["threshold_scale"] is not None:
bins = kwargs["threshold_scale"]
warnings.warn(
"choropleth `threshold_scale` parameter is now depreciated "
"in favor of the `bins` parameter.",
DeprecationWarning,
)
# Create color_data dict
if hasattr(data, "set_index"):
# This is a pd.DataFrame
assert columns is not None
color_data = data.set_index(columns[0])[columns[1]].to_dict() # type: ignore
elif hasattr(data, "to_dict"):
# This is a pd.Series
color_data = data.to_dict() # type: ignore
elif data:
color_data = dict(data)
else:
color_data = None
self.color_scale = None
if color_data is not None and key_on is not None:
real_values = np.array(list(color_data.values()))
real_values = real_values[~np.isnan(real_values)]
if use_jenks:
from jenkspy import jenks_breaks
if not isinstance(bins, int):
raise ValueError(
f"bins value must be an integer when using Jenks."
f' Invalid value "{bins}" received.'
)
bin_edges = np.array(jenks_breaks(real_values, bins), dtype=float)
else:
_, bin_edges = np.histogram(real_values, bins=bins)
bins_min, bins_max = min(bin_edges), max(bin_edges)
if np.any((real_values < bins_min) | (real_values > bins_max)):
raise ValueError(
"All values are expected to fall into one of the provided "
"bins (or to be Nan). Please check the `bins` parameter "
"and/or your data."
)
# We add the colorscale
nb_bins = len(bin_edges) - 1
color_range = color_brewer(fill_color, n=nb_bins)
self.color_scale = StepColormap(
color_range,
index=bin_edges,
vmin=bins_min,
vmax=bins_max,
caption=legend_name,
)
# then we 'correct' the last edge for numpy digitize
# (we add a very small amount to fake an inclusive right interval)
increasing = bin_edges[0] <= bin_edges[-1]
bin_edges = bin_edges.astype(float)
bin_edges[-1] = np.nextafter(
bin_edges[-1], (1 if increasing else -1) * np.inf
)
key_on = key_on[8:] if key_on.startswith("feature.") else key_on
def color_scale_fun(x):
key_of_x = self._get_by_key(x, key_on)
if key_of_x is None:
raise ValueError(f"key_on `{key_on!r}` not found in GeoJSON.")
try:
value_of_x = color_data[key_of_x]
except KeyError:
try:
# try again but match str to int and vice versa
if isinstance(key_of_x, int):
value_of_x = color_data[str(key_of_x)]
elif isinstance(key_of_x, str):
value_of_x = color_data[int(key_of_x)]
else:
return nan_fill_color, nan_fill_opacity
except (KeyError, ValueError):
return nan_fill_color, nan_fill_opacity
if np.isnan(value_of_x):
return nan_fill_color, nan_fill_opacity
color_idx = np.digitize(value_of_x, bin_edges, right=False) - 1
return color_range[color_idx], fill_opacity
else:
def color_scale_fun(x):
return fill_color, fill_opacity
def style_function(x):
color, opacity = color_scale_fun(x)
return {
"weight": line_weight,
"opacity": line_opacity,
"color": line_color,
"fillOpacity": opacity,
"fillColor": color,
}
def highlight_function(x):
return {"weight": line_weight + 2, "fillOpacity": fill_opacity + 0.2}
if topojson:
self.geojson = TopoJson(
geo_data,
topojson,
style_function=style_function,
smooth_factor=smooth_factor,
)
else:
self.geojson = GeoJson(
geo_data,
style_function=style_function,
smooth_factor=smooth_factor,
highlight_function=highlight_function if highlight else None,
)
self.add_child(self.geojson)
if self.color_scale:
self.add_child(self.color_scale)
@classmethod
def _get_by_key(cls, obj: Union[dict, list], key: str) -> Union[float, str, None]:
key_parts = key.split(".")
first_key_part = key_parts[0]
if first_key_part.isdigit():
value = obj[int(first_key_part)]
else:
value = obj.get(first_key_part, None) # type: ignore
if len(key_parts) > 1:
new_key = ".".join(key_parts[1:])
return cls._get_by_key(value, new_key)
else:
return value
def render(self, **kwargs) -> None:
"""Render the GeoJson/TopoJson and color scale objects."""
if self.color_scale:
# ColorMap needs Map as its parent
assert isinstance(
self._parent, Map
), "Choropleth must be added to a Map object."
self.color_scale._parent = self._parent
super().render(**kwargs)
class DivIcon(MacroElement):
"""
Represents a lightweight icon for markers that uses a simple `div`
element instead of an image.
Parameters
----------
icon_size : tuple of 2 int
Size of the icon image in pixels.
icon_anchor : tuple of 2 int
The coordinates of the "tip" of the icon
(relative to its top left corner).
The icon will be aligned so that this point is at the
marker's geographical location.
popup_anchor : tuple of 2 int
The coordinates of the point from which popups will "open",
relative to the icon anchor.
class_name : string
A custom class name to assign to the icon.
Leaflet defaults is 'leaflet-div-icon' which draws a little white
square with a shadow. We set it 'empty' in folium.
html : string
A custom HTML code to put inside the div element.
See https://leafletjs.com/reference.html#divicon
"""
_template = Template(
"""
{% macro script(this, kwargs) %}
var {{ this.get_name() }} = L.divIcon({{ this.options|tojson }});
{{this._parent.get_name()}}.setIcon({{this.get_name()}});
{% endmacro %}
"""
) # noqa
def __init__(
self,
html: Optional[str] = None,
icon_size: Optional[Tuple[int, int]] = None,
icon_anchor: Optional[Tuple[int, int]] = None,
popup_anchor: Optional[Tuple[int, int]] = None,
class_name: str = "empty",
):
super().__init__()
self._name = "DivIcon"
self.options = parse_options(
html=html,
icon_size=icon_size,
icon_anchor=icon_anchor,
popup_anchor=popup_anchor,
class_name=class_name,
)
class LatLngPopup(MacroElement):
"""
When one clicks on a Map that contains a LatLngPopup,
a popup is shown that displays the latitude and longitude of the pointer.
"""
_template = Template(
"""
{% macro script(this, kwargs) %}
var {{this.get_name()}} = L.popup();
function latLngPop(e) {
{{this.get_name()}}
.setLatLng(e.latlng)
.setContent("Latitude: " + e.latlng.lat.toFixed(4) +
"<br>Longitude: " + e.latlng.lng.toFixed(4))
.openOn({{this._parent.get_name()}});
}
{{this._parent.get_name()}}.on('click', latLngPop);
{% endmacro %}
"""
) # noqa
def __init__(self):
super().__init__()
self._name = "LatLngPopup"
class ClickForMarker(MacroElement):
"""
When one clicks on a Map that contains a ClickForMarker,
a Marker is created at the pointer's position.
Parameters
----------
popup: str or IFrame or Html, default None
Text to display in the markers' popups.
This can also be an Element like IFrame or Html.
If None, the popups will display the marker's latitude and longitude.
You can include the latitude and longitude with ${lat} and ${lng}.
Examples
--------
>>> ClickForMarker("<b>Lat:</b> ${lat}<br /><b>Lon:</b> ${lng}")
"""
_template = Template(
"""
{% macro script(this, kwargs) %}
function newMarker(e){
var new_mark = L.marker().setLatLng(e.latlng).addTo({{this._parent.get_name()}});
new_mark.dragging.enable();
new_mark.on('dblclick', function(e){ {{this._parent.get_name()}}.removeLayer(e.target)})
var lat = e.latlng.lat.toFixed(4),
lng = e.latlng.lng.toFixed(4);
new_mark.bindPopup({{ this.popup }});
};
{{this._parent.get_name()}}.on('click', newMarker);
{% endmacro %}
"""
) # noqa
def __init__(self, popup: Union[IFrame, Html, str, None] = None):
super().__init__()
self._name = "ClickForMarker"
if isinstance(popup, Element):
popup = popup.render()
if popup:
self.popup = "`" + escape_backticks(popup) + "`"
else:
self.popup = '"Latitude: " + lat + "<br>Longitude: " + lng '
class ClickForLatLng(MacroElement):
"""
When one clicks on a Map that contains a ClickForLatLng,
the coordinates of the pointer's position are copied to clipboard.
Parameters
==========
format_str : str, default 'lat + "," + lng'
The javascript string used to format the text copied to clipboard.
eg:
format_str = 'lat + "," + lng' >> 46.558860,3.397397
format_str = '"[" + lat + "," + lng + "]"' >> [46.558860,3.397397]
alert : bool, default True
Whether there should be an alert when something has been copied to clipboard.
"""
_template = Template(
"""
{% macro script(this, kwargs) %}
function getLatLng(e){
var lat = e.latlng.lat.toFixed(6),
lng = e.latlng.lng.toFixed(6);
var txt = {{this.format_str}};
navigator.clipboard.writeText(txt);
{% if this.alert %}alert("Copied to clipboard : \\n " + txt);{% endif %}
};
{{this._parent.get_name()}}.on('click', getLatLng);
{% endmacro %}
"""
) # noqa
def __init__(self, format_str: Optional[str] = None, alert: bool = True):
super().__init__()
self._name = "ClickForLatLng"
self.format_str = format_str or 'lat + "," + lng'
self.alert = alert
class CustomIcon(Icon):
"""
Create a custom icon, based on an image.
Parameters
----------
icon_image : string, file or array-like object
The data you want to use as an icon.
* If string, it will be written directly in the output file.
* If file, it's content will be converted as embedded in the
output file.
* If array-like, it will be converted to PNG base64 string
and embedded in the output.
icon_size : tuple of 2 int, optional
Size of the icon image in pixels.
icon_anchor : tuple of 2 int, optional
The coordinates of the "tip" of the icon
(relative to its top left corner).
The icon will be aligned so that this point is at the
marker's geographical location.
shadow_image : string, file or array-like object, optional
The data for the shadow image. If not specified,
no shadow image will be created.
shadow_size : tuple of 2 int, optional
Size of the shadow image in pixels.
shadow_anchor : tuple of 2 int, optional
The coordinates of the "tip" of the shadow relative to its
top left corner (the same as icon_anchor if not specified).
popup_anchor : tuple of 2 int, optional
The coordinates of the point from which popups will "open",
relative to the icon anchor.
"""
_template = Template(
"""
{% macro script(this, kwargs) %}
var {{ this.get_name() }} = L.icon({{ this.options|tojson }});
{{ this._parent.get_name() }}.setIcon({{ this.get_name() }});
{% endmacro %}
"""
) # noqa
def __init__(
self,
icon_image: Any,
icon_size: Optional[Tuple[int, int]] = None,
icon_anchor: Optional[Tuple[int, int]] = None,
shadow_image: Any = None,
shadow_size: Optional[Tuple[int, int]] = None,
shadow_anchor: Optional[Tuple[int, int]] = None,
popup_anchor: Optional[Tuple[int, int]] = None,
):
super(Icon, self).__init__()
self._name = "CustomIcon"
self.options = parse_options(
icon_url=image_to_url(icon_image),
icon_size=icon_size,
icon_anchor=icon_anchor,
shadow_url=shadow_image and image_to_url(shadow_image),
shadow_size=shadow_size,
shadow_anchor=shadow_anchor,
popup_anchor=popup_anchor,
)
class ColorLine(FeatureGroup):
"""
Draw data on a map with specified colors.
Parameters
----------
positions: iterable of (lat, lon) pairs
The points on the line. Segments between points will be colored.
colors: iterable of float
Values that determine the color of a line segment.
It must have length equal to `len(positions)-1`.
colormap: branca.colormap.Colormap or list or tuple
The colormap to use. If a list or tuple of colors is provided,
a LinearColormap will be created from it.
nb_steps: int, default 12
The colormap will be discretized to this number of colors.
opacity: float, default 1
Line opacity, scale 0-1
weight: int, default 2
Stroke weight in pixels
**kwargs
Further parameters available. See folium.map.FeatureGroup
Returns
-------
A ColorLine object that you can `add_to` a Map.
"""
def __init__(
self,
positions: TypeLine,
colors: Iterable[float],
colormap: Union[ColorMap, Sequence[Any], None] = None,
nb_steps: int = 12,
weight: Optional[int] = None,
opacity: Optional[float] = None,
**kwargs: Any,
):
super().__init__(**kwargs)
self._name = "ColorLine"
coords = validate_locations(positions)
if colormap is None:
cm: StepColormap = LinearColormap(
["green", "yellow", "red"],
vmin=min(colors),
vmax=max(colors),
).to_step(nb_steps)
elif isinstance(colormap, LinearColormap):
cm = colormap.to_step(nb_steps)
elif isinstance(colormap, list) or isinstance(colormap, tuple):
cm = LinearColormap(
colormap,
vmin=min(colors),
vmax=max(colors),
).to_step(nb_steps)
else:
cm = colormap
out: Dict[str, List[List[List[float]]]] = {}
for (lat1, lng1), (lat2, lng2), color in zip(coords[:-1], coords[1:], colors):
out.setdefault(cm(color), []).append([[lat1, lng1], [lat2, lng2]])
for key, val in out.items():
self.add_child(PolyLine(val, color=key, weight=weight, opacity=opacity))