Skip to content

← Back to PlotHandler documentation

PlotHandler - Source Code

File: Distributed_Design_Optimizer/postprocess/handlers/PlotHandler.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.
"""Plotting engine — orchestrates figure creation and customization.

Delegates actual plot building to _plot_builder and customization to _plot_customizer.
Stores last plot state so theme toggles and auto-updates can regenerate the current figure.
Emits interactive Plotly HTML via the plot_ready signal.
"""

import logging

import plotly.graph_objects as go

from PySide6.QtCore import QObject, Signal

from ..models.PlotConfig import PlotConfig
from . import _plot_builder as builder
from . import _plot_customizer as customizer

logger = logging.getLogger(__name__)

# Client-side script injected for aligned x-axis modes. It re-thins the outer
# iteration labels live whenever the user zooms/pans into a sub-range, using the
# same "nice number" spacing as the server so the visible detail adapts to what
# is currently shown. ``{plot_id}`` is substituted by Plotly; ``__DDO_ALIGN_DATA__``
# is substituted (as JSON) by us before rendering.
_ALIGN_ZOOM_JS = r"""
(function() {
  var gd = document.getElementById('{plot_id}');
  if (!gd || typeof Plotly === 'undefined') return;
  var DATA = __DDO_ALIGN_DATA__;
  var VALS = DATA.tickvals, LABELS = DATA.labels, VALUES = DATA.values;
  var MIN_PX = DATA.min_px, FONT = DATA.font_size;

  function niceStep(rough) {
    if (rough <= 0) return 1.0;
    var base = Math.pow(10, Math.floor(Math.log10(rough)));
    var frac = rough / base;
    var nice = frac <= 1 ? 1 : frac <= 2 ? 2 : frac <= 5 ? 5 : 10;
    return nice * base;
  }

  function thinForAxis(ax) {
    var out = VALS.map(function() { return ''; });
    if (!ax || !ax.range) return out;
    var lo = Math.min(ax.range[0], ax.range[1]);
    var hi = Math.max(ax.range[0], ax.range[1]);
    var vis = [];
    for (var i = 0; i < VALS.length; i++) {
      if (VALS[i] >= lo && VALS[i] <= hi) vis.push(i);
    }
    var n = vis.length;
    if (n === 0) return out;
    var size = gd._fullLayout && gd._fullLayout._size;
    var availPx = size ? size.w : Math.max(100, gd.clientWidth - 100);
    var minPx = MIN_PX * Math.max(1, FONT / 12);
    var visVals = vis.map(function(i) { return VALUES[i]; });
    var vmin = Math.min.apply(null, visVals);
    var vmax = Math.max.apply(null, visVals);
    var vrange = vmax - vmin;
    if (n <= 2 || vrange <= 0) {
      vis.forEach(function(i) { out[i] = LABELS[i]; });
      return out;
    }
    var targetCount = Math.max(2, Math.min(n, Math.floor(availPx / minPx)));
    var step = niceStep(vrange / targetCount);
    var keep = {};
    keep[vis[0]] = 1;
    keep[vis[n - 1]] = 1;
    var start = Math.ceil(vmin / step) * step;
    for (var t = start; t <= vmax + step * 1e-9; t += step) {
      var best = vis[0], bd = Infinity;
      for (var k = 0; k < n; k++) {
        var d = Math.abs(VALUES[vis[k]] - t);
        if (d < bd) { bd = d; best = vis[k]; }
      }
      keep[best] = 1;
    }
    for (var idx in keep) { out[idx] = LABELS[idx]; }
    return out;
  }

  function applyAll() {
    var fl = gd._fullLayout;
    if (!fl) return;
    var upd = {};
    Object.keys(fl).forEach(function(k) {
      if (/^xaxis\d*$/.test(k) && fl[k] && fl[k].range) {
        upd[k + '.ticktext'] = thinForAxis(fl[k]);
        upd[k + '.tickvals'] = VALS;
      }
    });
    if (Object.keys(upd).length) Plotly.relayout(gd, upd);
  }

  var timer = null;
  function schedule() {
    if (timer) clearTimeout(timer);
    timer = setTimeout(applyAll, 60);
  }

  gd.on('plotly_relayout', function(ev) {
    for (var key in ev) {
      if (/^xaxis\d*\.(range|autorange)/.test(key)) { schedule(); return; }
    }
  });

  // Correct the initial labels to the true rendered width.
  schedule();
})();
"""



class PlotHandler(QObject):
    """Plotting engine — creates Plotly figures and emits HTML strings."""

    plot_ready = Signal(str)       # HTML string
    plot_error = Signal(str)

    def __init__(self, parent: QObject | None = None) -> None:
        """Initialize the plot handler with default empty state.

        Args:
            parent: Optional parent QObject for Qt ownership.
        """
        super().__init__(parent)
        self._last_plottable_data: list[tuple[str, list[float]]] = []
        self._last_labels_map: dict[str, list[dict]] = {}
        self._last_customization: PlotConfig | None = None
        self._canvas_size: tuple[int, int] = (1000, 600)
        self._last_fig: go.Figure | None = None

    @property
    def last_plottable_data(self) -> list[tuple[str, list[float]]]:
        """Return the most recently plotted data series.

        Returns:
            List of (series_name, values) tuples from the last plot.
        """
        return self._last_plottable_data

    @property
    def last_labels_map(self) -> dict[str, list[dict]]:
        """Return the most recently used labels map.

        Returns:
            Mapping of series names to their label metadata dicts.
        """
        return self._last_labels_map

    @property
    def last_figure(self) -> go.Figure | None:
        """The most recent Plotly figure (for export).

        Returns:
            The last created Plotly figure, or None if no plot has been created.
        """
        return self._last_fig

    def clear(self) -> None:
        """Reset all stored plot state."""
        self._last_plottable_data = []
        self._last_labels_map = {}
        self._last_customization = None
        self._last_fig = None

    def set_canvas_size(self, width: int, height: int) -> None:
        """Set the canvas size used for figure creation.

        Args:
            width: Canvas width in pixels.
            height: Canvas height in pixels.
        """
        self._canvas_size = (width, height)

    def create_plot(self, plottable_data: list[tuple[str, list[float]]], config: PlotConfig,
                    labels_map: dict[str, list[dict]] | None = None) -> None:
        """Create a plot and emit plot_ready with the resulting HTML.

        Args:
            plottable_data: List of (series_name, values) tuples to plot.
            config: Plot configuration controlling type, style, and layout.
            labels_map: Optional mapping of series names to label metadata.
        """
        if not plottable_data:
            self.plot_error.emit("No data to plot.")
            return

        self._last_plottable_data = plottable_data
        self._last_labels_map = labels_map or {}
        try:
            fig = self._dispatch_plot(plottable_data, config)
            # Apply all customizations (fonts, labels, ref lines, per-line styles)
            customizer.apply_all(fig, config)
            self._last_fig = fig
            # For aligned x-axis modes, inject a client-side script so labels
            # re-thin live when the user zooms/pans into a sub-range.
            post_script = None
            align = getattr(fig, "_ddo_align", None)
            if align:
                import json
                post_script = _ALIGN_ZOOM_JS.replace(
                    "__DDO_ALIGN_DATA__", json.dumps(align),
                )
            # Inline plotly.js (no CDN) so charts render without an internet connection.
            raw_html = fig.to_html(include_plotlyjs=True, full_html=True,
                                   post_script=post_script,
                                   config={"responsive": True, "displaylogo": False,
                                           "displayModeBar": True,
                                           "toImageButtonOptions": {
                                               "format": "svg",
                                               "filename": "ddo_plot",
                                               "width": 1200,
                                               "height": 700,
                                               "scale": 1,
                                           }})
            # Inject CSS to make the plot fill the QWebEngineView (no scrollbars)
            responsive_css = (
                "<style>html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden;}"
                ".plotly-graph-div{width:100%!important;height:100%!important;}</style>"
            )
            html = raw_html.replace("</head>", responsive_css + "</head>", 1)
            self.plot_ready.emit(html)
        except Exception as e:
            logger.error(f"Plot creation failed: {e}", exc_info=True)
            self.plot_error.emit(str(e))

    def clear_customization(self) -> None:
        """Clear stored customization so it won't be re-applied on next plot."""
        self._last_customization = None

    def apply_customization(self, fig: go.Figure, config: PlotConfig) -> None:
        """Apply font, labels, reference lines, per-line styles to an existing Figure.

        Args:
            fig: The Plotly figure to customize.
            config: Plot configuration with customization settings.
        """
        if fig is None:
            return
        self._last_customization = config
        try:
            customizer.apply_all(fig, config)
        except Exception as e:
            logger.error(f"Customization failed: {e}", exc_info=True)

    # ------------------------------------------------------------------
    # Plot type dispatch
    # ------------------------------------------------------------------

    def _dispatch_plot(self, plottable_data: list[tuple[str, list[float]]], config: PlotConfig) -> go.Figure:
        from dataclasses import replace as _replace
        # Fall back to shared if fewer than 2 series for multi-axis modes
        if config.plot_type in ("independent", "centered", "stacked") and len(plottable_data) < 2:
            config = _replace(config, plot_type="shared")

        cs = self._canvas_size
        lm = self._last_labels_map
        pt = config.plot_type
        if pt == "independent":
            return builder.multi_axes_plot(plottable_data, config, cs, lm, centered=False)
        if pt == "centered":
            return builder.multi_axes_plot(plottable_data, config, cs, lm, centered=True)
        if pt == "stacked":
            return builder.stacked_plot(plottable_data, config, cs, lm)
        return builder.shared_axes_plot(plottable_data, config, cs, lm)