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.
- package/.claude/launch.json +11 -0
- package/.claude/settings.local.json +21 -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.css +1 -4
- package/dist/index.css.map +1 -1
- package/dist/index.js +2308 -433
- package/dist/index.js.map +1 -1
- package/package.json +5 -2
- package/src/bundles/api.js +24 -42
- package/src/bundles/docs.js +2 -1
- 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 +24 -2
- 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 -49
- package/src/components/results/OntologyEnrichment.js +13 -6
- package/src/components/results/TaxDist.js +11 -0
- package/src/components/results/details/BAR.js +148 -0
- package/src/components/results/details/Expression.js +50 -14
- package/src/components/results/details/Homology.js +171 -39
- package/src/components/results/details/Pathways.js +4 -2
- package/src/components/results/details/Sequences.js +24 -8
- package/src/components/results/details/VEP.js +65 -19
- package/src/components/styles.css +1 -4
- package/src/demo.js +30 -13
- 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;
|
|
@@ -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}
|
|
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.
|
|
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
|
-
|
|
245
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
331
|
+
{this.expandedDetail && this.ensureGene(searchResult.id) && (
|
|
344
332
|
<FullscreenContainer
|
|
345
333
|
className="visible-detail"
|
|
346
|
-
fullscreen={this.
|
|
347
|
-
onExitFullscreen={() => this.
|
|
348
|
-
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}
|
|
349
337
|
>
|
|
350
|
-
{React.createElement(inventory[this.
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
))}
|
|
@@ -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
|
+
}
|