Skip to content

Commit 7c21976

Browse files
committed
feat: Enhance time range filtering in TimeSeriesDataUIManager to handle non-datetime indices gracefully
1 parent 2ab90dd commit 7c21976

2 files changed

Lines changed: 135 additions & 34 deletions

File tree

dvue/dataui.py

Lines changed: 124 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -923,22 +923,37 @@ def update_map_features(
923923
query = query.strip()
924924
dfs = self._get_map_catalog()
925925
if self.map_filters_table:
926-
# In filter mode the map is decoupled from the table: always show the
927-
# full map catalog (not restricted to display_table.current_view) and
928-
# never highlight features based on table selection while this mode is active.
929-
current_view = dfs
926+
# In filter mode the map is restricted to stations visible in the
927+
# current table view (based on header filters / query) so that the
928+
# user can see which stations are available before clicking to select.
929+
# When nothing is selected on the map yet the full filtered set is
930+
# shown; once a map click fires, select_data_catalog filters the table.
931+
table_view = self._safe_current_view()
932+
if (
933+
self._station_id_column
934+
and self._station_id_column in table_view.columns
935+
):
936+
current_view = dfs[
937+
dfs[self._station_id_column].isin(
938+
table_view[self._station_id_column]
939+
)
940+
]
941+
else:
942+
current_view = dfs.loc[
943+
dfs.index.isin(table_view.index)
944+
]
930945
if isinstance(current_view, gpd.GeoDataFrame):
931946
current_view = current_view.loc[current_view.is_valid]
932947
current_selection = []
933948
else:
934949
# select only those rows in dfs that have station_id_column in self.display_table.current_view
935950
if (
936951
self._station_id_column
937-
and self._station_id_column in self.display_table.current_view.columns
952+
and self._station_id_column in self._safe_current_view().columns
938953
):
939954
current_view = dfs[
940955
dfs[self._station_id_column].isin(
941-
self.display_table.current_view[self._station_id_column]
956+
self._safe_current_view()[self._station_id_column]
942957
)
943958
]
944959
# if current_view is a geodataframe, keep only valid geometries
@@ -951,7 +966,7 @@ def update_map_features(
951966
)
952967
]
953968
else:
954-
current_view = dfs.loc[self.display_table.current_view.index]
969+
current_view = dfs.loc[self._safe_current_view().index]
955970
if isinstance(current_view, gpd.GeoDataFrame):
956971
current_view = current_view.loc[current_view.is_valid]
957972
current_table_selected = self._dfcat.iloc[selection]
@@ -1114,8 +1129,8 @@ def select_data_catalog(self, index=[]):
11141129
set(stations_map_selected) - set(stations_table_selected)
11151130
)
11161131
# get the indices of the stations that are not in the selected stations in the current view
1117-
current_view_selected_indices = table.current_view[
1118-
table.current_view[idcol].isin(stations_to_be_selected)
1132+
current_view_selected_indices = self._safe_current_view()[
1133+
self._safe_current_view()[idcol].isin(stations_to_be_selected)
11191134
].index.to_list()
11201135
# First get the indices of matching rows
11211136
matching_indices = table.selected_dataframe[
@@ -1403,20 +1418,92 @@ def _on_selection_change(event):
14031418

14041419
self.display_table.param.watch(_on_selection_change, "selection")
14051420

1421+
def _safe_current_view(self):
1422+
"""Return ``display_table.current_view``, falling back to ``display_table.value``.
1423+
1424+
Panel's server-side ``current_view`` computation raises
1425+
``ValueError('Regex filtering not supported.')`` when any header filter
1426+
has ``func='regex'``, because Panel cannot apply regex filters in Python.
1427+
When regex mode is active we therefore fall back to the unfiltered
1428+
``display_table.value`` so map interactions (which only need the
1429+
station-ID column, not strict row-count accuracy) continue to work.
1430+
"""
1431+
try:
1432+
return self.display_table.current_view
1433+
except ValueError:
1434+
return self.display_table.value
1435+
14061436
@param.depends("use_regex_filter", watch=True)
14071437
def update_data_table_filters(self):
1408-
"""Update the table filters based on the use_regex_filter parameter."""
1409-
if self.use_regex_filter:
1410-
# Update filters to use regex
1411-
for column in self.display_table.header_filters:
1412-
# self.display_table.header_filters[column]["type"] = "regex"
1413-
self.display_table.header_filters[column]["func"] = "regex"
1414-
else:
1415-
# Revert to 'like' functionality
1416-
for column in self.display_table.header_filters:
1417-
# self.display_table.header_filters[column]["type"] = "input"
1418-
self.display_table.header_filters[column]["func"] = "like"
1419-
self.display_table.header_filters = self.display_table.header_filters
1438+
"""Recreate the Tabulator widget to apply changed header filter functions.
1439+
1440+
Tabulator column definitions (including ``headerFilterFunc``) are fixed
1441+
at JS initialisation time and cannot be changed reactively. A new
1442+
widget is built with the correct ``func``, current data/state is copied
1443+
across, all registered watchers are re-wired, and the old widget is
1444+
swapped out of its container.
1445+
"""
1446+
if not hasattr(self, "_table_container"):
1447+
return # called before create_view(); nothing to do
1448+
1449+
if pn.state.notifications:
1450+
pn.state.notifications.warning(
1451+
"Regex filter mode changed — rebuilding table. "
1452+
"Active filters and current selection will be cleared.",
1453+
duration=5000,
1454+
)
1455+
1456+
old = self.display_table
1457+
self.display_table = pn.widgets.Tabulator(
1458+
old.value,
1459+
disabled=True,
1460+
widths=old.widths,
1461+
hidden_columns=list(old.hidden_columns or []),
1462+
show_index=False,
1463+
sizing_mode="stretch_width",
1464+
header_filters=self._build_header_filters(self.use_regex_filter),
1465+
sorters=list(old.sorters or []),
1466+
pagination="local",
1467+
page_size=old.page_size or 200,
1468+
configuration={
1469+
"headerFilterLiveFilterDelay": 600,
1470+
"columnDefaults": {"tooltip": True},
1471+
},
1472+
)
1473+
# Re-wire watchers on the new widget.
1474+
self._setup_selection_btn_watcher()
1475+
for event_name, callback in getattr(self, "_tbl_watchers", []):
1476+
self.display_table.param.watch(callback, event_name)
1477+
# Swap the widget into the live container.
1478+
self._table_container.clear()
1479+
self._table_container.append(self.display_table)
1480+
1481+
def _build_header_filters(self, use_regex: bool) -> dict:
1482+
"""Return header_filters dict with ``func`` set to ``'regex'`` or ``'like'``.
1483+
1484+
Only overrides ``func`` for columns whose filter type is ``'input'``
1485+
(the default text-search type); numeric/boolean filters are left unchanged.
1486+
"""
1487+
func = "regex" if use_regex else "like"
1488+
result = {}
1489+
for col, spec in self._dataui_manager.get_table_filters().items():
1490+
new_spec = dict(spec)
1491+
if new_spec.get("type", "input") == "input":
1492+
new_spec["func"] = func
1493+
result[col] = new_spec
1494+
return result
1495+
1496+
def _register_table_watcher(self, event_name: str, callback) -> None:
1497+
"""Register *callback* on ``display_table`` and remember it for re-registration.
1498+
1499+
When ``update_data_table_filters`` swaps the Tabulator widget, all
1500+
callbacks registered through this method are automatically re-wired
1501+
onto the new widget.
1502+
"""
1503+
if not hasattr(self, "_tbl_watchers"):
1504+
self._tbl_watchers = []
1505+
self._tbl_watchers.append((event_name, callback))
1506+
self.display_table.param.watch(callback, event_name)
14201507

14211508
def create_data_table(self, dfs):
14221509
column_width_map = self._dataui_manager.get_table_column_width_map()
@@ -1455,14 +1542,15 @@ def create_data_table(self, dfs):
14551542
if not TimeSeriesDataUIManager._has_mixed_ref_types(dfs):
14561543
if "ref_type" not in initial_hidden:
14571544
initial_hidden.append("ref_type")
1545+
self._tbl_watchers = []
14581546
self.display_table = pn.widgets.Tabulator(
14591547
dfs,
14601548
disabled=True,
14611549
widths=column_width_map,
14621550
hidden_columns=initial_hidden,
14631551
show_index=False,
14641552
sizing_mode="stretch_width",
1465-
header_filters=self._dataui_manager.get_table_filters(),
1553+
header_filters=self._build_header_filters(self.use_regex_filter),
14661554
pagination="local",
14671555
page_size=200,
14681556
configuration={
@@ -1506,7 +1594,8 @@ def create_data_table(self, dfs):
15061594
)
15071595
)
15081596
gspec = pn.GridStack(sizing_mode="stretch_both", allow_resize=True, allow_drag=False)
1509-
gspec[0:4, 0:10] = fullscreen.FullScreen(pn.Row(self.display_table, scroll=True))
1597+
self._table_container = pn.Row(self.display_table, scroll=True)
1598+
gspec[0:4, 0:10] = fullscreen.FullScreen(self._table_container)
15101599
gspec[5:14, 0:10] = fullscreen.FullScreen(self._display_panel)
15111600
self._main_panel = gspec
15121601
return gspec
@@ -1633,7 +1722,7 @@ def _on_selection_change(event):
16331722
loc.update_query(sel=sel_encoded)
16341723

16351724
if hasattr(self, "display_table"):
1636-
self.display_table.param.watch(_on_selection_change, "selection")
1725+
self._register_table_watcher("selection", _on_selection_change)
16371726

16381727
def _setup_filter_url_sync(self):
16391728
"""Sync table header filter values with the URL ``flt`` query parameter.
@@ -1747,7 +1836,7 @@ def _create_main_view(self):
17471836
"""Create the main view content based on the current view_type"""
17481837
if self.view_type == "table":
17491838
gspec = pn.GridStack(sizing_mode="stretch_both", allow_resize=False, allow_drag=False)
1750-
gspec[0:14, 0:10] = fullscreen.FullScreen(pn.Row(self.display_table, scroll=True))
1839+
gspec[0:14, 0:10] = fullscreen.FullScreen(self._table_container)
17511840
return pn.Column(self._action_panel, gspec, sizing_mode="stretch_both")
17521841
elif self.view_type == "display":
17531842
gspec = pn.GridStack(sizing_mode="stretch_both", allow_resize=False, allow_drag=False)
@@ -2093,11 +2182,13 @@ def _on_tbl_selection(event):
20932182
self._map_sel_proxy = list(event.new)
20942183

20952184
def _on_tbl_filters(event):
2096-
if not self.map_filters_table:
2097-
self._map_filter_proxy += 1
2185+
# Always refresh the map when table filters change:
2186+
# - map_filters_table=False: map highlights the filtered subset
2187+
# - map_filters_table=True: map restricts to filtered stations
2188+
self._map_filter_proxy += 1
20982189

2099-
self.display_table.param.watch(_on_tbl_selection, "selection")
2100-
self.display_table.param.watch(_on_tbl_filters, "filters")
2190+
self._register_table_watcher("selection", _on_tbl_selection)
2191+
self._register_table_watcher("filters", _on_tbl_filters)
21012192
self.param.watch(self._on_map_filter_mode_changed, "map_filters_table")
21022193
map_tooltip = pn.widgets.TooltipIcon(
21032194
value="""Map of geographical features. Click on a feature to see data available in the table. <br/>
@@ -2366,11 +2457,11 @@ def _on_tbl_selection(event):
23662457
self._map_sel_proxy = list(event.new)
23672458

23682459
def _on_tbl_filters(event):
2369-
if not self.map_filters_table:
2370-
self._map_filter_proxy += 1
2460+
# Always refresh the map when table filters change.
2461+
self._map_filter_proxy += 1
23712462

2372-
self.display_table.param.watch(_on_tbl_selection, "selection")
2373-
self.display_table.param.watch(_on_tbl_filters, "filters")
2463+
self._register_table_watcher("selection", _on_tbl_selection)
2464+
self._register_table_watcher("filters", _on_tbl_filters)
23742465
self.param.watch(self._on_map_filter_mode_changed, "map_filters_table")
23752466

23762467
# Touch-friendly map — tap only, no lasso/box

dvue/tsdataui.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1213,7 +1213,17 @@ def _process_curve_data(self, data, r, time_range):
12131213
if time_range is not None:
12141214
t0 = pd.Timestamp(time_range[0])
12151215
t1 = pd.Timestamp(time_range[1])
1216-
data = data[(data.index >= t0) & (data.index <= t1)]
1216+
if not isinstance(data.index, pd.DatetimeIndex):
1217+
# Non-datetime index (e.g. RangeIndex from an empty or mis-typed
1218+
# DataFrame) cannot be compared to Timestamps — skip the filter
1219+
# and let the empty-check below discard the frame.
1220+
logger.warning(
1221+
"_process_curve_data: expected DatetimeIndex, got %s"
1222+
" — time-range filter skipped",
1223+
type(data.index).__name__,
1224+
)
1225+
else:
1226+
data = data[(data.index >= t0) & (data.index <= t1)]
12171227

12181228
# Apply optional data transformations
12191229

0 commit comments

Comments
 (0)