gramene-search 2.0.1 → 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,194 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
2
+ import { AgGridReact } from 'ag-grid-react';
3
+ import { OverlayTrigger, Popover } from 'react-bootstrap';
4
+ import 'ag-grid-community/styles/ag-grid.css';
5
+ import 'ag-grid-community/styles/ag-theme-quartz.css';
6
+
7
+ const baseColDefs = [
8
+ { field: 'id', headerName: 'Gene ID', pinned: 'left', width: 160, suppressMovable: true },
9
+ { field: 'name', headerName: 'Name', width: 140, suppressMovable: true }
10
+ ];
11
+
12
+ function arraysEqual(a, b) {
13
+ if (a.length !== b.length) return false;
14
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
15
+ return true;
16
+ }
17
+
18
+ function buildFieldInfo(fields, studies, expressionSamples) {
19
+ const info = {};
20
+ if (!fields || !studies || !expressionSamples) return info;
21
+ const wanted = new Set(fields);
22
+ for (const study of studies) {
23
+ const studyId = study._id;
24
+ const samples = expressionSamples[studyId];
25
+ if (!samples) continue;
26
+ const byGroup = {};
27
+ for (const s of samples) if (!byGroup[s.group]) byGroup[s.group] = s;
28
+ for (const group of Object.keys(byGroup)) {
29
+ const fieldName = `${studyId.replace(/-/g, '_')}_${group}__expr`;
30
+ if (!wanted.has(fieldName)) continue;
31
+ const sample = byGroup[group];
32
+ const factors = {};
33
+ (sample.factor || []).forEach(f => { factors[f.type] = f.label; });
34
+ const characteristics = {};
35
+ (sample.characteristic || []).forEach(c => {
36
+ if (factors[c.type] != null) return;
37
+ characteristics[c.type] = c.label;
38
+ });
39
+ info[fieldName] = {
40
+ studyId,
41
+ studyDescription: study.description || studyId,
42
+ group,
43
+ replicates: samples.filter(s => s.group === group).length,
44
+ factors,
45
+ characteristics
46
+ };
47
+ }
48
+ }
49
+ return info;
50
+ }
51
+
52
+ // Custom header: re-implements sort click + menu button so ag-grid's column
53
+ // drag/menu/filter still work, with an info icon as the popover trigger.
54
+ const HeaderWithInfo = (props) => {
55
+ const { displayName, enableSorting, enableMenu, showColumnMenu, progressSort, column, info } = props;
56
+ const [sort, setSort] = useState(column && column.getSort ? column.getSort() : null);
57
+ const menuRef = useRef(null);
58
+
59
+ useEffect(() => {
60
+ if (!column || !column.addEventListener) return;
61
+ const handler = () => setSort(column.getSort());
62
+ column.addEventListener('sortChanged', handler);
63
+ return () => column.removeEventListener('sortChanged', handler);
64
+ }, [column]);
65
+
66
+ const onSortClick = (event) => {
67
+ if (enableSorting && progressSort) progressSort(event.shiftKey);
68
+ };
69
+
70
+ const onMenuClick = (event) => {
71
+ event.stopPropagation();
72
+ if (showColumnMenu && menuRef.current) showColumnMenu(menuRef.current);
73
+ };
74
+
75
+ const factorEntries = info ? Object.entries(info.factors || {}) : [];
76
+ const charEntries = info ? Object.entries(info.characteristics || {}) : [];
77
+ const popover = info ? (
78
+ <Popover id={`exprviz-header-${info.studyId}-${info.group}`} className="exprviz-header-popover">
79
+ <Popover.Header as="h6">{info.studyDescription}</Popover.Header>
80
+ <Popover.Body>
81
+ <div><strong>Study:</strong> {info.studyId}</div>
82
+ <div><strong>Group:</strong> {info.group} ({info.replicates} {info.replicates === 1 ? 'rep' : 'reps'})</div>
83
+ {factorEntries.length > 0 && (
84
+ <div className="exprviz-header-section">
85
+ <strong>Factors</strong>
86
+ <ul>{factorEntries.map(([t, v]) => <li key={t}><em>{t}:</em> {v}</li>)}</ul>
87
+ </div>
88
+ )}
89
+ {charEntries.length > 0 && (
90
+ <div className="exprviz-header-section">
91
+ <strong>Characteristics</strong>
92
+ <ul>{charEntries.map(([t, v]) => <li key={t}><em>{t}:</em> {v}</li>)}</ul>
93
+ </div>
94
+ )}
95
+ </Popover.Body>
96
+ </Popover>
97
+ ) : null;
98
+
99
+ return (
100
+ <div className="exprviz-header">
101
+ <span
102
+ className="exprviz-header-text"
103
+ onClick={onSortClick}
104
+ style={{ cursor: enableSorting ? 'pointer' : 'default' }}
105
+ >
106
+ {displayName}
107
+ {sort === 'asc' && <span className="exprviz-header-sort"> ▲</span>}
108
+ {sort === 'desc' && <span className="exprviz-header-sort"> ▼</span>}
109
+ </span>
110
+ {info && (
111
+ <OverlayTrigger
112
+ trigger={['hover', 'focus']}
113
+ placement="bottom"
114
+ overlay={popover}
115
+ delay={{ show: 200, hide: 100 }}
116
+ >
117
+ <span
118
+ className="exprviz-header-info"
119
+ onClick={e => e.stopPropagation()}
120
+ onMouseDown={e => e.stopPropagation()}
121
+ aria-label="More info"
122
+ >ⓘ</span>
123
+ </OverlayTrigger>
124
+ )}
125
+ {enableMenu && (
126
+ <span
127
+ ref={menuRef}
128
+ className="exprviz-header-menu ag-icon ag-icon-menu"
129
+ onClick={onMenuClick}
130
+ />
131
+ )}
132
+ </div>
133
+ );
134
+ };
135
+
136
+ const ExprTable = ({ rows, fields, onReorder, studies, expressionSamples, onHoverRow }) => {
137
+ const fieldInfo = useMemo(
138
+ () => buildFieldInfo(fields, studies, expressionSamples),
139
+ [fields, studies, expressionSamples]
140
+ );
141
+
142
+ const columnDefs = useMemo(() => {
143
+ const expressionCols = (fields || []).map(f => ({
144
+ field: f,
145
+ headerName: f.replace(/__expr$/, ''),
146
+ width: 160,
147
+ suppressMovable: false,
148
+ headerComponent: HeaderWithInfo,
149
+ headerComponentParams: { info: fieldInfo[f] },
150
+ valueFormatter: p => {
151
+ const v = p.value;
152
+ if (v == null) return '';
153
+ if (Array.isArray(v)) return v.join(', ');
154
+ if (typeof v === 'object') return JSON.stringify(v);
155
+ return String(v);
156
+ }
157
+ }));
158
+ return [...baseColDefs, ...expressionCols];
159
+ }, [fields, fieldInfo]);
160
+
161
+ const onColumnMoved = (e) => {
162
+ if (!onReorder || !e.finished) return;
163
+ const allCols = e.api.getAllGridColumns ? e.api.getAllGridColumns() : (e.columnApi && e.columnApi.getAllGridColumns && e.columnApi.getAllGridColumns());
164
+ if (!allCols) return;
165
+ const next = allCols
166
+ .map(c => c.getColId())
167
+ .filter(id => fields.includes(id));
168
+ if (!arraysEqual(next, fields)) {
169
+ onReorder(next);
170
+ }
171
+ };
172
+
173
+ if (!rows || rows.length === 0) {
174
+ return <div className="exprviz-table-empty"><em>No data loaded.</em></div>;
175
+ }
176
+
177
+ return (
178
+ <div className="ag-theme-quartz exprviz-aggrid">
179
+ <AgGridReact
180
+ rowData={rows}
181
+ columnDefs={columnDefs}
182
+ defaultColDef={{ resizable: true, sortable: true, filter: true }}
183
+ animateRows={false}
184
+ suppressFieldDotNotation={true}
185
+ suppressDragLeaveHidesColumns={true}
186
+ onColumnMoved={onColumnMoved}
187
+ onCellMouseOver={e => onHoverRow && onHoverRow(e.data && e.data.id)}
188
+ onCellMouseOut={() => onHoverRow && onHoverRow(null)}
189
+ />
190
+ </div>
191
+ );
192
+ };
193
+
194
+ export default ExprTable;
@@ -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
+ );