Skip to content

← Back to PlotlyPanel documentation

PlotlyPanel - Source Code

File: Distributed_Design_Optimizer/postprocess/widgets/PlotlyPanel.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.
"""
Reusable widget for embedding interactive Plotly charts via QWebEngineView.

Provides the common set_html / clear / export pattern used by both
PlotCanvas and TopologyPanel's chart page.
"""

from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QApplication, QFileDialog
from PySide6.QtCore import Qt, QUrl, QObject, Signal, Slot, QTimer
import base64
import logging
import os
import tempfile
import uuid
from urllib.parse import unquote

logger = logging.getLogger(__name__)

# JavaScript snippet injected into every Plotly HTML to replace the default
# camera button with a single "Save As…" dropdown offering SVG/PNG/JPEG/WebP.
# Uses Plotly.toImage() → base64 → sends to Qt via QWebChannel for a native
# file-save dialog.
_EXTRA_DOWNLOAD_JS = """<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
<script>
(function addExportDropdown() {
    var gd = document.querySelector('.plotly-graph-div');
    if (!gd || !window.Plotly) { setTimeout(addExportDropdown, 100); return; }

    var opts = {width: 1200, height: 700, scale: 2};
    var formats = [
        {fmt: 'svg',  label: 'SVG (vector)',  ext: '.svg'},
        {fmt: 'png',  label: 'PNG',           ext: '.png'},
        {fmt: 'jpeg', label: 'JPEG',          ext: '.jpg'},
        {fmt: 'webp', label: 'WebP',          ext: '.webp'},
    ];

    /* Remove the default camera / download button */
    var btns = gd.querySelectorAll('.modebar-btn');
    btns.forEach(function(b) {
        var t = (b.getAttribute('data-title') || '').toLowerCase();
        if (t.indexOf('download') !== -1 || t.indexOf('png') !== -1) {
            b.remove();
        }
    });

    var bar = gd.querySelector('.modebar-group:last-child');
    if (!bar) return;

    var wrap = document.createElement('div');
    wrap.style.cssText = 'position:relative;display:inline-flex;align-items:center;';

    var btn = document.createElement('a');
    btn.className = 'modebar-btn';
    btn.setAttribute('data-title', 'Save image as\u2026');
    btn.innerHTML = '<svg viewBox="0 0 24 24" width="1.3em" height="1.3em" stroke="currentColor" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'
        + '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>'
        + '<polyline points="7 10 12 15 17 10"/>'
        + '<line x1="12" y1="15" x2="12" y2="3"/>'
        + '</svg>'
        + '<span style="font-size:10px;margin-left:1px;">\u25BE</span>';
    btn.style.cssText = 'cursor:pointer;display:flex;align-items:center;';

    var menu = document.createElement('div');
    menu.style.cssText = 'display:none;position:absolute;top:100%;right:0;'
        + 'background:#fff;border:1px solid #ccc;border-radius:4px;'
        + 'box-shadow:0 2px 8px rgba(0,0,0,.18);z-index:9999;min-width:130px;padding:4px 0;';

    formats.forEach(function(f) {
        var item = document.createElement('div');
        item.textContent = f.label;
        item.style.cssText = 'padding:5px 14px;cursor:pointer;font-size:13px;white-space:nowrap;color:#333;';
        item.addEventListener('mouseenter', function() { item.style.background = '#e6f4f7'; });
        item.addEventListener('mouseleave', function() { item.style.background = 'none'; });
        item.addEventListener('click', function(e) {
            e.stopPropagation();
            menu.style.display = 'none';
            Plotly.toImage(gd, Object.assign({}, opts, {format: f.fmt})).then(function(dataUrl) {
                if (window._qtBridge) {
                    window._qtBridge.saveImage(f.fmt, f.ext, dataUrl);
                }
            });
        });
        menu.appendChild(item);
    });

    btn.addEventListener('click', function(e) {
        e.stopPropagation();
        menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
    });

    document.addEventListener('click', function() { menu.style.display = 'none'; });

    wrap.appendChild(btn);
    wrap.appendChild(menu);
    bar.appendChild(wrap);

    /* Set up QWebChannel bridge */
    if (typeof QWebChannel !== 'undefined') {
        new QWebChannel(qt.webChannelTransport, function(channel) {
            window._qtBridge = channel.objects.bridge;
        });
    }
})();
</script>"""

# Lazy import — avoids pulling in WebEngine until actually needed
_QWebEngineView = None


def _get_web_engine_class():
    global _QWebEngineView
    if _QWebEngineView is None:
        from PySide6.QtWebEngineWidgets import QWebEngineView
        _QWebEngineView = QWebEngineView
    return _QWebEngineView


class _ImageSaveBridge(QObject):
    """QWebChannel bridge for saving Plotly chart images via native file dialog."""

    image_saved = Signal(str)  # emitted with status message after successful save

    _FILTER_MAP = {
        'svg':  'SVG Files (*.svg)',
        'png':  'PNG Files (*.png)',
        'jpeg': 'JPEG Files (*.jpg *.jpeg)',
        'webp': 'WebP Files (*.webp)',
    }

    @Slot(str, str, str)
    def saveImage(self, fmt: str, ext: str, data_url: str) -> None:
        """Handle an image-save request forwarded from JavaScript.

        Args:
            fmt: Image format identifier (e.g. ``'png'``, ``'svg'``).
            ext: File extension including dot (e.g. ``'.png'``).
            data_url: Base64-encoded data URL of the image.
        """
        path, _ = QFileDialog.getSaveFileName(
            None, 'Save Plot Image', f'ddo_plot{ext}',
            self._FILTER_MAP.get(fmt, 'All Files (*)'),
        )
        if not path:
            return
        try:
            header, payload = data_url.split(',', 1)
            if 'base64' in header:
                raw = base64.b64decode(payload)
            else:
                # SVG comes as data:image/svg+xml,<url-encoded content>
                raw = unquote(payload).encode('utf-8')
            if fmt == 'svg':
                # SVG is XML text — try UTF-8, fall back to latin-1
                try:
                    text = raw.decode('utf-8')
                except UnicodeDecodeError:
                    text = raw.decode('latin-1')
                with open(path, 'w', encoding='utf-8') as f:
                    f.write(text)
            else:
                with open(path, 'wb') as f:
                    f.write(raw)
            filename = os.path.basename(path)
            self.image_saved.emit(f"Plot image saved to {filename}")
        except Exception as exc:
            logger.error('Failed to save image: %s', exc, exc_info=True)


class PlotlyPanel(QWidget):
    """Embeds a Plotly chart rendered as HTML inside a QWebEngineView."""

    image_saved = Signal(str)  # forwarded from bridge

    def __init__(self, placeholder_text: str = "", parent: QWidget | None = None) -> None:
        """Initialize the Plotly panel.

        Args:
            placeholder_text: Text shown when no chart is loaded.
            parent: Optional parent widget.
        """
        super().__init__(parent)

        self._layout = QVBoxLayout(self)
        self._layout.setContentsMargins(0, 0, 0, 0)
        self._bridge = _ImageSaveBridge(self)
        self._bridge.image_saved.connect(self.image_saved)

        # Optional placeholder label
        self._placeholder: QLabel | None = None
        if placeholder_text:
            self._placeholder = QLabel(placeholder_text)
            self._placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter)
            self._placeholder.setStyleSheet("color: gray; font-size: 12pt;")
            self._layout.addWidget(self._placeholder)

        self._web_view = None
        self._html: str | None = None
        self._plotly_fig = None  # cache for export
        # Charts are loaded from a local temp file rather than via setHtml(),
        # because inlined plotly.js (used for offline rendering) is several MB —
        # well past Qt's documented ~2MB content limit for QWebEnginePage.setHtml().
        self._temp_dir = tempfile.mkdtemp(prefix="ddo_plot_")
        self._temp_html_path: str | None = None

    @property
    def html(self) -> str | None:
        """Return the current HTML string, or ``None`` if empty.

        Returns:
            The current HTML content, or None if no content is set.
        """
        return self._html

    @property
    def figure(self):
        """Return the cached Plotly figure (if any) for export."""
        return self._plotly_fig

    def set_html(self, html: str, fig=None) -> None:
        """Display Plotly HTML content, replacing any existing view.

        Args:
            html: Raw HTML string containing a Plotly chart.
            fig: Optional Plotly figure cached for later image export.
        """
        self._ensure_web_view()
        if self._placeholder:
            self._placeholder.hide()

        self._html = html
        self._plotly_fig = fig
        # Inject white body background to prevent black flash during compositing
        styled_html = html.replace(
            "<head>",
            "<head><style>body{background:#fff !important;}</style>",
            1,
        )
        # Inject extra download-format buttons into the modebar
        styled_html = styled_html.replace("</body>", _EXTRA_DOWNLOAD_JS + "</body>", 1)
        # Write to a fresh local temp file and load it (instead of setHtml()) so
        # the several-MB inlined plotly.js payload isn't subject to Qt's setHtml()
        # size limit. A new filename is used each time to avoid any browser-side
        # caching of a previously-loaded local file at the same path.
        old_path = self._temp_html_path
        new_path = os.path.join(self._temp_dir, f"plot_{uuid.uuid4().hex}.html")
        with open(new_path, "w", encoding="utf-8") as f:
            f.write(styled_html)
        self._temp_html_path = new_path
        self._web_view.load(QUrl.fromLocalFile(new_path))
        self._web_view.show()
        if old_path and old_path != new_path:
            try:
                os.remove(old_path)
            except OSError:
                pass  # best-effort cleanup; harmless if still locked/in use

    def set_figure(self, fig) -> None:
        """Accept a Plotly go.Figure, convert to HTML, and display.

        Args:
            fig: A Plotly ``go.Figure`` to render.
        """
        # Inline plotly.js (no CDN) so charts render without an internet connection.
        html = fig.to_html(include_plotlyjs=True, full_html=True,
                           config={"responsive": True, "displaylogo": False,
                                   "displayModeBar": True,
                                   "toImageButtonOptions": {
                                       "format": "svg",
                                       "filename": "ddo_plot",
                                       "width": 1200,
                                       "height": 700,
                                       "scale": 1,
                                   }})
        self.set_html(html, fig)

    def refresh(self) -> None:
        """Re-render the current HTML."""
        if self._html and self._web_view and self._temp_html_path:
            self._web_view.load(QUrl.fromLocalFile(self._temp_html_path))

    def clear(self) -> None:
        """Remove the chart and show placeholder."""
        self._html = None
        self._plotly_fig = None
        if self._web_view:
            self._web_view.setHtml("")
        if self._placeholder:
            self._placeholder.show()
        if self._temp_html_path:
            try:
                os.remove(self._temp_html_path)
            except OSError:
                pass
            self._temp_html_path = None

    def __del__(self) -> None:
        """Best-effort cleanup of the temp directory used for local chart HTML."""
        try:
            import shutil
            shutil.rmtree(self._temp_dir, ignore_errors=True)
        except Exception:
            pass

    def export_image(self, path: str, dpi: int = 150, width: int = 1200, height: int = 700) -> None:
        """Save the current figure to a static image file (PNG/SVG/PDF via kaleido).

        Args:
            path: Destination file path.
            dpi: Resolution in dots per inch.
            width: Image width in pixels.
            height: Image height in pixels.
        """
        if self._plotly_fig is not None:
            scale = dpi / 100.0
            self._plotly_fig.write_image(path, width=width, height=height, scale=scale)
        elif self._html:
            # Fallback: grab the web view as a pixmap
            if self._web_view:
                pixmap = self._web_view.grab()
                pixmap.save(path)

    def copy_to_clipboard(self) -> None:
        """Copy the current web view content to the system clipboard as an image."""
        if self._web_view:
            pixmap = self._web_view.grab()
            QApplication.clipboard().setPixmap(pixmap)

    def init_web_view(self) -> None:
        """Eagerly create the QWebEngineView (call during startup to avoid flash)."""
        self._ensure_web_view()

    def _ensure_web_view(self) -> None:
        """Lazily create the QWebEngineView on first use."""
        if self._web_view is None:
            from PySide6.QtWebEngineCore import QWebEngineSettings
            from PySide6.QtWebChannel import QWebChannel
            WebView = _get_web_engine_class()
            self._web_view = WebView(self)
            # White background to avoid black flash during compositor init
            self._web_view.page().setBackgroundColor(Qt.GlobalColor.white)
            self._web_view.setStyleSheet("background: white;")
            self._web_view.setHtml(
                "<html><body style='background:#fff;'></body></html>",
                QUrl("about:blank"),
            )
            # Register JS ↔ Qt bridge for the image-save dialog
            channel = QWebChannel(self._web_view.page())
            channel.registerObject('bridge', self._bridge)
            self._web_view.page().setWebChannel(channel)
            # Trigger Plotly resize after page finishes loading
            self._web_view.loadFinished.connect(self._on_load_finished)
            self._layout.addWidget(self._web_view)

    def _on_load_finished(self, ok: bool) -> None:
        """Trigger Plotly relayout after load so autosize charts render."""
        if self._web_view and self._html:
            # Poll until Plotly is loaded, then resize; force white bg
            resize_js = """
            (function waitForPlotly() {
                document.body.style.background = '#fff';
                var gd = document.querySelector('.plotly-graph-div');
                if (gd && window.Plotly) {
                    Plotly.Plots.resize(gd);
                } else {
                    setTimeout(waitForPlotly, 50);
                }
            })();
            """
            self._web_view.page().runJavaScript(resize_js)
            # QtWebEngine/Chromium (on some Windows GPU/D3D11 configurations)
            # doesn't actually present the first composited frame after a full
            # page load() until an input or resize event nudges it — the chart
            # appears black/blank until the user hovers over it. Force a
            # imperceptible 1px window resize to trigger a real repaint.
            self._nudge_repaint()

    def _nudge_repaint(self) -> None:
        """Force a compositor repaint by nudging the top-level window size.

        Works around a QtWebEngine quirk where the first frame after
        navigating to a new page isn't presented until an input/resize event
        occurs. The 1px resize-and-restore is visually imperceptible.
        """
        win = self._web_view.window() if self._web_view else None
        if win is None:
            return
        size = win.size()
        win.resize(size.width() + 1, size.height())
        QTimer.singleShot(0, lambda: win.resize(size) if win else None)

    def _deferred_resize(self) -> None:
        if self._web_view and self._html:
            self._web_view.page().runJavaScript(
                "var gd = document.querySelector('.plotly-graph-div');"
                "if (gd && window.Plotly) { Plotly.Plots.resize(gd); }"
            )