Skip to content

Commit 8e96dd6

Browse files
committed
feat: Add contour level selection and label rendering to GeoAnimatorManager
1 parent 52d2452 commit 8e96dd6

2 files changed

Lines changed: 146 additions & 12 deletions

File tree

dvue/animator/ui.py

Lines changed: 143 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,14 @@ class GeoAnimatorManager(pn.viewable.Viewer):
170170
doc="Gaussian smoothing sigma applied to the raster before contouring "
171171
"(grid cells). 0 = no smoothing.",
172172
)
173+
contour_levels: str = param.Selector(
174+
default="nice",
175+
objects=["linear", "nice", "eq_hist"],
176+
doc="How contour levels are placed.\n"
177+
"linear — equally spaced between vmin and vmax.\n"
178+
"nice — rounded tick-like values (matplotlib MaxNLocator).\n"
179+
"eq_hist — quantile-spaced so each band covers equal data density.",
180+
)
173181
current_dt: Optional[datetime.datetime] = param.Parameter(
174182
default=None, doc="Current animation datetime.",
175183
)
@@ -401,6 +409,22 @@ def __init__(
401409
)
402410
p.add_tools(contour_hover)
403411

412+
# Contour label renderer — one text label per level (longest path).
413+
self._contour_label_source = ColumnDataSource(
414+
{"x": [], "y": [], "text": []}
415+
)
416+
self._contour_label_renderer = p.text(
417+
x="x", y="y", text="text",
418+
source=self._contour_label_source,
419+
text_font_size="10px",
420+
text_color="black",
421+
text_align="center",
422+
text_baseline="middle",
423+
background_fill_color="white",
424+
background_fill_alpha=0.6,
425+
visible=False,
426+
)
427+
404428
# X2 isohaline renderer — a single bold line, initially invisible.
405429
self._x2_source = ColumnDataSource({"xs": [], "ys": []})
406430
self._x2_renderer = p.multi_line(
@@ -421,17 +445,22 @@ def __init__(
421445
# from the options list position inside _on_slider_change.
422446
# ----------------------------------------------------------------
423447
ti = reader.time_index
424-
# IntSlider (0..N-1): fast even for multi-year 15-min series.
425-
# A Bokeh Div shows the current timestamp below the slider;
426-
# it is updated via _on_slider_change without causing a Panel
427-
# layout reflow (Div is a leaf Bokeh model, not a Panel pane).
448+
# DiscretePlayer with integer indices 0..N-1 — compact options list,
449+
# no per-step string serialisation overhead. Built-in play/pause/
450+
# loop controls drive the animation; the Bokeh Div shows the resolved
451+
# timestamp so the user sees readable dates throughout.
428452
self._time_div = Div(
429453
text=f"<b>{ti[0].strftime('%Y-%m-%d %H:%M')}</b>",
430454
styles={"font-size": "13px", "margin": "2px 0 6px 0"},
431455
)
432456
self._time_label_pane = pn.pane.Bokeh(self._time_div, sizing_mode="stretch_width")
433-
self._time_slider = pn.widgets.IntSlider(
434-
name="", start=0, end=len(ti) - 1, step=1, value=0,
457+
self._time_slider = pn.widgets.DiscretePlayer(
458+
name="",
459+
options=list(range(len(ti))),
460+
value=0,
461+
interval=500, # ms between steps when playing
462+
loop_policy="once",
463+
show_value=False, # we show timestamp in the Div above
435464
sizing_mode="stretch_width",
436465
)
437466
self._clim_input = pn.widgets.TextInput(
@@ -459,6 +488,16 @@ def __init__(
459488
name="Contour smoothing", start=0.0, end=20.0, step=0.5, value=3.0,
460489
sizing_mode="stretch_width", visible=False,
461490
)
491+
self._contour_levels_select = pn.widgets.Select(
492+
name="Contour level mode",
493+
options=["linear", "nice", "eq_hist"],
494+
value="nice",
495+
sizing_mode="stretch_width", visible=False,
496+
)
497+
self._contour_labels_check = pn.widgets.Checkbox(
498+
name="Label contours", value=False,
499+
sizing_mode="stretch_width", visible=False,
500+
)
462501
# X2 controls — only shown when an x2_callback is provided.
463502
_has_x2 = x2_callback is not None
464503
self._x2_check = pn.widgets.Checkbox(
@@ -491,6 +530,8 @@ def __init__(
491530
self._contours_check,
492531
self._n_contours_slider,
493532
self._contour_smooth_slider,
533+
self._contour_levels_select,
534+
self._contour_labels_check,
494535
*x2_row,
495536
sizing_mode="stretch_width",
496537
max_width=260,
@@ -514,6 +555,8 @@ def __init__(
514555
self._contours_check.param.watch(self._on_contours_toggle, "value")
515556
self._n_contours_slider.param.watch(self._on_n_contours_change, "value")
516557
self._contour_smooth_slider.param.watch(self._on_contour_smooth_change, "value")
558+
self._contour_levels_select.param.watch(self._on_contour_levels_change, "value")
559+
self._contour_labels_check.param.watch(self._on_contour_labels_toggle, "value")
517560
if x2_callback is not None:
518561
self._x2_check.param.watch(self._on_x2_toggle, "value")
519562
self._x2_threshold_input.param.watch(self._on_x2_threshold_change, "value")
@@ -531,6 +574,69 @@ def _current_clim(self) -> tuple[float, float]:
531574
vmax = vmin + 1.0
532575
return float(vmin), float(vmax)
533576

577+
def _compute_label_positions(
578+
self,
579+
xs_list: list,
580+
ys_list: list,
581+
lvls: list,
582+
) -> dict:
583+
"""Pick one label position per unique level (midpoint of longest path).
584+
585+
Returns a dict suitable for ``ColumnDataSource.data``:
586+
``{"x": [...], "y": [...], "text": [...]}``.
587+
"""
588+
# Group paths by level, keep the longest for labelling
589+
best: dict[float, tuple[float, float, int]] = {} # level -> (mx, my, length)
590+
for xs, ys, lvl in zip(xs_list, ys_list, lvls):
591+
n = len(xs)
592+
if n < 2:
593+
continue
594+
if lvl not in best or n > best[lvl][2]:
595+
mid = n // 2
596+
best[lvl] = (xs[mid], ys[mid], n)
597+
598+
lx, ly, lt = [], [], []
599+
for lvl, (mx, my, _) in sorted(best.items()):
600+
lx.append(mx)
601+
ly.append(my)
602+
lt.append(f"{lvl:.4g}")
603+
return {"x": lx, "y": ly, "text": lt}
604+
605+
def _update_contour_labels(
606+
self, xs_list: list, ys_list: list, lvls: list
607+
) -> None:
608+
"""Update the label source if the label renderer is visible."""
609+
if self._contour_label_renderer.visible:
610+
self._contour_label_source.data = (
611+
self._compute_label_positions(xs_list, ys_list, lvls)
612+
)
613+
614+
def _compute_levels(
615+
self, finite_vals: np.ndarray, vmin: float, vmax: float
616+
) -> np.ndarray:
617+
"""Compute contour level positions according to ``contour_levels`` param."""
618+
n = self.n_contours
619+
mode = self.contour_levels
620+
621+
if mode == "eq_hist" and len(finite_vals) >= n:
622+
quantiles = np.linspace(0.0, 1.0, n + 2)[1:-1]
623+
levels = np.quantile(finite_vals, quantiles)
624+
levels = levels[(levels > vmin) & (levels < vmax)]
625+
if len(levels) == 0:
626+
levels = np.linspace(vmin, vmax, n + 2)[1:-1]
627+
return levels
628+
629+
if mode == "nice":
630+
from matplotlib.ticker import MaxNLocator
631+
locator = MaxNLocator(nbins=n, steps=[1, 2, 2.5, 5, 10])
632+
levels = np.asarray(locator.tick_values(vmin, vmax))
633+
levels = levels[(levels > vmin) & (levels < vmax)]
634+
if len(levels) == 0:
635+
levels = np.linspace(vmin, vmax, n + 2)[1:-1]
636+
return levels
637+
638+
return np.linspace(vmin, vmax, n + 2)[1:-1]
639+
534640
def _compute_contours(self, values: list[float]) -> tuple[list, list, list]:
535641
"""Interpolate values to a regular grid, optionally smooth, then contour.
536642
@@ -567,7 +673,7 @@ def _compute_contours(self, values: list[float]) -> tuple[list, list, list]:
567673
grid_z = gaussian_filter(grid_z.astype(float), sigma=sigma)
568674

569675
eff_vmin, eff_vmax = self._current_clim()
570-
levels = np.linspace(eff_vmin, eff_vmax, self.n_contours + 2)[1:-1]
676+
levels = self._compute_levels(vals[mask], eff_vmin, eff_vmax)
571677

572678
fig, ax = plt.subplots(1, 1)
573679
try:
@@ -655,6 +761,7 @@ def _load_frame(self, step_idx: int) -> None:
655761
if self._contour_renderer.visible:
656762
xs, ys, lvls = self._compute_contours(new_values)
657763
self._contour_source.data = {"xs": xs, "ys": ys, "level": lvls}
764+
self._update_contour_labels(xs, ys, lvls)
658765

659766
if self._x2_renderer.visible and self._x2_callback is not None:
660767
threshold = float(self._x2_threshold_input.value)
@@ -700,6 +807,7 @@ def _on_style_change(self, *events) -> None:
700807
current_values = self._bk_source.data["_value"]
701808
xs, ys, lvls = self._compute_contours(list(current_values))
702809
self._contour_source.data = {"xs": xs, "ys": ys, "level": lvls}
810+
self._update_contour_labels(xs, ys, lvls)
703811

704812
def _on_colormap_widget_change(self, event: param.parameterized.Event) -> None:
705813
self.colormap = event.new
@@ -712,29 +820,54 @@ def _on_contours_toggle(self, event: param.parameterized.Event) -> None:
712820
self._contour_renderer.visible = bool(event.new)
713821
self._n_contours_slider.visible = bool(event.new)
714822
self._contour_smooth_slider.visible = bool(event.new)
823+
self._contour_levels_select.visible = bool(event.new)
824+
self._contour_labels_check.visible = bool(event.new)
715825
if event.new:
716-
# Compute contours for the current frame immediately
717826
current_values = self._bk_source.data["_value"]
718827
xs, ys, lvls = self._compute_contours(list(current_values))
719828
self._contour_source.data = {"xs": xs, "ys": ys, "level": lvls}
829+
self._update_contour_labels(xs, ys, lvls)
720830
else:
721831
self._contour_source.data = {"xs": [], "ys": [], "level": []}
832+
self._contour_label_source.data = {"x": [], "y": [], "text": []}
722833

723834
def _on_n_contours_change(self, event: param.parameterized.Event) -> None:
724-
"""Recompute contours when the number of levels changes."""
725835
self.n_contours = int(event.new)
726836
if self._contour_renderer.visible:
727837
current_values = self._bk_source.data["_value"]
728838
xs, ys, lvls = self._compute_contours(list(current_values))
729839
self._contour_source.data = {"xs": xs, "ys": ys, "level": lvls}
840+
self._update_contour_labels(xs, ys, lvls)
730841

731842
def _on_contour_smooth_change(self, event: param.parameterized.Event) -> None:
732-
"""Recompute contours when the smoothing sigma changes."""
733843
self.contour_smooth = float(event.new)
734844
if self._contour_renderer.visible:
735845
current_values = self._bk_source.data["_value"]
736846
xs, ys, lvls = self._compute_contours(list(current_values))
737847
self._contour_source.data = {"xs": xs, "ys": ys, "level": lvls}
848+
self._update_contour_labels(xs, ys, lvls)
849+
850+
def _on_contour_levels_change(self, event: param.parameterized.Event) -> None:
851+
self.contour_levels = event.new
852+
if self._contour_renderer.visible:
853+
current_values = self._bk_source.data["_value"]
854+
xs, ys, lvls = self._compute_contours(list(current_values))
855+
self._contour_source.data = {"xs": xs, "ys": ys, "level": lvls}
856+
self._update_contour_labels(xs, ys, lvls)
857+
858+
def _on_contour_labels_toggle(self, event: param.parameterized.Event) -> None:
859+
"""Show or hide contour labels."""
860+
self._contour_label_renderer.visible = bool(event.new)
861+
if event.new:
862+
xs = self._contour_source.data["xs"]
863+
ys = self._contour_source.data["ys"]
864+
lvls = self._contour_source.data["level"]
865+
if xs:
866+
self._contour_label_source.data = (
867+
self._compute_label_positions(xs, ys, lvls)
868+
)
869+
else:
870+
self._contour_label_source.data = {"x": [], "y": [], "text": []}
738871

739872
def _on_x2_toggle(self, event: param.parameterized.Event) -> None:
740873
"""Show or hide the X2 isohaline line."""

tests/test_animator.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -314,8 +314,9 @@ def test_current_dt_set_to_first_timestep_on_init(self, manager_points, reader):
314314

315315
def test_time_slider_start_end_match_reader(self, manager_points, reader):
316316
slider = manager_points._time_slider
317-
assert slider.start == 0
318-
assert slider.end == len(reader.time_index) - 1
317+
# DiscretePlayer: options is a list of integer indices 0..N-1
318+
assert slider.options[0] == 0
319+
assert slider.options[-1] == len(reader.time_index) - 1
319320
assert slider.value == 0
320321

321322
def test_gdf_no_crs_raises(self, reader):

0 commit comments

Comments
 (0)