openms-insight 0.1.8__tar.gz → 0.1.10__tar.gz
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-0.1.8 → openms_insight-0.1.10}/PKG-INFO +1 -1
- {openms_insight-0.1.8 → openms_insight-0.1.10}/openms_insight/components/sequenceview.py +20 -0
- {openms_insight-0.1.8 → openms_insight-0.1.10}/openms_insight/components/table.py +158 -31
- {openms_insight-0.1.8 → openms_insight-0.1.10}/openms_insight/rendering/bridge.py +205 -22
- {openms_insight-0.1.8 → openms_insight-0.1.10}/pyproject.toml +1 -1
- {openms_insight-0.1.8 → openms_insight-0.1.10}/.gitignore +0 -0
- {openms_insight-0.1.8 → openms_insight-0.1.10}/LICENSE +0 -0
- {openms_insight-0.1.8 → openms_insight-0.1.10}/README.md +0 -0
- {openms_insight-0.1.8 → openms_insight-0.1.10}/openms_insight/__init__.py +0 -0
- {openms_insight-0.1.8 → openms_insight-0.1.10}/openms_insight/components/__init__.py +0 -0
- {openms_insight-0.1.8 → openms_insight-0.1.10}/openms_insight/components/heatmap.py +0 -0
- {openms_insight-0.1.8 → openms_insight-0.1.10}/openms_insight/components/lineplot.py +0 -0
- {openms_insight-0.1.8 → openms_insight-0.1.10}/openms_insight/components/volcanoplot.py +0 -0
- {openms_insight-0.1.8 → openms_insight-0.1.10}/openms_insight/core/__init__.py +0 -0
- {openms_insight-0.1.8 → openms_insight-0.1.10}/openms_insight/core/base.py +0 -0
- {openms_insight-0.1.8 → openms_insight-0.1.10}/openms_insight/core/cache.py +0 -0
- {openms_insight-0.1.8 → openms_insight-0.1.10}/openms_insight/core/registry.py +0 -0
- {openms_insight-0.1.8 → openms_insight-0.1.10}/openms_insight/core/state.py +0 -0
- {openms_insight-0.1.8 → openms_insight-0.1.10}/openms_insight/core/subprocess_preprocess.py +0 -0
- {openms_insight-0.1.8 → openms_insight-0.1.10}/openms_insight/js-component/dist/assets/index.css +0 -0
- {openms_insight-0.1.8 → openms_insight-0.1.10}/openms_insight/js-component/dist/assets/index.js +0 -0
- {openms_insight-0.1.8 → openms_insight-0.1.10}/openms_insight/js-component/dist/assets/materialdesignicons-webfont.eot +0 -0
- {openms_insight-0.1.8 → openms_insight-0.1.10}/openms_insight/js-component/dist/assets/materialdesignicons-webfont.ttf +0 -0
- {openms_insight-0.1.8 → openms_insight-0.1.10}/openms_insight/js-component/dist/assets/materialdesignicons-webfont.woff +0 -0
- {openms_insight-0.1.8 → openms_insight-0.1.10}/openms_insight/js-component/dist/assets/materialdesignicons-webfont.woff2 +0 -0
- {openms_insight-0.1.8 → openms_insight-0.1.10}/openms_insight/js-component/dist/index.html +0 -0
- {openms_insight-0.1.8 → openms_insight-0.1.10}/openms_insight/preprocessing/__init__.py +0 -0
- {openms_insight-0.1.8 → openms_insight-0.1.10}/openms_insight/preprocessing/compression.py +0 -0
- {openms_insight-0.1.8 → openms_insight-0.1.10}/openms_insight/preprocessing/filtering.py +0 -0
- {openms_insight-0.1.8 → openms_insight-0.1.10}/openms_insight/preprocessing/scatter.py +0 -0
- {openms_insight-0.1.8 → openms_insight-0.1.10}/openms_insight/rendering/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openms-insight
|
|
3
|
-
Version: 0.1.
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
#
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
go_to_value =
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
-
#
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
384
|
-
|
|
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"
|
|
389
|
-
f"
|
|
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(
|
|
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] = (
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openms_insight-0.1.8 → openms_insight-0.1.10}/openms_insight/js-component/dist/assets/index.css
RENAMED
|
File without changes
|
{openms_insight-0.1.8 → openms_insight-0.1.10}/openms_insight/js-component/dist/assets/index.js
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|