Source code for palm_tracer.UI.BasePlotlyWidget
"""Module contenant la classe mère :class:`BasePlotlyWidget`, permettant de centraliser des fonctions communes aux widgets Stand Alone."""
import json
from pathlib import Path
from typing import Optional
import plotly.graph_objects as go
from qtpy.QtCore import QUrl
from qtpy.QtGui import QCloseEvent
from qtpy.QtWidgets import QFileDialog, QTextBrowser, QWidget
from palm_tracer.Processing import Grapher
from palm_tracer.Tools import Ui
from palm_tracer.Tools.Ui import print_warning
# Tentative d'import QtWebEngine (via qtpy) —En cas d'UI defectueuse
try:
from qtpy.QtWebEngineWidgets import QWebEngineView # type: ignore
_HAS_WEBENGINE = True
except Exception:
QWebEngineView = None # type: ignore
_HAS_WEBENGINE = False
##################################################
[docs]
class BasePlotlyWidget(QWidget):
"""Classe mère avec les fonctions internes aux widgets Stand Alone (hors Napari)."""
PLOT_DIV_ID = "plotly_graph"
RESOURCE_DIR = Path(__file__).resolve().parent / "res"
PLOTLY_JS_PATH = RESOURCE_DIR / "plotly.min.js"
PLOTLY_JS_URL = "https://cdn.plot.ly/plotly-3.4.0.min.js"
##################################################
def __init__(self, parent: Optional[QWidget] = None):
"""
Construit le widget et initialise l'interface.
:param parent: Widget parent Qt, ou :obj:`None` si widget racine.
"""
super().__init__(parent)
self._pending_download_path: str = ""
self._grapher = Grapher()
self._graph_folder: Path = Path.cwd() / "image"
self._fig: go.Figure = Grapher.blank()
self._html: str = ""
self._plotly_page_loaded = False
self._web_page_ready = False
self._web = self._make_web_widget()
self._connect_web_widget()
# On applique un style général aux QPushButton
self.setStyleSheet(Ui.STYLESHEET_GENERAL)
# ==================================================
# region Web Widget (for Plotly)
# ==================================================
##################################################
def _make_web_widget(self):
"""
Créé un Widget pour integrer plotly
:return: QWebEngineView ou QTextBrowser si indisponible
"""
# Zone droite : QWebEngineView avec Plotly
if _HAS_WEBENGINE: res = QWebEngineView(self)
else: # pragma: no cover — Fallback affichant un message d'erreur explicite
res = QTextBrowser(self)
res.setText("<b>QtWebEngine unavailable</b><br>Install PyQtWebEngine for Plotly display.")
return res
##################################################
def _get_plotly_js_url(self) -> QUrl:
"""
Construit l'URL locale vers le fichier JavaScript Plotly embarqué.
Le fichier est recherché relativement à ce module, dans le dossier <c>res</c>.
Une URL de type <c>file:///...</c> est renvoyée afin de pouvoir être injectée dans la page HTML du :class:`QWebEngineView`.
:return: URL locale absolue vers <c>plotly.min.js</c>.
:raises FileNotFoundError: Si le fichier JavaScript local est introuvable.
"""
if not self.PLOTLY_JS_PATH.is_file():
raise FileNotFoundError(f"Plotly JavaScript file not found: {self.PLOTLY_JS_PATH}")
return QUrl.fromLocalFile(str(self.PLOTLY_JS_PATH.resolve()))
##################################################
def _build_plotly_shell_html(self) -> str:
"""
Construit la page HTML minimale contenant le conteneur Plotly.
La page est chargée une seule fois dans le :class:`QWebEngineView`,
puis les mises à jour utilisent :c:`Plotly.newPlot` ou :c:`Plotly.react` via du JavaScript injecté.
:return: HTML minimal servant de conteneur au graphe Plotly.
"""
config_json = json.dumps(Ui.CONFIG_PLOTLY)
plot_div_id_json = json.dumps(self.PLOT_DIV_ID)
try: url = self._get_plotly_js_url().toString()
except FileNotFoundError: url = self.PLOTLY_JS_URL
plotly_js_url_json = json.dumps(url)
return f"""<!DOCTYPE html>
<html>
<head>
<meta charset=\"utf-8\">
<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob: https:; script-src 'self'
'unsafe-inline' 'unsafe-eval' https:; style-src 'self' 'unsafe-inline' https:; img-src 'self' data: blob: https:;\">
<style>
html, body {{ margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: transparent; }}
#{self.PLOT_DIV_ID} {{ width: 100%; height: 100%; }}
</style>
</head>
<body>
<div id=\"{self.PLOT_DIV_ID}\"></div>
<script>
window._plotlyConfig = {config_json};
window._plotlyReady = false;
window._plotlyQueue = [];
window._flushPlotlyQueue = function() {{
if (!window._plotlyReady || typeof Plotly === "undefined") {{ return; }}
while (window._plotlyQueue.length > 0) {{
const callback = window._plotlyQueue.shift();
try {{ callback(); }} catch (error) {{ console.error("Plotly callback failed", error); }}
}}
}};
window._runWhenPlotlyReady = function(callback) {{
if (window._plotlyReady && typeof Plotly !== "undefined") {{ callback(); return; }}
window._plotlyQueue.push(callback);
}};
(function() {{
const script = document.createElement("script");
script.src = {plotly_js_url_json};
script.onload = function() {{ window._plotlyReady = true; window._flushPlotlyQueue(); }};
script.onerror = function() {{ console.error("Unable to load local Plotly JavaScript file."); }};
document.head.appendChild(script);
}})();
window._renderPlotlyFigure = function(data, layout, config) {{
window._runWhenPlotlyReady(function() {{
const gd = document.getElementById({plot_div_id_json});
if (!gd) {{ return; }}
if (!gd.data) {{ Plotly.newPlot(gd, data, layout, config); return; }}
Plotly.react(gd, data, layout, config);
}});
}};
</script>
</body>
</html>
"""
##################################################
def _load_plotly_page_once(self):
"""
Charge la page HTML minimale contenant Plotly une seule fois.
Le graphe lui-même n'est pas injecté ici. Il est créé ensuite via :c:`Plotly.newPlot`, puis mis à jour via :c:`Plotly.react`.
"""
if not (_HAS_WEBENGINE and isinstance(self._web, QWebEngineView)):
self._web.setText("<b>QtWebEngine unavailable</b><br>Install PyQtWebEngine for Plotly display.")
return
if self._plotly_page_loaded: return
try: html = self._build_plotly_shell_html()
except FileNotFoundError as error:
print_warning(str(error))
self._web.setText(f"<b>Plotly unavailable</b><br>{error}")
return
self._web.setHtml(html, QUrl.fromLocalFile(str(Path(__file__).resolve().parent)))
self._plotly_page_loaded = True
##################################################
def _update_web_widget(self):
"""
Met à jour le widget Web affichant la figure Plotly.
La page HTML contenant le conteneur Plotly n'est chargée qu'une seule fois.
Les mises à jour successives de la figure évitent ainsi de reconstruire toute la page et utilisent :c:`Plotly.react`,
ce qui réduit fortement le coût des rafraîchissements fréquents.
"""
try: plotly_js_url = self._get_plotly_js_url().toString()
except FileNotFoundError: plotly_js_url = self.PLOTLY_JS_URL
self._html = self._fig.to_html(include_plotlyjs=plotly_js_url, full_html=True, config=Ui.CONFIG_PLOTLY, div_id=self.PLOT_DIV_ID)
if not (_HAS_WEBENGINE and isinstance(self._web, QWebEngineView)):
self._web.setText("<b>QtWebEngine unavailable</b><br>Install PyQtWebEngine for Plotly display.")
return
self._load_plotly_page_once()
figure_dict = self._fig.to_plotly_json()
data_json = json.dumps(figure_dict.get("data", []))
layout_json = json.dumps(figure_dict.get("layout", {}))
config_json = json.dumps(Ui.CONFIG_PLOTLY)
js = f"""
(function() {{
const data = {data_json};
const layout = {layout_json};
const config = {config_json};
if (typeof window._renderPlotlyFigure !== "function") {{ return "Plotly renderer not ready"; }}
window._renderPlotlyFigure(data, layout, config);
return "queued";
}})();
"""
self._web.page().runJavaScript(js)
##################################################
def _connect_web_widget(self):
"""Connecte les signaux aux callbacks."""
if _HAS_WEBENGINE and isinstance(self._web, QWebEngineView):
profile = self._web.page().profile()
profile.downloadRequested.connect(self._on_download_requested)
self._web.loadFinished.connect(self._update_web_widget)
##################################################
def _on_download_requested(self, download):
"""Intercepte le téléchargement Plotly (Save image) pour demander explicitement où enregistrer le fichier."""
if not self._pending_download_path:
path, _ = QFileDialog.getSaveFileName(self, "Export the graph", str(self._graph_folder), "Images (*.png)")
if not path:
download.cancel()
return
else:
path = self._pending_download_path
self._pending_download_path = ""
path = Path(path)
self._graph_folder = path.parent
print(self._graph_folder)
# Qt6 : on règle le dossier + le nom de fichier séparément.
download.setDownloadDirectory(str(path.parent))
download.setDownloadFileName(path.name)
download.accept()
# ==================================================
# endregion Web Widget (for Plotly)
# ==================================================
# ==================================================
# region Export (for Plotly)
# ==================================================
##################################################
def _export_via_plotly_download(self, path: Path, fmt: str):
"""Export via Plotly.downloadImage (même mécanisme que le bouton caméra). Requiert QtWebEngine + QWebEngineView."""
if not (_HAS_WEBENGINE and isinstance(self._web, QWebEngineView)): raise RuntimeError("QtWebEngine is required for Plotly downloadImage export.")
# Plotly va initier un téléchargement -> on capte le prochain downloadRequested
self._pending_download_path = str(path)
# Paramètres cohérents avec toImageButtonOptions
opts = {"format": fmt, "filename": path.name, "height": 1200, "width": 1200, "scale": 2}
# NOTE: pour SVG, plotly ignore souvent scale (c'est vectoriel), width/height restent utiles.
js = f"""
(function() {{
const gd = document.getElementById({json.dumps(self.PLOT_DIV_ID)});
if (!gd || typeof Plotly === "undefined") {{return "Plotly not ready";}}
Plotly.downloadImage(gd, {json.dumps(opts)});
return "ok";
}})();
"""
self._web.page().runJavaScript(js)
##################################################
def _on_export(self):
"""
Ouvre un dialogue et exporte la figure selon l'extension choisie.
Formats supportés :
- .html : enregistre l'HTML interactif.
- .png : exporte une image du rendu Plotly.
- .svg : exporte une image vectorielle du rendu Plotly.
- .webp : exporte une image WebP du rendu Plotly.
- .pdf : imprime via QWebEngineView.printToPdf.
Comportement :
- En l'absence de figure/HTML, avertit l'utilisateur.
- Sur échec d'écriture, affiche un message d'erreur.
"""
if not (_HAS_WEBENGINE and isinstance(self._web, QWebEngineView)): raise RuntimeError("QtWebEngine is required for Plotly downloadImage export.")
if not self._html:
print_warning("No figures to export.")
return
path, selected_filter = QFileDialog.getSaveFileName(self, "Export the graph", str(self._graph_folder),
"PNG (*.png);;SVG (*.svg);;WEBP (*.webp);;HTML (*.html);;PDF (*.pdf)")
if not path: return
try:
lower = path.lower()
path = Path(path)
if lower.endswith(".png"): self._export_via_plotly_download(path, fmt="png")
elif lower.endswith(".svg"): self._export_via_plotly_download(path, fmt="svg")
elif lower.endswith(".webp"): self._export_via_plotly_download(path, fmt="webp")
elif lower.endswith(".html"):
with open(path, "w", encoding="utf-8") as f: f.write(self._html)
elif lower.endswith(".pdf"): self._web.page().printToPdf(str(path))
else: self._export_via_plotly_download(path, fmt="png") # Pas d'extension reconnue ⇾ PNG par défaut
except Exception as e: print_warning(f"Export failed : {e}")
# ==================================================
# endregion Export (for Plotly)
# ==================================================
##################################################
[docs]
def closeEvent(self, event: QCloseEvent) -> None:
"""Assure une destruction propre de QtWebEngine (évite warnings et crash à la sortie)."""
try:
if _HAS_WEBENGINE and hasattr(self, "_web") and isinstance(self._web, QWebEngineView):
page = self._web.page()
if page is not None:
# Arrête chargements / timers internes WebEngine.
page.triggerAction(page.WebAction.Stop)
self._web.setHtml("") # Optionnel: libère le contenu HTML pour réduire l'activité pendant le teardown.
# Déconnecte proprement le signal de download (évite callbacks tardifs).
try: page.profile().downloadRequested.disconnect(self._on_download_requested)
except Exception: pass
# Destruction différée (Qt-safe).
page.deleteLater()
self._web.deleteLater()
# On ne doit jamais crasher sur un closeEvent durant des tests.
except Exception: pass
super().closeEvent(event)
##################################################
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
w = BasePlotlyWidget()
w.resize(1280, 720)
w.show()
sys.exit(app.exec_())