Source code for mappyng.layers.base

"""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
import copy
from typing import Any


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}


def _normalize_params(params: dict[str, Any]) -> dict[str, Any]:
    """Convert parameter objects to their plain dict form."""
    from ..border_contrast import BorderContrast
    from ..elements import Legend

    out = dict(params)
    if isinstance(out.get("legend"), Legend):
        out["legend"] = out["legend"].to_dict()
    if isinstance(out.get("border_contrast"), BorderContrast):
        out["border_contrast"] = out["border_contrast"].to_dict()
    return out


[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. """ #: 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: Any | None = 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] = _normalize_params(params) self.id: str | None = None self._rendered = False #: Weak reference to the map the layer was added to, if any. self._owner: Any = None self.validate()
# -- lifecycle ----------------------------------------------------
[docs] def validate(self) -> None: # noqa: B027 - optional hook, not abstract """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(_normalize_params(kwargs)) self.validate() return self
[docs] def copy(self) -> Layer: """Return an unrendered copy that shares the same data. The parameters are copied, the GeoDataFrame is shared. The copy has no ``id`` until it is added to a map. """ new = copy.copy(self) new.params = dict(self.params) new.id = None new._rendered = False new._owner = None if hasattr(new, "report"): new.report = None # a copy has not been drawn yet return new
# -- 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": self.type_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 type_name(cls) -> str: """Name under which the layer type is registered.""" for klass in cls.__mro__: if _LAYER_REGISTRY.get(klass.__name__) is klass: return klass.__name__ return cls.__name__
[docs] @classmethod def from_dict(cls, data: dict[str, Any], gdf: Any | None = 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 type_name is not None else None) if target is None: renamed = "" if (isinstance(type_name, str) and type_name.endswith("Layer") and type_name[:-len("Layer")] in _LAYER_REGISTRY): new_name = type_name[:-len("Layer")] renamed = (f" {type_name!r} was renamed {new_name!r} in " f"mappyng 2.0: edit the 'type' of the layer.") raise ValueError( f"Unknown layer type {type_name!r}.{renamed} 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})")