"""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
import os
import stat
import tempfile
import uuid
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_POSITIONS = _project.CONTROL_POSITIONS
ORIENTATIONS = frozenset({"vertical", "horizontal"})
LEGEND_SHAPES = frozenset({"square", "circle", "line"})
BASEMAP_LAYER_ID = "__basemap__"
RASTER_STYLE_LAYER_TYPES = frozenset({"raster", "wms", "wmts", "xyz"})
MAX_PROJECT_BYTES = 256 * 1024 * 1024
def _finite(value: Any, field: str) -> float:
"""Coerce to float, rejecting the values JSON cannot represent.
``json.loads`` turns an out-of-range literal like ``1e400`` into ``inf``
without raising, and ``json.dumps`` writes it back as a bare ``Infinity``
token, which is not valid JSON per RFC 8259 and fails the app's
``JSON.parse``. Every camera field a client can set goes through here so
that never reaches the file.
Args:
value: The client-supplied number.
field: The field name, for the error message.
Returns:
The value as a float.
Raises:
ValueError: If the value is not finite.
"""
number = float(value)
if not math.isfinite(number):
raise ValueError(f"{field} must be a finite number, got {number}")
return number
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}")
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.
The write goes to a temporary file alongside the destination and is then
moved into place, so an interrupted write cannot leave a half-written
project where a complete one used to be. A project inlines its GeoJSON and
can approach ``MAX_PROJECT_BYTES``, and the MCP server rewrites the whole
file on every edit, so a truncating write is a real way to lose work.
Note:
This writes *verbatim*, credentials included. It is the lossless
primitive the MCP server round-trips a user's own project file through,
where stripping an API key on every small edit would quietly destroy the
file's usefulness. :meth:`geolibre.Map.save_project` is the counterpart
for producing a file to share: it redacts unless
``keep_credentials=True``. Run a project through
:func:`geolibre.project.redact_credentials` before calling this if the
result is going anywhere untrusted.
Args:
path: Destination path.
project: The project dict to serialize, written as given.
Returns:
The resolved path written to.
"""
file = Path(path).expanduser()
file.parent.mkdir(parents=True, exist_ok=True)
handle = tempfile.NamedTemporaryFile(
"w", encoding="utf-8", dir=file.parent, prefix=f".{file.name}.", delete=False
)
temporary = Path(handle.name)
try:
with handle:
handle.write(json.dumps(project, indent=2, allow_nan=False) + "\n")
if file.exists():
os.chmod(temporary, stat.S_IMODE(file.stat().st_mode))
os.replace(temporary, file)
except BaseException:
temporary.unlink(missing_ok=True)
raise
return file
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]
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. The source URL is
reported with its credentials stripped: a summary exists to be shown, and
both callers show it somewhere untrusted (a notebook cell that gets
committed, an MCP tool result that goes to a model client).
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"] = _project.redact_url(str(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.
URLs come back with their credentials stripped, as in :func:`layer_summary`;
several basemap providers put an API key in the style URL itself.
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")
active = plugins.get("activePluginIds")
active_ids = set(active) if isinstance(active, list) else set()
if isinstance(settings, dict):
if _project.SWIPE_PLUGIN_ID in settings and _project.SWIPE_PLUGIN_ID in active_ids:
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)
map_legend = project.get("legend")
if isinstance(map_legend, dict) and map_legend.get("panelVisible") is True:
controls.append("map-legend")
basemap_url = project.get("basemapStyleUrl")
return {
"name": project.get("name"),
"version": project.get("version"),
"mapView": project.get("mapView"),
"basemapStyleUrl": (
_project.redact_url(str(basemap_url)) if basemap_url is not None else basemap_url
),
"layerCount": len(layers_of(project)),
"layers": [layer_summary(layer) for layer in layers_of(project) if isinstance(layer, dict)],
"mapControls": controls,
**({"bookmarks": bookmark_summary(project)} if project.get("bookmarks") else {}),
}
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 the property is
absent from every feature, or present but null in all of them.
"""
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 = []
present = False
for feature in geojson.get("features", []):
if not isinstance(feature, dict):
continue
properties = feature.get("properties")
if not isinstance(properties, dict):
values.append(None)
continue
present = present or column in properties
values.append(properties.get(column))
if all(value is None for value in values):
if present:
raise ValueError(f"Column {column!r} is null in every feature")
raise ValueError(f"Column {column!r} not found in any feature's properties")
return values
def _reject_reserved_name(name: Any) -> None:
"""Refuse the basemap pseudo-id as a layer's display name.
:func:`resolve_layer_ids` passes this sentinel straight through before it
consults the layer list, so a layer wearing it would be unaddressable by
name there — the swipe control would silently target the basemap instead of
the layer. Enforced at creation as well as on rename, since a layer can
acquire the name either way.
Raises:
ValueError: If *name* is the reserved pseudo-id.
"""
if name is not None and str(name) == BASEMAP_LAYER_ID:
raise ValueError(
f"{BASEMAP_LAYER_ID!r} is reserved for the basemap and cannot name a layer"
)
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.
Raises:
ValueError: If the layer's name is the reserved basemap pseudo-id.
"""
_reject_reserved_name(layer.get("name"))
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.
Any swipe control referencing the layer drops it from its side, so the
saved project cannot carry a split pointing at a layer that is gone.
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)
layer_id = str(layer["id"])
_drop_swipe_reference(project, layer)
return layer_id
def _drop_swipe_reference(project: dict[str, Any], layer: dict[str, Any]) -> None:
"""Remove a layer's ids from the swipe control's two sides.
Mirrors `_expand_swipe_side`: whatever that adds to a side, this takes back
out, so removing a layer cannot leave a derived style layer id behind.
"""
plugins = project.get("plugins")
settings = plugins.get("settings") if isinstance(plugins, dict) else None
swipe = settings.get(_project.SWIPE_PLUGIN_ID) if isinstance(settings, dict) else None
if not isinstance(swipe, dict):
return
dropped = {str(layer.get("id", "")), *_style_layer_ids(layer)}
for side in ("leftLayers", "rightLayers"):
ids = swipe.get(side)
if isinstance(ids, list):
swipe[side] = [value for value in ids if value not in dropped]
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, or
``name`` is the reserved basemap pseudo-id.
"""
layer = find_layer(project, ref)
if name is not None:
_reject_reserved_name(name)
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 set_popup(
project: dict[str, Any],
ref: str,
fields: Any = None,
*,
click: bool | None = None,
hover: bool | None = None,
title: str | None = None,
title_expression: str | None = None,
body_expression: str | None = None,
show_feature_id: bool | None = None,
max_width: int | None = None,
image_height: int | None = None,
tooltip: Any = None,
merge: bool = False,
) -> dict[str, Any]:
"""Configure what a layer shows when a feature is clicked or hovered.
A layer with no popup config keeps the app's default: the layer name as the
heading, then every visible property as a key/value row, and no hover
tooltip. Configuring one narrows and formats that -- see
:func:`geolibre.project.popup_config` for the field vocabulary.
Args:
project: The project dict (mutated in place).
ref: A layer id or display name.
fields: The fields to show and their order; property names and/or
:func:`geolibre.project.popup_field` mappings.
click: ``False`` suppresses the click popup.
hover: ``True`` shows a hover tooltip built from the ``hover`` fields.
title: Property whose value titles the popup.
title_expression: MapLibre expression source producing the title.
body_expression: MapLibre expression source producing the popup body.
show_feature_id: ``False`` drops the synthetic ``id`` row.
max_width: Widest the click popup may draw, in CSS pixels.
image_height: Tallest an ``"image"`` field may draw, in CSS pixels.
tooltip: Hover shorthand -- a property name, a sequence of names,
``True`` to flag every configured field, or ``False`` to turn the
tooltip off.
merge: Merge into the layer's existing popup config instead of
replacing it, so a tooltip can be added without restating the
fields.
Returns:
The layer's popup config after the change.
Raises:
ValueError: If the reference does not resolve to exactly one layer, or
the popup specification is unusable.
"""
layer = find_layer(project, ref)
config = _project.popup_config(
fields,
click=click,
hover=hover,
title=title,
title_expression=title_expression,
body_expression=body_expression,
show_feature_id=show_feature_id,
max_width=max_width,
image_height=image_height,
)
if merge:
current = layer.get("popup")
if isinstance(current, dict):
config = {**copy.deepcopy(current), **config}
config = _project.apply_tooltip(config, tooltip)
layer["popup"] = config
return config
def clear_popup(project: dict[str, Any], ref: str) -> dict[str, Any]:
"""Drop a layer's popup config, restoring the app's default popup.
Args:
project: The project dict (mutated in place).
ref: A layer id or display name.
Returns:
A summary of the updated layer.
Raises:
ValueError: If the reference does not resolve to exactly one layer.
"""
layer = find_layer(project, ref)
layer.pop("popup", None)
return layer_summary(layer)
def set_layer_metadata(
project: dict[str, Any],
ref: str,
*,
merge: bool = False,
**fields: Any,
) -> dict[str, Any] | None:
"""Set a layer's descriptive (catalog) metadata.
This is what the app's layer Metadata dialog edits and exports as a STAC
Item: title, abstract, keywords, license, attribution, contact, lineage,
temporal extent and links. See :func:`geolibre.project.layer_metadata`.
Args:
project: The project dict (mutated in place).
ref: A layer id or display name.
merge: Keep the layer's existing fields that ``fields`` does not name,
instead of replacing the whole block.
**fields: Keyword arguments of :func:`geolibre.project.layer_metadata`
(``title``, ``abstract``, ``keywords``, ``license``,
``attribution``, ``contact``, ``lineage``, ``temporal_extent``,
``links``).
Returns:
The layer's metadata block after the change, or ``None`` when it ended
up empty (and was removed).
Raises:
ValueError: If the reference does not resolve to exactly one layer, a
field name is unknown, or a value fails validation.
"""
unknown = sorted(set(fields) - set(_METADATA_FIELD_KEYS))
if unknown:
raise ValueError(f"unknown layer metadata field(s): {', '.join(unknown)}")
layer = find_layer(project, ref)
built = _project.layer_metadata(**fields) or {}
if merge:
current = _project.normalize_layer_metadata(layer.get("descriptiveMetadata")) or {}
named = {_METADATA_FIELD_KEYS[name] for name in fields}
built = {
**{key: value for key, value in current.items() if key not in named},
**built,
}
metadata = _project.normalize_layer_metadata(built)
if metadata is None:
layer.pop("descriptiveMetadata", None)
else:
layer["descriptiveMetadata"] = metadata
return copy.deepcopy(metadata)
_METADATA_FIELD_KEYS = {
"title": "title",
"abstract": "abstract",
"keywords": "keywords",
"license": "license",
"attribution": "attribution",
"contact": "contact",
"lineage": "lineage",
"temporal_extent": "temporalExtent",
"links": "links",
}
def clear_layer_metadata(project: dict[str, Any], ref: str) -> dict[str, Any]:
"""Drop a layer's descriptive metadata.
Args:
project: The project dict (mutated in place).
ref: A layer id or display name.
Returns:
A summary of the updated layer.
Raises:
ValueError: If the reference does not resolve to exactly one layer.
"""
layer = find_layer(project, ref)
layer.pop("descriptiveMetadata", None)
return layer_summary(layer)
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
_RENDERERS = frozenset({"maplibre", "cesium", "mapbox", "arcgis"})
def _is_renderer(value: Any) -> bool:
"""Whether ``value`` names a supported renderer (a non-string is never one)."""
return isinstance(value, str) and value in _RENDERERS
def secondary_panes(project: dict[str, Any]) -> list[dict[str, Any]]:
"""Return ``secondaryMapViews`` validated as a list of renderable panes.
Args:
project: The project dict.
Returns:
The pane list (empty when the project has none).
Raises:
ValueError: If the field is not a list of pane objects carrying a
unique string ``id`` and, when present, a ``maplibre``/``cesium``/``mapbox``/
``arcgis`` ``viewKind`` (an omitted ``viewKind`` means ``maplibre``), e.g.
from a hand-edited project file.
"""
panes = project.get("secondaryMapViews", [])
if not isinstance(panes, list) or any(
not isinstance(p, dict)
or not isinstance(p.get("id"), str)
or not _is_renderer(p.get("viewKind", "maplibre"))
for p in panes
):
raise ValueError(
"secondaryMapViews must be a list of pane objects with an id "
"and a maplibre, cesium, mapbox, or arcgis viewKind"
)
if len({p["id"] for p in panes}) != len(panes):
raise ValueError("secondaryMapViews pane ids must be unique")
return panes
def set_renderer(project: dict[str, Any], renderer: str, *, pane_id: str | None = None) -> str:
"""Select ``maplibre``, ``cesium``, ``mapbox``, or ``arcgis`` for the primary map or a pane."""
if not _is_renderer(renderer):
raise ValueError("renderer must be maplibre, cesium, mapbox, or arcgis")
if pane_id is None:
project["primaryRenderer"] = renderer
else:
pane = next((p for p in secondary_panes(project) if p["id"] == pane_id), None)
if pane is None:
raise ValueError(f"Unknown pane: {pane_id}")
pane["viewKind"] = renderer
return renderer
def set_map_layout(
project: dict[str, Any],
rows: int,
cols: int,
*,
view_kinds: list[str] | None = None,
sync_view: bool = True,
) -> list[dict[str, Any]]:
"""Set a 1–4 row/column grid. ``view_kinds`` lists every pane, primary first."""
if any(isinstance(v, bool) or not isinstance(v, int) or not 1 <= v <= 4 for v in (rows, cols)):
raise ValueError("rows and cols must be integers between 1 and 4")
count = rows * cols
if view_kinds is not None and (
len(view_kinds) != count or not all(_is_renderer(k) for k in view_kinds)
):
raise ValueError(
"view_kinds must contain one maplibre, cesium, mapbox, or arcgis renderer per pane"
)
panes = copy.deepcopy(secondary_panes(project)[: count - 1])
while len(panes) < count - 1:
panes.append(
{
"id": str(uuid.uuid4()),
"view": copy.deepcopy(project.get("mapView", _project.default_map_view())),
"layerVisibility": {},
}
)
if view_kinds is not None:
set_renderer(project, view_kinds[0])
for pane, kind in zip(panes, view_kinds[1:]):
pane["viewKind"] = kind
project["mapLayout"] = {"rows": rows, "cols": cols, "syncView": bool(sync_view)}
project["secondaryMapViews"] = panes
return panes
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]``.
Note:
Setting ``center`` or ``zoom`` clears any ``bbox`` a previous
:func:`fit_bounds` recorded, since it no longer describes the camera.
Returns:
The project's ``mapView`` after the change.
Raises:
ValueError: If ``center`` is not a 2-element ``[lng, lat]``, or any
value is not finite.
"""
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 or not all(math.isfinite(value) for value in coords):
raise ValueError("center must be a [lng, lat] sequence of exactly 2 finite numbers")
view["center"] = coords
if zoom is not None:
view["zoom"] = min(24.0, max(0.0, _finite(zoom, "zoom")))
if center is not None or zoom is not None:
view.pop("bbox", None)
if bearing is not None:
view["bearing"] = _finite(bearing, "bearing")
if pitch is not None:
view["pitch"] = min(85.0, max(0.0, _finite(pitch, "pitch")))
return view
_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]``. Per RFC 7946 section
5.2, ``min_lng > max_lng`` means the box crosses the antimeridian
(Fiji is ``[170, -20, -170, -10]``) and is framed as such.
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, has its latitudes
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_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}")
lng_span = (max_lng - min_lng) % 360 if min_lng > max_lng else max_lng - min_lng
center_lng = min_lng + lng_span / 2
if center_lng > 180:
center_lng -= 360
width, height = _FIT_VIEWPORT
usable_width = max(1, width - 2 * padding)
usable_height = max(1, height - 2 * padding)
lng_fraction = lng_span / 360
lat_fraction = abs(_mercator_y(max_lat) - _mercator_y(min_lat))
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=[
center_lng,
_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)
def set_point_cloud_classes(
project: dict[str, Any], classes: list[dict[str, Any]]
) -> list[dict[str, Any]]:
"""Set the point cloud annotator's custom classes (its label schema).
Replaces the custom classes and keeps the saved point labels, instance ids
and 3D boxes. The annotator applies them when the project opens, so the
LiDAR layer draws those codes in their colour and names them in its
legend, and the classes can be assigned from the annotator's panel.
Args:
project: The project dict (mutated in place).
classes: ``{"code", "name", "color"}`` dicts; see
:func:`geolibre.project.point_cloud_class_schema`. An empty list
clears them.
Returns:
The validated classes as saved.
Raises:
ValueError: For an invalid class.
"""
schema = _project.point_cloud_class_schema(classes)
plugins = _project.ensure_plugins_block(project)
current = plugins["settings"].get(_project.POINT_CLOUD_ANNOTATION_PLUGIN_ID)
state = dict(current) if isinstance(current, dict) else {}
state.setdefault("version", 1)
state.setdefault("sources", [])
state.setdefault("cuboids", [])
state["customClasses"] = schema
_project.set_plugin_state(
project, _project.POINT_CLOUD_ANNOTATION_PLUGIN_ID, state, activate=False
)
return schema
def lidar_source_urls(project: dict[str, Any]) -> list[str]:
"""The URLs of the project's LiDAR point cloud layers.
Args:
project: The project dict.
Returns:
Each LiDAR layer's source URL, in layer order.
"""
urls: list[str] = []
for layer in layers_of(project):
source = layer.get("source")
if (
isinstance(source, dict)
and source.get("type") == "lidar"
and isinstance(source.get("url"), str)
):
urls.append(source["url"])
return urls
def merge_point_labels(
project: dict[str, Any],
url: str,
labels: dict[str, dict[int, int]],
instances: dict[str, dict[int, int]] | None = None,
) -> int:
"""Merge point labels into the annotator's saved state for one source.
New edits override any saved edit for the same point; other saved labels,
boxes, vectors and custom classes are kept.
Args:
project: The project dict (mutated in place).
url: The point cloud's source URL (its LiDAR layer's URL).
labels: Node key -> {index within node: class}.
instances: Node key -> {index within node: instance id}, optional.
Returns:
How many point edits were merged.
"""
from . import pointcloud as _pointcloud
plugins = _project.ensure_plugins_block(project)
current = plugins["settings"].get(_project.POINT_CLOUD_ANNOTATION_PLUGIN_ID)
state = dict(current) if isinstance(current, dict) else {}
state.setdefault("version", 1)
state.setdefault("cuboids", [])
def merge(key: str, edits_by_node: dict[str, dict[int, int]], wide: bool) -> None:
entries = [entry for entry in state.get(key) or [] if isinstance(entry, dict)]
entry = next((item for item in entries if item.get("url") == url), None)
if entry is None:
entry = {"url": url, "nodes": {}}
entries.append(entry)
nodes = dict(entry.get("nodes") or {})
for node_key, edits in edits_by_node.items():
existing: dict[int, int] = {}
if isinstance(nodes.get(node_key), str):
try:
existing = _project._decode_point_label_node_sized(
nodes[node_key], _project.MAX_POINT_LABEL_NODE_BYTES, wide
)[0]
except ValueError:
existing = {}
existing.update(edits)
nodes[node_key] = _pointcloud.encode_point_label_node(existing, wide=wide)
entry["nodes"] = nodes
state[key] = entries
merge("sources", labels, wide=False)
if instances:
merge("instances", instances, wide=True)
_project.set_plugin_state(
project, _project.POINT_CLOUD_ANNOTATION_PLUGIN_ID, state, activate=False
)
return sum(len(edits) for edits in labels.values()) + sum(
len(edits) for edits in (instances or {}).values()
)
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))
_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}")
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 set_map_legend(
project: dict[str, Any],
title: str | None = None,
*,
position: str | None = None,
group_by_layer: bool | None = None,
visible: bool | None = None,
collapsed: bool | None = None,
) -> dict[str, Any]:
"""Show the map legend, the panel behind the app's Controls -> Legend.
Unlike :func:`add_legend`, which draws hand-written entries, the map legend
derives its rows from each visible layer's symbology (graduated classes,
categories, heatmap ramps, ...), so it stays in step with the layers
without restating their colors. A project has one; calling this again
updates it and keeps any item order, label overrides, and custom entries
already saved on it.
Args:
project: The project dict (mutated in place).
title: Heading drawn above the entries. Keeps the current one (the
app default is ``"Legend"``) when omitted.
position: Map corner, one of :data:`CONTROL_POSITIONS`. Keeps the
current one (the app default is ``"top-left"``) when omitted.
group_by_layer: Group each layer's classes under a layer heading. Keeps
the current setting (the app default groups) when omitted.
visible: Whether the on-map panel is open. Keeps the current state
when omitted, and opens it when the project has no legend yet.
collapsed: Whether the open panel is collapsed to its header bar.
Keeps the current state when omitted.
Returns:
The project's legend config.
Raises:
ValueError: If ``position`` is not a map corner.
"""
if position is not None and position not in CONTROL_POSITIONS:
raise ValueError(f"position must be one of {sorted(CONTROL_POSITIONS)}, got {position!r}")
existing = project.get("legend")
if visible is None and not isinstance(existing, dict):
visible = True
legend: dict[str, Any] = dict(existing) if isinstance(existing, dict) else {}
legend.setdefault("title", "Legend")
legend.setdefault("groupByLayer", True)
legend.setdefault("order", [])
legend.setdefault("overrides", {})
if title is not None:
legend["title"] = str(title)
if group_by_layer is not None:
legend["groupByLayer"] = bool(group_by_layer)
if position is not None:
legend["panelPosition"] = position
for key, flag in (("panelVisible", visible), ("panelCollapsed", collapsed)):
if flag is None:
continue
if flag:
legend[key] = True
else:
legend.pop(key, None)
project["legend"] = legend
return legend
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)
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 _style_layer_ids(layer: dict[str, Any]) -> list[str]:
"""The MapLibre style layer ids a layer is drawn as, when they are derivable.
Only layers the app draws through a single style layer with a predictable
id qualify. `pmtiles` vector layers are deliberately absent: their ids are
`<sourceId>-<sourceLayer>-<kind>` (``pmtilesNativeLayerIds`` in
pmtiles-layer.ts), which the layer dict alone cannot spell out.
Args:
layer: A layer dict from the project's ``layers`` array.
Returns:
The style layer ids, or an empty list when none are derivable.
"""
layer_id = str(layer.get("id", ""))
layer_type = layer.get("type")
metadata = layer.get("metadata")
metadata = metadata if isinstance(metadata, dict) else {}
source = layer.get("source")
source = source if isinstance(source, dict) else {}
is_raster = metadata.get("tileType") == "raster" or source.get("type") == "raster"
if layer_type == "pmtiles":
if not is_raster:
return []
return [f"{metadata.get('sourceId') or layer_id}-raster"]
if layer_type == "video":
return [f"layer-{layer_id}-video"]
if layer_type == "mbtiles":
return [f"layer-{layer_id}-raster"] if is_raster else []
if layer_type in RASTER_STYLE_LAYER_TYPES:
return [f"layer-{layer_id}-raster"]
return []
def _expand_swipe_side(project: dict[str, Any], layer_ids: list[str]) -> list[str]:
"""Add the derived style layer ids of every listed layer to one swipe side.
The swipe control drives what each half shows by toggling MapLibre style
layer ids. A layer drawn through a style layer of its own — a raster tile
source as ``layer-<id>-raster``, a video as ``layer-<id>-video`` — leaves a
side holding only the project layer id matching no style layer: the control
treats the layer as assigned to neither side, which it renders on both
halves. Listing both ids keeps the project layer id (what the panel
checkboxes read) and adds the ids the control acts on.
"""
layers = {
layer.get("id"): layer for layer in project.get("layers", []) if isinstance(layer, dict)
}
expanded: list[str] = []
for layer_id in layer_ids:
if layer_id not in expanded:
expanded.append(layer_id)
layer = layers.get(layer_id)
if not isinstance(layer, dict):
continue
for style_id in _style_layer_ids(layer):
if style_id not in expanded:
expanded.append(style_id)
return expanded
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=_expand_swipe_side(project, list(left_layers)),
right_layers=_expand_swipe_side(project, 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
def set_layer_filter(project: dict[str, Any], ref: str, expression: Any) -> dict[str, Any]:
"""Set or clear a layer's persistent feature filter.
The filter is the project's ``filterExpression`` -- the same one the app's
**Select by Expression -> Filter layer** writes. It hides the features that
do not match without changing the data, and combines with quick filters
and the Time Slider window.
Args:
project: The project dict (mutated in place).
ref: A layer id or display name.
expression: A boolean MapLibre expression (list or JSON string), e.g.
``[">=", ["get", "population"], 100000]``; ``None`` or ``[]``
clears the filter.
Returns:
A summary of the layer, with the filter that is now set (or ``None``).
Raises:
ValueError: If the reference does not resolve to exactly one layer, or
the expression is not a boolean expression array.
"""
layer = find_layer(project, ref)
if expression is None or expression == [] or expression == "":
layer.pop("filterExpression", None)
else:
layer["filterExpression"] = _project.filter_expression(expression)
return {**layer_summary(layer), "filterExpression": layer.get("filterExpression")}
def set_labels(
project: dict[str, Any],
ref: str,
field: str | None = None,
*,
expression: Any = None,
enabled: bool | None = None,
**options: Any,
) -> dict[str, Any]:
"""Label a vector layer's features from an attribute or an expression.
Unspecified options keep the layer's current label settings, so a call can
restyle labels without restating the field.
Args:
project: The project dict (mutated in place).
ref: A layer id or display name.
field: Property whose value becomes the label text.
expression: MapLibre expression (list or JSON string) for the label
text; overrides ``field``. ``""`` clears it.
enabled: ``False`` hides the labels but keeps their settings, ``True``
shows them; omitted, labels keep their current state (on for a
layer that had none).
**options: Label options; see :func:`geolibre.project.label_style`.
Returns:
The layer's labels object after the change.
Raises:
ValueError: If the reference does not resolve to exactly one layer, or
an option is invalid.
"""
layer = find_layer(project, ref)
style = layer.get("style")
if not isinstance(style, dict):
style = copy.deepcopy(_project.DEFAULT_LAYER_STYLE)
layer["style"] = style
current = style.get("labels")
labels = _project.label_style(
field,
expression=expression,
enabled=enabled,
base=current if isinstance(current, dict) else None,
**options,
)
style["labels"] = labels
return copy.deepcopy(labels)
def set_plugin_state(
project: dict[str, Any],
plugin_id: str,
state: Any = None,
*,
position: str | None = None,
activate: bool = True,
allow_unknown: bool = False,
clear: bool = False,
) -> dict[str, Any]:
"""Store a plugin's saved state in the project, the way the app saves it.
The blob is opaque to GeoLibre: each plugin reads its own shape in
``applyProjectState`` (for example the Time Slider's timeline config). The
swipe, legend and colorbar have dedicated, validated builders --
:func:`add_swipe`, :func:`add_legend`, :func:`add_colorbar` -- and are the
better choice for those.
Args:
project: The project dict (mutated in place).
plugin_id: A built-in plugin id from
:data:`geolibre.project.PLUGIN_STATE_IDS`, or an external plugin's
id with ``allow_unknown=True``.
state: The plugin's settings blob; must be plain JSON. ``None``
keeps the stored settings, so ``position``/``activate`` can be
changed without resending them.
position: Optional control corner, one of :data:`CONTROL_POSITIONS`.
activate: Add the plugin to ``activePluginIds`` so it starts active.
allow_unknown: Accept an id that is not a built-in plugin with saved
state (an external plugin loaded from a manifest URL).
clear: Remove the stored settings and nothing else (``state``,
``position`` and ``activate`` are then not applied, so clearing
cannot switch the plugin on).
Returns:
``{"pluginId", "active", "position", "state"}`` as now stored.
Raises:
ValueError: If the id is unknown (and not allowed), the position is
invalid, the state is not plain JSON, or ``clear`` is combined
with a ``state``.
"""
if clear and state is not None:
raise ValueError("pass either a state or clear=True, not both")
if not isinstance(plugin_id, str) or not plugin_id.strip():
raise ValueError("plugin_id must be a non-empty string")
plugin_id = plugin_id.strip()
if plugin_id not in _project.PLUGIN_STATE_IDS and not allow_unknown:
raise ValueError(
f"{plugin_id!r} is not a built-in plugin that saves project state. "
f"Known ids: {', '.join(sorted(_project.PLUGIN_STATE_IDS))}. "
"For an external plugin, pass allow_unknown=True."
)
if position is not None and position not in CONTROL_POSITIONS:
raise ValueError(f"position must be one of {sorted(CONTROL_POSITIONS)}, got {position!r}")
plugins = _project.ensure_plugins_block(project)
if clear:
plugins["settings"].pop(plugin_id, None)
elif state is None:
if activate and plugin_id not in plugins["activePluginIds"]:
plugins["activePluginIds"].append(plugin_id)
if position is not None:
plugins["mapControlPositions"][plugin_id] = position
else:
_project.set_plugin_state(
project,
plugin_id,
_project.json_compatible(state, "state"),
position=position,
activate=activate,
)
return {
"pluginId": plugin_id,
"active": plugin_id in plugins["activePluginIds"],
"position": plugins["mapControlPositions"].get(plugin_id),
"state": copy.deepcopy(plugins["settings"].get(plugin_id)),
}
def _story_map(project: dict[str, Any]) -> dict[str, Any]:
"""Return the project's story map, creating a default one when absent.
Args:
project: The project dict (mutated in place when a story is created).
Returns:
The live ``storymap`` dict, with every ``StoryMap`` key present.
"""
story = project.get("storymap")
if not isinstance(story, dict):
story = copy.deepcopy(_project.DEFAULT_STORY_MAP)
project["storymap"] = story
for key, value in _project.DEFAULT_STORY_MAP.items():
story.setdefault(key, copy.deepcopy(value))
chapters = story["chapters"]
story["chapters"] = (
[chapter for chapter in chapters if isinstance(chapter, dict)]
if isinstance(chapters, list)
else []
)
return story
def _story_summary(story: dict[str, Any]) -> dict[str, Any]:
"""Summarize a story map: its settings plus one line per chapter.
Args:
story: A ``storymap`` dict.
Returns:
The settings with ``chapters`` reduced to ``{"id", "title"}`` entries.
"""
return {
**{key: value for key, value in story.items() if key != "chapters"},
"chapters": [
{"id": chapter.get("id"), "title": chapter.get("title")}
for chapter in story["chapters"]
if isinstance(chapter, dict)
],
}
def find_story_chapter(project: dict[str, Any], ref: str | int) -> int:
"""Resolve a story chapter by id, title, or 0-based position.
Args:
project: The project dict.
ref: A chapter id, a chapter title (exact, then case-insensitive), or
an integer index.
Returns:
The chapter's index in ``storymap.chapters``.
Raises:
ValueError: If nothing matches, or a title matches several chapters.
"""
story = project.get("storymap")
chapters = story.get("chapters") if isinstance(story, dict) else None
chapters = [c for c in chapters if isinstance(c, dict)] if isinstance(chapters, list) else []
if isinstance(ref, int) and not isinstance(ref, bool):
if -len(chapters) <= ref < len(chapters):
return ref % len(chapters)
raise ValueError(f"No chapter at index {ref}; the story has {len(chapters)} chapter(s)")
for index, chapter in enumerate(chapters):
if chapter.get("id") == ref:
return index
for match in (
lambda chapter: chapter.get("title") == ref,
lambda chapter: str(chapter.get("title", "")).casefold() == str(ref).casefold(),
):
hits = [index for index, chapter in enumerate(chapters) if match(chapter)]
if len(hits) == 1:
return hits[0]
if len(hits) > 1:
raise ValueError(f"{len(hits)} chapters are titled {ref!r}; reference one by id")
known = ", ".join(repr(chapter.get("title")) for chapter in chapters) or "none"
raise ValueError(f"No chapter matches {ref!r}. Chapters: {known}")
def set_story_map(project: dict[str, Any], **settings: Any) -> dict[str, Any]:
"""Set the story map's title block and presentation settings.
Args:
project: The project dict (mutated in place).
**settings: Any of :data:`geolibre.project.STORY_SETTING_NAMES` --
``title``, ``subtitle``, ``byline``, ``footer``, ``theme``
(``"light"``/``"dark"``), ``show_markers``, ``marker_color``,
``inset``, ``inset_position``, ``hide_chapter_nav``,
``start_slide`` and ``end_slide`` (``"none"``, ``"blank"``,
``"black"``, ``"global"``, ``"adjacent"``).
Returns:
A summary of the story map (settings plus chapter ids and titles).
Raises:
ValueError: If a setting is unknown or invalid.
"""
updates = _project.story_map_settings(**settings)
story = _story_map(project)
story.update(updates)
return _story_summary(story)
def _resolve_opacity_layers(project: dict[str, Any], entries: Any) -> Any:
"""Resolve the layer references in chapter opacity changes to layer ids.
Args:
project: The project dict.
entries: ``None`` or a list of opacity-change mappings.
Returns:
The entries with ``layer`` replaced by the resolved ``layerId``.
Raises:
ValueError: If a layer reference does not resolve.
"""
if not isinstance(entries, (list, tuple)):
return entries
resolved = []
for entry in entries:
if isinstance(entry, dict):
ref = entry.get("layerId", entry.get("layer_id", entry.get("layer")))
if isinstance(ref, str) and ref:
entry = {
**{k: v for k, v in entry.items() if k not in ("layer", "layer_id")},
"layerId": str(find_layer(project, ref)["id"]),
}
resolved.append(entry)
return resolved
def add_story_chapter(
project: dict[str, Any],
title: str,
*,
description: str = "",
center: Iterable[float] | None = None,
zoom: float | None = None,
pitch: float | None = None,
bearing: float | None = None,
image: str | None = None,
alignment: str = "left",
hidden: bool = False,
map_animation: str = "flyTo",
rotate_animation: bool = False,
on_enter: Any = None,
on_exit: Any = None,
index: int | None = None,
chapter_id: str | None = None,
) -> dict[str, Any]:
"""Add a chapter to the project's story map (Project -> Story Map).
A camera value left out is taken from the project's saved view, the way
the app's **Add chapter** button captures the current map.
Args:
project: The project dict (mutated in place).
title: Chapter heading.
description: Chapter body text.
center: Camera target ``[lng, lat]``.
zoom: Camera zoom, 0-24.
pitch: Camera tilt in degrees, 0-85.
bearing: Camera rotation in degrees.
image: Optional image URL shown in the chapter panel.
alignment: ``"left"``, ``"center"``, ``"right"``, or ``"full"``.
hidden: Hide the text panel while still moving the map.
map_animation: ``"flyTo"``, ``"easeTo"``, or ``"jumpTo"``.
rotate_animation: Slowly rotate the camera once the move settles.
on_enter: Layer opacity changes on entering, as ``{"layer": <id or
name>, "opacity": 0-1, "duration": ms}`` entries.
on_exit: Layer opacity changes on leaving, in the same form.
index: Position to insert at (clamped); appended when omitted.
chapter_id: Explicit chapter id; a UUID by default.
Returns:
The chapter that was added.
Raises:
ValueError: If a value is invalid, a layer reference does not resolve,
or ``chapter_id`` is already used.
"""
view = project.get("mapView") if isinstance(project.get("mapView"), dict) else {}
view_center = view.get("center") or [0, 0]
chapter = _project.story_chapter(
title,
center=list(center) if center is not None else list(view_center),
zoom=zoom if zoom is not None else view.get("zoom", 2),
pitch=pitch if pitch is not None else view.get("pitch", 0),
bearing=bearing if bearing is not None else view.get("bearing", 0),
description=description,
image=image,
alignment=alignment,
hidden=hidden,
map_animation=map_animation,
rotate_animation=rotate_animation,
on_enter=_resolve_opacity_layers(project, on_enter),
on_exit=_resolve_opacity_layers(project, on_exit),
chapter_id=chapter_id,
)
story = _story_map(project)
chapters = story["chapters"]
if any(isinstance(c, dict) and c.get("id") == chapter["id"] for c in chapters):
raise ValueError(f"a chapter with id {chapter['id']!r} already exists")
position = len(chapters) if index is None else max(0, min(len(chapters), int(index)))
chapters.insert(position, chapter)
return copy.deepcopy(chapter)
def remove_story_chapter(project: dict[str, Any], ref: str | int) -> dict[str, Any]:
"""Remove a story chapter by id, title, or index.
Args:
project: The project dict (mutated in place).
ref: A chapter id, title, or 0-based index.
Returns:
A summary of the story map after the removal.
Raises:
ValueError: If the reference does not resolve to exactly one chapter.
"""
story = _story_map(project)
story["chapters"].pop(find_story_chapter(project, ref))
return _story_summary(story)
def move_story_chapter(project: dict[str, Any], ref: str | int, index: int) -> dict[str, Any]:
"""Move a story chapter to a new position.
Args:
project: The project dict (mutated in place).
ref: A chapter id, title, or 0-based index.
index: The destination position (clamped to the chapter list).
Returns:
A summary of the story map after the move.
Raises:
ValueError: If the reference does not resolve to exactly one chapter.
"""
story = _story_map(project)
chapter = story["chapters"].pop(find_story_chapter(project, ref))
destination = max(0, min(len(story["chapters"]), int(index)))
story["chapters"].insert(destination, chapter)
return _story_summary(story)
def _bookmark_lists(project: dict[str, Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Return the project's bookmark and folder lists, creating them if absent.
Args:
project: The project dict (mutated in place when a list is created).
Returns:
The live ``bookmarks`` and ``bookmarkGroups`` lists.
"""
lists = []
for key in ("bookmarks", "bookmarkGroups"):
value = project.get(key)
if not isinstance(value, list):
value = []
project[key] = value
lists.append(value)
return lists[0], lists[1]
def _drop_empty_bookmark_lists(project: dict[str, Any]) -> None:
"""Remove empty bookmark lists, as the app omits them when saving."""
for key in ("bookmarks", "bookmarkGroups"):
if project.get(key) == []:
del project[key]
def bookmark_summary(project: dict[str, Any]) -> list[dict[str, Any]]:
"""List the project's bookmarks with the name of the folder each is in.
Args:
project: The project dict.
Returns:
``{"id", "name", "folder"}`` entries in panel order.
"""
folders = {
folder.get("id"): folder.get("name")
for folder in project.get("bookmarkGroups") or []
if isinstance(folder, dict)
}
return [
{
"id": entry.get("id"),
"name": entry.get("name"),
"folder": folders.get(entry.get("groupId")),
}
for entry in project.get("bookmarks") or []
if isinstance(entry, dict)
]
def find_bookmark(project: dict[str, Any], ref: str | int) -> int:
"""Resolve a bookmark by id, name, or 0-based position.
Args:
project: The project dict.
ref: A bookmark id, a bookmark name (exact, then case-insensitive), or
an integer index.
Returns:
The bookmark's index in ``bookmarks``.
Raises:
ValueError: If nothing matches, or a name matches several bookmarks.
"""
entries = [entry for entry in project.get("bookmarks") or [] if isinstance(entry, dict)]
if isinstance(ref, int) and not isinstance(ref, bool):
if 0 <= ref < len(entries):
return ref
raise ValueError(f"bookmark index {ref} is out of range (0-{len(entries) - 1})")
text = str(ref)
for index, entry in enumerate(entries):
if entry.get("id") == text:
return index
for matcher in (lambda name: name == text, lambda name: name.lower() == text.lower()):
matches = [i for i, entry in enumerate(entries) if matcher(str(entry.get("name", "")))]
if len(matches) == 1:
return matches[0]
if len(matches) > 1:
raise ValueError(f"several bookmarks are named {text!r}; use the id")
raise ValueError(f"no bookmark matches {text!r}")
def add_bookmark(
project: dict[str, Any],
name: str,
*,
center: Iterable[float] | None = None,
zoom: float | None = None,
pitch: float | None = None,
bearing: float | None = None,
folder: str | None = None,
visible_layers: Iterable[str] | None = None,
bookmark_id: str | None = None,
created_at: int | None = None,
) -> dict[str, Any]:
"""Add a saved map view to the project's Bookmarks panel.
A camera value left out is taken from the project's saved view, the way
the panel's **Add** button captures the current map.
Args:
project: The project dict (mutated in place).
name: Bookmark name.
center: Camera target ``[lng, lat]``.
zoom: Camera zoom, 0-24.
pitch: Camera tilt in degrees, 0-85.
bearing: Camera rotation in degrees.
folder: A folder id or name to file it under; a new name creates the
folder. Ungrouped when omitted.
visible_layers: Layers (ids or names) to show when the bookmark is
opened; the others are hidden. Omit to leave visibility alone.
bookmark_id: Explicit bookmark id; a UUID by default.
created_at: Creation time in milliseconds since the epoch; now by
default.
Returns:
The bookmark that was added.
Raises:
ValueError: If a value is invalid, a layer does not resolve,
``bookmark_id`` is already used, or the project already holds
:data:`geolibre.project.MAX_BOOKMARKS` bookmarks.
"""
existing = [b for b in project.get("bookmarks") or [] if isinstance(b, dict)]
if len(existing) >= _project.MAX_BOOKMARKS:
raise ValueError(
f"the project already has {len(existing)} bookmarks, the most the app keeps "
f"({_project.MAX_BOOKMARKS}); remove one first"
)
view = project.get("mapView") if isinstance(project.get("mapView"), dict) else {}
existing_folders = [f for f in project.get("bookmarkGroups") or [] if isinstance(f, dict)]
new_folder: dict[str, Any] | None = None
group_id: str | None = None
if folder is not None:
match = next((f for f in existing_folders if folder in (f.get("id"), f.get("name"))), None)
if match is None:
new_folder = match = _project.bookmark_folder(str(folder))
group_id = str(match["id"])
entry = _project.bookmark(
name,
center=list(center) if center is not None else list(view.get("center") or [0, 0]),
zoom=zoom if zoom is not None else view.get("zoom", 2),
pitch=pitch if pitch is not None else view.get("pitch", 0),
bearing=bearing if bearing is not None else view.get("bearing", 0),
group_id=group_id,
visible_layer_ids=(
resolve_layer_ids(project, visible_layers) if visible_layers is not None else None
),
bookmark_id=bookmark_id,
created_at=created_at,
)
if any(
isinstance(b, dict) and b.get("id") == entry["id"] for b in project.get("bookmarks") or []
):
raise ValueError(f"a bookmark with id {entry['id']!r} already exists")
bookmarks, folders = _bookmark_lists(project)
if new_folder is not None:
folders.append(new_folder)
position = len(bookmarks)
if group_id is not None:
members = [
i
for i, b in enumerate(bookmarks)
if isinstance(b, dict) and b.get("groupId") == group_id
]
if members:
position = members[-1] + 1
bookmarks.insert(position, entry)
_drop_empty_bookmark_lists(project)
return copy.deepcopy(entry)
def remove_bookmark(project: dict[str, Any], ref: str | int) -> list[dict[str, Any]]:
"""Remove a bookmark by id, name, or index.
Args:
project: The project dict (mutated in place).
ref: A bookmark id, name, or 0-based index.
Returns:
The remaining bookmarks, as :func:`bookmark_summary` lists them.
Raises:
ValueError: If the reference does not resolve to exactly one bookmark.
"""
index = find_bookmark(project, ref)
bookmarks, _ = _bookmark_lists(project)
entries = [i for i, entry in enumerate(bookmarks) if isinstance(entry, dict)]
bookmarks.pop(entries[index])
_drop_empty_bookmark_lists(project)
return bookmark_summary(project)