Skip to content

Commit 1e79985

Browse files
committed
Add docstrings + transform from times to start
1 parent 6336e2d commit 1e79985

File tree

2 files changed

+302
-10
lines changed

2 files changed

+302
-10
lines changed

folium/plugins/timeline.py

Lines changed: 300 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,109 @@
1+
from typing import Optional, TextIO, Union
2+
3+
import isodate
4+
import jq
15
from branca.element import MacroElement
26
from jinja2 import Template
37

48
from folium.elements import JSCSSMixin
5-
from folium.utilities import parse_options
9+
from folium.utilities import JsCode, camelize, parse_options
610

711

812
class Timeline(JSCSSMixin, MacroElement):
913
"""
10-
TODO
14+
Creates a TimestampedGeoJson plugin from timestamped GeoJSONs to append
15+
into a map with Map.add_child.
16+
17+
There are three main methods to make a geojson timestamped:
18+
19+
Adding a 'times' array property to each feature
20+
----------------
21+
* it contains only features of types LineString, MultiPoint, MultiLineString,
22+
Polygon and MultiPolygon.
23+
* each feature has a 'times' property with the same length as the
24+
coordinates array.
25+
* each element of each 'times' property is a timestamp in ms since epoch,
26+
or in ISO string.
27+
28+
Eventually, you may have Point features with a 'times' property being an
29+
array of length 1.
30+
31+
Add 'start' and 'end' properties to each feature.
32+
-----------
33+
* Each feature contains a 'start' and 'end' property. The start and end
34+
can be any comparable item.
35+
36+
Use JsCode
37+
----------
38+
* Provide a JsCode `get_interval`. This function should take as parameter
39+
a GeoJson feature and return either a dict containing values for
40+
'start', 'end', 'startExclusive' and 'endExcusive' or false if no
41+
data could be extracted from the feature.
42+
* 'start' and 'end' can be any comparable items
43+
* 'startExclusive' and 'endExclusive' should be boolean values.
44+
45+
Parameters
46+
----------
47+
data: file, dict or str.
48+
The timestamped geo-json data you want to plot.
49+
50+
* If file, then data will be read in the file and fully embedded in
51+
Leaflet's javascript.
52+
* If dict, then data will be converted to json and embedded in the
53+
javascript.
54+
* If str, then data will be passed to the javascript as-is.
55+
56+
transition_time: int, default 200.
57+
This parameter exists for backward compatibility but is ignored.
58+
loop: bool, default True
59+
This parameter exists for backward compatibility but is ignored.
60+
period: str, default "P1D"
61+
This parameter exists for backward compatibility but is ignored.
62+
63+
duration: str, default None
64+
Period of time which the features will be shown on the map after their
65+
time has passed. If None, all previous times will be shown.
66+
Format: ISO8601 Duration
67+
ex: 'P1M' 1/month, 'P1D' 1/day, 'PT1H' 1/hour, and 'PT1M' 1/minute
68+
get_interval: JsCode
69+
Called for each feature, and should return either a time range for the
70+
feature or `false`, indicating that it should not be included in the
71+
timeline. The time range object should have 'start' and 'end' properties.
72+
Optionally, the boolean keys 'startExclusive' and 'endExclusive' allow the
73+
interval to be considered exclusive.
74+
75+
If `get_interval` is not provided, 'start' and 'end' properties are
76+
assumed to be present on each feature.
77+
start: str, int or float, default earliest 'start' in GeoJson
78+
The beginning/minimum value of the timeline.
79+
end: str, int or float, default latest 'end' in GeoJSON
80+
The end/maximum value of the timeline.
81+
add_last_point: bool, default True
82+
If the FeatureType is a line string, add a the last remaining
83+
element as a Point.
84+
auto_play: bool, default True
85+
Whether the animation shall start automatically at startup.
86+
format_output: JsCode
87+
A function that takes in a Unix timestamp and outputs a string.
88+
enable_playback: bool, default True
89+
Show playback controls (i.e. prev/play/pause/next).
90+
enable_keyboard_controls: bool, default False
91+
Allow playback to be controlled using the spacebar (play/pause) and
92+
right/left arrow keys (next/previous).
93+
show_ticks: bool, default True
94+
Show tick marks on the slider
95+
steps: int, default 1000
96+
How many steps to break the timeline into.
97+
Each step will then be (end-start) / steps. Only affects playback.
98+
playback_duration: int, default 10000
99+
Minimum time, in ms, for the playback to take. Will almost certainly
100+
actually take at least a bit longer -- after each frame, the next
101+
one displays in playback_duration/steps ms, so each frame really
102+
takes frame processing time PLUS step time.
103+
104+
Other keyword arguments are passed to the GeoJson layer, so you can pass
105+
`style`, `point_to_layer` and/or `on_each_feature`.
106+
11107
"""
12108

13109
_template = Template(
@@ -27,17 +123,19 @@ class Timeline(JSCSSMixin, MacroElement):
27123
{% endmacro %}
28124
29125
{% macro script(this, kwargs) %}
126+
var {{ this.get_name() }}_options = {{ this.options|tojson }};
127+
{% for key, value in this.functions.items() %}
128+
{{ this.get_name() }}_options["{{key}}"] = {{ value }};
129+
{% endfor %}
130+
30131
31132
var {{ this.get_name() }} = L.timeline(
32133
{{ this.data|tojson }},
33-
{{ this.options|tojson }}
134+
{{ this.get_name() }}_options
135+
);
136+
var control = L.timelineSliderControl(
137+
{{ this.get_name() }}_options
34138
);
35-
var control = L.timelineSliderControl({
36-
formatOutput: function (date) {
37-
return new Date(date).toLocaleDateString();
38-
},
39-
enableKeyboardControls: true,
40-
});
41139
control.addTo({{ this._parent.get_name() }});
42140
43141
{{ this._parent.get_name() }}.addControl(control);
@@ -56,9 +154,201 @@ class Timeline(JSCSSMixin, MacroElement):
56154
)
57155
]
58156

59-
def __init__(self, data, **kwargs):
157+
format_output = JsCode(
158+
"""
159+
function (date) {
160+
return new Date(date).toLocaleDateString();
161+
}
162+
"""
163+
)
164+
165+
style = JsCode(
166+
"""
167+
function (feature) {
168+
return feature.properties.style;
169+
}
170+
"""
171+
)
172+
173+
on_each_feature = JsCode(
174+
"""
175+
function(feature, layer) {
176+
if (feature.properties.popup) {
177+
layer.bindPopup(feature.properties.popup);
178+
}
179+
if (feature.properties.tooltip) {
180+
layer.bindTooltip(feature.properties.tooltip);
181+
}
182+
}
183+
"""
184+
)
185+
186+
point_to_layer = JsCode(
187+
"""
188+
function (feature, latLng) {
189+
if (feature.properties.icon == 'marker') {
190+
if(feature.properties.iconstyle) {
191+
return new L.Marker(latLng, {
192+
icon: L.icon(feature.properties.iconstyle)});
193+
}
194+
//else
195+
return new L.Marker(latLng);
196+
}
197+
if (feature.properties.icon == 'circle') {
198+
if (feature.properties.iconstyle) {
199+
return new L.circleMarker(latLng,
200+
feature.properties.iconstyle)
201+
};
202+
//else
203+
return new L.circleMarker(latLng);
204+
}
205+
//else
206+
207+
return new L.Marker(latLng);
208+
}
209+
"""
210+
)
211+
212+
def __init__(
213+
self,
214+
data: Union[TextIO, str, dict],
215+
duration: str = None,
216+
convert: bool = True,
217+
get_interval: Optional[JsCode] = None,
218+
start: Union[str, int, float] = None,
219+
end: Union[str, int, float] = None,
220+
add_last_point: bool = True,
221+
auto_play: bool = True,
222+
format_output: Optional[JsCode] = format_output,
223+
enable_playback: bool = True,
224+
enable_keyboard_controls: bool = False,
225+
show_ticks: bool = True,
226+
steps: int = 1000,
227+
playback_duration: int = 10000,
228+
point_to_layer: Optional[JsCode] = point_to_layer,
229+
style: Optional[JsCode] = style,
230+
on_each_feature: Optional[JsCode] = on_each_feature,
231+
**kwargs
232+
):
60233
super().__init__()
61234
self._name = "Timeline"
62235

236+
# If required we take a GeoJson with 'times' and convert
237+
# to 'start' and 'end'
238+
if convert:
239+
data = _convert_from_times(data, duration, add_last_point)
240+
63241
self.data = data
242+
kwargs["start"] = start
243+
kwargs["end"] = end
244+
kwargs["auto_play"] = auto_play
245+
kwargs["enable_playback"] = enable_playback
246+
kwargs["enable_keyboard_controls"] = enable_keyboard_controls
247+
kwargs["show_ticks"] = show_ticks
248+
kwargs["steps"] = steps
249+
kwargs["duration"] = playback_duration
250+
kwargs["point_to_layer"] = point_to_layer
251+
kwargs["on_each_feature"] = on_each_feature
252+
kwargs["style"] = style
253+
254+
if format_output is not None:
255+
kwargs["format_output"] = format_output
256+
if get_interval is not None:
257+
kwargs["get_interval"] = get_interval
258+
259+
# extract JsCode objects
260+
self.functions = {}
261+
for key, value in list(kwargs.items()):
262+
if isinstance(value, JsCode):
263+
self.functions[camelize(key)] = value.js_code
264+
kwargs.pop(key)
265+
64266
self.options = parse_options(**kwargs)
267+
268+
269+
def _convert_to_feature_collection(obj) -> dict:
270+
"""Convert data into a FeatureCollection if it is not already."""
271+
if obj["type"] == "FeatureCollection":
272+
return obj
273+
# Catch case when GeoJSON is just a single Feature or a geometry.
274+
if "geometry" not in obj.keys():
275+
# Catch case when GeoJSON is just a geometry.
276+
return {"type": "Feature", "geometry": obj}
277+
return {"type": "FeatureCollection", "features": [obj]}
278+
279+
280+
def _convert_from_times(obj: dict, duration: str, add_last_point: bool) -> dict:
281+
"Converts a GeoJson from the TimeDimension format to the Timeline format"
282+
obj = _convert_to_feature_collection(obj)
283+
if duration is not None:
284+
duration = int(isodate.parse_duration(duration).total_seconds() * 1000)
285+
else:
286+
# if duration is none, we show until the end of times
287+
duration = (
288+
jq.compile(
289+
"""
290+
.features[].properties.times |
291+
flatten | max as $highest | $highest |
292+
try (fromdate) catch $highest
293+
"""
294+
)
295+
.input(obj)
296+
.first()
297+
)
298+
transform_times = """
299+
.features |= (.[] |
300+
[
301+
# collect coordinates and times
302+
([.geometry.coordinates,
303+
[.properties.times[] as $times | $times |
304+
# try to convert from strings
305+
# to timestamps, but if it fails
306+
# return it unchanged
307+
try (fromdate) catch $times
308+
]
309+
]
310+
# match coordinates to times
311+
# making new elements for each combination
312+
| transpose
313+
# make an array
314+
| .[]
315+
# and make a dict for each array element
316+
| {"coordinates": .[0], "start": .[1]}
317+
# store it for safe keeping
318+
) as $features
319+
# remove the old coordinates and times
320+
| del(.geometry.coordinates, .properties.times)
321+
# insert saved coordinates
322+
| .geometry += {"coordinates": $features.coordinates}
323+
# insert saved time(s) as start
324+
| .properties +=
325+
{"start": $features.start,
326+
# calculate the end value
327+
"end": ($features.start | . + ($duration | fromjson))}
328+
])
329+
"""
330+
331+
without_times = (
332+
jq.compile(
333+
transform_times,
334+
args={"duration": str(duration)},
335+
)
336+
.input(obj)
337+
.first()
338+
)
339+
340+
if add_last_point:
341+
without_times = (
342+
jq.compile(
343+
"""
344+
.features[]
345+
| select(.geometry.type="LineString"
346+
and (.coordinates | length) == 0)
347+
| .geometry.type |= "Point"
348+
"""
349+
)
350+
.input(without_times)
351+
.first()
352+
)
353+
354+
return without_times

requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
branca>=0.6.0
2+
isodate
23
jinja2>=2.9
4+
jq
35
numpy
46
requests
57
xyzservices

0 commit comments

Comments
 (0)