|
| 1 | +# Bug Report: Heatmap-to-Table Row Selection Not Working |
| 2 | + |
| 3 | +## Issue for openms-insight Repository |
| 4 | + |
| 5 | +### Title |
| 6 | +Table component does not highlight/scroll to row when Heatmap selection changes |
| 7 | + |
| 8 | +### Description |
| 9 | + |
| 10 | +When using a shared `StateManager` between `Heatmap` and `Table` components with matching `interactivity` identifiers, clicking a point in the Heatmap does not scroll to or highlight the corresponding row in the Table. |
| 11 | + |
| 12 | +### Expected Behavior |
| 13 | + |
| 14 | +1. User clicks a point in the Heatmap |
| 15 | +2. Heatmap sets selection via `state_manager.set_selection("identification", id_idx_value)` |
| 16 | +3. Table's Vue component (`TabulatorTable.vue`) detects the selection change |
| 17 | +4. Table scrolls to the row with matching `id_idx` and highlights it |
| 18 | +5. **All rows remain visible** (no filtering) |
| 19 | + |
| 20 | +### Actual Behavior |
| 21 | + |
| 22 | +Clicking a Heatmap point does NOT cause the Table to scroll or highlight the corresponding row. The selection appears to be set (SequenceView and LinePlot update correctly), but the Table does not respond. |
| 23 | + |
| 24 | +### Reproduction Steps |
| 25 | + |
| 26 | +```python |
| 27 | +from openms_insight import Table, Heatmap, StateManager |
| 28 | + |
| 29 | +# Initialize components with shared identifier |
| 30 | +table = Table( |
| 31 | + cache_id="my_table", |
| 32 | + data=df.lazy(), |
| 33 | + cache_path=str(cache_dir), |
| 34 | + interactivity={"identification": "id_idx"}, # Shared identifier |
| 35 | + column_definitions=[...], |
| 36 | + index_field="id_idx", |
| 37 | +) |
| 38 | + |
| 39 | +heatmap = Heatmap( |
| 40 | + cache_id="my_heatmap", |
| 41 | + data=df.lazy(), |
| 42 | + cache_path=str(cache_dir), |
| 43 | + x_column="rt", |
| 44 | + y_column="mz", |
| 45 | + intensity_column="score", |
| 46 | + interactivity={"identification": "id_idx"}, # Same identifier |
| 47 | +) |
| 48 | + |
| 49 | +# Render with shared state |
| 50 | +state_manager = StateManager() |
| 51 | +heatmap(state_manager=state_manager, height=350) |
| 52 | +table(state_manager=state_manager, height=533) |
| 53 | +``` |
| 54 | + |
| 55 | +### Environment |
| 56 | + |
| 57 | +- **openms-insight version**: >=0.1.10 |
| 58 | +- **Streamlit version**: 1.43.0 |
| 59 | +- **Browser**: Chrome/Firefox (tested both) |
| 60 | +- **Python**: 3.12 |
| 61 | + |
| 62 | +### Root Cause Analysis (Deep Investigation) |
| 63 | + |
| 64 | +After thorough code analysis, the root cause has been identified: |
| 65 | + |
| 66 | +#### Primary Issue: Iterator Order Bug in `syncSelectionFromStore()` |
| 67 | + |
| 68 | +**Location:** `TabulatorTable.vue` lines 1005-1034 |
| 69 | + |
| 70 | +The `syncSelectionFromStore()` function iterates through interactivity entries and uses the **first identifier with a non-null value**, then breaks: |
| 71 | + |
| 72 | +```javascript |
| 73 | +for (const [identifier, column] of Object.entries(interactivity)) { |
| 74 | + const selectedValue = this.selectionStore.$state[identifier] |
| 75 | + if (selectedValue !== undefined && selectedValue !== null) { |
| 76 | + // ... try to find row |
| 77 | + break // STOPS after first non-null identifier |
| 78 | + } |
| 79 | +} |
| 80 | +``` |
| 81 | + |
| 82 | +**The problem:** When Table has multiple interactivity mappings (common case): |
| 83 | +```python |
| 84 | +interactivity={"file": "file_index", "spectrum": "scan_id", "identification": "id_idx"} |
| 85 | +``` |
| 86 | + |
| 87 | +And Heatmap only sets: |
| 88 | +```python |
| 89 | +interactivity={"identification": "id_idx"} |
| 90 | +``` |
| 91 | + |
| 92 | +**Failure scenario:** |
| 93 | +1. User previously selected a file → "file" has value (e.g., 0) |
| 94 | +2. User clicks Heatmap → sets "identification" = 50 |
| 95 | +3. `syncSelectionFromStore()` iterates |
| 96 | +4. "file" has value 0 (non-null) → enters if block |
| 97 | +5. Searches for row where `file_index === 0` (wrong identifier!) |
| 98 | +6. **Breaks** - never checks "identification" |
| 99 | + |
| 100 | +The function doesn't detect **which identifier changed**, it just uses the first one with a value. |
| 101 | + |
| 102 | +#### Secondary Issue: Two-Render Cycle Timing |
| 103 | + |
| 104 | +**Flow for external selection (Heatmap → Table):** |
| 105 | + |
| 106 | +1. **First render:** Python cache miss → sends `dataChanged: false` |
| 107 | + - Vue selection store IS updated |
| 108 | + - `syncSelectionFromStore()` fires with stale `preparedTableData` |
| 109 | + - Row may not be found → stored as `pendingSelection` |
| 110 | + |
| 111 | +2. **Second render:** Python cache hit → sends `dataChanged: true` |
| 112 | + - Selection store already has correct values (no change detected) |
| 113 | + - Watcher **doesn't fire** (values unchanged) |
| 114 | + - Navigation hints are in the data, but `navigateToPage` watcher may not trigger |
| 115 | + |
| 116 | +This two-phase update cycle can cause the selection highlight to be missed. |
| 117 | + |
| 118 | +### Suggested Fixes |
| 119 | + |
| 120 | +#### Fix 1: Track Which Identifier Changed |
| 121 | + |
| 122 | +Modify `syncSelectionFromStore()` to compare current vs previous selection state and prioritize the changed identifier: |
| 123 | + |
| 124 | +```javascript |
| 125 | +syncSelectionFromStore(): void { |
| 126 | + const interactivity = this.args.interactivity || {} |
| 127 | + |
| 128 | + // Find which identifier actually changed |
| 129 | + for (const [identifier, column] of Object.entries(interactivity)) { |
| 130 | + const selectedValue = this.selectionStore.$state[identifier] |
| 131 | + const previousValue = this.lastSyncedSelections?.[identifier] |
| 132 | + |
| 133 | + // Prioritize changed identifiers |
| 134 | + if (selectedValue !== previousValue && selectedValue != null) { |
| 135 | + // Handle this identifier first |
| 136 | + this.selectRowByColumn(column, selectedValue) |
| 137 | + break |
| 138 | + } |
| 139 | + } |
| 140 | + |
| 141 | + // Store current state for next comparison |
| 142 | + this.lastSyncedSelections = {...this.selectionStore.$state} |
| 143 | +} |
| 144 | +``` |
| 145 | +
|
| 146 | +#### Fix 2: Ensure Selection Sync After Data Update |
| 147 | +
|
| 148 | +In the `currentDataHash` watcher, explicitly call `syncSelectionFromStore()` after data updates: |
| 149 | +
|
| 150 | +```javascript |
| 151 | +currentDataHash: { |
| 152 | + handler(newHash: string, oldHash: string) { |
| 153 | + // ... existing logic ... |
| 154 | + |
| 155 | + // After data update, re-sync selection |
| 156 | + this.$nextTick(() => { |
| 157 | + this.syncSelectionFromStore() |
| 158 | + }) |
| 159 | + } |
| 160 | +} |
| 161 | +``` |
| 162 | +
|
| 163 | +### Verified Working Components |
| 164 | +
|
| 165 | +- **SequenceView**: Uses `filters` parameter → actively filters data based on selection (different mechanism) |
| 166 | +- **LinePlot**: Linked via SequenceView → inherits working filter-based selection |
| 167 | +- **Heatmap**: Correctly sets selection → Python StateManager updates correctly |
| 168 | +
|
| 169 | +### Related Code Paths |
| 170 | +
|
| 171 | +| File | Lines | Description | |
| 172 | +|------|-------|-------------| |
| 173 | +| `TabulatorTable.vue` | 989-1038 | `syncSelectionFromStore()` - main issue location | |
| 174 | +| `TabulatorTable.vue` | 370-376 | Selection store watcher | |
| 175 | +| `TabulatorTable.vue` | 378-410 | `navigateToPage` watcher | |
| 176 | +| `table.py` | 711-856 | Python-side navigation hint generation | |
| 177 | +| `bridge.py` | 456-797 | Render cycle with two-phase cache handling | |
| 178 | +| `streamlit-data.ts` | 39-203 | Vue data store update logic | |
| 179 | +
|
| 180 | +### Workaround |
| 181 | +
|
| 182 | +Currently, using `filters` instead of `interactivity` works but **filters** data instead of highlighting: |
| 183 | +
|
| 184 | +```python |
| 185 | +table = Table( |
| 186 | + ... |
| 187 | + filters={"identification": "id_idx"}, # Filters, not highlights |
| 188 | +) |
| 189 | +``` |
0 commit comments