# Python package (Jupyter)

[![image](https://img.shields.io/pypi/v/geolibre.svg)](https://pypi.python.org/pypi/geolibre)
[![image](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/opengeos/GeoLibre/blob/main/python/examples/getting-started.ipynb)
[![image](https://img.shields.io/conda/vn/conda-forge/geolibre.svg)](https://anaconda.org/conda-forge/geolibre)
[![Conda Recipe](https://img.shields.io/badge/recipe-geolibre-green.svg)](https://github.com/conda-forge/geolibre-feedstock)

GeoLibre ships a Python package, **`geolibre`**, that embeds the full GeoLibre
app inside a Jupyter notebook cell as an [anywidget](https://anywidget.dev),
with a [leafmap](https://leafmap.org)-style API.

The widget loads the complete GeoLibre app (menus, panels, processing tools) in
an iframe. 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
(panning, zooming, adding layers) are readable back from Python.

## Install

```bash
pip install geolibre
```

Or with conda from [conda-forge](https://anaconda.org/conda-forge/geolibre):

```bash
conda install -c conda-forge geolibre
```

Optional extras for `add_geojson()` from a GeoDataFrame and for reading **local**
vector files with `add_vector()` / `add_geoparquet()` / `add_flatgeobuf()` /
`add_shp()` / `add_kml()` / `add_gpkg()` (remote URLs for those formats need no
extras):

```bash
pip install "geolibre[all]"   # adds GeoPandas and Shapely
```

The optional `[all]` extra is pip-only. If you installed via conda, add it with
`pip install "geolibre[all]"` inside the same environment.

## Quickstart

```python
from geolibre import Map

m = Map(center=(-100, 40), zoom=4)
m.add_geojson("https://example.com/data.geojson", name="Data")
m
```

The full GeoLibre UI renders in the cell. Add more data and drive the view:

```python
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)
```

`add_raster` / `add_cog` also accept a **local** GeoTIFF path on the kernel host:
the file is served by the bundled localhost server so the app can read it. This
only works where the **browser can reach the kernel's localhost** (local Jupyter,
VS Code); on remote/browser-separated setups (Colab, JupyterHub, remote servers)
the localhost route is unreachable, so pass a hosted URL there. The served URL is
also session-scoped, so a project saved with a local raster will not restore it
when reopened later — pass a hosted URL for durable projects.

Add markers and data-driven symbology without precomputing styles:

```python
m.add_marker(-122.4, 37.8, properties={"name": "San Francisco"})
m.add_marker_cluster([(-122.4, 37.8), (-122.3, 37.9), (-122.5, 37.7)])
m.add_heatmap([(-122.4, 37.8), (-122.3, 37.9)], radius=35)
m.add_csv("cities.csv", x="longitude", y="latitude", name="Cities")
m.add_choropleth(
    "https://example.com/counties.geojson",
    column="population",
    colormap="blues",
    scheme="quantile",
)
```

Add a legend, a colorbar, and a swipe (split-map) comparison:

```python
# A built-in land-cover legend, or your own {label: color} dict.
m.add_legend(builtin="nlcd")
m.add_legend(legend_dict={"Water": "#0000ff", "Land": "#00ff00"})

# A colorbar for a continuous raster.
m.add_colorbar(colormap="terrain", vmin=0, vmax=4000, label="Elevation", units="m")

# Compare two layers (or a layer against the basemap) with a swipe slider.
before = m.add_cog("https://example.com/before.tif", name="Before")
after = m.add_cog("https://example.com/after.tif", name="After")
m.split_map(before, after)
```

## Two-way sync

Because the project syncs both ways, you can pan or zoom the map in the UI and
then read the live state back from Python:

```python
proj = m.to_project()
proj["mapView"]["center"]              # reflects the live UI view
[layer["name"] for layer in proj["layers"]]
```

Save and reload projects, fully interchangeable with the desktop and web apps:

```python
m.save_project("my-map.geolibre.json")

m2 = Map()
m2.load_project("my-map.geolibre.json")
m2
```

`to_project()`, `save_project()`, and `to_html()` redact credentials — API keys,
tokens, authenticated request headers, environment variables, geocoder keys, and
credential URL parameters — so anything you serialize, commit, or share is safe
by default. Pass `keep_credentials=True` to `to_project()` or `save_project()`
for a trusted local file that must keep working without re-entering them:

```python
m.save_project("private.geolibre.json", keep_credentials=True)
```

The `project` trait itself is unredacted, so live two-way sync with the widget
keeps authenticated layers rendering.

## Map options

```python
Map(
    center=(-100, 40),   # [lng, lat]
    zoom=4,
    basemap="dark",      # a basemap name or a MapLibre style URL
    height="800px",
    layout="embed",      # "embed" (compact UI), "full" (desktop UI), or "maponly"
    theme="light",       # "light" or "dark"
)
```

## Interactive scripting

Beyond adding data, the widget can **query the live app and react to it** — the
same surface as the in-app [Python Console](user-guide/python-console.md). These
calls round-trip to the running map, so the map must be displayed first (show
`m` in a cell), then run the queries in a later cell.

```python
m.get_center()                 # live [lng, lat], reflecting UI pans/zooms
m.get_bounds()                 # [west, south, east, north]
m.fly_to(-122.4, 37.8, zoom=10)
m.identify(-122.4, 37.8)       # features at a point, like clicking the map

# Layer objects: read and mutate layers, read their features
for layer in m.layers:
    layer.opacity = 0.6
features = m.layers[0].get_features()   # list of GeoJSON Feature objects

# Run a processing algorithm; result layers are added to the map
m.list_algorithms()
m.run_algorithm("buffer", {"layer": layer_id, "distance": 1000})

# Read back what the user selected or drew on the map
m.get_selected_features()      # the clicked feature(s) as Feature objects
m.get_drawn_features()         # features sketched with the Geo Editor
m.user_rois                    # the drawn ROIs as a GeoJSON FeatureCollection
m.get_drawn_features(as_gdf=True)   # the same, as a GeoDataFrame (needs GeoPandas)

png = m.to_image()             # PNG bytes (or m.to_image("map.png"))
m.to_html("map.html")          # standalone HTML embedding the live project
```

React to user interaction with event callbacks:

```python
m.on_click(lambda e: print("clicked", e["lngLat"]))
m.on_selection_change(lambda e: print("selected", e))
m.on_layer_change(lambda e: print("layers", e["layerIds"]))
```

!!! note "Blocking queries"
    Interactive queries block the kernel until the app replies (via
    `jupyter_ui_poll`, installed automatically). Pass `timeout=` for slow calls,
    e.g. `m.run_algorithm(..., timeout=300)`.

## API reference

### Interactive queries, events & processing

| Method | Description |
| --- | --- |
| `get_view()` / `get_center()` / `get_bounds()` | Read the live camera / center / viewport bounds. |
| `fly_to(lng, lat, zoom=, bearing=, pitch=, duration=)` | Animate the camera. |
| `fit_bounds([w, s, e, n])` | Fit the camera to a bounding box. |
| `zoom_to_bounds([w, s, e, n])` / `zoom_to_layer(layer)` | Leafmap-style view helpers; layers may be addressed by id, name, or handle. |
| `identify(lng, lat, layer_id=None)` | Query rendered features at a point. |
| `get_features(layer_id)` | A layer's features as `Feature` objects. |
| `get_selected_features(as_gdf=False)` | The feature(s) selected in the app, as `Feature` objects (or a GeoDataFrame). |
| `get_drawn_features(as_gdf=False)` / `user_rois` | Features drawn with the Geo Editor; `user_rois` returns them as a FeatureCollection. |
| `layers` / `get_layer(id)` | `Layer` handles (read state; set `name`/`visible`/`opacity`, `set_style`, `get_features`, `zoom_to`, `remove`). |
| `layer_names` / `find_layer(name)` / `find_layer_index(name)` | Inspect layers by display name. |
| `set_layer_visibility(layer, visible)` / `set_layer_opacity(layer, opacity)` | Change a layer by id, name, or handle. |
| `list_algorithms()` | Available processing algorithms (`id`, `parameters`, …). |
| `run_algorithm(id, parameters=None, timeout=)` | Run an algorithm; returns `{logs, resultLayerIds}`. |
| `to_image(path=None, timeout=)` | Capture the map as PNG bytes, or write to `path`. |
| `to_html(path=None, title=, width=, height=, app_url=)` | Export a standalone HTML page that embeds the current project (credentials redacted); returns the HTML or writes to `path`. |
| `on(event, cb)` / `on_click` / `on_selection_change` / `on_layer_change` | Register event callbacks; returns an unsubscribe function. |
| `request(method, params=None, timeout=)` | Low-level command primitive behind the methods above. |

### Data, view & projects

| Method | Description |
| --- | --- |
| `Map(center, zoom, basemap=, height=, layout=, theme=)` | Create a map. |
| `add_geojson(data, name=, **style)` | Add GeoJSON from a dict, file path, URL, JSON string, or GeoDataFrame. |
| `add_gdf(gdf, name=, column=None, **style)` | Add a GeoDataFrame, optionally as a choropleth. |
| `add_csv(data, x="longitude", y="latitude", name=, **style)` / `add_xy_data(...)` | Add points from a CSV path, URL, text, DataFrame, or row mappings. |
| `add_marker(lng, lat, name=, properties=, **style)` | Add a single point marker (shown as a circle; `properties` appear on click). |
| `add_markers(points, name=, **style)` | Add point markers from `(lng, lat)` pairs, `{lng/lon/x, lat/y, …}` dicts, GeoJSON, or a GeoDataFrame. |
| `add_circle_markers(points, name=, radius=, **style)` | Add circle markers with an explicit `radius`. |
| `add_marker_cluster(points, name=, cluster_radius=, cluster_max_zoom=, **style)` | Add clustered point markers. |
| `add_heatmap(points, name=, radius=, intensity=, **style)` | Add point data using the density heatmap renderer. |
| `add_choropleth(data, column, name=, class_count=, colormap=, scheme=, **style)` | Add a GeoJSON layer with graduated symbology computed from a numeric `column`. |
| `add_data(data, column=None, name=, **kwargs)` | Add data; a choropleth when `column` is given, else a plain GeoJSON layer (leafmap parity). |
| `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 and inlined). |
| `add_geoparquet(data, name=, **style)` | Add a GeoParquet dataset (URL or local file). |
| `add_flatgeobuf(data, name=, **style)` | Add a FlatGeobuf dataset (URL or local file). |
| `add_shp(data, name=, **style)` | Add a Shapefile (zipped URL or local `.shp`). |
| `add_kml(data, name=, **style)` / `add_gpkg(data, name=, layer=None, **style)` | Add KML/KMZ or GeoPackage data. |
| `add_vector_tiles(url, name=, source_layers=, source_layer=, **style)` | Add a vector tile layer 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 layer (GetMap, tiled raster). |
| `add_wmts(url, name=, tile_size=, **style)` | Add a WMTS layer from a tile URL template. |
| `add_wfs(endpoint, type_name, name=, version=, output_format=, srs_name=, max_features=, **style)` | Add a WFS layer (GetFeature GeoJSON, fetched and inlined). |
| `add_cog(url, name=, bands=, colormap=, rescale=, **style)` | Add a Cloud Optimized GeoTIFF (URL or a kernel-side local GeoTIFF path). |
| `add_raster(url, name=, bands=, colormap=, rescale=, **style)` | Add a raster (COG/GeoTIFF), URL or local path; 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. |
| `split_map(left_layers=None, right_layers=None, orientation=, position=, control_position=)` | Add a swipe (split-map) comparison slider between two layer sets. |
| `add_legend(title=None, legend_dict=, labels=, colors=, builtin=, position=, shape=)` | Add a legend from a `{label: color}` dict, parallel `labels`/`colors`, or a `builtin` preset (`"nlcd"`, `"esa_worldcover"`). |
| `add_colorbar(colormap=, vmin=, vmax=, label=, units=, colors=, orientation=, position=)` | Add a colorbar for a continuous raster, from a named colormap or custom `colors`. |
| `add_colormap(colormap, vmin=, vmax=, label=, **kwargs)` | Add a colorbar from a named colormap (leafmap-style alias of `add_colorbar`). |
| `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). |
| `set_zoom(zoom)` / `set_bearing(bearing)` / `set_pitch(pitch)` / `fit_project_bounds(bounds)` | Persist camera changes without requiring the widget to be displayed. |
| `center` / `zoom` / `bearing` / `pitch` / `basemap` / `name` | Read persisted project and camera state; `name` is writable. |
| `rename_layer(layer, name)` / `move_layer(layer, index)` / `duplicate_layer(layer, name=)` / `show_layer(layer)` / `hide_layer(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. |
| `to_project(keep_credentials=False)` | Return the current project as a dict, credentials redacted unless `keep_credentials=True`. |
| `load_project(src)` | Replace the project from a dict, JSON string, or `.geolibre.json` path. |
| `save_project(path, keep_credentials=False)` | Write the current project to a `.geolibre.json` file, credentials redacted unless `keep_credentials=True`. |

Style keyword arguments (for example `fillColor`, `strokeColor`, `strokeWidth`,
`circleRadius`) map to the GeoLibre [layer style fields](project-format.md).

## Use in marimo

[marimo](https://marimo.io/) can render GeoLibre's anywidget, but its browser
may not be able to reach the random `127.0.0.1` port where GeoLibre normally
serves the bundled app. The symptom is an iframe displaying
`127.0.0.1 refused to connect`. Point the widget at GeoLibre's hosted app before
displaying it:

```python
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
```

This uses the same project-sync bridge as the regular widget; only the app's
location changes. Set `_app_url` before returning `m` from the cell so the
iframe uses the hosted URL on its first render.

The example uses `add_vector()` so the hosted browser app fetches the remote
GeoJSON directly. `add_geojson(url)` instead downloads and inlines the file in
Python, which can fail when a data host rejects Python's HTTP client.

Because the hosted app cannot access files exposed by the kernel's temporary
localhost server, use hosted URLs for rasters and other sources the browser
loads directly. Local GeoJSON, CSV, and vector files that GeoLibre reads in
Python and inlines into the project continue to work. The `_app_url` attribute
is currently an internal compatibility workaround rather than a public
constructor option.

**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.

## How it works

The wheel bundles the GeoLibre web build. At import time the package starts a
small localhost static server that serves the bundled app; the widget renders
that app in an iframe and exchanges the project over `window.postMessage`.
Adding data from Python rewrites the synced project and pushes it into the app;
UI edits flow back the same way.

<!-- markdownlint-disable MD046 -->

!!! note "Environment support"

    The interactive widget works in **local Jupyter, VS Code, Google Colab,
    JupyterHub / remote servers, and marimo**:

    - **Local Jupyter / VS Code** - the app is served directly from localhost.
    - **Google Colab** - routes through Colab's built-in port proxy
      (`google.colab.kernel.proxyPort`) automatically.
    - **JupyterHub** (including managed/shared hubs, detected at runtime via
      `JUPYTERHUB_SERVICE_PREFIX`) - the front-end probes two same-origin routes
      and uses whichever is live, so a host needs only **one** of them:
        - the Jupyter Server extension bundled with `geolibre`, mounted at
          `{base_url}geolibre/app/` on the notebook server's own origin. It is
          enabled automatically on `pip install geolibre` and needs no
          `jupyter-server-proxy` and no extra port, so it works on locked-down
          hubs that block raw-port proxying -- but it only registers after the
          Jupyter server restarts, since it loads from a startup config drop-in.
        - `jupyter-server-proxy` at `{base_url}proxy/{port}/`, which reaches the
          kernel's localhost bundle in the **running** server with no restart,
          wherever `jupyter-server-proxy` is installed.
    - **Other remote servers** (Binder, remote JupyterLab over SSH/network) -
      pass `Map(server_proxy=True)` to use that same dual-route remote path.
    - **marimo** - use the hosted app URL shown in [Use in
      marimo](#use-in-marimo); Jupyter's proxy and server-extension routes are
      not available in marimo.

    Set `Map(server_proxy=False)` to force the direct localhost path. If the app
    fails to load on a hub, either install `jupyter-server-proxy`, or confirm the
    extension is enabled with `jupyter server extension list` (look for
    `geolibre`; run `jupyter server extension enable geolibre` if absent) and
    **restart** the Jupyter server so the extension loads.

<!-- markdownlint-enable MD046 -->

!!! warning "URL fetching"

    `add_geojson(url)`, `add_csv(url)` / `add_xy_data(url)`, and `add_wfs()`
    fetch the URL from the **kernel**, following redirects, so a notebook can
    reach any host the kernel can. Every hop is checked, and a URL that resolves
    to a non-public address (private, loopback, or link-local — including cloud
    metadata endpoints such as `169.254.169.254`) is refused; responses are
    capped at 50 MB. Tile and service layers are fetched by the **browser**
    instead, so those can still point at a local server. Do not load untrusted
    `.geolibre.json` projects or URLs on a shared/multi-tenant kernel.

## MCP server

The same package ships an [MCP](https://modelcontextprotocol.io) server that
authors `.geolibre.json` projects from an AI client, with no notebook and no
running app involved:

```bash
pip install "geolibre[mcp]"
geolibre-mcp --root ~/maps
```

It builds projects through the same builders this package uses, so anything it
writes opens in the widget (and in the desktop and web apps) unchanged. See
[MCP server](mcp.md) for the tool list and client configuration.

## Building from source

The package lives in [`python/`](https://github.com/opengeos/GeoLibre/tree/main/python).
The bundled app is produced from the monorepo with:

```bash
npm run build:embed      # builds the app and stages it into the wheel
python -m build          # builds the wheel
python -m twine upload dist/*  # upload to PyPI
pip install -e python    # editable install for development
```

Changes to the Python code are picked up on kernel restart. Changes to the app
(TypeScript) require re-running `npm run build:embed` and restarting the kernel.
