"""Base classes for first-class map layers.
A :class:`Layer` is a *description of intent*: it carries its data and
parameters, validates itself, and knows how to draw itself into a map
when asked. It does not touch the SVG at construction time.
"""
from __future__ import annotations
import abc
from typing import Any, Dict, Optional, Type
class _Unset:
"""Sentinel for an unset keyword argument.
Lets a layer expose every option as a named parameter while storing
only the ones the caller actually set. Omitted options stay out of
``params``, so each renderer keeps resolving its own default value
and serialisation round-trips only explicit choices.
"""
__slots__ = ()
def __repr__(self) -> str:
return "<unset>"
_UNSET = _Unset()
def collect_set(**kwargs: Any) -> Dict[str, Any]:
"""Return only the keyword arguments whose value is not ``_UNSET``."""
return {k: v for k, v in kwargs.items() if v is not _UNSET}
[docs]
class LayerStateError(RuntimeError):
"""Raised when a layer is mutated after it has been rendered.
A layer's parameters describe an intent that is consumed at render
time. Once rendered, changing them would silently desynchronise the
object from the SVG already produced, so :meth:`Layer.update` is
refused.
"""
[docs]
class RenderContext:
"""Carries what a :class:`Layer` needs to render itself.
The context is the decoupling point between a layer and the map: a
layer never reaches into ``Map`` directly, it goes through the
context. It transports the host map (whose viewports, CRS,
internal state and font scaling a layer reaches through).
Parameters
----------
map : Map
The host map the layer draws into.
"""
[docs]
def __init__(self, map: "Any") -> None: # noqa: A002 - mirrors public name
self.map = map
# Registry of concrete layer types, keyed by class name, used by
# ``Layer.from_dict`` to reconstruct a layer from its serialised form.
_LAYER_REGISTRY: Dict[str, Type["Layer"]] = {}
def register_layer(cls: Type["Layer"]) -> Type["Layer"]:
"""Register a concrete :class:`Layer` subclass for deserialisation.
Used as a decorator on every concrete layer class so that
:meth:`Layer.from_dict` can map a serialised ``type`` back to its
class.
"""
_LAYER_REGISTRY[cls.__name__] = cls
return cls
[docs]
class Layer(abc.ABC):
"""Abstract description of a cartographic layer.
A layer holds its data (``gdf``) and parameters (``params``) and
draws nothing until :meth:`render` is called. It is validated at
construction and at every :meth:`update`.
Parameters
----------
gdf : GeoDataFrame, optional
The layer data. Required for data layers (``requires_gdf``);
ignored by reference layers such as the basemap.
z_index : int, default 0
Render order. Layers with equal ``z_index`` keep insertion
order.
visible : bool, default True
Whether the layer is drawn.
**params
Layer-specific parameters (see each concrete subclass).
Attributes
----------
id : str or None
Identifier, assigned by the map when the layer is added.
kind : str
Identifier prefix for the layer family (class attribute).
"""
#: Identifier prefix for this layer family.
kind: str = "layer"
#: Whether a non-empty ``gdf`` is required at construction.
requires_gdf: bool = False
[docs]
def __init__(self, gdf: Optional[Any] = None, *, z_index: int = 0,
visible: bool = True, **params: Any) -> None:
if self.requires_gdf and (gdf is None or getattr(gdf, "empty", False)):
raise ValueError(
f"{type(self).__name__} is a data layer and needs a "
f"non-empty GeoDataFrame. None or an empty frame was "
f"given, so there is nothing to map. Pass the data as the "
f"first argument: {type(self).__name__}(gdf, ...)."
)
self.gdf = gdf
self.z_index = z_index
self.visible = visible
self.params: Dict[str, Any] = dict(params)
self.id: Optional[str] = None
self._rendered = False
self.validate()
# -- lifecycle ----------------------------------------------------
[docs]
def validate(self) -> None:
"""Check internal consistency. Override in subclasses.
Raises in plain language (see ``errors.py`` convention) for
structural problems such as a missing required parameter.
Semiological advice is emitted as a warning, never an error.
"""
[docs]
@abc.abstractmethod
def render(self, ctx: RenderContext) -> None:
"""Draw the layer into the map carried by ``ctx``.
Called when the map is built. Implementations must set
``self._rendered = True`` once drawn.
"""
[docs]
def update(self, **kwargs: Any) -> "Layer":
"""Update parameters and re-validate. Returns ``self``.
``z_index`` and ``visible`` are treated as layer metadata; any
other keyword updates :attr:`params`.
Raises
------
LayerStateError
If the layer has already been rendered.
"""
if self._rendered:
raise LayerStateError(
f"{type(self).__name__}(id={self.id!r}) has already been "
f"rendered; its parameters are now fixed. Build a fresh "
f"layer instead of mutating a rendered one."
)
for meta in ("z_index", "visible"):
if meta in kwargs:
setattr(self, meta, kwargs.pop(meta))
self.params.update(kwargs)
self.validate()
return self
# -- serialisation ------------------------------------------------
[docs]
def to_dict(self) -> Dict[str, Any]:
"""Serialise the layer's intent (not its geometry).
The geometry is flagged by ``data['in_memory']``; persisting it
by reference or path is handled by serialisation.
"""
return {
"type": type(self).__name__,
"id": self.id,
"z_index": self.z_index,
"visible": self.visible,
"params": dict(self.params),
"data": {"in_memory": self.gdf is not None},
}
[docs]
@classmethod
def from_dict(cls, data: Dict[str, Any],
gdf: Optional[Any] = None) -> "Layer":
"""Rebuild a layer from :meth:`to_dict` output.
Parameters
----------
data : dict
A serialised layer.
gdf : GeoDataFrame, optional
Geometry to reattach (the serialised form does not carry it).
"""
type_name = data.get("type")
target = _LAYER_REGISTRY.get(type_name)
if target is None:
raise ValueError(
f"Unknown layer type {type_name!r}. Known types: "
f"{sorted(_LAYER_REGISTRY)}. The serialised data may come "
f"from a newer mappyng or a custom layer that is not "
f"imported."
)
obj = target(gdf, z_index=data.get("z_index", 0),
visible=data.get("visible", True),
**dict(data.get("params", {})))
obj.id = data.get("id")
return obj
def __repr__(self) -> str:
return (f"{type(self).__name__}(id={self.id!r}, "
f"z_index={self.z_index}, params={self.params!r})")