Skip to content

Commit 05f8e81

Browse files
jtbakerConengmo
authored andcommitted
Tooltip classes (simple and geojson/topojson) (#883)
Add two new classes: `Tooltip` and `GeoJsonTooltip`. The first can be used to simply add a fixed text tooltip to most folium objects. The second only works with the `GeoJson` and `TopoJson` classes and can utilize the data fields in those classes to render different tooltip contents for each feature.
1 parent bbf57a2 commit 05f8e81

File tree

7 files changed

+327
-114
lines changed

7 files changed

+327
-114
lines changed

CHANGES.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
- Added `zoom_control` to `Map` to toggle zoom controls as per enhancement (#795) (okomarov #899)
2222
- Change default `date_options` in TimestampedGeoJson (andy23512 #914)
2323
- Added gradient argument to HeatMapWithTime (jtbaker #925)
24+
- Added `Tooltip` and `GeoJsonTooltip` classes (jtbaker #883)
2425

2526
API changes
2627

folium/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,15 @@
1111
from folium.features import (
1212
ClickForMarker, ColorLine, CustomIcon, DivIcon, GeoJson,
1313
LatLngPopup, RegularPolygonMarker, TopoJson, Vega, VegaLite,
14+
GeoJsonTooltip,
1415
)
1516

1617
from folium.raster_layers import TileLayer, WmsTileLayer
1718

1819
from folium.folium import Map
1920

2021
from folium.map import (
21-
FeatureGroup, FitBounds, Icon, LayerControl, Marker, Popup
22+
FeatureGroup, FitBounds, Icon, LayerControl, Marker, Popup, Tooltip
2223
)
2324

2425
from folium.vector_layers import Circle, CircleMarker, PolyLine, Polygon, Rectangle # noqa
@@ -52,6 +53,7 @@
5253
'LayerControl',
5354
'Marker',
5455
'Popup',
56+
'Tooltip',
5557
'TileLayer',
5658
'ClickForMarker',
5759
'CustomIcon',

folium/features.py

Lines changed: 191 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@
1010
import json
1111

1212
from branca.colormap import LinearColormap
13-
from branca.element import (CssLink, Element, Figure, JavascriptLink, MacroElement) # noqa
14-
from branca.utilities import (_locations_tolist, _parse_size, image_to_url, iter_points, none_max, none_min) # noqa
13+
from branca.element import (Element, Figure, JavascriptLink, MacroElement)
14+
from branca.utilities import (_locations_tolist, _parse_size, image_to_url,
15+
none_max, none_min)
16+
17+
from folium.map import (FeatureGroup, Icon, Layer, Marker, Tooltip)
1518

16-
from folium.map import FeatureGroup, Icon, Layer, Marker
1719
from folium.utilities import get_bounds
1820
from folium.vector_layers import PolyLine
1921

@@ -49,10 +51,9 @@ class RegularPolygonMarker(Marker):
4951
radius: int, default 15
5052
Marker radius, in pixels
5153
popup: string or folium.Popup, default None
52-
Input text or visualization for object. Can pass either text,
53-
or a folium.Popup object.
54-
If None, no popup will be displayed.
55-
54+
Input text or visualization for object displayed when clicking.
55+
tooltip: str or folium.Tooltip, default None
56+
Display a text when hovering over the object.
5657
5758
https://humangeo.github.io/leaflet-dvf/
5859
@@ -72,17 +73,16 @@ class RegularPolygonMarker(Marker):
7273
rotation: {{this.rotation}},
7374
radius: {{this.radius}}
7475
}
75-
)
76-
.addTo({{this._parent.get_name()}});
76+
).addTo({{this._parent.get_name()}});
7777
{% endmacro %}
7878
""")
7979

8080
def __init__(self, location, color='black', opacity=1, weight=2,
81-
fill_color='blue', fill_opacity=1,
82-
number_of_sides=4, rotation=0, radius=15, popup=None):
81+
fill_color='blue', fill_opacity=1, number_of_sides=4,
82+
rotation=0, radius=15, popup=None, tooltip=None):
8383
super(RegularPolygonMarker, self).__init__(
8484
_locations_tolist(location),
85-
popup=popup
85+
popup=popup, tooltip=tooltip
8686
)
8787
self._name = 'RegularPolygonMarker'
8888
self.color = color
@@ -325,6 +325,9 @@ class GeoJson(Layer):
325325
How much to simplify the polyline on each zoom level. More means
326326
better performance and smoother look, and less means more accurate
327327
representation. Leaflet defaults to 1.0.
328+
tooltip: GeoJsonTooltip, Tooltip or str, default None
329+
Display a text when hovering over the object. Can utilize the data,
330+
see folium.GeoJsonTooltip for info on how to do that.
328331
329332
Examples
330333
--------
@@ -345,52 +348,46 @@ class GeoJson(Layer):
345348
346349
"""
347350
_template = Template(u"""
348-
{% macro script(this, kwargs) %}
349-
350-
{% if this.highlight %}
351-
{{this.get_name()}}_onEachFeature = function onEachFeature(feature, layer) {
352-
layer.on({
353-
mouseout: function(e) {
354-
e.target.setStyle(e.target.feature.properties.style);},
355-
mouseover: function(e) {
356-
e.target.setStyle(e.target.feature.properties.highlight);},
357-
click: function(e) {
358-
{{this._parent.get_name()}}.fitBounds(e.target.getBounds());}
359-
});
360-
};
361-
{% endif %}
362-
363-
var {{this.get_name()}} = L.geoJson(
364-
{% if this.embed %}{{this.style_data()}}{% else %}"{{this.data}}"{% endif %}
365-
{% if this.smooth_factor is not none or this.highlight %}
366-
, {
367-
{% if this.smooth_factor is not none %}
368-
smoothFactor:{{this.smooth_factor}}
369-
{% endif %}
370-
371-
{% if this.highlight %}
372-
{% if this.smooth_factor is not none %}
373-
,
374-
{% endif %}
375-
onEachFeature: {{this.get_name()}}_onEachFeature
376-
{% endif %}
377-
}
351+
{% macro script(this, kwargs) %}
352+
{% if this.highlight %}
353+
{{this.get_name()}}_onEachFeature = function onEachFeature(feature, layer) {
354+
layer.on({
355+
mouseout: function(e) {
356+
e.target.setStyle(e.target.feature.properties.style);},
357+
mouseover: function(e) {
358+
e.target.setStyle(e.target.feature.properties.highlight);},
359+
click: function(e) {
360+
{{this._parent.get_name()}}.fitBounds(e.target.getBounds());}
361+
});
362+
};
363+
{% endif %}
364+
var {{this.get_name()}} = L.geoJson(
365+
{% if this.embed %}{{this.style_data()}}{% else %}"{{this.data}}"{% endif %}
366+
{% if this.smooth_factor is not none or this.highlight %}
367+
, {
368+
{% if this.smooth_factor is not none %}
369+
smoothFactor:{{this.smooth_factor}}
370+
{% endif %}
371+
372+
{% if this.highlight %}
373+
{% if this.smooth_factor is not none %}
374+
,
378375
{% endif %}
379-
)
380-
{% if this.tooltip %}.bindTooltip("{{this.tooltip.__str__()}}"){% endif %}
381-
.addTo({{this._parent.get_name()}});
382-
{{this.get_name()}}.setStyle(function(feature) {return feature.properties.style;});
383-
384-
{% endmacro %}
385-
""") # noqa
376+
onEachFeature: {{this.get_name()}}_onEachFeature
377+
{% endif %}
378+
}
379+
{% endif %}
380+
).addTo({{this._parent.get_name()}});
381+
{{this.get_name()}}.setStyle(function(feature) {return feature.properties.style;});
382+
{% endmacro %}
383+
""") # noqa
386384

387385
def __init__(self, data, style_function=None, name=None,
388386
overlay=True, control=True, show=True,
389387
smooth_factor=None, highlight_function=None, tooltip=None):
390388
super(GeoJson, self).__init__(name=name, overlay=overlay,
391389
control=control, show=show)
392390
self._name = 'GeoJson'
393-
self.tooltip = tooltip
394391
if isinstance(data, dict):
395392
self.embed = True
396393
self.data = data
@@ -410,7 +407,6 @@ def __init__(self, data, style_function=None, name=None,
410407
self.data = json.loads(json.dumps(data.__geo_interface__)) # noqa
411408
else:
412409
raise ValueError('Unhandled object {!r}.'.format(data))
413-
414410
self.style_function = style_function or (lambda x: {})
415411

416412
self.highlight = highlight_function is not None
@@ -419,6 +415,11 @@ def __init__(self, data, style_function=None, name=None,
419415

420416
self.smooth_factor = smooth_factor
421417

418+
if isinstance(tooltip, (GeoJsonTooltip, Tooltip)):
419+
self.add_child(tooltip)
420+
elif tooltip is not None:
421+
self.add_child(Tooltip(tooltip))
422+
422423
def style_data(self):
423424
"""
424425
Applies `self.style_function` to each feature of `self.data` and
@@ -476,6 +477,9 @@ class TopoJson(Layer):
476477
How much to simplify the polyline on each zoom level. More means
477478
better performance and smoother look, and less means more accurate
478479
representation. Leaflet defaults to 1.0.
480+
tooltip: GeoJsonTooltip, Tooltip or str, default None
481+
Display a text when hovering over the object. Can utilize the data,
482+
see folium.GeoJsonTooltip for info on how to do that.
479483
480484
Examples
481485
--------
@@ -496,29 +500,26 @@ class TopoJson(Layer):
496500
497501
"""
498502
_template = Template(u"""
499-
{% macro script(this, kwargs) %}
500-
var {{this.get_name()}}_data = {{this.style_data()}};
501-
var {{this.get_name()}} = L.geoJson(topojson.feature(
502-
{{this.get_name()}}_data,
503-
{{this.get_name()}}_data.{{this.object_path}})
504-
{% if this.smooth_factor is not none %}
505-
, {smoothFactor: {{this.smooth_factor}}}
506-
{% endif %}
507-
)
508-
{% if this.tooltip %}.bindTooltip("{{this.tooltip.__str__()}}"){% endif %}
509-
.addTo({{this._parent.get_name()}});
510-
{{this.get_name()}}.setStyle(function(feature) {return feature.properties.style;});
511-
512-
{% endmacro %}
513-
""") # noqa
503+
{% macro script(this, kwargs) %}
504+
var {{this.get_name()}}_data = {{this.style_data()}};
505+
var {{this.get_name()}} = L.geoJson(topojson.feature(
506+
{{this.get_name()}}_data,
507+
{{this.get_name()}}_data.{{this.object_path}})
508+
{% if this.smooth_factor is not none %}
509+
, {smoothFactor: {{this.smooth_factor}}}
510+
{% endif %}
511+
).addTo({{this._parent.get_name()}});
512+
{{this.get_name()}}.setStyle(function(feature) {return feature.properties.style;});
513+
{% endmacro %}
514+
""") # noqa
514515

515516
def __init__(self, data, object_path, style_function=None,
516517
name=None, overlay=True, control=True, show=True,
517518
smooth_factor=None, tooltip=None):
518519
super(TopoJson, self).__init__(name=name, overlay=overlay,
519520
control=control, show=show)
520521
self._name = 'TopoJson'
521-
self.tooltip = tooltip
522+
522523
if 'read' in dir(data):
523524
self.embed = True
524525
self.data = json.load(data)
@@ -538,6 +539,11 @@ def style_function(x):
538539

539540
self.smooth_factor = smooth_factor
540541

542+
if isinstance(tooltip, (GeoJsonTooltip, Tooltip)):
543+
self.add_child(tooltip)
544+
elif tooltip is not None:
545+
self.add_child(Tooltip(tooltip))
546+
541547
def style_data(self):
542548
"""
543549
Applies self.style_function to each feature of self.data and returns
@@ -595,10 +601,127 @@ def get_bounds(self):
595601
self.data['transform']['translate'][1] + self.data['transform']['scale'][1] * ymax, # noqa
596602
self.data['transform']['translate'][0] + self.data['transform']['scale'][0] * xmax # noqa
597603
]
598-
599604
]
600605

601606

607+
class GeoJsonTooltip(Tooltip):
608+
"""
609+
Create a tooltip that uses data from either geojson or topojson.
610+
611+
Parameters
612+
----------
613+
fields: list or tuple.
614+
Labels of GeoJson/TopoJson 'properties' or GeoPandas GeoDataFrame
615+
columns you'd like to display.
616+
aliases: list/tuple of strings, same length/order as fields, default None.
617+
Optional aliases you'd like to display in the tooltip as field name
618+
instead of the keys of `fields`.
619+
labels: bool, default True.
620+
Set to False to disable displaying the field names or aliases.
621+
localize: bool, default False.
622+
This will use JavaScript's .toLocaleString() to format 'clean' values
623+
as strings for the user's location; i.e. 1,000,000.00 comma separators,
624+
float truncation, etc.
625+
*Available for most of JavaScript's primitive types (any data you'll
626+
serve into the template).
627+
style: str, default None.
628+
HTML inline style properties like font and colors. Will be applied to
629+
a div with the text in it.
630+
sticky: bool, default True
631+
Whether the tooltip should follow the mouse.
632+
**kwargs: Assorted.
633+
These values will map directly to the Leaflet Options. More info
634+
available here: https://leafletjs.com/reference-1.2.0#tooltip
635+
636+
Examples
637+
--------
638+
# Provide fields and aliases, with Style.
639+
>>> Tooltip(
640+
>>> fields=['CNTY_NM', 'census-pop-2015', 'census-md-income-2015'],
641+
>>> aliases=['County', '2015 Census Population', '2015 Median Income'],
642+
>>> localize=True,
643+
>>> style=('background-color: grey; color: white; font-family:'
644+
>>> 'courier new; font-size: 24px; padding: 10px;')
645+
>>> )
646+
# Provide fields, with labels off and fixed tooltip positions.
647+
>>> Tooltip(fields=('CNTY_NM',), labels=False, sticky=False)
648+
"""
649+
_template = Template(u"""
650+
{% macro script(this, kwargs) %}
651+
{{ this._parent.get_name() }}.bindTooltip(
652+
function(layer){
653+
// Convert non-primitive to String.
654+
let handleObject = (feature)=>typeof(feature)=='object' ? JSON.stringify(feature) : feature;
655+
let fields = {{ this.fields }};
656+
{% if this.aliases %}
657+
let aliases = {{ this.aliases }};
658+
{% endif %}
659+
return '<table{% if this.style %} style="{{this.style}}"{% endif%}>' +
660+
String(
661+
fields.map(
662+
columnname=>
663+
`<tr style="text-align: left;">{% if this.labels %}
664+
<th style="padding: 4px; padding-right: 10px;">{% if this.aliases %}
665+
${aliases[fields.indexOf(columnname)]
666+
{% if this.localize %}.toLocaleString(){% endif %}}
667+
{% else %}
668+
${ columnname{% if this.localize %}.toLocaleString(){% endif %}}
669+
{% endif %}</th>
670+
{% endif %}
671+
<td style="padding: 4px;">${handleObject(layer.feature.properties[columnname])
672+
{% if this.localize %}.toLocaleString(){% endif %}}</td></tr>`
673+
).join(''))
674+
+'</table>'
675+
}, {{ this.options }});
676+
{% endmacro %}
677+
""")
678+
679+
def __init__(self, fields, aliases=None, labels=True,
680+
localize=False, style=None, sticky=True, **kwargs):
681+
super(GeoJsonTooltip, self).__init__(
682+
text='', style=style, sticky=sticky, **kwargs
683+
)
684+
self._name = "GeoJsonTooltip"
685+
686+
assert isinstance(fields, (list, tuple)), "Please pass a list or " \
687+
"tuple to fields."
688+
if aliases is not None:
689+
assert isinstance(aliases, (list, tuple))
690+
assert len(fields) == len(aliases), "fields and aliases must have" \
691+
" the same length."
692+
assert isinstance(labels, bool), "labels requires a boolean value."
693+
assert isinstance(localize, bool), "localize must be bool."
694+
assert 'permanent' not in kwargs, "The `permanent` option does not " \
695+
"work with GeoJsonTooltip."
696+
697+
self.fields = fields
698+
self.aliases = aliases
699+
self.labels = labels
700+
self.localize = localize
701+
if style:
702+
assert isinstance(style, str), \
703+
"Pass a valid inline HTML style property string to style."
704+
# noqa outside of type checking.
705+
self.style = style
706+
707+
def render(self, **kwargs):
708+
"""Renders the HTML representation of the element."""
709+
if isinstance(self._parent, GeoJson):
710+
keys = tuple(self._parent.data['features'][0]['properties'].keys())
711+
elif isinstance(self._parent, TopoJson):
712+
obj_name = self._parent.object_path.split('.')[-1]
713+
keys = tuple(self._parent.data['objects'][obj_name][
714+
'geometries'][0]['properties'].keys())
715+
else:
716+
raise TypeError('You cannot add a GeoJsonTooltip to anything else '
717+
'than a GeoJson or TopoJson object.')
718+
keys = tuple(x for x in keys if x not in ('style', 'highlight'))
719+
for value in self.fields:
720+
assert value in keys, ("The field {} is not available in the data. "
721+
"Choose from: {}.".format(value, keys))
722+
super(GeoJsonTooltip, self).render(**kwargs)
723+
724+
602725
class DivIcon(MacroElement):
603726
"""
604727
Represents a lightweight icon for markers that uses a simple `div`

0 commit comments

Comments
 (0)