Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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` / `bearing` / `pitch` / `basemap` / `name` | Read persisted project and camera state; `name` is writable. |
| `set_zoom` / `set_bearing` / `set_pitch` / `fit_project_bounds` | Persist camera changes without requiring the widget to be displayed. |
| `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
240 changes: 218 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,99 @@ 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.

Args:
layer: The layer to rename, by id, name, or handle.
name: The new display name, surrounding whitespace stripped.

Raises:
ValueError: If ``name`` is blank or the reserved basemap pseudo-id.
"""
handle = self._resolve_layer(layer)
clean = self._clean_layer_name(name)
self._update_project(lambda p: _authoring.update_layer(p, handle.id, name=clean))

@staticmethod
def _clean_layer_name(name: str) -> str:
"""Strip a display name and refuse a blank one.

`authoring.update_layer` guards only the reserved basemap pseudo-id, so
emptiness is checked here, matching the `name` setter. A layer named ""
or " " renders as a blank row that cannot be referenced back by name.
"""
clean = str(name).strip()
if not clean:
raise ValueError("name must be a non-empty string")
return clean

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.

The copy is appended to the draw order (drawn on top), the same place a
newly added layer lands, rather than next to its source. Use
:meth:`move_layer` to put it elsewhere.

Args:
layer: The layer to copy, by id, name, or handle.
name: Name for the copy, surrounding whitespace stripped; defaults
to the source name plus ``copy``.

Raises:
ValueError: If ``name`` is blank or the reserved basemap pseudo-id.
"""
if name is not None:
name = self._clean_layer_name(name)
source = copy.deepcopy(self._resolve_layer(layer)._layer())
source["id"] = str(uuid.uuid4())
source["name"] = name if name is not None else f"{source.get('name', 'Layer')} copy"
# `_add_layer` appends raw; `authoring.add_layer` is the entry point that
# applies the reserved-name check `rename_layer` gets from `update_layer`.
self._update_project(lambda p: _authoring.add_layer(p, source))
return str(source["id"])

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."""
# Copy the summary, not the project: `describe_project` hands back the
# live `mapView`, so the result needs detaching, but deep-copying the
# project first would duplicate every inlined GeoJSON blob only to
# report a feature count.
return copy.deepcopy(_authoring.describe_project(self.project))
Comment thread
giswqs marked this conversation as resolved.

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 +1993,20 @@ 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]
Raises:
ValueError: If the reference matches no layer, or matches a display
name several layers share. Removing an unknown layer used to be
a silent no-op; it now reports the miss.
"""

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 +2040,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 +2482,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 +2510,37 @@ 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.

Credentials are swept the way :meth:`Map.to_project` sweeps them: a
notebook auto-displays whatever a cell returns, and a source built with
``request_headers`` or a signed URL would otherwise print its secrets
into an output that often gets committed or shared. Read
:attr:`Map.project` for the record exactly as stored.
"""
return _project.redact_layer(self._layer()).get("source")
Comment thread
giswqs marked this conversation as resolved.

@property
def data(self) -> dict[str, Any]:
"""A detached copy of the complete layer record.

Credentials are swept, as in :attr:`source`.
"""
return _project.redact_layer(self._layer())
Comment thread
giswqs marked this conversation as resolved.

@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 +2553,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
Loading
Loading