""" 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, get_args, ) import numpy as np import requests from branca.colormap import ColorMap, LinearColormap, StepColormap from branca.element import ( Div, Element, Figure, Html, IFrame, JavascriptLink, MacroElement, ) from branca.utilities import color_brewer from folium.elements import JSCSSMixin from folium.folium import Map from folium.map import Class, FeatureGroup, Icon, Layer, Marker, Popup, Tooltip from folium.template import Template from folium.utilities import ( JsCode, TypeBoundsReturn, TypeContainer, TypeJsonValue, TypeLine, TypePathOptions, TypePosition, _parse_size, escape_backticks, get_bounds, get_obj_in_upper_tree, image_to_url, javascript_identifier_path_to_array_notation, none_max, none_min, remove_empty, 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|tojavascript }} ).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( dict( number_of_sides=number_of_sides, rotation=rotation, ) ) class Vega(JSCSSMixin): """ 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): """Renders the HTML representation of the element.""" super().render(**kwargs) self.json = json.dumps(self.data) self._parent.html.add_child( Element( Template( """
""" ).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( """ """ ).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(MacroElement): """ 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): """Renders the HTML representation of the element.""" parent = self._parent if not isinstance(parent, (Figure, Div, Popup)): raise TypeError( "VegaLite elements can only be added to a Figure, Div, or Popup" ) parent.html.add_child( Element( Template( """ """ ).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( """ """ ).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=figure, parent=parent) @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, parent: TypeContainer) -> None: self._vega_embed(parent=parent) 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, parent: TypeContainer) -> None: self._vega_embed(parent=parent) 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, parent: TypeContainer) -> None: self._vega_embed(parent=parent) 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, parent: TypeContainer) -> None: self._vega_embed(parent=parent) 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, parent: TypeContainer) -> None: 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, parent: TypeContainer) -> None: 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", ) figure.header.add_child( JavascriptLink( "https://cdnjs.cloudflare.com/ajax/libs/vega-lite/1.3.1/vega-lite.js" ), name="vega-lite", ) figure.header.add_child( JavascriptLink( "https://cdnjs.cloudflare.com/ajax/libs/vega-embed/2.2.0/vega-embed.js" ), name="vega-embed", ) 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. on_each_feature: JsCode, optional Javascript code to be called on each feature. See https://leafletjs.com/examples/geojson/ `onEachFeature` for more information. **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 | tojavascript }}; {% if this.marker._name == 'Marker' and this.marker.icon %} const iconOptions = {{ this.marker.icon.options | tojavascript }} 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) { {%- if this.on_each_feature %} ({{this.on_each_feature}})(feature, layer); {%- endif %} 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 %} ...{{this.options | tojavascript }} }); 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, on_each_feature: Optional[JsCode] = None, marker: Union[Circle, CircleMarker, Marker, None] = None, **kwargs: Any, ): 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.on_each_feature = on_each_feature self.options = remove_empty(**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): 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): """Renders the HTML representation of the element.""" self.style_data() super().render(**kwargs) def get_bounds(self) -> TypeBoundsReturn: """ 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 => { if (feature === null) { return ''; } else if (typeof(feature)=='object') { return JSON.stringify(feature); } else { return feature; } } let fields = {{ this.fields | tojson | safe }}; let aliases = {{ this.aliases | tojson | safe }}; let table = '| ${aliases[i]{% if this.localize %}.toLocaleString(){% endif %}} | {% endif %}${handleObject(layer.feature.properties[v]){% if this.localize %}.toLocaleString(){% endif %}} |
|---|