Source code for process_improve.visualization.spec

"""Backend-agnostic chart specification (ChartSpec IR).

The intermediate representation captures *what* to draw - data, visual
encodings, annotations - while backend adapters handle *how* to draw it.
Inspired by Vega-Lite's declarative approach, with DOE-specific
primitives (significance thresholds, constraint regions) as first-class
concepts.

Dataclasses are used over Pydantic to keep this layer dependency-free
and fast to construct.  All fields are JSON-serialisable via
:meth:`ChartSpec.to_dict`.
"""

from __future__ import annotations

from dataclasses import asdict, dataclass, field
from enum import Enum
from typing import Any

from process_improve.visualization.types import (
    AnnotationType,
    MarkType,
    ScaleType,
)

# ---------------------------------------------------------------------------
# Encoding: maps a data field to a visual channel
# ---------------------------------------------------------------------------


[docs] @dataclass class Encoding: """Map a data field to a visual channel (x, y, colour, size, …). Parameters ---------- field : str Column name in the layer's row-oriented data. title : str Human-readable axis or legend label. scale : ScaleType Axis scale (linear, log, category). domain : tuple[float, float] or None Explicit axis min/max override. format_str : str Number format (e.g. ``".2f"``). """ field: str title: str = "" scale: ScaleType = ScaleType.linear domain: tuple[float, float] | None = None format_str: str = ""
# --------------------------------------------------------------------------- # LayerSpec: one visual layer (trace) in a chart # ---------------------------------------------------------------------------
[docs] @dataclass class LayerSpec: """A single visual layer - one trace in Plotly, one series in ECharts. Parameters ---------- mark : MarkType Visual mark type (bar, line, scatter, contour, …). data : list[dict] Row-oriented records for this layer. x : Encoding or None Horizontal-axis encoding. y : Encoding or None Vertical-axis encoding. z : Encoding or None Depth / colour-intensity encoding (contour, surface, heatmap). color : str or None Literal CSS colour string, or field name for colour encoding. name : str Trace / series name for the legend. opacity : float Layer opacity (0–1). style : dict Catch-all for extra visual properties (line dash, marker size, bar width, …). """ # noqa: RUF002 mark: MarkType data: list[dict[str, Any]] x: Encoding | None = None y: Encoding | None = None z: Encoding | None = None color: str | None = None name: str = "" opacity: float = 1.0 style: dict[str, Any] = field(default_factory=dict)
# --------------------------------------------------------------------------- # Annotations: reference lines, thresholds, regions # ---------------------------------------------------------------------------
[docs] @dataclass class Annotation: """An overlay annotation on a chart panel. Parameters ---------- annotation_type : AnnotationType What kind of annotation this is. axis : str ``"x"`` or ``"y"`` - which axis the annotation references. value : float or None Position for reference lines / thresholds. value_end : float or None End position for bands / regions. label : str Annotation text label. style : dict Visual overrides (color, dash, width, …). """ annotation_type: AnnotationType axis: str = "y" value: float | None = None value_end: float | None = None label: str = "" style: dict[str, Any] = field(default_factory=dict)
[docs] def significance_threshold( value: float, *, alpha: float = 0.05, label: str | None = None, name: str = "ME", ) -> Annotation: """Create a DOE significance-threshold annotation. Used on Pareto and half-normal plots to indicate the margin of error (ME) or simultaneous margin of error (SME) from Lenth's method. Parameters ---------- value : float Threshold position on the y-axis. alpha : float Significance level (for the label). label : str or None Override label (default: ``"ME (α=0.05)"``). name : str Threshold name (``"ME"`` or ``"SME"``). Returns ------- Annotation """ # noqa: RUF002 return Annotation( annotation_type=AnnotationType.significance_threshold, axis="y", value=value, label=label or f"{name} (α={alpha})", # noqa: RUF001 style={"color": "#F59E0B", "dash": "dash", "width": 2}, )
[docs] def constraint_region( *, x_min: float | None = None, x_max: float | None = None, y_min: float | None = None, y_max: float | None = None, label: str = "Infeasible", ) -> Annotation: """Create a DOE constraint-region annotation. Used on contour and overlay plots to shade infeasible areas. Parameters ---------- x_min, x_max, y_min, y_max : float or None Region boundaries. label : str Region label. Returns ------- Annotation """ return Annotation( annotation_type=AnnotationType.constraint_region, axis="xy", label=label, style={ "x_min": x_min, "x_max": x_max, "y_min": y_min, "y_max": y_max, "color": "rgba(220, 38, 38, 0.15)", }, )
# --------------------------------------------------------------------------- # PanelSpec: one chart panel in a layout # ---------------------------------------------------------------------------
[docs] @dataclass class PanelSpec: """A single chart panel - becomes one Plotly subplot or ECharts grid. Parameters ---------- layers : list[LayerSpec] Visual layers drawn in this panel. annotations : list[Annotation] Overlaid annotations. title : str Panel title. x_title : str Horizontal-axis label. y_title : str Vertical-axis label. z_title : str Depth-axis label (3D plots only). secondary_y : bool Whether a secondary y-axis is needed. secondary_y_title : str Label for the secondary y-axis. width : int Panel width in pixels. height : int Panel height in pixels. backend_hints : dict Escape hatch for backend-specific options. """ layers: list[LayerSpec] = field(default_factory=list) annotations: list[Annotation] = field(default_factory=list) title: str = "" x_title: str = "" y_title: str = "" z_title: str = "" secondary_y: bool = False secondary_y_title: str = "" width: int = 700 height: int = 500 backend_hints: dict[str, Any] = field(default_factory=dict)
# --------------------------------------------------------------------------- # ChartSpec: the top-level specification (may contain multiple panels) # ---------------------------------------------------------------------------
[docs] @dataclass class ChartSpec: """Top-level chart specification - the IR that adapters consume. A ``ChartSpec`` may contain one or more :class:`PanelSpec` panels. Single-panel charts (Pareto, contour, …) have ``len(panels) == 1``. Multi-panel charts (diagnostic trio, linked contours) have 2–4 panels with a grid layout. Parameters ---------- panels : list[PanelSpec] One or more chart panels. title : str Overall chart / dashboard title. plot_type : str DOE plot type name (for metadata). layout : str ``"single"``, ``"row"``, ``"column"``, or ``"grid"``. columns : int Number of columns for grid layout. linked : bool Whether panels share brush / zoom interactions. metadata : dict Extra metadata passed through to the output. link_group : str or None Cross-chart linking key. Charts that share this key form a brushing group: a selection in any member highlights the matching ``point_ids`` in the others. Frontends read this from the rendered option dict (``option["__link_group"]`` in ECharts). point_ids : list[str] or None Stable per-observation identifiers aligned with the points emitted by this chart. Paired with *link_group* by the frontend link coordinator. ``None`` means the chart is not linkable. """ # noqa: RUF002 panels: list[PanelSpec] = field(default_factory=list) title: str = "" plot_type: str = "" layout: str = "single" columns: int = 2 linked: bool = False metadata: dict[str, Any] = field(default_factory=dict) link_group: str | None = None point_ids: list[str] | None = None # -- helpers -----------------------------------------------------------
[docs] def to_dict(self) -> dict[str, Any]: """Serialise the entire spec to a plain dict (JSON-safe). Enum values are converted to their string representations. """ raw = asdict(self) return _clean_enums(raw)
[docs] def to_data_dict(self) -> dict[str, Any]: """Extract computed data arrays from the spec. Returns a lightweight dict containing just the raw data from each panel's layers - useful when the consumer wants to render with a custom frontend. """ panels_data: list[dict[str, Any]] = [] for panel in self.panels: layers_data = [] for layer in panel.layers: layers_data.append({ # noqa: PERF401 "mark": layer.mark.value if isinstance(layer.mark, MarkType) else layer.mark, "data": layer.data, "name": layer.name, }) panels_data.append({ "title": panel.title, "layers": layers_data, }) return { "plot_type": self.plot_type, "title": self.title, "panels": panels_data, }
# --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _clean_enums(obj: Any) -> Any: # noqa: ANN401 """Recursively convert Enum values to their ``.value`` string.""" if isinstance(obj, dict): return {k: _clean_enums(v) for k, v in obj.items()} if isinstance(obj, list): return [_clean_enums(v) for v in obj] if isinstance(obj, Enum): return obj.value return obj