77import matplotlib .dates as mdates
88from IPython .display import display
99import asyncio
10+ from typing import Optional , Any
1011
1112import themachinethatgoesping as theping
1213import themachinethatgoesping .pingprocessing .watercolumn .echograms as echograms
@@ -29,7 +30,9 @@ def __init__(self,
2930 show = True ,
3031 voffsets = None ,
3132 cmap = "YlGnBu_r" ,
32- cmap_layer = "jet" ,
33+ cmap_layer = "jet" ,
34+ auto_update : bool = True ,
35+ auto_update_delay_ms : int = 300 ,
3336 ** kwargs ):
3437
3538 self .mapables = []
@@ -148,13 +151,30 @@ def __init__(self,
148151
149152 self .output = ipywidgets .Output ()
150153
154+ # Auto-update on zoom/pan state
155+ self ._auto_update_enabled = auto_update
156+ self ._auto_update_delay_ms = auto_update_delay_ms
157+ self ._last_range_change_time : float = 0.0
158+ self ._debounce_task : Optional [Any ] = None
159+ self ._last_view_range : Optional [tuple ] = None
160+ self ._ignore_range_changes = False
161+
162+ # Auto-update checkbox
163+ self .w_auto_update = ipywidgets .Checkbox (
164+ value = auto_update ,
165+ description = "Auto-update on zoom" ,
166+ indent = False ,
167+ )
168+ self .w_auto_update .observe (self ._on_auto_update_toggle , names = "value" )
169+
151170 # observers for view changers
152171 for w in [self .w_vmin , self .w_vmax , self .w_interpolation ]:
153172 w .observe (self .update_view , names = ["value" ])
154173
155174 self .box_buttons = ipywidgets .HBox ([
156175 self .update_button ,
157176 self .clear_button ,
177+ self .w_auto_update ,
158178 ])
159179 self .box_sliders = ipywidgets .HBox ([
160180 self .w_vmin ,
@@ -206,6 +226,7 @@ def init_ax(self, adapt_axis_names=True):
206226 def show_background_echogram (self ):
207227 with self .output :
208228 self .init_ax ()
229+ self ._setup_auto_update () # Connect axis callbacks for auto-update
209230
210231 self .images_background , self .extents_background = [],[]
211232 self .high_res_images , self .high_res_extents = [],[]
@@ -231,6 +252,86 @@ def show_background_echogram(self):
231252 def clear_output (self ,event = 0 ):
232253 with self .output :
233254 self .output .clear_output ()
255+
256+ # =========================================================================
257+ # Auto-update on zoom/pan
258+ # =========================================================================
259+
260+ def _setup_auto_update (self ) -> None :
261+ """Set up canvas events for auto-update on zoom/pan."""
262+ # Use button_release_event which fires after zoom/pan toolbar operations
263+ self .fig .canvas .mpl_connect ('button_release_event' , self ._on_mouse_release )
264+ # Also connect to draw_event as a fallback
265+ self .fig .canvas .mpl_connect ('draw_event' , self ._on_draw_event )
266+ # Store initial view range
267+ if self .axes :
268+ self ._last_view_range = (
269+ tuple (self .axes [0 ].get_xlim ()),
270+ tuple (self .axes [0 ].get_ylim ())
271+ )
272+
273+ def _on_auto_update_toggle (self , change ) -> None :
274+ """Handle auto-update checkbox toggle."""
275+ self ._auto_update_enabled = change ["new" ]
276+ if not self ._auto_update_enabled and self ._debounce_task is not None :
277+ self ._debounce_task .cancel ()
278+ self ._debounce_task = None
279+
280+ def _on_mouse_release (self , event ) -> None :
281+ """Called on mouse button release - check if view changed."""
282+ self ._check_view_changed ()
283+
284+ def _on_draw_event (self , event ) -> None :
285+ """Called after canvas draw - check if view changed."""
286+ self ._check_view_changed ()
287+
288+ def _check_view_changed (self ) -> None :
289+ """Check if view range changed and schedule update if needed."""
290+ if not self ._auto_update_enabled :
291+ return
292+ if self ._ignore_range_changes :
293+ return
294+ if not self .axes :
295+ return
296+
297+ # Get current view range from first axis
298+ current_range = (
299+ tuple (self .axes [0 ].get_xlim ()),
300+ tuple (self .axes [0 ].get_ylim ())
301+ )
302+
303+ # Only trigger if view range actually changed
304+ if current_range != self ._last_view_range :
305+ print (f"DEBUG: View range changed, scheduling update" )
306+ self ._last_view_range = current_range
307+ self ._last_range_change_time = time ()
308+ self ._schedule_debounced_update ()
309+
310+ def _schedule_debounced_update (self ) -> None :
311+ """Schedule a debounced auto-update using asyncio."""
312+ # Cancel any existing debounce task
313+ if self ._debounce_task is not None and not self ._debounce_task .done ():
314+ self ._debounce_task .cancel ()
315+
316+ async def debounced_update ():
317+ """Wait for debounce delay, then trigger update if no new changes."""
318+ try :
319+ await asyncio .sleep (self ._auto_update_delay_ms / 1000.0 )
320+ # Check if more changes happened during the wait
321+ elapsed = time () - self ._last_range_change_time
322+ if elapsed >= (self ._auto_update_delay_ms / 1000.0 ) - 0.01 :
323+ # No new changes, call the same function as the update button
324+ self .show_background_zoom ()
325+ except asyncio .CancelledError :
326+ pass # Task was cancelled by a new range change
327+
328+ # Get the running event loop (Jupyter provides one)
329+ try :
330+ loop = asyncio .get_running_loop ()
331+ self ._debounce_task = loop .create_task (debounced_update ())
332+ except RuntimeError :
333+ # No running event loop - fall back to immediate update
334+ self .show_background_zoom ()
234335
235336 def show_background_zoom (self , event = 0 ):
236337 with self .output :
@@ -331,71 +432,76 @@ def get_args_plot(self, axis_nr, layer=False):
331432
332433
333434 def update_view (self , w = None , reset = False ):
334- with self .output :
335-
336- try :
337- self .xlim = self .axes [- 1 ].get_xlim ()
338- self .ylim = self .axes [- 1 ].get_ylim ()
339-
340- self .init_ax (reset )
341- minx ,maxx ,miny ,maxy = np .nan ,np .nan ,np .nan ,np .nan
342-
343- for i ,ax in enumerate (self .axes ):
344- #zorder=1
345- self .mapables .append (ax .imshow (
346- self .images_background [i ].transpose (),
347- extent = self .extents_background [i ],
348- #zorder=zorder,
349- ** self .get_args_plot (i )))
350-
351- if reset :
352- xlim = ax .get_xlim ()
353- ylim = ax .get_ylim ()
354- minx = np .nanmin ([xlim [0 ],minx ])
355- maxx = np .nanmax ([xlim [1 ],maxx ])
356- miny = np .nanmin ([ylim [1 ],miny ])
357- maxy = np .nanmax ([ylim [0 ],maxy ])
435+ # Temporarily ignore range changes to prevent recursive updates
436+ self ._ignore_range_changes = True
437+ try :
438+ with self .output :
358439
359- if len (self .high_res_images ) > i :
360- #zorder+=1
361- self .mapables .append (
362- ax .imshow (self .high_res_images [i ].transpose (),
363- extent = self .high_res_extents [i ],
364- #zorder=zorder,
365- ** self .get_args_plot (i )))
366-
367- if len (self .layer_images ) > i :
368- #zorder+=1
369- self .mapables .append (
370- ax .imshow (self .layer_images [i ].transpose (),
371- extent = self .layer_extents [i ],
372- #zorder=zorder,
373- ** self .get_args_plot (i ,layer = True )))
440+ try :
441+ self .xlim = self .axes [- 1 ].get_xlim ()
442+ self .ylim = self .axes [- 1 ].get_ylim ()
443+
444+ self .init_ax (reset )
445+ minx ,maxx ,miny ,maxy = np .nan ,np .nan ,np .nan ,np .nan
374446
447+ for i ,ax in enumerate (self .axes ):
448+ #zorder=1
449+ self .mapables .append (ax .imshow (
450+ self .images_background [i ].transpose (),
451+ extent = self .extents_background [i ],
452+ #zorder=zorder,
453+ ** self .get_args_plot (i )))
454+
455+ if reset :
456+ xlim = ax .get_xlim ()
457+ ylim = ax .get_ylim ()
458+ minx = np .nanmin ([xlim [0 ],minx ])
459+ maxx = np .nanmax ([xlim [1 ],maxx ])
460+ miny = np .nanmin ([ylim [1 ],miny ])
461+ maxy = np .nanmax ([ylim [0 ],maxy ])
462+
463+ if len (self .high_res_images ) > i :
464+ #zorder+=1
465+ self .mapables .append (
466+ ax .imshow (self .high_res_images [i ].transpose (),
467+ extent = self .high_res_extents [i ],
468+ #zorder=zorder,
469+ ** self .get_args_plot (i )))
470+
471+ if len (self .layer_images ) > i :
472+ #zorder+=1
473+ self .mapables .append (
474+ ax .imshow (self .layer_images [i ].transpose (),
475+ extent = self .layer_extents [i ],
476+ #zorder=zorder,
477+ ** self .get_args_plot (i ,layer = True )))
478+
375479
376- if self .colorbar [i ] is None :
377- self .colorbar [i ] = self .fig .colorbar (self .mapables [- 1 ],ax = ax , label = "(dB)" )
378- else :
379- self .colorbar [i ].update_normal (self .mapables [- 1 ])
480+ if self .colorbar [i ] is None :
481+ self .colorbar [i ] = self .fig .colorbar (self .mapables [- 1 ],ax = ax , label = "(dB)" )
482+ else :
483+ self .colorbar [i ].update_normal (self .mapables [- 1 ])
380484
381- self .callback_view ()
485+ self .callback_view ()
382486
383- if reset :
384- ax .set_xlim (minx ,maxx )
385- ax .set_ylim (maxy ,miny )
386- else :
387- ax .set_xlim (self .xlim )
388- ax .set_ylim (self .ylim )
389-
390- if len (self .mapables ) > len (self .echogramdata )* 3 :
391- for m in self .mapables [len (self .echogramdata )* 3 - 1 :]:
392- m .remove ()
393- self .mapables = self .mapables [:len (self .echogramdata )* 3 ]
487+ if reset :
488+ ax .set_xlim (minx ,maxx )
489+ ax .set_ylim (maxy ,miny )
490+ else :
491+ ax .set_xlim (self .xlim )
492+ ax .set_ylim (self .ylim )
493+
494+ if len (self .mapables ) > len (self .echogramdata )* 3 :
495+ for m in self .mapables [len (self .echogramdata )* 3 - 1 :]:
496+ m .remove ()
497+ self .mapables = self .mapables [:len (self .echogramdata )* 3 ]
394498
395- self .fig .canvas .draw_idle ()
499+ self .fig .canvas .draw_idle ()
396500
397- except Exception as e :
398- raise (e )
501+ except Exception as e :
502+ raise (e )
503+ finally :
504+ self ._ignore_range_changes = False
399505
400506 def callback_view (self ):
401507 pass
0 commit comments