@@ -486,6 +486,8 @@ def __init__(
486486 self ._transform_options = transform_options or {}
487487 self ._buffer_chunk_size = buffer_chunk_size
488488 self ._reader = self ._setup_reader (initial_transform )
489+ self ._initial_transform = initial_transform # for spurious-event guard
490+ self ._transform_init_done = False # becomes True after first real change
489491 self ._geo_id_column = geo_id_column
490492 self ._title = title
491493 self ._map_height = map_height
@@ -541,7 +543,7 @@ def __init__(
541543 if init_vmin == init_vmax :
542544 init_vmax = init_vmin + 1.0
543545
544- init_series = reader . get_slice (reader .time_index [0 ])
546+ init_series = self . _reader . get_slice (self . _reader .time_index [0 ])
545547 init_values = [
546548 float (init_series .get (gid , np .nan )) for gid in self ._geo_ids
547549 ]
@@ -601,7 +603,7 @@ def __init__(
601603 sizing_mode = "stretch_both" ,
602604 min_height = map_height ,
603605 title = f"{ title + ' \u2014 ' if title else '' } "
604- f"{ reader .time_index [0 ].strftime ('%Y-%m-%d %H:%M' )} " ,
606+ f"{ self . _reader .time_index [0 ].strftime ('%Y-%m-%d %H:%M' )} " ,
605607 tools = "pan,wheel_zoom,box_zoom,reset,save" ,
606608 active_scroll = "wheel_zoom" ,
607609 )
@@ -699,12 +701,16 @@ def __init__(
699701 # ----------------------------------------------------------------
700702 # 8. Control widgets.
701703 #
702- # Time slider: DiscreteSlider whose options are the actual datetime
703- # strings. The user sees readable timestamps instead of integers.
704- # The slider value is the timestamp string; the index is derived
705- # from the options list position inside _on_slider_change.
704+ # Use self._reader.time_index (the potentially-transformed reader)
705+ # rather than the raw 'reader' parameter so the slider options and
706+ # DatetimePicker range reflect the ACTUAL output time resolution.
707+ # When initial_transform is "none" these are identical; when it is
708+ # e.g. "Rolling 14 D → Daily mean" self._reader has a daily
709+ # time_index while 'reader' has the raw hourly one. Using the
710+ # wrong one here is the root cause of the "slider index N out of
711+ # range [0, M)" warnings when loading from a saved config.
706712 # ----------------------------------------------------------------
707- ti = reader .time_index
713+ ti = self . _reader .time_index
708714 # DiscretePlayer with integer indices 0..N-1 — compact options list,
709715 # no per-step string serialisation overhead. Built-in play/pause/
710716 # loop controls drive the animation; the Bokeh Div shows the resolved
@@ -1117,7 +1123,27 @@ def _on_slider_change(self, event: param.parameterized.Event) -> None:
11171123 if self ._syncing :
11181124 return
11191125 idx = int (event .new )
1120- ts = self ._reader .time_index [idx ]
1126+ ti = self ._reader .time_index
1127+ if len (ti ) == 0 :
1128+ return
1129+ if not (0 <= idx < len (ti )):
1130+ import logging as _log
1131+ _log .getLogger (__name__ ).warning (
1132+ "GeoAnimatorManager: slider index %d is out of range "
1133+ "[0, %d) — stale browser session value; resetting to 0. "
1134+ "This typically happens when a browser tab reconnects to a "
1135+ "new server session that has a shorter dataset." ,
1136+ idx , len (ti ),
1137+ )
1138+ # Snap to the beginning and push that value back to the browser
1139+ # so the slider thumb and the displayed timestamp are in sync.
1140+ idx = 0
1141+ self ._syncing = True
1142+ try :
1143+ self ._time_slider .value = idx
1144+ finally :
1145+ self ._syncing = False
1146+ ts = ti [idx ]
11211147 ts_str = ts .strftime ("%Y-%m-%d %H:%M" )
11221148 # Sync DatetimePicker without causing a feedback loop.
11231149 self ._syncing = True
@@ -1340,11 +1366,43 @@ def _on_transform_change(self, event: param.parameterized.Event) -> None:
13401366 while the computation is in progress.
13411367 """
13421368 import threading
1369+ import logging as _log
1370+
1371+ new_name = event .new
1372+
1373+ # Guard against the spurious "none" event that Panel fires the first
1374+ # time the browser connects to sync widget state. When loading from a
1375+ # saved config (initial_transform != "none"), the Bokeh Select model
1376+ # briefly reports value="none" (the first option / default) before the
1377+ # Python-set value propagates to the browser. Applying this spurious
1378+ # "none" would silently reset the reader to the raw (untransformed)
1379+ # data — the symptom reported as "doing none transform on init".
1380+ #
1381+ # We suppress this ONLY once (first call) when the incoming value is
1382+ # "none" but the initially configured transform was non-"none". All
1383+ # subsequent "none" selections (user-initiated) are applied normally.
1384+ if (not self ._transform_init_done
1385+ and new_name == "none"
1386+ and self ._initial_transform not in (None , "" , "none" )):
1387+ _log .getLogger (__name__ ).debug (
1388+ "_on_transform_change: suppressing spurious 'none' event on "
1389+ "browser connect (initial_transform=%r); restoring widget label" ,
1390+ self ._initial_transform ,
1391+ )
1392+ self ._transform_init_done = True # set before restore to skip guard on re-entry
1393+ # Restore the widget label so the dropdown shows the correct
1394+ # transform name. Setting this value triggers a recursive call to
1395+ # _on_transform_change with initial_transform (guard is skipped
1396+ # because _transform_init_done is now True), which rebuilds the
1397+ # reader under the document lock and loads frame 0 — making the
1398+ # data, the slider options, and the dropdown label all consistent.
1399+ self ._transform_select .value = self ._initial_transform
1400+ return
1401+ self ._transform_init_done = True
13431402
13441403 current_ts = pd .Timestamp (
13451404 self ._reader .time_index [self ._time_slider .value ]
13461405 )
1347- new_name = event .new
13481406
13491407 # Show loading indicator immediately (Panel param — safe from watcher).
13501408 self ._chart_pane .loading = True
0 commit comments