# -*- coding: ascii -*-
"""
Utility functions for visualizers module.
This module provides helper functions for common visualizers tasks,
including backend-agnostic style translation and figure manipulation.
"""
__author__ = "Danil Totmyanin"
__copyright__ = "Copyright (c) 2026 PySATL project"
__license__ = "SPDX-License-Identifier: MIT"
from typing import Any, TypeGuard
import matplotlib as mpl
import numpy as np
import plotly.graph_objects as go
from pysatl_cpd.analysis.visualization.specs import FilledLineSpec, FillSpec, LineSpec, PlotSpec
from pysatl_cpd.analysis.visualization.typedefs import (
Axes,
AxMapping,
DrawBackend,
Figure,
GoAxes,
GoAxMapping,
GoFigure,
PltAxes,
PltAxMapping,
PltFigure,
)
PLOTLY_TO_MPL_LINESTYLE = {
"solid": "-",
"dash": "--",
"dot": ":",
"dashdot": "-.",
"longdash": "--",
"longdashdot": "-.",
}
MPL_TO_PLOTLY_DASH = {
"-": "solid",
"--": "dash",
":": "dot",
"-.": "dashdot",
}
TAB_COLOR_TO_HEX = {
"tab:blue": "#1f77b4",
"tab:orange": "#ff7f0e",
"tab:green": "#2ca02c",
"tab:red": "#d62728",
"tab:purple": "#9467bd",
"tab:brown": "#8c564b",
"tab:pink": "#e377c2",
"tab:gray": "#7f7f7f",
"tab:olive": "#bcbd22",
"tab:cyan": "#17becf",
}
MPL_TO_PLOTLY_MARKER = {
".": "circle",
",": "circle",
"o": "circle",
"s": "square",
"^": "triangle-up",
"v": "triangle-down",
"<": "triangle-left",
">": "triangle-right",
"d": "diamond",
"D": "diamond",
"x": "x",
"+": "cross",
"*": "star",
"p": "pentagon",
"h": "hexagon",
"H": "hexagon",
}
[docs]
def normalize_backend(value: DrawBackend | str) -> DrawBackend:
"""Normalise a backend enum or string to ``DrawBackend``.
Parameters
----------
value
Backend as enum or string.
Returns
-------
DrawBackend
Raises
------
ValueError
If the string does not match any known backend.
"""
if isinstance(value, DrawBackend):
return value
try:
return DrawBackend(value)
except ValueError as exc:
raise ValueError(f"Unknown backend {value}") from exc
# Figure TypeGuards
# Axes TypeGuards
[docs]
def is_plotly_axes(axes: Axes) -> TypeGuard[GoAxes]:
"""Check whether axes is a Plotly subplot position tuple.
Parameters
----------
axes
Axes to check.
Returns
-------
TypeGuard[GoAxes]
"""
return isinstance(axes, tuple) and len(axes) == 2 and isinstance(axes[0], int) and isinstance(axes[1], int)
[docs]
def is_matplotlib_axes(axes: Axes) -> TypeGuard[PltAxes]:
"""Check whether axes is a Matplotlib Axes object.
Parameters
----------
axes
Axes to check.
Returns
-------
TypeGuard[PltAxes]
"""
return isinstance(axes, mpl.axes.Axes)
# AxMapping TypeGuards
[docs]
def is_plotly_mapping(mapping: AxMapping) -> TypeGuard[GoAxMapping]:
"""Check whether mapping contains Plotly subplot positions.
Parameters
----------
mapping
Axes mapping to check.
Returns
-------
TypeGuard[GoAxMapping]
"""
return all(is_plotly_axes(v) for v in mapping.values())
[docs]
def is_matplotlib_mapping(mapping: AxMapping) -> TypeGuard[PltAxMapping]:
"""Check whether mapping contains Matplotlib Axes objects.
Parameters
----------
mapping
Axes mapping to check.
Returns
-------
TypeGuard[PltAxMapping]
"""
return all(is_matplotlib_axes(v) for v in mapping.values())
[docs]
def get_plotly_subplot_annotation_index(figure: go.Figure, row: int, col: int) -> int:
"""
Get the annotation index for a subplot in a Plotly figure.
This function calculates the correct annotation index for subplot titles
in Plotly figures with arbitrary grid layouts using row-major ordering.
Parameters
----------
figure
The Plotly figure containing subplots
row
Subplot row position (1-indexed)
col
Subplot column position (1-indexed)
Returns
-------
int
Annotation index in figure.layout.annotations for the subplot title
Examples
--------
>>> import plotly.graph_objects as go
>>> fig = go.Figure()
>>> fig = make_subplots(rows=2, cols=2)
>>> # Top-left subplot (1,1) -> index 0
>>> get_plotly_subplot_annotation_index(fig, 1, 1)
0
>>> # Bottom-right subplot (2,2) -> index 3
>>> get_plotly_subplot_annotation_index(fig, 2, 2)
3
"""
rows, cols = figure._get_subplot_rows_columns()
return (row - 1) * max(cols) + (col - 1) # type: ignore[no-any-return]
[docs]
def translate_linestyle(linestyle: str) -> str:
"""
Translate linestyle strings from Plotly to Matplotlib format.
Parameters
----------
linestyle
Linestyle string in source format (Plotly).
Returns
-------
str
Linestyle string in target format.
Raises
------
ValueError
If linestyle is not recognized.
Examples
--------
>>> translate_linestyle("dash")
'--'
>>> translate_linestyle("dashdot")
'-.'
"""
if linestyle in PLOTLY_TO_MPL_LINESTYLE:
return PLOTLY_TO_MPL_LINESTYLE[linestyle]
if linestyle in MPL_TO_PLOTLY_DASH:
return linestyle
raise ValueError(f"Unrecognized linestyle: {linestyle}")
[docs]
def to_plotly_color(color: str | None) -> str | None:
"""Convert a Matplotlib-style color to a Plotly-compatible color."""
if color is None:
return None
return TAB_COLOR_TO_HEX.get(color, color)
[docs]
def to_plotly_dash(linestyle: str | None) -> str | None:
"""Convert a Matplotlib linestyle to Plotly dash style."""
if linestyle is None:
return None
return MPL_TO_PLOTLY_DASH.get(linestyle, linestyle)
[docs]
def to_plotly_marker(marker: str | None) -> str | None:
"""Convert a Matplotlib marker to a Plotly marker symbol."""
if marker is None:
return None
return MPL_TO_PLOTLY_MARKER.get(marker, marker)
[docs]
def resolve_legend_visibility(spec: LineSpec | FillSpec | FilledLineSpec, *, allow_legend: bool = True) -> bool:
"""Return whether a visual element should participate in the legend."""
return allow_legend and spec.get("legend", True)
[docs]
def resolve_legend_label(spec: LineSpec | FillSpec | FilledLineSpec) -> str | None:
"""Return the effective legend label for a visual element."""
return spec.get("label")
[docs]
def resolve_legend_group(spec: LineSpec | FillSpec | FilledLineSpec) -> str | None:
"""Return the effective Plotly legend group for a visual element."""
return spec.get("legend_group") or resolve_legend_label(spec)
[docs]
def get_matplotlib_legend_label(
spec: LineSpec | FillSpec | FilledLineSpec,
*,
allow_legend: bool = True,
) -> str | None:
"""Return the label to pass to Matplotlib, or ``None``."""
if not resolve_legend_visibility(spec, allow_legend=allow_legend):
return None
return resolve_legend_label(spec)
[docs]
def get_plotly_legend_kwargs(
spec: LineSpec | FillSpec | FilledLineSpec,
*,
allow_legend: bool = True,
primary_trace: bool = True,
) -> dict[str, Any]:
"""Return Plotly legend kwargs for a logical visual element."""
label = resolve_legend_label(spec)
legend_group = resolve_legend_group(spec)
show_legend = resolve_legend_visibility(spec, allow_legend=allow_legend) and primary_trace and label is not None
kwargs: dict[str, Any] = {"showlegend": show_legend}
if label is not None:
kwargs["name"] = label
if legend_group is not None:
kwargs["legendgroup"] = legend_group
return kwargs
[docs]
def line_spec_to_mpl_kwargs(spec: LineSpec) -> dict[str, Any]:
"""Convert a shared line spec to Matplotlib line kwargs."""
kwargs: dict[str, Any] = {}
if "color" in spec:
kwargs["color"] = spec["color"]
if "linewidth" in spec:
kwargs["linewidth"] = spec["linewidth"]
if "alpha" in spec:
kwargs["alpha"] = spec["alpha"]
if "linestyle" in spec:
kwargs["linestyle"] = translate_linestyle(spec["linestyle"])
if "marker" in spec:
kwargs["marker"] = spec["marker"]
if "markersize" in spec:
kwargs["markersize"] = spec["markersize"]
return kwargs
[docs]
def line_spec_to_plotly_trace_kwargs(spec: LineSpec) -> dict[str, Any]:
"""Convert a shared line spec to Plotly trace kwargs."""
line: dict[str, Any] = {}
marker: dict[str, Any] = {}
kwargs: dict[str, Any] = {}
color = to_plotly_color(spec.get("color"))
if color is not None:
line["color"] = color
marker["color"] = color
if "linewidth" in spec:
line["width"] = spec["linewidth"]
dash = to_plotly_dash(spec.get("linestyle"))
if dash is not None:
line["dash"] = dash
plotly_marker = to_plotly_marker(spec.get("marker"))
if plotly_marker is not None:
marker["symbol"] = plotly_marker
if "markersize" in spec:
marker["size"] = spec["markersize"]
if "alpha" in spec:
kwargs["opacity"] = spec["alpha"]
if line:
kwargs["line"] = line
if marker:
kwargs["marker"] = marker
return kwargs
[docs]
def fill_spec_to_mpl_kwargs(spec: FillSpec) -> dict[str, Any]:
"""Convert a shared fill spec to Matplotlib fill kwargs."""
kwargs: dict[str, Any] = {}
if "fill_color" in spec:
kwargs["color"] = spec["fill_color"]
if "fill_alpha" in spec:
kwargs["alpha"] = spec["fill_alpha"]
if "edge_color" in spec:
kwargs["edgecolor"] = spec["edge_color"]
if "edge_linewidth" in spec:
kwargs["linewidth"] = spec["edge_linewidth"]
return kwargs
[docs]
def fill_spec_to_plotly_trace_kwargs(spec: FillSpec) -> dict[str, Any]:
"""Convert a shared fill spec to Plotly trace kwargs."""
kwargs: dict[str, Any] = {"fill": "toself"}
if "fill_color" in spec:
kwargs["fillcolor"] = to_plotly_color(spec["fill_color"])
if "fill_alpha" in spec:
kwargs["opacity"] = spec["fill_alpha"]
if "edge_linewidth" in spec:
kwargs["line"] = {"width": spec["edge_linewidth"]}
return kwargs
[docs]
def filled_line_spec_to_mpl_line_kwargs(spec: FilledLineSpec) -> dict[str, Any]:
"""Convert a filled line spec to Matplotlib line kwargs."""
return line_spec_to_mpl_kwargs(spec)
[docs]
def filled_line_spec_to_mpl_fill_kwargs(spec: FilledLineSpec) -> dict[str, Any]:
"""Convert a filled line spec to Matplotlib fill kwargs."""
kwargs: dict[str, Any] = {}
fill_color = spec.get("fill_color", spec.get("color"))
if fill_color is not None:
kwargs["color"] = fill_color
if "fill_alpha" in spec:
kwargs["alpha"] = spec["fill_alpha"]
return kwargs
[docs]
def rgba_color(color: str, alpha: float) -> str:
"""Return an rgba Plotly color string for a line/fill color."""
plotly_color = to_plotly_color(color) or color
if plotly_color.startswith("#"):
r = int(plotly_color[1:3], 16)
g = int(plotly_color[3:5], 16)
b = int(plotly_color[5:7], 16)
return f"rgba({r}, {g}, {b}, {alpha})"
if plotly_color.startswith("rgb("):
return plotly_color.replace("rgb", "rgba").replace(")", f", {alpha})")
return plotly_color
[docs]
def filled_line_spec_to_plotly_trace_kwargs(spec: FilledLineSpec) -> dict[str, Any]:
"""Convert a filled line spec to Plotly trace kwargs."""
kwargs = line_spec_to_plotly_trace_kwargs(spec)
kwargs["fill"] = "tozeroy"
fill_alpha = spec.get("fill_alpha")
fill_color = spec.get("fill_color")
color = fill_color or spec.get("color")
if fill_alpha is not None and color is not None:
kwargs["fillcolor"] = rgba_color(color, fill_alpha)
elif color is not None:
kwargs["fillcolor"] = to_plotly_color(color)
return kwargs
[docs]
def apply_matplotlib_plot_spec(ax: PltAxes, spec: PlotSpec, *, grid_alpha: float = 0.3) -> None:
"""Apply plot metadata from a shared plot spec to Matplotlib axes."""
title = spec.get("title")
if "xlabel" in spec:
ax.set_xlabel(spec["xlabel"])
if "ylabel" in spec:
ax.set_ylabel(spec["ylabel"])
if spec.get("grid"):
ax.grid(True, alpha=grid_alpha)
if title is not None:
ax.set_title(title)
[docs]
def apply_matplotlib_twin_plot_spec(ax: PltAxes, spec: PlotSpec) -> None:
"""Apply twin-axis metadata from a shared plot spec to Matplotlib axes."""
if "ylabel_twin" in spec:
ax.set_ylabel(spec["ylabel_twin"])
[docs]
def apply_plotly_plot_spec(
figure: GoFigure,
row: int,
col: int,
spec: PlotSpec,
*,
grid_width: int = 1,
grid_color: str = "lightgray",
) -> None:
"""Apply plot metadata from a shared plot spec to a Plotly subplot."""
figure.update_xaxes(
title_text=spec.get("xlabel"),
showgrid=spec.get("grid", False),
gridwidth=grid_width,
gridcolor=grid_color,
row=row,
col=col,
)
figure.update_yaxes(
title_text=spec.get("ylabel"),
showgrid=spec.get("grid", False),
gridwidth=grid_width,
gridcolor=grid_color,
row=row,
col=col,
)
if spec.get("title") is not None:
annotation_index = get_plotly_subplot_annotation_index(figure, row, col)
if len(figure.layout.annotations) > annotation_index:
figure.layout.annotations[annotation_index].text = spec["title"]
[docs]
def apply_plotly_twin_plot_spec(figure: GoFigure, row: int, col: int, spec: PlotSpec) -> None:
"""Apply twin-axis metadata from a shared plot spec to a Plotly subplot."""
figure.update_yaxes(title_text=spec.get("ylabel_twin"), row=row, col=col, secondary_y=True)
[docs]
def get_subplot_y_limits(fig: GoFigure, axes: GoAxes) -> tuple[float, float]:
"""
Extract min and max y-values from actual data traces for a Plotly subplot.
This function analyzes all traces associated with a specific subplot
and computes the data range, adding a small padding to ensure comfortable
viewing margins.
Parameters
----------
fig
Plotly figure containing the subplot.
axes
Subplot position as a tuple (row, column). Row and column indices
are 1-based as used in Plotly's subplot addressing.
Returns
-------
tuple[float, float]
(y_min, y_max) representing the recommended y-axis limits for the
subplot, with padding applied. Returns (0.0, 1.0) if no data found.
Raises
------
ValueError
If no finite y values are found in the subplot traces.
Notes
-----
The function uses a hack with dynamic attributes to track whether padding
has already been applied for a given subplot. Padding is applied only once
per subplot to avoid recursive expansion.
The y-axis mapping is determined using Plotly's internal `_grid_ref`
structure. If not available, a fallback mapping based on row number is used.
Examples
--------
>>> import plotly.graph_objects as go
>>> fig = go.Figure()
>>> fig.add_trace(go.Scatter(x=[0,1,2], y=[5,10,15]), row=1, col=1)
>>> y_min, y_max = get_subplot_y_limits(fig, (1, 1))
>>> print(f"{y_min:.2f}, {y_max:.2f}")
4.75, 15.25
"""
# Extract row and col (1-based indices)
row, col = axes
# Determine if padding has already been applied for this subplot
padding_flag_attr = f"_hack_padding_flag_{row}_{col}"
padding = 0.00 if hasattr(fig, padding_flag_attr) else 0.05
if not hasattr(fig, padding_flag_attr):
setattr(fig, padding_flag_attr, True)
# Map subplot to yaxis key using _grid_ref
if hasattr(fig, "_grid_ref") and fig._grid_ref:
# Find which yaxis this subplot uses
grid_row = fig._grid_ref[row - 1][col - 1]
yaxis_key = grid_row[0].layout_keys[1] # Second element is yaxis key
else:
# Fallback mapping: yaxis for first row, yaxis2, yaxis3, etc.
yaxis_key = "yaxis" if row == 1 else f"yaxis{row}"
# Collect all y values from traces that use this yaxis
all_y = []
for trace in fig.data:
trace_yaxis = getattr(trace, "yaxis", "y")
trace_yaxis_key = f"yaxis{trace_yaxis[1:]}" if trace_yaxis != "y" else "yaxis"
if trace_yaxis_key == yaxis_key and hasattr(trace, "y") and trace.y is not None:
y_vals = trace.y
if hasattr(y_vals, "__iter__") and not isinstance(y_vals, str):
all_y.extend([y for y in y_vals if y is not None])
elif y_vals is not None:
all_y.append(y_vals)
# Filter out 'nan'
all_y = [y for y in all_y if not np.isnan(y)]
if all_y:
y_min, y_max = min(all_y), max(all_y)
y_range = y_max - y_min
if y_range == 0:
y_range = abs(y_min) if y_min != 0 else 1
y_min = y_min - (y_range * padding)
y_max = y_max + (y_range * padding)
return y_min, y_max
raise ValueError("Could not infer y-axis range because no finite y values were found")