From c9cd715df801d0acb1dc882bc03284b7f0b75358 Mon Sep 17 00:00:00 2001 From: giswqs Date: Thu, 6 Aug 2026 00:39:18 -0400 Subject: [PATCH 01/12] feat(python): add an MCP server for authoring GeoLibre projects Adds `geolibre-mcp`, a headless stdio MCP server that writes real `.geolibre.json` projects from an AI client. No browser, no running app, and no bundled web build: it composes projects with the same builders the Python package already uses, so anything it writes opens unchanged in the desktop app, the web app, and the Jupyter widget. Layering, so nothing is duplicated: - `project.py` keeps *building* pieces (a layer, a plugin-state blob). - New `authoring.py` *applies* them to a whole project: add/remove/reorder and restyle layers, classify a choropleth, move the camera, compose the legend/colorbar/swipe controls, and summarize a project back. - `Map` now delegates its split-map, legend, colorbar, and choropleth composition to `authoring.py` instead of holding its own copy, so the widget and the MCP server cannot drift. - `to_html` splits into a module-level `render_project_html()` the server can call without a widget; `Map.to_html` delegates to it. The server itself is `mcp/server.py` (21 tools), the only module that imports the `mcp` SDK, kept behind the optional `geolibre[mcp]` extra. `mcp/workspace.py` confines every read and write to roots given via `--root` or `GEOLIBRE_MCP_ROOTS`, mirroring the sidecar's `GEOLIBRE_CONVERSION_ROOTS`; symlinks out of a root are refused, writes are limited to `.json`/`.html`, and an existing file needs `overwrite`. Verified end to end: a client spawned the server over stdio, listed the tools, and built a choropleth of 52 US states fetched from a live URL plus COG, XYZ, legend, colorbar, and swipe layers; the resulting project round-trips through the app's own `parseProject` with its symbology and plugin state intact, and a workspace escape is refused over the wire. `tests/test_mcp_server.py` skips itself without the SDK, so publish-python.yml installs `mcp` explicitly rather than shipping the server untested. --- .github/workflows/publish-python.yml | 4 +- CLAUDE.md | 4 +- docs/mcp.md | 159 +++++ docs/python.md | 15 + mkdocs.yml | 1 + python/README.md | 16 + python/pyproject.toml | 8 +- python/src/geolibre/authoring.py | 887 +++++++++++++++++++++++++++ python/src/geolibre/geolibre.py | 315 ++++------ python/src/geolibre/mcp/__init__.py | 32 + python/src/geolibre/mcp/__main__.py | 8 + python/src/geolibre/mcp/server.py | 850 +++++++++++++++++++++++++ python/src/geolibre/mcp/workspace.py | 127 ++++ python/tests/test_authoring.py | 337 ++++++++++ python/tests/test_mcp_server.py | 317 ++++++++++ 15 files changed, 2873 insertions(+), 207 deletions(-) create mode 100644 docs/mcp.md create mode 100644 python/src/geolibre/authoring.py create mode 100644 python/src/geolibre/mcp/__init__.py create mode 100644 python/src/geolibre/mcp/__main__.py create mode 100644 python/src/geolibre/mcp/server.py create mode 100644 python/src/geolibre/mcp/workspace.py create mode 100644 python/tests/test_authoring.py create mode 100644 python/tests/test_mcp_server.py diff --git a/.github/workflows/publish-python.yml b/.github/workflows/publish-python.yml index 800dc919f6..8914103bd5 100644 --- a/.github/workflows/publish-python.yml +++ b/.github/workflows/publish-python.yml @@ -42,8 +42,10 @@ jobs: # Tests cover the pure-Python builders and do not need the bundled web # app, so install only the runtime/test deps and run against the sources. + # `mcp` is an optional extra, but tests/test_mcp_server.py skips itself + # without it, so install it here or the MCP server ships untested. - name: Install test dependencies - run: python -m pip install anywidget traitlets pytest + run: python -m pip install anywidget traitlets pytest "mcp>=2.0" - name: Run tests run: python -m pytest python/tests diff --git a/CLAUDE.md b/CLAUDE.md index 55ba62a003..2fcfc65b5b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,6 +83,8 @@ Rendering is MapLibre GL JS in the webview, with **deck.gl** for raster/point-cl The browser build proxies the sidecar at `/sidecar` (same-origin, no CORS); confined to `GEOLIBRE_CONVERSION_ROOTS` (default `/data`). Local MBTiles use a custom MapLibre protocol backed by Tauri commands. +**MCP server** (`python/src/geolibre/mcp/`, the `geolibre-mcp` console script): a headless stdio MCP server that authors `.geolibre.json` files. It is layered so nothing duplicates: `project.py` *builds* pieces (a layer, a plugin-state blob), `authoring.py` *applies* them to a whole project (add/remove/restyle a layer, move the camera, compose the legend/colorbar/swipe controls), and both `Map` and the MCP tools delegate to `authoring.py` — so a change to how a control is composed lands in one place. `server.py` is the only module that imports the `mcp` SDK (optional extra `geolibre[mcp]`), and `workspace.py` confines every path to `GEOLIBRE_MCP_ROOTS`/`--root` the way the sidecar confines to `GEOLIBRE_CONVERSION_ROOTS`. `tests/test_mcp_server.py` skips itself without the SDK, so `publish-python.yml` installs `mcp` explicitly — drop it and the server ships untested. + ## Conventions - Never commit directly to `main`; branch and open a PR. @@ -97,4 +99,4 @@ The browser build proxies the sidecar at `/sidecar` (same-origin, no CORS); conf - `propertySpecFor` (`packages/core/src/expressions.ts`) fabricates the **unexported** `StylePropertySpecification` shape that `@maplibre/maplibre-gl-style-spec`'s `createExpression` uses for expected-result-type enforcement (the Expression Builder's filter → boolean / color checks). The cast hides any contract change from the compiler, so whenever `@maplibre/maplibre-gl-style-spec` is bumped (including Dependabot PRs) run the frontend suite — the "enforces an expected result type" test in `tests/expressions.test.ts` fails if the shape stops being honored. - `DISTANCE_SEGMENTS` / `NON_DISTANCE_NAMES` (`apps/geolibre-desktop/src/lib/whitebox-distance-params.ts`) decide, by parameter *name*, which Whitebox parameters are ground distances and so get the Processing dialog's metric unit picker (GeoLibre#1540). The segments are generic (`tolerance`, `radius`, `length`, `resolution`), so a tool can carry a matching name that is not a length — `corridor_tolerance` is a 0-1 fraction. Those are safe today only because the picker is confined to tools whose every dataset input is a vector layer, and the colliding names happen to sit on imagery/LiDAR tools; that is a coincidence, not a guarantee. So whenever `geolibre-wasm` is bumped (in `packages/processing/package.json`) — including Dependabot PRs — scan the new catalog for a `double` matching the rule whose description reads as a fraction, ratio, angle or weight, and add it to `NON_DISTANCE_NAMES`. If one is missed, that tool's field offers metres and silently converts a dimensionless number as if it were a distance, with no build error. - UI strings are translatable via **react-i18next**; catalogs live in `apps/geolibre-desktop/src/i18n/locales/*.json` (`en.json` is the source of truth, typed by `i18next.d.ts`). Use `t()` for new user-facing strings; a `?locale`/`?lang` query param sets the embed language. The UI mirrors for right-to-left locales (Arabic), so style new components with Tailwind's logical utilities (`ms-`/`me-`/`ps-`/`pe-`/`text-start`/`border-s`/`start-`…), not the physical `ml-`/`left-` forms. See `docs/i18n.md`. -- Reference docs: `docs/architecture.md`, `docs/project-format.md`, `docs/plugin-api.md`, `docs/python.md`, `docs/i18n.md`, `docs/contributing.md`. +- Reference docs: `docs/architecture.md`, `docs/project-format.md`, `docs/plugin-api.md`, `docs/python.md`, `docs/mcp.md`, `docs/i18n.md`, `docs/contributing.md`. diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 0000000000..70761665a9 --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,159 @@ +# MCP server + +GeoLibre ships an [MCP](https://modelcontextprotocol.io) server that authors +GeoLibre projects. Point an MCP client (Claude Desktop, Claude Code, or any +other) at it and you can ask for a map in words: the server writes a real +`.geolibre.json` project you open in the desktop app, the web app, or the +`geolibre` Jupyter widget, and can export it as a standalone HTML page. + +The server is **headless**. It needs no browser, no running GeoLibre instance, +and no bundled web build. It builds project files with the same +[project builders](python.md) the Python package uses, so a project it writes is +byte-for-byte the kind the app already loads. + +## Install + +The MCP SDK is an optional extra: + +```bash +pip install "geolibre[mcp]" +``` + +## Run it + +```bash +geolibre-mcp --root ~/maps +``` + +The server speaks MCP over stdio, which is what desktop clients spawn. The +`--root` flag is repeatable, and `GEOLIBRE_MCP_ROOTS` (`:`-separated, `;` on +Windows) does the same job from the environment. With neither set, the workspace +is the current directory. + +### Client configuration + +Claude Desktop (`claude_desktop_config.json`) and most other clients take the +same shape: + +```json +{ + "mcpServers": { + "geolibre": { + "command": "geolibre-mcp", + "args": ["--root", "/Users/you/maps"] + } + } +} +``` + +For Claude Code: + +```bash +claude mcp add geolibre -- geolibre-mcp --root ~/maps +``` + +If `geolibre-mcp` is not on the client's `PATH` (common when it was installed +into a virtualenv), give the interpreter instead: + +```json +{ + "mcpServers": { + "geolibre": { + "command": "/path/to/venv/bin/python", + "args": ["-m", "geolibre.mcp", "--root", "/Users/you/maps"] + } + } +} +``` + +## The workspace + +Every path in every tool call is resolved against the allowed roots before the +server touches it, mirroring `GEOLIBRE_CONVERSION_ROOTS` in the +[sidecar](server-api.md). Paths outside them are refused, and so is a symlink +inside a root that points out of it. Relative paths resolve against the first +root, so a client can say `city.geolibre.json` without knowing the host layout. + +Two more guards on writes: the server only writes files ending in `.json` +(projects) or `.html` (exports), and it refuses to replace an existing file +unless the call passes `overwrite`. + +Give it a directory meant for maps, not your home directory. + +## Tools + +### Project lifecycle + +| Tool | What it does | +| --- | --- | +| `create_project` | Write a new, empty project with a name, center, zoom, and basemap. | +| `describe_project` | Summarize the camera, basemap, layers, and map controls. Inlined feature data is reported as a count, never echoed back. | +| `list_catalog` | List the named basemaps, color ramps, and legend presets, plus the active workspace roots. | + +### Adding layers + +| Tool | For | +| --- | --- | +| `add_geojson_layer` | Vector data inlined into the project, from a URL, a workspace file, or literal GeoJSON. Self-contained, and the only kind `classify_layer` can style. | +| `add_vector_layer` | A large remote FlatGeobuf / GeoParquet / GeoJSON read in place. | +| `add_raster_layer` | A Cloud Optimized GeoTIFF, with band, colormap, and rescale options. | +| `add_tile_layer` | A raster XYZ tile template. | +| `add_tiles_layer` | PMTiles archives and vector tile services. | +| `add_ogc_layer` | WMS and WMTS endpoints. | +| `add_3d_tiles_layer` | OGC 3D Tiles tilesets. | + +### Editing + +| Tool | What it does | +| --- | --- | +| `update_layer` | Rename, show/hide, set opacity, or reorder. | +| `remove_layer` | Drop a layer. | +| `style_layer` | Merge style keys (`fillColor`, `strokeWidth`, `circleRadius`, …). | +| `classify_layer` | Build a graduated choropleth from a numeric column. | +| `list_layer_properties` | List a layer's feature properties with sample values. | + +Layers are addressed by id **or** by display name, so a client can work from +what `describe_project` showed it without tracking UUIDs. + +### Framing and decoration + +| Tool | What it does | +| --- | --- | +| `set_view` | Set center, zoom, bearing, and pitch, or pass a `bbox` to frame an area. | +| `set_basemap` | Switch the background style. | +| `add_legend` | Add a legend from a preset, a `{label: color}` map, or paired lists. | +| `add_colorbar` | Add a colorbar for continuous data. | +| `add_swipe` | Configure the split-map comparison slider. | + +### Export + +`export_html` writes a standalone page that embeds the hosted GeoLibre viewer +and injects the project into it, so the recipient needs no install. Credentials +are stripped from the project on the way out. Layers pointing at local files +will not load for anyone else, so use hosted URLs for a shareable export. + +## Notes and limits + +- **`set_view` with a `bbox` is approximate.** A saved project stores a center + and zoom, and the app applies those verbatim on load rather than fitting a + stored bbox. The server therefore resolves the box to a camera itself, using + an assumed map-pane size, and lands within roughly half a zoom level of what + the app's own "zoom to layer" would pick. Pass `center` and `zoom` when you + need exact framing. +- **Inlined GeoJSON is capped at 50 MB**, and a project file the server reads at + 256 MB. Past those, use `add_vector_layer` or a tiled source. +- **Remote fetches are checked**: a URL whose host resolves to a private, + loopback, or link-local address is refused, on every redirect hop as well as + the first request, so a crafted URL cannot reach a cloud metadata endpoint. +- The server authors projects; it does **not** drive a live map. Interactive + control of a running GeoLibre instance goes through the scripting bridge that + backs the [Python widget](python.md) and the + [embed API](user-guide/embedding.md). + +## Under the hood + +The tools are thin wrappers over `geolibre.authoring`, a widget-free module of +operations on project dicts (add/remove/restyle a layer, move the camera, +compose the map controls). `geolibre.Map` delegates to the same module, so the +notebook widget and the MCP server cannot drift apart in how they build a +project. diff --git a/docs/python.md b/docs/python.md index 884f93cd9d..7942b2fb81 100644 --- a/docs/python.md +++ b/docs/python.md @@ -309,6 +309,21 @@ UI edits flow back the same way. 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). diff --git a/mkdocs.yml b/mkdocs.yml index 3beb17e249..8f5b055b65 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -84,6 +84,7 @@ nav: - UI Profiles: ui-profiles.md - Internationalization: i18n.md - Python Package: python.md + - MCP Server: mcp.md - Notebook Panel: notebook.md - Roadmap: roadmap.md - Contributing: contributing.md diff --git a/python/README.md b/python/README.md index b7db2a8061..85a7af77ff 100644 --- a/python/README.md +++ b/python/README.md @@ -111,3 +111,19 @@ m.to_project()["mapView"]["center"] 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](https://modelcontextprotocol.io) server +that authors `.geolibre.json` projects from an AI client: + +```bash +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](https://geolibre.app/mcp/) for the tool list and client +configuration. diff --git a/python/pyproject.toml b/python/pyproject.toml index d30bbe7f33..615661469a 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -23,7 +23,13 @@ dependencies = ["anywidget>=0.9", "traitlets>=5", "jupyter-ui-poll>=0.2"] [project.optional-dependencies] all = ["geopandas", "shapely"] -dev = ["pytest", "build", "anywidget[dev]", "jupyter-server"] +# The MCP server (geolibre.mcp) is the only thing that imports the SDK, so it +# stays optional: the widget and the project builders work without it. +mcp = ["mcp>=2.0"] +dev = ["pytest", "build", "anywidget[dev]", "jupyter-server", "mcp>=2.0"] + +[project.scripts] +geolibre-mcp = "geolibre.mcp.server:main" [project.urls] Homepage = "https://geolibre.app" diff --git a/python/src/geolibre/authoring.py b/python/src/geolibre/authoring.py new file mode 100644 index 0000000000..ef95e554f0 --- /dev/null +++ b/python/src/geolibre/authoring.py @@ -0,0 +1,887 @@ +"""Widget-free operations on whole GeoLibre project dicts. + +``project.py`` *builds* pieces (a layer, a plugin-state blob). This module +*applies* them to a project: adding and restyling layers, moving the camera, +composing the map controls, and reading a project back as a summary. + +Everything here is pure Python on plain dicts, with no widget, no browser, and +no network. :class:`geolibre.Map` delegates to these functions so the Jupyter +widget and the MCP server (:mod:`geolibre.mcp`) share one implementation rather +than growing two copies of the same composition rules. +""" + +from __future__ import annotations + +import copy +import json +import math +from pathlib import Path +from typing import Any, Callable, Iterable + +from . import project as _project +from .basemaps import BASEMAPS, resolve_basemap +from .color_ramp import VECTOR_COLOR_RAMPS, graduated_stops +from .legends import get_builtin_legend + +# Control placement/orientation vocabularies, shared with Map so the widget and +# the MCP server reject the same values. +CONTROL_POSITIONS = _project.CONTROL_POSITIONS +ORIENTATIONS = frozenset({"vertical", "horizontal"}) +LEGEND_SHAPES = frozenset({"square", "circle", "line"}) + +# The pseudo-id the swipe control uses for the basemap (maplibre-swipe.ts). +BASEMAP_LAYER_ID = "__basemap__" + +# Cap a project file read from disk. A project inlines its GeoJSON, so the +# ceiling has to clear _MAX_GEOJSON_BYTES for a single layer with room for a few +# more; past that the caller is better served by a tiled source than by loading +# the whole thing into memory. +MAX_PROJECT_BYTES = 256 * 1024 * 1024 + + +# -- file I/O ----------------------------------------------------------------- + + +def load_project(path: str | Path) -> dict[str, Any]: + """Read a ``.geolibre.json`` file into a project dict. + + Args: + path: Path to the project file. + + Returns: + The parsed project dict. + + Raises: + ValueError: If the file is missing, oversized, not JSON, or not a + project object. + """ + file = Path(path).expanduser() + if not file.is_file(): + raise ValueError(f"Project file not found: {file}") + if file.stat().st_size > MAX_PROJECT_BYTES: + raise ValueError(f"Project file exceeds the {MAX_PROJECT_BYTES // (1024 * 1024)} MB limit") + try: + data = json.loads(file.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ValueError(f"Project file is not valid JSON: {file} ({exc})") from exc + if not isinstance(data, dict): + raise ValueError(f"Project file must contain a JSON object: {file}") + # `layers` is the one field every operation here indexes into. Seeding it + # keeps a hand-written or truncated project usable instead of raising a + # KeyError from deep inside an unrelated call. + if not isinstance(data.get("layers"), list): + data["layers"] = [] + return data + + +def save_project(path: str | Path, project: dict[str, Any]) -> Path: + """Write a project dict to disk as formatted JSON. + + Parent directories are created. The file is written with a trailing newline + and two-space indentation so it reads and diffs like the rest of the repo's + JSON. + + Args: + path: Destination path. + project: The project dict to serialize. + + Returns: + The resolved path written to. + """ + file = Path(path).expanduser() + file.parent.mkdir(parents=True, exist_ok=True) + file.write_text(json.dumps(project, indent=2) + "\n", encoding="utf-8") + return file + + +# -- layer lookup ------------------------------------------------------------- + + +def layers_of(project: dict[str, Any]) -> list[dict[str, Any]]: + """Return the project's layer list, creating it when absent.""" + layers = project.get("layers") + if not isinstance(layers, list): + layers = [] + project["layers"] = layers + return layers + + +def find_layer(project: dict[str, Any], ref: str) -> dict[str, Any]: + """Resolve a layer by id or by display name. + + An exact id match wins outright, so a layer whose *name* happens to equal + another layer's *id* cannot shadow it. Name matching is tried next, exact + first and then case-insensitively. + + Args: + project: The project dict. + ref: A layer id or display name. + + Returns: + The matching layer dict (the live object, not a copy). + + Raises: + ValueError: If nothing matches, or if a name matches several layers. + """ + layers = [layer for layer in layers_of(project) if isinstance(layer, dict)] + for layer in layers: + if layer.get("id") == ref: + return layer + for match_name in ( + lambda layer: layer.get("name") == ref, + lambda layer: str(layer.get("name", "")).casefold() == ref.casefold(), + ): + matches = [layer for layer in layers if match_name(layer)] + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + raise ValueError( + f"{len(matches)} layers are named {ref!r}; reference it by id instead " + f"(ids: {', '.join(str(layer.get('id')) for layer in matches)})" + ) + known = ", ".join(f"{layer.get('name')!r}" for layer in layers) or "none" + raise ValueError(f"No layer matches {ref!r}. Layers in this project: {known}") + + +def resolve_layer_ids(project: dict[str, Any], refs: Iterable[str]) -> list[str]: + """Resolve layer ids/names to ids, passing the basemap pseudo-id through. + + Args: + project: The project dict. + refs: Layer ids, layer names, or ``"__basemap__"``. + + Returns: + The resolved layer ids, in input order. + + Raises: + ValueError: If a reference matches no layer or several. + """ + return [ref if ref == BASEMAP_LAYER_ID else str(find_layer(project, ref)["id"]) for ref in refs] + + +# -- reading ------------------------------------------------------------------ + + +def layer_summary(layer: dict[str, Any]) -> dict[str, Any]: + """Summarize one layer for display, omitting any inlined data. + + A GeoJSON layer's ``geojson`` blob can be tens of megabytes, so it is + reported as a feature count rather than echoed back. + + Args: + layer: A layer dict. + + Returns: + A small dict of the layer's identity, visibility, and source. + """ + summary: dict[str, Any] = { + "id": layer.get("id"), + "name": layer.get("name"), + "type": layer.get("type"), + "visible": bool(layer.get("visible", True)), + "opacity": layer.get("opacity", 1), + } + source = layer.get("source") + if isinstance(source, dict): + url = source.get("url") or (source.get("tiles") or [None])[0] + if url: + summary["source"] = url + geojson = layer.get("geojson") + if isinstance(geojson, dict): + features = geojson.get("features") + summary["featureCount"] = len(features) if isinstance(features, list) else 0 + style = layer.get("style") + if isinstance(style, dict) and style.get("vectorStyleMode") not in (None, "single"): + summary["symbology"] = { + "mode": style.get("vectorStyleMode"), + "property": style.get("vectorStyleProperty"), + "colorRamp": style.get("vectorStyleColorRamp"), + } + return summary + + +def describe_project(project: dict[str, Any]) -> dict[str, Any]: + """Summarize a project: its camera, basemap, layers, and map controls. + + Args: + project: The project dict. + + Returns: + A compact, JSON-serializable overview. + """ + plugins = project.get("plugins") + controls: list[str] = [] + if isinstance(plugins, dict): + settings = plugins.get("settings") + if isinstance(settings, dict): + if _project.SWIPE_PLUGIN_ID in settings: + controls.append("swipe") + components = settings.get(_project.COMPONENTS_PLUGIN_ID) + if isinstance(components, dict): + controls.extend(key for key in ("legend", "colorbar") if key in components) + return { + "name": project.get("name"), + "version": project.get("version"), + "mapView": project.get("mapView"), + "basemapStyleUrl": project.get("basemapStyleUrl"), + "layerCount": len(layers_of(project)), + "layers": [layer_summary(layer) for layer in layers_of(project) if isinstance(layer, dict)], + "mapControls": controls, + } + + +def layer_properties(layer: dict[str, Any]) -> dict[str, list[Any]]: + """Collect the distinct property values of an inlined GeoJSON layer. + + Lets a caller discover what a layer can be styled or filtered by without + reading the whole feature collection back. + + Args: + layer: A layer dict carrying an inlined ``geojson`` FeatureCollection. + + Returns: + A mapping of property name to up to 25 sample values (in first-seen + order). + + Raises: + ValueError: If the layer carries no inlined GeoJSON. + """ + geojson = layer.get("geojson") + if not isinstance(geojson, dict): + raise ValueError( + f"Layer {layer.get('name')!r} has no inlined GeoJSON, so its properties " + "cannot be read without fetching the source." + ) + samples: dict[str, list[Any]] = {} + for feature in geojson.get("features", []): + if not isinstance(feature, dict): + continue + properties = feature.get("properties") + if not isinstance(properties, dict): + continue + for key, value in properties.items(): + seen = samples.setdefault(key, []) + if len(seen) < 25 and value not in seen: + seen.append(value) + return samples + + +def column_values(layer: dict[str, Any], column: str) -> list[Any]: + """Read one property's values across an inlined GeoJSON layer's features. + + Args: + layer: A layer dict carrying an inlined ``geojson`` FeatureCollection. + column: The feature property name. + + Returns: + The raw values, one per feature (``None`` where the property is absent). + + Raises: + ValueError: If the layer has no inlined GeoJSON, or no feature carries + the property. + """ + geojson = layer.get("geojson") + if not isinstance(geojson, dict): + raise ValueError( + f"Layer {layer.get('name')!r} has no inlined GeoJSON, so column " + f"{column!r} cannot be read." + ) + values = [ + feature.get("properties", {}).get(column) + for feature in geojson.get("features", []) + if isinstance(feature, dict) + ] + if all(value is None for value in values): + raise ValueError(f"Column {column!r} not found in any feature's properties") + return values + + +# -- layer mutation ----------------------------------------------------------- + + +def add_layer(project: dict[str, Any], layer: dict[str, Any], *, index: int | None = None) -> str: + """Insert a built layer into the project's draw order. + + Args: + project: The project dict (mutated in place). + layer: A layer dict from one of the ``project.py`` builders. + index: Draw-order position; appended (drawn on top) when omitted. + + Returns: + The layer's id. + """ + layers = layers_of(project) + if index is None: + layers.append(layer) + else: + layers.insert(max(0, min(len(layers), int(index))), layer) + return str(layer["id"]) + + +def remove_layer(project: dict[str, Any], ref: str) -> str: + """Remove a layer by id or name. + + Args: + project: The project dict (mutated in place). + ref: A layer id or display name. + + Returns: + The removed layer's id. + + Raises: + ValueError: If the reference does not resolve to exactly one layer. + """ + layer = find_layer(project, ref) + layers_of(project).remove(layer) + return str(layer["id"]) + + +def update_layer( + project: dict[str, Any], + ref: str, + *, + name: str | None = None, + visible: bool | None = None, + opacity: float | None = None, + index: int | None = None, +) -> dict[str, Any]: + """Change a layer's identity, visibility, or draw order. + + Only the arguments you pass are applied; the rest are left alone. + + Args: + project: The project dict (mutated in place). + ref: A layer id or display name. + name: New display name. + visible: New visibility. + opacity: New opacity, clamped to ``[0, 1]``. + index: New draw-order position, clamped to the layer list's bounds. + + Returns: + A summary of the updated layer. + + Raises: + ValueError: If the reference does not resolve to exactly one layer. + """ + layer = find_layer(project, ref) + if name is not None: + layer["name"] = str(name) + if visible is not None: + layer["visible"] = bool(visible) + if opacity is not None: + layer["opacity"] = min(1.0, max(0.0, float(opacity))) + if index is not None: + layers = layers_of(project) + layers.remove(layer) + layers.insert(max(0, min(len(layers), int(index))), layer) + return layer_summary(layer) + + +def apply_style(project: dict[str, Any], ref: str, style: dict[str, Any]) -> dict[str, Any]: + """Merge style overrides into a layer's existing style. + + Args: + project: The project dict (mutated in place). + ref: A layer id or display name. + style: Style keys to set (e.g. ``{"fillColor": "#ff0000"}``). Keys not + mentioned keep their current values. + + Returns: + The layer's full style after the merge. + + Raises: + ValueError: If the reference does not resolve to exactly one layer, or + ``style`` is not a mapping. + """ + if not isinstance(style, dict): + raise ValueError(f"style must be an object of style keys, got {type(style).__name__}") + layer = find_layer(project, ref) + current = layer.get("style") + merged = ( + dict(current) if isinstance(current, dict) else copy.deepcopy(_project.DEFAULT_LAYER_STYLE) + ) + merged.update(style) + layer["style"] = merged + return merged + + +def build_choropleth_style( + values: list[Any], + column: str, + *, + class_count: int = 5, + colormap: str = "viridis", + scheme: str = "equal-interval", +) -> dict[str, Any]: + """Build the ``vectorStyle*`` keys for a graduated (choropleth) symbology. + + Mirrors what the app's Style panel writes for a graduated fill, so a + classification computed here renders the same as one built in the UI. + + Args: + values: The column's raw values across the features. + column: The feature property being classified. + class_count: Number of classes (clamped to 2-12 by the stop builder). + colormap: A ramp name from :data:`geolibre.color_ramp.VECTOR_COLOR_RAMPS`. + scheme: ``"equal-interval"`` or ``"quantile"``. + + Returns: + A style fragment to merge into a layer's style. + + Raises: + ValueError: If no value is numeric, or ``scheme`` is unsupported. + """ + if not any(_is_finite_number(value) for value in values): + raise ValueError( + f"Column {column!r} must contain at least one numeric value for a graduated choropleth" + ) + stops = graduated_stops( + values, + class_count=class_count, + color_ramp=colormap, + classification_scheme=scheme, + ) + return { + "vectorStyleMode": "graduated", + "vectorStyleProperty": column, + "vectorStyleClassCount": min(12, max(2, int(class_count))), + "vectorStyleColorRamp": colormap, + "vectorStyleClassificationScheme": scheme, + "vectorStyleStops": stops, + } + + +def _is_finite_number(value: Any) -> bool: + """Return True when *value* coerces to a finite float (mirrors isFinite).""" + try: + return math.isfinite(float(value)) + except (TypeError, ValueError): + return False + + +def classify_layer( + project: dict[str, Any], + ref: str, + column: str, + *, + class_count: int = 5, + colormap: str = "viridis", + scheme: str = "equal-interval", +) -> dict[str, Any]: + """Symbolize an inlined GeoJSON layer as a choropleth on one column. + + Args: + project: The project dict (mutated in place). + ref: A layer id or display name. + column: The numeric feature property to classify. + class_count: Number of classes. + colormap: A ramp name. + scheme: ``"equal-interval"`` or ``"quantile"``. + + Returns: + The computed ``vectorStyle*`` fragment. + + Raises: + ValueError: If the layer has no inlined GeoJSON, the column is missing + or non-numeric, or ``scheme`` is unsupported. + """ + layer = find_layer(project, ref) + values = column_values(layer, column) + fragment = build_choropleth_style( + values, + column, + class_count=class_count, + colormap=colormap, + scheme=scheme, + ) + apply_style(project, str(layer["id"]), fragment) + return fragment + + +# -- camera and basemap ------------------------------------------------------- + + +def set_view( + project: dict[str, Any], + *, + center: Iterable[float] | None = None, + zoom: float | None = None, + bearing: float | None = None, + pitch: float | None = None, +) -> dict[str, Any]: + """Set the saved camera the project opens at. + + Args: + project: The project dict (mutated in place). + center: ``[lng, lat]``. + zoom: Zoom level, clamped to ``[0, 24]``. + bearing: Rotation in degrees. + pitch: Tilt in degrees, clamped to ``[0, 85]``. + + Returns: + The project's ``mapView`` after the change. + + Raises: + ValueError: If ``center`` is not a 2-element ``[lng, lat]``. + """ + view = project.get("mapView") + if not isinstance(view, dict): + view = _project.default_map_view() + project["mapView"] = view + if center is not None: + coords = [float(value) for value in center] + if len(coords) != 2: + raise ValueError("center must be a [lng, lat] sequence with exactly 2 elements") + view["center"] = coords + if zoom is not None: + view["zoom"] = min(24.0, max(0.0, float(zoom))) + if bearing is not None: + view["bearing"] = float(bearing) + if pitch is not None: + view["pitch"] = min(85.0, max(0.0, float(pitch))) + return view + + +# The viewport the bbox fit assumes. The app sizes the map to its container, so +# the true value is only known at runtime; this is a typical desktop map pane and +# keeps the computed zoom within about half a level of what the app settles on. +_FIT_VIEWPORT = (1024, 768) +_FIT_PADDING_PX = 40 +_TILE_SIZE = 512 + + +def fit_bounds( + project: dict[str, Any], + bbox: Iterable[float], + *, + padding: int = _FIT_PADDING_PX, +) -> dict[str, Any]: + """Frame a bounding box by computing a center and zoom for it. + + The saved project records a center/zoom, not a bbox to fit: the app applies + ``mapView.center``/``zoom`` verbatim when it opens a project and never fits + the stored ``bbox``. So this resolves the box to a camera here, using an + assumed viewport (see ``_FIT_VIEWPORT``), and records the box alongside it + for reference. The result is approximate by construction; expect the app's + own "zoom to layer" to land within roughly half a zoom level. + + Args: + project: The project dict (mutated in place). + bbox: ``[min_lng, min_lat, max_lng, max_lat]``. + padding: Pixels of margin to leave around the box. + + Returns: + The project's ``mapView`` after the change. + + Raises: + ValueError: If the box is not 4 finite numbers, is inverted, or falls + outside the Web Mercator latitude limits. + """ + box = [float(value) for value in bbox] + if len(box) != 4: + raise ValueError("bbox must be [min_lng, min_lat, max_lng, max_lat]") + if not all(math.isfinite(value) for value in box): + raise ValueError(f"bbox must be finite numbers, got {box}") + min_lng, min_lat, max_lng, max_lat = box + if min_lng > max_lng or min_lat > max_lat: + raise ValueError(f"bbox is inverted: {box}") + if not (-85.051129 <= min_lat and max_lat <= 85.051129): + raise ValueError(f"bbox latitudes must lie within +/-85.051129 (Web Mercator), got {box}") + + width, height = _FIT_VIEWPORT + usable_width = max(1, width - 2 * padding) + usable_height = max(1, height - 2 * padding) + # Web Mercator world fractions spanned by the box, at zoom 0. + lng_fraction = (max_lng - min_lng) / 360 + lat_fraction = abs(_mercator_y(max_lat) - _mercator_y(min_lat)) + # A degenerate (point) box has no extent to fit; fall back to a close-in + # zoom rather than dividing by zero. + zoom_candidates = [ + math.log2(usable / (_TILE_SIZE * fraction)) + for usable, fraction in ((usable_width, lng_fraction), (usable_height, lat_fraction)) + if fraction > 0 + ] + zoom = min(zoom_candidates) if zoom_candidates else 14.0 + view = set_view( + project, + center=[ + (min_lng + max_lng) / 2, + _inverse_mercator_y((_mercator_y(min_lat) + _mercator_y(max_lat)) / 2), + ], + zoom=zoom, + ) + view["bbox"] = box + return view + + +def _mercator_y(lat: float) -> float: + """Project a latitude to its Web Mercator world fraction in ``[0, 1]``.""" + sin_lat = math.sin(math.radians(lat)) + return 0.5 - math.log((1 + sin_lat) / (1 - sin_lat)) / (4 * math.pi) + + +def _inverse_mercator_y(y: float) -> float: + """Invert :func:`_mercator_y` back to a latitude in degrees.""" + return math.degrees(2 * math.atan(math.exp((0.5 - y) * 2 * math.pi)) - math.pi / 2) + + +def set_basemap(project: dict[str, Any], basemap: str) -> str: + """Set the project's background basemap style. + + Args: + project: The project dict (mutated in place). + basemap: A known basemap name (see :data:`geolibre.basemaps.BASEMAPS`) + or a MapLibre style JSON URL. + + Returns: + The resolved style URL. + + Raises: + ValueError: If the name is unknown and the value is not a URL. + """ + url = resolve_basemap(basemap) + project["basemapStyleUrl"] = url + return url + + +def basemap_catalog() -> dict[str, str]: + """Return the named basemaps, mapping friendly name to style URL.""" + return dict(BASEMAPS) + + +def color_ramp_names() -> list[str]: + """Return the color-ramp names accepted for choropleths and colorbars.""" + return list(VECTOR_COLOR_RAMPS) + + +# -- map controls ------------------------------------------------------------- + + +def merge_components_state( + project: dict[str, Any], + key: str, + entry_state_builder: Callable[[Any], dict[str, Any]], +) -> None: + """Merge one feature's state into the Components plugin settings. + + The Components plugin (legend / colorbar / html) stores all its features + under a single settings blob keyed by feature name, so a new legend must be + merged in without dropping an existing colorbar (and vice versa). + + Args: + project: The project dict (mutated in place). + key: The feature key (``"legend"`` or ``"colorbar"``). + entry_state_builder: Called with the feature's current state (or + ``None``) and returns its new state. + """ + plugins = _project.ensure_plugins_block(project) + current = plugins["settings"].get(_project.COMPONENTS_PLUGIN_ID) + components = dict(current) if isinstance(current, dict) else {} + components[key] = entry_state_builder(components.get(key)) + # The legend/colorbar restore from their settings blob alone, so the plugin + # is configured but not added to activePluginIds (activating it would also + # mount the full Components toolbar). + _project.set_plugin_state(project, _project.COMPONENTS_PLUGIN_ID, components, activate=False) + + +def add_legend( + project: dict[str, Any], + title: str | None = None, + *, + legend_dict: dict[str, str] | None = None, + labels: list[str] | None = None, + colors: list[str] | None = None, + builtin: str | None = None, + position: str = "bottom-left", + shape: str = "square", +) -> dict[str, Any]: + """Add a legend control to the project. + + Supply the entries exactly one of three ways: a built-in preset + (``builtin``), a ``{label: color}`` mapping (``legend_dict``), or parallel + ``labels`` and ``colors`` lists. Each call adds another legend. + + Args: + project: The project dict (mutated in place). + title: Legend title. Defaults to ``"Legend"``, or the preset's title + when ``builtin`` is given without one. + legend_dict: A mapping of label to CSS color (order preserved). + labels: Item labels, paired position-wise with ``colors``. + colors: Item CSS colors, paired position-wise with ``labels``. + builtin: A preset name (e.g. ``"nlcd"``, ``"esa_worldcover"``). + position: One of :data:`CONTROL_POSITIONS`. + shape: Swatch shape for every item; one of :data:`LEGEND_SHAPES`. + + Returns: + The legend entry that was added. + + Raises: + ValueError: If no entries are supplied, several sources are combined, + ``labels``/``colors`` lengths differ, or ``position``/``shape``/ + ``builtin`` is invalid. + """ + if position not in CONTROL_POSITIONS: + raise ValueError(f"position must be one of {sorted(CONTROL_POSITIONS)}, got {position!r}") + if shape not in LEGEND_SHAPES: + raise ValueError(f"shape must be one of {sorted(LEGEND_SHAPES)}, got {shape!r}") + + # The three ways to supply entries are mutually exclusive; reject a + # combination rather than silently letting one win by check order. + sources = ( + builtin is not None, + legend_dict is not None, + labels is not None or colors is not None, + ) + if sum(sources) > 1: + raise ValueError( + "Provide legend entries via exactly one of: builtin=, " + "legend_dict=, or labels= and colors=." + ) + + pairs: list[tuple[str, str]] + if builtin is not None: + preset = get_builtin_legend(builtin) + pairs = list(preset["items"]) + if title is None: + title = preset["title"] + elif legend_dict is not None: + pairs = [(str(label), str(color)) for label, color in legend_dict.items()] + elif labels is not None or colors is not None: + if labels is None or colors is None: + raise ValueError("labels and colors must be provided together") + if len(labels) != len(colors): + raise ValueError( + f"labels and colors must have the same length ({len(labels)} != {len(colors)})" + ) + pairs = [(str(label), str(color)) for label, color in zip(labels, colors)] + else: + raise ValueError( + "Provide legend entries via builtin=, legend_dict=, or labels= and colors=." + ) + if not pairs: + raise ValueError("Legend has no items") + + items = [{"label": label, "color": color, "shape": shape} for label, color in pairs] + entry = _project.legend_gui_entry(title or "Legend", items, position) + merge_components_state( + project, + "legend", + lambda existing: _project.legend_gui_state(entry, existing=existing), + ) + return entry + + +def add_colorbar( + project: dict[str, Any], + *, + colormap: str = "viridis", + vmin: float = 0.0, + vmax: float = 1.0, + label: str = "", + units: str = "", + colors: list[str] | None = None, + orientation: str = "vertical", + position: str = "bottom-right", +) -> dict[str, Any]: + """Add a colorbar control for a continuous (single-band) raster. + + Args: + project: The project dict (mutated in place). + colormap: A named colormap. Ignored when ``colors`` is given. + vmin: Value at the low end. + vmax: Value at the high end. + label: Title shown alongside the colorbar. + units: Units suffix shown with the values. + colors: Optional CSS colors defining a custom gradient. + orientation: One of :data:`ORIENTATIONS`. + position: One of :data:`CONTROL_POSITIONS`. + + Returns: + The colorbar entry that was added. + + Raises: + ValueError: If ``orientation`` or ``position`` is invalid, ``vmin`` is + not less than ``vmax``, or ``colors`` is given but empty. + """ + if orientation not in ORIENTATIONS: + raise ValueError(f"orientation must be one of {sorted(ORIENTATIONS)}, got {orientation!r}") + if position not in CONTROL_POSITIONS: + raise ValueError(f"position must be one of {sorted(CONTROL_POSITIONS)}, got {position!r}") + vmin_f, vmax_f = float(vmin), float(vmax) + # The app's normalizer only fixes vmin == vmax; an inverted range would + # otherwise render a reversed gradient, so reject it here. + if vmin_f >= vmax_f: + raise ValueError(f"vmin ({vmin_f}) must be less than vmax ({vmax_f})") + if colors is not None: + if not colors: + raise ValueError("colors must be a non-empty list when provided") + mode = "custom" + custom_colors = ", ".join(str(color) for color in colors) + else: + mode = "named" + custom_colors = "" + entry = _project.colorbar_gui_entry( + mode=mode, + colormap=colormap, + custom_colors=custom_colors, + vmin=vmin_f, + vmax=vmax_f, + label=label, + units=units, + orientation=orientation, + position=position, + ) + merge_components_state( + project, + "colorbar", + lambda existing: _project.colorbar_gui_state(entry, existing=existing), + ) + return entry + + +def add_swipe( + project: dict[str, Any], + *, + left_layers: list[str], + right_layers: list[str], + orientation: str = "vertical", + position: float = 50, + control_position: str = "top-right", +) -> dict[str, Any]: + """Configure the split-map (swipe) control. + + Args: + project: The project dict (mutated in place). + left_layers: Layer ids shown on the left/top of the slider. + ``"__basemap__"`` selects the basemap. + right_layers: Layer ids shown on the right/bottom of the slider. + orientation: One of :data:`ORIENTATIONS`. + position: Initial slider position as a percentage, clamped to + ``[0, 100]``. + control_position: One of :data:`CONTROL_POSITIONS`. + + Returns: + The swipe plugin state that was written. + + Raises: + ValueError: If ``orientation`` or ``control_position`` is invalid. + """ + if orientation not in ORIENTATIONS: + raise ValueError(f"orientation must be one of {sorted(ORIENTATIONS)}, got {orientation!r}") + if control_position not in CONTROL_POSITIONS: + raise ValueError( + f"control_position must be one of {sorted(CONTROL_POSITIONS)}, got {control_position!r}" + ) + state = _project.swipe_state( + left_layers=list(left_layers), + right_layers=list(right_layers), + orientation=orientation, + position=min(100.0, max(0.0, float(position))), + ) + _project.set_plugin_state( + project, + _project.SWIPE_PLUGIN_ID, + state, + position=control_position, + ) + return state diff --git a/python/src/geolibre/geolibre.py b/python/src/geolibre/geolibre.py index 94432a8a8e..ae38958608 100644 --- a/python/src/geolibre/geolibre.py +++ b/python/src/geolibre/geolibre.py @@ -21,11 +21,10 @@ import anywidget import traitlets +from . import authoring as _authoring from . import project as _project from ._server import app_port, register_local_file, serve_app from .basemaps import resolve_basemap -from .color_ramp import graduated_stops -from .legends import get_builtin_legend _HERE = pathlib.Path(__file__).parent _STATIC_APP = _HERE / "static" / "app" @@ -35,13 +34,6 @@ _VALID_LAYOUTS = frozenset({"embed", "full", "maponly"}) _VALID_THEMES = frozenset({"light", "dark"}) -# Accepted values for the split-map / legend / colorbar helpers, validated up -# front so a typo surfaces in Python instead of silently falling back in the app. -# Reuse the canonical corner set from project.py so the two cannot drift. -_VALID_CONTROL_POSITIONS = _project.CONTROL_POSITIONS -_VALID_ORIENTATIONS = frozenset({"vertical", "horizontal"}) -_VALID_LEGEND_SHAPES = frozenset({"square", "circle", "line"}) - # CSV/tabular input is inlined into the project exactly like GeoJSON is, so the # same 50 MB ceiling applies to a fetched response or a local file. _MAX_TABULAR_BYTES = _project._MAX_GEOJSON_BYTES @@ -181,6 +173,71 @@ def _html_escape(value: str) -> str: """ +# Where a standalone export loads the app from by default: the hosted viewer, so +# the exported file stays portable once the kernel is gone. +DEFAULT_HTML_APP_URL = "https://web.geolibre.app/" + + +def render_project_html( + project: dict[str, Any], + *, + title: str = "GeoLibre Map", + width: str = "100%", + height: str = "800px", + app_url: str | None = None, +) -> str: + """Render a project dict as a standalone HTML page. + + The page embeds the GeoLibre app in an ``