openms-insight 0.1.8__py3-none-any.whl → 0.1.10__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.
@@ -440,6 +440,9 @@ class SequenceView:
440
440
  self._deconvolved = deconvolved
441
441
  self._config = kwargs
442
442
  self._filters = filters or {}
443
+ self._filter_defaults = {}
444
+ for identifier in self._filters.keys():
445
+ self._filter_defaults[identifier] = None
443
446
  self._interactivity = interactivity or {}
444
447
 
445
448
  # Store annotation config with defaults
@@ -534,6 +537,9 @@ class SequenceView:
534
537
 
535
538
  # Restore all configuration
536
539
  self._filters = config.get("filters", {})
540
+ self._filter_defaults = {}
541
+ for identifier in self._filters.keys():
542
+ self._filter_defaults[identifier] = None
537
543
  self._interactivity = config.get("interactivity", {})
538
544
  self._title = config.get("title")
539
545
  self._height = config.get("height", 400)
@@ -650,6 +656,12 @@ class SequenceView:
650
656
  filter_value = state.get(identifier)
651
657
  if filter_value is not None:
652
658
  filtered = filtered.filter(pl.col(column) == filter_value)
659
+ elif (
660
+ identifier in self._filter_defaults
661
+ and self._filter_defaults[identifier] is None
662
+ ):
663
+ # Filter has None default and state is None - return empty intentionally
664
+ return "", 1
653
665
 
654
666
  # Collect and get first row
655
667
  try:
@@ -681,6 +693,14 @@ class SequenceView:
681
693
  filter_value = state.get(identifier)
682
694
  if filter_value is not None:
683
695
  filtered = filtered.filter(pl.col(column) == filter_value)
696
+ elif (
697
+ identifier in self._filter_defaults
698
+ and self._filter_defaults[identifier] is None
699
+ ):
700
+ # Filter has None default and state is None - return empty intentionally
701
+ return pl.DataFrame(
702
+ schema={"peak_id": pl.Int64, "mass": pl.Float64}
703
+ )
684
704
 
685
705
  # Select available columns
686
706
  cols = ["peak_id", "mass"]
@@ -1,6 +1,7 @@
1
1
  """Table component using Tabulator.js."""
2
2
 
3
3
  import logging
4
+ import re
4
5
  from typing import Any, Dict, List, Optional
5
6
 
6
7
  import polars as pl
@@ -11,6 +12,20 @@ from ..preprocessing.filtering import compute_dataframe_hash
11
12
 
12
13
  logger = logging.getLogger(__name__)
13
14
 
15
+ # Numeric data types for dtype checking
16
+ NUMERIC_DTYPES = (
17
+ pl.Int8,
18
+ pl.Int16,
19
+ pl.Int32,
20
+ pl.Int64,
21
+ pl.UInt8,
22
+ pl.UInt16,
23
+ pl.UInt32,
24
+ pl.UInt64,
25
+ pl.Float32,
26
+ pl.Float64,
27
+ )
28
+
14
29
  # Session state key for tracking last rendered selection per table component
15
30
  _LAST_SELECTION_KEY = "_svc_table_last_selection"
16
31
  # Session state key for tracking last sort/filter state per table component
@@ -419,10 +434,71 @@ class Table(BaseComponent):
419
434
 
420
435
  self._preprocessed_data["column_metadata"] = column_metadata
421
436
 
437
+ # Auto-detect go-to fields if not explicitly provided
438
+ if self._go_to_fields is None:
439
+ self._go_to_fields = self._auto_detect_go_to_fields(data)
440
+ elif self._go_to_fields == []:
441
+ # Explicitly disabled - keep empty list
442
+ pass
443
+ # else: use user-provided list as-is
444
+
422
445
  # Store LazyFrame for streaming to disk (filter happens at render time)
423
446
  # Base class will use sink_parquet() to stream without full materialization
424
447
  self._preprocessed_data["data"] = data # Keep lazy
425
448
 
449
+ def _auto_detect_go_to_fields(self, data: pl.LazyFrame) -> List[str]:
450
+ """
451
+ Auto-detect columns suitable for go-to navigation.
452
+
453
+ Criteria:
454
+ - Integer or String (Utf8) type only (excludes Float)
455
+ - 100% unique values (no duplicates)
456
+ - Samples first 10,000 rows for performance
457
+
458
+ Args:
459
+ data: LazyFrame to analyze for unique columns
460
+
461
+ Returns:
462
+ List of column names in original schema order
463
+ """
464
+ schema = data.collect_schema()
465
+ sample = data.head(10000)
466
+
467
+ candidates = []
468
+ for col_name in schema.names():
469
+ dtype = schema[col_name]
470
+
471
+ # Only Integer and String types (exclude Float)
472
+ if dtype not in (
473
+ pl.Int8,
474
+ pl.Int16,
475
+ pl.Int32,
476
+ pl.Int64,
477
+ pl.UInt8,
478
+ pl.UInt16,
479
+ pl.UInt32,
480
+ pl.UInt64,
481
+ pl.Utf8,
482
+ ):
483
+ continue
484
+
485
+ # Check 100% uniqueness in sample
486
+ stats = sample.select(
487
+ [
488
+ pl.col(col_name).len().alias("count"),
489
+ pl.col(col_name).n_unique().alias("n_unique"),
490
+ ]
491
+ ).collect()
492
+
493
+ count = stats["count"][0]
494
+ n_unique = stats["n_unique"][0]
495
+
496
+ # Must be 100% unique (count == n_unique)
497
+ if count > 0 and count == n_unique:
498
+ candidates.append(col_name)
499
+
500
+ return candidates
501
+
426
502
  def _get_columns_to_select(self) -> Optional[List[str]]:
427
503
  """Get list of columns needed for this table."""
428
504
  if not self._column_definitions:
@@ -527,6 +603,7 @@ class Table(BaseComponent):
527
603
  "total_rows": 0,
528
604
  "total_pages": 0,
529
605
  },
606
+ "_auto_selection": {}, # No data = no auto-selection
530
607
  }
531
608
 
532
609
  # Convert float to int for integer columns (JS numbers come as floats)
@@ -563,8 +640,13 @@ class Table(BaseComponent):
563
640
  elif filter_type == "<=":
564
641
  data = data.filter(pl.col(field) <= value)
565
642
  elif filter_type == "regex":
566
- # Text search with regex
567
- data = data.filter(pl.col(field).str.contains(value, literal=False))
643
+ # Text search with regex - invalid patterns match nothing
644
+ try:
645
+ re.compile(value)
646
+ data = data.filter(pl.col(field).str.contains(value, literal=False))
647
+ except re.error:
648
+ # Invalid regex pattern - filter to empty result
649
+ data = data.filter(pl.lit(False))
568
650
 
569
651
  # Apply server-side sort
570
652
  if sort_column:
@@ -578,34 +660,44 @@ class Table(BaseComponent):
578
660
  # Handle go-to request (server-side search for row by field value)
579
661
  navigate_to_page = None
580
662
  target_row_index = None
663
+ go_to_not_found = False
581
664
 
582
665
  if go_to_request:
583
666
  go_to_field = go_to_request.get("field")
584
667
  go_to_value = go_to_request.get("value")
585
668
  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
669
+ # Only convert to numeric if the target column is numeric
670
+ schema = data.collect_schema()
671
+ if go_to_field in schema and schema[go_to_field] in NUMERIC_DTYPES:
672
+ try:
673
+ go_to_value = float(go_to_value)
674
+ if go_to_value.is_integer():
675
+ go_to_value = int(go_to_value)
676
+ except (ValueError, TypeError):
677
+ # Non-numeric string for numeric column - mark as not found
678
+ go_to_not_found = True
679
+ # If column is string (Utf8), keep go_to_value as-is
680
+
681
+ # Only search if we have a valid value (not already marked as not found)
682
+ if not go_to_not_found:
683
+ # Find the row with row_number
684
+ search_result = (
685
+ data.with_row_index("_row_num")
686
+ .filter(pl.col(go_to_field) == go_to_value)
687
+ .select("_row_num")
688
+ .head(1)
689
+ .collect()
690
+ )
691
+
692
+ if len(search_result) > 0:
693
+ row_num = search_result["_row_num"][0]
694
+ target_page = (row_num // page_size) + 1
695
+ navigate_to_page = target_page
696
+ target_row_index = row_num % page_size
697
+ page = target_page # Jump to target page
698
+ else:
699
+ # Row not found - set flag for Vue to show "not found" feedback
700
+ go_to_not_found = True
609
701
 
610
702
  # === Selection and Sort/Filter based navigation ===
611
703
  # PURPOSE: When user sorts/filters, find where the selected row ended up and navigate there
@@ -669,12 +761,28 @@ class Table(BaseComponent):
669
761
  for identifier, column in self._interactivity.items():
670
762
  selected_value = state.get(identifier)
671
763
  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)
764
+ # Type conversion based on column dtype (same logic as go-to)
765
+ schema = data.collect_schema()
766
+ if column in schema:
767
+ col_dtype = schema[column]
768
+ if col_dtype in NUMERIC_DTYPES:
769
+ # Column is numeric - convert value to numeric if possible
770
+ if isinstance(selected_value, str):
771
+ try:
772
+ selected_value = float(selected_value)
773
+ if selected_value.is_integer():
774
+ selected_value = int(selected_value)
775
+ except (ValueError, TypeError):
776
+ pass
777
+ elif (
778
+ isinstance(selected_value, float)
779
+ and selected_value.is_integer()
780
+ ):
781
+ selected_value = int(selected_value)
782
+ else:
783
+ # Column is string - convert value to string
784
+ if not isinstance(selected_value, str):
785
+ selected_value = str(selected_value)
678
786
 
679
787
  # SEARCH for the selected row in the sorted/filtered data
680
788
  # with_row_index adds position so we know which page it's on
@@ -742,6 +850,22 @@ class Table(BaseComponent):
742
850
  # Clamp page to valid range
743
851
  page = max(1, min(page, total_pages))
744
852
 
853
+ # Compute auto-selection from first row (before pagination)
854
+ # This provides the first row's values for interactivity columns
855
+ # so downstream components can receive initial data when filters change
856
+ auto_selection: Dict[str, Any] = {}
857
+ if self._interactivity and total_rows > 0:
858
+ # Get the first row of sorted/filtered data
859
+ first_row = data.head(1).collect()
860
+ if first_row.height > 0:
861
+ for identifier, column in self._interactivity.items():
862
+ if column in first_row.columns:
863
+ value = first_row[column][0]
864
+ # Convert numpy/polars types to Python types for JSON
865
+ if hasattr(value, "item"):
866
+ value = value.item()
867
+ auto_selection[identifier] = value
868
+
745
869
  # Slice to current page
746
870
  offset = (page - 1) * page_size
747
871
  df_polars = data.slice(offset, page_size).collect()
@@ -761,12 +885,15 @@ class Table(BaseComponent):
761
885
  "sort_column": sort_column,
762
886
  "sort_dir": sort_dir,
763
887
  },
888
+ "_auto_selection": auto_selection,
764
889
  }
765
890
 
766
891
  if navigate_to_page is not None:
767
892
  result["_navigate_to_page"] = navigate_to_page
768
893
  if target_row_index is not None:
769
894
  result["_target_row_index"] = target_row_index
895
+ if go_to_not_found:
896
+ result["_go_to_not_found"] = True
770
897
 
771
898
  logger.info(
772
899
  f"[Table._prepare_vue_data] Returning: page={page}, total_rows={total_rows}, data_rows={len(df_polars)}"
@@ -16,6 +16,20 @@ _DEBUG_HASH_TRACKING = os.environ.get("SVC_DEBUG_HASH", "").lower() == "true"
16
16
  _DEBUG_STATE_SYNC = os.environ.get("SVC_DEBUG_STATE", "").lower() == "true"
17
17
  _logger = logging.getLogger(__name__)
18
18
 
19
+ # Numeric data types for type conversion during selection validation
20
+ NUMERIC_DTYPES = (
21
+ pl.Int8,
22
+ pl.Int16,
23
+ pl.Int32,
24
+ pl.Int64,
25
+ pl.UInt8,
26
+ pl.UInt16,
27
+ pl.UInt32,
28
+ pl.UInt64,
29
+ pl.Float32,
30
+ pl.Float64,
31
+ )
32
+
19
33
 
20
34
  def _make_hashable(value: Any) -> Any:
21
35
  """
@@ -130,10 +144,27 @@ def clear_component_annotations() -> None:
130
144
  st.session_state[_COMPONENT_ANNOTATIONS_KEY].clear()
131
145
 
132
146
 
147
+ def _compute_annotation_hash(component: "BaseComponent") -> Optional[str]:
148
+ """
149
+ Compute hash of component's dynamic annotations, if any.
150
+
151
+ Args:
152
+ component: The component to check for annotations
153
+
154
+ Returns:
155
+ Short hash string if annotations exist, None otherwise
156
+ """
157
+ annotations = getattr(component, "_dynamic_annotations", None)
158
+ if annotations is None:
159
+ return None
160
+ # Hash the sorted keys (sufficient for change detection)
161
+ return hashlib.md5(str(sorted(annotations.keys())).encode()).hexdigest()[:8]
162
+
163
+
133
164
  def _get_cached_vue_data(
134
165
  component_id: str,
135
166
  filter_state_hashable: Tuple[Tuple[str, Any], ...],
136
- ) -> Optional[Tuple[Dict[str, Any], str]]:
167
+ ) -> Optional[Tuple[Dict[str, Any], str, Optional[str]]]:
137
168
  """
138
169
  Get cached Vue data for component if filter state matches.
139
170
 
@@ -145,13 +176,19 @@ def _get_cached_vue_data(
145
176
  filter_state_hashable: Current filter state (for cache validation)
146
177
 
147
178
  Returns:
148
- Tuple of (vue_data, data_hash) if cache hit, None otherwise
179
+ Tuple of (vue_data, data_hash, annotation_hash) if cache hit, None otherwise
149
180
  """
150
181
  cache = _get_component_cache()
151
182
  if component_id in cache:
152
- cached_state, vue_data, data_hash = cache[component_id]
183
+ entry = cache[component_id]
184
+ # Support both old (3-tuple) and new (4-tuple) format
185
+ if len(entry) == 4:
186
+ cached_state, vue_data, data_hash, ann_hash = entry
187
+ else:
188
+ cached_state, vue_data, data_hash = entry
189
+ ann_hash = None
153
190
  if cached_state == filter_state_hashable:
154
- return (vue_data, data_hash)
191
+ return (vue_data, data_hash, ann_hash)
155
192
  return None
156
193
 
157
194
 
@@ -160,6 +197,7 @@ def _set_cached_vue_data(
160
197
  filter_state_hashable: Tuple[Tuple[str, Any], ...],
161
198
  vue_data: Dict[str, Any],
162
199
  data_hash: str,
200
+ ann_hash: Optional[str] = None,
163
201
  ) -> None:
164
202
  """
165
203
  Cache Vue data for component, replacing any previous entry.
@@ -171,9 +209,10 @@ def _set_cached_vue_data(
171
209
  filter_state_hashable: Current filter state
172
210
  vue_data: Data to cache
173
211
  data_hash: Hash of the data
212
+ ann_hash: Hash of dynamic annotations (if any)
174
213
  """
175
214
  cache = _get_component_cache()
176
- cache[component_id] = (filter_state_hashable, vue_data, data_hash)
215
+ cache[component_id] = (filter_state_hashable, vue_data, data_hash, ann_hash)
177
216
 
178
217
 
179
218
  def _prepare_vue_data_cached(
@@ -212,12 +251,10 @@ def _prepare_vue_data_cached(
212
251
 
213
252
  if _DEBUG_HASH_TRACKING:
214
253
  cache_hit = cached is not None
215
- _logger.warning(
216
- f"[CacheDebug] {component._cache_id}: cache_hit={cache_hit}"
217
- )
254
+ _logger.warning(f"[CacheDebug] {component._cache_id}: cache_hit={cache_hit}")
218
255
 
219
256
  if cached is not None:
220
- cached_data, cached_hash = cached
257
+ cached_data, cached_hash, _ = cached # Ignore cached annotation hash here
221
258
 
222
259
  if has_dynamic_annotations:
223
260
  # Cache hit but need to re-apply annotations (they may have changed)
@@ -240,6 +277,9 @@ def _prepare_vue_data_cached(
240
277
  # Cache miss - compute data
241
278
  vue_data = component._prepare_vue_data(state_dict)
242
279
 
280
+ # Compute annotation hash for cache storage
281
+ ann_hash = _compute_annotation_hash(component)
282
+
243
283
  if has_dynamic_annotations:
244
284
  # Store BASE data (without dynamic annotation columns) in cache
245
285
  if hasattr(component, "_strip_dynamic_columns"):
@@ -248,7 +288,9 @@ def _prepare_vue_data_cached(
248
288
  # Fallback: store without _plotConfig (may have stale column refs)
249
289
  base_data = {k: v for k, v in vue_data.items() if k != "_plotConfig"}
250
290
  base_hash = _hash_data(base_data)
251
- _set_cached_vue_data(component_id, filter_state_hashable, base_data, base_hash)
291
+ _set_cached_vue_data(
292
+ component_id, filter_state_hashable, base_data, base_hash, ann_hash
293
+ )
252
294
 
253
295
  # Return full data with annotations
254
296
  data_hash = _hash_data(vue_data)
@@ -256,7 +298,9 @@ def _prepare_vue_data_cached(
256
298
  else:
257
299
  # Store complete data in cache
258
300
  data_hash = _hash_data(vue_data)
259
- _set_cached_vue_data(component_id, filter_state_hashable, vue_data, data_hash)
301
+ _set_cached_vue_data(
302
+ component_id, filter_state_hashable, vue_data, data_hash, ann_hash
303
+ )
260
304
  return vue_data, data_hash
261
305
 
262
306
 
@@ -302,6 +346,101 @@ def get_vue_component_function():
302
346
  return _vue_component_func
303
347
 
304
348
 
349
+ def _validate_interactivity_selections(
350
+ component: "BaseComponent",
351
+ state_manager: "StateManager",
352
+ state: Dict[str, Any],
353
+ ) -> bool:
354
+ """
355
+ Validate that interactivity selections still exist in filtered data.
356
+
357
+ When an external filter changes, the currently selected value may no longer
358
+ exist in the filtered dataset. This function checks each interactivity
359
+ selection and clears it if invalid.
360
+
361
+ Args:
362
+ component: The component to validate selections for
363
+ state_manager: StateManager to clear invalid selections
364
+ state: Current state dict (already has filter values)
365
+
366
+ Returns:
367
+ True if any selection was cleared (state changed), False otherwise
368
+ """
369
+ interactivity = getattr(component, "_interactivity", None)
370
+ if not interactivity:
371
+ return False
372
+
373
+ filters = getattr(component, "_filters", None) or {}
374
+ filter_defaults = getattr(component, "_filter_defaults", None) or {}
375
+
376
+ # Get the preprocessed data
377
+ preprocessed = getattr(component, "_preprocessed_data", None)
378
+ if preprocessed is not None:
379
+ data = preprocessed.get("data")
380
+ else:
381
+ data = None
382
+ if data is None:
383
+ data = getattr(component, "_raw_data", None)
384
+ if data is None:
385
+ # Component doesn't have data we can validate against
386
+ return False
387
+
388
+ # Ensure LazyFrame
389
+ if isinstance(data, pl.DataFrame):
390
+ data = data.lazy()
391
+
392
+ # Apply filters to get the filtered dataset
393
+ for identifier, column in filters.items():
394
+ selected_value = state.get(identifier)
395
+ if selected_value is None and identifier in filter_defaults:
396
+ selected_value = filter_defaults[identifier]
397
+
398
+ if selected_value is None:
399
+ # Awaiting filter - no data to validate against
400
+ return False
401
+
402
+ # Convert float to int for integer columns (type mismatch handling)
403
+ if isinstance(selected_value, float) and selected_value.is_integer():
404
+ selected_value = int(selected_value)
405
+
406
+ data = data.filter(pl.col(column) == selected_value)
407
+
408
+ # Validate each interactivity selection against filtered data
409
+ state_changed = False
410
+ schema = data.collect_schema()
411
+
412
+ for identifier, column in interactivity.items():
413
+ selected_value = state.get(identifier)
414
+ if selected_value is None:
415
+ continue # No selection to validate
416
+
417
+ if column not in schema.names():
418
+ continue # Column doesn't exist
419
+
420
+ # Type conversion for numeric columns
421
+ col_dtype = schema[column]
422
+ if col_dtype in NUMERIC_DTYPES:
423
+ if isinstance(selected_value, float) and selected_value.is_integer():
424
+ selected_value = int(selected_value)
425
+
426
+ # Check if value exists in filtered data (efficient: only fetch 1 row)
427
+ exists = (
428
+ data.filter(pl.col(column) == selected_value).head(1).collect().height > 0
429
+ )
430
+
431
+ if not exists:
432
+ # Selection is invalid - clear it
433
+ state_manager.set_selection(identifier, None)
434
+ state_changed = True
435
+ if _DEBUG_STATE_SYNC:
436
+ _logger.warning(
437
+ f"[Bridge:{component._cache_id}] Cleared invalid selection: "
438
+ f"{identifier}={selected_value} not in filtered data"
439
+ )
440
+
441
+ return state_changed
442
+
443
+
305
444
  def render_component(
306
445
  component: "BaseComponent",
307
446
  state_manager: "StateManager",
@@ -377,16 +516,28 @@ def render_component(
377
516
  # Check if cached data is VALID for current state
378
517
  # KEY FIX: Only send data when cache matches current state
379
518
  # - Before: Always sent cached data, even if stale (page 38 when Vue wants page 1)
380
- # - Now: Only send if cache matches current state
519
+ # - Now: Only send if cache matches current state AND annotation state matches
381
520
  cache_valid = False
521
+ current_ann_hash = _compute_annotation_hash(component)
522
+
382
523
  if cached_entry is not None:
383
- cached_state, cached_data, cached_hash = cached_entry
384
- cache_valid = (cached_state == current_filter_state)
524
+ # Support both old (3-tuple) and new (4-tuple) cache format
525
+ if len(cached_entry) == 4:
526
+ cached_state, cached_data, cached_hash, cached_ann_hash = cached_entry
527
+ else:
528
+ cached_state, cached_data, cached_hash = cached_entry
529
+ cached_ann_hash = None
530
+
531
+ # Cache valid only if BOTH filter state AND annotation state match
532
+ filter_state_matches = cached_state == current_filter_state
533
+ ann_state_matches = cached_ann_hash == current_ann_hash
534
+ cache_valid = filter_state_matches and ann_state_matches
535
+
385
536
  if _DEBUG_STATE_SYNC:
386
537
  _logger.warning(
387
538
  f"[Bridge:{component._cache_id}] Phase1: cache_valid={cache_valid}, "
388
- f"cached_state={cached_state[:2] if cached_state else None}..., "
389
- f"current_state={current_filter_state[:2] if current_filter_state else None}..."
539
+ f"filter_match={filter_state_matches}, ann_match={ann_state_matches}, "
540
+ f"cached_ann={cached_ann_hash}, current_ann={current_ann_hash}"
390
541
  )
391
542
 
392
543
  # Build payload - only send data if cache is valid for current state
@@ -482,6 +633,15 @@ def render_component(
482
633
  awaiting_filter = True
483
634
  break
484
635
 
636
+ # === Validate interactivity selections BEFORE preparing data ===
637
+ # When filter changes, the current selection may no longer exist
638
+ # in the filtered data. Clear it so auto-selection can kick in.
639
+ if not awaiting_filter:
640
+ if _validate_interactivity_selections(component, state_manager, state):
641
+ state_changed = True
642
+ # Refresh state after clearing invalid selections
643
+ state = state_manager.get_state_for_vue()
644
+
485
645
  if not awaiting_filter:
486
646
  # Extract state keys that affect this component's data
487
647
  state_keys = set(component.get_state_dependencies())
@@ -508,6 +668,14 @@ def render_component(
508
668
  component, component_id, filter_state_hashable, relevant_state
509
669
  )
510
670
 
671
+ # Apply auto-selection from first row (for tables with interactivity)
672
+ # This ensures downstream components receive data when a filter changes
673
+ auto_selection = vue_data.pop("_auto_selection", {})
674
+ for identifier, value in auto_selection.items():
675
+ if state_manager.get_selection(identifier) is None:
676
+ state_manager.set_selection(identifier, value)
677
+ state_changed = True
678
+
511
679
  # Check if Python overrode state during _prepare_vue_data
512
680
  # (e.g., table.py sets page to last page after sort)
513
681
  final_state = state_manager.get_state_for_vue()
@@ -522,7 +690,9 @@ def render_component(
522
690
  data_hash = "awaiting_filter"
523
691
  filter_state_hashable = ()
524
692
 
525
- _logger.info(f"[bridge] Phase4: {component._cache_id} prepared data, hash={data_hash[:8] if data_hash else 'None'}")
693
+ _logger.info(
694
+ f"[bridge] Phase4: {component._cache_id} prepared data, hash={data_hash[:8] if data_hash else 'None'}"
695
+ )
526
696
 
527
697
  # === PHASE 5: Cache data for next render ===
528
698
  if vue_data:
@@ -538,8 +708,13 @@ def render_component(
538
708
  else:
539
709
  converted_data[data_key] = value
540
710
 
541
- # Store in cache for next render
542
- cache[component_id] = (filter_state_hashable, converted_data, data_hash)
711
+ # Store in cache for next render (include annotation hash for validity check)
712
+ cache[component_id] = (
713
+ filter_state_hashable,
714
+ converted_data,
715
+ data_hash,
716
+ current_ann_hash,
717
+ )
543
718
 
544
719
  # If cache was invalid at Phase 1, we didn't send data to Vue (dataChanged=False).
545
720
  # Trigger a rerun so the newly cached data gets sent on the next render.
@@ -555,9 +730,13 @@ def render_component(
555
730
 
556
731
  if _DEBUG_STATE_SYNC:
557
732
  # Log what we're caching for debugging
558
- pagination_key = next((k for k, v in filter_state_hashable if "page" in k.lower()), None)
733
+ pagination_key = next(
734
+ (k for k, v in filter_state_hashable if "page" in k.lower()), None
735
+ )
559
736
  if pagination_key:
560
- pagination_val = next((v for k, v in filter_state_hashable if k == pagination_key), None)
737
+ pagination_val = next(
738
+ (v for k, v in filter_state_hashable if k == pagination_key), None
739
+ )
561
740
  _logger.warning(
562
741
  f"[Bridge:{component._cache_id}] Phase5: Cached data with hash={data_hash[:8]}, "
563
742
  f"filter_state includes {pagination_key}={pagination_val}"
@@ -624,7 +803,11 @@ def _hash_data(data: Dict[str, Any]) -> str:
624
803
  hash_parts = []
625
804
  for key, value in sorted(data.items()):
626
805
  # Skip internal metadata but NOT dynamic annotation columns or pagination
627
- if key.startswith("_") and not key.startswith("_dynamic") and not key.startswith("_pagination"):
806
+ if (
807
+ key.startswith("_")
808
+ and not key.startswith("_dynamic")
809
+ and not key.startswith("_pagination")
810
+ ):
628
811
  continue
629
812
  if isinstance(value, pd.DataFrame):
630
813
  # Efficient hash for DataFrames
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openms-insight
3
- Version: 0.1.8
3
+ Version: 0.1.10
4
4
  Summary: Interactive visualization components for mass spectrometry data in Streamlit
5
5
  Project-URL: Homepage, https://github.com/t0mdavid-m/OpenMS-Insight
6
6
  Project-URL: Documentation, https://github.com/t0mdavid-m/OpenMS-Insight#readme
@@ -2,8 +2,8 @@ openms_insight/__init__.py,sha256=b4H9k0fPVZ6jCiJ4QVSwzlUQnyElF6ZwAlDI8fdPMJE,11
2
2
  openms_insight/components/__init__.py,sha256=Lcg-D0FILta-YVgMJBlWMKLKC5G5kXOqdy9hBOENABw,233
3
3
  openms_insight/components/heatmap.py,sha256=psrdW4gNzZR1jAIox9YS9EHaZaTRrDHFR0t2_0APU9Y,47214
4
4
  openms_insight/components/lineplot.py,sha256=I-JPvDzCr3Nu8Boc1V4D8QQ1bHgTqvM6CbeoIe7zJ-s,30896
5
- openms_insight/components/sequenceview.py,sha256=0pDOE0xeoc1-85QZNGdNwwoBwXi-5MFfeb9pCcOi6rc,30274
6
- openms_insight/components/table.py,sha256=6r0SiWTDSJS6AHNCy4jTxByY9aCt4ussCQFKKrEg77U,36955
5
+ openms_insight/components/sequenceview.py,sha256=ufvtzuU9zaAybsT3pqfqIR0ZHCl7dKJ-jk3kwJALr54,31241
6
+ openms_insight/components/table.py,sha256=0LiJetxECoGLZqhkUhDXp1_92FYgGeajm4zDTWjfc1U,42146
7
7
  openms_insight/components/volcanoplot.py,sha256=F-cmYxJMKXVK-aYJpifp8be7nB8hkQd2kLi9DrBElD8,15155
8
8
  openms_insight/core/__init__.py,sha256=EPjKX_FFQRgO8mWHs59I-o0BiuzEMzEU1Pfu9YOfLC4,338
9
9
  openms_insight/core/base.py,sha256=h0OaubHLky8mk7Yfy3HTIimsz-DfuNRgLfotJu3pZVw,20517
@@ -16,7 +16,7 @@ openms_insight/preprocessing/compression.py,sha256=T4YbX9PUlfTfPit_kpuLZn8hYpqLY
16
16
  openms_insight/preprocessing/filtering.py,sha256=fkmaIXfR5hfjyWfaMYqaeybMHaZjvUZYaKCqvxPOWMQ,14152
17
17
  openms_insight/preprocessing/scatter.py,sha256=2ifCNTUKHEW9UVpv4z9c5GaLnz5zw9o1119IenzAe9s,4703
18
18
  openms_insight/rendering/__init__.py,sha256=ApHvKeh87yY4GTIEai-tCeIXpNbwOXWlmcmIwMMRZYc,198
19
- openms_insight/rendering/bridge.py,sha256=3FiMlNL3hBHue28-0RWP9pt7GaNeczZbQwZl4cupXlc,23935
19
+ openms_insight/rendering/bridge.py,sha256=yl8QmeGnoaK2ZM4kf-XQW47o4DZ5S2lnkL0Iu7C5CjE,30331
20
20
  openms_insight/js-component/dist/index.html,sha256=LSJ3B_YmGUrCCdZ1UaZO2p6Wqsih6nTH62Z_0uZxpD8,430
21
21
  openms_insight/js-component/dist/assets/index.css,sha256=wFvo7FbG132LL7uw0slXfrL_oSQ8u2RKL1DW9hD9-kk,884264
22
22
  openms_insight/js-component/dist/assets/index.js,sha256=aqGc3g7XLTRr7ptEgoA3XDu5oMS47yxxjUBXgansIo0,6091480
@@ -24,7 +24,7 @@ openms_insight/js-component/dist/assets/materialdesignicons-webfont.eot,sha256=C
24
24
  openms_insight/js-component/dist/assets/materialdesignicons-webfont.ttf,sha256=YeirpaTpgf4iz3yOi82-oAR251xiw38Bv37jM2HWhCg,1307660
25
25
  openms_insight/js-component/dist/assets/materialdesignicons-webfont.woff,sha256=pZKKDVwvYk5G-Y2bFcL2AEU3f3xZTdeKF1kTLqO0Y-s,587984
26
26
  openms_insight/js-component/dist/assets/materialdesignicons-webfont.woff2,sha256=Zi_vqPL4qVwYWI0hd0eJwQfGTnccvmWmmvRikcQxGvw,403216
27
- openms_insight-0.1.8.dist-info/METADATA,sha256=l5aHKdSHf5z046vRxq8xJLDN7UywXFfLEcLTxfEEn4Q,16787
28
- openms_insight-0.1.8.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
29
- openms_insight-0.1.8.dist-info/licenses/LICENSE,sha256=INFF4rOMmpah7Oi14hLqu7NTOsx56KRRNChAAUcfh2E,1823
30
- openms_insight-0.1.8.dist-info/RECORD,,
27
+ openms_insight-0.1.10.dist-info/METADATA,sha256=diBvV5CHNzaJ8RsS4b24ywYnnFFSM_TMkYkLuEpAUnc,16788
28
+ openms_insight-0.1.10.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
29
+ openms_insight-0.1.10.dist-info/licenses/LICENSE,sha256=INFF4rOMmpah7Oi14hLqu7NTOsx56KRRNChAAUcfh2E,1823
30
+ openms_insight-0.1.10.dist-info/RECORD,,