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,228 @@
1
+ """The export dialog — image formats, size, DPI, or the generated Python code.
2
+
3
+ Exporting code sits in the same dialog as exporting an image on purpose. Reproducibility is a
4
+ headline feature, not a hidden menu item, and putting the two side by side makes "you can take
5
+ the script with you" discoverable.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ from PySide6.QtWidgets import (
13
+ QCheckBox,
14
+ QComboBox,
15
+ QDialog,
16
+ QDialogButtonBox,
17
+ QDoubleSpinBox,
18
+ QFileDialog,
19
+ QFormLayout,
20
+ QGroupBox,
21
+ QHBoxLayout,
22
+ QLabel,
23
+ QLineEdit,
24
+ QPushButton,
25
+ QRadioButton,
26
+ QSpinBox,
27
+ QVBoxLayout,
28
+ QWidget,
29
+ )
30
+
31
+ from ..core.exporter import DEFAULT_DPI, ExportOptions
32
+
33
+ #: Image formats offered, with a note on when each is the right choice.
34
+ _FORMATS = (
35
+ ("png", "PNG — raster, good for slides and documents"),
36
+ ("svg", "SVG — vector, scales without blurring"),
37
+ ("pdf", "PDF — vector, best for print and LaTeX"),
38
+ ("jpg", "JPEG — smaller files, lossy"),
39
+ ("webp", "WebP — small raster files for the web"),
40
+ )
41
+
42
+
43
+ class ExportDialog(QDialog):
44
+ """Collects export settings.
45
+
46
+ Args:
47
+ parent: Parent widget.
48
+ default_name: Suggested file stem, usually derived from the dataset name.
49
+ start_dir: Directory the file picker opens in.
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ parent: QWidget | None = None,
55
+ *,
56
+ default_name: str = "chart",
57
+ start_dir: str | Path = ".",
58
+ ) -> None:
59
+ super().__init__(parent)
60
+ self.setWindowTitle("Export")
61
+ self.setMinimumWidth(520)
62
+ self._start_dir = Path(start_dir)
63
+ self._default_name = default_name
64
+
65
+ layout = QVBoxLayout(self)
66
+
67
+ # --- what to export
68
+ what = QGroupBox("What to export", self)
69
+ what_layout = QVBoxLayout(what)
70
+ self.image_radio = QRadioButton("Image of the chart", what)
71
+ self.image_radio.setChecked(True)
72
+ self.code_radio = QRadioButton("Python code that reproduces it", what)
73
+ self.html_radio = QRadioButton("Interactive HTML page", what)
74
+ for radio in (self.image_radio, self.code_radio, self.html_radio):
75
+ radio.toggled.connect(self._sync_enabled)
76
+ what_layout.addWidget(radio)
77
+ code_note = QLabel(
78
+ "The generated script is standalone — it reads your data file, replays the exact "
79
+ "cleaning steps, and draws the chart. It does not import PlotaViz.",
80
+ what,
81
+ )
82
+ code_note.setWordWrap(True)
83
+ code_note.setStyleSheet("color: palette(mid); font-size: 11px;")
84
+ what_layout.addWidget(code_note)
85
+ layout.addWidget(what)
86
+
87
+ # --- image settings
88
+ self.image_box = QGroupBox("Image settings", self)
89
+ image_form = QFormLayout(self.image_box)
90
+
91
+ self.format_combo = QComboBox(self.image_box)
92
+ for value, label in _FORMATS:
93
+ self.format_combo.addItem(label, value)
94
+ self.format_combo.currentIndexChanged.connect(self._sync_enabled)
95
+ image_form.addRow("Format", self.format_combo)
96
+
97
+ self.dpi_spin = QSpinBox(self.image_box)
98
+ self.dpi_spin.setRange(72, 1200)
99
+ self.dpi_spin.setSingleStep(50)
100
+ self.dpi_spin.setValue(DEFAULT_DPI)
101
+ self.dpi_spin.setSuffix(" dpi")
102
+ image_form.addRow("Resolution", self.dpi_spin)
103
+
104
+ size_row = QHBoxLayout()
105
+ self.width_spin = QDoubleSpinBox(self.image_box)
106
+ self.width_spin.setRange(2.0, 40.0)
107
+ self.width_spin.setValue(10.0)
108
+ self.width_spin.setSuffix(" in")
109
+ self.height_spin = QDoubleSpinBox(self.image_box)
110
+ self.height_spin.setRange(2.0, 40.0)
111
+ self.height_spin.setValue(6.0)
112
+ self.height_spin.setSuffix(" in")
113
+ size_row.addWidget(self.width_spin)
114
+ size_row.addWidget(QLabel("×", self.image_box))
115
+ size_row.addWidget(self.height_spin)
116
+ size_row.addStretch(1)
117
+ image_form.addRow("Size", size_row)
118
+
119
+ self.transparent_check = QCheckBox("Transparent background", self.image_box)
120
+ image_form.addRow("", self.transparent_check)
121
+
122
+ self.notice_check = QCheckBox(
123
+ "Include the sampling notice when data was downsampled", self.image_box
124
+ )
125
+ self.notice_check.setChecked(True)
126
+ self.notice_check.setToolTip(
127
+ "An exported chart that hides its own sampling misleads whoever reads it. "
128
+ "Leave this on unless you have a reason not to."
129
+ )
130
+ image_form.addRow("", self.notice_check)
131
+ layout.addWidget(self.image_box)
132
+
133
+ # --- code settings
134
+ self.code_box = QGroupBox("Code settings", self)
135
+ code_form = QFormLayout(self.code_box)
136
+ self.flavour_combo = QComboBox(self.code_box)
137
+ self.flavour_combo.addItem("matplotlib — static figure, best for reports", "matplotlib")
138
+ self.flavour_combo.addItem("Plotly — interactive figure, best for notebooks", "plotly")
139
+ code_form.addRow("Library", self.flavour_combo)
140
+ layout.addWidget(self.code_box)
141
+
142
+ # --- destination
143
+ destination = QGroupBox("Save to", self)
144
+ dest_layout = QHBoxLayout(destination)
145
+ self.path_edit = QLineEdit(destination)
146
+ browse = QPushButton("Browse…", destination)
147
+ browse.clicked.connect(self._browse)
148
+ dest_layout.addWidget(self.path_edit, stretch=1)
149
+ dest_layout.addWidget(browse)
150
+ layout.addWidget(destination)
151
+
152
+ buttons = QDialogButtonBox(
153
+ QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel, self
154
+ )
155
+ buttons.accepted.connect(self.accept)
156
+ buttons.rejected.connect(self.reject)
157
+ layout.addWidget(buttons)
158
+
159
+ self._sync_enabled()
160
+
161
+ # ------------------------------------------------------------------ behaviour
162
+
163
+ @property
164
+ def mode(self) -> str:
165
+ """``"image"``, ``"code"``, or ``"html"``."""
166
+ if self.code_radio.isChecked():
167
+ return "code"
168
+ if self.html_radio.isChecked():
169
+ return "html"
170
+ return "image"
171
+
172
+ def _suggested_suffix(self) -> str:
173
+ """The extension implied by the current mode and format."""
174
+ if self.mode == "code":
175
+ return ".py"
176
+ if self.mode == "html":
177
+ return ".html"
178
+ return "." + str(self.format_combo.currentData())
179
+
180
+ def _sync_enabled(self, *_: object) -> None:
181
+ """Enable only the settings that apply to the selected mode, and fix the extension."""
182
+ is_image = self.mode == "image"
183
+ self.image_box.setEnabled(is_image)
184
+ self.code_box.setEnabled(self.mode == "code")
185
+
186
+ raster = str(self.format_combo.currentData()) in {"png", "jpg", "jpeg", "webp", "tiff"}
187
+ self.dpi_spin.setEnabled(is_image and raster)
188
+
189
+ current = self.path_edit.text().strip()
190
+ stem = Path(current).stem if current else self._default_name
191
+ parent = Path(current).parent if current else self._start_dir
192
+ self.path_edit.setText(str(Path(parent) / f"{stem}{self._suggested_suffix()}"))
193
+
194
+ def _browse(self) -> None:
195
+ """Open a save dialog seeded with the current path."""
196
+ suffix = self._suggested_suffix()
197
+ chosen, _ = QFileDialog.getSaveFileName(
198
+ self,
199
+ "Export",
200
+ self.path_edit.text() or str(self._start_dir / f"{self._default_name}{suffix}"),
201
+ f"*{suffix}",
202
+ )
203
+ if chosen:
204
+ self.path_edit.setText(chosen)
205
+
206
+ # ------------------------------------------------------------------ results
207
+
208
+ def path(self) -> Path:
209
+ """The destination the user chose, with the right extension enforced."""
210
+ raw = self.path_edit.text().strip() or f"{self._default_name}{self._suggested_suffix()}"
211
+ target = Path(raw).expanduser()
212
+ if target.suffix.lower() != self._suggested_suffix():
213
+ target = target.with_suffix(self._suggested_suffix())
214
+ return target
215
+
216
+ def options(self) -> ExportOptions:
217
+ """Image settings as an :class:`~plotaviz.core.exporter.ExportOptions`."""
218
+ return ExportOptions(
219
+ dpi=self.dpi_spin.value(),
220
+ width=self.width_spin.value(),
221
+ height=self.height_spin.value(),
222
+ transparent=self.transparent_check.isChecked(),
223
+ include_notice=self.notice_check.isChecked(),
224
+ )
225
+
226
+ def flavour(self) -> str:
227
+ """``"matplotlib"`` or ``"plotly"`` for code export."""
228
+ return str(self.flavour_combo.currentData())
@@ -0,0 +1,273 @@
1
+ """The filter panel — per-column widgets plus a raw pandas-query bar.
2
+
3
+ Filters are :class:`~plotaviz.core.preprocess.Step` objects, exactly like the cleaning steps.
4
+ That is not a detail: because a filter *is* a pipeline step, it serializes into sessions and
5
+ appears in generated code for free, with no separate code path and no chance of the exported
6
+ script disagreeing with what is on screen.
7
+
8
+ Re-rendering is debounced. Dragging a range slider fires continuously, and re-running a pipeline
9
+ plus a chart on every pixel is how an app earns a reputation for being slow.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from PySide6.QtCore import QTimer, Signal
15
+ from PySide6.QtWidgets import (
16
+ QCheckBox,
17
+ QDateEdit,
18
+ QDoubleSpinBox,
19
+ QFormLayout,
20
+ QGroupBox,
21
+ QHBoxLayout,
22
+ QLabel,
23
+ QLineEdit,
24
+ QListWidget,
25
+ QListWidgetItem,
26
+ QPushButton,
27
+ QScrollArea,
28
+ QVBoxLayout,
29
+ QWidget,
30
+ )
31
+
32
+ from ..core.preprocess import ColumnFilter, QueryFilter, Step
33
+ from ..core.profiler import BOOLEAN, CATEGORICAL, DATETIME, NUMERIC, DatasetProfile
34
+
35
+ #: Milliseconds of quiet before a filter change triggers a re-render.
36
+ DEBOUNCE_MS = 350
37
+
38
+ #: Categorical columns with more distinct values than this get a search box instead of a list.
39
+ MAX_LISTED_CATEGORIES = 200
40
+
41
+
42
+ class FilterPanel(QWidget):
43
+ """Builds filter widgets from a dataset profile.
44
+
45
+ Signals:
46
+ filters_changed: Emitted with a list of filter steps after the debounce interval.
47
+ """
48
+
49
+ filters_changed = Signal(list)
50
+
51
+ def __init__(self, parent: QWidget | None = None) -> None:
52
+ super().__init__(parent)
53
+ self._profile: DatasetProfile | None = None
54
+ self._numeric: dict[str, tuple[QDoubleSpinBox, QDoubleSpinBox]] = {}
55
+ self._categorical: dict[str, QListWidget] = {}
56
+ self._datetime: dict[str, tuple[QDateEdit, QDateEdit]] = {}
57
+ self._boolean: dict[str, QCheckBox] = {}
58
+
59
+ self._debounce = QTimer(self)
60
+ self._debounce.setSingleShot(True)
61
+ self._debounce.setInterval(DEBOUNCE_MS)
62
+ self._debounce.timeout.connect(self._emit_filters)
63
+
64
+ outer = QVBoxLayout(self)
65
+ outer.setContentsMargins(8, 8, 8, 8)
66
+
67
+ # --- free-text pandas query
68
+ query_box = QGroupBox("Query", self)
69
+ query_layout = QVBoxLayout(query_box)
70
+ self.query_edit = QLineEdit(self)
71
+ self.query_edit.setPlaceholderText("revenue > 1000 and region == 'EMEA'")
72
+ self.query_edit.setClearButtonEnabled(True)
73
+ self.query_edit.returnPressed.connect(self._emit_filters)
74
+ query_layout.addWidget(self.query_edit)
75
+ hint = QLabel("pandas query syntax. Press Enter to apply.", self)
76
+ hint.setStyleSheet("color: palette(mid); font-size: 11px;")
77
+ query_layout.addWidget(hint)
78
+ self.query_error = QLabel("", self)
79
+ self.query_error.setWordWrap(True)
80
+ self.query_error.setStyleSheet("color: #C0392B; font-size: 11px;")
81
+ self.query_error.hide()
82
+ query_layout.addWidget(self.query_error)
83
+ outer.addWidget(query_box)
84
+
85
+ # --- per-column widgets, scrollable
86
+ self._scroll = QScrollArea(self)
87
+ self._scroll.setWidgetResizable(True)
88
+ self._container = QWidget()
89
+ self._container_layout = QVBoxLayout(self._container)
90
+ self._container_layout.setContentsMargins(0, 0, 0, 0)
91
+ self._container_layout.addStretch(1)
92
+ self._scroll.setWidget(self._container)
93
+ outer.addWidget(self._scroll, stretch=1)
94
+
95
+ buttons = QHBoxLayout()
96
+ self.clear_button = QPushButton("Clear all filters", self)
97
+ self.clear_button.clicked.connect(self.clear)
98
+ buttons.addWidget(self.clear_button)
99
+ buttons.addStretch(1)
100
+ outer.addLayout(buttons)
101
+
102
+ # ------------------------------------------------------------------ population
103
+
104
+ def set_profile(self, profile: DatasetProfile) -> None:
105
+ """Rebuild the per-column widgets for a dataset."""
106
+ self._profile = profile
107
+ self._numeric.clear()
108
+ self._categorical.clear()
109
+ self._datetime.clear()
110
+ self._boolean.clear()
111
+
112
+ while self._container_layout.count():
113
+ item = self._container_layout.takeAt(0)
114
+ widget = item.widget()
115
+ if widget is not None:
116
+ widget.deleteLater()
117
+
118
+ for name, prof in profile.columns.items():
119
+ if prof.is_derived or prof.is_identifier:
120
+ continue
121
+ if prof.role == NUMERIC:
122
+ self._container_layout.addWidget(self._numeric_widget(name, prof))
123
+ elif prof.role == DATETIME:
124
+ self._container_layout.addWidget(self._datetime_widget(name, prof))
125
+ elif prof.role == BOOLEAN:
126
+ self._container_layout.addWidget(self._boolean_widget(name))
127
+ elif prof.role == CATEGORICAL and prof.n_unique <= MAX_LISTED_CATEGORIES:
128
+ self._container_layout.addWidget(self._categorical_widget(name, prof))
129
+
130
+ self._container_layout.addStretch(1)
131
+
132
+ def _numeric_widget(self, name: str, prof: object) -> QWidget:
133
+ """A low/high pair bounded by the column's actual range."""
134
+ stats = getattr(prof, "stats", {}) or {}
135
+ low_value = float(stats.get("min", 0.0))
136
+ high_value = float(stats.get("max", 0.0))
137
+ span = high_value - low_value or 1.0
138
+
139
+ box = QGroupBox(name, self)
140
+ form = QFormLayout(box)
141
+ low = QDoubleSpinBox(box)
142
+ high = QDoubleSpinBox(box)
143
+ for spin in (low, high):
144
+ spin.setRange(low_value - span, high_value + span)
145
+ spin.setDecimals(4 if span < 10 else 2)
146
+ spin.setSingleStep(span / 100.0)
147
+ spin.valueChanged.connect(self._schedule)
148
+ low.setValue(low_value)
149
+ high.setValue(high_value)
150
+ form.addRow("From", low)
151
+ form.addRow("To", high)
152
+ self._numeric[name] = (low, high)
153
+ return box
154
+
155
+ def _datetime_widget(self, name: str, prof: object) -> QWidget:
156
+ """A start/end date pair."""
157
+ box = QGroupBox(name, self)
158
+ form = QFormLayout(box)
159
+ start, end = QDateEdit(box), QDateEdit(box)
160
+ for edit in (start, end):
161
+ edit.setCalendarPopup(True)
162
+ edit.setDisplayFormat("yyyy-MM-dd")
163
+ edit.dateChanged.connect(self._schedule)
164
+ form.addRow("From", start)
165
+ form.addRow("To", end)
166
+ self._datetime[name] = (start, end)
167
+ return box
168
+
169
+ def _boolean_widget(self, name: str) -> QWidget:
170
+ """A single checkbox meaning "only rows where this is true"."""
171
+ box = QGroupBox(name, self)
172
+ layout = QVBoxLayout(box)
173
+ check = QCheckBox("Only true", box)
174
+ check.stateChanged.connect(self._schedule)
175
+ layout.addWidget(check)
176
+ self._boolean[name] = check
177
+ return box
178
+
179
+ def _categorical_widget(self, name: str, prof: object) -> QWidget:
180
+ """A multi-select list of the column's values, everything selected by default."""
181
+ box = QGroupBox(name, self)
182
+ layout = QVBoxLayout(box)
183
+ listing = QListWidget(box)
184
+ listing.setSelectionMode(QListWidget.SelectionMode.MultiSelection)
185
+ listing.setMaximumHeight(140)
186
+ for value, count in getattr(prof, "top_values", []) or []:
187
+ item = QListWidgetItem(f"{value} ({count:,})")
188
+ item.setData(0x0100, value) # Qt.UserRole
189
+ listing.addItem(item)
190
+ item.setSelected(True)
191
+ listing.itemSelectionChanged.connect(self._schedule)
192
+ layout.addWidget(listing)
193
+ self._categorical[name] = listing
194
+ return box
195
+
196
+ # ------------------------------------------------------------------ filter building
197
+
198
+ def filters(self) -> list[Step]:
199
+ """Build the current filter steps.
200
+
201
+ Widgets left at their full range contribute nothing, so an untouched panel produces an
202
+ empty list rather than a pile of no-op steps.
203
+ """
204
+ steps: list[Step] = []
205
+ profile = self._profile
206
+
207
+ expression = self.query_edit.text().strip()
208
+ if expression:
209
+ steps.append(QueryFilter(expression))
210
+
211
+ for name, (low, high) in self._numeric.items():
212
+ prof = profile.columns.get(name) if profile else None
213
+ stats = getattr(prof, "stats", {}) or {}
214
+ full_low, full_high = float(stats.get("min", 0.0)), float(stats.get("max", 0.0))
215
+ if low.value() > full_low or high.value() < full_high:
216
+ steps.append(ColumnFilter(name, "between", [low.value(), high.value()]))
217
+
218
+ for name, (start, end) in self._datetime.items():
219
+ if start.date() != start.minimumDate() or end.date() != end.maximumDate():
220
+ steps.append(
221
+ ColumnFilter(
222
+ name,
223
+ "between",
224
+ [start.date().toString("yyyy-MM-dd"), end.date().toString("yyyy-MM-dd")],
225
+ )
226
+ )
227
+
228
+ for name, listing in self._categorical.items():
229
+ selected = [item.data(0x0100) for item in listing.selectedItems()]
230
+ if selected and len(selected) < listing.count():
231
+ steps.append(ColumnFilter(name, "in", selected))
232
+
233
+ for name, check in self._boolean.items():
234
+ if check.isChecked():
235
+ steps.append(ColumnFilter(name, "in", [True]))
236
+
237
+ return steps
238
+
239
+ def clear(self) -> None:
240
+ """Reset every widget and emit an empty filter set."""
241
+ self.query_edit.clear()
242
+ self.query_error.hide()
243
+ for name, (low, high) in self._numeric.items():
244
+ prof = self._profile.columns.get(name) if self._profile else None
245
+ stats = getattr(prof, "stats", {}) or {}
246
+ low.setValue(float(stats.get("min", low.minimum())))
247
+ high.setValue(float(stats.get("max", high.maximum())))
248
+ for listing in self._categorical.values():
249
+ listing.selectAll()
250
+ for check in self._boolean.values():
251
+ check.setChecked(False)
252
+ self._emit_filters()
253
+
254
+ def show_query_error(self, message: str) -> None:
255
+ """Display a failed query message under the query bar."""
256
+ self.query_error.setText(message)
257
+ self.query_error.show()
258
+
259
+ def clear_query_error(self) -> None:
260
+ """Hide the query error."""
261
+ self.query_error.hide()
262
+
263
+ # ------------------------------------------------------------------ signalling
264
+
265
+ def _schedule(self, *_: object) -> None:
266
+ """Restart the debounce timer. Called by every widget's change signal."""
267
+ self._debounce.start()
268
+
269
+ def _emit_filters(self) -> None:
270
+ """Emit the current filter steps."""
271
+ self._debounce.stop()
272
+ self.clear_query_error()
273
+ self.filters_changed.emit(self.filters())