qplotter 1.6.0__py3-none-any.whl

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