gramene-search 2.0.2 → 2.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,441 @@
1
+ import React, { useState, useMemo, useEffect } from 'react';
2
+ import { connect } from 'redux-bundler-react';
3
+ import { Modal, Button, ToggleButton, ToggleButtonGroup } from 'react-bootstrap';
4
+
5
+ function speciesTaxonId(tid) {
6
+ const n = +tid;
7
+ return n > 1000000 ? Math.floor(n / 1000) : n;
8
+ }
9
+
10
+ const EXPERIMENT_KEYS = [
11
+ { key: 'exp:study', label: 'Study' },
12
+ { key: 'exp:type', label: 'Study type' },
13
+ { key: 'exp:organism', label: 'Organism' }
14
+ ];
15
+
16
+ // One record per expression field (one sample group within one study).
17
+ // `props` is a flat map of property-key → value used for filtering and display.
18
+ function buildFieldRecords(taxon, studyIds, expressionStudies, expressionSamples, fieldCatalog, grameneMaps) {
19
+ const records = [];
20
+ const factorTypes = new Set();
21
+ const charTypes = new Set();
22
+ if (!expressionSamples || !expressionStudies) return { records, factorTypes: [], charTypes: [] };
23
+
24
+ const studyById = {};
25
+ const list = expressionStudies[taxon] || expressionStudies[speciesTaxonId(taxon)] || [];
26
+ list.forEach(s => { studyById[s._id] = s; });
27
+
28
+ for (const studyId of studyIds) {
29
+ const study = studyById[studyId];
30
+ if (!study) continue;
31
+ const samples = expressionSamples[studyId];
32
+ if (!samples) continue;
33
+
34
+ const byGroup = {};
35
+ for (const s of samples) if (!byGroup[s.group]) byGroup[s.group] = s;
36
+
37
+ for (const group of Object.keys(byGroup)) {
38
+ const sample = byGroup[group];
39
+ const fieldName = `${studyId.replace(/-/g, '_')}_${group}__expr`;
40
+ if (fieldCatalog && fieldCatalog.fields && !fieldCatalog.fields[fieldName]) continue;
41
+
42
+ const props = {};
43
+ props['exp:study'] = study.description || studyId;
44
+ props['exp:type'] = study.type || '(unknown)';
45
+ const taxon_id = study.taxon_id;
46
+ const taxName = grameneMaps && (grameneMaps[taxon_id] || grameneMaps[speciesTaxonId(taxon_id)]);
47
+ props['exp:organism'] = (taxName && taxName.display_name) || String(taxon_id);
48
+
49
+ const usedFactorTypes = new Set();
50
+ (sample.factor || []).forEach(f => {
51
+ props[`fac:${f.type}`] = f.label;
52
+ factorTypes.add(f.type);
53
+ usedFactorTypes.add(f.type);
54
+ });
55
+ (sample.characteristic || []).forEach(c => {
56
+ if (usedFactorTypes.has(c.type)) return; // factor takes precedence
57
+ props[`char:${c.type}`] = c.label;
58
+ charTypes.add(c.type);
59
+ });
60
+
61
+ records.push({ fieldName, studyId, group, props });
62
+ }
63
+ }
64
+ return {
65
+ records,
66
+ factorTypes: Array.from(factorTypes).sort(),
67
+ charTypes: Array.from(charTypes).sort()
68
+ };
69
+ }
70
+
71
+ function fieldMatches(field, selections, excludeKey) {
72
+ for (const key of Object.keys(selections)) {
73
+ if (key === excludeKey) continue;
74
+ const values = selections[key];
75
+ if (!values || values.size === 0) continue;
76
+ const v = field.props[key];
77
+ if (v == null || !values.has(v)) return false;
78
+ }
79
+ return true;
80
+ }
81
+
82
+ function valueCounts(records, key, selections) {
83
+ const counts = new Map();
84
+ for (const f of records) {
85
+ if (!fieldMatches(f, selections, key)) continue;
86
+ const v = f.props[key];
87
+ if (v == null) continue;
88
+ counts.set(v, (counts.get(v) || 0) + 1);
89
+ }
90
+ return counts;
91
+ }
92
+
93
+ function labelForKey(key, tree) {
94
+ for (const grp of tree) {
95
+ for (const t of grp.types) if (t.key === key) return t.label;
96
+ }
97
+ return key;
98
+ }
99
+
100
+ const FieldsModalCmp = props => {
101
+ const {
102
+ fieldCatalog,
103
+ exprViz,
104
+ expressionStudies,
105
+ expressionSamples,
106
+ grameneMaps,
107
+ doToggleExprVizFieldsModal,
108
+ doSetExprVizFields,
109
+ doFetchExprVizFieldExistence
110
+ } = props;
111
+
112
+ const taxon = Object.keys(exprViz.byTaxon).find(t => exprViz.byTaxon[t].fieldsModalOpen);
113
+ const open = !!taxon;
114
+ const availableAttrs = taxon && exprViz.byTaxon[taxon] && exprViz.byTaxon[taxon].availableAttrs;
115
+
116
+ const studyIds = useMemo(() => {
117
+ if (!taxon || !expressionStudies) return [];
118
+ const list = expressionStudies[taxon] || expressionStudies[speciesTaxonId(taxon)] || [];
119
+ let ids = list.map(s => s._id);
120
+ if (availableAttrs) {
121
+ const allow = new Set(availableAttrs);
122
+ ids = ids.filter(id => allow.has(id));
123
+ }
124
+ return ids;
125
+ }, [taxon, expressionStudies, availableAttrs]);
126
+
127
+ const allRecords = useMemo(
128
+ () => buildFieldRecords(taxon, studyIds, expressionStudies, expressionSamples, fieldCatalog, grameneMaps),
129
+ [taxon, studyIds, expressionStudies, expressionSamples, fieldCatalog, grameneMaps]
130
+ );
131
+
132
+ const candidateFields = useMemo(
133
+ () => allRecords.records.map(r => r.fieldName),
134
+ [allRecords]
135
+ );
136
+
137
+ // Trigger the field-existence facet check whenever the modal is open and
138
+ // the candidate field list is known. The bundle caches per (q, taxon, fields).
139
+ useEffect(() => {
140
+ if (open && taxon && candidateFields.length > 0) {
141
+ doFetchExprVizFieldExistence(taxon, candidateFields);
142
+ }
143
+ }, [open, taxon, candidateFields, doFetchExprVizFieldExistence]);
144
+
145
+ const fieldExistence = taxon && exprViz.byTaxon[taxon] && exprViz.byTaxon[taxon].fieldExistence;
146
+ const existenceStatus = taxon && exprViz.byTaxon[taxon] && exprViz.byTaxon[taxon].existenceStatus;
147
+
148
+ // Once existence counts are loaded, drop fields with no data in the current
149
+ // result set. Until then, render with the full candidate set so the modal
150
+ // doesn't flash empty during the precheck.
151
+ const { records, factorTypes, charTypes } = useMemo(() => {
152
+ if (!fieldExistence) return allRecords;
153
+ const live = new Set(Object.keys(fieldExistence).filter(f => fieldExistence[f] > 0));
154
+ const filtered = allRecords.records.filter(r => live.has(r.fieldName));
155
+ const factorTypes = new Set();
156
+ const charTypes = new Set();
157
+ for (const r of filtered) {
158
+ for (const k of Object.keys(r.props)) {
159
+ if (k.startsWith('fac:')) factorTypes.add(k.slice(4));
160
+ else if (k.startsWith('char:')) charTypes.add(k.slice(5));
161
+ }
162
+ }
163
+ return {
164
+ records: filtered,
165
+ factorTypes: Array.from(factorTypes).sort(),
166
+ charTypes: Array.from(charTypes).sort()
167
+ };
168
+ }, [allRecords, fieldExistence]);
169
+
170
+ const propTree = useMemo(() => [
171
+ { group: 'experiment', label: 'Experiment', types: EXPERIMENT_KEYS },
172
+ { group: 'factors', label: 'Factors', types: factorTypes.map(t => ({ key: `fac:${t}`, label: t })) },
173
+ { group: 'characteristics', label: 'Characteristics', types: charTypes.map(t => ({ key: `char:${t}`, label: t })) }
174
+ ], [factorTypes, charTypes]);
175
+
176
+ const [selections, setSelections] = useState({});
177
+ const [expandedKey, setExpandedKey] = useState(null);
178
+ const [collapsedGroups, setCollapsedGroups] = useState({});
179
+ const [valueSort, setValueSort] = useState('count');
180
+ const [orderedSelectedKeys, setOrderedSelectedKeys] = useState([]);
181
+ const [searchQuery, setSearchQuery] = useState('');
182
+ const searchLc = searchQuery.trim().toLowerCase();
183
+ const isSearching = searchLc.length > 0;
184
+
185
+ // Distinct values per property type, ignoring the type's own selection but
186
+ // applying every other selected type. Counts shown next to a property type
187
+ // reflect "how many distinct values exist among the fields currently
188
+ // matching the rest of the filter."
189
+ const valueSetByKey = useMemo(() => {
190
+ const allKeys = new Set();
191
+ for (const r of records) for (const k of Object.keys(r.props)) allKeys.add(k);
192
+ const m = {};
193
+ for (const key of allKeys) {
194
+ const set = new Set();
195
+ for (const r of records) {
196
+ if (!fieldMatches(r, selections, key)) continue;
197
+ const v = r.props[key];
198
+ if (v != null) set.add(v);
199
+ }
200
+ m[key] = set;
201
+ }
202
+ return m;
203
+ }, [records, selections]);
204
+
205
+ // Distinct values per property type across the unfiltered candidate set.
206
+ // A type with a single value across every record can't discriminate (e.g.
207
+ // "Organism" when every field belongs to the same species), so we hide it
208
+ // from the property tree.
209
+ const totalValueCountByKey = useMemo(() => {
210
+ const m = {};
211
+ for (const r of records) {
212
+ for (const [k, v] of Object.entries(r.props)) {
213
+ if (v == null) continue;
214
+ if (!m[k]) m[k] = new Set();
215
+ m[k].add(v);
216
+ }
217
+ }
218
+ return m;
219
+ }, [records]);
220
+
221
+ // Reset state when modal (re)opens for a taxon.
222
+ useEffect(() => {
223
+ if (open && taxon) {
224
+ setSelections({});
225
+ setExpandedKey(null);
226
+ setCollapsedGroups({});
227
+ setOrderedSelectedKeys([]);
228
+ setSearchQuery('');
229
+ }
230
+ }, [open, taxon]);
231
+
232
+ // Keep orderedSelectedKeys in sync with selections (preserves the order in
233
+ // which the user first selected each property type).
234
+ useEffect(() => {
235
+ setOrderedSelectedKeys(prev => {
236
+ const active = Object.keys(selections);
237
+ const activeSet = new Set(active);
238
+ const kept = prev.filter(k => activeSet.has(k));
239
+ const newOnes = active.filter(k => !prev.includes(k));
240
+ return [...kept, ...newOnes];
241
+ });
242
+ }, [selections]);
243
+
244
+ const matchingFields = useMemo(
245
+ () => records.filter(r => fieldMatches(r, selections, null)),
246
+ [records, selections]
247
+ );
248
+
249
+ if (!open) return null;
250
+
251
+ const toggleValue = (key, value) => {
252
+ setSelections(prev => {
253
+ const next = { ...prev };
254
+ const set = new Set(next[key] || []);
255
+ if (set.has(value)) set.delete(value); else set.add(value);
256
+ if (set.size === 0) delete next[key]; else next[key] = set;
257
+ return next;
258
+ });
259
+ };
260
+
261
+ const clearTypeSelection = (key) => {
262
+ setSelections(prev => {
263
+ if (!prev[key]) return prev;
264
+ const next = { ...prev };
265
+ delete next[key];
266
+ return next;
267
+ });
268
+ };
269
+
270
+ const toggleGroup = (g) => setCollapsedGroups(prev => ({ ...prev, [g]: !prev[g] }));
271
+
272
+ const renderValues = (key, typeLabelMatches) => {
273
+ const counts = valueCounts(records, key, selections);
274
+ let entries = Array.from(counts.entries());
275
+ if (isSearching && !typeLabelMatches) {
276
+ entries = entries.filter(([v]) => String(v).toLowerCase().includes(searchLc));
277
+ }
278
+ if (valueSort === 'name') entries.sort((a, b) => String(a[0]).localeCompare(String(b[0])));
279
+ else entries.sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0])));
280
+ const sel = selections[key] || new Set();
281
+
282
+ return (
283
+ <div className="exprviz-tree-values">
284
+ <div className="exprviz-values-toolbar">
285
+ <ToggleButtonGroup
286
+ type="radio"
287
+ name={`exprviz-vsort-${key}`}
288
+ size="sm"
289
+ value={valueSort}
290
+ onChange={setValueSort}
291
+ >
292
+ <ToggleButton id={`exprviz-vsort-name-${key}`} value="name" variant="outline-secondary">Name</ToggleButton>
293
+ <ToggleButton id={`exprviz-vsort-count-${key}`} value="count" variant="outline-secondary">Count</ToggleButton>
294
+ </ToggleButtonGroup>
295
+ {sel.size > 0 && (
296
+ <Button size="sm" variant="link" onClick={() => clearTypeSelection(key)}>clear</Button>
297
+ )}
298
+ </div>
299
+ {entries.map(([v, c]) => (
300
+ <label key={v} className="exprviz-tree-value">
301
+ <input type="checkbox" checked={sel.has(v)} onChange={() => toggleValue(key, v)} />
302
+ <span className="exprviz-tree-value-label" title={v}>{v}</span>
303
+ <span className="exprviz-tree-value-count">{c}</span>
304
+ </label>
305
+ ))}
306
+ {entries.length === 0 && <em className="exprviz-tree-empty">No values</em>}
307
+ </div>
308
+ );
309
+ };
310
+
311
+ return (
312
+ <Modal show={open} onHide={() => doToggleExprVizFieldsModal(taxon, false)} size="xl" scrollable>
313
+ <Modal.Header closeButton>
314
+ <Modal.Title>
315
+ Filter expression fields ({matchingFields.length} of {records.length} match)
316
+ {existenceStatus === 'loading' && (
317
+ <span className="exprviz-modal-loading"> · checking field availability…</span>
318
+ )}
319
+ </Modal.Title>
320
+ </Modal.Header>
321
+ <Modal.Body>
322
+ <div className="exprviz-fields-layout">
323
+ <div className="exprviz-tree">
324
+ <input
325
+ type="text"
326
+ className="exprviz-tree-search"
327
+ placeholder="Search property types and values…"
328
+ value={searchQuery}
329
+ onChange={e => setSearchQuery(e.target.value)}
330
+ />
331
+ {propTree.map(grp => {
332
+ const typeRows = grp.types.map(t => {
333
+ // Hide property types that can't discriminate among the
334
+ // candidates: zero values, or a single value shared by every
335
+ // record. The selection-aware count (valueSetByKey) is what
336
+ // we display; the unfiltered count gates visibility so a type
337
+ // doesn't pop in/out as the user clicks values.
338
+ const totalDistinct = (totalValueCountByKey[t.key] && totalValueCountByKey[t.key].size) || 0;
339
+ if (totalDistinct < 2) return null;
340
+ const numValues = (valueSetByKey[t.key] && valueSetByKey[t.key].size) || 0;
341
+ if (numValues === 0) return null;
342
+ const typeLabelMatches = isSearching && t.label.toLowerCase().includes(searchLc);
343
+ let matchingValueCount = numValues;
344
+ if (isSearching && !typeLabelMatches) {
345
+ const vset = valueSetByKey[t.key] || new Set();
346
+ matchingValueCount = 0;
347
+ for (const v of vset) if (String(v).toLowerCase().includes(searchLc)) matchingValueCount++;
348
+ if (matchingValueCount === 0) return null;
349
+ }
350
+ const sel = selections[t.key];
351
+ const selCount = sel ? sel.size : 0;
352
+ const isExpanded = isSearching ? !typeLabelMatches : (expandedKey === t.key);
353
+ return (
354
+ <div key={t.key} className={`exprviz-tree-type${selCount > 0 ? ' is-active' : ''}`}>
355
+ <div
356
+ className="exprviz-tree-type-header"
357
+ onClick={() => setExpandedKey(isExpanded ? null : t.key)}
358
+ >
359
+ <span className="exprviz-tree-caret">{isExpanded ? '▾' : '▸'}</span>
360
+ <span className="exprviz-tree-type-label">{t.label}</span>
361
+ <span className="exprviz-tree-type-count">
362
+ {selCount > 0 ? `${selCount}/${numValues}` : numValues}
363
+ </span>
364
+ </div>
365
+ {isExpanded && renderValues(t.key, typeLabelMatches)}
366
+ </div>
367
+ );
368
+ }).filter(Boolean);
369
+ if (isSearching && typeRows.length === 0) return null;
370
+ const groupCollapsed = !isSearching && collapsedGroups[grp.group];
371
+ return (
372
+ <div key={grp.group} className="exprviz-tree-group">
373
+ <div className="exprviz-tree-group-header" onClick={() => toggleGroup(grp.group)}>
374
+ <span className="exprviz-tree-caret">{groupCollapsed ? '▶' : '▼'}</span>
375
+ <strong>{grp.label}</strong>
376
+ </div>
377
+ {!groupCollapsed && grp.types.length === 0 && (
378
+ <div className="exprviz-tree-empty"><em>(none)</em></div>
379
+ )}
380
+ {!groupCollapsed && typeRows}
381
+ </div>
382
+ );
383
+ })}
384
+ </div>
385
+ <div className="exprviz-fields-preview">
386
+ {orderedSelectedKeys.length === 0 ? (
387
+ <div className="exprviz-fields-placeholder">
388
+ <em>Select a property type to preview the property values for each matching field.</em>
389
+ </div>
390
+ ) : (
391
+ <table className="exprviz-fields-table">
392
+ <thead>
393
+ <tr>
394
+ {orderedSelectedKeys.map(k => <th key={k}>{labelForKey(k, propTree)}</th>)}
395
+ </tr>
396
+ </thead>
397
+ <tbody>
398
+ {matchingFields.map(f => (
399
+ <tr key={f.fieldName} title={f.fieldName}>
400
+ {orderedSelectedKeys.map(k => <td key={k}>{f.props[k] || ''}</td>)}
401
+ </tr>
402
+ ))}
403
+ {matchingFields.length === 0 && (
404
+ <tr><td colSpan={orderedSelectedKeys.length}><em>No matching fields</em></td></tr>
405
+ )}
406
+ </tbody>
407
+ </table>
408
+ )}
409
+ </div>
410
+ </div>
411
+ </Modal.Body>
412
+ <Modal.Footer>
413
+ <Button variant="secondary" onClick={() => doToggleExprVizFieldsModal(taxon, false)}>
414
+ Cancel
415
+ </Button>
416
+ <Button
417
+ variant="primary"
418
+ disabled={matchingFields.length === 0}
419
+ onClick={() => {
420
+ doSetExprVizFields(taxon, matchingFields.map(f => f.fieldName));
421
+ doToggleExprVizFieldsModal(taxon, false);
422
+ }}
423
+ >
424
+ Apply ({matchingFields.length} fields)
425
+ </Button>
426
+ </Modal.Footer>
427
+ </Modal>
428
+ );
429
+ };
430
+
431
+ export default connect(
432
+ 'selectFieldCatalog',
433
+ 'selectExprViz',
434
+ 'selectExpressionStudies',
435
+ 'selectExpressionSamples',
436
+ 'selectGrameneMaps',
437
+ 'doToggleExprVizFieldsModal',
438
+ 'doSetExprVizFields',
439
+ 'doFetchExprVizFieldExistence',
440
+ FieldsModalCmp
441
+ );