Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,46 @@ m.to_project()["mapView"]["center"]
| `set_center_zoom(lng, lat, zoom=None)` | Alias of `set_center` (leafmap compatibility). |
| `zoom_to_bounds(bounds)` / `zoom_to_layer(layer)` | Fit the view to bounds or a layer id/name/handle. |
| `layer_names` / `find_layer(name)` / `set_layer_visibility` / `set_layer_opacity` | Inspect and update layers conveniently. |
| `remove_layer(layer_id)` / `clear_layers()` | Remove layers. |
| `rename_layer` / `move_layer` / `duplicate_layer` / `show_layer` / `hide_layer` | Manage layers by id, name, or `Layer` handle. |
| `layer_properties(layer)` / `column_values(layer, column)` / `describe()` | Inspect inlined data and summarize a project without a browser round trip. |
| `remove_layer(layer)` / `clear_layers()` | Remove one layer by id, name, or handle, or remove all layers. |
| `center` / `zoom` / `basemap` / `name` | Read persisted project and camera state; `name` is writable. |
Comment thread
giswqs marked this conversation as resolved.
Outdated
| `set_zoom` / `set_bearing` / `set_pitch` / `fit_project_bounds` | Persist camera changes without requiring the widget to be displayed. |
| `to_project()` / `load_project(src)` / `save_project(path)` | Project I/O. |

Layer handles provide the same operations in an object-oriented form:

```python
m.add_geojson("https://example.com/roads.geojson", name="Roads")

roads = m.find_layer("Roads") # None when no layer has that name
roads.opacity = 0.6
roads.set_style(lineColor="#e63946", lineWidth=3)
roads.move(0)

print(roads.properties()) # sampled values for every property
print(roads.column("highway")) # one value per feature
roads_copy = roads.duplicate(name="Roads (proposed)")
```

For headless authoring and scripts that do not need a widget, commonly used
project utilities are available directly from the top-level package:

```python
from geolibre import (
basemap_catalog,
builtin_legend_names,
color_ramp_names,
describe_project,
load_project,
save_project,
)

project = load_project("my-map.geolibre.json")
print(describe_project(project))
save_project("copy.geolibre.json", project)
Comment thread
giswqs marked this conversation as resolved.
```

## Notes

- The bundled app is served from a localhost HTTP server, so the interactive
Expand Down
21 changes: 20 additions & 1 deletion python/src/geolibre/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,29 @@

from typing import Any

from .authoring import (
basemap_catalog,
color_ramp_names,
describe_project,
load_project,
save_project,
)
from .geolibre import Feature, Layer, Map
from .legends import builtin_legend_names

__version__ = "2.5.0"
__all__ = ["Feature", "Layer", "Map", "__version__"]
__all__ = [
"Feature",
"Layer",
"Map",
"__version__",
"basemap_catalog",
"builtin_legend_names",
"color_ramp_names",
"describe_project",
"load_project",
"save_project",
]
Comment thread
giswqs marked this conversation as resolved.


def _jupyter_server_extension_points() -> list[dict[str, str]]:
Expand Down
189 changes: 167 additions & 22 deletions python/src/geolibre/geolibre.py
Original file line number Diff line number Diff line change
Expand Up @@ -960,13 +960,12 @@ def _resolve_layer(self, layer: str | Layer) -> Layer:
# Access verifies that a stale handle has not been removed.
layer._layer()
return layer
try:
return self.get_layer(str(layer))
except ValueError:
match = self.find_layer(str(layer))
if match is not None:
return match
raise ValueError(f"No layer with id or name {layer!r}")
# Share the authoring resolver so scripting and the MCP tools agree on
# what a reference means: an id wins outright, then an exact name, then a
# case-insensitive one, and a name several layers share is an error rather
# than an arbitrary pick. `find_layer` returns the first name match by
# design (leafmap compatibility), so it is not the resolver for mutations.
return Layer(self, str(_authoring.find_layer(self.project, str(layer))["id"]))
Comment thread
giswqs marked this conversation as resolved.

def set_layer_visibility(self, layer: str | Layer, visible: bool = True) -> None:
"""Show or hide a layer addressed by id, name, or layer handle."""
Expand All @@ -976,6 +975,63 @@ def set_layer_opacity(self, layer: str | Layer, opacity: float) -> None:
"""Set a layer's opacity in ``[0, 1]``."""
self._resolve_layer(layer).opacity = opacity

def rename_layer(self, layer: str | Layer, name: str) -> None:
"""Rename a layer addressed by id, name, or handle."""
handle = self._resolve_layer(layer)
self._update_project(lambda p: _authoring.update_layer(p, handle.id, name=name))
Comment thread
giswqs marked this conversation as resolved.
Outdated

def move_layer(self, layer: str | Layer, index: int) -> None:
"""Move a layer to ``index`` in the project's draw order.

Negative indices count from the end the way sequence *indexing* does, so
``-1`` moves the layer to the last position (not ``list.insert(-1, ...)``,
which would leave it second to last). Out-of-range indices are clamped.
"""
handle = self._resolve_layer(layer)

def _move(project: dict[str, Any]) -> None:
destination = int(index)
if destination < 0:
destination = max(0, len(project.get("layers", [])) + destination)
_authoring.update_layer(project, handle.id, index=destination)

self._update_project(_move)
Comment thread
giswqs marked this conversation as resolved.

def duplicate_layer(self, layer: str | Layer, *, name: str | None = None) -> str:
"""Duplicate a layer, returning the new layer id.

Raises:
ValueError: If ``name`` is the reserved basemap pseudo-id.
"""
if name is not None:
# `_add_layer` appends straight to the project, so the check
# `rename_layer` gets from `update_layer` has to happen here.
_authoring._reject_reserved_name(name)
Comment thread
giswqs marked this conversation as resolved.
Outdated
source = copy.deepcopy(self._resolve_layer(layer)._layer())
source["id"] = str(uuid.uuid4())
source["name"] = name or f"{source.get('name', 'Layer')} copy"
Comment thread
giswqs marked this conversation as resolved.
Outdated
return self._add_layer(source)

def show_layer(self, layer: str | Layer) -> None:
"""Show a layer."""
self.set_layer_visibility(layer, True)

def hide_layer(self, layer: str | Layer) -> None:
"""Hide a layer."""
self.set_layer_visibility(layer, False)

def layer_properties(self, layer: str | Layer) -> dict[str, list[Any]]:
"""Return sampled property values for an inlined GeoJSON layer."""
return _authoring.layer_properties(self._resolve_layer(layer)._layer())

def column_values(self, layer: str | Layer, column: str) -> list[Any]:
"""Return one property column from an inlined GeoJSON layer."""
return _authoring.column_values(self._resolve_layer(layer)._layer(), column)

def describe(self) -> dict[str, Any]:
"""Return a compact, JSON-serializable project summary."""
return _authoring.describe_project(copy.deepcopy(self.project))
Comment thread
giswqs marked this conversation as resolved.
Outdated

def _mutate_layer(self, layer_id: str, mutate: Callable[[dict[str, Any]], None]) -> None:
"""Apply an in-place mutation to one layer through the project trait."""

Expand Down Expand Up @@ -1901,17 +1957,15 @@ def add_video(
url_list = [urls] if isinstance(urls, str) else list(urls)
return self._add_layer(_project.video_layer(name, url_list, coordinates, **style))

def remove_layer(self, layer_id: str) -> None:
"""Remove a layer by id.
def remove_layer(self, layer_id: str | Layer) -> None:
Comment thread
giswqs marked this conversation as resolved.
Comment thread
giswqs marked this conversation as resolved.
"""Remove a layer by id, display name, or handle.

Args:
layer_id: The id returned when the layer was added.
layer_id: A layer id, display name, or :class:`Layer` handle.
"""

def _drop(p: dict[str, Any]) -> None:
p["layers"] = [layer for layer in p["layers"] if layer.get("id") != layer_id]

self._update_project(_drop)
resolved_id = self._resolve_layer(layer_id).id
self._update_project(lambda p: _authoring.remove_layer(p, resolved_id))

def clear_layers(self) -> None:
"""Remove all layers from the map."""
Expand Down Expand Up @@ -1945,17 +1999,71 @@ def set_center(self, lng: float, lat: float, zoom: float | None = None) -> None:
lat: Latitude of the new center.
zoom: Optional zoom level.
"""

def mutate(p: dict[str, Any]) -> None:
p["mapView"]["center"] = [float(lng), float(lat)]
if zoom is not None:
p["mapView"]["zoom"] = float(zoom)

self._update_project(mutate)
self._update_project(
lambda p: _authoring.set_view(p, center=(lng, lat), zoom=zoom),
)

# leafmap compatibility alias for set_center
set_center_zoom = set_center

def set_zoom(self, zoom: float) -> None:
"""Set the map zoom while preserving the other camera fields."""
self._update_project(lambda p: _authoring.set_view(p, zoom=zoom))

def set_bearing(self, bearing: float) -> None:
"""Set clockwise camera bearing in degrees."""
self._update_project(lambda p: _authoring.set_view(p, bearing=bearing))

def set_pitch(self, pitch: float) -> None:
"""Set camera pitch in degrees (clamped to the supported range)."""
self._update_project(lambda p: _authoring.set_view(p, pitch=pitch))

def fit_project_bounds(self, bounds: list[float] | tuple[float, float, float, float]) -> None:
"""Persist a fitted camera for ``[west, south, east, north]`` bounds.

Unlike :meth:`fit_bounds`, this is a pure project mutation and does not
require a live browser connection.
"""
self._update_project(lambda p: _authoring.fit_bounds(p, bounds))
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@property
def center(self) -> tuple[float, float]:
"""The persisted ``(longitude, latitude)`` camera center."""
center = self.project.get("mapView", {}).get("center", [0, 0])
return float(center[0]), float(center[1])

@property
def zoom(self) -> float:
"""The persisted camera zoom."""
return float(self.project.get("mapView", {}).get("zoom", 0))

@property
def bearing(self) -> float:
"""The persisted clockwise camera bearing in degrees."""
return float(self.project.get("mapView", {}).get("bearing", 0))

@property
def pitch(self) -> float:
"""The persisted camera pitch in degrees."""
return float(self.project.get("mapView", {}).get("pitch", 0))

@property
def basemap(self) -> str | None:
"""The current basemap style URL."""
value = self.project.get("basemapStyleUrl")
return str(value) if value is not None else None
Comment thread
giswqs marked this conversation as resolved.
Outdated

@property
def name(self) -> str:
"""The project name."""
return str(self.project.get("name", ""))

@name.setter
def name(self, value: str) -> None:
if not isinstance(value, str) or not value.strip():
raise ValueError("name must be a non-empty string")
self._update_project(lambda p: p.update(name=value.strip()))

# -- map controls: split map / legend / colorbar --------------------

@staticmethod
Expand Down Expand Up @@ -2333,7 +2441,7 @@ def name(self) -> Any:

@name.setter
def name(self, value: str) -> None:
self._map._mutate_layer(self._id, lambda layer: layer.update(name=value))
self._map.rename_layer(self, value)

@property
def visible(self) -> bool:
Expand Down Expand Up @@ -2361,6 +2469,27 @@ def style(self) -> dict[str, Any]:
"""A copy of the layer's style object."""
return copy.deepcopy(self._layer().get("style", {}))

@property
def source(self) -> Any:
"""A detached copy of the layer source configuration."""
return copy.deepcopy(self._layer().get("source"))

@property
def data(self) -> dict[str, Any]:
"""A detached copy of the complete layer record."""
return copy.deepcopy(self._layer())
Comment thread
giswqs marked this conversation as resolved.
Outdated

@property
def index(self) -> int:
"""The layer's current index in draw order.

Raises:
ValueError: If the layer has been removed, matching the other
accessors rather than raising ``StopIteration``.
"""
self._layer()
return next(i for i, layer in enumerate(self._map.layers) if layer.id == self._id)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
giswqs marked this conversation as resolved.

def set_style(self, **style: Any) -> None:
"""Merge style overrides into the layer (e.g. ``fillColor="#ff0000"``)."""

Expand All @@ -2373,6 +2502,22 @@ def get_features(self, *, timeout: float = 10.0) -> list[Feature]:
"""Return this layer's features (see :meth:`Map.get_features`)."""
return self._map.get_features(self._id, timeout=timeout)

def properties(self) -> dict[str, list[Any]]:
"""Return sampled property values for inlined GeoJSON."""
return self._map.layer_properties(self)

def column(self, name: str) -> list[Any]:
"""Return a property column from inlined GeoJSON."""
return self._map.column_values(self, name)

def move(self, index: int) -> None:
"""Move this layer to an index in draw order."""
self._map.move_layer(self, index)

def duplicate(self, *, name: str | None = None) -> Layer:
"""Duplicate this layer and return its new handle."""
return self._map.get_layer(self._map.duplicate_layer(self, name=name))

def zoom_to(self, *, timeout: float = 10.0) -> None:
"""Fit the map camera to this layer's extent."""
self._map.zoom_to_layer(self, timeout=timeout)
Expand Down
13 changes: 13 additions & 0 deletions python/tests/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import pytest

import geolibre
from geolibre import project

POINT_FC = {
Expand All @@ -33,6 +34,18 @@ def test_build_empty_project_defaults():
assert proj["preferences"] is not project.DEFAULT_PROJECT_PREFERENCES


def test_top_level_package_exports_headless_authoring_api(tmp_path):
proj = project.build_empty_project()
assert geolibre.basemap_catalog()
assert geolibre.builtin_legend_names()
assert geolibre.color_ramp_names()

path = tmp_path / "map.geolibre.json"
geolibre.save_project(path, proj)
loaded = geolibre.load_project(path)
assert geolibre.describe_project(loaded)["layerCount"] == 0


def test_build_empty_project_overrides():
proj = project.build_empty_project(center=(10, 20), zoom=7, basemap_url="x")
assert proj["mapView"]["center"] == [10.0, 20.0]
Expand Down
Loading
Loading