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.
- package/.claude/settings.local.json +5 -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 +314 -0
- package/dist/index.css.map +1 -1
- package/dist/index.js +2022 -13
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/bundles/exprViz.js +422 -0
- package/src/bundles/index.js +2 -1
- package/src/bundles/views.js +18 -12
- package/src/components/exprViz/ExprTable.js +194 -0
- package/src/components/exprViz/ExprVizView.js +294 -0
- package/src/components/exprViz/FieldsModal.js +414 -0
- package/src/components/exprViz/ParallelCoordsPlot.js +314 -0
- package/src/components/exprViz/styles.css +316 -0
- package/src/components/geneSearchUI.js +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gramene-search",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.4",
|
|
4
4
|
"description": "search wrapper for gramene",
|
|
5
5
|
"source": "src/index.js",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"ag-grid-community": "^31.1.1",
|
|
28
28
|
"ag-grid-react": "^31.0.3",
|
|
29
29
|
"axios": "^1.6.8",
|
|
30
|
+
"d3": "^7.9.0",
|
|
30
31
|
"firebase": "^11.0.1",
|
|
31
32
|
"flat-to-nested": "^1.1.1",
|
|
32
33
|
"gramene-bins-client": "^2.3.3",
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { createSelector } from 'redux-bundler';
|
|
2
|
+
|
|
3
|
+
// State shape:
|
|
4
|
+
// {
|
|
5
|
+
// pivot: { status, signature, data: { [taxon_id]: <gene count> }, error, requestId },
|
|
6
|
+
// activeTaxon: <taxon_id|null>,
|
|
7
|
+
// byTaxon: {
|
|
8
|
+
// [taxon_id]: {
|
|
9
|
+
// selectedFields: [<solr field name>...],
|
|
10
|
+
// fieldsModalOpen: <bool>,
|
|
11
|
+
// fetch: { status, offset, total, signature, requestId, error },
|
|
12
|
+
// rows: [<doc>...]
|
|
13
|
+
// }
|
|
14
|
+
// }
|
|
15
|
+
// }
|
|
16
|
+
// Per-taxon study lists are derived in the view layer from expressionStudies.
|
|
17
|
+
|
|
18
|
+
const PAGE_SIZE = 200;
|
|
19
|
+
|
|
20
|
+
let pivotPendingId = 0;
|
|
21
|
+
const fetchPending = {}; // per-taxon request id
|
|
22
|
+
const attrsPending = {}; // per-taxon request id for available attrs
|
|
23
|
+
const existencePending = {}; // per-taxon request id for field-existence check
|
|
24
|
+
|
|
25
|
+
function pivotSignature(store) {
|
|
26
|
+
const q = store.selectGrameneFiltersQueryString();
|
|
27
|
+
const g = store.selectGrameneGenomes();
|
|
28
|
+
const m = store.selectGrameneMaps() || {};
|
|
29
|
+
const taxa = Object.keys(g.active || {}).filter(tid => m[tid] && !m[tid].hidden);
|
|
30
|
+
return `${q}|${taxa.sort().join(',')}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function fetchSignature(store, taxon) {
|
|
34
|
+
const q = store.selectGrameneFiltersQueryString();
|
|
35
|
+
const ev = store.selectExprViz();
|
|
36
|
+
const sel = (ev.byTaxon[taxon] && ev.byTaxon[taxon].selectedFields) || [];
|
|
37
|
+
return `${q}|${taxon}|${sel.slice().sort().join(',')}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const ALWAYS_FL = ['id', 'name', 'system_name', 'taxon_id'];
|
|
41
|
+
|
|
42
|
+
const exprViz = {
|
|
43
|
+
name: 'exprViz',
|
|
44
|
+
|
|
45
|
+
getReducer: () => {
|
|
46
|
+
const initialState = {
|
|
47
|
+
pivot: { status: 'idle', signature: null, data: {}, error: null, requestId: 0 },
|
|
48
|
+
activeTaxon: null,
|
|
49
|
+
byTaxon: {}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function ensureTaxon(state, taxon) {
|
|
53
|
+
if (state.byTaxon[taxon]) return state.byTaxon[taxon];
|
|
54
|
+
return {
|
|
55
|
+
selectedFields: [],
|
|
56
|
+
fieldsModalOpen: false,
|
|
57
|
+
fetch: { status: 'idle', offset: 0, total: 0, signature: null, requestId: 0, error: null },
|
|
58
|
+
rows: [],
|
|
59
|
+
availableAttrs: null,
|
|
60
|
+
attrsSignature: null,
|
|
61
|
+
attrsRequestId: 0,
|
|
62
|
+
fieldExistence: null,
|
|
63
|
+
existenceStatus: 'idle',
|
|
64
|
+
existenceSignature: null,
|
|
65
|
+
existenceRequestId: 0
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return (state = initialState, { type, payload }) => {
|
|
70
|
+
switch (type) {
|
|
71
|
+
case 'EXPRVIZ_PIVOT_STARTED':
|
|
72
|
+
return { ...state, pivot: { status: 'loading', signature: payload.signature, data: state.pivot.data, error: null, requestId: payload.requestId } };
|
|
73
|
+
case 'EXPRVIZ_PIVOT_SUCCEEDED':
|
|
74
|
+
if (payload.requestId !== state.pivot.requestId) return state;
|
|
75
|
+
return { ...state, pivot: { status: 'ready', signature: state.pivot.signature, data: payload.data, error: null, requestId: payload.requestId } };
|
|
76
|
+
case 'EXPRVIZ_PIVOT_FAILED':
|
|
77
|
+
if (payload.requestId !== state.pivot.requestId) return state;
|
|
78
|
+
return { ...state, pivot: { ...state.pivot, status: 'error', error: payload.error } };
|
|
79
|
+
|
|
80
|
+
case 'EXPRVIZ_ACTIVE_TAXON_SET':
|
|
81
|
+
return { ...state, activeTaxon: payload };
|
|
82
|
+
|
|
83
|
+
case 'EXPRVIZ_FIELDS_MODAL_TOGGLED': {
|
|
84
|
+
const t = ensureTaxon(state, payload.taxon);
|
|
85
|
+
return {
|
|
86
|
+
...state,
|
|
87
|
+
byTaxon: { ...state.byTaxon, [payload.taxon]: { ...t, fieldsModalOpen: payload.open } }
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
case 'EXPRVIZ_FIELDS_SET': {
|
|
91
|
+
const t = ensureTaxon(state, payload.taxon);
|
|
92
|
+
return {
|
|
93
|
+
...state,
|
|
94
|
+
byTaxon: {
|
|
95
|
+
...state.byTaxon,
|
|
96
|
+
[payload.taxon]: {
|
|
97
|
+
...t,
|
|
98
|
+
selectedFields: payload.fields,
|
|
99
|
+
rows: [],
|
|
100
|
+
fetch: { status: 'idle', offset: 0, total: 0, signature: null, requestId: 0, error: null }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
case 'EXPRVIZ_FIELDS_REORDERED': {
|
|
106
|
+
const t = state.byTaxon[payload.taxon];
|
|
107
|
+
if (!t) return state;
|
|
108
|
+
// Same set of fields — preserve rows; only the column/axis order changes.
|
|
109
|
+
const setA = new Set(t.selectedFields);
|
|
110
|
+
const setB = new Set(payload.fields);
|
|
111
|
+
if (setA.size !== setB.size || ![...setA].every(f => setB.has(f))) {
|
|
112
|
+
return state;
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
...state,
|
|
116
|
+
byTaxon: {
|
|
117
|
+
...state.byTaxon,
|
|
118
|
+
[payload.taxon]: { ...t, selectedFields: payload.fields }
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
case 'EXPRVIZ_FETCH_STARTED': {
|
|
124
|
+
const t = ensureTaxon(state, payload.taxon);
|
|
125
|
+
return {
|
|
126
|
+
...state,
|
|
127
|
+
byTaxon: {
|
|
128
|
+
...state.byTaxon,
|
|
129
|
+
[payload.taxon]: {
|
|
130
|
+
...t,
|
|
131
|
+
fetch: { ...t.fetch, status: 'loading', signature: payload.signature, requestId: payload.requestId, error: null }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
case 'EXPRVIZ_FETCH_BATCH': {
|
|
137
|
+
const t = state.byTaxon[payload.taxon];
|
|
138
|
+
if (!t || payload.requestId !== t.fetch.requestId) return state;
|
|
139
|
+
const rows = t.rows.concat(payload.docs);
|
|
140
|
+
return {
|
|
141
|
+
...state,
|
|
142
|
+
byTaxon: {
|
|
143
|
+
...state.byTaxon,
|
|
144
|
+
[payload.taxon]: {
|
|
145
|
+
...t,
|
|
146
|
+
rows,
|
|
147
|
+
fetch: { ...t.fetch, offset: rows.length, total: payload.total, status: rows.length >= payload.total ? 'done' : 'loading' }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
case 'EXPRVIZ_FETCH_FAILED': {
|
|
153
|
+
const t = state.byTaxon[payload.taxon];
|
|
154
|
+
if (!t || payload.requestId !== t.fetch.requestId) return state;
|
|
155
|
+
return {
|
|
156
|
+
...state,
|
|
157
|
+
byTaxon: {
|
|
158
|
+
...state.byTaxon,
|
|
159
|
+
[payload.taxon]: { ...t, fetch: { ...t.fetch, status: 'error', error: payload.error } }
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
case 'EXPRVIZ_ATTRS_STARTED': {
|
|
165
|
+
const t = ensureTaxon(state, payload.taxon);
|
|
166
|
+
return {
|
|
167
|
+
...state,
|
|
168
|
+
byTaxon: {
|
|
169
|
+
...state.byTaxon,
|
|
170
|
+
[payload.taxon]: { ...t, attrsRequestId: payload.requestId, attrsSignature: payload.signature }
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
case 'EXPRVIZ_ATTRS_SUCCEEDED': {
|
|
175
|
+
const t = state.byTaxon[payload.taxon];
|
|
176
|
+
if (!t || payload.requestId !== t.attrsRequestId) return state;
|
|
177
|
+
return {
|
|
178
|
+
...state,
|
|
179
|
+
byTaxon: {
|
|
180
|
+
...state.byTaxon,
|
|
181
|
+
[payload.taxon]: { ...t, availableAttrs: payload.attrs }
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
case 'EXPRVIZ_EXISTENCE_STARTED': {
|
|
187
|
+
const t = ensureTaxon(state, payload.taxon);
|
|
188
|
+
return {
|
|
189
|
+
...state,
|
|
190
|
+
byTaxon: {
|
|
191
|
+
...state.byTaxon,
|
|
192
|
+
[payload.taxon]: {
|
|
193
|
+
...t,
|
|
194
|
+
existenceStatus: 'loading',
|
|
195
|
+
existenceRequestId: payload.requestId,
|
|
196
|
+
existenceSignature: payload.signature
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
case 'EXPRVIZ_EXISTENCE_SUCCEEDED': {
|
|
202
|
+
const t = state.byTaxon[payload.taxon];
|
|
203
|
+
if (!t || payload.requestId !== t.existenceRequestId) return state;
|
|
204
|
+
return {
|
|
205
|
+
...state,
|
|
206
|
+
byTaxon: {
|
|
207
|
+
...state.byTaxon,
|
|
208
|
+
[payload.taxon]: { ...t, existenceStatus: 'ready', fieldExistence: payload.counts }
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
case 'GRAMENE_SEARCH_CLEARED': {
|
|
214
|
+
// Search context changed — invalidate pivot and any loaded rows.
|
|
215
|
+
// Selected fields are kept so the user can re-run the load.
|
|
216
|
+
const newByTaxon = {};
|
|
217
|
+
for (const tid of Object.keys(state.byTaxon)) {
|
|
218
|
+
newByTaxon[tid] = {
|
|
219
|
+
...state.byTaxon[tid],
|
|
220
|
+
rows: [],
|
|
221
|
+
fetch: { status: 'idle', offset: 0, total: 0, signature: null, requestId: 0, error: null },
|
|
222
|
+
availableAttrs: null,
|
|
223
|
+
attrsSignature: null,
|
|
224
|
+
fieldExistence: null,
|
|
225
|
+
existenceStatus: 'idle',
|
|
226
|
+
existenceSignature: null
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
...state,
|
|
231
|
+
pivot: { status: 'idle', signature: null, data: {}, error: null, requestId: 0 },
|
|
232
|
+
byTaxon: newByTaxon
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
default:
|
|
237
|
+
return state;
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
doFetchExprVizPivot: () => ({ dispatch, store }) => {
|
|
243
|
+
const requestId = ++pivotPendingId;
|
|
244
|
+
const signature = pivotSignature(store);
|
|
245
|
+
dispatch({ type: 'EXPRVIZ_PIVOT_STARTED', payload: { requestId, signature } });
|
|
246
|
+
|
|
247
|
+
const api = store.selectGrameneAPI();
|
|
248
|
+
const q = store.selectGrameneFiltersQueryString();
|
|
249
|
+
const g = store.selectGrameneGenomes();
|
|
250
|
+
const m = store.selectGrameneMaps() || {};
|
|
251
|
+
const taxa = Object.keys(g.active || {}).filter(tid => m[tid] && !m[tid].hidden);
|
|
252
|
+
const fq = taxa.length ? `&fq=taxon_id:(${taxa.join(' OR ')})` : '';
|
|
253
|
+
const facetField = "{!facet.limit='300' facet.mincount='1' key='taxon_id'}taxon_id";
|
|
254
|
+
const url = `${api}/search?q=${q}${fq}&fq=expressed_in_gxa_attr_ss:*&rows=0&facet=true&facet.field=${facetField}`;
|
|
255
|
+
|
|
256
|
+
fetch(url)
|
|
257
|
+
.then(r => r.json())
|
|
258
|
+
.then(json => {
|
|
259
|
+
const arr = (json && json.facet_counts && json.facet_counts.facet_fields && json.facet_counts.facet_fields.taxon_id) || [];
|
|
260
|
+
const data = {};
|
|
261
|
+
for (let i = 0; i < arr.length; i += 2) {
|
|
262
|
+
data[arr[i]] = arr[i + 1];
|
|
263
|
+
}
|
|
264
|
+
if (Object.keys(data).length === 0) {
|
|
265
|
+
console.warn('[exprViz] no taxon_ids found with expression', { url, json });
|
|
266
|
+
}
|
|
267
|
+
dispatch({ type: 'EXPRVIZ_PIVOT_SUCCEEDED', payload: { requestId, data } });
|
|
268
|
+
})
|
|
269
|
+
.catch(err => {
|
|
270
|
+
dispatch({ type: 'EXPRVIZ_PIVOT_FAILED', payload: { requestId, error: String(err) } });
|
|
271
|
+
});
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
doSetExprVizActiveTaxon: tid => ({ dispatch }) =>
|
|
275
|
+
dispatch({ type: 'EXPRVIZ_ACTIVE_TAXON_SET', payload: tid }),
|
|
276
|
+
|
|
277
|
+
doToggleExprVizFieldsModal: (taxon, open) => ({ dispatch, store }) => {
|
|
278
|
+
dispatch({ type: 'EXPRVIZ_FIELDS_MODAL_TOGGLED', payload: { taxon, open: !!open } });
|
|
279
|
+
if (open) store.doFetchExprVizAvailableAttrs(taxon);
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
doFetchExprVizAvailableAttrs: taxon => ({ dispatch, store }) => {
|
|
283
|
+
const q = store.selectGrameneFiltersQueryString();
|
|
284
|
+
const signature = `${q}|${taxon}`;
|
|
285
|
+
const ev = store.selectExprViz();
|
|
286
|
+
const t = ev.byTaxon[taxon];
|
|
287
|
+
if (t && t.availableAttrs && t.attrsSignature === signature) return;
|
|
288
|
+
const requestId = (attrsPending[taxon] = (attrsPending[taxon] || 0) + 1);
|
|
289
|
+
dispatch({ type: 'EXPRVIZ_ATTRS_STARTED', payload: { taxon, requestId, signature } });
|
|
290
|
+
|
|
291
|
+
const api = store.selectGrameneAPI();
|
|
292
|
+
const facetField = "{!facet.limit='2000' facet.mincount='1' key='attrs'}expressed_in_gxa_attr_ss";
|
|
293
|
+
const url = `${api}/search?q=${q}&fq=taxon_id:${taxon}&fq=expressed_in_gxa_attr_ss:*&rows=0&facet=true&facet.field=${facetField}`;
|
|
294
|
+
fetch(url)
|
|
295
|
+
.then(r => r.json())
|
|
296
|
+
.then(json => {
|
|
297
|
+
if (requestId !== attrsPending[taxon]) return;
|
|
298
|
+
const arr = (json && json.facet_counts && json.facet_counts.facet_fields && json.facet_counts.facet_fields.attrs) || [];
|
|
299
|
+
const attrs = [];
|
|
300
|
+
for (let i = 0; i < arr.length; i += 2) attrs.push(arr[i]);
|
|
301
|
+
dispatch({ type: 'EXPRVIZ_ATTRS_SUCCEEDED', payload: { taxon, requestId, attrs } });
|
|
302
|
+
})
|
|
303
|
+
.catch(() => { /* swallow — non-fatal; modal still works without filtering */ });
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
doSetExprVizFields: (taxon, fields) => ({ dispatch }) =>
|
|
307
|
+
dispatch({ type: 'EXPRVIZ_FIELDS_SET', payload: { taxon, fields } }),
|
|
308
|
+
|
|
309
|
+
doReorderExprVizFields: (taxon, fields) => ({ dispatch }) =>
|
|
310
|
+
dispatch({ type: 'EXPRVIZ_FIELDS_REORDERED', payload: { taxon, fields } }),
|
|
311
|
+
|
|
312
|
+
doFetchExprVizData: taxon => ({ dispatch, store }) => {
|
|
313
|
+
const ev = store.selectExprViz();
|
|
314
|
+
const t = ev.byTaxon[taxon];
|
|
315
|
+
if (!t || t.selectedFields.length === 0) return;
|
|
316
|
+
const requestId = (fetchPending[taxon] = (fetchPending[taxon] || 0) + 1);
|
|
317
|
+
const signature = fetchSignature(store, taxon);
|
|
318
|
+
dispatch({ type: 'EXPRVIZ_FETCH_STARTED', payload: { taxon, requestId, signature } });
|
|
319
|
+
|
|
320
|
+
const api = store.selectGrameneAPI();
|
|
321
|
+
const q = store.selectGrameneFiltersQueryString();
|
|
322
|
+
const fl = [...new Set([...ALWAYS_FL, ...t.selectedFields])].join(',');
|
|
323
|
+
|
|
324
|
+
const fetchPage = (offset) => {
|
|
325
|
+
if (requestId !== fetchPending[taxon]) return; // superseded
|
|
326
|
+
const url = `${api}/search?q=${q}&fq=taxon_id:${taxon}&fl=${fl}&rows=${PAGE_SIZE}&start=${offset}`;
|
|
327
|
+
fetch(url)
|
|
328
|
+
.then(r => r.json())
|
|
329
|
+
.then(json => {
|
|
330
|
+
if (requestId !== fetchPending[taxon]) return;
|
|
331
|
+
const docs = (json.response && json.response.docs) || [];
|
|
332
|
+
const total = (json.response && json.response.numFound) || 0;
|
|
333
|
+
dispatch({ type: 'EXPRVIZ_FETCH_BATCH', payload: { taxon, requestId, docs, total } });
|
|
334
|
+
const next = offset + docs.length;
|
|
335
|
+
if (docs.length > 0 && next < total) {
|
|
336
|
+
fetchPage(next);
|
|
337
|
+
}
|
|
338
|
+
})
|
|
339
|
+
.catch(err => {
|
|
340
|
+
dispatch({ type: 'EXPRVIZ_FETCH_FAILED', payload: { taxon, requestId, error: String(err) } });
|
|
341
|
+
});
|
|
342
|
+
};
|
|
343
|
+
fetchPage(0);
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
// Per-candidate-field non-null counts under the current q + taxon. Used by
|
|
347
|
+
// FieldsModal to hide fields that have no data in the current result set
|
|
348
|
+
// before the user picks. Uses stats.field (the swagger whitelist drops
|
|
349
|
+
// facet.query) batched across multiple GETs to stay under URL limits.
|
|
350
|
+
doFetchExprVizFieldExistence: (taxon, fields) => ({ dispatch, store }) => {
|
|
351
|
+
if (!fields || fields.length === 0) return;
|
|
352
|
+
const q = store.selectGrameneFiltersQueryString();
|
|
353
|
+
const fieldsKey = fields.slice().sort().join(',');
|
|
354
|
+
const signature = `${q}|${taxon}|${fieldsKey}`;
|
|
355
|
+
const ev = store.selectExprViz();
|
|
356
|
+
const t = ev.byTaxon[taxon];
|
|
357
|
+
if (t && t.fieldExistence && t.existenceSignature === signature) return;
|
|
358
|
+
|
|
359
|
+
const requestId = (existencePending[taxon] = (existencePending[taxon] || 0) + 1);
|
|
360
|
+
dispatch({ type: 'EXPRVIZ_EXISTENCE_STARTED', payload: { taxon, requestId, signature } });
|
|
361
|
+
|
|
362
|
+
const api = store.selectGrameneAPI();
|
|
363
|
+
const BATCH_SIZE = 40;
|
|
364
|
+
const batches = [];
|
|
365
|
+
for (let i = 0; i < fields.length; i += BATCH_SIZE) {
|
|
366
|
+
batches.push(fields.slice(i, i + BATCH_SIZE));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
Promise.all(batches.map(batch => {
|
|
370
|
+
const params = new URLSearchParams();
|
|
371
|
+
params.append('q', q);
|
|
372
|
+
params.append('fq', `taxon_id:${taxon}`);
|
|
373
|
+
params.append('rows', '0');
|
|
374
|
+
params.append('stats', 'true');
|
|
375
|
+
batch.forEach(f => params.append('stats.field', f));
|
|
376
|
+
return fetch(`${api}/search?${params.toString()}`)
|
|
377
|
+
.then(r => r.json())
|
|
378
|
+
.then(json => {
|
|
379
|
+
const sf = (json && json.stats && json.stats.stats_fields) || {};
|
|
380
|
+
const out = {};
|
|
381
|
+
for (const f of Object.keys(sf)) {
|
|
382
|
+
const s = sf[f];
|
|
383
|
+
out[f] = s && typeof s.count === 'number' ? s.count : 0;
|
|
384
|
+
}
|
|
385
|
+
return out;
|
|
386
|
+
});
|
|
387
|
+
}))
|
|
388
|
+
.then(results => {
|
|
389
|
+
if (requestId !== existencePending[taxon]) return;
|
|
390
|
+
const counts = Object.assign({}, ...results);
|
|
391
|
+
dispatch({ type: 'EXPRVIZ_EXISTENCE_SUCCEEDED', payload: { taxon, requestId, counts } });
|
|
392
|
+
})
|
|
393
|
+
.catch(() => { /* swallow — non-fatal; modal falls back to showing all */ });
|
|
394
|
+
},
|
|
395
|
+
|
|
396
|
+
reactExprVizPivot: createSelector(
|
|
397
|
+
'selectExprViz',
|
|
398
|
+
'selectGrameneFiltersStatus',
|
|
399
|
+
'selectGrameneViews',
|
|
400
|
+
'selectGrameneFiltersQueryString',
|
|
401
|
+
'selectGrameneGenomes',
|
|
402
|
+
'selectGrameneMaps',
|
|
403
|
+
(ev, filtersStatus, views, q, g, m) => {
|
|
404
|
+
if (!ev || filtersStatus === 'init') return;
|
|
405
|
+
const onView = views && views.options && views.options.find(v => v.id === 'exprViz');
|
|
406
|
+
if (!onView || onView.show !== 'on') return;
|
|
407
|
+
const maps = m || {};
|
|
408
|
+
const taxa = Object.keys((g && g.active) || {}).filter(tid => maps[tid] && !maps[tid].hidden);
|
|
409
|
+
const sig = `${q}|${taxa.sort().join(',')}`;
|
|
410
|
+
if (ev.pivot.status === 'loading') return;
|
|
411
|
+
if (ev.pivot.signature === sig && ev.pivot.status === 'ready') return;
|
|
412
|
+
return { actionCreator: 'doFetchExprVizPivot' };
|
|
413
|
+
}
|
|
414
|
+
),
|
|
415
|
+
|
|
416
|
+
selectExprViz: state => state.exprViz,
|
|
417
|
+
selectExprVizPivot: state => state.exprViz.pivot,
|
|
418
|
+
selectExprVizActiveTaxon: state => state.exprViz.activeTaxon,
|
|
419
|
+
selectExprVizByTaxon: state => state.exprViz.byTaxon
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
export default exprViz;
|
package/src/bundles/index.js
CHANGED
|
@@ -6,5 +6,6 @@ import genomesBundle from './genomes'
|
|
|
6
6
|
import fieldCatalogBundle from './swaggerFields'
|
|
7
7
|
import exporterBundle from './exporter'
|
|
8
8
|
import ontologiesBundle from './ontologies'
|
|
9
|
+
import exprVizBundle from './exprViz'
|
|
9
10
|
|
|
10
|
-
export default [...apiBundles, docsBundle, filterBundle, viewsBundle, genomesBundle, fieldCatalogBundle, exporterBundle, ontologiesBundle];
|
|
11
|
+
export default [...apiBundles, docsBundle, filterBundle, viewsBundle, genomesBundle, fieldCatalogBundle, exporterBundle, ontologiesBundle, exprVizBundle];
|
package/src/bundles/views.js
CHANGED
|
@@ -11,12 +11,6 @@ const grameneViews = {
|
|
|
11
11
|
show: 'off',
|
|
12
12
|
shouldScroll: false
|
|
13
13
|
},
|
|
14
|
-
{
|
|
15
|
-
id: 'userLists',
|
|
16
|
-
name: 'User Gene Lists',
|
|
17
|
-
show: 'off',
|
|
18
|
-
shouldScroll: false
|
|
19
|
-
},
|
|
20
14
|
{
|
|
21
15
|
id: 'taxonomy',
|
|
22
16
|
name: 'Taxonomic distribution',
|
|
@@ -30,15 +24,14 @@ const grameneViews = {
|
|
|
30
24
|
shouldScroll: false
|
|
31
25
|
},
|
|
32
26
|
{
|
|
33
|
-
id: '
|
|
34
|
-
name: '
|
|
27
|
+
id: 'exprViz',
|
|
28
|
+
name: 'Expression visualization',
|
|
35
29
|
show: 'off',
|
|
36
|
-
shouldScroll: false
|
|
37
|
-
desiredSamples: {}
|
|
30
|
+
shouldScroll: false
|
|
38
31
|
},
|
|
39
32
|
{
|
|
40
|
-
id: '
|
|
41
|
-
name: 'Gene
|
|
33
|
+
id: 'userLists',
|
|
34
|
+
name: 'User Gene Lists',
|
|
42
35
|
show: 'off',
|
|
43
36
|
shouldScroll: false
|
|
44
37
|
},
|
|
@@ -47,6 +40,19 @@ const grameneViews = {
|
|
|
47
40
|
name: 'Data exporter',
|
|
48
41
|
show: 'off',
|
|
49
42
|
shouldScroll: false
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 'expression',
|
|
46
|
+
name: 'Gene expression',
|
|
47
|
+
show: 'off',
|
|
48
|
+
shouldScroll: false,
|
|
49
|
+
desiredSamples: {}
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: 'attribs',
|
|
53
|
+
name: 'Gene attributes',
|
|
54
|
+
show: 'off',
|
|
55
|
+
shouldScroll: false
|
|
50
56
|
}
|
|
51
57
|
// {
|
|
52
58
|
// id: 'domains',
|
|
@@ -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;
|