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.
Files changed (35) hide show
  1. package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +14 -34
  2. package/dist/LaunchMsaView/components/OrthologQuery/QuerySpeciesSelect.d.ts +15 -0
  3. package/dist/LaunchMsaView/components/OrthologQuery/QuerySpeciesSelect.js +67 -0
  4. package/dist/LaunchMsaView/index.js +19 -1
  5. package/dist/MsaViewPanel/doLaunchOrthologs.js +43 -12
  6. package/dist/MsaViewPanel/doLaunchOrthologs.test.js +93 -19
  7. package/dist/MsaViewPanel/model.d.ts +19 -8
  8. package/dist/jbrowse-plugin-msaview.umd.production.min.js +30 -32
  9. package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
  10. package/dist/utils/ebiJobDispatcher.js +26 -1
  11. package/dist/utils/ebiJobDispatcher.test.d.ts +1 -0
  12. package/dist/utils/ebiJobDispatcher.test.js +46 -0
  13. package/dist/utils/eutils.d.ts +9 -0
  14. package/dist/utils/eutils.js +18 -0
  15. package/dist/utils/ncbiOrthologs.d.ts +17 -75
  16. package/dist/utils/ncbiOrthologs.js +67 -63
  17. package/dist/utils/ncbiOrthologs.test.js +82 -2
  18. package/dist/utils/ncbiTaxonomy.d.ts +11 -0
  19. package/dist/utils/ncbiTaxonomy.js +33 -0
  20. package/dist/version.d.ts +1 -1
  21. package/dist/version.js +1 -1
  22. package/package.json +3 -3
  23. package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +33 -71
  24. package/src/LaunchMsaView/components/OrthologQuery/QuerySpeciesSelect.tsx +92 -0
  25. package/src/LaunchMsaView/index.ts +20 -2
  26. package/src/MsaViewPanel/doLaunchOrthologs.test.ts +109 -21
  27. package/src/MsaViewPanel/doLaunchOrthologs.ts +56 -13
  28. package/src/MsaViewPanel/model.ts +9 -2
  29. package/src/utils/ebiJobDispatcher.test.ts +54 -0
  30. package/src/utils/ebiJobDispatcher.ts +32 -3
  31. package/src/utils/eutils.ts +19 -0
  32. package/src/utils/ncbiOrthologs.test.ts +96 -2
  33. package/src/utils/ncbiOrthologs.ts +83 -71
  34. package/src/utils/ncbiTaxonomy.ts +37 -0
  35. package/src/version.ts +1 -1
@@ -1,12 +1,13 @@
1
1
  import React, { useMemo, useState } from 'react'
2
2
 
3
- import { Checkbox, FormControlLabel, MenuItem, Typography } from '@mui/material'
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 { COMMON_SPECIES } from '../../../utils/ncbiOrthologs'
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 [excluded, setExcluded] = useState<number[]>([])
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 taxa = COMMON_SPECIES.map(s => s.taxId).filter(
70
- t => !excluded.includes(t),
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
- to pick, and that is the seconds-against-minutes comparison; the
78
- rest (species labels, CDD overlay, the query row being the selected
79
- transcript) is visible in the result or documented, and as prose it
80
- was most of the dialog's height. */}
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&apos;s precomputed orthologs, one gene per species, aligned at EBI
83
- in seconds rather than the 10+ minutes BLAST takes.
68
+ NCBI&apos;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
- <TextField2
88
- variant="outlined"
89
- label="Query species"
73
+ <QuerySpeciesSelect
90
74
  className={classes.selectField}
91
- select
92
75
  value={taxId}
93
- onChange={event => {
94
- setTaxId(Number(event.target.value))
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
- <Typography variant="subtitle2" style={{ marginTop: 8 }}>
113
- Species to include (those without an ortholog are skipped)
114
- </Typography>
115
- <div className={classes.speciesBox}>
116
- {COMMON_SPECIES.map(s => (
117
- <FormControlLabel
118
- className={classes.species}
119
- key={s.taxId}
120
- control={
121
- <Checkbox
122
- checked={!excluded.includes(s.taxId)}
123
- onChange={event => {
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 || taxa.length < 2}
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
- taxa,
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 && self.isGeneLike
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
- COMMON_SPECIES,
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 taxa get asked for, what
17
- // becomes the QUERY row, and whether the row earns the Accession that drives the
18
- // CDD overlay -- so the real COMMON_SPECIES list and the real
19
- // cleanProteinSequence stay in the picture.
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 taxa
54
- // default is observable.
55
- function taxaAskedFor() {
56
- return [...mockFetchRows.mock.calls[0]![0].taxa].sort((a, b) => a - b)
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).QUERY as Record<string, string>
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('taxa', () => {
78
- test('omitted asks for every species the dialog offers, less the query', async () => {
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(taxaAskedFor()).toEqual(
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, less the query', async () => {
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(taxaAskedFor()).toEqual([9615, 10090])
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(taxaAskedFor()).toEqual([])
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 QUERY row', () => {
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
- COMMON_SPECIES,
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 { taxId, taxa, geneCandidates, msaAlgorithm, proteinSequence } =
34
- self.orthologParams!
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 the panel offers, when a launch names none. A spec that wants
65
- // a narrower comparison says so; one that just wants "this gene across
66
- // species" should not have to enumerate the list the dialog would have
67
- // checked for it.
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: wanted,
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
- QUERY: buildQueryMetadata(self, resolved.geneId, cleanedSeq, representative),
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
- `>QUERY\n${cleanedSeq}`,
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 the launch dialog offers, which is what a
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