gramene-search 2.1.11 → 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 (35) hide show
  1. package/.claude/launch.json +11 -0
  2. package/.claude/settings.local.json +6 -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.js +2065 -154
  11. package/dist/index.js.map +1 -1
  12. package/package.json +5 -2
  13. package/src/bundles/api.js +10 -4
  14. package/src/bundles/exprViz.js +97 -1
  15. package/src/bundles/index.js +4 -1
  16. package/src/bundles/ontologyEnrichment.js +14 -1
  17. package/src/bundles/savedViews.js +335 -0
  18. package/src/bundles/uiViewState.js +174 -0
  19. package/src/bundles/viewSnapshot.js +313 -0
  20. package/src/bundles/views.js +22 -0
  21. package/src/components/Auth.js +23 -3
  22. package/src/components/SaveView.js +157 -0
  23. package/src/components/exprViz/ExprVizView.js +16 -11
  24. package/src/components/exprViz/ParallelCoordsPlot.js +15 -0
  25. package/src/components/results/GeneList.js +45 -15
  26. package/src/components/results/OntologyEnrichment.js +13 -6
  27. package/src/components/results/details/BAR.js +148 -0
  28. package/src/components/results/details/Expression.js +43 -11
  29. package/src/components/results/details/Homology.js +170 -32
  30. package/src/components/results/details/Pathways.js +4 -2
  31. package/src/components/results/details/Sequences.js +24 -8
  32. package/src/demo.js +22 -17
  33. package/src/index.js +2 -1
  34. package/src/suppressDevWarnings.js +13 -0
  35. 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]);
@@ -91,6 +115,7 @@ const Detail = props => {
91
115
  <Tab tabClassName="gxa" eventKey="paralogs" title={`Paralogs`} key="gxaparalogs">
92
116
  <Form.Select aria-label='experiment selector'
93
117
  placeholder='Select experiment'
118
+ value={atlasExperiment || ''}
94
119
  onChange={(e) => setAtlasExperiment(e.target.value)}>
95
120
  { atlasExperimentList.map((e,idx) =>
96
121
  <option key={idx} value={e._id}>{e.type}: {e.description || e._id}</option>
@@ -110,7 +135,11 @@ const Detail = props => {
110
135
  {activeTab === "gene" && <DynamicIframe url={gene_url}/> }
111
136
  </Tab>
112
137
  {haveBAR(gene) &&
113
- <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>
114
143
  }
115
144
  </Tabs>
116
145
  };
@@ -118,7 +147,10 @@ const Detail = props => {
118
147
  export default connect(
119
148
  'selectGrameneParalogs',
120
149
  'selectExpressionStudies',
150
+ 'selectUiViewState',
121
151
  'doRequestParalogs',
152
+ 'doFetchExpressionStudies',
153
+ 'doSetExpressionState',
122
154
  //'doRequestParalogExpression',
123
155
  Detail
124
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,
@@ -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,7 +470,7 @@ 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
  }
@@ -349,7 +483,7 @@ class Homology extends React.Component {
349
483
  </Description>
350
484
  {this.tree && <Content key="content">
351
485
  {this.renderViewerToggle()}
352
- {this.state.viewer === 'tbrowse' ? this.renderTBrowse() : this.renderTreeVis()}
486
+ {this.getViewer() === 'tbrowse' ? this.renderTBrowse() : this.renderTreeVis()}
353
487
  </Content>}
354
488
  {this.tree && <Explore key="explore" explorations={this.explorations()}/>}
355
489
  <Links key="links" links={this.links()}/>
@@ -365,7 +499,11 @@ export default connect(
365
499
  'selectGrameneAPI',
366
500
  'selectConfiguration',
367
501
  'selectCuration',
502
+ 'selectUiViewState',
368
503
  'doRequestGrameneTree',
504
+ 'doSetHomologyViewer',
505
+ 'doSetHomologyHeight',
506
+ 'doSetHomologyTbrowseViewState',
369
507
  'doAcceptGrameneSuggestion',
370
508
  'doReplaceGrameneFilters',
371
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
 
package/src/demo.js CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  } from "react-router-dom";
22
22
  import Feedback from './components/Feedback';
23
23
  import MDView from 'gramene-mdview';
24
+ import bootViewFromUrl from './utils/bootView';
24
25
 
25
26
  // const subsite = 'main';
26
27
  const subsite = process.env.SUBSITE;
@@ -56,14 +57,6 @@ const panSites = [
56
57
  targetTaxonId: 3702,
57
58
  alertText: 'Main site',
58
59
  showViews: true,
59
- firebaseConfig: {
60
- apiKey: "AIzaSyCyTJmxfWgfuhI6-8uqocSiE9KOWUlkgkk",
61
- authDomain: "gramene-auth.firebaseapp.com",
62
- projectId: "gramene-auth",
63
- storageBucket: "gramene-auth.appspot.com",
64
- messagingSenderId: "590873346270",
65
- appId: "1:590873346270:web:f76a31a93619e69439824f"
66
- },
67
60
  details: {
68
61
  sequences: true,
69
62
  VEP: false,
@@ -153,20 +146,12 @@ const panSites = [
153
146
  ensemblURL: 'https://ensembl.sorghumbase.org',
154
147
  ensemblSite: 'https://ensembl.sorghumbase.org',
155
148
  ensemblRest: 'https://data.gramene.org/pansite-ensembl-108',
156
- grameneData: 'https://data.sorghumbase.org/auth_testing',
149
+ grameneData: 'https://data.sorghumbase.org/sorghum_v10',
157
150
  ga: 'G-L5KXDCCZ16',
158
151
  targetTaxonId: 4558001,
159
152
  alertText: 'Click the search icon in the menu bar or type /',
160
153
  showViews: true,
161
154
  partialCompara: true,
162
- firebaseConfig: {
163
- apiKey: "AIzaSyCyTJmxfWgfuhI6-8uqocSiE9KOWUlkgkk",
164
- authDomain: "gramene-auth.firebaseapp.com",
165
- projectId: "gramene-auth",
166
- storageBucket: "gramene-auth.appspot.com",
167
- messagingSenderId: "590873346270",
168
- appId: "1:590873346270:web:f76a31a93619e69439824f"
169
- },
170
155
  details: {
171
156
  sequences: true,
172
157
  VEP: true,
@@ -285,6 +270,23 @@ const panSites = [
285
270
  }
286
271
  }
287
272
  ];
273
+
274
+ // Firebase web config is loaded from FIREBASE_CONFIG_JSON in the build-time
275
+ // env (.env, never committed). If unset, the Auth panel mounts but stays
276
+ // inert. Attached to the sites that use gramene-auth.
277
+ const firebaseConfig = (() => {
278
+ const raw = process.env.FIREBASE_CONFIG_JSON;
279
+ if (!raw) return null;
280
+ try { return JSON.parse(raw); }
281
+ catch (e) { console.warn('FIREBASE_CONFIG_JSON is not valid JSON; Auth disabled.', e); return null; }
282
+ })();
283
+ if (firebaseConfig) {
284
+ ['main', 'sorghum'].forEach(id => {
285
+ const site = panSites[subsitelut[id]];
286
+ if (site) site.firebaseConfig = firebaseConfig;
287
+ });
288
+ }
289
+
288
290
  const initialState = Object.assign({helpIsOn:false}, panSites[subsitelut[subsite]]);
289
291
 
290
292
  const config = {
@@ -546,6 +548,9 @@ cache.getAll().then(initialData => {
546
548
  window.store = store;
547
549
  const config = store.selectConfiguration();
548
550
  ReactGA.initialize(config.ga);
551
+ // If the URL has ?view=<hash>, hydrate the snapshot before the user
552
+ // interacts. Errors are non-fatal (see utils/bootView.js).
553
+ bootViewFromUrl(store);
549
554
  if (initialData.hasOwnProperty('grameneMaps')) {
550
555
  // check for hidden genomes
551
556
  let notHidden = {};
package/src/index.js CHANGED
@@ -3,5 +3,6 @@ import resultList from './components/resultList';
3
3
  import resultSummary from './components/resultSummary';
4
4
  import suggestions from './components/suggestions';
5
5
  import {Status, Filters, Results, Views, Auth} from './components/geneSearchUI';
6
+ import bootViewFromUrl from './utils/bootView';
6
7
 
7
- export { bundles, resultList, resultSummary, suggestions, Status, Filters, Results, Views, Auth };
8
+ export { bundles, resultList, resultSummary, suggestions, Status, Filters, Results, Views, Auth, bootViewFromUrl };
@@ -12,6 +12,19 @@ if (typeof console !== 'undefined' && typeof console.error === 'function') {
12
12
  'uses the legacy childContextTypes API',
13
13
  'uses the legacy contextTypes API',
14
14
  'findDOMNode is deprecated',
15
+ // Third-party libs (gramene-search-vis, gramene-mdview, Pathways, etc.)
16
+ // still use the pre-16.3 lifecycle methods. We can't fix them from here.
17
+ 'componentWillReceiveProps has been renamed',
18
+ 'componentWillMount has been renamed',
19
+ 'componentWillUpdate has been renamed',
20
+ // Kept as a guard — react warns on any kebab-case inline-style property
21
+ // and Parcel's overlay turns that into a fatal runtime error. The known
22
+ // offender (BAR's max-width) was fixed when the component was absorbed
23
+ // from gramene-efp-browser, but the suppressor protects against any
24
+ // future regression in third-party libs.
25
+ 'Unsupported style property',
26
+ // Stale-state setState after unmount in legacy class components.
27
+ "Can't perform a React state update on an unmounted component",
15
28
  ];
16
29
  console.error = function (...args) {
17
30
  const msg = typeof args[0] === 'string' ? args[0] : '';
@@ -0,0 +1,38 @@
1
+ // Boot-time hydration of a shared view link.
2
+ //
3
+ // Entry points call `bootViewFromUrl(store)` once the store is created.
4
+ // Public views resolve anonymously. For private views, the initial call
5
+ // will 401; the Auth panel then re-invokes with `{user}` once Firebase has
6
+ // emitted its first signed-in state, and the fetch retries with a Bearer
7
+ // token. On success we apply the snapshot and strip `?view=` from the URL
8
+ // so subsequent user actions don't sit under a stale shared-state URL.
9
+ //
10
+ // All errors are non-fatal — they surface in state.savedViews.fetchError
11
+ // for any UI that wants to show them.
12
+
13
+ const PARAM = 'view';
14
+
15
+ export default function bootViewFromUrl(store, opts = {}) {
16
+ if (typeof window === 'undefined') return Promise.resolve(null);
17
+ const url = new URL(window.location.href);
18
+ const hash = url.searchParams.get(PARAM);
19
+ if (!hash) return Promise.resolve(null);
20
+
21
+ const { user = null } = opts;
22
+
23
+ return store.doFetchView({ hash, user })
24
+ .then(({ snapshot }) => {
25
+ store.doApplyViewSnapshot(snapshot);
26
+ url.searchParams.delete(PARAM);
27
+ window.history.replaceState({}, '', url.toString());
28
+ return { hash, applied: true };
29
+ })
30
+ .catch((err) => {
31
+ // 401 here on the anonymous pass is expected for private views — the
32
+ // Auth panel will retry once it has a user. Other errors (404, 5xx,
33
+ // network) are reported and leave the param so a manual refresh can
34
+ // retry too.
35
+ console.warn('bootViewFromUrl:', err.message || err);
36
+ return { hash, applied: false, error: err.message || String(err) };
37
+ });
38
+ }