qplotter 1.6.0b3__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.
- qplot/__init__.py +46 -0
- qplot/__main__.py +232 -0
- qplot/_auxiliary_paths.py +63 -0
- qplot/_metadata.py +12 -0
- qplot/_repair.py +14 -0
- qplot/_shutdown_supervisor.py +4049 -0
- qplot/_version.py +12 -0
- qplot/_windows_shutdown_job.py +1166 -0
- qplot/configuration/__init__.py +3 -0
- qplot/configuration/config.py +573 -0
- qplot/configuration/config_schema.json +319 -0
- qplot/configuration/scripts.py +255 -0
- qplot/configuration/themes/__init__.py +13 -0
- qplot/configuration/themes/_base.py +579 -0
- qplot/configuration/themes/blank.py +17 -0
- qplot/configuration/themes/dark.py +54 -0
- qplot/configuration/themes/light.py +54 -0
- qplot/datahandling/LoadFromDB.py +224 -0
- qplot/datahandling/__init__.py +47 -0
- qplot/datahandling/_trusted_live_helper.py +812 -0
- qplot/datahandling/_trusted_live_protocol.py +1745 -0
- qplot/datahandling/database.py +2330 -0
- qplot/datahandling/dimensions.py +43 -0
- qplot/datahandling/file_identity.py +461 -0
- qplot/datahandling/parameter_data.py +149 -0
- qplot/datahandling/plot_progress.py +17 -0
- qplot/datahandling/qcodes_cache.py +277 -0
- qplot/datahandling/qcodes_compat.py +14 -0
- qplot/datahandling/readSQL.py +2442 -0
- qplot/datahandling/readonly.py +2561 -0
- qplot/datahandling/source_activity.py +21 -0
- qplot/datahandling/trusted_array_preview.py +127 -0
- qplot/datahandling/trusted_derived_cache.py +1605 -0
- qplot/datahandling/trusted_derived_rendering.py +868 -0
- qplot/datahandling/trusted_heatmap.py +241 -0
- qplot/datahandling/trusted_live.py +2808 -0
- qplot/datahandling/trusted_live_queries.py +5907 -0
- qplot/datahandling/trusted_live_service.py +1894 -0
- qplot/datahandling/trusted_live_supervisor.py +2161 -0
- qplot/datahandling/trusted_plot.py +283 -0
- qplot/datahandling/trusted_presentation.py +1065 -0
- qplot/datahandling/trusted_snapshot.py +1132 -0
- qplot/datahandling/trusted_work_coordinator.py +2157 -0
- qplot/datahandling/trusted_work_scheduler.py +1204 -0
- qplot/diagnostics.py +241 -0
- qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
- qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
- qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
- qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
- qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
- qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
- qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
- qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
- qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
- qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
- qplot/testdata.py +1733 -0
- qplot/tools/__init__.py +13 -0
- qplot/tools/finite_means.py +114 -0
- qplot/tools/general.py +134 -0
- qplot/tools/heatmap_geometry.py +381 -0
- qplot/tools/operation_registry.py +179 -0
- qplot/tools/plot_tools.py +625 -0
- qplot/tools/sample_statistics.py +56 -0
- qplot/tools/worker.py +2787 -0
- qplot/windows/__init__.py +9 -0
- qplot/windows/_colorbar.py +422 -0
- qplot/windows/_commands.py +523 -0
- qplot/windows/_config_persistence.py +123 -0
- qplot/windows/_database_actions.py +4904 -0
- qplot/windows/_dataset_handle.py +221 -0
- qplot/windows/_dragdrop.py +71 -0
- qplot/windows/_export_paths.py +1167 -0
- qplot/windows/_help.py +169 -0
- qplot/windows/_native_averaging.py +173 -0
- qplot/windows/_native_transforms.py +389 -0
- qplot/windows/_plot1d_snap.py +608 -0
- qplot/windows/_plot1d_traces.py +2183 -0
- qplot/windows/_plot2d_colorbar.py +1126 -0
- qplot/windows/_plot2d_colorbar_dialog.py +786 -0
- qplot/windows/_plot2d_layers.py +2197 -0
- qplot/windows/_plot2d_sweeps.py +712 -0
- qplot/windows/_plotWin.py +1993 -0
- qplot/windows/_plot_actions.py +2695 -0
- qplot/windows/_plot_appearance.py +276 -0
- qplot/windows/_plot_axis_scaling.py +1485 -0
- qplot/windows/_plot_export.py +1445 -0
- qplot/windows/_plot_feedback.py +110 -0
- qplot/windows/_plot_marquee.py +656 -0
- qplot/windows/_plot_refresh.py +942 -0
- qplot/windows/_plot_state.py +239 -0
- qplot/windows/_plot_transform_labels.py +60 -0
- qplot/windows/_preferences.py +635 -0
- qplot/windows/_refresh_interval.py +70 -0
- qplot/windows/_run_controls.py +828 -0
- qplot/windows/_shortcuts.py +47 -0
- qplot/windows/_subplots/__init__.py +8 -0
- qplot/windows/_subplots/subplot1d.py +531 -0
- qplot/windows/_subplots/subplot2d.py +918 -0
- qplot/windows/_svg_export.py +209 -0
- qplot/windows/_trusted_derived_qt.py +1759 -0
- qplot/windows/_widgets/__init__.py +23 -0
- qplot/windows/_widgets/_run_formatting.py +387 -0
- qplot/windows/_widgets/details_tables.py +1119 -0
- qplot/windows/_widgets/dropbox.py +290 -0
- qplot/windows/_widgets/operations.py +460 -0
- qplot/windows/_widgets/preview.py +2997 -0
- qplot/windows/_widgets/run_list_items.py +488 -0
- qplot/windows/_widgets/toolbar.py +111 -0
- qplot/windows/_widgets/treeWidgets.py +2572 -0
- qplot/windows/_window_controls.py +413 -0
- qplot/windows/main.py +1635 -0
- qplot/windows/plot1d.py +311 -0
- qplot/windows/plot2d.py +1901 -0
- qplotter-1.6.0b3.dist-info/METADATA +318 -0
- qplotter-1.6.0b3.dist-info/RECORD +119 -0
- qplotter-1.6.0b3.dist-info/WHEEL +5 -0
- qplotter-1.6.0b3.dist-info/entry_points.txt +4 -0
- qplotter-1.6.0b3.dist-info/licenses/LICENSE +21 -0
- qplotter-1.6.0b3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2197 @@
|
|
|
1
|
+
"""Secondary heatmap layers for :mod:`qplot.windows.plot2d`.
|
|
2
|
+
|
|
3
|
+
The primary heatmap remains owned by ``plot2d``. This module supplies the
|
|
4
|
+
renderers and source-window lifecycle needed to place additional compatible
|
|
5
|
+
heatmaps in the same view, plus lightweight controls for opacity and removal.
|
|
6
|
+
All layers attached to one host deliberately share its colour map and levels.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, cast
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import pyqtgraph as pg
|
|
15
|
+
from PyQt6 import QtCore, QtGui
|
|
16
|
+
from PyQt6 import QtWidgets as qtw
|
|
17
|
+
|
|
18
|
+
from qplot.tools.heatmap_geometry import (
|
|
19
|
+
HeatmapGeometry,
|
|
20
|
+
canonicalize_heatmap_data,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from ._colorbar import (
|
|
24
|
+
_COLORBAR_COLORMAP_LABELS,
|
|
25
|
+
_colorbar_colormap_for_name,
|
|
26
|
+
_colorbar_colormap_preview,
|
|
27
|
+
)
|
|
28
|
+
from ._plot_appearance import ReorderAppearanceTable, configure_appearance_table
|
|
29
|
+
from ._plot_refresh import plot_refresh_required
|
|
30
|
+
from ._refresh_interval import refresh_interval_value, set_refresh_interval
|
|
31
|
+
|
|
32
|
+
DEFAULT_OVERLAY_OPACITY = 0.65
|
|
33
|
+
_PRIMARY_HEATMAP_KEY = "__qplot_primary_heatmap__"
|
|
34
|
+
_FIRST_OVERLAY_Z_VALUE = 0.1
|
|
35
|
+
_OVERLAY_Z_VALUE_STEP = 0.01
|
|
36
|
+
_MAX_OVERLAY_Z_VALUE = 0.89
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _heatmap_axis_order(
|
|
40
|
+
parent_options: dict[str, str],
|
|
41
|
+
source_options: dict[str, str],
|
|
42
|
+
) -> tuple[str, str] | None:
|
|
43
|
+
"""Map a source heatmap's axes onto a host with the same two axis names."""
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
parent_x = str(parent_options["x"])
|
|
47
|
+
parent_y = str(parent_options["y"])
|
|
48
|
+
source_x = str(source_options["x"])
|
|
49
|
+
source_y = str(source_options["y"])
|
|
50
|
+
except (KeyError, TypeError):
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
if (
|
|
54
|
+
not parent_x
|
|
55
|
+
or not parent_y
|
|
56
|
+
or not source_x
|
|
57
|
+
or not source_y
|
|
58
|
+
or parent_x == parent_y
|
|
59
|
+
or source_x == source_y
|
|
60
|
+
or {parent_x, parent_y} != {source_x, source_y}
|
|
61
|
+
):
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
if parent_x == source_x and parent_y == source_y:
|
|
65
|
+
return "x", "y"
|
|
66
|
+
if parent_x == source_y and parent_y == source_x:
|
|
67
|
+
return "y", "x"
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _window_display_unit(window: Any) -> str | None:
|
|
72
|
+
"""Return a plot's currently rendered dependent-value unit, when known."""
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
state = window.__dict__
|
|
76
|
+
except (AttributeError, RuntimeError):
|
|
77
|
+
state = {}
|
|
78
|
+
parameter = state.get("display_param") or state.get("param")
|
|
79
|
+
if parameter is None:
|
|
80
|
+
try:
|
|
81
|
+
parameter = getattr(window, "display_param", None) or getattr(
|
|
82
|
+
window,
|
|
83
|
+
"param",
|
|
84
|
+
None,
|
|
85
|
+
)
|
|
86
|
+
except RuntimeError:
|
|
87
|
+
parameter = None
|
|
88
|
+
if parameter is None:
|
|
89
|
+
return None
|
|
90
|
+
unit = getattr(parameter, "unit", None)
|
|
91
|
+
return None if unit is None else str(unit or "").strip()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _window_axis_unit(window: Any, parameter_name: str) -> str | None:
|
|
95
|
+
"""Return one independent parameter's unit without opening its dataset."""
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
parameters = window.__dict__.get("param_dict")
|
|
99
|
+
except (AttributeError, RuntimeError):
|
|
100
|
+
parameters = None
|
|
101
|
+
if not isinstance(parameters, dict):
|
|
102
|
+
try:
|
|
103
|
+
parameters = getattr(window, "param_dict", None)
|
|
104
|
+
except RuntimeError:
|
|
105
|
+
parameters = None
|
|
106
|
+
if not isinstance(parameters, dict):
|
|
107
|
+
return None
|
|
108
|
+
parameter = parameters.get(parameter_name)
|
|
109
|
+
if parameter is None:
|
|
110
|
+
return None
|
|
111
|
+
unit = getattr(parameter, "unit", None)
|
|
112
|
+
return None if unit is None else str(unit or "").strip()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _heatmap_layer_compatibility(
|
|
116
|
+
parent: Any,
|
|
117
|
+
source: Any,
|
|
118
|
+
) -> tuple[tuple[str, str] | None, str | None]:
|
|
119
|
+
"""Return source-axis mapping and any reason the maps cannot share a view."""
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
parent_options = parent.axis_options
|
|
123
|
+
source_options = source.axis_options
|
|
124
|
+
except (AttributeError, RuntimeError):
|
|
125
|
+
return None, "the heatmap axes are unavailable"
|
|
126
|
+
|
|
127
|
+
axis_order = _heatmap_axis_order(parent_options, source_options)
|
|
128
|
+
if axis_order is None:
|
|
129
|
+
return None, "the heatmap axes do not match"
|
|
130
|
+
|
|
131
|
+
parent_value_unit = _window_display_unit(parent)
|
|
132
|
+
source_value_unit = _window_display_unit(source)
|
|
133
|
+
if parent_value_unit is None or source_value_unit is None:
|
|
134
|
+
return None, "the displayed value units are unavailable"
|
|
135
|
+
if parent_value_unit != source_value_unit:
|
|
136
|
+
return None, "the displayed value units do not match"
|
|
137
|
+
|
|
138
|
+
for parent_axis, source_axis in zip(("x", "y"), axis_order, strict=True):
|
|
139
|
+
parameter_name = str(parent_options[parent_axis])
|
|
140
|
+
parent_axis_unit = _window_axis_unit(parent, parameter_name)
|
|
141
|
+
source_axis_name = str(source_options[source_axis])
|
|
142
|
+
source_axis_unit = _window_axis_unit(source, source_axis_name)
|
|
143
|
+
if parent_axis_unit is None or source_axis_unit is None:
|
|
144
|
+
return None, f"the {parameter_name} axis units are unavailable"
|
|
145
|
+
if parent_axis_unit != source_axis_unit:
|
|
146
|
+
return None, f"the {parameter_name} axis units do not match"
|
|
147
|
+
|
|
148
|
+
return axis_order, None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class HeatmapLayer:
|
|
152
|
+
"""A pair of alternate renderers backed by another ``plot2d`` window."""
|
|
153
|
+
|
|
154
|
+
def __init__(
|
|
155
|
+
self,
|
|
156
|
+
parent: Any,
|
|
157
|
+
from_win: Any,
|
|
158
|
+
*,
|
|
159
|
+
opacity: float = DEFAULT_OVERLAY_OPACITY,
|
|
160
|
+
z_value: float = _FIRST_OVERLAY_Z_VALUE,
|
|
161
|
+
) -> None:
|
|
162
|
+
self.parent = parent
|
|
163
|
+
self.from_win = from_win
|
|
164
|
+
try:
|
|
165
|
+
source_state = from_win.__dict__
|
|
166
|
+
except (AttributeError, RuntimeError):
|
|
167
|
+
source_state = {}
|
|
168
|
+
self.trace_key = source_state.get("_trace_key")
|
|
169
|
+
if self.trace_key is None:
|
|
170
|
+
self.trace_key = source_state.get("label")
|
|
171
|
+
if self.trace_key is None:
|
|
172
|
+
try:
|
|
173
|
+
self.trace_key = getattr(from_win, "_trace_key", None)
|
|
174
|
+
except RuntimeError:
|
|
175
|
+
self.trace_key = None
|
|
176
|
+
try:
|
|
177
|
+
source_label = getattr(from_win, "label", self.trace_key)
|
|
178
|
+
except RuntimeError:
|
|
179
|
+
source_label = self.trace_key
|
|
180
|
+
self.label = str(source_label)
|
|
181
|
+
self.running = plot_refresh_required(from_win)
|
|
182
|
+
self.geometry: HeatmapGeometry | None = None
|
|
183
|
+
self.axis_data: dict[str, np.ndarray] = {
|
|
184
|
+
"x": np.asarray([], dtype=float),
|
|
185
|
+
"y": np.asarray([], dtype=float),
|
|
186
|
+
}
|
|
187
|
+
self.data_grid = np.empty((0, 0), dtype=float)
|
|
188
|
+
self.compatibility_error: str | None = None
|
|
189
|
+
|
|
190
|
+
self.image = pg.ImageItem(axisOrder="row-major")
|
|
191
|
+
self.heatmap_mesh = pg.PColorMeshItem()
|
|
192
|
+
safe_z_value = min(
|
|
193
|
+
max(float(z_value), _FIRST_OVERLAY_Z_VALUE),
|
|
194
|
+
_MAX_OVERLAY_Z_VALUE,
|
|
195
|
+
)
|
|
196
|
+
self.image.setZValue(safe_z_value)
|
|
197
|
+
self.heatmap_mesh.setZValue(safe_z_value)
|
|
198
|
+
self.heatmap_mesh.hide()
|
|
199
|
+
|
|
200
|
+
self.opacity = DEFAULT_OVERLAY_OPACITY
|
|
201
|
+
self.visible = True
|
|
202
|
+
self.set_opacity(opacity)
|
|
203
|
+
|
|
204
|
+
self._renderers_added = False
|
|
205
|
+
self._source_consumer_registered = False
|
|
206
|
+
self._source_interval_signal = None
|
|
207
|
+
self._source_interval_slot = None
|
|
208
|
+
self._source_update_signal = None
|
|
209
|
+
self._source_end_wait_signal = None
|
|
210
|
+
self._source_end_wait_slot = None
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
self._add_renderers()
|
|
214
|
+
self._connect_source_updates()
|
|
215
|
+
self.refresh()
|
|
216
|
+
self._register_source_consumer()
|
|
217
|
+
except Exception:
|
|
218
|
+
self.disconnect_source_updates()
|
|
219
|
+
self.remove_renderers()
|
|
220
|
+
raise
|
|
221
|
+
|
|
222
|
+
def render_items(self) -> list[Any]:
|
|
223
|
+
"""Return both renderers controlled by the host colour bar."""
|
|
224
|
+
|
|
225
|
+
return [self.image, self.heatmap_mesh]
|
|
226
|
+
|
|
227
|
+
def set_opacity(self, opacity: float) -> None:
|
|
228
|
+
"""Set a finite opacity in the inclusive range from zero to one."""
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
value = float(opacity)
|
|
232
|
+
except (TypeError, ValueError):
|
|
233
|
+
value = DEFAULT_OVERLAY_OPACITY
|
|
234
|
+
if not np.isfinite(value):
|
|
235
|
+
value = DEFAULT_OVERLAY_OPACITY
|
|
236
|
+
value = min(max(value, 0.0), 1.0)
|
|
237
|
+
self.opacity = value
|
|
238
|
+
self.image.setOpacity(value)
|
|
239
|
+
self.heatmap_mesh.setOpacity(value)
|
|
240
|
+
|
|
241
|
+
def set_visible(self, visible: bool) -> None:
|
|
242
|
+
"""Persist visibility across source refreshes and renderer changes."""
|
|
243
|
+
|
|
244
|
+
self.visible = bool(visible)
|
|
245
|
+
if not self.visible:
|
|
246
|
+
self._hide_renderers()
|
|
247
|
+
return
|
|
248
|
+
self._render()
|
|
249
|
+
|
|
250
|
+
def refresh(
|
|
251
|
+
self,
|
|
252
|
+
*,
|
|
253
|
+
source_ready: bool = False,
|
|
254
|
+
sync_colorbar: bool = True,
|
|
255
|
+
) -> bool:
|
|
256
|
+
"""Copy ready source data, orient it to the host, and redraw the layer."""
|
|
257
|
+
|
|
258
|
+
self._disconnect_pending_end_wait()
|
|
259
|
+
self.running = plot_refresh_required(self.from_win)
|
|
260
|
+
|
|
261
|
+
axis_order, compatibility_error = _heatmap_layer_compatibility(
|
|
262
|
+
self.parent,
|
|
263
|
+
self.from_win,
|
|
264
|
+
)
|
|
265
|
+
if axis_order is None:
|
|
266
|
+
self._report_compatibility_error(compatibility_error)
|
|
267
|
+
self._clear_data()
|
|
268
|
+
if sync_colorbar:
|
|
269
|
+
self._sync_parent_colorbar()
|
|
270
|
+
return False
|
|
271
|
+
self.compatibility_error = None
|
|
272
|
+
|
|
273
|
+
worker = getattr(self.from_win, "worker", None)
|
|
274
|
+
if bool(getattr(worker, "running", False)) and not source_ready:
|
|
275
|
+
# A normal plot2d publishes trace_updated only after its concrete
|
|
276
|
+
# render. end_wait is a fallback for simpler legacy/test sources
|
|
277
|
+
# which do not expose that signal; base end_wait is otherwise too
|
|
278
|
+
# early because it fires before plot2d finishes rendering.
|
|
279
|
+
if self._source_update_signal is None:
|
|
280
|
+
self._connect_pending_end_wait()
|
|
281
|
+
return False
|
|
282
|
+
|
|
283
|
+
source_axis_data = getattr(self.from_win, "axis_data", None)
|
|
284
|
+
source_grid = getattr(self.from_win, "dataGrid", None)
|
|
285
|
+
if not isinstance(source_axis_data, dict) or source_grid is None:
|
|
286
|
+
self._clear_data()
|
|
287
|
+
if sync_colorbar:
|
|
288
|
+
self._sync_parent_colorbar()
|
|
289
|
+
return False
|
|
290
|
+
|
|
291
|
+
source_x_axis, source_y_axis = axis_order
|
|
292
|
+
try:
|
|
293
|
+
x_data = source_axis_data[source_x_axis]
|
|
294
|
+
y_data = source_axis_data[source_y_axis]
|
|
295
|
+
data_grid = np.asarray(source_grid)
|
|
296
|
+
if axis_order == ("y", "x"):
|
|
297
|
+
data_grid = data_grid.transpose()
|
|
298
|
+
x_data, y_data, data_grid = canonicalize_heatmap_data(
|
|
299
|
+
x_data,
|
|
300
|
+
y_data,
|
|
301
|
+
data_grid,
|
|
302
|
+
)
|
|
303
|
+
geometry = HeatmapGeometry.from_centres(x_data, y_data)
|
|
304
|
+
except (KeyError, TypeError, ValueError):
|
|
305
|
+
self._clear_data()
|
|
306
|
+
if sync_colorbar:
|
|
307
|
+
self._sync_parent_colorbar()
|
|
308
|
+
return False
|
|
309
|
+
|
|
310
|
+
self.axis_data = {
|
|
311
|
+
"x": np.asarray(x_data),
|
|
312
|
+
"y": np.asarray(y_data),
|
|
313
|
+
}
|
|
314
|
+
self.data_grid = np.asarray(data_grid)
|
|
315
|
+
self.geometry = geometry
|
|
316
|
+
self._render()
|
|
317
|
+
if sync_colorbar:
|
|
318
|
+
self._sync_parent_colorbar()
|
|
319
|
+
return True
|
|
320
|
+
|
|
321
|
+
def disconnect_source_updates(self) -> None:
|
|
322
|
+
"""Disconnect source signals and relinquish the hidden source window."""
|
|
323
|
+
|
|
324
|
+
if self._source_update_signal is not None:
|
|
325
|
+
try:
|
|
326
|
+
self._source_update_signal.disconnect(self._source_trace_updated)
|
|
327
|
+
except (TypeError, RuntimeError):
|
|
328
|
+
pass
|
|
329
|
+
self._source_update_signal = None
|
|
330
|
+
self._disconnect_pending_end_wait()
|
|
331
|
+
self._release_source_consumer()
|
|
332
|
+
|
|
333
|
+
def remove_renderers(self) -> None:
|
|
334
|
+
"""Remove this layer's graphics items from the host plot."""
|
|
335
|
+
|
|
336
|
+
for item in self.render_items():
|
|
337
|
+
item.hide()
|
|
338
|
+
if not self._renderers_added:
|
|
339
|
+
continue
|
|
340
|
+
remove_item = getattr(
|
|
341
|
+
self.parent,
|
|
342
|
+
"_remove_heatmap_render_item",
|
|
343
|
+
None,
|
|
344
|
+
)
|
|
345
|
+
if callable(remove_item):
|
|
346
|
+
remove_item(item)
|
|
347
|
+
continue
|
|
348
|
+
try:
|
|
349
|
+
self.parent.plot.removeItem(item)
|
|
350
|
+
except (AttributeError, RuntimeError, ValueError):
|
|
351
|
+
pass
|
|
352
|
+
self._renderers_added = False
|
|
353
|
+
|
|
354
|
+
def _add_renderers(self) -> None:
|
|
355
|
+
plot = getattr(self.parent, "plot", None)
|
|
356
|
+
add_item = getattr(plot, "addItem", None)
|
|
357
|
+
if not callable(add_item):
|
|
358
|
+
return
|
|
359
|
+
add_item(self.image)
|
|
360
|
+
try:
|
|
361
|
+
add_item(self.heatmap_mesh)
|
|
362
|
+
except Exception:
|
|
363
|
+
try:
|
|
364
|
+
plot.removeItem(self.image)
|
|
365
|
+
except (AttributeError, RuntimeError, ValueError):
|
|
366
|
+
pass
|
|
367
|
+
raise
|
|
368
|
+
self._renderers_added = True
|
|
369
|
+
|
|
370
|
+
def _connect_source_updates(self) -> None:
|
|
371
|
+
signal = getattr(self.from_win, "trace_updated", None)
|
|
372
|
+
connect = getattr(signal, "connect", None)
|
|
373
|
+
if not callable(connect):
|
|
374
|
+
return
|
|
375
|
+
connect(self._source_trace_updated)
|
|
376
|
+
self._source_update_signal = signal
|
|
377
|
+
|
|
378
|
+
def _source_trace_updated(self) -> None:
|
|
379
|
+
self.refresh(source_ready=True)
|
|
380
|
+
sync_view = getattr(
|
|
381
|
+
self.parent,
|
|
382
|
+
"_sync_secondary_heatmap_view_ranges",
|
|
383
|
+
None,
|
|
384
|
+
)
|
|
385
|
+
if callable(sync_view):
|
|
386
|
+
sync_view(layer=self)
|
|
387
|
+
|
|
388
|
+
def _source_end_wait_finished(self) -> None:
|
|
389
|
+
self.refresh(source_ready=True)
|
|
390
|
+
|
|
391
|
+
def _connect_pending_end_wait(self) -> None:
|
|
392
|
+
if self._source_end_wait_signal is not None:
|
|
393
|
+
return
|
|
394
|
+
signal = getattr(self.from_win, "end_wait", None)
|
|
395
|
+
connect = getattr(signal, "connect", None)
|
|
396
|
+
if not callable(connect):
|
|
397
|
+
return
|
|
398
|
+
slot = self._source_end_wait_finished
|
|
399
|
+
connect(slot)
|
|
400
|
+
self._source_end_wait_signal = signal
|
|
401
|
+
self._source_end_wait_slot = slot
|
|
402
|
+
|
|
403
|
+
def _disconnect_pending_end_wait(self) -> None:
|
|
404
|
+
if self._source_end_wait_signal is None:
|
|
405
|
+
return
|
|
406
|
+
try:
|
|
407
|
+
self._source_end_wait_signal.disconnect(self._source_end_wait_slot)
|
|
408
|
+
except (TypeError, RuntimeError):
|
|
409
|
+
pass
|
|
410
|
+
self._source_end_wait_signal = None
|
|
411
|
+
self._source_end_wait_slot = None
|
|
412
|
+
|
|
413
|
+
def _register_source_consumer(self) -> None:
|
|
414
|
+
if self._source_consumer_registered:
|
|
415
|
+
return
|
|
416
|
+
|
|
417
|
+
source = self.from_win
|
|
418
|
+
source._merged_trace_users = (
|
|
419
|
+
max(
|
|
420
|
+
int(getattr(source, "_merged_trace_users", 0)),
|
|
421
|
+
0,
|
|
422
|
+
)
|
|
423
|
+
+ 1
|
|
424
|
+
)
|
|
425
|
+
self._source_consumer_registered = True
|
|
426
|
+
|
|
427
|
+
if not getattr(source, "visible", True):
|
|
428
|
+
parent_spinbox = getattr(self.parent, "spinBox", None)
|
|
429
|
+
source_spinbox = getattr(source, "spinBox", None)
|
|
430
|
+
if parent_spinbox is not None and source_spinbox is not None:
|
|
431
|
+
def sync_interval(interval):
|
|
432
|
+
interval = refresh_interval_value(parent_spinbox)
|
|
433
|
+
set_refresh_interval(source_spinbox, interval)
|
|
434
|
+
source.monitorIntervalChanged(interval)
|
|
435
|
+
|
|
436
|
+
sync_interval(refresh_interval_value(parent_spinbox))
|
|
437
|
+
interval_signal = getattr(parent_spinbox, "valueChanged", None)
|
|
438
|
+
if interval_signal is not None:
|
|
439
|
+
interval_slot = sync_interval
|
|
440
|
+
interval_signal.connect(interval_slot)
|
|
441
|
+
self._source_interval_signal = interval_signal
|
|
442
|
+
self._source_interval_slot = interval_slot
|
|
443
|
+
|
|
444
|
+
monitor = getattr(source, "monitor", None)
|
|
445
|
+
if (
|
|
446
|
+
getattr(source, "_closed", False)
|
|
447
|
+
and plot_refresh_required(source)
|
|
448
|
+
and monitor is not None
|
|
449
|
+
and not monitor.isActive()
|
|
450
|
+
):
|
|
451
|
+
source.monitorIntervalChanged(refresh_interval_value(source.spinBox))
|
|
452
|
+
|
|
453
|
+
def _release_source_consumer(self) -> None:
|
|
454
|
+
if not self._source_consumer_registered:
|
|
455
|
+
return
|
|
456
|
+
|
|
457
|
+
source = self.from_win
|
|
458
|
+
remaining = max(int(getattr(source, "_merged_trace_users", 1)) - 1, 0)
|
|
459
|
+
source._merged_trace_users = remaining
|
|
460
|
+
self._source_consumer_registered = False
|
|
461
|
+
|
|
462
|
+
if self._source_interval_signal is not None:
|
|
463
|
+
try:
|
|
464
|
+
self._source_interval_signal.disconnect(self._source_interval_slot)
|
|
465
|
+
except (TypeError, RuntimeError):
|
|
466
|
+
pass
|
|
467
|
+
self._source_interval_signal = None
|
|
468
|
+
self._source_interval_slot = None
|
|
469
|
+
|
|
470
|
+
if (
|
|
471
|
+
remaining == 0
|
|
472
|
+
and getattr(source, "_closed", False)
|
|
473
|
+
and not getattr(source, "visible", True)
|
|
474
|
+
):
|
|
475
|
+
monitor = getattr(source, "monitor", None)
|
|
476
|
+
if monitor is not None:
|
|
477
|
+
monitor.stop()
|
|
478
|
+
timer = source.__dict__.get("_heatmap_view_reload_timer")
|
|
479
|
+
if timer is not None:
|
|
480
|
+
timer.stop()
|
|
481
|
+
source._refresh_pending = False
|
|
482
|
+
source._refresh_pending_force = False
|
|
483
|
+
worker = source.__dict__.get("worker")
|
|
484
|
+
cancel = getattr(worker, "cancel", None)
|
|
485
|
+
if bool(getattr(worker, "running", False)) and callable(cancel):
|
|
486
|
+
cancel()
|
|
487
|
+
|
|
488
|
+
def _render(self) -> None:
|
|
489
|
+
geometry = self.geometry
|
|
490
|
+
if geometry is None or not self.visible:
|
|
491
|
+
self._hide_renderers()
|
|
492
|
+
return
|
|
493
|
+
|
|
494
|
+
if geometry.is_uniform:
|
|
495
|
+
self.image.setImage(np.asarray(self.data_grid, dtype=float), autoLevels=False)
|
|
496
|
+
self.image.setRect(QtCore.QRectF(*geometry.rect))
|
|
497
|
+
self.heatmap_mesh.hide()
|
|
498
|
+
self.image.show()
|
|
499
|
+
return
|
|
500
|
+
|
|
501
|
+
mesh_data = np.asarray(self.data_grid, dtype=float).copy()
|
|
502
|
+
mesh_data[~np.isfinite(mesh_data)] = np.nan
|
|
503
|
+
x_vertices, y_vertices = np.meshgrid(
|
|
504
|
+
geometry.x.edges,
|
|
505
|
+
geometry.y.edges,
|
|
506
|
+
indexing="xy",
|
|
507
|
+
)
|
|
508
|
+
self.heatmap_mesh.setData(
|
|
509
|
+
x_vertices,
|
|
510
|
+
y_vertices,
|
|
511
|
+
mesh_data,
|
|
512
|
+
autoLevels=False,
|
|
513
|
+
)
|
|
514
|
+
self.image.hide()
|
|
515
|
+
self.heatmap_mesh.show()
|
|
516
|
+
|
|
517
|
+
def _clear_data(self) -> None:
|
|
518
|
+
self.geometry = None
|
|
519
|
+
self.axis_data = {
|
|
520
|
+
"x": np.asarray([], dtype=float),
|
|
521
|
+
"y": np.asarray([], dtype=float),
|
|
522
|
+
}
|
|
523
|
+
self.data_grid = np.empty((0, 0), dtype=float)
|
|
524
|
+
self._hide_renderers()
|
|
525
|
+
|
|
526
|
+
def _hide_renderers(self) -> None:
|
|
527
|
+
self.image.hide()
|
|
528
|
+
self.heatmap_mesh.hide()
|
|
529
|
+
|
|
530
|
+
def _sync_parent_colorbar(self) -> None:
|
|
531
|
+
sync = getattr(self.parent, "_sync_heatmap_colorbar_items", None)
|
|
532
|
+
if callable(sync):
|
|
533
|
+
sync(rescale=self._parent_refresh_colorbar_enabled())
|
|
534
|
+
|
|
535
|
+
def _parent_refresh_colorbar_enabled(self) -> bool:
|
|
536
|
+
try:
|
|
537
|
+
checkbox = self.parent.__dict__.get("relevel_refresh")
|
|
538
|
+
except (AttributeError, RuntimeError):
|
|
539
|
+
checkbox = None
|
|
540
|
+
is_checked = getattr(checkbox, "isChecked", None)
|
|
541
|
+
return bool(is_checked()) if callable(is_checked) else False
|
|
542
|
+
|
|
543
|
+
def _report_compatibility_error(self, error: str | None) -> None:
|
|
544
|
+
if not error or error == self.compatibility_error:
|
|
545
|
+
return
|
|
546
|
+
self.compatibility_error = error
|
|
547
|
+
show_status = getattr(self.parent, "show_status", None)
|
|
548
|
+
if callable(show_status):
|
|
549
|
+
show_status(f"{self.label} hidden because {error}.", 5000)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
class Plot2DLayerMixin:
|
|
553
|
+
"""Registry, controls, colour-bar sync, and cleanup for heatmap layers."""
|
|
554
|
+
|
|
555
|
+
heatmaps: dict[Any, Any]
|
|
556
|
+
|
|
557
|
+
def initMenu(self) -> None:
|
|
558
|
+
"""Add Heatmap Appearance to the standard View menu."""
|
|
559
|
+
|
|
560
|
+
super().initMenu()
|
|
561
|
+
menu_bar = self.menuBar()
|
|
562
|
+
if menu_bar is None:
|
|
563
|
+
return
|
|
564
|
+
view_menu = None
|
|
565
|
+
for action in menu_bar.actions():
|
|
566
|
+
if action.text().replace("&", "") == "View":
|
|
567
|
+
view_menu = action.menu()
|
|
568
|
+
break
|
|
569
|
+
if view_menu is None:
|
|
570
|
+
return
|
|
571
|
+
view_menu.addSeparator()
|
|
572
|
+
action = QtGui.QAction(
|
|
573
|
+
"Heatmap Appearance…",
|
|
574
|
+
cast(QtCore.QObject, self),
|
|
575
|
+
)
|
|
576
|
+
action.triggered.connect(self.open_heatmap_appearance_dialog)
|
|
577
|
+
view_menu.addAction(action)
|
|
578
|
+
self.__dict__["heatmap_appearance_action"] = action
|
|
579
|
+
|
|
580
|
+
def open_heatmap_appearance_dialog(self, heatmap_key: Any = None) -> None:
|
|
581
|
+
"""Show the shared-item-style editor for this plot's heatmap layers."""
|
|
582
|
+
|
|
583
|
+
dialog = self.__dict__.get("_heatmap_appearance_dialog")
|
|
584
|
+
if dialog is None:
|
|
585
|
+
dialog = _HeatmapAppearanceDialog(self)
|
|
586
|
+
self.__dict__["_heatmap_appearance_dialog"] = dialog
|
|
587
|
+
dialog.refresh_rows()
|
|
588
|
+
if heatmap_key is not None:
|
|
589
|
+
dialog.select_heatmap(heatmap_key)
|
|
590
|
+
dialog.show()
|
|
591
|
+
dialog.raise_()
|
|
592
|
+
dialog.activateWindow()
|
|
593
|
+
|
|
594
|
+
def available_heatmap_candidates(self) -> list[tuple[str, Any]]:
|
|
595
|
+
"""Return database-backed heatmaps eligible for the Add control."""
|
|
596
|
+
|
|
597
|
+
provider = self.__dict__.get("_heatmap_candidate_provider")
|
|
598
|
+
if callable(provider):
|
|
599
|
+
return list(provider())
|
|
600
|
+
return []
|
|
601
|
+
|
|
602
|
+
def add_heatmap_from_dialog(self, label: str, heatmap_key: Any) -> bool:
|
|
603
|
+
"""Ask the main window to construct and retain one selected layer."""
|
|
604
|
+
|
|
605
|
+
request = self.__dict__.get("_heatmap_add_request")
|
|
606
|
+
dataset_key = getattr(heatmap_key, "dataset_key", None)
|
|
607
|
+
parameter_name = getattr(heatmap_key, "parameter_name", None)
|
|
608
|
+
if callable(request) and dataset_key is not None and parameter_name:
|
|
609
|
+
return bool(request(dataset_key, parameter_name))
|
|
610
|
+
return False
|
|
611
|
+
|
|
612
|
+
def _heatmap_display_label(self, key: Any, layer: Any) -> str:
|
|
613
|
+
if layer is self:
|
|
614
|
+
return str(self.__dict__.get("label", key))
|
|
615
|
+
return str(getattr(layer, "label", key))
|
|
616
|
+
|
|
617
|
+
def _heatmap_measurement_name(self, key: Any, layer: Any) -> str:
|
|
618
|
+
source = self if layer is self else getattr(layer, "from_win", None)
|
|
619
|
+
parameter = getattr(source, "param", None)
|
|
620
|
+
return str(getattr(parameter, "name", key))
|
|
621
|
+
|
|
622
|
+
def _heatmap_is_visible(self, layer: Any) -> bool:
|
|
623
|
+
if layer is self:
|
|
624
|
+
return bool(self.__dict__.get("_primary_heatmap_visible", True))
|
|
625
|
+
return bool(getattr(layer, "visible", True))
|
|
626
|
+
|
|
627
|
+
def _heatmap_opacity(self, layer: Any) -> float:
|
|
628
|
+
if layer is self:
|
|
629
|
+
return float(self.__dict__.get("_primary_heatmap_opacity", 1.0))
|
|
630
|
+
return float(getattr(layer, "opacity", DEFAULT_OVERLAY_OPACITY))
|
|
631
|
+
|
|
632
|
+
def _set_layer_visibility(self, layer: Any, visible: bool) -> None:
|
|
633
|
+
if layer is not self:
|
|
634
|
+
setter = getattr(layer, "set_visible", None)
|
|
635
|
+
if callable(setter):
|
|
636
|
+
setter(visible)
|
|
637
|
+
return
|
|
638
|
+
|
|
639
|
+
visible = bool(visible)
|
|
640
|
+
self.__dict__["_primary_heatmap_visible"] = visible
|
|
641
|
+
if not visible:
|
|
642
|
+
for item in (
|
|
643
|
+
self.__dict__.get("image"),
|
|
644
|
+
self.__dict__.get("heatmap_mesh"),
|
|
645
|
+
):
|
|
646
|
+
if item is not None:
|
|
647
|
+
item.hide()
|
|
648
|
+
return
|
|
649
|
+
render = getattr(self, "_render_heatmap", None)
|
|
650
|
+
geometry = getattr(self, "_heatmap_geometry", None)
|
|
651
|
+
if callable(render) and callable(geometry) and geometry() is not None:
|
|
652
|
+
render()
|
|
653
|
+
|
|
654
|
+
def _sync_heatmap_layer_order(self) -> None:
|
|
655
|
+
"""Map table/registry order onto heatmap renderer Z values."""
|
|
656
|
+
|
|
657
|
+
heatmaps = self.__dict__.get("heatmaps", {})
|
|
658
|
+
count = len(heatmaps)
|
|
659
|
+
if not count:
|
|
660
|
+
return
|
|
661
|
+
step = _MAX_OVERLAY_Z_VALUE / max(count, 1)
|
|
662
|
+
for row, layer in enumerate(heatmaps.values()):
|
|
663
|
+
z_value = min(
|
|
664
|
+
_MAX_OVERLAY_Z_VALUE,
|
|
665
|
+
max(0.0, (row + 1) * step),
|
|
666
|
+
)
|
|
667
|
+
items = (
|
|
668
|
+
(
|
|
669
|
+
self.__dict__.get("image"),
|
|
670
|
+
self.__dict__.get("heatmap_mesh"),
|
|
671
|
+
)
|
|
672
|
+
if layer is self
|
|
673
|
+
else tuple(layer.render_items())
|
|
674
|
+
)
|
|
675
|
+
for item in items:
|
|
676
|
+
if item is not None:
|
|
677
|
+
item.setZValue(z_value)
|
|
678
|
+
|
|
679
|
+
def _default_heatmap_axis_names(self) -> tuple[str, str] | None:
|
|
680
|
+
names = tuple(getattr(self.param, "depends_on_", ()))
|
|
681
|
+
if len(names) != 2 or names[0] == names[1]:
|
|
682
|
+
return None
|
|
683
|
+
return str(names[1]), str(names[0])
|
|
684
|
+
|
|
685
|
+
def can_swap_plot_axes(self) -> bool:
|
|
686
|
+
names = self._default_heatmap_axis_names()
|
|
687
|
+
dropdowns = self.__dict__.get("axis_dropdown", {})
|
|
688
|
+
return bool(
|
|
689
|
+
names is not None
|
|
690
|
+
and set(dropdowns) == {"x", "y"}
|
|
691
|
+
and all(
|
|
692
|
+
dropdown.findText(name) >= 0
|
|
693
|
+
for dropdown in dropdowns.values()
|
|
694
|
+
for name in names
|
|
695
|
+
)
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
def plot_axes_swapped(self) -> bool:
|
|
699
|
+
names = self._default_heatmap_axis_names()
|
|
700
|
+
if names is None:
|
|
701
|
+
return False
|
|
702
|
+
default_x, default_y = names
|
|
703
|
+
options = self.axis_options
|
|
704
|
+
return (
|
|
705
|
+
options.get("x") == default_y
|
|
706
|
+
and options.get("y") == default_x
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
def set_plot_axes_swapped(self, swapped: bool) -> bool:
|
|
710
|
+
if not self.can_swap_plot_axes():
|
|
711
|
+
return False
|
|
712
|
+
names = self._default_heatmap_axis_names()
|
|
713
|
+
assert names is not None
|
|
714
|
+
default_x, default_y = names
|
|
715
|
+
target = (
|
|
716
|
+
{"x": default_y, "y": default_x}
|
|
717
|
+
if swapped
|
|
718
|
+
else {"x": default_x, "y": default_y}
|
|
719
|
+
)
|
|
720
|
+
previous = dict(self.axis_options)
|
|
721
|
+
if previous == target:
|
|
722
|
+
return True
|
|
723
|
+
for axis, name in target.items():
|
|
724
|
+
dropdown = self.axis_dropdown[axis]
|
|
725
|
+
blocked = dropdown.blockSignals(True)
|
|
726
|
+
try:
|
|
727
|
+
dropdown.setCurrentIndex(dropdown.findText(name))
|
|
728
|
+
finally:
|
|
729
|
+
dropdown.blockSignals(blocked)
|
|
730
|
+
self._axis_selection = dict(target)
|
|
731
|
+
if previous == {"x": target["y"], "y": target["x"]}:
|
|
732
|
+
# Blocked dropdown signals bypass the ordinary axis-change handler.
|
|
733
|
+
# Its pending rotation keeps existing cut markers on their fixed
|
|
734
|
+
# parameter when the refreshed heatmap exchanges X and Y.
|
|
735
|
+
self.__dict__["rotate"] = True
|
|
736
|
+
self._transpose_heatmap_axis_assignments()
|
|
737
|
+
self.refreshWindow(force=True)
|
|
738
|
+
dialog = self.__dict__.get("_heatmap_appearance_dialog")
|
|
739
|
+
if dialog is not None:
|
|
740
|
+
dialog.sync_swap_axes_control()
|
|
741
|
+
return True
|
|
742
|
+
|
|
743
|
+
def _transpose_heatmap_axis_assignments(self) -> None:
|
|
744
|
+
"""Keep heatmaps on equivalent physical sides after swapping X/Y."""
|
|
745
|
+
|
|
746
|
+
assignments = self.__dict__.get("_heatmap_axis_assignments", {})
|
|
747
|
+
for assignment in assignments.values():
|
|
748
|
+
previous_x = assignment.get("x", "Bottom")
|
|
749
|
+
previous_y = assignment.get("y", "Left")
|
|
750
|
+
assignment["x"] = "Bottom" if previous_y == "Left" else "Top"
|
|
751
|
+
assignment["y"] = "Left" if previous_x == "Bottom" else "Right"
|
|
752
|
+
for layer in self.__dict__.get("heatmaps", {}).values():
|
|
753
|
+
self._apply_heatmap_axis_assignment(layer, auto_range=False)
|
|
754
|
+
|
|
755
|
+
@staticmethod
|
|
756
|
+
def _window_heatmap_key(window: Any) -> Any:
|
|
757
|
+
try:
|
|
758
|
+
state = window.__dict__
|
|
759
|
+
except (AttributeError, RuntimeError):
|
|
760
|
+
state = {}
|
|
761
|
+
if "_trace_key" in state:
|
|
762
|
+
return state["_trace_key"]
|
|
763
|
+
if "label" in state:
|
|
764
|
+
return state["label"]
|
|
765
|
+
try:
|
|
766
|
+
return getattr(window, "_trace_key", None) or getattr(
|
|
767
|
+
window,
|
|
768
|
+
"label",
|
|
769
|
+
None,
|
|
770
|
+
)
|
|
771
|
+
except RuntimeError:
|
|
772
|
+
return None
|
|
773
|
+
|
|
774
|
+
def _init_heatmap_layers(self) -> None:
|
|
775
|
+
"""Register the primary heatmap and its appearance state."""
|
|
776
|
+
|
|
777
|
+
primary_key = self._window_heatmap_key(self)
|
|
778
|
+
if primary_key is None:
|
|
779
|
+
primary_key = _PRIMARY_HEATMAP_KEY
|
|
780
|
+
self._primary_heatmap_key = primary_key
|
|
781
|
+
|
|
782
|
+
heatmaps = self.__dict__.get("heatmaps")
|
|
783
|
+
if not isinstance(heatmaps, dict):
|
|
784
|
+
heatmaps = {}
|
|
785
|
+
self.__dict__["heatmaps"] = heatmaps
|
|
786
|
+
heatmaps.setdefault(primary_key, self)
|
|
787
|
+
self.__dict__.setdefault("_primary_heatmap_opacity", 1.0)
|
|
788
|
+
self.__dict__.setdefault("_primary_heatmap_visible", True)
|
|
789
|
+
self.__dict__.setdefault("_heatmap_appearance_dialog", None)
|
|
790
|
+
assignments = self.__dict__.get("_heatmap_axis_assignments")
|
|
791
|
+
if not isinstance(assignments, dict):
|
|
792
|
+
assignments = {}
|
|
793
|
+
self.__dict__["_heatmap_axis_assignments"] = assignments
|
|
794
|
+
assignments.setdefault(
|
|
795
|
+
primary_key,
|
|
796
|
+
{"x": "Bottom", "y": "Left"},
|
|
797
|
+
)
|
|
798
|
+
renderer_viewboxes = self.__dict__.get("_heatmap_renderer_viewboxes")
|
|
799
|
+
if not isinstance(renderer_viewboxes, dict):
|
|
800
|
+
renderer_viewboxes = {}
|
|
801
|
+
self.__dict__["_heatmap_renderer_viewboxes"] = renderer_viewboxes
|
|
802
|
+
primary_viewbox = self.__dict__.get("vb")
|
|
803
|
+
if primary_viewbox is not None:
|
|
804
|
+
for item in self._primary_heatmap_axis_items():
|
|
805
|
+
renderer_viewboxes.setdefault(id(item), primary_viewbox)
|
|
806
|
+
|
|
807
|
+
self._install_heatmap_double_click_handlers(
|
|
808
|
+
self,
|
|
809
|
+
primary_key,
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
def _has_heatmap_window(self, window: Any) -> bool:
|
|
813
|
+
"""Return whether ``window`` is already represented on this heatmap."""
|
|
814
|
+
|
|
815
|
+
candidate_key = self._window_heatmap_key(window)
|
|
816
|
+
for key, layer in self.__dict__.get("heatmaps", {}).items():
|
|
817
|
+
if candidate_key is not None and key == candidate_key:
|
|
818
|
+
return True
|
|
819
|
+
source = layer if layer is self else getattr(layer, "from_win", None)
|
|
820
|
+
if source is window:
|
|
821
|
+
return True
|
|
822
|
+
if (
|
|
823
|
+
candidate_key is not None
|
|
824
|
+
and source is not None
|
|
825
|
+
and self._window_heatmap_key(source) == candidate_key
|
|
826
|
+
):
|
|
827
|
+
return True
|
|
828
|
+
return False
|
|
829
|
+
|
|
830
|
+
def add_heatmap(self, from_win: Any) -> bool:
|
|
831
|
+
"""Add a compatible source heatmap as a translucent plot layer."""
|
|
832
|
+
|
|
833
|
+
if not isinstance(self.__dict__.get("heatmaps"), dict):
|
|
834
|
+
self._init_heatmap_layers()
|
|
835
|
+
if from_win is None or from_win is self or self._has_heatmap_window(from_win):
|
|
836
|
+
return False
|
|
837
|
+
|
|
838
|
+
_axis_order, compatibility_error = _heatmap_layer_compatibility(
|
|
839
|
+
self,
|
|
840
|
+
from_win,
|
|
841
|
+
)
|
|
842
|
+
if compatibility_error is not None:
|
|
843
|
+
show_status = getattr(self, "show_status", None)
|
|
844
|
+
if callable(show_status):
|
|
845
|
+
show_status(
|
|
846
|
+
f"Cannot add {getattr(from_win, 'label', 'heatmap')}; "
|
|
847
|
+
f"{compatibility_error}.",
|
|
848
|
+
5000,
|
|
849
|
+
)
|
|
850
|
+
return False
|
|
851
|
+
|
|
852
|
+
dataset_key = getattr(from_win, "_dataset_key", None)
|
|
853
|
+
try:
|
|
854
|
+
make_ds = getattr(self, "make_ds", None)
|
|
855
|
+
except RuntimeError:
|
|
856
|
+
make_ds = None
|
|
857
|
+
emit_make_ds = getattr(make_ds, "emit", None)
|
|
858
|
+
retained = dataset_key is not None and callable(emit_make_ds)
|
|
859
|
+
if retained:
|
|
860
|
+
emit_make_ds(dataset_key)
|
|
861
|
+
|
|
862
|
+
trace_key = self._window_heatmap_key(from_win)
|
|
863
|
+
if trace_key is None:
|
|
864
|
+
trace_key = id(from_win)
|
|
865
|
+
overlay_count = sum(1 for layer in self.heatmaps.values() if layer is not self)
|
|
866
|
+
z_value = min(
|
|
867
|
+
_FIRST_OVERLAY_Z_VALUE + overlay_count * _OVERLAY_Z_VALUE_STEP,
|
|
868
|
+
_MAX_OVERLAY_Z_VALUE,
|
|
869
|
+
)
|
|
870
|
+
layer = None
|
|
871
|
+
try:
|
|
872
|
+
layer = HeatmapLayer(
|
|
873
|
+
self,
|
|
874
|
+
from_win,
|
|
875
|
+
opacity=DEFAULT_OVERLAY_OPACITY,
|
|
876
|
+
z_value=z_value,
|
|
877
|
+
)
|
|
878
|
+
self.heatmaps[trace_key] = layer
|
|
879
|
+
self._heatmap_axis_assignments[trace_key] = {
|
|
880
|
+
"x": "Bottom",
|
|
881
|
+
"y": "Left",
|
|
882
|
+
}
|
|
883
|
+
self._install_heatmap_double_click_handlers(layer, trace_key)
|
|
884
|
+
self._apply_heatmap_axis_assignment(layer, auto_range=False)
|
|
885
|
+
self._sync_heatmap_layer_order()
|
|
886
|
+
self._sync_heatmap_colorbar_items(rescale=True)
|
|
887
|
+
self._sync_secondary_heatmap_view_ranges(layer=layer)
|
|
888
|
+
except Exception:
|
|
889
|
+
if layer is not None:
|
|
890
|
+
layer.disconnect_source_updates()
|
|
891
|
+
layer.remove_renderers()
|
|
892
|
+
self.heatmaps.pop(trace_key, None)
|
|
893
|
+
self.__dict__.get("_heatmap_axis_assignments", {}).pop(
|
|
894
|
+
trace_key,
|
|
895
|
+
None,
|
|
896
|
+
)
|
|
897
|
+
if retained:
|
|
898
|
+
self._emit_remove_dataset(dataset_key)
|
|
899
|
+
raise
|
|
900
|
+
dialog = self.__dict__.get("_heatmap_appearance_dialog")
|
|
901
|
+
if dialog is not None:
|
|
902
|
+
dialog.refresh_rows()
|
|
903
|
+
return True
|
|
904
|
+
|
|
905
|
+
def remove_heatmap(self, label: str = "", trace_key: Any = None) -> bool:
|
|
906
|
+
"""Remove one secondary heatmap selected by stable key or display label."""
|
|
907
|
+
|
|
908
|
+
heatmaps = self.__dict__.get("heatmaps")
|
|
909
|
+
if not isinstance(heatmaps, dict):
|
|
910
|
+
return False
|
|
911
|
+
|
|
912
|
+
selected_key = trace_key
|
|
913
|
+
layer = heatmaps.get(selected_key) if selected_key is not None else None
|
|
914
|
+
if layer is self:
|
|
915
|
+
return False
|
|
916
|
+
if layer is None:
|
|
917
|
+
for key, candidate in heatmaps.items():
|
|
918
|
+
if candidate is self:
|
|
919
|
+
continue
|
|
920
|
+
if label and str(getattr(candidate, "label", "")) == str(label):
|
|
921
|
+
selected_key = key
|
|
922
|
+
layer = candidate
|
|
923
|
+
break
|
|
924
|
+
if layer is None or selected_key is None:
|
|
925
|
+
return False
|
|
926
|
+
|
|
927
|
+
heatmaps.pop(selected_key, None)
|
|
928
|
+
self.__dict__.get("_heatmap_axis_assignments", {}).pop(
|
|
929
|
+
selected_key,
|
|
930
|
+
None,
|
|
931
|
+
)
|
|
932
|
+
layer.disconnect_source_updates()
|
|
933
|
+
layer.remove_renderers()
|
|
934
|
+
dataset_key = getattr(getattr(layer, "from_win", None), "_dataset_key", None)
|
|
935
|
+
if dataset_key is not None:
|
|
936
|
+
self._emit_remove_dataset(dataset_key)
|
|
937
|
+
self._sync_heatmap_layer_order()
|
|
938
|
+
self._sync_heatmap_axis_visibility()
|
|
939
|
+
self._sync_heatmap_colorbar_items(rescale=True)
|
|
940
|
+
dialog = self.__dict__.get("_heatmap_appearance_dialog")
|
|
941
|
+
if dialog is not None:
|
|
942
|
+
dialog.refresh_rows()
|
|
943
|
+
return True
|
|
944
|
+
|
|
945
|
+
def refresh_secondary_heatmaps(self) -> None:
|
|
946
|
+
"""Refresh layers and restart monitors retained by hidden live sources."""
|
|
947
|
+
|
|
948
|
+
for layer in list(self.__dict__.get("heatmaps", {}).values()):
|
|
949
|
+
if layer is self:
|
|
950
|
+
continue
|
|
951
|
+
layer.refresh(sync_colorbar=False)
|
|
952
|
+
source = layer.from_win
|
|
953
|
+
monitor = getattr(source, "monitor", None)
|
|
954
|
+
if (
|
|
955
|
+
not getattr(source, "visible", True)
|
|
956
|
+
and plot_refresh_required(source)
|
|
957
|
+
and monitor is not None
|
|
958
|
+
and not monitor.isActive()
|
|
959
|
+
):
|
|
960
|
+
source.monitorIntervalChanged(refresh_interval_value(source.spinBox))
|
|
961
|
+
|
|
962
|
+
def _sync_secondary_heatmap_view_ranges(
|
|
963
|
+
self,
|
|
964
|
+
*,
|
|
965
|
+
layer: Any = None,
|
|
966
|
+
) -> None:
|
|
967
|
+
"""Map the host viewport onto SQL-backed hidden heatmap sources."""
|
|
968
|
+
|
|
969
|
+
if layer is None:
|
|
970
|
+
layers = [
|
|
971
|
+
candidate
|
|
972
|
+
for candidate in self.__dict__.get("heatmaps", {}).values()
|
|
973
|
+
if candidate is not self
|
|
974
|
+
]
|
|
975
|
+
else:
|
|
976
|
+
layers = [layer]
|
|
977
|
+
|
|
978
|
+
for candidate in layers:
|
|
979
|
+
source = getattr(candidate, "from_win", None)
|
|
980
|
+
if (
|
|
981
|
+
source is None
|
|
982
|
+
or not source.__dict__.get("_large_heatmap_sql_mode", False)
|
|
983
|
+
or getattr(source, "visible", True)
|
|
984
|
+
):
|
|
985
|
+
continue
|
|
986
|
+
axis_order, compatibility_error = _heatmap_layer_compatibility(
|
|
987
|
+
self,
|
|
988
|
+
source,
|
|
989
|
+
)
|
|
990
|
+
if axis_order is None or compatibility_error is not None:
|
|
991
|
+
continue
|
|
992
|
+
|
|
993
|
+
view_box = self._heatmap_axis_viewbox(candidate)
|
|
994
|
+
view_range = getattr(view_box, "viewRange", None)
|
|
995
|
+
if not callable(view_range):
|
|
996
|
+
continue
|
|
997
|
+
try:
|
|
998
|
+
host_x_range, host_y_range = view_range()
|
|
999
|
+
except (TypeError, ValueError, RuntimeError):
|
|
1000
|
+
continue
|
|
1001
|
+
host_ranges = {
|
|
1002
|
+
"x": tuple(host_x_range),
|
|
1003
|
+
"y": tuple(host_y_range),
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
source_ranges = {
|
|
1007
|
+
axis_order[index]: host_ranges[parent_axis]
|
|
1008
|
+
for index, parent_axis in enumerate(("x", "y"))
|
|
1009
|
+
}
|
|
1010
|
+
source_view_box = source.__dict__.get("vb")
|
|
1011
|
+
set_range = getattr(source_view_box, "setRange", None)
|
|
1012
|
+
schedule_reload = getattr(
|
|
1013
|
+
source,
|
|
1014
|
+
"_schedule_visible_heatmap_reload",
|
|
1015
|
+
None,
|
|
1016
|
+
)
|
|
1017
|
+
if not callable(set_range) or not callable(schedule_reload):
|
|
1018
|
+
continue
|
|
1019
|
+
|
|
1020
|
+
source.__dict__["_heatmap_layer_view_sync_active"] = True
|
|
1021
|
+
try:
|
|
1022
|
+
set_range(
|
|
1023
|
+
xRange=source_ranges["x"],
|
|
1024
|
+
yRange=source_ranges["y"],
|
|
1025
|
+
padding=0,
|
|
1026
|
+
)
|
|
1027
|
+
schedule_reload()
|
|
1028
|
+
finally:
|
|
1029
|
+
source.__dict__["_heatmap_layer_view_sync_active"] = False
|
|
1030
|
+
|
|
1031
|
+
def close_secondary_heatmaps(self) -> None:
|
|
1032
|
+
"""Disconnect and remove every secondary layer while keeping the primary."""
|
|
1033
|
+
|
|
1034
|
+
heatmaps = self.__dict__.get("heatmaps", {})
|
|
1035
|
+
secondary_keys = [
|
|
1036
|
+
key for key, layer in list(heatmaps.items()) if layer is not self
|
|
1037
|
+
]
|
|
1038
|
+
for key in secondary_keys:
|
|
1039
|
+
self.remove_heatmap(trace_key=key)
|
|
1040
|
+
|
|
1041
|
+
def _heatmap_colorbar_items(self) -> list[Any]:
|
|
1042
|
+
"""Return both alternate renderers for the primary and every layer."""
|
|
1043
|
+
|
|
1044
|
+
layers = list(self.__dict__.get("heatmaps", {}).values())
|
|
1045
|
+
if not layers:
|
|
1046
|
+
layers = [self]
|
|
1047
|
+
elif self not in layers:
|
|
1048
|
+
layers.insert(0, self)
|
|
1049
|
+
|
|
1050
|
+
items: list[Any] = []
|
|
1051
|
+
seen: set[int] = set()
|
|
1052
|
+
for layer in layers:
|
|
1053
|
+
if layer is self:
|
|
1054
|
+
candidates = [
|
|
1055
|
+
self.__dict__.get("image"),
|
|
1056
|
+
self.__dict__.get("heatmap_mesh"),
|
|
1057
|
+
]
|
|
1058
|
+
else:
|
|
1059
|
+
render_items = getattr(layer, "render_items", None)
|
|
1060
|
+
if callable(render_items):
|
|
1061
|
+
candidates = render_items()
|
|
1062
|
+
else:
|
|
1063
|
+
candidates = [
|
|
1064
|
+
getattr(layer, "image", None),
|
|
1065
|
+
getattr(layer, "heatmap_mesh", None),
|
|
1066
|
+
]
|
|
1067
|
+
for item in candidates:
|
|
1068
|
+
if item is None or id(item) in seen:
|
|
1069
|
+
continue
|
|
1070
|
+
seen.add(id(item))
|
|
1071
|
+
items.append(item)
|
|
1072
|
+
return items
|
|
1073
|
+
|
|
1074
|
+
def _heatmap_colorbar_data_arrays(self) -> list[np.ndarray]:
|
|
1075
|
+
"""Return primary and secondary data arrays used for colour autoscaling."""
|
|
1076
|
+
|
|
1077
|
+
arrays: list[np.ndarray] = []
|
|
1078
|
+
primary_data = self.__dict__.get("dataGrid")
|
|
1079
|
+
if primary_data is not None:
|
|
1080
|
+
arrays.append(np.asarray(primary_data))
|
|
1081
|
+
|
|
1082
|
+
for layer in self.__dict__.get("heatmaps", {}).values():
|
|
1083
|
+
if layer is self:
|
|
1084
|
+
continue
|
|
1085
|
+
data = getattr(layer, "data_grid", None)
|
|
1086
|
+
if data is not None:
|
|
1087
|
+
arrays.append(np.asarray(data))
|
|
1088
|
+
return arrays
|
|
1089
|
+
|
|
1090
|
+
def _sync_heatmap_colorbar_items(self, *, rescale: bool = False) -> None:
|
|
1091
|
+
"""Rebind renderers and apply manual or union-autoscaled colour levels."""
|
|
1092
|
+
|
|
1093
|
+
bar = self.__dict__.get("bar")
|
|
1094
|
+
if bar is None:
|
|
1095
|
+
return
|
|
1096
|
+
|
|
1097
|
+
previous_levels = self._valid_colorbar_levels(bar)
|
|
1098
|
+
items = self._heatmap_colorbar_items()
|
|
1099
|
+
item_ids = tuple(id(item) for item in items)
|
|
1100
|
+
if item_ids != self.__dict__.get("_heatmap_colorbar_item_ids"):
|
|
1101
|
+
set_image_item = getattr(bar, "setImageItem", None)
|
|
1102
|
+
if callable(set_image_item):
|
|
1103
|
+
self._disconnect_heatmap_colorbar_items(bar)
|
|
1104
|
+
set_image_item(items)
|
|
1105
|
+
self.__dict__["_heatmap_colorbar_item_ids"] = item_ids
|
|
1106
|
+
|
|
1107
|
+
manual_levels = self.__dict__.get("_colorbar_manual_levels")
|
|
1108
|
+
levels = None
|
|
1109
|
+
if manual_levels is not None:
|
|
1110
|
+
levels = self._normalise_colorbar_levels(manual_levels)
|
|
1111
|
+
elif rescale:
|
|
1112
|
+
levels = self._heatmap_union_colorbar_levels()
|
|
1113
|
+
if levels is None:
|
|
1114
|
+
levels = previous_levels
|
|
1115
|
+
if levels is None:
|
|
1116
|
+
return
|
|
1117
|
+
|
|
1118
|
+
set_levels = getattr(self, "_set_colorbar_levels", None)
|
|
1119
|
+
if callable(set_levels):
|
|
1120
|
+
set_levels(*levels)
|
|
1121
|
+
else:
|
|
1122
|
+
bar_set_levels = getattr(bar, "setLevels", None)
|
|
1123
|
+
if callable(bar_set_levels):
|
|
1124
|
+
bar_set_levels(levels)
|
|
1125
|
+
|
|
1126
|
+
@staticmethod
|
|
1127
|
+
def _disconnect_heatmap_colorbar_items(bar: Any) -> None:
|
|
1128
|
+
"""Remove connections that pyqtgraph leaves behind when rebinding."""
|
|
1129
|
+
|
|
1130
|
+
handler = getattr(bar, "_levelsChangedHandler", None)
|
|
1131
|
+
if handler is None:
|
|
1132
|
+
return
|
|
1133
|
+
for item_reference in list(getattr(bar, "img_list", ())):
|
|
1134
|
+
try:
|
|
1135
|
+
item = item_reference()
|
|
1136
|
+
except TypeError:
|
|
1137
|
+
item = item_reference
|
|
1138
|
+
signal = getattr(item, "sigLevelsChanged", None)
|
|
1139
|
+
disconnect = getattr(signal, "disconnect", None)
|
|
1140
|
+
if not callable(disconnect):
|
|
1141
|
+
continue
|
|
1142
|
+
try:
|
|
1143
|
+
disconnect(handler)
|
|
1144
|
+
except (TypeError, RuntimeError):
|
|
1145
|
+
pass
|
|
1146
|
+
|
|
1147
|
+
def _set_layer_opacity(self, layer: Any, opacity: float) -> None:
|
|
1148
|
+
if layer is not self:
|
|
1149
|
+
set_opacity = getattr(layer, "set_opacity", None)
|
|
1150
|
+
if callable(set_opacity):
|
|
1151
|
+
set_opacity(opacity)
|
|
1152
|
+
return
|
|
1153
|
+
|
|
1154
|
+
value = min(max(float(opacity), 0.0), 1.0)
|
|
1155
|
+
self.__dict__["_primary_heatmap_opacity"] = value
|
|
1156
|
+
for item in (
|
|
1157
|
+
self.__dict__.get("image"),
|
|
1158
|
+
self.__dict__.get("heatmap_mesh"),
|
|
1159
|
+
):
|
|
1160
|
+
if item is not None:
|
|
1161
|
+
item.setOpacity(value)
|
|
1162
|
+
|
|
1163
|
+
def _heatmap_key_for_layer(self, layer: Any) -> Any:
|
|
1164
|
+
"""Return the stable registry key for a heatmap object."""
|
|
1165
|
+
|
|
1166
|
+
for key, candidate in self.__dict__.get("heatmaps", {}).items():
|
|
1167
|
+
if candidate is layer:
|
|
1168
|
+
return key
|
|
1169
|
+
return None
|
|
1170
|
+
|
|
1171
|
+
def _install_heatmap_double_click_handlers(
|
|
1172
|
+
self,
|
|
1173
|
+
layer: Any,
|
|
1174
|
+
layer_key: Any,
|
|
1175
|
+
) -> None:
|
|
1176
|
+
"""Bind double-click handlers so a heatmap image opens its appearance row."""
|
|
1177
|
+
|
|
1178
|
+
for item in self._heatmap_render_items(layer):
|
|
1179
|
+
item._qplot_heatmap_key = layer_key
|
|
1180
|
+
if getattr(item, "_qplot_heatmap_double_click_handler", False):
|
|
1181
|
+
continue
|
|
1182
|
+
|
|
1183
|
+
previous_handler = getattr(item, "mouseDoubleClickEvent", None)
|
|
1184
|
+
|
|
1185
|
+
def mouse_double_click(
|
|
1186
|
+
event,
|
|
1187
|
+
target=item,
|
|
1188
|
+
previous_double_click=previous_handler,
|
|
1189
|
+
):
|
|
1190
|
+
button = getattr(event, "button", lambda: None)()
|
|
1191
|
+
if button == QtCore.Qt.MouseButton.LeftButton:
|
|
1192
|
+
heatmap_key = getattr(
|
|
1193
|
+
target,
|
|
1194
|
+
"_qplot_heatmap_key",
|
|
1195
|
+
layer_key,
|
|
1196
|
+
)
|
|
1197
|
+
opener = getattr(
|
|
1198
|
+
self,
|
|
1199
|
+
"open_heatmap_appearance_dialog",
|
|
1200
|
+
None,
|
|
1201
|
+
)
|
|
1202
|
+
if callable(opener):
|
|
1203
|
+
opener(heatmap_key)
|
|
1204
|
+
event.accept()
|
|
1205
|
+
return
|
|
1206
|
+
|
|
1207
|
+
if previous_double_click is not None:
|
|
1208
|
+
previous_double_click(event)
|
|
1209
|
+
|
|
1210
|
+
item.mouseDoubleClickEvent = mouse_double_click
|
|
1211
|
+
item._qplot_heatmap_double_click_handler = True
|
|
1212
|
+
|
|
1213
|
+
def _heatmap_axis_sides(self, layer: Any) -> tuple[str, str]:
|
|
1214
|
+
"""Return the horizontal and vertical display sides for one heatmap."""
|
|
1215
|
+
|
|
1216
|
+
key = self._heatmap_key_for_layer(layer)
|
|
1217
|
+
assignment = self.__dict__.get("_heatmap_axis_assignments", {}).get(
|
|
1218
|
+
key,
|
|
1219
|
+
{},
|
|
1220
|
+
)
|
|
1221
|
+
x_axis = "Top" if assignment.get("x") == "Top" else "Bottom"
|
|
1222
|
+
y_axis = "Right" if assignment.get("y") == "Right" else "Left"
|
|
1223
|
+
return x_axis, y_axis
|
|
1224
|
+
|
|
1225
|
+
def _primary_heatmap_axis_items(self) -> list[Any]:
|
|
1226
|
+
"""Return graphics whose coordinates belong to the primary heatmap."""
|
|
1227
|
+
|
|
1228
|
+
names = (
|
|
1229
|
+
"image",
|
|
1230
|
+
"heatmap_mesh",
|
|
1231
|
+
"hover_pixel_outline",
|
|
1232
|
+
"marquee_highlight",
|
|
1233
|
+
"marquee_outline",
|
|
1234
|
+
"marquee_handles",
|
|
1235
|
+
)
|
|
1236
|
+
items = [self.__dict__.get(name) for name in names]
|
|
1237
|
+
items.extend(self.__dict__.get("sweep_lines", {}).values())
|
|
1238
|
+
return [item for item in items if item is not None]
|
|
1239
|
+
|
|
1240
|
+
def _heatmap_axis_items(self, layer: Any) -> list[Any]:
|
|
1241
|
+
if layer is self:
|
|
1242
|
+
return self._primary_heatmap_axis_items()
|
|
1243
|
+
render_items = getattr(layer, "render_items", None)
|
|
1244
|
+
return list(render_items()) if callable(render_items) else []
|
|
1245
|
+
|
|
1246
|
+
def _heatmap_render_items(self, layer: Any) -> list[Any]:
|
|
1247
|
+
"""Return only data renderers, excluding interaction decorations."""
|
|
1248
|
+
|
|
1249
|
+
if layer is self:
|
|
1250
|
+
return [
|
|
1251
|
+
item
|
|
1252
|
+
for item in (
|
|
1253
|
+
self.__dict__.get("image"),
|
|
1254
|
+
self.__dict__.get("heatmap_mesh"),
|
|
1255
|
+
)
|
|
1256
|
+
if item is not None
|
|
1257
|
+
]
|
|
1258
|
+
render_items = getattr(layer, "render_items", None)
|
|
1259
|
+
return list(render_items()) if callable(render_items) else []
|
|
1260
|
+
|
|
1261
|
+
def _ensure_heatmap_axis_viewboxes(
|
|
1262
|
+
self,
|
|
1263
|
+
*,
|
|
1264
|
+
top: bool = False,
|
|
1265
|
+
right: bool = False,
|
|
1266
|
+
) -> None:
|
|
1267
|
+
"""Create the linked ViewBoxes required by a heatmap's axis pair."""
|
|
1268
|
+
|
|
1269
|
+
plot = self.__dict__.get("plot")
|
|
1270
|
+
primary = self.__dict__.get("vb")
|
|
1271
|
+
if plot is None or primary is None:
|
|
1272
|
+
return
|
|
1273
|
+
|
|
1274
|
+
if right and self.__dict__.get("right_vb") is None:
|
|
1275
|
+
right_viewbox = pg.ViewBox()
|
|
1276
|
+
right_viewbox.setDefaultPadding(0)
|
|
1277
|
+
plot.scene().addItem(right_viewbox)
|
|
1278
|
+
plot.getAxis("right").linkToView(right_viewbox)
|
|
1279
|
+
right_viewbox.setXLink(primary)
|
|
1280
|
+
right_viewbox.sigRangeChanged.connect(
|
|
1281
|
+
self._heatmap_overlay_range_changed
|
|
1282
|
+
)
|
|
1283
|
+
self.__dict__["right_vb"] = right_viewbox
|
|
1284
|
+
|
|
1285
|
+
if top and self.__dict__.get("top_vb") is None:
|
|
1286
|
+
top_viewbox = pg.ViewBox()
|
|
1287
|
+
top_viewbox.setDefaultPadding(0)
|
|
1288
|
+
plot.scene().addItem(top_viewbox)
|
|
1289
|
+
plot.getAxis("top").linkToView(top_viewbox)
|
|
1290
|
+
top_viewbox.setYLink(primary)
|
|
1291
|
+
top_viewbox.sigRangeChanged.connect(
|
|
1292
|
+
self._heatmap_overlay_range_changed
|
|
1293
|
+
)
|
|
1294
|
+
self.__dict__["top_vb"] = top_viewbox
|
|
1295
|
+
|
|
1296
|
+
if top and right and self.__dict__.get("top_right_vb") is None:
|
|
1297
|
+
top_right_viewbox = pg.ViewBox()
|
|
1298
|
+
top_right_viewbox.setDefaultPadding(0)
|
|
1299
|
+
plot.scene().addItem(top_right_viewbox)
|
|
1300
|
+
top_right_viewbox.setXLink(self.top_vb)
|
|
1301
|
+
top_right_viewbox.setYLink(self.right_vb)
|
|
1302
|
+
top_right_viewbox.sigRangeChanged.connect(
|
|
1303
|
+
self._heatmap_overlay_range_changed
|
|
1304
|
+
)
|
|
1305
|
+
self.__dict__["top_right_vb"] = top_right_viewbox
|
|
1306
|
+
|
|
1307
|
+
if not self.__dict__.get("_heatmap_axis_viewboxes_connected", False):
|
|
1308
|
+
moved = getattr(primary, "main_moved", None)
|
|
1309
|
+
if moved is not None:
|
|
1310
|
+
moved.connect(self._update_heatmap_axis_viewboxes)
|
|
1311
|
+
primary.sigResized.connect(self._update_heatmap_axis_viewboxes)
|
|
1312
|
+
auto_button = getattr(plot, "autoBtn", None)
|
|
1313
|
+
if auto_button is not None:
|
|
1314
|
+
auto_button.clicked.connect(
|
|
1315
|
+
self._heatmap_axis_auto_button_clicked
|
|
1316
|
+
)
|
|
1317
|
+
auto_requested = getattr(primary, "autoRange_triggered", None)
|
|
1318
|
+
if auto_requested is not None:
|
|
1319
|
+
auto_requested.connect(self._heatmap_axis_auto_range_requested)
|
|
1320
|
+
self.__dict__["_heatmap_axis_viewboxes_connected"] = True
|
|
1321
|
+
|
|
1322
|
+
install_range_handlers = getattr(
|
|
1323
|
+
self,
|
|
1324
|
+
"_install_axis_scale_viewbox_range_handlers",
|
|
1325
|
+
None,
|
|
1326
|
+
)
|
|
1327
|
+
if callable(install_range_handlers):
|
|
1328
|
+
if self.__dict__.get("right_vb") is not None:
|
|
1329
|
+
install_range_handlers(self.right_vb, y_axis="y2")
|
|
1330
|
+
if self.__dict__.get("top_vb") is not None:
|
|
1331
|
+
install_range_handlers(self.top_vb, x_axis="x2")
|
|
1332
|
+
self._update_heatmap_axis_viewboxes(None)
|
|
1333
|
+
|
|
1334
|
+
def _heatmap_axis_viewbox(self, layer: Any) -> Any:
|
|
1335
|
+
"""Return the ViewBox representing a heatmap's selected axis pair."""
|
|
1336
|
+
|
|
1337
|
+
primary = self.__dict__.get("vb")
|
|
1338
|
+
if primary is None:
|
|
1339
|
+
plot = self.__dict__.get("plot")
|
|
1340
|
+
primary = getattr(plot, "vb", None)
|
|
1341
|
+
x_axis, y_axis = self._heatmap_axis_sides(layer)
|
|
1342
|
+
uses_top = x_axis == "Top"
|
|
1343
|
+
uses_right = y_axis == "Right"
|
|
1344
|
+
if uses_top or uses_right:
|
|
1345
|
+
self._ensure_heatmap_axis_viewboxes(
|
|
1346
|
+
top=uses_top,
|
|
1347
|
+
right=uses_right,
|
|
1348
|
+
)
|
|
1349
|
+
if uses_top and uses_right:
|
|
1350
|
+
return self.__dict__.get("top_right_vb") or primary
|
|
1351
|
+
if uses_top:
|
|
1352
|
+
return self.__dict__.get("top_vb") or primary
|
|
1353
|
+
if uses_right:
|
|
1354
|
+
return self.__dict__.get("right_vb") or primary
|
|
1355
|
+
return primary
|
|
1356
|
+
|
|
1357
|
+
def _primary_heatmap_viewbox(self) -> Any:
|
|
1358
|
+
"""Return the coordinate owner used by primary heatmap interactions."""
|
|
1359
|
+
|
|
1360
|
+
return self._heatmap_axis_viewbox(self)
|
|
1361
|
+
|
|
1362
|
+
def _primary_heatmap_semantic_axes(self) -> tuple[str, str]:
|
|
1363
|
+
x_axis, y_axis = self._heatmap_axis_sides(self)
|
|
1364
|
+
return (
|
|
1365
|
+
"x2" if x_axis == "Top" else "x",
|
|
1366
|
+
"y2" if y_axis == "Right" else "y",
|
|
1367
|
+
)
|
|
1368
|
+
|
|
1369
|
+
def _move_heatmap_item_to_viewbox(self, item: Any, target: Any) -> None:
|
|
1370
|
+
"""Move one heatmap-related graphics item without recreating it."""
|
|
1371
|
+
|
|
1372
|
+
if target is None:
|
|
1373
|
+
return
|
|
1374
|
+
if getattr(item, "_qplot_ui_overlay", False):
|
|
1375
|
+
overlays = self.__dict__.get("_plot_overlay_viewboxes", {})
|
|
1376
|
+
current = overlays.get(id(item))
|
|
1377
|
+
if current is not target:
|
|
1378
|
+
if current is not None:
|
|
1379
|
+
current.removeItem(item)
|
|
1380
|
+
self._add_plot_overlay(item, target)
|
|
1381
|
+
return
|
|
1382
|
+
tracked = self.__dict__.setdefault("_heatmap_renderer_viewboxes", {})
|
|
1383
|
+
get_viewbox = getattr(item, "getViewBox", None)
|
|
1384
|
+
current = get_viewbox() if callable(get_viewbox) else None
|
|
1385
|
+
current = current or tracked.get(id(item))
|
|
1386
|
+
if current is target:
|
|
1387
|
+
tracked[id(item)] = target
|
|
1388
|
+
return
|
|
1389
|
+
|
|
1390
|
+
if current is self.__dict__.get("vb"):
|
|
1391
|
+
self.plot.removeItem(item)
|
|
1392
|
+
elif current is not None:
|
|
1393
|
+
current.removeItem(item)
|
|
1394
|
+
else:
|
|
1395
|
+
try:
|
|
1396
|
+
self.plot.removeItem(item)
|
|
1397
|
+
except (AttributeError, RuntimeError, ValueError):
|
|
1398
|
+
pass
|
|
1399
|
+
|
|
1400
|
+
if target is self.__dict__.get("vb"):
|
|
1401
|
+
self.plot.addItem(item)
|
|
1402
|
+
else:
|
|
1403
|
+
target.addItem(item)
|
|
1404
|
+
tracked[id(item)] = target
|
|
1405
|
+
|
|
1406
|
+
def _remove_heatmap_render_item(self, item: Any) -> None:
|
|
1407
|
+
"""Remove a renderer from whichever ViewBox currently owns it."""
|
|
1408
|
+
|
|
1409
|
+
tracked = self.__dict__.get("_heatmap_renderer_viewboxes", {})
|
|
1410
|
+
get_viewbox = getattr(item, "getViewBox", None)
|
|
1411
|
+
current = get_viewbox() if callable(get_viewbox) else None
|
|
1412
|
+
tracked_current = tracked.pop(id(item), None)
|
|
1413
|
+
current = current or tracked_current
|
|
1414
|
+
if current is self.__dict__.get("vb") or current is None:
|
|
1415
|
+
self.plot.removeItem(item)
|
|
1416
|
+
else:
|
|
1417
|
+
current.removeItem(item)
|
|
1418
|
+
|
|
1419
|
+
def _apply_heatmap_axis_assignment(
|
|
1420
|
+
self,
|
|
1421
|
+
layer: Any,
|
|
1422
|
+
*,
|
|
1423
|
+
auto_range: bool = True,
|
|
1424
|
+
) -> None:
|
|
1425
|
+
target = self._heatmap_axis_viewbox(layer)
|
|
1426
|
+
for item in self._heatmap_axis_items(layer):
|
|
1427
|
+
self._move_heatmap_item_to_viewbox(item, target)
|
|
1428
|
+
self._sync_heatmap_axis_visibility()
|
|
1429
|
+
if auto_range and target is not None:
|
|
1430
|
+
target.autoRange()
|
|
1431
|
+
self._sync_secondary_heatmap_view_ranges(layer=None if layer is self else layer)
|
|
1432
|
+
|
|
1433
|
+
def _set_layer_axes(self, layer: Any, x_axis: str, y_axis: str) -> None:
|
|
1434
|
+
"""Assign one heatmap to any of the four displayed axis pairs."""
|
|
1435
|
+
|
|
1436
|
+
key = self._heatmap_key_for_layer(layer)
|
|
1437
|
+
if key is None:
|
|
1438
|
+
return
|
|
1439
|
+
assignment = {
|
|
1440
|
+
"x": "Top" if x_axis == "Top" else "Bottom",
|
|
1441
|
+
"y": "Right" if y_axis == "Right" else "Left",
|
|
1442
|
+
}
|
|
1443
|
+
assignments = self.__dict__.setdefault("_heatmap_axis_assignments", {})
|
|
1444
|
+
if assignments.get(key) == assignment:
|
|
1445
|
+
return
|
|
1446
|
+
assignments[key] = assignment
|
|
1447
|
+
self._apply_heatmap_axis_assignment(layer)
|
|
1448
|
+
|
|
1449
|
+
def _heatmap_axis_parameter(self, display_axis: str) -> Any:
|
|
1450
|
+
parameters = self.__dict__.get("axis_param", {})
|
|
1451
|
+
if isinstance(parameters, dict):
|
|
1452
|
+
parameter = parameters.get(display_axis)
|
|
1453
|
+
if parameter is not None:
|
|
1454
|
+
return parameter
|
|
1455
|
+
options = getattr(self, "axis_options", {})
|
|
1456
|
+
name = options.get(display_axis) if isinstance(options, dict) else None
|
|
1457
|
+
parameter_dict = self.__dict__.get("param_dict", {})
|
|
1458
|
+
if isinstance(parameter_dict, dict) and name is not None:
|
|
1459
|
+
return parameter_dict.get(name)
|
|
1460
|
+
return None
|
|
1461
|
+
|
|
1462
|
+
def _sync_heatmap_axis_visibility(self) -> None:
|
|
1463
|
+
"""Show and label secondary axes used by at least one heatmap."""
|
|
1464
|
+
|
|
1465
|
+
plot = self.__dict__.get("plot")
|
|
1466
|
+
if plot is None:
|
|
1467
|
+
return
|
|
1468
|
+
layers = list(self.__dict__.get("heatmaps", {}).values())
|
|
1469
|
+
for side, display_axis, selected_side in (
|
|
1470
|
+
("top", "x", "Top"),
|
|
1471
|
+
("right", "y", "Right"),
|
|
1472
|
+
):
|
|
1473
|
+
used = any(
|
|
1474
|
+
selected_side in self._heatmap_axis_sides(layer)
|
|
1475
|
+
for layer in layers
|
|
1476
|
+
)
|
|
1477
|
+
axis = plot.getAxis(side)
|
|
1478
|
+
axis.setStyle(showValues=used)
|
|
1479
|
+
if not used:
|
|
1480
|
+
axis.setLabel(text="", units="")
|
|
1481
|
+
continue
|
|
1482
|
+
parameter = self._heatmap_axis_parameter(display_axis)
|
|
1483
|
+
label = getattr(parameter, "label", None) or getattr(
|
|
1484
|
+
parameter,
|
|
1485
|
+
"name",
|
|
1486
|
+
getattr(self, "axis_options", {}).get(display_axis, ""),
|
|
1487
|
+
)
|
|
1488
|
+
unit = getattr(parameter, "unit", "") or ""
|
|
1489
|
+
axis.setLabel(text=str(label), units=str(unit))
|
|
1490
|
+
sync_tabs = getattr(self, "_sync_axis_scale_tab_states", None)
|
|
1491
|
+
if callable(sync_tabs):
|
|
1492
|
+
sync_tabs()
|
|
1493
|
+
|
|
1494
|
+
def _set_param_axis_labels(self) -> None:
|
|
1495
|
+
"""Refresh primary labels without losing active heatmap side axes."""
|
|
1496
|
+
|
|
1497
|
+
super()._set_param_axis_labels()
|
|
1498
|
+
self._sync_heatmap_axis_visibility()
|
|
1499
|
+
|
|
1500
|
+
def _update_heatmap_axis_viewboxes(self, event: Any = None) -> None:
|
|
1501
|
+
"""Keep secondary ViewBoxes aligned and mirror primary navigation."""
|
|
1502
|
+
|
|
1503
|
+
primary = self.__dict__.get("vb")
|
|
1504
|
+
if primary is None:
|
|
1505
|
+
return
|
|
1506
|
+
geometry = primary.sceneBoundingRect()
|
|
1507
|
+
for name in ("right_vb", "top_vb", "top_right_vb"):
|
|
1508
|
+
viewbox = self.__dict__.get(name)
|
|
1509
|
+
if viewbox is not None:
|
|
1510
|
+
viewbox.setGeometry(geometry)
|
|
1511
|
+
|
|
1512
|
+
right_viewbox = self.__dict__.get("right_vb")
|
|
1513
|
+
top_viewbox = self.__dict__.get("top_vb")
|
|
1514
|
+
constrained_axis = getattr(primary, "_main_moved_axis", None)
|
|
1515
|
+
if event is not None:
|
|
1516
|
+
if event.__class__.__name__ == "QGraphicsSceneWheelEvent":
|
|
1517
|
+
if right_viewbox is not None:
|
|
1518
|
+
right_viewbox.wheelEvent(event, axis=1)
|
|
1519
|
+
if top_viewbox is not None:
|
|
1520
|
+
top_viewbox.wheelEvent(event, axis=0)
|
|
1521
|
+
elif event.__class__.__name__ == "MouseDragEvent":
|
|
1522
|
+
if right_viewbox is not None and constrained_axis in (None, 1):
|
|
1523
|
+
right_viewbox.mouseDragEvent(event, axis=1)
|
|
1524
|
+
if top_viewbox is not None and constrained_axis in (None, 0):
|
|
1525
|
+
top_viewbox.mouseDragEvent(event, axis=0)
|
|
1526
|
+
|
|
1527
|
+
def _heatmap_axis_auto_button_clicked(self, *_args: Any) -> None:
|
|
1528
|
+
auto_button = getattr(self.plot, "autoBtn", None)
|
|
1529
|
+
enabled = auto_button is None or auto_button.mode == "auto"
|
|
1530
|
+
for name in ("right_vb", "top_vb", "top_right_vb"):
|
|
1531
|
+
viewbox = self.__dict__.get(name)
|
|
1532
|
+
if viewbox is not None:
|
|
1533
|
+
viewbox.enableAutoRange(enable=enabled)
|
|
1534
|
+
|
|
1535
|
+
def _heatmap_axis_auto_range_requested(self, *_args: Any) -> None:
|
|
1536
|
+
for name in ("right_vb", "top_vb", "top_right_vb"):
|
|
1537
|
+
viewbox = self.__dict__.get(name)
|
|
1538
|
+
if viewbox is not None:
|
|
1539
|
+
viewbox.autoRange()
|
|
1540
|
+
self._sync_secondary_heatmap_view_ranges()
|
|
1541
|
+
|
|
1542
|
+
def _heatmap_overlay_range_changed(self, *_args: Any) -> None:
|
|
1543
|
+
self._sync_secondary_heatmap_view_ranges()
|
|
1544
|
+
primary_viewbox = self._primary_heatmap_viewbox()
|
|
1545
|
+
if primary_viewbox is not self.__dict__.get("vb"):
|
|
1546
|
+
schedule = getattr(self, "_schedule_visible_heatmap_reload", None)
|
|
1547
|
+
if callable(schedule):
|
|
1548
|
+
schedule()
|
|
1549
|
+
|
|
1550
|
+
def _emit_remove_dataset(self, dataset_key: Any) -> None:
|
|
1551
|
+
try:
|
|
1552
|
+
signal = getattr(self, "remove_dataset", None)
|
|
1553
|
+
except RuntimeError:
|
|
1554
|
+
signal = None
|
|
1555
|
+
emit = getattr(signal, "emit", None)
|
|
1556
|
+
if callable(emit):
|
|
1557
|
+
emit(dataset_key)
|
|
1558
|
+
|
|
1559
|
+
def _heatmap_union_colorbar_levels(self) -> tuple[float, float] | None:
|
|
1560
|
+
minimum = np.inf
|
|
1561
|
+
maximum = -np.inf
|
|
1562
|
+
found_finite = False
|
|
1563
|
+
for data in self._heatmap_colorbar_data_arrays():
|
|
1564
|
+
values = np.asarray(data, dtype=float)
|
|
1565
|
+
finite_values = values[np.isfinite(values)]
|
|
1566
|
+
if finite_values.size == 0:
|
|
1567
|
+
continue
|
|
1568
|
+
found_finite = True
|
|
1569
|
+
minimum = min(minimum, float(np.min(finite_values)))
|
|
1570
|
+
maximum = max(maximum, float(np.max(finite_values)))
|
|
1571
|
+
|
|
1572
|
+
if not found_finite:
|
|
1573
|
+
return None
|
|
1574
|
+
if minimum == maximum:
|
|
1575
|
+
constant_levels = getattr(self, "_constant_colorbar_levels", None)
|
|
1576
|
+
if callable(constant_levels):
|
|
1577
|
+
return constant_levels(minimum)
|
|
1578
|
+
padding = 1e-6 if minimum == 0 else abs(minimum) * 1e-6
|
|
1579
|
+
return minimum - padding, maximum + padding
|
|
1580
|
+
return minimum, maximum
|
|
1581
|
+
|
|
1582
|
+
@classmethod
|
|
1583
|
+
def _normalise_colorbar_levels(
|
|
1584
|
+
cls,
|
|
1585
|
+
levels: Any,
|
|
1586
|
+
) -> tuple[float, float] | None:
|
|
1587
|
+
try:
|
|
1588
|
+
low, high = levels
|
|
1589
|
+
low = float(low)
|
|
1590
|
+
high = float(high)
|
|
1591
|
+
except (TypeError, ValueError):
|
|
1592
|
+
return None
|
|
1593
|
+
if not np.isfinite(low) or not np.isfinite(high) or low >= high:
|
|
1594
|
+
return None
|
|
1595
|
+
return low, high
|
|
1596
|
+
|
|
1597
|
+
@classmethod
|
|
1598
|
+
def _valid_colorbar_levels(cls, bar: Any) -> tuple[float, float] | None:
|
|
1599
|
+
levels_getter = getattr(bar, "levels", None)
|
|
1600
|
+
if not callable(levels_getter):
|
|
1601
|
+
return None
|
|
1602
|
+
try:
|
|
1603
|
+
levels = levels_getter()
|
|
1604
|
+
except (TypeError, ValueError, RuntimeError):
|
|
1605
|
+
return None
|
|
1606
|
+
return cls._normalise_colorbar_levels(levels)
|
|
1607
|
+
|
|
1608
|
+
|
|
1609
|
+
class _HeatmapTableWidget(ReorderAppearanceTable):
|
|
1610
|
+
"""Heatmap-specific instance of the shared appearance reorder table."""
|
|
1611
|
+
|
|
1612
|
+
_REORDER_MIME_TYPE = "application/x-qplot-heatmap-reorder"
|
|
1613
|
+
|
|
1614
|
+
def __init__(self, dialog: _HeatmapAppearanceDialog) -> None:
|
|
1615
|
+
super().__init__(dialog, mime_type=self._REORDER_MIME_TYPE)
|
|
1616
|
+
|
|
1617
|
+
|
|
1618
|
+
class _OpacitySpinBox(qtw.QSpinBox):
|
|
1619
|
+
"""Reserve only enough editor space for the maximum percentage."""
|
|
1620
|
+
|
|
1621
|
+
def sizeHint(self):
|
|
1622
|
+
size = super().sizeHint()
|
|
1623
|
+
option = qtw.QStyleOptionSpinBox()
|
|
1624
|
+
self.initStyleOption(option)
|
|
1625
|
+
option.rect = QtCore.QRect(QtCore.QPoint(), size)
|
|
1626
|
+
edit_rect = self.style().subControlRect(
|
|
1627
|
+
qtw.QStyle.ComplexControl.CC_SpinBox,
|
|
1628
|
+
option, qtw.QStyle.SubControl.SC_SpinBoxEditField, self,
|
|
1629
|
+
)
|
|
1630
|
+
editor = self.lineEdit()
|
|
1631
|
+
frame = qtw.QStyleOptionFrame()
|
|
1632
|
+
frame.initFrom(editor)
|
|
1633
|
+
frame.state |= qtw.QStyle.StateFlag.State_Sunken
|
|
1634
|
+
if editor.hasFrame():
|
|
1635
|
+
frame.lineWidth = editor.style().pixelMetric(
|
|
1636
|
+
qtw.QStyle.PixelMetric.PM_DefaultFrameWidth, frame, editor
|
|
1637
|
+
)
|
|
1638
|
+
frame.rect = QtCore.QRect(QtCore.QPoint(), edit_rect.size())
|
|
1639
|
+
contents = editor.style().subElementRect(
|
|
1640
|
+
qtw.QStyle.SubElement.SE_LineEditContents, frame, editor
|
|
1641
|
+
)
|
|
1642
|
+
text_width = editor.fontMetrics().horizontalAdvance(
|
|
1643
|
+
self.textFromValue(self.maximum()) + self.suffix()
|
|
1644
|
+
)
|
|
1645
|
+
margins = editor.textMargins()
|
|
1646
|
+
# QLineEdit adds two pixels on each side of its text.
|
|
1647
|
+
size.setWidth(
|
|
1648
|
+
size.width() - contents.width() + text_width
|
|
1649
|
+
+ margins.left() + margins.right() + 4
|
|
1650
|
+
)
|
|
1651
|
+
return size
|
|
1652
|
+
|
|
1653
|
+
def minimumSizeHint(self):
|
|
1654
|
+
return self.sizeHint()
|
|
1655
|
+
|
|
1656
|
+
|
|
1657
|
+
class _HeatmapAppearanceDialog(qtw.QDialog):
|
|
1658
|
+
"""Edit heatmap layers using the same interaction model as traces."""
|
|
1659
|
+
|
|
1660
|
+
_COL_ID = 0
|
|
1661
|
+
_COL_PREVIEW = 1
|
|
1662
|
+
_COL_MEASUREMENT = 2
|
|
1663
|
+
|
|
1664
|
+
def __init__(self, owner: Plot2DLayerMixin) -> None:
|
|
1665
|
+
super().__init__(cast(qtw.QWidget, owner))
|
|
1666
|
+
self.owner = owner
|
|
1667
|
+
self._building = False
|
|
1668
|
+
self.setWindowTitle("Heatmap Appearance")
|
|
1669
|
+
self.resize(780, 360)
|
|
1670
|
+
self.setMinimumSize(700, 300)
|
|
1671
|
+
self.setStyleSheet(
|
|
1672
|
+
self.styleSheet()
|
|
1673
|
+
+ """
|
|
1674
|
+
QTableWidget#heatmapAppearanceTable {
|
|
1675
|
+
border: 1px solid palette(mid);
|
|
1676
|
+
background-color: palette(base);
|
|
1677
|
+
alternate-background-color: palette(alternate-base);
|
|
1678
|
+
gridline-color: palette(midlight);
|
|
1679
|
+
}
|
|
1680
|
+
QTableWidget#heatmapAppearanceTable::item {
|
|
1681
|
+
padding: 2px 6px;
|
|
1682
|
+
border: none;
|
|
1683
|
+
}
|
|
1684
|
+
QTableWidget#heatmapAppearanceTable QHeaderView::section {
|
|
1685
|
+
font-weight: normal;
|
|
1686
|
+
}
|
|
1687
|
+
"""
|
|
1688
|
+
)
|
|
1689
|
+
|
|
1690
|
+
main = qtw.QVBoxLayout(self)
|
|
1691
|
+
main.setContentsMargins(10, 10, 10, 10)
|
|
1692
|
+
main.setSpacing(8)
|
|
1693
|
+
body = qtw.QHBoxLayout()
|
|
1694
|
+
body.setSpacing(10)
|
|
1695
|
+
main.addLayout(body, 1)
|
|
1696
|
+
|
|
1697
|
+
heatmap_group = qtw.QGroupBox(self)
|
|
1698
|
+
heatmap_layout = qtw.QVBoxLayout(heatmap_group)
|
|
1699
|
+
heatmap_layout.setContentsMargins(8, 8, 8, 8)
|
|
1700
|
+
heatmap_layout.setSpacing(6)
|
|
1701
|
+
|
|
1702
|
+
self.table = _HeatmapTableWidget(self)
|
|
1703
|
+
configure_appearance_table(
|
|
1704
|
+
self.table,
|
|
1705
|
+
self,
|
|
1706
|
+
object_name="heatmapAppearanceTable",
|
|
1707
|
+
item_name="heatmap",
|
|
1708
|
+
)
|
|
1709
|
+
self.table.itemSelectionChanged.connect(
|
|
1710
|
+
self._sync_controls_from_selection
|
|
1711
|
+
)
|
|
1712
|
+
heatmap_layout.addWidget(self.table)
|
|
1713
|
+
|
|
1714
|
+
actions = qtw.QHBoxLayout()
|
|
1715
|
+
actions.setContentsMargins(0, 0, 0, 0)
|
|
1716
|
+
actions.setSpacing(6)
|
|
1717
|
+
self.add_heatmap_combo = qtw.QComboBox(self)
|
|
1718
|
+
self.add_heatmap_combo.setObjectName("heatmapAppearanceAddCombo")
|
|
1719
|
+
self.add_heatmap_combo.setMinimumContentsLength(18)
|
|
1720
|
+
self.add_heatmap_combo.setSizeAdjustPolicy(
|
|
1721
|
+
qtw.QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon
|
|
1722
|
+
)
|
|
1723
|
+
self.add_heatmap_combo.setToolTip(
|
|
1724
|
+
"Choose an available measurement to add."
|
|
1725
|
+
)
|
|
1726
|
+
self.add_heatmap_button = qtw.QPushButton("Add Heatmap", self)
|
|
1727
|
+
self.add_heatmap_button.setObjectName("heatmapAppearanceAddButton")
|
|
1728
|
+
self.add_heatmap_button.setEnabled(False)
|
|
1729
|
+
self.remove_heatmap_button = qtw.QPushButton("Remove Heatmap", self)
|
|
1730
|
+
self.remove_heatmap_button.setObjectName("heatmapAppearanceRemoveButton")
|
|
1731
|
+
self.remove_heatmap_button.setEnabled(False)
|
|
1732
|
+
actions.addWidget(self.add_heatmap_combo, 1)
|
|
1733
|
+
actions.addWidget(self.add_heatmap_button)
|
|
1734
|
+
actions.addWidget(self.remove_heatmap_button)
|
|
1735
|
+
heatmap_layout.addLayout(actions)
|
|
1736
|
+
body.addWidget(heatmap_group, 5)
|
|
1737
|
+
|
|
1738
|
+
panel = qtw.QWidget(self)
|
|
1739
|
+
panel_layout = qtw.QVBoxLayout(panel)
|
|
1740
|
+
panel_layout.setContentsMargins(0, 0, 0, 0)
|
|
1741
|
+
panel_layout.setSpacing(8)
|
|
1742
|
+
|
|
1743
|
+
color_scale_group = qtw.QGroupBox("Color scale", panel)
|
|
1744
|
+
color_scale_layout = qtw.QGridLayout(color_scale_group)
|
|
1745
|
+
color_scale_layout.setContentsMargins(8, 10, 8, 8)
|
|
1746
|
+
color_scale_layout.setHorizontalSpacing(8)
|
|
1747
|
+
self.color_scale_name = qtw.QLabel()
|
|
1748
|
+
self.color_scale_name.setObjectName("heatmapColorScaleName")
|
|
1749
|
+
color_scale_layout.addWidget(self.color_scale_name, 0, 0)
|
|
1750
|
+
self.color_scale_preview = qtw.QLabel()
|
|
1751
|
+
self.color_scale_preview.setObjectName("heatmapColorScalePreview")
|
|
1752
|
+
self.color_scale_preview.setMinimumWidth(170)
|
|
1753
|
+
self.color_scale_preview.setFixedHeight(18)
|
|
1754
|
+
self.color_scale_preview.setScaledContents(True)
|
|
1755
|
+
color_scale_layout.addWidget(self.color_scale_preview, 1, 0)
|
|
1756
|
+
self.color_scale_button = qtw.QPushButton("Color scale…")
|
|
1757
|
+
self.color_scale_button.setObjectName("heatmapColorScaleButton")
|
|
1758
|
+
self.color_scale_button.setToolTip("Open the Color scale dialog.")
|
|
1759
|
+
color_scale_layout.addWidget(
|
|
1760
|
+
self.color_scale_button,
|
|
1761
|
+
2,
|
|
1762
|
+
0,
|
|
1763
|
+
1,
|
|
1764
|
+
1,
|
|
1765
|
+
QtCore.Qt.AlignmentFlag.AlignLeft,
|
|
1766
|
+
)
|
|
1767
|
+
panel_layout.addWidget(color_scale_group)
|
|
1768
|
+
|
|
1769
|
+
self.visible = qtw.QCheckBox("Visible")
|
|
1770
|
+
self.visible.setChecked(True)
|
|
1771
|
+
self.opacity = _OpacitySpinBox()
|
|
1772
|
+
self.opacity.setObjectName("heatmapAppearanceOpacity")
|
|
1773
|
+
self.opacity.setRange(0, 100)
|
|
1774
|
+
self.opacity.setValue(100)
|
|
1775
|
+
self.opacity.setSuffix("%")
|
|
1776
|
+
self.opacity.setSizePolicy(
|
|
1777
|
+
qtw.QSizePolicy.Policy.Fixed, qtw.QSizePolicy.Policy.Fixed
|
|
1778
|
+
)
|
|
1779
|
+
self.opacity_slider = qtw.QSlider(QtCore.Qt.Orientation.Horizontal)
|
|
1780
|
+
self.opacity_slider.setObjectName("heatmapAppearanceOpacitySlider")
|
|
1781
|
+
self.opacity_slider.setRange(0, 100)
|
|
1782
|
+
self.opacity_slider.setValue(100)
|
|
1783
|
+
visibility_layout = qtw.QHBoxLayout()
|
|
1784
|
+
visibility_layout.setContentsMargins(0, 0, 0, 0)
|
|
1785
|
+
visibility_layout.setSpacing(6)
|
|
1786
|
+
visibility_layout.addWidget(self.visible)
|
|
1787
|
+
visibility_layout.addSpacing(16)
|
|
1788
|
+
visibility_layout.addWidget(qtw.QLabel("Opacity"))
|
|
1789
|
+
visibility_layout.addWidget(self.opacity_slider, 1)
|
|
1790
|
+
visibility_layout.addWidget(self.opacity)
|
|
1791
|
+
panel_layout.addLayout(visibility_layout)
|
|
1792
|
+
|
|
1793
|
+
self.x_axis = qtw.QComboBox()
|
|
1794
|
+
self.x_axis.addItems(["Bottom", "Top"])
|
|
1795
|
+
self.x_axis.setToolTip("Choose the horizontal axis for this heatmap.")
|
|
1796
|
+
self.y_axis = qtw.QComboBox()
|
|
1797
|
+
self.y_axis.addItems(["Left", "Right"])
|
|
1798
|
+
self.y_axis.setToolTip("Choose the vertical axis for this heatmap.")
|
|
1799
|
+
for combo in (self.x_axis, self.y_axis):
|
|
1800
|
+
combo.setFixedWidth(108)
|
|
1801
|
+
heatmap_axes = qtw.QGroupBox("Heatmap axes", panel)
|
|
1802
|
+
heatmap_axes_grid = qtw.QGridLayout(heatmap_axes)
|
|
1803
|
+
heatmap_axes_grid.setContentsMargins(8, 10, 8, 8)
|
|
1804
|
+
heatmap_axes_grid.addWidget(qtw.QLabel("Horizontal"), 0, 0)
|
|
1805
|
+
heatmap_axes_grid.addWidget(self.x_axis, 0, 1)
|
|
1806
|
+
heatmap_axes_grid.addWidget(qtw.QLabel("Vertical"), 1, 0)
|
|
1807
|
+
heatmap_axes_grid.addWidget(self.y_axis, 1, 1)
|
|
1808
|
+
heatmap_axes_grid.setColumnStretch(2, 1)
|
|
1809
|
+
panel_layout.addWidget(heatmap_axes)
|
|
1810
|
+
|
|
1811
|
+
self.swap_axes = qtw.QCheckBox("Swap X/Y")
|
|
1812
|
+
self.swap_axes.setToolTip("Exchange the two plotted heatmap axes.")
|
|
1813
|
+
plot_axes = qtw.QGroupBox("Plot axes", panel)
|
|
1814
|
+
plot_axes_layout = qtw.QVBoxLayout(plot_axes)
|
|
1815
|
+
plot_axes_layout.setContentsMargins(8, 10, 8, 8)
|
|
1816
|
+
plot_axes_layout.addWidget(self.swap_axes)
|
|
1817
|
+
panel_layout.addWidget(plot_axes)
|
|
1818
|
+
panel_layout.addStretch()
|
|
1819
|
+
body.addWidget(panel, 4)
|
|
1820
|
+
|
|
1821
|
+
buttons = qtw.QDialogButtonBox(
|
|
1822
|
+
qtw.QDialogButtonBox.StandardButton.Close,
|
|
1823
|
+
self,
|
|
1824
|
+
)
|
|
1825
|
+
buttons.rejected.connect(self.close)
|
|
1826
|
+
main.addWidget(buttons)
|
|
1827
|
+
|
|
1828
|
+
self.opacity_slider.valueChanged.connect(self.opacity.setValue)
|
|
1829
|
+
self.opacity.valueChanged.connect(self.opacity_slider.setValue)
|
|
1830
|
+
self.opacity.valueChanged.connect(self._apply_selection)
|
|
1831
|
+
self.visible.toggled.connect(self._apply_selection)
|
|
1832
|
+
self.x_axis.currentTextChanged.connect(self._apply_axis_selection)
|
|
1833
|
+
self.y_axis.currentTextChanged.connect(self._apply_axis_selection)
|
|
1834
|
+
self.swap_axes.toggled.connect(self._swap_axes_toggled)
|
|
1835
|
+
self.color_scale_button.clicked.connect(self._open_color_scale)
|
|
1836
|
+
self.add_heatmap_combo.currentIndexChanged.connect(
|
|
1837
|
+
self._add_heatmap_selection_changed
|
|
1838
|
+
)
|
|
1839
|
+
self.add_heatmap_button.clicked.connect(self._add_selected_heatmap)
|
|
1840
|
+
self.remove_heatmap_button.clicked.connect(
|
|
1841
|
+
self._remove_selected_heatmaps
|
|
1842
|
+
)
|
|
1843
|
+
self._update_control_enabled_states(False)
|
|
1844
|
+
self.sync_swap_axes_control()
|
|
1845
|
+
self.refresh_color_scale(refresh_rows=False)
|
|
1846
|
+
|
|
1847
|
+
def refresh_color_scale(self, *, refresh_rows: bool = True) -> None:
|
|
1848
|
+
"""Refresh the shared color-map name and both kinds of preview."""
|
|
1849
|
+
|
|
1850
|
+
name_getter = getattr(self.owner, "_current_colorbar_colormap_name", None)
|
|
1851
|
+
name = str(name_getter()) if callable(name_getter) else "viridis"
|
|
1852
|
+
self.color_scale_name.setText(_COLORBAR_COLORMAP_LABELS.get(name, name))
|
|
1853
|
+
self.color_scale_name.setToolTip(name)
|
|
1854
|
+
self.color_scale_preview.setPixmap(
|
|
1855
|
+
_colorbar_colormap_preview(name, width=190, height=18)
|
|
1856
|
+
)
|
|
1857
|
+
if refresh_rows:
|
|
1858
|
+
self.refresh_rows()
|
|
1859
|
+
|
|
1860
|
+
def _open_color_scale(self, _checked: bool = False) -> None:
|
|
1861
|
+
opener = getattr(self.owner, "open_colorbar_scale_dialog", None)
|
|
1862
|
+
if callable(opener):
|
|
1863
|
+
opener()
|
|
1864
|
+
|
|
1865
|
+
def sync_swap_axes_control(self) -> None:
|
|
1866
|
+
can_swap = getattr(self.owner, "can_swap_plot_axes", None)
|
|
1867
|
+
is_swapped = getattr(self.owner, "plot_axes_swapped", None)
|
|
1868
|
+
enabled = bool(callable(can_swap) and can_swap())
|
|
1869
|
+
checked = bool(enabled and callable(is_swapped) and is_swapped())
|
|
1870
|
+
blocked = self.swap_axes.blockSignals(True)
|
|
1871
|
+
try:
|
|
1872
|
+
self.swap_axes.setEnabled(enabled)
|
|
1873
|
+
self.swap_axes.setChecked(checked)
|
|
1874
|
+
finally:
|
|
1875
|
+
self.swap_axes.blockSignals(blocked)
|
|
1876
|
+
|
|
1877
|
+
def _swap_axes_toggled(self, checked: bool) -> None:
|
|
1878
|
+
setter = getattr(self.owner, "set_plot_axes_swapped", None)
|
|
1879
|
+
if not callable(setter) or not setter(checked):
|
|
1880
|
+
self.sync_swap_axes_control()
|
|
1881
|
+
|
|
1882
|
+
def refresh_available_heatmaps(self) -> None:
|
|
1883
|
+
previous_key = self.add_heatmap_combo.currentData()
|
|
1884
|
+
plotted_keys = set(self.owner.heatmaps)
|
|
1885
|
+
available = [
|
|
1886
|
+
(label, key)
|
|
1887
|
+
for label, key in self.owner.available_heatmap_candidates()
|
|
1888
|
+
if key not in plotted_keys
|
|
1889
|
+
]
|
|
1890
|
+
blocked = self.add_heatmap_combo.blockSignals(True)
|
|
1891
|
+
try:
|
|
1892
|
+
self.add_heatmap_combo.clear()
|
|
1893
|
+
self.add_heatmap_combo.addItem(
|
|
1894
|
+
"Select a heatmap to add…",
|
|
1895
|
+
userData=None,
|
|
1896
|
+
)
|
|
1897
|
+
for label, key in available:
|
|
1898
|
+
self.add_heatmap_combo.addItem(label, userData=key)
|
|
1899
|
+
index = self.add_heatmap_combo.findData(previous_key)
|
|
1900
|
+
self.add_heatmap_combo.setCurrentIndex(max(index, 0))
|
|
1901
|
+
finally:
|
|
1902
|
+
self.add_heatmap_combo.blockSignals(blocked)
|
|
1903
|
+
self._add_heatmap_selection_changed(
|
|
1904
|
+
self.add_heatmap_combo.currentIndex()
|
|
1905
|
+
)
|
|
1906
|
+
|
|
1907
|
+
def _add_heatmap_selection_changed(self, _index: int) -> None:
|
|
1908
|
+
self.add_heatmap_button.setEnabled(
|
|
1909
|
+
self.add_heatmap_combo.currentData() is not None
|
|
1910
|
+
)
|
|
1911
|
+
|
|
1912
|
+
def _add_selected_heatmap(self, _checked: bool = False) -> None:
|
|
1913
|
+
key = self.add_heatmap_combo.currentData()
|
|
1914
|
+
if key is None:
|
|
1915
|
+
return
|
|
1916
|
+
try:
|
|
1917
|
+
added = self.owner.add_heatmap_from_dialog(
|
|
1918
|
+
self.add_heatmap_combo.currentText(),
|
|
1919
|
+
key,
|
|
1920
|
+
)
|
|
1921
|
+
except Exception as error:
|
|
1922
|
+
qtw.QMessageBox.critical(
|
|
1923
|
+
self,
|
|
1924
|
+
"Could Not Add Heatmap",
|
|
1925
|
+
f"The selected heatmap could not be added:\n{error}",
|
|
1926
|
+
)
|
|
1927
|
+
added = False
|
|
1928
|
+
self.refresh_rows()
|
|
1929
|
+
if added:
|
|
1930
|
+
self.select_heatmap(key)
|
|
1931
|
+
|
|
1932
|
+
def _remove_selected_heatmaps(self, _checked: bool = False) -> None:
|
|
1933
|
+
selected = self._selected_keys()
|
|
1934
|
+
if not selected or self.owner._primary_heatmap_key in selected:
|
|
1935
|
+
return
|
|
1936
|
+
for key in selected:
|
|
1937
|
+
layer = self.owner.heatmaps.get(key)
|
|
1938
|
+
if layer is not None:
|
|
1939
|
+
self.owner.remove_heatmap(
|
|
1940
|
+
self.owner._heatmap_display_label(key, layer),
|
|
1941
|
+
key,
|
|
1942
|
+
)
|
|
1943
|
+
self.refresh_rows()
|
|
1944
|
+
|
|
1945
|
+
def refresh_rows(self) -> None:
|
|
1946
|
+
self.sync_swap_axes_control()
|
|
1947
|
+
selected = set(self._selected_keys())
|
|
1948
|
+
self.table.blockSignals(True)
|
|
1949
|
+
self.table.setRowCount(0)
|
|
1950
|
+
try:
|
|
1951
|
+
for row, (key, layer) in enumerate(self.owner.heatmaps.items()):
|
|
1952
|
+
self.table.insertRow(row)
|
|
1953
|
+
label = self.owner._heatmap_display_label(key, layer)
|
|
1954
|
+
heatmap_id = (
|
|
1955
|
+
label.split()[0].replace("ID:", "")
|
|
1956
|
+
if label.startswith("ID:")
|
|
1957
|
+
else str(row + 1)
|
|
1958
|
+
)
|
|
1959
|
+
measurement = self.owner._heatmap_measurement_name(key, layer)
|
|
1960
|
+
for column, value in (
|
|
1961
|
+
(self._COL_ID, heatmap_id),
|
|
1962
|
+
(self._COL_MEASUREMENT, measurement),
|
|
1963
|
+
):
|
|
1964
|
+
item = qtw.QTableWidgetItem(value)
|
|
1965
|
+
item.setFlags(
|
|
1966
|
+
item.flags()
|
|
1967
|
+
& ~QtCore.Qt.ItemFlag.ItemIsEditable
|
|
1968
|
+
| QtCore.Qt.ItemFlag.ItemIsDragEnabled
|
|
1969
|
+
)
|
|
1970
|
+
item.setTextAlignment(
|
|
1971
|
+
(
|
|
1972
|
+
QtCore.Qt.AlignmentFlag.AlignRight
|
|
1973
|
+
| QtCore.Qt.AlignmentFlag.AlignVCenter
|
|
1974
|
+
)
|
|
1975
|
+
if column == self._COL_ID
|
|
1976
|
+
else QtCore.Qt.AlignmentFlag.AlignVCenter
|
|
1977
|
+
)
|
|
1978
|
+
item.setToolTip(value)
|
|
1979
|
+
self.table.setItem(row, column, item)
|
|
1980
|
+
|
|
1981
|
+
preview = qtw.QTableWidgetItem()
|
|
1982
|
+
preview.setIcon(QtGui.QIcon(self._heatmap_pixmap(layer)))
|
|
1983
|
+
preview.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
|
1984
|
+
preview.setFlags(
|
|
1985
|
+
preview.flags()
|
|
1986
|
+
& ~QtCore.Qt.ItemFlag.ItemIsEditable
|
|
1987
|
+
& ~QtCore.Qt.ItemFlag.ItemIsDragEnabled
|
|
1988
|
+
)
|
|
1989
|
+
preview.setToolTip("Heatmap preview")
|
|
1990
|
+
self.table.setItem(row, self._COL_PREVIEW, preview)
|
|
1991
|
+
id_item = self.table.item(row, self._COL_ID)
|
|
1992
|
+
if id_item is not None:
|
|
1993
|
+
id_item.setData(QtCore.Qt.ItemDataRole.UserRole, key)
|
|
1994
|
+
id_item.setToolTip(label)
|
|
1995
|
+
self.table.setRowHeight(row, 28)
|
|
1996
|
+
if key in selected:
|
|
1997
|
+
self.table.selectRow(row)
|
|
1998
|
+
finally:
|
|
1999
|
+
self.table.blockSignals(False)
|
|
2000
|
+
|
|
2001
|
+
self.owner._sync_heatmap_layer_order()
|
|
2002
|
+
if self.table.rowCount() and not self._selected_keys():
|
|
2003
|
+
self.table.selectRow(0)
|
|
2004
|
+
else:
|
|
2005
|
+
self._sync_controls_from_selection()
|
|
2006
|
+
self.refresh_available_heatmaps()
|
|
2007
|
+
|
|
2008
|
+
def _heatmap_pixmap(self, layer: Any) -> QtGui.QPixmap:
|
|
2009
|
+
"""Render a small data-shaped image rather than a line glyph."""
|
|
2010
|
+
|
|
2011
|
+
size = QtCore.QSize(64, 22)
|
|
2012
|
+
source = (
|
|
2013
|
+
self.owner.__dict__.get("dataGrid")
|
|
2014
|
+
if layer is self.owner
|
|
2015
|
+
else getattr(layer, "data_grid", None)
|
|
2016
|
+
)
|
|
2017
|
+
try:
|
|
2018
|
+
data = np.asarray(source, dtype=float)
|
|
2019
|
+
except (TypeError, ValueError):
|
|
2020
|
+
data = np.empty((0, 0))
|
|
2021
|
+
if data.ndim != 2 or data.size == 0:
|
|
2022
|
+
x_values = np.linspace(-1.0, 1.0, size.width())
|
|
2023
|
+
y_values = np.linspace(-1.0, 1.0, size.height())[:, None]
|
|
2024
|
+
sampled = np.sin(3 * x_values) + np.cos(4 * y_values)
|
|
2025
|
+
else:
|
|
2026
|
+
y_indices = np.linspace(0, data.shape[0] - 1, size.height()).astype(int)
|
|
2027
|
+
x_indices = np.linspace(0, data.shape[1] - 1, size.width()).astype(int)
|
|
2028
|
+
sampled = data[np.ix_(y_indices, x_indices)]
|
|
2029
|
+
|
|
2030
|
+
finite = sampled[np.isfinite(sampled)]
|
|
2031
|
+
if finite.size:
|
|
2032
|
+
low = float(np.min(finite))
|
|
2033
|
+
high = float(np.max(finite))
|
|
2034
|
+
if high > low:
|
|
2035
|
+
normalised = (sampled - low) / (high - low)
|
|
2036
|
+
else:
|
|
2037
|
+
normalised = np.full(sampled.shape, 0.5)
|
|
2038
|
+
else:
|
|
2039
|
+
normalised = np.zeros(sampled.shape)
|
|
2040
|
+
|
|
2041
|
+
name_getter = getattr(self.owner, "_current_colorbar_colormap_name", None)
|
|
2042
|
+
name = str(name_getter()) if callable(name_getter) else "viridis"
|
|
2043
|
+
color_map = _colorbar_colormap_for_name(name)
|
|
2044
|
+
if not isinstance(color_map, pg.ColorMap):
|
|
2045
|
+
color_map = _colorbar_colormap_for_name("viridis")
|
|
2046
|
+
lookup = color_map.getLookupTable(nPts=256, alpha=True)
|
|
2047
|
+
image = QtGui.QImage(
|
|
2048
|
+
size.width(),
|
|
2049
|
+
size.height(),
|
|
2050
|
+
QtGui.QImage.Format.Format_ARGB32,
|
|
2051
|
+
)
|
|
2052
|
+
alpha = self.owner._heatmap_opacity(layer)
|
|
2053
|
+
if not self.owner._heatmap_is_visible(layer):
|
|
2054
|
+
alpha *= 0.35
|
|
2055
|
+
for y_position in range(size.height()):
|
|
2056
|
+
source_y = size.height() - y_position - 1
|
|
2057
|
+
for x_position in range(size.width()):
|
|
2058
|
+
value = normalised[source_y, x_position]
|
|
2059
|
+
if not np.isfinite(value):
|
|
2060
|
+
image.setPixelColor(
|
|
2061
|
+
x_position,
|
|
2062
|
+
y_position,
|
|
2063
|
+
QtGui.QColor(0, 0, 0, 0),
|
|
2064
|
+
)
|
|
2065
|
+
continue
|
|
2066
|
+
rgba = lookup[min(255, max(0, round(float(value) * 255)))]
|
|
2067
|
+
color = QtGui.QColor(*[int(channel) for channel in rgba[:4]])
|
|
2068
|
+
color.setAlphaF(alpha * color.alphaF())
|
|
2069
|
+
image.setPixelColor(x_position, y_position, color)
|
|
2070
|
+
return QtGui.QPixmap.fromImage(image)
|
|
2071
|
+
|
|
2072
|
+
def _selected_rows(self) -> list[int]:
|
|
2073
|
+
model = self.table.selectionModel()
|
|
2074
|
+
if model is None:
|
|
2075
|
+
return []
|
|
2076
|
+
rows = sorted({index.row() for index in model.selectedRows()})
|
|
2077
|
+
current_row = self.table.currentRow()
|
|
2078
|
+
if not rows and 0 <= current_row < self.table.rowCount():
|
|
2079
|
+
return [current_row]
|
|
2080
|
+
return rows
|
|
2081
|
+
|
|
2082
|
+
def _selected_keys(self) -> list[Any]:
|
|
2083
|
+
keys = []
|
|
2084
|
+
for row in self._selected_rows():
|
|
2085
|
+
item = self.table.item(row, self._COL_ID)
|
|
2086
|
+
if item is not None:
|
|
2087
|
+
key = item.data(QtCore.Qt.ItemDataRole.UserRole)
|
|
2088
|
+
if key in self.owner.heatmaps:
|
|
2089
|
+
keys.append(key)
|
|
2090
|
+
return keys
|
|
2091
|
+
|
|
2092
|
+
def _key_for_row(self, row: int) -> Any:
|
|
2093
|
+
item = self.table.item(row, self._COL_ID)
|
|
2094
|
+
return (
|
|
2095
|
+
None
|
|
2096
|
+
if item is None
|
|
2097
|
+
else item.data(QtCore.Qt.ItemDataRole.UserRole)
|
|
2098
|
+
)
|
|
2099
|
+
|
|
2100
|
+
def select_heatmap(self, key: Any) -> bool:
|
|
2101
|
+
for row in range(self.table.rowCount()):
|
|
2102
|
+
if self._key_for_row(row) != key:
|
|
2103
|
+
continue
|
|
2104
|
+
self.table.clearSelection()
|
|
2105
|
+
self.table.selectRow(row)
|
|
2106
|
+
item = self.table.item(row, self._COL_ID)
|
|
2107
|
+
if item is not None:
|
|
2108
|
+
self.table.setCurrentItem(item)
|
|
2109
|
+
self.table.scrollToItem(item)
|
|
2110
|
+
return True
|
|
2111
|
+
return False
|
|
2112
|
+
|
|
2113
|
+
def _move_rows_to_position(
|
|
2114
|
+
self,
|
|
2115
|
+
source_rows: list[int],
|
|
2116
|
+
destination_row: int,
|
|
2117
|
+
) -> None:
|
|
2118
|
+
keys = list(self.owner.heatmaps)
|
|
2119
|
+
source_rows = sorted(
|
|
2120
|
+
{row for row in source_rows if 0 <= row < len(keys)}
|
|
2121
|
+
)
|
|
2122
|
+
if not source_rows:
|
|
2123
|
+
return
|
|
2124
|
+
destination_row = max(0, min(destination_row, len(keys)))
|
|
2125
|
+
selected = [keys[row] for row in source_rows]
|
|
2126
|
+
destination_row -= sum(row < destination_row for row in source_rows)
|
|
2127
|
+
remaining = [
|
|
2128
|
+
key for row, key in enumerate(keys) if row not in source_rows
|
|
2129
|
+
]
|
|
2130
|
+
reordered = (
|
|
2131
|
+
remaining[:destination_row]
|
|
2132
|
+
+ selected
|
|
2133
|
+
+ remaining[destination_row:]
|
|
2134
|
+
)
|
|
2135
|
+
if reordered == keys:
|
|
2136
|
+
return
|
|
2137
|
+
heatmaps = self.owner.heatmaps
|
|
2138
|
+
reordered_layers = [(key, heatmaps[key]) for key in reordered]
|
|
2139
|
+
heatmaps.clear()
|
|
2140
|
+
heatmaps.update(reordered_layers)
|
|
2141
|
+
self.owner._sync_heatmap_layer_order()
|
|
2142
|
+
self.refresh_rows()
|
|
2143
|
+
|
|
2144
|
+
def _sync_controls_from_selection(self) -> None:
|
|
2145
|
+
if self._building:
|
|
2146
|
+
return
|
|
2147
|
+
keys = self._selected_keys()
|
|
2148
|
+
if not keys:
|
|
2149
|
+
self._update_control_enabled_states(False)
|
|
2150
|
+
return
|
|
2151
|
+
layer = self.owner.heatmaps[keys[0]]
|
|
2152
|
+
self._building = True
|
|
2153
|
+
try:
|
|
2154
|
+
self.visible.setChecked(self.owner._heatmap_is_visible(layer))
|
|
2155
|
+
self.opacity.setValue(
|
|
2156
|
+
round(self.owner._heatmap_opacity(layer) * 100)
|
|
2157
|
+
)
|
|
2158
|
+
x_axis, y_axis = self.owner._heatmap_axis_sides(layer)
|
|
2159
|
+
self.x_axis.setCurrentText(x_axis)
|
|
2160
|
+
self.y_axis.setCurrentText(y_axis)
|
|
2161
|
+
finally:
|
|
2162
|
+
self._building = False
|
|
2163
|
+
self._update_control_enabled_states(True)
|
|
2164
|
+
|
|
2165
|
+
def _update_control_enabled_states(self, has_selection: bool) -> None:
|
|
2166
|
+
self.visible.setEnabled(has_selection)
|
|
2167
|
+
self.opacity.setEnabled(has_selection)
|
|
2168
|
+
self.opacity_slider.setEnabled(has_selection)
|
|
2169
|
+
self.x_axis.setEnabled(has_selection)
|
|
2170
|
+
self.y_axis.setEnabled(has_selection)
|
|
2171
|
+
keys = self._selected_keys() if has_selection else []
|
|
2172
|
+
self.remove_heatmap_button.setEnabled(
|
|
2173
|
+
bool(keys) and self.owner._primary_heatmap_key not in keys
|
|
2174
|
+
)
|
|
2175
|
+
|
|
2176
|
+
def _apply_selection(self, *_args: Any) -> None:
|
|
2177
|
+
if self._building:
|
|
2178
|
+
return
|
|
2179
|
+
keys = self._selected_keys()
|
|
2180
|
+
self._update_control_enabled_states(bool(keys))
|
|
2181
|
+
for key in keys:
|
|
2182
|
+
layer = self.owner.heatmaps[key]
|
|
2183
|
+
self.owner._set_layer_visibility(layer, self.visible.isChecked())
|
|
2184
|
+
self.owner._set_layer_opacity(layer, self.opacity.value() / 100)
|
|
2185
|
+
self.refresh_rows()
|
|
2186
|
+
|
|
2187
|
+
def _apply_axis_selection(self, *_args: Any) -> None:
|
|
2188
|
+
if self._building:
|
|
2189
|
+
return
|
|
2190
|
+
keys = self._selected_keys()
|
|
2191
|
+
self._update_control_enabled_states(bool(keys))
|
|
2192
|
+
for key in keys:
|
|
2193
|
+
self.owner._set_layer_axes(
|
|
2194
|
+
self.owner.heatmaps[key],
|
|
2195
|
+
self.x_axis.currentText(),
|
|
2196
|
+
self.y_axis.currentText(),
|
|
2197
|
+
)
|