* feat: add per-layer cartographic blend modes (#1981) Overlaying colour or imagery on a hillshade needs Multiply, not reduced opacity, which dilutes both layers instead of combining them. MapLibre exposes no public per-layer blend API, but its render loop can be wrapped to set the GPU blend state while one layer draws, so blending happens in the right z-position of the same canvas rather than over it. Fills and lines are routed through MapLibre's layer-opacity composite so a layer blends as one surface instead of once per overlapping polygon, and labels are excluded so place names stay legible. * Address review feedback - Document that circle and fill-extrusion layers blend per symbol, not per layer. Confirmed in the browser: overlapping points double-darken under Multiply. Only fill and line have a *-layer-opacity in the style spec, so there is nothing to elect for the other types; a new test pins that set and fails if a maplibre-gl bump adds one, so the caveat can be lifted then. - Subscribe the Layers panel to blend-mode support via useSyncExternalStore. Support is decided when the map installs the wrappers, which can happen after the panel first renders, so reading the module state once could leave a control visible that only saves a mode nothing applies. - Add DEFAULT_BLEND_MODE so the panel falls back to a non-optional BlendMode instead of repeating a literal the type already implies. - Make the e2e mode loop prove the mode was applied. It asserted only that the canvas stayed opaque, which is true before any mode reaches the GPU; it now waits for the pixel to settle and requires a colour change first. - Match the maplibre-gl seam names in definition or call position. A bare substring search passes on names like blendFunc that occur incidentally elsewhere in the bundle, so a rename would not have failed the drift test. * Address CodeRabbit review feedback - Assert a mode-specific rendered result for every blend mode in the e2e spec. Comparing each mode against the unblended baseline did not prove the mode had been applied: a stale frame left over from the previous mode also differs from the baseline, so a dropped update would still pass. Each mode must now render a pixel no other mode produced, and multiply must darken while screen, lighten, and add must lighten against the same backdrop. * Address Claude review feedback - Retry installLayerBlendModes from handleStyleReady. The constructor call is a no-op when the painter does not exist yet and leaves support undecided for "the next call", but it was the only call site, so a future MapLibre that deferred painter creation would strand the Blend control doing nothing. - Resolve a native sub-layer to the registration with the longest matching prefix rather than the first. Store ids are UUIDs when data is added in the app, but a hand-authored or MCP-authored project may use any string, and ids like abc and abc-2 both prefix layer-abc-2-fill; insertion order then decided which layer's mode applied. Covered by a test in both registration orders. - Trim the resetLayerBlendModes comment to what the code does. It is not called from destroy() on purpose: the registry is shared by every map, so clearing it when one pane of a split view closes would drop the other pane's modes. - Drop the unused id on the blend label, matching the aria-label-only convention the rest of the panel uses. * Address Claude review feedback - Hide the Blend control on layers a control draws itself. The gate only checked metadata.paintMode, but the custom-layer controls (3D Tiles, Gaussian splats, LiDAR, the deck.gl COG raster engine) set metadata.customLayerType instead, so the control rendered on layers it cannot affect and still saved a mode into the project. Contradicted this PR's own documentation. Verified in a browser that plain XYZ rasters, which set neither mark, still show the control and still blend to the same pixels as before. - Correct the user guide accordingly: it named deck.gl, 3D Tiles, and Gaussian splats but not LiDAR or Add Data > Raster Layer, and did not say which raster and vector layers do blend. - Guard this.map before the style-ready install call instead of force unwrapping, matching the other calls in the same closure. - Note why an exact native-id match outranks any prefix match: it is an explicit claim by the layer's control, where a prefix is inferred. * Address Claude review feedback Correct an inaccurate claim about Add Vector Layer, not the gate itself. createVectorStoreLayer always sets metadata.customLayerType alongside controlOwnsPaint, so those layers are gated out of the Blend control, and correctly so: the control paints its own native fill/line layers, layer-sync never applies fillPaint/linePaint to them, and the *-layer-opacity that elects MapLibre's composite path never lands. A Blend menu there would do nothing. The previous commit's user guide, code comment, and test said the opposite. The test in particular built its mock without customLayerType, a shape the real control never produces, so it passed while asserting the wrong thing. All three now describe what the code does, and the mock is built the way the control builds it. * Address review feedback - Gate layerOpacityForBlend on layerBlendModesSupported() so a build where the render wrappers failed to install is fully inert. A project saved with a blend mode would otherwise still pay for a render-to-texture pass per blended layer, compositing a mode nothing is left to apply. - Say Layers panel, not Style panel: LayerPanel.tsx is the only consumer of layerBlendModesSupported, and StylePanel.tsx does not reference blend modes. - Fix the referent in the user guide, which read as though the list that follows were the layers GeoLibre styles rather than the ones it does not. * Address Claude review feedback - Mirror blendMode into python/src/geolibre/project.py's DEFAULT_LAYER_STYLE. That dict documents itself as a mirror of the TypeScript default, and nothing in CI catches drift, so projects authored through the MCP server were the one path not carrying the field. - Stop the native-layer resolution cache growing for the life of the page. It was only cleared when the set of blending layers changed, so a session that never sets a mode never cleared it while still inserting an entry per native style layer drawn. It is now dropped on every sync, and a map with no blended layer answers without touching the cache at all. * Address Claude review feedback - Stop a blend mode leaking onto a non-blending layer whose id extends a blending one's. Longest-prefix-wins only disambiguated between layers that were both in the registry, so with "region" on Multiply and "region-2" on Normal, layer-region-2-fill matched "region"'s prefix with nothing registered to contest it and a layer explicitly set to Normal blended. Resolution now runs over every layer's prefix, so the longest match is always the owning layer and a non-blending owner resolves to no mode. The new test fails against the previous implementation. - Document why blendMode travels with copy/paste style while opacity does not, and pin both halves with a test. They sit together in the panel, but opacity is a transient knob and a blend mode is part of the look being copied, which is also how QGIS treats it. * Address Claude review feedback - Find renderLayer's style-layer argument by shape rather than by position, so a maplibre-gl bump that reorders the internal signature without renaming the method keeps working instead of silently reading the wrong object. The loud guard for the mechanism as a whole stays e2e/blend-modes.spec.ts, which asserts real pixels per mode. - Make the blend label a real label element bound to the select, so clicking the word focuses the menu and selectId is actually referenced. * Address Claude review feedback - Fix the Portuguese label for Screen: "Divisão" is Division/Split, which this catalog already uses for splitView, and corresponds to the Divide blend mode rather than Screen. The Photoshop/GIMP pt-BR term is "Tela". Checked the other Romance and Germanic catalogs against the same glossary; they were correct. - Assert exported Mapbox styles carry no *-layer-opacity paint keys. fillPaint and linePaint always emit them, so only withoutLayerOpacity keeps them out of a portable style, and nothing caught a call site that skipped it. Confirmed the test fails when the stripper is removed. * Move the Blend menu to the Style panel Blend mode is symbology rather than a layer-card control, so it now sits in the Style panel above the symbology sections, and the layer card is back to just opacity. Defined once as blendModeControl and rendered in each of the panel's terminal branches, so raster, vector, and the no-paint-controls fallback all offer it under the same gate as before. Message keys moved layers.* -> style.* across every catalog to match. Also fixes a multi-pane bug found in review while relocating the control: the repaint after a mode change was gated on syncLayerBlendModes reporting that the shared registry changed, but that registry is module-level, so with a split view open the first pane to sync won the diff and the others kept rendering the old mode until something unrelated redrew them. Each controller now compares its own blendModeSignature, which covers only the blending layers so a map where nothing blends still asks for no extra frames. * Address Claude review feedback Two leftovers from moving the Blend menu to the Style panel: - Drop the now-unused setLayerStyle selector from LayerPanel; the Blend menu was its only consumer. - Drop the stale JSDoc block above blendMenu in the e2e spec, which described the earlier openBlendMenu helper and sat above the current one.
geolibre
GeoLibre in Jupyter: the full GeoLibre GIS app as an anywidget, with a leafmap-style Python API.
The widget embeds the complete GeoLibre app (menus, panels, processing tools)
inside a notebook cell. State syncs both ways through a single
.geolibre.json project, so data you add from Python appears in the UI, and
edits you make in the UI are readable back from Python.
Install
pip install geolibre
Or with conda from conda-forge:
conda install -c conda-forge geolibre
Quickstart
from geolibre import Map
m = Map(center=(-100, 40), zoom=4)
m.add_geojson("https://example.com/data.geojson", name="Data")
m
Add more data and drive the view:
m.add_tile_layer(
"https://tile.openstreetmap.org/{z}/{x}/{y}.png",
name="OpenStreetMap",
attribution="(c) OpenStreetMap contributors",
)
m.add_cog("https://example.com/dem.tif", name="DEM", colormap="terrain")
m.add_basemap("dark")
m.set_center(-120, 47, zoom=8)
Round-trip the project:
m.save_project("my-map.geolibre.json")
m2 = Map()
m2.load_project("my-map.geolibre.json")
# Read state edited in the UI (e.g. after panning/zooming):
m.to_project()["mapView"]["center"]
API
| Method | Description |
|---|---|
Map(center, zoom, basemap=, height=, layout=, theme=) |
Create a map. layout is "embed", "full", or "maponly". |
add_geojson(data, name=, **style) |
Add GeoJSON (dict, path, URL, JSON, or GeoDataFrame). |
add_gdf(gdf, name=, column=None, **style) |
Add a GeoDataFrame, optionally as a choropleth. |
add_csv / add_xy_data (data, x=, y=, name=, **style) |
Add points from CSV, a DataFrame, or row mappings. |
add_heatmap(points, name=, radius=, intensity=, **style) |
Add a point density heatmap. |
add_vector(data, name=, render_mode=, data_format=, source_layer=, **style) |
Add a vector dataset from a URL (GeoParquet, FlatGeobuf, zipped Shapefile, GeoJSON) or a local file (read via GeoPandas, inlined). |
add_geoparquet / add_flatgeobuf / add_shp / add_kml / add_gpkg |
Format-specific wrappers over add_vector. |
add_vector_tiles(url, name=, source_layers=, source_layer=, **style) |
Add vector tiles from a TileJSON endpoint. |
add_pmtiles(url, name=, tile_type=, source_layers=, **style) |
Add a PMTiles archive (vector or raster). |
add_tile_layer(url, name=, tile_size=, attribution=) |
Add a raster XYZ tile layer. |
add_wms(endpoint, layers, name=, styles=, image_format=, transparent=, tile_size=, **style) |
Add a WMS (GetMap) tiled raster layer. |
add_wmts(url, name=, tile_size=, **style) |
Add a WMTS tile URL template. |
add_wfs(endpoint, type_name, name=, version=, output_format=, srs_name=, max_features=, **style) |
Add a WFS layer (GeoJSON, fetched and inlined). |
add_cog(url, name=, bands=, colormap=, rescale=) |
Add a Cloud Optimized GeoTIFF. |
add_raster(url, name=, bands=, colormap=, rescale=) |
Add a raster (alias of add_cog). |
add_3d_tiles(url, name=, altitude_offset=, request_headers=, **style) |
Add a 3D Tiles tileset.json. |
add_video(urls, coordinates, name=, **style) |
Add a georeferenced video (four [lng, lat] corners). |
add_basemap(basemap) |
Set the background basemap. |
set_center(lng, lat, zoom=None) |
Center (and optionally zoom) the map. |
set_center_zoom(lng, lat, zoom=None) |
Alias of set_center (leafmap compatibility). |
zoom_to_bounds(bounds) / zoom_to_layer(layer) |
Fit the view to bounds or a layer id/name/handle. |
layer_names / find_layer(name) / set_layer_visibility / set_layer_opacity |
Inspect and update layers conveniently. |
rename_layer / move_layer / duplicate_layer / show_layer / hide_layer |
Manage layers by id, name, or Layer handle. |
layer_properties(layer) / column_values(layer, column) / describe() |
Inspect inlined data and summarize a project without a browser round trip. |
remove_layer(layer_id) / clear_layers() |
Remove one layer by id, name, or handle, or remove all layers. |
center / zoom / bearing / pitch / basemap / name |
Read persisted project and camera state; name is writable. |
set_zoom / set_bearing / set_pitch / fit_project_bounds |
Persist camera changes without requiring the widget to be displayed. |
list_whitebox_tools() / run_whitebox_tool(id, parameters) |
Discover and run bundled Whitebox tools locally via browser WASM. |
to_project() / load_project(src) / save_project(path) |
Project I/O. |
Layer handles provide the same operations in an object-oriented form:
m.add_geojson("https://example.com/roads.geojson", name="Roads")
roads = m.find_layer("Roads") # None when no layer has that name
roads.opacity = 0.6
roads.set_style(lineColor="#e63946", lineWidth=3)
roads.move(0)
print(roads.properties()) # sampled values for every property
print(roads.column("highway")) # one value per feature
roads_copy = roads.duplicate(name="Roads (proposed)")
Run a Whitebox tool against a map layer (the map must be displayed first):
dem = m.get_layer(m.add_raster("https://example.com/dem.tif", name="DEM"))
result = m.run_whitebox_tool("slope", {"input": dem, "units": "degrees"})
slope = m.get_layer(result["resultLayerIds"][0])
For headless authoring and scripts that do not need a widget, commonly used project utilities are available directly from the top-level package:
from geolibre import (
basemap_catalog,
builtin_legend_names,
color_ramp_names,
describe_project,
load_project,
save_project,
)
project = load_project("my-map.geolibre.json")
print(describe_project(project))
save_project("copy.geolibre.json", project)
These are the lossless file primitives: unlike Map.save_project, the top-level
save_project writes the project verbatim, credentials included, so that
editing a project in place cannot strip your own API keys out of it. Pass a
project through geolibre.project.redact_credentials first if the file is going
anywhere untrusted, or use Map.save_project, which redacts by default.
Notes
-
marimo can render the anywidget, but its browser may not be able to reach GeoLibre's random localhost port. If the iframe reports
127.0.0.1 refused to connect, select the hosted app before displaying the map:from geolibre import Map m = Map(center=(-100, 40), zoom=4) m._app_url = "https://web.geolibre.app/" m.add_basemap("dark") m.add_vector( "https://data.source.coop/giswqs/opengeos/world_cities.geojson", name="World cities", ) mSet
_app_urlbefore returningmfrom the cell. With the hosted app, use hosted URLs for rasters and other browser-loaded sources; it cannot access files served from the kernel's temporary localhost server. Local GeoJSON, CSV, and vector files that are read in Python and inlined still work. The example usesadd_vector()so the browser, rather than Python, fetches the remote GeoJSON URL.Privacy: The widget sends its synchronized project, including any inlined local data, to the origin in
_app_urlthroughwindow.postMessage. Use only a trusted app URL, or host the GeoLibre app yourself, when working with sensitive data. -
The bundled app is served from a localhost HTTP server, so the interactive widget works in local Jupyter and VS Code directly. Google Colab routes through its built-in port proxy automatically. On JupyterHub (including managed/shared hubs) the front-end tries two same-origin routes and uses whichever is live, so a host needs only one of them: the Jupyter Server extension bundled with
geolibreat{base_url}geolibre/app/(enabled automatically onpip install geolibre, but registered only after the Jupyter server restarts), andjupyter-server-proxyat{base_url}proxy/{port}/(works in the running server with no restart where it is installed). On other remote servers (Binder, remote JupyterLab), passMap(server_proxy=True)to use that same remote path;Map(server_proxy=False)forces the direct path. -
Optional extras:
pip install "geolibre[all]"adds GeoPandas/Shapely support foradd_geojson(geodataframe)and for reading local vector files (add_vector/add_geoparquet/add_flatgeobuf/add_shp/add_kml/add_gpkg), which the kernel reads and inlines as GeoJSON. Remote URLs for the same formats stream through the in-browser vector control and need no extras. -
add_geojsoninlines file/URL data into the project (up to 50 MB), so a large dataset is held in memory and re-synced on every project update. For very large layers, prefer a tile or COG source (add_tile_layer/add_cog) the app fetches directly.
MCP server
The package also ships a headless MCP server
that authors .geolibre.json projects from an AI client:
pip install "geolibre[mcp]"
geolibre-mcp --root ~/maps
It confines every read and write to the roots you pass (--root, repeatable, or
GEOLIBRE_MCP_ROOTS) and builds projects through the same builders this package
uses, so anything it writes opens in the widget unchanged. See
docs/mcp.md for the tool list and client
configuration.