Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
37 changes: 36 additions & 1 deletion python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,44 @@ 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
roads = m.find_layer("Roads")
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")
Comment thread
giswqs marked this conversation as resolved.
Outdated
```

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",
"basemap_catalog",
"builtin_legend_names",
"color_ramp_names",
"describe_project",
"load_project",
"save_project",
"__version__",
]
Comment thread
giswqs marked this conversation as resolved.


def _jupyter_server_extension_points() -> list[dict[str, str]]:
Expand Down
141 changes: 133 additions & 8 deletions python/src/geolibre/geolibre.py
Original file line number Diff line number Diff line change
Expand Up @@ -976,6 +976,54 @@ 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 follow normal Python insertion semantics: ``-1`` moves
the layer to the end. 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."""
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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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
coderabbitai[bot] marked this conversation as resolved.
Outdated
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 +1949,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 @@ -1956,6 +2002,54 @@ def mutate(p: dict[str, Any]) -> None:
# 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 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
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 +2427,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 +2455,21 @@ 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."""
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 +2482,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
71 changes: 71 additions & 0 deletions python/tests/test_scripting.py
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,77 @@ def test_layer_remove(m):
assert m.project["layers"] == []


def test_map_layer_management_and_introspection(m):
first = m.add_geojson(
{
"type": "FeatureCollection",
"features": [
{"type": "Feature", "geometry": None, "properties": {"kind": "a", "value": 2}},
{"type": "Feature", "geometry": None, "properties": {"kind": "b", "value": 3}},
],
},
name="First",
)
second = m.add_geojson({"type": "FeatureCollection", "features": []}, name="Second")

m.rename_layer(first, "Renamed")
m.hide_layer("Renamed")
assert m.get_layer(first).name == "Renamed"
assert m.get_layer(first).visible is False
assert m.layer_properties(first)["kind"] == ["a", "b"]
assert m.column_values(first, "value") == [2, 3]

m.move_layer(second, 0)
assert [layer.id for layer in m.layers] == [second, first]
copy_id = m.duplicate_layer(first, name="Clone")
assert m.get_layer(copy_id).name == "Clone"
assert m.get_layer(copy_id).data["geojson"] == m.get_layer(first).data["geojson"]
assert m.describe()["layerCount"] == 3

m.remove_layer("Clone")
assert m.find_layer("Clone") is None


def test_layer_handle_expanded_helpers(m):
layer_id = m.add_geojson(
{
"type": "FeatureCollection",
"features": [{"type": "Feature", "geometry": None, "properties": {"x": 1}}],
},
name="Data",
)
layer = m.get_layer(layer_id)
assert layer.index == 0
assert layer.source == {"type": "geojson"}
assert layer.properties() == {"x": [1]}
assert layer.column("x") == [1]
duplicate = layer.duplicate()
assert duplicate.name == "Data copy"
duplicate.move(0)
assert duplicate.index == 0


def test_persisted_camera_and_project_metadata_helpers(m):
m.name = "My analysis"
m.set_center(-80, 35, zoom=4)
m.set_zoom(6)
m.set_bearing(370)
m.set_pitch(100)
assert m.name == "My analysis"
assert m.center == (-80.0, 35.0)
assert m.zoom == 6
assert m.project["mapView"]["bearing"] == 370
assert m.project["mapView"]["pitch"] == 85
with pytest.raises(ValueError, match="non-empty"):
m.name = " "


def test_fit_project_bounds_is_browser_independent(m):
m.fit_project_bounds([-10, -5, 10, 5])
assert m.center == (0.0, 0.0)
assert m.zoom > 0


def test_layer_zoom_to_sends_command(m, monkeypatch):
captured = {}
monkeypatch.setattr(
Expand Down
Loading