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
@@ -0,0 +1,157 @@
1
+ // "Save this view" UI — sits inside the Auth sidebar panel.
2
+ //
3
+ // Sign-in-gated by Auth.js (we only mount this when `user` is truthy). Opens
4
+ // a modal with label + description + visibility toggle; on save, swaps to a
5
+ // post-save state that shows the shareable URL with a copy button.
6
+ //
7
+ // Talks to the savedViews bundle. The snapshot itself is built inside
8
+ // doSaveView via selectViewSnapshot — this component doesn't know or care
9
+ // what the snapshot looks like.
10
+
11
+ import React, { useState, useEffect, useCallback } from 'react';
12
+ import { Button, Modal, Form, InputGroup, Alert, Spinner } from 'react-bootstrap';
13
+ import { connect } from 'redux-bundler-react';
14
+ import { BsClipboard, BsCheck2 } from 'react-icons/bs';
15
+
16
+ const SaveViewButtonCmp = ({ user, savedViews, doSaveView, doResetSavedViewState }) => {
17
+ const [showModal, setShowModal] = useState(false);
18
+ const [label, setLabel] = useState('');
19
+ const [description, setDescription] = useState('');
20
+ const [isPublic, setIsPublic] = useState(false);
21
+ const [shareUrl, setShareUrl] = useState(null);
22
+ const [copied, setCopied] = useState(false);
23
+
24
+ const reset = useCallback(() => {
25
+ setLabel('');
26
+ setDescription('');
27
+ setIsPublic(false);
28
+ setShareUrl(null);
29
+ setCopied(false);
30
+ doResetSavedViewState();
31
+ }, [doResetSavedViewState]);
32
+
33
+ const handleOpen = () => { reset(); setShowModal(true); };
34
+ const handleClose = () => { setShowModal(false); reset(); };
35
+
36
+ const handleSave = async (e) => {
37
+ e.preventDefault();
38
+ if (!label.trim()) return;
39
+ try {
40
+ const { shareUrl } = await doSaveView({
41
+ user,
42
+ label: label.trim(),
43
+ description: description.trim(),
44
+ isPublic
45
+ });
46
+ setShareUrl(shareUrl);
47
+ } catch (_) {
48
+ // savedViews.saveError is already set; modal renders it
49
+ }
50
+ };
51
+
52
+ const handleCopy = async () => {
53
+ if (!shareUrl) return;
54
+ try {
55
+ await navigator.clipboard.writeText(shareUrl);
56
+ setCopied(true);
57
+ setTimeout(() => setCopied(false), 1500);
58
+ } catch (_) { /* fallback: user can select-all */ }
59
+ };
60
+
61
+ const saving = savedViews && savedViews.saving;
62
+ const saveError = savedViews && savedViews.saveError;
63
+
64
+ return (
65
+ <>
66
+ <Button
67
+ size="sm"
68
+ variant="outline-primary"
69
+ style={{ marginTop: 8 }}
70
+ onClick={handleOpen}
71
+ title="Save the current filters, views, and detail tabs as a shareable link"
72
+ >
73
+ Save this view
74
+ </Button>
75
+
76
+ <Modal show={showModal} onHide={handleClose} centered>
77
+ <Modal.Header closeButton>
78
+ <Modal.Title>{shareUrl ? 'View saved' : 'Save this view'}</Modal.Title>
79
+ </Modal.Header>
80
+ <Modal.Body>
81
+ {!shareUrl && (
82
+ <Form onSubmit={handleSave}>
83
+ <Form.Group className="mb-3">
84
+ <Form.Label>Name</Form.Label>
85
+ <Form.Control
86
+ autoFocus
87
+ type="text"
88
+ placeholder="e.g. TAIR loci with TF binding sites"
89
+ value={label}
90
+ onChange={(e) => setLabel(e.target.value)}
91
+ disabled={saving}
92
+ required
93
+ />
94
+ </Form.Group>
95
+ <Form.Group className="mb-3">
96
+ <Form.Label>Description (optional)</Form.Label>
97
+ <Form.Control
98
+ as="textarea"
99
+ rows={2}
100
+ value={description}
101
+ onChange={(e) => setDescription(e.target.value)}
102
+ disabled={saving}
103
+ />
104
+ </Form.Group>
105
+ <Form.Group className="mb-3">
106
+ <Form.Check
107
+ type="switch"
108
+ id="save-view-public"
109
+ label="Make this view public (visible to anyone with the link)"
110
+ checked={isPublic}
111
+ onChange={(e) => setIsPublic(e.target.checked)}
112
+ disabled={saving}
113
+ />
114
+ </Form.Group>
115
+ {saveError && <Alert variant="danger">{saveError}</Alert>}
116
+ <div className="d-flex justify-content-end gap-2">
117
+ <Button variant="secondary" onClick={handleClose} disabled={saving}>
118
+ Cancel
119
+ </Button>
120
+ <Button type="submit" variant="primary" disabled={saving || !label.trim()}>
121
+ {saving ? <><Spinner as="span" size="sm" animation="border" /> Saving…</> : 'Save'}
122
+ </Button>
123
+ </div>
124
+ </Form>
125
+ )}
126
+
127
+ {shareUrl && (
128
+ <>
129
+ <p>Share this link to let others open the same search and detail state:</p>
130
+ <InputGroup>
131
+ <Form.Control
132
+ type="text"
133
+ value={shareUrl}
134
+ readOnly
135
+ onFocus={(e) => e.target.select()}
136
+ />
137
+ <Button variant={copied ? 'success' : 'outline-secondary'} onClick={handleCopy}>
138
+ {copied ? <><BsCheck2 /> Copied</> : <><BsClipboard /> Copy</>}
139
+ </Button>
140
+ </InputGroup>
141
+ <div className="mt-3 d-flex justify-content-end">
142
+ <Button variant="primary" onClick={handleClose}>Done</Button>
143
+ </div>
144
+ </>
145
+ )}
146
+ </Modal.Body>
147
+ </Modal>
148
+ </>
149
+ );
150
+ };
151
+
152
+ export default connect(
153
+ 'selectSavedViews',
154
+ 'doSaveView',
155
+ 'doResetSavedViewState',
156
+ SaveViewButtonCmp
157
+ );
@@ -92,6 +92,9 @@ const ExprVizViewCmp = props => {
92
92
  onLoad={() => doFetchExprVizData(tid)}
93
93
  onReorder={(next) => props.doReorderExprVizFields(tid, next)}
94
94
  onAddRangeQuery={props.doAddGrameneRangeQuery}
95
+ onSetVizMode={(m) => props.doSetExprVizVizMode(tid, m)}
96
+ onSetScale={(s) => props.doSetExprVizScale(tid, s)}
97
+ onSetBrushes={(b) => props.doSetExprVizBrushes(tid, b)}
95
98
  />
96
99
  </Tab>
97
100
  );
@@ -248,13 +251,18 @@ function downloadTsv(filename, rows, fields, studies, expressionSamples) {
248
251
  URL.revokeObjectURL(url);
249
252
  }
250
253
 
251
- const TaxonPanel = ({ taxon, studies, expressionSamples, tabState, onOpenFields, onLoad, onReorder, onAddRangeQuery }) => {
254
+ const TaxonPanel = ({ taxon, studies, expressionSamples, tabState, onOpenFields, onLoad, onReorder, onAddRangeQuery, onSetVizMode, onSetScale, onSetBrushes }) => {
252
255
  const selected = (tabState && tabState.selectedFields) || [];
253
256
  const rows = (tabState && tabState.rows) || [];
254
257
  const fetchInfo = (tabState && tabState.fetch) || { status: 'idle', total: 0 };
255
- const [scale, setScale] = useState('linear');
256
- const [vizMode, setVizMode] = useState('heatmap'); // 'parallel' | 'heatmap'
257
- const [selections, setSelections] = useState({});
258
+ // View config (sub-tab, scale, brushes) is persisted in the exprViz bundle
259
+ // per taxon so a saved view can round-trip it. Driven controlled from here.
260
+ const scale = (tabState && tabState.scale) || 'linear';
261
+ const vizMode = (tabState && tabState.vizMode) || 'heatmap'; // 'parallel' | 'heatmap'
262
+ const selections = (tabState && tabState.brushes) || {};
263
+ const setScale = (s) => onSetScale && onSetScale(s);
264
+ const setVizMode = (m) => onSetVizMode && onSetVizMode(m);
265
+ const setSelections = (b) => onSetBrushes && onSetBrushes(b);
258
266
  const [clearVersion, setClearVersion] = useState(0);
259
267
  const [hoveredId, setHoveredId] = useState(null);
260
268
  const [plotHeight, setPlotHeight] = useState(320);
@@ -324,13 +332,6 @@ const TaxonPanel = ({ taxon, studies, expressionSamples, tabState, onOpenFields,
324
332
  [visibleFields, studies, expressionSamples]
325
333
  );
326
334
 
327
- useEffect(() => {
328
- if (rows.length === 0 && hasBrush) {
329
- setSelections({});
330
- setClearVersion(v => v + 1);
331
- }
332
- }, [rows.length, hasBrush]);
333
-
334
335
  return (
335
336
  <div className="exprviz-tab-panel">
336
337
  <div className="exprviz-toolbar">
@@ -429,6 +430,7 @@ const TaxonPanel = ({ taxon, studies, expressionSamples, tabState, onOpenFields,
429
430
  rows={rows}
430
431
  fields={visibleFields}
431
432
  scale={scale}
433
+ initialSelections={selections}
432
434
  onBrushChange={setSelections}
433
435
  onReorder={handleReorder}
434
436
  clearVersion={clearVersion}
@@ -472,6 +474,9 @@ export default connect(
472
474
  'doToggleExprVizFieldsModal',
473
475
  'doFetchExprVizData',
474
476
  'doReorderExprVizFields',
477
+ 'doSetExprVizVizMode',
478
+ 'doSetExprVizScale',
479
+ 'doSetExprVizBrushes',
475
480
  'doAddGrameneRangeQuery',
476
481
  ExprVizViewCmp
477
482
  );
@@ -59,6 +59,7 @@ const ParallelCoordsPlot = ({
59
59
  rows,
60
60
  fields,
61
61
  scale = 'linear',
62
+ initialSelections = null,
62
63
  onBrushChange,
63
64
  onReorder,
64
65
  clearVersion = 0,
@@ -70,6 +71,13 @@ const ParallelCoordsPlot = ({
70
71
  // selections in data domain: { [field]: [lo, hi] }
71
72
  const selectionsRef = useRef({});
72
73
  const lastClearRef = useRef(0);
74
+ // Seed brushes from a restored saved view exactly once (the first render
75
+ // where `initialSelections` is non-empty). Read via a ref so a change to the
76
+ // prop — which happens on every brush as it round-trips through the store —
77
+ // doesn't force an SVG rebuild; the deps below already rerun on data changes.
78
+ const initialSelectionsRef = useRef(initialSelections);
79
+ initialSelectionsRef.current = initialSelections;
80
+ const seededRef = useRef(false);
73
81
  // Track container size so the d3 render reruns when the user drags the
74
82
  // pane resizer (or when the window is resized). The values themselves
75
83
  // aren't read inside the effect — the effect always reads clientWidth/
@@ -96,6 +104,13 @@ const ParallelCoordsPlot = ({
96
104
  }, []);
97
105
 
98
106
  useEffect(() => {
107
+ if (!seededRef.current) {
108
+ const init = initialSelectionsRef.current;
109
+ if (init && Object.keys(init).length) {
110
+ selectionsRef.current = { ...init };
111
+ seededRef.current = true;
112
+ }
113
+ }
99
114
  if (clearVersion !== lastClearRef.current) {
100
115
  selectionsRef.current = {};
101
116
  lastClearRef.current = clearVersion;
@@ -190,10 +190,10 @@ const allDetails = [
190
190
  class Gene extends React.Component {
191
191
  constructor(props) {
192
192
  super(props);
193
+ // `details` is derived per-render config (which tabs the site enables,
194
+ // crossed with this row's `capabilities`) — keep it local.
193
195
  this.state = {
194
196
  details: allDetails.map(o => ({...o})).filter(d => props.config.details[d.id]),
195
- expandedDetail: props.expandedDetail,
196
- fullscreen: false,
197
197
  };
198
198
  let hasData = {};
199
199
  props.searchResult.capabilities.forEach(c => {
@@ -204,18 +204,38 @@ class Gene extends React.Component {
204
204
  });
205
205
  this.state.details.forEach(d => d.available |= hasData.hasOwnProperty(d.id));
206
206
  }
207
+ componentDidMount() {
208
+ // Seed the auto-open-homology intent (from GeneList when numFound===1) the
209
+ // first time this row mounts, only if the user hasn't already opened/closed
210
+ // a tab here. Without this, the bundle would have no entry and the prior
211
+ // "auto-expand homology on single-result" behavior would be lost.
212
+ if (this.props.expandedDetail && this.expandedDetail === undefined) {
213
+ this.props.doExpandGeneDetail({
214
+ geneId: this.props.searchResult.id,
215
+ detail: this.props.expandedDetail
216
+ });
217
+ }
218
+ }
219
+ get expandedDetail() {
220
+ const slice = this.props.uiViewState && this.props.uiViewState.byGene[this.props.searchResult.id];
221
+ return slice ? slice.expandedDetail : undefined;
222
+ }
223
+ get fullscreen() {
224
+ const slice = this.props.uiViewState && this.props.uiViewState.byGene[this.props.searchResult.id];
225
+ return !!(slice && slice.fullscreen);
226
+ }
207
227
  getDetailStatus(d) {
208
- if (this.state.expandedDetail === d.id) return 'expanded';
228
+ if (this.expandedDetail === d.id) return 'expanded';
209
229
  if (d.available) return 'closed'
210
230
  return d.id === "pubs" ? 'empty' : 'disabled';
211
231
  }
212
232
  setExpanded(d) {
213
233
  if (d.available || d.id === "pubs") {
214
- if (this.state.expandedDetail === d.id) {
215
- this.setState({expandedDetail: null, fullscreen: false})
234
+ const geneId = this.props.searchResult.id;
235
+ if (this.expandedDetail === d.id) {
236
+ this.props.doExpandGeneDetail({geneId, detail: null});
216
237
  }
217
238
  else {
218
- const geneId = this.props.searchResult.id;
219
239
  if (!(this.props.geneDocs && this.props.geneDocs.hasOwnProperty(geneId))) {
220
240
  this.props.requestGene(geneId)
221
241
  }
@@ -224,7 +244,7 @@ class Gene extends React.Component {
224
244
  action: 'Details',
225
245
  label: d.label
226
246
  });
227
- this.setState({expandedDetail: d.id, fullscreen: false})
247
+ this.props.doExpandGeneDetail({geneId, detail: d.id});
228
248
  }
229
249
  }
230
250
  }
@@ -272,7 +292,7 @@ class Gene extends React.Component {
272
292
  </div>
273
293
  <div className="gene-detail-tabs">
274
294
  {this.state.details.map((d,idx) => {
275
- const isExpanded = this.state.expandedDetail === d.id;
295
+ const isExpanded = this.expandedDetail === d.id;
276
296
  return (
277
297
  <OverlayTrigger
278
298
  key={idx}
@@ -299,7 +319,7 @@ class Gene extends React.Component {
299
319
  title="View full screen"
300
320
  onClick={(e) => {
301
321
  e.stopPropagation();
302
- this.setState({fullscreen: true});
322
+ this.props.doSetGeneFullscreen({geneId: searchResult.id, fullscreen: true});
303
323
  }}
304
324
  />
305
325
  )}
@@ -308,14 +328,14 @@ class Gene extends React.Component {
308
328
  );
309
329
  })}
310
330
  </div>
311
- {this.state.expandedDetail && this.ensureGene(searchResult.id) && (
331
+ {this.expandedDetail && this.ensureGene(searchResult.id) && (
312
332
  <FullscreenContainer
313
333
  className="visible-detail"
314
- fullscreen={this.state.fullscreen}
315
- onExitFullscreen={() => this.setState({fullscreen: false})}
316
- title={(this.state.details.find(d => d.id === this.state.expandedDetail) || {}).label}
334
+ fullscreen={this.fullscreen}
335
+ onExitFullscreen={() => this.props.doSetGeneFullscreen({geneId: searchResult.id, fullscreen: false})}
336
+ title={(this.state.details.find(d => d.id === this.expandedDetail) || {}).label}
317
337
  >
318
- {React.createElement(inventory[this.state.expandedDetail], this.props)}
338
+ {React.createElement(inventory[this.expandedDetail], this.props)}
319
339
  </FullscreenContainer>
320
340
  )}
321
341
  </div>
@@ -323,6 +343,16 @@ class Gene extends React.Component {
323
343
  }
324
344
  }
325
345
 
346
+ // Wrap Gene so each row gets uiViewState + dispatchers. (We don't pull these
347
+ // in at the GeneList level because the slice we care about is per-geneId; the
348
+ // component-side getters read by row id.)
349
+ const ConnectedGene = connect(
350
+ 'selectUiViewState',
351
+ 'doExpandGeneDetail',
352
+ 'doSetGeneFullscreen',
353
+ Gene
354
+ );
355
+
326
356
  const GeneList = props => {
327
357
  if (props.grameneSearch && props.grameneSearch.response && props.grameneTaxonomy) {
328
358
  let prev,page,next;
@@ -342,7 +372,7 @@ const GeneList = props => {
342
372
  return <div>
343
373
  <div>{prev}{page}{next}</div>
344
374
  {props.grameneSearch.response.docs.map((g,idx) => (
345
- <Gene key={idx}
375
+ <ConnectedGene key={idx}
346
376
  searchResult={g}
347
377
  config={props.configuration}
348
378
  taxName={props.grameneTaxonomy[g.taxon_id].name}
@@ -399,7 +399,7 @@ const SortableTh = ({ label, sortKey, activeKey, activeDir, onSort, numeric, hel
399
399
  );
400
400
  };
401
401
 
402
- const OntologySection = ({ block, search, onAddFilter }) => {
402
+ const OntologySection = ({ block, search, sort, onSortChange, onAddFilter }) => {
403
403
  const filtered = useMemo(() => {
404
404
  if (!search) return block.rows;
405
405
  const needle = search.toLowerCase();
@@ -410,15 +410,16 @@ const OntologySection = ({ block, search, onAddFilter }) => {
410
410
  }, [block.rows, search]);
411
411
 
412
412
  const showType = ONTS_WITH_TYPE_COLUMN.has(block.ontology);
413
- const [sortKey, setSortKey] = useState('pAdj');
414
- const [sortDir, setSortDir] = useState('asc');
413
+ // Sort state is persisted per section in the bundle ui so a saved view
414
+ // restores it. Defaults match the historical local-state initials.
415
+ const sortKey = (sort && sort.key) || 'pAdj';
416
+ const sortDir = (sort && sort.dir) || 'asc';
415
417
 
416
418
  const handleSort = (key) => {
417
419
  if (key === sortKey) {
418
- setSortDir(d => (d === 'asc' ? 'desc' : 'asc'));
420
+ onSortChange(block.ontology, sortKey, sortDir === 'asc' ? 'desc' : 'asc');
419
421
  } else {
420
- setSortKey(key);
421
- setSortDir(SORT_DEFAULT_DIR[key] || 'asc');
422
+ onSortChange(block.ontology, key, SORT_DEFAULT_DIR[key] || 'asc');
422
423
  }
423
424
  };
424
425
 
@@ -512,6 +513,10 @@ const TaxonPanel = ({ taxon, ontologyEnrichment, results, ui, onUiChange, onAddF
512
513
  // Hide ontologies that aren't used in this species at all.
513
514
  const blocks = allBlocks.filter(b => b.tested > 0);
514
515
 
516
+ const handleSortChange = (sectionKey, key, dir) => {
517
+ onUiChange({ sort: { ...(ui.sort || {}), [sectionKey]: { key, dir } } });
518
+ };
519
+
515
520
  return (
516
521
  <div className="oe-panel">
517
522
  <div className="oe-summary">
@@ -525,6 +530,8 @@ const TaxonPanel = ({ taxon, ontologyEnrichment, results, ui, onUiChange, onAddF
525
530
  key={b.ontology}
526
531
  block={b}
527
532
  search={ui.search}
533
+ sort={ui.sort && ui.sort[b.ontology]}
534
+ onSortChange={handleSortChange}
528
535
  onAddFilter={onAddFilter}
529
536
  />
530
537
  ))}
@@ -0,0 +1,148 @@
1
+ // eFP Browser embed — was its own package (gramene-efp-browser@1.0.11),
2
+ // absorbed here on 2026-05-29 to drop the unmaintained nwb/webpack-4 build
3
+ // pipeline and remove a dependency that needed --openssl-legacy-provider.
4
+ // All upstream URLs and species-specific id formatters preserved as-is.
5
+
6
+ import React, { Component, useState, useEffect } from 'react';
7
+
8
+ const urls = {
9
+ image: (genome, study, gene) => `https://bar.utoronto.ca/api/efp_image/efp_${genome}/${study}/Absolute/${gene}`,
10
+ app: (genome, study, gene) => `https://bar.utoronto.ca/efp_${genome}/cgi-bin/efpWeb.cgi?dataSource=${study}&mode=Absolute&primaryGene=${gene}`,
11
+ studies: genome => `https://bar.utoronto.ca/api/efp_image/get_efp_data_source/${genome}`,
12
+ logo: 'https://bar.utoronto.ca/bbc_logo_small.gif',
13
+ spinner: 'https://www.sorghumbase.org/static/images/dna_spinner.svg'
14
+ };
15
+
16
+ const zmv4_re = /Zm00001d/;
17
+ const browsers = {
18
+ sorghum_bicolor: {
19
+ formatGene: gene => gene._id.replace('SORBI_3', 'Sobic.'),
20
+ genome: 'sorghum'
21
+ },
22
+ vitis_vinifera: {
23
+ formatGene: gene => gene._id,
24
+ genome: 'grape'
25
+ },
26
+ arabidopsis_thaliana: {
27
+ formatGene: gene => gene._id,
28
+ fixStudies: studies => {
29
+ studies = studies.filter(s => s.value !== 'Klepikova_Atlas');
30
+ studies.unshift({ value: 'Klepikova_Atlas', label: 'Klepikova Atlas' });
31
+ return studies;
32
+ },
33
+ genome: 'arabidopsis'
34
+ },
35
+ zea_mays: {
36
+ formatGene: gene => {
37
+ let id = gene._id;
38
+ if (gene.synonyms) {
39
+ gene.synonyms.forEach(syn => {
40
+ if (zmv4_re.test(syn)) { id = syn; }
41
+ });
42
+ }
43
+ return id;
44
+ },
45
+ fixStudies: studies => {
46
+ studies = studies.filter(s => s.value !== 'Hoopes_et_al_Atlas' && s.value !== 'Hoopes_et_al_Stress');
47
+ studies.unshift({ value: 'Hoopes_et_al_Stress', label: 'Hoopes et. al., Stress' });
48
+ studies.unshift({ value: 'Hoopes_et_al_Atlas', label: 'Hoopes et. al., Atlas' });
49
+ return studies;
50
+ },
51
+ genome: 'maize'
52
+ },
53
+ glycine_max: {
54
+ formatGene: gene => gene._id.replace('GLYMA_', 'Glyma.'),
55
+ fixStudies: studies => studies.filter(s => s.value !== 'soybean_senescence'),
56
+ genome: 'soybean'
57
+ },
58
+ oryza_sativa: {
59
+ genome: 'rice',
60
+ formatGene: gene => gene.MSU_id, // TODO: lookup IRGSP → LOC_ when MSU_id absent
61
+ fixStudies: studies => {
62
+ studies = studies.filter(s => s.value !== 'rice_rma' && s.value !== 'rice_mas');
63
+ studies.unshift({ value: 'rice_rma', label: 'rice rma' });
64
+ studies.unshift({ value: 'rice_mas', label: 'rice mas' });
65
+ return studies;
66
+ }
67
+ }
68
+ };
69
+ // Subsites that refer to maize b73 as `zea_maysb73`.
70
+ browsers.zea_maysb73 = browsers.zea_mays;
71
+
72
+ const ImageLoader = ({ url }) => {
73
+ const [loading, setLoading] = useState(true);
74
+ const [error, setError] = useState(false);
75
+
76
+ useEffect(() => {
77
+ setLoading(true);
78
+ setError(false);
79
+
80
+ const image = new Image();
81
+ image.src = url;
82
+ image.onload = () => setLoading(false);
83
+ image.onerror = () => { setLoading(false); setError(true); };
84
+ }, [url]);
85
+
86
+ return (
87
+ <div style={{ padding: 20 }}>
88
+ {loading && <img src={urls.spinner} alt="Loading…" />}
89
+ {!loading && !error && <img style={{ maxWidth: '100%' }} src={url} alt="eFP browser output" />}
90
+ {error && <p>Error: Failed to load image</p>}
91
+ </div>
92
+ );
93
+ };
94
+
95
+ export default class BAR extends Component {
96
+ constructor(props) {
97
+ super(props);
98
+ this.state = { currentStudy: null };
99
+ }
100
+ getStudies(browser) {
101
+ fetch(urls.studies(browser.genome))
102
+ .then(res => res.json())
103
+ .then(res => {
104
+ if (res.wasSuccessful) {
105
+ let studies = res.data.sort().map(v => ({ value: v, label: v.replace(/_/g, ' ') }));
106
+ if (browser.fixStudies) studies = browser.fixStudies(studies);
107
+ this.setState({ studies });
108
+ }
109
+ })
110
+ .catch(console.error);
111
+ }
112
+ render() {
113
+ const gene = this.props.gene;
114
+ const browser = browsers[gene.system_name];
115
+ if (!browser) {
116
+ return <div><h2>Can't find eFP browser for {gene.system_name}</h2></div>;
117
+ }
118
+ if (!this.state.studies) {
119
+ this.getStudies(browser);
120
+ return <img src={urls.spinner} alt="Loading…" />;
121
+ }
122
+ const efp_gene = browser.formatGene(gene);
123
+ // `study`/`onStudyChange` let a host drive the selection (so a saved view
124
+ // can restore it); fall back to local state when uncontrolled.
125
+ const study = this.props.study || this.state.currentStudy || this.state.studies[0].value;
126
+ const onSelect = e => {
127
+ if (this.props.onStudyChange) this.props.onStudyChange(e.target.value);
128
+ else this.setState({ currentStudy: e.target.value });
129
+ };
130
+ return (
131
+ <div style={{ paddingTop: 10 }}>
132
+ <label style={{ paddingLeft: 20, paddingRight: 10 }}>Select a study:</label>
133
+ <select value={study} onChange={onSelect}>
134
+ {this.state.studies.map((s, idx) => <option key={idx} value={s.value}>{s.label}</option>)}
135
+ </select>
136
+ <br />
137
+ <ImageLoader url={urls.image(browser.genome, study, efp_gene)} />
138
+ <a style={{ paddingLeft: 100 }} href={urls.app(browser.genome, study, efp_gene)} target="_blank" rel="noreferrer">
139
+ Powered by <img src={urls.logo} style={{ maxWidth: 40 }} alt="BBC" /> Webservices
140
+ </a>
141
+ </div>
142
+ );
143
+ }
144
+ }
145
+
146
+ export function haveBAR(gene) {
147
+ return browsers.hasOwnProperty(gene.system_name);
148
+ }