plotaviz 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,210 @@
1
+ """The chart view — Plotly in a QWebEngineView, with a matplotlib fallback.
2
+
3
+ **Every QtWebEngine import in PlotaViz is inside this module.** That isolation is deliberate:
4
+ WebEngine drags in Chromium, which is most of the bundle size and nearly all of the packaging
5
+ pain (AppImage in particular). Keeping it behind one seam means it can be lazy-loaded, swapped,
6
+ or dropped entirely without touching the rest of the app.
7
+
8
+ When WebEngine is unavailable — a slim build, a headless box, a distro that ships PySide6 without
9
+ it — :class:`ChartView` silently falls back to a matplotlib canvas embedded in Qt. The user loses
10
+ hover and zoom, not the chart, and the status line says which renderer is active.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import contextlib
16
+ import tempfile
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ import pandas as pd
21
+ from PySide6.QtCore import Qt, QUrl
22
+ from PySide6.QtWidgets import QLabel, QSizePolicy, QVBoxLayout, QWidget
23
+
24
+ from ..core.errors import PlotError
25
+ from ..core.plotter import build_matplotlib, build_plotly, prepare
26
+ from ..core.spec import ChartSpec
27
+
28
+ #: Set once by :func:`webengine_available` so the probe import happens at most once.
29
+ _WEBENGINE_STATE: bool | None = None
30
+
31
+
32
+ def webengine_available() -> bool:
33
+ """Whether QtWebEngine can be imported in this environment.
34
+
35
+ The import is attempted once and the answer cached. A failure here is completely normal —
36
+ several Linux distributions package ``pyside6`` without ``pyside6-webengine``.
37
+ """
38
+ global _WEBENGINE_STATE
39
+ if _WEBENGINE_STATE is None:
40
+ try:
41
+ from PySide6.QtWebEngineWidgets import QWebEngineView # noqa: F401
42
+
43
+ _WEBENGINE_STATE = True
44
+ except Exception:
45
+ _WEBENGINE_STATE = False
46
+ return _WEBENGINE_STATE
47
+
48
+
49
+ class ChartView(QWidget):
50
+ """Displays a chart, interactively when it can and statically when it cannot.
51
+
52
+ Args:
53
+ parent: Parent widget.
54
+ prefer_static: Force the matplotlib canvas even when WebEngine is present. Useful for
55
+ very large charts and for users who dislike the browser view.
56
+ """
57
+
58
+ def __init__(self, parent: QWidget | None = None, *, prefer_static: bool = False) -> None:
59
+ super().__init__(parent)
60
+ self._layout = QVBoxLayout(self)
61
+ self._layout.setContentsMargins(0, 0, 0, 0)
62
+
63
+ self._interactive = webengine_available() and not prefer_static
64
+ self._temp_files: list[Path] = []
65
+ self._placeholder: QLabel | None = None
66
+ self._canvas: Any = None
67
+ self._web: Any = None
68
+ self.last_notes: list[str] = []
69
+
70
+ self._show_placeholder(
71
+ "Open a dataset to get started.\n\nDrag a CSV, Excel, JSON, or Parquet file here."
72
+ )
73
+
74
+ # ------------------------------------------------------------------ properties
75
+
76
+ @property
77
+ def renderer(self) -> str:
78
+ """``"interactive"`` (Plotly/WebEngine) or ``"static"`` (matplotlib)."""
79
+ return "interactive" if self._interactive else "static"
80
+
81
+ def renderer_note(self) -> str:
82
+ """One line for the status bar explaining what the user is looking at."""
83
+ if self._interactive:
84
+ return "Interactive chart — zoom, pan, hover, and toggle series in the legend."
85
+ if webengine_available():
86
+ return "Static chart (interactive view disabled in settings)."
87
+ return "Static chart — QtWebEngine is not installed, so interactive charts are unavailable."
88
+
89
+ def set_prefer_static(self, prefer_static: bool) -> None:
90
+ """Switch renderers. The caller re-renders afterwards."""
91
+ self._interactive = webengine_available() and not prefer_static
92
+
93
+ # ------------------------------------------------------------------ rendering
94
+
95
+ def render_spec(self, df: pd.DataFrame, spec: ChartSpec) -> list[str]:
96
+ """Draw a chart.
97
+
98
+ Args:
99
+ df: The cleaned, filtered data.
100
+ spec: What to draw.
101
+
102
+ Returns:
103
+ User-facing notes from preparation (sampling, category capping) so the caller can
104
+ surface them. They are also stored on :attr:`last_notes`.
105
+
106
+ Raises:
107
+ PlotError: If the chart cannot be built. The caller shows this in a dialog.
108
+ """
109
+ prepared = prepare(df, spec)
110
+ self.last_notes = list(prepared.notes)
111
+
112
+ if self._interactive:
113
+ try:
114
+ self._render_interactive(df, spec, prepared)
115
+ return self.last_notes
116
+ except PlotError:
117
+ raise
118
+ except Exception as exc:
119
+ self._interactive = False
120
+ self.last_notes.append(
121
+ f"The interactive view failed ({exc}); showing a static chart instead."
122
+ )
123
+
124
+ self._render_static(df, spec, prepared)
125
+ return self.last_notes
126
+
127
+ def _render_interactive(self, df: pd.DataFrame, spec: ChartSpec, prepared: Any) -> None:
128
+ """Render through Plotly into a QWebEngineView."""
129
+ from PySide6.QtWebEngineWidgets import QWebEngineView
130
+
131
+ figure = build_plotly(df, spec, prepared=prepared)
132
+
133
+ if not isinstance(self._web, QWebEngineView):
134
+ self._clear()
135
+ self._web = QWebEngineView(self)
136
+ self._web.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
137
+ self._layout.addWidget(self._web)
138
+
139
+ # Plotly's bundled JS is several megabytes and `setHtml` truncates above ~2 MB, so the
140
+ # figure goes to a temp file and is loaded as a URL instead.
141
+ with tempfile.NamedTemporaryFile(
142
+ mode="w", suffix=".html", prefix="plotaviz_", delete=False, encoding="utf-8"
143
+ ) as handle:
144
+ handle.write(
145
+ figure.to_html(include_plotlyjs=True, full_html=True, config={"responsive": True})
146
+ )
147
+ path = Path(handle.name)
148
+ self._temp_files.append(path)
149
+ self._web.load(QUrl.fromLocalFile(str(path)))
150
+ self._prune_temp_files()
151
+
152
+ def _render_static(self, df: pd.DataFrame, spec: ChartSpec, prepared: Any) -> None:
153
+ """Render through matplotlib into an embedded Qt canvas."""
154
+ from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg
155
+ from matplotlib.backends.backend_qtagg import (
156
+ NavigationToolbar2QT as NavigationToolbar,
157
+ )
158
+
159
+ figure = build_matplotlib(df, spec, prepared=prepared, figsize=(9, 5.5), dpi=100)
160
+
161
+ self._clear()
162
+ self._canvas = FigureCanvasQTAgg(figure)
163
+ self._canvas.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
164
+ toolbar = NavigationToolbar(self._canvas, self)
165
+ self._layout.addWidget(toolbar)
166
+ self._layout.addWidget(self._canvas)
167
+ self._canvas.draw_idle()
168
+
169
+ # ------------------------------------------------------------------ housekeeping
170
+
171
+ def _show_placeholder(self, text: str) -> None:
172
+ """Replace the chart with centred guidance text."""
173
+ self._clear()
174
+ self._placeholder = QLabel(text, self)
175
+ self._placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter)
176
+ self._placeholder.setWordWrap(True)
177
+ self._placeholder.setStyleSheet("color: palette(mid); font-size: 14px; padding: 40px;")
178
+ self._layout.addWidget(self._placeholder)
179
+
180
+ def show_message(self, text: str) -> None:
181
+ """Show a message in place of a chart (no data, or an error the user must act on)."""
182
+ self._show_placeholder(text)
183
+
184
+ def _clear(self) -> None:
185
+ """Tear down whatever widget is currently showing."""
186
+ for attr in ("_placeholder", "_canvas", "_web"):
187
+ widget = getattr(self, attr, None)
188
+ if widget is not None:
189
+ self._layout.removeWidget(widget)
190
+ widget.setParent(None)
191
+ widget.deleteLater()
192
+ setattr(self, attr, None)
193
+
194
+ def _prune_temp_files(self, keep: int = 2) -> None:
195
+ """Delete old temp HTML files, keeping the most recent few.
196
+
197
+ The current page cannot be deleted out from under WebEngine on Windows-style locking
198
+ semantics, so a small tail is retained rather than deleting eagerly.
199
+ """
200
+ while len(self._temp_files) > keep:
201
+ stale = self._temp_files.pop(0)
202
+ with contextlib.suppress(OSError):
203
+ stale.unlink(missing_ok=True)
204
+
205
+ def cleanup(self) -> None:
206
+ """Remove every temp file. Called when the window closes."""
207
+ for path in self._temp_files:
208
+ with contextlib.suppress(OSError):
209
+ path.unlink(missing_ok=True)
210
+ self._temp_files.clear()