From ee4ad66ed3f3cd78a94c53e7590c823b13cccb0c Mon Sep 17 00:00:00 2001
From: Fateme <108584390+Fatemebookanian@users.noreply.github.com>
Date: Sun, 24 May 2026 12:14:59 +0000
Subject: [PATCH 1/6] feat: add WebGL Earth 3D globe plugin
- WebGLEarth: 3D globe that replaces the flat Leaflet map
- WebGLEarthMarker: static markers on the globe
- WebGLEarthTileLayer: additional tile layer overlays
- WebGLEarthRealtime: live-updating data (e.g. ISS tracker)
Closes #2162
---
folium/plugins/webgl_earth.py | 337 ++++++++++++++++++++++++++++++
tests/plugins/test_webgl_earth.py | 259 +++++++++++++++++++++++
2 files changed, 596 insertions(+)
create mode 100644 folium/plugins/webgl_earth.py
create mode 100644 tests/plugins/test_webgl_earth.py
diff --git a/folium/plugins/webgl_earth.py b/folium/plugins/webgl_earth.py
new file mode 100644
index 0000000000..45d1dee381
--- /dev/null
+++ b/folium/plugins/webgl_earth.py
@@ -0,0 +1,337 @@
+
+from typing import List, Optional, Union
+
+from branca.element import MacroElement
+from folium.elements import JSCSSMixin
+from folium.template import Template
+from folium.utilities import JsCode, remove_empty, validate_location
+
+
+class WebGLEarth(JSCSSMixin, MacroElement):
+ """Create a 3D globe visualization using WebGL Earth.
+
+ Based on: https://www.webglearth.com/
+
+ Parameters
+ ----------
+ center : list, default [20, 0]
+ Initial center of the globe as [lat, lng].
+ zoom : float, default 2.5
+ Initial zoom level of the globe.
+ tile_url : str, optional
+ URL template for the tile layer. Defaults to OpenStreetMap tiles.
+ tile_subdomains : str, default "abc"
+ Subdomains for the tile layer URL.
+ height : int, default 600
+ Height of the globe container in pixels.
+ atmosphere : bool, default True
+ Whether to show the atmosphere effect around the globe.
+
+ Examples
+ --------
+ >>> import folium
+ >>> from folium.plugins import WebGLEarth, WebGLEarthMarker
+
+ >>> m = folium.Map()
+ >>> globe = WebGLEarth(center=[48.2, 16.4], zoom=4)
+ >>> globe.add_to(m)
+
+ >>> marker = WebGLEarthMarker(
+ ... location=[48.2, 16.4],
+ ... popup="Vienna",
+ ... )
+ >>> marker.add_to(globe)
+ """
+
+ _template = Template(
+ """
+ {% macro header(this, kwargs) %}
+
+ {% endmacro %}
+
+ {% macro html(this, kwargs) %}
+
+ {% endmacro %}
+
+ {% macro script(this, kwargs) %}
+ var {{ this.get_name() }} = WE.map(
+ '{{ this.container_id }}',
+ {{ this.options | tojavascript }}
+ );
+
+ WE.tileLayer(
+ {{ this.tile_url | tojson }},
+ {
+ attribution: '© OpenStreetMap contributors',
+ subdomains: {{ this.tile_subdomains | tojson }}
+ }
+ ).addTo({{ this.get_name() }});
+
+ // Block right-click tilt
+ (function() {
+ var container = document.getElementById('{{ this.container_id }}');
+ container.addEventListener('contextmenu', function(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ });
+ container.addEventListener('mousedown', function(e) {
+ if (e.button === 2) {
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ });
+ })();
+ {% endmacro %}
+ """
+ )
+
+ default_js = [
+ (
+ "webglearth_v2_js",
+ "https://www.webglearth.com/v2/api.js",
+ ),
+ ]
+
+ def __init__(
+ self,
+ center: Optional[List[float]] = None,
+ zoom: float = 2.5,
+ tile_url: Optional[str] = None,
+ tile_subdomains: str = "abc",
+ height: int = 600,
+ atmosphere: bool = True,
+ ):
+ super().__init__()
+ self._name = "WebGLEarth"
+
+ if center is None:
+ center = [20, 0]
+ self.center = validate_location(center)
+ if not (-90 <= self.center[0] <= 90) or not (-180 <= self.center[1] <= 180):
+ raise ValueError(
+ f"Invalid center: {list(self.center)}. "
+ "Latitude must be -90..90, longitude -180..180."
+ )
+ self.zoom = zoom
+ self.height = height
+ self.tile_url = (
+ tile_url or "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
+ )
+ self.tile_subdomains = list(tile_subdomains)
+ self.container_id = f"webgl-earth-{self.get_name()}"
+
+ self.options = remove_empty(
+ center=list(self.center),
+ zoom=zoom,
+ atmosphere=atmosphere,
+ )
+
+
+class WebGLEarthMarker(MacroElement):
+ """Add a marker to a WebGLEarth globe.
+
+ Parameters
+ ----------
+ location : list
+ Marker location as [lat, lng].
+ popup : str, optional
+ Text to show in a popup when the marker is clicked.
+
+ Examples
+ --------
+ >>> globe = WebGLEarth()
+ >>> marker = WebGLEarthMarker(
+ ... location=[48.2, 16.4],
+ ... popup="Vienna",
+ ... )
+ >>> marker.add_to(globe)
+ """
+
+ _template = Template(
+ """
+ {% macro script(this, kwargs) %}
+ var {{ this.get_name() }} = WE.marker(
+ [{{ this.location[0] }}, {{ this.location[1] }}]
+ ).addTo({{ this._parent.get_name() }});
+ {% if this.popup %}
+ {{ this.get_name() }}.bindPopup({{ this.popup | tojson }});
+ {% endif %}
+ {% endmacro %}
+ """
+ )
+
+ def __init__(
+ self,
+ location: List[float],
+ popup: Optional[str] = None,
+ ):
+ super().__init__()
+ self._name = "WebGLEarthMarker"
+ self.location = validate_location(location)
+ if not (-90 <= self.location[0] <= 90) or not (-180 <= self.location[1] <= 180):
+ raise ValueError(
+ f"Invalid location: {list(self.location)}. "
+ "Latitude must be -90..90, longitude -180..180."
+ )
+ self.popup = popup
+
+
+class WebGLEarthTileLayer(MacroElement):
+ """Add an additional tile layer to a WebGLEarth globe.
+
+ Parameters
+ ----------
+ url : str
+ URL template for the tile layer.
+ attribution : str, default ""
+ Attribution text for the tile layer.
+ subdomains : str, default "abc"
+ Subdomains for the tile layer URL.
+ opacity : float, default 1.0
+ Opacity of the tile layer (0.0 to 1.0).
+
+ Examples
+ --------
+ >>> globe = WebGLEarth()
+ >>> tiles = WebGLEarthTileLayer(
+ ... url="https://tiles.example.com/{z}/{x}/{y}.png",
+ ... attribution="Example Tiles",
+ ... opacity=0.7,
+ ... )
+ >>> tiles.add_to(globe)
+ """
+
+ _template = Template(
+ """
+ {% macro script(this, kwargs) %}
+ var {{ this.get_name() }} = WE.tileLayer(
+ {{ this.url | tojson }},
+ {{ this.options | tojavascript }}
+ ).addTo({{ this._parent.get_name() }});
+ {% endmacro %}
+ """
+ )
+
+ def __init__(
+ self,
+ url: str,
+ attribution: str = "",
+ subdomains: str = "abc",
+ opacity: float = 1.0,
+ ):
+ super().__init__()
+ self._name = "WebGLEarthTileLayer"
+ self.url = url
+ self.options = remove_empty(
+ attribution=attribution,
+ subdomains=list(subdomains),
+ opacity=opacity,
+ )
+
+
+class WebGLEarthRealtime(JSCSSMixin, MacroElement):
+ """Show realtime-updating data on a WebGLEarth globe.
+
+ This is the 3D equivalent of the Realtime plugin.
+
+ Parameters
+ ----------
+ source_url : str
+ URL to fetch JSON data from.
+ interval : int, default 5000
+ Update interval in milliseconds.
+ on_update : str or JsCode
+ A JavaScript function called with (data, earth) on each update.
+ Use this to parse the response and update markers/layers.
+
+ Examples
+ --------
+ >>> from folium.utilities import JsCode
+ >>> globe = WebGLEarth(center=[0, 0], zoom=1.5)
+ >>> iss = WebGLEarthRealtime(
+ ... source_url="https://api.wheretheiss.at/v1/satellites/25544",
+ ... interval=3000,
+ ... on_update=JsCode('''
+ ... function(data, earth) {
+ ... if (window._issMarker) window._issMarker.removeFrom(earth);
+ ... window._issMarker = WE.marker(
+ ... [data.latitude, data.longitude]
+ ... ).addTo(earth);
+ ... }
+ ... '''),
+ ... )
+ >>> iss.add_to(globe)
+ """
+
+ _template = Template(
+ """
+ {% macro script(this, kwargs) %}
+ (function() {
+ var earth = {{ this._parent.get_name() }};
+ function {{ this.get_name() }}_update() {
+ fetch({{ this.source_url | tojson }})
+ .then(function(response) { return response.json(); })
+ .then(function(data) {
+ var callback = {{ this.on_update.js_code }};
+ callback(data, earth);
+ })
+ .catch(function(err) {
+ console.warn('WebGLEarthRealtime error:', err);
+ });
+ }
+ {{ this.get_name() }}_update();
+ setInterval({{ this.get_name() }}_update, {{ this.interval }});
+ })();
+ {% endmacro %}
+ """
+ )
+
+ def __init__(
+ self,
+ source_url: str,
+ interval: int = 5000,
+ on_update: Union[JsCode, str, None] = None,
+ ):
+ super().__init__()
+ self._name = "WebGLEarthRealtime"
+ self.source_url = source_url
+ self.interval = interval
+ if on_update is None:
+ raise ValueError("on_update is required.")
+ self.on_update = JsCode(on_update)
diff --git a/tests/plugins/test_webgl_earth.py b/tests/plugins/test_webgl_earth.py
new file mode 100644
index 0000000000..87d6139a24
--- /dev/null
+++ b/tests/plugins/test_webgl_earth.py
@@ -0,0 +1,259 @@
+"""
+Tests for the WebGL Earth folium plugin.
+
+Run with: pytest test_webgl_earth.py -v
+"""
+
+import folium
+import pytest
+from folium.utilities import JsCode
+
+from folium.plugins.webgl_earth import (
+ WebGLEarth,
+ WebGLEarthMarker,
+ WebGLEarthRealtime,
+ WebGLEarthTileLayer
+)
+
+
+# ─────────────────────────── WebGLEarth ───────────────────────────
+
+
+class TestWebGLEarth:
+ def test_default(self):
+ globe = WebGLEarth()
+ assert globe._name == "WebGLEarth"
+ assert list(globe.center) == [20, 0]
+ assert globe.zoom == 2.5
+ assert globe.height == 600
+
+ def test_custom_params(self):
+ globe = WebGLEarth(
+ center=[48.2, 16.4],
+ zoom=5,
+ height=400,
+ atmosphere=False,
+ )
+ assert list(globe.center) == [48.2, 16.4]
+ assert globe.zoom == 5
+ assert globe.height == 400
+ assert globe.options["atmosphere"] is False
+
+ def test_custom_tile_url(self):
+ url = "https://tiles.example.com/{z}/{x}/{y}.png"
+ globe = WebGLEarth(tile_url=url)
+ assert globe.tile_url == url
+
+ def test_tile_subdomains(self):
+ globe = WebGLEarth(tile_subdomains="1234")
+ assert globe.tile_subdomains == ["1", "2", "3", "4"]
+
+ def test_invalid_center(self):
+ with pytest.raises(Exception):
+ WebGLEarth(center=[999, 999])
+
+ def test_add_to_map(self):
+ m = folium.Map()
+ globe = WebGLEarth()
+ globe.add_to(m)
+ html = m._repr_html_()
+ assert "webglearth" in html.lower() or "WE.map" in html
+
+ def test_renders_js_dependency(self):
+ m = folium.Map()
+ globe = WebGLEarth()
+ globe.add_to(m)
+ html = m._repr_html_()
+ assert "webglearth.com/v2/api.js" in html
+
+ def test_container_id_unique(self):
+ g1 = WebGLEarth()
+ g2 = WebGLEarth()
+ assert g1.container_id != g2.container_id
+
+ def test_renders_reset_button(self):
+ m = folium.Map()
+ globe = WebGLEarth()
+ globe.add_to(m)
+ html = m._repr_html_()
+ assert "Reset View" in html
+
+
+# ─────────────────────────── WebGLEarthMarker ────────────────────
+
+
+class TestWebGLEarthMarker:
+ def test_default(self):
+ marker = WebGLEarthMarker(location=[48.2, 16.4])
+ assert marker._name == "WebGLEarthMarker"
+ assert list(marker.location) == [48.2, 16.4]
+ assert marker.popup is None
+
+ def test_with_popup(self):
+ marker = WebGLEarthMarker(location=[48.2, 16.4], popup="Vienna")
+ assert marker.popup == "Vienna"
+
+ def test_invalid_location(self):
+ with pytest.raises(Exception):
+ WebGLEarthMarker(location=[999, 999])
+
+ def test_renders_on_globe(self):
+ m = folium.Map()
+ globe = WebGLEarth()
+ globe.add_to(m)
+ WebGLEarthMarker(location=[48.2, 16.4], popup="Test").add_to(globe)
+ html = m._repr_html_()
+ assert "WE.marker" in html
+ assert "Test" in html
+
+ def test_marker_references_parent(self):
+ m = folium.Map()
+ globe = WebGLEarth()
+ globe.add_to(m)
+ marker = WebGLEarthMarker(location=[0, 0])
+ marker.add_to(globe)
+ html = m._repr_html_()
+ assert f".addTo({globe.get_name()})" in html
+
+
+# ─────────────────────────── WebGLEarthTileLayer ─────────────────
+
+
+class TestWebGLEarthTileLayer:
+ def test_default(self):
+ tiles = WebGLEarthTileLayer(url="https://example.com/{z}/{x}/{y}.png")
+ assert tiles._name == "WebGLEarthTileLayer"
+ assert tiles.url == "https://example.com/{z}/{x}/{y}.png"
+
+ def test_with_options(self):
+ tiles = WebGLEarthTileLayer(
+ url="https://example.com/{z}/{x}/{y}.png",
+ attribution="Test",
+ opacity=0.5,
+ )
+ assert tiles.options["attribution"] == "Test"
+ assert tiles.options["opacity"] == 0.5
+
+ def test_renders_on_globe(self):
+ m = folium.Map()
+ globe = WebGLEarth()
+ globe.add_to(m)
+ WebGLEarthTileLayer(url="https://example.com/{z}/{x}/{y}.png").add_to(
+ globe
+ )
+ html = m._repr_html_()
+ assert "WE.tileLayer" in html
+ assert "example.com" in html
+
+
+# ─────────────────────────── WebGLEarthRealtime ──────────────────
+
+
+class TestWebGLEarthRealtime:
+ def _make_callback(self):
+ return JsCode(
+ """
+ function(data, earth) {
+ WE.marker([data.lat, data.lng]).addTo(earth);
+ }
+ """
+ )
+
+ def test_default(self):
+ rt = WebGLEarthRealtime(
+ source_url="https://api.example.com/data",
+ on_update=self._make_callback(),
+ )
+ assert rt._name == "WebGLEarthRealtime"
+ assert rt.source_url == "https://api.example.com/data"
+ assert rt.interval == 5000
+
+ def test_custom_interval(self):
+ rt = WebGLEarthRealtime(
+ source_url="https://api.example.com/data",
+ interval=2000,
+ on_update=self._make_callback(),
+ )
+ assert rt.interval == 2000
+
+ def test_requires_on_update(self):
+ with pytest.raises(ValueError, match="on_update is required"):
+ WebGLEarthRealtime(source_url="https://api.example.com/data")
+
+ def test_accepts_string_callback(self):
+ rt = WebGLEarthRealtime(
+ source_url="https://api.example.com/data",
+ on_update="function(data, earth) {}",
+ )
+ assert isinstance(rt.on_update, JsCode)
+
+ def test_renders_on_globe(self):
+ m = folium.Map()
+ globe = WebGLEarth()
+ globe.add_to(m)
+ WebGLEarthRealtime(
+ source_url="https://api.example.com/data",
+ interval=3000,
+ on_update=self._make_callback(),
+ ).add_to(globe)
+ html = m._repr_html_()
+ assert "api.example.com/data" in html
+ assert "setInterval" in html
+
+ def test_renders_fetch_call(self):
+ m = folium.Map()
+ globe = WebGLEarth()
+ globe.add_to(m)
+ WebGLEarthRealtime(
+ source_url="https://api.example.com/data",
+ on_update=self._make_callback(),
+ ).add_to(globe)
+ html = m._repr_html_()
+ assert "fetch(" in html
+
+
+# ─────────────────────────── Integration ─────────────────────────
+
+
+class TestIntegration:
+ def test_full_setup(self):
+ """Full globe with markers, tiles, and realtime — must render."""
+ m = folium.Map()
+ globe = WebGLEarth(center=[0, 0], zoom=2)
+ globe.add_to(m)
+
+ WebGLEarthMarker(location=[48.8, 2.3], popup="Paris").add_to(globe)
+ WebGLEarthMarker(location=[35.7, 139.7], popup="Tokyo").add_to(globe)
+
+ WebGLEarthTileLayer(
+ url="https://tiles.example.com/{z}/{x}/{y}.png",
+ opacity=0.5,
+ ).add_to(globe)
+
+ WebGLEarthRealtime(
+ source_url="https://api.example.com/live",
+ interval=5000,
+ on_update=JsCode("function(d, e) {}"),
+ ).add_to(globe)
+
+ html = m._repr_html_()
+ assert "WE.map" in html
+ assert "WE.marker" in html
+ assert "WE.tileLayer" in html
+ assert "setInterval" in html
+ assert "Paris" in html
+ assert "Tokyo" in html
+
+ def test_save_html(self, tmp_path):
+ """Must produce a valid HTML file."""
+ m = folium.Map()
+ globe = WebGLEarth()
+ globe.add_to(m)
+ WebGLEarthMarker(location=[0, 0], popup="Origin").add_to(globe)
+
+ path = tmp_path / "globe.html"
+ m.save(str(path))
+ content = path.read_text()
+ assert "" in content
+ assert "WE.map" in content
+ assert "Origin" in content
From ef79bec9b43b24be711745e363f0f912a7436962 Mon Sep 17 00:00:00 2001
From: Fateme <108584390+Fatemebookanian@users.noreply.github.com>
Date: Sun, 24 May 2026 12:22:27 +0000
Subject: [PATCH 2/6] docs: add WebGL Earth plugin documentation
---
docs/user_guide/plugins.rst | 4 ++
docs/user_guide/plugins/webgl_earth.md | 95 ++++++++++++++++++++++++++
2 files changed, 99 insertions(+)
create mode 100644 docs/user_guide/plugins/webgl_earth.md
diff --git a/docs/user_guide/plugins.rst b/docs/user_guide/plugins.rst
index 7bfbd95bc3..0de18d3838 100644
--- a/docs/user_guide/plugins.rst
+++ b/docs/user_guide/plugins.rst
@@ -40,6 +40,8 @@ Plugins
plugins/timestamped_geojson
plugins/treelayercontrol
plugins/vector_tiles
+ plugins/webgl_earth
+
plugins/WmsTimeDimension
.. list-table::
@@ -120,5 +122,7 @@ Plugins
- Add a control for a tree of layers with checkboxes for visibility control.
* - :doc:`Vector Tiles using VectorGridProtobuf `
- Display gridded vector data (GeoJSON or TopoJSON sliced with geojson-vt, or protobuf vector tiles).
+ * - :doc:`WebGL Earth `
+ - Render a 3D interactive globe with live data support, replacing the flat Leaflet map.
* - :doc:`WMS Time Dimension `
- Create a time-aware WmsTileLayer.
diff --git a/docs/user_guide/plugins/webgl_earth.md b/docs/user_guide/plugins/webgl_earth.md
new file mode 100644
index 0000000000..b1152504ed
--- /dev/null
+++ b/docs/user_guide/plugins/webgl_earth.md
@@ -0,0 +1,95 @@
+```{code-cell} ipython3
+---
+nbsphinx: hidden
+---
+import folium
+import folium.plugins
+```
+
+# WebGL Earth
+
+Render a 3D interactive globe instead of a flat map, using the
+WebGL Earth v2 library.
+
+Based on: https://www.webglearth.com/
+
+The flat Leaflet map is replaced by a 3D globe. Markers, tile
+layers, and live data updates are all supported.
+
+## Simple example
+
+```{code-cell} ipython3
+import folium
+from folium.plugins import WebGLEarth, WebGLEarthMarker
+
+m = folium.Map()
+
+globe = WebGLEarth(center=[20, 0], zoom=2)
+globe.add_to(m)
+
+WebGLEarthMarker(location=[48.8566, 2.3522], popup="Paris").add_to(globe)
+WebGLEarthMarker(location=[35.6762, 139.6503], popup="Tokyo").add_to(globe)
+WebGLEarthMarker(location=[40.7128, -74.0060], popup="New York").add_to(globe)
+
+m
+```
+
+## Custom tile layer
+
+Add an additional tile overlay on top of the default OpenStreetMap tiles.
+
+```{code-cell} ipython3
+import folium
+from folium.plugins import WebGLEarth, WebGLEarthTileLayer
+
+m = folium.Map()
+
+globe = WebGLEarth(center=[20, 0], zoom=2)
+globe.add_to(m)
+
+WebGLEarthTileLayer(
+ url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
+ attribution="© OpenStreetMap contributors",
+ opacity=0.5,
+).add_to(globe)
+
+m
+```
+
+## Live ISS tracker
+
+Use `WebGLEarthRealtime` to show live-updating data on the globe.
+In this example we track the International Space Station using a
+public API — the 3D equivalent of the Realtime plugin demo.
+
+```{code-cell} ipython3
+import folium
+from folium import JsCode
+from folium.plugins import WebGLEarth, WebGLEarthRealtime
+
+m = folium.Map()
+
+globe = WebGLEarth(center=[0, 0], zoom=1.8)
+globe.add_to(m)
+
+WebGLEarthRealtime(
+ source_url="https://api.wheretheiss.at/v1/satellites/25544",
+ interval=3000,
+ on_update=JsCode("""
+ function(data, earth) {
+ if (window._issMarker) window._issMarker.removeFrom(earth);
+ window._issMarker = WE.marker(
+ [data.latitude, data.longitude]
+ ).addTo(earth);
+ window._issMarker.bindPopup(
+ 'ISS
'
+ + 'Lat: ' + data.latitude.toFixed(2)
+ + '
Lng: ' + data.longitude.toFixed(2)
+ + '
Alt: ' + data.altitude.toFixed(1) + ' km'
+ );
+ }
+ """),
+).add_to(globe)
+
+m
+```
\ No newline at end of file
From 32b4080697e7f6cb0d5e453776170f4533a6180c Mon Sep 17 00:00:00 2001
From: "pre-commit-ci[bot]"
<66853113+pre-commit-ci[bot]@users.noreply.github.com>
Date: Mon, 25 May 2026 07:35:55 +0000
Subject: [PATCH 3/6] [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
---
docs/user_guide/plugins/webgl_earth.md | 2 +-
folium/plugins/webgl_earth.py | 29 ++++++++------------------
tests/plugins/test_webgl_earth.py | 13 ++++--------
3 files changed, 14 insertions(+), 30 deletions(-)
diff --git a/docs/user_guide/plugins/webgl_earth.md b/docs/user_guide/plugins/webgl_earth.md
index b1152504ed..bd38904607 100644
--- a/docs/user_guide/plugins/webgl_earth.md
+++ b/docs/user_guide/plugins/webgl_earth.md
@@ -92,4 +92,4 @@ WebGLEarthRealtime(
).add_to(globe)
m
-```
\ No newline at end of file
+```
diff --git a/folium/plugins/webgl_earth.py b/folium/plugins/webgl_earth.py
index 45d1dee381..a81f0dfb04 100644
--- a/folium/plugins/webgl_earth.py
+++ b/folium/plugins/webgl_earth.py
@@ -1,4 +1,3 @@
-
from typing import List, Optional, Union
from branca.element import MacroElement
@@ -43,8 +42,7 @@ class WebGLEarth(JSCSSMixin, MacroElement):
>>> marker.add_to(globe)
"""
- _template = Template(
- """
+ _template = Template("""
{% macro header(this, kwargs) %}