Skip to content

Commit f1689fa

Browse files
committed
feat: Add map filtering capabilities to DataUI for table synchronization
1 parent c7b4e01 commit f1689fa

1 file changed

Lines changed: 153 additions & 36 deletions

File tree

dvue/dataui.py

Lines changed: 153 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -646,6 +646,11 @@ class DataUI(param.Parameterized):
646646
map_default_span = param.Number(default=15000, doc="Default span for map zoom in meters")
647647
map_non_selection_alpha = param.Number(default=0.2, doc="Non selection alpha")
648648
map_point_size = param.Number(default=10, doc="Point size for map")
649+
map_filters_table = param.Boolean(
650+
default=True,
651+
doc="When enabled, map selections filter the table rows instead of highlighting them. Table selections do not affect the map.",
652+
)
653+
_table_selection_for_map = param.List(default=[], precedence=-1)
649654

650655
query = param.String(
651656
default="",
@@ -672,6 +677,7 @@ def __init__(self, dataui_manager, crs=ccrs.PlateCarree(), station_id_column=Non
672677
if self.param.map_marker_category.objects:
673678
self.map_marker_category = self.param.map_marker_category.objects[0]
674679
self._dfmapcat = self._get_map_catalog()
680+
self._map_filter_station_ids = None
675681

676682
if isinstance(self._dfcat, gpd.GeoDataFrame):
677683
self._tmap = gv.tile_sources.CartoLight()
@@ -842,43 +848,54 @@ def update_map_features(
842848
map_default_span,
843849
map_non_selection_alpha,
844850
map_point_size,
851+
map_filters_table=False,
845852
):
846853
"""Update the map features based on the selection in the table or filters or query. Also updates if the color or marker by columns are changed"""
847854
query = query.strip()
848855
dfs = self._get_map_catalog()
849-
# select only those rows in dfs that have station_id_column in self.display_table.current_view
850-
if (
851-
self._station_id_column
852-
and self._station_id_column in self.display_table.current_view.columns
853-
):
854-
current_view = dfs[
855-
dfs[self._station_id_column].isin(
856-
self.display_table.current_view[self._station_id_column]
857-
)
858-
]
859-
# if current_view is a geodataframe, keep only valid geometries
856+
if map_filters_table:
857+
# In filter mode the map is decoupled from the table: always show the
858+
# full map catalog (not restricted to display_table.current_view) and
859+
# never highlight features based on table selection. _table_selection_for_map
860+
# is never updated while this mode is active, so selection is always [].
861+
current_view = dfs
860862
if isinstance(current_view, gpd.GeoDataFrame):
861863
current_view = current_view.loc[current_view.is_valid]
862-
current_table_selected = self._dfcat.iloc[selection]
863-
current_selected = current_view[
864-
current_view[self._station_id_column].isin(
865-
current_table_selected[self._station_id_column]
866-
)
867-
]
864+
current_selection = []
868865
else:
869-
current_view = dfs.loc[self.display_table.current_view.index]
870-
if isinstance(current_view, gpd.GeoDataFrame):
871-
current_view = current_view.loc[current_view.is_valid]
872-
current_table_selected = self._dfcat.iloc[selection]
873-
current_selected = current_table_selected
874-
# Filter out -1 entries: math refs with NaN geometry are absent from
875-
# current_view (filtered by .is_valid above) so get_indexer returns -1
876-
# for them. Passing -1 to Bokeh's selected= is interpreted as "last row"
877-
# (Python-style negative index), selecting the wrong geo point on the map.
878-
current_selection = [
879-
i for i in current_view.index.get_indexer(current_selected.index).tolist()
880-
if i >= 0
881-
]
866+
# select only those rows in dfs that have station_id_column in self.display_table.current_view
867+
if (
868+
self._station_id_column
869+
and self._station_id_column in self.display_table.current_view.columns
870+
):
871+
current_view = dfs[
872+
dfs[self._station_id_column].isin(
873+
self.display_table.current_view[self._station_id_column]
874+
)
875+
]
876+
# if current_view is a geodataframe, keep only valid geometries
877+
if isinstance(current_view, gpd.GeoDataFrame):
878+
current_view = current_view.loc[current_view.is_valid]
879+
current_table_selected = self._dfcat.iloc[selection]
880+
current_selected = current_view[
881+
current_view[self._station_id_column].isin(
882+
current_table_selected[self._station_id_column]
883+
)
884+
]
885+
else:
886+
current_view = dfs.loc[self.display_table.current_view.index]
887+
if isinstance(current_view, gpd.GeoDataFrame):
888+
current_view = current_view.loc[current_view.is_valid]
889+
current_table_selected = self._dfcat.iloc[selection]
890+
current_selected = current_table_selected
891+
# Filter out -1 entries: math refs with NaN geometry are absent from
892+
# current_view (filtered by .is_valid above) so get_indexer returns -1
893+
# for them. Passing -1 to Bokeh's selected= is interpreted as "last row"
894+
# (Python-style negative index), selecting the wrong geo point on the map.
895+
current_selection = [
896+
i for i in current_view.index.get_indexer(current_selected.index).tolist()
897+
if i >= 0
898+
]
882899
try:
883900
if len(query) > 0:
884901
current_view = current_view.query(query)
@@ -921,10 +938,92 @@ def update_map_features(
921938
)
922939
return self._map_features
923940

941+
# ------------------------------------------------------------------
942+
# Map-filter-table helpers
943+
# ------------------------------------------------------------------
944+
945+
def _get_table_df_for_display(self, df):
946+
"""Convert *df* (possibly a GeoDataFrame) to a plain DataFrame with the
947+
same columns as the current Tabulator widget, ready for assignment to
948+
``display_table.value``."""
949+
try:
950+
tbl_df = pd.DataFrame(df) if isinstance(df, gpd.GeoDataFrame) else df
951+
except Exception:
952+
tbl_df = df
953+
tbl_cols = self.display_table.value.columns
954+
return tbl_df.reindex(columns=tbl_cols)
955+
956+
def _refresh_table_with_map_filter(self):
957+
"""Restrict the Tabulator to rows matching ``_map_filter_station_ids``."""
958+
ids = self._map_filter_station_ids
959+
if ids is None:
960+
return
961+
idcol = self._station_id_column
962+
if idcol and idcol in self._dfcat.columns:
963+
filtered = self._dfcat[self._dfcat[idcol].isin(ids)]
964+
else:
965+
# ids is a set of index labels when there is no station_id_column
966+
filtered = self._dfcat.loc[self._dfcat.index.isin(ids)]
967+
tbl_df = self._get_table_df_for_display(filtered)
968+
self.display_table.param.update(value=tbl_df, selection=[])
969+
970+
def _clear_map_filter(self):
971+
"""Remove the active map filter and restore the full catalog in the table."""
972+
if self._map_filter_station_ids is None:
973+
return
974+
self._map_filter_station_ids = None
975+
tbl_df = self._get_table_df_for_display(self._dfcat)
976+
self.display_table.param.update(value=tbl_df, selection=[])
977+
978+
def _on_table_selection_changed(self, event):
979+
"""Gate table-selection → map sync: only propagate when filter mode is off."""
980+
if not self.map_filters_table:
981+
self._table_selection_for_map = list(event.new)
982+
983+
def _on_map_filter_mode_changed(self, event):
984+
"""Restore normal table and re-sync map selection when filter mode is toggled off."""
985+
if not event.new:
986+
self._clear_map_filter()
987+
# Re-sync proxy param with current table selection so map highlights
988+
# immediately reflect any rows selected while filter mode was active.
989+
self._table_selection_for_map = list(self.display_table.selection)
990+
991+
def _apply_map_filter(self, index):
992+
"""Apply a filter to the table based on the *index* of selected map features.
993+
994+
Called instead of the normal ``select_data_catalog`` path when
995+
``map_filters_table`` is True.
996+
"""
997+
# Empty selection — clear any active filter
998+
if not index:
999+
self._clear_map_filter()
1000+
return
1001+
map_df = self._map_features.dframe()
1002+
n_map = len(map_df)
1003+
index = [i for i in index if 0 <= i < n_map]
1004+
if not index:
1005+
self._clear_map_filter()
1006+
return
1007+
idcol = self._station_id_column
1008+
if idcol and idcol in self._dfcat.columns:
1009+
self._map_filter_station_ids = set(map_df.iloc[index][idcol].tolist())
1010+
else:
1011+
# Use the index labels of the matching rows in _dfcat
1012+
dfs = map_df.iloc[index]
1013+
merged_indices = self._dfcat.reset_index().merge(dfs)["index"].to_list()
1014+
self._map_filter_station_ids = set(merged_indices)
1015+
self._refresh_table_with_map_filter()
1016+
9241017
def select_data_catalog(self, index=[]):
9251018
"""Select the rows in the table that correspond to the selected features in the map"""
9261019
if index is None or (len(index) == 1 and index[0] == -1):
9271020
return
1021+
1022+
# In filter mode: filter the table instead of selecting rows
1023+
if self.map_filters_table:
1024+
self._apply_map_filter(index)
1025+
return
1026+
9281027
idcol = self._station_id_column
9291028
table = self.display_table
9301029

@@ -1512,6 +1611,10 @@ def _refresh_table_from_view(self, event=None) -> None:
15121611
"""
15131612
if not hasattr(self, "display_table"):
15141613
return
1614+
# Clear any active map filter so it does not persist into the new view.
1615+
if getattr(self, "_map_filter_station_ids", None) is not None:
1616+
self._map_filter_station_ids = None
1617+
notifications.info("Map filter cleared — catalog view changed.")
15151618
filtered = self._views_manager.filter_dataframe(self._dfcat_full)
15161619
self._dfcat = filtered
15171620
# Normalise to plain DataFrame (Tabulator can't JSON-serialise geometry)
@@ -1712,6 +1815,7 @@ def _on_column_picker_change(event):
17121815
self.param.map_non_selection_alpha,
17131816
self.param.map_point_size,
17141817
self.param.query,
1818+
self.param.map_filters_table,
17151819
]
17161820
if _extra_map_widgets is not None:
17171821
_map_option_items.append(_extra_map_widgets)
@@ -1740,11 +1844,15 @@ def _on_column_picker_change(event):
17401844
"map_default_span",
17411845
"map_non_selection_alpha",
17421846
"map_point_size",
1847+
"map_filters_table",
1848+
"_table_selection_for_map",
17431849
],
17441850
)
1851+
# selection is intentionally excluded: table row clicks are gated
1852+
# through _table_selection_for_map so filter mode can suppress them.
17451853
_table_stream = streams.Params(
17461854
parameterized=self.display_table,
1747-
parameters=["filters", "selection"],
1855+
parameters=["filters"],
17481856
)
17491857

17501858
def _map_callback(
@@ -1756,8 +1864,9 @@ def _map_callback(
17561864
map_default_span,
17571865
map_non_selection_alpha,
17581866
map_point_size,
1867+
map_filters_table,
1868+
_table_selection_for_map,
17591869
filters,
1760-
selection,
17611870
):
17621871
return self.update_map_features(
17631872
show_color_by=show_map_colors,
@@ -1766,15 +1875,17 @@ def _map_callback(
17661875
marker_by=map_marker_category,
17671876
query=query,
17681877
filters=filters,
1769-
selection=selection,
1878+
selection=_table_selection_for_map,
17701879
map_default_span=map_default_span,
17711880
map_non_selection_alpha=map_non_selection_alpha,
17721881
map_point_size=map_point_size,
1882+
map_filters_table=map_filters_table,
17731883
)
17741884

17751885
self._map_function = hv.DynamicMap(_map_callback, streams=[_self_stream, _table_stream])
17761886
self._station_select.source = self._map_function
17771887
self._station_select.param.watch_values(self.select_data_catalog, "index")
1888+
self.display_table.param.watch(self._on_table_selection_changed, "selection")
17781889
map_tooltip = pn.widgets.TooltipIcon(
17791890
value="""Map of geographical features. Click on a feature to see data available in the table. <br/>
17801891
See <a href="https://docs.bokeh.org/en/latest/docs/user_guide/interaction/tools.html">Bokeh Tools</a> for toolbar operation"""
@@ -1842,6 +1953,8 @@ def _map_callback(
18421953
)
18431954
# Wire view switching → table refresh.
18441955
self._views_manager.param.watch(self._refresh_table_from_view, "active_view")
1956+
# Clear map filter when filter mode is toggled off.
1957+
self.param.watch(self._on_map_filter_mode_changed, "map_filters_table")
18451958
# Let actions inject their sidebar tabs now that _sidebar_tabs exists.
18461959
self._setup_action_sidebars()
18471960
# Append the view-navigation widget to the right end of the action
@@ -2006,27 +2119,31 @@ def create_mobile_view(self, title="Data User Interface"):
20062119
"map_default_span",
20072120
"map_non_selection_alpha",
20082121
"map_point_size",
2122+
"map_filters_table",
2123+
"_table_selection_for_map",
20092124
],
20102125
)
20112126
_table_stream = streams.Params(
20122127
parameterized=self.display_table,
2013-
parameters=["filters", "selection"],
2128+
parameters=["filters"],
20142129
)
20152130

20162131
def _map_callback(
20172132
show_map_colors, map_color_category,
20182133
show_map_markers, map_marker_category,
20192134
query, map_default_span,
20202135
map_non_selection_alpha, map_point_size,
2021-
filters, selection,
2136+
map_filters_table, _table_selection_for_map,
2137+
filters,
20222138
):
20232139
return self.update_map_features(
20242140
show_color_by=show_map_colors, color_by=map_color_category,
20252141
show_marker_by=show_map_markers, marker_by=map_marker_category,
2026-
query=query, filters=filters, selection=selection,
2142+
query=query, filters=filters, selection=_table_selection_for_map,
20272143
map_default_span=map_default_span,
20282144
map_non_selection_alpha=map_non_selection_alpha,
20292145
map_point_size=map_point_size,
2146+
map_filters_table=map_filters_table,
20302147
)
20312148

20322149
self._map_function = hv.DynamicMap(

0 commit comments

Comments
 (0)