jbrowse-plugin-msaview 2.9.0 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +14 -34
- package/dist/LaunchMsaView/components/OrthologQuery/QuerySpeciesSelect.d.ts +15 -0
- package/dist/LaunchMsaView/components/OrthologQuery/QuerySpeciesSelect.js +67 -0
- package/dist/LaunchMsaView/index.js +19 -1
- package/dist/MsaViewPanel/doLaunchOrthologs.js +43 -12
- package/dist/MsaViewPanel/doLaunchOrthologs.test.js +93 -19
- package/dist/MsaViewPanel/model.d.ts +19 -8
- package/dist/jbrowse-plugin-msaview.umd.production.min.js +30 -32
- package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
- package/dist/utils/ebiJobDispatcher.js +26 -1
- package/dist/utils/ebiJobDispatcher.test.d.ts +1 -0
- package/dist/utils/ebiJobDispatcher.test.js +46 -0
- package/dist/utils/eutils.d.ts +9 -0
- package/dist/utils/eutils.js +18 -0
- package/dist/utils/ncbiOrthologs.d.ts +17 -75
- package/dist/utils/ncbiOrthologs.js +67 -63
- package/dist/utils/ncbiOrthologs.test.js +82 -2
- package/dist/utils/ncbiTaxonomy.d.ts +11 -0
- package/dist/utils/ncbiTaxonomy.js +33 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
- package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +33 -71
- package/src/LaunchMsaView/components/OrthologQuery/QuerySpeciesSelect.tsx +92 -0
- package/src/LaunchMsaView/index.ts +20 -2
- package/src/MsaViewPanel/doLaunchOrthologs.test.ts +109 -21
- package/src/MsaViewPanel/doLaunchOrthologs.ts +56 -13
- package/src/MsaViewPanel/model.ts +9 -2
- package/src/utils/ebiJobDispatcher.test.ts +54 -0
- package/src/utils/ebiJobDispatcher.ts +32 -3
- package/src/utils/eutils.ts +19 -0
- package/src/utils/ncbiOrthologs.test.ts +96 -2
- package/src/utils/ncbiOrthologs.ts +83 -71
- package/src/utils/ncbiTaxonomy.ts +37 -0
- package/src/version.ts +1 -1
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import React, { useMemo, useState } from 'react'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { Typography } from '@mui/material'
|
|
4
4
|
import { observer } from 'mobx-react'
|
|
5
5
|
import { makeStyles } from 'tss-react/mui'
|
|
6
6
|
|
|
7
|
+
import QuerySpeciesSelect from './QuerySpeciesSelect'
|
|
7
8
|
import { orthologLaunchView } from './orthologLaunchView'
|
|
8
9
|
import TextField2 from '../../../components/TextField2'
|
|
9
|
-
import {
|
|
10
|
+
import { defaultMaxSpecies } from '../../../utils/ncbiOrthologs'
|
|
10
11
|
import {
|
|
11
12
|
getGeneDisplayName,
|
|
12
13
|
getGeneIdentifiers,
|
|
@@ -26,23 +27,6 @@ const useStyles = makeStyles()({
|
|
|
26
27
|
selectField: {
|
|
27
28
|
width: 180,
|
|
28
29
|
},
|
|
29
|
-
// A GRID, not a wrapping flex row of fixed-width items. The old form was three
|
|
30
|
-
// 160px columns inside a 560px box, which is five rows for thirteen species and
|
|
31
|
-
// eight for twenty-three -- and the checkbox list is the tallest thing in the
|
|
32
|
-
// dialog, so those rows are the dialog's height. Five auto-fitted columns is
|
|
33
|
-
// five rows for twenty-three, i.e. more species in less space, and it reflows
|
|
34
|
-
// rather than being pinned to a width the dialog may not have.
|
|
35
|
-
speciesBox: {
|
|
36
|
-
display: 'grid',
|
|
37
|
-
gridTemplateColumns: 'repeat(auto-fit, minmax(130px, 1fr))',
|
|
38
|
-
maxWidth: 700,
|
|
39
|
-
marginTop: 4,
|
|
40
|
-
},
|
|
41
|
-
// The label carries the row height; the default control padding is what makes
|
|
42
|
-
// 23 rows of it tall.
|
|
43
|
-
species: {
|
|
44
|
-
marginRight: 0,
|
|
45
|
-
},
|
|
46
30
|
})
|
|
47
31
|
|
|
48
32
|
const OrthologPanel = observer(function ({
|
|
@@ -59,86 +43,64 @@ const OrthologPanel = observer(function ({
|
|
|
59
43
|
const [launchViewError, setLaunchViewError] = useState<unknown>()
|
|
60
44
|
const [taxId, setTaxId] = useState(9606)
|
|
61
45
|
const [msaAlgorithm, setMsaAlgorithm] = useState<MsaAlgorithm>('clustalo')
|
|
62
|
-
const [
|
|
46
|
+
const [maxSpecies, setMaxSpecies] = useState(String(defaultMaxSpecies))
|
|
63
47
|
|
|
64
48
|
const geneCandidates = useMemo(() => getGeneIdentifiers(feature), [feature])
|
|
65
49
|
const transcriptSelection = useTranscriptSelection({ feature, view })
|
|
66
50
|
const { selectedTranscript, proteinSequence } = transcriptSelection
|
|
67
51
|
const e = transcriptSelection.error ?? launchViewError
|
|
68
52
|
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
)
|
|
53
|
+
const rowCount = Number(maxSpecies)
|
|
54
|
+
const rowCountValid = Number.isInteger(rowCount) && rowCount >= 2
|
|
72
55
|
|
|
73
56
|
return (
|
|
74
57
|
<>
|
|
75
58
|
<LaunchPanelContent error={e}>
|
|
76
|
-
{/* One line rather than seven. What a reader needs here is which tab
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
59
|
+
{/* One line rather than seven. What a reader needs here is which tab to
|
|
60
|
+
pick; the rest (species labels, CDD overlay, the query row being the
|
|
61
|
+
selected transcript) is visible in the result or documented, and as
|
|
62
|
+
prose it was most of the dialog's height.
|
|
63
|
+
|
|
64
|
+
The comparison stays but the number moved: the lookup is still
|
|
65
|
+
instant, and it is now the aligner that costs the wait, about half a
|
|
66
|
+
second per row. */}
|
|
81
67
|
<Typography variant="body2">
|
|
82
|
-
NCBI's precomputed orthologs, one gene per species,
|
|
83
|
-
|
|
68
|
+
NCBI's precomputed orthologs, one gene per species, looked up
|
|
69
|
+
rather than searched for. No BLAST job to queue.
|
|
84
70
|
</Typography>
|
|
85
71
|
|
|
86
72
|
<div>
|
|
87
|
-
<
|
|
88
|
-
variant="outlined"
|
|
89
|
-
label="Query species"
|
|
73
|
+
<QuerySpeciesSelect
|
|
90
74
|
className={classes.selectField}
|
|
91
|
-
select
|
|
92
75
|
value={taxId}
|
|
93
|
-
onChange={
|
|
94
|
-
|
|
95
|
-
}}
|
|
96
|
-
helperText="the species this gene is from"
|
|
97
|
-
>
|
|
98
|
-
{COMMON_SPECIES.map(s => (
|
|
99
|
-
<MenuItem value={s.taxId} key={s.taxId}>
|
|
100
|
-
{s.label}
|
|
101
|
-
</MenuItem>
|
|
102
|
-
))}
|
|
103
|
-
</TextField2>
|
|
76
|
+
onChange={setTaxId}
|
|
77
|
+
/>
|
|
104
78
|
|
|
105
79
|
<MsaAlgorithmSelect
|
|
106
80
|
className={classes.selectField}
|
|
107
81
|
value={msaAlgorithm}
|
|
108
82
|
onChange={setMsaAlgorithm}
|
|
109
83
|
/>
|
|
110
|
-
</div>
|
|
111
84
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
setExcluded(
|
|
125
|
-
event.target.checked
|
|
126
|
-
? excluded.filter(t => t !== s.taxId)
|
|
127
|
-
: [...excluded, s.taxId],
|
|
128
|
-
)
|
|
129
|
-
}}
|
|
130
|
-
/>
|
|
131
|
-
}
|
|
132
|
-
label={s.label}
|
|
133
|
-
/>
|
|
134
|
-
))}
|
|
85
|
+
<TextField2
|
|
86
|
+
variant="outlined"
|
|
87
|
+
label="Rows to align"
|
|
88
|
+
className={classes.selectField}
|
|
89
|
+
type="number"
|
|
90
|
+
value={maxSpecies}
|
|
91
|
+
onChange={event => {
|
|
92
|
+
setMaxSpecies(event.target.value)
|
|
93
|
+
}}
|
|
94
|
+
error={!rowCountValid}
|
|
95
|
+
helperText="the closest N species NCBI has"
|
|
96
|
+
/>
|
|
135
97
|
</div>
|
|
136
98
|
|
|
137
99
|
<TranscriptSelector feature={feature} {...transcriptSelection} />
|
|
138
100
|
|
|
139
101
|
</LaunchPanelContent>
|
|
140
102
|
<SubmitCancelActions
|
|
141
|
-
submitDisabled={!proteinSequence ||
|
|
103
|
+
submitDisabled={!proteinSequence || !rowCountValid}
|
|
142
104
|
onSubmit={() => {
|
|
143
105
|
try {
|
|
144
106
|
if (selectedTranscript) {
|
|
@@ -149,7 +111,7 @@ const OrthologPanel = observer(function ({
|
|
|
149
111
|
newViewTitle: `Orthologs - ${getGeneDisplayName(feature)} - ${getTranscriptDisplayName(selectedTranscript)}`,
|
|
150
112
|
orthologParams: {
|
|
151
113
|
taxId,
|
|
152
|
-
|
|
114
|
+
maxSpecies: rowCount,
|
|
153
115
|
geneCandidates,
|
|
154
116
|
msaAlgorithm,
|
|
155
117
|
selectedTranscript,
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import TextField2 from '../../../components/TextField2'
|
|
4
|
+
import { resolveTaxId } from '../../../utils/ncbiTaxonomy'
|
|
5
|
+
import { fetchTaxonomyInfo } from '../../../utils/taxonomyNames'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The species the query gene came from, as free text resolved against NCBI's
|
|
9
|
+
* taxonomy rather than picked from a fixed list.
|
|
10
|
+
*
|
|
11
|
+
* The resolved taxon is shown back as helper text, which is the whole point of
|
|
12
|
+
* resolving on a debounce instead of on submit: a typo resolves to some other
|
|
13
|
+
* organism rather than to nothing, and the only place that surfaces is the gene
|
|
14
|
+
* lookup, as "could not resolve NLRP1 in taxon 9986".
|
|
15
|
+
*/
|
|
16
|
+
export default function QuerySpeciesSelect({
|
|
17
|
+
value,
|
|
18
|
+
onChange,
|
|
19
|
+
className,
|
|
20
|
+
}: {
|
|
21
|
+
value: number
|
|
22
|
+
onChange: (taxId: number) => void
|
|
23
|
+
className?: string
|
|
24
|
+
}) {
|
|
25
|
+
const [text, setText] = useState('human')
|
|
26
|
+
const [resolved, setResolved] = useState<string>()
|
|
27
|
+
const [error, setError] = useState<unknown>()
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
// read through a call rather than as a property: the cleanup writes it from
|
|
31
|
+
// another turn of the loop, and a bare `run.live` narrows to true after the
|
|
32
|
+
// first check, which reads to the compiler as a redundant second one
|
|
33
|
+
const run = { live: true }
|
|
34
|
+
const cancelled = () => !run.live
|
|
35
|
+
async function lookup() {
|
|
36
|
+
try {
|
|
37
|
+
setError(undefined)
|
|
38
|
+
const taxId = await resolveTaxId(text)
|
|
39
|
+
if (cancelled()) {
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
if (!taxId) {
|
|
43
|
+
setResolved(undefined)
|
|
44
|
+
setError(new Error(`No NCBI taxon matches "${text}"`))
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
const info = (await fetchTaxonomyInfo([taxId])).get(taxId)
|
|
48
|
+
if (cancelled()) {
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
setResolved(
|
|
52
|
+
[info?.sciname, info?.commonName && `(${info.commonName})`]
|
|
53
|
+
.filter(Boolean)
|
|
54
|
+
.join(' ') || `taxon ${taxId}`,
|
|
55
|
+
)
|
|
56
|
+
onChange(taxId)
|
|
57
|
+
} catch (e) {
|
|
58
|
+
if (!cancelled()) {
|
|
59
|
+
setError(e)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const timer = setTimeout(() => {
|
|
64
|
+
void lookup()
|
|
65
|
+
}, 400)
|
|
66
|
+
return () => {
|
|
67
|
+
run.live = false
|
|
68
|
+
clearTimeout(timer)
|
|
69
|
+
}
|
|
70
|
+
// onChange is a setState updater from the parent and stable in practice;
|
|
71
|
+
// including it would re-run the lookup on every parent render
|
|
72
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
73
|
+
}, [text])
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<TextField2
|
|
77
|
+
variant="outlined"
|
|
78
|
+
label="Query species"
|
|
79
|
+
className={className}
|
|
80
|
+
value={text}
|
|
81
|
+
onChange={event => {
|
|
82
|
+
setText(event.target.value)
|
|
83
|
+
}}
|
|
84
|
+
error={!!error}
|
|
85
|
+
helperText={
|
|
86
|
+
error
|
|
87
|
+
? `${error}`
|
|
88
|
+
: (resolved ?? `the species this gene is from (taxon ${value})`)
|
|
89
|
+
}
|
|
90
|
+
/>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
@@ -26,7 +26,6 @@ interface ContextMenuInfo {
|
|
|
26
26
|
interface DisplayModel {
|
|
27
27
|
contextMenuItems: () => MenuItem[]
|
|
28
28
|
contextMenuInfo?: ContextMenuInfo
|
|
29
|
-
isGeneLike?: boolean
|
|
30
29
|
fetchFullFeature?: (
|
|
31
30
|
featureId: string,
|
|
32
31
|
displayedRegionIndex: number,
|
|
@@ -34,6 +33,25 @@ interface DisplayModel {
|
|
|
34
33
|
contextMenuFeature?: Feature
|
|
35
34
|
}
|
|
36
35
|
|
|
36
|
+
// Read off the clicked item rather than off the display.
|
|
37
|
+
//
|
|
38
|
+
// LinearBasicDisplay used to publish an `isGeneLike` getter and this gated on
|
|
39
|
+
// it. jbrowse-components 684142b3 (2026-08-16) inlined that getter into its own
|
|
40
|
+
// `contextMenuItems`, and every host built after it returns `undefined` here --
|
|
41
|
+
// so the gate was never satisfied, `onClick` stayed undefined, and the item
|
|
42
|
+
// silently left the right-click menu on every gene track. Nothing failed loudly:
|
|
43
|
+
// the display still had contextMenuInfo and fetchFullFeature, and the menu still
|
|
44
|
+
// opened with its own items in it.
|
|
45
|
+
//
|
|
46
|
+
// A predicate over the type we were already given cannot go the same way, and it
|
|
47
|
+
// costs one comparison. Deliberately the same loose case-insensitive test the
|
|
48
|
+
// host applies (`isGeneLikeType` in collapseIntronsMenu.ts): real GFFs carry
|
|
49
|
+
// 'mRNA', 'lnc_RNA', 'protein_coding_gene', 'transcript'.
|
|
50
|
+
function isGeneLikeType(type: string | undefined) {
|
|
51
|
+
const t = (type ?? '').toLowerCase()
|
|
52
|
+
return t.includes('gene') || t.includes('rna') || t.includes('transcript')
|
|
53
|
+
}
|
|
54
|
+
|
|
37
55
|
const GENE_LIKE_TYPES = new Set(['gene', 'mRNA', 'transcript'])
|
|
38
56
|
|
|
39
57
|
function extendStateModel(stateModel: IAnyModelType) {
|
|
@@ -54,7 +72,7 @@ function extendStateModel(stateModel: IAnyModelType) {
|
|
|
54
72
|
const fetchFullFeature = self.fetchFullFeature
|
|
55
73
|
const legacyFeature = self.contextMenuFeature
|
|
56
74
|
const onClick =
|
|
57
|
-
info && fetchFullFeature &&
|
|
75
|
+
info && fetchFullFeature && isGeneLikeType(info.item.type)
|
|
58
76
|
? () => {
|
|
59
77
|
fetchFullFeature(info.item.featureId, info.displayedRegionIndex)
|
|
60
78
|
.then(feature => {
|
|
@@ -3,20 +3,21 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'
|
|
|
3
3
|
import { doLaunchOrthologs } from './doLaunchOrthologs'
|
|
4
4
|
import { launchMSA } from '../utils/msa'
|
|
5
5
|
import {
|
|
6
|
-
|
|
6
|
+
defaultMaxSpecies,
|
|
7
7
|
fetchOrthologRows,
|
|
8
8
|
fetchProteinForGene,
|
|
9
9
|
resolveGeneId,
|
|
10
10
|
} from '../utils/ncbiOrthologs'
|
|
11
|
+
import { fetchTaxonomyInfo } from '../utils/taxonomyNames'
|
|
11
12
|
|
|
12
13
|
import type { JBrowsePluginMsaViewModel } from './model'
|
|
13
14
|
import type { OrthologRow } from '../utils/ncbiOrthologs'
|
|
14
15
|
|
|
15
16
|
// Every network call is mocked and nothing else is. What is under test is the
|
|
16
|
-
// argument shaping either side of those calls -- which
|
|
17
|
-
// becomes the QUERY row, and whether the row earns the Accession that
|
|
18
|
-
// CDD overlay -- so the real
|
|
19
|
-
//
|
|
17
|
+
// argument shaping either side of those calls -- which species get asked for,
|
|
18
|
+
// what becomes the QUERY row, and whether the row earns the Accession that
|
|
19
|
+
// drives the CDD overlay -- so the real cleanProteinSequence stays in the
|
|
20
|
+
// picture.
|
|
20
21
|
vi.mock('../utils/ncbiOrthologs', async importOriginal => ({
|
|
21
22
|
...(await importOriginal<Record<string, unknown>>()),
|
|
22
23
|
resolveGeneId: vi.fn(),
|
|
@@ -24,20 +25,25 @@ vi.mock('../utils/ncbiOrthologs', async importOriginal => ({
|
|
|
24
25
|
fetchOrthologRows: vi.fn(),
|
|
25
26
|
}))
|
|
26
27
|
vi.mock('../utils/msa', () => ({ launchMSA: vi.fn() }))
|
|
28
|
+
vi.mock('../utils/taxonomyNames', () => ({ fetchTaxonomyInfo: vi.fn() }))
|
|
27
29
|
|
|
28
30
|
const mockResolveGeneId = vi.mocked(resolveGeneId)
|
|
29
31
|
const mockFetchProtein = vi.mocked(fetchProteinForGene)
|
|
30
32
|
const mockFetchRows = vi.mocked(fetchOrthologRows)
|
|
31
33
|
const mockLaunchMSA = vi.mocked(launchMSA)
|
|
34
|
+
const mockFetchTaxonomy = vi.mocked(fetchTaxonomyInfo)
|
|
32
35
|
|
|
33
36
|
const HUMAN = 9606
|
|
34
37
|
const GENE_ID = '22861'
|
|
35
38
|
const REPRESENTATIVE = { accession: 'NP_127497.1', sequence: 'MAGGAWGRLACY' }
|
|
36
39
|
|
|
40
|
+
const setQuerySeqName = vi.fn()
|
|
41
|
+
|
|
37
42
|
function makeModel(orthologParams: Record<string, unknown>) {
|
|
38
43
|
return {
|
|
39
44
|
orthologParams,
|
|
40
45
|
setProgress: () => {},
|
|
46
|
+
setQuerySeqName,
|
|
41
47
|
} as unknown as JBrowsePluginMsaViewModel
|
|
42
48
|
}
|
|
43
49
|
|
|
@@ -50,10 +56,15 @@ function params(extra: Record<string, unknown> = {}) {
|
|
|
50
56
|
}
|
|
51
57
|
}
|
|
52
58
|
|
|
53
|
-
// What fetchOrthologRows was asked for, which is the only place the
|
|
54
|
-
//
|
|
55
|
-
function
|
|
56
|
-
|
|
59
|
+
// What fetchOrthologRows was asked for, which is the only place the species
|
|
60
|
+
// defaults are observable.
|
|
61
|
+
function rowRequest() {
|
|
62
|
+
const { taxa, exclude, limit } = mockFetchRows.mock.calls[0]![0]
|
|
63
|
+
return {
|
|
64
|
+
taxa: taxa && [...taxa].sort((a, b) => a - b),
|
|
65
|
+
exclude,
|
|
66
|
+
limit,
|
|
67
|
+
}
|
|
57
68
|
}
|
|
58
69
|
|
|
59
70
|
// The QUERY row as it went to the aligner, read back out of the FASTA rather
|
|
@@ -62,8 +73,18 @@ function queryRowSent() {
|
|
|
62
73
|
return mockLaunchMSA.mock.calls[0]![0].sequence.split('\n')[1]
|
|
63
74
|
}
|
|
64
75
|
|
|
76
|
+
// Keyed by the row's own label, which has to be the name the FASTA header
|
|
77
|
+
// carries -- the tree comes back from the aligner naming its leaves that way,
|
|
78
|
+
// and the metadata is paired to a leaf by name.
|
|
65
79
|
function queryMetadata(result: { treeMetadata: string }) {
|
|
66
|
-
return JSON.parse(result.treeMetadata)
|
|
80
|
+
return JSON.parse(result.treeMetadata)[queryRowName()] as Record<
|
|
81
|
+
string,
|
|
82
|
+
string
|
|
83
|
+
>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function queryRowName() {
|
|
87
|
+
return mockLaunchMSA.mock.calls[0]![0].sequence.split('\n')[0]!.slice(1)
|
|
67
88
|
}
|
|
68
89
|
|
|
69
90
|
beforeEach(() => {
|
|
@@ -72,32 +93,99 @@ beforeEach(() => {
|
|
|
72
93
|
mockFetchProtein.mockResolvedValue(REPRESENTATIVE)
|
|
73
94
|
mockFetchRows.mockResolvedValue([] as OrthologRow[])
|
|
74
95
|
mockLaunchMSA.mockResolvedValue({ msa: '', tree: '' })
|
|
96
|
+
mockFetchTaxonomy.mockResolvedValue(
|
|
97
|
+
new Map([[HUMAN, { sciname: 'Homo sapiens', commonName: 'human' }]]),
|
|
98
|
+
)
|
|
75
99
|
})
|
|
76
100
|
|
|
77
|
-
describe('
|
|
78
|
-
test('omitted asks for
|
|
101
|
+
describe('which species become rows', () => {
|
|
102
|
+
test('omitted taxa asks for no restriction at all, which is every ortholog NCBI has', async () => {
|
|
79
103
|
await doLaunchOrthologs({ self: makeModel(params()) })
|
|
80
|
-
expect(
|
|
81
|
-
COMMON_SPECIES.map(s => s.taxId as number)
|
|
82
|
-
.filter(t => t !== HUMAN)
|
|
83
|
-
.sort((a, b) => a - b),
|
|
84
|
-
)
|
|
104
|
+
expect(rowRequest().taxa).toBeUndefined()
|
|
85
105
|
})
|
|
86
106
|
|
|
87
|
-
test('given is taken as written
|
|
107
|
+
test('given taxa is taken as written', async () => {
|
|
88
108
|
await doLaunchOrthologs({
|
|
89
109
|
self: makeModel(params({ taxa: [HUMAN, 10090, 9615] })),
|
|
90
110
|
})
|
|
91
|
-
expect(
|
|
111
|
+
expect(rowRequest().taxa).toEqual([9606, 9615, 10090])
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
// Not folded into the taxa list before the call, so that "restrict to these"
|
|
115
|
+
// and "the query row already covers this one" stay separable -- an unrestricted
|
|
116
|
+
// launch still has to drop the query species.
|
|
117
|
+
test('the query species is excluded whether or not taxa was given', async () => {
|
|
118
|
+
await doLaunchOrthologs({ self: makeModel(params()) })
|
|
119
|
+
expect(rowRequest().exclude).toBe(HUMAN)
|
|
120
|
+
vi.clearAllMocks()
|
|
121
|
+
mockResolveGeneId.mockResolvedValue({ geneId: GENE_ID, matched: 'NLRP1' })
|
|
122
|
+
mockFetchProtein.mockResolvedValue(REPRESENTATIVE)
|
|
123
|
+
mockFetchRows.mockResolvedValue([] as OrthologRow[])
|
|
124
|
+
mockLaunchMSA.mockResolvedValue({ msa: '', tree: '' })
|
|
125
|
+
await doLaunchOrthologs({
|
|
126
|
+
self: makeModel(params({ taxa: [HUMAN, 10090] })),
|
|
127
|
+
})
|
|
128
|
+
expect(rowRequest().exclude).toBe(HUMAN)
|
|
92
129
|
})
|
|
93
130
|
|
|
94
131
|
test('an empty list is a request for no rows, not a request for all of them', async () => {
|
|
95
132
|
await doLaunchOrthologs({ self: makeModel(params({ taxa: [] })) })
|
|
96
|
-
expect(
|
|
133
|
+
expect(rowRequest().taxa).toEqual([])
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// The cap is the only thing standing between a launch and a 7 minute EBI job:
|
|
138
|
+
// NCBI publishes 865 orthologs for CFTR and the aligner runs at roughly half a
|
|
139
|
+
// second a row.
|
|
140
|
+
describe('the row cap', () => {
|
|
141
|
+
test('is passed through when given', async () => {
|
|
142
|
+
await doLaunchOrthologs({ self: makeModel(params({ maxSpecies: 12 })) })
|
|
143
|
+
expect(rowRequest().limit).toBe(12)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('omitted leaves the default to fetchOrthologGenes rather than sending Infinity', async () => {
|
|
147
|
+
await doLaunchOrthologs({ self: makeModel(params()) })
|
|
148
|
+
expect(rowRequest().limit).toBeUndefined()
|
|
149
|
+
expect(defaultMaxSpecies).toBeGreaterThan(2)
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
// The row's name is load bearing three times over: it is the FASTA header, it
|
|
154
|
+
// is therefore the tree leaf the aligner returns, and it is what
|
|
155
|
+
// `seqPosToVisibleCol` looks up to turn a genome hover into a column. So the
|
|
156
|
+
// model's querySeqName and the header have to be the same string.
|
|
157
|
+
describe('the query row name', () => {
|
|
158
|
+
test('is the species, marked, rather than a bare QUERY among named rows', async () => {
|
|
159
|
+
await doLaunchOrthologs({ self: makeModel(params()) })
|
|
160
|
+
expect(queryRowName()).toBe('human_query')
|
|
161
|
+
expect(setQuerySeqName).toHaveBeenCalledWith('human_query')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('cannot collide with an ortholog row that sanitizes to the same token', async () => {
|
|
165
|
+
mockFetchRows.mockResolvedValue([
|
|
166
|
+
{ label: 'human_query', sequence: 'MM' },
|
|
167
|
+
] as OrthologRow[])
|
|
168
|
+
await doLaunchOrthologs({ self: makeModel(params()) })
|
|
169
|
+
expect(queryRowName()).toBe('human_query_2')
|
|
170
|
+
expect(setQuerySeqName).toHaveBeenCalledWith('human_query_2')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('falls back rather than throwing when NCBI cannot name the taxon', async () => {
|
|
174
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
175
|
+
mockFetchTaxonomy.mockRejectedValue(new Error('429'))
|
|
176
|
+
await doLaunchOrthologs({ self: makeModel(params()) })
|
|
177
|
+
expect(queryRowName()).toBe('query_query')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test('the metadata that drives the domain overlay is keyed to that same name', async () => {
|
|
181
|
+
const result = await doLaunchOrthologs({ self: makeModel(params()) })
|
|
182
|
+
expect(Object.keys(JSON.parse(result.treeMetadata))).toContain(
|
|
183
|
+
'human_query',
|
|
184
|
+
)
|
|
97
185
|
})
|
|
98
186
|
})
|
|
99
187
|
|
|
100
|
-
describe('the
|
|
188
|
+
describe('the query row sequence', () => {
|
|
101
189
|
test('omitted proteinSequence falls back to the representative protein', async () => {
|
|
102
190
|
await doLaunchOrthologs({ self: makeModel(params()) })
|
|
103
191
|
expect(queryRowSent()).toBe(REPRESENTATIVE.sequence)
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { cleanProteinSequence } from '../LaunchMsaView/util'
|
|
2
2
|
import { launchMSA } from '../utils/msa'
|
|
3
3
|
import {
|
|
4
|
-
|
|
4
|
+
dedupeLabels,
|
|
5
5
|
fetchOrthologRows,
|
|
6
6
|
fetchProteinForGene,
|
|
7
7
|
resolveGeneId,
|
|
8
8
|
} from '../utils/ncbiOrthologs'
|
|
9
|
+
import { fetchTaxonomyInfo } from '../utils/taxonomyNames'
|
|
9
10
|
|
|
10
11
|
import type { JBrowsePluginMsaViewModel } from './model'
|
|
11
12
|
import type { OrthologRow } from '../utils/ncbiOrthologs'
|
|
@@ -30,8 +31,14 @@ export async function doLaunchOrthologs({
|
|
|
30
31
|
}: {
|
|
31
32
|
self: JBrowsePluginMsaViewModel
|
|
32
33
|
}) {
|
|
33
|
-
const {
|
|
34
|
-
|
|
34
|
+
const {
|
|
35
|
+
taxId,
|
|
36
|
+
taxa,
|
|
37
|
+
maxSpecies,
|
|
38
|
+
geneCandidates,
|
|
39
|
+
msaAlgorithm,
|
|
40
|
+
proteinSequence,
|
|
41
|
+
} = self.orthologParams!
|
|
35
42
|
|
|
36
43
|
const onProgress = (arg: string) => {
|
|
37
44
|
self.setProgress(arg)
|
|
@@ -61,21 +68,40 @@ export async function doLaunchOrthologs({
|
|
|
61
68
|
)
|
|
62
69
|
}
|
|
63
70
|
|
|
64
|
-
// Every species
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
const wantedTaxa = taxa ?? COMMON_SPECIES.map(s => s.taxId as number)
|
|
69
|
-
// the query species is represented by the query row above
|
|
70
|
-
const wanted = new Set(wantedTaxa.filter(t => t !== taxId))
|
|
71
|
+
// Every species NCBI has an ortholog for, when a launch names none, capped at
|
|
72
|
+
// maxSpecies. A launch that wants specific species lists them; one that just
|
|
73
|
+
// wants "this gene across species" gets NCBI's own order, which leads with the
|
|
74
|
+
// reference organisms.
|
|
71
75
|
const rows = await fetchOrthologRows({
|
|
72
76
|
geneId: resolved.geneId,
|
|
73
|
-
taxa:
|
|
77
|
+
taxa: taxa ? new Set(taxa) : undefined,
|
|
78
|
+
// the query species is represented by the query row above
|
|
79
|
+
exclude: taxId,
|
|
80
|
+
limit: maxSpecies,
|
|
74
81
|
onProgress,
|
|
75
82
|
})
|
|
76
83
|
|
|
84
|
+
// The query row is named for its species like every other row, with a suffix
|
|
85
|
+
// marking it as the one the genome view is linked to. A bare `QUERY` among
|
|
86
|
+
// ninety-nine named species reads as a row whose species failed to resolve,
|
|
87
|
+
// and there is nothing else in the picture saying which row the hover
|
|
88
|
+
// highlight travels through -- react-msaview has no notion of a query row, it
|
|
89
|
+
// only looks one up by name.
|
|
90
|
+
//
|
|
91
|
+
// Deduped against the ortholog labels rather than assumed unique: the query
|
|
92
|
+
// taxon is excluded from that set, but a subspecies can sanitize to the same
|
|
93
|
+
// token, and a collision would silently point the coordinate mapping at
|
|
94
|
+
// another animal's row.
|
|
95
|
+
const queryLabel = await queryRowLabel(taxId, rows)
|
|
96
|
+
self.setQuerySeqName(queryLabel)
|
|
97
|
+
|
|
77
98
|
const treeMetadata: Record<string, Record<string, string>> = {
|
|
78
|
-
|
|
99
|
+
[queryLabel]: buildQueryMetadata(
|
|
100
|
+
self,
|
|
101
|
+
resolved.geneId,
|
|
102
|
+
cleanedSeq,
|
|
103
|
+
representative,
|
|
104
|
+
),
|
|
79
105
|
}
|
|
80
106
|
for (const row of rows) {
|
|
81
107
|
treeMetadata[row.label] = buildRowMetadata(row)
|
|
@@ -84,7 +110,7 @@ export async function doLaunchOrthologs({
|
|
|
84
110
|
const result = await launchMSA({
|
|
85
111
|
algorithm: msaAlgorithm,
|
|
86
112
|
sequence: [
|
|
87
|
-
|
|
113
|
+
`>${queryLabel}\n${cleanedSeq}`,
|
|
88
114
|
...rows.map(r => `>${r.label}\n${r.sequence}`),
|
|
89
115
|
].join('\n'),
|
|
90
116
|
onProgress,
|
|
@@ -96,6 +122,23 @@ export async function doLaunchOrthologs({
|
|
|
96
122
|
}
|
|
97
123
|
}
|
|
98
124
|
|
|
125
|
+
/**
|
|
126
|
+
* `<species>_query`, unique against the ortholog labels. Falls back to the bare
|
|
127
|
+
* marker when NCBI cannot name the taxon, which is a naming failure and must not
|
|
128
|
+
* take down the launch.
|
|
129
|
+
*/
|
|
130
|
+
async function queryRowLabel(taxId: number, rows: OrthologRow[]) {
|
|
131
|
+
let name: string | undefined
|
|
132
|
+
try {
|
|
133
|
+
const info = (await fetchTaxonomyInfo([taxId])).get(taxId)
|
|
134
|
+
name = info?.commonName ?? info?.sciname
|
|
135
|
+
} catch (e) {
|
|
136
|
+
console.warn('[msaview-orthologs] taxonomy name lookup failed:', e)
|
|
137
|
+
}
|
|
138
|
+
return dedupeLabels([...rows.map(r => r.label), `${name ?? 'query'}_query`])
|
|
139
|
+
.at(-1)!
|
|
140
|
+
}
|
|
141
|
+
|
|
99
142
|
/**
|
|
100
143
|
* A failed lookup only costs the query row its domain overlay and, for a launch
|
|
101
144
|
* that supplied no sequence of its own, the alignment — so it is reported by
|
|
@@ -56,10 +56,17 @@ export interface OrthologParams {
|
|
|
56
56
|
taxId: number
|
|
57
57
|
/**
|
|
58
58
|
* taxon ids to include as rows (the query taxon is represented by QUERY).
|
|
59
|
-
* Omitted means every species
|
|
60
|
-
* launch that just wants "this gene across species" wants.
|
|
59
|
+
* Omitted means every species NCBI has an ortholog for, in its report order,
|
|
60
|
+
* which is what a launch that just wants "this gene across species" wants.
|
|
61
61
|
*/
|
|
62
62
|
taxa?: number[]
|
|
63
|
+
/**
|
|
64
|
+
* how many ortholog rows to align, `defaultMaxSpecies` when omitted. The
|
|
65
|
+
* aligner is what this bounds: EBI runs at roughly half a second per row for
|
|
66
|
+
* a ~1400aa protein, so a gene with 865 orthologs is a 7 minute job at no
|
|
67
|
+
* cap.
|
|
68
|
+
*/
|
|
69
|
+
maxSpecies?: number
|
|
63
70
|
/** candidate gene identifiers off the feature, tried in order */
|
|
64
71
|
geneCandidates: string[]
|
|
65
72
|
msaAlgorithm: MsaAlgorithm
|