Skip to content

Commit 75a44c3

Browse files
ColinTalbertConengmo
authored andcommitted
Pattern plugin for GeoJson (#966)
This adds a plugin to add fill patterns to a GeoJson object. * Create pattern.py plugin with StripePattern and CirclePattern classes. * Change GeoJson template to accommodate patterns. * Further refactor the GeoJson template a bit. * Add example notebook. * Add utilities function to get an object in the object tree. * Add tests for plugin and utilities function.
1 parent 588670c commit 75a44c3

File tree

9 files changed

+425
-28
lines changed

9 files changed

+425
-28
lines changed

CHANGES.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
- Automatically detect VegaLite version from specs (JarnoRFB #959)
77
- Validate style and highlight functions in GeoJson (jtbaker #1024)
88
- AntPath plugin (ocefpaf #1016)
9+
- Update Leaflet version to 1.4.0 (conengmo #1047)
10+
- DualMap plugin (conengmo #933)
11+
- CirclePattern and StripePattern plugins (talbertc-usgs #966)
912

1013
API changes
1114

examples/plugin-patterns.ipynb

Lines changed: 95 additions & 0 deletions
Large diffs are not rendered by default.

folium/features.py

Lines changed: 43 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
image_to_url,
2424
none_max,
2525
none_min,
26+
get_obj_in_upper_tree
2627
)
2728
from folium.vector_layers import PolyLine
2829

@@ -392,35 +393,29 @@ class GeoJson(Layer):
392393
"""
393394
_template = Template(u"""
394395
{% macro script(this, kwargs) %}
395-
{% if this.highlight %}
396+
{%- if this.highlight %}
396397
{{this.get_name()}}_onEachFeature = function onEachFeature(feature, layer) {
397398
layer.on({
398399
mouseout: function(e) {
399400
e.target.setStyle(e.target.feature.properties.style);},
400401
mouseover: function(e) {
401402
e.target.setStyle(e.target.feature.properties.highlight);},
402403
click: function(e) {
403-
{{this._parent.get_name()}}.fitBounds(e.target.getBounds());}
404-
});
404+
{{ this.parent_map.get_name() }}.fitBounds(e.target.getBounds());}
405+
});
405406
};
406-
{% endif %}
407+
{%- endif %}
407408
var {{this.get_name()}} = L.geoJson(
408-
{% if this.embed %}{{this.style_data()}}{% else %}"{{this.data}}"{% endif %}
409-
{% if this.smooth_factor is not none or this.highlight %}
410-
, {
411-
{% if this.smooth_factor is not none %}
412-
smoothFactor:{{this.smooth_factor}}
413-
{% endif %}
414-
415-
{% if this.highlight %}
416-
{% if this.smooth_factor is not none %}
417-
,
418-
{% endif %}
419-
onEachFeature: {{this.get_name()}}_onEachFeature
420-
{% endif %}
421-
}
422-
{% endif %}
423-
).addTo({{this._parent.get_name()}});
409+
{{ this.json }},
410+
{
411+
{%- if this.smooth_factor is not none %}
412+
smoothFactor: {{ this.smooth_factor }},
413+
{%- endif %}
414+
{%- if this.highlight %}
415+
onEachFeature: {{ this.get_name() }}_onEachFeature,
416+
{%- endif %}
417+
}
418+
).addTo({{ this._parent.get_name()}} );
424419
{{this.get_name()}}.setStyle(function(feature) {return feature.properties.style;});
425420
{% endmacro %}
426421
""") # noqa
@@ -454,7 +449,6 @@ def __init__(self, data, style_function=None, name=None,
454449
self.style_function = style_function or (lambda x: {})
455450

456451
self.highlight = highlight_function is not None
457-
458452
self.highlight_function = highlight_function or (lambda x: {})
459453

460454
self.smooth_factor = smooth_factor
@@ -467,12 +461,16 @@ def __init__(self, data, style_function=None, name=None,
467461
elif tooltip is not None:
468462
self.add_child(Tooltip(tooltip))
469463

464+
self.parent_map = None
465+
self.json = None
466+
470467
def _validate_function(self, func, name):
471468
"""
472469
Tests `self.style_function` and `self.highlight_function` to ensure
473470
they are functions returning dictionaries.
474471
"""
475-
test_feature = self.data if self.data.get('features') is None else self.data['features'][0] # noqa
472+
test_feature = self.data if self.data.get('features') is None \
473+
else self.data['features'][0]
476474
if not callable(func) or not isinstance(func(test_feature), dict):
477475
raise ValueError('{} should be a function that accepts items from '
478476
'data[\'features\'] and returns a dictionary.'
@@ -492,10 +490,24 @@ def style_data(self):
492490
self.data = {'type': 'FeatureCollection', 'features': [self.data]}
493491

494492
for feature in self.data['features']:
495-
feature.setdefault('properties', {}).setdefault('style', {}).update(self.style_function(feature)) # noqa
496-
feature.setdefault('properties', {}).setdefault('highlight', {}).update(
497-
self.highlight_function(feature)) # noqa
498-
return json.dumps(self.data, sort_keys=True)
493+
feature_style = self.style_function(feature)
494+
for key, value in feature_style.items():
495+
if isinstance(value, MacroElement):
496+
# Make sure objects are rendered:
497+
if value._parent is None:
498+
value._parent = self
499+
value.render()
500+
# Replace objects with their Javascript var names:
501+
feature_style[key] = "{{'" + value.get_name() + "'}}"
502+
503+
feature.setdefault('properties', {}).setdefault('style', {}) \
504+
.update(feature_style)
505+
feature.setdefault('properties', {}).setdefault('highlight', {}) \
506+
.update(self.highlight_function(feature))
507+
508+
data_json = json.dumps(self.data, sort_keys=True)
509+
# Remove quotes around Jinja2 template expressions:
510+
return data_json.replace('"{{', '{{').replace('}}"', '}}')
499511

500512
def _get_self_bounds(self):
501513
"""
@@ -505,6 +517,11 @@ def _get_self_bounds(self):
505517
"""
506518
return get_bounds(self.data, lonlat=True)
507519

520+
def render(self, **kwargs):
521+
self.parent_map = get_obj_in_upper_tree(self, Map)
522+
self.json = self.style_data() if self.embed else json.dumps(self.data)
523+
super(GeoJson, self).render()
524+
508525

509526
class TopoJson(Layer):
510527
"""

folium/plugins/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from folium.plugins.measure_control import MeasureControl
2626
from folium.plugins.minimap import MiniMap
2727
from folium.plugins.mouse_position import MousePosition
28+
from folium.plugins.pattern import CirclePattern, StripePattern
2829
from folium.plugins.polyline_text_path import PolyLineTextPath
2930
from folium.plugins.scroll_zoom_toggler import ScrollZoomToggler
3031
from folium.plugins.search import Search
@@ -37,6 +38,7 @@
3738
'AntPath',
3839
'BeautifyIcon',
3940
'BoatMarker',
41+
'CirclePattern',
4042
'Draw',
4143
'DualMap',
4244
'FastMarkerCluster',
@@ -52,6 +54,7 @@
5254
'PolyLineTextPath',
5355
'ScrollZoomToggler',
5456
'Search',
57+
'StripePattern',
5558
'Terminator',
5659
'TimeSliderChoropleth',
5760
'TimestampedGeoJson',

folium/plugins/pattern.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
# -*- coding: utf-8 -*-
2+
3+
from __future__ import (absolute_import, division, print_function)
4+
import json
5+
6+
from branca.element import Figure, JavascriptLink, MacroElement
7+
8+
from folium.folium import Map
9+
from folium.utilities import get_obj_in_upper_tree
10+
11+
from jinja2 import Template
12+
13+
14+
class StripePattern(MacroElement):
15+
"""Fill Pattern for polygon composed of alternating lines.
16+
17+
Add these to the 'fillPattern' field in GeoJson style functions.
18+
19+
Parameters
20+
----------
21+
angle: float, default 0.5
22+
Angle of the line pattern (degrees). Should be between -360 and 360.
23+
weight: float, default 4
24+
Width of the main lines (pixels).
25+
space_weight: float
26+
Width of the alternate lines (pixels).
27+
color: string with hexadecimal, RGB, or named color, default "#000000"
28+
Color of the main lines.
29+
space_color: string with hexadecimal, RGB, or named color, default "#ffffff"
30+
Color of the alternate lines.
31+
opacity: float, default 0.75
32+
Opacity of the main lines. Should be between 0 and 1.
33+
space_opacity: float, default 0.0
34+
Opacity of the alternate lines. Should be between 0 and 1.
35+
36+
See https://github.com/teastman/Leaflet.pattern for more information.
37+
"""
38+
39+
_template = Template(u"""
40+
{% macro script(this, kwargs) %}
41+
var {{ this.get_name() }} = new L.StripePattern(
42+
{{ this.options }}
43+
);
44+
{{ this.get_name() }}.addTo({{ this.parent_map.get_name() }});
45+
{% endmacro %}
46+
""")
47+
48+
def __init__(self, angle=.5, weight=4, space_weight=4,
49+
color="#000000", space_color="#ffffff",
50+
opacity=0.75, space_opacity=0.0):
51+
super(StripePattern, self).__init__()
52+
self._name = 'StripePattern'
53+
self.options = json.dumps({
54+
'angle': angle,
55+
'weight': weight,
56+
'spaceWeight': space_weight,
57+
'color': color,
58+
'spaceColor': space_color,
59+
'opacity': opacity,
60+
'spaceOpacity': space_opacity
61+
})
62+
self.parent_map = None
63+
64+
def render(self, **kwargs):
65+
self.parent_map = get_obj_in_upper_tree(self, Map)
66+
super(StripePattern, self).render(**kwargs)
67+
68+
figure = self.get_root()
69+
assert isinstance(figure, Figure), ('You cannot render this Element '
70+
'if it is not in a Figure.')
71+
72+
figure.header.add_child(
73+
JavascriptLink('https://teastman.github.io/Leaflet.pattern/leaflet.pattern.js'), # noqa
74+
name='pattern'
75+
)
76+
77+
78+
class CirclePattern(MacroElement):
79+
"""Fill Pattern for polygon composed of repeating circles.
80+
81+
Add these to the 'fillPattern' field in GeoJson style functions.
82+
83+
Parameters
84+
----------
85+
width: int, default 20
86+
Horizontal distance between circles (pixels).
87+
height: int, default 20
88+
Vertical distance between circles (pixels).
89+
radius: int, default 12
90+
Radius of each circle (pixels).
91+
weight: float, default 2.0
92+
Width of outline around each circle (pixels).
93+
color: string with hexadecimal, RGB, or named color, default "#3388ff"
94+
Color of the circle outline.
95+
fill_color: string with hexadecimal, RGB, or named color, default "#3388ff"
96+
Color of the circle interior.
97+
opacity: float, default 0.75
98+
Opacity of the circle outline. Should be between 0 and 1.
99+
fill_opacity: float, default 0.5
100+
Opacity of the circle interior. Should be between 0 and 1.
101+
102+
See https://github.com/teastman/Leaflet.pattern for more information.
103+
"""
104+
105+
_template = Template(u"""
106+
{% macro script(this, kwargs) %}
107+
var shape = new L.PatternCircle(
108+
{{ this.options_pattern_circle }}
109+
);
110+
var {{this.get_name()}} = new L.Pattern(
111+
{{ this.options_pattern }}
112+
);
113+
{{ this.get_name() }}.addShape(shape);
114+
{{ this.get_name() }}.addTo({{ this.parent_map }});
115+
{% endmacro %}
116+
""")
117+
118+
def __init__(self, width=20, height=20, radius=12, weight=2.0,
119+
color="#3388ff", fill_color="#3388ff",
120+
opacity=0.75, fill_opacity=0.5):
121+
super(CirclePattern, self).__init__()
122+
self._name = 'CirclePattern'
123+
self.options_pattern_circle = json.dumps({
124+
'x': radius + 2 * weight,
125+
'y': radius + 2 * weight,
126+
'weight': weight,
127+
'radius': radius,
128+
'color': color,
129+
'fillColor': fill_color,
130+
'opacity': opacity,
131+
'fillOpacity': fill_opacity,
132+
'fill': True,
133+
})
134+
self.options_pattern = json.dumps({
135+
'width': width,
136+
'height': height,
137+
})
138+
self.parent_map = None
139+
140+
def render(self, **kwargs):
141+
self.parent_map = get_obj_in_upper_tree(self, Map).get_name()
142+
super(CirclePattern, self).render(**kwargs)
143+
144+
figure = self.get_root()
145+
assert isinstance(figure, Figure), ('You cannot render this Element '
146+
'if it is not in a Figure.')
147+
148+
figure.header.add_child(
149+
JavascriptLink('https://teastman.github.io/Leaflet.pattern/leaflet.pattern.js'), # noqa
150+
name='pattern'
151+
)

folium/utilities.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,3 +432,14 @@ def deep_copy(item_original):
432432
children_new[subitem.get_name()] = subitem
433433
item._children = children_new
434434
return item
435+
436+
437+
def get_obj_in_upper_tree(element, cls):
438+
"""Return the first object in the parent tree of class `cls`."""
439+
if not hasattr(element, '_parent'):
440+
raise ValueError('The top of the tree was reached without finding a {}'
441+
.format(cls))
442+
parent = element._parent
443+
if not isinstance(parent, cls):
444+
return get_obj_in_upper_tree(parent, cls)
445+
return parent

tests/plugins/test_pattern.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# -*- coding: utf-8 -*-
2+
3+
"""
4+
Test pattern
5+
---------------
6+
7+
"""
8+
9+
from __future__ import (absolute_import, division, print_function)
10+
import os
11+
12+
import folium
13+
14+
from folium import plugins
15+
16+
17+
def test_pattern():
18+
m = folium.Map([40., -105.], zoom_start=6)
19+
20+
stripes = plugins.pattern.StripePattern(angle=-45)
21+
stripes.add_to(m)
22+
circles = plugins.pattern.CirclePattern(width=20, height=20, radius=5,
23+
fill_opacity=0.5, opacity=1)
24+
25+
def style_function(feature):
26+
default_style = {
27+
'opacity': 1.0,
28+
'fillColor': '#ffff00',
29+
'color': 'black',
30+
'weight': 2
31+
}
32+
33+
if feature['properties']['name'] == 'Colorado':
34+
default_style['fillPattern'] = stripes
35+
default_style['fillOpacity'] = 1.0
36+
37+
if feature['properties']['name'] == 'Utah':
38+
default_style['fillPattern'] = circles
39+
default_style['fillOpacity'] = 1.0
40+
41+
return default_style
42+
43+
data = os.path.join(os.path.dirname(__file__), os.pardir, 'us-states.json')
44+
folium.GeoJson(data, style_function=style_function).add_to(m)
45+
46+
out = m._parent.render()
47+
48+
# We verify that the script import is present.
49+
script = '<script src="https://teastman.github.io/Leaflet.pattern/leaflet.pattern.js"></script>' # noqa
50+
assert script in out

0 commit comments

Comments
 (0)