gramene-search 2.0.2 → 2.0.4

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.
@@ -0,0 +1,294 @@
1
+ import React, { useEffect, useMemo, useState } from 'react';
2
+ import { connect } from 'redux-bundler-react';
3
+ import { Tabs, Tab, Button, ToggleButton, ToggleButtonGroup } from 'react-bootstrap';
4
+ import FieldsModal from './FieldsModal';
5
+ import ExprTable from './ExprTable';
6
+ import ParallelCoordsPlot from './ParallelCoordsPlot';
7
+ import './styles.css';
8
+
9
+ function speciesTaxonId(tid) {
10
+ const n = +tid;
11
+ return n > 1000000 ? Math.floor(n / 1000) : n;
12
+ }
13
+
14
+ function genomeName(grameneMaps, tid) {
15
+ if (!grameneMaps) return tid;
16
+ const direct = grameneMaps[tid];
17
+ if (direct && direct.display_name) return direct.display_name;
18
+ const sp = grameneMaps[speciesTaxonId(tid)];
19
+ if (sp && sp.display_name) return sp.display_name;
20
+ return tid;
21
+ }
22
+
23
+ const ExprVizViewCmp = props => {
24
+ const {
25
+ exprVizPivot: pivot,
26
+ exprViz,
27
+ exprVizActiveTaxon: activeTaxon,
28
+ grameneMaps,
29
+ expressionStudies,
30
+ expressionSamples,
31
+ doSetExprVizActiveTaxon,
32
+ doToggleExprVizFieldsModal,
33
+ doFetchExprVizData
34
+ } = props;
35
+
36
+ const studiesFor = tid => {
37
+ if (!expressionStudies) return [];
38
+ return expressionStudies[tid] || expressionStudies[speciesTaxonId(tid)] || [];
39
+ };
40
+
41
+ const taxa = useMemo(() => {
42
+ const ids = Object.keys(pivot.data || {});
43
+ if (!grameneMaps) return ids;
44
+ return ids.sort((a, b) => {
45
+ const ma = grameneMaps[a] || grameneMaps[speciesTaxonId(a)];
46
+ const mb = grameneMaps[b] || grameneMaps[speciesTaxonId(b)];
47
+ return ((ma && ma.left_index) || 0) - ((mb && mb.left_index) || 0);
48
+ });
49
+ }, [pivot.data, grameneMaps]);
50
+
51
+ useEffect(() => {
52
+ if (taxa.length === 0) return;
53
+ if (!activeTaxon || !taxa.includes(String(activeTaxon))) {
54
+ doSetExprVizActiveTaxon(taxa[0]);
55
+ }
56
+ }, [taxa, activeTaxon, doSetExprVizActiveTaxon]);
57
+
58
+ if (pivot.status === 'loading') {
59
+ return <div className="exprviz-view"><em>Loading studies…</em></div>;
60
+ }
61
+ if (pivot.status === 'error') {
62
+ return <div className="exprviz-view"><em>Error: {pivot.error}</em></div>;
63
+ }
64
+ if (taxa.length === 0) {
65
+ return <div className="exprviz-view"><em>No expression studies for current results.</em></div>;
66
+ }
67
+
68
+ return (
69
+ <div className="exprviz-view">
70
+ <Tabs
71
+ activeKey={activeTaxon || taxa[0]}
72
+ onSelect={k => doSetExprVizActiveTaxon(k)}
73
+ className="exprviz-tabs"
74
+ >
75
+ {taxa.map(tid => {
76
+ const studies = studiesFor(tid);
77
+ const taxName = genomeName(grameneMaps, tid);
78
+ const geneCount = pivot.data[tid] || 0;
79
+ return (
80
+ <Tab
81
+ key={tid}
82
+ eventKey={tid}
83
+ title={`${taxName} (${studies.length} studies · ${geneCount} genes)`}
84
+ >
85
+ <TaxonPanel
86
+ taxon={tid}
87
+ studies={studies}
88
+ expressionSamples={expressionSamples}
89
+ tabState={exprViz.byTaxon[tid]}
90
+ onOpenFields={() => doToggleExprVizFieldsModal(tid, true)}
91
+ onLoad={() => doFetchExprVizData(tid)}
92
+ onReorder={(next) => props.doReorderExprVizFields(tid, next)}
93
+ onAddRangeQuery={props.doAddGrameneRangeQuery}
94
+ />
95
+ </Tab>
96
+ );
97
+ })}
98
+ </Tabs>
99
+ <FieldsModal/>
100
+ </div>
101
+ );
102
+ };
103
+
104
+ function rowMatchesSelections(row, selections) {
105
+ for (const f of Object.keys(selections)) {
106
+ const v = row[f];
107
+ if (v == null || Array.isArray(v)) return false;
108
+ const n = +v;
109
+ if (!Number.isFinite(n)) return false;
110
+ const [lo, hi] = selections[f];
111
+ if (n < lo || n > hi) return false;
112
+ }
113
+ return true;
114
+ }
115
+
116
+ function fmt(n) {
117
+ if (!Number.isFinite(n)) return String(n);
118
+ const a = Math.abs(n);
119
+ if (a !== 0 && (a < 0.001 || a >= 1e6)) return n.toExponential(3);
120
+ return Number(n.toFixed(4)).toString();
121
+ }
122
+
123
+ function tsvCell(v) {
124
+ if (v == null) return '';
125
+ if (Array.isArray(v)) return v.join(',');
126
+ const s = typeof v === 'object' ? JSON.stringify(v) : String(v);
127
+ return s.replace(/[\t\r\n]+/g, ' ');
128
+ }
129
+
130
+ function downloadTsv(filename, rows, fields) {
131
+ const cols = ['id', 'name', ...fields];
132
+ const headerLabels = ['id', 'name', ...fields.map(f => f.replace(/__expr$/, ''))];
133
+ const lines = [headerLabels.join('\t')];
134
+ for (const r of rows) lines.push(cols.map(c => tsvCell(r[c])).join('\t'));
135
+ const blob = new Blob([lines.join('\n') + '\n'], { type: 'text/tab-separated-values' });
136
+ const url = URL.createObjectURL(blob);
137
+ const a = document.createElement('a');
138
+ a.href = url;
139
+ a.download = filename;
140
+ document.body.appendChild(a);
141
+ a.click();
142
+ document.body.removeChild(a);
143
+ URL.revokeObjectURL(url);
144
+ }
145
+
146
+ const TaxonPanel = ({ taxon, studies, expressionSamples, tabState, onOpenFields, onLoad, onReorder, onAddRangeQuery }) => {
147
+ const selected = (tabState && tabState.selectedFields) || [];
148
+ const rows = (tabState && tabState.rows) || [];
149
+ const fetchInfo = (tabState && tabState.fetch) || { status: 'idle', total: 0 };
150
+ const [scale, setScale] = useState('linear');
151
+ const [selections, setSelections] = useState({});
152
+ const [clearVersion, setClearVersion] = useState(0);
153
+ const [hoveredId, setHoveredId] = useState(null);
154
+
155
+ const hasBrush = Object.keys(selections).length > 0;
156
+ const filteredRows = useMemo(() => {
157
+ if (!hasBrush) return rows;
158
+ return rows.filter(r => rowMatchesSelections(r, selections));
159
+ }, [rows, selections, hasBrush]);
160
+
161
+ // Drop fields with no numeric data in the loaded rows so empty axes/columns
162
+ // don't clutter the visualization. Selected-but-empty fields stay in the
163
+ // underlying selection so a future load can repopulate them.
164
+ const visibleFields = useMemo(() => {
165
+ if (rows.length === 0 || selected.length === 0) return selected;
166
+ return selected.filter(f => rows.some(r => {
167
+ const v = r[f];
168
+ return v != null && !Array.isArray(v) && Number.isFinite(+v);
169
+ }));
170
+ }, [rows, selected]);
171
+
172
+ const handleReorder = onReorder
173
+ ? (newVisibleOrder) => {
174
+ const visibleSet = new Set(newVisibleOrder);
175
+ const hidden = selected.filter(f => !visibleSet.has(f));
176
+ onReorder([...newVisibleOrder, ...hidden]);
177
+ }
178
+ : undefined;
179
+
180
+ useEffect(() => {
181
+ if (rows.length === 0 && hasBrush) {
182
+ setSelections({});
183
+ setClearVersion(v => v + 1);
184
+ }
185
+ }, [rows.length, hasBrush]);
186
+
187
+ return (
188
+ <div className="exprviz-tab-panel">
189
+ <div className="exprviz-toolbar">
190
+ <Button size="sm" onClick={onOpenFields}>
191
+ Select fields ({selected.length} selected, {studies.length} studies)
192
+ </Button>
193
+ <Button
194
+ size="sm"
195
+ variant="primary"
196
+ disabled={selected.length === 0 || fetchInfo.status === 'loading'}
197
+ onClick={onLoad}
198
+ >
199
+ {fetchInfo.status === 'loading' ? 'Loading…' : 'Load data'}
200
+ </Button>
201
+ <ToggleButtonGroup
202
+ type="radio"
203
+ name={`exprviz-scale-${taxon}`}
204
+ size="sm"
205
+ value={scale}
206
+ onChange={setScale}
207
+ >
208
+ <ToggleButton id={`exprviz-scale-${taxon}-lin`} value="linear" variant="outline-secondary">Linear</ToggleButton>
209
+ <ToggleButton id={`exprviz-scale-${taxon}-log`} value="log" variant="outline-secondary">Log</ToggleButton>
210
+ </ToggleButtonGroup>
211
+ <Button
212
+ size="sm"
213
+ variant="outline-secondary"
214
+ disabled={!hasBrush}
215
+ onClick={() => { setClearVersion(v => v + 1); setSelections({}); }}
216
+ >
217
+ Clear brushes
218
+ </Button>
219
+ <Button
220
+ size="sm"
221
+ variant="outline-secondary"
222
+ disabled={filteredRows.length === 0 || visibleFields.length === 0}
223
+ onClick={() => downloadTsv(`expression_${taxon}.tsv`, filteredRows, visibleFields)}
224
+ title="Download the visible rows and columns as tab-delimited text"
225
+ >
226
+ Download TSV
227
+ </Button>
228
+ <Button
229
+ size="sm"
230
+ variant="success"
231
+ disabled={!hasBrush || !onAddRangeQuery}
232
+ onClick={() => {
233
+ const terms = Object.keys(selections).map(field => {
234
+ const [lo, hi] = selections[field];
235
+ return {
236
+ category: 'Expression',
237
+ name: `${field}: ${fmt(lo)}–${fmt(hi)}`,
238
+ fq_field: field,
239
+ fq_value: `[${lo} TO ${hi}]`
240
+ };
241
+ });
242
+ onAddRangeQuery(terms);
243
+ }}
244
+ title="Add brush ranges as an AND-conjunction filter on the search"
245
+ >
246
+ Apply as filter
247
+ </Button>
248
+ <span className="exprviz-status">
249
+ {hasBrush ? `${filteredRows.length} of ${rows.length}` : rows.length}
250
+ {fetchInfo.total ? ` / ${fetchInfo.total}` : ''} genes
251
+ {hasBrush ? ' (brushed)' : ' loaded'}
252
+ </span>
253
+ </div>
254
+ <div className="exprviz-body">
255
+ <div className="exprviz-plot">
256
+ <ParallelCoordsPlot
257
+ rows={rows}
258
+ fields={visibleFields}
259
+ scale={scale}
260
+ onBrushChange={setSelections}
261
+ onReorder={handleReorder}
262
+ clearVersion={clearVersion}
263
+ hoveredId={hoveredId}
264
+ />
265
+ </div>
266
+ <div className="exprviz-table">
267
+ <ExprTable
268
+ rows={filteredRows}
269
+ fields={visibleFields}
270
+ onReorder={handleReorder}
271
+ studies={studies}
272
+ expressionSamples={expressionSamples}
273
+ onHoverRow={setHoveredId}
274
+ />
275
+ </div>
276
+ </div>
277
+ </div>
278
+ );
279
+ };
280
+
281
+ export default connect(
282
+ 'selectExprViz',
283
+ 'selectExprVizPivot',
284
+ 'selectExprVizActiveTaxon',
285
+ 'selectGrameneMaps',
286
+ 'selectExpressionStudies',
287
+ 'selectExpressionSamples',
288
+ 'doSetExprVizActiveTaxon',
289
+ 'doToggleExprVizFieldsModal',
290
+ 'doFetchExprVizData',
291
+ 'doReorderExprVizFields',
292
+ 'doAddGrameneRangeQuery',
293
+ ExprVizViewCmp
294
+ );