gramene-search 2.1.11 → 2.2.0
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/launch.json +11 -0
- package/.claude/settings.local.json +6 -1
- package/.env.example +6 -0
- package/.parcel-cache/13f2d5707e7af45c-RequestGraph +0 -0
- package/.parcel-cache/5ae0570a78c0dba3-AssetGraph +0 -0
- package/.parcel-cache/9ac092379278e465-BundleGraph +0 -0
- package/.parcel-cache/data.mdb +0 -0
- package/.parcel-cache/lock.mdb +0 -0
- package/.parcel-cache/snapshot-13f2d5707e7af45c.txt +2 -2
- package/dist/index.js +2065 -154
- package/dist/index.js.map +1 -1
- package/package.json +5 -2
- package/src/bundles/api.js +10 -4
- package/src/bundles/exprViz.js +97 -1
- package/src/bundles/index.js +4 -1
- package/src/bundles/ontologyEnrichment.js +14 -1
- package/src/bundles/savedViews.js +335 -0
- package/src/bundles/uiViewState.js +174 -0
- package/src/bundles/viewSnapshot.js +313 -0
- package/src/bundles/views.js +22 -0
- package/src/components/Auth.js +23 -3
- package/src/components/SaveView.js +157 -0
- package/src/components/exprViz/ExprVizView.js +16 -11
- package/src/components/exprViz/ParallelCoordsPlot.js +15 -0
- package/src/components/results/GeneList.js +45 -15
- package/src/components/results/OntologyEnrichment.js +13 -6
- package/src/components/results/details/BAR.js +148 -0
- package/src/components/results/details/Expression.js +43 -11
- package/src/components/results/details/Homology.js +170 -32
- package/src/components/results/details/Pathways.js +4 -2
- package/src/components/results/details/Sequences.js +24 -8
- package/src/demo.js +22 -17
- package/src/index.js +2 -1
- package/src/suppressDevWarnings.js +13 -0
- package/src/utils/bootView.js +38 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Per-gene UI state lifted out of <Gene> and <Homology> class components.
|
|
2
|
+
// Keyed by geneId so it survives unmount/remount (e.g. scrolling a row out
|
|
3
|
+
// of view and back), and so a snapshot serializer can later round-trip it
|
|
4
|
+
// for the shareable-views feature.
|
|
5
|
+
//
|
|
6
|
+
// What lives here:
|
|
7
|
+
// - expandedDetail: which detail tab is open in the gene-list card
|
|
8
|
+
// - fullscreen: whether the expanded detail is full-screened
|
|
9
|
+
// - homology.viewer: 'treevis' | 'tbrowse'
|
|
10
|
+
// - homology.height: drag-resize height of the homology detail pane
|
|
11
|
+
// - homology.tbrowse: the tbrowse ViewState (collapsedNodeIds, prunedNodeIds,
|
|
12
|
+
// swappedNodeIds, compressedNodeIds, nodeOfInterestId, zones, zoneStates,
|
|
13
|
+
// search, selectedNodeId). Tbrowse is driven in controlled mode from this.
|
|
14
|
+
// - sequences: the Sequences detail's internal state — tab ('dna'|'rna'|'pep'),
|
|
15
|
+
// tid (selected transcript id), upstream/downstream (flanking bp). Driven in
|
|
16
|
+
// controlled mode from this so a saved view restores the chosen sub-tab and
|
|
17
|
+
// isoform.
|
|
18
|
+
// - expression: the Expression detail's internal state — activeTab
|
|
19
|
+
// ('gene'|'paralogs'|'eFP'), atlasExperiment (selected GXA experiment id),
|
|
20
|
+
// barStudy (selected eFP/BAR study). Driven in controlled mode from this so a
|
|
21
|
+
// saved view restores the chosen sub-tab and study.
|
|
22
|
+
//
|
|
23
|
+
// What does NOT live here:
|
|
24
|
+
// - derived/computed state like `details` (per-render config from
|
|
25
|
+
// props.config.details + capabilities)
|
|
26
|
+
// - async-fetched data caches in <Homology> (neighborhood, geneStructures)
|
|
27
|
+
|
|
28
|
+
const initialState = {
|
|
29
|
+
byGene: {}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const ensureGene = (state, geneId) => {
|
|
33
|
+
if (state.byGene[geneId]) return state;
|
|
34
|
+
return {
|
|
35
|
+
...state,
|
|
36
|
+
byGene: {
|
|
37
|
+
...state.byGene,
|
|
38
|
+
[geneId]: { homology: {} }
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const setGene = (state, geneId, patch) => {
|
|
44
|
+
const next = ensureGene(state, geneId);
|
|
45
|
+
return {
|
|
46
|
+
...next,
|
|
47
|
+
byGene: {
|
|
48
|
+
...next.byGene,
|
|
49
|
+
[geneId]: { ...next.byGene[geneId], ...patch }
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const setHomology = (state, geneId, patch) => {
|
|
55
|
+
const next = ensureGene(state, geneId);
|
|
56
|
+
const prev = next.byGene[geneId];
|
|
57
|
+
return {
|
|
58
|
+
...next,
|
|
59
|
+
byGene: {
|
|
60
|
+
...next.byGene,
|
|
61
|
+
[geneId]: {
|
|
62
|
+
...prev,
|
|
63
|
+
homology: { ...(prev.homology || {}), ...patch }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const setSequences = (state, geneId, patch) => {
|
|
70
|
+
const next = ensureGene(state, geneId);
|
|
71
|
+
const prev = next.byGene[geneId];
|
|
72
|
+
return {
|
|
73
|
+
...next,
|
|
74
|
+
byGene: {
|
|
75
|
+
...next.byGene,
|
|
76
|
+
[geneId]: {
|
|
77
|
+
...prev,
|
|
78
|
+
sequences: { ...(prev.sequences || {}), ...patch }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const setExpression = (state, geneId, patch) => {
|
|
85
|
+
const next = ensureGene(state, geneId);
|
|
86
|
+
const prev = next.byGene[geneId];
|
|
87
|
+
return {
|
|
88
|
+
...next,
|
|
89
|
+
byGene: {
|
|
90
|
+
...next.byGene,
|
|
91
|
+
[geneId]: {
|
|
92
|
+
...prev,
|
|
93
|
+
expression: { ...(prev.expression || {}), ...patch }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const uiViewState = {
|
|
100
|
+
name: 'uiViewState',
|
|
101
|
+
getReducer: () => (state = initialState, { type, payload }) => {
|
|
102
|
+
switch (type) {
|
|
103
|
+
case 'UI_GENE_DETAIL_EXPANDED':
|
|
104
|
+
// payload: { geneId, detail } (detail = null to collapse)
|
|
105
|
+
return setGene(state, payload.geneId, {
|
|
106
|
+
expandedDetail: payload.detail,
|
|
107
|
+
// collapsing also exits fullscreen
|
|
108
|
+
fullscreen: payload.detail === null ? false : state.byGene[payload.geneId]?.fullscreen || false
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
case 'UI_GENE_FULLSCREEN_SET':
|
|
112
|
+
// payload: { geneId, fullscreen }
|
|
113
|
+
return setGene(state, payload.geneId, { fullscreen: !!payload.fullscreen });
|
|
114
|
+
|
|
115
|
+
case 'UI_HOMOLOGY_VIEWER_SET':
|
|
116
|
+
// payload: { geneId, viewer }
|
|
117
|
+
return setHomology(state, payload.geneId, { viewer: payload.viewer });
|
|
118
|
+
|
|
119
|
+
case 'UI_HOMOLOGY_HEIGHT_SET':
|
|
120
|
+
// payload: { geneId, height }
|
|
121
|
+
return setHomology(state, payload.geneId, { height: payload.height });
|
|
122
|
+
|
|
123
|
+
case 'UI_HOMOLOGY_TBROWSE_SET':
|
|
124
|
+
// payload: { geneId, tbrowse } -- a full ViewState object
|
|
125
|
+
return setHomology(state, payload.geneId, { tbrowse: payload.tbrowse });
|
|
126
|
+
|
|
127
|
+
case 'UI_SEQUENCES_SET':
|
|
128
|
+
// payload: { geneId, patch } -- merges into the sequences slice
|
|
129
|
+
// (e.g. { tab }, { tid }, { upstream }, { downstream })
|
|
130
|
+
return setSequences(state, payload.geneId, payload.patch);
|
|
131
|
+
|
|
132
|
+
case 'UI_EXPRESSION_SET':
|
|
133
|
+
// payload: { geneId, patch } -- merges into the expression slice
|
|
134
|
+
// (e.g. { activeTab }, { atlasExperiment }, { barStudy })
|
|
135
|
+
return setExpression(state, payload.geneId, payload.patch);
|
|
136
|
+
|
|
137
|
+
case 'UI_VIEW_STATE_REPLACED':
|
|
138
|
+
// payload: { byGene } -- used by the snapshot loader in a later phase
|
|
139
|
+
return { byGene: payload.byGene || {} };
|
|
140
|
+
|
|
141
|
+
default:
|
|
142
|
+
return state;
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
selectUiViewState: state => state.uiViewState,
|
|
147
|
+
|
|
148
|
+
doExpandGeneDetail: ({ geneId, detail }) => ({ dispatch }) => {
|
|
149
|
+
dispatch({ type: 'UI_GENE_DETAIL_EXPANDED', payload: { geneId, detail } });
|
|
150
|
+
},
|
|
151
|
+
doSetGeneFullscreen: ({ geneId, fullscreen }) => ({ dispatch }) => {
|
|
152
|
+
dispatch({ type: 'UI_GENE_FULLSCREEN_SET', payload: { geneId, fullscreen } });
|
|
153
|
+
},
|
|
154
|
+
doSetHomologyViewer: ({ geneId, viewer }) => ({ dispatch }) => {
|
|
155
|
+
dispatch({ type: 'UI_HOMOLOGY_VIEWER_SET', payload: { geneId, viewer } });
|
|
156
|
+
},
|
|
157
|
+
doSetHomologyHeight: ({ geneId, height }) => ({ dispatch }) => {
|
|
158
|
+
dispatch({ type: 'UI_HOMOLOGY_HEIGHT_SET', payload: { geneId, height } });
|
|
159
|
+
},
|
|
160
|
+
doSetHomologyTbrowseViewState: ({ geneId, tbrowse }) => ({ dispatch }) => {
|
|
161
|
+
dispatch({ type: 'UI_HOMOLOGY_TBROWSE_SET', payload: { geneId, tbrowse } });
|
|
162
|
+
},
|
|
163
|
+
doSetSequencesState: ({ geneId, patch }) => ({ dispatch }) => {
|
|
164
|
+
dispatch({ type: 'UI_SEQUENCES_SET', payload: { geneId, patch } });
|
|
165
|
+
},
|
|
166
|
+
doSetExpressionState: ({ geneId, patch }) => ({ dispatch }) => {
|
|
167
|
+
dispatch({ type: 'UI_EXPRESSION_SET', payload: { geneId, patch } });
|
|
168
|
+
},
|
|
169
|
+
doReplaceUiViewState: byGene => ({ dispatch }) => {
|
|
170
|
+
dispatch({ type: 'UI_VIEW_STATE_REPLACED', payload: { byGene } });
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
export default uiViewState;
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// Serializer/deserializer for shareable gene-search views.
|
|
2
|
+
//
|
|
3
|
+
// `selectViewSnapshot` produces a versioned JSON-safe blob from the current
|
|
4
|
+
// store state. `doApplyViewSnapshot` validates a blob and restores it via
|
|
5
|
+
// existing action creators. Persistence + UI live in later phases — this
|
|
6
|
+
// bundle is the pure state-shape layer they build on.
|
|
7
|
+
//
|
|
8
|
+
// Snapshot schema, v1:
|
|
9
|
+
// {
|
|
10
|
+
// v: 1,
|
|
11
|
+
// capturedAt: <ISO 8601 string>,
|
|
12
|
+
// site: <subsite id>,
|
|
13
|
+
// filters: <grameneFilters tree, stripped of ephemeral UI flags>,
|
|
14
|
+
// views: { on: [id,...], touched: {id: true, ...} },
|
|
15
|
+
// genomeSubset: { taxonId: true, ... } | null,
|
|
16
|
+
// searchPage: { offset: number, rows: number } | null,
|
|
17
|
+
// exprViz: { // Expression-visualization view
|
|
18
|
+
// activeTaxon: <taxon_id|null>,
|
|
19
|
+
// byTaxon: { [taxon_id]: { selectedFields: [field,...], vizMode, scale, brushes } }
|
|
20
|
+
// } | undefined, // loaded rows re-fetch on apply
|
|
21
|
+
// ontologyEnrichment: { // Ontology Enrichment view
|
|
22
|
+
// activeTaxon: <taxon_id|null>,
|
|
23
|
+
// ui: { pAdjCutoff, minGSSize, maxGSSize, mostSpecific, ontology, search, sort }
|
|
24
|
+
// } | undefined, // facets re-fetch on apply
|
|
25
|
+
// expandedDetails: [
|
|
26
|
+
// { geneId,
|
|
27
|
+
// expandedDetail: string|null,
|
|
28
|
+
// fullscreen: boolean,
|
|
29
|
+
// homology: { viewer, height, tbrowse: <ViewState> } | undefined,
|
|
30
|
+
// sequences: { tab, tid, upstream, downstream } | undefined,
|
|
31
|
+
// expression: { activeTab, atlasExperiment, barStudy } | undefined
|
|
32
|
+
// }, ...
|
|
33
|
+
// ]
|
|
34
|
+
// }
|
|
35
|
+
//
|
|
36
|
+
// Anything not in this schema is intentionally NOT persisted — async fetch
|
|
37
|
+
// caches, search-results, pagination cursors mid-flight, etc. all rehydrate
|
|
38
|
+
// from the API once the snapshot is applied.
|
|
39
|
+
|
|
40
|
+
const SCHEMA_VERSION = 1;
|
|
41
|
+
|
|
42
|
+
// Keys on a filter node we want to keep. Everything else (showMenu, marked,
|
|
43
|
+
// status flags from the root, etc.) is ephemeral UI and not meaningful in a
|
|
44
|
+
// shared view.
|
|
45
|
+
const FILTER_NODE_KEEP = [
|
|
46
|
+
'leftIdx', 'rightIdx', 'operation', 'negate',
|
|
47
|
+
'fq_field', 'fq_value', 'name', 'category',
|
|
48
|
+
'warning'
|
|
49
|
+
];
|
|
50
|
+
const FILTER_ROOT_KEEP = [
|
|
51
|
+
'operation', 'negate', 'leftIdx', 'rightIdx',
|
|
52
|
+
'children', 'searchOffset', 'rows'
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const cleanFilterNode = (node) => {
|
|
56
|
+
const out = {};
|
|
57
|
+
for (const k of FILTER_NODE_KEEP) {
|
|
58
|
+
if (node[k] !== undefined) out[k] = node[k];
|
|
59
|
+
}
|
|
60
|
+
if (Array.isArray(node.children)) {
|
|
61
|
+
out.children = node.children.map(cleanFilterNode);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const cleanFilters = (filters) => {
|
|
67
|
+
const out = {};
|
|
68
|
+
for (const k of FILTER_ROOT_KEEP) {
|
|
69
|
+
if (filters[k] !== undefined) out[k] = filters[k];
|
|
70
|
+
}
|
|
71
|
+
if (Array.isArray(filters.children)) {
|
|
72
|
+
out.children = filters.children.map(cleanFilterNode);
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Drop bundle entries that hold no meaningful state (e.g. a row the user
|
|
78
|
+
// scrolled past but never opened). Otherwise snapshots bloat with empties.
|
|
79
|
+
const isMeaningfulGene = (entry) => {
|
|
80
|
+
if (!entry) return false;
|
|
81
|
+
if (entry.expandedDetail) return true;
|
|
82
|
+
if (entry.fullscreen) return true;
|
|
83
|
+
if (entry.homology && (
|
|
84
|
+
entry.homology.viewer !== undefined ||
|
|
85
|
+
entry.homology.height !== undefined ||
|
|
86
|
+
entry.homology.tbrowse !== undefined
|
|
87
|
+
)) return true;
|
|
88
|
+
if (entry.sequences && Object.keys(entry.sequences).length) return true;
|
|
89
|
+
if (entry.expression && Object.keys(entry.expression).length) return true;
|
|
90
|
+
return false;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const viewSnapshot = {
|
|
94
|
+
name: 'viewSnapshot',
|
|
95
|
+
// No reducer — this bundle is pure derivation + dispatch orchestration.
|
|
96
|
+
getReducer: () => (state = {}) => state,
|
|
97
|
+
|
|
98
|
+
selectViewSnapshot: createSnapshotSelector(),
|
|
99
|
+
|
|
100
|
+
// Apply a snapshot to the store. Returns an array of unresolved-id warnings
|
|
101
|
+
// so the caller can surface a non-blocking notice. The action creators we
|
|
102
|
+
// delegate to are existing ones; nothing here knows about API/UI/share-link
|
|
103
|
+
// — that's later phases.
|
|
104
|
+
doApplyViewSnapshot: (snapshot) => ({dispatch, store}) => {
|
|
105
|
+
const warnings = validateSnapshot(snapshot);
|
|
106
|
+
if (warnings.fatal) {
|
|
107
|
+
console.warn('viewSnapshot: refusing to apply', warnings.fatal);
|
|
108
|
+
return { applied: false, warnings };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 1. Filters (also clears search; replays a fresh fetch).
|
|
112
|
+
if (snapshot.filters) {
|
|
113
|
+
dispatch({
|
|
114
|
+
type: 'BATCH_ACTIONS', actions: [
|
|
115
|
+
{type: 'GRAMENE_SEARCH_CLEARED'},
|
|
116
|
+
{type: 'GRAMENE_FILTERS_REPLACED', payload: cloneForDispatch(snapshot.filters)}
|
|
117
|
+
]
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 2. Views.
|
|
122
|
+
if (snapshot.views) {
|
|
123
|
+
dispatch({
|
|
124
|
+
type: 'GRAMENE_VIEWS_REPLACED',
|
|
125
|
+
payload: {
|
|
126
|
+
on: snapshot.views.on || [],
|
|
127
|
+
touched: snapshot.views.touched || {}
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 3. Genome subset, if present.
|
|
133
|
+
if (snapshot.genomeSubset && Object.keys(snapshot.genomeSubset).length) {
|
|
134
|
+
dispatch({type: 'GRAMENE_GENOMES_UPDATED', payload: {...snapshot.genomeSubset}});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 4. Per-gene UI state (expandedDetail, fullscreen, homology viewer/
|
|
138
|
+
// height, tbrowse view state). Replace wholesale via the uiViewState
|
|
139
|
+
// bundle's bulk action.
|
|
140
|
+
const byGene = {};
|
|
141
|
+
if (Array.isArray(snapshot.expandedDetails)) {
|
|
142
|
+
for (const e of snapshot.expandedDetails) {
|
|
143
|
+
if (!e || !e.geneId) continue;
|
|
144
|
+
byGene[e.geneId] = {
|
|
145
|
+
expandedDetail: e.expandedDetail || null,
|
|
146
|
+
fullscreen: !!e.fullscreen,
|
|
147
|
+
homology: e.homology ? {...e.homology} : {},
|
|
148
|
+
sequences: e.sequences ? {...e.sequences} : {},
|
|
149
|
+
expression: e.expression ? {...e.expression} : {}
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
dispatch({type: 'UI_VIEW_STATE_REPLACED', payload: {byGene}});
|
|
154
|
+
|
|
155
|
+
// 5. Expression-visualization view config. Delegate to the exprViz bundle,
|
|
156
|
+
// which flags each taxon for a re-fetch now that the restored filters/
|
|
157
|
+
// genomes are in place. Done last so the re-fetch sees the live query.
|
|
158
|
+
if (snapshot.exprViz && store.doApplyExprVizSnapshot) {
|
|
159
|
+
store.doApplyExprVizSnapshot(snapshot.exprViz);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 6. Ontology Enrichment view config. Setting the active taxon lets that
|
|
163
|
+
// bundle's fetch reactor re-derive the enrichment for the restored
|
|
164
|
+
// filters/genomes.
|
|
165
|
+
if (snapshot.ontologyEnrichment && store.doApplyOntologyEnrichmentSnapshot) {
|
|
166
|
+
store.doApplyOntologyEnrichmentSnapshot(snapshot.ontologyEnrichment);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { applied: true, warnings };
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
// Helper exposed for the bootstrap path that wants to read warnings without
|
|
173
|
+
// dispatching. Returns { fatal: <string|null>, unresolved: { ids/taxa/views/genes lists } }.
|
|
174
|
+
doValidateViewSnapshot: (snapshot) => () => validateSnapshot(snapshot)
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
function createSnapshotSelector() {
|
|
178
|
+
// We deliberately don't memoize with createSelector — snapshots are taken
|
|
179
|
+
// on demand (Save button click, link generation) rather than on every
|
|
180
|
+
// store tick, so a fresh build is fine and avoids stale-dep bugs.
|
|
181
|
+
return (state) => buildSnapshot(state);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function buildSnapshot(state) {
|
|
185
|
+
const snap = {
|
|
186
|
+
v: SCHEMA_VERSION,
|
|
187
|
+
capturedAt: new Date().toISOString(),
|
|
188
|
+
site: (state.config && state.config.id) || null,
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
if (state.grameneFilters) {
|
|
192
|
+
snap.filters = cleanFilters(state.grameneFilters);
|
|
193
|
+
snap.searchPage = {
|
|
194
|
+
offset: state.grameneFilters.searchOffset || 0,
|
|
195
|
+
rows: state.grameneFilters.rows || 20
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (state.grameneViews && Array.isArray(state.grameneViews.options)) {
|
|
200
|
+
snap.views = {
|
|
201
|
+
on: state.grameneViews.options
|
|
202
|
+
.filter(v => v && v.show === 'on')
|
|
203
|
+
.map(v => v.id),
|
|
204
|
+
touched: {...(state.grameneViews.touched || {})}
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (state.grameneGenomes && state.grameneGenomes.active) {
|
|
209
|
+
const keys = Object.keys(state.grameneGenomes.active);
|
|
210
|
+
if (keys.length) {
|
|
211
|
+
snap.genomeSubset = {};
|
|
212
|
+
for (const k of keys) snap.genomeSubset[k] = true;
|
|
213
|
+
} else {
|
|
214
|
+
snap.genomeSubset = null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (state.uiViewState && state.uiViewState.byGene) {
|
|
219
|
+
snap.expandedDetails = Object.entries(state.uiViewState.byGene)
|
|
220
|
+
.filter(([, e]) => isMeaningfulGene(e))
|
|
221
|
+
.map(([geneId, e]) => ({
|
|
222
|
+
geneId,
|
|
223
|
+
expandedDetail: e.expandedDetail || null,
|
|
224
|
+
fullscreen: !!e.fullscreen,
|
|
225
|
+
homology: e.homology && Object.keys(e.homology).length ? {...e.homology} : undefined,
|
|
226
|
+
sequences: e.sequences && Object.keys(e.sequences).length ? {...e.sequences} : undefined,
|
|
227
|
+
expression: e.expression && Object.keys(e.expression).length ? {...e.expression} : undefined
|
|
228
|
+
}));
|
|
229
|
+
} else {
|
|
230
|
+
snap.expandedDetails = [];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Expression-visualization view config. Capture only the selection/config
|
|
234
|
+
// (active genome tab, per-taxon selected fields + column order, viz mode,
|
|
235
|
+
// scale, brush ranges) — never the bulk fetched rows, which rehydrate via
|
|
236
|
+
// the view's re-fetch on apply.
|
|
237
|
+
if (state.exprViz) {
|
|
238
|
+
const ev = state.exprViz;
|
|
239
|
+
const byTaxon = {};
|
|
240
|
+
for (const tid of Object.keys(ev.byTaxon || {})) {
|
|
241
|
+
const t = ev.byTaxon[tid];
|
|
242
|
+
if (!t) continue;
|
|
243
|
+
const selectedFields = Array.isArray(t.selectedFields) ? t.selectedFields : [];
|
|
244
|
+
const brushes = t.brushes && Object.keys(t.brushes).length ? {...t.brushes} : undefined;
|
|
245
|
+
const nonDefaultMode = t.vizMode && t.vizMode !== 'heatmap';
|
|
246
|
+
const nonDefaultScale = t.scale && t.scale !== 'linear';
|
|
247
|
+
if (selectedFields.length || brushes || nonDefaultMode || nonDefaultScale) {
|
|
248
|
+
byTaxon[tid] = {
|
|
249
|
+
selectedFields,
|
|
250
|
+
vizMode: t.vizMode || 'heatmap',
|
|
251
|
+
scale: t.scale || 'linear',
|
|
252
|
+
...(brushes ? { brushes } : {})
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (ev.activeTaxon || Object.keys(byTaxon).length) {
|
|
257
|
+
snap.exprViz = { activeTaxon: ev.activeTaxon || null, byTaxon };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Ontology Enrichment view config: active genome + the analysis knobs
|
|
262
|
+
// (significance cutoff, gene-set-size bounds, most-specific, ontology
|
|
263
|
+
// section, term search, per-section sort). The enrichment facets re-fetch
|
|
264
|
+
// on apply, so no bulk term data is carried.
|
|
265
|
+
if (state.ontologyEnrichment) {
|
|
266
|
+
const oe = state.ontologyEnrichment;
|
|
267
|
+
const ui = oe.ui || {};
|
|
268
|
+
const DEF = { pAdjCutoff: 0.05, minGSSize: 10, maxGSSize: 500, mostSpecific: false, ontology: 'all', search: '' };
|
|
269
|
+
const hasSort = ui.sort && Object.keys(ui.sort).length;
|
|
270
|
+
const uiChanged = Object.keys(DEF).some(k => ui[k] !== DEF[k]) || hasSort;
|
|
271
|
+
if (oe.activeTaxon || uiChanged) {
|
|
272
|
+
snap.ontologyEnrichment = {
|
|
273
|
+
activeTaxon: oe.activeTaxon || null,
|
|
274
|
+
ui: {
|
|
275
|
+
pAdjCutoff: ui.pAdjCutoff != null ? ui.pAdjCutoff : DEF.pAdjCutoff,
|
|
276
|
+
minGSSize: ui.minGSSize != null ? ui.minGSSize : DEF.minGSSize,
|
|
277
|
+
maxGSSize: ui.maxGSSize != null ? ui.maxGSSize : DEF.maxGSSize,
|
|
278
|
+
mostSpecific: !!ui.mostSpecific,
|
|
279
|
+
ontology: ui.ontology || DEF.ontology,
|
|
280
|
+
search: ui.search || DEF.search,
|
|
281
|
+
...(hasSort ? { sort: {...ui.sort} } : {})
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return snap;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function validateSnapshot(snapshot) {
|
|
291
|
+
const warnings = { fatal: null, unresolved: { views: [], genes: [], taxa: [] } };
|
|
292
|
+
if (!snapshot || typeof snapshot !== 'object') {
|
|
293
|
+
warnings.fatal = 'snapshot is not an object';
|
|
294
|
+
return warnings;
|
|
295
|
+
}
|
|
296
|
+
if (snapshot.v !== SCHEMA_VERSION) {
|
|
297
|
+
warnings.fatal = `unsupported snapshot version ${snapshot.v} (expected ${SCHEMA_VERSION})`;
|
|
298
|
+
return warnings;
|
|
299
|
+
}
|
|
300
|
+
// Strict-id checks happen later — they need the store's grameneMaps,
|
|
301
|
+
// grameneTaxonomy, etc. which are async-loaded. The boot path will call
|
|
302
|
+
// doValidateViewSnapshot again after those land. For now we just
|
|
303
|
+
// structurally validate.
|
|
304
|
+
return warnings;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// JSON.parse(JSON.stringify(x)) — bundles see a wholly fresh object so
|
|
308
|
+
// reducers can't accidentally mutate the snapshot we're holding for retry.
|
|
309
|
+
function cloneForDispatch(x) {
|
|
310
|
+
return JSON.parse(JSON.stringify(x));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export default viewSnapshot;
|
package/src/bundles/views.js
CHANGED
|
@@ -102,6 +102,21 @@ const grameneViews = {
|
|
|
102
102
|
newState = Object.assign({}, state);
|
|
103
103
|
newState.options.forEach(v => v.shouldScroll = false)
|
|
104
104
|
return newState;
|
|
105
|
+
case 'GRAMENE_VIEWS_REPLACED': {
|
|
106
|
+
// Snapshot restore. payload: { on: Set|Array<id>, touched: {id: true} }
|
|
107
|
+
// Sets options[].show directly to 'on' or 'off' based on membership in
|
|
108
|
+
// `on`. Leaves 'disabled' entries untouched (site config wins).
|
|
109
|
+
const onSet = payload.on instanceof Set
|
|
110
|
+
? payload.on
|
|
111
|
+
: new Set(payload.on || []);
|
|
112
|
+
newState = Object.assign({}, state);
|
|
113
|
+
newState.touched = { ...(payload.touched || {}) };
|
|
114
|
+
newState.options = state.options.map(v => {
|
|
115
|
+
if (v.show === 'disabled') return v;
|
|
116
|
+
return { ...v, show: onSet.has(v.id) ? 'on' : 'off', shouldScroll: false };
|
|
117
|
+
});
|
|
118
|
+
return newState;
|
|
119
|
+
}
|
|
105
120
|
default:
|
|
106
121
|
return state;
|
|
107
122
|
}
|
|
@@ -116,6 +131,13 @@ const grameneViews = {
|
|
|
116
131
|
doCancelShouldScroll: () => ({dispatch, getState}) => {
|
|
117
132
|
dispatch({type: 'GRAMENE_VIEW_SCROLLED', payload: null})
|
|
118
133
|
},
|
|
134
|
+
// Snapshot restore. `on` is an array of view ids to switch on; everything
|
|
135
|
+
// else (except disabled views) goes off. `touched` is the same map the
|
|
136
|
+
// user-toggled view bundle maintains, so the auto-default logic in
|
|
137
|
+
// selectGrameneViews respects what the user has explicitly set.
|
|
138
|
+
doReplaceGrameneViews: ({on, touched}) => ({dispatch}) => {
|
|
139
|
+
dispatch({type: 'GRAMENE_VIEWS_REPLACED', payload: {on, touched}});
|
|
140
|
+
},
|
|
119
141
|
selectRawGrameneViews: state => state.grameneViews,
|
|
120
142
|
selectGrameneViews: createSelector(
|
|
121
143
|
'selectRawGrameneViews',
|
package/src/components/Auth.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import React, { useMemo, useState } from 'react'
|
|
1
|
+
import React, { useMemo, useState, useEffect } from 'react'
|
|
2
2
|
import { Button, Modal } from 'react-bootstrap'
|
|
3
3
|
import { connect } from "redux-bundler-react";
|
|
4
4
|
import { getFirebaseApp } from "./utils";
|
|
5
5
|
import { getAuth, onAuthStateChanged, signOut, GoogleAuthProvider, signInWithPopup } from "firebase/auth";
|
|
6
6
|
import { BsChevronDown, BsChevronRight } from 'react-icons/bs';
|
|
7
|
+
import SaveViewButton from './SaveView';
|
|
7
8
|
|
|
8
9
|
const provider = new GoogleAuthProvider();
|
|
9
10
|
|
|
@@ -13,10 +14,23 @@ const Auth = props => {
|
|
|
13
14
|
const app = getFirebaseApp(firebaseConfig);
|
|
14
15
|
return app ? getAuth(app) : null;
|
|
15
16
|
}, [firebaseConfig]);
|
|
16
|
-
const [user, setUser] = useState(
|
|
17
|
+
const [user, setUser] = useState(null);
|
|
17
18
|
const [open, setOpen] = useState(true);
|
|
19
|
+
|
|
20
|
+
// Subscribe once, unsubscribe on unmount. On each auth-state emission,
|
|
21
|
+
// also poke the shared-view boot path so a `?view=<hash>` for a private
|
|
22
|
+
// view gets retried with the now-available Bearer token. bootViewFromUrl
|
|
23
|
+
// no-ops when the param isn't present, so this is cheap.
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!auth) return undefined;
|
|
26
|
+
const unsub = onAuthStateChanged(auth, (u) => {
|
|
27
|
+
setUser(u);
|
|
28
|
+
props.doBootSharedView({user: u});
|
|
29
|
+
});
|
|
30
|
+
return unsub;
|
|
31
|
+
}, [auth]);
|
|
32
|
+
|
|
18
33
|
if (!auth) return null;
|
|
19
|
-
onAuthStateChanged(auth, (user) => setUser(user));
|
|
20
34
|
|
|
21
35
|
function handleLogin() {
|
|
22
36
|
signInWithPopup(auth, provider)
|
|
@@ -52,6 +66,11 @@ const Auth = props => {
|
|
|
52
66
|
: <Button size="sm" variant="success" onClick={handleLogin}>Login</Button>
|
|
53
67
|
}
|
|
54
68
|
</div>
|
|
69
|
+
{user && user.uid && (
|
|
70
|
+
<div>
|
|
71
|
+
<SaveViewButton user={user}/>
|
|
72
|
+
</div>
|
|
73
|
+
)}
|
|
55
74
|
</div>}
|
|
56
75
|
</div>
|
|
57
76
|
</div>
|
|
@@ -60,5 +79,6 @@ const Auth = props => {
|
|
|
60
79
|
|
|
61
80
|
export default connect(
|
|
62
81
|
'selectConfiguration',
|
|
82
|
+
'doBootSharedView',
|
|
63
83
|
Auth
|
|
64
84
|
)
|