gramene-search 2.0.4 → 2.0.6
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/settings.local.json +4 -1
- package/.parcel-cache/83e7562660f7cc15-BundleGraph +0 -0
- package/.parcel-cache/d3a1b9507cb44047-AssetGraph +0 -0
- package/.parcel-cache/data.mdb +0 -0
- package/.parcel-cache/dc1da35000e13623-RequestGraph +0 -0
- package/.parcel-cache/lock.mdb +0 -0
- package/.parcel-cache/snapshot-dc1da35000e13623.txt +2 -2
- package/dist/index.css +174 -57
- package/dist/index.css.map +1 -1
- package/dist/index.js +710 -252
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/bundles/api.js +33 -26
- package/src/bundles/exporter.js +4 -1
- package/src/bundles/swaggerFields.js +18 -6
- package/src/bundles/views.js +12 -0
- package/src/components/exporter/expressionResolver.js +2 -2
- package/src/components/exprViz/ExprTable.js +257 -101
- package/src/components/exprViz/ExprVizView.js +151 -7
- package/src/components/exprViz/FieldsModal.js +45 -18
- package/src/components/exprViz/ParallelCoordsPlot.js +99 -6
- package/src/components/exprViz/styles.css +211 -56
- package/src/fieldCatalog.overlay.json +1 -1
package/package.json
CHANGED
package/src/bundles/api.js
CHANGED
|
@@ -172,12 +172,15 @@ const expressionStudies = createAsyncResourceBundle( {
|
|
|
172
172
|
.then(res => _.groupBy(res, 'taxon_id'))
|
|
173
173
|
}
|
|
174
174
|
});
|
|
175
|
+
// Only fetch when a view that consumes expression studies is enabled.
|
|
176
|
+
const EXPRESSION_VIEWS = ['exprViz', 'expression', 'export'];
|
|
175
177
|
expressionStudies.reactExpressionStudies = createSelector(
|
|
176
178
|
'selectExpressionStudiesShouldUpdate',
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
179
|
+
'selectGrameneViewsOn',
|
|
180
|
+
(shouldUpdate, viewsOn) => {
|
|
181
|
+
if (!shouldUpdate) return;
|
|
182
|
+
if (!viewsOn || !EXPRESSION_VIEWS.some(id => viewsOn.has(id))) return;
|
|
183
|
+
return { actionCreator: 'doFetchExpressionStudies' }
|
|
181
184
|
}
|
|
182
185
|
);
|
|
183
186
|
|
|
@@ -202,10 +205,11 @@ const expressionSamples = createAsyncResourceBundle( {
|
|
|
202
205
|
});
|
|
203
206
|
expressionSamples.reactExpressionSamples = createSelector(
|
|
204
207
|
'selectExpressionSamplesShouldUpdate',
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
208
|
+
'selectGrameneViewsOn',
|
|
209
|
+
(shouldUpdate, viewsOn) => {
|
|
210
|
+
if (!shouldUpdate) return;
|
|
211
|
+
if (!viewsOn || !EXPRESSION_VIEWS.some(id => viewsOn.has(id))) return;
|
|
212
|
+
return { actionCreator: 'doFetchExpressionSamples' }
|
|
209
213
|
}
|
|
210
214
|
);
|
|
211
215
|
|
|
@@ -219,12 +223,15 @@ const curatedGenes = createAsyncResourceBundle( {
|
|
|
219
223
|
.then(curation => _.keyBy(curation.genes, 'gene_id'))
|
|
220
224
|
}
|
|
221
225
|
});
|
|
226
|
+
// Curated annotations are consumed by GeneList rows and Homology details,
|
|
227
|
+
// both inside the gene-list view.
|
|
222
228
|
curatedGenes.reactCuratedGenes = createSelector(
|
|
223
229
|
'selectCuratedGenesShouldUpdate',
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
230
|
+
'selectGrameneViewsOn',
|
|
231
|
+
(shouldUpdate, viewsOn) => {
|
|
232
|
+
if (!shouldUpdate) return;
|
|
233
|
+
if (!viewsOn || !viewsOn.has('list')) return;
|
|
234
|
+
return { actionCreator: 'doFetchCuratedGenes' }
|
|
228
235
|
}
|
|
229
236
|
);
|
|
230
237
|
|
|
@@ -245,12 +252,14 @@ const grameneGermplasm = createAsyncResourceBundle( {
|
|
|
245
252
|
})
|
|
246
253
|
}
|
|
247
254
|
});
|
|
255
|
+
// Germplasm metadata is consumed by the VEP detail panel inside the gene list.
|
|
248
256
|
grameneGermplasm.reactGrameneGermplasm = createSelector(
|
|
249
257
|
'selectGrameneGermplasmShouldUpdate',
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
258
|
+
'selectGrameneViewsOn',
|
|
259
|
+
(shouldUpdate, viewsOn) => {
|
|
260
|
+
if (!shouldUpdate) return;
|
|
261
|
+
if (!viewsOn || !viewsOn.has('list')) return;
|
|
262
|
+
return { actionCreator: 'doFetchGrameneGermplasm' }
|
|
254
263
|
}
|
|
255
264
|
);
|
|
256
265
|
//
|
|
@@ -334,14 +343,12 @@ const grameneGeneAttribs = createAsyncResourceBundle( {
|
|
|
334
343
|
grameneGeneAttribs.reactGrameneGeneAttribs = createSelector(
|
|
335
344
|
'selectGrameneGeneAttribsShouldUpdate',
|
|
336
345
|
'selectGrameneFiltersStatus',
|
|
337
|
-
'
|
|
338
|
-
(shouldUpdate, status,
|
|
339
|
-
if (shouldUpdate
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
}
|
|
344
|
-
}
|
|
346
|
+
'selectGrameneViewsOn',
|
|
347
|
+
(shouldUpdate, status, viewsOn) => {
|
|
348
|
+
if (!shouldUpdate) return;
|
|
349
|
+
if (status !== 'finished' && status !== 'ready') return;
|
|
350
|
+
if (!viewsOn || !viewsOn.has('attribs')) return;
|
|
351
|
+
return { actionCreator: 'doFetchGrameneGeneAttribs' }
|
|
345
352
|
}
|
|
346
353
|
);
|
|
347
354
|
|
|
@@ -353,8 +360,8 @@ const grameneSearch = createAsyncResourceBundle({
|
|
|
353
360
|
const offset = store.selectGrameneSearchOffset();
|
|
354
361
|
const rows = store.selectGrameneSearchRows();
|
|
355
362
|
const g = store.selectGrameneGenomes();
|
|
356
|
-
const m = store.selectGrameneMaps();
|
|
357
|
-
const taxa = Object.keys(g.active).filter(tid => !m[tid].hidden);
|
|
363
|
+
const m = store.selectGrameneMaps() || {};
|
|
364
|
+
const taxa = Object.keys((g && g.active) || {}).filter(tid => m[tid] && !m[tid].hidden);
|
|
358
365
|
let fq='';
|
|
359
366
|
if (taxa.length) {
|
|
360
367
|
console.log('search add a fq for ',taxa);
|
package/src/bundles/exporter.js
CHANGED
|
@@ -181,10 +181,13 @@ const exporter = {
|
|
|
181
181
|
'selectGrameneFiltersQueryString',
|
|
182
182
|
'selectGrameneGenomes',
|
|
183
183
|
'selectGrameneMaps',
|
|
184
|
-
|
|
184
|
+
'selectGrameneViewsOn',
|
|
185
|
+
(exp, filtersStatus, q, g, m, viewsOn) => {
|
|
185
186
|
if (!exp || !exp.preview) return;
|
|
186
187
|
if (filtersStatus === 'init') return;
|
|
187
188
|
if (exp.preview.status === 'loading') return;
|
|
189
|
+
// Don't run the preview query unless the user has the exporter view open.
|
|
190
|
+
if (!viewsOn || !viewsOn.has('export')) return;
|
|
188
191
|
const maps = m || {};
|
|
189
192
|
const taxa = Object.keys((g && g.active) || {}).filter(tid => maps[tid] && !maps[tid].hidden);
|
|
190
193
|
const sig = `${q}|${taxa.sort().join(',')}`;
|
|
@@ -188,6 +188,19 @@ const grameneFieldCatalog = createAsyncResourceBundle({
|
|
|
188
188
|
}
|
|
189
189
|
});
|
|
190
190
|
|
|
191
|
+
// Auto-fetch the field catalog only when a view that needs it (exprViz fields
|
|
192
|
+
// modal, exporter field tree) is enabled. Other views never read it.
|
|
193
|
+
const FIELD_CATALOG_VIEWS = ['exprViz', 'export'];
|
|
194
|
+
grameneFieldCatalog.reactGrameneFieldCatalog = createSelector(
|
|
195
|
+
'selectGrameneFieldCatalogShouldUpdate',
|
|
196
|
+
'selectGrameneViewsOn',
|
|
197
|
+
(shouldUpdate, viewsOn) => {
|
|
198
|
+
if (!shouldUpdate) return;
|
|
199
|
+
if (!viewsOn || !FIELD_CATALOG_VIEWS.some(id => viewsOn.has(id))) return;
|
|
200
|
+
return { actionCreator: 'doFetchGrameneFieldCatalog' };
|
|
201
|
+
}
|
|
202
|
+
);
|
|
203
|
+
|
|
191
204
|
function assayFactorLabel(assay) {
|
|
192
205
|
if (!assay) return '';
|
|
193
206
|
const factors = Array.isArray(assay.factor) ? assay.factor : [];
|
|
@@ -255,12 +268,12 @@ function buildSpeciesNameIndex(grameneMaps) {
|
|
|
255
268
|
function fieldExperimentId(name) {
|
|
256
269
|
let m = name.match(/^(\w+?)_g\d+__expr$/);
|
|
257
270
|
if (m) return m[1].replace(/_/g, '-');
|
|
258
|
-
m = name.match(/^(\w+?)_g\d+_g\d+_(pval|
|
|
271
|
+
m = name.match(/^(\w+?)_g\d+_g\d+_(pval|l2fc)_attr_[a-z]$/);
|
|
259
272
|
if (m) return m[1].replace(/_/g, '-');
|
|
260
273
|
return null;
|
|
261
274
|
}
|
|
262
275
|
|
|
263
|
-
const STAT_RANK = { l2fc: 0,
|
|
276
|
+
const STAT_RANK = { l2fc: 0, pval: 1 };
|
|
264
277
|
|
|
265
278
|
function collapseDiffExprInSubgroups(subgroups, fieldsOut, collator) {
|
|
266
279
|
for (const taxGroup of subgroups) {
|
|
@@ -287,7 +300,7 @@ function collapseDiffExprInSubgroups(subgroups, fieldsOut, collator) {
|
|
|
287
300
|
});
|
|
288
301
|
const rep = names[0];
|
|
289
302
|
const repEntry = fieldsOut[rep];
|
|
290
|
-
const label = (repEntry.label || rep).replace(/\s+\((?:pval|
|
|
303
|
+
const label = (repEntry.label || rep).replace(/\s+\((?:pval|l2fc)\)/, '');
|
|
291
304
|
fieldsOut[rep] = { ...repEntry, label, linkedFields: names.slice() };
|
|
292
305
|
newFields.push(rep);
|
|
293
306
|
}
|
|
@@ -564,14 +577,13 @@ grameneFieldCatalog.selectFieldCatalog = createSelector(
|
|
|
564
577
|
const present = new Set(fieldNames);
|
|
565
578
|
// Build a synthetic doc from the discovered field names; values carry the
|
|
566
579
|
// right JS type so inferType picks the correct multiValued/type (arrays
|
|
567
|
-
// for multi-valued fields, scalars otherwise). Derive pval
|
|
568
|
-
//
|
|
580
|
+
// for multi-valued fields, scalars otherwise). Derive the pval companion
|
|
581
|
+
// from every l2fc field.
|
|
569
582
|
const doc = {};
|
|
570
583
|
for (const name of present) {
|
|
571
584
|
doc[name] = synthesizedValue(name);
|
|
572
585
|
if (/_l2fc_attr_f$/.test(name)) {
|
|
573
586
|
doc[name.replace('_l2fc_', '_pval_')] = 0;
|
|
574
|
-
doc[name.replace('_l2fc_', '_logfc_')] = 0;
|
|
575
587
|
}
|
|
576
588
|
}
|
|
577
589
|
const catalog = buildCatalog([doc]);
|
package/src/bundles/views.js
CHANGED
|
@@ -149,6 +149,18 @@ const grameneViews = {
|
|
|
149
149
|
}
|
|
150
150
|
return { ...raw, options };
|
|
151
151
|
}
|
|
152
|
+
),
|
|
153
|
+
// Set of view ids whose `show` is currently 'on'. Used by data bundles to
|
|
154
|
+
// gate their auto-fetch reactors so we don't pay the cost of loading data
|
|
155
|
+
// for views the user has turned off.
|
|
156
|
+
selectGrameneViewsOn: createSelector(
|
|
157
|
+
'selectGrameneViews',
|
|
158
|
+
(views) => {
|
|
159
|
+
const ids = new Set();
|
|
160
|
+
const opts = (views && views.options) || [];
|
|
161
|
+
for (const v of opts) if (v && v.show === 'on') ids.add(v.id);
|
|
162
|
+
return ids;
|
|
163
|
+
}
|
|
152
164
|
)
|
|
153
165
|
};
|
|
154
166
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const EXPR_FIELD_RE = /^(E[-_][A-Za-z0-9_-]+?)_g(\d+)__expr$/;
|
|
2
|
-
const DIFFEXPR_FIELD_RE = /^(E[-_][A-Za-z0-9_-]+?)_g(\d+)_g(\d+)_(pval|
|
|
2
|
+
const DIFFEXPR_FIELD_RE = /^(E[-_][A-Za-z0-9_-]+?)_g(\d+)_g(\d+)_(pval|l2fc)_attr_([a-z])$/;
|
|
3
3
|
|
|
4
4
|
export const EXPRESSION_EXTRA_COLUMNS = [
|
|
5
5
|
'experiment',
|
|
@@ -132,7 +132,7 @@ export function resolveDiffExpressionForDoc(doc, diffExpressionFields, expressio
|
|
|
132
132
|
byContrast.set(key, entry);
|
|
133
133
|
}
|
|
134
134
|
if (parsed.stat === 'pval') entry.pval = val;
|
|
135
|
-
else entry.l2fc = val;
|
|
135
|
+
else entry.l2fc = val;
|
|
136
136
|
}
|
|
137
137
|
const maxPval = cutoffs && cutoffs.diffMaxPval;
|
|
138
138
|
const maxPvalActive = maxPval !== null && maxPval !== undefined && maxPval !== '' && Number.isFinite(+maxPval);
|
|
@@ -1,21 +1,31 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, { useCallback, useMemo, useState } from 'react';
|
|
2
2
|
import { AgGridReact } from 'ag-grid-react';
|
|
3
|
-
import { OverlayTrigger, Popover } from 'react-bootstrap';
|
|
4
3
|
import 'ag-grid-community/styles/ag-grid.css';
|
|
5
4
|
import 'ag-grid-community/styles/ag-theme-quartz.css';
|
|
6
5
|
|
|
7
6
|
const baseColDefs = [
|
|
8
7
|
{ field: 'id', headerName: 'Gene ID', pinned: 'left', width: 160, suppressMovable: true },
|
|
9
|
-
{ field: 'name', headerName: 'Name', width: 140, suppressMovable: true }
|
|
8
|
+
{ field: 'name', headerName: 'Name', pinned: 'left', width: 140, suppressMovable: true }
|
|
10
9
|
];
|
|
11
10
|
|
|
11
|
+
// Hoisted so the reference is stable across renders. ag-grid otherwise sees a
|
|
12
|
+
// "new" defaultColDef on every parent re-render (e.g. when hovering a row
|
|
13
|
+
// triggers setHoveredId in the parent) and re-applies column state, which
|
|
14
|
+
// snaps any user-resized columns back to their original widths.
|
|
15
|
+
const DEFAULT_COL_DEF = {
|
|
16
|
+
resizable: true,
|
|
17
|
+
sortable: true,
|
|
18
|
+
filter: false,
|
|
19
|
+
suppressMenu: true
|
|
20
|
+
};
|
|
21
|
+
|
|
12
22
|
function arraysEqual(a, b) {
|
|
13
23
|
if (a.length !== b.length) return false;
|
|
14
24
|
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
15
25
|
return true;
|
|
16
26
|
}
|
|
17
27
|
|
|
18
|
-
function buildFieldInfo(fields, studies, expressionSamples) {
|
|
28
|
+
export function buildFieldInfo(fields, studies, expressionSamples) {
|
|
19
29
|
const info = {};
|
|
20
30
|
if (!fields || !studies || !expressionSamples) return info;
|
|
21
31
|
const wanted = new Set(fields);
|
|
@@ -49,114 +59,258 @@ function buildFieldInfo(fields, studies, expressionSamples) {
|
|
|
49
59
|
return info;
|
|
50
60
|
}
|
|
51
61
|
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
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;
|
|
62
|
+
// Plain right-aligned label cell for the metadata header rows above the
|
|
63
|
+
// Gene ID / Name columns. Rendered as a React component (instead of
|
|
64
|
+
// relying on ag-grid's default group label) so the alignment is controlled
|
|
65
|
+
// by our own DOM and isn't fighting the quartz theme's CSS.
|
|
66
|
+
const LabelHeaderGroup = (props) => {
|
|
67
|
+
const { displayName } = props;
|
|
68
|
+
return <div className="exprviz-label-header">{displayName}</div>;
|
|
69
|
+
};
|
|
98
70
|
|
|
71
|
+
// Same idea, but for the cells that toggle a section between expanded and
|
|
72
|
+
// collapsed. The caret + label sit at the right edge.
|
|
73
|
+
const ToggleHeaderGroup = (props) => {
|
|
74
|
+
const { displayName, onToggle, expanded, suffix } = props;
|
|
99
75
|
return (
|
|
100
|
-
<div
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
)}
|
|
76
|
+
<div
|
|
77
|
+
className="exprviz-toggle-header"
|
|
78
|
+
onClick={onToggle}
|
|
79
|
+
role="button"
|
|
80
|
+
tabIndex={0}
|
|
81
|
+
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggle && onToggle(); } }}
|
|
82
|
+
title={expanded ? 'Click to collapse' : 'Click to expand'}
|
|
83
|
+
>
|
|
84
|
+
<span className="exprviz-toggle-caret">{expanded ? '▼' : '▶'}</span>
|
|
85
|
+
{' '}{displayName}{suffix ? ` ${suffix}` : ''}
|
|
132
86
|
</div>
|
|
133
87
|
);
|
|
134
88
|
};
|
|
135
89
|
|
|
90
|
+
function exprValueFormatter(p) {
|
|
91
|
+
const v = p.value;
|
|
92
|
+
if (v == null) return '';
|
|
93
|
+
if (Array.isArray(v)) return v.join(', ');
|
|
94
|
+
if (typeof v === 'object') return JSON.stringify(v);
|
|
95
|
+
return String(v);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Discover every distinct factor and characteristic type across the loaded
|
|
99
|
+
// fields. Each becomes one header row (with adjacent equal-value cells
|
|
100
|
+
// merged). Order is alphabetical for stability across reloads.
|
|
101
|
+
function collectMetadataTypes(fields, fieldInfo) {
|
|
102
|
+
const factorTypes = new Set();
|
|
103
|
+
const charTypes = new Set();
|
|
104
|
+
for (const f of fields || []) {
|
|
105
|
+
const info = fieldInfo[f];
|
|
106
|
+
if (!info) continue;
|
|
107
|
+
Object.keys(info.factors || {}).forEach(t => factorTypes.add(t));
|
|
108
|
+
Object.keys(info.characteristics || {}).forEach(t => charTypes.add(t));
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
factorTypes: Array.from(factorTypes).sort(),
|
|
112
|
+
charTypes: Array.from(charTypes).sort()
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Wrap a leaf column in N nested column groups so it participates at every
|
|
117
|
+
// header row, with a custom label per level. Lets the Gene ID / Name columns
|
|
118
|
+
// carry the row labels ("Study"/"Factor"/"Characteristic" and the type name).
|
|
119
|
+
function wrapLeafWithLabels(leaf, labels) {
|
|
120
|
+
let wrapped = leaf;
|
|
121
|
+
for (let i = labels.length - 1; i >= 0; i--) {
|
|
122
|
+
const label = labels[i];
|
|
123
|
+
const cls = label.headerClass
|
|
124
|
+
? `${label.headerClass} exprviz-hg-labels`
|
|
125
|
+
: 'exprviz-hg-labels';
|
|
126
|
+
wrapped = {
|
|
127
|
+
headerName: label.headerName,
|
|
128
|
+
headerClass: cls,
|
|
129
|
+
// Always render through one of our components so the alignment and
|
|
130
|
+
// spacing are controlled by our own DOM, not ag-grid's defaults.
|
|
131
|
+
headerGroupComponent: label.headerGroupComponent || LabelHeaderGroup,
|
|
132
|
+
headerGroupComponentParams: label.headerGroupComponentParams,
|
|
133
|
+
children: [wrapped]
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return wrapped;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Build the column tree:
|
|
140
|
+
// row 1 — Study | Title | <study description>…
|
|
141
|
+
// row 2..(2+F-1) — Factor | <factor type> | <value or blank>…
|
|
142
|
+
// row 2+F..end-1 — Characteristic | <char type> | <value or blank>…
|
|
143
|
+
// leaf row — Gene ID | Name | g3, g4, …
|
|
144
|
+
// Adjacent expression cells sharing the same value at a given level merge
|
|
145
|
+
// (because they're children of one group definition). Blank values for a
|
|
146
|
+
// type that's defined only in other studies render as empty cells, and
|
|
147
|
+
// adjacent blanks under the same parent merge automatically.
|
|
148
|
+
function buildColumnDefs(fields, fieldInfo, expanded, toggles) {
|
|
149
|
+
if (!fields || fields.length === 0) return baseColDefs;
|
|
150
|
+
const { factorTypes, charTypes } = collectMetadataTypes(fields, fieldInfo);
|
|
151
|
+
|
|
152
|
+
const levels = [{ kind: 'study', getValue: (info) => (info && info.studyDescription) || '' }];
|
|
153
|
+
if (expanded.factors) {
|
|
154
|
+
for (const t of factorTypes) {
|
|
155
|
+
levels.push({
|
|
156
|
+
kind: 'factor', type: t,
|
|
157
|
+
getValue: (info) => (info && info.factors && info.factors[t]) || '',
|
|
158
|
+
headerClass: 'exprviz-hg-factors'
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
} else if (factorTypes.length > 0) {
|
|
162
|
+
// One placeholder level standing in for the collapsed factor rows.
|
|
163
|
+
levels.push({
|
|
164
|
+
kind: 'factors-collapsed',
|
|
165
|
+
getValue: () => '',
|
|
166
|
+
headerClass: 'exprviz-hg-factors exprviz-hg-collapsed'
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
if (expanded.chars) {
|
|
170
|
+
for (const t of charTypes) {
|
|
171
|
+
levels.push({
|
|
172
|
+
kind: 'char', type: t,
|
|
173
|
+
getValue: (info) => (info && info.characteristics && info.characteristics[t]) || '',
|
|
174
|
+
headerClass: 'exprviz-hg-chars'
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
} else if (charTypes.length > 0) {
|
|
178
|
+
levels.push({
|
|
179
|
+
kind: 'chars-collapsed',
|
|
180
|
+
getValue: () => '',
|
|
181
|
+
headerClass: 'exprviz-hg-chars exprviz-hg-collapsed'
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Walk fields, opening a new group at level i (and resetting all levels
|
|
186
|
+
// below) the first time the value at level i differs from the previous
|
|
187
|
+
// field's. Same value → same parent group → ag-grid merges the cells.
|
|
188
|
+
const exprTopGroups = [];
|
|
189
|
+
const currentGroups = new Array(levels.length).fill(null);
|
|
190
|
+
const currentKeys = new Array(levels.length).fill(undefined);
|
|
191
|
+
|
|
192
|
+
for (const f of fields) {
|
|
193
|
+
const info = fieldInfo[f] || {};
|
|
194
|
+
let firstChange = levels.length;
|
|
195
|
+
for (let i = 0; i < levels.length; i++) {
|
|
196
|
+
const key = levels[i].getValue(info);
|
|
197
|
+
if (currentGroups[i] === null || currentKeys[i] !== key) {
|
|
198
|
+
firstChange = i;
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
for (let i = firstChange; i < levels.length; i++) {
|
|
203
|
+
const key = levels[i].getValue(info);
|
|
204
|
+
const group = {
|
|
205
|
+
headerName: key,
|
|
206
|
+
headerClass: levels[i].headerClass,
|
|
207
|
+
children: []
|
|
208
|
+
};
|
|
209
|
+
currentGroups[i] = group;
|
|
210
|
+
currentKeys[i] = key;
|
|
211
|
+
if (i === 0) exprTopGroups.push(group);
|
|
212
|
+
else currentGroups[i - 1].children.push(group);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const leaf = {
|
|
216
|
+
field: f,
|
|
217
|
+
headerName: info.group || f.replace(/__expr$/, ''),
|
|
218
|
+
width: 160,
|
|
219
|
+
suppressMovable: false,
|
|
220
|
+
valueFormatter: exprValueFormatter
|
|
221
|
+
};
|
|
222
|
+
currentGroups[levels.length - 1].children.push(leaf);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Labels for the two pinned columns. The leftmost column carries the row
|
|
226
|
+
// category ("Study" / "Factor" / "Characteristic"); the next column shows
|
|
227
|
+
// the specific row name (the literal "Title" for the study row, then each
|
|
228
|
+
// factor/characteristic type name). The first row of each
|
|
229
|
+
// factor/characteristic section gets a clickable caret that toggles the
|
|
230
|
+
// section between expanded (one row per type) and collapsed (one
|
|
231
|
+
// placeholder row).
|
|
232
|
+
const studyLabels = [{ headerName: 'Study' }];
|
|
233
|
+
const titleLabels = [{ headerName: 'Title' }];
|
|
234
|
+
|
|
235
|
+
if (expanded.factors) {
|
|
236
|
+
factorTypes.forEach((t, i) => {
|
|
237
|
+
studyLabels.push({
|
|
238
|
+
headerName: 'Factor',
|
|
239
|
+
headerClass: 'exprviz-hg-factors',
|
|
240
|
+
...(i === 0 ? {
|
|
241
|
+
headerGroupComponent: ToggleHeaderGroup,
|
|
242
|
+
headerGroupComponentParams: { onToggle: toggles.toggleFactors, expanded: true }
|
|
243
|
+
} : {})
|
|
244
|
+
});
|
|
245
|
+
titleLabels.push({ headerName: t, headerClass: 'exprviz-hg-factors' });
|
|
246
|
+
});
|
|
247
|
+
} else if (factorTypes.length > 0) {
|
|
248
|
+
studyLabels.push({
|
|
249
|
+
headerName: 'Factors',
|
|
250
|
+
headerClass: 'exprviz-hg-factors exprviz-hg-collapsed',
|
|
251
|
+
headerGroupComponent: ToggleHeaderGroup,
|
|
252
|
+
headerGroupComponentParams: {
|
|
253
|
+
onToggle: toggles.toggleFactors,
|
|
254
|
+
expanded: false,
|
|
255
|
+
suffix: `(${factorTypes.length})`
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
titleLabels.push({ headerName: '', headerClass: 'exprviz-hg-factors exprviz-hg-collapsed' });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (expanded.chars) {
|
|
262
|
+
charTypes.forEach((t, i) => {
|
|
263
|
+
studyLabels.push({
|
|
264
|
+
headerName: 'Characteristic',
|
|
265
|
+
headerClass: 'exprviz-hg-chars',
|
|
266
|
+
...(i === 0 ? {
|
|
267
|
+
headerGroupComponent: ToggleHeaderGroup,
|
|
268
|
+
headerGroupComponentParams: { onToggle: toggles.toggleChars, expanded: true }
|
|
269
|
+
} : {})
|
|
270
|
+
});
|
|
271
|
+
titleLabels.push({ headerName: t, headerClass: 'exprviz-hg-chars' });
|
|
272
|
+
});
|
|
273
|
+
} else if (charTypes.length > 0) {
|
|
274
|
+
studyLabels.push({
|
|
275
|
+
headerName: 'Characteristics',
|
|
276
|
+
headerClass: 'exprviz-hg-chars exprviz-hg-collapsed',
|
|
277
|
+
headerGroupComponent: ToggleHeaderGroup,
|
|
278
|
+
headerGroupComponentParams: {
|
|
279
|
+
onToggle: toggles.toggleChars,
|
|
280
|
+
expanded: false,
|
|
281
|
+
suffix: `(${charTypes.length})`
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
titleLabels.push({ headerName: '', headerClass: 'exprviz-hg-chars exprviz-hg-collapsed' });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const idCol = wrapLeafWithLabels(baseColDefs[0], studyLabels);
|
|
288
|
+
const nameCol = wrapLeafWithLabels(baseColDefs[1], titleLabels);
|
|
289
|
+
|
|
290
|
+
return [idCol, nameCol, ...exprTopGroups];
|
|
291
|
+
}
|
|
292
|
+
|
|
136
293
|
const ExprTable = ({ rows, fields, onReorder, studies, expressionSamples, onHoverRow }) => {
|
|
137
294
|
const fieldInfo = useMemo(
|
|
138
295
|
() => buildFieldInfo(fields, studies, expressionSamples),
|
|
139
296
|
[fields, studies, expressionSamples]
|
|
140
297
|
);
|
|
141
298
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
}));
|
|
158
|
-
return [...baseColDefs, ...expressionCols];
|
|
159
|
-
}, [fields, fieldInfo]);
|
|
299
|
+
// Default: factor rows expanded, characteristic rows collapsed (per spec).
|
|
300
|
+
const [expanded, setExpanded] = useState({ factors: true, chars: false });
|
|
301
|
+
const toggleFactors = useCallback(
|
|
302
|
+
() => setExpanded(e => ({ ...e, factors: !e.factors })),
|
|
303
|
+
[]
|
|
304
|
+
);
|
|
305
|
+
const toggleChars = useCallback(
|
|
306
|
+
() => setExpanded(e => ({ ...e, chars: !e.chars })),
|
|
307
|
+
[]
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
const columnDefs = useMemo(
|
|
311
|
+
() => buildColumnDefs(fields, fieldInfo, expanded, { toggleFactors, toggleChars }),
|
|
312
|
+
[fields, fieldInfo, expanded, toggleFactors, toggleChars]
|
|
313
|
+
);
|
|
160
314
|
|
|
161
315
|
const onColumnMoved = (e) => {
|
|
162
316
|
if (!onReorder || !e.finished) return;
|
|
@@ -179,10 +333,12 @@ const ExprTable = ({ rows, fields, onReorder, studies, expressionSamples, onHove
|
|
|
179
333
|
<AgGridReact
|
|
180
334
|
rowData={rows}
|
|
181
335
|
columnDefs={columnDefs}
|
|
182
|
-
defaultColDef={
|
|
336
|
+
defaultColDef={DEFAULT_COL_DEF}
|
|
183
337
|
animateRows={false}
|
|
184
338
|
suppressFieldDotNotation={true}
|
|
185
339
|
suppressDragLeaveHidesColumns={true}
|
|
340
|
+
suppressColumnVirtualisation={true}
|
|
341
|
+
groupHeaderHeight={24}
|
|
186
342
|
onColumnMoved={onColumnMoved}
|
|
187
343
|
onCellMouseOver={e => onHoverRow && onHoverRow(e.data && e.data.id)}
|
|
188
344
|
onCellMouseOut={() => onHoverRow && onHoverRow(null)}
|