diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 99e0d4905..39992c9c5 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -36,6 +36,9 @@ jobs: --file requirements.txt --file requirements-dev.txt + - name: Install js_loader + run: python -m pip install js_loader pythonmonkey + - name: Install folium from source run: python -m pip install -e . --no-deps --force-reinstall diff --git a/.github/workflows/test_code.yml b/.github/workflows/test_code.yml index ee75fe41e..b68e1a0ec 100644 --- a/.github/workflows/test_code.yml +++ b/.github/workflows/test_code.yml @@ -33,5 +33,8 @@ jobs: - name: Install folium from source run: python -m pip install -e . --no-deps --force-reinstall + - name: Install js_loader + run: python -m pip install js_loader + - name: Code tests run: python -m pytest -vv --ignore=tests/selenium diff --git a/.github/workflows/test_latest_branca.yml b/.github/workflows/test_latest_branca.yml index 73b025402..a29107447 100644 --- a/.github/workflows/test_latest_branca.yml +++ b/.github/workflows/test_latest_branca.yml @@ -26,6 +26,10 @@ jobs: shell: bash -l {0} run: python -m pip install -e . --no-deps --force-reinstall + - name: Install js_loader + shell: bash -l {0} + run: python -m pip install js_loader + - name: Tests with latest branca shell: bash -l {0} run: | diff --git a/.github/workflows/test_mypy.yml b/.github/workflows/test_mypy.yml index e99fb75ca..0a2e7332c 100644 --- a/.github/workflows/test_mypy.yml +++ b/.github/workflows/test_mypy.yml @@ -27,6 +27,11 @@ jobs: run: | python -m pip install -e . --no-deps --force-reinstall + - name: Install js_loader + shell: bash -l {0} + run: | + python -m pip install js_loader + - name: Mypy test shell: bash -l {0} run: | diff --git a/.github/workflows/test_selenium.yml b/.github/workflows/test_selenium.yml index 34cd05430..494b0181f 100644 --- a/.github/workflows/test_selenium.yml +++ b/.github/workflows/test_selenium.yml @@ -30,6 +30,10 @@ jobs: shell: bash -l {0} run: python -m pip install -e . --no-deps --force-reinstall + - name: Install js_loader + shell: bash -l {0} + run: python -m pip install js_loader + - name: Selenium tests shell: bash -l {0} run: python -m pytest tests/selenium -vv diff --git a/docs/advanced_guide.rst b/docs/advanced_guide.rst index 579eada3d..f4aa1d748 100644 --- a/docs/advanced_guide.rst +++ b/docs/advanced_guide.rst @@ -15,3 +15,4 @@ Advanced guide advanced_guide/piechart_icons advanced_guide/polygons_from_list_of_points advanced_guide/customize_javascript_and_css + advanced_guide/js_loader diff --git a/docs/advanced_guide/js/handlers.js b/docs/advanced_guide/js/handlers.js new file mode 100644 index 000000000..90331e297 --- /dev/null +++ b/docs/advanced_guide/js/handlers.js @@ -0,0 +1,39 @@ +/* located in js/handlers.js */ +on_each_feature = function(f, l) { + l.bindPopup(function() { + return '
' + dayjs.unix(f.properties.timestamp).format() + '
'; + }); +} + +source = function(responseHandler, errorHandler) { + var url = 'https://api.wheretheiss.at/v1/satellites/25544'; + + fetch(url) + .then((response) => { + return response.json().then((data) => { + var { id, timestamp, longitude, latitude } = data; + + return { + 'type': 'FeatureCollection', + 'features': [{ + 'type': 'Feature', + 'geometry': { + 'type': 'Point', + 'coordinates': [longitude, latitude] + }, + 'properties': { + 'id': id, + 'timestamp': timestamp + } + }] + }; + }) + }) + .then(responseHandler) + .catch(errorHandler); +} + +module.exports = { + source, + on_each_feature +} diff --git a/docs/advanced_guide/js_loader.md b/docs/advanced_guide/js_loader.md new file mode 100644 index 000000000..a355fe2fb --- /dev/null +++ b/docs/advanced_guide/js_loader.md @@ -0,0 +1,76 @@ +# Loading event handlers from a CommonJS module + +```{code-cell} ipython3 +--- +nbsphinx: hidden +--- +import folium +``` + +## Loading Event handlers from javascript +Folium supports event handlers via the `JsCode` class. However, for more than a few lines of code, it becomes unwieldy to write javascript inside python using +only strings. For more complex code, it is much nicer to write javascript inside js files. This allows editor support, such as syntax highlighting, code completion +and linting. + +Suppose we have the following javascript file: + +``` +/* located in js/handlers.js */ +on_each_feature = function(f, l) { + l.bindPopup(function() { + return '
' + dayjs.unix(f.properties.timestamp).format() + '
'; + }); +} + +source = function(responseHandler, errorHandler) { + var url = 'https://api.wheretheiss.at/v1/satellites/25544'; + + fetch(url) + .then((response) => { + return response.json().then((data) => { + var { id, timestamp, longitude, latitude } = data; + + return { + 'type': 'FeatureCollection', + 'features': [{ + 'type': 'Feature', + 'geometry': { + 'type': 'Point', + 'coordinates': [longitude, latitude] + }, + 'properties': { + 'id': id, + 'timestamp': timestamp + } + }] + }; + }) + }) + .then(responseHandler) + .catch(errorHandler); +} + +module.exports = { + source, + on_each_feature +} +``` + +Now we can load it as follows inside our python code: + +```{code-cell} ipython3 +from js_loader import install_js_loader +from folium.plugins import Realtime +install_js_loader() + +from js import handlers + +m = folium.Map() + +rt = Realtime(handlers.source, + on_each_feature=handlers.on_each_feature, + interval=1000) +rt.add_js_link("dayjs", "https://cdn.jsdelivr.net/npm/dayjs@1.11.10/dayjs.min.js") +rt.add_to(m) +m +``` diff --git a/folium/elements.py b/folium/elements.py index 56b3ba904..e3f85e727 100644 --- a/folium/elements.py +++ b/folium/elements.py @@ -9,7 +9,7 @@ ) from folium.template import Template -from folium.utilities import JsCode +from folium.utilities import TypeJsCode class JSCSSMixin(MacroElement): @@ -121,7 +121,7 @@ class EventHandler(MacroElement): """ ) - def __init__(self, event: str, handler: JsCode, once: bool = False): + def __init__(self, event: str, handler: TypeJsCode, once: bool = False): super().__init__() self._name = "EventHandler" self.event = event diff --git a/folium/plugins/timeline.py b/folium/plugins/timeline.py index fce034024..4a26018f4 100644 --- a/folium/plugins/timeline.py +++ b/folium/plugins/timeline.py @@ -6,7 +6,7 @@ from folium.features import GeoJson from folium.folium import Map from folium.template import Template -from folium.utilities import JsCode, get_bounds, remove_empty +from folium.utilities import JsCode, TypeJsCode, get_bounds, remove_empty class Timeline(GeoJson): @@ -108,7 +108,7 @@ class Timeline(GeoJson): def __init__( self, data: Union[dict, str, TextIO], - get_interval: Optional[JsCode] = None, + get_interval: Optional[TypeJsCode] = None, **kwargs ): super().__init__(data) diff --git a/folium/template.py b/folium/template.py index 4dcd01f0b..52d831ecf 100644 --- a/folium/template.py +++ b/folium/template.py @@ -4,11 +4,11 @@ import jinja2 from branca.element import Element -from folium.utilities import JsCode, TypeJsonValue, camelize +from folium.utilities import TypeJsCode, TypeJsonValue, camelize -def tojavascript(obj: Union[str, JsCode, dict, list, Element]) -> str: - if isinstance(obj, JsCode): +def tojavascript(obj: Union[str, TypeJsCode, dict, list, Element]) -> str: + if isinstance(obj, TypeJsCode): return obj.js_code elif isinstance(obj, Element): return obj.get_name() diff --git a/folium/utilities.py b/folium/utilities.py index 9210bf59e..45b387628 100644 --- a/folium/utilities.py +++ b/folium/utilities.py @@ -17,10 +17,12 @@ Iterator, List, Optional, + Protocol, Sequence, Tuple, Type, Union, + runtime_checkable, ) from urllib.parse import urlparse, uses_netloc, uses_params, uses_relative @@ -64,6 +66,25 @@ _VALID_URLS.add("data") +@runtime_checkable +class TypeJsCode(Protocol): + # we only care about this attribute. + js_code: str + + +class JsCode(TypeJsCode): + """Wrapper around Javascript code.""" + + def __init__(self, js_code: Union[str, "JsCode"]): + if isinstance(js_code, JsCode): + self.js_code: str = js_code.js_code + else: + self.js_code = js_code + + def __str__(self): + return self.js_code + + def validate_location(location: Sequence[float]) -> List[float]: """Validate a single lat/lon coordinate pair and convert to a list @@ -436,19 +457,6 @@ def get_and_assert_figure_root(obj: Element) -> Figure: return figure -class JsCode: - """Wrapper around Javascript code.""" - - def __init__(self, js_code: Union[str, "JsCode"]): - if isinstance(js_code, JsCode): - self.js_code: str = js_code.js_code - else: - self.js_code = js_code - - def __str__(self): - return self.js_code - - def parse_font_size(value: Union[str, int, float]) -> str: """Parse a font size value, if number set as px""" if isinstance(value, (int, float)): diff --git a/tests/test_utilities.py b/tests/test_utilities.py index a1f10be36..12e21f4fe 100644 --- a/tests/test_utilities.py +++ b/tests/test_utilities.py @@ -1,10 +1,12 @@ import numpy as np import pandas as pd import pytest +from js_loader import JsCode as external_JsCode from folium import FeatureGroup, Map, Marker, Popup from folium.utilities import ( JsCode, + TypeJsCode, _is_url, camelize, deep_copy, @@ -248,6 +250,11 @@ def test_js_code_init_js_code(): assert isinstance(js_code_2.js_code, str) +def test_external_js_code(): + js_code = external_JsCode("hi") + assert isinstance(js_code, TypeJsCode) + + @pytest.mark.parametrize( "value,expected", [