openms-insight 0.1.6__py3-none-any.whl → 0.1.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openms_insight/__init__.py +1 -1
- openms_insight/components/table.py +490 -28
- openms_insight/core/base.py +20 -0
- openms_insight/core/state.py +142 -24
- openms_insight/js-component/dist/assets/index.js +113 -113
- openms_insight/rendering/bridge.py +231 -139
- {openms_insight-0.1.6.dist-info → openms_insight-0.1.8.dist-info}/METADATA +3 -3
- {openms_insight-0.1.6.dist-info → openms_insight-0.1.8.dist-info}/RECORD +10 -10
- {openms_insight-0.1.6.dist-info → openms_insight-0.1.8.dist-info}/WHEEL +0 -0
- {openms_insight-0.1.6.dist-info → openms_insight-0.1.8.dist-info}/licenses/LICENSE +0 -0
openms_insight/__init__.py
CHANGED
|
@@ -16,7 +16,7 @@ from .core.registry import get_component_class, register_component
|
|
|
16
16
|
from .core.state import StateManager
|
|
17
17
|
from .rendering.bridge import clear_component_annotations, get_component_annotations
|
|
18
18
|
|
|
19
|
-
__version__ = "0.1.
|
|
19
|
+
__version__ = "0.1.7"
|
|
20
20
|
|
|
21
21
|
__all__ = [
|
|
22
22
|
# Core
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
"""Table component using Tabulator.js."""
|
|
2
2
|
|
|
3
|
+
import logging
|
|
3
4
|
from typing import Any, Dict, List, Optional
|
|
4
5
|
|
|
5
6
|
import polars as pl
|
|
6
7
|
|
|
7
8
|
from ..core.base import BaseComponent
|
|
8
9
|
from ..core.registry import register_component
|
|
9
|
-
from ..preprocessing.filtering import
|
|
10
|
+
from ..preprocessing.filtering import compute_dataframe_hash
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
# Session state key for tracking last rendered selection per table component
|
|
15
|
+
_LAST_SELECTION_KEY = "_svc_table_last_selection"
|
|
16
|
+
# Session state key for tracking last sort/filter state per table component
|
|
17
|
+
_LAST_SORT_FILTER_KEY = "_svc_table_last_sort_filter"
|
|
10
18
|
|
|
11
19
|
|
|
12
20
|
@register_component("table")
|
|
@@ -64,6 +72,7 @@ class Table(BaseComponent):
|
|
|
64
72
|
initial_sort: Optional[List[Dict[str, Any]]] = None,
|
|
65
73
|
pagination: bool = True,
|
|
66
74
|
page_size: int = 100,
|
|
75
|
+
pagination_identifier: Optional[str] = None,
|
|
67
76
|
**kwargs,
|
|
68
77
|
):
|
|
69
78
|
"""
|
|
@@ -104,9 +113,13 @@ class Table(BaseComponent):
|
|
|
104
113
|
default_row: Default row to select on load (-1 for none)
|
|
105
114
|
initial_sort: List of sort configurations like [{'column': 'field', 'dir': 'asc'}]
|
|
106
115
|
pagination: Enable pagination for large tables (default: True).
|
|
107
|
-
|
|
108
|
-
|
|
116
|
+
When enabled, uses server-side pagination where only the current
|
|
117
|
+
page of data is sent to the frontend, dramatically reducing browser
|
|
118
|
+
memory usage for large datasets.
|
|
109
119
|
page_size: Number of rows per page when pagination is enabled (default: 100)
|
|
120
|
+
pagination_identifier: State key for storing pagination state (page, sort,
|
|
121
|
+
filters). Default: "{cache_id}_page". Used by StateManager to track
|
|
122
|
+
pagination state across reruns.
|
|
110
123
|
**kwargs: Additional configuration options
|
|
111
124
|
"""
|
|
112
125
|
self._column_definitions = column_definitions
|
|
@@ -118,6 +131,8 @@ class Table(BaseComponent):
|
|
|
118
131
|
self._initial_sort = initial_sort
|
|
119
132
|
self._pagination = pagination
|
|
120
133
|
self._page_size = page_size
|
|
134
|
+
# Default pagination identifier based on cache_id
|
|
135
|
+
self._pagination_identifier = pagination_identifier or f"{cache_id}_page"
|
|
121
136
|
|
|
122
137
|
super().__init__(
|
|
123
138
|
cache_id=cache_id,
|
|
@@ -138,6 +153,7 @@ class Table(BaseComponent):
|
|
|
138
153
|
initial_sort=initial_sort,
|
|
139
154
|
pagination=pagination,
|
|
140
155
|
page_size=page_size,
|
|
156
|
+
pagination_identifier=self._pagination_identifier,
|
|
141
157
|
**kwargs,
|
|
142
158
|
)
|
|
143
159
|
|
|
@@ -158,6 +174,7 @@ class Table(BaseComponent):
|
|
|
158
174
|
"initial_sort": self._initial_sort,
|
|
159
175
|
"pagination": self._pagination,
|
|
160
176
|
"page_size": self._page_size,
|
|
177
|
+
"pagination_identifier": self._pagination_identifier,
|
|
161
178
|
}
|
|
162
179
|
|
|
163
180
|
def _restore_cache_config(self, config: Dict[str, Any]) -> None:
|
|
@@ -171,22 +188,100 @@ class Table(BaseComponent):
|
|
|
171
188
|
self._initial_sort = config.get("initial_sort")
|
|
172
189
|
self._pagination = config.get("pagination", True)
|
|
173
190
|
self._page_size = config.get("page_size", 100)
|
|
191
|
+
self._pagination_identifier = config.get(
|
|
192
|
+
"pagination_identifier", f"{self._cache_id}_page"
|
|
193
|
+
)
|
|
174
194
|
|
|
175
|
-
def
|
|
195
|
+
def get_state_dependencies(self) -> List[str]:
|
|
176
196
|
"""
|
|
177
|
-
|
|
197
|
+
Return list of state keys that affect this component's data.
|
|
178
198
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
199
|
+
Tables depend on:
|
|
200
|
+
- filters (for data filtering)
|
|
201
|
+
- pagination state (for page, sort, and column filters)
|
|
202
|
+
- interactivity identifiers (for page navigation to selected row)
|
|
183
203
|
|
|
184
204
|
Returns:
|
|
185
|
-
|
|
205
|
+
List of state identifier keys
|
|
186
206
|
"""
|
|
187
|
-
if self._filters
|
|
188
|
-
|
|
189
|
-
|
|
207
|
+
deps = list(self._filters.keys()) if self._filters else []
|
|
208
|
+
deps.append(self._pagination_identifier)
|
|
209
|
+
# Include interactivity identifiers for page navigation
|
|
210
|
+
if self._interactivity:
|
|
211
|
+
deps.extend(self._interactivity.keys())
|
|
212
|
+
return deps
|
|
213
|
+
|
|
214
|
+
def get_initial_selection(self, state: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
215
|
+
"""
|
|
216
|
+
Compute the initial selection for this table WITHOUT triggering Vue updates.
|
|
217
|
+
|
|
218
|
+
Only returns a value on INITIAL LOAD when:
|
|
219
|
+
- Table has interactivity configured
|
|
220
|
+
- default_row >= 0 (not disabled)
|
|
221
|
+
- No selection already exists for any interactivity identifier
|
|
222
|
+
- Not awaiting a required filter value
|
|
223
|
+
- No pagination state exists yet (truly initial load, not page navigation)
|
|
224
|
+
|
|
225
|
+
This is safe because:
|
|
226
|
+
- We compute from the same data _prepare_vue_data() returns
|
|
227
|
+
- Initial load always shows page 1 with default sort
|
|
228
|
+
- No user-applied column filters exist yet
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
state: Current selection state from StateManager
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
Dict mapping identifier names to their initial values,
|
|
235
|
+
or None if no initial selection should be set.
|
|
236
|
+
"""
|
|
237
|
+
# Skip if no interactivity or default_row disabled
|
|
238
|
+
if not self._interactivity or self._default_row < 0:
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
# Skip if selection already exists for ANY interactivity identifier
|
|
242
|
+
for identifier in self._interactivity.keys():
|
|
243
|
+
if state.get(identifier) is not None:
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
# Skip if NOT initial load (pagination state exists = user already interacted)
|
|
247
|
+
# This ensures we only pre-compute for the true first render
|
|
248
|
+
pagination_state = state.get(self._pagination_identifier)
|
|
249
|
+
if pagination_state is not None:
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
# Skip if awaiting required filter (no data to select from)
|
|
253
|
+
for identifier in self._filters.keys():
|
|
254
|
+
filter_value = state.get(identifier)
|
|
255
|
+
has_default = self._filter_defaults and identifier in self._filter_defaults
|
|
256
|
+
if filter_value is None and not has_default:
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
# Get first page data and extract default row values
|
|
260
|
+
try:
|
|
261
|
+
vue_data = self._prepare_vue_data(state)
|
|
262
|
+
table_data = vue_data.get("tableData")
|
|
263
|
+
if table_data is None or len(table_data) == 0:
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
# Clamp to available rows
|
|
267
|
+
default_idx = min(self._default_row, len(table_data) - 1)
|
|
268
|
+
if default_idx < 0:
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
result = {}
|
|
272
|
+
for identifier, column in self._interactivity.items():
|
|
273
|
+
if column in table_data.columns:
|
|
274
|
+
value = table_data[column].iloc[default_idx]
|
|
275
|
+
# Convert numpy types to Python types for JSON serialization
|
|
276
|
+
if hasattr(value, "item"):
|
|
277
|
+
value = value.item()
|
|
278
|
+
result[identifier] = value
|
|
279
|
+
|
|
280
|
+
return result if result else None
|
|
281
|
+
|
|
282
|
+
except Exception:
|
|
283
|
+
# If anything fails, let Vue handle it normally
|
|
284
|
+
return None
|
|
190
285
|
|
|
191
286
|
def _preprocess(self) -> None:
|
|
192
287
|
"""
|
|
@@ -194,6 +289,7 @@ class Table(BaseComponent):
|
|
|
194
289
|
|
|
195
290
|
Sorts by filter columns for efficient predicate pushdown, then
|
|
196
291
|
collects the LazyFrame and generates column definitions if needed.
|
|
292
|
+
Also computes column metadata for server-side filtering in filter dialogs.
|
|
197
293
|
Data is cached by base class for fast subsequent loads.
|
|
198
294
|
"""
|
|
199
295
|
data = self._raw_data
|
|
@@ -245,6 +341,84 @@ class Table(BaseComponent):
|
|
|
245
341
|
# Store column definitions in preprocessed data for serialization
|
|
246
342
|
self._preprocessed_data["column_definitions"] = self._column_definitions
|
|
247
343
|
|
|
344
|
+
# Compute column metadata for server-side filter dialogs
|
|
345
|
+
# This is computed once at preprocessing time and cached
|
|
346
|
+
column_metadata: Dict[str, Dict[str, Any]] = {}
|
|
347
|
+
for name, dtype in zip(schema.names(), schema.dtypes()):
|
|
348
|
+
meta: Dict[str, Any] = {}
|
|
349
|
+
|
|
350
|
+
if dtype in (
|
|
351
|
+
pl.Int8,
|
|
352
|
+
pl.Int16,
|
|
353
|
+
pl.Int32,
|
|
354
|
+
pl.Int64,
|
|
355
|
+
pl.UInt8,
|
|
356
|
+
pl.UInt16,
|
|
357
|
+
pl.UInt32,
|
|
358
|
+
pl.UInt64,
|
|
359
|
+
pl.Float32,
|
|
360
|
+
pl.Float64,
|
|
361
|
+
):
|
|
362
|
+
# Numeric column - compute min/max and unique count
|
|
363
|
+
stats = data.select(
|
|
364
|
+
[
|
|
365
|
+
pl.col(name).min().alias("min"),
|
|
366
|
+
pl.col(name).max().alias("max"),
|
|
367
|
+
pl.col(name).n_unique().alias("n_unique"),
|
|
368
|
+
]
|
|
369
|
+
).collect()
|
|
370
|
+
min_val = stats["min"][0]
|
|
371
|
+
max_val = stats["max"][0]
|
|
372
|
+
n_unique = stats["n_unique"][0]
|
|
373
|
+
|
|
374
|
+
# If few unique values, treat as categorical
|
|
375
|
+
if n_unique is not None and n_unique <= 10:
|
|
376
|
+
meta["type"] = "categorical"
|
|
377
|
+
unique_vals = (
|
|
378
|
+
data.select(pl.col(name))
|
|
379
|
+
.unique()
|
|
380
|
+
.sort(name)
|
|
381
|
+
.collect()
|
|
382
|
+
.to_series()
|
|
383
|
+
.to_list()
|
|
384
|
+
)
|
|
385
|
+
meta["unique_values"] = [v for v in unique_vals if v is not None][
|
|
386
|
+
:100
|
|
387
|
+
]
|
|
388
|
+
else:
|
|
389
|
+
meta["type"] = "numeric"
|
|
390
|
+
if min_val is not None:
|
|
391
|
+
meta["min"] = float(min_val)
|
|
392
|
+
if max_val is not None:
|
|
393
|
+
meta["max"] = float(max_val)
|
|
394
|
+
elif dtype == pl.Utf8:
|
|
395
|
+
# String column - check unique count
|
|
396
|
+
n_unique = data.select(pl.col(name).n_unique()).collect().item()
|
|
397
|
+
if n_unique is not None and n_unique <= 50:
|
|
398
|
+
meta["type"] = "categorical"
|
|
399
|
+
unique_vals = (
|
|
400
|
+
data.select(pl.col(name))
|
|
401
|
+
.unique()
|
|
402
|
+
.sort(name, nulls_last=True)
|
|
403
|
+
.collect()
|
|
404
|
+
.to_series()
|
|
405
|
+
.to_list()
|
|
406
|
+
)
|
|
407
|
+
meta["unique_values"] = [
|
|
408
|
+
v for v in unique_vals if v is not None and v != ""
|
|
409
|
+
][:100]
|
|
410
|
+
else:
|
|
411
|
+
meta["type"] = "text"
|
|
412
|
+
elif dtype == pl.Boolean:
|
|
413
|
+
meta["type"] = "categorical"
|
|
414
|
+
meta["unique_values"] = [True, False]
|
|
415
|
+
else:
|
|
416
|
+
meta["type"] = "text"
|
|
417
|
+
|
|
418
|
+
column_metadata[name] = meta
|
|
419
|
+
|
|
420
|
+
self._preprocessed_data["column_metadata"] = column_metadata
|
|
421
|
+
|
|
248
422
|
# Store LazyFrame for streaming to disk (filter happens at render time)
|
|
249
423
|
# Base class will use sink_parquet() to stream without full materialization
|
|
250
424
|
self._preprocessed_data["data"] = data # Keep lazy
|
|
@@ -285,40 +459,322 @@ class Table(BaseComponent):
|
|
|
285
459
|
|
|
286
460
|
def _prepare_vue_data(self, state: Dict[str, Any]) -> Dict[str, Any]:
|
|
287
461
|
"""
|
|
288
|
-
Prepare table data for Vue component.
|
|
462
|
+
Prepare table data for Vue component with server-side pagination.
|
|
289
463
|
|
|
290
|
-
|
|
291
|
-
|
|
464
|
+
Implements streaming pagination where only the current page of data
|
|
465
|
+
is sent to the frontend. Handles server-side sorting, column filtering,
|
|
466
|
+
and cross-component selection navigation.
|
|
292
467
|
|
|
293
468
|
Args:
|
|
294
469
|
state: Current selection state from StateManager
|
|
295
470
|
|
|
296
471
|
Returns:
|
|
297
|
-
Dict with tableData (pandas DataFrame)
|
|
472
|
+
Dict with tableData (pandas DataFrame), _hash, _pagination metadata,
|
|
473
|
+
and optional _navigate_to_page/_target_row_index for selection navigation
|
|
298
474
|
"""
|
|
475
|
+
import time
|
|
476
|
+
|
|
477
|
+
logger.info(f"[Table._prepare_vue_data] ===== START ===== ts={time.time()}")
|
|
478
|
+
logger.info(f"[Table._prepare_vue_data] cache_id={self._cache_id}")
|
|
479
|
+
logger.info(
|
|
480
|
+
f"[Table._prepare_vue_data] pagination_identifier={self._pagination_identifier}"
|
|
481
|
+
)
|
|
482
|
+
pagination_state_for_log = state.get(self._pagination_identifier)
|
|
483
|
+
logger.info(
|
|
484
|
+
f"[Table._prepare_vue_data] pagination_state={pagination_state_for_log}"
|
|
485
|
+
)
|
|
486
|
+
|
|
299
487
|
# Get columns to select for projection pushdown
|
|
300
488
|
columns = self._get_columns_to_select()
|
|
301
489
|
|
|
302
490
|
# Get cached data (DataFrame or LazyFrame)
|
|
303
491
|
data = self._preprocessed_data.get("data")
|
|
304
492
|
if data is None:
|
|
305
|
-
# Fallback to raw data if available
|
|
306
493
|
data = self._raw_data
|
|
307
494
|
|
|
308
495
|
# Ensure we have a LazyFrame for filtering
|
|
309
496
|
if isinstance(data, pl.DataFrame):
|
|
310
497
|
data = data.lazy()
|
|
311
498
|
|
|
312
|
-
#
|
|
313
|
-
|
|
314
|
-
data
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
)
|
|
499
|
+
# Apply column projection first for efficiency
|
|
500
|
+
if columns:
|
|
501
|
+
schema_names = data.collect_schema().names()
|
|
502
|
+
available_cols = [c for c in columns if c in schema_names]
|
|
503
|
+
if available_cols:
|
|
504
|
+
data = data.select(available_cols)
|
|
505
|
+
|
|
506
|
+
# Apply cross-component filters (from self._filters)
|
|
507
|
+
for identifier, column in self._filters.items():
|
|
508
|
+
selected_value = state.get(identifier)
|
|
509
|
+
# Apply default if value is None and default exists
|
|
510
|
+
if (
|
|
511
|
+
selected_value is None
|
|
512
|
+
and self._filter_defaults
|
|
513
|
+
and identifier in self._filter_defaults
|
|
514
|
+
):
|
|
515
|
+
selected_value = self._filter_defaults[identifier]
|
|
516
|
+
|
|
517
|
+
if selected_value is None:
|
|
518
|
+
# No selection for this filter - return empty DataFrame
|
|
519
|
+
df_polars = data.head(0).collect()
|
|
520
|
+
data_hash = compute_dataframe_hash(df_polars)
|
|
521
|
+
return {
|
|
522
|
+
"tableData": df_polars.to_pandas(),
|
|
523
|
+
"_hash": data_hash,
|
|
524
|
+
"_pagination": {
|
|
525
|
+
"page": 1,
|
|
526
|
+
"page_size": self._page_size,
|
|
527
|
+
"total_rows": 0,
|
|
528
|
+
"total_pages": 0,
|
|
529
|
+
},
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
# Convert float to int for integer columns (JS numbers come as floats)
|
|
533
|
+
if isinstance(selected_value, float) and selected_value.is_integer():
|
|
534
|
+
selected_value = int(selected_value)
|
|
535
|
+
data = data.filter(pl.col(column) == selected_value)
|
|
536
|
+
|
|
537
|
+
# Get pagination state
|
|
538
|
+
pagination_state = state.get(self._pagination_identifier)
|
|
539
|
+
if pagination_state is None:
|
|
540
|
+
pagination_state = {}
|
|
541
|
+
|
|
542
|
+
page = pagination_state.get("page", 1)
|
|
543
|
+
page_size = pagination_state.get("page_size", self._page_size)
|
|
544
|
+
sort_column = pagination_state.get("sort_column")
|
|
545
|
+
sort_dir = pagination_state.get("sort_dir", "asc")
|
|
546
|
+
column_filters = pagination_state.get("column_filters", [])
|
|
547
|
+
go_to_request = pagination_state.get("go_to_request")
|
|
548
|
+
|
|
549
|
+
# Apply column filters from filter dialog
|
|
550
|
+
for col_filter in column_filters:
|
|
551
|
+
field = col_filter.get("field")
|
|
552
|
+
filter_type = col_filter.get("type")
|
|
553
|
+
value = col_filter.get("value")
|
|
554
|
+
|
|
555
|
+
if not field or value is None:
|
|
556
|
+
continue
|
|
557
|
+
|
|
558
|
+
if filter_type == "in" and isinstance(value, list):
|
|
559
|
+
# Categorical filter - match any of the values
|
|
560
|
+
data = data.filter(pl.col(field).is_in(value))
|
|
561
|
+
elif filter_type == ">=":
|
|
562
|
+
data = data.filter(pl.col(field) >= value)
|
|
563
|
+
elif filter_type == "<=":
|
|
564
|
+
data = data.filter(pl.col(field) <= value)
|
|
565
|
+
elif filter_type == "regex":
|
|
566
|
+
# Text search with regex
|
|
567
|
+
data = data.filter(pl.col(field).str.contains(value, literal=False))
|
|
568
|
+
|
|
569
|
+
# Apply server-side sort
|
|
570
|
+
if sort_column:
|
|
571
|
+
descending = sort_dir == "desc"
|
|
572
|
+
data = data.sort(sort_column, descending=descending)
|
|
573
|
+
|
|
574
|
+
# Get total row count (after filters, before pagination)
|
|
575
|
+
total_rows = data.select(pl.len()).collect().item()
|
|
576
|
+
total_pages = max(1, (total_rows + page_size - 1) // page_size)
|
|
577
|
+
|
|
578
|
+
# Handle go-to request (server-side search for row by field value)
|
|
579
|
+
navigate_to_page = None
|
|
580
|
+
target_row_index = None
|
|
581
|
+
|
|
582
|
+
if go_to_request:
|
|
583
|
+
go_to_field = go_to_request.get("field")
|
|
584
|
+
go_to_value = go_to_request.get("value")
|
|
585
|
+
if go_to_field and go_to_value is not None:
|
|
586
|
+
# Try to convert to number if applicable
|
|
587
|
+
try:
|
|
588
|
+
go_to_value = float(go_to_value)
|
|
589
|
+
if go_to_value.is_integer():
|
|
590
|
+
go_to_value = int(go_to_value)
|
|
591
|
+
except (ValueError, TypeError):
|
|
592
|
+
pass
|
|
593
|
+
|
|
594
|
+
# Find the row with row_number
|
|
595
|
+
search_result = (
|
|
596
|
+
data.with_row_index("_row_num")
|
|
597
|
+
.filter(pl.col(go_to_field) == go_to_value)
|
|
598
|
+
.select("_row_num")
|
|
599
|
+
.head(1)
|
|
600
|
+
.collect()
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
if len(search_result) > 0:
|
|
604
|
+
row_num = search_result["_row_num"][0]
|
|
605
|
+
target_page = (row_num // page_size) + 1
|
|
606
|
+
navigate_to_page = target_page
|
|
607
|
+
target_row_index = row_num % page_size
|
|
608
|
+
page = target_page # Jump to target page
|
|
609
|
+
|
|
610
|
+
# === Selection and Sort/Filter based navigation ===
|
|
611
|
+
# PURPOSE: When user sorts/filters, find where the selected row ended up and navigate there
|
|
612
|
+
if self._interactivity and self._pagination:
|
|
613
|
+
import json
|
|
614
|
+
|
|
615
|
+
import streamlit as st
|
|
616
|
+
|
|
617
|
+
# Initialize tracking dicts (per-component storage)
|
|
618
|
+
if _LAST_SELECTION_KEY not in st.session_state:
|
|
619
|
+
st.session_state[_LAST_SELECTION_KEY] = {}
|
|
620
|
+
if _LAST_SORT_FILTER_KEY not in st.session_state:
|
|
621
|
+
st.session_state[_LAST_SORT_FILTER_KEY] = {}
|
|
622
|
+
|
|
623
|
+
component_key = self._cache_id # Unique key for this table instance
|
|
624
|
+
|
|
625
|
+
# Get PREVIOUS states (from last render)
|
|
626
|
+
last_selections = st.session_state[_LAST_SELECTION_KEY].get(
|
|
627
|
+
component_key, {}
|
|
628
|
+
)
|
|
629
|
+
last_sort_filter = st.session_state[_LAST_SORT_FILTER_KEY].get(
|
|
630
|
+
component_key, {}
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
# Build CURRENT selection state
|
|
634
|
+
current_selections = {}
|
|
635
|
+
for identifier in self._interactivity.keys():
|
|
636
|
+
current_selections[identifier] = state.get(identifier)
|
|
637
|
+
|
|
638
|
+
# Build CURRENT sort/filter state
|
|
639
|
+
# Use JSON for column_filters to enable deep comparison of nested dicts
|
|
640
|
+
current_sort_filter = {
|
|
641
|
+
"sort_column": sort_column,
|
|
642
|
+
"sort_dir": sort_dir,
|
|
643
|
+
"column_filters_json": json.dumps(column_filters, sort_keys=True),
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
# DETECT what changed by comparing current vs previous
|
|
647
|
+
selection_changed = current_selections != last_selections
|
|
648
|
+
sort_filter_changed = current_sort_filter != last_sort_filter
|
|
649
|
+
|
|
650
|
+
# CRITICAL: Update tracking state AFTER detecting changes
|
|
651
|
+
# This prevents infinite loops: next render will see no change
|
|
652
|
+
st.session_state[_LAST_SELECTION_KEY][component_key] = current_selections
|
|
653
|
+
st.session_state[_LAST_SORT_FILTER_KEY][component_key] = current_sort_filter
|
|
654
|
+
|
|
655
|
+
# DECIDE whether to navigate
|
|
656
|
+
# - Don't override go_to navigation (user explicitly requested a row)
|
|
657
|
+
# - Navigate if selection changed (find new selection's page)
|
|
658
|
+
# - Navigate if sort/filter changed AND we have a selection (find existing selection's new page)
|
|
659
|
+
should_navigate = False
|
|
660
|
+
if navigate_to_page is None:
|
|
661
|
+
if selection_changed:
|
|
662
|
+
should_navigate = True
|
|
663
|
+
elif sort_filter_changed and any(
|
|
664
|
+
v is not None for v in current_selections.values()
|
|
665
|
+
):
|
|
666
|
+
should_navigate = True
|
|
667
|
+
|
|
668
|
+
if should_navigate:
|
|
669
|
+
for identifier, column in self._interactivity.items():
|
|
670
|
+
selected_value = state.get(identifier)
|
|
671
|
+
if selected_value is not None:
|
|
672
|
+
# Convert float to int if needed (JS numbers come as floats)
|
|
673
|
+
if (
|
|
674
|
+
isinstance(selected_value, float)
|
|
675
|
+
and selected_value.is_integer()
|
|
676
|
+
):
|
|
677
|
+
selected_value = int(selected_value)
|
|
678
|
+
|
|
679
|
+
# SEARCH for the selected row in the sorted/filtered data
|
|
680
|
+
# with_row_index adds position so we know which page it's on
|
|
681
|
+
search_result = (
|
|
682
|
+
data.with_row_index("_row_num")
|
|
683
|
+
.filter(pl.col(column) == selected_value)
|
|
684
|
+
.select("_row_num")
|
|
685
|
+
.head(1)
|
|
686
|
+
.collect()
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
if len(search_result) > 0:
|
|
690
|
+
# ROW FOUND - update page in pagination state if needed
|
|
691
|
+
row_num = search_result["_row_num"][0]
|
|
692
|
+
target_page = (row_num // page_size) + 1
|
|
693
|
+
if target_page != page:
|
|
694
|
+
# Update pagination state directly (same as Vue would)
|
|
695
|
+
from openms_insight.core.state import (
|
|
696
|
+
get_default_state_manager,
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
state_manager = get_default_state_manager()
|
|
700
|
+
updated_pagination = {
|
|
701
|
+
**pagination_state,
|
|
702
|
+
"page": target_page,
|
|
703
|
+
}
|
|
704
|
+
state_manager.set_selection(
|
|
705
|
+
self._pagination_identifier, updated_pagination
|
|
706
|
+
)
|
|
707
|
+
navigate_to_page = target_page
|
|
708
|
+
target_row_index = row_num % page_size
|
|
709
|
+
page = target_page # Use new page for slicing
|
|
710
|
+
else:
|
|
711
|
+
# ROW NOT FOUND - it was filtered out
|
|
712
|
+
# Update selection to first row's value AND set page to 1
|
|
713
|
+
if sort_filter_changed and not selection_changed:
|
|
714
|
+
first_row_result = (
|
|
715
|
+
data.select(pl.col(column)).head(1).collect()
|
|
716
|
+
)
|
|
717
|
+
if len(first_row_result) > 0:
|
|
718
|
+
first_value = first_row_result[column][0]
|
|
719
|
+
if hasattr(first_value, "item"):
|
|
720
|
+
first_value = first_value.item()
|
|
721
|
+
|
|
722
|
+
from openms_insight.core.state import (
|
|
723
|
+
get_default_state_manager,
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
state_manager = get_default_state_manager()
|
|
727
|
+
|
|
728
|
+
# Update selection to first row
|
|
729
|
+
state_manager.set_selection(identifier, first_value)
|
|
730
|
+
|
|
731
|
+
# Update pagination state to page 1
|
|
732
|
+
updated_pagination = {
|
|
733
|
+
**pagination_state,
|
|
734
|
+
"page": 1,
|
|
735
|
+
}
|
|
736
|
+
state_manager.set_selection(
|
|
737
|
+
self._pagination_identifier, updated_pagination
|
|
738
|
+
)
|
|
739
|
+
page = 1 # Use page 1 for slicing
|
|
740
|
+
break
|
|
741
|
+
|
|
742
|
+
# Clamp page to valid range
|
|
743
|
+
page = max(1, min(page, total_pages))
|
|
744
|
+
|
|
745
|
+
# Slice to current page
|
|
746
|
+
offset = (page - 1) * page_size
|
|
747
|
+
df_polars = data.slice(offset, page_size).collect()
|
|
748
|
+
|
|
749
|
+
# Compute hash for change detection
|
|
750
|
+
data_hash = compute_dataframe_hash(df_polars)
|
|
751
|
+
|
|
752
|
+
# Build result
|
|
753
|
+
result: Dict[str, Any] = {
|
|
754
|
+
"tableData": df_polars.to_pandas(),
|
|
755
|
+
"_hash": data_hash,
|
|
756
|
+
"_pagination": {
|
|
757
|
+
"page": page,
|
|
758
|
+
"page_size": page_size,
|
|
759
|
+
"total_rows": total_rows,
|
|
760
|
+
"total_pages": total_pages,
|
|
761
|
+
"sort_column": sort_column,
|
|
762
|
+
"sort_dir": sort_dir,
|
|
763
|
+
},
|
|
764
|
+
}
|
|
320
765
|
|
|
321
|
-
|
|
766
|
+
if navigate_to_page is not None:
|
|
767
|
+
result["_navigate_to_page"] = navigate_to_page
|
|
768
|
+
if target_row_index is not None:
|
|
769
|
+
result["_target_row_index"] = target_row_index
|
|
770
|
+
|
|
771
|
+
logger.info(
|
|
772
|
+
f"[Table._prepare_vue_data] Returning: page={page}, total_rows={total_rows}, data_rows={len(df_polars)}"
|
|
773
|
+
)
|
|
774
|
+
logger.info(
|
|
775
|
+
f"[Table._prepare_vue_data] hash={data_hash[:8] if data_hash else 'None'}"
|
|
776
|
+
)
|
|
777
|
+
return result
|
|
322
778
|
|
|
323
779
|
def _get_component_args(self) -> Dict[str, Any]:
|
|
324
780
|
"""
|
|
@@ -332,6 +788,9 @@ class Table(BaseComponent):
|
|
|
332
788
|
if column_defs is None:
|
|
333
789
|
column_defs = self._preprocessed_data.get("column_definitions", [])
|
|
334
790
|
|
|
791
|
+
# Get column metadata for filter dialogs (computed during preprocessing)
|
|
792
|
+
column_metadata = self._preprocessed_data.get("column_metadata", {})
|
|
793
|
+
|
|
335
794
|
args: Dict[str, Any] = {
|
|
336
795
|
"componentType": self._get_vue_component_name(),
|
|
337
796
|
"columnDefinitions": column_defs,
|
|
@@ -340,9 +799,12 @@ class Table(BaseComponent):
|
|
|
340
799
|
"defaultRow": self._default_row,
|
|
341
800
|
# Pass interactivity so Vue knows which identifier to update on row click
|
|
342
801
|
"interactivity": self._interactivity,
|
|
343
|
-
# Pagination settings
|
|
802
|
+
# Pagination settings - always use server-side pagination
|
|
344
803
|
"pagination": self._pagination,
|
|
345
804
|
"pageSize": self._page_size,
|
|
805
|
+
"paginationIdentifier": self._pagination_identifier,
|
|
806
|
+
# Column metadata for filter dialogs (precomputed unique values, min/max)
|
|
807
|
+
"columnMetadata": column_metadata,
|
|
346
808
|
}
|
|
347
809
|
|
|
348
810
|
if self._title:
|
openms_insight/core/base.py
CHANGED
|
@@ -467,6 +467,26 @@ class BaseComponent(ABC):
|
|
|
467
467
|
"""
|
|
468
468
|
return list(self._filters.keys())
|
|
469
469
|
|
|
470
|
+
def get_initial_selection(self, state: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
471
|
+
"""
|
|
472
|
+
Compute initial selection values for this component.
|
|
473
|
+
|
|
474
|
+
Called by render_component() BEFORE Vue renders to pre-populate
|
|
475
|
+
selections. This prevents race conditions when multiple tables
|
|
476
|
+
render simultaneously - all default selections are set in Python
|
|
477
|
+
before any Vue component has a chance to trigger a rerun.
|
|
478
|
+
|
|
479
|
+
Override in subclasses that support default selection (e.g., Table).
|
|
480
|
+
|
|
481
|
+
Args:
|
|
482
|
+
state: Current selection state from StateManager
|
|
483
|
+
|
|
484
|
+
Returns:
|
|
485
|
+
Dict mapping identifier names to their initial values,
|
|
486
|
+
or None if no initial selection should be set.
|
|
487
|
+
"""
|
|
488
|
+
return None
|
|
489
|
+
|
|
470
490
|
def _get_primary_data(self) -> Optional[pl.LazyFrame]:
|
|
471
491
|
"""
|
|
472
492
|
Get the primary data for operations.
|