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
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})")