44
55from __future__ import annotations
66
7- from typing import Dict , Iterable , Sequence , Tuple
7+ from typing import Any , Dict , Iterable , List , Sequence , Tuple
88
99import numpy as np
1010import pyqtgraph as pg # type: ignore
@@ -57,6 +57,7 @@ def __init__(
5757 self ._curves : Dict [str , pg .PlotDataItem ] = {}
5858 self ._annotations : Dict [Point , pg .TextItem ] = {}
5959 self ._markers : Dict [Point , pg .ScatterPlotItem ] = {}
60+ self ._annotation_sources : Dict [Point , str ] = {}
6061 self ._palette : Iterable = ColorPalette ()
6162
6263 # UI set‑up
@@ -85,21 +86,23 @@ def set_signal_provider(self, signal_provider) -> None:
8586 self ._update_realtime_curves
8687 )
8788
88- def update_plots (self , paths : Sequence [Path ], legends : Sequence [Legend ]) -> None :
89+ def update_plots (
90+ self , paths : Sequence [Path ], legends : Sequence [Legend ]
91+ ) -> List [str ]:
8992 """Synchronise plots with the *paths* list.
9093
9194 New items are added, disappeared items removed. Existing ones are
9295 left untouched to avoid flicker.
9396 """
9497 if self ._signal_provider is None :
95- return
98+ return []
9699
97100 # For real-time provider, update the set of selected signals to buffer
98101 if self ._signal_provider .provider_type == ProviderType .REALTIME :
99102 selected_keys = ["::" .join (path ) for path in paths ]
100103 self ._signal_provider .add_signals_to_buffer (selected_keys )
101104
102- self ._add_missing_curves (paths , legends )
105+ missing_paths = self ._add_missing_curves (paths , legends )
103106 self ._remove_obsolete_curves (paths )
104107
105108 # Set the X axis range based on the provider type
@@ -117,6 +120,8 @@ def update_plots(self, paths: Sequence[Path], legends: Sequence[Legend]) -> None
117120 self ._signal_provider .end_time - self ._signal_provider .initial_time ,
118121 )
119122
123+ return missing_paths
124+
120125 # The following trio is wired to whoever controls the replay/stream
121126 def pause_animation (self ) -> None : # noqa: D401
122127 """Pause the vertical‑line animation."""
@@ -173,23 +178,46 @@ def _connect_signals(self) -> None:
173178
174179 def _add_missing_curves (
175180 self , paths : Sequence [Path ], legends : Sequence [Legend ]
176- ) -> None :
181+ ) -> List [ str ] :
177182 """Plot curves that are present in *paths* but not yet on screen."""
183+
184+ missing : List [str ] = []
178185 for path , legend in zip (paths , legends ):
179186 key = "/" .join (path )
180187 if key in self ._curves :
181188 continue
182189
183190 # Drill down to the data array
184- data = self ._signal_provider .data
185- for subkey in path [:- 1 ]:
186- data = data [subkey ]
187191 try :
188- y = data ["data" ][:, int (path [- 1 ])]
189- except (IndexError , ValueError ): # scalar variable
190- y = data ["data" ][:]
192+ data = self ._signal_provider .data
193+ for subkey in path [:- 1 ]:
194+ data = data [subkey ]
195+
196+ data_array = data ["data" ]
197+ timestamps = data ["timestamps" ]
198+ except (KeyError , TypeError ):
199+ missing .append (key )
200+ continue
201+
202+ try :
203+ y = data_array [:, int (path [- 1 ])]
204+ except (
205+ IndexError ,
206+ ValueError ,
207+ TypeError ,
208+ ): # scalar variable or invalid index
209+ try :
210+ y = data_array [:]
211+ except Exception :
212+ missing .append (key )
213+ continue
214+
215+ try :
216+ x = timestamps - self ._signal_provider .initial_time
217+ except Exception :
218+ missing .append (key )
219+ continue
191220
192- x = data ["timestamps" ] - self ._signal_provider .initial_time
193221 palette_color = next (self ._palette )
194222 pen = pg .mkPen (palette_color .as_hex (), width = 2 )
195223
@@ -201,12 +229,21 @@ def _add_missing_curves(
201229 symbol = None ,
202230 )
203231
232+ return missing
233+
204234 def _remove_obsolete_curves (self , paths : Sequence [Path ]) -> None :
205235 """Delete curves that disappeared from *paths*."""
206236 valid = {"/" .join (p ) for p in paths }
207237 for key in [k for k in self ._curves if k not in valid ]:
208238 self ._plot .removeItem (self ._curves .pop (key ))
209239
240+ # Remove annotations associated to the deleted curve
241+ orphan_points = [
242+ pt for pt , src in self ._annotation_sources .items () if src == key
243+ ]
244+ for point in orphan_points :
245+ self ._deselect (point )
246+
210247 def _update_vline (self ) -> None :
211248 """Move the vertical line to ``current_time``."""
212249 if self ._signal_provider is None :
@@ -279,10 +316,11 @@ def _on_mouse_click(self, event) -> None: # noqa: N802
279316 self ._deselect (candidate )
280317 else :
281318 assert nearest_curve is not None # mypy‑friendly
282- self ._select (candidate , nearest_curve . opts [ "pen" ] )
319+ self ._select (candidate , nearest_curve )
283320
284- def _select (self , pt : Point , pen : pg .QtGui . QPen ) -> None :
321+ def _select (self , pt : Point , curve : pg .PlotDataItem ) -> None :
285322 """Add label + circle marker on *pt*."""
323+ pen = curve .opts ["pen" ]
286324 x_span = np .diff (self ._plot .viewRange ()[0 ])[0 ]
287325 y_span = np .diff (self ._plot .viewRange ()[1 ])[0 ]
288326 x_prec = max (0 , int (- np .log10 (max (x_span , 1e-12 ))) + 2 )
@@ -313,14 +351,153 @@ def _select(self, pt: Point, pen: pg.QtGui.QPen) -> None:
313351 self ._plot .addItem (marker )
314352 self ._markers [pt ] = marker
315353
354+ curve_key = self ._curve_key (curve )
355+ if curve_key is not None :
356+ self ._annotation_sources [pt ] = curve_key
357+
316358 def _deselect (self , pt : Point ) -> None :
317359 """Remove annotation + marker on *pt*."""
318360 self ._plot .removeItem (self ._annotations .pop (pt ))
319361 self ._plot .removeItem (self ._markers .pop (pt ))
362+ self ._annotation_sources .pop (pt , None )
320363
321364 def clear_selections (self ) -> None : # noqa: D401
322365 """Remove **all** annotations and markers."""
323366 for item in (* self ._annotations .values (), * self ._markers .values ()):
324367 self ._plot .removeItem (item )
325368 self ._annotations .clear ()
326369 self ._markers .clear ()
370+ self ._annotation_sources .clear ()
371+
372+ def clear_curves (self ) -> None :
373+ """Remove every plotted curve and related markers."""
374+ for key in list (self ._curves .keys ()):
375+ self ._plot .removeItem (self ._curves .pop (key ))
376+ self .clear_selections ()
377+
378+ def capture_state (self ) -> Dict [str , Any ]:
379+ """Return a snapshot of the current canvas configuration."""
380+
381+ annotations : List [Dict [str , Any ]] = []
382+ for pt , label in self ._annotations .items ():
383+ source = self ._annotation_sources .get (pt )
384+ if source is None :
385+ continue
386+ annotations .append (
387+ {
388+ "path" : source ,
389+ "point" : [float (pt [0 ]), float (pt [1 ])],
390+ "label" : label .toPlainText (),
391+ }
392+ )
393+
394+ curve_meta : Dict [str , Dict [str , Any ]] = {}
395+ for key , curve in self ._curves .items ():
396+ pen_info : Dict [str , Any ] = {}
397+ pen = curve .opts .get ("pen" )
398+ if pen is not None :
399+ qcol = pen .color ()
400+ pen_info ["color" ] = (
401+ f"#{ qcol .red ():02x} { qcol .green ():02x} { qcol .blue ():02x} "
402+ )
403+ pen_info ["width" ] = pen .width ()
404+ curve_meta [key ] = {
405+ "label" : curve .opts .get ("name" , "" ),
406+ "pen" : pen_info ,
407+ }
408+
409+ return {
410+ "curves" : curve_meta ,
411+ "view_range" : self ._plot .viewRange (),
412+ "legend_visible" : bool (self ._plot .plotItem .legend .isVisible ()),
413+ "annotations" : annotations ,
414+ }
415+
416+ def apply_view_range (self , view_range : Sequence [Sequence [float ]]) -> None :
417+ """Restore the axes limits from a snapshot."""
418+
419+ if len (view_range ) != 2 :
420+ return
421+
422+ x_range , y_range = view_range
423+ if len (x_range ) == 2 :
424+ self ._plot .setXRange (float (x_range [0 ]), float (x_range [1 ]), padding = 0 )
425+ if len (y_range ) == 2 :
426+ self ._plot .setYRange (float (y_range [0 ]), float (y_range [1 ]), padding = 0 )
427+
428+ def set_legend_visible (self , visible : bool ) -> None :
429+ """Toggle legend visibility."""
430+
431+ legend = getattr (self ._plot .plotItem , "legend" , None )
432+ if legend is not None :
433+ legend .setVisible (bool (visible ))
434+
435+ def restore_annotations (self , annotations : Sequence [Dict [str , Any ]]) -> List [str ]:
436+ """Re-create selection markers from saved data.
437+
438+ Returns:
439+ List of curve identifiers that could not be restored.
440+ """
441+
442+ missing : List [str ] = []
443+ for ann in annotations :
444+ key = ann .get ("path" )
445+ point = ann .get ("point" )
446+ if key is None or point is None :
447+ continue
448+ curve = self ._curves .get (str (key ))
449+ if curve is None :
450+ missing .append (str (key ))
451+ continue
452+ try :
453+ pt_tuple : Point = (float (point [0 ]), float (point [1 ]))
454+ except (TypeError , ValueError , IndexError ):
455+ missing .append (str (key ))
456+ continue
457+ self ._select (pt_tuple , curve )
458+ return missing
459+
460+ def _curve_key (self , curve : pg .PlotDataItem ) -> str | None :
461+ for key , item in self ._curves .items ():
462+ if item is curve :
463+ return key
464+ return None
465+
466+ def apply_curve_metadata (self , metadata : Dict [str , Dict [str , Any ]]) -> None :
467+ for key , info in metadata .items ():
468+ curve = self ._curves .get (key )
469+ if curve is None :
470+ continue
471+
472+ label = info .get ("label" )
473+ if label is not None :
474+ label_text = str (label )
475+ if hasattr (curve , "setName" ):
476+ curve .setName (label_text )
477+ else :
478+ curve .opts ["name" ] = label_text
479+ legend = getattr (self ._plot .plotItem , "legend" , None )
480+ if legend is not None :
481+ if hasattr (legend , "itemChanged" ):
482+ legend .itemChanged (curve )
483+ else : # compatibility fallback for older pyqtgraph releases
484+ try :
485+ legend .removeItem (curve )
486+ except Exception :
487+ pass
488+ legend .addItem (curve , label_text )
489+
490+ pen_info = info .get ("pen" , {})
491+ color = pen_info .get ("color" )
492+ width = pen_info .get ("width" )
493+ if color is not None or width is not None :
494+ kwargs : Dict [str , Any ] = {}
495+ if color is not None :
496+ kwargs ["color" ] = color
497+ if width is not None :
498+ try :
499+ kwargs ["width" ] = float (width )
500+ except (TypeError , ValueError ):
501+ pass
502+ if kwargs :
503+ curve .setPen (pg .mkPen (** kwargs ))
0 commit comments