gramene-search 2.1.10 → 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.
Files changed (41) hide show
  1. package/.claude/launch.json +11 -0
  2. package/.claude/settings.local.json +21 -1
  3. package/.env.example +6 -0
  4. package/.parcel-cache/13f2d5707e7af45c-RequestGraph +0 -0
  5. package/.parcel-cache/5ae0570a78c0dba3-AssetGraph +0 -0
  6. package/.parcel-cache/9ac092379278e465-BundleGraph +0 -0
  7. package/.parcel-cache/data.mdb +0 -0
  8. package/.parcel-cache/lock.mdb +0 -0
  9. package/.parcel-cache/snapshot-13f2d5707e7af45c.txt +2 -2
  10. package/dist/index.css +1 -4
  11. package/dist/index.css.map +1 -1
  12. package/dist/index.js +2308 -433
  13. package/dist/index.js.map +1 -1
  14. package/package.json +5 -2
  15. package/src/bundles/api.js +24 -42
  16. package/src/bundles/docs.js +2 -1
  17. package/src/bundles/exprViz.js +97 -1
  18. package/src/bundles/index.js +4 -1
  19. package/src/bundles/ontologyEnrichment.js +14 -1
  20. package/src/bundles/savedViews.js +335 -0
  21. package/src/bundles/uiViewState.js +174 -0
  22. package/src/bundles/viewSnapshot.js +313 -0
  23. package/src/bundles/views.js +24 -2
  24. package/src/components/Auth.js +23 -3
  25. package/src/components/SaveView.js +157 -0
  26. package/src/components/exprViz/ExprVizView.js +16 -11
  27. package/src/components/exprViz/ParallelCoordsPlot.js +15 -0
  28. package/src/components/results/GeneList.js +45 -49
  29. package/src/components/results/OntologyEnrichment.js +13 -6
  30. package/src/components/results/TaxDist.js +11 -0
  31. package/src/components/results/details/BAR.js +148 -0
  32. package/src/components/results/details/Expression.js +50 -14
  33. package/src/components/results/details/Homology.js +171 -39
  34. package/src/components/results/details/Pathways.js +4 -2
  35. package/src/components/results/details/Sequences.js +24 -8
  36. package/src/components/results/details/VEP.js +65 -19
  37. package/src/components/styles.css +1 -4
  38. package/src/demo.js +30 -13
  39. package/src/index.js +2 -1
  40. package/src/suppressDevWarnings.js +13 -0
  41. package/src/utils/bootView.js +38 -0
package/dist/index.js CHANGED
@@ -7,8 +7,8 @@ require("ag-grid-community/styles/ag-grid.css");
7
7
  require("ag-grid-community/styles/ag-theme-quartz.css");
8
8
  var $gXNCa$reduxbundler = require("redux-bundler");
9
9
  var $gXNCa$lodash = require("lodash");
10
- var $gXNCa$gramenebinsclient = require("gramene-bins-client");
11
- var $gXNCa$gramenetreesclient = require("gramene-trees-client");
10
+ var $gXNCa$gramenebinsclientsrcbins = require("gramene-bins-client/src/bins");
11
+ var $gXNCa$gramenetreesclientsrctaxonomy = require("gramene-trees-client/src/taxonomy");
12
12
  var $gXNCa$gramenetaxonomywithgenomes = require("gramene-taxonomy-with-genomes");
13
13
  var $gXNCa$reactga4 = require("react-ga4");
14
14
  var $gXNCa$moneyclip = require("money-clip");
@@ -17,8 +17,8 @@ var $gXNCa$reactswitch = require("react-switch");
17
17
  var $gXNCa$reacticonsio5 = require("react-icons/io5");
18
18
  var $gXNCa$reacticonsbs = require("react-icons/bs");
19
19
  var $gXNCa$reacticonsgr = require("react-icons/gr");
20
- var $gXNCa$grameneefpbrowser = require("gramene-efp-browser");
21
20
  var $gXNCa$gramenegenetreevis = require("gramene-genetree-vis");
21
+ var $gXNCa$gramenetreesclientsrcgenetree = require("gramene-trees-client/src/genetree");
22
22
  var $gXNCa$tbrowse = require("tbrowse");
23
23
  var $gXNCa$reactdom = require("react-dom");
24
24
  var $gXNCa$lodashkeyBy = require("lodash/keyBy");
@@ -222,6 +222,7 @@ $parcel$export(module.exports, "Filters", () => $693dd8c7a5607c3a$export$92a576f
222
222
  $parcel$export(module.exports, "Results", () => $693dd8c7a5607c3a$export$4a34de49b46a2bfa);
223
223
  $parcel$export(module.exports, "Views", () => $693dd8c7a5607c3a$export$5cb791131c501f6a);
224
224
  $parcel$export(module.exports, "Auth", () => $c5d403787de8b05f$export$2e2bcd8739ae039);
225
+ $parcel$export(module.exports, "bootViewFromUrl", () => $c69c61828e36a328$export$2e2bcd8739ae039);
225
226
 
226
227
 
227
228
 
@@ -399,24 +400,6 @@ $9d9aeaf9299e61a1$var$expressionSamples.reactExpressionSamples = (0, $gXNCa$redu
399
400
  actionCreator: 'doFetchExpressionSamples'
400
401
  };
401
402
  });
402
- const $9d9aeaf9299e61a1$var$curatedGenes = (0, $gXNCa$reduxbundler.createAsyncResourceBundle)({
403
- name: 'curatedGenes',
404
- actionBaseType: 'CURATED_GENES',
405
- persist: true,
406
- staleAfter: 86400000,
407
- getPromise: ({ store: store })=>{
408
- return fetch(`https://devdata.gramene.org/curation/curations?rows=0&minFlagged=0&since=12-12-2029`).then((res)=>res.json()).then((curation)=>(0, ($parcel$interopDefault($gXNCa$lodash))).keyBy(curation.genes, 'gene_id'));
409
- }
410
- });
411
- // Curated annotations are consumed by GeneList rows and Homology details,
412
- // both inside the gene-list view.
413
- $9d9aeaf9299e61a1$var$curatedGenes.reactCuratedGenes = (0, $gXNCa$reduxbundler.createSelector)('selectCuratedGenesShouldUpdate', 'selectGrameneViewsOn', (shouldUpdate, viewsOn)=>{
414
- if (!shouldUpdate) return;
415
- if (!viewsOn || !viewsOn.has('list')) return;
416
- return {
417
- actionCreator: 'doFetchCuratedGenes'
418
- };
419
- });
420
403
  const $9d9aeaf9299e61a1$var$grameneGermplasm = (0, $gXNCa$reduxbundler.createAsyncResourceBundle)({
421
404
  name: 'grameneGermplasm',
422
405
  actionBaseType: 'GRAMENE_GERMPLASM',
@@ -585,26 +568,25 @@ const $9d9aeaf9299e61a1$var$grameneSearch = (0, $gXNCa$reduxbundler.createAsyncR
585
568
  const noFilters = q === '*:*' && !userSubsetGenomes;
586
569
  const effectiveRows = noFilters ? 0 : rows;
587
570
  const facetField = noFilters ? $9d9aeaf9299e61a1$var$noFilterFacets : $9d9aeaf9299e61a1$var$facets;
588
- // Build the set of hidden taxon_ids (as strings for stable comparison
589
- // against either string or numeric facet values from Solr).
590
- const hiddenTaxa = new Set(Object.keys(m).filter((tid)=>m[tid].hidden));
571
+ // Set of taxon_ids the UI knows about keep only these in the response.
572
+ // Anything else (hidden taxa, or taxa that exist in Solr but not in this
573
+ // site's maps collection) is stripped so downstream consumers — notably
574
+ // the TaxDist Vis which throws on unknown tids — see a clean visible-only
575
+ // view. Mirrors the old server-side `fq=taxon_id:(visible)` behavior.
576
+ const visibleTaxaSet = new Set(visibleTaxa);
591
577
  // return fetch(`${store.selectGrameneAPI()}/search?q=${q}&facet.field=${facetField}&rows=${effectiveRows}&start=${offset}${fq}&stats=true&${statsFields.join('&')}`)
592
578
  return fetch(`${store.selectGrameneAPI()}/search?q=${q}&facet.field=${facetField}&rows=${effectiveRows}&start=${offset}${fq}`).then((res)=>res.json()).then((res)=>{
593
- // Strip hidden-genome contributions client-side. With no fq filter
594
- // on the server, the response reflects every taxon; we subtract
595
- // hidden ones from totals and facets so the rest of the UI sees a
596
- // "visible-only" view.
597
- if (hiddenTaxa.size && res.facet_counts && res.facet_counts.facet_fields) {
579
+ if (visibleTaxaSet.size && res.facet_counts && res.facet_counts.facet_fields) {
598
580
  const tf = res.facet_counts.facet_fields.taxon_id;
599
581
  if (Array.isArray(tf)) {
600
- let hiddenGeneCount = 0;
582
+ let droppedGeneCount = 0;
601
583
  const kept = [];
602
- for(let i = 0; i < tf.length; i += 2)if (hiddenTaxa.has(String(tf[i]))) hiddenGeneCount += tf[i + 1];
603
- else kept.push(tf[i], tf[i + 1]);
584
+ for(let i = 0; i < tf.length; i += 2)if (visibleTaxaSet.has(String(tf[i]))) kept.push(tf[i], tf[i + 1]);
585
+ else droppedGeneCount += tf[i + 1];
604
586
  res.facet_counts.facet_fields.taxon_id = kept;
605
587
  if (res.response) {
606
- res.response.numFound = Math.max(0, res.response.numFound - hiddenGeneCount);
607
- if (Array.isArray(res.response.docs)) res.response.docs = res.response.docs.filter((d)=>!hiddenTaxa.has(String(d.taxon_id)));
588
+ res.response.numFound = Math.max(0, res.response.numFound - droppedGeneCount);
589
+ if (Array.isArray(res.response.docs)) res.response.docs = res.response.docs.filter((d)=>visibleTaxaSet.has(String(d.taxon_id)));
608
590
  }
609
591
  }
610
592
  }
@@ -657,8 +639,8 @@ const $9d9aeaf9299e61a1$var$grameneTaxDist = {
657
639
  grameneTaxonomy[tid].name = map.display_name;
658
640
  });
659
641
  const binnedResults = $9d9aeaf9299e61a1$var$formatFacetCountsForViz(grameneSearch.facet_counts.facet_fields.fixed_1000__bin);
660
- let speciesTree = (0, ($parcel$interopDefault($gXNCa$gramenetreesclient))).taxonomy.tree(Object.values(grameneTaxonomy));
661
- let binMapper = (0, ($parcel$interopDefault($gXNCa$gramenebinsclient))).bins(grameneMaps);
642
+ let speciesTree = (0, ($parcel$interopDefault($gXNCa$gramenetreesclientsrctaxonomy))).tree(Object.values(grameneTaxonomy));
643
+ let binMapper = (0, ($parcel$interopDefault($gXNCa$gramenebinsclientsrcbins)))(grameneMaps);
662
644
  let taxDist = (0, $gXNCa$gramenetaxonomywithgenomes.build)(binMapper, speciesTree);
663
645
  taxDist.setBinType('fixed', 1000);
664
646
  taxDist.setResults(binnedResults);
@@ -816,7 +798,6 @@ $9d9aeaf9299e61a1$export$2e2bcd8739ae039 = [
816
798
  $9d9aeaf9299e61a1$var$grameneTaxDist,
817
799
  $9d9aeaf9299e61a1$var$grameneOrthologs,
818
800
  $9d9aeaf9299e61a1$var$grameneParalogs,
819
- $9d9aeaf9299e61a1$var$curatedGenes,
820
801
  $9d9aeaf9299e61a1$var$grameneGermplasm,
821
802
  $9d9aeaf9299e61a1$var$expressionSamples,
822
803
  $9d9aeaf9299e61a1$var$expressionStudies
@@ -1521,14 +1502,14 @@ const $24971af0a229e0e3$var$grameneViews = {
1521
1502
  {
1522
1503
  id: 'expression',
1523
1504
  name: 'Gene expression',
1524
- show: 'off',
1505
+ show: 'disabled',
1525
1506
  shouldScroll: false,
1526
1507
  desiredSamples: {}
1527
1508
  },
1528
1509
  {
1529
1510
  id: 'attribs',
1530
1511
  name: 'Gene attributes',
1531
- show: 'off',
1512
+ show: 'disabled',
1532
1513
  shouldScroll: false
1533
1514
  }
1534
1515
  ],
@@ -1569,6 +1550,26 @@ const $24971af0a229e0e3$var$grameneViews = {
1569
1550
  newState = Object.assign({}, state);
1570
1551
  newState.options.forEach((v)=>v.shouldScroll = false);
1571
1552
  return newState;
1553
+ case 'GRAMENE_VIEWS_REPLACED':
1554
+ {
1555
+ // Snapshot restore. payload: { on: Set|Array<id>, touched: {id: true} }
1556
+ // Sets options[].show directly to 'on' or 'off' based on membership in
1557
+ // `on`. Leaves 'disabled' entries untouched (site config wins).
1558
+ const onSet = payload.on instanceof Set ? payload.on : new Set(payload.on || []);
1559
+ newState = Object.assign({}, state);
1560
+ newState.touched = {
1561
+ ...payload.touched || {}
1562
+ };
1563
+ newState.options = state.options.map((v)=>{
1564
+ if (v.show === 'disabled') return v;
1565
+ return {
1566
+ ...v,
1567
+ show: onSet.has(v.id) ? 'on' : 'off',
1568
+ shouldScroll: false
1569
+ };
1570
+ });
1571
+ return newState;
1572
+ }
1572
1573
  default:
1573
1574
  return state;
1574
1575
  }
@@ -1592,6 +1593,19 @@ const $24971af0a229e0e3$var$grameneViews = {
1592
1593
  payload: null
1593
1594
  });
1594
1595
  },
1596
+ // Snapshot restore. `on` is an array of view ids to switch on; everything
1597
+ // else (except disabled views) goes off. `touched` is the same map the
1598
+ // user-toggled view bundle maintains, so the auto-default logic in
1599
+ // selectGrameneViews respects what the user has explicitly set.
1600
+ doReplaceGrameneViews: ({ on: on, touched: touched })=>({ dispatch: dispatch })=>{
1601
+ dispatch({
1602
+ type: 'GRAMENE_VIEWS_REPLACED',
1603
+ payload: {
1604
+ on: on,
1605
+ touched: touched
1606
+ }
1607
+ });
1608
+ },
1595
1609
  selectRawGrameneViews: (state)=>state.grameneViews,
1596
1610
  selectGrameneViews: (0, $gXNCa$reduxbundler.createSelector)('selectRawGrameneViews', 'selectConfiguration', 'selectGrameneSearch', 'selectGrameneFilters', (raw, config, search, filters)=>{
1597
1611
  const overrides = config && config.views || null;
@@ -1656,9 +1670,10 @@ var $24971af0a229e0e3$export$2e2bcd8739ae039 = $24971af0a229e0e3$var$grameneView
1656
1670
  // indefinitely (default maxAge of `Infinity`). The pathway set is small
1657
1671
  // and stable enough to keep around across sessions, so we bulk-load it
1658
1672
  // once with `?rows=-1` and reuse it instead of issuing per-id requests.
1673
+ // Scoped to the subsite so per-site pathway corpora don't collide.
1659
1674
  const $671312b287158a8a$var$pathwayCache = (0, $gXNCa$moneyclip.getConfiguredCache)({
1660
1675
  version: 1,
1661
- name: 'gramene_pathways'
1676
+ name: `gramene_pathways_${process.env.SUBSITE || 'default'}`
1662
1677
  });
1663
1678
  let $671312b287158a8a$var$pathwaysBulkPromise = null;
1664
1679
  const $671312b287158a8a$var$grameneDocs = {
@@ -3932,8 +3947,12 @@ var $c921a0d2b34aadb6$export$2e2bcd8739ae039 = $c921a0d2b34aadb6$var$ontologies;
3932
3947
  // activeTaxon: <taxon_id|null>,
3933
3948
  // byTaxon: {
3934
3949
  // [taxon_id]: {
3935
- // selectedFields: [<solr field name>...],
3950
+ // selectedFields: [<solr field name>...], // order = column/axis order
3936
3951
  // fieldsModalOpen: <bool>,
3952
+ // vizMode: 'heatmap'|'parallel', // persisted view config
3953
+ // scale: 'linear'|'log', // persisted view config
3954
+ // brushes: { [field]: [lo, hi] }, // persisted parallel-coords brushes
3955
+ // pendingLoad: <bool>, // transient: a restored view wants a re-fetch
3937
3956
  // fetch: { status, offset, total, signature, requestId, error },
3938
3957
  // rows: [<doc>...]
3939
3958
  // }
@@ -3983,6 +4002,10 @@ const $4f15cd8a7d970b18$var$exprViz = {
3983
4002
  return {
3984
4003
  selectedFields: [],
3985
4004
  fieldsModalOpen: false,
4005
+ vizMode: 'heatmap',
4006
+ scale: 'linear',
4007
+ brushes: {},
4008
+ pendingLoad: false,
3986
4009
  fetch: {
3987
4010
  status: 'idle',
3988
4011
  offset: 0,
@@ -4065,6 +4088,9 @@ const $4f15cd8a7d970b18$var$exprViz = {
4065
4088
  [payload.taxon]: {
4066
4089
  ...t,
4067
4090
  selectedFields: payload.fields,
4091
+ // A new field selection invalidates any prior brushes (they
4092
+ // reference axes that may no longer exist) and the loaded rows.
4093
+ brushes: {},
4068
4094
  rows: [],
4069
4095
  fetch: {
4070
4096
  status: 'idle',
@@ -4078,6 +4104,77 @@ const $4f15cd8a7d970b18$var$exprViz = {
4078
4104
  }
4079
4105
  };
4080
4106
  }
4107
+ case 'EXPRVIZ_VIZMODE_SET':
4108
+ {
4109
+ const t = ensureTaxon(state, payload.taxon);
4110
+ return {
4111
+ ...state,
4112
+ byTaxon: {
4113
+ ...state.byTaxon,
4114
+ [payload.taxon]: {
4115
+ ...t,
4116
+ vizMode: payload.vizMode
4117
+ }
4118
+ }
4119
+ };
4120
+ }
4121
+ case 'EXPRVIZ_SCALE_SET':
4122
+ {
4123
+ const t = ensureTaxon(state, payload.taxon);
4124
+ return {
4125
+ ...state,
4126
+ byTaxon: {
4127
+ ...state.byTaxon,
4128
+ [payload.taxon]: {
4129
+ ...t,
4130
+ scale: payload.scale
4131
+ }
4132
+ }
4133
+ };
4134
+ }
4135
+ case 'EXPRVIZ_BRUSHES_SET':
4136
+ {
4137
+ const t = ensureTaxon(state, payload.taxon);
4138
+ return {
4139
+ ...state,
4140
+ byTaxon: {
4141
+ ...state.byTaxon,
4142
+ [payload.taxon]: {
4143
+ ...t,
4144
+ brushes: payload.brushes || {}
4145
+ }
4146
+ }
4147
+ };
4148
+ }
4149
+ case 'EXPRVIZ_RESTORED':
4150
+ {
4151
+ // Re-apply persisted view config from a saved-view snapshot. Async
4152
+ // data (rows) is NOT in the snapshot — instead each taxon with a
4153
+ // field selection is flagged pendingLoad so reactExprVizRestoreLoad
4154
+ // re-fetches it once the search context is ready.
4155
+ const byTaxon = {
4156
+ ...state.byTaxon
4157
+ };
4158
+ const cfg = payload.byTaxon || {};
4159
+ for (const tid of Object.keys(cfg)){
4160
+ const base = ensureTaxon(state, tid);
4161
+ const c = cfg[tid] || {};
4162
+ const selectedFields = Array.isArray(c.selectedFields) ? c.selectedFields : [];
4163
+ byTaxon[tid] = {
4164
+ ...base,
4165
+ selectedFields: selectedFields,
4166
+ vizMode: c.vizMode || 'heatmap',
4167
+ scale: c.scale || 'linear',
4168
+ brushes: c.brushes || {},
4169
+ pendingLoad: selectedFields.length > 0
4170
+ };
4171
+ }
4172
+ return {
4173
+ ...state,
4174
+ activeTaxon: payload.activeTaxon || state.activeTaxon,
4175
+ byTaxon: byTaxon
4176
+ };
4177
+ }
4081
4178
  case 'EXPRVIZ_FIELDS_REORDERED':
4082
4179
  {
4083
4180
  const t = state.byTaxon[payload.taxon];
@@ -4108,6 +4205,7 @@ const $4f15cd8a7d970b18$var$exprViz = {
4108
4205
  ...state.byTaxon,
4109
4206
  [payload.taxon]: {
4110
4207
  ...t,
4208
+ pendingLoad: false,
4111
4209
  fetch: {
4112
4210
  ...t.fetch,
4113
4211
  status: 'loading',
@@ -4365,6 +4463,41 @@ const $4f15cd8a7d970b18$var$exprViz = {
4365
4463
  fields: fields
4366
4464
  }
4367
4465
  }),
4466
+ doSetExprVizVizMode: (taxon, vizMode)=>({ dispatch: dispatch })=>dispatch({
4467
+ type: 'EXPRVIZ_VIZMODE_SET',
4468
+ payload: {
4469
+ taxon: taxon,
4470
+ vizMode: vizMode
4471
+ }
4472
+ }),
4473
+ doSetExprVizScale: (taxon, scale)=>({ dispatch: dispatch })=>dispatch({
4474
+ type: 'EXPRVIZ_SCALE_SET',
4475
+ payload: {
4476
+ taxon: taxon,
4477
+ scale: scale
4478
+ }
4479
+ }),
4480
+ doSetExprVizBrushes: (taxon, brushes)=>({ dispatch: dispatch })=>dispatch({
4481
+ type: 'EXPRVIZ_BRUSHES_SET',
4482
+ payload: {
4483
+ taxon: taxon,
4484
+ brushes: brushes
4485
+ }
4486
+ }),
4487
+ // Re-apply persisted exprViz view config from a saved-view snapshot. Called
4488
+ // by viewSnapshot's doApplyViewSnapshot after filters/views have been
4489
+ // restored, so the re-fetch (driven by reactExprVizRestoreLoad) sees the
4490
+ // restored query context.
4491
+ doApplyExprVizSnapshot: (snap)=>({ dispatch: dispatch })=>{
4492
+ if (!snap || typeof snap !== 'object') return;
4493
+ dispatch({
4494
+ type: 'EXPRVIZ_RESTORED',
4495
+ payload: {
4496
+ activeTaxon: snap.activeTaxon || null,
4497
+ byTaxon: snap.byTaxon || {}
4498
+ }
4499
+ });
4500
+ },
4368
4501
  doFetchExprVizData: (taxon)=>({ dispatch: dispatch, store: store })=>{
4369
4502
  const ev = store.selectExprViz();
4370
4503
  const t = ev.byTaxon[taxon];
@@ -4485,6 +4618,25 @@ const $4f15cd8a7d970b18$var$exprViz = {
4485
4618
  actionCreator: 'doFetchExprVizPivot'
4486
4619
  };
4487
4620
  }),
4621
+ // After a saved view is applied, EXPRVIZ_RESTORED flags each taxon that had
4622
+ // a field selection with pendingLoad. Once the search context is live (not
4623
+ // 'init') and the view is on, re-fetch that taxon's rows. Fires one taxon at
4624
+ // a time; EXPRVIZ_FETCH_STARTED clears the flag so this won't loop. Robust to
4625
+ // a GRAMENE_SEARCH_CLEARED that wipes rows mid-restore — the flag persists.
4626
+ reactExprVizRestoreLoad: (0, $gXNCa$reduxbundler.createSelector)('selectExprViz', 'selectGrameneFiltersStatus', 'selectGrameneViews', (ev, filtersStatus, views)=>{
4627
+ if (!ev || filtersStatus === 'init') return;
4628
+ const onView = views && views.options && views.options.find((v)=>v.id === 'exprViz');
4629
+ if (!onView || onView.show !== 'on') return;
4630
+ for (const tid of Object.keys(ev.byTaxon || {})){
4631
+ const t = ev.byTaxon[tid];
4632
+ if (t && t.pendingLoad && t.selectedFields.length > 0 && t.rows.length === 0 && t.fetch.status !== 'loading') return {
4633
+ actionCreator: 'doFetchExprVizData',
4634
+ args: [
4635
+ tid
4636
+ ]
4637
+ };
4638
+ }
4639
+ }),
4488
4640
  selectExprViz: (state)=>state.exprViz,
4489
4641
  selectExprVizPivot: (state)=>state.exprViz.pivot,
4490
4642
  selectExprVizActiveTaxon: (state)=>state.exprViz.activeTaxon,
@@ -4588,7 +4740,10 @@ const $d365d8c287ab0c94$var$ontologyEnrichment = {
4588
4740
  maxGSSize: 500,
4589
4741
  mostSpecific: false,
4590
4742
  ontology: 'all',
4591
- search: ''
4743
+ search: '',
4744
+ // Per-section table sort, keyed by ontology section id (e.g.
4745
+ // 'GO:biological_process'): { [sectionKey]: { key, dir } }.
4746
+ sort: {}
4592
4747
  }
4593
4748
  };
4594
4749
  function ensureTaxon(state, tid) {
@@ -4792,6 +4947,21 @@ const $d365d8c287ab0c94$var$ontologyEnrichment = {
4792
4947
  type: 'ONTOLOGY_ENRICHMENT_UI_SET',
4793
4948
  payload: patch
4794
4949
  }),
4950
+ // Re-apply persisted view config from a saved-view snapshot. Setting the
4951
+ // active taxon (last) lets reactOntologyEnrichmentFetch re-fetch the
4952
+ // foreground/background facets for the restored filters — no bulk data is
4953
+ // carried in the snapshot.
4954
+ doApplyOntologyEnrichmentSnapshot: (snap)=>({ dispatch: dispatch })=>{
4955
+ if (!snap || typeof snap !== 'object') return;
4956
+ if (snap.ui) dispatch({
4957
+ type: 'ONTOLOGY_ENRICHMENT_UI_SET',
4958
+ payload: snap.ui
4959
+ });
4960
+ if (snap.activeTaxon) dispatch({
4961
+ type: 'ONTOLOGY_ENRICHMENT_ACTIVE_TAXON_SET',
4962
+ payload: snap.activeTaxon
4963
+ });
4964
+ },
4795
4965
  doFetchOntologyEnrichmentForeground: (taxon)=>({ dispatch: dispatch, store: store })=>{
4796
4966
  const q = store.selectGrameneFiltersQueryString();
4797
4967
  const signature = $d365d8c287ab0c94$var$fgSig(q, taxon);
@@ -5149,143 +5319,1149 @@ function $d365d8c287ab0c94$var$collapseToMostSpecific(ontKey, rows, recs) {
5149
5319
  var $d365d8c287ab0c94$export$2e2bcd8739ae039 = $d365d8c287ab0c94$var$ontologyEnrichment;
5150
5320
 
5151
5321
 
5152
- var $5df6c55c1bef3469$export$2e2bcd8739ae039 = [
5153
- ...(0, $9d9aeaf9299e61a1$export$2e2bcd8739ae039),
5154
- (0, $671312b287158a8a$export$2e2bcd8739ae039),
5155
- (0, $af4441dd29af05df$export$2e2bcd8739ae039),
5156
- (0, $24971af0a229e0e3$export$2e2bcd8739ae039),
5157
- (0, $0d54502f6cafe273$export$2e2bcd8739ae039),
5158
- (0, $0f839422d0d8c772$export$2e2bcd8739ae039),
5159
- (0, $1508f5a42be6e7b5$export$2e2bcd8739ae039),
5160
- (0, $c921a0d2b34aadb6$export$2e2bcd8739ae039),
5161
- (0, $4f15cd8a7d970b18$export$2e2bcd8739ae039),
5162
- (0, $d365d8c287ab0c94$export$2e2bcd8739ae039)
5163
- ];
5164
-
5165
-
5166
-
5167
-
5168
-
5169
- const $2fec4872fbf7ebd2$var$Gene = ({ gene: gene })=>{
5170
- return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("div", {
5171
- className: "row",
5172
- children: gene.id
5173
- });
5174
- };
5175
- const $2fec4872fbf7ebd2$var$Genes = (results, rows, doChangeQuantity)=>{
5176
- if (results && results.numFound > 0) {
5177
- const moreButton = results.numFound > rows ? /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("button", {
5178
- onClick: (e)=>doChangeQuantity('Genes', 20),
5179
- children: "more"
5180
- }) : '';
5181
- const fewerButton = rows > 20 ? /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("button", {
5182
- onClick: (e)=>doChangeQuantity('Genes', -20),
5183
- children: "fewer"
5184
- }) : '';
5185
- const docsToShow = results.response.docs.slice(0, rows);
5186
- return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
5187
- id: "Genes",
5188
- className: "container mb40 anchor",
5189
- children: [
5190
- /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("div", {
5191
- className: "fancy-title mb40",
5192
- children: /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("h4", {
5193
- children: "Genes"
5194
- })
5195
- }),
5196
- docsToShow.map((doc, idx)=>/*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)($2fec4872fbf7ebd2$var$Gene, {
5197
- gene: doc
5198
- }, idx)),
5199
- fewerButton,
5200
- moreButton
5201
- ]
5202
- });
5203
- }
5204
- };
5205
- const $2fec4872fbf7ebd2$var$Pathway = ({ pathway: pathway })=>{
5206
- return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("div", {
5207
- className: "row",
5208
- children: pathway.name
5209
- });
5322
+ // Per-gene UI state lifted out of <Gene> and <Homology> class components.
5323
+ // Keyed by geneId so it survives unmount/remount (e.g. scrolling a row out
5324
+ // of view and back), and so a snapshot serializer can later round-trip it
5325
+ // for the shareable-views feature.
5326
+ //
5327
+ // What lives here:
5328
+ // - expandedDetail: which detail tab is open in the gene-list card
5329
+ // - fullscreen: whether the expanded detail is full-screened
5330
+ // - homology.viewer: 'treevis' | 'tbrowse'
5331
+ // - homology.height: drag-resize height of the homology detail pane
5332
+ // - homology.tbrowse: the tbrowse ViewState (collapsedNodeIds, prunedNodeIds,
5333
+ // swappedNodeIds, compressedNodeIds, nodeOfInterestId, zones, zoneStates,
5334
+ // search, selectedNodeId). Tbrowse is driven in controlled mode from this.
5335
+ // - sequences: the Sequences detail's internal state — tab ('dna'|'rna'|'pep'),
5336
+ // tid (selected transcript id), upstream/downstream (flanking bp). Driven in
5337
+ // controlled mode from this so a saved view restores the chosen sub-tab and
5338
+ // isoform.
5339
+ // - expression: the Expression detail's internal state — activeTab
5340
+ // ('gene'|'paralogs'|'eFP'), atlasExperiment (selected GXA experiment id),
5341
+ // barStudy (selected eFP/BAR study). Driven in controlled mode from this so a
5342
+ // saved view restores the chosen sub-tab and study.
5343
+ //
5344
+ // What does NOT live here:
5345
+ // - derived/computed state like `details` (per-render config from
5346
+ // props.config.details + capabilities)
5347
+ // - async-fetched data caches in <Homology> (neighborhood, geneStructures)
5348
+ const $7f865ea0feda21af$var$initialState = {
5349
+ byGene: {}
5210
5350
  };
5211
- const $2fec4872fbf7ebd2$var$Pathways = (results)=>{
5212
- if (results && results.numFound > 0) return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
5213
- id: "Pathways",
5214
- className: "container mb40 anchor",
5215
- children: [
5216
- /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("div", {
5217
- className: "fancy-title",
5218
- children: /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("h4", {
5219
- children: "Pathways"
5220
- })
5221
- }),
5222
- results.pathways.map((doc, idx)=>/*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)($2fec4872fbf7ebd2$var$Pathway, {
5223
- pathway: doc
5224
- }, idx))
5225
- ]
5226
- });
5351
+ const $7f865ea0feda21af$var$ensureGene = (state, geneId)=>{
5352
+ if (state.byGene[geneId]) return state;
5353
+ return {
5354
+ ...state,
5355
+ byGene: {
5356
+ ...state.byGene,
5357
+ [geneId]: {
5358
+ homology: {}
5359
+ }
5360
+ }
5361
+ };
5227
5362
  };
5228
- const $2fec4872fbf7ebd2$var$Domain = ({ domain: domain })=>{
5229
- return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("div", {
5230
- className: "row",
5231
- children: domain.id
5232
- });
5363
+ const $7f865ea0feda21af$var$setGene = (state, geneId, patch)=>{
5364
+ const next = $7f865ea0feda21af$var$ensureGene(state, geneId);
5365
+ return {
5366
+ ...next,
5367
+ byGene: {
5368
+ ...next.byGene,
5369
+ [geneId]: {
5370
+ ...next.byGene[geneId],
5371
+ ...patch
5372
+ }
5373
+ }
5374
+ };
5233
5375
  };
5234
- const $2fec4872fbf7ebd2$var$Domains = (results)=>{
5235
- if (results && results.numFound > 0) return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
5236
- id: "Domains",
5237
- className: "container mb40 anchor",
5238
- children: [
5239
- /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("div", {
5240
- className: "fancy-title mb40",
5241
- children: /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("h4", {
5242
- children: "Domains"
5243
- })
5244
- }),
5245
- results.domains.map((doc, idx)=>/*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)($2fec4872fbf7ebd2$var$Domain, {
5246
- domain: doc
5247
- }, idx))
5248
- ]
5249
- });
5376
+ const $7f865ea0feda21af$var$setHomology = (state, geneId, patch)=>{
5377
+ const next = $7f865ea0feda21af$var$ensureGene(state, geneId);
5378
+ const prev = next.byGene[geneId];
5379
+ return {
5380
+ ...next,
5381
+ byGene: {
5382
+ ...next.byGene,
5383
+ [geneId]: {
5384
+ ...prev,
5385
+ homology: {
5386
+ ...prev.homology || {},
5387
+ ...patch
5388
+ }
5389
+ }
5390
+ }
5391
+ };
5250
5392
  };
5251
- const $2fec4872fbf7ebd2$var$Taxon = ({ taxon: taxon })=>{
5252
- return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("div", {
5253
- className: "row",
5254
- children: taxon.id
5255
- });
5393
+ const $7f865ea0feda21af$var$setSequences = (state, geneId, patch)=>{
5394
+ const next = $7f865ea0feda21af$var$ensureGene(state, geneId);
5395
+ const prev = next.byGene[geneId];
5396
+ return {
5397
+ ...next,
5398
+ byGene: {
5399
+ ...next.byGene,
5400
+ [geneId]: {
5401
+ ...prev,
5402
+ sequences: {
5403
+ ...prev.sequences || {},
5404
+ ...patch
5405
+ }
5406
+ }
5407
+ }
5408
+ };
5256
5409
  };
5257
- const $2fec4872fbf7ebd2$var$Species = (results)=>{
5258
- if (results && results.numFound > 0) return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
5259
- id: "Species",
5260
- className: "container mb40 anchor",
5261
- children: [
5262
- /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("div", {
5263
- className: "fancy-title mb40",
5264
- children: /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("h4", {
5265
- children: "Species"
5266
- })
5267
- }),
5268
- results.taxonomy.map((doc, idx)=>/*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)($2fec4872fbf7ebd2$var$Taxon, {
5269
- taxon: doc
5270
- }, idx))
5271
- ]
5272
- });
5410
+ const $7f865ea0feda21af$var$setExpression = (state, geneId, patch)=>{
5411
+ const next = $7f865ea0feda21af$var$ensureGene(state, geneId);
5412
+ const prev = next.byGene[geneId];
5413
+ return {
5414
+ ...next,
5415
+ byGene: {
5416
+ ...next.byGene,
5417
+ [geneId]: {
5418
+ ...prev,
5419
+ expression: {
5420
+ ...prev.expression || {},
5421
+ ...patch
5422
+ }
5423
+ }
5424
+ }
5425
+ };
5273
5426
  };
5274
- const $2fec4872fbf7ebd2$var$ResultList = ({ grameneGenes: grameneGenes, grameneDomains: grameneDomains, gramenePathways: gramenePathways, grameneTaxonomy: grameneTaxonomy, searchUI: searchUI, searchUpdated: searchUpdated, doChangeQuantity: doChangeQuantity })=>{
5275
- if (searchUI.Gramene) return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
5276
- id: "gramene",
5277
- className: "row",
5278
- children: [
5279
- /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("div", {
5280
- className: "fancy-title pt50",
5281
- children: /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("h3", {
5282
- children: "Gramene search results"
5283
- })
5284
- }),
5285
- searchUI.Genes && $2fec4872fbf7ebd2$var$Genes(grameneGenes, searchUI.rows.Genes, doChangeQuantity),
5286
- searchUI.Domains && $2fec4872fbf7ebd2$var$Domains(grameneDomains),
5287
- searchUI.Pathways && $2fec4872fbf7ebd2$var$Pathways(gramenePathways),
5288
- searchUI.Species && $2fec4872fbf7ebd2$var$Species(grameneTaxonomy)
5427
+ const $7f865ea0feda21af$var$uiViewState = {
5428
+ name: 'uiViewState',
5429
+ getReducer: ()=>(state = $7f865ea0feda21af$var$initialState, { type: type, payload: payload })=>{
5430
+ switch(type){
5431
+ case 'UI_GENE_DETAIL_EXPANDED':
5432
+ // payload: { geneId, detail } (detail = null to collapse)
5433
+ return $7f865ea0feda21af$var$setGene(state, payload.geneId, {
5434
+ expandedDetail: payload.detail,
5435
+ // collapsing also exits fullscreen
5436
+ fullscreen: payload.detail === null ? false : state.byGene[payload.geneId]?.fullscreen || false
5437
+ });
5438
+ case 'UI_GENE_FULLSCREEN_SET':
5439
+ // payload: { geneId, fullscreen }
5440
+ return $7f865ea0feda21af$var$setGene(state, payload.geneId, {
5441
+ fullscreen: !!payload.fullscreen
5442
+ });
5443
+ case 'UI_HOMOLOGY_VIEWER_SET':
5444
+ // payload: { geneId, viewer }
5445
+ return $7f865ea0feda21af$var$setHomology(state, payload.geneId, {
5446
+ viewer: payload.viewer
5447
+ });
5448
+ case 'UI_HOMOLOGY_HEIGHT_SET':
5449
+ // payload: { geneId, height }
5450
+ return $7f865ea0feda21af$var$setHomology(state, payload.geneId, {
5451
+ height: payload.height
5452
+ });
5453
+ case 'UI_HOMOLOGY_TBROWSE_SET':
5454
+ // payload: { geneId, tbrowse } -- a full ViewState object
5455
+ return $7f865ea0feda21af$var$setHomology(state, payload.geneId, {
5456
+ tbrowse: payload.tbrowse
5457
+ });
5458
+ case 'UI_SEQUENCES_SET':
5459
+ // payload: { geneId, patch } -- merges into the sequences slice
5460
+ // (e.g. { tab }, { tid }, { upstream }, { downstream })
5461
+ return $7f865ea0feda21af$var$setSequences(state, payload.geneId, payload.patch);
5462
+ case 'UI_EXPRESSION_SET':
5463
+ // payload: { geneId, patch } -- merges into the expression slice
5464
+ // (e.g. { activeTab }, { atlasExperiment }, { barStudy })
5465
+ return $7f865ea0feda21af$var$setExpression(state, payload.geneId, payload.patch);
5466
+ case 'UI_VIEW_STATE_REPLACED':
5467
+ // payload: { byGene } -- used by the snapshot loader in a later phase
5468
+ return {
5469
+ byGene: payload.byGene || {}
5470
+ };
5471
+ default:
5472
+ return state;
5473
+ }
5474
+ },
5475
+ selectUiViewState: (state)=>state.uiViewState,
5476
+ doExpandGeneDetail: ({ geneId: geneId, detail: detail })=>({ dispatch: dispatch })=>{
5477
+ dispatch({
5478
+ type: 'UI_GENE_DETAIL_EXPANDED',
5479
+ payload: {
5480
+ geneId: geneId,
5481
+ detail: detail
5482
+ }
5483
+ });
5484
+ },
5485
+ doSetGeneFullscreen: ({ geneId: geneId, fullscreen: fullscreen })=>({ dispatch: dispatch })=>{
5486
+ dispatch({
5487
+ type: 'UI_GENE_FULLSCREEN_SET',
5488
+ payload: {
5489
+ geneId: geneId,
5490
+ fullscreen: fullscreen
5491
+ }
5492
+ });
5493
+ },
5494
+ doSetHomologyViewer: ({ geneId: geneId, viewer: viewer })=>({ dispatch: dispatch })=>{
5495
+ dispatch({
5496
+ type: 'UI_HOMOLOGY_VIEWER_SET',
5497
+ payload: {
5498
+ geneId: geneId,
5499
+ viewer: viewer
5500
+ }
5501
+ });
5502
+ },
5503
+ doSetHomologyHeight: ({ geneId: geneId, height: height })=>({ dispatch: dispatch })=>{
5504
+ dispatch({
5505
+ type: 'UI_HOMOLOGY_HEIGHT_SET',
5506
+ payload: {
5507
+ geneId: geneId,
5508
+ height: height
5509
+ }
5510
+ });
5511
+ },
5512
+ doSetHomologyTbrowseViewState: ({ geneId: geneId, tbrowse: tbrowse })=>({ dispatch: dispatch })=>{
5513
+ dispatch({
5514
+ type: 'UI_HOMOLOGY_TBROWSE_SET',
5515
+ payload: {
5516
+ geneId: geneId,
5517
+ tbrowse: tbrowse
5518
+ }
5519
+ });
5520
+ },
5521
+ doSetSequencesState: ({ geneId: geneId, patch: patch })=>({ dispatch: dispatch })=>{
5522
+ dispatch({
5523
+ type: 'UI_SEQUENCES_SET',
5524
+ payload: {
5525
+ geneId: geneId,
5526
+ patch: patch
5527
+ }
5528
+ });
5529
+ },
5530
+ doSetExpressionState: ({ geneId: geneId, patch: patch })=>({ dispatch: dispatch })=>{
5531
+ dispatch({
5532
+ type: 'UI_EXPRESSION_SET',
5533
+ payload: {
5534
+ geneId: geneId,
5535
+ patch: patch
5536
+ }
5537
+ });
5538
+ },
5539
+ doReplaceUiViewState: (byGene)=>({ dispatch: dispatch })=>{
5540
+ dispatch({
5541
+ type: 'UI_VIEW_STATE_REPLACED',
5542
+ payload: {
5543
+ byGene: byGene
5544
+ }
5545
+ });
5546
+ }
5547
+ };
5548
+ var $7f865ea0feda21af$export$2e2bcd8739ae039 = $7f865ea0feda21af$var$uiViewState;
5549
+
5550
+
5551
+ // Serializer/deserializer for shareable gene-search views.
5552
+ //
5553
+ // `selectViewSnapshot` produces a versioned JSON-safe blob from the current
5554
+ // store state. `doApplyViewSnapshot` validates a blob and restores it via
5555
+ // existing action creators. Persistence + UI live in later phases — this
5556
+ // bundle is the pure state-shape layer they build on.
5557
+ //
5558
+ // Snapshot schema, v1:
5559
+ // {
5560
+ // v: 1,
5561
+ // capturedAt: <ISO 8601 string>,
5562
+ // site: <subsite id>,
5563
+ // filters: <grameneFilters tree, stripped of ephemeral UI flags>,
5564
+ // views: { on: [id,...], touched: {id: true, ...} },
5565
+ // genomeSubset: { taxonId: true, ... } | null,
5566
+ // searchPage: { offset: number, rows: number } | null,
5567
+ // exprViz: { // Expression-visualization view
5568
+ // activeTaxon: <taxon_id|null>,
5569
+ // byTaxon: { [taxon_id]: { selectedFields: [field,...], vizMode, scale, brushes } }
5570
+ // } | undefined, // loaded rows re-fetch on apply
5571
+ // ontologyEnrichment: { // Ontology Enrichment view
5572
+ // activeTaxon: <taxon_id|null>,
5573
+ // ui: { pAdjCutoff, minGSSize, maxGSSize, mostSpecific, ontology, search, sort }
5574
+ // } | undefined, // facets re-fetch on apply
5575
+ // expandedDetails: [
5576
+ // { geneId,
5577
+ // expandedDetail: string|null,
5578
+ // fullscreen: boolean,
5579
+ // homology: { viewer, height, tbrowse: <ViewState> } | undefined,
5580
+ // sequences: { tab, tid, upstream, downstream } | undefined,
5581
+ // expression: { activeTab, atlasExperiment, barStudy } | undefined
5582
+ // }, ...
5583
+ // ]
5584
+ // }
5585
+ //
5586
+ // Anything not in this schema is intentionally NOT persisted — async fetch
5587
+ // caches, search-results, pagination cursors mid-flight, etc. all rehydrate
5588
+ // from the API once the snapshot is applied.
5589
+ const $472fe3745b238881$var$SCHEMA_VERSION = 1;
5590
+ // Keys on a filter node we want to keep. Everything else (showMenu, marked,
5591
+ // status flags from the root, etc.) is ephemeral UI and not meaningful in a
5592
+ // shared view.
5593
+ const $472fe3745b238881$var$FILTER_NODE_KEEP = [
5594
+ 'leftIdx',
5595
+ 'rightIdx',
5596
+ 'operation',
5597
+ 'negate',
5598
+ 'fq_field',
5599
+ 'fq_value',
5600
+ 'name',
5601
+ 'category',
5602
+ 'warning'
5603
+ ];
5604
+ const $472fe3745b238881$var$FILTER_ROOT_KEEP = [
5605
+ 'operation',
5606
+ 'negate',
5607
+ 'leftIdx',
5608
+ 'rightIdx',
5609
+ 'children',
5610
+ 'searchOffset',
5611
+ 'rows'
5612
+ ];
5613
+ const $472fe3745b238881$var$cleanFilterNode = (node)=>{
5614
+ const out = {};
5615
+ for (const k of $472fe3745b238881$var$FILTER_NODE_KEEP)if (node[k] !== undefined) out[k] = node[k];
5616
+ if (Array.isArray(node.children)) out.children = node.children.map($472fe3745b238881$var$cleanFilterNode);
5617
+ return out;
5618
+ };
5619
+ const $472fe3745b238881$var$cleanFilters = (filters)=>{
5620
+ const out = {};
5621
+ for (const k of $472fe3745b238881$var$FILTER_ROOT_KEEP)if (filters[k] !== undefined) out[k] = filters[k];
5622
+ if (Array.isArray(filters.children)) out.children = filters.children.map($472fe3745b238881$var$cleanFilterNode);
5623
+ return out;
5624
+ };
5625
+ // Drop bundle entries that hold no meaningful state (e.g. a row the user
5626
+ // scrolled past but never opened). Otherwise snapshots bloat with empties.
5627
+ const $472fe3745b238881$var$isMeaningfulGene = (entry)=>{
5628
+ if (!entry) return false;
5629
+ if (entry.expandedDetail) return true;
5630
+ if (entry.fullscreen) return true;
5631
+ if (entry.homology && (entry.homology.viewer !== undefined || entry.homology.height !== undefined || entry.homology.tbrowse !== undefined)) return true;
5632
+ if (entry.sequences && Object.keys(entry.sequences).length) return true;
5633
+ if (entry.expression && Object.keys(entry.expression).length) return true;
5634
+ return false;
5635
+ };
5636
+ const $472fe3745b238881$var$viewSnapshot = {
5637
+ name: 'viewSnapshot',
5638
+ // No reducer — this bundle is pure derivation + dispatch orchestration.
5639
+ getReducer: ()=>(state = {})=>state,
5640
+ selectViewSnapshot: $472fe3745b238881$var$createSnapshotSelector(),
5641
+ // Apply a snapshot to the store. Returns an array of unresolved-id warnings
5642
+ // so the caller can surface a non-blocking notice. The action creators we
5643
+ // delegate to are existing ones; nothing here knows about API/UI/share-link
5644
+ // — that's later phases.
5645
+ doApplyViewSnapshot: (snapshot)=>({ dispatch: dispatch, store: store })=>{
5646
+ const warnings = $472fe3745b238881$var$validateSnapshot(snapshot);
5647
+ if (warnings.fatal) {
5648
+ console.warn('viewSnapshot: refusing to apply', warnings.fatal);
5649
+ return {
5650
+ applied: false,
5651
+ warnings: warnings
5652
+ };
5653
+ }
5654
+ // 1. Filters (also clears search; replays a fresh fetch).
5655
+ if (snapshot.filters) dispatch({
5656
+ type: 'BATCH_ACTIONS',
5657
+ actions: [
5658
+ {
5659
+ type: 'GRAMENE_SEARCH_CLEARED'
5660
+ },
5661
+ {
5662
+ type: 'GRAMENE_FILTERS_REPLACED',
5663
+ payload: $472fe3745b238881$var$cloneForDispatch(snapshot.filters)
5664
+ }
5665
+ ]
5666
+ });
5667
+ // 2. Views.
5668
+ if (snapshot.views) dispatch({
5669
+ type: 'GRAMENE_VIEWS_REPLACED',
5670
+ payload: {
5671
+ on: snapshot.views.on || [],
5672
+ touched: snapshot.views.touched || {}
5673
+ }
5674
+ });
5675
+ // 3. Genome subset, if present.
5676
+ if (snapshot.genomeSubset && Object.keys(snapshot.genomeSubset).length) dispatch({
5677
+ type: 'GRAMENE_GENOMES_UPDATED',
5678
+ payload: {
5679
+ ...snapshot.genomeSubset
5680
+ }
5681
+ });
5682
+ // 4. Per-gene UI state (expandedDetail, fullscreen, homology viewer/
5683
+ // height, tbrowse view state). Replace wholesale via the uiViewState
5684
+ // bundle's bulk action.
5685
+ const byGene = {};
5686
+ if (Array.isArray(snapshot.expandedDetails)) for (const e of snapshot.expandedDetails){
5687
+ if (!e || !e.geneId) continue;
5688
+ byGene[e.geneId] = {
5689
+ expandedDetail: e.expandedDetail || null,
5690
+ fullscreen: !!e.fullscreen,
5691
+ homology: e.homology ? {
5692
+ ...e.homology
5693
+ } : {},
5694
+ sequences: e.sequences ? {
5695
+ ...e.sequences
5696
+ } : {},
5697
+ expression: e.expression ? {
5698
+ ...e.expression
5699
+ } : {}
5700
+ };
5701
+ }
5702
+ dispatch({
5703
+ type: 'UI_VIEW_STATE_REPLACED',
5704
+ payload: {
5705
+ byGene: byGene
5706
+ }
5707
+ });
5708
+ // 5. Expression-visualization view config. Delegate to the exprViz bundle,
5709
+ // which flags each taxon for a re-fetch now that the restored filters/
5710
+ // genomes are in place. Done last so the re-fetch sees the live query.
5711
+ if (snapshot.exprViz && store.doApplyExprVizSnapshot) store.doApplyExprVizSnapshot(snapshot.exprViz);
5712
+ // 6. Ontology Enrichment view config. Setting the active taxon lets that
5713
+ // bundle's fetch reactor re-derive the enrichment for the restored
5714
+ // filters/genomes.
5715
+ if (snapshot.ontologyEnrichment && store.doApplyOntologyEnrichmentSnapshot) store.doApplyOntologyEnrichmentSnapshot(snapshot.ontologyEnrichment);
5716
+ return {
5717
+ applied: true,
5718
+ warnings: warnings
5719
+ };
5720
+ },
5721
+ // Helper exposed for the bootstrap path that wants to read warnings without
5722
+ // dispatching. Returns { fatal: <string|null>, unresolved: { ids/taxa/views/genes lists } }.
5723
+ doValidateViewSnapshot: (snapshot)=>()=>$472fe3745b238881$var$validateSnapshot(snapshot)
5724
+ };
5725
+ function $472fe3745b238881$var$createSnapshotSelector() {
5726
+ // We deliberately don't memoize with createSelector — snapshots are taken
5727
+ // on demand (Save button click, link generation) rather than on every
5728
+ // store tick, so a fresh build is fine and avoids stale-dep bugs.
5729
+ return (state)=>$472fe3745b238881$var$buildSnapshot(state);
5730
+ }
5731
+ function $472fe3745b238881$var$buildSnapshot(state) {
5732
+ const snap = {
5733
+ v: $472fe3745b238881$var$SCHEMA_VERSION,
5734
+ capturedAt: new Date().toISOString(),
5735
+ site: state.config && state.config.id || null
5736
+ };
5737
+ if (state.grameneFilters) {
5738
+ snap.filters = $472fe3745b238881$var$cleanFilters(state.grameneFilters);
5739
+ snap.searchPage = {
5740
+ offset: state.grameneFilters.searchOffset || 0,
5741
+ rows: state.grameneFilters.rows || 20
5742
+ };
5743
+ }
5744
+ if (state.grameneViews && Array.isArray(state.grameneViews.options)) snap.views = {
5745
+ on: state.grameneViews.options.filter((v)=>v && v.show === 'on').map((v)=>v.id),
5746
+ touched: {
5747
+ ...state.grameneViews.touched || {}
5748
+ }
5749
+ };
5750
+ if (state.grameneGenomes && state.grameneGenomes.active) {
5751
+ const keys = Object.keys(state.grameneGenomes.active);
5752
+ if (keys.length) {
5753
+ snap.genomeSubset = {};
5754
+ for (const k of keys)snap.genomeSubset[k] = true;
5755
+ } else snap.genomeSubset = null;
5756
+ }
5757
+ if (state.uiViewState && state.uiViewState.byGene) snap.expandedDetails = Object.entries(state.uiViewState.byGene).filter(([, e])=>$472fe3745b238881$var$isMeaningfulGene(e)).map(([geneId, e])=>({
5758
+ geneId: geneId,
5759
+ expandedDetail: e.expandedDetail || null,
5760
+ fullscreen: !!e.fullscreen,
5761
+ homology: e.homology && Object.keys(e.homology).length ? {
5762
+ ...e.homology
5763
+ } : undefined,
5764
+ sequences: e.sequences && Object.keys(e.sequences).length ? {
5765
+ ...e.sequences
5766
+ } : undefined,
5767
+ expression: e.expression && Object.keys(e.expression).length ? {
5768
+ ...e.expression
5769
+ } : undefined
5770
+ }));
5771
+ else snap.expandedDetails = [];
5772
+ // Expression-visualization view config. Capture only the selection/config
5773
+ // (active genome tab, per-taxon selected fields + column order, viz mode,
5774
+ // scale, brush ranges) — never the bulk fetched rows, which rehydrate via
5775
+ // the view's re-fetch on apply.
5776
+ if (state.exprViz) {
5777
+ const ev = state.exprViz;
5778
+ const byTaxon = {};
5779
+ for (const tid of Object.keys(ev.byTaxon || {})){
5780
+ const t = ev.byTaxon[tid];
5781
+ if (!t) continue;
5782
+ const selectedFields = Array.isArray(t.selectedFields) ? t.selectedFields : [];
5783
+ const brushes = t.brushes && Object.keys(t.brushes).length ? {
5784
+ ...t.brushes
5785
+ } : undefined;
5786
+ const nonDefaultMode = t.vizMode && t.vizMode !== 'heatmap';
5787
+ const nonDefaultScale = t.scale && t.scale !== 'linear';
5788
+ if (selectedFields.length || brushes || nonDefaultMode || nonDefaultScale) byTaxon[tid] = {
5789
+ selectedFields: selectedFields,
5790
+ vizMode: t.vizMode || 'heatmap',
5791
+ scale: t.scale || 'linear',
5792
+ ...brushes ? {
5793
+ brushes: brushes
5794
+ } : {}
5795
+ };
5796
+ }
5797
+ if (ev.activeTaxon || Object.keys(byTaxon).length) snap.exprViz = {
5798
+ activeTaxon: ev.activeTaxon || null,
5799
+ byTaxon: byTaxon
5800
+ };
5801
+ }
5802
+ // Ontology Enrichment view config: active genome + the analysis knobs
5803
+ // (significance cutoff, gene-set-size bounds, most-specific, ontology
5804
+ // section, term search, per-section sort). The enrichment facets re-fetch
5805
+ // on apply, so no bulk term data is carried.
5806
+ if (state.ontologyEnrichment) {
5807
+ const oe = state.ontologyEnrichment;
5808
+ const ui = oe.ui || {};
5809
+ const DEF = {
5810
+ pAdjCutoff: 0.05,
5811
+ minGSSize: 10,
5812
+ maxGSSize: 500,
5813
+ mostSpecific: false,
5814
+ ontology: 'all',
5815
+ search: ''
5816
+ };
5817
+ const hasSort = ui.sort && Object.keys(ui.sort).length;
5818
+ const uiChanged = Object.keys(DEF).some((k)=>ui[k] !== DEF[k]) || hasSort;
5819
+ if (oe.activeTaxon || uiChanged) snap.ontologyEnrichment = {
5820
+ activeTaxon: oe.activeTaxon || null,
5821
+ ui: {
5822
+ pAdjCutoff: ui.pAdjCutoff != null ? ui.pAdjCutoff : DEF.pAdjCutoff,
5823
+ minGSSize: ui.minGSSize != null ? ui.minGSSize : DEF.minGSSize,
5824
+ maxGSSize: ui.maxGSSize != null ? ui.maxGSSize : DEF.maxGSSize,
5825
+ mostSpecific: !!ui.mostSpecific,
5826
+ ontology: ui.ontology || DEF.ontology,
5827
+ search: ui.search || DEF.search,
5828
+ ...hasSort ? {
5829
+ sort: {
5830
+ ...ui.sort
5831
+ }
5832
+ } : {}
5833
+ }
5834
+ };
5835
+ }
5836
+ return snap;
5837
+ }
5838
+ function $472fe3745b238881$var$validateSnapshot(snapshot) {
5839
+ const warnings = {
5840
+ fatal: null,
5841
+ unresolved: {
5842
+ views: [],
5843
+ genes: [],
5844
+ taxa: []
5845
+ }
5846
+ };
5847
+ if (!snapshot || typeof snapshot !== 'object') {
5848
+ warnings.fatal = 'snapshot is not an object';
5849
+ return warnings;
5850
+ }
5851
+ if (snapshot.v !== $472fe3745b238881$var$SCHEMA_VERSION) {
5852
+ warnings.fatal = `unsupported snapshot version ${snapshot.v} (expected ${$472fe3745b238881$var$SCHEMA_VERSION})`;
5853
+ return warnings;
5854
+ }
5855
+ // Strict-id checks happen later — they need the store's grameneMaps,
5856
+ // grameneTaxonomy, etc. which are async-loaded. The boot path will call
5857
+ // doValidateViewSnapshot again after those land. For now we just
5858
+ // structurally validate.
5859
+ return warnings;
5860
+ }
5861
+ // JSON.parse(JSON.stringify(x)) — bundles see a wholly fresh object so
5862
+ // reducers can't accidentally mutate the snapshot we're holding for retry.
5863
+ function $472fe3745b238881$var$cloneForDispatch(x) {
5864
+ return JSON.parse(JSON.stringify(x));
5865
+ }
5866
+ var $472fe3745b238881$export$2e2bcd8739ae039 = $472fe3745b238881$var$viewSnapshot;
5867
+
5868
+
5869
+ // Boot-time hydration of a shared view link.
5870
+ //
5871
+ // Entry points call `bootViewFromUrl(store)` once the store is created.
5872
+ // Public views resolve anonymously. For private views, the initial call
5873
+ // will 401; the Auth panel then re-invokes with `{user}` once Firebase has
5874
+ // emitted its first signed-in state, and the fetch retries with a Bearer
5875
+ // token. On success we apply the snapshot and strip `?view=` from the URL
5876
+ // so subsequent user actions don't sit under a stale shared-state URL.
5877
+ //
5878
+ // All errors are non-fatal — they surface in state.savedViews.fetchError
5879
+ // for any UI that wants to show them.
5880
+ const $c69c61828e36a328$var$PARAM = 'view';
5881
+ function $c69c61828e36a328$export$2e2bcd8739ae039(store, opts = {}) {
5882
+ if (typeof window === 'undefined') return Promise.resolve(null);
5883
+ const url = new URL(window.location.href);
5884
+ const hash = url.searchParams.get($c69c61828e36a328$var$PARAM);
5885
+ if (!hash) return Promise.resolve(null);
5886
+ const { user: user = null } = opts;
5887
+ return store.doFetchView({
5888
+ hash: hash,
5889
+ user: user
5890
+ }).then(({ snapshot: snapshot })=>{
5891
+ store.doApplyViewSnapshot(snapshot);
5892
+ url.searchParams.delete($c69c61828e36a328$var$PARAM);
5893
+ window.history.replaceState({}, '', url.toString());
5894
+ return {
5895
+ hash: hash,
5896
+ applied: true
5897
+ };
5898
+ }).catch((err)=>{
5899
+ // 401 here on the anonymous pass is expected for private views — the
5900
+ // Auth panel will retry once it has a user. Other errors (404, 5xx,
5901
+ // network) are reported and leave the param so a manual refresh can
5902
+ // retry too.
5903
+ console.warn('bootViewFromUrl:', err.message || err);
5904
+ return {
5905
+ hash: hash,
5906
+ applied: false,
5907
+ error: err.message || String(err)
5908
+ };
5909
+ });
5910
+ }
5911
+
5912
+
5913
+ // Client for the saved-views API ({api}/saved_views — see gramene-swagger
5914
+ // phase 3b). Mirrors the gene_lists pattern: Firebase Bearer ID token,
5915
+ // hash-based addressing, public vs private scope.
5916
+ //
5917
+ // Action creators all return Promises so calling UI (modal, boot path,
5918
+ // list view) can await them and surface success/error inline.
5919
+ //
5920
+ // Dev-mode mock: set `window.__SAVED_VIEWS_MOCK__ = true` in the console
5921
+ // and saves/fetches go through localStorage instead of the network. Lets us
5922
+ // drive Phase 4 (UI) without waiting on Phase 3b (server). Production
5923
+ // builds never touch the mock unless the flag is set at runtime, so this
5924
+ // is safe to ship.
5925
+ const $85a9f6732ceb79db$var$STORAGE_PREFIX = 'gramene_saved_view_mock_v1::';
5926
+ const $85a9f6732ceb79db$var$initialState = {
5927
+ saving: false,
5928
+ saveError: null,
5929
+ lastSavedHash: null,
5930
+ fetching: false,
5931
+ fetchError: null,
5932
+ lastFetched: null,
5933
+ privateList: null,
5934
+ publicList: null,
5935
+ listError: null
5936
+ };
5937
+ const $85a9f6732ceb79db$var$savedViews = {
5938
+ name: 'savedViews',
5939
+ getReducer: ()=>(state = $85a9f6732ceb79db$var$initialState, { type: type, payload: payload })=>{
5940
+ switch(type){
5941
+ case 'SAVED_VIEW_SAVE_STARTED':
5942
+ return {
5943
+ ...state,
5944
+ saving: true,
5945
+ saveError: null
5946
+ };
5947
+ case 'SAVED_VIEW_SAVE_SUCCEEDED':
5948
+ return {
5949
+ ...state,
5950
+ saving: false,
5951
+ lastSavedHash: payload.hash
5952
+ };
5953
+ case 'SAVED_VIEW_SAVE_FAILED':
5954
+ return {
5955
+ ...state,
5956
+ saving: false,
5957
+ saveError: payload.error
5958
+ };
5959
+ case 'SAVED_VIEW_FETCH_STARTED':
5960
+ return {
5961
+ ...state,
5962
+ fetching: true,
5963
+ fetchError: null
5964
+ };
5965
+ case 'SAVED_VIEW_FETCH_SUCCEEDED':
5966
+ return {
5967
+ ...state,
5968
+ fetching: false,
5969
+ lastFetched: payload
5970
+ };
5971
+ case 'SAVED_VIEW_FETCH_FAILED':
5972
+ return {
5973
+ ...state,
5974
+ fetching: false,
5975
+ fetchError: payload.error
5976
+ };
5977
+ case 'SAVED_VIEW_LIST_RECEIVED':
5978
+ return {
5979
+ ...state,
5980
+ [payload.kind === 'public' ? 'publicList' : 'privateList']: payload.rows,
5981
+ listError: null
5982
+ };
5983
+ case 'SAVED_VIEW_LIST_FAILED':
5984
+ return {
5985
+ ...state,
5986
+ listError: payload.error
5987
+ };
5988
+ case 'SAVED_VIEW_RESET':
5989
+ return {
5990
+ ...$85a9f6732ceb79db$var$initialState
5991
+ };
5992
+ default:
5993
+ return state;
5994
+ }
5995
+ },
5996
+ selectSavedViews: (state)=>state.savedViews,
5997
+ // POST a snapshot. Returns Promise<{hash, shareUrl}> on success.
5998
+ // `user` is a Firebase user object (must respond to .getIdToken()).
5999
+ doSaveView: ({ user: user, label: label, description: description, isPublic: isPublic })=>async ({ dispatch: dispatch, store: store })=>{
6000
+ dispatch({
6001
+ type: 'SAVED_VIEW_SAVE_STARTED'
6002
+ });
6003
+ try {
6004
+ const snapshot = store.selectViewSnapshot();
6005
+ const hash = await $85a9f6732ceb79db$var$computeContentHash(snapshot);
6006
+ const site = store.selectConfiguration && store.selectConfiguration().id || '';
6007
+ const token = user && user.getIdToken ? await user.getIdToken() : null;
6008
+ if (!token) throw new Error('Not signed in');
6009
+ const meta = {
6010
+ hash: hash,
6011
+ label: label,
6012
+ site: site,
6013
+ isPublic: !!isPublic,
6014
+ description: description || ''
6015
+ };
6016
+ if ($85a9f6732ceb79db$var$useMock()) $85a9f6732ceb79db$var$mockSave({
6017
+ ...meta,
6018
+ state: snapshot,
6019
+ uid: user.uid || 'mock',
6020
+ createdAt: new Date().toISOString()
6021
+ });
6022
+ else {
6023
+ const api = store.selectGrameneAPI();
6024
+ const res = await fetch(`${api}/saved_views`, {
6025
+ method: 'POST',
6026
+ headers: {
6027
+ 'Content-Type': 'application/json',
6028
+ Authorization: `Bearer ${token}`
6029
+ },
6030
+ body: JSON.stringify({
6031
+ ...meta,
6032
+ state: snapshot
6033
+ })
6034
+ });
6035
+ if (!res.ok) throw new Error(`Save failed (${res.status})`);
6036
+ }
6037
+ const shareUrl = $85a9f6732ceb79db$var$buildShareUrl(hash);
6038
+ dispatch({
6039
+ type: 'SAVED_VIEW_SAVE_SUCCEEDED',
6040
+ payload: {
6041
+ hash: hash
6042
+ }
6043
+ });
6044
+ return {
6045
+ hash: hash,
6046
+ shareUrl: shareUrl
6047
+ };
6048
+ } catch (err) {
6049
+ dispatch({
6050
+ type: 'SAVED_VIEW_SAVE_FAILED',
6051
+ payload: {
6052
+ error: err.message || String(err)
6053
+ }
6054
+ });
6055
+ throw err;
6056
+ }
6057
+ },
6058
+ // GET a snapshot by share hash. Anonymous-OK for public views; token is
6059
+ // optional and only sent if the caller provides a user.
6060
+ // Returns Promise<{snapshot, meta}>.
6061
+ doFetchView: ({ hash: hash, user: user })=>async ({ dispatch: dispatch, store: store })=>{
6062
+ dispatch({
6063
+ type: 'SAVED_VIEW_FETCH_STARTED'
6064
+ });
6065
+ try {
6066
+ let row;
6067
+ if ($85a9f6732ceb79db$var$useMock()) {
6068
+ row = $85a9f6732ceb79db$var$mockFetch(hash);
6069
+ if (!row) throw new Error(`No saved view with hash ${hash}`);
6070
+ } else {
6071
+ const api = store.selectGrameneAPI();
6072
+ const headers = {
6073
+ Accept: 'application/json'
6074
+ };
6075
+ if (user && user.getIdToken) try {
6076
+ headers.Authorization = `Bearer ${await user.getIdToken()}`;
6077
+ } catch (_) {}
6078
+ const res = await fetch(`${api}/saved_views?hash=${encodeURIComponent(hash)}`, {
6079
+ headers: headers
6080
+ });
6081
+ if (res.status === 401) throw new Error('Sign in to load this private view.');
6082
+ if (res.status === 404) throw new Error('Saved view not found (it may have been deleted).');
6083
+ if (!res.ok) throw new Error(`Fetch failed (${res.status})`);
6084
+ row = await res.json();
6085
+ }
6086
+ const out = {
6087
+ hash: hash,
6088
+ snapshot: row.state,
6089
+ meta: {
6090
+ label: row.label,
6091
+ description: row.description || '',
6092
+ site: row.site,
6093
+ isPublic: !!row.isPublic,
6094
+ owner: row.owner || null,
6095
+ createdAt: row.createdAt || null
6096
+ }
6097
+ };
6098
+ dispatch({
6099
+ type: 'SAVED_VIEW_FETCH_SUCCEEDED',
6100
+ payload: out
6101
+ });
6102
+ return out;
6103
+ } catch (err) {
6104
+ dispatch({
6105
+ type: 'SAVED_VIEW_FETCH_FAILED',
6106
+ payload: {
6107
+ error: err.message || String(err)
6108
+ }
6109
+ });
6110
+ throw err;
6111
+ }
6112
+ },
6113
+ // List views for the current site. `scope` is 'public' (anonymous-OK)
6114
+ // or 'private' (requires `user`).
6115
+ doListSavedViews: ({ scope: scope, user: user })=>async ({ dispatch: dispatch, store: store })=>{
6116
+ try {
6117
+ const site = store.selectConfiguration && store.selectConfiguration().id || '';
6118
+ let rows;
6119
+ if ($85a9f6732ceb79db$var$useMock()) rows = $85a9f6732ceb79db$var$mockList({
6120
+ site: site,
6121
+ scope: scope,
6122
+ uid: user && user.uid
6123
+ });
6124
+ else {
6125
+ const api = store.selectGrameneAPI();
6126
+ const headers = {
6127
+ Accept: 'application/json'
6128
+ };
6129
+ if (scope === 'private') {
6130
+ if (!user || !user.getIdToken) throw new Error('Not signed in');
6131
+ headers.Authorization = `Bearer ${await user.getIdToken()}`;
6132
+ }
6133
+ const url = `${api}/saved_views?site=${encodeURIComponent(site)}&isPublic=${scope === 'public'}`;
6134
+ const res = await fetch(url, {
6135
+ headers: headers
6136
+ });
6137
+ if (!res.ok) throw new Error(`List failed (${res.status})`);
6138
+ rows = await res.json();
6139
+ }
6140
+ dispatch({
6141
+ type: 'SAVED_VIEW_LIST_RECEIVED',
6142
+ payload: {
6143
+ kind: scope,
6144
+ rows: rows
6145
+ }
6146
+ });
6147
+ return rows;
6148
+ } catch (err) {
6149
+ dispatch({
6150
+ type: 'SAVED_VIEW_LIST_FAILED',
6151
+ payload: {
6152
+ error: err.message || String(err)
6153
+ }
6154
+ });
6155
+ throw err;
6156
+ }
6157
+ },
6158
+ // PATCH label / isPublic on a view I own. Mirrors updateList.
6159
+ doUpdateSavedView: ({ viewId: viewId, user: user, label: label, isPublic: isPublic })=>async ({ store: store })=>{
6160
+ const updates = {};
6161
+ if (typeof label === 'string') updates.label = label;
6162
+ if (typeof isPublic === 'boolean') updates.isPublic = isPublic;
6163
+ if (!Object.keys(updates).length) return;
6164
+ if ($85a9f6732ceb79db$var$useMock()) {
6165
+ $85a9f6732ceb79db$var$mockUpdate(viewId, updates);
6166
+ return {
6167
+ viewId: viewId,
6168
+ updates: updates
6169
+ };
6170
+ }
6171
+ const token = await user.getIdToken();
6172
+ const api = store.selectGrameneAPI();
6173
+ const res = await fetch(`${api}/saved_views?viewId=${encodeURIComponent(viewId)}`, {
6174
+ method: 'PATCH',
6175
+ headers: {
6176
+ 'Content-Type': 'application/json',
6177
+ Authorization: `Bearer ${token}`
6178
+ },
6179
+ body: JSON.stringify(updates)
6180
+ });
6181
+ if (!res.ok) throw new Error(`Update failed (${res.status})`);
6182
+ return res.json();
6183
+ },
6184
+ // DELETE one of my saved views. Mirrors deleteList.
6185
+ doDeleteSavedView: ({ viewId: viewId, user: user })=>async ({ store: store })=>{
6186
+ if ($85a9f6732ceb79db$var$useMock()) {
6187
+ $85a9f6732ceb79db$var$mockDelete(viewId);
6188
+ return {
6189
+ viewId: viewId
6190
+ };
6191
+ }
6192
+ const token = await user.getIdToken();
6193
+ const api = store.selectGrameneAPI();
6194
+ const res = await fetch(`${api}/saved_views?viewId=${encodeURIComponent(viewId)}`, {
6195
+ method: 'DELETE',
6196
+ headers: {
6197
+ Authorization: `Bearer ${token}`
6198
+ }
6199
+ });
6200
+ if (!res.ok) throw new Error(`Delete failed (${res.status})`);
6201
+ return res.json();
6202
+ },
6203
+ doResetSavedViewState: ()=>({ dispatch: dispatch })=>dispatch({
6204
+ type: 'SAVED_VIEW_RESET'
6205
+ }),
6206
+ // Connect-friendly wrapper around bootViewFromUrl. Auth.js calls this on
6207
+ // each auth-state emission, passing the current Firebase user (or null);
6208
+ // bootView.js no-ops when ?view= isn't in the URL, so this is cheap to
6209
+ // call repeatedly.
6210
+ doBootSharedView: ({ user: user } = {})=>({ store: store })=>{
6211
+ return (0, $c69c61828e36a328$export$2e2bcd8739ae039)(store, {
6212
+ user: user
6213
+ });
6214
+ }
6215
+ };
6216
+ // ── helpers ─────────────────────────────────────────────────────────────
6217
+ // Test/dev: either set the runtime-only flag (`window.__SAVED_VIEWS_MOCK__ = true`,
6218
+ // cleared on reload), or persist via localStorage (`localStorage.setItem(
6219
+ // '__SAVED_VIEWS_MOCK__', 'true')`, survives reloads — needed for the
6220
+ // share-link round-trip which navigates).
6221
+ function $85a9f6732ceb79db$var$useMock() {
6222
+ if (typeof window === 'undefined') return false;
6223
+ if (window.__SAVED_VIEWS_MOCK__) return true;
6224
+ try {
6225
+ return typeof localStorage !== 'undefined' && localStorage.getItem('__SAVED_VIEWS_MOCK__') === 'true';
6226
+ } catch (_) {
6227
+ return false;
6228
+ }
6229
+ }
6230
+ function $85a9f6732ceb79db$var$buildShareUrl(hash) {
6231
+ if (typeof window === 'undefined') return `?view=${hash}`;
6232
+ const u = new URL(window.location.href);
6233
+ u.search = '';
6234
+ u.searchParams.set('view', hash);
6235
+ return u.toString();
6236
+ }
6237
+ // Content-addressable, ~72-bit short hash. The snapshot's `capturedAt` is
6238
+ // excluded from the hash input so two saves of the same view by the same
6239
+ // user produce the same hash (and the server's $setOnInsert preserves the
6240
+ // original createdAt).
6241
+ async function $85a9f6732ceb79db$var$computeContentHash(snapshot) {
6242
+ const forHash = {
6243
+ ...snapshot,
6244
+ capturedAt: undefined
6245
+ };
6246
+ const text = $85a9f6732ceb79db$var$canonicalize(forHash);
6247
+ if (typeof crypto !== 'undefined' && crypto.subtle) {
6248
+ const buf = new TextEncoder().encode(text);
6249
+ const digest = await crypto.subtle.digest('SHA-256', buf);
6250
+ const bytes = new Uint8Array(digest);
6251
+ let b64 = '';
6252
+ for(let i = 0; i < bytes.length; i++)b64 += String.fromCharCode(bytes[i]);
6253
+ return btoa(b64).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '').slice(0, 12);
6254
+ }
6255
+ // Last-resort fallback for environments without WebCrypto.
6256
+ let h = 0;
6257
+ for(let i = 0; i < text.length; i++)h = (h << 5) - h + text.charCodeAt(i) | 0;
6258
+ return ('h' + (h >>> 0).toString(36)).slice(0, 12);
6259
+ }
6260
+ // Stable stringify: sort object keys recursively so semantically-equivalent
6261
+ // snapshots hash identically.
6262
+ function $85a9f6732ceb79db$var$canonicalize(v) {
6263
+ if (v === null || typeof v !== 'object') return JSON.stringify(v);
6264
+ if (Array.isArray(v)) return '[' + v.map($85a9f6732ceb79db$var$canonicalize).join(',') + ']';
6265
+ const keys = Object.keys(v).filter((k)=>v[k] !== undefined).sort();
6266
+ return '{' + keys.map((k)=>JSON.stringify(k) + ':' + $85a9f6732ceb79db$var$canonicalize(v[k])).join(',') + '}';
6267
+ }
6268
+ // ── mock backend (localStorage) ─────────────────────────────────────────
6269
+ function $85a9f6732ceb79db$var$mockSave(row) {
6270
+ if (typeof localStorage === 'undefined') return;
6271
+ localStorage.setItem($85a9f6732ceb79db$var$STORAGE_PREFIX + row.hash, JSON.stringify({
6272
+ ...row,
6273
+ _id: row.hash + ' ' + row.uid
6274
+ }));
6275
+ }
6276
+ function $85a9f6732ceb79db$var$mockFetch(hash) {
6277
+ if (typeof localStorage === 'undefined') return null;
6278
+ const raw = localStorage.getItem($85a9f6732ceb79db$var$STORAGE_PREFIX + hash);
6279
+ return raw ? JSON.parse(raw) : null;
6280
+ }
6281
+ function $85a9f6732ceb79db$var$mockList({ site: site, scope: scope, uid: uid }) {
6282
+ if (typeof localStorage === 'undefined') return [];
6283
+ const out = [];
6284
+ for(let i = 0; i < localStorage.length; i++){
6285
+ const k = localStorage.key(i);
6286
+ if (!k || !k.startsWith($85a9f6732ceb79db$var$STORAGE_PREFIX)) continue;
6287
+ const row = JSON.parse(localStorage.getItem(k));
6288
+ if (row.site !== site) continue;
6289
+ if (scope === 'public' && !row.isPublic) continue;
6290
+ if (scope === 'private' && (row.isPublic || row.uid !== uid)) continue;
6291
+ out.push(row);
6292
+ }
6293
+ return out.sort((a, b)=>(b.createdAt || '').localeCompare(a.createdAt || ''));
6294
+ }
6295
+ function $85a9f6732ceb79db$var$mockUpdate(viewId, updates) {
6296
+ if (typeof localStorage === 'undefined') return;
6297
+ for(let i = 0; i < localStorage.length; i++){
6298
+ const k = localStorage.key(i);
6299
+ if (!k || !k.startsWith($85a9f6732ceb79db$var$STORAGE_PREFIX)) continue;
6300
+ const row = JSON.parse(localStorage.getItem(k));
6301
+ if (row._id === viewId) {
6302
+ localStorage.setItem(k, JSON.stringify({
6303
+ ...row,
6304
+ ...updates
6305
+ }));
6306
+ return;
6307
+ }
6308
+ }
6309
+ }
6310
+ function $85a9f6732ceb79db$var$mockDelete(viewId) {
6311
+ if (typeof localStorage === 'undefined') return;
6312
+ for(let i = 0; i < localStorage.length; i++){
6313
+ const k = localStorage.key(i);
6314
+ if (!k || !k.startsWith($85a9f6732ceb79db$var$STORAGE_PREFIX)) continue;
6315
+ const row = JSON.parse(localStorage.getItem(k));
6316
+ if (row._id === viewId) {
6317
+ localStorage.removeItem(k);
6318
+ return;
6319
+ }
6320
+ }
6321
+ }
6322
+ var $85a9f6732ceb79db$export$2e2bcd8739ae039 = $85a9f6732ceb79db$var$savedViews;
6323
+
6324
+
6325
+ var $5df6c55c1bef3469$export$2e2bcd8739ae039 = [
6326
+ ...(0, $9d9aeaf9299e61a1$export$2e2bcd8739ae039),
6327
+ (0, $671312b287158a8a$export$2e2bcd8739ae039),
6328
+ (0, $af4441dd29af05df$export$2e2bcd8739ae039),
6329
+ (0, $24971af0a229e0e3$export$2e2bcd8739ae039),
6330
+ (0, $0d54502f6cafe273$export$2e2bcd8739ae039),
6331
+ (0, $0f839422d0d8c772$export$2e2bcd8739ae039),
6332
+ (0, $1508f5a42be6e7b5$export$2e2bcd8739ae039),
6333
+ (0, $c921a0d2b34aadb6$export$2e2bcd8739ae039),
6334
+ (0, $4f15cd8a7d970b18$export$2e2bcd8739ae039),
6335
+ (0, $d365d8c287ab0c94$export$2e2bcd8739ae039),
6336
+ (0, $7f865ea0feda21af$export$2e2bcd8739ae039),
6337
+ (0, $472fe3745b238881$export$2e2bcd8739ae039),
6338
+ (0, $85a9f6732ceb79db$export$2e2bcd8739ae039)
6339
+ ];
6340
+
6341
+
6342
+
6343
+
6344
+
6345
+ const $2fec4872fbf7ebd2$var$Gene = ({ gene: gene })=>{
6346
+ return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("div", {
6347
+ className: "row",
6348
+ children: gene.id
6349
+ });
6350
+ };
6351
+ const $2fec4872fbf7ebd2$var$Genes = (results, rows, doChangeQuantity)=>{
6352
+ if (results && results.numFound > 0) {
6353
+ const moreButton = results.numFound > rows ? /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("button", {
6354
+ onClick: (e)=>doChangeQuantity('Genes', 20),
6355
+ children: "more"
6356
+ }) : '';
6357
+ const fewerButton = rows > 20 ? /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("button", {
6358
+ onClick: (e)=>doChangeQuantity('Genes', -20),
6359
+ children: "fewer"
6360
+ }) : '';
6361
+ const docsToShow = results.response.docs.slice(0, rows);
6362
+ return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
6363
+ id: "Genes",
6364
+ className: "container mb40 anchor",
6365
+ children: [
6366
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("div", {
6367
+ className: "fancy-title mb40",
6368
+ children: /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("h4", {
6369
+ children: "Genes"
6370
+ })
6371
+ }),
6372
+ docsToShow.map((doc, idx)=>/*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)($2fec4872fbf7ebd2$var$Gene, {
6373
+ gene: doc
6374
+ }, idx)),
6375
+ fewerButton,
6376
+ moreButton
6377
+ ]
6378
+ });
6379
+ }
6380
+ };
6381
+ const $2fec4872fbf7ebd2$var$Pathway = ({ pathway: pathway })=>{
6382
+ return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("div", {
6383
+ className: "row",
6384
+ children: pathway.name
6385
+ });
6386
+ };
6387
+ const $2fec4872fbf7ebd2$var$Pathways = (results)=>{
6388
+ if (results && results.numFound > 0) return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
6389
+ id: "Pathways",
6390
+ className: "container mb40 anchor",
6391
+ children: [
6392
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("div", {
6393
+ className: "fancy-title",
6394
+ children: /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("h4", {
6395
+ children: "Pathways"
6396
+ })
6397
+ }),
6398
+ results.pathways.map((doc, idx)=>/*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)($2fec4872fbf7ebd2$var$Pathway, {
6399
+ pathway: doc
6400
+ }, idx))
6401
+ ]
6402
+ });
6403
+ };
6404
+ const $2fec4872fbf7ebd2$var$Domain = ({ domain: domain })=>{
6405
+ return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("div", {
6406
+ className: "row",
6407
+ children: domain.id
6408
+ });
6409
+ };
6410
+ const $2fec4872fbf7ebd2$var$Domains = (results)=>{
6411
+ if (results && results.numFound > 0) return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
6412
+ id: "Domains",
6413
+ className: "container mb40 anchor",
6414
+ children: [
6415
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("div", {
6416
+ className: "fancy-title mb40",
6417
+ children: /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("h4", {
6418
+ children: "Domains"
6419
+ })
6420
+ }),
6421
+ results.domains.map((doc, idx)=>/*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)($2fec4872fbf7ebd2$var$Domain, {
6422
+ domain: doc
6423
+ }, idx))
6424
+ ]
6425
+ });
6426
+ };
6427
+ const $2fec4872fbf7ebd2$var$Taxon = ({ taxon: taxon })=>{
6428
+ return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("div", {
6429
+ className: "row",
6430
+ children: taxon.id
6431
+ });
6432
+ };
6433
+ const $2fec4872fbf7ebd2$var$Species = (results)=>{
6434
+ if (results && results.numFound > 0) return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
6435
+ id: "Species",
6436
+ className: "container mb40 anchor",
6437
+ children: [
6438
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("div", {
6439
+ className: "fancy-title mb40",
6440
+ children: /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("h4", {
6441
+ children: "Species"
6442
+ })
6443
+ }),
6444
+ results.taxonomy.map((doc, idx)=>/*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)($2fec4872fbf7ebd2$var$Taxon, {
6445
+ taxon: doc
6446
+ }, idx))
6447
+ ]
6448
+ });
6449
+ };
6450
+ const $2fec4872fbf7ebd2$var$ResultList = ({ grameneGenes: grameneGenes, grameneDomains: grameneDomains, gramenePathways: gramenePathways, grameneTaxonomy: grameneTaxonomy, searchUI: searchUI, searchUpdated: searchUpdated, doChangeQuantity: doChangeQuantity })=>{
6451
+ if (searchUI.Gramene) return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
6452
+ id: "gramene",
6453
+ className: "row",
6454
+ children: [
6455
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("div", {
6456
+ className: "fancy-title pt50",
6457
+ children: /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("h3", {
6458
+ children: "Gramene search results"
6459
+ })
6460
+ }),
6461
+ searchUI.Genes && $2fec4872fbf7ebd2$var$Genes(grameneGenes, searchUI.rows.Genes, doChangeQuantity),
6462
+ searchUI.Domains && $2fec4872fbf7ebd2$var$Domains(grameneDomains),
6463
+ searchUI.Pathways && $2fec4872fbf7ebd2$var$Pathways(gramenePathways),
6464
+ searchUI.Species && $2fec4872fbf7ebd2$var$Species(grameneTaxonomy)
5289
6465
  ]
5290
6466
  });
5291
6467
  else return null;
@@ -5582,6 +6758,225 @@ var $541b8b0d8c5501d2$export$2e2bcd8739ae039 = (0, $gXNCa$reduxbundlerreact.conn
5582
6758
 
5583
6759
 
5584
6760
 
6761
+ // eFP Browser embed — was its own package (gramene-efp-browser@1.0.11),
6762
+ // absorbed here on 2026-05-29 to drop the unmaintained nwb/webpack-4 build
6763
+ // pipeline and remove a dependency that needed --openssl-legacy-provider.
6764
+ // All upstream URLs and species-specific id formatters preserved as-is.
6765
+
6766
+
6767
+ const $59a241f3912e631e$var$urls = {
6768
+ image: (genome, study, gene)=>`https://bar.utoronto.ca/api/efp_image/efp_${genome}/${study}/Absolute/${gene}`,
6769
+ app: (genome, study, gene)=>`https://bar.utoronto.ca/efp_${genome}/cgi-bin/efpWeb.cgi?dataSource=${study}&mode=Absolute&primaryGene=${gene}`,
6770
+ studies: (genome)=>`https://bar.utoronto.ca/api/efp_image/get_efp_data_source/${genome}`,
6771
+ logo: 'https://bar.utoronto.ca/bbc_logo_small.gif',
6772
+ spinner: 'https://www.sorghumbase.org/static/images/dna_spinner.svg'
6773
+ };
6774
+ const $59a241f3912e631e$var$zmv4_re = /Zm00001d/;
6775
+ const $59a241f3912e631e$var$browsers = {
6776
+ sorghum_bicolor: {
6777
+ formatGene: (gene)=>gene._id.replace('SORBI_3', 'Sobic.'),
6778
+ genome: 'sorghum'
6779
+ },
6780
+ vitis_vinifera: {
6781
+ formatGene: (gene)=>gene._id,
6782
+ genome: 'grape'
6783
+ },
6784
+ arabidopsis_thaliana: {
6785
+ formatGene: (gene)=>gene._id,
6786
+ fixStudies: (studies)=>{
6787
+ studies = studies.filter((s)=>s.value !== 'Klepikova_Atlas');
6788
+ studies.unshift({
6789
+ value: 'Klepikova_Atlas',
6790
+ label: 'Klepikova Atlas'
6791
+ });
6792
+ return studies;
6793
+ },
6794
+ genome: 'arabidopsis'
6795
+ },
6796
+ zea_mays: {
6797
+ formatGene: (gene)=>{
6798
+ let id = gene._id;
6799
+ if (gene.synonyms) gene.synonyms.forEach((syn)=>{
6800
+ if ($59a241f3912e631e$var$zmv4_re.test(syn)) id = syn;
6801
+ });
6802
+ return id;
6803
+ },
6804
+ fixStudies: (studies)=>{
6805
+ studies = studies.filter((s)=>s.value !== 'Hoopes_et_al_Atlas' && s.value !== 'Hoopes_et_al_Stress');
6806
+ studies.unshift({
6807
+ value: 'Hoopes_et_al_Stress',
6808
+ label: 'Hoopes et. al., Stress'
6809
+ });
6810
+ studies.unshift({
6811
+ value: 'Hoopes_et_al_Atlas',
6812
+ label: 'Hoopes et. al., Atlas'
6813
+ });
6814
+ return studies;
6815
+ },
6816
+ genome: 'maize'
6817
+ },
6818
+ glycine_max: {
6819
+ formatGene: (gene)=>gene._id.replace('GLYMA_', 'Glyma.'),
6820
+ fixStudies: (studies)=>studies.filter((s)=>s.value !== 'soybean_senescence'),
6821
+ genome: 'soybean'
6822
+ },
6823
+ oryza_sativa: {
6824
+ genome: 'rice',
6825
+ formatGene: (gene)=>gene.MSU_id,
6826
+ fixStudies: (studies)=>{
6827
+ studies = studies.filter((s)=>s.value !== 'rice_rma' && s.value !== 'rice_mas');
6828
+ studies.unshift({
6829
+ value: 'rice_rma',
6830
+ label: 'rice rma'
6831
+ });
6832
+ studies.unshift({
6833
+ value: 'rice_mas',
6834
+ label: 'rice mas'
6835
+ });
6836
+ return studies;
6837
+ }
6838
+ }
6839
+ };
6840
+ // Subsites that refer to maize b73 as `zea_maysb73`.
6841
+ $59a241f3912e631e$var$browsers.zea_maysb73 = $59a241f3912e631e$var$browsers.zea_mays;
6842
+ const $59a241f3912e631e$var$ImageLoader = ({ url: url })=>{
6843
+ const [loading, setLoading] = (0, $gXNCa$react.useState)(true);
6844
+ const [error, setError] = (0, $gXNCa$react.useState)(false);
6845
+ (0, $gXNCa$react.useEffect)(()=>{
6846
+ setLoading(true);
6847
+ setError(false);
6848
+ const image = new Image();
6849
+ image.src = url;
6850
+ image.onload = ()=>setLoading(false);
6851
+ image.onerror = ()=>{
6852
+ setLoading(false);
6853
+ setError(true);
6854
+ };
6855
+ }, [
6856
+ url
6857
+ ]);
6858
+ return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
6859
+ style: {
6860
+ padding: 20
6861
+ },
6862
+ children: [
6863
+ loading && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("img", {
6864
+ src: $59a241f3912e631e$var$urls.spinner,
6865
+ alt: "Loading\u2026"
6866
+ }),
6867
+ !loading && !error && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("img", {
6868
+ style: {
6869
+ maxWidth: '100%'
6870
+ },
6871
+ src: url,
6872
+ alt: "eFP browser output"
6873
+ }),
6874
+ error && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("p", {
6875
+ children: "Error: Failed to load image"
6876
+ })
6877
+ ]
6878
+ });
6879
+ };
6880
+ class $59a241f3912e631e$export$2e2bcd8739ae039 extends (0, $gXNCa$react.Component) {
6881
+ constructor(props){
6882
+ super(props);
6883
+ this.state = {
6884
+ currentStudy: null
6885
+ };
6886
+ }
6887
+ getStudies(browser) {
6888
+ fetch($59a241f3912e631e$var$urls.studies(browser.genome)).then((res)=>res.json()).then((res)=>{
6889
+ if (res.wasSuccessful) {
6890
+ let studies = res.data.sort().map((v)=>({
6891
+ value: v,
6892
+ label: v.replace(/_/g, ' ')
6893
+ }));
6894
+ if (browser.fixStudies) studies = browser.fixStudies(studies);
6895
+ this.setState({
6896
+ studies: studies
6897
+ });
6898
+ }
6899
+ }).catch(console.error);
6900
+ }
6901
+ render() {
6902
+ const gene = this.props.gene;
6903
+ const browser = $59a241f3912e631e$var$browsers[gene.system_name];
6904
+ if (!browser) return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("div", {
6905
+ children: /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("h2", {
6906
+ children: [
6907
+ "Can't find eFP browser for ",
6908
+ gene.system_name
6909
+ ]
6910
+ })
6911
+ });
6912
+ if (!this.state.studies) {
6913
+ this.getStudies(browser);
6914
+ return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("img", {
6915
+ src: $59a241f3912e631e$var$urls.spinner,
6916
+ alt: "Loading\u2026"
6917
+ });
6918
+ }
6919
+ const efp_gene = browser.formatGene(gene);
6920
+ // `study`/`onStudyChange` let a host drive the selection (so a saved view
6921
+ // can restore it); fall back to local state when uncontrolled.
6922
+ const study = this.props.study || this.state.currentStudy || this.state.studies[0].value;
6923
+ const onSelect = (e)=>{
6924
+ if (this.props.onStudyChange) this.props.onStudyChange(e.target.value);
6925
+ else this.setState({
6926
+ currentStudy: e.target.value
6927
+ });
6928
+ };
6929
+ return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
6930
+ style: {
6931
+ paddingTop: 10
6932
+ },
6933
+ children: [
6934
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("label", {
6935
+ style: {
6936
+ paddingLeft: 20,
6937
+ paddingRight: 10
6938
+ },
6939
+ children: "Select a study:"
6940
+ }),
6941
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("select", {
6942
+ value: study,
6943
+ onChange: onSelect,
6944
+ children: this.state.studies.map((s, idx)=>/*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("option", {
6945
+ value: s.value,
6946
+ children: s.label
6947
+ }, idx))
6948
+ }),
6949
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("br", {}),
6950
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)($59a241f3912e631e$var$ImageLoader, {
6951
+ url: $59a241f3912e631e$var$urls.image(browser.genome, study, efp_gene)
6952
+ }),
6953
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("a", {
6954
+ style: {
6955
+ paddingLeft: 100
6956
+ },
6957
+ href: $59a241f3912e631e$var$urls.app(browser.genome, study, efp_gene),
6958
+ target: "_blank",
6959
+ rel: "noreferrer",
6960
+ children: [
6961
+ "Powered by ",
6962
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("img", {
6963
+ src: $59a241f3912e631e$var$urls.logo,
6964
+ style: {
6965
+ maxWidth: 40
6966
+ },
6967
+ alt: "BBC"
6968
+ }),
6969
+ " Webservices"
6970
+ ]
6971
+ })
6972
+ ]
6973
+ });
6974
+ }
6975
+ }
6976
+ function $59a241f3912e631e$export$1bc0f3596ef2efe9(gene) {
6977
+ return $59a241f3912e631e$var$browsers.hasOwnProperty(gene.system_name);
6978
+ }
6979
+
5585
6980
 
5586
6981
  function $9e29a4f60318db7a$var$DynamicIframe(props) {
5587
6982
  // Create a ref for the iframe element
@@ -5606,15 +7001,43 @@ function $9e29a4f60318db7a$var$DynamicIframe(props) {
5606
7001
  });
5607
7002
  }
5608
7003
  const $9e29a4f60318db7a$var$Detail = (props)=>{
5609
- const gene = props.geneDocs[props.searchResult.id];
5610
- const [atlasExperiment, setAtlasExperiment] = (0, $gXNCa$react.useState)(null);
7004
+ const geneId = props.searchResult.id;
7005
+ const gene = props.geneDocs[geneId];
7006
+ // User-selected view state (active sub-tab, chosen GXA experiment, chosen eFP
7007
+ // study) lives in the uiViewState bundle keyed by geneId, so the shareable-
7008
+ // views snapshot can round-trip it. Fetched data + the dev-only Local API
7009
+ // toggle stay in local state. Defaults match the old local-state initials.
7010
+ const expr = props.uiViewState && props.uiViewState.byGene[geneId] && props.uiViewState.byGene[geneId].expression || {};
7011
+ const activeTab = expr.activeTab || 'gene';
7012
+ const atlasExperiment = expr.atlasExperiment || null;
7013
+ const setActiveTab = (k)=>props.doSetExpressionState({
7014
+ geneId: geneId,
7015
+ patch: {
7016
+ activeTab: k
7017
+ }
7018
+ });
7019
+ const setAtlasExperiment = (v)=>props.doSetExpressionState({
7020
+ geneId: geneId,
7021
+ patch: {
7022
+ atlasExperiment: v
7023
+ }
7024
+ });
5611
7025
  const [atlasExperimentList, setAtlasExperimentList] = (0, $gXNCa$react.useState)([]);
5612
7026
  const [atlasFacets, setAtlasFacets] = (0, $gXNCa$react.useState)(null);
5613
7027
  const [isLocal, setIsLocal] = (0, $gXNCa$react.useState)(false);
5614
- const [activeTab, setActiveTab] = (0, $gXNCa$react.useState)('gene');
5615
7028
  const handleLocalAPIChange = (event)=>{
5616
7029
  setIsLocal(event.target.checked);
5617
7030
  };
7031
+ // The expressionStudies resource is otherwise fetched only when a top-level
7032
+ // expression view (exprViz/expression/export) is on — but this per-gene
7033
+ // Expression detail also needs it (the Paralogs sub-tab's experiment list and
7034
+ // the atlasExperiment selection both derive from it). Self-fetch on mount so
7035
+ // opening the detail populates the studies even when no such view is enabled.
7036
+ (0, $gXNCa$react.useEffect)(()=>{
7037
+ if (!props.expressionStudies && props.doFetchExpressionStudies) props.doFetchExpressionStudies();
7038
+ }, [
7039
+ props.expressionStudies
7040
+ ]);
5618
7041
  (0, $gXNCa$react.useEffect)(()=>{
5619
7042
  if (!props.expressionStudies) return;
5620
7043
  const tid = Math.floor(gene.taxon_id / 1000);
@@ -5637,10 +7060,15 @@ const $9e29a4f60318db7a$var$Detail = (props)=>{
5637
7060
  });
5638
7061
  setAtlasExperimentList(eList);
5639
7062
  setAtlasFacets(facets);
5640
- let refExp = eList.filter((e)=>e.isRef);
5641
- if (refExp.length === 1) setAtlasExperiment(refExp[0]._id);
5642
- else // no reference experiment - choose first
5643
- setAtlasExperiment(eList[0]._id);
7063
+ // Only pick a default experiment when the user (or a restored snapshot)
7064
+ // hasn't already chosen one — otherwise we'd clobber a saved selection
7065
+ // the moment the studies list loads.
7066
+ if (!atlasExperiment) {
7067
+ let refExp = eList.filter((e)=>e.isRef);
7068
+ if (refExp.length === 1) setAtlasExperiment(refExp[0]._id);
7069
+ else // no reference experiment - choose first
7070
+ setAtlasExperiment(eList[0]._id);
7071
+ }
5644
7072
  }
5645
7073
  }, [
5646
7074
  props.expressionStudies
@@ -5648,8 +7076,14 @@ const $9e29a4f60318db7a$var$Detail = (props)=>{
5648
7076
  let paralogs_url;
5649
7077
  let gene_url = `https://dev.gramene.org/static/atlasWidget.html?genes=${gene.atlas_id || gene._id}&localAPI=${isLocal}`;
5650
7078
  let paralogs = [];
5651
- if (props.grameneParalogs && props.grameneParalogs[gene._id]) paralogs = props.grameneParalogs[gene._id];
5652
- else if (gene.homology) props.doRequestParalogs(gene._id, gene.homology.supertree, gene.taxon_id);
7079
+ const haveParalogs = props.grameneParalogs && props.grameneParalogs[gene._id];
7080
+ if (haveParalogs) paralogs = props.grameneParalogs[gene._id];
7081
+ (0, $gXNCa$react.useEffect)(()=>{
7082
+ if (!haveParalogs && gene.homology) props.doRequestParalogs(gene._id, gene.homology.supertree, gene.taxon_id);
7083
+ }, [
7084
+ gene._id,
7085
+ haveParalogs
7086
+ ]);
5653
7087
  // if (gene.homology && gene.homology.homologous_genes && gene.homology.homologous_genes.within_species_paralog) {
5654
7088
  // paralogs = gene.homology.homologous_genes.within_species_paralog;
5655
7089
  // }
@@ -5666,6 +7100,7 @@ const $9e29a4f60318db7a$var$Detail = (props)=>{
5666
7100
  /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.Form).Select, {
5667
7101
  "aria-label": "experiment selector",
5668
7102
  placeholder: "Select experiment",
7103
+ value: atlasExperiment || '',
5669
7104
  onChange: (e)=>setAtlasExperiment(e.target.value),
5670
7105
  children: atlasExperimentList.map((e, idx)=>/*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("option", {
5671
7106
  value: e._id,
@@ -5689,18 +7124,25 @@ const $9e29a4f60318db7a$var$Detail = (props)=>{
5689
7124
  url: gene_url
5690
7125
  })
5691
7126
  }, "gxa"),
5692
- (0, $gXNCa$grameneefpbrowser.haveBAR)(gene) && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.Tab), {
7127
+ (0, $59a241f3912e631e$export$1bc0f3596ef2efe9)(gene) && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.Tab), {
5693
7128
  tabClassName: "eFP",
5694
7129
  eventKey: "eFP",
5695
7130
  title: "eFP Browser",
5696
- children: /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, ($parcel$interopDefault($gXNCa$grameneefpbrowser))), {
5697
- gene: gene
7131
+ children: /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $59a241f3912e631e$export$2e2bcd8739ae039), {
7132
+ gene: gene,
7133
+ study: expr.barStudy,
7134
+ onStudyChange: (v)=>props.doSetExpressionState({
7135
+ geneId: geneId,
7136
+ patch: {
7137
+ barStudy: v
7138
+ }
7139
+ })
5698
7140
  })
5699
7141
  }, "bar")
5700
7142
  ]
5701
7143
  });
5702
7144
  };
5703
- var $9e29a4f60318db7a$export$2e2bcd8739ae039 = (0, $gXNCa$reduxbundlerreact.connect)('selectGrameneParalogs', 'selectExpressionStudies', 'doRequestParalogs', //'doRequestParalogExpression',
7145
+ var $9e29a4f60318db7a$export$2e2bcd8739ae039 = (0, $gXNCa$reduxbundlerreact.connect)('selectGrameneParalogs', 'selectExpressionStudies', 'selectUiViewState', 'doRequestParalogs', 'doFetchExpressionStudies', 'doSetExpressionState', //'doRequestParalogExpression',
5704
7146
  $9e29a4f60318db7a$var$Detail);
5705
7147
 
5706
7148
 
@@ -5719,6 +7161,7 @@ $9e29a4f60318db7a$var$Detail);
5719
7161
 
5720
7162
 
5721
7163
 
7164
+
5722
7165
  const $5c2c79352d3d7b81$export$ec6b54b688be1708 = ({ fullscreen: fullscreen, onExitFullscreen: onExitFullscreen, title: title, className: className, children: children })=>{
5723
7166
  const [stableNode] = (0, $gXNCa$react.useState)(()=>typeof document !== 'undefined' ? document.createElement('div') : null);
5724
7167
  const inlineRef = (0, $gXNCa$react.useRef)(null);
@@ -5936,20 +7379,140 @@ const $64fad37f770d2bfe$var$TBROWSE_ZONES = [
5936
7379
  class $64fad37f770d2bfe$var$Homology extends (0, ($parcel$interopDefault($gXNCa$react))).Component {
5937
7380
  constructor(props){
5938
7381
  super(props);
7382
+ // Async data caches stay in local state (per mount, per tree). The
7383
+ // user-controlled view state (viewer toggle, resize height, tbrowse
7384
+ // ViewState) is lifted into the uiViewState bundle, keyed by geneId.
7385
+ //
7386
+ // `*Status` fields track per-zone load lifecycle so the TBrowse
7387
+ // toolbar can render loading/error affordances via its zoneStatus
7388
+ // prop. They start `undefined` (no signal); flipped to 'loading'
7389
+ // when the fetch kicks off and 'ready' / 'error' on settle.
5939
7390
  this.state = {
5940
- viewer: 'treevis',
5941
7391
  neighborhood: null,
5942
7392
  neighborhoodTreeId: null,
7393
+ neighborhoodStatus: undefined,
5943
7394
  geneStructures: null,
5944
7395
  geneStructuresTreeId: null,
5945
- height: 600
7396
+ geneStructuresStatus: undefined
5946
7397
  };
5947
7398
  if (!props.geneDocs.hasOwnProperty(props.searchResult.id)) props.requestGene(props.searchResult.id);
5948
- this.taxonomy = (0, ($parcel$interopDefault($gXNCa$gramenetreesclient))).taxonomy.tree(Object.values(props.grameneTaxonomy));
7399
+ this.taxonomy = (0, ($parcel$interopDefault($gXNCa$gramenetreesclientsrctaxonomy))).tree(Object.values(props.grameneTaxonomy));
7400
+ }
7401
+ // ----- uiViewState accessors (with sensible defaults) -----
7402
+ getGeneId() {
7403
+ return this.props.searchResult.id;
7404
+ }
7405
+ getHomologySlice() {
7406
+ const slice = this.props.uiViewState && this.props.uiViewState.byGene[this.getGeneId()];
7407
+ return slice && slice.homology || {};
7408
+ }
7409
+ getViewer() {
7410
+ return this.getHomologySlice().viewer || 'treevis';
7411
+ }
7412
+ getHeight() {
7413
+ const h = this.getHomologySlice().height;
7414
+ return typeof h === 'number' ? h : 600;
7415
+ }
7416
+ setViewer(viewer) {
7417
+ this.props.doSetHomologyViewer({
7418
+ geneId: this.getGeneId(),
7419
+ viewer: viewer
7420
+ });
7421
+ }
7422
+ setHeight(height) {
7423
+ this.props.doSetHomologyHeight({
7424
+ geneId: this.getGeneId(),
7425
+ height: height
7426
+ });
7427
+ }
7428
+ setTbrowseViewState(viewState) {
7429
+ this.props.doSetHomologyTbrowseViewState({
7430
+ geneId: this.getGeneId(),
7431
+ tbrowse: viewState
7432
+ });
7433
+ }
7434
+ // Seed the bundle with a pivot-computed initial tbrowse view state once the
7435
+ // tree data is available and the user is actually looking at the tbrowse
7436
+ // viewer. Called from lifecycle (not render) to avoid dispatching mid-render.
7437
+ maybeSeedTbrowseViewState() {
7438
+ if (this.getViewer() !== 'tbrowse') return;
7439
+ if (this.getHomologySlice().tbrowse) return;
7440
+ const id = this.getGeneId();
7441
+ if (!this.props.geneDocs.hasOwnProperty(id)) return;
7442
+ const gene = this.props.geneDocs[id];
7443
+ if (!gene.homology) return;
7444
+ const treeId = gene.homology.gene_tree.id;
7445
+ const raw = this.props.grameneTrees[treeId];
7446
+ if (!raw || !raw.taxon_id) return;
7447
+ const adapted = (0, $gXNCa$tbrowse.fromGrameneGenetree)([
7448
+ raw
7449
+ ]);
7450
+ const pivot = (0, $gXNCa$tbrowse.computePivotState)(adapted.tree, gene._id);
7451
+ this.setTbrowseViewState({
7452
+ selectedNodeId: null,
7453
+ collapsedNodeIds: pivot ? pivot.collapsedNodeIds : [],
7454
+ prunedNodeIds: [],
7455
+ swappedNodeIds: pivot ? pivot.swappedNodeIds : [],
7456
+ compressedNodeIds: [],
7457
+ nodeOfInterestId: pivot ? pivot.targetId : null,
7458
+ // Use each zone's intended fr-share + initial visibility from its
7459
+ // definition (tree:30 / labels:20 / msa:50 / neighborhood:30 /
7460
+ // genome:32) instead of uniform 25 across the board. Gives the MSA
7461
+ // a wider initial pane and the tree a narrower one.
7462
+ //
7463
+ // `defaultVisible ?? true` matches tbrowse's own buildInitialViewState
7464
+ // logic — zones default to visible unless their definition opts out
7465
+ // (e.g. neighborhood and genome opt out so they only appear once
7466
+ // their async data lands; tbrowse's Layout auto-flips them on then).
7467
+ zones: $64fad37f770d2bfe$var$TBROWSE_ZONES.map((z)=>({
7468
+ id: z.id,
7469
+ width: z.defaultWidth,
7470
+ visible: z.defaultVisible ?? true
7471
+ })),
7472
+ zoneStates: {},
7473
+ search: null
7474
+ });
7475
+ }
7476
+ componentDidMount() {
7477
+ this.maybeSeedTbrowseViewState();
7478
+ this.maybeFetchTbrowseData();
7479
+ }
7480
+ componentDidUpdate() {
7481
+ this.maybeSeedTbrowseViewState();
7482
+ this.maybeFetchTbrowseData();
7483
+ }
7484
+ // Kick off the neighborhood + gene-structure fetches from lifecycle,
7485
+ // not render. Each fetch internally dedupes on `_*FetchedFor === treeId`
7486
+ // so calling on every commit is cheap. Gated on the user actually being
7487
+ // on the tbrowse viewer + the tree data being loaded — otherwise we'd
7488
+ // pay network cost for genes the user never looks at.
7489
+ maybeFetchTbrowseData() {
7490
+ if (this.getViewer() !== 'tbrowse') return;
7491
+ const id = this.getGeneId();
7492
+ if (!this.props.geneDocs.hasOwnProperty(id)) return;
7493
+ const gene = this.props.geneDocs[id];
7494
+ if (!gene.homology) return;
7495
+ const treeId = gene.homology.gene_tree.id;
7496
+ const raw = this.props.grameneTrees[treeId];
7497
+ if (!raw || !raw.taxon_id) return;
7498
+ // Lazily compute the adapted tbrowse data the same way renderTBrowse
7499
+ // does, so fetchGeneStructures has the leaf ids without rerunning
7500
+ // fromGrameneGenetree on every commit.
7501
+ if (this._tbrowseTreeId !== treeId) {
7502
+ this._tbrowseTreeId = treeId;
7503
+ this._tbrowseData = (0, $gXNCa$tbrowse.fromGrameneGenetree)([
7504
+ raw
7505
+ ]);
7506
+ }
7507
+ this.fetchNeighborhood(treeId);
7508
+ this.fetchGeneStructures(treeId, this._tbrowseData.tree);
5949
7509
  }
5950
7510
  fetchNeighborhood(treeId) {
5951
7511
  if (this._neighborhoodFetchedFor === treeId) return;
5952
7512
  this._neighborhoodFetchedFor = treeId;
7513
+ this.setState({
7514
+ neighborhoodStatus: 'loading'
7515
+ });
5953
7516
  const api = this.props.grameneAPI;
5954
7517
  const url = new URL(`${api}/search`);
5955
7518
  url.searchParams.set('fl', 'id,name,gene_tree,gene_idx,region,start,end,strand,biotype,system_name,description');
@@ -5967,10 +7530,16 @@ class $64fad37f770d2bfe$var$Homology extends (0, ($parcel$interopDefault($gXNCa$
5967
7530
  if (this._neighborhoodFetchedFor !== treeId) return;
5968
7531
  this.setState({
5969
7532
  neighborhood: (0, $gXNCa$tbrowse.fromGrameneNeighborhood)(json),
5970
- neighborhoodTreeId: treeId
7533
+ neighborhoodTreeId: treeId,
7534
+ neighborhoodStatus: 'ready'
5971
7535
  });
5972
7536
  }).catch((err)=>{
5973
7537
  console.warn('tbrowse neighborhood fetch failed:', err);
7538
+ // Surface the failure via the zoneStatus prop. Reset the
7539
+ // dedupe key so a re-mount or tree-change can retry.
7540
+ if (this._neighborhoodFetchedFor === treeId) this.setState({
7541
+ neighborhoodStatus: 'error'
7542
+ });
5974
7543
  this._neighborhoodFetchedFor = null;
5975
7544
  });
5976
7545
  }
@@ -5979,6 +7548,9 @@ class $64fad37f770d2bfe$var$Homology extends (0, ($parcel$interopDefault($gXNCa$
5979
7548
  this._geneStructuresFetchedFor = treeId;
5980
7549
  const ids = Object.values(tree.nodes).filter((n)=>n.isLeaf && n.geneId).map((n)=>n.geneId);
5981
7550
  if (ids.length === 0) return;
7551
+ this.setState({
7552
+ geneStructuresStatus: 'loading'
7553
+ });
5982
7554
  const api = this.props.grameneAPI;
5983
7555
  const BATCH_SIZE = 50;
5984
7556
  const batches = [];
@@ -6001,22 +7573,24 @@ class $64fad37f770d2bfe$var$Homology extends (0, ($parcel$interopDefault($gXNCa$
6001
7573
  const combined = [].concat(...results.map((r)=>Array.isArray(r) ? r : []));
6002
7574
  this.setState({
6003
7575
  geneStructures: (0, $gXNCa$tbrowse.fromGrameneGeneStructures)(combined),
6004
- geneStructuresTreeId: treeId
7576
+ geneStructuresTreeId: treeId,
7577
+ geneStructuresStatus: 'ready'
6005
7578
  });
6006
7579
  }).catch((err)=>{
6007
7580
  console.warn('tbrowse gene-structures fetch failed:', err);
7581
+ if (this._geneStructuresFetchedFor === treeId) this.setState({
7582
+ geneStructuresStatus: 'error'
7583
+ });
6008
7584
  this._geneStructuresFetchedFor = null;
6009
7585
  });
6010
7586
  }
6011
7587
  startResize(e) {
6012
7588
  e.preventDefault();
6013
7589
  const startY = e.clientY;
6014
- const startHeight = this.state.height;
7590
+ const startHeight = this.getHeight();
6015
7591
  const onMouseMove = (moveEvent)=>{
6016
7592
  const newHeight = Math.max(200, startHeight + (moveEvent.clientY - startY));
6017
- this.setState({
6018
- height: newHeight
6019
- });
7593
+ this.setHeight(newHeight);
6020
7594
  };
6021
7595
  const onMouseUp = ()=>{
6022
7596
  window.removeEventListener('mousemove', onMouseMove);
@@ -6039,7 +7613,7 @@ class $64fad37f770d2bfe$var$Homology extends (0, ($parcel$interopDefault($gXNCa$
6039
7613
  /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("div", {
6040
7614
  className: "gene-genetree",
6041
7615
  style: {
6042
- height: this.state.height,
7616
+ height: this.getHeight(),
6043
7617
  width: '100%'
6044
7618
  },
6045
7619
  children: /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, ($parcel$interopDefault($gXNCa$gramenegenetreevis))), {
@@ -6061,41 +7635,36 @@ class $64fad37f770d2bfe$var$Homology extends (0, ($parcel$interopDefault($gXNCa$
6061
7635
  }
6062
7636
  renderTBrowse() {
6063
7637
  const treeId = this.gene.homology.gene_tree.id;
7638
+ // Adapted tbrowse data is computed lazily in maybeFetchTbrowseData
7639
+ // (called from lifecycle). If the user just flipped to tbrowse and
7640
+ // the lifecycle hasn't fired yet, compute it once here without
7641
+ // calling any fetches — those will fire from componentDidUpdate.
6064
7642
  if (this._tbrowseTreeId !== treeId) {
6065
- const raw = this.props.grameneTrees[treeId];
6066
- const adapted = (0, $gXNCa$tbrowse.fromGrameneGenetree)([
6067
- raw
6068
- ]);
6069
7643
  this._tbrowseTreeId = treeId;
6070
- this._tbrowseData = adapted;
6071
- const pivot = (0, $gXNCa$tbrowse.computePivotState)(adapted.tree, this.gene._id);
6072
- const zoneIds = $64fad37f770d2bfe$var$TBROWSE_ZONES.map((z)=>z.id);
6073
- this._tbrowseInitialViewState = {
6074
- selectedNodeId: null,
6075
- collapsedNodeIds: pivot ? pivot.collapsedNodeIds : [],
6076
- prunedNodeIds: [],
6077
- swappedNodeIds: pivot ? pivot.swappedNodeIds : [],
6078
- compressedNodeIds: [],
6079
- nodeOfInterestId: pivot ? pivot.targetId : null,
6080
- zones: zoneIds.map((id)=>({
6081
- id: id,
6082
- width: 25,
6083
- visible: true
6084
- })),
6085
- zoneStates: {},
6086
- search: null
6087
- };
7644
+ this._tbrowseData = (0, $gXNCa$tbrowse.fromGrameneGenetree)([
7645
+ this.props.grameneTrees[treeId]
7646
+ ]);
6088
7647
  }
6089
- this.fetchNeighborhood(treeId);
6090
- this.fetchGeneStructures(treeId, this._tbrowseData.tree);
6091
7648
  const neighborhood = this.state.neighborhoodTreeId === treeId ? this.state.neighborhood : undefined;
6092
7649
  const geneStructures = this.state.geneStructuresTreeId === treeId ? this.state.geneStructures : undefined;
7650
+ // Per-zone status for the TBrowse toolbar — pulses while a fetch
7651
+ // is in flight, turns red on failure. Tracked per-tree so a
7652
+ // tree-change resets any stale 'ready'/'error' from the prior gene.
7653
+ const zoneStatus = {
7654
+ neighborhood: this.state.neighborhoodTreeId === treeId ? this.state.neighborhoodStatus : this.state.neighborhoodStatus === 'loading' ? 'loading' : undefined,
7655
+ genome: this.state.geneStructuresTreeId === treeId ? this.state.geneStructuresStatus : this.state.geneStructuresStatus === 'loading' ? 'loading' : undefined
7656
+ };
7657
+ // Bundle-driven (controlled) view state. If we're rendering tbrowse before
7658
+ // componentDidMount/Update has seeded the bundle slice, skip this turn and
7659
+ // let the re-render with the seeded state do the work.
7660
+ const tbrowseVS = this.getHomologySlice().tbrowse;
7661
+ if (!tbrowseVS) return null;
6093
7662
  return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)((0, $gXNCa$reactjsxruntime.Fragment), {
6094
7663
  children: [
6095
7664
  /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("div", {
6096
7665
  className: "gene-genetree",
6097
7666
  style: {
6098
- height: this.state.height,
7667
+ height: this.getHeight(),
6099
7668
  width: '100%'
6100
7669
  },
6101
7670
  children: /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$tbrowse.TBrowse), {
@@ -6109,7 +7678,13 @@ class $64fad37f770d2bfe$var$Homology extends (0, ($parcel$interopDefault($gXNCa$
6109
7678
  geneStructures: geneStructures,
6110
7679
  zones: $64fad37f770d2bfe$var$TBROWSE_ZONES,
6111
7680
  nodeOfInterest: this.gene._id,
6112
- initialViewState: this._tbrowseInitialViewState
7681
+ viewState: tbrowseVS,
7682
+ onViewStateChange: (next)=>this.setTbrowseViewState(next),
7683
+ defaultOpenSections: {
7684
+ zones: true,
7685
+ search: true
7686
+ },
7687
+ zoneStatus: zoneStatus
6113
7688
  })
6114
7689
  }),
6115
7690
  this.renderResizeHandle()
@@ -6117,12 +7692,10 @@ class $64fad37f770d2bfe$var$Homology extends (0, ($parcel$interopDefault($gXNCa$
6117
7692
  });
6118
7693
  }
6119
7694
  renderViewerToggle() {
6120
- const { viewer: viewer } = this.state;
7695
+ const viewer = this.getViewer();
6121
7696
  const btn = (id, label)=>/*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("button", {
6122
7697
  type: "button",
6123
- onClick: ()=>this.setState({
6124
- viewer: id
6125
- }),
7698
+ onClick: ()=>this.setViewer(id),
6126
7699
  style: {
6127
7700
  padding: '4px 10px',
6128
7701
  marginRight: 4,
@@ -6251,36 +7824,22 @@ class $64fad37f770d2bfe$var$Homology extends (0, ($parcel$interopDefault($gXNCa$
6251
7824
  else {
6252
7825
  const tree = this.props.grameneTrees[treeId];
6253
7826
  if (tree.hasOwnProperty('taxon_id')) {
6254
- this.tree = (0, ($parcel$interopDefault($gXNCa$gramenetreesclient))).genetree.tree([
7827
+ this.tree = (0, ($parcel$interopDefault($gXNCa$gramenetreesclientsrcgenetree))).tree([
6255
7828
  this.props.grameneTrees[treeId]
6256
7829
  ]);
6257
7830
  this.orthologs = this.orthologList();
6258
7831
  this.paralogs = this.paralogList();
6259
7832
  }
6260
7833
  }
6261
- let flagged = 0;
6262
- // if (this.props.curation && this.props.curation.taxa.hasOwnProperty(this.gene.taxon_id)) {
6263
- // flagged = this.props.curatedGenes && this.props.curatedGenes[id] ? this.props.curatedGenes[id].flagged : 0;
6264
- // }
6265
7834
  return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)((0, $5c2c79352d3d7b81$export$47eb42ff093406d4), {
6266
7835
  children: [
6267
- /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)((0, $5c2c79352d3d7b81$export$393edc798c47379d), {
6268
- children: [
6269
- "This phylogram shows the relationships between this gene and others similar to it, as determined by Ensembl Compara.",
6270
- flagged > 1 && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)((0, $gXNCa$reactbootstrap.Alert), {
6271
- variant: 'warning',
6272
- children: [
6273
- "This gene was flagged for potential gene structural annotation issues by ",
6274
- flagged,
6275
- " curators"
6276
- ]
6277
- })
6278
- ]
7836
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $5c2c79352d3d7b81$export$393edc798c47379d), {
7837
+ children: "This phylogram shows the relationships between this gene and others similar to it, as determined by Ensembl Compara."
6279
7838
  }, "description"),
6280
7839
  this.tree && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)((0, $5c2c79352d3d7b81$export$7c6e2c02157bb7d2), {
6281
7840
  children: [
6282
7841
  this.renderViewerToggle(),
6283
- this.state.viewer === 'tbrowse' ? this.renderTBrowse() : this.renderTreeVis()
7842
+ this.getViewer() === 'tbrowse' ? this.renderTBrowse() : this.renderTreeVis()
6284
7843
  ]
6285
7844
  }, "content"),
6286
7845
  this.tree && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $5c2c79352d3d7b81$export$900f1bfcd1cb6476), {
@@ -6293,7 +7852,7 @@ class $64fad37f770d2bfe$var$Homology extends (0, ($parcel$interopDefault($gXNCa$
6293
7852
  });
6294
7853
  }
6295
7854
  }
6296
- var $64fad37f770d2bfe$export$2e2bcd8739ae039 = (0, $gXNCa$reduxbundlerreact.connect)('selectGrameneTaxonomy', 'selectGrameneTrees', 'selectGrameneGenomes', 'selectGrameneAPI', 'selectConfiguration', 'selectCuration', 'selectCuratedGenes', 'doRequestGrameneTree', 'doAcceptGrameneSuggestion', 'doReplaceGrameneFilters', $64fad37f770d2bfe$var$Homology);
7855
+ var $64fad37f770d2bfe$export$2e2bcd8739ae039 = (0, $gXNCa$reduxbundlerreact.connect)('selectGrameneTaxonomy', 'selectGrameneTrees', 'selectGrameneGenomes', 'selectGrameneAPI', 'selectConfiguration', 'selectCuration', 'selectUiViewState', 'doRequestGrameneTree', 'doSetHomologyViewer', 'doSetHomologyHeight', 'doSetHomologyTbrowseViewState', 'doAcceptGrameneSuggestion', 'doReplaceGrameneFilters', $64fad37f770d2bfe$var$Homology);
6297
7856
 
6298
7857
 
6299
7858
 
@@ -6784,7 +8343,7 @@ function $54c74a4689d5a778$var$buildIframeSrcDoc() {
6784
8343
  class $54c74a4689d5a778$var$Pathways extends (0, ($parcel$interopDefault($gXNCa$react))).Component {
6785
8344
  constructor(props){
6786
8345
  super(props);
6787
- this.taxonomy = (0, ($parcel$interopDefault($gXNCa$gramenetreesclient))).taxonomy.tree(Object.values(props.grameneTaxonomy));
8346
+ this.taxonomy = (0, ($parcel$interopDefault($gXNCa$gramenetreesclientsrctaxonomy))).tree(Object.values(props.grameneTaxonomy));
6788
8347
  this.gene = props.geneDocs[props.searchResult.id];
6789
8348
  this.iframeRef = /*#__PURE__*/ (0, ($parcel$interopDefault($gXNCa$react))).createRef();
6790
8349
  // srcDoc is constant — the per-instance pathway/reaction state is
@@ -7195,25 +8754,45 @@ function $283508ffcf8a47c4$var$compareGermplasm(a, b) {
7195
8754
  }
7196
8755
  function $283508ffcf8a47c4$var$group_germplasm(gene, germplasmLUT, vep_obj) {
7197
8756
  let accessionTable = [];
8757
+ let missingMetadata = 0;
7198
8758
  Object.entries(vep_obj).forEach(([key, accessions])=>{
7199
8759
  const parts = key.split("__");
7200
8760
  if (parts[0] === "VEP") {
7201
- if (parts[1] !== "merged") accessions.filter((ens_id)=>germplasmLUT.hasOwnProperty(ens_id)).forEach((ens_id)=>{
7202
- const germplasm = germplasmLUT[ens_id][0];
7203
- const pop = (0, $49d5cbca2ec74b2f$export$428c2f647a2a7545)[parts[3]][parts[4]];
8761
+ if (parts[1] !== "merged") {
8762
+ const studyForSystem = (0, $49d5cbca2ec74b2f$export$428c2f647a2a7545)[parts[3]];
8763
+ const pop = studyForSystem && studyForSystem[parts[4]] || {
8764
+ label: `${parts[3]}/${parts[4]}`,
8765
+ type: parts[4]
8766
+ };
7204
8767
  const conseq = parts[1].replaceAll("_", " ");
7205
8768
  const status = parts[2] === "het" ? "heterozygous" : "homozygous";
7206
- const accInfo = {
7207
- key: [
7208
- pop.label,
7209
- conseq,
7210
- status
7211
- ].join('%%%'),
7212
- germplasm: germplasm,
7213
- pop: pop
7214
- };
7215
- accessionTable.push(accInfo);
7216
- });
8769
+ accessions.forEach((ens_id)=>{
8770
+ let germplasm;
8771
+ if (germplasmLUT && germplasmLUT.hasOwnProperty(ens_id)) germplasm = germplasmLUT[ens_id][0];
8772
+ else {
8773
+ // Fall back to a minimal record so the row is still rendered with
8774
+ // whatever info we have from the VEP fields alone.
8775
+ missingMetadata++;
8776
+ germplasm = {
8777
+ ens_id: ens_id,
8778
+ pub_id: ens_id,
8779
+ subpop: '?',
8780
+ stock_center: null,
8781
+ germplasm_dbid: null,
8782
+ pop_id: null
8783
+ };
8784
+ }
8785
+ accessionTable.push({
8786
+ key: [
8787
+ pop.label,
8788
+ conseq,
8789
+ status
8790
+ ].join('%%%'),
8791
+ germplasm: germplasm,
8792
+ pop: pop
8793
+ });
8794
+ });
8795
+ }
7217
8796
  }
7218
8797
  });
7219
8798
  // group accessionTable by key field
@@ -7247,6 +8826,8 @@ function $283508ffcf8a47c4$var$group_germplasm(gene, germplasmLUT, vep_obj) {
7247
8826
  });
7248
8827
  });
7249
8828
  });
8829
+ grouped.missingMetadata = missingMetadata;
8830
+ grouped.totalAccessions = accessionTable.length;
7250
8831
  return grouped;
7251
8832
  }
7252
8833
  const $283508ffcf8a47c4$var$THRESHOLD = 5;
@@ -7431,37 +9012,70 @@ const $283508ffcf8a47c4$var$GridWithGroups = ({ groups: groups, gene_id: gene_id
7431
9012
  };
7432
9013
  const $283508ffcf8a47c4$var$Detail = (props)=>{
7433
9014
  const gene = props.geneDocs[props.searchResult.id];
7434
- if (props.grameneConsequences && props.grameneConsequences[gene._id] && props.grameneGermplasm) {
7435
- const groups = $283508ffcf8a47c4$var$group_germplasm(gene, props.grameneGermplasm, props.grameneConsequences[gene._id]);
7436
- return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
7437
- children: [
7438
- /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("h5", {
7439
- children: "Predicted loss-of-function alleles were detected in these germplasm."
7440
- }),
7441
- /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
7442
- children: [
7443
- "Explore other variants within this gene in the ",
7444
- /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("a", {
7445
- target: "_blank",
7446
- href: `${props.configuration.ensemblURL}/${gene.system_name}/Gene/Variation_Gene/Image?db=core;g=${props.searchResult.id}`,
7447
- children: "Variant image"
7448
- }),
7449
- " page in the Ensembl genome browser."
7450
- ]
7451
- }),
7452
- /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)($283508ffcf8a47c4$var$GridWithGroups, {
7453
- groups: groups,
7454
- gene_id: gene._id,
7455
- doGrin: !props.configuration.hasOwnProperty('noGRIN')
7456
- })
7457
- ]
7458
- });
7459
- } else {
7460
- props.doRequestVEP(gene._id);
7461
- return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("pre", {
7462
- children: "loading"
7463
- });
7464
- }
9015
+ const haveConsequences = props.grameneConsequences && props.grameneConsequences[gene._id];
9016
+ (0, $gXNCa$react.useEffect)(()=>{
9017
+ if (!haveConsequences) props.doRequestVEP(gene._id);
9018
+ }, [
9019
+ gene._id,
9020
+ haveConsequences
9021
+ ]);
9022
+ if (!haveConsequences) return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("pre", {
9023
+ children: "loading"
9024
+ });
9025
+ // Render even if grameneGermplasm hasn't loaded — we'll fall back to
9026
+ // VEP-only rows so the user sees something instead of a silent empty table.
9027
+ const germplasmLUT = props.grameneGermplasm || {};
9028
+ const groups = $283508ffcf8a47c4$var$group_germplasm(gene, germplasmLUT, props.grameneConsequences[gene._id]);
9029
+ const { missingMetadata: missingMetadata, totalAccessions: totalAccessions } = groups;
9030
+ let notice = null;
9031
+ if (totalAccessions === 0) notice = /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("div", {
9032
+ className: "alert alert-warning",
9033
+ style: {
9034
+ padding: '8px',
9035
+ marginTop: '8px'
9036
+ },
9037
+ children: "VEP results were found for this gene but could not be grouped into accession-level rows."
9038
+ });
9039
+ else if (missingMetadata > 0) notice = /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
9040
+ className: "alert alert-info",
9041
+ style: {
9042
+ padding: '8px',
9043
+ marginTop: '8px'
9044
+ },
9045
+ children: [
9046
+ "Germplasm metadata could not be found for ",
9047
+ missingMetadata,
9048
+ " of ",
9049
+ totalAccessions,
9050
+ " accession",
9051
+ totalAccessions === 1 ? '' : 's',
9052
+ ". Affected rows show the raw accession id without stock-center links or subpopulation info."
9053
+ ]
9054
+ });
9055
+ return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
9056
+ children: [
9057
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("h5", {
9058
+ children: "Predicted loss-of-function alleles were detected in these germplasm."
9059
+ }),
9060
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
9061
+ children: [
9062
+ "Explore other variants within this gene in the ",
9063
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("a", {
9064
+ target: "_blank",
9065
+ href: `${props.configuration.ensemblURL}/${gene.system_name}/Gene/Variation_Gene/Image?db=core;g=${props.searchResult.id}`,
9066
+ children: "Variant image"
9067
+ }),
9068
+ " page in the Ensembl genome browser."
9069
+ ]
9070
+ }),
9071
+ notice,
9072
+ totalAccessions > 0 && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)($283508ffcf8a47c4$var$GridWithGroups, {
9073
+ groups: groups,
9074
+ gene_id: gene._id,
9075
+ doGrin: !props.configuration.hasOwnProperty('noGRIN')
9076
+ })
9077
+ ]
9078
+ });
7465
9079
  };
7466
9080
  var $283508ffcf8a47c4$export$2e2bcd8739ae039 = (0, $gXNCa$reduxbundlerreact.connect)('selectConfiguration', 'selectGrameneConsequences', 'selectGrameneGermplasm', 'doRequestVEP', $283508ffcf8a47c4$var$Detail);
7467
9081
 
@@ -7984,22 +9598,55 @@ const $527ebc19dc92444d$var$buildId = (gene, geneSeq, up, down)=>{
7984
9598
  return `${geneSeq.genome}|${gene._id}|${gene.location.region}:${gs}..${ge} ${extras.join('|')}`;
7985
9599
  };
7986
9600
  const $527ebc19dc92444d$var$Detail = (props)=>{
7987
- const gene = props.geneDocs[props.searchResult.id];
7988
- const [tab, setTab] = (0, $gXNCa$react.useState)('dna');
7989
- const [upstream, setUpstream] = (0, $gXNCa$react.useState)(0);
7990
- const [downstream, setDownstream] = (0, $gXNCa$react.useState)(0);
7991
- const [tid, setTid] = (0, $gXNCa$react.useState)(gene.gene_structure.canonical_transcript);
9601
+ const geneId = props.searchResult.id;
9602
+ const gene = props.geneDocs[geneId];
9603
+ // Sub-tab + selected isoform + flanking lengths live in the uiViewState
9604
+ // bundle (keyed by geneId) so the shareable-views snapshot can round-trip
9605
+ // them. Defaults match the old local-state initial values.
9606
+ const slice = props.uiViewState && props.uiViewState.byGene[geneId] && props.uiViewState.byGene[geneId].sequences || {};
9607
+ const tab = slice.tab || 'dna';
9608
+ const tid = slice.tid || gene.gene_structure.canonical_transcript;
9609
+ const upstream = slice.upstream || 0;
9610
+ const downstream = slice.downstream || 0;
9611
+ const setTab = (k)=>props.doSetSequencesState({
9612
+ geneId: geneId,
9613
+ patch: {
9614
+ tab: k
9615
+ }
9616
+ });
9617
+ const setTid = (v)=>props.doSetSequencesState({
9618
+ geneId: geneId,
9619
+ patch: {
9620
+ tid: v
9621
+ }
9622
+ });
9623
+ const setUpstream = (v)=>props.doSetSequencesState({
9624
+ geneId: geneId,
9625
+ patch: {
9626
+ upstream: +v
9627
+ }
9628
+ });
9629
+ const setDownstream = (v)=>props.doSetSequencesState({
9630
+ geneId: geneId,
9631
+ patch: {
9632
+ downstream: +v
9633
+ }
9634
+ });
7992
9635
  let geneSeq;
7993
9636
  let rnaSeq;
7994
9637
  let pepSeq;
7995
- if (props.geneSequences && props.geneSequences[gene._id]) geneSeq = props.geneSequences[gene._id];
9638
+ // The *_SEQUENCE_REQUESTED reducer inserts a `{}` placeholder before the
9639
+ // fetch resolves, so a key-only existence check passes while the body is
9640
+ // still empty — leading to `seq.substring` of undefined downstream. Gate
9641
+ // on the actual payload field instead.
9642
+ if (props.geneSequences && props.geneSequences[gene._id] && props.geneSequences[gene._id].seq) geneSeq = props.geneSequences[gene._id];
7996
9643
  else {
7997
9644
  props.doRequestGeneSequence(gene);
7998
9645
  return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("pre", {
7999
9646
  children: "loading"
8000
9647
  });
8001
9648
  }
8002
- if (props.rnaSequences && props.rnaSequences[tid]) rnaSeq = props.rnaSequences[tid];
9649
+ if (props.rnaSequences && props.rnaSequences[tid] && props.rnaSequences[tid].seq) rnaSeq = props.rnaSequences[tid];
8003
9650
  else {
8004
9651
  props.doRequestRnaSequence(tid, gene);
8005
9652
  return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("pre", {
@@ -8012,7 +9659,7 @@ const $527ebc19dc92444d$var$Detail = (props)=>{
8012
9659
  let tl_id;
8013
9660
  if (transcript.translation) {
8014
9661
  tl_id = transcript.translation.id;
8015
- if (props.pepSequences && props.pepSequences[tl_id]) pepSeq = props.pepSequences[tl_id];
9662
+ if (props.pepSequences && props.pepSequences[tl_id] && props.pepSequences[tl_id].seq) pepSeq = props.pepSequences[tl_id];
8016
9663
  else {
8017
9664
  props.doRequestPepSequence(tl_id, gene);
8018
9665
  return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("pre", {
@@ -8229,7 +9876,7 @@ const $527ebc19dc92444d$var$Detail = (props)=>{
8229
9876
  ]
8230
9877
  });
8231
9878
  };
8232
- var $527ebc19dc92444d$export$2e2bcd8739ae039 = (0, $gXNCa$reduxbundlerreact.connect)('selectConfiguration', 'selectGeneSequences', 'selectRnaSequences', 'selectPepSequences', 'doRequestGeneSequence', 'doRequestRnaSequence', 'doRequestPepSequence', $527ebc19dc92444d$var$Detail);
9879
+ var $527ebc19dc92444d$export$2e2bcd8739ae039 = (0, $gXNCa$reduxbundlerreact.connect)('selectConfiguration', 'selectGeneSequences', 'selectRnaSequences', 'selectPepSequences', 'selectUiViewState', 'doRequestGeneSequence', 'doRequestRnaSequence', 'doRequestPepSequence', 'doSetSequencesState', $527ebc19dc92444d$var$Detail);
8233
9880
 
8234
9881
 
8235
9882
 
@@ -8299,73 +9946,6 @@ const $6c5c4f90059875bf$var$PanLink = (props)=>{
8299
9946
  })
8300
9947
  });
8301
9948
  };
8302
- const $6c5c4f90059875bf$var$CurationComponent1 = ({ curation: curation })=>{
8303
- return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
8304
- className: "gene-curation",
8305
- children: [
8306
- /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("span", {
8307
- children: "Curated gene structure"
8308
- }),
8309
- curation.okay > 0 && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("span", {
8310
- className: "status",
8311
- children: [
8312
- "\u2714\uFE0F ",
8313
- curation.okay
8314
- ]
8315
- }),
8316
- curation.flagged > -10 && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("span", {
8317
- className: "status",
8318
- children: [
8319
- "\u26A0\uFE0F ",
8320
- curation.flagged
8321
- ]
8322
- })
8323
- ]
8324
- });
8325
- };
8326
- const $6c5c4f90059875bf$var$CurationComponent = ({ curation: curation, gene: gene })=>{
8327
- const total = curation.okay + curation.flagged;
8328
- const okayRatio = curation.okay / total;
8329
- const flaggedRatio = curation.flagged / total;
8330
- const tooltip = /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)((0, $gXNCa$reactbootstrap.Tooltip), {
8331
- id: "tooltip",
8332
- children: [
8333
- /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("strong", {
8334
- children: "Gene structure"
8335
- }),
8336
- /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("br", {}),
8337
- "Okay: ",
8338
- curation.okay,
8339
- " \xa0 Flagged: ",
8340
- curation.flagged
8341
- ]
8342
- });
8343
- return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.OverlayTrigger), {
8344
- placement: "left",
8345
- overlay: tooltip,
8346
- children: /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("a", {
8347
- className: "gene-curation",
8348
- target: "_blank",
8349
- href: `https://devdata.gramene.org/curation/curations?idList=${gene.id}`,
8350
- children: [
8351
- /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("span", {
8352
- className: "okay",
8353
- style: {
8354
- opacity: okayRatio
8355
- },
8356
- children: "\u2611"
8357
- }),
8358
- /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("span", {
8359
- className: "flagged",
8360
- style: {
8361
- opacity: flaggedRatio
8362
- },
8363
- children: "\u26A0"
8364
- })
8365
- ]
8366
- })
8367
- });
8368
- };
8369
9949
  const $6c5c4f90059875bf$var$ClosestOrthologCmp = (props)=>{
8370
9950
  let id, taxon_id, name, desc, species, className, identity;
8371
9951
  const gene = props.gene;
@@ -8486,12 +10066,12 @@ const $6c5c4f90059875bf$var$allDetails = [
8486
10066
  class $6c5c4f90059875bf$var$Gene extends (0, ($parcel$interopDefault($gXNCa$react))).Component {
8487
10067
  constructor(props){
8488
10068
  super(props);
10069
+ // `details` is derived per-render config (which tabs the site enables,
10070
+ // crossed with this row's `capabilities`) — keep it local.
8489
10071
  this.state = {
8490
10072
  details: $6c5c4f90059875bf$var$allDetails.map((o)=>({
8491
10073
  ...o
8492
- })).filter((d)=>props.config.details[d.id]),
8493
- expandedDetail: props.expandedDetail,
8494
- fullscreen: false
10074
+ })).filter((d)=>props.config.details[d.id])
8495
10075
  };
8496
10076
  let hasData = {};
8497
10077
  props.searchResult.capabilities.forEach((c)=>{
@@ -8500,28 +10080,46 @@ class $6c5c4f90059875bf$var$Gene extends (0, ($parcel$interopDefault($gXNCa$reac
8500
10080
  });
8501
10081
  this.state.details.forEach((d)=>d.available |= hasData.hasOwnProperty(d.id));
8502
10082
  }
10083
+ componentDidMount() {
10084
+ // Seed the auto-open-homology intent (from GeneList when numFound===1) the
10085
+ // first time this row mounts, only if the user hasn't already opened/closed
10086
+ // a tab here. Without this, the bundle would have no entry and the prior
10087
+ // "auto-expand homology on single-result" behavior would be lost.
10088
+ if (this.props.expandedDetail && this.expandedDetail === undefined) this.props.doExpandGeneDetail({
10089
+ geneId: this.props.searchResult.id,
10090
+ detail: this.props.expandedDetail
10091
+ });
10092
+ }
10093
+ get expandedDetail() {
10094
+ const slice = this.props.uiViewState && this.props.uiViewState.byGene[this.props.searchResult.id];
10095
+ return slice ? slice.expandedDetail : undefined;
10096
+ }
10097
+ get fullscreen() {
10098
+ const slice = this.props.uiViewState && this.props.uiViewState.byGene[this.props.searchResult.id];
10099
+ return !!(slice && slice.fullscreen);
10100
+ }
8503
10101
  getDetailStatus(d) {
8504
- if (this.state.expandedDetail === d.id) return 'expanded';
10102
+ if (this.expandedDetail === d.id) return 'expanded';
8505
10103
  if (d.available) return 'closed';
8506
10104
  return d.id === "pubs" ? 'empty' : 'disabled';
8507
10105
  }
8508
10106
  setExpanded(d) {
8509
10107
  if (d.available || d.id === "pubs") {
8510
- if (this.state.expandedDetail === d.id) this.setState({
8511
- expandedDetail: null,
8512
- fullscreen: false
10108
+ const geneId = this.props.searchResult.id;
10109
+ if (this.expandedDetail === d.id) this.props.doExpandGeneDetail({
10110
+ geneId: geneId,
10111
+ detail: null
8513
10112
  });
8514
10113
  else {
8515
- const geneId = this.props.searchResult.id;
8516
10114
  if (!(this.props.geneDocs && this.props.geneDocs.hasOwnProperty(geneId))) this.props.requestGene(geneId);
8517
10115
  (0, ($parcel$interopDefault($gXNCa$reactga4))).event({
8518
10116
  category: 'Search',
8519
10117
  action: 'Details',
8520
10118
  label: d.label
8521
10119
  });
8522
- this.setState({
8523
- expandedDetail: d.id,
8524
- fullscreen: false
10120
+ this.props.doExpandGeneDetail({
10121
+ geneId: geneId,
10122
+ detail: d.id
8525
10123
  });
8526
10124
  }
8527
10125
  }
@@ -8543,7 +10141,6 @@ class $6c5c4f90059875bf$var$Gene extends (0, ($parcel$interopDefault($gXNCa$reac
8543
10141
  const panSite = this.props.config.panSite;
8544
10142
  const searchResult = this.props.searchResult;
8545
10143
  const taxName = this.props.taxName;
8546
- const curation = this.props.curation;
8547
10144
  // let orthologs='';
8548
10145
  // if (this.props.orthologs && this.props.orthologs.hasOwnProperty(searchResult.id)) {
8549
10146
  // orthologs = this.props.orthologs[searchResult.id].join(', ');
@@ -8562,10 +10159,6 @@ class $6c5c4f90059875bf$var$Gene extends (0, ($parcel$interopDefault($gXNCa$reac
8562
10159
  pan: panSite[searchResult.system_name],
8563
10160
  gene: searchResult
8564
10161
  }),
8565
- curation && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)($6c5c4f90059875bf$var$CurationComponent, {
8566
- curation: curation,
8567
- gene: searchResult
8568
- }),
8569
10162
  /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
8570
10163
  className: "gene-title",
8571
10164
  children: [
@@ -8604,7 +10197,7 @@ class $6c5c4f90059875bf$var$Gene extends (0, ($parcel$interopDefault($gXNCa$reac
8604
10197
  /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("div", {
8605
10198
  className: "gene-detail-tabs",
8606
10199
  children: this.state.details.map((d, idx)=>{
8607
- const isExpanded = this.state.expandedDetail === d.id;
10200
+ const isExpanded = this.expandedDetail === d.id;
8608
10201
  return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.OverlayTrigger), {
8609
10202
  placement: 'bottom',
8610
10203
  overlay: /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.Tooltip), {
@@ -8630,7 +10223,8 @@ class $6c5c4f90059875bf$var$Gene extends (0, ($parcel$interopDefault($gXNCa$reac
8630
10223
  title: "View full screen",
8631
10224
  onClick: (e)=>{
8632
10225
  e.stopPropagation();
8633
- this.setState({
10226
+ this.props.doSetGeneFullscreen({
10227
+ geneId: searchResult.id,
8634
10228
  fullscreen: true
8635
10229
  });
8636
10230
  }
@@ -8640,19 +10234,24 @@ class $6c5c4f90059875bf$var$Gene extends (0, ($parcel$interopDefault($gXNCa$reac
8640
10234
  }, idx);
8641
10235
  })
8642
10236
  }),
8643
- this.state.expandedDetail && this.ensureGene(searchResult.id) && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $5c2c79352d3d7b81$export$ec6b54b688be1708), {
10237
+ this.expandedDetail && this.ensureGene(searchResult.id) && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $5c2c79352d3d7b81$export$ec6b54b688be1708), {
8644
10238
  className: "visible-detail",
8645
- fullscreen: this.state.fullscreen,
8646
- onExitFullscreen: ()=>this.setState({
10239
+ fullscreen: this.fullscreen,
10240
+ onExitFullscreen: ()=>this.props.doSetGeneFullscreen({
10241
+ geneId: searchResult.id,
8647
10242
  fullscreen: false
8648
10243
  }),
8649
- title: (this.state.details.find((d)=>d.id === this.state.expandedDetail) || {}).label,
8650
- children: /*#__PURE__*/ (0, ($parcel$interopDefault($gXNCa$react))).createElement($6c5c4f90059875bf$var$inventory[this.state.expandedDetail], this.props)
10244
+ title: (this.state.details.find((d)=>d.id === this.expandedDetail) || {}).label,
10245
+ children: /*#__PURE__*/ (0, ($parcel$interopDefault($gXNCa$react))).createElement($6c5c4f90059875bf$var$inventory[this.expandedDetail], this.props)
8651
10246
  })
8652
10247
  ]
8653
10248
  });
8654
10249
  }
8655
10250
  }
10251
+ // Wrap Gene so each row gets uiViewState + dispatchers. (We don't pull these
10252
+ // in at the GeneList level because the slice we care about is per-geneId; the
10253
+ // component-side getters read by row id.)
10254
+ const $6c5c4f90059875bf$var$ConnectedGene = (0, $gXNCa$reduxbundlerreact.connect)('selectUiViewState', 'doExpandGeneDetail', 'doSetGeneFullscreen', $6c5c4f90059875bf$var$Gene);
8656
10255
  const $6c5c4f90059875bf$var$GeneList = (props)=>{
8657
10256
  if (props.grameneSearch && props.grameneSearch.response && props.grameneTaxonomy) {
8658
10257
  let prev, page, next;
@@ -8694,7 +10293,7 @@ const $6c5c4f90059875bf$var$GeneList = (props)=>{
8694
10293
  next
8695
10294
  ]
8696
10295
  }),
8697
- props.grameneSearch.response.docs.map((g, idx)=>/*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)($6c5c4f90059875bf$var$Gene, {
10296
+ props.grameneSearch.response.docs.map((g, idx)=>/*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)($6c5c4f90059875bf$var$ConnectedGene, {
8698
10297
  searchResult: g,
8699
10298
  config: props.configuration,
8700
10299
  taxName: props.grameneTaxonomy[g.taxon_id].name,
@@ -8703,7 +10302,6 @@ const $6c5c4f90059875bf$var$GeneList = (props)=>{
8703
10302
  requestOrthologs: props.doRequestOrthologs,
8704
10303
  orthologs: props.grameneOrthologs,
8705
10304
  taxLut: props.grameneTaxonomy,
8706
- curation: props.curatedGenes ? props.curatedGenes[g.id] : null,
8707
10305
  expandedDetail: props.grameneSearch.response.numFound === 1 && g.can_show.homology ? 'homology' : null
8708
10306
  }, idx)),
8709
10307
  prev,
@@ -8714,7 +10312,7 @@ const $6c5c4f90059875bf$var$GeneList = (props)=>{
8714
10312
  }
8715
10313
  return null;
8716
10314
  };
8717
- var $6c5c4f90059875bf$export$2e2bcd8739ae039 = (0, $gXNCa$reduxbundlerreact.connect)('selectConfiguration', 'selectGrameneSearch', 'selectGrameneTaxonomy', 'selectGrameneGenes', 'selectGrameneOrthologs', 'selectGrameneSearchOffset', 'selectGrameneSearchRows', 'selectCuratedGenes', 'doRequestGrameneGene', 'doRequestOrthologs', 'doRequestResultsPage', $6c5c4f90059875bf$var$GeneList);
10315
+ var $6c5c4f90059875bf$export$2e2bcd8739ae039 = (0, $gXNCa$reduxbundlerreact.connect)('selectConfiguration', 'selectGrameneSearch', 'selectGrameneTaxonomy', 'selectGrameneGenes', 'selectGrameneOrthologs', 'selectGrameneSearchOffset', 'selectGrameneSearchRows', 'doRequestGrameneGene', 'doRequestOrthologs', 'doRequestResultsPage', $6c5c4f90059875bf$var$GeneList);
8718
10316
 
8719
10317
 
8720
10318
 
@@ -8849,6 +10447,15 @@ class $a67cad486021eb32$var$TaxDist extends (0, ($parcel$interopDefault($gXNCa$r
8849
10447
  if (this.state.showCompara && this.state.comparaOnly && this.props.grameneMaps) Object.keys(selectedTaxa).forEach((tid)=>{
8850
10448
  if (!this.props.grameneMaps[tid].in_compara) delete selectedTaxa[tid];
8851
10449
  });
10450
+ // Drop any tid the taxonomy tree doesn't know about — gramene-search-vis
10451
+ // throws when it encounters one (nodeIndex[tid].getPath() on undefined),
10452
+ // which silently leaves every branch expanded.
10453
+ if (this.props.grameneTaxDist && this.props.grameneTaxDist.indices && this.props.grameneTaxDist.indices.id) {
10454
+ const nodeIndex = this.props.grameneTaxDist.indices.id;
10455
+ Object.keys(selectedTaxa).forEach((tid)=>{
10456
+ if (!nodeIndex[tid]) delete selectedTaxa[tid];
10457
+ });
10458
+ }
8852
10459
  }
8853
10460
  return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
8854
10461
  className: "results-vis big-vis",
@@ -12900,12 +14507,19 @@ function $caf32827df861c4e$var$logTickFormat(v) {
12900
14507
  if (a >= 0.01 && a < 10000) return $gXNCa$d3.format('~g')(v);
12901
14508
  return $gXNCa$d3.format('.0e')(v);
12902
14509
  }
12903
- const $caf32827df861c4e$var$ParallelCoordsPlot = ({ rows: rows, fields: fields, scale: scale = 'linear', onBrushChange: onBrushChange, onReorder: onReorder, clearVersion: clearVersion = 0, hoveredId: hoveredId = null, axisLabels: axisLabels = null })=>{
14510
+ const $caf32827df861c4e$var$ParallelCoordsPlot = ({ rows: rows, fields: fields, scale: scale = 'linear', initialSelections: initialSelections = null, onBrushChange: onBrushChange, onReorder: onReorder, clearVersion: clearVersion = 0, hoveredId: hoveredId = null, axisLabels: axisLabels = null })=>{
12904
14511
  const svgRef = (0, $gXNCa$react.useRef)(null);
12905
14512
  const containerRef = (0, $gXNCa$react.useRef)(null);
12906
14513
  // selections in data domain: { [field]: [lo, hi] }
12907
14514
  const selectionsRef = (0, $gXNCa$react.useRef)({});
12908
14515
  const lastClearRef = (0, $gXNCa$react.useRef)(0);
14516
+ // Seed brushes from a restored saved view exactly once (the first render
14517
+ // where `initialSelections` is non-empty). Read via a ref so a change to the
14518
+ // prop — which happens on every brush as it round-trips through the store —
14519
+ // doesn't force an SVG rebuild; the deps below already rerun on data changes.
14520
+ const initialSelectionsRef = (0, $gXNCa$react.useRef)(initialSelections);
14521
+ initialSelectionsRef.current = initialSelections;
14522
+ const seededRef = (0, $gXNCa$react.useRef)(false);
12909
14523
  // Track container size so the d3 render reruns when the user drags the
12910
14524
  // pane resizer (or when the window is resized). The values themselves
12911
14525
  // aren't read inside the effect — the effect always reads clientWidth/
@@ -12936,6 +14550,15 @@ const $caf32827df861c4e$var$ParallelCoordsPlot = ({ rows: rows, fields: fields,
12936
14550
  return ()=>ro.disconnect();
12937
14551
  }, []);
12938
14552
  (0, $gXNCa$react.useEffect)(()=>{
14553
+ if (!seededRef.current) {
14554
+ const init = initialSelectionsRef.current;
14555
+ if (init && Object.keys(init).length) {
14556
+ selectionsRef.current = {
14557
+ ...init
14558
+ };
14559
+ seededRef.current = true;
14560
+ }
14561
+ }
12939
14562
  if (clearVersion !== lastClearRef.current) {
12940
14563
  selectionsRef.current = {};
12941
14564
  lastClearRef.current = clearVersion;
@@ -13803,7 +15426,10 @@ const $1fd2507769d5bd00$var$ExprVizViewCmp = (props)=>{
13803
15426
  onOpenFields: ()=>doToggleExprVizFieldsModal(tid, true),
13804
15427
  onLoad: ()=>doFetchExprVizData(tid),
13805
15428
  onReorder: (next)=>props.doReorderExprVizFields(tid, next),
13806
- onAddRangeQuery: props.doAddGrameneRangeQuery
15429
+ onAddRangeQuery: props.doAddGrameneRangeQuery,
15430
+ onSetVizMode: (m)=>props.doSetExprVizVizMode(tid, m),
15431
+ onSetScale: (s)=>props.doSetExprVizScale(tid, s),
15432
+ onSetBrushes: (b)=>props.doSetExprVizBrushes(tid, b)
13807
15433
  })
13808
15434
  }, tid);
13809
15435
  })
@@ -13963,16 +15589,21 @@ function $1fd2507769d5bd00$var$downloadTsv(filename, rows, fields, studies, expr
13963
15589
  document.body.removeChild(a);
13964
15590
  URL.revokeObjectURL(url);
13965
15591
  }
13966
- const $1fd2507769d5bd00$var$TaxonPanel = ({ taxon: taxon, studies: studies, expressionSamples: expressionSamples, tabState: tabState, onOpenFields: onOpenFields, onLoad: onLoad, onReorder: onReorder, onAddRangeQuery: onAddRangeQuery })=>{
15592
+ const $1fd2507769d5bd00$var$TaxonPanel = ({ taxon: taxon, studies: studies, expressionSamples: expressionSamples, tabState: tabState, onOpenFields: onOpenFields, onLoad: onLoad, onReorder: onReorder, onAddRangeQuery: onAddRangeQuery, onSetVizMode: onSetVizMode, onSetScale: onSetScale, onSetBrushes: onSetBrushes })=>{
13967
15593
  const selected = tabState && tabState.selectedFields || [];
13968
15594
  const rows = tabState && tabState.rows || [];
13969
15595
  const fetchInfo = tabState && tabState.fetch || {
13970
15596
  status: 'idle',
13971
15597
  total: 0
13972
15598
  };
13973
- const [scale, setScale] = (0, $gXNCa$react.useState)('linear');
13974
- const [vizMode, setVizMode] = (0, $gXNCa$react.useState)('heatmap'); // 'parallel' | 'heatmap'
13975
- const [selections, setSelections] = (0, $gXNCa$react.useState)({});
15599
+ // View config (sub-tab, scale, brushes) is persisted in the exprViz bundle
15600
+ // per taxon so a saved view can round-trip it. Driven controlled from here.
15601
+ const scale = tabState && tabState.scale || 'linear';
15602
+ const vizMode = tabState && tabState.vizMode || 'heatmap'; // 'parallel' | 'heatmap'
15603
+ const selections = tabState && tabState.brushes || {};
15604
+ const setScale = (s)=>onSetScale && onSetScale(s);
15605
+ const setVizMode = (m)=>onSetVizMode && onSetVizMode(m);
15606
+ const setSelections = (b)=>onSetBrushes && onSetBrushes(b);
13976
15607
  const [clearVersion, setClearVersion] = (0, $gXNCa$react.useState)(0);
13977
15608
  const [hoveredId, setHoveredId] = (0, $gXNCa$react.useState)(null);
13978
15609
  const [plotHeight, setPlotHeight] = (0, $gXNCa$react.useState)(320);
@@ -14048,15 +15679,6 @@ const $1fd2507769d5bd00$var$TaxonPanel = ({ taxon: taxon, studies: studies, expr
14048
15679
  studies,
14049
15680
  expressionSamples
14050
15681
  ]);
14051
- (0, $gXNCa$react.useEffect)(()=>{
14052
- if (rows.length === 0 && hasBrush) {
14053
- setSelections({});
14054
- setClearVersion((v)=>v + 1);
14055
- }
14056
- }, [
14057
- rows.length,
14058
- hasBrush
14059
- ]);
14060
15682
  return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
14061
15683
  className: "exprviz-tab-panel",
14062
15684
  children: [
@@ -14197,6 +15819,7 @@ const $1fd2507769d5bd00$var$TaxonPanel = ({ taxon: taxon, studies: studies, expr
14197
15819
  rows: rows,
14198
15820
  fields: visibleFields,
14199
15821
  scale: scale,
15822
+ initialSelections: selections,
14200
15823
  onBrushChange: setSelections,
14201
15824
  onReorder: handleReorder,
14202
15825
  clearVersion: clearVersion,
@@ -14229,7 +15852,7 @@ const $1fd2507769d5bd00$var$TaxonPanel = ({ taxon: taxon, studies: studies, expr
14229
15852
  ]
14230
15853
  });
14231
15854
  };
14232
- var $1fd2507769d5bd00$export$2e2bcd8739ae039 = (0, $gXNCa$reduxbundlerreact.connect)('selectExprViz', 'selectExprVizPivot', 'selectExprVizActiveTaxon', 'selectGrameneMaps', 'selectExpressionStudies', 'selectExpressionSamples', 'doSetExprVizActiveTaxon', 'doToggleExprVizFieldsModal', 'doFetchExprVizData', 'doReorderExprVizFields', 'doAddGrameneRangeQuery', $1fd2507769d5bd00$var$ExprVizViewCmp);
15855
+ var $1fd2507769d5bd00$export$2e2bcd8739ae039 = (0, $gXNCa$reduxbundlerreact.connect)('selectExprViz', 'selectExprVizPivot', 'selectExprVizActiveTaxon', 'selectGrameneMaps', 'selectExpressionStudies', 'selectExpressionSamples', 'doSetExprVizActiveTaxon', 'doToggleExprVizFieldsModal', 'doFetchExprVizData', 'doReorderExprVizFields', 'doSetExprVizVizMode', 'doSetExprVizScale', 'doSetExprVizBrushes', 'doAddGrameneRangeQuery', $1fd2507769d5bd00$var$ExprVizViewCmp);
14233
15856
 
14234
15857
 
14235
15858
 
@@ -14718,7 +16341,7 @@ const $597fe213417ee6ca$var$SortableTh = ({ label: label, sortKey: sortKey, acti
14718
16341
  }) : inner
14719
16342
  });
14720
16343
  };
14721
- const $597fe213417ee6ca$var$OntologySection = ({ block: block, search: search, onAddFilter: onAddFilter })=>{
16344
+ const $597fe213417ee6ca$var$OntologySection = ({ block: block, search: search, sort: sort, onSortChange: onSortChange, onAddFilter: onAddFilter })=>{
14722
16345
  const filtered = (0, $gXNCa$react.useMemo)(()=>{
14723
16346
  if (!search) return block.rows;
14724
16347
  const needle = search.toLowerCase();
@@ -14728,14 +16351,13 @@ const $597fe213417ee6ca$var$OntologySection = ({ block: block, search: search, o
14728
16351
  search
14729
16352
  ]);
14730
16353
  const showType = $597fe213417ee6ca$var$ONTS_WITH_TYPE_COLUMN.has(block.ontology);
14731
- const [sortKey, setSortKey] = (0, $gXNCa$react.useState)('pAdj');
14732
- const [sortDir, setSortDir] = (0, $gXNCa$react.useState)('asc');
16354
+ // Sort state is persisted per section in the bundle ui so a saved view
16355
+ // restores it. Defaults match the historical local-state initials.
16356
+ const sortKey = sort && sort.key || 'pAdj';
16357
+ const sortDir = sort && sort.dir || 'asc';
14733
16358
  const handleSort = (key)=>{
14734
- if (key === sortKey) setSortDir((d)=>d === 'asc' ? 'desc' : 'asc');
14735
- else {
14736
- setSortKey(key);
14737
- setSortDir($597fe213417ee6ca$var$SORT_DEFAULT_DIR[key] || 'asc');
14738
- }
16359
+ if (key === sortKey) onSortChange(block.ontology, sortKey, sortDir === 'asc' ? 'desc' : 'asc');
16360
+ else onSortChange(block.ontology, key, $597fe213417ee6ca$var$SORT_DEFAULT_DIR[key] || 'asc');
14739
16361
  };
14740
16362
  const sorted = (0, $gXNCa$react.useMemo)(()=>{
14741
16363
  const accessor = $597fe213417ee6ca$var$SORT_ACCESSORS[sortKey];
@@ -14932,6 +16554,17 @@ const $597fe213417ee6ca$var$TaxonPanel = ({ taxon: taxon, ontologyEnrichment: on
14932
16554
  ] : [];
14933
16555
  // Hide ontologies that aren't used in this species at all.
14934
16556
  const blocks = allBlocks.filter((b)=>b.tested > 0);
16557
+ const handleSortChange = (sectionKey, key, dir)=>{
16558
+ onUiChange({
16559
+ sort: {
16560
+ ...ui.sort || {},
16561
+ [sectionKey]: {
16562
+ key: key,
16563
+ dir: dir
16564
+ }
16565
+ }
16566
+ });
16567
+ };
14935
16568
  return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
14936
16569
  className: "oe-panel",
14937
16570
  children: [
@@ -14959,6 +16592,8 @@ const $597fe213417ee6ca$var$TaxonPanel = ({ taxon: taxon, ontologyEnrichment: on
14959
16592
  children: blocks.map((b)=>/*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)($597fe213417ee6ca$var$OntologySection, {
14960
16593
  block: b,
14961
16594
  search: ui.search,
16595
+ sort: ui.sort && ui.sort[b.ontology],
16596
+ onSortChange: handleSortChange,
14962
16597
  onAddFilter: onAddFilter
14963
16598
  }, b.ontology))
14964
16599
  })
@@ -15097,6 +16732,223 @@ var $597fe213417ee6ca$export$2e2bcd8739ae039 = (0, $gXNCa$reduxbundlerreact.conn
15097
16732
 
15098
16733
 
15099
16734
 
16735
+ // "Save this view" UI — sits inside the Auth sidebar panel.
16736
+ //
16737
+ // Sign-in-gated by Auth.js (we only mount this when `user` is truthy). Opens
16738
+ // a modal with label + description + visibility toggle; on save, swaps to a
16739
+ // post-save state that shows the shareable URL with a copy button.
16740
+ //
16741
+ // Talks to the savedViews bundle. The snapshot itself is built inside
16742
+ // doSaveView via selectViewSnapshot — this component doesn't know or care
16743
+ // what the snapshot looks like.
16744
+
16745
+
16746
+
16747
+
16748
+
16749
+ const $e63a3bee79bd1c7a$var$SaveViewButtonCmp = ({ user: user, savedViews: savedViews, doSaveView: doSaveView, doResetSavedViewState: doResetSavedViewState })=>{
16750
+ const [showModal, setShowModal] = (0, $gXNCa$react.useState)(false);
16751
+ const [label, setLabel] = (0, $gXNCa$react.useState)('');
16752
+ const [description, setDescription] = (0, $gXNCa$react.useState)('');
16753
+ const [isPublic, setIsPublic] = (0, $gXNCa$react.useState)(false);
16754
+ const [shareUrl, setShareUrl] = (0, $gXNCa$react.useState)(null);
16755
+ const [copied, setCopied] = (0, $gXNCa$react.useState)(false);
16756
+ const reset = (0, $gXNCa$react.useCallback)(()=>{
16757
+ setLabel('');
16758
+ setDescription('');
16759
+ setIsPublic(false);
16760
+ setShareUrl(null);
16761
+ setCopied(false);
16762
+ doResetSavedViewState();
16763
+ }, [
16764
+ doResetSavedViewState
16765
+ ]);
16766
+ const handleOpen = ()=>{
16767
+ reset();
16768
+ setShowModal(true);
16769
+ };
16770
+ const handleClose = ()=>{
16771
+ setShowModal(false);
16772
+ reset();
16773
+ };
16774
+ const handleSave = async (e)=>{
16775
+ e.preventDefault();
16776
+ if (!label.trim()) return;
16777
+ try {
16778
+ const { shareUrl: shareUrl } = await doSaveView({
16779
+ user: user,
16780
+ label: label.trim(),
16781
+ description: description.trim(),
16782
+ isPublic: isPublic
16783
+ });
16784
+ setShareUrl(shareUrl);
16785
+ } catch (_) {
16786
+ // savedViews.saveError is already set; modal renders it
16787
+ }
16788
+ };
16789
+ const handleCopy = async ()=>{
16790
+ if (!shareUrl) return;
16791
+ try {
16792
+ await navigator.clipboard.writeText(shareUrl);
16793
+ setCopied(true);
16794
+ setTimeout(()=>setCopied(false), 1500);
16795
+ } catch (_) {}
16796
+ };
16797
+ const saving = savedViews && savedViews.saving;
16798
+ const saveError = savedViews && savedViews.saveError;
16799
+ return /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)((0, $gXNCa$reactjsxruntime.Fragment), {
16800
+ children: [
16801
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.Button), {
16802
+ size: "sm",
16803
+ variant: "outline-primary",
16804
+ style: {
16805
+ marginTop: 8
16806
+ },
16807
+ onClick: handleOpen,
16808
+ title: "Save the current filters, views, and detail tabs as a shareable link",
16809
+ children: "Save this view"
16810
+ }),
16811
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)((0, $gXNCa$reactbootstrap.Modal), {
16812
+ show: showModal,
16813
+ onHide: handleClose,
16814
+ centered: true,
16815
+ children: [
16816
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.Modal).Header, {
16817
+ closeButton: true,
16818
+ children: /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.Modal).Title, {
16819
+ children: shareUrl ? 'View saved' : 'Save this view'
16820
+ })
16821
+ }),
16822
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)((0, $gXNCa$reactbootstrap.Modal).Body, {
16823
+ children: [
16824
+ !shareUrl && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)((0, $gXNCa$reactbootstrap.Form), {
16825
+ onSubmit: handleSave,
16826
+ children: [
16827
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)((0, $gXNCa$reactbootstrap.Form).Group, {
16828
+ className: "mb-3",
16829
+ children: [
16830
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.Form).Label, {
16831
+ children: "Name"
16832
+ }),
16833
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.Form).Control, {
16834
+ autoFocus: true,
16835
+ type: "text",
16836
+ placeholder: "e.g. TAIR loci with TF binding sites",
16837
+ value: label,
16838
+ onChange: (e)=>setLabel(e.target.value),
16839
+ disabled: saving,
16840
+ required: true
16841
+ })
16842
+ ]
16843
+ }),
16844
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)((0, $gXNCa$reactbootstrap.Form).Group, {
16845
+ className: "mb-3",
16846
+ children: [
16847
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.Form).Label, {
16848
+ children: "Description (optional)"
16849
+ }),
16850
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.Form).Control, {
16851
+ as: "textarea",
16852
+ rows: 2,
16853
+ value: description,
16854
+ onChange: (e)=>setDescription(e.target.value),
16855
+ disabled: saving
16856
+ })
16857
+ ]
16858
+ }),
16859
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.Form).Group, {
16860
+ className: "mb-3",
16861
+ children: /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.Form).Check, {
16862
+ type: "switch",
16863
+ id: "save-view-public",
16864
+ label: "Make this view public (visible to anyone with the link)",
16865
+ checked: isPublic,
16866
+ onChange: (e)=>setIsPublic(e.target.checked),
16867
+ disabled: saving
16868
+ })
16869
+ }),
16870
+ saveError && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.Alert), {
16871
+ variant: "danger",
16872
+ children: saveError
16873
+ }),
16874
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
16875
+ className: "d-flex justify-content-end gap-2",
16876
+ children: [
16877
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.Button), {
16878
+ variant: "secondary",
16879
+ onClick: handleClose,
16880
+ disabled: saving,
16881
+ children: "Cancel"
16882
+ }),
16883
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.Button), {
16884
+ type: "submit",
16885
+ variant: "primary",
16886
+ disabled: saving || !label.trim(),
16887
+ children: saving ? /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)((0, $gXNCa$reactjsxruntime.Fragment), {
16888
+ children: [
16889
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.Spinner), {
16890
+ as: "span",
16891
+ size: "sm",
16892
+ animation: "border"
16893
+ }),
16894
+ " Saving\u2026"
16895
+ ]
16896
+ }) : 'Save'
16897
+ })
16898
+ ]
16899
+ })
16900
+ ]
16901
+ }),
16902
+ shareUrl && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)((0, $gXNCa$reactjsxruntime.Fragment), {
16903
+ children: [
16904
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("p", {
16905
+ children: "Share this link to let others open the same search and detail state:"
16906
+ }),
16907
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)((0, $gXNCa$reactbootstrap.InputGroup), {
16908
+ children: [
16909
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.Form).Control, {
16910
+ type: "text",
16911
+ value: shareUrl,
16912
+ readOnly: true,
16913
+ onFocus: (e)=>e.target.select()
16914
+ }),
16915
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.Button), {
16916
+ variant: copied ? 'success' : 'outline-secondary',
16917
+ onClick: handleCopy,
16918
+ children: copied ? /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)((0, $gXNCa$reactjsxruntime.Fragment), {
16919
+ children: [
16920
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reacticonsbs.BsCheck2), {}),
16921
+ " Copied"
16922
+ ]
16923
+ }) : /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)((0, $gXNCa$reactjsxruntime.Fragment), {
16924
+ children: [
16925
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reacticonsbs.BsClipboard), {}),
16926
+ " Copy"
16927
+ ]
16928
+ })
16929
+ })
16930
+ ]
16931
+ }),
16932
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("div", {
16933
+ className: "mt-3 d-flex justify-content-end",
16934
+ children: /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.Button), {
16935
+ variant: "primary",
16936
+ onClick: handleClose,
16937
+ children: "Done"
16938
+ })
16939
+ })
16940
+ ]
16941
+ })
16942
+ ]
16943
+ })
16944
+ ]
16945
+ })
16946
+ ]
16947
+ });
16948
+ };
16949
+ var $e63a3bee79bd1c7a$export$2e2bcd8739ae039 = (0, $gXNCa$reduxbundlerreact.connect)('selectSavedViews', 'doSaveView', 'doResetSavedViewState', $e63a3bee79bd1c7a$var$SaveViewButtonCmp);
16950
+
16951
+
15100
16952
  const $c5d403787de8b05f$var$provider = new (0, $gXNCa$firebaseauth.GoogleAuthProvider)();
15101
16953
  const $c5d403787de8b05f$var$Auth = (props)=>{
15102
16954
  const firebaseConfig = props.configuration && props.configuration.firebaseConfig;
@@ -15106,10 +16958,25 @@ const $c5d403787de8b05f$var$Auth = (props)=>{
15106
16958
  }, [
15107
16959
  firebaseConfig
15108
16960
  ]);
15109
- const [user, setUser] = (0, $gXNCa$react.useState)({});
16961
+ const [user, setUser] = (0, $gXNCa$react.useState)(null);
15110
16962
  const [open, setOpen] = (0, $gXNCa$react.useState)(true);
16963
+ // Subscribe once, unsubscribe on unmount. On each auth-state emission,
16964
+ // also poke the shared-view boot path so a `?view=<hash>` for a private
16965
+ // view gets retried with the now-available Bearer token. bootViewFromUrl
16966
+ // no-ops when the param isn't present, so this is cheap.
16967
+ (0, $gXNCa$react.useEffect)(()=>{
16968
+ if (!auth) return undefined;
16969
+ const unsub = (0, $gXNCa$firebaseauth.onAuthStateChanged)(auth, (u)=>{
16970
+ setUser(u);
16971
+ props.doBootSharedView({
16972
+ user: u
16973
+ });
16974
+ });
16975
+ return unsub;
16976
+ }, [
16977
+ auth
16978
+ ]);
15111
16979
  if (!auth) return null;
15112
- (0, $gXNCa$firebaseauth.onAuthStateChanged)(auth, (user)=>setUser(user));
15113
16980
  function handleLogin() {
15114
16981
  (0, $gXNCa$firebaseauth.signInWithPopup)(auth, $c5d403787de8b05f$var$provider).then((result)=>{
15115
16982
  setUser(result.user);
@@ -15145,27 +17012,34 @@ const $c5d403787de8b05f$var$Auth = (props)=>{
15145
17012
  })
15146
17013
  ]
15147
17014
  }),
15148
- open && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("div", {
17015
+ open && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsxs)("div", {
15149
17016
  className: "sidebar-section-body",
15150
- children: /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("div", {
15151
- children: user ? /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.Button), {
15152
- size: "sm",
15153
- variant: "success",
15154
- onClick: handleLogout,
15155
- children: user.displayName
15156
- }) : /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.Button), {
15157
- size: "sm",
15158
- variant: "success",
15159
- onClick: handleLogin,
15160
- children: "Login"
17017
+ children: [
17018
+ /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("div", {
17019
+ children: user ? /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.Button), {
17020
+ size: "sm",
17021
+ variant: "success",
17022
+ onClick: handleLogout,
17023
+ children: user.displayName
17024
+ }) : /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $gXNCa$reactbootstrap.Button), {
17025
+ size: "sm",
17026
+ variant: "success",
17027
+ onClick: handleLogin,
17028
+ children: "Login"
17029
+ })
17030
+ }),
17031
+ user && user.uid && /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)("div", {
17032
+ children: /*#__PURE__*/ (0, $gXNCa$reactjsxruntime.jsx)((0, $e63a3bee79bd1c7a$export$2e2bcd8739ae039), {
17033
+ user: user
17034
+ })
15161
17035
  })
15162
- })
17036
+ ]
15163
17037
  })
15164
17038
  ]
15165
17039
  })
15166
17040
  });
15167
17041
  };
15168
- var $c5d403787de8b05f$export$2e2bcd8739ae039 = (0, $gXNCa$reduxbundlerreact.connect)('selectConfiguration', $c5d403787de8b05f$var$Auth);
17042
+ var $c5d403787de8b05f$export$2e2bcd8739ae039 = (0, $gXNCa$reduxbundlerreact.connect)('selectConfiguration', 'doBootSharedView', $c5d403787de8b05f$var$Auth);
15169
17043
 
15170
17044
 
15171
17045
 
@@ -15541,4 +17415,5 @@ const $693dd8c7a5607c3a$export$5cb791131c501f6a = (0, $gXNCa$reduxbundlerreact.c
15541
17415
 
15542
17416
 
15543
17417
 
17418
+
15544
17419
  //# sourceMappingURL=index.js.map