Skip to content

← Back to _plot_builder documentation

_plot_builder - Source Code

File: Distributed_Design_Optimizer/postprocess/handlers/_plot_builder.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 type builders for PlotHandler — Plotly implementation.

Produces plotly.graph_objects.Figure objects for shared, independent, centered,
and stacked plot types.
"""

import logging
import math

import plotly.graph_objects as go
from plotly.subplots import make_subplots

from ..models.PlotConfig import PlotConfig, FontConfig
from ._axis_aligner import (
    generate_x_values, get_x_label, build_aligned_data,
    AlignedAxisData, get_series_labels,
)

logger = logging.getLogger(__name__)

# Mapping from legacy matplotlib line-style strings to Plotly dash values
MPL_TO_PLOTLY_DASH = {"-": "solid", "--": "dash", "-.": "dashdot", ":": "dot"}

# Fixed tick marker length-to-width ratio. The default tick length is 8px with
# a 1px line width, so a length:width ratio of 8:1. Tick line width is derived
# from the tick length via this ratio so the marker's shape stays identical as
# its size changes (thicker when longer, thinner when shorter).
TICK_LENGTH_TO_WIDTH_RATIO = 8.0


def tick_width_for(tick_length: int | float) -> float:
    """Return the tick line width that preserves the fixed length:width ratio.

    Args:
        tick_length: The tick marker length in px.

    Returns:
        The proportional tick line width in px so the length:width ratio stays
        constant regardless of the configured tick size.
    """
    return tick_length / TICK_LENGTH_TO_WIDTH_RATIO


def _font_dict(fc: FontConfig, **overrides) -> dict:
    """Convert a FontConfig to a Plotly font dict."""
    d = dict(family=fc.family, size=fc.size)
    d.update(overrides)
    return d


# ------------------------------------------------------------------
# Utility helpers
# ------------------------------------------------------------------

def generate_colors(n: int) -> list[str]:
    """Generate n distinct colours using Plotly's qualitative palette.

    Args:
        n: Number of distinct colours to generate.

    Returns:
        List of CSS colour strings from the Dark24 palette.
    """
    import plotly.express as px
    palette = px.colors.qualitative.Dark24
    return [palette[i % len(palette)] for i in range(n)]


def get_line_style(item_path: str, config: PlotConfig) -> tuple[str, float]:
    """Get Plotly dash style and width from config.

    Args:
        item_path: Data series identifier key.
        config: Plot configuration containing per-line style overrides.

    Returns:
        Tuple of (dash style string, line width).
    """
    lc = config.line_configs.get(item_path)
    if lc:
        return MPL_TO_PLOTLY_DASH.get(lc.style, "solid"), lc.width
    return "solid", 2.0


def get_legend_label(item_path: str, config: PlotConfig) -> str:
    """Get display label for a data series.

    Args:
        item_path: Data series identifier key.
        config: Plot configuration containing per-line label overrides.

    Returns:
        Human-readable legend label string.
    """
    if item_path in config.line_configs:
        return config.line_configs[item_path].label
    parts = item_path.split("/", 1)
    if len(parts) == 2:
        entry_id = parts[0]
        path_part = parts[1]
        segments = path_part.split(".")
        if len(segments) >= 2:
            tail = ".".join(segments[-2:])
        else:
            tail = segments[-1]
        if tail == "0" or tail.isdigit():
            tail = ".".join(segments[-3:]) if len(segments) >= 3 else path_part
        short_entry = (entry_id.replace("historyfile_", "")
                       .replace("_coordinator.dill", "[C]")
                       .replace("_subsystem_", "[S:")
                       .replace(".dill", "]"))
        return f"{short_entry}: {tail}"
    return item_path.split(".")[-1]


def _get_plotly_template() -> str:
    """Return 'plotly_white' for the light theme."""
    return "plotly_white"


def _base_layout(config: PlotConfig, canvas_size: tuple[int, int]) -> dict:
    """Return common layout kwargs."""
    from ..styles.Theme import get_chart_colors
    colors = get_chart_colors()
    return dict(
        template=_get_plotly_template(),
        autosize=True,
        paper_bgcolor=colors["fig_face"],
        plot_bgcolor=colors["ax_face"],
        font=dict(
            family=config.title_font.family,
            size=config.title_font.size,
            color=colors["text"],
        ),
        margin=dict(l=60, r=40, t=50, b=60),
        hovermode="x unified",
    )


def _apply_common_axis(fig: go.Figure, config: PlotConfig, *,
                       x_grid_managed: bool = False,
                       skip_yaxis: bool = False) -> None:
    """Apply common axis settings: grid, log scale, labels.

    Args:
        x_grid_managed: If True, skip x-axis grid/tick settings (managed by
                        _add_aligned_ticks).
        skip_yaxis: If True, skip y-axis updates (e.g. multi_axes_plot
                    configures y-axes individually).
    """
    from ..styles.Theme import get_chart_colors
    colors = get_chart_colors()
    x_update = dict(
        title=dict(text=get_x_label(config), font=_font_dict(config.x_label_font)),
        showline=True,
        linecolor=colors["axis_line"],
        mirror=True,
        zeroline=False,
        exponentformat="power",
        showexponent="all",
    )
    if not x_grid_managed:
        x_update["gridcolor"] = colors["grid"]
        x_update["griddash"] = "dash"
        x_update["showgrid"] = config.show_grid
        x_update["ticks"] = "outside"
    if config.x_axis_type == "cumulative_evaluations":
        x_update["exponentformat"] = "power"
    layout_updates = dict(xaxis=x_update)
    if not skip_yaxis:
        y_update = dict(
            title=dict(text=config.custom_y_label or "Value", font=_font_dict(config.y_label_font)),
            gridcolor=colors["grid"],
            griddash="dash",
            showgrid=config.show_grid,
            ticks="outside",
            showline=True,
            linecolor=colors["axis_line"],
            mirror=True,
            zeroline=False,
            hoverformat=".6~r",
            exponentformat="power",
            showexponent="all",
        )
        if config.log_scale:
            y_update["type"] = "log"
            y_update["exponentformat"] = "power"
            y_update["dtick"] = 1  # only powers of 10
            # Minor ticks are 50% of the major length, with width scaled by the
            # same ratio so their length:width shape matches the major ticks.
            y_update["minor"] = dict(ticks="outside", showgrid=config.show_grid,
                                     gridcolor=colors["grid"], griddash="dot",
                                     ticklen=0.5 * config.y_tick_size,
                                     tickwidth=tick_width_for(0.5 * config.y_tick_size))
        layout_updates["yaxis"] = y_update
    fig.update_layout(**layout_updates)


def _nice_step(rough: float) -> float:
    """Round a rough step up to the nearest 1/2/5 x 10^n (Plotly-style).

    Args:
        rough: A positive rough step size.

    Returns:
        The smallest "nice" step >= ``rough`` from the {1, 2, 5} x 10^n family.
    """
    if rough <= 0:
        return 1.0
    base = 10.0 ** math.floor(math.log10(rough))
    frac = rough / base
    if frac <= 1:
        nice = 1.0
    elif frac <= 2:
        nice = 2.0
    elif frac <= 5:
        nice = 5.0
    else:
        nice = 10.0
    return nice * base


def _thin_major_labels(major_labels: list[str], canvas_width: int,
                       tick_font_size: int) -> list[str]:
    """Blank out overlapping outer-iteration labels using Plotly-style spacing.

    Every major tick MARK is preserved (this only affects which labels are
    drawn). The number of visible labels adapts to the available pixel width
    and the tick font size, so wider plots / smaller fonts show more labels.

    Args:
        major_labels: Outer-iteration labels in axis order (numeric strings).
        canvas_width: Current rendered figure width in pixels.
        tick_font_size: X tick label font size in points.

    Returns:
        A list the same length as ``major_labels`` with dropped entries set
        to the empty string.
    """
    n = len(major_labels)
    if n <= 2:
        return list(major_labels)

    # Interpret labels as numeric outer-iteration values; fall back to index
    # positions if any label is non-numeric.
    values: list[float] = []
    try:
        values = [float(lbl) for lbl in major_labels]
    except (TypeError, ValueError):
        values = [float(i) for i in range(n)]

    vmin, vmax = min(values), max(values)
    vrange = vmax - vmin
    if vrange <= 0:
        return list(major_labels)

    # Available horizontal space for the axis (figure width minus L/R margins).
    avail = max(100.0, float(canvas_width) - 100.0)
    # Minimum pixels between labels, scaled by font size (default 12pt) so
    # larger fonts yield fewer labels.
    min_px = 70.0 * max(1.0, float(tick_font_size) / 12.0)
    target_count = max(2, min(n, int(avail / min_px)))

    step = _nice_step(vrange / target_count)
    start = math.ceil(vmin / step) * step

    # Target gridline values (multiples of step spanning the range); snap each
    # to the nearest present major so sparse / non-contiguous outers still show.
    keep: set[int] = {0, n - 1}
    t = start
    while t <= vmax + step * 1e-9:
        best_i = min(range(n), key=lambda i: abs(values[i] - t))
        keep.add(best_i)
        t += step

    return [major_labels[i] if i in keep else "" for i in range(n)]


def _add_aligned_ticks(fig: go.Figure, major_pos: list, major_labels: list[str],
                       minor_pos: list, *, xaxis_key: str = "xaxis",
                       show_grid: bool = False,
                       canvas_width: int = 1000, tick_font_size: int = 12,
                       major_tick_size: int = 8,
                       subplot_domains: list[tuple[float, float]] | None = None) -> None:
    """Apply major and minor tick marks for outer/inner iteration alignment.

    Major ticks (outer iterations): black tick marks with labels.
    Minor ticks (inner iterations): red tick marks, drawn as shapes below the axis.
    Grid lines are drawn as shapes at all positions when show_grid is True.

    Args:
        canvas_width: Current rendered figure width in pixels; drives how many
                      outer-iteration labels can be shown without overlap.
        tick_font_size: X tick label font size; larger fonts need more spacing,
                        so fewer labels are shown.
        major_tick_size: Outer (major) tick marker length in px (config.x_tick_size).
                         The inner (minor) ticks are always drawn at 50% of this,
                         so the two scale together as the user changes tick size.
        subplot_domains: For stacked plots, list of (y_bottom, y_top) paper
                         coordinates for each subplot. Grid and ticks are drawn
                         per-subplot to avoid crossing subplot boundaries.
                         Also applies tick config to all x-axes (xaxis, xaxis2, ...).
    """
    from ..styles.Theme import get_chart_colors
    chart_colors = get_chart_colors()

    all_pos = sorted(set(major_pos) | set(minor_pos))
    major_set = set(major_pos)

    # Keep every major tick MARK (tickvals) but thin the visible LABELS so
    # they never overlap at the current width / font size (Plotly-style
    # "nice number" spacing).
    ticktext = _thin_major_labels(major_labels, canvas_width, tick_font_size)

    # Stash the full (unthinned) major data on the figure so the browser can
    # re-thin labels live when the user zooms/pans into a sub-range.
    try:
        _vals = [float(lbl) for lbl in major_labels]
    except (TypeError, ValueError):
        _vals = [float(i) for i in range(len(major_labels))]
    try:
        object.__setattr__(fig, "_ddo_align", dict(
            tickvals=[float(v) for v in major_pos],
            labels=[str(lbl) for lbl in major_labels],
            values=_vals,
            min_px=70.0,
            font_size=float(tick_font_size),
        ))
    except Exception:  # pragma: no cover - defensive; zoom-thinning is optional
        pass

    minor_only = [pos for pos in minor_pos if pos not in major_set]
    minor_tick_color = chart_colors["minor_tick"]

    tick_config = dict(
        tickmode="array",
        tickvals=major_pos,
        ticktext=ticktext,
        tickfont=dict(size=9),
        ticks="outside",
        ticklen=major_tick_size,
        # Width scales with length so the tick shape (length:width ratio) is
        # preserved as the user changes the tick size.
        tickwidth=tick_width_for(major_tick_size),
        tickcolor=chart_colors["axis_line"],
        showgrid=False,  # we draw grid manually as shapes
        # Inner (red) ticks are rendered as native axis minor ticks whose
        # ticklen is in px — exactly like the major (outer) ticks — so they are
        # always 50% of the outer tick length regardless of the rendered figure
        # size. major_tick_size is config.x_tick_size (the GUI tick-size value).
        # The width is scaled by the same ratio so minors keep the same shape.
        minor=dict(
            tickmode="array",
            tickvals=minor_only,
            ticks="outside",
            ticklen=0.5 * major_tick_size,
            tickwidth=tick_width_for(0.5 * major_tick_size),
            tickcolor=minor_tick_color,
            showgrid=False,
        ),
    )

    if subplot_domains is not None:
        # Apply tick config to ALL x-axes in stacked subplots
        n = len(subplot_domains)
        for i in range(n):
            key = "xaxis" if i == 0 else f"xaxis{i + 1}"
            fig.update_layout(**{key: tick_config})
    else:
        fig.update_layout(**{xaxis_key: tick_config})

    ax_ref = xaxis_key.replace("axis", "")  # "xaxis" -> "x"

    if subplot_domains is None:
        # Single plot: ticks below axis at y=0, grid from 0 to 1
        domains = [(0.0, 1.0)]
    else:
        domains = subplot_domains

    # Accumulate shapes in a plain list and assign once. Each fig.add_shape()
    # appends to the layout.shapes tuple (O(n) copy per call -> O(n^2) overall),
    # which dominates runtime for large tick counts; a single batched assignment
    # produces identical shapes in O(n).
    new_shapes: list[dict] = []

    # Draw grid lines within each subplot domain when grid is enabled
    if show_grid:
        grid_color = chart_colors["grid"]
        for y_bot, y_top in domains:
            for pos in all_pos:
                new_shapes.append(dict(
                    type="line",
                    x0=pos, x1=pos,
                    y0=y_bot, y1=y_top,
                    yref="paper",
                    xref=ax_ref,
                    line=dict(color=grid_color, width=0.5, dash="dash"),
                    layer="below",
                ))

    if new_shapes:
        existing = list(fig.layout.shapes) if fig.layout.shapes else []
        fig.update_layout(shapes=existing + new_shapes)


# ------------------------------------------------------------------
# Public plot builders — each returns a go.Figure
# ------------------------------------------------------------------

def shared_axes_plot(plottable_data: list[tuple[str, list[float]]], config: PlotConfig,
                     canvas_size: tuple[int, int], labels_map: dict[str, list[dict]]) -> go.Figure:
    """Single shared Y-axis for all data series.

    Args:
        plottable_data: List of (item_path, y_values) tuples to plot.
        config: Plot configuration controlling axes, styles, and layout.
        canvas_size: Width and height in pixels for the figure.
        labels_map: Mapping of item paths to per-point label metadata.

    Returns:
        Configured Plotly Figure with all series on a shared Y-axis.
    """
    fig = go.Figure()
    colors = generate_colors(len(plottable_data))

    if config.x_axis_type in ("combined_ticks", "equispaced_major"):
        ad = build_aligned_data(plottable_data, config, labels_map)
        for idx, (item_path, _) in enumerate(plottable_data):
            label = get_legend_label(item_path, config)
            dash, width = get_line_style(item_path, config)
            lc = config.line_configs.get(item_path)
            color = lc.color if lc and lc.color else colors[idx]
            fig.add_trace(go.Scatter(
                x=ad.x_positions, y=ad.aligned_y[idx], name=label,
                mode="lines+markers", marker=dict(size=4, color=color),
                line=dict(color=color, dash=dash, width=width),
                text=ad.hover_labels,
                hovertemplate="%{text}<br>y: %{y}<extra>%{fullData.name}</extra>",
            ))
        _add_aligned_ticks(fig, ad.major_positions, ad.major_labels, ad.minor_positions,
                           show_grid=config.show_grid,
                           canvas_width=canvas_size[0],
                           tick_font_size=config.x_tick_label_font.size,
                           major_tick_size=config.x_tick_size)
    elif config.x_axis_type in ("cumulative_runtime", "cumulative_evaluations"):
        for idx, (item_path, values) in enumerate(plottable_data):
            series_labels = get_series_labels(item_path, labels_map)
            x_vals = generate_x_values(values, config, series_labels)
            label = get_legend_label(item_path, config)
            dash, width = get_line_style(item_path, config)
            lc = config.line_configs.get(item_path)
            color = lc.color if lc and lc.color else colors[idx]
            fig.add_trace(go.Scatter(
                x=x_vals, y=values, name=label,
                mode="lines+markers", marker=dict(size=4, color=color),
                line=dict(color=color, dash=dash, width=width),
            ))
    else:
        for idx, (item_path, values) in enumerate(plottable_data):
            series_labels = get_series_labels(item_path, labels_map)
            x_vals = generate_x_values(values, config, series_labels)
            label = get_legend_label(item_path, config)
            dash, width = get_line_style(item_path, config)
            lc = config.line_configs.get(item_path)
            color = lc.color if lc and lc.color else colors[idx]
            fig.add_trace(go.Scatter(
                x=x_vals, y=values, name=label,
                mode="lines+markers", marker=dict(size=4, color=color),
                line=dict(color=color, dash=dash, width=width),
            ))

    fig.update_layout(**_base_layout(config, canvas_size))
    uses_aligned = config.x_axis_type in ("combined_ticks", "equispaced_major")
    _apply_common_axis(fig, config, x_grid_managed=uses_aligned)

    return fig


def multi_axes_plot(plottable_data: list[tuple[str, list[float]]], config: PlotConfig,
                    canvas_size: tuple[int, int], labels_map: dict[str, list[dict]],
                    *, centered: bool = False) -> go.Figure:
    """Multiple independent or centered Y-axes (overlaid).

    Args:
        plottable_data: List of (item_path, y_values) tuples to plot.
        config: Plot configuration controlling axes, styles, and layout.
        canvas_size: Width and height in pixels for the figure.
        labels_map: Mapping of item paths to per-point label metadata.

    Returns:
        Configured Plotly Figure with independent Y-axes per series.
    """
    n = len(plottable_data)
    fig = go.Figure()
    colors = generate_colors(n)

    ad: AlignedAxisData | None = None
    if config.x_axis_type in ("combined_ticks", "equispaced_major"):
        ad = build_aligned_data(plottable_data, config, labels_map)

    for idx, (item_path, values) in enumerate(plottable_data):
        label = get_legend_label(item_path, config)
        dash, width = get_line_style(item_path, config)
        lc = config.line_configs.get(item_path)
        color = lc.color if lc and lc.color else colors[idx]
        yaxis_name = "y" if idx == 0 else f"y{idx + 1}"

        if ad is not None:
            x_vals = ad.x_positions
            y_vals = ad.aligned_y[idx]
        else:
            series_labels = get_series_labels(item_path, labels_map)
            x_vals = generate_x_values(values, config, series_labels)
            y_vals = values

        hover_kwargs = {}
        if ad is not None:
            hover_kwargs["text"] = ad.hover_labels
            hover_kwargs["hovertemplate"] = "%{text}<br>y: %{y}<extra>%{fullData.name}</extra>"

        fig.add_trace(go.Scatter(
            x=x_vals, y=y_vals, name=label,
            mode="lines+markers", marker=dict(size=4, color=color),
            line=dict(color=color, dash=dash, width=width),
            yaxis=yaxis_name, **hover_kwargs,
        ))

    # Configure Y-axes
    layout_kwargs = _base_layout(config, canvas_size)
    from ..styles.Theme import get_chart_colors
    chart_colors = get_chart_colors()

    y_type = "log" if config.log_scale else None

    for idx, (item_path, _) in enumerate(plottable_data):
        label = get_legend_label(item_path, config)
        # Use custom per-axis y-label if provided
        if config.independent_y_labels and idx < len(config.independent_y_labels) and config.independent_y_labels[idx]:
            label = config.independent_y_labels[idx]
        color = colors[idx]
        lc = config.line_configs.get(item_path)
        if lc and lc.color:
            color = lc.color
        axis_key = "yaxis" if idx == 0 else f"yaxis{idx + 1}"
        axis_def = dict(
            title=dict(text=label, font=dict(color=color)),
            tickfont=dict(color=color),
            gridcolor=chart_colors["grid"],
            griddash="dash",
            showgrid=(idx == 0 and config.show_grid),
            showline=True,
            linecolor=chart_colors["axis_line"],
            mirror=True,
            zeroline=False,
            ticks="outside",
            hoverformat=".6~r",
            exponentformat="power",
            showexponent="all",
        )
        if y_type:
            axis_def["type"] = y_type
            axis_def["exponentformat"] = "power"
            axis_def["showexponent"] = "all"
            axis_def["dtick"] = 1
            # Minor ticks: 50% of the major length, same length:width ratio.
            minor_len = 0.5 * config.y_tick_size
            if idx == 0:
                axis_def["minor"] = dict(ticks="outside", showgrid=config.show_grid,
                                         gridcolor=chart_colors["grid"], griddash="dot",
                                         ticklen=minor_len, tickwidth=tick_width_for(minor_len))
            else:
                axis_def["minor"] = dict(ticks="outside", showgrid=False,
                                         ticklen=minor_len, tickwidth=tick_width_for(minor_len))
        # Apply centering: symmetric y-range around zero
        if centered and not config.log_scale:
            if ad is not None:
                y_data = [v for v in ad.aligned_y[idx] if v is not None and not (isinstance(v, float) and v != v)]
            else:
                y_data = [v for v in plottable_data[idx][1] if v is not None and not (isinstance(v, float) and v != v)]
            if y_data:
                data_min, data_max = min(y_data), max(y_data)
                padding = (data_max - data_min) * 0.05 if data_max != data_min else 1.0
                ymin, ymax = data_min - padding, data_max + padding
                if ymin > 0:
                    ymin = 0
                elif ymax < 0:
                    ymax = 0
                max_abs = max(abs(ymin), abs(ymax))
                axis_def["range"] = [-max_abs, max_abs]
            else:
                axis_def["range"] = [-1, 1]
        if idx > 0:
            axis_def["overlaying"] = "y"
            axis_def["side"] = "right"
            axis_def["mirror"] = False
            axis_def["showticklabels"] = True
            axis_def["showexponent"] = "all"
            if idx > 1:
                axis_def["anchor"] = "free"
                axis_def["position"] = 1.0 - 0.05 * (idx - 1)
        layout_kwargs[axis_key] = axis_def

    if n > 1:
        layout_kwargs["margin"]["r"] = max(60, 40 + 50 * (n - 1))

    fig.update_layout(**layout_kwargs)

    uses_aligned = ad is not None
    _apply_common_axis(fig, config, x_grid_managed=uses_aligned, skip_yaxis=True)

    if ad is not None:
        _add_aligned_ticks(fig, ad.major_positions, ad.major_labels, ad.minor_positions,
                           show_grid=config.show_grid,
                           canvas_width=canvas_size[0],
                           tick_font_size=config.x_tick_label_font.size,
                           major_tick_size=config.x_tick_size)

    return fig


def stacked_plot(plottable_data: list[tuple[str, list[float]]], config: PlotConfig,
                 canvas_size: tuple[int, int], labels_map: dict[str, list[dict]]) -> go.Figure:
    """Stacked subplots (one per data series).

    Args:
        plottable_data: List of (item_path, y_values) tuples to plot.
        config: Plot configuration controlling axes, styles, and layout.
        canvas_size: Width and height in pixels for the figure.
        labels_map: Mapping of item paths to per-point label metadata.

    Returns:
        Configured Plotly Figure with vertically stacked subplots.
    """
    n = len(plottable_data)
    # Use custom stacked titles if provided, otherwise default to legend labels
    subplot_titles = []
    for idx, (p, _) in enumerate(plottable_data):
        default_label = get_legend_label(p, config)
        if config.stacked_titles and idx < len(config.stacked_titles) and config.stacked_titles[idx]:
            subplot_titles.append(config.stacked_titles[idx])
        else:
            subplot_titles.append(default_label)
    fig = make_subplots(rows=n, cols=1, shared_xaxes=True, subplot_titles=subplot_titles,
                        vertical_spacing=max(0.02, 0.08 / n))
    colors = generate_colors(n)
    from ..styles.Theme import get_chart_colors
    chart_colors = get_chart_colors()

    ad: AlignedAxisData | None = None
    if config.x_axis_type in ("combined_ticks", "equispaced_major"):
        ad = build_aligned_data(plottable_data, config, labels_map)

    for idx, (item_path, values) in enumerate(plottable_data):
        label = get_legend_label(item_path, config)
        dash, width = get_line_style(item_path, config)
        lc = config.line_configs.get(item_path)
        color = lc.color if lc and lc.color else colors[idx]
        row = idx + 1

        if ad is not None:
            x_vals = ad.x_positions
            y_vals = ad.aligned_y[idx]
        else:
            series_labels = get_series_labels(item_path, labels_map)
            x_vals = generate_x_values(values, config, series_labels)
            y_vals = values

        hover_kwargs = {}
        if ad is not None:
            hover_kwargs["text"] = ad.hover_labels
            hover_kwargs["hovertemplate"] = "%{text}<br>y: %{y}<extra>%{fullData.name}</extra>"

        fig.add_trace(go.Scatter(
            x=x_vals, y=y_vals, name=label,
            mode="lines+markers", marker=dict(size=4, color=color),
            line=dict(color=color, dash=dash, width=width), **hover_kwargs,
            showlegend=config.show_legend,
        ), row=row, col=1)

        if config.log_scale:
            fig.update_yaxes(type="log", exponentformat="power", dtick=1,
                             minor=dict(ticks="outside", showgrid=config.show_grid,
                                        gridcolor=chart_colors["minor_grid"], griddash="dot",
                                        ticklen=0.5 * config.y_tick_size,
                                        tickwidth=tick_width_for(0.5 * config.y_tick_size)),
                             row=row, col=1)
        fig.update_yaxes(showgrid=config.show_grid, griddash="dash",
                         showline=True, linecolor=chart_colors["axis_line"], mirror=True,
                         zeroline=False, ticks="outside",
                         exponentformat="power", showexponent="all", row=row, col=1)
        uses_aligned = config.x_axis_type in ("combined_ticks", "equispaced_major")
        x_grid = False if uses_aligned else config.show_grid
        fig.update_xaxes(showline=True, linecolor=chart_colors["axis_line"], mirror=True,
                         zeroline=False, ticks="outside",
                         showgrid=x_grid, gridcolor=chart_colors["minor_grid"], griddash="dash",
                         exponentformat="power", showexponent="all",
                         row=row, col=1)

        # Apply custom per-subplot y-label
        if config.stacked_y_labels and idx < len(config.stacked_y_labels) and config.stacked_y_labels[idx]:
            fig.update_yaxes(title_text=config.stacked_y_labels[idx], row=row, col=1)
        elif config.custom_y_label:
            fig.update_yaxes(title_text=config.custom_y_label, row=row, col=1)

    w, h = canvas_size
    stacked_h = max(400, 250 * n)
    layout_kwargs = _base_layout(config, (w, stacked_h))
    layout_kwargs["height"] = stacked_h
    fig.update_layout(**layout_kwargs)

    fig.update_xaxes(title_text=get_x_label(config), row=n, col=1)

    if ad is not None:
        # Extract per-subplot y-domains from the figure layout
        subplot_domains = []
        for row_idx in range(n):
            yaxis_key = "yaxis" if row_idx == 0 else f"yaxis{row_idx + 1}"
            domain = fig.layout[yaxis_key].domain
            if domain:
                subplot_domains.append((domain[0], domain[1]))
            else:
                subplot_domains.append((0.0, 1.0))
        _add_aligned_ticks(fig, ad.major_positions, ad.major_labels, ad.minor_positions,
                           show_grid=config.show_grid,
                           canvas_width=canvas_size[0],
                           tick_font_size=config.x_tick_label_font.size,
                           major_tick_size=config.x_tick_size,
                           subplot_domains=subplot_domains)

    return fig