diff --git a/examples/airports/marker.py b/.examples/airports/marker.py similarity index 94% rename from examples/airports/marker.py rename to .examples/airports/marker.py index f3b9f321..9c7565d0 100644 --- a/examples/airports/marker.py +++ b/.examples/airports/marker.py @@ -1,15 +1,8 @@ import json import pandas as pd -from maplibre import ( - Layer, - LayerType, - Map, - MapContext, - MapOptions, - output_maplibregl, - render_maplibregl, -) +from maplibre import (Layer, LayerType, Map, MapContext, MapOptions, + output_maplibregl, render_maplibregl) from maplibre.basemaps import Carto from maplibre.controls import Marker, MarkerOptions, Popup, PopupOptions from maplibre.sources import GeoJSONSource diff --git a/examples/circle_layer/app.py b/.examples/circle_layer/app.py similarity index 100% rename from examples/circle_layer/app.py rename to .examples/circle_layer/app.py diff --git a/examples/earthquakes_cluster/app.py b/.examples/earthquakes_cluster/app.py similarity index 100% rename from examples/earthquakes_cluster/app.py rename to .examples/earthquakes_cluster/app.py diff --git a/examples/every_person_in_manhattan/app.py b/.examples/every_person_in_manhattan/app.py similarity index 93% rename from examples/every_person_in_manhattan/app.py rename to .examples/every_person_in_manhattan/app.py index 36b96e66..b5523dac 100644 --- a/examples/every_person_in_manhattan/app.py +++ b/.examples/every_person_in_manhattan/app.py @@ -2,8 +2,15 @@ import pandas as pd import shapely -from maplibre import (Layer, LayerType, Map, MapContext, MapOptions, - output_maplibregl, render_maplibregl) +from maplibre import ( + Layer, + LayerType, + Map, + MapContext, + MapOptions, + output_maplibregl, + render_maplibregl, +) from maplibre.basemaps import Carto from maplibre.utils import df_to_geojson from shiny import App, reactive, ui diff --git a/.examples/every_person_in_manhattan/app2.py b/.examples/every_person_in_manhattan/app2.py new file mode 100644 index 00000000..4c57839f --- /dev/null +++ b/.examples/every_person_in_manhattan/app2.py @@ -0,0 +1,77 @@ +import geopandas as gpd +import pandas as pd +from maplibre import ( + Layer, + LayerType, + Map, + MapContext, + MapOptions, + output_maplibregl, + render_maplibregl, +) +from maplibre.basemaps import Carto +from maplibre.controls import ScaleControl +from maplibre.sources import GeoJSONSource +from maplibre.utils import geopandas_to_geojson +from shiny import App, reactive, ui + +MALE_COLOR = "rgb(0, 128, 255)" +FEMALE_COLOR = "rgb(255, 0, 128)" +LAYER_ID = "every-person-in-manhattan-circles" +CIRCLE_RADIUS = 2 + +point_data = pd.read_json( + "https://raw.githubusercontent.com/visgl/deck.gl-data/master/examples/scatterplot/manhattan.json" +) + +point_data.columns = ["lng", "lat", "sex"] + +point_data = gpd.GeoDataFrame( + point_data["sex"], geometry=gpd.points_from_xy(point_data.lng, point_data.lat) +) + +every_person_in_manhattan_source = GeoJSONSource(data=geopandas_to_geojson(point_data)) + + +every_person_in_manhattan_circles = Layer( + type=LayerType.CIRCLE, + id=LAYER_ID, + source=every_person_in_manhattan_source, + paint={ + "circle-color": ["match", ["get", "sex"], 1, MALE_COLOR, FEMALE_COLOR], + "circle-radius": CIRCLE_RADIUS, + }, +) + +map_options = MapOptions( + style=Carto.POSITRON, + bounds=point_data.total_bounds, + fit_bounds_options={"padding": 20}, +) + +app_ui = ui.page_fluid( + ui.panel_title("Every Person in Manhattan"), + output_maplibregl("maplibre", height=600), + ui.input_slider("radius", "Radius", value=CIRCLE_RADIUS, min=1, max=5), +) + + +def server(input, output, session): + @render_maplibregl + def maplibre(): + m = Map(map_options) + m.add_control(ScaleControl(), position="bottom-left") + m.add_layer(every_person_in_manhattan_circles) + return m + + @reactive.Effect + @reactive.event(input.radius, ignore_init=True) + async def radius(): + async with MapContext("maplibre") as m: + m.set_paint_property(LAYER_ID, "circle-radius", input.radius()) + + +app = App(app_ui, server) + +if __name__ == "__main__": + app.run() diff --git a/examples/experimental/app.py b/.examples/experimental/app.py similarity index 100% rename from examples/experimental/app.py rename to .examples/experimental/app.py diff --git a/examples/experimental/app2.py b/.examples/experimental/app2.py similarity index 100% rename from examples/experimental/app2.py rename to .examples/experimental/app2.py diff --git a/examples/experimental/app3.py b/.examples/experimental/app3.py similarity index 100% rename from examples/experimental/app3.py rename to .examples/experimental/app3.py diff --git a/examples/experimental/flights.py b/.examples/experimental/flights.py similarity index 94% rename from examples/experimental/flights.py rename to .examples/experimental/flights.py index 5026d750..24e783b6 100644 --- a/examples/experimental/flights.py +++ b/.examples/experimental/flights.py @@ -2,8 +2,14 @@ import pandas as pd import shapely -from maplibre import (Layer, LayerType, Map, MapContext, output_maplibregl, - render_maplibregl) +from maplibre import ( + Layer, + LayerType, + Map, + MapContext, + output_maplibregl, + render_maplibregl, +) from maplibre.basemaps import Carto from maplibre.utils import GeometryType, df_to_geojson from shiny import App, reactive, ui diff --git a/examples/experimental/points_to_h3_hexagons.py b/.examples/experimental/points_to_h3_hexagons.py similarity index 100% rename from examples/experimental/points_to_h3_hexagons.py rename to .examples/experimental/points_to_h3_hexagons.py diff --git a/examples/experimental/readme.txt b/.examples/experimental/readme.txt similarity index 100% rename from examples/experimental/readme.txt rename to .examples/experimental/readme.txt diff --git a/examples/fill_extrusion_layer/app.py b/.examples/fill_extrusion_layer/app.py similarity index 100% rename from examples/fill_extrusion_layer/app.py rename to .examples/fill_extrusion_layer/app.py diff --git a/examples/fill_layer/app.py b/.examples/fill_layer/app.py similarity index 100% rename from examples/fill_layer/app.py rename to .examples/fill_layer/app.py diff --git a/examples/h3_hexagons/app.py b/.examples/h3_hexagons/app.py similarity index 96% rename from examples/h3_hexagons/app.py rename to .examples/h3_hexagons/app.py index 8031eca5..1ef8a02c 100644 --- a/examples/h3_hexagons/app.py +++ b/.examples/h3_hexagons/app.py @@ -1,15 +1,8 @@ import h3 import pandas as pd - # import shapely -from maplibre import ( - Layer, - LayerType, - Map, - MapContext, - output_maplibregl, - render_maplibregl, -) +from maplibre import (Layer, LayerType, Map, MapContext, output_maplibregl, + render_maplibregl) from maplibre.basemaps import Carto from maplibre.utils import GeometryType, df_to_geojson, get_bounds from shiny import App, reactive, ui diff --git a/examples/heatmap_layer/app.py b/.examples/heatmap_layer/app.py similarity index 100% rename from examples/heatmap_layer/app.py rename to .examples/heatmap_layer/app.py diff --git a/examples/marker/app.py b/.examples/marker/app.py similarity index 100% rename from examples/marker/app.py rename to .examples/marker/app.py diff --git a/examples/motor_vehicle_collisions/app.py b/.examples/motor_vehicle_collisions/app.py similarity index 100% rename from examples/motor_vehicle_collisions/app.py rename to .examples/motor_vehicle_collisions/app.py diff --git a/examples/text_layer/app.py b/.examples/text_layer/app.py similarity index 100% rename from examples/text_layer/app.py rename to .examples/text_layer/app.py diff --git a/examples/to_html/app.py b/.examples/to_html/app.py similarity index 100% rename from examples/to_html/app.py rename to .examples/to_html/app.py diff --git a/examples/vancouver_blocks/line.py b/.examples/vancouver_blocks/line.py similarity index 100% rename from examples/vancouver_blocks/line.py rename to .examples/vancouver_blocks/line.py diff --git a/.github/workflows/docker-image-animation.yml b/.github/workflows/docker-image-animation.yml new file mode 100644 index 00000000..62e7bcea --- /dev/null +++ b/.github/workflows/docker-image-animation.yml @@ -0,0 +1,33 @@ +name: Docker Image CI + +on: + push: + branches: [ "feature/examples" ] + pull_request: + branches: [ "main" ] + +env: + IMAGE_NAME: hike-animation + VERSION: latest + +jobs: + + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Build the Docker image + run: | + cd docs/examples/hike + docker build . --file Dockerfile --tag $IMAGE_NAME + - name: Log in to registry + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u $ --password-stdin + - name: Push image + run: | + IMAGE_ID=ghcr.io/$GITHUB_REPOSITORY/$IMAGE_NAME + # repository name must be lowercase + IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]') + docker tag $IMAGE_NAME $IMAGE_ID:$VERSION + docker push $IMAGE_ID:$VERSION diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 9c166dcd..013080be 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -6,6 +6,10 @@ on: pull_request: branches: [ "main" ] +env: + IMAGE_NAME: vancouver-blocks + VERSION: latest + jobs: build: @@ -16,5 +20,14 @@ jobs: - uses: actions/checkout@v3 - name: Build the Docker image run: | - cd docs/examples/vancouver-blocks - docker build . --file Dockerfile --tag my-image-name:$(date +%s) + cd docs/examples/vancouver_blocks + docker build . --file Dockerfile --tag $IMAGE_NAME + - name: Log in to registry + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u $ --password-stdin + - name: Push image + run: | + IMAGE_ID=ghcr.io/$GITHUB_REPOSITORY/$IMAGE_NAME + # repository name must be lowercase + IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]') + docker tag $IMAGE_NAME $IMAGE_ID:$VERSION + docker push $IMAGE_ID:$VERSION diff --git a/.github/workflows/docker-image2.yml b/.github/workflows/docker-image2.yml new file mode 100644 index 00000000..e7d9ced8 --- /dev/null +++ b/.github/workflows/docker-image2.yml @@ -0,0 +1,33 @@ +name: Docker Image CI + +on: + push: + branches: [ "dev" ] + pull_request: + branches: [ "main" ] + +env: + IMAGE_NAME: airports + VERSION: latest + +jobs: + + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Build the Docker image + run: | + cd docs/examples/airports + docker build . --file Dockerfile --tag $IMAGE_NAME + - name: Log in to registry + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u $ --password-stdin + - name: Push image + run: | + IMAGE_ID=ghcr.io/$GITHUB_REPOSITORY/$IMAGE_NAME + # repository name must be lowercase + IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]') + docker tag $IMAGE_NAME $IMAGE_ID:$VERSION + docker push $IMAGE_ID:$VERSION diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml new file mode 100644 index 00000000..ae7cc3df --- /dev/null +++ b/.github/workflows/pytest.yml @@ -0,0 +1,30 @@ +name: Python package + +on: [push] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + # You can test your matrix by printing the current Python version + - name: Display Python version + run: python -c "import sys; print(sys.version)" + - name: Install Poetry and pytest + run: pip install poetry pytest + - name: Install package + run: poetry install + - name: Test package + run: | + poetry run pytest + poetry run pytest --doctest-modules maplibre --ignore maplibre/ipywidget.py + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..03ed3ca0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog for MapLibre for Python + +## maplibre v0.1.2 + +* Add `Map.set_data` +* Add `Map.set_visibility` +* Do not import `ipywidget.MapWidget` in `__init__` and skip tests for `MapWidget`, because it causes a `core dumped` error, see [anywidget issue](https://github.com/manzt/anywidget/issues/374) +* Remove `requests` dependency +* Remove dead code +* Add more examples + +## maplibre v0.1.1 + +* Initial PyPI release diff --git a/README.md b/README.md index 4ceb2ad4..a1388f7c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # MapLibre for Python +[![Release](https://img.shields.io/github/v/release/eodaGmbH/py-maplibregl)](https://img.shields.io/github/v/release/eodaGmbH/py-maplibregl) +[![Build status](https://img.shields.io/github/actions/workflow/status/eodaGmbH/py-maplibregl/pytest.yaml?branch=main)](https://img.shields.io/github/actions/workflow/status/eodaGmbH/py-maplibregl/pytest.yaml?branch=main) +[![License](https://img.shields.io/github/license/eodaGmbH/py-maplibregl)](https://img.shields.io/github/license/eodaGmbH/py-maplibregl) + MapLibre for Python provides Python bindings for [MapLibre GL JS](https://github.com/maplibre/maplibre-gl-js). It integrates seamlessly into [Shiny for Python](https://github.com/posit-dev/py-shiny) and [Jupyter](https://jupyter.org/). @@ -20,6 +24,10 @@ pip install "maplibre[all] @ git+https://github.com/eodaGmbH/py-maplibregl@dev" ## Getting started +```bash +docker run --rm -p 8050:8050 ghcr.io/eodagmbh/py-maplibregl/vancouver-blocks:latest +``` + * [Basic usage](https://eodagmbh.github.io/py-maplibregl/) * [API Documentation](https://eodagmbh.github.io/py-maplibregl/api/map/) * [Examples](https://eodagmbh.github.io/py-maplibregl/examples/every_person_in_manhattan/) diff --git a/docs/api/controls.md b/docs/api/controls.md index 5a0b6198..90e065f0 100644 --- a/docs/api/controls.md +++ b/docs/api/controls.md @@ -11,3 +11,9 @@ docstring_section_style: list ::: maplibre.controls.FullscreenControl + +::: maplibre.controls.ScaleControl + +::: maplibre.controls.NavigationControl + +::: maplibre.controls.GeolocateControl diff --git a/docs/api/map.md b/docs/api/map.md index 8e92fd89..a98122cb 100644 --- a/docs/api/map.md +++ b/docs/api/map.md @@ -2,7 +2,9 @@ ::: maplibre.MapOptions +::: maplibre.MapContext + ::: maplibre.ipywidget.MapWidget options: - inherited_members: true + inherited_members: false \ No newline at end of file diff --git a/docs/examples/3d_indoor_mapping/app.html b/docs/examples/3d_indoor_mapping/app.html new file mode 100644 index 00000000..a8eb1593 --- /dev/null +++ b/docs/examples/3d_indoor_mapping/app.html @@ -0,0 +1,163 @@ + + + +Pymaplibregl + + + + +
+ + + \ No newline at end of file diff --git a/docs/examples/3d_indoor_mapping/app.py b/docs/examples/3d_indoor_mapping/app.py index 337b8638..dcf072fd 100644 --- a/docs/examples/3d_indoor_mapping/app.py +++ b/docs/examples/3d_indoor_mapping/app.py @@ -1,10 +1,12 @@ +import sys import webbrowser from maplibre import Layer, LayerType, Map, MapOptions from maplibre.basemaps import background from maplibre.sources import GeoJSONSource, RasterTileSource -TEMP_FILE = "/tmp/pymaplibregl_temp.html" +file_name = "/tmp/pymaplibregl_temp.html" + FLOORPLAN_SOURCE_ID = "floorplan" raster_source = RasterTileSource( @@ -52,7 +54,10 @@ def create_map(): if __name__ == "__main__": m = create_map() - with open(TEMP_FILE, "w") as f: + if len(sys.argv) == 2: + file_name = sys.argv[1] + + with open(file_name, "w") as f: f.write(m.to_html()) - webbrowser.open(TEMP_FILE) + webbrowser.open(file_name) diff --git a/docs/examples/3d_indoor_mapping/index.md b/docs/examples/3d_indoor_mapping/index.md index 7b9d38fd..9f231f34 100644 --- a/docs/examples/3d_indoor_mapping/index.md +++ b/docs/examples/3d_indoor_mapping/index.md @@ -1,3 +1,5 @@ +See example in action + ```python -8<-- "3d_indoor_mapping/app.py" ``` diff --git a/docs/examples/airports/Dockerfile b/docs/examples/airports/Dockerfile new file mode 100644 index 00000000..71835dd1 --- /dev/null +++ b/docs/examples/airports/Dockerfile @@ -0,0 +1,7 @@ +FROM bitnami/python:3.11.5 + +RUN pip install maplibre pandas + +COPY ./app.py ./app.py + +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8050"] diff --git a/docs/examples/custom_basemap/app.html b/docs/examples/custom_basemap/app.html new file mode 100644 index 00000000..bc0913ba --- /dev/null +++ b/docs/examples/custom_basemap/app.html @@ -0,0 +1,163 @@ + + + +Pymaplibregl + + + + +
+ + + \ No newline at end of file diff --git a/docs/examples/custom_basemap/app.py b/docs/examples/custom_basemap/app.py index df831ecb..a2fb7967 100644 --- a/docs/examples/custom_basemap/app.py +++ b/docs/examples/custom_basemap/app.py @@ -1,10 +1,11 @@ +import sys import webbrowser from maplibre import Layer, LayerType, Map, MapOptions from maplibre.basemaps import construct_basemap_style from maplibre.sources import GeoJSONSource -TEMP_FILE = "/tmp/pymaplibregl_temp.html" +file_name = "/tmp/pymaplibregl_temp.html" bg_layer = Layer( type=LayerType.BACKGROUND, @@ -60,7 +61,10 @@ def create_map(): if __name__ == "__main__": m = create_map() - with open(TEMP_FILE, "w") as f: + if len(sys.argv) == 2: + file_name = sys.argv[1] + + with open(file_name, "w") as f: f.write(m.to_html()) - webbrowser.open(TEMP_FILE) + webbrowser.open(file_name) diff --git a/docs/examples/custom_basemap/index.md b/docs/examples/custom_basemap/index.md index 6bbd4385..608ef7df 100644 --- a/docs/examples/custom_basemap/index.md +++ b/docs/examples/custom_basemap/index.md @@ -2,6 +2,8 @@ -8<-- "custom_basemap/app.py" ``` +See example in action + Run example: ``` bash diff --git a/docs/examples/earthquake_clusters/Dockerfile b/docs/examples/earthquake_clusters/Dockerfile new file mode 100644 index 00000000..e8bec61c --- /dev/null +++ b/docs/examples/earthquake_clusters/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.11.5 + +RUN pip install maplibre + +COPY ./app.py ./app.py + +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8050"] diff --git a/docs/examples/every_person_in_manhattan/Dockerfile b/docs/examples/every_person_in_manhattan/Dockerfile new file mode 100644 index 00000000..389b411b --- /dev/null +++ b/docs/examples/every_person_in_manhattan/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.11.5 + +RUN pip install maplibre pandas shapely + +COPY ./app.py ./app.py + +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8050"] diff --git a/docs/examples/geopandas/app.html b/docs/examples/geopandas/app.html new file mode 100644 index 00000000..e8578429 --- /dev/null +++ b/docs/examples/geopandas/app.html @@ -0,0 +1,163 @@ + + + +Pymaplibregl + + + + +
+ + + \ No newline at end of file diff --git a/docs/examples/geopandas/app.py b/docs/examples/geopandas/app.py index fefca9d9..bafebd69 100644 --- a/docs/examples/geopandas/app.py +++ b/docs/examples/geopandas/app.py @@ -1,3 +1,4 @@ +import sys import webbrowser from maplibre import Layer, LayerType, Map, MapOptions @@ -6,7 +7,7 @@ import geopandas as gpd -TEMP_FILE = "/tmp/pymaplibregl_temp.html" +file_name = "/tmp/pymaplibregl_temp.html" LAYER_ID = "wilderness" df_geo = gpd.read_file( @@ -34,7 +35,10 @@ def create_map(): if __name__ == "__main__": m = create_map() - with open(TEMP_FILE, "w") as f: + if len(sys.argv) == 2: + file_name = sys.argv[1] + + with open(file_name, "w") as f: f.write(m.to_html()) - webbrowser.open(TEMP_FILE) + webbrowser.open(file_name) diff --git a/docs/examples/geopandas/index.md b/docs/examples/geopandas/index.md index b856f565..fffccb96 100644 --- a/docs/examples/geopandas/index.md +++ b/docs/examples/geopandas/index.md @@ -1,3 +1,5 @@ +See example in action + ```python -8<-- "geopandas/app.py" ``` diff --git a/docs/examples/getting_started/Dockerfile b/docs/examples/getting_started/Dockerfile new file mode 100644 index 00000000..6ee41fe2 --- /dev/null +++ b/docs/examples/getting_started/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.11.5 + +RUN pip install maplibre + +COPY ./basic_usage_shiny.py ./app.py + +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8050"] diff --git a/docs/examples/getting_started/basic_usage_shiny.py b/docs/examples/getting_started/basic_usage_shiny.py index 3cf092b4..f0418037 100644 --- a/docs/examples/getting_started/basic_usage_shiny.py +++ b/docs/examples/getting_started/basic_usage_shiny.py @@ -4,6 +4,7 @@ app_ui = ui.page_fluid( output_maplibregl("maplibre", height=600), + ui.div("Click on map to set a marker"), ) @@ -18,7 +19,10 @@ def maplibre(): async def coords(): async with MapContext("maplibre") as m: print(input.maplibre()) - m.add_marker(Marker(lng_lat=input.maplibre()["coords"].values())) + lng_lat = tuple(input.maplibre()["coords"].values()) + marker = Marker(lng_lat=lng_lat) + m.add_marker(marker) + m.add_call("flyTo", {"center": lng_lat}) app = App(app_ui, server) diff --git a/docs/examples/hike/Dockerfile b/docs/examples/hike/Dockerfile new file mode 100644 index 00000000..d41f777c --- /dev/null +++ b/docs/examples/hike/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.11.5 + +# RUN pip install maplibre geopandas +RUN pip install "maplibre[all] @ git+https://github.com/eodaGmbH/py-maplibregl@feature/examples" + +COPY ./app.py ./app.py + +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8050"] diff --git a/docs/examples/hike/app.py b/docs/examples/hike/app.py new file mode 100644 index 00000000..06ec65ba --- /dev/null +++ b/docs/examples/hike/app.py @@ -0,0 +1,83 @@ +import asyncio + +import geopandas as gpd +from maplibre import ( + Layer, + LayerType, + Map, + MapContext, + MapOptions, + output_maplibregl, + render_maplibregl, +) +from maplibre.controls import Marker +from maplibre.sources import GeoJSONSource +from shiny import App, reactive, ui + +SOURCE_ID = "hike" +LAYER_ID = "hike" +data = gpd.read_file("https://docs.mapbox.com/mapbox-gl-js/assets/hike.geojson") +# print(data) +# print(data.get_coordinates().to_numpy()[0]) + + +feature_collection = { + "type": "FeatureCollection", + "features": [ + {"type": "Feature", "geometry": {"type": "LineString", "coordinates": []}} + ], +} +print(feature_collection) + +hike = GeoJSONSource(data="https://docs.mapbox.com/mapbox-gl-js/assets/hike.geojson") +layer = Layer( + type=LayerType.LINE, + id=LAYER_ID, + source="hike", + paint={"line-color": "orange", "line-width": 3}, + layout={"visibility": "visible"}, +) + +app_ui = ui.page_fluid( + output_maplibregl("mapylibre", height=600), + ui.input_action_button("run", "Run"), +) + + +def server(input, output, session): + @render_maplibregl + def mapylibre(): + m = Map( + # MapOptions(center=data.get_coordinates().to_numpy()[0], zoom=16, pitch=30) + MapOptions(bounds=data.total_bounds, fit_bounds_options={"padding": 10}) + ) + m.add_source(SOURCE_ID, hike) + m.add_layer(layer) + m.add_source("hike-animation", GeoJSONSource(data=feature_collection)) + m.add_layer( + Layer( + type=LayerType.LINE, + id="hike-animation", + source="hike-animation", + paint={"line-color": "yellow", "line-width": 5}, + ) + ) + return m + + @reactive.Effect + @reactive.event(input.run) + async def run(): + for lng_lat in data.get_coordinates().to_numpy(): + # print(lng_lat) + feature_collection["features"][0]["geometry"]["coordinates"].append( + tuple(lng_lat) + ) + async with MapContext("mapylibre") as m: + m.set_data("hike-animation", feature_collection) + m.add_call("panTo", tuple(lng_lat)) + # await asyncio.sleep(0.005) + + feature_collection["features"][0]["geometry"]["coordinates"] = [] + + +app = App(app_ui, server) diff --git a/docs/examples/jupyter/getting_started.ipynb b/docs/examples/jupyter/getting_started.ipynb index 1e7a794d..388953c0 100644 --- a/docs/examples/jupyter/getting_started.ipynb +++ b/docs/examples/jupyter/getting_started.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "code", - "execution_count": 8, + "execution_count": 1, "id": "8ae5dd75-d944-4304-88c6-ec2db700dcec", "metadata": {}, "outputs": [], @@ -70,22 +70,22 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 4, "id": "e6e2284c-1862-4697-ad94-f535b3682197", "metadata": {}, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "32df09ba11cb4c64b7c79c3e389e4662", + "model_id": "ece4babe53924aa7886553d1b5d53bec", "version_major": 2, "version_minor": 0 }, "text/plain": [ - "MapWidget(height='400px', map_options={'style': 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.j…" + "MapWidget(calls=[['addControl', ('ScaleControl', {'unit': 'metric'}, 'bottom-left')], ['addLayer', ({'id': 'ea…" ] }, - "execution_count": 9, + "execution_count": 4, "metadata": {}, "output_type": "execute_result" } @@ -109,14 +109,14 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 6, "id": "356960fa-b866-42c8-a58e-0c9a417c28eb", "metadata": {}, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "437bd09dbdd24f0896193788a76610b4", + "model_id": "60ef57a918734016b9766e41615c1935", "version_major": 2, "version_minor": 0 }, @@ -133,7 +133,7 @@ "(radius)>" ] }, - "execution_count": 5, + "execution_count": 6, "metadata": {}, "output_type": "execute_result" } @@ -284,9 +284,143 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 5, "id": "7ad74d91-1137-45b4-8791-83dc3546535e", "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Help on _InteractFactory in module ipywidgets.widgets.interaction object:\n", + "\n", + "class _InteractFactory(builtins.object)\n", + " | _InteractFactory(cls, options, kwargs={})\n", + " | \n", + " | Factory for instances of :class:`interactive`.\n", + " | \n", + " | This class is needed to support options like::\n", + " | \n", + " | >>> @interact.options(manual=True)\n", + " | ... def greeting(text=\"World\"):\n", + " | ... print(\"Hello {}\".format(text))\n", + " | \n", + " | Parameters\n", + " | ----------\n", + " | cls : class\n", + " | The subclass of :class:`interactive` to construct.\n", + " | options : dict\n", + " | A dict of options used to construct the interactive\n", + " | function. By default, this is returned by\n", + " | ``cls.default_options()``.\n", + " | kwargs : dict\n", + " | A dict of **kwargs to use for widgets.\n", + " | \n", + " | Methods defined here:\n", + " | \n", + " | __call__(self, _InteractFactory__interact_f=None, **kwargs)\n", + " | Make the given function interactive by adding and displaying\n", + " | the corresponding :class:`interactive` widget.\n", + " | \n", + " | Expects the first argument to be a function. Parameters to this\n", + " | function are widget abbreviations passed in as keyword arguments\n", + " | (``**kwargs``). Can be used as a decorator (see examples).\n", + " | \n", + " | Returns\n", + " | -------\n", + " | f : __interact_f with interactive widget attached to it.\n", + " | \n", + " | Parameters\n", + " | ----------\n", + " | __interact_f : function\n", + " | The function to which the interactive widgets are tied. The `**kwargs`\n", + " | should match the function signature. Passed to :func:`interactive()`\n", + " | **kwargs : various, optional\n", + " | An interactive widget is created for each keyword argument that is a\n", + " | valid widget abbreviation. Passed to :func:`interactive()`\n", + " | \n", + " | Examples\n", + " | --------\n", + " | Render an interactive text field that shows the greeting with the passed in\n", + " | text::\n", + " | \n", + " | # 1. Using interact as a function\n", + " | def greeting(text=\"World\"):\n", + " | print(\"Hello {}\".format(text))\n", + " | interact(greeting, text=\"Jupyter Widgets\")\n", + " | \n", + " | # 2. Using interact as a decorator\n", + " | @interact\n", + " | def greeting(text=\"World\"):\n", + " | print(\"Hello {}\".format(text))\n", + " | \n", + " | # 3. Using interact as a decorator with named parameters\n", + " | @interact(text=\"Jupyter Widgets\")\n", + " | def greeting(text=\"World\"):\n", + " | print(\"Hello {}\".format(text))\n", + " | \n", + " | Render an interactive slider widget and prints square of number::\n", + " | \n", + " | # 1. Using interact as a function\n", + " | def square(num=1):\n", + " | print(\"{} squared is {}\".format(num, num*num))\n", + " | interact(square, num=5)\n", + " | \n", + " | # 2. Using interact as a decorator\n", + " | @interact\n", + " | def square(num=2):\n", + " | print(\"{} squared is {}\".format(num, num*num))\n", + " | \n", + " | # 3. Using interact as a decorator with named parameters\n", + " | @interact(num=5)\n", + " | def square(num=2):\n", + " | print(\"{} squared is {}\".format(num, num*num))\n", + " | \n", + " | __init__(self, cls, options, kwargs={})\n", + " | Initialize self. See help(type(self)) for accurate signature.\n", + " | \n", + " | options(self, **kwds)\n", + " | Change options for interactive functions.\n", + " | \n", + " | Returns\n", + " | -------\n", + " | A new :class:`_InteractFactory` which will apply the\n", + " | options when called.\n", + " | \n", + " | widget(self, f)\n", + " | Return an interactive function widget for the given function.\n", + " | \n", + " | The widget is only constructed, not displayed nor attached to\n", + " | the function.\n", + " | \n", + " | Returns\n", + " | -------\n", + " | An instance of ``self.cls`` (typically :class:`interactive`).\n", + " | \n", + " | Parameters\n", + " | ----------\n", + " | f : function\n", + " | The function to which the interactive widgets are tied.\n", + " | \n", + " | ----------------------------------------------------------------------\n", + " | Data descriptors defined here:\n", + " | \n", + " | __dict__\n", + " | dictionary for instance variables (if defined)\n", + " | \n", + " | __weakref__\n", + " | list of weak references to the object (if defined)\n", + "\n" + ] + } + ], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9d34e0d6-7ff9-4f72-9af7-e8a19d51604a", + "metadata": {}, "outputs": [], "source": [] } diff --git a/docs/examples/road_safety/app.html b/docs/examples/road_safety/app.html new file mode 100644 index 00000000..1482e9a7 --- /dev/null +++ b/docs/examples/road_safety/app.html @@ -0,0 +1,167 @@ + + + +Pymaplibregl + + + + +
+ + + \ No newline at end of file diff --git a/docs/examples/road_safety/app.py b/docs/examples/road_safety/app.py new file mode 100644 index 00000000..2e8b9c8d --- /dev/null +++ b/docs/examples/road_safety/app.py @@ -0,0 +1,103 @@ +import sys +import webbrowser + +import h3 +import pandas as pd +from maplibre import ( + Layer, + LayerType, + Map, + MapContext, + MapOptions, + output_maplibregl, + render_maplibregl, +) +from maplibre.sources import GeoJSONSource +from maplibre.utils import df_to_geojson +from shiny import App, reactive, ui + +RESOLUTION = 7 +COLORS = ( + "lightblue", + "turquoise", + "lightgreen", + "yellow", + "orange", + "darkred", +) + +road_safety = pd.read_csv( + "https://raw.githubusercontent.com/visgl/deck.gl-data/master/examples/3d-heatmap/heatmap-data.csv" +).dropna() + + +def create_h3_grid(res=RESOLUTION) -> dict: + road_safety["h3"] = road_safety.apply( + lambda x: h3.geo_to_h3(x["lat"], x["lng"], resolution=res), axis=1 + ) + df = road_safety.groupby("h3").h3.agg("count").to_frame("count").reset_index() + df["hexagon"] = df.apply( + lambda x: [h3.h3_to_geo_boundary(x["h3"], geo_json=True)], axis=1 + ) + df["color"] = pd.cut( + df["count"], + bins=len(COLORS), + labels=COLORS, + ) + return df_to_geojson( + df, "hexagon", geometry_type="Polygon", properties=["count", "color"] + ) + + +source = GeoJSONSource(data=create_h3_grid()) + + +def create_map() -> Map: + m = Map(MapOptions(center=(-1.415727, 52.232395), zoom=7, pitch=40, bearing=-27)) + m.add_layer( + Layer( + id="road-safety", + type=LayerType.FILL_EXTRUSION, + source=source, + paint={ + "fill-extrusion-color": ["get", "color"], + "fill-extrusion-opacity": 0.7, + "fill-extrusion-height": ["*", 100, ["get", "count"]], + }, + ) + ) + m.add_tooltip("road-safety", "count") + return m + + +app_ui = ui.page_fluid( + ui.panel_title("Road safety in UK"), + output_maplibregl("mapylibre", height=700), + ui.input_slider("res", "Resolution", min=4, max=8, step=1, value=RESOLUTION), +) + + +def server(input, output, session): + @render_maplibregl + def mapylibre(): + return create_map() + + @reactive.Effect + @reactive.event(input.res, ignore_init=True) + async def resolution(): + async with MapContext("mapylibre") as m: + with ui.Progress() as p: + p.set(message="H3 calculation in progress") + m.set_data("road-safety", create_h3_grid(input.res())) + p.set(1, message="Calculation finished") + + +app = App(app_ui, server) + +if __name__ == "__main__": + filename = sys.argv[1] if len(sys.argv) == 2 else "/tmp/road_safety.html" + with open(filename, "w") as f: + m = create_map() + f.write(m.to_html(style="height: 700px;")) + + webbrowser.open(filename) diff --git a/docs/examples/road_safety/index.md b/docs/examples/road_safety/index.md new file mode 100644 index 00000000..4173e20c --- /dev/null +++ b/docs/examples/road_safety/index.md @@ -0,0 +1,11 @@ +See example in action (without reactive effects) + +```python +-8<-- "road_safety/app.py" +``` + +Run example: + +``` bash +poetry run uvicorn docs.examples.road_safety.app:app --reload +``` diff --git a/docs/examples/vancouver_blocks/Dockerfile b/docs/examples/vancouver_blocks/Dockerfile new file mode 100644 index 00000000..e8bec61c --- /dev/null +++ b/docs/examples/vancouver_blocks/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.11.5 + +RUN pip install maplibre + +COPY ./app.py ./app.py + +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8050"] diff --git a/docs/examples/vancouver_blocks/app.html b/docs/examples/vancouver_blocks/app.html new file mode 100644 index 00000000..d5cebb70 --- /dev/null +++ b/docs/examples/vancouver_blocks/app.html @@ -0,0 +1,163 @@ + + + +Pymaplibregl + + + + +
+ + + \ No newline at end of file diff --git a/docs/examples/vancouver_blocks/app.py b/docs/examples/vancouver_blocks/app.py index 648389fe..8b1b7c97 100644 --- a/docs/examples/vancouver_blocks/app.py +++ b/docs/examples/vancouver_blocks/app.py @@ -1,3 +1,5 @@ +import sys + from maplibre import ( Layer, LayerType, @@ -42,8 +44,8 @@ [0, "grey"], [1000, "yellow"], [5000, "orange"], - [10000, "red"], - [50000, "darkred"], + [10000, "darkred"], + [50000, "lightblue"], ], }, "fill-extrusion-height": ["*", 10, ["sqrt", ["get", "valuePerSqm"]]], @@ -59,31 +61,43 @@ bearing=0, ) + +def create_map() -> Map: + m = Map(map_options) + m.add_control(ScaleControl(), position="bottom-left") + m.add_source(SOURCE_ID, vancouver_blocks_source) + m.add_layer(vancouver_blocks_lines) + m.add_layer(vancouver_blocks_fill) + m.add_tooltip(LAYER_ID_FILL, "valuePerSqm") + return m + + app_ui = ui.page_fluid( ui.panel_title("Vancouver Property Value"), ui.div( "Height of polygons - average property value per square meter of lot", style="padding: 10px;", ), - output_maplibregl("maplibre", height=700), + output_maplibregl("maplibre", height=600), ui.input_select( "filter", "max property value per square meter", choices=[0, 1000, 5000, 10000, 50000, 100000, MAX_FILTER_VALUE], selected=MAX_FILTER_VALUE, ), + ui.input_checkbox_group( + "layers", + "Layers", + choices=[LAYER_ID_FILL, LAYER_ID_LINES], + selected=[LAYER_ID_FILL, LAYER_ID_LINES], + ), ) def server(input, output, session): @render_maplibregl def maplibre(): - m = Map(map_options) - m.add_control(ScaleControl(), position="bottom-left") - m.add_source(SOURCE_ID, vancouver_blocks_source) - m.add_layer(vancouver_blocks_lines) - m.add_layer(vancouver_blocks_fill) - m.add_tooltip(LAYER_ID_FILL, "valuePerSqm") + m = create_map() return m @reactive.Effect @@ -93,8 +107,22 @@ async def filter(): filter_ = ["<=", ["get", "valuePerSqm"], int(input.filter())] m.set_filter(LAYER_ID_FILL, filter_) + @reactive.Effect + @reactive.event(input.layers) + async def layers(): + visible_layers = input.layers() + async with MapContext("maplibre") as m: + for layer in [LAYER_ID_FILL, LAYER_ID_LINES]: + m.set_visibility(layer, layer in visible_layers) + app = App(app_ui, server) if __name__ == "__main__": - app.run() + if len(sys.argv) == 2: + file_name = sys.argv[1] + m = create_map() + with open(file_name, "w") as f: + f.write(m.to_html()) + else: + app.run() diff --git a/docs/examples/vancouver_blocks/index.md b/docs/examples/vancouver_blocks/index.md index 3b668664..5af20d82 100644 --- a/docs/examples/vancouver_blocks/index.md +++ b/docs/examples/vancouver_blocks/index.md @@ -1,3 +1,5 @@ +See example in action (without reactive effects) + ```python -8<-- "vancouver_blocks/app.py" ``` diff --git a/docs/examples/where_is_the_iss/app.py b/docs/examples/where_is_the_iss/app.py new file mode 100644 index 00000000..d1daf24c --- /dev/null +++ b/docs/examples/where_is_the_iss/app.py @@ -0,0 +1,94 @@ +import requests +from maplibre import ( + Layer, + LayerType, + Map, + MapContext, + MapOptions, + output_maplibregl, + render_maplibregl, +) +from maplibre.sources import GeoJSONSource +from shiny import App, reactive, ui + +MAX_FEATURES = 30 +SOURCE_ID_ISS_POSITION = "iss_position" +SOURCE_ID_ISS_LAST_POSITIONS = "iss-last-positions" + + +def where_is_the_iss() -> tuple: + r = requests.get("https://api.wheretheiss.at/v1/satellites/25544").json() + return (r["longitude"], r["latitude"]) + + +def create_feature(lng_lat: tuple) -> dict: + return { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": lng_lat, + }, + "properties": {"coords": ", ".join(map(str, lng_lat))}, + } + + +lng_lat = where_is_the_iss() +feature = create_feature(where_is_the_iss()) +feature_collection = {"type": "FeatureCollection", "features": [feature]} + +app_ui = ui.page_fluid( + ui.panel_title("Where is the ISS"), + ui.div("Click on Update to get the current position of the ISS."), + ui.div( + "The yellow dots show the positions before. Hover the blue dot to display the coordinates." + ), + output_maplibregl("mapylibre", height=600), + ui.input_action_button("update", "Update"), +) + + +def server(input, output, session): + @render_maplibregl + def mapylibre(): + m = Map(MapOptions(center=lng_lat, zoom=3)) + m.set_paint_property("water", "fill-color", "darkblue") + m.add_source( + SOURCE_ID_ISS_LAST_POSITIONS, GeoJSONSource(data=feature_collection) + ) + m.add_layer( + Layer( + type=LayerType.CIRCLE, + source=SOURCE_ID_ISS_LAST_POSITIONS, + paint={"circle-color": "yellow", "circle-radius": 5}, + ), + ) + m.add_source(SOURCE_ID_ISS_POSITION, GeoJSONSource(data=feature)) + m.add_layer( + Layer( + type=LayerType.CIRCLE, + id="iss-position", + source=SOURCE_ID_ISS_POSITION, + paint={"circle-color": "lightblue", "circle-radius": 7}, + ) + ) + m.add_tooltip("iss-position", "coords") + return m + + @reactive.Effect + @reactive.event(input.update) + async def update(): + print("Fetching new position") + lng_lat = where_is_the_iss() + print(lng_lat) + if len(feature_collection["features"]) == MAX_FEATURES: + feature_collection["features"] = [] + + async with MapContext("mapylibre") as m: + feature = create_feature(lng_lat) + m.set_data(SOURCE_ID_ISS_POSITION, feature) + feature_collection["features"].append(feature) + m.set_data(SOURCE_ID_ISS_LAST_POSITIONS, feature_collection) + m.add_call("flyTo", {"center": lng_lat, "speed": 0.5}) + + +app = App(app_ui, server) diff --git a/docs/examples/where_is_the_iss/index.md b/docs/examples/where_is_the_iss/index.md new file mode 100644 index 00000000..89bbed77 --- /dev/null +++ b/docs/examples/where_is_the_iss/index.md @@ -0,0 +1,9 @@ +```python +-8<-- "where_is_the_iss/app.py" +``` + +Run example: + +```bash +poetry run uvicorn docs.examples.where_is_the_iss.app:app --reload +``` diff --git a/docs/index.md b/docs/index.md index 250242a4..d7aae84e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,7 +8,9 @@ It integrates seamlessly into [Shiny for Python](https://github.com/posit-dev/py ```bash # Stable -pip install git+https://github.com/eodaGmbH/py-maplibregl +pip install maplibre + +pip install "maplibre[all]" # Dev pip install git+https://github.com/eodaGmbH/py-maplibregl@dev diff --git a/maplibre/__init__.py b/maplibre/__init__.py index b9b8cea0..d1bd37cc 100644 --- a/maplibre/__init__.py +++ b/maplibre/__init__.py @@ -1,5 +1,6 @@ from .controls import ControlPosition, ControlType -from .ipywidget import MapWidget + +# from .ipywidget import MapWidget from .layer import Layer, LayerType from .map import Map, MapOptions from .mapcontext import MapContext diff --git a/maplibre/controls.py b/maplibre/controls.py index 62b0cab8..ceba25eb 100644 --- a/maplibre/controls.py +++ b/maplibre/controls.py @@ -92,6 +92,8 @@ def type(self): class AttributionControl(Control): + """Attribution control""" + # _name: str = ControlType.ATTRIBUTION.value compact: bool = None custom_attribution: Union[str, list] = Field( @@ -115,6 +117,8 @@ class FullscreenControl(Control): class GeolocateControl(Control): + """Geolocate control""" + # _name: str = ControlType.GEOLOCATE.value position_options: dict = Field(None, serialization_alias="positionOptions") show_accuracy_circle: bool = Field(True, serialization_alias="showAccuracyCircle") @@ -124,8 +128,10 @@ class GeolocateControl(Control): class NavigationControl(Control): + """Navigation control""" + # _name: str = ControlType.NAVIGATION.value - sho_compass: bool = Field(True, serialization_alias="showCompass") + show_compass: bool = Field(True, serialization_alias="showCompass") show_zoom: bool = Field(True, serialization_alias="showZoom") visualize_pitch: bool = Field(False, serialization_alias="visualizePitch") @@ -137,6 +143,8 @@ class ScaleUnit(Enum): class ScaleControl(Control): + """Scale control""" + # _name: str = ControlType.SCALE.value max_width: int = Field(None, serialization_alias="maxWidth") unit: Literal["imperial", "metric", "nautical"] = "metric" diff --git a/maplibre/ipywidget.py b/maplibre/ipywidget.py index 3d9b2175..08a8c5a1 100644 --- a/maplibre/ipywidget.py +++ b/maplibre/ipywidget.py @@ -12,59 +12,20 @@ from .sources import Source -# DEPRECATED: MapWidget now uses map.Map as base class -class BaseMap(object): - def __init__(self, map_options=MapOptions(), **kwargs) -> None: - self.map_options = map_options.to_dict() | kwargs +class MapWidget(AnyWidget, Map): + """MapWidget - # This method must be overwritten - def add_call(self, method_name: str, *args) -> None: - """Add a method call that is executed on the map instance + Use this class to display and update maps in Jupyter Notebooks. + + See `maplibre.Map` for available methods. + + Examples: + >>> from maplibre import MapOptions + >>> from maplibre.ipywidget import MapWidget as Map + >>> m = Map(MapOptions(center=(-123.13, 49.254), zoom=11, pitch=45)) + >>> m # doctest: +SKIP + """ - Args: - method_name (str): The name of the map method to be executed. - *args (any): The arguments to be passed to the map method. - """ - # TODO: Pass as dict? {"name": method_name, "args": args} - call = [method_name, args] - print(call) - - def add_source(self, source_id: str, source: Source) -> None: - """Add a source to the map""" - self.add_call("addSource", source_id, source.to_dict()) - - def add_layer(self, layer: Layer) -> None: - """Add a layer to the map""" - self.add_call("addLayer", layer.to_dict()) - - def add_control( - self, - control: Control, - position: [str | ControlPosition] = ControlPosition.TOP_RIGHT, - ) -> None: - """Add a control to the map""" - self.add_call( - "addControl", - control.type, - control.to_dict(), - ControlPosition(position).value, - ) - - def set_paint_property(self, layer_id: str, prop: str, value: any) -> None: - """Update a paint property of a layer""" - self.add_call("setPaintProperty", layer_id, prop, value) - - def set_layout_property(self, layer_id: str, prop: str, value: any) -> None: - """Update a layout property of a layer""" - self.add_call("setLayoutProperty", layer_id, prop, value) - - def add_tooltip(self, layer_id: str, prop: str) -> None: - """Add a tooltip to the map""" - self.add_call("addPopup", layer_id, prop) - - -# TODO: Rename to MapWidget or IpyMap -class MapWidget(AnyWidget, Map): _esm = join(Path(__file__).parent, "srcjs", "ipywidget.js") _css = join(Path(__file__).parent, "srcjs", "maplibre-gl.css") _use_message_queue = False diff --git a/maplibre/map.py b/maplibre/map.py index 4f3d7c61..ede455fb 100644 --- a/maplibre/map.py +++ b/maplibre/map.py @@ -205,6 +205,25 @@ def set_layout_property(self, layer_id: str, prop: str, value: any) -> None: """ self.add_call("setLayoutProperty", layer_id, prop, value) + def set_data(self, source_id: str, data: dict) -> None: + """Update the data of a GeoJSON source + + Args: + source_id (str): The name of the source to be updated. + data (dict): The data of the source. + """ + self.add_call("setSourceData", source_id, data) + + def set_visibility(self, layer_id: str, visible: bool = True) -> None: + """Update the visibility of a layer + + Args: + layer_id (str): The name of the layer to be updated. + visible (bool): Whether the layer is visible or not. + """ + value = "visible" if visible else "none" + self.add_call("setLayoutProperty", layer_id, "visibility", value) + def to_html(self, **kwargs) -> str: """Render to html diff --git a/maplibre/mapcontext.py b/maplibre/mapcontext.py index 06fdca82..33eebff7 100644 --- a/maplibre/mapcontext.py +++ b/maplibre/mapcontext.py @@ -4,6 +4,19 @@ class MapContext(Map): + """MapContext + + Use this class to update a `Map` instance in a Shiny app. + Must be used inside an async function. + + See `maplibre.Map` for available methods. + + Args: + id (string): The id of the map to be updated. + session (Session): A Shiny session. + If `None`, the active session is used. + """ + def __init__(self, id: str, session: Session = None) -> None: self.id = id self._session = require_active_session(session) diff --git a/maplibre/server.py b/maplibre/server.py index b4827073..165932ae 100644 --- a/maplibre/server.py +++ b/maplibre/server.py @@ -1,7 +1,11 @@ from __future__ import annotations -from shiny.render.transformer import (TransformerMetadata, ValueFn, - output_transformer, resolve_value_fn) +from shiny.render.transformer import ( + TransformerMetadata, + ValueFn, + output_transformer, + resolve_value_fn, +) from shiny.session import get_current_session from .map import Map diff --git a/maplibre/srcjs/index.js b/maplibre/srcjs/index.js index e333e08f..c1121f06 100644 --- a/maplibre/srcjs/index.js +++ b/maplibre/srcjs/index.js @@ -71,6 +71,9 @@ popup.remove(); }); } + setSourceData(sourceId, data) { + this._map.getSource(sourceId).setData(data); + } render(calls) { calls.forEach(([name, params]) => { if ([ @@ -79,7 +82,8 @@ "addTooltip", "addMarker", "addPopup", - "addControl" + "addControl", + "setSourceData" ].includes(name)) { console.log("Custom method", name, params); this[name](...params); diff --git a/maplibre/srcjs/ipywidget.js b/maplibre/srcjs/ipywidget.js index ec8c48bc..3a488ce7 100644 --- a/maplibre/srcjs/ipywidget.js +++ b/maplibre/srcjs/ipywidget.js @@ -45,6 +45,9 @@ function getCustomMapMethods(maplibregl2, map) { marker.setPopup(popup_); } marker.addTo(map); + }, + setSourceData: function(sourceId, data) { + map.getSource(sourceId).setData(data); } }; } diff --git a/mkdocs.yml b/mkdocs.yml index 67d226bb..95e5a0d3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -29,6 +29,8 @@ nav: - 3D Indoor mapping: examples/3d_indoor_mapping/index.md - Custom basemap: examples/custom_basemap/index.md - GeoPandas: examples/geopandas/index.md + - UK Road Safety: examples/road_safety/index.md + - Where is the ISS: examples/where_is_the_iss/index.md plugins: - search: diff --git a/poetry.lock b/poetry.lock index 358abc41..51db4cb3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -203,7 +203,7 @@ files = [ name = "charset-normalizer" version = "3.3.2" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -category = "main" +category = "dev" optional = false python-versions = ">=3.7.0" files = [ @@ -1845,7 +1845,7 @@ files = [ name = "requests" version = "2.31.0" description = "Python HTTP for Humans." -category = "main" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2105,7 +2105,7 @@ test = ["coverage", "pytest", "pytest-cov"] name = "urllib3" version = "2.1.0" description = "HTTP library with thread-safe connection pooling, file post, and more." -category = "main" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -2394,4 +2394,4 @@ all = ["geopandas", "pandas"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4" -content-hash = "f63acd17c501b755d5a19a20a822b8673678bb1eb09a5dea577d6fefaf1a9b6d" +content-hash = "47ba10b55a403b30b258b7298c519ac5c9e0a4b5c611c578f0d3a665233d7d88" diff --git a/pyproject.toml b/pyproject.toml index 2faec20e..43e3cd7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [tool.poetry] name = "maplibre" -version = "0.1.1" -description = "" +version = "0.1.2" +description = "Python bindings for MapLibre GL JS" authors = ["Stefan Kuethe "] readme = "README.md" license = "MIT" @@ -13,7 +13,6 @@ include = [ python = ">=3.9,<4" shiny = "^0.6.1" htmltools = "^0.5.1" -requests = "^2.31.0" jinja2 = "^3.1.3" pydantic = "^2.5.3" anywidget = "^0.8.1" diff --git a/srcjs/mapmethods.js b/srcjs/mapmethods.js index 35820ee1..17d094fd 100644 --- a/srcjs/mapmethods.js +++ b/srcjs/mapmethods.js @@ -50,6 +50,10 @@ function getCustomMapMethods(maplibregl, map) { } marker.addTo(map); }, + + setSourceData: function (sourceId, data) { + map.getSource(sourceId).setData(data); + }, }; } diff --git a/srcjs/pymaplibregl.js b/srcjs/pymaplibregl.js index 5cebe38a..52e0e4c6 100644 --- a/srcjs/pymaplibregl.js +++ b/srcjs/pymaplibregl.js @@ -88,6 +88,10 @@ export default class PyMapLibreGL { }); } + setSourceData(sourceId, data) { + this._map.getSource(sourceId).setData(data); + } + render(calls) { calls.forEach(([name, params]) => { // Custom method @@ -99,6 +103,7 @@ export default class PyMapLibreGL { "addMarker", "addPopup", "addControl", + "setSourceData", ].includes(name) ) { console.log("Custom method", name, params); diff --git a/tests/test_controls.py b/tests/test_controls.py index 72abd426..f7414a73 100644 --- a/tests/test_controls.py +++ b/tests/test_controls.py @@ -7,6 +7,3 @@ def test_scale_control(): assert control.type == ControlType.SCALE.value assert control.unit == "metric" - - -# def test_scale_control(): diff --git a/tests/test_experimental.py b/tests/test_experimental.py deleted file mode 100644 index 4d16ec31..00000000 --- a/tests/test_experimental.py +++ /dev/null @@ -1,19 +0,0 @@ -from maplibre.experimental import PydanticSer -from maplibre.layer import Layer, LayerType - - -def test_pydantic_model(): - m = PydanticSer(a=1, b=2) - - print(m) - print(m.model_dump()) - print(dict(m)) - print(m.to_dict()) - - -def test_layer(): - layer = Layer(type=LayerType.LINE) - print("line", LayerType(LayerType.LINE).value) - - print(layer.model_dump()) - # print(layer.to_dict()) diff --git a/tests/test_mapwidget.py b/tests/test_mapwidget.py index ce8ddc38..ee6198e0 100644 --- a/tests/test_mapwidget.py +++ b/tests/test_mapwidget.py @@ -1,11 +1,14 @@ import pytest -from maplibre.ipywidget import MapWidget +# anywidget causes "core dumped" error +# from maplibre.ipywidget import MapWidget -# @pytest.mark.skip("enable me") +""" +@pytest.mark.skip("enable me") def test_maplibre_widget(): widget = MapWidget(height=200) print(widget.map_options) print(widget.height) assert widget.height == "200px" +"""