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",
[