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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gramene-search",
3
- "version": "2.0.5",
3
+ "version": "2.0.7",
4
4
  "description": "search wrapper for gramene",
5
5
  "source": "src/index.js",
6
6
  "main": "dist/index.js",
@@ -1,3 +1,16 @@
1
+ import { getConfiguredCache } from 'money-clip';
2
+
3
+ // Dedicated IndexedDB store for the full pathway corpus, persisted
4
+ // indefinitely (default maxAge of `Infinity`). The pathway set is small
5
+ // and stable enough to keep around across sessions, so we bulk-load it
6
+ // once with `?rows=-1` and reuse it instead of issuing per-id requests.
7
+ const pathwayCache = getConfiguredCache({
8
+ version: 1,
9
+ name: 'gramene_pathways'
10
+ });
11
+
12
+ let pathwaysBulkPromise = null;
13
+
1
14
  const grameneDocs = {
2
15
  name: 'grameneDocs',
3
16
  getReducer: () => {
@@ -201,22 +214,36 @@ const grameneDocs = {
201
214
  })
202
215
  }
203
216
  },
204
- doRequestGramenePathways: ids => ({dispatch, store}) => {
205
- const pathways = store.selectGramenePathways();
206
- let newIds = ids.filter(id => !pathways.hasOwnProperty(id));
207
- if (newIds) {
208
- dispatch({type: 'GRAMENE_PATHWAYS_REQUESTED', payload: newIds});
209
- if (newIds.length === 1) newIds.push(0);
210
- fetch(`${store.selectGrameneAPI()}/pathways?idList=${newIds.join(',')}`)
211
- .then(res => res.json())
212
- .then(res => {
213
- let pathways = {};
214
- res.forEach(p => {
215
- pathways[p._id] = p;
217
+ // Signature kept for backward compatibility; the `ids` argument is
218
+ // ignored. On first call we bulk-load every pathway record from the
219
+ // dedicated IndexedDB cache (or `${api}/pathways?rows=-1` on miss),
220
+ // dispatch a single GRAMENE_PATHWAYS_RECEIVED, and short-circuit all
221
+ // subsequent calls.
222
+ doRequestGramenePathways: _ids => ({dispatch, store}) => {
223
+ if (pathwaysBulkPromise) return pathwaysBulkPromise;
224
+
225
+ pathwaysBulkPromise = pathwayCache.get('all')
226
+ .then(cached => {
227
+ if (cached) {
228
+ dispatch({type: 'GRAMENE_PATHWAYS_RECEIVED', payload: cached});
229
+ return;
230
+ }
231
+ return fetch(`${store.selectGrameneAPI()}/pathways?rows=-1`)
232
+ .then(res => res.json())
233
+ .then(res => {
234
+ const pathways = {};
235
+ (res || []).forEach(p => {
236
+ if (p && p._id != null) pathways[p._id] = p;
237
+ });
238
+ dispatch({type: 'GRAMENE_PATHWAYS_RECEIVED', payload: pathways});
239
+ pathwayCache.set('all', pathways).catch(e => console.warn('Failed to cache pathways', e));
216
240
  });
217
- dispatch({type: 'GRAMENE_PATHWAYS_RECEIVED', payload: pathways})
218
- })
219
- }
241
+ })
242
+ .catch(err => {
243
+ console.error('Failed to load pathways', err);
244
+ pathwaysBulkPromise = null; // allow retry on next call
245
+ });
246
+ return pathwaysBulkPromise;
220
247
  },
221
248
  doRequestExpressionStudies: id => ({dispatch, store}) => {
222
249
  fetch(`${store.selectGrameneAPI()}/experiments?rows=-1`)
@@ -0,0 +1,568 @@
1
+ import { createSelector } from 'redux-bundler';
2
+
3
+ // Gene Set Enrichment Analysis bundle.
4
+ //
5
+ // For each species tab we run:
6
+ // foreground: q=<filters>&fq=taxon_id:<tid>&rows=0 + facet on six __ancestors fields
7
+ // background: q=taxon_id:<tid>&rows=0 + same facets (cached forever per tid)
8
+ //
9
+ // Per-ontology denominators (n_ont, N_ont) come from the root term's facet
10
+ // count, since every annotated gene carries the root in __ancestors. Roots
11
+ // are identified once ontology records are loaded by picking the ancestor
12
+ // (or self) with the highest background facet count — this works for
13
+ // multi-rooted GO (BP/MF/CC) and for the rice-rooted pathway tree.
14
+ //
15
+ // Enrichment uses the upper-tail Fisher exact (hypergeometric); p-values
16
+ // are corrected per ontology with Benjamini–Hochberg. The "most-specific"
17
+ // collapse is applied AFTER BH so a parent term that's significant on its
18
+ // own is preserved even when none of its children clear the cutoff.
19
+
20
+ const ONTOLOGIES = [
21
+ { key: 'GO', field: 'GO__ancestors', label: 'Gene Ontology', bucket: 'GO' },
22
+ { key: 'PO', field: 'PO__ancestors', label: 'Plant Ontology', bucket: 'PO' },
23
+ { key: 'TO', field: 'TO__ancestors', label: 'Trait Ontology', bucket: 'TO' },
24
+ { key: 'QTL_TO', field: 'QTL_TO__ancestors', label: 'QTL Traits (TO)', bucket: 'TO' },
25
+ { key: 'domains', field: 'domains__ancestors', label: 'InterPro Domains', bucket: 'domains' },
26
+ { key: 'pathways', field: 'pathways__ancestors', label: 'Pathways', bucket: null }
27
+ ];
28
+
29
+ const FACET_PARAMS = ONTOLOGIES.map(o =>
30
+ `facet.field=${encodeURIComponent(`{!facet.limit=10000 facet.mincount=1 key=${o.key}}${o.field}`)}`
31
+ ).join('&');
32
+
33
+ const fgPending = {};
34
+ const bgPending = {};
35
+
36
+ function fgSig(q, taxon) { return `${q}|${taxon}`; }
37
+ function bgSig(taxon) { return `bg|${taxon}`; }
38
+
39
+ function parseFacets(json) {
40
+ const out = {};
41
+ const ff = (json && json.facet_counts && json.facet_counts.facet_fields) || {};
42
+ for (const o of ONTOLOGIES) {
43
+ const arr = ff[o.key] || [];
44
+ const map = {};
45
+ for (let i = 0; i < arr.length; i += 2) {
46
+ map[+arr[i]] = +arr[i + 1];
47
+ }
48
+ out[o.key] = map;
49
+ }
50
+ return out;
51
+ }
52
+
53
+ const gsea = {
54
+ name: 'gsea',
55
+
56
+ // Background facet counts depend only on the species — they're invariant
57
+ // across filter changes and across sessions, so we persist whenever a bg
58
+ // fetch completes. Foreground state piggybacks on the same write but is
59
+ // self-invalidated by the signature check in the reactor.
60
+ persistActions: ['GSEA_BG_SUCCEEDED'],
61
+
62
+ getReducer: () => {
63
+ const initialState = {
64
+ activeTaxon: null,
65
+ byTaxon: {},
66
+ ui: {
67
+ pAdjCutoff: 0.05,
68
+ minK: 2,
69
+ mostSpecific: true,
70
+ ontology: 'all',
71
+ search: ''
72
+ }
73
+ };
74
+
75
+ function ensureTaxon(state, tid) {
76
+ if (state.byTaxon[tid]) return state;
77
+ return {
78
+ ...state,
79
+ byTaxon: {
80
+ ...state.byTaxon,
81
+ [tid]: {
82
+ fg: { status: 'idle', signature: null, requestId: 0, terms: null, numFound: 0, error: null },
83
+ bg: { status: 'idle', signature: null, requestId: 0, terms: null, numFound: 0, error: null }
84
+ }
85
+ }
86
+ };
87
+ }
88
+
89
+ return (state = initialState, { type, payload }) => {
90
+ switch (type) {
91
+ case 'GSEA_ACTIVE_TAXON_SET':
92
+ return { ...ensureTaxon(state, payload), activeTaxon: payload };
93
+
94
+ case 'GSEA_UI_SET':
95
+ return { ...state, ui: { ...state.ui, ...(payload || {}) } };
96
+
97
+ case 'GSEA_FG_STARTED': {
98
+ const next = ensureTaxon(state, payload.taxon);
99
+ const t = next.byTaxon[payload.taxon];
100
+ return {
101
+ ...next,
102
+ byTaxon: {
103
+ ...next.byTaxon,
104
+ [payload.taxon]: {
105
+ ...t,
106
+ fg: { status: 'loading', signature: payload.signature, requestId: payload.requestId, terms: t.fg.terms, numFound: t.fg.numFound, error: null }
107
+ }
108
+ }
109
+ };
110
+ }
111
+ case 'GSEA_FG_SUCCEEDED': {
112
+ const t = state.byTaxon[payload.taxon];
113
+ if (!t || payload.requestId !== t.fg.requestId) return state;
114
+ return {
115
+ ...state,
116
+ byTaxon: {
117
+ ...state.byTaxon,
118
+ [payload.taxon]: { ...t, fg: { ...t.fg, status: 'ready', terms: payload.terms, numFound: payload.numFound } }
119
+ }
120
+ };
121
+ }
122
+ case 'GSEA_FG_FAILED': {
123
+ const t = state.byTaxon[payload.taxon];
124
+ if (!t || payload.requestId !== t.fg.requestId) return state;
125
+ return {
126
+ ...state,
127
+ byTaxon: {
128
+ ...state.byTaxon,
129
+ [payload.taxon]: { ...t, fg: { ...t.fg, status: 'error', error: payload.error } }
130
+ }
131
+ };
132
+ }
133
+
134
+ case 'GSEA_BG_STARTED': {
135
+ const next = ensureTaxon(state, payload.taxon);
136
+ const t = next.byTaxon[payload.taxon];
137
+ return {
138
+ ...next,
139
+ byTaxon: {
140
+ ...next.byTaxon,
141
+ [payload.taxon]: {
142
+ ...t,
143
+ bg: { status: 'loading', signature: payload.signature, requestId: payload.requestId, terms: t.bg.terms, numFound: t.bg.numFound, error: null }
144
+ }
145
+ }
146
+ };
147
+ }
148
+ case 'GSEA_BG_SUCCEEDED': {
149
+ const t = state.byTaxon[payload.taxon];
150
+ if (!t || payload.requestId !== t.bg.requestId) return state;
151
+ return {
152
+ ...state,
153
+ byTaxon: {
154
+ ...state.byTaxon,
155
+ [payload.taxon]: { ...t, bg: { ...t.bg, status: 'ready', terms: payload.terms, numFound: payload.numFound } }
156
+ }
157
+ };
158
+ }
159
+ case 'GSEA_BG_FAILED': {
160
+ const t = state.byTaxon[payload.taxon];
161
+ if (!t || payload.requestId !== t.bg.requestId) return state;
162
+ return {
163
+ ...state,
164
+ byTaxon: {
165
+ ...state.byTaxon,
166
+ [payload.taxon]: { ...t, bg: { ...t.bg, status: 'error', error: payload.error } }
167
+ }
168
+ };
169
+ }
170
+
171
+ case 'GRAMENE_SEARCH_CLEARED': {
172
+ // Filters changed — invalidate foreground but keep background
173
+ // (it depends only on taxon).
174
+ const newByTaxon = {};
175
+ for (const tid of Object.keys(state.byTaxon)) {
176
+ const t = state.byTaxon[tid];
177
+ newByTaxon[tid] = {
178
+ ...t,
179
+ fg: { status: 'idle', signature: null, requestId: 0, terms: null, numFound: 0, error: null }
180
+ };
181
+ }
182
+ return { ...state, byTaxon: newByTaxon };
183
+ }
184
+
185
+ default:
186
+ return state;
187
+ }
188
+ };
189
+ },
190
+
191
+ doSetGseaActiveTaxon: tid => ({ dispatch }) =>
192
+ dispatch({ type: 'GSEA_ACTIVE_TAXON_SET', payload: tid }),
193
+
194
+ doSetGseaUI: patch => ({ dispatch }) =>
195
+ dispatch({ type: 'GSEA_UI_SET', payload: patch }),
196
+
197
+ doFetchGseaForeground: taxon => ({ dispatch, store }) => {
198
+ const q = store.selectGrameneFiltersQueryString();
199
+ const signature = fgSig(q, taxon);
200
+ const state = store.selectGsea();
201
+ const t = state.byTaxon[taxon];
202
+ if (t && t.fg.signature === signature && (t.fg.status === 'loading' || t.fg.status === 'ready')) return;
203
+ const requestId = (fgPending[taxon] = (fgPending[taxon] || 0) + 1);
204
+ dispatch({ type: 'GSEA_FG_STARTED', payload: { taxon, signature, requestId } });
205
+
206
+ const api = store.selectGrameneAPI();
207
+ const url = `${api}/search?q=${q}&fq=taxon_id:${taxon}&rows=0&facet=true&${FACET_PARAMS}`;
208
+ fetch(url)
209
+ .then(r => r.json())
210
+ .then(json => {
211
+ if (requestId !== fgPending[taxon]) return;
212
+ const terms = parseFacets(json);
213
+ const numFound = (json && json.response && json.response.numFound) || 0;
214
+ dispatch({ type: 'GSEA_FG_SUCCEEDED', payload: { taxon, requestId, terms, numFound } });
215
+ store.doEnsureGseaTermRecords(taxon);
216
+ })
217
+ .catch(err => {
218
+ if (requestId !== fgPending[taxon]) return;
219
+ dispatch({ type: 'GSEA_FG_FAILED', payload: { taxon, requestId, error: String(err) } });
220
+ });
221
+ },
222
+
223
+ doFetchGseaBackground: taxon => ({ dispatch, store }) => {
224
+ const signature = bgSig(taxon);
225
+ const state = store.selectGsea();
226
+ const t = state.byTaxon[taxon];
227
+ if (t && t.bg.signature === signature && (t.bg.status === 'loading' || t.bg.status === 'ready')) return;
228
+ const requestId = (bgPending[taxon] = (bgPending[taxon] || 0) + 1);
229
+ dispatch({ type: 'GSEA_BG_STARTED', payload: { taxon, signature, requestId } });
230
+
231
+ const api = store.selectGrameneAPI();
232
+ const url = `${api}/search?q=taxon_id:${taxon}&rows=0&facet=true&${FACET_PARAMS}`;
233
+ fetch(url)
234
+ .then(r => r.json())
235
+ .then(json => {
236
+ if (requestId !== bgPending[taxon]) return;
237
+ const terms = parseFacets(json);
238
+ const numFound = (json && json.response && json.response.numFound) || 0;
239
+ dispatch({ type: 'GSEA_BG_SUCCEEDED', payload: { taxon, requestId, terms, numFound } });
240
+ store.doEnsureGseaTermRecords(taxon);
241
+ })
242
+ .catch(err => {
243
+ if (requestId !== bgPending[taxon]) return;
244
+ dispatch({ type: 'GSEA_BG_FAILED', payload: { taxon, requestId, error: String(err) } });
245
+ });
246
+ },
247
+
248
+ doEnsureGseaTermRecords: taxon => ({ store }) => {
249
+ const state = store.selectGsea();
250
+ if (!state.byTaxon[taxon]) return;
251
+ // The ontologies and pathways bundles bulk-load + persist on first
252
+ // request; we just need to nudge each one once.
253
+ const buckets = new Set();
254
+ for (const o of ONTOLOGIES) {
255
+ if (o.bucket) buckets.add(o.bucket);
256
+ }
257
+ for (const k of buckets) {
258
+ store.doEnsureOntologyRecords(k);
259
+ }
260
+ if (store.doRequestGramenePathways) {
261
+ store.doRequestGramenePathways();
262
+ }
263
+ },
264
+
265
+ reactGseaFetch: createSelector(
266
+ 'selectGsea',
267
+ 'selectGrameneFiltersStatus',
268
+ 'selectGrameneFiltersQueryString',
269
+ 'selectGrameneViewsOn',
270
+ (gs, fStatus, q, viewsOn) => {
271
+ if (!viewsOn || !viewsOn.has('gsea')) return;
272
+ if (fStatus === 'init') return;
273
+ const tid = gs.activeTaxon;
274
+ if (!tid) return;
275
+ const t = gs.byTaxon[tid];
276
+ if (!t) return;
277
+ const sig = fgSig(q, tid);
278
+ // A 'loading' status from a rehydrated state with no live request is
279
+ // treated as idle — otherwise we'd deadlock waiting on a fetch that
280
+ // ended when the previous tab closed.
281
+ const fgInFlight = t.fg.status === 'loading' && (fgPending[tid] || 0) === t.fg.requestId && t.fg.requestId > 0;
282
+ if (t.fg.signature !== sig && !fgInFlight) {
283
+ return { actionCreator: 'doFetchGseaForeground', args: [tid] };
284
+ }
285
+ const bgInFlight = t.bg.status === 'loading' && (bgPending[tid] || 0) === t.bg.requestId && t.bg.requestId > 0;
286
+ if (t.bg.status !== 'ready' && !bgInFlight) {
287
+ return { actionCreator: 'doFetchGseaBackground', args: [tid] };
288
+ }
289
+ }
290
+ ),
291
+
292
+ selectGsea: state => state.gsea,
293
+ selectGseaUI: state => state.gsea.ui,
294
+ selectGseaOntologyDefs: () => ONTOLOGIES,
295
+
296
+ selectGseaResults: createSelector(
297
+ 'selectGsea',
298
+ 'selectOntologies',
299
+ 'selectGramenePathways',
300
+ (gs, ontoBuckets, pathwayDocs) => {
301
+ const tid = gs.activeTaxon;
302
+ if (!tid) return null;
303
+ const t = gs.byTaxon[tid];
304
+ if (!t || !t.fg.terms || !t.bg.terms) return null;
305
+ const ui = gs.ui;
306
+ const out = {};
307
+ for (const o of ONTOLOGIES) {
308
+ const fg = t.fg.terms[o.key] || {};
309
+ const bg = t.bg.terms[o.key] || {};
310
+ const recs = o.key === 'pathways' ? (pathwayDocs || {}) : ((ontoBuckets && ontoBuckets[o.bucket]) || {});
311
+
312
+ // Forest-fallback denominators: when a term is itself a forest root
313
+ // (no parents in `recs` — common for InterPro), root finding returns
314
+ // the term and we'd get fold=1 by construction. Use the maximum
315
+ // counts across the ontology as a proxy for "annotated in this
316
+ // ontology" instead. For ontologies with a true synthetic root,
317
+ // these maxima equal the root counts and the answer is unchanged.
318
+ let maxFg = 0, maxBg = 0;
319
+ for (const idStr of Object.keys(bg)) {
320
+ const v = bg[idStr];
321
+ if (v > maxBg) maxBg = v;
322
+ }
323
+ for (const idStr of Object.keys(fg)) {
324
+ const v = fg[idStr];
325
+ if (v > maxFg) maxFg = v;
326
+ }
327
+
328
+ const rootCache = {};
329
+ const rootOf = (id) => {
330
+ if (rootCache.hasOwnProperty(id)) return rootCache[id];
331
+ const r = findRoot(o.key, id, recs, bg);
332
+ rootCache[id] = r;
333
+ return r;
334
+ };
335
+
336
+ const rows = [];
337
+ for (const idStr of Object.keys(fg)) {
338
+ const id = +idStr;
339
+ const k = fg[id];
340
+ const K = bg[id] || 0;
341
+ if (K < k) continue; // bg should always be >= fg
342
+ if (k < ui.minK) continue;
343
+ const termRec = recs && recs[id];
344
+ if (termRec && (termRec.is_obsolete || termRec.obsolete)) continue;
345
+ const root = rootOf(id);
346
+ // If the "root" is the term itself, the term is a forest root in
347
+ // this ontology — fall back to ontology-wide maxima.
348
+ const fellBack = (+root === id);
349
+ const n = fellBack ? maxFg : ((root != null && fg[root]) ? fg[root] : k);
350
+ const N = fellBack ? maxBg : ((root != null && bg[root]) ? bg[root] : K);
351
+ if (n <= 0 || N <= 0) continue;
352
+ if (k > n || K > N) continue;
353
+ const fold = (k / n) / (K / N);
354
+ const p = fisherUpperTail(k, n, K, N);
355
+ rows.push({
356
+ ontology: o.key,
357
+ ontology_label: o.label,
358
+ term_id: id,
359
+ field: o.field,
360
+ k, n, K, N, fold, p, pAdj: 1,
361
+ root,
362
+ denomFallback: fellBack
363
+ });
364
+ }
365
+
366
+ // GO is split into its three top-level namespaces (BP / MF / CC)
367
+ // and each is tested as its own ontology — both BH correction and
368
+ // most-specific collapse run within a namespace. We only split once
369
+ // ontology records have arrived and root finding has produced
370
+ // canonical roots; otherwise we'd see a swarm of singleton groups
371
+ // during the brief loading window between bg landing and records
372
+ // being fetched.
373
+ if (o.key === 'GO' && Object.keys(recs).length > 0) {
374
+ const byRoot = {};
375
+ for (const r of rows) {
376
+ const k = String(r.root);
377
+ if (!byRoot[k]) byRoot[k] = [];
378
+ byRoot[k].push(r);
379
+ }
380
+ const rootKeys = Object.keys(byRoot).sort((a, b) => {
381
+ const na = goRootName(recs[+a]) || a;
382
+ const nb = goRootName(recs[+b]) || b;
383
+ return na.localeCompare(nb);
384
+ });
385
+ for (const rootKey of rootKeys) {
386
+ const rootRec = recs[+rootKey];
387
+ const rootName = goRootName(rootRec);
388
+ const sectionKey = `GO:${rootKey}`;
389
+ const sectionLabel = rootName ? `GO: ${titleCase(rootName)}` : `GO: ${rootKey}`;
390
+ out[sectionKey] = finalizeBlock(
391
+ byRoot[rootKey], o.key, o.field, recs, ui, sectionKey, sectionLabel
392
+ );
393
+ }
394
+ } else {
395
+ out[o.key] = finalizeBlock(rows, o.key, o.field, recs, ui, o.key, o.label);
396
+ }
397
+ }
398
+ return out;
399
+ }
400
+ )
401
+ };
402
+
403
+ function goRootName(rec) {
404
+ if (!rec) return '';
405
+ return rec.name || rec.display_name || rec.namespace || '';
406
+ }
407
+
408
+ function titleCase(s) {
409
+ return String(s).split('_').map(w => w ? w[0].toUpperCase() + w.slice(1) : '').join(' ');
410
+ }
411
+
412
+ // BH correction + most-specific collapse + metadata + final sort, returning
413
+ // the block descriptor consumed by the view layer.
414
+ function finalizeBlock(rows, ontKey, ontField, recs, ui, sectionKey, sectionLabel) {
415
+ rows.sort((a, b) => a.p - b.p);
416
+ const m = rows.length;
417
+ let prev = 1;
418
+ for (let i = m - 1; i >= 0; i--) {
419
+ const adj = Math.min(prev, rows[i].p * m / (i + 1));
420
+ rows[i].pAdj = adj;
421
+ prev = adj;
422
+ }
423
+ const passing = rows.filter(r => r.pAdj <= ui.pAdjCutoff);
424
+ let display = passing;
425
+ if (ui.mostSpecific) {
426
+ display = collapseToMostSpecific(ontKey, passing, recs);
427
+ }
428
+ for (const r of display) {
429
+ const rec = recs && recs[r.term_id];
430
+ if (rec) {
431
+ r.term_name = rec.name || rec.display_name || '';
432
+ r.term_namespace = rec.namespace || rec.type || '';
433
+ r.term_display_id = rec.id != null ? String(rec.id) : String(r.term_id);
434
+ } else {
435
+ r.term_name = '';
436
+ r.term_namespace = '';
437
+ r.term_display_id = String(r.term_id);
438
+ }
439
+ }
440
+ display.sort((a, b) => a.pAdj - b.pAdj || b.fold - a.fold);
441
+ return {
442
+ ontology: sectionKey,
443
+ label: sectionLabel,
444
+ field: ontField,
445
+ tested: rows.length,
446
+ passing: passing.length,
447
+ rows: display
448
+ };
449
+ }
450
+
451
+ // ---------- math helpers ----------
452
+
453
+ const LF_CACHE = [0, 0];
454
+ function logFactorial(n) {
455
+ if (n < LF_CACHE.length) return LF_CACHE[n];
456
+ let lf = LF_CACHE[LF_CACHE.length - 1];
457
+ for (let i = LF_CACHE.length; i <= n; i++) {
458
+ lf += Math.log(i);
459
+ LF_CACHE.push(lf);
460
+ }
461
+ return LF_CACHE[n];
462
+ }
463
+ function logChoose(n, k) {
464
+ if (k < 0 || k > n) return -Infinity;
465
+ return logFactorial(n) - logFactorial(k) - logFactorial(n - k);
466
+ }
467
+ function logHypergeom(x, n, K, N) {
468
+ return logChoose(K, x) + logChoose(N - K, n - x) - logChoose(N, n);
469
+ }
470
+ function logSumExp(a, b) {
471
+ if (a === -Infinity) return b;
472
+ if (b === -Infinity) return a;
473
+ const m = Math.max(a, b);
474
+ return m + Math.log(Math.exp(a - m) + Math.exp(b - m));
475
+ }
476
+ function fisherUpperTail(k, n, K, N) {
477
+ const upper = Math.min(n, K);
478
+ let logP = -Infinity;
479
+ for (let x = k; x <= upper; x++) {
480
+ logP = logSumExp(logP, logHypergeom(x, n, K, N));
481
+ }
482
+ const p = Math.exp(logP);
483
+ if (!isFinite(p)) return 1;
484
+ return Math.min(1, Math.max(0, p));
485
+ }
486
+
487
+ // ---------- ontology graph helpers ----------
488
+
489
+ // Pick the "root" for a term as the ancestor (or self) with the highest
490
+ // background facet count. The true root has every annotated gene under it,
491
+ // so this selects BP/MF/CC for GO terms automatically and the rice root
492
+ // for pathways without needing per-ontology hardcoded ids.
493
+ function findRoot(ontKey, id, recs, bgCounts) {
494
+ const rec = recs && recs[id];
495
+ const candidates = new Set([+id]);
496
+ if (rec) {
497
+ if (ontKey === 'pathways') {
498
+ for (const k of Object.keys(rec)) {
499
+ if (k.startsWith('ancestors_') && Array.isArray(rec[k])) {
500
+ for (const a of rec[k]) candidates.add(+a);
501
+ }
502
+ }
503
+ } else if (Array.isArray(rec.ancestors)) {
504
+ for (const a of rec.ancestors) candidates.add(+a);
505
+ } else if (Array.isArray(rec.is_a)) {
506
+ const stack = rec.is_a.slice();
507
+ while (stack.length) {
508
+ const cur = +stack.pop();
509
+ if (candidates.has(cur)) continue;
510
+ candidates.add(cur);
511
+ const r = recs[cur];
512
+ if (r && Array.isArray(r.is_a)) for (const p of r.is_a) stack.push(p);
513
+ }
514
+ }
515
+ }
516
+ let best = +id;
517
+ let bestCount = bgCounts[+id] || 0;
518
+ for (const c of candidates) {
519
+ const cnt = bgCounts[c] || 0;
520
+ if (cnt > bestCount) { bestCount = cnt; best = c; }
521
+ }
522
+ return best;
523
+ }
524
+
525
+ function ancestorsOf(ontKey, id, recs) {
526
+ const out = new Set();
527
+ const rec = recs && recs[id];
528
+ if (!rec) return out;
529
+ if (ontKey === 'pathways') {
530
+ for (const k of Object.keys(rec)) {
531
+ if (k.startsWith('ancestors_') && Array.isArray(rec[k])) {
532
+ for (const a of rec[k]) if (+a !== +id) out.add(+a);
533
+ }
534
+ }
535
+ return out;
536
+ }
537
+ if (Array.isArray(rec.ancestors)) {
538
+ for (const a of rec.ancestors) if (+a !== +id) out.add(+a);
539
+ return out;
540
+ }
541
+ if (Array.isArray(rec.is_a)) {
542
+ const stack = rec.is_a.slice();
543
+ while (stack.length) {
544
+ const cur = +stack.pop();
545
+ if (out.has(cur)) continue;
546
+ out.add(cur);
547
+ const r = recs[cur];
548
+ if (r && Array.isArray(r.is_a)) for (const p of r.is_a) stack.push(p);
549
+ }
550
+ }
551
+ return out;
552
+ }
553
+
554
+ // Drop any term that is an ancestor of another term in the same passing set.
555
+ // Applied AFTER BH so a parent term can survive when none of its children
556
+ // pass the p_adj cutoff.
557
+ function collapseToMostSpecific(ontKey, rows, recs) {
558
+ if (!rows || rows.length <= 1) return rows;
559
+ const inSet = new Set(rows.map(r => +r.term_id));
560
+ const covered = new Set();
561
+ for (const r of rows) {
562
+ const ancs = ancestorsOf(ontKey, +r.term_id, recs);
563
+ for (const a of ancs) if (inSet.has(a)) covered.add(a);
564
+ }
565
+ return rows.filter(r => !covered.has(+r.term_id));
566
+ }
567
+
568
+ export default gsea;
@@ -7,5 +7,6 @@ import fieldCatalogBundle from './swaggerFields'
7
7
  import exporterBundle from './exporter'
8
8
  import ontologiesBundle from './ontologies'
9
9
  import exprVizBundle from './exprViz'
10
+ import gseaBundle from './gsea'
10
11
 
11
- export default [...apiBundles, docsBundle, filterBundle, viewsBundle, genomesBundle, fieldCatalogBundle, exporterBundle, ontologiesBundle, exprVizBundle];
12
+ export default [...apiBundles, docsBundle, filterBundle, viewsBundle, genomesBundle, fieldCatalogBundle, exporterBundle, ontologiesBundle, exprVizBundle, gseaBundle];