Skip to content

← Back to _plot_customizer documentation

_plot_customizer - Source Code

File: Distributed_Design_Optimizer/postprocess/handlers/_plot_customizer.py

# Copyright (C) The DistributedDesignOptimizer Contributors
# Licensed under the GNU General Public License v3.0. See LICENSE file for details.
#
# Additional Request:
# We kindly request that any modifications or changes to the source code be
# shared with us by submitting them as pull requests to our GitHub repository.
# This request is not legally binding but is made in the spirit of
# collaboration and open-source development.
"""Plot customization helpers for PlotHandler — Plotly implementation."""

import logging

import plotly.graph_objects as go

from ..models.PlotConfig import PlotConfig
from ._plot_builder import MPL_TO_PLOTLY_DASH, tick_width_for

logger = logging.getLogger(__name__)


def apply_font(fig: go.Figure, config: PlotConfig) -> None:
    """Apply per-element font settings to all text elements.

    Args:
        fig: Plotly Figure to update in place.
        config: Plot configuration with font settings.
    """
    fig.update_layout(font=dict(
        family=config.title_font.family,
        size=config.title_font.size,
    ))
    # Apply tick label font sizes and tick marker lengths. The tick line width
    # scales with the tick length (via tick_width_for) so the marker's
    # length:width ratio stays constant as the tick size changes.
    fig.update_xaxes(
        tickfont=dict(size=config.x_tick_label_font.size),
        ticklen=config.x_tick_size,
        tickwidth=tick_width_for(config.x_tick_size),
    )
    fig.update_yaxes(
        tickfont=dict(size=config.y_tick_label_font.size),
        ticklen=config.y_tick_size,
        tickwidth=tick_width_for(config.y_tick_size),
    )


def apply_labels(fig: go.Figure, config: PlotConfig) -> None:
    """Apply custom axis labels and title.

    Args:
        fig: Plotly Figure to update in place.
        config: Plot configuration with custom label text.
    """
    updates: dict = {}
    if config.custom_x_label:
        updates["xaxis_title"] = dict(
            text=config.custom_x_label,
            font=dict(family=config.x_label_font.family, size=config.x_label_font.size),
        )
    if config.custom_y_label:
        updates["yaxis_title"] = dict(
            text=config.custom_y_label,
            font=dict(family=config.y_label_font.family, size=config.y_label_font.size),
        )
    if config.custom_title:
        updates["title"] = dict(
            text=config.custom_title,
            font=dict(family=config.title_font.family, size=config.title_font.size),
            x=0.5,
            xanchor="center",
        )
    if updates:
        fig.update_layout(**updates)


def apply_reference_lines(fig: go.Figure, config: PlotConfig) -> None:
    """Add / replace horizontal reference lines as traces (shown in legend).

    Args:
        fig: Plotly Figure to update in place.
        config: Plot configuration with reference line definitions.
    """
    # Remove previous reference line traces
    fig.data = tuple(t for t in fig.data
                     if not (hasattr(t, 'meta') and isinstance(t.meta, dict)
                             and t.meta.get('_ref_line')))
    # Remove previous reference line shapes
    if fig.layout.shapes:
        fig.layout.shapes = tuple(
            s for s in fig.layout.shapes
            if not (hasattr(s, 'name') and s.name and s.name.startswith('_ref_'))
        )
    for i, ref in enumerate(config.reference_lines):
        if ref.enabled:
            label = ref.name or f"ref {ref.value}"
            # Collect ALL unique x-values from existing data traces
            # so the ref line appears in hover at every x-tick
            all_x = set()
            for t in fig.data:
                if hasattr(t, 'x') and t.x is not None:
                    if hasattr(t, 'meta') and isinstance(t.meta, dict) and t.meta.get('_ref_line'):
                        continue
                    all_x.update(v for v in t.x if v is not None)
            x_vals = sorted(all_x) if all_x else [0, 1]
            # Add as a real scatter trace at every x position
            fig.add_trace(go.Scatter(
                x=x_vals,
                y=[ref.value] * len(x_vals),
                mode="lines+markers" if ref.markers else "lines",
                name=label,
                line=dict(dash=ref.style, color=ref.color, width=ref.width),
                marker=dict(size=4, color=ref.color),
                showlegend=True,
                meta={"_ref_line": True},
            ))


def apply_per_line_styles(fig: go.Figure, config: PlotConfig) -> None:
    """Update trace colors, dash styles, and widths from per-line config.

    Args:
        fig: Plotly Figure to update in place.
        config: Plot configuration with per-line style overrides.
    """
    for trace in fig.data:
        if not hasattr(trace, 'name') or trace.name is None:
            continue
        for path, lc in config.line_configs.items():
            if trace.name == lc.label:
                dash = MPL_TO_PLOTLY_DASH.get(lc.style, lc.style)
                trace.update(line=dict(dash=dash, width=lc.width))
                # Toggle point markers without discarding line styling
                if "markers" in (trace.mode or ""):
                    trace.update(mode="lines+markers" if lc.markers else "lines")
                elif lc.markers:
                    trace.update(mode="lines+markers")
                if lc.color:
                    trace.update(line_color=lc.color, marker_color=lc.color)
                break


def apply_legend(fig: go.Figure, config: PlotConfig) -> None:
    """Show / hide legend and apply themed positioning.

    Args:
        fig: Plotly Figure to update in place.
        config: Plot configuration with legend visibility and position.
    """
    if not config.show_legend:
        fig.update_layout(showlegend=False)
        return

    from ..styles.Theme import get_chart_colors
    colors = get_chart_colors()

    # Map legend position strings to Plotly x/y anchor
    _POS_MAP = {
        "upper right": dict(x=1, y=1, xanchor="right", yanchor="top"),
        "upper left": dict(x=0, y=1, xanchor="left", yanchor="top"),
        "lower right": dict(x=1, y=0, xanchor="right", yanchor="bottom"),
        "lower left": dict(x=0, y=0, xanchor="left", yanchor="bottom"),
        "upper center": dict(x=0.5, y=1, xanchor="center", yanchor="top"),
        "lower center": dict(x=0.5, y=0, xanchor="center", yanchor="bottom"),
        "center left": dict(x=0, y=0.5, xanchor="left", yanchor="middle"),
        "center right": dict(x=1, y=0.5, xanchor="right", yanchor="middle"),
    }
    pos = _POS_MAP.get(config.legend_position,
                       dict(x=1, y=1, xanchor="right", yanchor="top"))

    fig.update_layout(
        showlegend=True,
        legend=dict(
            bgcolor=colors["legend_face"],
            bordercolor=colors["legend_edge"],
            borderwidth=1,
            font=dict(
                color=colors["legend_text"],
                family=config.legend_font.family,
                size=config.legend_font.size,
            ),
            **pos,
        ),
    )


def apply_all(fig: go.Figure, config: PlotConfig) -> None:
    """Apply all customizations to a Plotly figure in order.

    Args:
        fig: Plotly Figure to update in place.
        config: Plot configuration with all customization settings.
    """
    apply_font(fig, config)
    apply_labels(fig, config)
    apply_reference_lines(fig, config)
    apply_per_line_styles(fig, config)
    apply_legend(fig, config)