diff --git a/folium/plugins/timeline.py b/folium/plugins/timeline.py index 6ff062eae..19edb5394 100644 --- a/folium/plugins/timeline.py +++ b/folium/plugins/timeline.py @@ -1,13 +1,109 @@ +from typing import Optional, TextIO, Union + +import isodate +import jq from branca.element import MacroElement from jinja2 import Template from folium.elements import JSCSSMixin -from folium.utilities import parse_options +from folium.utilities import JsCode, camelize, parse_options class Timeline(JSCSSMixin, MacroElement): """ - TODO + Creates a TimestampedGeoJson plugin from timestamped GeoJSONs to append + into a map with Map.add_child. + + There are three main methods to make a geojson timestamped: + + Adding a 'times' array property to each feature + ---------------- + * it contains only features of types LineString, MultiPoint, MultiLineString, + Polygon and MultiPolygon. + * each feature has a 'times' property with the same length as the + coordinates array. + * each element of each 'times' property is a timestamp in ms since epoch, + or in ISO string. + + Eventually, you may have Point features with a 'times' property being an + array of length 1. + + Add 'start' and 'end' properties to each feature. + ----------- + * Each feature contains a 'start' and 'end' property. The start and end + can be any comparable item. + + Use JsCode + ---------- + * Provide a JsCode `get_interval`. This function should take as parameter + a GeoJson feature and return either a dict containing values for + 'start', 'end', 'startExclusive' and 'endExcusive' or false if no + data could be extracted from the feature. + * 'start' and 'end' can be any comparable items + * 'startExclusive' and 'endExclusive' should be boolean values. + + Parameters + ---------- + data: file, dict or str. + The timestamped geo-json data you want to plot. + + * If file, then data will be read in the file and fully embedded in + Leaflet's javascript. + * If dict, then data will be converted to json and embedded in the + javascript. + * If str, then data will be passed to the javascript as-is. + + transition_time: int, default 200. + This parameter exists for backward compatibility but is ignored. + loop: bool, default True + This parameter exists for backward compatibility but is ignored. + period: str, default "P1D" + This parameter exists for backward compatibility but is ignored. + + duration: str, default None + Period of time which the features will be shown on the map after their + time has passed. If None, all previous times will be shown. + Format: ISO8601 Duration + ex: 'P1M' 1/month, 'P1D' 1/day, 'PT1H' 1/hour, and 'PT1M' 1/minute + get_interval: JsCode + Called for each feature, and should return either a time range for the + feature or `false`, indicating that it should not be included in the + timeline. The time range object should have 'start' and 'end' properties. + Optionally, the boolean keys 'startExclusive' and 'endExclusive' allow the + interval to be considered exclusive. + + If `get_interval` is not provided, 'start' and 'end' properties are + assumed to be present on each feature. + start: str, int or float, default earliest 'start' in GeoJson + The beginning/minimum value of the timeline. + end: str, int or float, default latest 'end' in GeoJSON + The end/maximum value of the timeline. + add_last_point: bool, default True + If the FeatureType is a line string, add a the last remaining + element as a Point. + auto_play: bool, default True + Whether the animation shall start automatically at startup. + format_output: JsCode + A function that takes in a Unix timestamp and outputs a string. + enable_playback: bool, default True + Show playback controls (i.e. prev/play/pause/next). + enable_keyboard_controls: bool, default False + Allow playback to be controlled using the spacebar (play/pause) and + right/left arrow keys (next/previous). + show_ticks: bool, default True + Show tick marks on the slider + steps: int, default 1000 + How many steps to break the timeline into. + Each step will then be (end-start) / steps. Only affects playback. + playback_duration: int, default 10000 + Minimum time, in ms, for the playback to take. Will almost certainly + actually take at least a bit longer -- after each frame, the next + one displays in playback_duration/steps ms, so each frame really + takes frame processing time PLUS step time. + + Other keyword arguments are passed to the GeoJson layer, so you can pass + `style`, `point_to_layer` and/or `on_each_feature`. + """ _template = Template( @@ -27,17 +123,19 @@ class Timeline(JSCSSMixin, MacroElement): {% endmacro %} {% macro script(this, kwargs) %} + var {{ this.get_name() }}_options = {{ this.options|tojson }}; + {% for key, value in this.functions.items() %} + {{ this.get_name() }}_options["{{key}}"] = {{ value }}; + {% endfor %} + var {{ this.get_name() }} = L.timeline( {{ this.data|tojson }}, - {{ this.options|tojson }} + {{ this.get_name() }}_options + ); + var control = L.timelineSliderControl( + {{ this.get_name() }}_options ); - var control = L.timelineSliderControl({ - formatOutput: function (date) { - return new Date(date).toLocaleDateString(); - }, - enableKeyboardControls: true, - }); control.addTo({{ this._parent.get_name() }}); {{ this._parent.get_name() }}.addControl(control); @@ -56,9 +154,201 @@ class Timeline(JSCSSMixin, MacroElement): ) ] - def __init__(self, data, **kwargs): + format_output = JsCode( + """ + function (date) { + return new Date(date).toLocaleDateString(); + } + """ + ) + + style = JsCode( + """ + function (feature) { + return feature.properties.style; + } + """ + ) + + on_each_feature = JsCode( + """ + function(feature, layer) { + if (feature.properties.popup) { + layer.bindPopup(feature.properties.popup); + } + if (feature.properties.tooltip) { + layer.bindTooltip(feature.properties.tooltip); + } + } + """ + ) + + point_to_layer = JsCode( + """ + function (feature, latLng) { + if (feature.properties.icon == 'marker') { + if(feature.properties.iconstyle) { + return new L.Marker(latLng, { + icon: L.icon(feature.properties.iconstyle)}); + } + //else + return new L.Marker(latLng); + } + if (feature.properties.icon == 'circle') { + if (feature.properties.iconstyle) { + return new L.circleMarker(latLng, + feature.properties.iconstyle) + }; + //else + return new L.circleMarker(latLng); + } + //else + + return new L.Marker(latLng); + } + """ + ) + + def __init__( + self, + data: Union[TextIO, str, dict], + duration: str = None, + convert: bool = True, + get_interval: Optional[JsCode] = None, + start: Union[str, int, float] = None, + end: Union[str, int, float] = None, + add_last_point: bool = True, + auto_play: bool = True, + format_output: Optional[JsCode] = format_output, + enable_playback: bool = True, + enable_keyboard_controls: bool = False, + show_ticks: bool = True, + steps: int = 1000, + playback_duration: int = 10000, + point_to_layer: Optional[JsCode] = point_to_layer, + style: Optional[JsCode] = style, + on_each_feature: Optional[JsCode] = on_each_feature, + **kwargs + ): super().__init__() self._name = "Timeline" + # If required we take a GeoJson with 'times' and convert + # to 'start' and 'end' + if convert: + data = _convert_from_times(data, duration, add_last_point) + self.data = data + kwargs["start"] = start + kwargs["end"] = end + kwargs["auto_play"] = auto_play + kwargs["enable_playback"] = enable_playback + kwargs["enable_keyboard_controls"] = enable_keyboard_controls + kwargs["show_ticks"] = show_ticks + kwargs["steps"] = steps + kwargs["duration"] = playback_duration + kwargs["point_to_layer"] = point_to_layer + kwargs["on_each_feature"] = on_each_feature + kwargs["style"] = style + + if format_output is not None: + kwargs["format_output"] = format_output + if get_interval is not None: + kwargs["get_interval"] = get_interval + + # extract JsCode objects + self.functions = {} + for key, value in list(kwargs.items()): + if isinstance(value, JsCode): + self.functions[camelize(key)] = value.js_code + kwargs.pop(key) + self.options = parse_options(**kwargs) + + +def _convert_to_feature_collection(obj) -> dict: + """Convert data into a FeatureCollection if it is not already.""" + if obj["type"] == "FeatureCollection": + return obj + # Catch case when GeoJSON is just a single Feature or a geometry. + if "geometry" not in obj.keys(): + # Catch case when GeoJSON is just a geometry. + return {"type": "Feature", "geometry": obj} + return {"type": "FeatureCollection", "features": [obj]} + + +def _convert_from_times(obj: dict, duration: str, add_last_point: bool) -> dict: + "Converts a GeoJson from the TimeDimension format to the Timeline format" + obj = _convert_to_feature_collection(obj) + if duration is not None: + duration = int(isodate.parse_duration(duration).total_seconds() * 1000) + else: + # if duration is none, we show until the end of times + duration = ( + jq.compile( + """ + .features[].properties.times | + flatten | max as $highest | $highest | + try (fromdate) catch $highest + """ + ) + .input(obj) + .first() + ) + transform_times = """ + .features |= (.[] | + [ + # collect coordinates and times + ([.geometry.coordinates, + [.properties.times[] as $times | $times | + # try to convert from strings + # to timestamps, but if it fails + # return it unchanged + try (fromdate) catch $times + ] + ] + # match coordinates to times + # making new elements for each combination + | transpose + # make an array + | .[] + # and make a dict for each array element + | {"coordinates": .[0], "start": .[1]} + # store it for safe keeping + ) as $features + # remove the old coordinates and times + | del(.geometry.coordinates, .properties.times) + # insert saved coordinates + | .geometry += {"coordinates": $features.coordinates} + # insert saved time(s) as start + | .properties += + {"start": $features.start, + # calculate the end value + "end": ($features.start | . + ($duration | fromjson))} + ]) + """ + + without_times = ( + jq.compile( + transform_times, + args={"duration": str(duration)}, + ) + .input(obj) + .first() + ) + + if add_last_point: + without_times = ( + jq.compile( + """ + .features[] + | select(.geometry.type="LineString" + and (.coordinates | length) == 0) + | .geometry.type |= "Point" + """ + ) + .input(without_times) + .first() + ) + + return without_times diff --git a/requirements.txt b/requirements.txt index dff8e41f4..5263e3d0d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,7 @@ branca>=0.6.0 +isodate jinja2>=2.9 +jq numpy requests xyzservices