@@ -53,7 +53,7 @@ def _load(step: int, read_func: Callable) -> object:
5353# Plot widget
5454# ---------------------------------------------------------------------------
5555
56- class MFCPlot (PlotextPlot ):
56+ class MFCPlot (PlotextPlot ): # pylint: disable=too-many-instance-attributes
5757 """Plotext plot widget. Caller sets ._x_cc / ._y_cc / ._data / ._ndim /
5858 ._varname / ._step before calling .refresh()."""
5959
@@ -74,19 +74,37 @@ def __init__(self, **kwargs):
7474 self ._varname : str = ""
7575 self ._step : int = 0
7676 self ._cmap_name : str = _CMAPS [0 ]
77+ self ._log_scale : bool = False
78+ self ._vmin : Optional [float ] = None
79+ self ._vmax : Optional [float ] = None
80+ self ._last_vmin : float = 0.0
81+ self ._last_vmax : float = 1.0
7782
78- def render (self ): # pylint: disable=too-many-branches,too-many-locals
83+ def render (self ): # pylint: disable=too-many-branches,too-many-locals,too-many-statements
7984 data = self ._data
8085 x_cc = self ._x_cc
8186 self .plt .clear_figure ()
8287
8388 # 1D: use normal plotext path — gives proper axes and title for free.
8489 if data is None or x_cc is None or self ._ndim == 1 :
8590 if data is not None and x_cc is not None :
86- self .plt .plot (x_cc .tolist (), data .tolist ())
91+ if self ._log_scale :
92+ plot_y = np .where (data > 0 , np .log10 (np .maximum (data , 1e-300 )), np .nan )
93+ ylabel = f"log\u2081 \u2080 ({ self ._varname } )"
94+ else :
95+ plot_y = data
96+ ylabel = self ._varname
97+ finite = plot_y [np .isfinite (plot_y )]
98+ self ._last_vmin = float (finite .min ()) if finite .size else 0.0
99+ self ._last_vmax = float (finite .max ()) if finite .size else 1.0
100+ self .plt .plot (x_cc .tolist (), plot_y .tolist ())
87101 self .plt .xlabel ("x" )
88- self .plt .ylabel (self . _varname )
102+ self .plt .ylabel (ylabel )
89103 self .plt .title (f"{ self ._varname } (step { self ._step } )" )
104+ if self ._vmin is not None or self ._vmax is not None :
105+ lo = self ._vmin if self ._vmin is not None else self ._last_vmin
106+ hi = self ._vmax if self ._vmax is not None else self ._last_vmax
107+ self .plt .ylim (lo , hi )
90108 else :
91109 self .plt .title ("No data loaded" )
92110 return super ().render ()
@@ -108,9 +126,17 @@ def render(self): # pylint: disable=too-many-branches,too-many-locals
108126 iy = np .linspace (0 , data .shape [1 ] - 1 , h_plot , dtype = int )
109127 ds = data [np .ix_ (ix , iy )] # pylint: disable=unsubscriptable-object
110128
111- vmin , vmax = float (ds .min ()), float (ds .max ())
129+ vmin = self ._vmin if self ._vmin is not None else float (ds .min ())
130+ vmax = self ._vmax if self ._vmax is not None else float (ds .max ())
131+ if vmax <= vmin :
132+ vmax = vmin + 1e-10
133+ self ._last_vmin = vmin
134+ self ._last_vmax = vmax
112135 cmap = mcm .get_cmap (self ._cmap_name )
113- norm = mcolors .Normalize (vmin = vmin , vmax = vmax )
136+ if self ._log_scale and vmin > 0 :
137+ norm = mcolors .LogNorm (vmin = vmin , vmax = vmax )
138+ else :
139+ norm = mcolors .Normalize (vmin = vmin , vmax = vmax )
114140 # Transpose + flip so y=0 appears at the bottom of the display.
115141 rgba = cmap (norm (ds .T [::- 1 ])) # (h_plot, w_map, 4)
116142
@@ -137,7 +163,8 @@ def render(self): # pylint: disable=too-many-branches,too-many-locals
137163 elif row == h_plot - 1 :
138164 lbl = f" { vmin :.3g} "
139165 elif row == h_plot // 2 :
140- lbl = f" { (vmin + vmax ) / 2 :.3g} "
166+ mid = np .sqrt (vmin * vmax ) if (self ._log_scale and vmin > 0 ) else (vmin + vmax ) / 2
167+ lbl = f" { mid :.3g} "
141168 else :
142169 lbl = ""
143170 line .append (lbl .ljust (_CB_LBL )[:_CB_LBL ])
@@ -205,12 +232,17 @@ class MFCTuiApp(App): # pylint: disable=too-many-instance-attributes
205232 Binding ("period" , "next_step" , "step ▶" ),
206233 Binding ("left" , "prev_step" , "◀ step" , show = False ),
207234 Binding ("right" , "next_step" , "step ▶" , show = False ),
235+ Binding ("space" , "toggle_play" , "▶/⏸" ),
208236 Binding ("c" , "cycle_cmap" , "cmap" ),
237+ Binding ("l" , "toggle_log" , "log" ),
238+ Binding ("f" , "toggle_freeze" , "freeze" ),
209239 ]
210240
211241 step_idx : reactive [int ] = reactive (0 , always_update = True )
212242 var_name : reactive [str ] = reactive ("" , always_update = True )
213243 cmap_name : reactive [str ] = reactive (_CMAPS [0 ], always_update = True )
244+ log_scale : reactive [bool ] = reactive (False , always_update = True )
245+ playing : reactive [bool ] = reactive (False , always_update = True )
214246
215247 def __init__ ( # pylint: disable=too-many-arguments,too-many-positional-arguments
216248 self ,
@@ -229,6 +261,8 @@ def __init__( # pylint: disable=too-many-arguments,too-many-positional-argument
229261 # Store init_var but don't set the reactive yet — the DOM doesn't exist
230262 # until on_mount, and the watcher calls query_one which needs the DOM.
231263 self ._init_var = init_var or (varnames [0 ] if varnames else "" )
264+ self ._frozen_range : Optional [tuple ] = None
265+ self ._play_timer = None
232266
233267 def compose (self ) -> ComposeResult :
234268 yield Header (show_clock = False )
@@ -266,18 +300,37 @@ def watch_var_name(self, _old: str, _new: str) -> None:
266300 def watch_cmap_name (self , _old : str , _new : str ) -> None :
267301 self ._push_data ()
268302
303+ def watch_log_scale (self , _old : bool , _new : bool ) -> None :
304+ self ._push_data ()
305+
306+ def watch_playing (self , _old : bool , new : bool ) -> None :
307+ if new :
308+ self ._play_timer = self .set_interval (0.5 , self ._auto_advance )
309+ else :
310+ if self ._play_timer is not None :
311+ self ._play_timer .stop ()
312+ self ._play_timer = None
313+
269314 # ------------------------------------------------------------------
270315 # Helpers
271316 # ------------------------------------------------------------------
272317
273318 def _status_text (self ) -> str :
274319 step = self ._steps [self .step_idx ] if self ._steps else 0
275320 total = len (self ._steps )
321+ flags = []
322+ if self .log_scale :
323+ flags .append ("log" )
324+ if self ._frozen_range is not None :
325+ flags .append ("frozen" )
326+ if self .playing :
327+ flags .append ("▶" )
328+ flag_str = (" " + " " .join (flags )) if flags else ""
276329 return (
277330 f" step { step } [{ self .step_idx + 1 } /{ total } ]"
278331 f" var: { self .var_name } "
279332 f" cmap: { self .cmap_name } "
280- f" [,] prev [.] next [c] cmap "
333+ f"{ flag_str } "
281334 )
282335
283336 def _push_data (self ) -> None :
@@ -302,6 +355,12 @@ def _push_data(self) -> None:
302355 plot ._varname = self .var_name # pylint: disable=protected-access
303356 plot ._step = step # pylint: disable=protected-access
304357 plot ._cmap_name = self .cmap_name # pylint: disable=protected-access
358+ plot ._log_scale = self .log_scale # pylint: disable=protected-access
359+ if self ._frozen_range is not None :
360+ plot ._vmin , plot ._vmax = self ._frozen_range # pylint: disable=protected-access
361+ else :
362+ plot ._vmin = None # pylint: disable=protected-access
363+ plot ._vmax = None # pylint: disable=protected-access
305364 plot .refresh ()
306365
307366 self .query_one ("#status" , Static ).update (self ._status_text ())
@@ -328,6 +387,23 @@ def action_cycle_cmap(self) -> None:
328387 idx = (_CMAPS .index (self .cmap_name ) + 1 ) % len (_CMAPS )
329388 self .cmap_name = _CMAPS [idx ]
330389
390+ def action_toggle_log (self ) -> None :
391+ self .log_scale = not self .log_scale
392+
393+ def action_toggle_freeze (self ) -> None :
394+ if self ._frozen_range is not None :
395+ self ._frozen_range = None
396+ else :
397+ plot = self .query_one ("#plot" , MFCPlot )
398+ self ._frozen_range = (plot ._last_vmin , plot ._last_vmax ) # pylint: disable=protected-access
399+ self ._push_data ()
400+
401+ def action_toggle_play (self ) -> None :
402+ self .playing = not self .playing
403+
404+ def _auto_advance (self ) -> None :
405+ self .step_idx = (self .step_idx + 1 ) % len (self ._steps )
406+
331407
332408# ---------------------------------------------------------------------------
333409# Public entry point
@@ -358,7 +434,7 @@ def run_tui(
358434 f"[bold]Launching TUI[/bold] — { len (steps )} step(s), "
359435 f"{ len (varnames )} variable(s)"
360436 )
361- cons .print ("[dim] ,/. or ←/→ prev/next step • ↑↓ select variable • q quit[/dim]" )
437+ cons .print ("[dim] ,/. or ←/→ prev/next step • space play • l log • f freeze • ↑↓ variable • q quit[/dim]" )
362438
363439 _cache .clear ()
364440 _cache [steps [0 ]] = first
0 commit comments