gramene-search 2.0.2 → 2.0.5

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,400 @@
1
+ import React, { useEffect, useMemo, useRef, 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
+ // Compact axis labels for the parallel-coords plot. The raw Solr field name
105
+ // (e.g. "E_CURD_148_g5__expr") is uninformative; we prefer the assay's
106
+ // factor labels, falling back to "organism part" then to the group id.
107
+ // `full` is exposed via an SVG <title> so the user can hover to see study,
108
+ // group, and every factor/characteristic.
109
+ const AXIS_LABEL_MAX = 22;
110
+
111
+ function truncateLabel(s, n) {
112
+ if (!s) return '';
113
+ return s.length > n ? s.slice(0, n - 1) + '…' : s;
114
+ }
115
+
116
+ function compactAssayLabel(assay, group) {
117
+ if (!assay) return group || '';
118
+ const factorVals = (assay.factor || []).map(f => f && f.label).filter(Boolean);
119
+ if (factorVals.length) return factorVals.join('; ');
120
+ const chars = assay.characteristic || [];
121
+ const organ = chars.find(c => c && c.type === 'organism part');
122
+ if (organ && organ.label) return organ.label;
123
+ const firstChar = chars.find(c => c && c.label);
124
+ if (firstChar) return firstChar.label;
125
+ return group || '';
126
+ }
127
+
128
+ function assayPairs(list) {
129
+ return (list || [])
130
+ .filter(x => x && x.label)
131
+ .map(x => ({ name: x.type || '', value: x.label }));
132
+ }
133
+
134
+ function buildAxisLabels(fields, studies, expressionSamples) {
135
+ const labels = {};
136
+ if (!fields) return labels;
137
+ const studyById = {};
138
+ (studies || []).forEach(s => { if (s && s._id) studyById[s._id] = s; });
139
+ const findAssay = (studyId, group) => {
140
+ const arr = expressionSamples && expressionSamples[studyId];
141
+ return arr ? arr.find(a => a.group === group) : null;
142
+ };
143
+ for (const f of fields) {
144
+ const m = f.match(/^(.+?)_g(\d+)__expr$/);
145
+ if (!m) {
146
+ labels[f] = {
147
+ short: truncateLabel(f.replace(/__expr$/, ''), AXIS_LABEL_MAX),
148
+ structured: { studyTitle: f, group: '', factors: [], characteristics: [] }
149
+ };
150
+ continue;
151
+ }
152
+ const expId = m[1].replace(/_/g, '-');
153
+ const group = 'g' + m[2];
154
+ const assay = findAssay(expId, group);
155
+ const study = studyById[expId];
156
+ const studyName = (study && study.description) || expId;
157
+ labels[f] = {
158
+ short: truncateLabel(compactAssayLabel(assay, group), AXIS_LABEL_MAX),
159
+ structured: {
160
+ studyTitle: studyName,
161
+ group,
162
+ factors: assayPairs(assay && assay.factor),
163
+ characteristics: assayPairs(assay && assay.characteristic)
164
+ }
165
+ };
166
+ }
167
+ return labels;
168
+ }
169
+
170
+ function rowMatchesSelections(row, selections) {
171
+ for (const f of Object.keys(selections)) {
172
+ const v = row[f];
173
+ if (v == null || Array.isArray(v)) return false;
174
+ const n = +v;
175
+ if (!Number.isFinite(n)) return false;
176
+ const [lo, hi] = selections[f];
177
+ if (n < lo || n > hi) return false;
178
+ }
179
+ return true;
180
+ }
181
+
182
+ function fmt(n) {
183
+ if (!Number.isFinite(n)) return String(n);
184
+ const a = Math.abs(n);
185
+ if (a !== 0 && (a < 0.001 || a >= 1e6)) return n.toExponential(3);
186
+ return Number(n.toFixed(4)).toString();
187
+ }
188
+
189
+ function tsvCell(v) {
190
+ if (v == null) return '';
191
+ if (Array.isArray(v)) return v.join(',');
192
+ const s = typeof v === 'object' ? JSON.stringify(v) : String(v);
193
+ return s.replace(/[\t\r\n]+/g, ' ');
194
+ }
195
+
196
+ function downloadTsv(filename, rows, fields) {
197
+ const cols = ['id', 'name', ...fields];
198
+ const headerLabels = ['id', 'name', ...fields.map(f => f.replace(/__expr$/, ''))];
199
+ const lines = [headerLabels.join('\t')];
200
+ for (const r of rows) lines.push(cols.map(c => tsvCell(r[c])).join('\t'));
201
+ const blob = new Blob([lines.join('\n') + '\n'], { type: 'text/tab-separated-values' });
202
+ const url = URL.createObjectURL(blob);
203
+ const a = document.createElement('a');
204
+ a.href = url;
205
+ a.download = filename;
206
+ document.body.appendChild(a);
207
+ a.click();
208
+ document.body.removeChild(a);
209
+ URL.revokeObjectURL(url);
210
+ }
211
+
212
+ const TaxonPanel = ({ taxon, studies, expressionSamples, tabState, onOpenFields, onLoad, onReorder, onAddRangeQuery }) => {
213
+ const selected = (tabState && tabState.selectedFields) || [];
214
+ const rows = (tabState && tabState.rows) || [];
215
+ const fetchInfo = (tabState && tabState.fetch) || { status: 'idle', total: 0 };
216
+ const [scale, setScale] = useState('linear');
217
+ const [selections, setSelections] = useState({});
218
+ const [clearVersion, setClearVersion] = useState(0);
219
+ const [hoveredId, setHoveredId] = useState(null);
220
+ const [plotHeight, setPlotHeight] = useState(320);
221
+ const resizeStateRef = useRef(null);
222
+
223
+ // Drag the horizontal separator between the plot and the table to retune
224
+ // their relative sizes. Bounded so neither pane disappears entirely.
225
+ const startResize = (e) => {
226
+ e.preventDefault();
227
+ resizeStateRef.current = { startY: e.clientY, startHeight: plotHeight };
228
+ const onMove = (ev) => {
229
+ const s = resizeStateRef.current;
230
+ if (!s) return;
231
+ const next = Math.max(120, Math.min(1200, s.startHeight + (ev.clientY - s.startY)));
232
+ setPlotHeight(next);
233
+ };
234
+ const onUp = () => {
235
+ resizeStateRef.current = null;
236
+ document.removeEventListener('mousemove', onMove);
237
+ document.removeEventListener('mouseup', onUp);
238
+ document.body.style.cursor = '';
239
+ document.body.style.userSelect = '';
240
+ };
241
+ document.body.style.cursor = 'row-resize';
242
+ document.body.style.userSelect = 'none';
243
+ document.addEventListener('mousemove', onMove);
244
+ document.addEventListener('mouseup', onUp);
245
+ };
246
+
247
+ const hasBrush = Object.keys(selections).length > 0;
248
+ const filteredRows = useMemo(() => {
249
+ if (!hasBrush) return rows;
250
+ return rows.filter(r => rowMatchesSelections(r, selections));
251
+ }, [rows, selections, hasBrush]);
252
+
253
+ // Drop fields with no numeric data in the loaded rows so empty axes/columns
254
+ // don't clutter the visualization. Selected-but-empty fields stay in the
255
+ // underlying selection so a future load can repopulate them.
256
+ const visibleFields = useMemo(() => {
257
+ if (rows.length === 0 || selected.length === 0) return selected;
258
+ return selected.filter(f => rows.some(r => {
259
+ const v = r[f];
260
+ return v != null && !Array.isArray(v) && Number.isFinite(+v);
261
+ }));
262
+ }, [rows, selected]);
263
+
264
+ const handleReorder = onReorder
265
+ ? (newVisibleOrder) => {
266
+ const visibleSet = new Set(newVisibleOrder);
267
+ const hidden = selected.filter(f => !visibleSet.has(f));
268
+ onReorder([...newVisibleOrder, ...hidden]);
269
+ }
270
+ : undefined;
271
+
272
+ const axisLabels = useMemo(
273
+ () => buildAxisLabels(visibleFields, studies, expressionSamples),
274
+ [visibleFields, studies, expressionSamples]
275
+ );
276
+
277
+ useEffect(() => {
278
+ if (rows.length === 0 && hasBrush) {
279
+ setSelections({});
280
+ setClearVersion(v => v + 1);
281
+ }
282
+ }, [rows.length, hasBrush]);
283
+
284
+ return (
285
+ <div className="exprviz-tab-panel">
286
+ <div className="exprviz-toolbar">
287
+ <Button size="sm" onClick={onOpenFields}>
288
+ Select fields ({selected.length} selected, {studies.length} studies)
289
+ </Button>
290
+ <Button
291
+ size="sm"
292
+ variant="primary"
293
+ disabled={selected.length === 0 || fetchInfo.status === 'loading'}
294
+ onClick={onLoad}
295
+ >
296
+ {fetchInfo.status === 'loading' ? 'Loading…' : 'Load data'}
297
+ </Button>
298
+ <ToggleButtonGroup
299
+ type="radio"
300
+ name={`exprviz-scale-${taxon}`}
301
+ size="sm"
302
+ value={scale}
303
+ onChange={setScale}
304
+ >
305
+ <ToggleButton id={`exprviz-scale-${taxon}-lin`} value="linear" variant="outline-secondary">Linear</ToggleButton>
306
+ <ToggleButton id={`exprviz-scale-${taxon}-log`} value="log" variant="outline-secondary">Log</ToggleButton>
307
+ </ToggleButtonGroup>
308
+ <Button
309
+ size="sm"
310
+ variant="outline-secondary"
311
+ disabled={!hasBrush}
312
+ onClick={() => { setClearVersion(v => v + 1); setSelections({}); }}
313
+ >
314
+ Clear brushes
315
+ </Button>
316
+ <Button
317
+ size="sm"
318
+ variant="outline-secondary"
319
+ disabled={filteredRows.length === 0 || visibleFields.length === 0}
320
+ onClick={() => downloadTsv(`expression_${taxon}.tsv`, filteredRows, visibleFields)}
321
+ title="Download the visible rows and columns as tab-delimited text"
322
+ >
323
+ Download TSV
324
+ </Button>
325
+ <Button
326
+ size="sm"
327
+ variant="success"
328
+ disabled={!hasBrush || !onAddRangeQuery}
329
+ onClick={() => {
330
+ const terms = Object.keys(selections).map(field => {
331
+ const [lo, hi] = selections[field];
332
+ return {
333
+ category: 'Expression',
334
+ name: `${field}: ${fmt(lo)}–${fmt(hi)}`,
335
+ fq_field: field,
336
+ fq_value: `[${lo} TO ${hi}]`
337
+ };
338
+ });
339
+ onAddRangeQuery(terms);
340
+ }}
341
+ title="Add brush ranges as an AND-conjunction filter on the search"
342
+ >
343
+ Apply as filter
344
+ </Button>
345
+ <span className="exprviz-status">
346
+ {hasBrush ? `${filteredRows.length} of ${rows.length}` : rows.length}
347
+ {fetchInfo.total ? ` / ${fetchInfo.total}` : ''} genes
348
+ {hasBrush ? ' (brushed)' : ' loaded'}
349
+ </span>
350
+ </div>
351
+ <div className="exprviz-body">
352
+ <div className="exprviz-plot" style={{ height: plotHeight }}>
353
+ <ParallelCoordsPlot
354
+ rows={rows}
355
+ fields={visibleFields}
356
+ scale={scale}
357
+ onBrushChange={setSelections}
358
+ onReorder={handleReorder}
359
+ clearVersion={clearVersion}
360
+ hoveredId={hoveredId}
361
+ axisLabels={axisLabels}
362
+ />
363
+ </div>
364
+ <div
365
+ className="exprviz-resizer"
366
+ role="separator"
367
+ aria-orientation="horizontal"
368
+ aria-label="Resize plot"
369
+ onMouseDown={startResize}
370
+ title="Drag to resize"
371
+ />
372
+ <div className="exprviz-table">
373
+ <ExprTable
374
+ rows={filteredRows}
375
+ fields={visibleFields}
376
+ onReorder={handleReorder}
377
+ studies={studies}
378
+ expressionSamples={expressionSamples}
379
+ onHoverRow={setHoveredId}
380
+ />
381
+ </div>
382
+ </div>
383
+ </div>
384
+ );
385
+ };
386
+
387
+ export default connect(
388
+ 'selectExprViz',
389
+ 'selectExprVizPivot',
390
+ 'selectExprVizActiveTaxon',
391
+ 'selectGrameneMaps',
392
+ 'selectExpressionStudies',
393
+ 'selectExpressionSamples',
394
+ 'doSetExprVizActiveTaxon',
395
+ 'doToggleExprVizFieldsModal',
396
+ 'doFetchExprVizData',
397
+ 'doReorderExprVizFields',
398
+ 'doAddGrameneRangeQuery',
399
+ ExprVizViewCmp
400
+ );