jbrowse-plugin-msaview 2.7.2 → 2.7.4

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 (64) hide show
  1. package/dist/AddHighlightModel/MsaToGenomeHighlight.js +6 -5
  2. package/dist/LaunchMsaView/components/LaunchMsaViewDialog.js +7 -1
  3. package/dist/LaunchMsaView/components/NCBIBlastQuery/CachedBlastResults.js +1 -1
  4. package/dist/LaunchMsaView/components/NCBIBlastQuery/useCachedBlastResults.d.ts +1 -1
  5. package/dist/LaunchMsaView/components/NCBIBlastQuery/useCachedBlastResults.js +5 -2
  6. package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.d.ts +8 -0
  7. package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +86 -0
  8. package/dist/LaunchMsaView/components/OrthologQuery/orthologLaunchView.d.ts +9 -0
  9. package/dist/LaunchMsaView/components/OrthologQuery/orthologLaunchView.js +13 -0
  10. package/dist/MsaViewPanel/afterCreateAutoruns.d.ts +8 -0
  11. package/dist/MsaViewPanel/afterCreateAutoruns.js +28 -0
  12. package/dist/MsaViewPanel/doLaunchBlast.js +10 -22
  13. package/dist/MsaViewPanel/doLaunchOrthologs.d.ts +23 -0
  14. package/dist/MsaViewPanel/doLaunchOrthologs.js +97 -0
  15. package/dist/MsaViewPanel/genomeToMSA.js +13 -6
  16. package/dist/MsaViewPanel/genomeToMSA.test.js +35 -6
  17. package/dist/MsaViewPanel/model.d.ts +88 -70
  18. package/dist/MsaViewPanel/model.js +26 -13
  19. package/dist/MsaViewPanel/msaCoordToGenomeCoord.d.ts +32 -13
  20. package/dist/MsaViewPanel/msaCoordToGenomeCoord.js +43 -14
  21. package/dist/MsaViewPanel/msaCoordToGenomeCoord.test.js +108 -18
  22. package/dist/MsaViewPanel/msaDataStore.js +8 -17
  23. package/dist/MsaViewPanel/syncGenomeHoverToMsaColumn.test.js +8 -6
  24. package/dist/jbrowse-plugin-msaview.umd.production.min.js +35 -31
  25. package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
  26. package/dist/utils/blastCache.d.ts +1 -2
  27. package/dist/utils/blastCache.js +9 -22
  28. package/dist/utils/domainCache.js +6 -15
  29. package/dist/utils/idb.d.ts +12 -0
  30. package/dist/utils/idb.js +21 -0
  31. package/dist/utils/ncbiOrthologs.d.ts +105 -0
  32. package/dist/utils/ncbiOrthologs.js +211 -0
  33. package/dist/utils/ncbiOrthologs.test.d.ts +1 -0
  34. package/dist/utils/ncbiOrthologs.test.js +41 -0
  35. package/dist/utils/taxonomyNames.js +13 -18
  36. package/dist/version.d.ts +1 -1
  37. package/dist/version.js +1 -1
  38. package/package.json +3 -3
  39. package/src/AddHighlightModel/MsaToGenomeHighlight.tsx +6 -8
  40. package/src/LaunchMsaView/components/LaunchMsaViewDialog.tsx +13 -2
  41. package/src/LaunchMsaView/components/NCBIBlastQuery/CachedBlastResults.tsx +1 -1
  42. package/src/LaunchMsaView/components/NCBIBlastQuery/useCachedBlastResults.ts +4 -2
  43. package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +172 -0
  44. package/src/LaunchMsaView/components/OrthologQuery/orthologLaunchView.ts +28 -0
  45. package/src/MsaViewPanel/afterCreateAutoruns.ts +27 -0
  46. package/src/MsaViewPanel/doLaunchBlast.ts +20 -29
  47. package/src/MsaViewPanel/doLaunchOrthologs.ts +123 -0
  48. package/src/MsaViewPanel/genomeToMSA.test.ts +38 -6
  49. package/src/MsaViewPanel/genomeToMSA.ts +14 -6
  50. package/src/MsaViewPanel/model.ts +41 -12
  51. package/src/MsaViewPanel/msaCoordToGenomeCoord.test.ts +117 -18
  52. package/src/MsaViewPanel/msaCoordToGenomeCoord.ts +70 -26
  53. package/src/MsaViewPanel/msaDataStore.ts +17 -17
  54. package/src/MsaViewPanel/syncGenomeHoverToMsaColumn.test.ts +8 -6
  55. package/src/utils/blastCache.ts +20 -23
  56. package/src/utils/domainCache.ts +14 -18
  57. package/src/utils/idb.ts +28 -0
  58. package/src/utils/ncbiOrthologs.test.ts +56 -0
  59. package/src/utils/ncbiOrthologs.ts +320 -0
  60. package/src/utils/taxonomyNames.ts +22 -24
  61. package/src/version.ts +1 -1
  62. package/dist/MsaViewPanel/blosum62.d.ts +0 -2
  63. package/dist/MsaViewPanel/blosum62.js +0 -627
  64. package/src/MsaViewPanel/blosum62.ts +0 -628
@@ -11,11 +11,12 @@ const MsaToGenomeHighlight = observer(function MsaToGenomeHighlight2({ model, })
11
11
  // The persistent click selection always shows. The hover codon is suppressed
12
12
  // while hovering the LGV — GenomeMouseoverHighlight handles the single-bp
13
13
  // display in that case, so we don't stack a wider codon band on top of it.
14
- const clickHighlight = msaView?.connectedClickHighlight;
15
- const hoverHighlight = hasHoverPosition(hovered)
16
- ? undefined
17
- : msaView?.connectedHoverHighlight;
18
- const highlights = [clickHighlight, hoverHighlight].filter((r) => r !== undefined);
14
+ const highlights = [
15
+ ...(msaView?.connectedClickHighlights ?? []),
16
+ ...(hasHoverPosition(hovered)
17
+ ? []
18
+ : (msaView?.connectedHoverHighlights ?? [])),
19
+ ];
19
20
  return highlights.length ? (React.createElement(MsaToGenomeHighlightRenderer, { model: model, highlights: highlights })) : null;
20
21
  });
21
22
  // Inner component: handles the scroll-dependent rendering
@@ -4,6 +4,7 @@ import { getSession } from '@jbrowse/core/util';
4
4
  import { Tab, Tabs } from '@mui/material';
5
5
  import ManualMSALoader from './ManualMSALoader/ManualMSALoader';
6
6
  import NCBIBlastPanel from './NCBIBlastQuery/NCBIBlastPanel';
7
+ import OrthologPanel from './OrthologQuery/OrthologPanel';
7
8
  import PreLoadedMSA from './PreLoadedMSA/PreLoadedMSADataPanel';
8
9
  import { readMsaDatasets } from './PreLoadedMSA/types';
9
10
  import TabPanel from './TabPanel';
@@ -11,14 +12,19 @@ export default function LaunchMsaViewDialog({ handleClose, feature, model, }) {
11
12
  const session = getSession(model);
12
13
  const datasets = readMsaDatasets(session.jbrowse);
13
14
  const hasPreloadedDatasets = !!datasets?.length;
14
- const [value, setValue] = useState('ncbi_blast');
15
+ // orthologs first, and the default: it answers the same question in ~10s
16
+ // that BLAST takes 10+ minutes to answer worse (see utils/ncbiOrthologs.ts)
17
+ const [value, setValue] = useState('orthologs');
15
18
  return (React.createElement(Dialog, { maxWidth: "xl", title: "Launch MSA view", open: true, onClose: handleClose },
16
19
  React.createElement(Tabs, { value: value, onChange: (_event, newValue) => {
17
20
  setValue(newValue);
18
21
  } },
22
+ React.createElement(Tab, { label: "Orthologs (fast)", value: "orthologs" }),
19
23
  React.createElement(Tab, { label: "NCBI BLAST query", value: "ncbi_blast" }),
20
24
  hasPreloadedDatasets ? (React.createElement(Tab, { label: "Pre-loaded MSA datasets", value: "preloaded_msa" })) : null,
21
25
  React.createElement(Tab, { label: "Manual upload", value: "manual_msa" })),
26
+ React.createElement(TabPanel, { value: value, index: "orthologs" },
27
+ React.createElement(OrthologPanel, { handleClose: handleClose, feature: feature, model: model })),
22
28
  React.createElement(TabPanel, { value: value, index: "ncbi_blast" },
23
29
  React.createElement(NCBIBlastPanel, { handleClose: handleClose, feature: feature, model: model })),
24
30
  hasPreloadedDatasets ? (React.createElement(TabPanel, { value: value, index: "preloaded_msa" },
@@ -68,7 +68,7 @@ const CachedBlastResults = observer(function ({ model, handleClose, feature, })
68
68
  catch (e) {
69
69
  setOperationError(e);
70
70
  }
71
- } }, "Clear All")),
71
+ } }, "Clear results for this gene")),
72
72
  React.createElement(List, { dense: true, className: classes.resultList }, results.map(result => (React.createElement(ListItem, { key: result.id, disablePadding: true, secondaryAction: React.createElement(IconButton, { edge: "end", size: "small", onClick: async (e) => {
73
73
  e.stopPropagation();
74
74
  try {
@@ -1,5 +1,5 @@
1
1
  export declare function useCachedBlastResults(geneIds: string[]): {
2
- results: any[];
2
+ results: import("../../../utils/blastCache").CachedBlastResult[];
3
3
  error: any;
4
4
  isLoading: boolean;
5
5
  handleDelete: (id: string) => Promise<void>;
@@ -1,5 +1,5 @@
1
1
  import useSWR from 'swr';
2
- import { clearAllCachedResults, deleteCachedResult, getAllCachedResults, } from '../../../utils/blastCache';
2
+ import { deleteCachedResult, getAllCachedResults, } from '../../../utils/blastCache';
3
3
  import { staticSwrConfig } from '../../../utils/swrConfig';
4
4
  export function useCachedBlastResults(geneIds) {
5
5
  const { data: results, error, isLoading, mutate, } = useSWR(`cached-blast-${geneIds.join(',')}`, async () => {
@@ -10,8 +10,11 @@ export function useCachedBlastResults(geneIds) {
10
10
  await deleteCachedResult(id);
11
11
  await mutate(results => results?.filter(result => result.id !== id) ?? [], false);
12
12
  };
13
+ // deletes only what this hook listed, i.e. the results for these gene ids.
14
+ // The list the user is looking at is gene-scoped, so a store-wide clear here
15
+ // would silently throw away every other gene's cached alignments too
13
16
  const handleClearAll = async () => {
14
- await clearAllCachedResults();
17
+ await Promise.all((results ?? []).map(r => deleteCachedResult(r.id)));
15
18
  await mutate([], false);
16
19
  };
17
20
  return {
@@ -0,0 +1,8 @@
1
+ import React from 'react';
2
+ import type { AbstractTrackModel, Feature } from '@jbrowse/core/util';
3
+ declare const OrthologPanel: ({ handleClose, feature, model, }: {
4
+ model: AbstractTrackModel;
5
+ feature: Feature;
6
+ handleClose: () => void;
7
+ }) => React.JSX.Element;
8
+ export default OrthologPanel;
@@ -0,0 +1,86 @@
1
+ import React, { useMemo, useState } from 'react';
2
+ import { Checkbox, FormControlLabel, MenuItem, Typography } from '@mui/material';
3
+ import { observer } from 'mobx-react';
4
+ import { makeStyles } from 'tss-react/mui';
5
+ import { orthologLaunchView } from './orthologLaunchView';
6
+ import TextField2 from '../../../components/TextField2';
7
+ import { COMMON_SPECIES } from '../../../utils/ncbiOrthologs';
8
+ import { getGeneDisplayName, getGeneIdentifiers, getLinearGenomeView, getTranscriptDisplayName, } from '../../util';
9
+ import LaunchPanelContent from '../LaunchPanelContent';
10
+ import MsaAlgorithmSelect from '../NCBIBlastQuery/MsaAlgorithmSelect';
11
+ import SubmitCancelActions from '../SubmitCancelActions';
12
+ import TranscriptSelector from '../TranscriptSelector';
13
+ import { useTranscriptSelection } from '../useTranscriptSelection';
14
+ const useStyles = makeStyles()({
15
+ selectField: {
16
+ width: 180,
17
+ },
18
+ speciesBox: {
19
+ display: 'flex',
20
+ flexWrap: 'wrap',
21
+ maxWidth: 560,
22
+ marginTop: 12,
23
+ },
24
+ species: {
25
+ width: 160,
26
+ },
27
+ infoText: {
28
+ marginTop: 20,
29
+ maxWidth: 620,
30
+ },
31
+ });
32
+ const OrthologPanel = observer(function ({ handleClose, feature, model, }) {
33
+ const { classes } = useStyles();
34
+ const view = getLinearGenomeView(model);
35
+ const [launchViewError, setLaunchViewError] = useState();
36
+ const [taxId, setTaxId] = useState(9606);
37
+ const [msaAlgorithm, setMsaAlgorithm] = useState('clustalo');
38
+ const [excluded, setExcluded] = useState([]);
39
+ const geneCandidates = useMemo(() => getGeneIdentifiers(feature), [feature]);
40
+ const transcriptSelection = useTranscriptSelection({ feature, view });
41
+ const { selectedTranscript, proteinSequence } = transcriptSelection;
42
+ const e = transcriptSelection.error ?? launchViewError;
43
+ const taxa = COMMON_SPECIES.map(s => s.taxId).filter(t => !excluded.includes(t));
44
+ return (React.createElement(React.Fragment, null,
45
+ React.createElement(LaunchPanelContent, { error: e },
46
+ React.createElement(Typography, null, "Builds the alignment from NCBI's precomputed orthologs \u2014 one gene per species \u2014 instead of searching. There is no job to wait on: the NCBI lookups take about a second, and only the multiple alignment at EBI costs real time (~10s), against 10+ minutes for BLAST. Rows come out labelled by species rather than by accession, and NCBI's CDD domains are overlaid automatically."),
47
+ React.createElement("div", null,
48
+ React.createElement(TextField2, { variant: "outlined", label: "Query species", className: classes.selectField, select: true, value: taxId, onChange: event => {
49
+ setTaxId(Number(event.target.value));
50
+ }, helperText: "the species this gene is from" }, COMMON_SPECIES.map(s => (React.createElement(MenuItem, { value: s.taxId, key: s.taxId }, s.label)))),
51
+ React.createElement(MsaAlgorithmSelect, { className: classes.selectField, value: msaAlgorithm, onChange: setMsaAlgorithm })),
52
+ React.createElement(Typography, { variant: "subtitle2", style: { marginTop: 12 } }, "Species to include (those without an ortholog are skipped)"),
53
+ React.createElement("div", { className: classes.speciesBox }, COMMON_SPECIES.map(s => (React.createElement(FormControlLabel, { className: classes.species, key: s.taxId, control: React.createElement(Checkbox, { checked: !excluded.includes(s.taxId), onChange: event => {
54
+ setExcluded(event.target.checked
55
+ ? excluded.filter(t => t !== s.taxId)
56
+ : [...excluded, s.taxId]);
57
+ } }), label: s.label })))),
58
+ React.createElement(TranscriptSelector, { feature: feature, ...transcriptSelection }),
59
+ React.createElement(Typography, { className: classes.infoText, variant: "body2" }, "The query row is the transcript selected above, not NCBI's representative protein, so the alignment stays linked to the genome view at codon resolution.")),
60
+ React.createElement(SubmitCancelActions, { submitDisabled: !proteinSequence || taxa.length < 2, onSubmit: () => {
61
+ try {
62
+ if (selectedTranscript) {
63
+ setLaunchViewError(undefined);
64
+ orthologLaunchView({
65
+ feature: selectedTranscript,
66
+ view,
67
+ newViewTitle: `Orthologs - ${getGeneDisplayName(feature)} - ${getTranscriptDisplayName(selectedTranscript)}`,
68
+ orthologParams: {
69
+ taxId,
70
+ taxa,
71
+ geneCandidates,
72
+ msaAlgorithm,
73
+ selectedTranscript,
74
+ proteinSequence,
75
+ },
76
+ });
77
+ handleClose();
78
+ }
79
+ }
80
+ catch (e) {
81
+ console.error(e);
82
+ setLaunchViewError(e);
83
+ }
84
+ }, onCancel: handleClose })));
85
+ });
86
+ export default OrthologPanel;
@@ -0,0 +1,9 @@
1
+ import type { OrthologParams } from '../../../MsaViewPanel/model';
2
+ import type { Feature } from '@jbrowse/core/util';
3
+ import type { LinearGenomeViewModel } from '@jbrowse/plugin-linear-genome-view';
4
+ export declare function orthologLaunchView({ newViewTitle, view, feature, orthologParams, }: {
5
+ newViewTitle: string;
6
+ view: LinearGenomeViewModel;
7
+ feature: Feature;
8
+ orthologParams: OrthologParams;
9
+ }): void;
@@ -0,0 +1,13 @@
1
+ import { getSession } from '@jbrowse/core/util';
2
+ export function orthologLaunchView({ newViewTitle, view, feature, orthologParams, }) {
3
+ getSession(view).addView('MsaView', {
4
+ type: 'MsaView',
5
+ displayName: newViewTitle,
6
+ connectedViewId: view.id,
7
+ connectedFeature: feature.toJSON(),
8
+ drawNodeBubbles: true,
9
+ colWidth: 10,
10
+ rowHeight: 12,
11
+ orthologParams,
12
+ });
13
+ }
@@ -1,6 +1,14 @@
1
1
  import type { JBrowsePluginMsaViewModel } from './model';
2
2
  export declare function loadStoredData(self: JBrowsePluginMsaViewModel): void;
3
3
  export declare function storeDataToIndexedDB(self: JBrowsePluginMsaViewModel): void;
4
+ /**
5
+ * Same shape as launchBlastIfNeeded, for the ortholog path: the params ARE the
6
+ * request, and clearing them on success is what marks it done. They are left in
7
+ * place on failure so the error stays attributable to a specific request; the
8
+ * autorun's only tracked read is orthologParams itself, so nothing refires
9
+ * until a new request replaces them.
10
+ */
11
+ export declare function launchOrthologsIfNeeded(self: JBrowsePluginMsaViewModel): void;
4
12
  export declare function launchBlastIfNeeded(self: JBrowsePluginMsaViewModel): void;
5
13
  /**
6
14
  * Once an accession-bearing alignment is present (fresh from BLAST or restored
@@ -1,5 +1,6 @@
1
1
  import { getSession } from '@jbrowse/core/util';
2
2
  import { doLaunchBlast } from './doLaunchBlast';
3
+ import { doLaunchOrthologs } from './doLaunchOrthologs';
3
4
  import { fetchIndexedMsa } from './fetchIndexedMsa';
4
5
  import { genomeToMSA } from './genomeToMSA';
5
6
  import { loadProteinDomains } from './loadProteinDomains';
@@ -69,6 +70,33 @@ export function storeDataToIndexedDB(self) {
69
70
  }
70
71
  }
71
72
  }
73
+ /**
74
+ * Same shape as launchBlastIfNeeded, for the ortholog path: the params ARE the
75
+ * request, and clearing them on success is what marks it done. They are left in
76
+ * place on failure so the error stays attributable to a specific request; the
77
+ * autorun's only tracked read is orthologParams itself, so nothing refires
78
+ * until a new request replaces them.
79
+ */
80
+ export function launchOrthologsIfNeeded(self) {
81
+ if (self.orthologParams) {
82
+ void (async () => {
83
+ try {
84
+ self.setProgress('Resolving orthologs');
85
+ self.setError(undefined);
86
+ const data = await doLaunchOrthologs({ self });
87
+ self.setData(data);
88
+ self.setOrthologParams(undefined);
89
+ }
90
+ catch (e) {
91
+ self.setError(e);
92
+ console.error(e);
93
+ }
94
+ finally {
95
+ self.setProgress('');
96
+ }
97
+ })();
98
+ }
99
+ }
72
100
  export function launchBlastIfNeeded(self) {
73
101
  if (self.blastParams) {
74
102
  void (async () => {
@@ -7,36 +7,26 @@ import { fetchTaxonomyInfo } from '../utils/taxonomyNames';
7
7
  export async function doLaunchBlast({ self, }) {
8
8
  const { baseUrl, blastDatabase, blastProgram, msaAlgorithm, proteinSequence, selectedTranscript, rid: existingRid, } = self.blastParams;
9
9
  const cleanedSeq = cleanProteinSequence(proteinSequence);
10
- let hits;
11
- let rid;
10
+ const onProgress = (arg) => {
11
+ self.setProgress(arg);
12
+ };
12
13
  if (existingRid) {
14
+ // publish it before the first poll so the view can link out to NCBI while
15
+ // the job is still running
13
16
  self.setRid(existingRid);
14
- const result = await queryBlastFromRid({
15
- rid: existingRid,
16
- baseUrl,
17
- onProgress: arg => {
18
- self.setProgress(arg);
19
- },
20
- });
21
- hits = result.hits;
22
- rid = result.rid;
23
17
  }
24
- else {
25
- const result = await queryBlast({
18
+ const { hits, rid } = existingRid
19
+ ? await queryBlastFromRid({ rid: existingRid, baseUrl, onProgress })
20
+ : await queryBlast({
26
21
  query: cleanedSeq,
27
22
  blastDatabase,
28
23
  blastProgram,
29
24
  baseUrl,
30
- onProgress: arg => {
31
- self.setProgress(arg);
32
- },
25
+ onProgress,
33
26
  onRid: r => {
34
27
  self.setRid(r);
35
28
  },
36
29
  });
37
- hits = result.hits;
38
- rid = result.rid;
39
- }
40
30
  self.setProgress('Fetching species taxonomy info...');
41
31
  const taxids = hits
42
32
  .map(h => h.description[0]?.taxid)
@@ -57,9 +47,7 @@ export async function doLaunchBlast({ self, }) {
57
47
  const result = await launchMSA({
58
48
  algorithm: msaAlgorithm,
59
49
  sequence: [`>QUERY\n${cleanedSeq}`, ...sequences].join('\n'),
60
- onProgress: arg => {
61
- self.setProgress(arg);
62
- },
50
+ onProgress,
63
51
  });
64
52
  const treeMetadataJson = JSON.stringify(treeMetadata);
65
53
  await saveBlastResult({
@@ -0,0 +1,23 @@
1
+ import type { JBrowsePluginMsaViewModel } from './model';
2
+ /**
3
+ * The no-search-job alternative to doLaunchBlast.
4
+ *
5
+ * BLAST spends 10+ minutes answering "what looks like this sequence" and
6
+ * returns a redundant, accession-labelled hit list. This asks NCBI the question
7
+ * the alignment actually wants — "what is this gene's ortholog in each species"
8
+ * — which NCBI has already computed, so the whole NCBI half returns in about a
9
+ * second and only the EBI alignment (~10s) costs real time.
10
+ *
11
+ * The query row is the user's OWN selected transcript, not NCBI's
12
+ * representative protein for the query species, because `connectedFeature`
13
+ * maps genome coordinates through that row — swapping in a different isoform
14
+ * would silently break the genome<->MSA linkage. The query species is therefore
15
+ * excluded from the ortholog set rather than appearing twice.
16
+ */
17
+ export declare function doLaunchOrthologs({ self, }: {
18
+ self: JBrowsePluginMsaViewModel;
19
+ }): Promise<{
20
+ treeMetadata: string;
21
+ msa: string;
22
+ tree: string;
23
+ }>;
@@ -0,0 +1,97 @@
1
+ import { cleanProteinSequence } from '../LaunchMsaView/util';
2
+ import { launchMSA } from '../utils/msa';
3
+ import { fetchOrthologRows, fetchProteinForGene, resolveGeneId, } from '../utils/ncbiOrthologs';
4
+ /**
5
+ * The no-search-job alternative to doLaunchBlast.
6
+ *
7
+ * BLAST spends 10+ minutes answering "what looks like this sequence" and
8
+ * returns a redundant, accession-labelled hit list. This asks NCBI the question
9
+ * the alignment actually wants — "what is this gene's ortholog in each species"
10
+ * — which NCBI has already computed, so the whole NCBI half returns in about a
11
+ * second and only the EBI alignment (~10s) costs real time.
12
+ *
13
+ * The query row is the user's OWN selected transcript, not NCBI's
14
+ * representative protein for the query species, because `connectedFeature`
15
+ * maps genome coordinates through that row — swapping in a different isoform
16
+ * would silently break the genome<->MSA linkage. The query species is therefore
17
+ * excluded from the ortholog set rather than appearing twice.
18
+ */
19
+ export async function doLaunchOrthologs({ self, }) {
20
+ const { taxId, taxa, geneCandidates, msaAlgorithm, proteinSequence } = self.orthologParams;
21
+ const cleanedSeq = cleanProteinSequence(proteinSequence);
22
+ const onProgress = (arg) => {
23
+ self.setProgress(arg);
24
+ };
25
+ onProgress('Resolving gene at NCBI...');
26
+ const resolved = await resolveGeneId(geneCandidates, taxId);
27
+ if (!resolved) {
28
+ throw new Error(`Could not resolve any of ${geneCandidates.join(', ')} to an NCBI gene in taxon ${taxId}. Try the NCBI BLAST tab, which needs no gene identifier.`);
29
+ }
30
+ // the query species is represented by the user's own transcript below
31
+ const wanted = new Set(taxa.filter(t => t !== taxId));
32
+ const rows = await fetchOrthologRows({
33
+ geneId: resolved.geneId,
34
+ taxa: wanted,
35
+ onProgress,
36
+ });
37
+ const treeMetadata = {
38
+ QUERY: await buildQueryMetadata(self, resolved.geneId, cleanedSeq),
39
+ };
40
+ for (const row of rows) {
41
+ treeMetadata[row.label] = buildRowMetadata(row);
42
+ }
43
+ const result = await launchMSA({
44
+ algorithm: msaAlgorithm,
45
+ sequence: [
46
+ `>QUERY\n${cleanedSeq}`,
47
+ ...rows.map(r => `>${r.label}\n${r.sequence}`),
48
+ ].join('\n'),
49
+ onProgress,
50
+ });
51
+ return {
52
+ ...result,
53
+ treeMetadata: JSON.stringify(treeMetadata),
54
+ };
55
+ }
56
+ /**
57
+ * The query row is the user's own translated transcript, so it carries an
58
+ * Accession — which is what drives the automatic CDD overlay
59
+ * (afterCreateAutoruns.autoLoadProteinDomains -> loadProteinDomains) — ONLY
60
+ * when its sequence is byte-identical to the RefSeq protein that accession
61
+ * names. Attaching it unconditionally would put every domain box at an offset
62
+ * whenever the user picked a non-representative isoform, which is a silently
63
+ * wrong figure rather than a missing one.
64
+ */
65
+ async function buildQueryMetadata(self, geneId, proteinSequence) {
66
+ const transcript = self.orthologParams?.selectedTranscript;
67
+ const metadata = { 'Gene ID': geneId };
68
+ const name = transcript?.get('name') ?? transcript?.get('id');
69
+ if (name) {
70
+ metadata.Transcript = name;
71
+ }
72
+ try {
73
+ const representative = await fetchProteinForGene(geneId);
74
+ if (representative?.sequence === proteinSequence) {
75
+ metadata.Accession = representative.accession;
76
+ }
77
+ }
78
+ catch (e) {
79
+ // a failed lookup only costs the query row its domain overlay, so it must
80
+ // not take down an alignment that is otherwise complete
81
+ console.warn('[msaview-orthologs] query protein lookup failed:', e);
82
+ }
83
+ return metadata;
84
+ }
85
+ function buildRowMetadata(row) {
86
+ const metadata = {
87
+ 'Scientific name': row.scientificName,
88
+ // Accession drives the automatic CDD domain overlay
89
+ // (afterCreateAutoruns.autoLoadProteinDomains -> loadProteinDomains)
90
+ Accession: row.protein,
91
+ 'Gene ID': row.geneId,
92
+ };
93
+ if (row.commonName) {
94
+ metadata['Common name'] = row.commonName;
95
+ }
96
+ return metadata;
97
+ }
@@ -6,18 +6,25 @@ export function genomeToMSA({ model }) {
6
6
  if (!connectedView?.initialized || !hasHoverPosition(hovered)) {
7
7
  return undefined;
8
8
  }
9
- const { coord: hoverCoord, refName } = hovered.hoverPosition;
9
+ const { coord, refName } = hovered.hoverPosition;
10
+ // hoverPosition.coord is a 1-based display coordinate (core's pxToBp adds the
11
+ // +1), while g2p and mafRegion are keyed by 0-based genome position
12
+ const genomePos = coord - 1;
10
13
  if (mafRegion) {
11
14
  if (refName !== mafRegion.refName ||
12
15
  !connectedView.assemblyNames.includes(mafRegion.assemblyName) ||
13
- hoverCoord < mafRegion.start ||
14
- hoverCoord >= mafRegion.end) {
16
+ genomePos < mafRegion.start ||
17
+ genomePos >= mafRegion.end) {
15
18
  return undefined;
16
19
  }
17
- return model.seqPosToVisibleCol(querySeqName, hoverCoord - mafRegion.start);
20
+ return model.seqPosToVisibleCol(querySeqName, genomePos - mafRegion.start);
18
21
  }
19
- if (transcriptToMsaMap) {
20
- const seqPos = transcriptToMsaMap.g2p[hoverCoord];
22
+ // session.hovered is global -- set by whichever LinearGenomeView the cursor
23
+ // was last over, on any assembly -- so the refName gate is load bearing:
24
+ // without it the same numeric coordinate on an unrelated chromosome matches a
25
+ // g2p key and lights up a column for a different locus
26
+ if (refName === transcriptToMsaMap?.refName) {
27
+ const seqPos = transcriptToMsaMap.g2p[genomePos];
21
28
  if (seqPos !== undefined) {
22
29
  return model.seqPosToVisibleCol(querySeqName, seqPos);
23
30
  }
@@ -76,8 +76,9 @@ describe('genomeToMSA', () => {
76
76
  seqPosToVisibleCol: mockSeqPosToVisibleCol,
77
77
  };
78
78
  const result = genomeToMSA({ model });
79
- // coord 1005 - start 1000 = ungapped position 5
80
- expect(mockSeqPosToVisibleCol).toHaveBeenCalledWith('hg38.chr1', 5);
79
+ // hover coord 1005 is 1-based, so the 0-based genome position is 1004,
80
+ // which is ungapped position 4 of a region starting at 1000
81
+ expect(mockSeqPosToVisibleCol).toHaveBeenCalledWith('hg38.chr1', 4);
81
82
  expect(result).toBe(5);
82
83
  });
83
84
  test('returns undefined when hover refName does not match mafRegion', () => {
@@ -106,10 +107,11 @@ describe('genomeToMSA', () => {
106
107
  expect(result).toBeUndefined();
107
108
  });
108
109
  test('returns undefined when hover coord is before mafRegion start', () => {
110
+ // 1-based coord 1000 is the 0-based base 999, one before the region
109
111
  mockGetSession.mockReturnValue({
110
112
  hovered: {
111
113
  hoverFeature: {},
112
- hoverPosition: { coord: 999, refName: 'chr1' },
114
+ hoverPosition: { coord: 1000, refName: 'chr1' },
113
115
  },
114
116
  });
115
117
  const model = {
@@ -131,10 +133,11 @@ describe('genomeToMSA', () => {
131
133
  expect(result).toBeUndefined();
132
134
  });
133
135
  test('returns undefined when hover coord is at or after mafRegion end', () => {
136
+ // 1-based coord 1011 is the 0-based base 1010, one past the region
134
137
  mockGetSession.mockReturnValue({
135
138
  hovered: {
136
139
  hoverFeature: {},
137
- hoverPosition: { coord: 1010, refName: 'chr1' },
140
+ hoverPosition: { coord: 1011, refName: 'chr1' },
138
141
  },
139
142
  });
140
143
  const model = {
@@ -193,7 +196,9 @@ describe('genomeToMSA', () => {
193
196
  const model = {
194
197
  querySeqName: 'QUERY',
195
198
  transcriptToMsaMap: {
196
- g2p: { 1005: 10 },
199
+ refName: 'chr1',
200
+ // g2p is keyed by 0-based genome position, the hover coord is 1-based
201
+ g2p: { 1004: 10 },
197
202
  },
198
203
  mafRegion: undefined,
199
204
  connectedView: { initialized: true },
@@ -203,6 +208,29 @@ describe('genomeToMSA', () => {
203
208
  expect(mockSeqPosToVisibleCol).toHaveBeenCalledWith('QUERY', 10);
204
209
  expect(result).toBe(10);
205
210
  });
211
+ test('returns undefined when the hover is on another refName', () => {
212
+ // session.hovered is global, so a hover on an unrelated chromosome can
213
+ // carry a coordinate that happens to be a g2p key
214
+ mockGetSession.mockReturnValue({
215
+ hovered: {
216
+ hoverFeature: {},
217
+ hoverPosition: { coord: 1005, refName: 'chr2' },
218
+ },
219
+ });
220
+ const mockSeqPosToVisibleCol = vi.fn();
221
+ const model = {
222
+ querySeqName: 'QUERY',
223
+ transcriptToMsaMap: {
224
+ refName: 'chr1',
225
+ g2p: { 1004: 10 },
226
+ },
227
+ mafRegion: undefined,
228
+ connectedView: { initialized: true },
229
+ seqPosToVisibleCol: mockSeqPosToVisibleCol,
230
+ };
231
+ expect(genomeToMSA({ model })).toBeUndefined();
232
+ expect(mockSeqPosToVisibleCol).not.toHaveBeenCalled();
233
+ });
206
234
  test('returns undefined when g2p has no mapping for coord', () => {
207
235
  mockGetSession.mockReturnValue({
208
236
  hovered: {
@@ -213,7 +241,8 @@ describe('genomeToMSA', () => {
213
241
  const model = {
214
242
  querySeqName: 'QUERY',
215
243
  transcriptToMsaMap: {
216
- g2p: { 1000: 0 }, // No entry for 1005
244
+ refName: 'chr1',
245
+ g2p: { 1000: 0 }, // No entry for 1004
217
246
  },
218
247
  mafRegion: undefined,
219
248
  connectedView: { initialized: true },