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.
- package/.claude/launch.json +11 -0
- package/.claude/settings.local.json +6 -1
- package/.env.example +6 -0
- package/.parcel-cache/13f2d5707e7af45c-RequestGraph +0 -0
- package/.parcel-cache/5ae0570a78c0dba3-AssetGraph +0 -0
- package/.parcel-cache/9ac092379278e465-BundleGraph +0 -0
- package/.parcel-cache/data.mdb +0 -0
- package/.parcel-cache/lock.mdb +0 -0
- package/.parcel-cache/snapshot-13f2d5707e7af45c.txt +2 -2
- package/dist/index.js +2065 -154
- package/dist/index.js.map +1 -1
- package/package.json +5 -2
- package/src/bundles/api.js +10 -4
- package/src/bundles/exprViz.js +97 -1
- package/src/bundles/index.js +4 -1
- package/src/bundles/ontologyEnrichment.js +14 -1
- package/src/bundles/savedViews.js +335 -0
- package/src/bundles/uiViewState.js +174 -0
- package/src/bundles/viewSnapshot.js +313 -0
- package/src/bundles/views.js +22 -0
- package/src/components/Auth.js +23 -3
- package/src/components/SaveView.js +157 -0
- package/src/components/exprViz/ExprVizView.js +16 -11
- package/src/components/exprViz/ParallelCoordsPlot.js +15 -0
- package/src/components/results/GeneList.js +45 -15
- package/src/components/results/OntologyEnrichment.js +13 -6
- package/src/components/results/details/BAR.js +148 -0
- package/src/components/results/details/Expression.js +43 -11
- package/src/components/results/details/Homology.js +170 -32
- package/src/components/results/details/Pathways.js +4 -2
- package/src/components/results/details/Sequences.js +24 -8
- package/src/demo.js +22 -17
- package/src/index.js +2 -1
- package/src/suppressDevWarnings.js +13 -0
- 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
|
-
|
|
256
|
-
|
|
257
|
-
const
|
|
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.
|
|
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
|
-
|
|
215
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
331
|
+
{this.expandedDetail && this.ensureGene(searchResult.id) && (
|
|
312
332
|
<FullscreenContainer
|
|
313
333
|
className="visible-detail"
|
|
314
|
-
fullscreen={this.
|
|
315
|
-
onExitFullscreen={() => this.
|
|
316
|
-
title={(this.state.details.find(d => d.id === this.
|
|
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.
|
|
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
|
-
<
|
|
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
|
-
|
|
414
|
-
|
|
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
|
-
|
|
420
|
+
onSortChange(block.ontology, sortKey, sortDir === 'asc' ? 'desc' : 'asc');
|
|
419
421
|
} else {
|
|
420
|
-
|
|
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
|
+
}
|