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.
- plotaviz/__init__.py +19 -0
- plotaviz/core/__init__.py +53 -0
- plotaviz/core/analysis.py +230 -0
- plotaviz/core/codegen.py +482 -0
- plotaviz/core/errors.py +65 -0
- plotaviz/core/exporter.py +145 -0
- plotaviz/core/llm/__init__.py +211 -0
- plotaviz/core/llm/anthropic.py +61 -0
- plotaviz/core/llm/base.py +373 -0
- plotaviz/core/llm/gemini.py +51 -0
- plotaviz/core/llm/ollama.py +113 -0
- plotaviz/core/llm/openai.py +58 -0
- plotaviz/core/loader.py +366 -0
- plotaviz/core/plotter.py +790 -0
- plotaviz/core/preprocess.py +836 -0
- plotaviz/core/profiler.py +509 -0
- plotaviz/core/rules.yaml +190 -0
- plotaviz/core/selector.py +495 -0
- plotaviz/core/session.py +174 -0
- plotaviz/core/spec.py +236 -0
- plotaviz/main.py +329 -0
- plotaviz/ui/__init__.py +23 -0
- plotaviz/ui/export_dialog.py +228 -0
- plotaviz/ui/filter_panel.py +273 -0
- plotaviz/ui/interactive_view.py +210 -0
- plotaviz/ui/main_window.py +859 -0
- plotaviz/ui/nl_query_bar.py +219 -0
- plotaviz/ui/settings_dialog.py +313 -0
- plotaviz/ui/table_model.py +184 -0
- plotaviz/ui/type_override.py +159 -0
- plotaviz/ui/workers.py +222 -0
- plotaviz-0.1.0.dist-info/METADATA +410 -0
- plotaviz-0.1.0.dist-info/RECORD +37 -0
- plotaviz-0.1.0.dist-info/WHEEL +5 -0
- plotaviz-0.1.0.dist-info/entry_points.txt +2 -0
- plotaviz-0.1.0.dist-info/licenses/LICENSE +21 -0
- plotaviz-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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())
|