Files
Qiusheng Wu 37bac0873e
CI / E2E smoke (Playwright) (push) Has been cancelled
CI / Dependency audit (push) Has been cancelled
CI / Validate CITATION.cff (push) Has been cancelled
CI / Collab relay image (push) Has been cancelled
CI / Build and test (push) Has been cancelled
Deploy Website / Build website and demo (push) Has been cancelled
Deploy Website / Deploy to GitHub Pages (push) Has been cancelled
Publish Container Image / Build and publish container image (push) Has been cancelled
Deploy studio.geolibre.app / Build and publish (push) Has been cancelled
Deploy web.geolibre.app / Build and publish (push) Has been cancelled
feat: add per-layer cartographic blend modes (#1981) (#2082)
* 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.
2026-08-23 18:57:39 -04:00
..
2026-06-10 09:38:26 -04:00

geolibre

image image image Conda Recipe

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",
    )
    m
    

    Set _app_url before returning m from 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 uses add_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_url through window.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 geolibre at {base_url}geolibre/app/ (enabled automatically on pip install geolibre, but registered only after the Jupyter server restarts), and jupyter-server-proxy at {base_url}proxy/{port}/ (works in the running server with no restart where it is installed). On other remote servers (Binder, remote JupyterLab), pass Map(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 for add_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_geojson inlines 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.