Skip to content

Commit 667ca10

Browse files
sbryngelsonclaude
andcommitted
Add log scale, freeze range, and autoplay to TUI
Three new keybindings in --tui mode: [l] toggle log scale (1D and 2D, with LogNorm for 2D heatmaps) [f] freeze/unfreeze color range at current frame vmin/vmax [space] toggle autoplay (0.5s interval, loops) Colorbar midpoint uses geometric mean when log scale is active. Status bar shows active flags (log / frozen / playing). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 4b78bf1 commit 667ca10

1 file changed

Lines changed: 85 additions & 9 deletions

File tree

toolchain/mfc/viz/tui.py

Lines changed: 85 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)