1
+ from typing import Optional , TextIO , Union
2
+
3
+ import isodate
4
+ import jq
1
5
from branca .element import MacroElement
2
6
from jinja2 import Template
3
7
4
8
from folium .elements import JSCSSMixin
5
- from folium .utilities import parse_options
9
+ from folium .utilities import JsCode , camelize , parse_options
6
10
7
11
8
12
class Timeline (JSCSSMixin , MacroElement ):
9
13
"""
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
+
11
107
"""
12
108
13
109
_template = Template (
@@ -27,17 +123,19 @@ class Timeline(JSCSSMixin, MacroElement):
27
123
{% endmacro %}
28
124
29
125
{% 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
+
30
131
31
132
var {{ this.get_name() }} = L.timeline(
32
133
{{ this.data|tojson }},
33
- {{ this.options|tojson }}
134
+ {{ this.get_name() }}_options
135
+ );
136
+ var control = L.timelineSliderControl(
137
+ {{ this.get_name() }}_options
34
138
);
35
- var control = L.timelineSliderControl({
36
- formatOutput: function (date) {
37
- return new Date(date).toLocaleDateString();
38
- },
39
- enableKeyboardControls: true,
40
- });
41
139
control.addTo({{ this._parent.get_name() }});
42
140
43
141
{{ this._parent.get_name() }}.addControl(control);
@@ -56,9 +154,201 @@ class Timeline(JSCSSMixin, MacroElement):
56
154
)
57
155
]
58
156
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
+ ):
60
233
super ().__init__ ()
61
234
self ._name = "Timeline"
62
235
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
+
63
241
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
+
64
266
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
0 commit comments