@@ -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."""
0 commit comments