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.
Files changed (119) hide show
  1. qplot/__init__.py +46 -0
  2. qplot/__main__.py +232 -0
  3. qplot/_auxiliary_paths.py +63 -0
  4. qplot/_metadata.py +12 -0
  5. qplot/_repair.py +14 -0
  6. qplot/_shutdown_supervisor.py +4049 -0
  7. qplot/_version.py +12 -0
  8. qplot/_windows_shutdown_job.py +1166 -0
  9. qplot/configuration/__init__.py +3 -0
  10. qplot/configuration/config.py +573 -0
  11. qplot/configuration/config_schema.json +319 -0
  12. qplot/configuration/scripts.py +255 -0
  13. qplot/configuration/themes/__init__.py +13 -0
  14. qplot/configuration/themes/_base.py +579 -0
  15. qplot/configuration/themes/blank.py +17 -0
  16. qplot/configuration/themes/dark.py +54 -0
  17. qplot/configuration/themes/light.py +54 -0
  18. qplot/datahandling/LoadFromDB.py +224 -0
  19. qplot/datahandling/__init__.py +47 -0
  20. qplot/datahandling/_trusted_live_helper.py +812 -0
  21. qplot/datahandling/_trusted_live_protocol.py +1745 -0
  22. qplot/datahandling/database.py +2330 -0
  23. qplot/datahandling/dimensions.py +43 -0
  24. qplot/datahandling/file_identity.py +461 -0
  25. qplot/datahandling/parameter_data.py +149 -0
  26. qplot/datahandling/plot_progress.py +17 -0
  27. qplot/datahandling/qcodes_cache.py +277 -0
  28. qplot/datahandling/qcodes_compat.py +14 -0
  29. qplot/datahandling/readSQL.py +2442 -0
  30. qplot/datahandling/readonly.py +2561 -0
  31. qplot/datahandling/source_activity.py +21 -0
  32. qplot/datahandling/trusted_array_preview.py +127 -0
  33. qplot/datahandling/trusted_derived_cache.py +1605 -0
  34. qplot/datahandling/trusted_derived_rendering.py +868 -0
  35. qplot/datahandling/trusted_heatmap.py +241 -0
  36. qplot/datahandling/trusted_live.py +2808 -0
  37. qplot/datahandling/trusted_live_queries.py +5907 -0
  38. qplot/datahandling/trusted_live_service.py +1894 -0
  39. qplot/datahandling/trusted_live_supervisor.py +2161 -0
  40. qplot/datahandling/trusted_plot.py +283 -0
  41. qplot/datahandling/trusted_presentation.py +1065 -0
  42. qplot/datahandling/trusted_snapshot.py +1132 -0
  43. qplot/datahandling/trusted_work_coordinator.py +2157 -0
  44. qplot/datahandling/trusted_work_scheduler.py +1204 -0
  45. qplot/diagnostics.py +241 -0
  46. qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
  47. qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
  48. qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
  49. qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
  50. qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
  51. qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
  52. qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
  53. qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
  54. qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
  55. qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
  56. qplot/testdata.py +1733 -0
  57. qplot/tools/__init__.py +13 -0
  58. qplot/tools/finite_means.py +114 -0
  59. qplot/tools/general.py +134 -0
  60. qplot/tools/heatmap_geometry.py +381 -0
  61. qplot/tools/operation_registry.py +179 -0
  62. qplot/tools/plot_tools.py +625 -0
  63. qplot/tools/sample_statistics.py +56 -0
  64. qplot/tools/worker.py +2787 -0
  65. qplot/windows/__init__.py +9 -0
  66. qplot/windows/_colorbar.py +422 -0
  67. qplot/windows/_commands.py +523 -0
  68. qplot/windows/_config_persistence.py +123 -0
  69. qplot/windows/_database_actions.py +4904 -0
  70. qplot/windows/_dataset_handle.py +221 -0
  71. qplot/windows/_dragdrop.py +71 -0
  72. qplot/windows/_export_paths.py +1167 -0
  73. qplot/windows/_help.py +169 -0
  74. qplot/windows/_native_averaging.py +173 -0
  75. qplot/windows/_native_transforms.py +389 -0
  76. qplot/windows/_plot1d_snap.py +608 -0
  77. qplot/windows/_plot1d_traces.py +2183 -0
  78. qplot/windows/_plot2d_colorbar.py +1126 -0
  79. qplot/windows/_plot2d_colorbar_dialog.py +786 -0
  80. qplot/windows/_plot2d_layers.py +2197 -0
  81. qplot/windows/_plot2d_sweeps.py +712 -0
  82. qplot/windows/_plotWin.py +1993 -0
  83. qplot/windows/_plot_actions.py +2695 -0
  84. qplot/windows/_plot_appearance.py +276 -0
  85. qplot/windows/_plot_axis_scaling.py +1485 -0
  86. qplot/windows/_plot_export.py +1445 -0
  87. qplot/windows/_plot_feedback.py +110 -0
  88. qplot/windows/_plot_marquee.py +656 -0
  89. qplot/windows/_plot_refresh.py +942 -0
  90. qplot/windows/_plot_state.py +239 -0
  91. qplot/windows/_plot_transform_labels.py +60 -0
  92. qplot/windows/_preferences.py +635 -0
  93. qplot/windows/_refresh_interval.py +70 -0
  94. qplot/windows/_run_controls.py +828 -0
  95. qplot/windows/_shortcuts.py +47 -0
  96. qplot/windows/_subplots/__init__.py +8 -0
  97. qplot/windows/_subplots/subplot1d.py +531 -0
  98. qplot/windows/_subplots/subplot2d.py +918 -0
  99. qplot/windows/_svg_export.py +209 -0
  100. qplot/windows/_trusted_derived_qt.py +1759 -0
  101. qplot/windows/_widgets/__init__.py +23 -0
  102. qplot/windows/_widgets/_run_formatting.py +387 -0
  103. qplot/windows/_widgets/details_tables.py +1119 -0
  104. qplot/windows/_widgets/dropbox.py +290 -0
  105. qplot/windows/_widgets/operations.py +460 -0
  106. qplot/windows/_widgets/preview.py +2997 -0
  107. qplot/windows/_widgets/run_list_items.py +488 -0
  108. qplot/windows/_widgets/toolbar.py +111 -0
  109. qplot/windows/_widgets/treeWidgets.py +2572 -0
  110. qplot/windows/_window_controls.py +413 -0
  111. qplot/windows/main.py +1635 -0
  112. qplot/windows/plot1d.py +311 -0
  113. qplot/windows/plot2d.py +1901 -0
  114. qplotter-1.6.0b3.dist-info/METADATA +318 -0
  115. qplotter-1.6.0b3.dist-info/RECORD +119 -0
  116. qplotter-1.6.0b3.dist-info/WHEEL +5 -0
  117. qplotter-1.6.0b3.dist-info/entry_points.txt +4 -0
  118. qplotter-1.6.0b3.dist-info/licenses/LICENSE +21 -0
  119. 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
+ )