Source code for sft_wick.render_style

"""Backend-agnostic visual styling for Feynman-diagram rendering.

This module defines a hierarchy of frozen dataclasses that capture
*what* a diagram should look like (colors, line styles, marker
sizes, fonts, layout parameters) without committing to *how* it
gets drawn.  The matplotlib renderer in
:mod:`sft_wick.drawing` and the TikZ/PGF renderer in
:mod:`sft_wick.drawing_tikz` both consume the same
:class:`RenderStyle` and translate it to backend-native settings.

Three named presets are provided:

* :func:`default_style`     — the colourful on-screen look (blue C,
  red R) used for quick inspection in notebooks.
* :func:`publication_style` — a cleaner, smaller-marker look with
  publication-friendly fonts.  Honours ``usetex`` if the user has a
  LaTeX install.
* :func:`grayscale_style`   — black/grey palette for printed papers
  and B&W reproduction.
* :func:`minimal_style`     — strips legend / labels / boxes for
  inset-style use inside a larger figure.

All four call into the same primitives, so users may freely mix
parts (e.g. ``publication_style().with_overrides(show_legend=False)``).

Three label-format flags control the default text of external
vertices (the ``$\\phi_a(\\cdot)$`` operators):

* :data:`LABEL_COMPACT` — ``$\\phi_a$`` (no spatial argument).  Default.
* :data:`LABEL_FULL`    — ``$\\phi_a(x_1)$`` (spatial argument
  retained — the pre-2026-04 default).
* :data:`LABEL_TIME_F`  — ``$\\phi_a(t_f)$`` (substitute ``t_f`` for
  the spatial argument).

Override these on a per-diagram or per-renderer basis with the
``label_format=…`` constructor argument or the ``external_labels``
keyword on :meth:`DiagramRenderer.draw`.
"""

from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass, field, replace
from types import MappingProxyType
from typing import Any


# ----------------------------------------------------------------------
# Label format flags
# ----------------------------------------------------------------------

LABEL_COMPACT: str = "compact"
LABEL_FULL: str = "full"
LABEL_TIME_F: str = "time_f"

LABEL_FORMATS: tuple[str, ...] = (LABEL_COMPACT, LABEL_FULL, LABEL_TIME_F)


# ----------------------------------------------------------------------
# Sub-dataclasses
# ----------------------------------------------------------------------

[docs] @dataclass(frozen=True) class PropagatorStyle: """Visual style for a single propagator kind (``C`` or ``R``). Attributes: color: Stroke colour as a hex string (``"#1f3a5f"``). linestyle: One of ``"solid" | "dashed" | "dotted"``. linewidth: Line thickness (matplotlib points / TikZ ``line width``). arrow: Whether to draw a directed arrowhead (used for R). legend_label: Text shown in the legend; empty string → no entry. """ color: str linestyle: str linewidth: float arrow: bool = False legend_label: str = ""
[docs] @dataclass(frozen=True) class NodeStyle: """Visual style for an external point or interaction vertex. Attributes: shape: ``"circle"`` or ``"square"``. size: Marker size (matplotlib ``markersize`` / TikZ ``minimum size``). fill: Whether the marker is filled. color: Fill colour (or stroke if ``fill=False``). edge_color: Outline colour; ``"none"`` for no outline. """ shape: str size: float fill: bool = True color: str = "black" edge_color: str = "none"
[docs] @dataclass(frozen=True) class LabelStyle: """Visual style for a label rendered next to a node. Attributes: fontsize: Label font size in points. bold: If ``True``, the label is bold. bbox: If ``True``, draw a white rounded box behind the label (matplotlib only; TikZ ignores this and lets the user style via ``every label/.style`` if desired). bbox_alpha: Opacity of that box. offset_pt: Distance from the node to the label centre, in typographic points. """ fontsize: float bold: bool = False bbox: bool = True bbox_alpha: float = 0.85 offset_pt: float = 26.0
[docs] @dataclass(frozen=True) class LayoutParams: """Numerical parameters controlling the position-computation step. These are honoured identically by both backends. Attributes: ext_radius: Radius of the circle on which external nodes are placed. spring_k: Optimal edge length for ``spring_layout``. spring_iterations: Iteration cap for ``spring_layout``. spring_seed: Seed for ``spring_layout`` (None → non-deterministic). min_vertex_dist: Minimum allowed Euclidean distance between interaction vertices; vertices closer than this are pushed apart in a post-pass. margin: Padding added to axis limits around the diagram bounding box (matplotlib only). component_gap: Minimum horizontal gap between disconnected connected components before bbox normalisation. Helps factorised diagrams remain visually separated. normalize_bbox: If ``True`` (default), the final layout is re-centred on the origin and uniformly scaled so it fits within :attr:`target_extent` without distortion. Gives every diagram in a grid roughly the same visual extent — recommended whenever drawing multiple diagrams side by side. target_extent: Target ``(width, height)`` for the normalised bounding box, in matplotlib units. Default ``(6.0, 4.0)`` matches a 4:3 ish aspect-ratio panel. parallel_edge_curvature: Bow strength for parallel edges between the same node pair (matplotlib backend). Each edge in a bundle is drawn as an ``arc3`` with ``rad = (key - (n-1)/2) * parallel_edge_curvature``, so a 2-edge bundle bows by ``±0.5 ×`` this value to opposite sides. Larger → fuller arcs. Default ``0.6`` (apex offset ≈ 30 % of the chord for a 2-edge bundle). """ ext_radius: float = 2.5 spring_k: float = 2.0 spring_iterations: int = 200 spring_seed: int | None = 42 min_vertex_dist: float = 0.8 margin: float = 0.75 component_gap: float = 1.0 normalize_bbox: bool = True target_extent: tuple[float, float] = (5.6, 3.6) parallel_edge_curvature: float = 0.6
# ---------------------------------------------------------------------- # Top-level style # ----------------------------------------------------------------------
[docs] @dataclass(frozen=True) class RenderStyle: """Complete visual specification for a Feynman-diagram render. Both the matplotlib (:class:`sft_wick.drawing.DiagramRenderer`) and the TikZ (:class:`sft_wick.drawing_tikz.TikzRenderer`) backends consume an instance of this class and translate it to their native style options. Use :func:`default_style`, :func:`publication_style`, :func:`grayscale_style`, or :func:`minimal_style` as a starting point and call :meth:`with_overrides` to customise. Attributes: propagators: Mapping ``{"C": PropagatorStyle, "R": PropagatorStyle}``. external_node: Style for external observable nodes. vertex_node: Style for interaction vertices. external_label: Style for labels next to external nodes. vertex_label: Style for labels next to interaction vertices (typically the coupling name). title_fontsize: Font size of an axes title. suptitle_fontsize: Font size of the figure-level suptitle. legend_fontsize: Font size of the propagator-kind legend. legend_loc: Matplotlib legend location string. show_legend: Master toggle for the legend. layout: Numerical layout parameters. rcparams: Optional mapping fed into a ``matplotlib.rc_context`` around the draw call. Useful for serif fonts, math rendering, etc. Ignored by TikZ. mathtext: If ``True``, labels are wrapped in ``$…$`` so matplotlib renders them as math. usetex: Convenience flag. When ``True``, sets ``"text.usetex": True`` in ``rcparams`` (overriding any prior value) — requires a working LaTeX install on the user's system. label_format: Default external-label format (``LABEL_COMPACT`` etc.). May be overridden per-call. """ propagators: Mapping[str, PropagatorStyle] external_node: NodeStyle vertex_node: NodeStyle external_label: LabelStyle vertex_label: LabelStyle title_fontsize: float = 12.0 suptitle_fontsize: float = 14.0 legend_fontsize: float = 12.0 legend_loc: str = "lower right" show_legend: bool = True layout: LayoutParams = field(default_factory=LayoutParams) rcparams: Mapping[str, Any] | None = None mathtext: bool = True usetex: bool = False label_format: str = LABEL_COMPACT def __post_init__(self) -> None: if self.label_format not in LABEL_FORMATS: raise ValueError( f"label_format must be one of {LABEL_FORMATS}, got " f"{self.label_format!r}." ) # Freeze the propagator mapping so callers can rely on # immutability of the whole object. if not isinstance(self.propagators, MappingProxyType): object.__setattr__( self, "propagators", MappingProxyType(dict(self.propagators)), ) if self.rcparams is not None and not isinstance( self.rcparams, MappingProxyType): object.__setattr__( self, "rcparams", MappingProxyType(dict(self.rcparams)), ) # ------------------------------------------------------------------ # Convenience: produce a modified copy # ------------------------------------------------------------------
[docs] def with_overrides(self, **kwargs: Any) -> "RenderStyle": """Return a new ``RenderStyle`` with the given top-level fields replaced. Nested fields (``propagators["C"].color``) need :meth:`with_propagator` or manual construction. """ return replace(self, **kwargs)
[docs] def with_propagator(self, kind: str, **prop_kwargs: Any) -> "RenderStyle": """Return a new ``RenderStyle`` with one propagator's style partially replaced. Example: .. code-block:: python style = publication_style().with_propagator("C", color="black") """ if kind not in self.propagators: raise KeyError( f"No propagator kind {kind!r} in style " f"(known: {sorted(self.propagators)})." ) new_prop = replace(self.propagators[kind], **prop_kwargs) new_props = dict(self.propagators) new_props[kind] = new_prop return replace(self, propagators=new_props)
[docs] def effective_rcparams(self) -> dict[str, Any]: """Resolve ``rcparams`` + ``usetex`` into a single dict. Used by the matplotlib backend to feed ``rc_context``. """ rc: dict[str, Any] = dict(self.rcparams) if self.rcparams else {} if self.usetex: rc["text.usetex"] = True return rc
# ---------------------------------------------------------------------- # Presets # ---------------------------------------------------------------------- def _default_propagators() -> dict[str, PropagatorStyle]: return { "C": PropagatorStyle( color="#2166ac", linestyle="solid", linewidth=2.0, arrow=False, legend_label="C (correlation)", ), "R": PropagatorStyle( color="#d6604d", linestyle="dashed", linewidth=2.0, arrow=True, legend_label="R (response)", ), }
[docs] def default_style() -> RenderStyle: """The colourful on-screen preset (blue C, red R, square vertices). Matches the pre-2026-04 visual defaults — use this as the backward-compatible baseline. """ return RenderStyle( propagators=_default_propagators(), external_node=NodeStyle(shape="circle", size=8.0), vertex_node=NodeStyle(shape="square", size=10.0), external_label=LabelStyle(fontsize=12.0, bold=False, offset_pt=24.0), vertex_label=LabelStyle(fontsize=12.0, bold=True, offset_pt=18.0), title_fontsize=12.0, suptitle_fontsize=14.0, legend_fontsize=12.0, show_legend=True, layout=LayoutParams(), label_format=LABEL_COMPACT, )
[docs] def publication_style(*, usetex: bool = False) -> RenderStyle: """Cleaner preset for paper-quality figures. Smaller, thinner markers, refined palette (slate-blue C, warm-grey R), serif labels via mathtext or LaTeX, compact (no boxed) labels, and a discreet legend. Pass ``usetex=True`` if you have a working LaTeX install — otherwise mathtext renders the math. """ rc = { "font.family": "serif", "font.serif": ["Computer Modern Roman", "Times", "DejaVu Serif"], "mathtext.fontset": "cm", "mathtext.rm": "serif", "axes.titleweight": "regular", } return RenderStyle( propagators={ "C": PropagatorStyle( color="#1f3a5f", linestyle="solid", linewidth=1.2, arrow=False, legend_label=r"$C$", ), "R": PropagatorStyle( color="#666666", linestyle="dashed", linewidth=1.2, arrow=True, legend_label=r"$R$", ), }, external_node=NodeStyle(shape="circle", size=6.0), vertex_node=NodeStyle(shape="square", size=7.0), external_label=LabelStyle( fontsize=12.0, bold=False, bbox=False, offset_pt=22.0, ), vertex_label=LabelStyle( fontsize=12.0, bold=False, bbox=False, offset_pt=18.0, ), title_fontsize=12.0, suptitle_fontsize=13.0, legend_fontsize=11.0, legend_loc="lower right", show_legend=True, layout=LayoutParams(), rcparams=rc, usetex=usetex, label_format=LABEL_COMPACT, )
[docs] def grayscale_style() -> RenderStyle: """Black-and-white preset. Distinguishes propagator kinds by line style and arrow-head only; no colour information. Suitable for printed papers and B&W reproduction. """ return RenderStyle( propagators={ "C": PropagatorStyle( color="black", linestyle="solid", linewidth=1.4, arrow=False, legend_label=r"$C$", ), "R": PropagatorStyle( color="black", linestyle="dashed", linewidth=1.4, arrow=True, legend_label=r"$R$", ), }, external_node=NodeStyle(shape="circle", size=6.5), vertex_node=NodeStyle(shape="square", size=7.5), external_label=LabelStyle( fontsize=11.0, bold=False, bbox=False, offset_pt=22.0, ), vertex_label=LabelStyle( fontsize=11.0, bold=False, bbox=False, offset_pt=18.0, ), title_fontsize=11.0, suptitle_fontsize=13.0, legend_fontsize=10.5, show_legend=True, layout=LayoutParams(), label_format=LABEL_COMPACT, )
[docs] def minimal_style() -> RenderStyle: """Stripped-down preset for inset use. No legend, no boxes behind labels, no per-axes title. Good when embedding diagrams as small panels inside a larger composite figure. """ style = publication_style() return style.with_overrides( show_legend=False, external_label=replace(style.external_label, bbox=False, fontsize=9.0), vertex_label=replace(style.vertex_label, bbox=False, fontsize=8.5), title_fontsize=0.0, # backend may treat 0 as "skip" )