Source code for mappyng.effects

"""
SVG visual effects for mappyng.

Reusable building blocks for decorative effects on map elements:
- Rounded corners (border-radius on clip paths and borders)
- Box shadows (drop-shadow filter on viewports/groups)
- Glow effects
- Gradient fills

All defaults are imported from :mod:`mappyng.defaults`.
"""

from __future__ import annotations

import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from typing import Any, Dict, Optional, Union

from . import defaults as D


# ============================================================================
# Effect configuration
# ============================================================================

[docs] @dataclass class BoxShadowConfig: """Drop-shadow configuration for a viewport or element. Can be created from a dict, a bool (``True`` for defaults), or directly. Attributes ---------- dx : float Horizontal shadow offset in SVG px. dy : float Vertical shadow offset in SVG px. blur : float Shadow blur radius in SVG px. color : str Shadow color. opacity : float Shadow opacity 0-1. """ dx: float = D.BOX_SHADOW_DX dy: float = D.BOX_SHADOW_DY blur: float = D.BOX_SHADOW_BLUR color: str = D.BOX_SHADOW_COLOR opacity: float = D.BOX_SHADOW_OPACITY
[docs] @classmethod def from_param(cls, value: Union[bool, Dict[str, Any], "BoxShadowConfig", None] ) -> Optional["BoxShadowConfig"]: """Parse user input into a BoxShadowConfig or None. - ``None`` / ``False``: None (no shadow) - ``True``: default shadow - ``dict``: shadow with overrides - ``BoxShadowConfig``: passed through """ if value is None or value is False: return None if value is True: return cls() if isinstance(value, cls): return value if isinstance(value, dict): return cls(**value) raise TypeError(f"Expected bool, dict, or BoxShadowConfig, got {type(value)}")
[docs] @dataclass class GlowConfig: """Outer glow configuration for a viewport or element. Attributes ---------- blur : float Glow blur radius in SVG px. color : str Glow color. opacity : float Glow opacity 0-1. """ blur: float = D.GLOW_BLUR color: str = D.GLOW_COLOR opacity: float = D.GLOW_OPACITY
[docs] @classmethod def from_param(cls, value: Union[bool, Dict[str, Any], "GlowConfig", None] ) -> Optional["GlowConfig"]: if value is None or value is False: return None if value is True: return cls() if isinstance(value, cls): return value if isinstance(value, dict): return cls(**value) raise TypeError(f"Expected bool, dict, or GlowConfig, got {type(value)}")
# ============================================================================ # SVG filter builders # ============================================================================ def build_box_shadow_filter( defs: ET.Element, filter_id: str, config: BoxShadowConfig, ) -> None: """Create a box-shadow ``<filter>`` in *defs*. The filter renders a colored, blurred, offset shadow *behind* the source graphic (using ``feMerge``). Parameters ---------- defs : ET.Element Document ``<defs>`` to append the filter to. filter_id : str Unique filter id (e.g. ``"box-shadow-cartouche_0"``). config : BoxShadowConfig Shadow parameters. """ margin = D.BOX_SHADOW_FILTER_MARGIN filt = ET.SubElement(defs, "filter", id=filter_id) filt.set("x", f"-{margin}%") filt.set("y", f"-{margin}%") filt.set("width", f"{100 + 2 * margin}%") filt.set("height", f"{100 + 2 * margin}%") # 1. Blur source alpha blur = ET.SubElement(filt, "feGaussianBlur") blur.set("in", "SourceAlpha") blur.set("stdDeviation", str(config.blur)) blur.set("result", "blur") # 2. Offset offset = ET.SubElement(filt, "feOffset") offset.set("in", "blur") offset.set("dx", str(config.dx)) offset.set("dy", str(config.dy)) offset.set("result", "offsetBlur") # 3. Flood with color + opacity flood = ET.SubElement(filt, "feFlood") flood.set("flood-color", config.color) flood.set("flood-opacity", str(config.opacity)) flood.set("result", "shadowColor") # 4. Clip flood to blurred shape comp = ET.SubElement(filt, "feComposite") comp.set("in", "shadowColor") comp.set("in2", "offsetBlur") comp.set("operator", "in") comp.set("result", "shadow") # 5. Merge: shadow first, then source on top merge = ET.SubElement(filt, "feMerge") ET.SubElement(merge, "feMergeNode").set("in", "shadow") ET.SubElement(merge, "feMergeNode").set("in", "SourceGraphic") def build_glow_filter( defs: ET.Element, filter_id: str, config: GlowConfig, ) -> None: """Create an outer-glow ``<filter>`` in *defs*. Parameters ---------- defs : ET.Element Document ``<defs>``. filter_id : str Unique filter id. config : GlowConfig Glow parameters. """ margin = D.GLOW_FILTER_MARGIN filt = ET.SubElement(defs, "filter", id=filter_id) filt.set("x", f"-{margin}%") filt.set("y", f"-{margin}%") filt.set("width", f"{100 + 2 * margin}%") filt.set("height", f"{100 + 2 * margin}%") # Blur the source alpha blur = ET.SubElement(filt, "feGaussianBlur") blur.set("in", "SourceAlpha") blur.set("stdDeviation", str(config.blur)) blur.set("result", "blur") # Flood with glow color flood = ET.SubElement(filt, "feFlood") flood.set("flood-color", config.color) flood.set("flood-opacity", str(config.opacity)) flood.set("result", "glowColor") # Clip to blurred shape comp = ET.SubElement(filt, "feComposite") comp.set("in", "glowColor") comp.set("in2", "blur") comp.set("operator", "in") comp.set("result", "glow") # Merge: glow behind, then source merge = ET.SubElement(filt, "feMerge") ET.SubElement(merge, "feMergeNode").set("in", "glow") ET.SubElement(merge, "feMergeNode").set("in", "SourceGraphic")