gramene-search 2.0.5 → 2.0.7

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.
@@ -1,81 +1,73 @@
1
+ import { getConfiguredCache } from 'money-clip';
2
+
1
3
  const ONT_KEYS = ['GO', 'PO', 'TO', 'domains'];
2
- const BATCH_SIZE = 200;
4
+
5
+ // Dedicated IndexedDB store for ontology records. The full term set per
6
+ // ontology is stable enough to keep around indefinitely (default maxAge of
7
+ // `Infinity`), and we don't want it to share the short TTL of the app-wide
8
+ // cache configured in demo.js.
9
+ const cache = getConfiguredCache({
10
+ version: 1,
11
+ name: 'gramene_ontologies'
12
+ });
3
13
 
4
14
  const inflight = {};
5
15
 
6
16
  const ontologies = {
7
17
  name: 'ontologies',
18
+
8
19
  getReducer: () => {
9
- const initialState = { GO: {}, PO: {}, TO: {}, domains: {} };
20
+ const initialState = { GO: {}, PO: {}, TO: {}, domains: {}, loaded: {} };
10
21
  return (state = initialState, { type, payload }) => {
11
22
  switch (type) {
12
- case 'ONTOLOGY_RECORDS_REQUESTED': {
13
- const { key, ids } = payload;
14
- const bucket = { ...state[key] };
15
- for (const id of ids) {
16
- if (!bucket.hasOwnProperty(id)) bucket[id] = null;
17
- }
18
- return { ...state, [key]: bucket };
19
- }
20
- case 'ONTOLOGY_RECORDS_RECEIVED': {
21
- const { key, records } = payload;
22
- return { ...state, [key]: { ...state[key], ...records } };
23
- }
23
+ case 'ONTOLOGY_BULK_LOADED':
24
+ return {
25
+ ...state,
26
+ [payload.key]: payload.records,
27
+ loaded: { ...state.loaded, [payload.key]: true }
28
+ };
24
29
  default:
25
30
  return state;
26
31
  }
27
32
  };
28
33
  },
29
34
 
30
- doEnsureOntologyRecords: (key, ids) => ({ dispatch, store }) => {
35
+ // Signature kept for backward compatibility the `ids` argument is
36
+ // ignored. On first call per ontology key we load the full term set
37
+ // (cache hit if available, otherwise `${api}/${key}?rows=-1`) and
38
+ // dispatch a single bulk load. Subsequent calls are no-ops.
39
+ doEnsureOntologyRecords: (key, _ids) => ({ dispatch, store }) => {
31
40
  if (!ONT_KEYS.includes(key)) return Promise.resolve();
32
- if (!Array.isArray(ids) || ids.length === 0) return Promise.resolve();
33
- const existing = store.selectOntologies()[key] || {};
34
- const missing = [];
35
- for (const id of ids) {
36
- if (id == null) continue;
37
- const idNum = +id;
38
- if (!existing.hasOwnProperty(idNum) && !(inflight[key] && inflight[key].has(idNum))) {
39
- missing.push(idNum);
40
- }
41
- }
42
- if (missing.length === 0) return Promise.resolve();
43
-
44
- if (!inflight[key]) inflight[key] = new Set();
45
- for (const id of missing) inflight[key].add(id);
46
- dispatch({ type: 'ONTOLOGY_RECORDS_REQUESTED', payload: { key, ids: missing } });
47
-
48
- const api = store.selectGrameneAPI();
49
- const batches = [];
50
- for (let i = 0; i < missing.length; i += BATCH_SIZE) {
51
- batches.push(missing.slice(i, i + BATCH_SIZE));
52
- }
41
+ const state = store.selectOntologies();
42
+ if (state.loaded && state.loaded[key]) return Promise.resolve();
43
+ if (inflight[key]) return inflight[key];
53
44
 
54
- const fetchBatch = (batch) => {
55
- const idList = batch.length === 1 ? `${batch[0]},0` : batch.join(',');
56
- return fetch(`${api}/${key}?idList=${idList}&rows=${batch.length + 1}`)
57
- .then(r => r.json())
58
- .then(docs => {
59
- const records = {};
60
- for (const d of (docs || [])) {
61
- if (d && d._id != null) records[d._id] = d;
62
- }
63
- for (const id of batch) {
64
- if (!records.hasOwnProperty(id)) records[id] = { _id: id, missing: true };
65
- }
66
- dispatch({ type: 'ONTOLOGY_RECORDS_RECEIVED', payload: { key, records } });
67
- })
68
- .catch(() => {
69
- const records = {};
70
- for (const id of batch) records[id] = { _id: id, missing: true };
71
- dispatch({ type: 'ONTOLOGY_RECORDS_RECEIVED', payload: { key, records } });
72
- })
73
- .finally(() => {
74
- for (const id of batch) inflight[key].delete(id);
75
- });
76
- };
45
+ inflight[key] = cache.get(key)
46
+ .then(cached => {
47
+ if (cached) {
48
+ dispatch({ type: 'ONTOLOGY_BULK_LOADED', payload: { key, records: cached } });
49
+ return;
50
+ }
51
+ const api = store.selectGrameneAPI();
52
+ return fetch(`${api}/${key}?rows=-1`)
53
+ .then(r => r.json())
54
+ .then(docs => {
55
+ const records = {};
56
+ for (const d of (docs || [])) {
57
+ if (d && d._id != null) records[d._id] = d;
58
+ }
59
+ dispatch({ type: 'ONTOLOGY_BULK_LOADED', payload: { key, records } });
60
+ cache.set(key, records).catch(e => console.warn('Failed to cache ontology', key, e));
61
+ });
62
+ })
63
+ .catch(err => {
64
+ console.error(`Failed to load ontology ${key}`, err);
65
+ })
66
+ .finally(() => {
67
+ delete inflight[key];
68
+ });
77
69
 
78
- return Promise.all(batches.map(fetchBatch));
70
+ return inflight[key];
79
71
  },
80
72
 
81
73
  selectOntologies: state => state.ontologies
@@ -268,12 +268,12 @@ function buildSpeciesNameIndex(grameneMaps) {
268
268
  function fieldExperimentId(name) {
269
269
  let m = name.match(/^(\w+?)_g\d+__expr$/);
270
270
  if (m) return m[1].replace(/_/g, '-');
271
- m = name.match(/^(\w+?)_g\d+_g\d+_(pval|logfc|l2fc)_attr_[a-z]$/);
271
+ m = name.match(/^(\w+?)_g\d+_g\d+_(pval|l2fc)_attr_[a-z]$/);
272
272
  if (m) return m[1].replace(/_/g, '-');
273
273
  return null;
274
274
  }
275
275
 
276
- const STAT_RANK = { l2fc: 0, logfc: 1, pval: 2 };
276
+ const STAT_RANK = { l2fc: 0, pval: 1 };
277
277
 
278
278
  function collapseDiffExprInSubgroups(subgroups, fieldsOut, collator) {
279
279
  for (const taxGroup of subgroups) {
@@ -300,7 +300,7 @@ function collapseDiffExprInSubgroups(subgroups, fieldsOut, collator) {
300
300
  });
301
301
  const rep = names[0];
302
302
  const repEntry = fieldsOut[rep];
303
- const label = (repEntry.label || rep).replace(/\s+\((?:pval|logfc|l2fc)\)/, '');
303
+ const label = (repEntry.label || rep).replace(/\s+\((?:pval|l2fc)\)/, '');
304
304
  fieldsOut[rep] = { ...repEntry, label, linkedFields: names.slice() };
305
305
  newFields.push(rep);
306
306
  }
@@ -577,14 +577,13 @@ grameneFieldCatalog.selectFieldCatalog = createSelector(
577
577
  const present = new Set(fieldNames);
578
578
  // Build a synthetic doc from the discovered field names; values carry the
579
579
  // right JS type so inferType picks the correct multiValued/type (arrays
580
- // for multi-valued fields, scalars otherwise). Derive pval/logfc
581
- // companions from every l2fc field.
580
+ // for multi-valued fields, scalars otherwise). Derive the pval companion
581
+ // from every l2fc field.
582
582
  const doc = {};
583
583
  for (const name of present) {
584
584
  doc[name] = synthesizedValue(name);
585
585
  if (/_l2fc_attr_f$/.test(name)) {
586
586
  doc[name.replace('_l2fc_', '_pval_')] = 0;
587
- doc[name.replace('_l2fc_', '_logfc_')] = 0;
588
587
  }
589
588
  }
590
589
  const catalog = buildCatalog([doc]);
@@ -29,6 +29,12 @@ const grameneViews = {
29
29
  show: 'off',
30
30
  shouldScroll: false
31
31
  },
32
+ {
33
+ id: 'gsea',
34
+ name: 'Gene set enrichment',
35
+ show: 'off',
36
+ shouldScroll: false
37
+ },
32
38
  {
33
39
  id: 'userLists',
34
40
  name: 'User Gene Lists',
@@ -121,7 +127,7 @@ const grameneViews = {
121
127
  const touched = raw.touched || {};
122
128
  const numFound = (search && search.response && search.response.numFound) || 0;
123
129
  const hasFilters = !!(filters && filters.rightIdx > 1);
124
- const resultDependentIds = new Set(['taxonomy', 'list', 'export']);
130
+ const resultDependentIds = new Set(['taxonomy', 'list', 'export', 'exprViz', 'gsea']);
125
131
  const autoDisable = (numFound === 0) || !hasFilters;
126
132
  const hasFirebase = !!(config && config.firebaseConfig);
127
133
 
@@ -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|logfc|l2fc)_attr_([a-z])$/;
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; // l2fc or logfc
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);
@@ -8,13 +8,24 @@ const baseColDefs = [
8
8
  { field: 'name', headerName: 'Name', pinned: 'left', width: 140, suppressMovable: true }
9
9
  ];
10
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
+
11
22
  function arraysEqual(a, b) {
12
23
  if (a.length !== b.length) return false;
13
24
  for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
14
25
  return true;
15
26
  }
16
27
 
17
- function buildFieldInfo(fields, studies, expressionSamples) {
28
+ export function buildFieldInfo(fields, studies, expressionSamples) {
18
29
  const info = {};
19
30
  if (!fields || !studies || !expressionSamples) return info;
20
31
  const wanted = new Set(fields);
@@ -322,7 +333,7 @@ const ExprTable = ({ rows, fields, onReorder, studies, expressionSamples, onHove
322
333
  <AgGridReact
323
334
  rowData={rows}
324
335
  columnDefs={columnDefs}
325
- defaultColDef={{ resizable: true, sortable: true, filter: false, suppressMenu: true }}
336
+ defaultColDef={DEFAULT_COL_DEF}
326
337
  animateRows={false}
327
338
  suppressFieldDotNotation={true}
328
339
  suppressDragLeaveHidesColumns={true}
@@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { connect } from 'redux-bundler-react';
3
3
  import { Tabs, Tab, Button, ToggleButton, ToggleButtonGroup } from 'react-bootstrap';
4
4
  import FieldsModal from './FieldsModal';
5
- import ExprTable from './ExprTable';
5
+ import ExprTable, { buildFieldInfo } from './ExprTable';
6
6
  import ParallelCoordsPlot from './ParallelCoordsPlot';
7
7
  import './styles.css';
8
8
 
@@ -193,11 +193,49 @@ function tsvCell(v) {
193
193
  return s.replace(/[\t\r\n]+/g, ' ');
194
194
  }
195
195
 
196
- function downloadTsv(filename, rows, fields) {
196
+ // Mirror the on-screen table header in the TSV: one row per metadata level
197
+ // the table is showing (Study, then one row per distinct factor type, then
198
+ // one row per distinct characteristic type), followed by the leaf header
199
+ // (Gene ID / Name / per-sample group). The first two columns are repurposed
200
+ // to carry the row category and the row's specific name, matching the
201
+ // pinned-column labels in ExprTable.
202
+ function downloadTsv(filename, rows, fields, studies, expressionSamples) {
197
203
  const cols = ['id', 'name', ...fields];
198
- const headerLabels = ['id', 'name', ...fields.map(f => f.replace(/__expr$/, ''))];
199
- const lines = [headerLabels.join('\t')];
204
+ const fieldInfo = buildFieldInfo(fields, studies, expressionSamples);
205
+
206
+ const factorTypes = new Set();
207
+ const charTypes = new Set();
208
+ for (const f of fields) {
209
+ const info = fieldInfo[f];
210
+ if (!info) continue;
211
+ Object.keys(info.factors || {}).forEach(t => factorTypes.add(t));
212
+ Object.keys(info.characteristics || {}).forEach(t => charTypes.add(t));
213
+ }
214
+ const factorTypeList = Array.from(factorTypes).sort();
215
+ const charTypeList = Array.from(charTypes).sort();
216
+
217
+ const metaRow = (cat, label, getValue) => {
218
+ const cells = [cat, label];
219
+ for (const f of fields) cells.push(tsvCell(getValue(fieldInfo[f] || {})));
220
+ return cells.join('\t');
221
+ };
222
+
223
+ const lines = [];
224
+ lines.push(metaRow('Study', 'Title', info => info.studyDescription || ''));
225
+ for (const t of factorTypeList) {
226
+ lines.push(metaRow('Factor', t, info => (info.factors && info.factors[t]) || ''));
227
+ }
228
+ for (const t of charTypeList) {
229
+ lines.push(metaRow('Characteristic', t, info => (info.characteristics && info.characteristics[t]) || ''));
230
+ }
231
+ // Leaf header — column ids for the data rows.
232
+ lines.push(['Gene ID', 'Name', ...fields.map(f => {
233
+ const info = fieldInfo[f];
234
+ return (info && info.group) || f.replace(/__expr$/, '');
235
+ })].join('\t'));
236
+
200
237
  for (const r of rows) lines.push(cols.map(c => tsvCell(r[c])).join('\t'));
238
+
201
239
  const blob = new Blob([lines.join('\n') + '\n'], { type: 'text/tab-separated-values' });
202
240
  const url = URL.createObjectURL(blob);
203
241
  const a = document.createElement('a');
@@ -317,7 +355,7 @@ const TaxonPanel = ({ taxon, studies, expressionSamples, tabState, onOpenFields,
317
355
  size="sm"
318
356
  variant="outline-secondary"
319
357
  disabled={filteredRows.length === 0 || visibleFields.length === 0}
320
- onClick={() => downloadTsv(`expression_${taxon}.tsv`, filteredRows, visibleFields)}
358
+ onClick={() => downloadTsv(`expression_${taxon}.tsv`, filteredRows, visibleFields, studies, expressionSamples)}
321
359
  title="Download the visible rows and columns as tab-delimited text"
322
360
  >
323
361
  Download TSV
@@ -13,6 +13,7 @@ import Expression from './results/Expression'
13
13
  import UserGeneLists from './results/UserGeneLists'
14
14
  import ExporterView from './exporter/ExporterView'
15
15
  import ExprVizView from './exprViz/ExprVizView'
16
+ import GSEA from './results/GSEA'
16
17
  import Auth from './Auth'
17
18
  import ReactGA from 'react-ga4'
18
19
  import './styles.css';
@@ -42,6 +43,7 @@ const inventory = {
42
43
  attribs: GeneAttribs,
43
44
  expression: Expression,
44
45
  exprViz: ExprVizView,
46
+ gsea: GSEA,
45
47
  userLists: UserGeneLists,
46
48
  export: ExporterView
47
49
  };