Skip to content

Commit 829020f

Browse files
committed
auto update for echogramviewer 1
1 parent f491dfd commit 829020f

File tree

2 files changed

+323
-101
lines changed

2 files changed

+323
-101
lines changed

python/themachinethatgoesping/pingprocessing/widgets/echogramviewer.py

Lines changed: 165 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import matplotlib.dates as mdates
88
from IPython.display import display
99
import asyncio
10+
from typing import Optional, Any
1011

1112
import themachinethatgoesping as theping
1213
import 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

Comments
 (0)