Skip to content

Commit 423993d

Browse files
Padarnocefpaf
authored andcommitted
Heat map with time (#567)
* Added HeatMapWithTimePluggin basic version
1 parent 780dd87 commit 423993d

4 files changed

Lines changed: 468 additions & 0 deletions

File tree

examples/HeatMapWithTime.ipynb

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

folium/plugins/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from folium.plugins.float_image import FloatImage
1616
from folium.plugins.fullscreen import Fullscreen
1717
from folium.plugins.heat_map import HeatMap
18+
from folium.plugins.heat_map_withtime import HeatMapWithTime
1819
from folium.plugins.image_overlay import ImageOverlay
1920
from folium.plugins.marker_cluster import MarkerCluster
2021
from folium.plugins.measure_control import MeasureControl
@@ -30,6 +31,7 @@
3031
'FloatImage',
3132
'Fullscreen',
3233
'HeatMap',
34+
'HeatMapWithTime',
3335
'ImageOverlay',
3436
'MarkerCluster',
3537
'MeasureControl',
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
# -*- coding: utf-8 -*-
2+
3+
from branca.element import JavascriptLink, Figure, CssLink, Element
4+
from branca.utilities import none_min, none_max
5+
6+
from folium.map import TileLayer
7+
8+
from jinja2 import Template
9+
10+
11+
class HeatMapWithTime(TileLayer):
12+
"""
13+
Create a HeatMapWithTime layer
14+
15+
Parameters
16+
----------
17+
data: list of list of points of the form [lat, lng] or [lat, lng, weight]
18+
The points you want to plot. The outer list corresponds to the various time
19+
steps in sequential order. (weight defaults to 1 if not specified for a point)
20+
index: Index giving the label (or timestamp) of the elements of data. Should have
21+
the same length as data, or is replaced by a simple count if not specified.
22+
name: str
23+
The name of the layer that will be created.
24+
radius: default 15.
25+
The radius used around points for the heatmap.
26+
min_opacity: default 0
27+
The minimum opacity for the heatmap.
28+
max_opacity: default 0.6
29+
The maximum opacity for the heatmap.
30+
scale_radius: default False
31+
Scale the radius of the points based on the zoom level.
32+
use_local_extrema: default False
33+
Defines whether the heatmap uses a global extrema set found from the input data
34+
OR a local extrema (the maximum and minimum of the currently displayed view).
35+
auto_play: default False
36+
Automatically play the animation across time.
37+
display_index: default True
38+
Display the index (usually time) in the time control.
39+
index_steps: default 1
40+
Steps to take in the index dimension between aimation steps.
41+
min_speed: default 0.1
42+
Minimum fps speed for animation.
43+
max_speed: default 10
44+
Maximum fps speed for animation.
45+
speed_step: default 0.1
46+
Step between different fps speeds on the speed slider.
47+
position: default 'bottomleft'
48+
Position string for the time slider. Format: 'bottom/top'+'left/right'.
49+
50+
"""
51+
def __init__(self, data, index=None, name=None, radius=15, min_opacity=0, max_opacity=0.6,
52+
scale_radius=False, use_local_extrema=False, auto_play=False, display_index=True,
53+
index_steps=1, min_speed=0.1, max_speed=10, speed_step=0.1, position='bottomleft'
54+
):
55+
super(TileLayer, self).__init__(name=name)
56+
self._name = 'HeatMap'
57+
self._control_name = self.get_name() + 'Control'
58+
self.tile_name = name if name is not None else self.get_name()
59+
60+
# Input data.
61+
self.data = data
62+
self.index = index if index is not None else [str(i) for i in range(1, len(data)+1)]
63+
if len(self.data) != len(self.index):
64+
raise ValueError('Input data and index are not of compatible lengths.')
65+
self.times = range(1, len(data)+1)
66+
67+
# Heatmap settings.
68+
self.radius = radius
69+
self.min_opacity = min_opacity
70+
self.max_opacity = max_opacity
71+
self.scale_radius = 'true' if scale_radius else 'false'
72+
self.use_local_extrema = 'true' if use_local_extrema else 'false'
73+
74+
# Time dimension settings.
75+
self.auto_play = 'true' if auto_play else 'false'
76+
self.display_index = 'true' if display_index else 'false'
77+
self.min_speed = min_speed
78+
self.max_speed = max_speed
79+
self.position = position
80+
self.speed_step = speed_step
81+
self.index_steps = index_steps
82+
83+
# Hard coded defaults for simplicity.
84+
self.backward_button = 'true'
85+
self.forward_button = 'true'
86+
self.limit_sliders = 'true'
87+
self.limit_minimum_range = 5
88+
self.loop_button = 'true'
89+
self.speed_slider = 'true'
90+
self.time_slider = 'true'
91+
self.play_button = 'true'
92+
self.play_reverse_button = 'true'
93+
self.time_slider_drap_update = 'false'
94+
self.style_NS = 'leaflet-control-timecontrol'
95+
96+
self._template = Template(u"""
97+
{% macro script(this, kwargs) %}
98+
99+
var times = {{this.times}};
100+
101+
{{this._parent.get_name()}}.timeDimension = L.timeDimension(
102+
{times : times, currentTime: new Date(1)}
103+
);
104+
105+
var {{this._control_name}} = new L.Control.TimeDimensionCustom({{this.index}}, {
106+
autoPlay: {{this.auto_play}},
107+
backwardButton: {{this.backward_button}},
108+
displayDate: {{this.display_index}},
109+
forwardButton: {{this.forward_button}},
110+
limitMinimumRange: {{this.limit_minimum_range}},
111+
limitSliders: {{this.limit_sliders}},
112+
loopButton: {{this.loop_button}},
113+
maxSpeed: {{this.max_speed}},
114+
minSpeed: {{this.min_speed}},
115+
playButton: {{this.play_button}},
116+
playReverseButton: {{this.play_reverse_button}},
117+
position: "{{this.position}}",
118+
speedSlider: {{this.speed_slider}},
119+
speedStep: {{this.speed_step}},
120+
styleNS: "{{this.style_NS}}",
121+
timeSlider: {{this.time_slider}},
122+
timeSliderDrapUpdate: {{this.time_slider_drap_update}},
123+
timeSteps: {{this.index_steps}}
124+
})
125+
.addTo({{this._parent.get_name()}});
126+
127+
var {{this.get_name()}} = new TDHeatmap({{this.data}},
128+
{heatmapOptions: {
129+
radius: {{this.radius}},
130+
minOpacity: {{this.min_opacity}},
131+
maxOpacity: {{this.max_opacity}},
132+
scaleRadius: {{this.scale_radius}},
133+
useLocalExtrema: {{this.use_local_extrema}},
134+
defaultWeight: 1 ,
135+
}
136+
})
137+
.addTo({{this._parent.get_name()}});
138+
139+
{% endmacro %}
140+
""")
141+
142+
def render(self, **kwargs):
143+
super(TileLayer, self).render()
144+
145+
figure = self.get_root()
146+
assert isinstance(figure, Figure), ('You cannot render this Element '
147+
'if it is not in a Figure.')
148+
149+
figure.header.add_child(
150+
JavascriptLink('https://rawgit.com/socib/Leaflet.TimeDimension/master/dist/leaflet.timedimension.min.js'), # noqa
151+
name='leaflet.timedimension.min.js')
152+
153+
figure.header.add_child(
154+
JavascriptLink(
155+
'https://cdnjs.cloudflare.com/ajax/libs/heatmap.js/2.0.2/heatmap.min.js'),
156+
name='heatmap.min.js')
157+
158+
figure.header.add_child(
159+
JavascriptLink('https://rawgit.com/pa7/heatmap.js/develop/plugins/leaflet-heatmap/leaflet-heatmap.js'), # noqa
160+
name='leaflet-heatmap.js')
161+
162+
figure.header.add_child(
163+
CssLink('http://apps.socib.es/Leaflet.TimeDimension/dist/leaflet.timedimension.control.min.css'), # noqa
164+
name='leaflet.timedimension.control.min.css')
165+
166+
figure.header.add_child(
167+
Element(
168+
"""
169+
<script>
170+
var TDHeatmap = L.TimeDimension.Layer.extend({
171+
172+
initialize: function(data, options) {
173+
var heatmapCfg = {
174+
radius: 15,
175+
maxOpacity: 1.,
176+
scaleRadius: false,
177+
useLocalExtrema: false,
178+
latField: 'lat',
179+
lngField: 'lng',
180+
valueField: 'count',
181+
defaultWeight : 1,
182+
};
183+
heatmapCfg = $.extend({}, heatmapCfg, options.heatmapOptions || {});
184+
var layer = new HeatmapOverlay(heatmapCfg);
185+
L.TimeDimension.Layer.prototype.initialize.call(this, layer, options);
186+
this._currentLoadedTime = 0;
187+
this._currentTimeData = {
188+
data: []
189+
};
190+
this.data= data;
191+
this.defaultWeight = heatmapCfg.defaultWeight || 1;
192+
},
193+
onAdd: function(map) {
194+
L.TimeDimension.Layer.prototype.onAdd.call(this, map);
195+
map.addLayer(this._baseLayer);
196+
if (this._timeDimension) {
197+
this._getDataForTime(this._timeDimension.getCurrentTime());
198+
}
199+
},
200+
_onNewTimeLoading: function(ev) {
201+
this._getDataForTime(ev.time);
202+
return;
203+
},
204+
isReady: function(time) {
205+
return (this._currentLoadedTime == time);
206+
},
207+
_update: function() {
208+
this._baseLayer.setData(this._currentTimeData);
209+
return true;
210+
},
211+
_getDataForTime: function(time) {
212+
delete this._currentTimeData.data;
213+
this._currentTimeData.data = [];
214+
var data = this.data[time-1];
215+
for (var i = 0; i < data.length; i++) {
216+
this._currentTimeData.data.push({
217+
lat: data[i][0],
218+
lng: data[i][1],
219+
count: data[i].length>2 ? data[i][2] : this.defaultWeight
220+
});
221+
}
222+
this._currentLoadedTime = time;
223+
if (this._timeDimension && time == this._timeDimension.getCurrentTime() && !this._timeDimension.isLoading()) {
224+
this._update();
225+
}
226+
this.fire('timeload', {
227+
time: time
228+
});
229+
}
230+
});
231+
232+
L.Control.TimeDimensionCustom = L.Control.TimeDimension.extend({
233+
initialize: function(index, options) {
234+
var playerOptions = {
235+
buffer: 1,
236+
minBufferReady: -1
237+
};
238+
options.playerOptions = $.extend({}, playerOptions, options.playerOptions || {});
239+
L.Control.TimeDimension.prototype.initialize.call(this, options);
240+
this.index = index;
241+
},
242+
_getDisplayDateFormat: function(date){
243+
return this.index[date.getTime()-1];
244+
}
245+
});
246+
</script>
247+
""",
248+
template_name="timeControlScript"
249+
)
250+
)
251+
252+
def _get_self_bounds(self):
253+
"""
254+
Computes the bounds of the object itself (not including it's children)
255+
in the form [[lat_min, lon_min], [lat_max, lon_max]].
256+
257+
"""
258+
bounds = [[None, None], [None, None]]
259+
for point in self.data:
260+
bounds = [
261+
[
262+
none_min(bounds[0][0], point[0]),
263+
none_min(bounds[0][1], point[1]),
264+
],
265+
[
266+
none_max(bounds[1][0], point[0]),
267+
none_max(bounds[1][1], point[1]),
268+
],
269+
]
270+
return bounds
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
Test HeatMapWithTime
4+
------------
5+
"""
6+
7+
from jinja2 import Template
8+
import numpy as np
9+
10+
import folium
11+
from folium import plugins
12+
13+
14+
def test_heat_map_with_time():
15+
np.random.seed(3141592)
16+
initial_data = (np.random.normal(size=(100, 2)) * np.array([[1, 1]]) +
17+
np.array([[48, 5]]))
18+
move_data = np.random.normal(size=(100, 2)) * 0.01
19+
data = [(initial_data + move_data * i).tolist() for i in range(100)]
20+
m = folium.Map([48., 5.], tiles='stamentoner', zoom_start=6)
21+
hm = plugins.HeatMapWithTime(data)
22+
m.add_child(hm)
23+
m._repr_html_()
24+
25+
out = m._parent.render()
26+
27+
# We verify that the script imports are present.
28+
script = '<script src="https://rawgit.com/socib/Leaflet.TimeDimension/master/dist/leaflet.timedimension.min.js"></script>' # noqa
29+
assert script in out
30+
script = '<script src="https://cdnjs.cloudflare.com/ajax/libs/heatmap.js/2.0.2/heatmap.min.js"></script>' # noqa
31+
assert script in out
32+
script = '<script src="https://rawgit.com/pa7/heatmap.js/develop/plugins/leaflet-heatmap/leaflet-heatmap.js"></script>' # noqa
33+
assert script in out
34+
script = '<link rel="stylesheet" href="http://apps.socib.es/Leaflet.TimeDimension/dist/leaflet.timedimension.control.min.css" />' # noqa
35+
assert script in out
36+
37+
# We verify that the script part is correct.
38+
tmpl = Template("""
39+
var times = {{this.times}};
40+
41+
{{this._parent.get_name()}}.timeDimension = L.timeDimension(
42+
{times : times, currentTime: new Date(1)}
43+
);
44+
45+
var {{this._control_name}} = new L.Control.TimeDimensionCustom({{this.index}}, {
46+
autoPlay: {{this.auto_play}},
47+
backwardButton: {{this.backward_button}},
48+
displayDate: {{this.display_index}},
49+
forwardButton: {{this.forward_button}},
50+
limitMinimumRange: {{this.limit_minimum_range}},
51+
limitSliders: {{this.limit_sliders}},
52+
loopButton: {{this.loop_button}},
53+
maxSpeed: {{this.max_speed}},
54+
minSpeed: {{this.min_speed}},
55+
playButton: {{this.play_button}},
56+
playReverseButton: {{this.play_reverse_button}},
57+
position: "{{this.position}}",
58+
speedSlider: {{this.speed_slider}},
59+
speedStep: {{this.speed_step}},
60+
styleNS: "{{this.style_NS}}",
61+
timeSlider: {{this.time_slider}},
62+
timeSliderDrapUpdate: {{this.time_slider_drap_update}},
63+
timeSteps: {{this.index_steps}}
64+
})
65+
.addTo({{this._parent.get_name()}});
66+
67+
var {{this.get_name()}} = new TDHeatmap({{this.data}},
68+
{heatmapOptions: {
69+
radius: {{this.radius}},
70+
minOpacity: {{this.min_opacity}},
71+
maxOpacity: {{this.max_opacity}},
72+
scaleRadius: {{this.scale_radius}},
73+
useLocalExtrema: {{this.use_local_extrema}},
74+
defaultWeight: 1 ,
75+
}
76+
})
77+
.addTo({{this._parent.get_name()}});
78+
""")
79+
80+
assert tmpl.render(this=hm)

0 commit comments

Comments
 (0)