diff --git a/docs/notebooks/94_mapbox.ipynb b/docs/notebooks/94_mapbox.ipynb new file mode 100644 index 0000000000..b0f737a858 --- /dev/null +++ b/docs/notebooks/94_mapbox.ipynb @@ -0,0 +1,307 @@ +{ + "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "[![image](https://jupyterlite.rtfd.io/en/latest/_static/badge.svg)](https://demo.leafmap.org/lab/index.html?path=notebooks/94_mapbox.ipynb)\n", + "[![image](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/opengeos/leafmap/blob/master/examples/notebooks/94_mapbox.ipynb)\n", + "[![image](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/opengeos/leafmap/HEAD)\n", + "\n", + "**Creating 3D maps with Mapbox**" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# %pip install -U leafmap" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import leafmap.mapbox as leafmap" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You need a Mapbox access token to use the Mapbox widget. First, [sign up](https://account.mapbox.com/auth/signup/) for a free Mapbox account. Then, you can create a token by following the instructions [here](https://docs.mapbox.com/api/accounts/tokens/). Set `Mapbox_TOKEN` as an environment variable to use the Mapbox widget. Alternatively, uncomment the following code block and replace `YOUR-API-TOKEN` with your Mapbox access token." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# import os\n", + "# os.environ['MAPBOX_TOKEN'] = 'YOUR-API-TOKEN'" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "m = leafmap.Map(center=[-100, 40], zoom=1.2, height=\"600px\")\n", + "m" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "![](https://i.imgur.com/1PbtnQE.gif)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "m = leafmap.Map(style=\"mapbox://styles/mapbox/satellite-streets-v12\")\n", + "m" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "m.clicked_lnglat" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "m.zoom" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "m.center" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "m.bounds" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "esm = \"\"\"\n", + "\n", + " const map = new mapboxgl.Map({\n", + " container: 'map',\n", + " zoom: 14,\n", + " center: [-114.26608, 32.7213],\n", + " pitch: 80,\n", + " bearing: 41,\n", + " // Choose from Mapbox's core styles, or make your own style with Mapbox Studio\n", + " style: 'mapbox://styles/mapbox/satellite-streets-v12'\n", + " });\n", + "\n", + " map.on('style.load', () => {\n", + " map.addSource('mapbox-dem', {\n", + " 'type': 'raster-dem',\n", + " 'url': 'mapbox://mapbox.mapbox-terrain-dem-v1',\n", + " 'tileSize': 512,\n", + " 'maxzoom': 14\n", + " });\n", + " // add the DEM source as a terrain layer with exaggerated height\n", + " map.setTerrain({ 'source': 'mapbox-dem', 'exaggeration': 1.5 });\n", + " });\n", + "\n", + "\n", + "\"\"\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "m.set_esm(esm)\n", + "m" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "![](https://i.imgur.com/xvdrvpx.png)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "esm = \"\"\"\n", + "\n", + " (async () => {\n", + " const map = new mapboxgl.Map({\n", + " container: 'map',\n", + " zoom: 13,\n", + " center: [6.6301, 45.35625],\n", + " pitch: 80,\n", + " bearing: 160,\n", + " interactive: false,\n", + " // Choose from Mapbox's core styles, or make your own style with Mapbox Studio\n", + " style: 'mapbox://styles/mapbox/satellite-v9'\n", + " });\n", + "\n", + " await map.once('style.load');\n", + "\n", + " // Add daytime fog\n", + " map.setFog({\n", + " 'range': [-1, 2],\n", + " 'horizon-blend': 0.3,\n", + " 'color': 'white',\n", + " 'high-color': '#add8e6',\n", + " 'space-color': '#d8f2ff',\n", + " 'star-intensity': 0.0\n", + " });\n", + "\n", + " // Add 3D terrain\n", + " map.addSource('mapbox-dem', {\n", + " 'type': 'raster-dem',\n", + " 'url': 'mapbox://mapbox.terrain-rgb',\n", + " 'tileSize': 512,\n", + " 'maxzoom': 14\n", + " });\n", + " map.setTerrain({\n", + " 'source': 'mapbox-dem',\n", + " 'exaggeration': 1.5\n", + " });\n", + "\n", + " // Run a timing loop to switch between day and night\n", + " await map.once('idle');\n", + "\n", + " let lastTime = 0.0;\n", + " let animationTime = 0.0;\n", + " let cycleTime = 0.0;\n", + " let night = true;\n", + "\n", + " const initialBearing = map.getBearing();\n", + "\n", + " function frame(time) {\n", + " const elapsedTime = (time - lastTime) / 1000.0;\n", + "\n", + " animationTime += elapsedTime;\n", + " cycleTime += elapsedTime;\n", + "\n", + " if (cycleTime >= 10.0) {\n", + " if (night) {\n", + " // night fog styling\n", + " map.setFog({\n", + " 'range': [-1, 2],\n", + " 'horizon-blend': 0.3,\n", + " 'color': '#242B4B',\n", + " 'high-color': '#161B36',\n", + " 'space-color': '#0B1026',\n", + " 'star-intensity': 0.8\n", + " });\n", + " } else {\n", + " // day fog styling\n", + " map.setFog({\n", + " 'range': [-1, 2],\n", + " 'horizon-blend': 0.3,\n", + " 'color': 'white',\n", + " 'high-color': '#add8e6',\n", + " 'space-color': '#d8f2ff',\n", + " 'star-intensity': 0.0\n", + " });\n", + " }\n", + "\n", + " night = !night;\n", + " cycleTime = 0.0;\n", + " }\n", + "\n", + " const rotation = initialBearing + animationTime * 2.0;\n", + " map.setBearing(rotation % 360);\n", + "\n", + " lastTime = time;\n", + "\n", + " window.requestAnimationFrame(frame);\n", + " }\n", + "\n", + " window.requestAnimationFrame(frame);\n", + " })();\n", + "\n", + "\"\"\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "m.set_esm(esm)\n", + "m" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "![](https://i.imgur.com/ZRRUK3v.png)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.8" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/docs/tutorials.md b/docs/tutorials.md index cc2801fdfc..4edd8a85cd 100644 --- a/docs/tutorials.md +++ b/docs/tutorials.md @@ -105,6 +105,7 @@ 91. Visualizing raster data interactively ([notebook](https://leafmap.org/notebooks/91_raster_viz_gui)) 92. Creating 3D maps with the MapLibre mapping backend ([notebook](https://leafmap.org/notebooks/92_maplibre)) 93. Visualizing PMTiles with Leafmap and MapLibre ([notebook](https://leafmap.org/notebooks/93_maplibre_pmtiles)) +94. Creating 3D maps with Mapbox ([notebook](https://leafmap.org/notebooks/94_mapbox)) ## Demo diff --git a/leafmap/javascript/mapbox.js b/leafmap/javascript/mapbox.js new file mode 100644 index 0000000000..05381deac8 --- /dev/null +++ b/leafmap/javascript/mapbox.js @@ -0,0 +1,63 @@ +import mapboxgl from "https://esm.sh/mapbox-gl@3.5.2"; + +function render({ model, el }) { + // Header + let center = model.get("center"); + let zoom = model.get("zoom"); + let width = model.get("width"); + let height = model.get("height"); + let style = model.get("style"); + + const div = document.createElement("div"); + div.style.width = width; + div.style.height = height; + + let token = model.get("token"); + mapboxgl.accessToken = token; + + // Map content + + const map = new mapboxgl.Map({ + container: div, + style: style, + center: center, + zoom: zoom, + }); + + map.on("click", function (e) { + model.set("clicked_lnglat", [e.lngLat.lng, e.lngLat.lat]); + model.save_changes(); + }); + + map.on("moveend", function (e) { + model.set("center", [map.getCenter().lng, map.getCenter().lat]); + let bbox = map.getBounds(); + let bounds = [bbox._sw.lng, bbox._sw.lat, bbox._ne.lng, bbox._ne.lat]; + model.set("bounds", bounds); + model.save_changes(); + }); + + map.on("zoomend", function (e) { + model.set("center", [map.getCenter().lng, map.getCenter().lat]); + model.set("zoom", map.getZoom()); + let bbox = map.getBounds(); + let bounds = [bbox._sw.lng, bbox._sw.lat, bbox._ne.lng, bbox._ne.lat]; + model.set("bounds", bounds); + model.save_changes(); + }); + + // model.on("change:center", function () { + // let center = model.get("center"); + // center.reverse(); + // map.setCenter(center); + // }); + + // model.on("change:zoom", function () { + // let zoom = model.get("zoom"); + // map.setZoom(zoom); + // }); + + // Footer + el.appendChild(div); +} +export default { render }; \ No newline at end of file diff --git a/leafmap/mapbox.py b/leafmap/mapbox.py new file mode 100644 index 0000000000..b4a3143d4d --- /dev/null +++ b/leafmap/mapbox.py @@ -0,0 +1,125 @@ +import os +import pathlib +import anywidget +import traitlets +from .common import * + + +class Map(anywidget.AnyWidget): + """Create a Mapbox map widget.""" + + _cwd = os.path.dirname(os.path.abspath(__file__)) + _esm = pathlib.Path(os.path.join(_cwd, "javascript", "mapbox.js")) + _css = pathlib.Path(os.path.join(_cwd, "styles", "mapbox.css")) + default_token = get_api_key("MAPBOX_TOKEN") + token = traitlets.Unicode(default_token).tag(sync=True) + center = traitlets.List([-100, 40]).tag(sync=True, o=True) + zoom = traitlets.Float(1.2).tag(sync=True, o=True) + bounds = traitlets.List([0, 0, 0, 0]).tag(sync=True, o=True) + width = traitlets.Unicode("100%").tag(sync=True, o=True) + height = traitlets.Unicode("600px").tag(sync=True, o=True) + clicked_lnglat = traitlets.List([None, None]).tag(sync=True, o=True) + style = traitlets.Unicode("mapbox://styles/mapbox/streets-v12").tag(sync=True) + + def set_esm(self, esm, container="map"): + """Set esm attribute. Can be a string, a file path, or a url. + See examples at https://docs.mapbox.com/mapbox-gl-js/example/ + Open an example and click on the 'Edit in CodePen' button. + Then copy the code from the 'JS' tab, and assign it to the esm parameter. + + Args: + esm (str): The esm string, file path, or url. + container (str, optional): The container name. Defaults to 'map'. + + Raises: + TypeError: If esm is not a string. + """ + if isinstance(esm, str): + if os.path.isfile(esm): + with open(esm, "r") as f: + content = f.read() + elif esm.startswith("http"): + import urllib.request + + with urllib.request.urlopen(esm) as response: + content = response.read().decode("utf-8") + else: + content = esm + + self._esm = self._create_esm(content, container=container) + + else: + raise TypeError("esm must be a string") + + def set_css(self, css, container="map"): + """Set css attribute. Can be a string, a file path, or a url. + See examples at https://docs.mapbox.com/mapbox-gl-js/example/ + Open an example and click on the 'Edit in CodePen' button. + Then copy the code from the 'CSS' tab, and assign it to the css parameter. + Args: + css (str): The css string, file path, or url. + + Raises: + TypeError: If css is not a string. + """ + if isinstance(css, str): + if os.path.isfile(css): + with open(css, "r") as f: + content = f.read() + elif css.startswith("http"): + import urllib.request + + with urllib.request.urlopen(css) as response: + content = response.read().decode("utf-8") + else: + content = css + + self._css = content.replace(f"#{container}", f"#div").replace( + f".{container}", f".div" + ) + else: + raise TypeError("css must be a string") + + def _create_esm(self, esm, container="map"): + """Create esm string by replacing the container name. + + Args: + esm (str): The esm string. + container (str, optional): The container name. Defaults to 'map'. + + Returns: + str: The esm string with the container name replaced. + """ + _cwd = os.path.dirname(os.path.abspath(__file__)) + _esm = pathlib.Path(os.path.join(_cwd, "javascript", "mapbox.js")) + + with open(_esm, "r") as f: + lines = f.readlines() + + header = [] + footer = [] + + for index, line in enumerate(lines): + if line.strip() == "// Map content": + header = lines[: index + 1] + break + + for index, line in enumerate(lines): + if line.strip() == "// Footer": + footer = lines[index:] + break + + content = esm.replace(f"'{container}'", "div").replace(f'"{container}"', "div") + esm = "".join(header) + content + "".join(footer) + + return esm + + def _save_esm(self, output): + """Save esm to file + + Args: + output (str): The output file path. + """ + + with open(output, "w") as f: + f.write(self._esm) diff --git a/leafmap/styles/mapbox.css b/leafmap/styles/mapbox.css new file mode 100644 index 0000000000..f2931f30d1 --- /dev/null +++ b/leafmap/styles/mapbox.css @@ -0,0 +1 @@ +@import url("https://esm.sh/mapbox-gl@3.5.2?css"); diff --git a/mkdocs.yml b/mkdocs.yml index 0855250e75..564ef47a4d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -77,6 +77,7 @@ plugins: "notebooks/84_read_parquet.ipynb", "notebooks/87_actinia.ipynb", "notebooks/88_nasa_earth_data.ipynb", + "notebooks/94_mapbox.ipynb", ] markdown_extensions: @@ -315,3 +316,4 @@ nav: - notebooks/91_raster_viz_gui.ipynb - notebooks/92_maplibre.ipynb - notebooks/93_maplibre_pmtiles.ipynb + - notebooks/94_mapbox.ipynb diff --git a/requirements.txt b/requirements.txt index e6ae6f488a..80a6c6024b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +anywidget>=0.9.13 bqplot>=0.12.43 colour>=0.1.5 duckdb>=1.0.0