jbrowse-plugin-msaview 3.1.0 → 3.3.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 (57) hide show
  1. package/dist/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.js +1 -1
  2. package/dist/LaunchMsaView/components/BlastQuery/BlastManualPanel.js +1 -1
  3. package/dist/LaunchMsaView/components/BlastQuery/blastLaunchView.js +6 -4
  4. package/dist/LaunchMsaView/components/ManualMSALoader/ManualMSALoader.js +1 -1
  5. package/dist/LaunchMsaView/components/ManualMSALoader/launchView.js +4 -2
  6. package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +8 -3
  7. package/dist/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.d.ts +9 -0
  8. package/dist/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.js +20 -0
  9. package/dist/LaunchMsaView/components/OrthologQuery/orthologLaunchView.js +4 -2
  10. package/dist/LaunchMsaView/components/PreLoadedMSA/PreLoadedMSADataPanel.js +1 -1
  11. package/dist/LaunchMsaView/components/PreLoadedMSA/preCalculatedLaunchView.js +4 -2
  12. package/dist/LaunchMsaView/components/SubmitCancelActions.d.ts +4 -1
  13. package/dist/LaunchMsaView/components/SubmitCancelActions.js +34 -10
  14. package/dist/LaunchMsaView/components/SubmitCancelActions.test.d.ts +1 -0
  15. package/dist/LaunchMsaView/components/SubmitCancelActions.test.js +55 -0
  16. package/dist/LaunchMsaViewExtensionPoint/index.js +2 -2
  17. package/dist/MsaViewPanel/doLaunchOrthologs.d.ts +7 -5
  18. package/dist/MsaViewPanel/doLaunchOrthologs.js +64 -30
  19. package/dist/MsaViewPanel/doLaunchOrthologs.test.js +105 -0
  20. package/dist/MsaViewPanel/model.d.ts +9 -0
  21. package/dist/MsaViewPanel/model.js +6 -0
  22. package/dist/index.js +4 -1
  23. package/dist/jbrowse-plugin-msaview.umd.production.min.js +30 -30
  24. package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
  25. package/dist/utils/launchMsaView.d.ts +19 -0
  26. package/dist/utils/launchMsaView.js +13 -0
  27. package/dist/utils/pantherOrthologs.d.ts +79 -0
  28. package/dist/utils/pantherOrthologs.js +262 -0
  29. package/dist/utils/workspaces.d.ts +34 -0
  30. package/dist/utils/workspaces.js +100 -0
  31. package/dist/utils/workspaces.test.d.ts +1 -0
  32. package/dist/utils/workspaces.test.js +100 -0
  33. package/dist/version.d.ts +1 -1
  34. package/dist/version.js +1 -1
  35. package/package.json +4 -1
  36. package/src/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.tsx +1 -0
  37. package/src/LaunchMsaView/components/BlastQuery/BlastManualPanel.tsx +1 -0
  38. package/src/LaunchMsaView/components/BlastQuery/blastLaunchView.ts +7 -4
  39. package/src/LaunchMsaView/components/ManualMSALoader/ManualMSALoader.tsx +1 -0
  40. package/src/LaunchMsaView/components/ManualMSALoader/launchView.ts +5 -2
  41. package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +20 -4
  42. package/src/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.tsx +52 -0
  43. package/src/LaunchMsaView/components/OrthologQuery/orthologLaunchView.ts +5 -2
  44. package/src/LaunchMsaView/components/PreLoadedMSA/PreLoadedMSADataPanel.tsx +1 -0
  45. package/src/LaunchMsaView/components/PreLoadedMSA/preCalculatedLaunchView.ts +5 -2
  46. package/src/LaunchMsaView/components/SubmitCancelActions.test.tsx +95 -0
  47. package/src/LaunchMsaView/components/SubmitCancelActions.tsx +80 -22
  48. package/src/LaunchMsaViewExtensionPoint/index.ts +21 -2
  49. package/src/MsaViewPanel/doLaunchOrthologs.test.ts +117 -0
  50. package/src/MsaViewPanel/doLaunchOrthologs.ts +99 -37
  51. package/src/MsaViewPanel/model.ts +10 -0
  52. package/src/index.ts +4 -1
  53. package/src/utils/launchMsaView.ts +30 -0
  54. package/src/utils/pantherOrthologs.ts +399 -0
  55. package/src/utils/workspaces.test.ts +132 -0
  56. package/src/utils/workspaces.ts +146 -0
  57. package/src/version.ts +1 -1
@@ -50,7 +50,7 @@ const BlastAutomaticPanel = observer(function ({ handleClose, feature, model, ch
50
50
  React.createElement(Typography, null, "Previous BLAST Results")),
51
51
  React.createElement(AccordionDetails, null,
52
52
  React.createElement(CachedBlastResults, { model: model, handleClose: handleClose, feature: feature })))) : null),
53
- React.createElement(SubmitCancelActions, { submitDisabled: !proteinSequence, onSubmit: () => {
53
+ React.createElement(SubmitCancelActions, { model: model, submitDisabled: !proteinSequence, onSubmit: () => {
54
54
  try {
55
55
  if (selectedTranscript) {
56
56
  setLaunchViewError(undefined);
@@ -79,7 +79,7 @@ const BlastManualPanel = observer(function ({ handleClose, feature, model, child
79
79
  setTreeText(event.target.value);
80
80
  } }),
81
81
  React.createElement(QueryRowSelector, { ...queryRow })))),
82
- React.createElement(SubmitCancelActions, { submitDisabled: !selectedTranscript || !msaText.trim(), onSubmit: () => {
82
+ React.createElement(SubmitCancelActions, { model: model, submitDisabled: !selectedTranscript || !msaText.trim(), onSubmit: () => {
83
83
  try {
84
84
  if (selectedTranscript) {
85
85
  setLaunchViewError(undefined);
@@ -1,7 +1,9 @@
1
1
  import { getSession } from '@jbrowse/core/util';
2
+ import { launchMsaView } from '../../../utils/launchMsaView';
3
+ import { readLaunchPlacement } from '../../../utils/workspaces';
2
4
  export function blastLaunchView({ newViewTitle, view, feature, blastParams, }) {
3
- getSession(view).addView('MsaView', {
4
- type: 'MsaView',
5
+ launchMsaView(getSession(view), {
6
+ placement: readLaunchPlacement(),
5
7
  displayName: newViewTitle,
6
8
  connectedViewId: view.id,
7
9
  connectedFeature: feature.toJSON(),
@@ -12,8 +14,8 @@ export function blastLaunchView({ newViewTitle, view, feature, blastParams, }) {
12
14
  });
13
15
  }
14
16
  export function blastLaunchViewFromCache({ newViewTitle, view, cached, connectedFeature, }) {
15
- getSession(view).addView('MsaView', {
16
- type: 'MsaView',
17
+ launchMsaView(getSession(view), {
18
+ placement: readLaunchPlacement(),
17
19
  displayName: newViewTitle,
18
20
  connectedViewId: view.id,
19
21
  connectedFeature,
@@ -58,7 +58,7 @@ const ManualMSALoader = observer(function PreLoadedMSA2({ model, feature, handle
58
58
  } })))),
59
59
  React.createElement(TranscriptSelector, { feature: feature, ...transcriptSelection }),
60
60
  React.createElement(QueryRowSelector, { ...queryRow })),
61
- React.createElement(SubmitCancelActions, { submitDisabled: !selectedTranscript ||
61
+ React.createElement(SubmitCancelActions, { model: model, submitDisabled: !selectedTranscript ||
62
62
  (inputMethod === 'file' && !msaFileLocation) ||
63
63
  (inputMethod === 'text' && !msaText.trim()), onSubmit: () => {
64
64
  try {
@@ -1,7 +1,9 @@
1
1
  import { getSession } from '@jbrowse/core/util';
2
+ import { launchMsaView } from '../../../utils/launchMsaView';
3
+ import { readLaunchPlacement } from '../../../utils/workspaces';
2
4
  export function launchView({ newViewTitle, view, feature, msaFilehandle, treeFilehandle, querySeqName, data, }) {
3
- getSession(view).addView('MsaView', {
4
- type: 'MsaView',
5
+ launchMsaView(getSession(view), {
6
+ placement: readLaunchPlacement(),
5
7
  displayName: newViewTitle,
6
8
  connectedViewId: view.id,
7
9
  connectedFeature: feature.toJSON(),
@@ -2,10 +2,12 @@ import React, { useMemo, useState } from 'react';
2
2
  import { Typography } from '@mui/material';
3
3
  import { observer } from 'mobx-react';
4
4
  import { makeStyles } from 'tss-react/mui';
5
+ import OrthologSourceSelect, { ORTHOLOG_SOURCE_STORAGE_KEY, } from './OrthologSourceSelect';
5
6
  import QuerySpeciesSelect from './QuerySpeciesSelect';
6
7
  import { orthologLaunchView } from './orthologLaunchView';
7
8
  import TextField2 from '../../../components/TextField2';
8
9
  import { defaultMaxSpecies } from '../../../utils/ncbiOrthologs';
10
+ import { useLocalStorage } from '../../../utils/useLocalStorage';
9
11
  import { getGeneDisplayName, getGeneIdentifiers, getLinearGenomeView, getTranscriptDisplayName, } from '../../util';
10
12
  import MsaAlgorithmSelect from '../BlastQuery/MsaAlgorithmSelect';
11
13
  import LaunchPanelContent from '../LaunchPanelContent';
@@ -22,6 +24,7 @@ const OrthologPanel = observer(function ({ handleClose, feature, model, }) {
22
24
  const view = getLinearGenomeView(model);
23
25
  const [launchViewError, setLaunchViewError] = useState();
24
26
  const [taxId, setTaxId] = useState(9606);
27
+ const [source, setSource] = useLocalStorage(ORTHOLOG_SOURCE_STORAGE_KEY, 'ncbi');
25
28
  const [msaAlgorithm, setMsaAlgorithm] = useState('clustalo');
26
29
  const [maxSpecies, setMaxSpecies] = useState(String(defaultMaxSpecies));
27
30
  const geneCandidates = useMemo(() => getGeneIdentifiers(feature), [feature]);
@@ -32,15 +35,16 @@ const OrthologPanel = observer(function ({ handleClose, feature, model, }) {
32
35
  const rowCountValid = Number.isInteger(rowCount) && rowCount >= 2;
33
36
  return (React.createElement(React.Fragment, null,
34
37
  React.createElement(LaunchPanelContent, { error: e },
35
- React.createElement(Typography, { variant: "body2" }, "NCBI's precomputed orthologs, one gene per species, looked up rather than searched for. No BLAST job to queue."),
38
+ React.createElement(Typography, { variant: "body2" }, "Precomputed orthologs, one gene per species, looked up rather than searched for. No BLAST job to queue."),
36
39
  React.createElement("div", null,
40
+ React.createElement(OrthologSourceSelect, { className: classes.selectField, value: source, onChange: setSource }),
37
41
  React.createElement(QuerySpeciesSelect, { className: classes.selectField, value: taxId, assemblyName: view.assemblyNames[0], onChange: setTaxId }),
38
42
  React.createElement(MsaAlgorithmSelect, { className: classes.selectField, value: msaAlgorithm, onChange: setMsaAlgorithm }),
39
43
  React.createElement(TextField2, { variant: "outlined", label: "Rows to align", className: classes.selectField, type: "number", value: maxSpecies, onChange: event => {
40
44
  setMaxSpecies(event.target.value);
41
- }, error: !rowCountValid, helperText: "the closest N species NCBI has" })),
45
+ }, error: !rowCountValid, helperText: `the closest N species ${source === 'panther' ? 'PANTHER' : 'NCBI'} has` })),
42
46
  React.createElement(TranscriptSelector, { feature: feature, ...transcriptSelection })),
43
- React.createElement(SubmitCancelActions, { submitDisabled: !proteinSequence || !rowCountValid, onSubmit: () => {
47
+ React.createElement(SubmitCancelActions, { model: model, submitDisabled: !proteinSequence || !rowCountValid, onSubmit: () => {
44
48
  try {
45
49
  if (selectedTranscript) {
46
50
  setLaunchViewError(undefined);
@@ -50,6 +54,7 @@ const OrthologPanel = observer(function ({ handleClose, feature, model, }) {
50
54
  newViewTitle: `Orthologs - ${getGeneDisplayName(feature)} - ${getTranscriptDisplayName(selectedTranscript)}`,
51
55
  orthologParams: {
52
56
  taxId,
57
+ source,
53
58
  maxSpecies: rowCount,
54
59
  geneCandidates,
55
60
  msaAlgorithm,
@@ -0,0 +1,9 @@
1
+ import React from 'react';
2
+ import type { OrthologSource } from '../../../MsaViewPanel/model';
3
+ export declare const ORTHOLOG_SOURCE_STORAGE_KEY = "msaview-ortholog-source";
4
+ export declare const orthologSourceLabels: Record<OrthologSource, string>;
5
+ export default function OrthologSourceSelect({ value, onChange, className, }: {
6
+ value: OrthologSource;
7
+ onChange: (val: OrthologSource) => void;
8
+ className?: string;
9
+ }): React.JSX.Element;
@@ -0,0 +1,20 @@
1
+ import React from 'react';
2
+ import { MenuItem } from '@mui/material';
3
+ import TextField2 from '../../../components/TextField2';
4
+ export const ORTHOLOG_SOURCE_STORAGE_KEY = 'msaview-ortholog-source';
5
+ export const orthologSourceLabels = {
6
+ ncbi: 'NCBI orthologs',
7
+ panther: 'PANTHER',
8
+ };
9
+ // Which species a source can answer for, in the words a reader picking one
10
+ // needs: NCBI's ortholog sets stop at vertebrates and insects, PANTHER's run
11
+ // from human to yeast and Arabidopsis.
12
+ const hints = {
13
+ ncbi: 'vertebrates and insects',
14
+ panther: 'also yeast, worm, fly and plants',
15
+ };
16
+ export default function OrthologSourceSelect({ value, onChange, className, }) {
17
+ return (React.createElement(TextField2, { variant: "outlined", label: "Source", className: className, select: true, value: value, helperText: hints[value], onChange: event => {
18
+ onChange(event.target.value);
19
+ } }, Object.keys(orthologSourceLabels).map(val => (React.createElement(MenuItem, { value: val, key: val }, orthologSourceLabels[val])))));
20
+ }
@@ -1,7 +1,9 @@
1
1
  import { getSession } from '@jbrowse/core/util';
2
+ import { launchMsaView } from '../../../utils/launchMsaView';
3
+ import { readLaunchPlacement } from '../../../utils/workspaces';
2
4
  export function orthologLaunchView({ newViewTitle, view, feature, orthologParams, }) {
3
- getSession(view).addView('MsaView', {
4
- type: 'MsaView',
5
+ launchMsaView(getSession(view), {
6
+ placement: readLaunchPlacement(),
5
7
  displayName: newViewTitle,
6
8
  connectedViewId: view.id,
7
9
  connectedFeature: feature.toJSON(),
@@ -58,7 +58,7 @@ const PreLoadedMSA = observer(function ({ model, feature, handleClose, }) {
58
58
  msaList ? (React.createElement("div", null,
59
59
  React.createElement(SanitizedHTML, { html: selectedDataset.description }),
60
60
  React.createElement(TranscriptSelector, { feature: feature, ...transcriptSelection }))) : null)) : null),
61
- React.createElement(SubmitCancelActions, { submitDisabled: !selectedTranscript || !msaData?.length, onSubmit: () => {
61
+ React.createElement(SubmitCancelActions, { model: model, submitDisabled: !selectedTranscript || !msaData?.length, onSubmit: () => {
62
62
  try {
63
63
  if (selectedTranscript && msaData) {
64
64
  const querySeqName = `${selectedId}_${assemblyNames[0]}`;
@@ -1,7 +1,9 @@
1
1
  import { getSession } from '@jbrowse/core/util';
2
+ import { launchMsaView } from '../../../utils/launchMsaView';
3
+ import { readLaunchPlacement } from '../../../utils/workspaces';
2
4
  export function preCalculatedLaunchView({ newViewTitle, view, feature, data, querySeqName, }) {
3
- getSession(view).addView('MsaView', {
4
- type: 'MsaView',
5
+ launchMsaView(getSession(view), {
6
+ placement: readLaunchPlacement(),
5
7
  displayName: newViewTitle,
6
8
  treeAreaWidth: 200,
7
9
  querySeqName,
@@ -1,8 +1,11 @@
1
1
  import React from 'react';
2
- export default function SubmitCancelActions({ onSubmit, onCancel, submitDisabled, submitLabel, cancelLabel, }: {
2
+ import type { AbstractTrackModel } from '@jbrowse/core/util';
3
+ export default function SubmitCancelActions({ onSubmit, onCancel, submitDisabled, submitLabel, cancelLabel, model, }: {
3
4
  onSubmit: () => void;
4
5
  onCancel: () => void;
5
6
  submitDisabled?: boolean;
6
7
  submitLabel?: string;
7
8
  cancelLabel?: string;
9
+ /** omitted by a panel that submits something other than a view launch */
10
+ model?: AbstractTrackModel;
8
11
  }): React.JSX.Element;
@@ -1,11 +1,35 @@
1
- import React from 'react';
2
- import { Button, DialogActions } from '@mui/material';
3
- export default function SubmitCancelActions({ onSubmit, onCancel, submitDisabled, submitLabel = 'Submit', cancelLabel = 'Cancel', }) {
4
- return (React.createElement(DialogActions, null,
5
- React.createElement(Button, { color: "primary", variant: "contained", disabled: submitDisabled, onClick: () => {
6
- onSubmit();
7
- } }, submitLabel),
8
- React.createElement(Button, { color: "secondary", variant: "contained", onClick: () => {
9
- onCancel();
10
- } }, cancelLabel)));
1
+ import React, { useState } from 'react';
2
+ import { getSession } from '@jbrowse/core/util';
3
+ import { Button, Checkbox, DialogActions, FormControlLabel, } from '@mui/material';
4
+ import { readLaunchPlacement, sessionSupportsPlacement, writeLaunchPlacement, } from '../../utils/workspaces';
5
+ /**
6
+ * Where the launch puts the view, offered wherever a launch is submitted.
7
+ *
8
+ * A checkbox rather than a menu of the three placements: the choice a reader
9
+ * has at this point is "beside the genome view or under it", and `newTab` is a
10
+ * spec's to state, not a thing to pick before you have seen the alignment.
11
+ *
12
+ * Absent entirely on a host that cannot tile — an embedded session, or a
13
+ * release that places views its own way — because the box would do nothing
14
+ * there and every launch would quietly ignore it.
15
+ */
16
+ function PlacementToggle({ model }) {
17
+ const session = getSession(model);
18
+ const [sideBySide, setSideBySide] = useState(() => readLaunchPlacement() === 'splitRight');
19
+ return sessionSupportsPlacement(session) ? (React.createElement(FormControlLabel, { label: "Open beside the genome view", control: React.createElement(Checkbox, { checked: sideBySide, onChange: event => {
20
+ const { checked } = event.target;
21
+ setSideBySide(checked);
22
+ writeLaunchPlacement(checked ? 'splitRight' : 'stack');
23
+ } }) })) : null;
24
+ }
25
+ export default function SubmitCancelActions({ onSubmit, onCancel, submitDisabled, submitLabel = 'Submit', cancelLabel = 'Cancel', model, }) {
26
+ return (React.createElement(DialogActions, { sx: { flexWrap: 'wrap', rowGap: 1 } },
27
+ model ? React.createElement(PlacementToggle, { model: model }) : null,
28
+ React.createElement("div", { style: { display: 'flex', gap: 8, marginLeft: 'auto' } },
29
+ React.createElement(Button, { sx: { flexShrink: 0 }, color: "primary", variant: "contained", disabled: submitDisabled, onClick: () => {
30
+ onSubmit();
31
+ } }, submitLabel),
32
+ React.createElement(Button, { sx: { flexShrink: 0 }, color: "secondary", variant: "contained", onClick: () => {
33
+ onCancel();
34
+ } }, cancelLabel))));
11
35
  }
@@ -0,0 +1,55 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { cleanup, render, screen } from '@testing-library/react';
4
+ import { afterEach, beforeEach, expect, test, vi } from 'vitest';
5
+ import SubmitCancelActions from './SubmitCancelActions';
6
+ import { LAUNCH_PLACEMENT_KEY } from '../../utils/workspaces';
7
+ // getSession walks the MST tree, and this component only wants the two actions
8
+ // off the far end of that walk
9
+ vi.mock('@jbrowse/core/util', () => ({
10
+ getSession: (model) => model.session,
11
+ }));
12
+ function trackModel(session) {
13
+ return { session };
14
+ }
15
+ const tiling = { setUseWorkspaces() { }, setPendingMove() { } };
16
+ beforeEach(() => {
17
+ localStorage.clear();
18
+ });
19
+ afterEach(() => {
20
+ cleanup();
21
+ });
22
+ function toggle() {
23
+ return screen.queryByRole('checkbox');
24
+ }
25
+ test('a host that can tile offers the choice, checked by default', () => {
26
+ render(React.createElement(SubmitCancelActions, { model: trackModel(tiling), onSubmit: () => { }, onCancel: () => { } }));
27
+ expect(toggle()).toBeTruthy();
28
+ expect(toggle().checked).toBe(true);
29
+ expect(localStorage.getItem(LAUNCH_PLACEMENT_KEY)).toBeNull();
30
+ });
31
+ // the box would do nothing on an embedded session, and a control that silently
32
+ // does nothing is worse than one that is not there
33
+ test('a host that cannot tile does not offer it', () => {
34
+ render(React.createElement(SubmitCancelActions, { model: trackModel({}), onSubmit: () => { }, onCancel: () => { } }));
35
+ expect(toggle()).toBeNull();
36
+ expect(screen.getByText('Submit')).toBeTruthy();
37
+ });
38
+ test('a panel that launches nothing passes no model, and gets no box', () => {
39
+ render(React.createElement(SubmitCancelActions, { onSubmit: () => { }, onCancel: () => { } }));
40
+ expect(toggle()).toBeNull();
41
+ });
42
+ test('clicking it writes the placement the next launch will read', () => {
43
+ render(React.createElement(SubmitCancelActions, { model: trackModel(tiling), onSubmit: () => { }, onCancel: () => { } }));
44
+ toggle().click();
45
+ expect(localStorage.getItem(LAUNCH_PLACEMENT_KEY)).toBe('stack');
46
+ expect(toggle().checked).toBe(false);
47
+ toggle().click();
48
+ expect(localStorage.getItem(LAUNCH_PLACEMENT_KEY)).toBe('splitRight');
49
+ expect(toggle().checked).toBe(true);
50
+ });
51
+ test('a stored choice is what the box opens on', () => {
52
+ localStorage.setItem(LAUNCH_PLACEMENT_KEY, 'stack');
53
+ render(React.createElement(SubmitCancelActions, { model: trackModel(tiling), onSubmit: () => { }, onCancel: () => { } }));
54
+ expect(toggle().checked).toBe(false);
55
+ });
@@ -1,3 +1,4 @@
1
+ import { launchMsaView } from '../utils/launchMsaView';
1
2
  export default function LaunchMsaViewExtensionPointF(pluginManager) {
2
3
  pluginManager.addToExtensionPoint('LaunchView-MsaView', (args) => {
3
4
  const { session, data, msaFileLocation, msaIndexedLocation, msaName, treeFileLocation, querySeqName, ...rest } = args;
@@ -14,8 +15,7 @@ export default function LaunchMsaViewExtensionPointF(pluginManager) {
14
15
  // directly, and so is orthologParams (the model's own autorun picks it up).
15
16
  // Only sources needing launch-time resolution go through `init`: msaUrl
16
17
  // (AlphaFold sniff) and the name-indexed bgzip block (no native loader).
17
- session.addView('MsaView', {
18
- type: 'MsaView',
18
+ launchMsaView(session, {
19
19
  ...rest,
20
20
  data,
21
21
  ...(treeFileLocation
@@ -3,12 +3,14 @@ import type { JBrowsePluginMsaViewModel } from './model';
3
3
  * The no-search-job alternative to doLaunchBlast.
4
4
  *
5
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.
6
+ * returns a redundant, accession-labelled hit list. This asks the question the
7
+ * alignment actually wants — "what is this gene's ortholog in each species"
8
+ * which NCBI and PANTHER have already computed, so the lookup returns in
9
+ * seconds and only the EBI alignment (~10s) costs real time. `source` picks
10
+ * which of the two answers: NCBI for vertebrates and insects, PANTHER for
11
+ * everything else (yeast, worm, plants, and a fly gene's vertebrate relatives).
10
12
  *
11
- * The query row is the user's OWN selected transcript, not NCBI's
13
+ * The query row is the user's OWN selected transcript, not the source's
12
14
  * representative protein for the query species, because `connectedFeature`
13
15
  * maps genome coordinates through that row — swapping in a different isoform
14
16
  * would silently break the genome<->MSA linkage. The query species is therefore
@@ -1,57 +1,55 @@
1
1
  import { cleanProteinSequence } from '../LaunchMsaView/util';
2
2
  import { launchMSA } from '../utils/msa';
3
3
  import { dedupeLabels, fetchOrthologRows, fetchProteinForGene, resolveGeneId, } from '../utils/ncbiOrthologs';
4
+ import { fetchPantherOrthologs } from '../utils/pantherOrthologs';
4
5
  import { fetchTaxonomyInfo } from '../utils/taxonomyNames';
5
6
  /**
6
7
  * The no-search-job alternative to doLaunchBlast.
7
8
  *
8
9
  * BLAST spends 10+ minutes answering "what looks like this sequence" and
9
- * returns a redundant, accession-labelled hit list. This asks NCBI the question
10
- * the alignment actually wants — "what is this gene's ortholog in each species"
11
- * which NCBI has already computed, so the whole NCBI half returns in about a
12
- * second and only the EBI alignment (~10s) costs real time.
10
+ * returns a redundant, accession-labelled hit list. This asks the question the
11
+ * alignment actually wants — "what is this gene's ortholog in each species"
12
+ * which NCBI and PANTHER have already computed, so the lookup returns in
13
+ * seconds and only the EBI alignment (~10s) costs real time. `source` picks
14
+ * which of the two answers: NCBI for vertebrates and insects, PANTHER for
15
+ * everything else (yeast, worm, plants, and a fly gene's vertebrate relatives).
13
16
  *
14
- * The query row is the user's OWN selected transcript, not NCBI's
17
+ * The query row is the user's OWN selected transcript, not the source's
15
18
  * representative protein for the query species, because `connectedFeature`
16
19
  * maps genome coordinates through that row — swapping in a different isoform
17
20
  * would silently break the genome<->MSA linkage. The query species is therefore
18
21
  * excluded from the ortholog set rather than appearing twice.
19
22
  */
20
23
  export async function doLaunchOrthologs({ self, }) {
21
- const { taxId, taxa, maxSpecies, geneCandidates, msaAlgorithm, proteinSequence, } = self.orthologParams;
24
+ const { taxId, taxa, maxSpecies, geneCandidates, msaAlgorithm, proteinSequence, source = 'ncbi', } = self.orthologParams;
22
25
  const onProgress = (arg) => {
23
26
  self.setProgress(arg);
24
27
  };
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
- }
28
+ const request = {
29
+ taxId,
30
+ geneCandidates,
31
+ taxa: taxa ? new Set(taxa) : undefined,
32
+ // the query species is represented by the query row below
33
+ exclude: taxId,
34
+ limit: maxSpecies,
35
+ onProgress,
36
+ };
37
+ const { geneId, representative, rows } = source === 'panther'
38
+ ? await findPantherOrthologs(request)
39
+ : await findNcbiOrthologs(request);
30
40
  // The query row. The dialog always supplies it — it is the user's OWN
31
41
  // selected transcript, which is what makes `connectedFeature` map genome
32
42
  // coordinates through this row. A launch that has no transcript to translate
33
- // (a session spec naming only a gene) falls back to NCBI's representative
34
- // protein for the resolved gene, which is the same choice made for every
35
- // other row, so the alignment is the one NCBI would build for that gene.
36
- const representative = await fetchRepresentativeQueryProtein(resolved.geneId);
43
+ // (a session spec naming only a gene) falls back to the source's
44
+ // representative protein for the resolved gene, which is the same choice
45
+ // made for every other row, so the alignment is the one the source would
46
+ // build for that gene.
37
47
  const cleanedSeq = proteinSequence
38
48
  ? cleanProteinSequence(proteinSequence)
39
49
  : representative?.sequence;
40
50
  if (!cleanedSeq) {
41
- throw new Error(`No query protein: none was supplied and NCBI returned no representative protein for gene ${resolved.geneId}.`);
51
+ throw new Error(`No query protein: none was supplied and ${source === 'panther' ? 'PANTHER' : 'NCBI'} returned no representative protein for gene ${geneId}.`);
42
52
  }
43
- // Every species NCBI has an ortholog for, when a launch names none, capped at
44
- // maxSpecies. A launch that wants specific species lists them; one that just
45
- // wants "this gene across species" gets NCBI's own order, which leads with the
46
- // reference organisms.
47
- const rows = await fetchOrthologRows({
48
- geneId: resolved.geneId,
49
- taxa: taxa ? new Set(taxa) : undefined,
50
- // the query species is represented by the query row above
51
- exclude: taxId,
52
- limit: maxSpecies,
53
- onProgress,
54
- });
55
53
  // The query row is named for its species like every other row, with a suffix
56
54
  // marking it as the one the genome view is linked to. A bare `QUERY` among
57
55
  // ninety-nine named species reads as a row whose species failed to resolve,
@@ -66,7 +64,7 @@ export async function doLaunchOrthologs({ self, }) {
66
64
  const queryLabel = await queryRowLabel(taxId, rows);
67
65
  self.setQuerySeqName(queryLabel);
68
66
  const treeMetadata = {
69
- [queryLabel]: buildQueryMetadata(self, resolved.geneId, cleanedSeq, representative),
67
+ [queryLabel]: buildQueryMetadata(self, geneId, cleanedSeq, representative),
70
68
  };
71
69
  for (const row of rows) {
72
70
  treeMetadata[row.label] = buildRowMetadata(row);
@@ -84,6 +82,42 @@ export async function doLaunchOrthologs({ self, }) {
84
82
  treeMetadata: JSON.stringify(treeMetadata),
85
83
  };
86
84
  }
85
+ /**
86
+ * Every species NCBI has an ortholog for, when a launch names none, capped at
87
+ * `limit`. A launch that wants specific species lists them; one that just
88
+ * wants "this gene across species" gets NCBI's own order, which leads with the
89
+ * reference organisms.
90
+ */
91
+ async function findNcbiOrthologs({ taxId, geneCandidates, onProgress, ...rest }) {
92
+ onProgress('Resolving gene at NCBI...');
93
+ const resolved = await resolveGeneId(geneCandidates, taxId);
94
+ if (!resolved) {
95
+ 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.`);
96
+ }
97
+ const representative = await fetchRepresentativeQueryProtein(resolved.geneId);
98
+ const rows = await fetchOrthologRows({
99
+ geneId: resolved.geneId,
100
+ onProgress,
101
+ ...rest,
102
+ });
103
+ return { geneId: resolved.geneId, representative, rows };
104
+ }
105
+ /**
106
+ * One `matchortho` call resolves the gene, names its own UniProt entry and
107
+ * lists an ortholog per genome, so the representative protein needs no second
108
+ * lookup here.
109
+ */
110
+ async function findPantherOrthologs({ geneCandidates, ...rest }) {
111
+ const found = await fetchPantherOrthologs({
112
+ candidates: geneCandidates,
113
+ ...rest,
114
+ });
115
+ return {
116
+ geneId: found.query?.geneRef ?? found.matched,
117
+ representative: found.query,
118
+ rows: found.rows,
119
+ };
120
+ }
87
121
  /**
88
122
  * `<species>_query`, unique against the ortholog labels. Falls back to the bare
89
123
  * marker when NCBI cannot name the taxon, which is a naming failure and must not
@@ -120,7 +154,7 @@ async function fetchRepresentativeQueryProtein(geneId) {
120
154
  /**
121
155
  * The query row carries an Accession — which is what drives the automatic CDD
122
156
  * overlay (afterCreateAutoruns.autoLoadProteinDomains -> loadProteinDomains) —
123
- * ONLY when its sequence is byte-identical to the RefSeq protein that accession
157
+ * ONLY when its sequence is byte-identical to the protein that accession
124
158
  * names. Attaching it unconditionally would put every domain box at an offset
125
159
  * whenever the user picked a non-representative isoform, which is a silently
126
160
  * wrong figure rather than a missing one. A launch that took the representative
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
2
2
  import { doLaunchOrthologs } from './doLaunchOrthologs';
3
3
  import { launchMSA } from '../utils/msa';
4
4
  import { defaultMaxSpecies, fetchOrthologRows, fetchProteinForGene, resolveGeneId, } from '../utils/ncbiOrthologs';
5
+ import { fetchPantherOrthologs } from '../utils/pantherOrthologs';
5
6
  import { fetchTaxonomyInfo } from '../utils/taxonomyNames';
6
7
  // Every network call is mocked and nothing else is. What is under test is the
7
8
  // argument shaping either side of those calls -- which species get asked for,
@@ -14,11 +15,15 @@ vi.mock('../utils/ncbiOrthologs', async (importOriginal) => ({
14
15
  fetchProteinForGene: vi.fn(),
15
16
  fetchOrthologRows: vi.fn(),
16
17
  }));
18
+ vi.mock('../utils/pantherOrthologs', () => ({
19
+ fetchPantherOrthologs: vi.fn(),
20
+ }));
17
21
  vi.mock('../utils/msa', () => ({ launchMSA: vi.fn() }));
18
22
  vi.mock('../utils/taxonomyNames', () => ({ fetchTaxonomyInfo: vi.fn() }));
19
23
  const mockResolveGeneId = vi.mocked(resolveGeneId);
20
24
  const mockFetchProtein = vi.mocked(fetchProteinForGene);
21
25
  const mockFetchRows = vi.mocked(fetchOrthologRows);
26
+ const mockFetchPanther = vi.mocked(fetchPantherOrthologs);
22
27
  const mockLaunchMSA = vi.mocked(launchMSA);
23
28
  const mockFetchTaxonomy = vi.mocked(fetchTaxonomyInfo);
24
29
  const HUMAN = 9606;
@@ -199,3 +204,103 @@ describe('the Accession that drives the domain overlay', () => {
199
204
  expect(queryMetadata(result).Accession).toBeUndefined();
200
205
  });
201
206
  });
207
+ // The second source. What is under test is the dispatch and what the PANTHER
208
+ // result becomes on the query row -- the rows themselves are shaped upstream,
209
+ // and the tail of the launch (labels, aligner, metadata) is the same code the
210
+ // NCBI tests above already cover.
211
+ describe('the PANTHER source', () => {
212
+ const YEAST = 559292;
213
+ const found = {
214
+ matched: 'CDC28',
215
+ query: {
216
+ code: 'YEAST',
217
+ accession: 'P00546',
218
+ geneRef: 'SGD=S000000364',
219
+ sequence: 'MSGELANYKRLEKVGEGTYGVVYKA',
220
+ },
221
+ rows: [
222
+ {
223
+ taxId: HUMAN,
224
+ label: 'human',
225
+ scientificName: 'Homo sapiens',
226
+ commonName: 'human',
227
+ geneId: 'HGNC=1771',
228
+ protein: 'P24941',
229
+ sequence: 'MENFQKVEKIGEGTYGVVYKARNK',
230
+ },
231
+ ],
232
+ };
233
+ beforeEach(() => {
234
+ mockFetchPanther.mockResolvedValue(found);
235
+ mockFetchTaxonomy.mockResolvedValue(new Map([[YEAST, { sciname: 'Saccharomyces cerevisiae' }]]));
236
+ });
237
+ test('source omitted is NCBI, so an old launch never reaches PANTHER', async () => {
238
+ await doLaunchOrthologs({ self: makeModel(params()) });
239
+ expect(mockFetchPanther).not.toHaveBeenCalled();
240
+ expect(mockResolveGeneId).toHaveBeenCalled();
241
+ });
242
+ test('source panther asks PANTHER with the same species semantics, and skips NCBI', async () => {
243
+ await doLaunchOrthologs({
244
+ self: makeModel({
245
+ taxId: YEAST,
246
+ source: 'panther',
247
+ geneCandidates: ['CDC28'],
248
+ msaAlgorithm: 'clustalo',
249
+ taxa: [HUMAN, YEAST],
250
+ maxSpecies: 7,
251
+ }),
252
+ });
253
+ expect(mockResolveGeneId).not.toHaveBeenCalled();
254
+ expect(mockFetchRows).not.toHaveBeenCalled();
255
+ const { candidates, taxId, taxa, exclude, limit } = mockFetchPanther.mock.calls[0][0];
256
+ expect(candidates).toEqual(['CDC28']);
257
+ expect(taxId).toBe(YEAST);
258
+ expect([...taxa]).toEqual([HUMAN, YEAST]);
259
+ expect(exclude).toBe(YEAST);
260
+ expect(limit).toBe(7);
261
+ });
262
+ test("the query row is PANTHER's own entry for the gene when no sequence was supplied, and carries its UniProt accession for the domain overlay", async () => {
263
+ const result = await doLaunchOrthologs({
264
+ self: makeModel({
265
+ taxId: YEAST,
266
+ source: 'panther',
267
+ geneCandidates: ['CDC28'],
268
+ msaAlgorithm: 'clustalo',
269
+ }),
270
+ });
271
+ expect(queryRowName()).toBe('Saccharomyces_cerevisiae_query');
272
+ expect(queryRowSent()).toBe(found.query.sequence);
273
+ expect(queryMetadata(result)).toEqual({
274
+ 'Gene ID': 'SGD=S000000364',
275
+ Accession: 'P00546',
276
+ });
277
+ expect(JSON.parse(result.treeMetadata).human).toMatchObject({
278
+ Accession: 'P24941',
279
+ 'Gene ID': 'HGNC=1771',
280
+ });
281
+ });
282
+ test('a supplied sequence still wins, and a different isoform earns no Accession', async () => {
283
+ const result = await doLaunchOrthologs({
284
+ self: makeModel({
285
+ taxId: YEAST,
286
+ source: 'panther',
287
+ geneCandidates: ['CDC28'],
288
+ msaAlgorithm: 'clustalo',
289
+ proteinSequence: 'MDIFFERENTISOFORM',
290
+ }),
291
+ });
292
+ expect(queryRowSent()).toBe('MDIFFERENTISOFORM');
293
+ expect(queryMetadata(result).Accession).toBeUndefined();
294
+ });
295
+ test('names PANTHER when it has no protein for the query row', async () => {
296
+ mockFetchPanther.mockResolvedValue({ ...found, query: undefined });
297
+ await expect(doLaunchOrthologs({
298
+ self: makeModel({
299
+ taxId: YEAST,
300
+ source: 'panther',
301
+ geneCandidates: ['CDC28'],
302
+ msaAlgorithm: 'clustalo',
303
+ }),
304
+ })).rejects.toThrow(/PANTHER returned no representative protein/);
305
+ });
306
+ });
@@ -17,9 +17,18 @@ export interface BlastParams {
17
17
  selectedTranscript?: Feature;
18
18
  proteinSequence: string;
19
19
  }
20
+ /**
21
+ * Where the ortholog set comes from. NCBI's sets cover vertebrates and
22
+ * insects; PANTHER's span its 144 reference proteomes, human to yeast to
23
+ * Arabidopsis, so a gene from outside NCBI's scope aligns only through it.
24
+ */
25
+ export declare const orthologSources: readonly ["ncbi", "panther"];
26
+ export type OrthologSource = (typeof orthologSources)[number];
20
27
  export interface OrthologParams {
21
28
  /** NCBI taxon id of the assembly the query gene came from */
22
29
  taxId: number;
30
+ /** `ncbi` when omitted, so every launch written before this key keeps its meaning */
31
+ source?: OrthologSource;
23
32
  /**
24
33
  * taxon ids to include as rows. The query taxon has its own row already, so
25
34
  * it is excluded from this set whether or not it is named.