jbrowse-plugin-msaview 2.7.2 → 2.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/AddHighlightModel/MsaToGenomeHighlight.js +6 -5
  2. package/dist/LaunchMsaView/components/LaunchMsaViewDialog.js +7 -1
  3. package/dist/LaunchMsaView/components/NCBIBlastQuery/CachedBlastResults.js +1 -1
  4. package/dist/LaunchMsaView/components/NCBIBlastQuery/useCachedBlastResults.d.ts +1 -1
  5. package/dist/LaunchMsaView/components/NCBIBlastQuery/useCachedBlastResults.js +5 -2
  6. package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.d.ts +8 -0
  7. package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +86 -0
  8. package/dist/LaunchMsaView/components/OrthologQuery/orthologLaunchView.d.ts +9 -0
  9. package/dist/LaunchMsaView/components/OrthologQuery/orthologLaunchView.js +13 -0
  10. package/dist/MsaViewPanel/afterCreateAutoruns.d.ts +8 -0
  11. package/dist/MsaViewPanel/afterCreateAutoruns.js +28 -0
  12. package/dist/MsaViewPanel/doLaunchBlast.js +10 -22
  13. package/dist/MsaViewPanel/doLaunchOrthologs.d.ts +23 -0
  14. package/dist/MsaViewPanel/doLaunchOrthologs.js +97 -0
  15. package/dist/MsaViewPanel/genomeToMSA.js +13 -6
  16. package/dist/MsaViewPanel/genomeToMSA.test.js +35 -6
  17. package/dist/MsaViewPanel/model.d.ts +88 -70
  18. package/dist/MsaViewPanel/model.js +26 -13
  19. package/dist/MsaViewPanel/msaCoordToGenomeCoord.d.ts +32 -13
  20. package/dist/MsaViewPanel/msaCoordToGenomeCoord.js +43 -14
  21. package/dist/MsaViewPanel/msaCoordToGenomeCoord.test.js +108 -18
  22. package/dist/MsaViewPanel/msaDataStore.js +8 -17
  23. package/dist/MsaViewPanel/syncGenomeHoverToMsaColumn.test.js +8 -6
  24. package/dist/jbrowse-plugin-msaview.umd.production.min.js +35 -31
  25. package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
  26. package/dist/utils/blastCache.d.ts +1 -2
  27. package/dist/utils/blastCache.js +9 -22
  28. package/dist/utils/domainCache.js +6 -15
  29. package/dist/utils/idb.d.ts +12 -0
  30. package/dist/utils/idb.js +21 -0
  31. package/dist/utils/ncbiOrthologs.d.ts +105 -0
  32. package/dist/utils/ncbiOrthologs.js +211 -0
  33. package/dist/utils/ncbiOrthologs.test.d.ts +1 -0
  34. package/dist/utils/ncbiOrthologs.test.js +41 -0
  35. package/dist/utils/taxonomyNames.js +13 -18
  36. package/dist/version.d.ts +1 -1
  37. package/dist/version.js +1 -1
  38. package/package.json +3 -3
  39. package/src/AddHighlightModel/MsaToGenomeHighlight.tsx +6 -8
  40. package/src/LaunchMsaView/components/LaunchMsaViewDialog.tsx +13 -2
  41. package/src/LaunchMsaView/components/NCBIBlastQuery/CachedBlastResults.tsx +1 -1
  42. package/src/LaunchMsaView/components/NCBIBlastQuery/useCachedBlastResults.ts +4 -2
  43. package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +172 -0
  44. package/src/LaunchMsaView/components/OrthologQuery/orthologLaunchView.ts +28 -0
  45. package/src/MsaViewPanel/afterCreateAutoruns.ts +27 -0
  46. package/src/MsaViewPanel/doLaunchBlast.ts +20 -29
  47. package/src/MsaViewPanel/doLaunchOrthologs.ts +123 -0
  48. package/src/MsaViewPanel/genomeToMSA.test.ts +38 -6
  49. package/src/MsaViewPanel/genomeToMSA.ts +14 -6
  50. package/src/MsaViewPanel/model.ts +41 -12
  51. package/src/MsaViewPanel/msaCoordToGenomeCoord.test.ts +117 -18
  52. package/src/MsaViewPanel/msaCoordToGenomeCoord.ts +70 -26
  53. package/src/MsaViewPanel/msaDataStore.ts +17 -17
  54. package/src/MsaViewPanel/syncGenomeHoverToMsaColumn.test.ts +8 -6
  55. package/src/utils/blastCache.ts +20 -23
  56. package/src/utils/domainCache.ts +14 -18
  57. package/src/utils/idb.ts +28 -0
  58. package/src/utils/ncbiOrthologs.test.ts +56 -0
  59. package/src/utils/ncbiOrthologs.ts +320 -0
  60. package/src/utils/taxonomyNames.ts +22 -24
  61. package/src/version.ts +1 -1
  62. package/dist/MsaViewPanel/blosum62.d.ts +0 -2
  63. package/dist/MsaViewPanel/blosum62.js +0 -627
  64. package/src/MsaViewPanel/blosum62.ts +0 -628
@@ -29,6 +29,5 @@ export declare function saveBlastResult({ proteinSequence, blastDatabase, blastP
29
29
  transcriptName?: string;
30
30
  geneName?: string;
31
31
  }): Promise<CachedBlastResult>;
32
- export declare function getAllCachedResults(): Promise<any[]>;
32
+ export declare function getAllCachedResults(): Promise<CachedBlastResult[]>;
33
33
  export declare function deleteCachedResult(id: string): Promise<void>;
34
- export declare function clearAllCachedResults(): Promise<void>;
@@ -1,24 +1,15 @@
1
- import { openDB } from 'idb';
1
+ import { createDbOpener } from './idb';
2
2
  const DB_NAME = 'jbrowse-msaview-blast-cache';
3
3
  const STORE_NAME = 'blast-results';
4
4
  const DB_VERSION = 2;
5
- let dbPromise;
6
- function getDB() {
7
- dbPromise ??= openDB(DB_NAME, DB_VERSION, {
8
- upgrade(db, oldVersion) {
9
- if (oldVersion < 2 && db.objectStoreNames.contains(STORE_NAME)) {
10
- db.deleteObjectStore(STORE_NAME);
11
- }
12
- if (!db.objectStoreNames.contains(STORE_NAME)) {
13
- db.createObjectStore(STORE_NAME, { keyPath: 'id' });
14
- }
15
- },
16
- }).catch((e) => {
17
- dbPromise = undefined;
18
- throw e;
19
- });
20
- return dbPromise;
21
- }
5
+ const getDB = createDbOpener(DB_NAME, DB_VERSION, (db, oldVersion) => {
6
+ if (oldVersion < 2 && db.objectStoreNames.contains(STORE_NAME)) {
7
+ db.deleteObjectStore(STORE_NAME);
8
+ }
9
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
10
+ db.createObjectStore(STORE_NAME, { keyPath: 'id' });
11
+ }
12
+ });
22
13
  function createCacheKey(proteinSequence, blastDatabase, blastProgram, msaAlgorithm, transcriptId) {
23
14
  const idPart = transcriptId ? `:${transcriptId}` : '';
24
15
  // msaAlgorithm is part of the key because the stored msa/tree are produced by
@@ -57,7 +48,3 @@ export async function deleteCachedResult(id) {
57
48
  const db = await getDB();
58
49
  await db.delete(STORE_NAME, id);
59
50
  }
60
- export async function clearAllCachedResults() {
61
- const db = await getDB();
62
- await db.clear(STORE_NAME);
63
- }
@@ -1,21 +1,12 @@
1
- import { openDB } from 'idb';
1
+ import { createDbOpener } from './idb';
2
2
  const DB_NAME = 'jbrowse-msaview-domain-cache';
3
3
  const STORE_NAME = 'domains';
4
4
  const DB_VERSION = 1;
5
- let dbPromise;
6
- function getDB() {
7
- dbPromise ??= openDB(DB_NAME, DB_VERSION, {
8
- upgrade(db) {
9
- if (!db.objectStoreNames.contains(STORE_NAME)) {
10
- db.createObjectStore(STORE_NAME, { keyPath: 'accession' });
11
- }
12
- },
13
- }).catch((e) => {
14
- dbPromise = undefined;
15
- throw e;
16
- });
17
- return dbPromise;
18
- }
5
+ const getDB = createDbOpener(DB_NAME, DB_VERSION, db => {
6
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
7
+ db.createObjectStore(STORE_NAME, { keyPath: 'accession' });
8
+ }
9
+ });
19
10
  export async function getCachedDomains(accessions) {
20
11
  const db = await getDB();
21
12
  const tx = db.transaction(STORE_NAME, 'readonly');
@@ -0,0 +1,12 @@
1
+ import type { DBSchema, IDBPDatabase, OpenDBCallbacks } from 'idb';
2
+ /**
3
+ * Memoized `openDB` for a typed schema, shared by this plugin's caches.
4
+ *
5
+ * The connection promise is cached so callers share one connection, and dropped
6
+ * again if the open fails, so a later call retries instead of replaying the same
7
+ * rejection forever (IndexedDB is unavailable in some private-browsing modes).
8
+ *
9
+ * Passing a DBSchema is what keeps `get`/`getAll` from returning `any`: with an
10
+ * untyped database every cached record reaches the UI unchecked.
11
+ */
12
+ export declare function createDbOpener<T extends DBSchema>(name: string, version: number, upgrade: OpenDBCallbacks<T>['upgrade']): () => Promise<IDBPDatabase<T>>;
@@ -0,0 +1,21 @@
1
+ import { openDB } from 'idb';
2
+ /**
3
+ * Memoized `openDB` for a typed schema, shared by this plugin's caches.
4
+ *
5
+ * The connection promise is cached so callers share one connection, and dropped
6
+ * again if the open fails, so a later call retries instead of replaying the same
7
+ * rejection forever (IndexedDB is unavailable in some private-browsing modes).
8
+ *
9
+ * Passing a DBSchema is what keeps `get`/`getAll` from returning `any`: with an
10
+ * untyped database every cached record reaches the UI unchecked.
11
+ */
12
+ export function createDbOpener(name, version, upgrade) {
13
+ let dbPromise;
14
+ return () => {
15
+ dbPromise ??= openDB(name, version, { upgrade }).catch((e) => {
16
+ dbPromise = undefined;
17
+ throw e;
18
+ });
19
+ return dbPromise;
20
+ };
21
+ }
@@ -0,0 +1,105 @@
1
+ export declare const COMMON_SPECIES: readonly [{
2
+ readonly label: "Human";
3
+ readonly taxId: 9606;
4
+ }, {
5
+ readonly label: "Mouse";
6
+ readonly taxId: 10090;
7
+ }, {
8
+ readonly label: "Rat";
9
+ readonly taxId: 10116;
10
+ }, {
11
+ readonly label: "Cow";
12
+ readonly taxId: 9913;
13
+ }, {
14
+ readonly label: "Pig";
15
+ readonly taxId: 9823;
16
+ }, {
17
+ readonly label: "Dog";
18
+ readonly taxId: 9615;
19
+ }, {
20
+ readonly label: "Chicken";
21
+ readonly taxId: 9031;
22
+ }, {
23
+ readonly label: "Frog";
24
+ readonly taxId: 8364;
25
+ }, {
26
+ readonly label: "Zebrafish";
27
+ readonly taxId: 7955;
28
+ }, {
29
+ readonly label: "Fruitfly";
30
+ readonly taxId: 7227;
31
+ }, {
32
+ readonly label: "C. elegans";
33
+ readonly taxId: 6239;
34
+ }, {
35
+ readonly label: "Yeast";
36
+ readonly taxId: 4932;
37
+ }, {
38
+ readonly label: "Arabidopsis";
39
+ readonly taxId: 3702;
40
+ }];
41
+ export declare const COMMON_TAX_RANK: Map<number, number>;
42
+ export interface OrthologRow {
43
+ taxId: number;
44
+ /** single-token id used identically in the FASTA, the tree and the domain GFF */
45
+ label: string;
46
+ scientificName: string;
47
+ commonName?: string;
48
+ geneId: string;
49
+ /** accession.version */
50
+ protein: string;
51
+ sequence: string;
52
+ }
53
+ /**
54
+ * A free-text gene reference -> NCBI gene id. A bare number is taken as the id
55
+ * itself; anything else is searched as a gene name within the query taxon.
56
+ * Several candidate identifiers are tried in order, because a JBrowse feature
57
+ * carries whatever its GFF/BigBed had — `id()`, `name`, `gene_name` — and only
58
+ * some of those are real symbols.
59
+ */
60
+ export declare function resolveGeneId(candidates: string[], taxId: number): Promise<{
61
+ geneId: string;
62
+ matched: string;
63
+ } | undefined>;
64
+ /** One ortholog gene per species, restricted to the requested taxa. */
65
+ export declare function fetchOrthologGenes(geneId: string, taxa: Set<number>): Promise<{
66
+ taxId: number;
67
+ geneId: string;
68
+ scientificName: string;
69
+ commonName?: string;
70
+ }[]>;
71
+ /**
72
+ * geneId -> representative protein accession: MANE Select where flagged, else
73
+ * the longest isoform. A stable, comparable choice across species — picking
74
+ * "the first" would silently vary with NCBI's ordering.
75
+ */
76
+ export declare function fetchRepresentativeProteins(geneIds: string[]): Promise<Map<string, string>>;
77
+ /** accession (first header token) -> ungapped sequence, from a multi-FASTA. */
78
+ export declare function parseFasta(text: string): Map<string, string>;
79
+ /**
80
+ * Sanitized, unique single-token labels used identically in the FASTA headers,
81
+ * the tree leaf names and the domain GFF seq_ids — that identity is how the
82
+ * viewer pairs a tree leaf to its alignment row to its domain track. Collisions
83
+ * get a numeric suffix rather than silently overwriting a row.
84
+ */
85
+ export declare function dedupeLabels(names: string[]): string[];
86
+ /**
87
+ * The representative protein for a single gene, with its sequence. Used to
88
+ * decide whether the user's own translated transcript is byte-identical to the
89
+ * RefSeq protein — if it is, that accession's precomputed CDD domains apply to
90
+ * the query row exactly, and if it isn't, they would land at an offset.
91
+ */
92
+ export declare function fetchProteinForGene(geneId: string): Promise<{
93
+ accession: string;
94
+ sequence: string;
95
+ } | undefined>;
96
+ /**
97
+ * The whole NCBI half of the pipeline: gene -> ortholog rows carrying labels,
98
+ * accessions and sequences. Everything here is a precomputed lookup, so this
99
+ * returns in seconds rather than the 10+ minutes a BLAST submission costs.
100
+ */
101
+ export declare function fetchOrthologRows({ geneId, taxa, onProgress, }: {
102
+ geneId: string;
103
+ taxa: Set<number>;
104
+ onProgress: (arg: string) => void;
105
+ }): Promise<OrthologRow[]>;
@@ -0,0 +1,211 @@
1
+ // Homolog discovery WITHOUT a search job.
2
+ //
3
+ // The BLAST path answers "what looks like this sequence", which is not the
4
+ // question an MSA row set wants — it wants "what is homologous to this gene,
5
+ // one per species, labelled by species". BLAST then costs 10+ minutes to
6
+ // return a redundant, accession-labelled hit list that has to be deduplicated
7
+ // before it reads. NCBI has already computed the answer: the Datasets
8
+ // orthologs endpoint returns one ortholog gene per species, instantly.
9
+ //
10
+ // gene symbol -> gene id -> orthologs -> a representative protein each ->
11
+ // sequences, all from NCBI, in a handful of requests. The caller aligns them
12
+ // (EBI Clustal Omega, ~10s) and overlays CDD domains, which are already baked
13
+ // into the GenPept records (see ncbiDomains.ts).
14
+ //
15
+ // Mirrors jb2hubs' website/src/components/proteinMsa.ts assembler, trimmed to
16
+ // what the launch dialog needs and using this plugin's fetch/eutils helpers.
17
+ import { NCBI_EMAIL, NCBI_TOOL } from './eutils';
18
+ import { jsonfetch, textfetch } from './fetch';
19
+ // v2, not v2alpha: the alpha path still answers /orthologs but 404s
20
+ // /product_report, so an assembler pointed at it silently resolves zero
21
+ // representative proteins and reports "no orthologs" for every gene.
22
+ const DATASETS = 'https://api.ncbi.nlm.nih.gov/datasets/v2';
23
+ const EUTILS = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils';
24
+ // The species panel offered in the launch dialog, ordered from the reference
25
+ // outward so a run that finds only close relatives still reads as a ladder.
26
+ // Orthologs absent for a given gene are skipped rather than erroring.
27
+ export const COMMON_SPECIES = [
28
+ { label: 'Human', taxId: 9606 },
29
+ { label: 'Mouse', taxId: 10090 },
30
+ { label: 'Rat', taxId: 10116 },
31
+ { label: 'Cow', taxId: 9913 },
32
+ { label: 'Pig', taxId: 9823 },
33
+ { label: 'Dog', taxId: 9615 },
34
+ { label: 'Chicken', taxId: 9031 },
35
+ { label: 'Frog', taxId: 8364 },
36
+ { label: 'Zebrafish', taxId: 7955 },
37
+ { label: 'Fruitfly', taxId: 7227 },
38
+ { label: 'C. elegans', taxId: 6239 },
39
+ { label: 'Yeast', taxId: 4932 },
40
+ { label: 'Arabidopsis', taxId: 3702 },
41
+ ];
42
+ export const COMMON_TAX_RANK = new Map(COMMON_SPECIES.map((s, i) => [s.taxId, i]));
43
+ function ncbiUrl(url) {
44
+ const sep = url.includes('?') ? '&' : '?';
45
+ return `${url}${sep}tool=${NCBI_TOOL}&email=${encodeURIComponent(NCBI_EMAIL)}`;
46
+ }
47
+ /**
48
+ * A free-text gene reference -> NCBI gene id. A bare number is taken as the id
49
+ * itself; anything else is searched as a gene name within the query taxon.
50
+ * Several candidate identifiers are tried in order, because a JBrowse feature
51
+ * carries whatever its GFF/BigBed had — `id()`, `name`, `gene_name` — and only
52
+ * some of those are real symbols.
53
+ */
54
+ export async function resolveGeneId(candidates, taxId) {
55
+ for (const raw of candidates) {
56
+ const query = raw.trim();
57
+ if (!query) {
58
+ continue;
59
+ }
60
+ if (/^\d+$/.test(query)) {
61
+ return { geneId: query, matched: query };
62
+ }
63
+ // strip a version suffix (NM_000546.6) and any GFF ID prefix (gene:TP53)
64
+ const cleaned = query.replace(/^\w+:/, '').replace(/\.\d+$/, '');
65
+ const term = `${cleaned}[Gene Name] AND ${taxId}[taxid]`;
66
+ const json = await jsonfetch(ncbiUrl(`${EUTILS}/esearch.fcgi?db=gene&term=${encodeURIComponent(term)}&retmode=json&retmax=1`));
67
+ const geneId = json.esearchresult?.idlist?.[0];
68
+ if (geneId) {
69
+ return { geneId, matched: cleaned };
70
+ }
71
+ }
72
+ return undefined;
73
+ }
74
+ /** One ortholog gene per species, restricted to the requested taxa. */
75
+ export async function fetchOrthologGenes(geneId, taxa) {
76
+ const json = await jsonfetch(ncbiUrl(`${DATASETS}/gene/id/${geneId}/orthologs?returned_content=COMPLETE`));
77
+ const byTaxon = new Map();
78
+ for (const { gene } of json.reports ?? []) {
79
+ const taxId = Number(gene?.tax_id);
80
+ if (gene?.gene_id && taxa.has(taxId) && !byTaxon.has(taxId)) {
81
+ byTaxon.set(taxId, {
82
+ taxId,
83
+ geneId: gene.gene_id,
84
+ scientificName: gene.taxname ?? String(taxId),
85
+ commonName: gene.common_name,
86
+ });
87
+ }
88
+ }
89
+ return [...byTaxon.values()].sort((a, b) => (COMMON_TAX_RANK.get(a.taxId) ?? Infinity) -
90
+ (COMMON_TAX_RANK.get(b.taxId) ?? Infinity));
91
+ }
92
+ /**
93
+ * geneId -> representative protein accession: MANE Select where flagged, else
94
+ * the longest isoform. A stable, comparable choice across species — picking
95
+ * "the first" would silently vary with NCBI's ordering.
96
+ */
97
+ export async function fetchRepresentativeProteins(geneIds) {
98
+ const byGene = new Map();
99
+ if (geneIds.length > 0) {
100
+ const json = await jsonfetch(ncbiUrl(`${DATASETS}/gene/id/${geneIds.join(',')}/product_report`));
101
+ for (const { product } of json.reports ?? []) {
102
+ const candidates = (product?.transcripts ?? [])
103
+ .map(t => ({
104
+ acc: t.protein?.accession_version,
105
+ len: t.protein?.length ?? 0,
106
+ mane: /select/i.test(t.select_category ?? ''),
107
+ }))
108
+ .filter((c) => !!c.acc);
109
+ const best = candidates.find(c => c.mane) ??
110
+ [...candidates].sort((a, b) => b.len - a.len).at(0);
111
+ if (product?.gene_id && best) {
112
+ byGene.set(product.gene_id, best.acc);
113
+ }
114
+ }
115
+ }
116
+ return byGene;
117
+ }
118
+ /** accession (first header token) -> ungapped sequence, from a multi-FASTA. */
119
+ export function parseFasta(text) {
120
+ const map = new Map();
121
+ let acc;
122
+ let buf = [];
123
+ for (const line of text.split('\n')) {
124
+ if (line.startsWith('>')) {
125
+ if (acc) {
126
+ map.set(acc, buf.join(''));
127
+ }
128
+ acc = line.slice(1).split(/\s+/)[0];
129
+ buf = [];
130
+ }
131
+ else {
132
+ buf.push(line.trim());
133
+ }
134
+ }
135
+ if (acc) {
136
+ map.set(acc, buf.join(''));
137
+ }
138
+ return map;
139
+ }
140
+ function sanitize(name) {
141
+ return name.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
142
+ }
143
+ /**
144
+ * Sanitized, unique single-token labels used identically in the FASTA headers,
145
+ * the tree leaf names and the domain GFF seq_ids — that identity is how the
146
+ * viewer pairs a tree leaf to its alignment row to its domain track. Collisions
147
+ * get a numeric suffix rather than silently overwriting a row.
148
+ */
149
+ export function dedupeLabels(names) {
150
+ const seen = new Map();
151
+ return names.map(name => {
152
+ const base = sanitize(name) || 'row';
153
+ const n = seen.get(base) ?? 0;
154
+ seen.set(base, n + 1);
155
+ return n === 0 ? base : `${base}_${n + 1}`;
156
+ });
157
+ }
158
+ /**
159
+ * The representative protein for a single gene, with its sequence. Used to
160
+ * decide whether the user's own translated transcript is byte-identical to the
161
+ * RefSeq protein — if it is, that accession's precomputed CDD domains apply to
162
+ * the query row exactly, and if it isn't, they would land at an offset.
163
+ */
164
+ export async function fetchProteinForGene(geneId) {
165
+ const acc = (await fetchRepresentativeProteins([geneId])).get(geneId);
166
+ if (!acc) {
167
+ return undefined;
168
+ }
169
+ const seq = parseFasta(await textfetch(ncbiUrl(`${EUTILS}/efetch.fcgi?db=protein&id=${acc}&rettype=fasta&retmode=text`))).get(acc);
170
+ return seq ? { accession: acc, sequence: seq } : undefined;
171
+ }
172
+ /**
173
+ * The whole NCBI half of the pipeline: gene -> ortholog rows carrying labels,
174
+ * accessions and sequences. Everything here is a precomputed lookup, so this
175
+ * returns in seconds rather than the 10+ minutes a BLAST submission costs.
176
+ */
177
+ export async function fetchOrthologRows({ geneId, taxa, onProgress, }) {
178
+ onProgress('Finding orthologs across species...');
179
+ const genes = await fetchOrthologGenes(geneId, taxa);
180
+ if (genes.length < 2) {
181
+ throw new Error(`Only ${genes.length} ortholog(s) found among the selected species — not enough to align`);
182
+ }
183
+ onProgress('Selecting a representative protein per species...');
184
+ const proteinByGene = await fetchRepresentativeProteins(genes.map(g => g.geneId));
185
+ const withProtein = genes.filter(g => proteinByGene.has(g.geneId));
186
+ if (withProtein.length < 2) {
187
+ throw new Error('Could not resolve representative proteins for the orthologs');
188
+ }
189
+ onProgress(`Fetching ${withProtein.length} protein sequences...`);
190
+ const accessions = withProtein.map(g => proteinByGene.get(g.geneId));
191
+ const seqByAcc = parseFasta(await textfetch(ncbiUrl(`${EUTILS}/efetch.fcgi?db=protein&id=${accessions.join(',')}&rettype=fasta&retmode=text`)));
192
+ const labels = dedupeLabels(withProtein.map(g => g.commonName ?? g.scientificName));
193
+ const rows = withProtein
194
+ .map((g, i) => {
195
+ const protein = proteinByGene.get(g.geneId);
196
+ return {
197
+ taxId: g.taxId,
198
+ label: labels[i],
199
+ scientificName: g.scientificName,
200
+ commonName: g.commonName,
201
+ geneId: g.geneId,
202
+ protein,
203
+ sequence: seqByAcc.get(protein) ?? '',
204
+ };
205
+ })
206
+ .filter(r => r.sequence);
207
+ if (rows.length < 2) {
208
+ throw new Error('Could not fetch protein sequences for the orthologs');
209
+ }
210
+ return rows;
211
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,41 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { dedupeLabels, parseFasta } from './ncbiOrthologs';
3
+ describe('dedupeLabels', () => {
4
+ test('sanitizes to single tokens', () => {
5
+ // labels are used identically as FASTA headers, Newick leaf names and GFF
6
+ // seq_ids, so anything that would need quoting in one of those is stripped
7
+ expect(dedupeLabels(['house mouse', 'Norway rat'])).toEqual([
8
+ 'house_mouse',
9
+ 'Norway_rat',
10
+ ]);
11
+ expect(dedupeLabels(['Frog (X. tropicalis)'])).toEqual([
12
+ 'Frog_X_tropicalis',
13
+ ]);
14
+ });
15
+ test('suffixes collisions rather than overwriting a row', () => {
16
+ expect(dedupeLabels(['a b', 'a-b', 'a_b'])).toEqual([
17
+ 'a_b',
18
+ 'a_b_2',
19
+ 'a_b_3',
20
+ ]);
21
+ });
22
+ test('falls back for a name with no usable characters', () => {
23
+ expect(dedupeLabels(['...', '...'])).toEqual(['row', 'row_2']);
24
+ });
25
+ });
26
+ describe('parseFasta', () => {
27
+ test('keys by the first header token and joins wrapped lines', () => {
28
+ const map = parseFasta(['>NP_000537.3 cellular tumor antigen p53', 'MEEP', 'QSDP', ''].join('\n'));
29
+ expect(map.get('NP_000537.3')).toBe('MEEPQSDP');
30
+ });
31
+ test('reads every record of a multi-FASTA', () => {
32
+ const map = parseFasta(['>A one', 'MMM', '>B two', 'KKK', '>C three', 'LLL'].join('\n'));
33
+ expect([...map.keys()]).toEqual(['A', 'B', 'C']);
34
+ expect(map.get('C')).toBe('LLL');
35
+ });
36
+ test('returns nothing for a response that carried no records', () => {
37
+ // efetch answers an unknown accession with an error body, not a 4xx, so a
38
+ // caller that assumed "text back = sequences" would build empty rows
39
+ expect(parseFasta('Error: CEFetchPApplication::proxy_stream()').size).toBe(0);
40
+ });
41
+ });
@@ -1,23 +1,15 @@
1
- import { openDB } from 'idb';
2
1
  import { efetchUrl } from './eutils';
2
+ import { textfetch } from './fetch';
3
+ import { createDbOpener } from './idb';
3
4
  const DB_NAME = 'jbrowse-msaview-taxonomy-cache';
4
5
  const STORE_NAME = 'common-names';
5
6
  const DB_VERSION = 2;
6
- let dbPromise;
7
- function getDB() {
8
- dbPromise ??= openDB(DB_NAME, DB_VERSION, {
9
- upgrade(db) {
10
- if (db.objectStoreNames.contains(STORE_NAME)) {
11
- db.deleteObjectStore(STORE_NAME);
12
- }
13
- db.createObjectStore(STORE_NAME, { keyPath: 'taxid' });
14
- },
15
- }).catch((e) => {
16
- dbPromise = undefined;
17
- throw e;
18
- });
19
- return dbPromise;
20
- }
7
+ const getDB = createDbOpener(DB_NAME, DB_VERSION, db => {
8
+ if (db.objectStoreNames.contains(STORE_NAME)) {
9
+ db.deleteObjectStore(STORE_NAME);
10
+ }
11
+ db.createObjectStore(STORE_NAME, { keyPath: 'taxid' });
12
+ });
21
13
  async function getCachedTaxonomies(taxids) {
22
14
  const db = await getDB();
23
15
  const tx = db.transaction(STORE_NAME, 'readonly');
@@ -59,8 +51,11 @@ export async function fetchTaxonomyInfo(taxids) {
59
51
  const batch = uncachedTaxids.slice(i, i + batchSize);
60
52
  const idsParam = batch.join(',');
61
53
  try {
62
- const response = await fetch(efetchUrl({ db: 'taxonomy', id: idsParam, retmode: 'xml' }));
63
- const text = await response.text();
54
+ // textfetch rather than a bare fetch: an NCBI 429/5xx returns an HTML
55
+ // error body that the regexes below silently find nothing in, so without
56
+ // the status check a throttled batch looks like "these taxa have no
57
+ // names" instead of reporting why
58
+ const text = await textfetch(efetchUrl({ db: 'taxonomy', id: idsParam, retmode: 'xml' }));
64
59
  // Build a map of taxid -> taxon block by finding Taxon elements.
65
60
  // Prefer entries with <LineageEx> (full top-level entries) over nested
66
61
  // entries inside another taxon's LineageEx
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const version = "2.7.2";
1
+ export declare const version = "2.7.4";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const version = '2.7.2';
1
+ export const version = '2.7.4';
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.7.2",
2
+ "version": "2.7.4",
3
3
  "license": "MIT",
4
4
  "name": "jbrowse-plugin-msaview",
5
5
  "repository": {
@@ -43,7 +43,7 @@
43
43
  "eslint-plugin-unicorn": "^72.0.0",
44
44
  "mobx": "^6.16.1",
45
45
  "mobx-react": "^9.2.2",
46
- "msa-parsers": "^5.6.1",
46
+ "msa-parsers": "^5.7.1",
47
47
  "pixelmatch": "^7.2.0",
48
48
  "pngjs": "^7.0.0",
49
49
  "prettier": "^3.9.6",
@@ -51,7 +51,7 @@
51
51
  "puppeteer": "^25.3.0",
52
52
  "react": "^19.2.8",
53
53
  "react-dom": "^19.2.8",
54
- "react-msaview": "^5.6.3",
54
+ "react-msaview": "^5.7.1",
55
55
  "rimraf": "^6.1.3",
56
56
  "rxjs": "^7.8.2",
57
57
  "serve": "^14.2.6",
@@ -23,14 +23,12 @@ const MsaToGenomeHighlight = observer(function MsaToGenomeHighlight2({
23
23
  // The persistent click selection always shows. The hover codon is suppressed
24
24
  // while hovering the LGV — GenomeMouseoverHighlight handles the single-bp
25
25
  // display in that case, so we don't stack a wider codon band on top of it.
26
- const clickHighlight = msaView?.connectedClickHighlight
27
- const hoverHighlight = hasHoverPosition(hovered)
28
- ? undefined
29
- : msaView?.connectedHoverHighlight
30
- const highlights = [clickHighlight, hoverHighlight].filter(
31
- (r): r is { refName: string; start: number; end: number } =>
32
- r !== undefined,
33
- )
26
+ const highlights = [
27
+ ...(msaView?.connectedClickHighlights ?? []),
28
+ ...(hasHoverPosition(hovered)
29
+ ? []
30
+ : (msaView?.connectedHoverHighlights ?? [])),
31
+ ]
34
32
 
35
33
  return highlights.length ? (
36
34
  <MsaToGenomeHighlightRenderer model={model} highlights={highlights} />
@@ -6,6 +6,7 @@ import { Tab, Tabs } from '@mui/material'
6
6
 
7
7
  import ManualMSALoader from './ManualMSALoader/ManualMSALoader'
8
8
  import NCBIBlastPanel from './NCBIBlastQuery/NCBIBlastPanel'
9
+ import OrthologPanel from './OrthologQuery/OrthologPanel'
9
10
  import PreLoadedMSA from './PreLoadedMSA/PreLoadedMSADataPanel'
10
11
  import { readMsaDatasets } from './PreLoadedMSA/types'
11
12
  import TabPanel from './TabPanel'
@@ -25,9 +26,11 @@ export default function LaunchMsaViewDialog({
25
26
  const datasets = readMsaDatasets(session.jbrowse)
26
27
  const hasPreloadedDatasets = !!datasets?.length
27
28
 
29
+ // orthologs first, and the default: it answers the same question in ~10s
30
+ // that BLAST takes 10+ minutes to answer worse (see utils/ncbiOrthologs.ts)
28
31
  const [value, setValue] = useState<
29
- 'ncbi_blast' | 'preloaded_msa' | 'manual_msa'
30
- >('ncbi_blast')
32
+ 'orthologs' | 'ncbi_blast' | 'preloaded_msa' | 'manual_msa'
33
+ >('orthologs')
31
34
 
32
35
  return (
33
36
  <Dialog maxWidth="xl" title="Launch MSA view" open onClose={handleClose}>
@@ -37,12 +40,20 @@ export default function LaunchMsaViewDialog({
37
40
  setValue(newValue)
38
41
  }}
39
42
  >
43
+ <Tab label="Orthologs (fast)" value="orthologs" />
40
44
  <Tab label="NCBI BLAST query" value="ncbi_blast" />
41
45
  {hasPreloadedDatasets ? (
42
46
  <Tab label="Pre-loaded MSA datasets" value="preloaded_msa" />
43
47
  ) : null}
44
48
  <Tab label="Manual upload" value="manual_msa" />
45
49
  </Tabs>
50
+ <TabPanel value={value} index="orthologs">
51
+ <OrthologPanel
52
+ handleClose={handleClose}
53
+ feature={feature}
54
+ model={model}
55
+ />
56
+ </TabPanel>
46
57
  <TabPanel value={value} index="ncbi_blast">
47
58
  <NCBIBlastPanel
48
59
  handleClose={handleClose}
@@ -117,7 +117,7 @@ const CachedBlastResults = observer(function ({
117
117
  }
118
118
  }}
119
119
  >
120
- Clear All
120
+ Clear results for this gene
121
121
  </Button>
122
122
  </div>
123
123
  <List dense className={classes.resultList}>
@@ -1,7 +1,6 @@
1
1
  import useSWR from 'swr'
2
2
 
3
3
  import {
4
- clearAllCachedResults,
5
4
  deleteCachedResult,
6
5
  getAllCachedResults,
7
6
  } from '../../../utils/blastCache'
@@ -30,8 +29,11 @@ export function useCachedBlastResults(geneIds: string[]) {
30
29
  )
31
30
  }
32
31
 
32
+ // deletes only what this hook listed, i.e. the results for these gene ids.
33
+ // The list the user is looking at is gene-scoped, so a store-wide clear here
34
+ // would silently throw away every other gene's cached alignments too
33
35
  const handleClearAll = async () => {
34
- await clearAllCachedResults()
36
+ await Promise.all((results ?? []).map(r => deleteCachedResult(r.id)))
35
37
  await mutate([], false)
36
38
  }
37
39