gramene-search 2.1.10 → 2.2.0

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.
Files changed (41) hide show
  1. package/.claude/launch.json +11 -0
  2. package/.claude/settings.local.json +21 -1
  3. package/.env.example +6 -0
  4. package/.parcel-cache/13f2d5707e7af45c-RequestGraph +0 -0
  5. package/.parcel-cache/5ae0570a78c0dba3-AssetGraph +0 -0
  6. package/.parcel-cache/9ac092379278e465-BundleGraph +0 -0
  7. package/.parcel-cache/data.mdb +0 -0
  8. package/.parcel-cache/lock.mdb +0 -0
  9. package/.parcel-cache/snapshot-13f2d5707e7af45c.txt +2 -2
  10. package/dist/index.css +1 -4
  11. package/dist/index.css.map +1 -1
  12. package/dist/index.js +2308 -433
  13. package/dist/index.js.map +1 -1
  14. package/package.json +5 -2
  15. package/src/bundles/api.js +24 -42
  16. package/src/bundles/docs.js +2 -1
  17. package/src/bundles/exprViz.js +97 -1
  18. package/src/bundles/index.js +4 -1
  19. package/src/bundles/ontologyEnrichment.js +14 -1
  20. package/src/bundles/savedViews.js +335 -0
  21. package/src/bundles/uiViewState.js +174 -0
  22. package/src/bundles/viewSnapshot.js +313 -0
  23. package/src/bundles/views.js +24 -2
  24. package/src/components/Auth.js +23 -3
  25. package/src/components/SaveView.js +157 -0
  26. package/src/components/exprViz/ExprVizView.js +16 -11
  27. package/src/components/exprViz/ParallelCoordsPlot.js +15 -0
  28. package/src/components/results/GeneList.js +45 -49
  29. package/src/components/results/OntologyEnrichment.js +13 -6
  30. package/src/components/results/TaxDist.js +11 -0
  31. package/src/components/results/details/BAR.js +148 -0
  32. package/src/components/results/details/Expression.js +50 -14
  33. package/src/components/results/details/Homology.js +171 -39
  34. package/src/components/results/details/Pathways.js +4 -2
  35. package/src/components/results/details/Sequences.js +24 -8
  36. package/src/components/results/details/VEP.js +65 -19
  37. package/src/components/styles.css +1 -4
  38. package/src/demo.js +30 -13
  39. package/src/index.js +2 -1
  40. package/src/suppressDevWarnings.js +13 -0
  41. package/src/utils/bootView.js +38 -0
@@ -1,7 +1,7 @@
1
1
  import React, { useRef, useEffect, useState } from 'react'
2
2
  import {connect} from "redux-bundler-react";
3
3
  import {Tabs, Tab, Form, Row, Col} from 'react-bootstrap';
4
- import BAR, {haveBAR} from "gramene-efp-browser";
4
+ import BAR, {haveBAR} from "./BAR";
5
5
 
6
6
  function DynamicIframe(props) {
7
7
  // Create a ref for the iframe element
@@ -31,16 +31,35 @@ function DynamicIframe(props) {
31
31
  }
32
32
 
33
33
  const Detail = props => {
34
- const gene = props.geneDocs[props.searchResult.id];
35
- const [atlasExperiment, setAtlasExperiment] = useState(null);
34
+ const geneId = props.searchResult.id;
35
+ const gene = props.geneDocs[geneId];
36
+ // User-selected view state (active sub-tab, chosen GXA experiment, chosen eFP
37
+ // study) lives in the uiViewState bundle keyed by geneId, so the shareable-
38
+ // views snapshot can round-trip it. Fetched data + the dev-only Local API
39
+ // toggle stay in local state. Defaults match the old local-state initials.
40
+ const expr = (props.uiViewState && props.uiViewState.byGene[geneId]
41
+ && props.uiViewState.byGene[geneId].expression) || {};
42
+ const activeTab = expr.activeTab || 'gene';
43
+ const atlasExperiment = expr.atlasExperiment || null;
44
+ const setActiveTab = (k) => props.doSetExpressionState({geneId, patch: {activeTab: k}});
45
+ const setAtlasExperiment = (v) => props.doSetExpressionState({geneId, patch: {atlasExperiment: v}});
36
46
  const [atlasExperimentList, setAtlasExperimentList] = useState([]);
37
47
  const [atlasFacets, setAtlasFacets] = useState(null);
38
48
  const [isLocal, setIsLocal] = useState(false);
39
- const [activeTab, setActiveTab] = useState('gene');
40
49
 
41
50
  const handleLocalAPIChange = (event) => {
42
51
  setIsLocal(event.target.checked);
43
52
  };
53
+ // The expressionStudies resource is otherwise fetched only when a top-level
54
+ // expression view (exprViz/expression/export) is on — but this per-gene
55
+ // Expression detail also needs it (the Paralogs sub-tab's experiment list and
56
+ // the atlasExperiment selection both derive from it). Self-fetch on mount so
57
+ // opening the detail populates the studies even when no such view is enabled.
58
+ useEffect(() => {
59
+ if (!props.expressionStudies && props.doFetchExpressionStudies) {
60
+ props.doFetchExpressionStudies();
61
+ }
62
+ }, [props.expressionStudies]);
44
63
  useEffect(() => {
45
64
  if (!props.expressionStudies) return;
46
65
  const tid = Math.floor(gene.taxon_id / 1000);
@@ -58,12 +77,17 @@ const Detail = props => {
58
77
  eList.forEach(e => {e.factors.forEach(factor => facets[e.type][factor] = 1);});
59
78
  setAtlasExperimentList(eList);
60
79
  setAtlasFacets(facets);
61
- let refExp = eList.filter(e => e.isRef);
62
- if (refExp.length === 1) {
63
- setAtlasExperiment(refExp[0]._id);
64
- } else {
65
- // no reference experiment - choose first
66
- setAtlasExperiment(eList[0]._id);
80
+ // Only pick a default experiment when the user (or a restored snapshot)
81
+ // hasn't already chosen one — otherwise we'd clobber a saved selection
82
+ // the moment the studies list loads.
83
+ if (!atlasExperiment) {
84
+ let refExp = eList.filter(e => e.isRef);
85
+ if (refExp.length === 1) {
86
+ setAtlasExperiment(refExp[0]._id);
87
+ } else {
88
+ // no reference experiment - choose first
89
+ setAtlasExperiment(eList[0]._id);
90
+ }
67
91
  }
68
92
  }
69
93
  }, [props.expressionStudies]);
@@ -71,11 +95,15 @@ const Detail = props => {
71
95
  let paralogs_url;
72
96
  let gene_url = `https://dev.gramene.org/static/atlasWidget.html?genes=${gene.atlas_id || gene._id}&localAPI=${isLocal}`;
73
97
  let paralogs = [];
74
- if (props.grameneParalogs && props.grameneParalogs[gene._id]) {
98
+ const haveParalogs = props.grameneParalogs && props.grameneParalogs[gene._id];
99
+ if (haveParalogs) {
75
100
  paralogs = props.grameneParalogs[gene._id];
76
- } else if (gene.homology) {
77
- props.doRequestParalogs(gene._id, gene.homology.supertree, gene.taxon_id);
78
101
  }
102
+ useEffect(() => {
103
+ if (!haveParalogs && gene.homology) {
104
+ props.doRequestParalogs(gene._id, gene.homology.supertree, gene.taxon_id);
105
+ }
106
+ }, [gene._id, haveParalogs]);
79
107
  // if (gene.homology && gene.homology.homologous_genes && gene.homology.homologous_genes.within_species_paralog) {
80
108
  // paralogs = gene.homology.homologous_genes.within_species_paralog;
81
109
  // }
@@ -87,6 +115,7 @@ const Detail = props => {
87
115
  <Tab tabClassName="gxa" eventKey="paralogs" title={`Paralogs`} key="gxaparalogs">
88
116
  <Form.Select aria-label='experiment selector'
89
117
  placeholder='Select experiment'
118
+ value={atlasExperiment || ''}
90
119
  onChange={(e) => setAtlasExperiment(e.target.value)}>
91
120
  { atlasExperimentList.map((e,idx) =>
92
121
  <option key={idx} value={e._id}>{e.type}: {e.description || e._id}</option>
@@ -106,7 +135,11 @@ const Detail = props => {
106
135
  {activeTab === "gene" && <DynamicIframe url={gene_url}/> }
107
136
  </Tab>
108
137
  {haveBAR(gene) &&
109
- <Tab tabClassName="eFP" eventKey="eFP" title="eFP Browser" key="bar"><BAR gene={gene}/></Tab>
138
+ <Tab tabClassName="eFP" eventKey="eFP" title="eFP Browser" key="bar">
139
+ <BAR gene={gene}
140
+ study={expr.barStudy}
141
+ onStudyChange={v => props.doSetExpressionState({geneId, patch: {barStudy: v}})}/>
142
+ </Tab>
110
143
  }
111
144
  </Tabs>
112
145
  };
@@ -114,7 +147,10 @@ const Detail = props => {
114
147
  export default connect(
115
148
  'selectGrameneParalogs',
116
149
  'selectExpressionStudies',
150
+ 'selectUiViewState',
117
151
  'doRequestParalogs',
152
+ 'doFetchExpressionStudies',
153
+ 'doSetExpressionState',
118
154
  //'doRequestParalogExpression',
119
155
  Detail
120
156
  );
@@ -2,7 +2,10 @@ import React from 'react'
2
2
  import _ from 'lodash';
3
3
  import {connect} from "redux-bundler-react";
4
4
  import TreeVis from "gramene-genetree-vis";
5
- import treesClient from "gramene-trees-client";
5
+ // Subpath imports avoid gramene-trees-client/index.js, whose require chain
6
+ // fires a /swagger fetch at module load (see comment in bundles/api.js).
7
+ import taxonomy from "gramene-trees-client/src/taxonomy";
8
+ import genetree from "gramene-trees-client/src/genetree";
6
9
  import {
7
10
  TBrowse,
8
11
  computePivotState,
@@ -17,7 +20,7 @@ import {
17
20
  } from "tbrowse";
18
21
  import {Detail, Title, Description, Content, Explore, Links} from "./generic";
19
22
  import {suggestionToFilters} from "../../utils";
20
- import {Spinner, Alert} from "react-bootstrap";
23
+ import {Spinner} from "react-bootstrap";
21
24
  import '../../../../node_modules/gramene-genetree-vis/src/styles/msa.less';
22
25
  import '../../../../node_modules/gramene-genetree-vis/src/styles/tree.less';
23
26
  import './tree-view.css';
@@ -28,15 +31,123 @@ const TBROWSE_ZONES = [treeZone, labelsZone, msaZone, neighborhoodZone, genomeZo
28
31
  class Homology extends React.Component {
29
32
  constructor(props) {
30
33
  super(props);
31
- this.state = {viewer: 'treevis', neighborhood: null, neighborhoodTreeId: null, geneStructures: null, geneStructuresTreeId: null, height: 600};
34
+ // Async data caches stay in local state (per mount, per tree). The
35
+ // user-controlled view state (viewer toggle, resize height, tbrowse
36
+ // ViewState) is lifted into the uiViewState bundle, keyed by geneId.
37
+ //
38
+ // `*Status` fields track per-zone load lifecycle so the TBrowse
39
+ // toolbar can render loading/error affordances via its zoneStatus
40
+ // prop. They start `undefined` (no signal); flipped to 'loading'
41
+ // when the fetch kicks off and 'ready' / 'error' on settle.
42
+ this.state = {
43
+ neighborhood: null,
44
+ neighborhoodTreeId: null,
45
+ neighborhoodStatus: undefined,
46
+ geneStructures: null,
47
+ geneStructuresTreeId: null,
48
+ geneStructuresStatus: undefined,
49
+ };
32
50
  if (!props.geneDocs.hasOwnProperty(props.searchResult.id)) {
33
51
  props.requestGene(props.searchResult.id)
34
52
  }
35
- this.taxonomy = treesClient.taxonomy.tree(Object.values(props.grameneTaxonomy))
53
+ this.taxonomy = taxonomy.tree(Object.values(props.grameneTaxonomy))
54
+ }
55
+ // ----- uiViewState accessors (with sensible defaults) -----
56
+ getGeneId() { return this.props.searchResult.id; }
57
+ getHomologySlice() {
58
+ const slice = this.props.uiViewState && this.props.uiViewState.byGene[this.getGeneId()];
59
+ return (slice && slice.homology) || {};
60
+ }
61
+ getViewer() { return this.getHomologySlice().viewer || 'treevis'; }
62
+ getHeight() {
63
+ const h = this.getHomologySlice().height;
64
+ return typeof h === 'number' ? h : 600;
65
+ }
66
+ setViewer(viewer) {
67
+ this.props.doSetHomologyViewer({geneId: this.getGeneId(), viewer});
68
+ }
69
+ setHeight(height) {
70
+ this.props.doSetHomologyHeight({geneId: this.getGeneId(), height});
71
+ }
72
+ setTbrowseViewState(viewState) {
73
+ this.props.doSetHomologyTbrowseViewState({geneId: this.getGeneId(), tbrowse: viewState});
74
+ }
75
+ // Seed the bundle with a pivot-computed initial tbrowse view state once the
76
+ // tree data is available and the user is actually looking at the tbrowse
77
+ // viewer. Called from lifecycle (not render) to avoid dispatching mid-render.
78
+ maybeSeedTbrowseViewState() {
79
+ if (this.getViewer() !== 'tbrowse') return;
80
+ if (this.getHomologySlice().tbrowse) return;
81
+ const id = this.getGeneId();
82
+ if (!this.props.geneDocs.hasOwnProperty(id)) return;
83
+ const gene = this.props.geneDocs[id];
84
+ if (!gene.homology) return;
85
+ const treeId = gene.homology.gene_tree.id;
86
+ const raw = this.props.grameneTrees[treeId];
87
+ if (!raw || !raw.taxon_id) return;
88
+ const adapted = fromGrameneGenetree([raw]);
89
+ const pivot = computePivotState(adapted.tree, gene._id);
90
+ this.setTbrowseViewState({
91
+ selectedNodeId: null,
92
+ collapsedNodeIds: pivot ? pivot.collapsedNodeIds : [],
93
+ prunedNodeIds: [],
94
+ swappedNodeIds: pivot ? pivot.swappedNodeIds : [],
95
+ compressedNodeIds: [],
96
+ nodeOfInterestId: pivot ? pivot.targetId : null,
97
+ // Use each zone's intended fr-share + initial visibility from its
98
+ // definition (tree:30 / labels:20 / msa:50 / neighborhood:30 /
99
+ // genome:32) instead of uniform 25 across the board. Gives the MSA
100
+ // a wider initial pane and the tree a narrower one.
101
+ //
102
+ // `defaultVisible ?? true` matches tbrowse's own buildInitialViewState
103
+ // logic — zones default to visible unless their definition opts out
104
+ // (e.g. neighborhood and genome opt out so they only appear once
105
+ // their async data lands; tbrowse's Layout auto-flips them on then).
106
+ zones: TBROWSE_ZONES.map(z => ({
107
+ id: z.id,
108
+ width: z.defaultWidth,
109
+ visible: z.defaultVisible ?? true
110
+ })),
111
+ zoneStates: {},
112
+ search: null,
113
+ });
114
+ }
115
+ componentDidMount() {
116
+ this.maybeSeedTbrowseViewState();
117
+ this.maybeFetchTbrowseData();
118
+ }
119
+ componentDidUpdate() {
120
+ this.maybeSeedTbrowseViewState();
121
+ this.maybeFetchTbrowseData();
122
+ }
123
+ // Kick off the neighborhood + gene-structure fetches from lifecycle,
124
+ // not render. Each fetch internally dedupes on `_*FetchedFor === treeId`
125
+ // so calling on every commit is cheap. Gated on the user actually being
126
+ // on the tbrowse viewer + the tree data being loaded — otherwise we'd
127
+ // pay network cost for genes the user never looks at.
128
+ maybeFetchTbrowseData() {
129
+ if (this.getViewer() !== 'tbrowse') return;
130
+ const id = this.getGeneId();
131
+ if (!this.props.geneDocs.hasOwnProperty(id)) return;
132
+ const gene = this.props.geneDocs[id];
133
+ if (!gene.homology) return;
134
+ const treeId = gene.homology.gene_tree.id;
135
+ const raw = this.props.grameneTrees[treeId];
136
+ if (!raw || !raw.taxon_id) return;
137
+ // Lazily compute the adapted tbrowse data the same way renderTBrowse
138
+ // does, so fetchGeneStructures has the leaf ids without rerunning
139
+ // fromGrameneGenetree on every commit.
140
+ if (this._tbrowseTreeId !== treeId) {
141
+ this._tbrowseTreeId = treeId;
142
+ this._tbrowseData = fromGrameneGenetree([raw]);
143
+ }
144
+ this.fetchNeighborhood(treeId);
145
+ this.fetchGeneStructures(treeId, this._tbrowseData.tree);
36
146
  }
37
147
  fetchNeighborhood(treeId) {
38
148
  if (this._neighborhoodFetchedFor === treeId) return;
39
149
  this._neighborhoodFetchedFor = treeId;
150
+ this.setState({neighborhoodStatus: 'loading'});
40
151
  const api = this.props.grameneAPI;
41
152
  const url = new URL(`${api}/search`);
42
153
  url.searchParams.set('fl', 'id,name,gene_tree,gene_idx,region,start,end,strand,biotype,system_name,description');
@@ -50,10 +161,19 @@ class Homology extends React.Component {
50
161
  })
51
162
  .then(json => {
52
163
  if (this._neighborhoodFetchedFor !== treeId) return;
53
- this.setState({neighborhood: fromGrameneNeighborhood(json), neighborhoodTreeId: treeId});
164
+ this.setState({
165
+ neighborhood: fromGrameneNeighborhood(json),
166
+ neighborhoodTreeId: treeId,
167
+ neighborhoodStatus: 'ready',
168
+ });
54
169
  })
55
170
  .catch(err => {
56
171
  console.warn('tbrowse neighborhood fetch failed:', err);
172
+ // Surface the failure via the zoneStatus prop. Reset the
173
+ // dedupe key so a re-mount or tree-change can retry.
174
+ if (this._neighborhoodFetchedFor === treeId) {
175
+ this.setState({neighborhoodStatus: 'error'});
176
+ }
57
177
  this._neighborhoodFetchedFor = null;
58
178
  });
59
179
  }
@@ -64,6 +184,7 @@ class Homology extends React.Component {
64
184
  .filter(n => n.isLeaf && n.geneId)
65
185
  .map(n => n.geneId);
66
186
  if (ids.length === 0) return;
187
+ this.setState({geneStructuresStatus: 'loading'});
67
188
  const api = this.props.grameneAPI;
68
189
  const BATCH_SIZE = 50;
69
190
  const batches = [];
@@ -84,21 +205,28 @@ class Homology extends React.Component {
84
205
  .then(results => {
85
206
  if (this._geneStructuresFetchedFor !== treeId) return;
86
207
  const combined = [].concat(...results.map(r => Array.isArray(r) ? r : []));
87
- this.setState({geneStructures: fromGrameneGeneStructures(combined), geneStructuresTreeId: treeId});
208
+ this.setState({
209
+ geneStructures: fromGrameneGeneStructures(combined),
210
+ geneStructuresTreeId: treeId,
211
+ geneStructuresStatus: 'ready',
212
+ });
88
213
  })
89
214
  .catch(err => {
90
215
  console.warn('tbrowse gene-structures fetch failed:', err);
216
+ if (this._geneStructuresFetchedFor === treeId) {
217
+ this.setState({geneStructuresStatus: 'error'});
218
+ }
91
219
  this._geneStructuresFetchedFor = null;
92
220
  });
93
221
  }
94
222
  startResize(e) {
95
223
  e.preventDefault();
96
224
  const startY = e.clientY;
97
- const startHeight = this.state.height;
225
+ const startHeight = this.getHeight();
98
226
 
99
227
  const onMouseMove = (moveEvent) => {
100
228
  const newHeight = Math.max(200, startHeight + (moveEvent.clientY - startY));
101
- this.setState({ height: newHeight });
229
+ this.setHeight(newHeight);
102
230
  };
103
231
 
104
232
  const onMouseUp = () => {
@@ -122,7 +250,7 @@ class Homology extends React.Component {
122
250
  renderTreeVis() {
123
251
  return (
124
252
  <>
125
- <div className="gene-genetree" style={{height: this.state.height, width: '100%'}}>
253
+ <div className="gene-genetree" style={{height: this.getHeight(), width: '100%'}}>
126
254
  <TreeVis genetree={this.tree}
127
255
  initialGeneOfInterest={this.gene}
128
256
  genomesOfInterest={this.props.grameneGenomes.active}
@@ -140,32 +268,35 @@ class Homology extends React.Component {
140
268
  }
141
269
  renderTBrowse() {
142
270
  const treeId = this.gene.homology.gene_tree.id;
271
+ // Adapted tbrowse data is computed lazily in maybeFetchTbrowseData
272
+ // (called from lifecycle). If the user just flipped to tbrowse and
273
+ // the lifecycle hasn't fired yet, compute it once here without
274
+ // calling any fetches — those will fire from componentDidUpdate.
143
275
  if (this._tbrowseTreeId !== treeId) {
144
- const raw = this.props.grameneTrees[treeId];
145
- const adapted = fromGrameneGenetree([raw]);
146
276
  this._tbrowseTreeId = treeId;
147
- this._tbrowseData = adapted;
148
- const pivot = computePivotState(adapted.tree, this.gene._id);
149
- const zoneIds = TBROWSE_ZONES.map(z => z.id);
150
- this._tbrowseInitialViewState = {
151
- selectedNodeId: null,
152
- collapsedNodeIds: pivot ? pivot.collapsedNodeIds : [],
153
- prunedNodeIds: [],
154
- swappedNodeIds: pivot ? pivot.swappedNodeIds : [],
155
- compressedNodeIds: [],
156
- nodeOfInterestId: pivot ? pivot.targetId : null,
157
- zones: zoneIds.map(id => ({id, width: 25, visible: true})),
158
- zoneStates: {},
159
- search: null,
160
- };
277
+ this._tbrowseData = fromGrameneGenetree([this.props.grameneTrees[treeId]]);
161
278
  }
162
- this.fetchNeighborhood(treeId);
163
- this.fetchGeneStructures(treeId, this._tbrowseData.tree);
164
279
  const neighborhood = this.state.neighborhoodTreeId === treeId ? this.state.neighborhood : undefined;
165
280
  const geneStructures = this.state.geneStructuresTreeId === treeId ? this.state.geneStructures : undefined;
281
+ // Per-zone status for the TBrowse toolbar — pulses while a fetch
282
+ // is in flight, turns red on failure. Tracked per-tree so a
283
+ // tree-change resets any stale 'ready'/'error' from the prior gene.
284
+ const zoneStatus = {
285
+ neighborhood: this.state.neighborhoodTreeId === treeId
286
+ ? this.state.neighborhoodStatus
287
+ : (this.state.neighborhoodStatus === 'loading' ? 'loading' : undefined),
288
+ genome: this.state.geneStructuresTreeId === treeId
289
+ ? this.state.geneStructuresStatus
290
+ : (this.state.geneStructuresStatus === 'loading' ? 'loading' : undefined),
291
+ };
292
+ // Bundle-driven (controlled) view state. If we're rendering tbrowse before
293
+ // componentDidMount/Update has seeded the bundle slice, skip this turn and
294
+ // let the re-render with the seeded state do the work.
295
+ const tbrowseVS = this.getHomologySlice().tbrowse;
296
+ if (!tbrowseVS) return null;
166
297
  return (
167
298
  <>
168
- <div className="gene-genetree" style={{height: this.state.height, width: '100%'}}>
299
+ <div className="gene-genetree" style={{height: this.getHeight(), width: '100%'}}>
169
300
  <TBrowse
170
301
  tree={this._tbrowseData.tree}
171
302
  taxonomy={this._tbrowseData.taxonomy}
@@ -177,7 +308,10 @@ class Homology extends React.Component {
177
308
  geneStructures={geneStructures}
178
309
  zones={TBROWSE_ZONES}
179
310
  nodeOfInterest={this.gene._id}
180
- initialViewState={this._tbrowseInitialViewState}
311
+ viewState={tbrowseVS}
312
+ onViewStateChange={next => this.setTbrowseViewState(next)}
313
+ defaultOpenSections={{ zones: true, search: true }}
314
+ zoneStatus={zoneStatus}
181
315
  />
182
316
  </div>
183
317
  {this.renderResizeHandle()}
@@ -185,12 +319,12 @@ class Homology extends React.Component {
185
319
  )
186
320
  }
187
321
  renderViewerToggle() {
188
- const {viewer} = this.state;
322
+ const viewer = this.getViewer();
189
323
  const btn = (id, label) => (
190
324
  <button
191
325
  key={id}
192
326
  type="button"
193
- onClick={() => this.setState({viewer: id})}
327
+ onClick={() => this.setViewer(id)}
194
328
  style={{
195
329
  padding: '4px 10px',
196
330
  marginRight: 4,
@@ -336,25 +470,20 @@ class Homology extends React.Component {
336
470
  else {
337
471
  const tree = this.props.grameneTrees[treeId];
338
472
  if (tree.hasOwnProperty('taxon_id')) {
339
- this.tree = treesClient.genetree.tree([this.props.grameneTrees[treeId]]);
473
+ this.tree = genetree.tree([this.props.grameneTrees[treeId]]);
340
474
  this.orthologs = this.orthologList();
341
475
  this.paralogs = this.paralogList();
342
476
  }
343
477
  }
344
- let flagged=0;
345
- // if (this.props.curation && this.props.curation.taxa.hasOwnProperty(this.gene.taxon_id)) {
346
- // flagged = this.props.curatedGenes && this.props.curatedGenes[id] ? this.props.curatedGenes[id].flagged : 0;
347
- // }
348
478
  return (
349
479
  <Detail>
350
480
  {/*<Title key="title">Compara Gene Tree</Title>*/}
351
481
  <Description key="description">
352
482
  This phylogram shows the relationships between this gene and others similar to it, as determined by Ensembl Compara.
353
- {flagged > 1 && <Alert variant={'warning'}>This gene was flagged for potential gene structural annotation issues by {flagged} curators</Alert>}
354
483
  </Description>
355
484
  {this.tree && <Content key="content">
356
485
  {this.renderViewerToggle()}
357
- {this.state.viewer === 'tbrowse' ? this.renderTBrowse() : this.renderTreeVis()}
486
+ {this.getViewer() === 'tbrowse' ? this.renderTBrowse() : this.renderTreeVis()}
358
487
  </Content>}
359
488
  {this.tree && <Explore key="explore" explorations={this.explorations()}/>}
360
489
  <Links key="links" links={this.links()}/>
@@ -370,8 +499,11 @@ export default connect(
370
499
  'selectGrameneAPI',
371
500
  'selectConfiguration',
372
501
  'selectCuration',
373
- 'selectCuratedGenes',
502
+ 'selectUiViewState',
374
503
  'doRequestGrameneTree',
504
+ 'doSetHomologyViewer',
505
+ 'doSetHomologyHeight',
506
+ 'doSetHomologyTbrowseViewState',
375
507
  'doAcceptGrameneSuggestion',
376
508
  'doReplaceGrameneFilters',
377
509
  Homology
@@ -2,7 +2,9 @@ import React from 'react'
2
2
  import {connect} from "redux-bundler-react";
3
3
  import FlatToNested from 'flat-to-nested';
4
4
  import {Explore, Links} from "./generic";
5
- import treesClient from "gramene-trees-client";
5
+ // Subpath import avoids gramene-trees-client/index.js, whose require chain
6
+ // fires a /swagger fetch at module load (see comment in bundles/api.js).
7
+ import taxonomy from "gramene-trees-client/src/taxonomy";
6
8
  import TreeMenu from "react-simple-tree-menu";
7
9
  import '../../../../node_modules/react-simple-tree-menu/dist/main.css';
8
10
 
@@ -101,7 +103,7 @@ function buildIframeSrcDoc() {
101
103
  class Pathways extends React.Component {
102
104
  constructor(props) {
103
105
  super(props);
104
- this.taxonomy = treesClient.taxonomy.tree(Object.values(props.grameneTaxonomy))
106
+ this.taxonomy = taxonomy.tree(Object.values(props.grameneTaxonomy))
105
107
  this.gene = props.geneDocs[props.searchResult.id];
106
108
  this.iframeRef = React.createRef();
107
109
  // srcDoc is constant — the per-instance pathway/reaction state is
@@ -169,22 +169,36 @@ const buildId = (gene, geneSeq, up, down) => {
169
169
  return `${geneSeq.genome}|${gene._id}|${gene.location.region}:${gs}..${ge} ${extras.join('|')}`
170
170
  };
171
171
  const Detail = props => {
172
- const gene = props.geneDocs[props.searchResult.id];
173
- const [tab, setTab] = useState('dna');
174
- const [upstream, setUpstream] = useState(0);
175
- const [downstream, setDownstream] = useState(0);
176
- const [tid, setTid] = useState(gene.gene_structure.canonical_transcript);
172
+ const geneId = props.searchResult.id;
173
+ const gene = props.geneDocs[geneId];
174
+ // Sub-tab + selected isoform + flanking lengths live in the uiViewState
175
+ // bundle (keyed by geneId) so the shareable-views snapshot can round-trip
176
+ // them. Defaults match the old local-state initial values.
177
+ const slice = (props.uiViewState && props.uiViewState.byGene[geneId]
178
+ && props.uiViewState.byGene[geneId].sequences) || {};
179
+ const tab = slice.tab || 'dna';
180
+ const tid = slice.tid || gene.gene_structure.canonical_transcript;
181
+ const upstream = slice.upstream || 0;
182
+ const downstream = slice.downstream || 0;
183
+ const setTab = (k) => props.doSetSequencesState({geneId, patch: {tab: k}});
184
+ const setTid = (v) => props.doSetSequencesState({geneId, patch: {tid: v}});
185
+ const setUpstream = (v) => props.doSetSequencesState({geneId, patch: {upstream: +v}});
186
+ const setDownstream = (v) => props.doSetSequencesState({geneId, patch: {downstream: +v}});
177
187
  let geneSeq;
178
188
  let rnaSeq;
179
189
  let pepSeq;
180
- if (props.geneSequences && props.geneSequences[gene._id]) {
190
+ // The *_SEQUENCE_REQUESTED reducer inserts a `{}` placeholder before the
191
+ // fetch resolves, so a key-only existence check passes while the body is
192
+ // still empty — leading to `seq.substring` of undefined downstream. Gate
193
+ // on the actual payload field instead.
194
+ if (props.geneSequences && props.geneSequences[gene._id] && props.geneSequences[gene._id].seq) {
181
195
  geneSeq = props.geneSequences[gene._id];
182
196
  }
183
197
  else {
184
198
  props.doRequestGeneSequence(gene)
185
199
  return <pre>loading</pre>;
186
200
  }
187
- if (props.rnaSequences && props.rnaSequences[tid]) {
201
+ if (props.rnaSequences && props.rnaSequences[tid] && props.rnaSequences[tid].seq) {
188
202
  rnaSeq = props.rnaSequences[tid]
189
203
  }
190
204
  else {
@@ -197,7 +211,7 @@ const Detail = props => {
197
211
  let tl_id;
198
212
  if (transcript.translation) {
199
213
  tl_id = transcript.translation.id;
200
- if (props.pepSequences && props.pepSequences[tl_id]) {
214
+ if (props.pepSequences && props.pepSequences[tl_id] && props.pepSequences[tl_id].seq) {
201
215
  pepSeq = props.pepSequences[tl_id];
202
216
  }
203
217
  else {
@@ -329,9 +343,11 @@ export default connect(
329
343
  'selectGeneSequences',
330
344
  'selectRnaSequences',
331
345
  'selectPepSequences',
346
+ 'selectUiViewState',
332
347
  'doRequestGeneSequence',
333
348
  'doRequestRnaSequence',
334
349
  'doRequestPepSequence',
350
+ 'doSetSequencesState',
335
351
  Detail
336
352
  );
337
353
 
@@ -56,21 +56,40 @@ function compareGermplasm(a, b) {
56
56
  }
57
57
  function group_germplasm(gene, germplasmLUT, vep_obj) {
58
58
  let accessionTable = [];
59
+ let missingMetadata = 0;
59
60
  Object.entries(vep_obj).forEach(([key,accessions]) => {
60
61
  const parts = key.split("__");
61
62
  if (parts[0] === "VEP") {
62
63
  if (parts[1] !== "merged") {
63
- accessions.filter(ens_id => germplasmLUT.hasOwnProperty(ens_id)).forEach(ens_id => {
64
- const germplasm = germplasmLUT[ens_id][0];
65
- const pop = study_info[parts[3]][parts[4]];
66
- const conseq = parts[1].replaceAll("_"," ");
67
- const status = parts[2] === "het" ? "heterozygous" : "homozygous";
68
- const accInfo = {
64
+ const studyForSystem = study_info[parts[3]];
65
+ const pop = (studyForSystem && studyForSystem[parts[4]]) || {
66
+ label: `${parts[3]}/${parts[4]}`,
67
+ type: parts[4]
68
+ };
69
+ const conseq = parts[1].replaceAll("_"," ");
70
+ const status = parts[2] === "het" ? "heterozygous" : "homozygous";
71
+ accessions.forEach(ens_id => {
72
+ let germplasm;
73
+ if (germplasmLUT && germplasmLUT.hasOwnProperty(ens_id)) {
74
+ germplasm = germplasmLUT[ens_id][0];
75
+ } else {
76
+ // Fall back to a minimal record so the row is still rendered with
77
+ // whatever info we have from the VEP fields alone.
78
+ missingMetadata++;
79
+ germplasm = {
80
+ ens_id: ens_id,
81
+ pub_id: ens_id,
82
+ subpop: '?',
83
+ stock_center: null,
84
+ germplasm_dbid: null,
85
+ pop_id: null
86
+ };
87
+ }
88
+ accessionTable.push({
69
89
  key: [pop.label,conseq,status].join('%%%'),
70
90
  germplasm: germplasm,
71
91
  pop: pop
72
- };
73
- accessionTable.push(accInfo);
92
+ });
74
93
  });
75
94
  }
76
95
  }
@@ -106,6 +125,8 @@ function group_germplasm(gene, germplasmLUT, vep_obj) {
106
125
  })
107
126
  })
108
127
  })
128
+ grouped.missingMetadata = missingMetadata;
129
+ grouped.totalAccessions = accessionTable.length;
109
130
  return grouped;
110
131
  }
111
132
  const THRESHOLD = 5;
@@ -259,20 +280,45 @@ const GridWithGroups = ({groups,gene_id,doGrin}) => {
259
280
 
260
281
  const Detail = props => {
261
282
  const gene = props.geneDocs[props.searchResult.id];
262
- if (props.grameneConsequences && props.grameneConsequences[gene._id] && props.grameneGermplasm) {
263
- const groups = group_germplasm(gene, props.grameneGermplasm, props.grameneConsequences[gene._id]);
283
+ const haveConsequences = props.grameneConsequences && props.grameneConsequences[gene._id];
284
+ useEffect(() => {
285
+ if (!haveConsequences) {
286
+ props.doRequestVEP(gene._id);
287
+ }
288
+ }, [gene._id, haveConsequences]);
264
289
 
265
- return <div>
266
- <h5>Predicted loss-of-function alleles were detected in these germplasm.</h5>
267
- <div >Explore other variants within this gene in the <a target="_blank"
268
- href={`${props.configuration.ensemblURL}/${gene.system_name}/Gene/Variation_Gene/Image?db=core;g=${props.searchResult.id}`}>
269
- Variant image</a> page in the Ensembl genome browser.</div>
270
- <GridWithGroups groups={...groups} gene_id={gene._id} doGrin={!props.configuration.hasOwnProperty('noGRIN')}/>
271
- </div>
272
- } else {
273
- props.doRequestVEP(gene._id);
290
+ if (!haveConsequences) {
274
291
  return <pre>loading</pre>;
275
292
  }
293
+
294
+ // Render even if grameneGermplasm hasn't loaded — we'll fall back to
295
+ // VEP-only rows so the user sees something instead of a silent empty table.
296
+ const germplasmLUT = props.grameneGermplasm || {};
297
+ const groups = group_germplasm(gene, germplasmLUT, props.grameneConsequences[gene._id]);
298
+ const { missingMetadata, totalAccessions } = groups;
299
+
300
+ let notice = null;
301
+ if (totalAccessions === 0) {
302
+ notice = <div className="alert alert-warning" style={{padding: '8px', marginTop: '8px'}}>
303
+ VEP results were found for this gene but could not be grouped into accession-level rows.
304
+ </div>;
305
+ } else if (missingMetadata > 0) {
306
+ notice = <div className="alert alert-info" style={{padding: '8px', marginTop: '8px'}}>
307
+ Germplasm metadata could not be found for {missingMetadata} of {totalAccessions} accession{totalAccessions === 1 ? '' : 's'}.
308
+ Affected rows show the raw accession id without stock-center links or subpopulation info.
309
+ </div>;
310
+ }
311
+
312
+ return <div>
313
+ <h5>Predicted loss-of-function alleles were detected in these germplasm.</h5>
314
+ <div>Explore other variants within this gene in the <a target="_blank"
315
+ href={`${props.configuration.ensemblURL}/${gene.system_name}/Gene/Variation_Gene/Image?db=core;g=${props.searchResult.id}`}>
316
+ Variant image</a> page in the Ensembl genome browser.</div>
317
+ {notice}
318
+ {totalAccessions > 0 && (
319
+ <GridWithGroups groups={groups} gene_id={gene._id} doGrin={!props.configuration.hasOwnProperty('noGRIN')}/>
320
+ )}
321
+ </div>
276
322
  };
277
323
 
278
324
  export default connect(