Skip to content

Commit

Permalink
Add docstrings + transform from times to start
Browse files Browse the repository at this point in the history
  • Loading branch information
hansthen committed Jan 24, 2024
1 parent 6336e2d commit 1e79985
Show file tree
Hide file tree
Showing 2 changed files with 302 additions and 10 deletions.
310 changes: 300 additions & 10 deletions folium/plugins/timeline.py
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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);
Expand All @@ -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
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
branca>=0.6.0
isodate
jinja2>=2.9
jq
numpy
requests
xyzservices

0 comments on commit 1e79985

Please sign in to comment.