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
@@ -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;
@@ -60,36 +60,6 @@ const PanLink = (props) => {
60
60
  </div>;
61
61
  };
62
62
 
63
- const CurationComponent1 = ({ curation }) => {
64
- return (
65
- <div className="gene-curation">
66
- <span>Curated gene structure</span>
67
- {curation.okay > 0 && <span className="status">✔️ {curation.okay}</span>}
68
- {curation.flagged > -10 && <span className="status">⚠️ {curation.flagged}</span>}
69
- </div>
70
- );
71
- };
72
- const CurationComponent = ({ curation, gene }) => {
73
- const total = curation.okay + curation.flagged;
74
- const okayRatio = (curation.okay / total);
75
- const flaggedRatio = (curation.flagged / total);
76
- const tooltip = (
77
- <Tooltip id="tooltip">
78
- <strong>Gene structure</strong><br/>
79
- Okay: {curation.okay} &nbsp;
80
- Flagged: {curation.flagged}
81
- </Tooltip>
82
- );
83
- return (
84
- <OverlayTrigger placement="left" overlay={tooltip}>
85
- <a className="gene-curation" target="_blank" href={`https://devdata.gramene.org/curation/curations?idList=${gene.id}`}>
86
- <span className="okay" style={{opacity: okayRatio}}>☑</span>
87
- <span className="flagged" style={{opacity: flaggedRatio}}>⚠</span>
88
- </a>
89
- </OverlayTrigger>
90
- )
91
- };
92
-
93
63
  const ClosestOrthologCmp = (props) => {
94
64
  let id, taxon_id, name, desc, species, className, identity;
95
65
  const gene = props.gene;
@@ -220,10 +190,10 @@ const allDetails = [
220
190
  class Gene extends React.Component {
221
191
  constructor(props) {
222
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.
223
195
  this.state = {
224
196
  details: allDetails.map(o => ({...o})).filter(d => props.config.details[d.id]),
225
- expandedDetail: props.expandedDetail,
226
- fullscreen: false,
227
197
  };
228
198
  let hasData = {};
229
199
  props.searchResult.capabilities.forEach(c => {
@@ -234,18 +204,38 @@ class Gene extends React.Component {
234
204
  });
235
205
  this.state.details.forEach(d => d.available |= hasData.hasOwnProperty(d.id));
236
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
+ }
237
227
  getDetailStatus(d) {
238
- if (this.state.expandedDetail === d.id) return 'expanded';
228
+ if (this.expandedDetail === d.id) return 'expanded';
239
229
  if (d.available) return 'closed'
240
230
  return d.id === "pubs" ? 'empty' : 'disabled';
241
231
  }
242
232
  setExpanded(d) {
243
233
  if (d.available || d.id === "pubs") {
244
- if (this.state.expandedDetail === d.id) {
245
- 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});
246
237
  }
247
238
  else {
248
- const geneId = this.props.searchResult.id;
249
239
  if (!(this.props.geneDocs && this.props.geneDocs.hasOwnProperty(geneId))) {
250
240
  this.props.requestGene(geneId)
251
241
  }
@@ -254,7 +244,7 @@ class Gene extends React.Component {
254
244
  action: 'Details',
255
245
  label: d.label
256
246
  });
257
- this.setState({expandedDetail: d.id, fullscreen: false})
247
+ this.props.doExpandGeneDetail({geneId, detail: d.id});
258
248
  }
259
249
  }
260
250
  }
@@ -279,7 +269,6 @@ class Gene extends React.Component {
279
269
  const panSite = this.props.config.panSite;
280
270
  const searchResult = this.props.searchResult;
281
271
  const taxName = this.props.taxName;
282
- const curation = this.props.curation;
283
272
  // let orthologs='';
284
273
  // if (this.props.orthologs && this.props.orthologs.hasOwnProperty(searchResult.id)) {
285
274
  // orthologs = this.props.orthologs[searchResult.id].join(', ');
@@ -290,7 +279,6 @@ class Gene extends React.Component {
290
279
  <div className="result-gene-summary">
291
280
  <div className="result-gene-title-body">
292
281
  {panSite.hasOwnProperty(searchResult.system_name) && <PanLink pan={panSite[searchResult.system_name]} gene={searchResult}/>}
293
- {curation && <CurationComponent curation={curation} gene={searchResult}/> }
294
282
  <div className="gene-title">
295
283
  <div className="gene-species">{taxName}</div>
296
284
  <h3 className="gene-name">{searchResult.name}
@@ -304,7 +292,7 @@ class Gene extends React.Component {
304
292
  </div>
305
293
  <div className="gene-detail-tabs">
306
294
  {this.state.details.map((d,idx) => {
307
- const isExpanded = this.state.expandedDetail === d.id;
295
+ const isExpanded = this.expandedDetail === d.id;
308
296
  return (
309
297
  <OverlayTrigger
310
298
  key={idx}
@@ -331,7 +319,7 @@ class Gene extends React.Component {
331
319
  title="View full screen"
332
320
  onClick={(e) => {
333
321
  e.stopPropagation();
334
- this.setState({fullscreen: true});
322
+ this.props.doSetGeneFullscreen({geneId: searchResult.id, fullscreen: true});
335
323
  }}
336
324
  />
337
325
  )}
@@ -340,14 +328,14 @@ class Gene extends React.Component {
340
328
  );
341
329
  })}
342
330
  </div>
343
- {this.state.expandedDetail && this.ensureGene(searchResult.id) && (
331
+ {this.expandedDetail && this.ensureGene(searchResult.id) && (
344
332
  <FullscreenContainer
345
333
  className="visible-detail"
346
- fullscreen={this.state.fullscreen}
347
- onExitFullscreen={() => this.setState({fullscreen: false})}
348
- 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}
349
337
  >
350
- {React.createElement(inventory[this.state.expandedDetail], this.props)}
338
+ {React.createElement(inventory[this.expandedDetail], this.props)}
351
339
  </FullscreenContainer>
352
340
  )}
353
341
  </div>
@@ -355,6 +343,16 @@ class Gene extends React.Component {
355
343
  }
356
344
  }
357
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
+
358
356
  const GeneList = props => {
359
357
  if (props.grameneSearch && props.grameneSearch.response && props.grameneTaxonomy) {
360
358
  let prev,page,next;
@@ -374,7 +372,7 @@ const GeneList = props => {
374
372
  return <div>
375
373
  <div>{prev}{page}{next}</div>
376
374
  {props.grameneSearch.response.docs.map((g,idx) => (
377
- <Gene key={idx}
375
+ <ConnectedGene key={idx}
378
376
  searchResult={g}
379
377
  config={props.configuration}
380
378
  taxName={props.grameneTaxonomy[g.taxon_id].name}
@@ -383,7 +381,6 @@ const GeneList = props => {
383
381
  requestOrthologs={props.doRequestOrthologs}
384
382
  orthologs={props.grameneOrthologs}
385
383
  taxLut={props.grameneTaxonomy}
386
- curation={props.curatedGenes ? props.curatedGenes[g.id] : null}
387
384
  expandedDetail={props.grameneSearch.response.numFound === 1 && g.can_show.homology ? 'homology' : null}
388
385
  />
389
386
  ))}
@@ -401,7 +398,6 @@ export default connect(
401
398
  'selectGrameneOrthologs',
402
399
  'selectGrameneSearchOffset',
403
400
  'selectGrameneSearchRows',
404
- 'selectCuratedGenes',
405
401
  'doRequestGrameneGene',
406
402
  'doRequestOrthologs',
407
403
  'doRequestResultsPage',
@@ -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
  ))}
@@ -49,6 +49,17 @@ class TaxDist extends React.Component {
49
49
  }
50
50
  })
51
51
  }
52
+ // Drop any tid the taxonomy tree doesn't know about — gramene-search-vis
53
+ // throws when it encounters one (nodeIndex[tid].getPath() on undefined),
54
+ // which silently leaves every branch expanded.
55
+ if (this.props.grameneTaxDist && this.props.grameneTaxDist.indices && this.props.grameneTaxDist.indices.id) {
56
+ const nodeIndex = this.props.grameneTaxDist.indices.id;
57
+ Object.keys(selectedTaxa).forEach(tid => {
58
+ if (!nodeIndex[tid]) {
59
+ delete selectedTaxa[tid];
60
+ }
61
+ });
62
+ }
52
63
  }
53
64
  return (
54
65
  <div className="results-vis big-vis">
@@ -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
+ }