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.
- package/dist/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.js +1 -1
- package/dist/LaunchMsaView/components/BlastQuery/BlastManualPanel.js +1 -1
- package/dist/LaunchMsaView/components/BlastQuery/blastLaunchView.js +6 -4
- package/dist/LaunchMsaView/components/ManualMSALoader/ManualMSALoader.js +1 -1
- package/dist/LaunchMsaView/components/ManualMSALoader/launchView.js +4 -2
- package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +8 -3
- package/dist/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.d.ts +9 -0
- package/dist/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.js +20 -0
- package/dist/LaunchMsaView/components/OrthologQuery/orthologLaunchView.js +4 -2
- package/dist/LaunchMsaView/components/PreLoadedMSA/PreLoadedMSADataPanel.js +1 -1
- package/dist/LaunchMsaView/components/PreLoadedMSA/preCalculatedLaunchView.js +4 -2
- package/dist/LaunchMsaView/components/SubmitCancelActions.d.ts +4 -1
- package/dist/LaunchMsaView/components/SubmitCancelActions.js +34 -10
- package/dist/LaunchMsaView/components/SubmitCancelActions.test.d.ts +1 -0
- package/dist/LaunchMsaView/components/SubmitCancelActions.test.js +55 -0
- package/dist/LaunchMsaViewExtensionPoint/index.js +2 -2
- package/dist/MsaViewPanel/doLaunchOrthologs.d.ts +7 -5
- package/dist/MsaViewPanel/doLaunchOrthologs.js +64 -30
- package/dist/MsaViewPanel/doLaunchOrthologs.test.js +105 -0
- package/dist/MsaViewPanel/model.d.ts +9 -0
- package/dist/MsaViewPanel/model.js +6 -0
- package/dist/index.js +4 -1
- package/dist/jbrowse-plugin-msaview.umd.production.min.js +30 -30
- package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
- package/dist/utils/launchMsaView.d.ts +19 -0
- package/dist/utils/launchMsaView.js +13 -0
- package/dist/utils/pantherOrthologs.d.ts +79 -0
- package/dist/utils/pantherOrthologs.js +262 -0
- package/dist/utils/workspaces.d.ts +34 -0
- package/dist/utils/workspaces.js +100 -0
- package/dist/utils/workspaces.test.d.ts +1 -0
- package/dist/utils/workspaces.test.js +100 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -1
- package/src/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.tsx +1 -0
- package/src/LaunchMsaView/components/BlastQuery/BlastManualPanel.tsx +1 -0
- package/src/LaunchMsaView/components/BlastQuery/blastLaunchView.ts +7 -4
- package/src/LaunchMsaView/components/ManualMSALoader/ManualMSALoader.tsx +1 -0
- package/src/LaunchMsaView/components/ManualMSALoader/launchView.ts +5 -2
- package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +20 -4
- package/src/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.tsx +52 -0
- package/src/LaunchMsaView/components/OrthologQuery/orthologLaunchView.ts +5 -2
- package/src/LaunchMsaView/components/PreLoadedMSA/PreLoadedMSADataPanel.tsx +1 -0
- package/src/LaunchMsaView/components/PreLoadedMSA/preCalculatedLaunchView.ts +5 -2
- package/src/LaunchMsaView/components/SubmitCancelActions.test.tsx +95 -0
- package/src/LaunchMsaView/components/SubmitCancelActions.tsx +80 -22
- package/src/LaunchMsaViewExtensionPoint/index.ts +21 -2
- package/src/MsaViewPanel/doLaunchOrthologs.test.ts +117 -0
- package/src/MsaViewPanel/doLaunchOrthologs.ts +99 -37
- package/src/MsaViewPanel/model.ts +10 -0
- package/src/index.ts +4 -1
- package/src/utils/launchMsaView.ts +30 -0
- package/src/utils/pantherOrthologs.ts +399 -0
- package/src/utils/workspaces.test.ts +132 -0
- package/src/utils/workspaces.ts +146 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { MsaViewPlacement } from './workspaces';
|
|
2
|
+
import type { AbstractSessionModel } from '@jbrowse/core/util';
|
|
3
|
+
/**
|
|
4
|
+
* A launch, stated: what the view is, and where it goes. Everything but
|
|
5
|
+
* `placement` is a react-msaview or plugin-model snapshot property, passed
|
|
6
|
+
* through untouched so this never becomes a list that has to grow.
|
|
7
|
+
*/
|
|
8
|
+
export interface MsaViewLaunchSpec extends Record<string, unknown> {
|
|
9
|
+
/** default `stack`, the only thing an embedded session can do */
|
|
10
|
+
placement?: MsaViewPlacement;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The one place a launch adds an MSA view -- the dialog's four tabs, the Add
|
|
14
|
+
* menu, and the `LaunchView-MsaView` extension point a session spec arrives on
|
|
15
|
+
* all come through here. Each of them used to run its own `addView` and none
|
|
16
|
+
* placed the result, which is how a launch from a gene feature landed stacked
|
|
17
|
+
* under the very genome view it was connected to.
|
|
18
|
+
*/
|
|
19
|
+
export declare function launchMsaView(session: AbstractSessionModel, { placement, ...snapshot }: MsaViewLaunchSpec): import("@jbrowse/core/util").AbstractViewModel;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { placeMsaView } from './workspaces';
|
|
2
|
+
/**
|
|
3
|
+
* The one place a launch adds an MSA view -- the dialog's four tabs, the Add
|
|
4
|
+
* menu, and the `LaunchView-MsaView` extension point a session spec arrives on
|
|
5
|
+
* all come through here. Each of them used to run its own `addView` and none
|
|
6
|
+
* placed the result, which is how a launch from a gene feature landed stacked
|
|
7
|
+
* under the very genome view it was connected to.
|
|
8
|
+
*/
|
|
9
|
+
export function launchMsaView(session, { placement = 'stack', ...snapshot }) {
|
|
10
|
+
const view = session.addView('MsaView', { type: 'MsaView', ...snapshot });
|
|
11
|
+
placeMsaView(session, view.id, placement);
|
|
12
|
+
return view;
|
|
13
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { OrthologRow } from './ncbiOrthologs';
|
|
2
|
+
export interface PantherGenome {
|
|
3
|
+
/** PANTHER's organism code, e.g. HUMAN, DROME */
|
|
4
|
+
code: string;
|
|
5
|
+
taxId: number;
|
|
6
|
+
/** short common name, e.g. fruit_fly */
|
|
7
|
+
name: string;
|
|
8
|
+
/** scientific name */
|
|
9
|
+
longName: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* `supportedgenomes` -> the code<->taxon map every other parse needs. PANTHER
|
|
13
|
+
* names organisms by code only in ortholog results.
|
|
14
|
+
*/
|
|
15
|
+
export declare function parseGenomes(json: unknown): PantherGenome[];
|
|
16
|
+
export interface PantherGene {
|
|
17
|
+
code: string;
|
|
18
|
+
/** UniProt accession */
|
|
19
|
+
accession: string;
|
|
20
|
+
/** the source database's own id, e.g. HGNC=1773, FlyBase=FBgn0016131 */
|
|
21
|
+
geneRef: string;
|
|
22
|
+
}
|
|
23
|
+
export interface PantherHit extends PantherGene {
|
|
24
|
+
symbol?: string;
|
|
25
|
+
/**
|
|
26
|
+
* LDO = least diverged ortholog, PANTHER's pick of the one-to-one; O = any
|
|
27
|
+
* other ortholog in a one-to-many or many-to-many family
|
|
28
|
+
*/
|
|
29
|
+
type: 'LDO' | 'O';
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* `matchortho` -> the query gene (PANTHER names it in every row) and one hit
|
|
33
|
+
* per target gene. An unknown gene comes back under `unmapped_ids`; a gene with
|
|
34
|
+
* no ortholog in the target set comes back as a bare `{ id }`.
|
|
35
|
+
*/
|
|
36
|
+
export declare function parseMatches(json: unknown): {
|
|
37
|
+
unmapped: boolean;
|
|
38
|
+
query?: PantherGene;
|
|
39
|
+
hits: PantherHit[];
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* One hit per organism, in first-seen order: the LDO where PANTHER named one,
|
|
43
|
+
* else the first other ortholog it listed. A many-to-many family (the Hox
|
|
44
|
+
* genes) has no LDO at all, so dropping to "first O" is what keeps those
|
|
45
|
+
* species in the alignment.
|
|
46
|
+
*/
|
|
47
|
+
export declare function pickOnePerGenome(hits: PantherHit[]): PantherHit[];
|
|
48
|
+
/** `uniprotkb/accessions` -> accession -> sequence */
|
|
49
|
+
export declare function parseSequences(json: unknown): Map<string, string>;
|
|
50
|
+
/** The proteome list, fetched once per page and forgotten on failure. */
|
|
51
|
+
export declare function fetchGenomes(): Promise<PantherGenome[]>;
|
|
52
|
+
export interface PantherOrthologs {
|
|
53
|
+
/** the candidate PANTHER recognised */
|
|
54
|
+
matched: string;
|
|
55
|
+
/** the query gene as PANTHER knows it, with its UniProt sequence */
|
|
56
|
+
query?: PantherGene & {
|
|
57
|
+
sequence: string;
|
|
58
|
+
};
|
|
59
|
+
rows: OrthologRow[];
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The whole PANTHER half of the pipeline: gene -> ortholog rows carrying
|
|
63
|
+
* labels, accessions and sequences, plus the query gene's own protein for the
|
|
64
|
+
* query row. Two lookups (genomes, orthologs), one taxonomy batch for the
|
|
65
|
+
* labels, one UniProt batch for the sequences.
|
|
66
|
+
*
|
|
67
|
+
* `taxa`, `exclude` and `limit` mean what they mean for fetchOrthologRows:
|
|
68
|
+
* `taxa` narrows the targets (omitted, every genome PANTHER has, in its order),
|
|
69
|
+
* `exclude` drops the query taxon, `limit` caps the rows before their
|
|
70
|
+
* sequences are fetched.
|
|
71
|
+
*/
|
|
72
|
+
export declare function fetchPantherOrthologs({ candidates, taxId, taxa, exclude, limit, onProgress, }: {
|
|
73
|
+
candidates: string[];
|
|
74
|
+
taxId: number;
|
|
75
|
+
taxa?: Set<number>;
|
|
76
|
+
exclude?: number;
|
|
77
|
+
limit?: number;
|
|
78
|
+
onProgress: (arg: string) => void;
|
|
79
|
+
}): Promise<PantherOrthologs>;
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// The second ortholog source, for the genes NCBI's ortholog sets leave out.
|
|
2
|
+
//
|
|
3
|
+
// NCBI Datasets computes orthologs for vertebrates and insects, so a yeast,
|
|
4
|
+
// worm or plant gene comes back with orthologs only in its own clade, and a fly
|
|
5
|
+
// gene gets insects and nothing else. PANTHER's ortholog sets span its 144
|
|
6
|
+
// reference proteomes, human to yeast to Arabidopsis, and one `matchortho` call
|
|
7
|
+
// answers "this gene's ortholog in every genome" with a UniProt accession per
|
|
8
|
+
// target. Sequences come from one UniProt batch call. Both hosts send
|
|
9
|
+
// `Access-Control-Allow-Origin: *`. The measurements that picked PANTHER over
|
|
10
|
+
// OMA, OrthoDB and Ensembl are in react-msaview's
|
|
11
|
+
// agent-docs/ideas/ortholog-sources-beyond-ncbi.md.
|
|
12
|
+
//
|
|
13
|
+
// Rows come out in the same shape as ncbiOrthologs.ts's, so the launch, the
|
|
14
|
+
// labels, the aligner and the CDD overlay do not know which source ran.
|
|
15
|
+
// `protein` is the UniProt accession; efetch serves UniProt accessions as
|
|
16
|
+
// GenPept records with CDD Region features, so the overlay attaches as it does
|
|
17
|
+
// to a RefSeq accession.
|
|
18
|
+
import { jsonfetch } from './fetch';
|
|
19
|
+
import { dedupeLabels, defaultMaxSpecies } from './ncbiOrthologs';
|
|
20
|
+
import { fetchTaxonomyInfo } from './taxonomyNames';
|
|
21
|
+
const PANTHER = 'https://pantherdb.org/services/oai/pantherdb';
|
|
22
|
+
const UNIPROT = 'https://rest.uniprot.org/uniprotkb';
|
|
23
|
+
/**
|
|
24
|
+
* `supportedgenomes` -> the code<->taxon map every other parse needs. PANTHER
|
|
25
|
+
* names organisms by code only in ortholog results.
|
|
26
|
+
*/
|
|
27
|
+
export function parseGenomes(json) {
|
|
28
|
+
const list = json.search?.output?.genomes?.genome ?? [];
|
|
29
|
+
return list.flatMap(g => g.short_name && g.taxon_id
|
|
30
|
+
? [
|
|
31
|
+
{
|
|
32
|
+
code: g.short_name,
|
|
33
|
+
taxId: g.taxon_id,
|
|
34
|
+
name: g.name ?? g.short_name,
|
|
35
|
+
longName: g.long_name ?? g.short_name,
|
|
36
|
+
},
|
|
37
|
+
]
|
|
38
|
+
: []);
|
|
39
|
+
}
|
|
40
|
+
// "HUMAN|HGNC=1773|UniProtKB=P11802" -> { code, geneRef, accession }
|
|
41
|
+
function parseGeneRef(ref) {
|
|
42
|
+
const [code, ...xrefs] = (ref ?? '').split('|');
|
|
43
|
+
const accession = xrefs
|
|
44
|
+
.find(x => x.startsWith('UniProtKB='))
|
|
45
|
+
?.slice('UniProtKB='.length);
|
|
46
|
+
const geneRef = xrefs.find(x => !x.startsWith('UniProtKB=')) ?? accession;
|
|
47
|
+
return code && accession && geneRef ? { code, accession, geneRef } : undefined;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* `matchortho` -> the query gene (PANTHER names it in every row) and one hit
|
|
51
|
+
* per target gene. An unknown gene comes back under `unmapped_ids`; a gene with
|
|
52
|
+
* no ortholog in the target set comes back as a bare `{ id }`.
|
|
53
|
+
*/
|
|
54
|
+
export function parseMatches(json) {
|
|
55
|
+
const mapping = json.search?.mapping;
|
|
56
|
+
const mapped = mapping?.mapped;
|
|
57
|
+
const rows = Array.isArray(mapped) ? mapped : mapped ? [mapped] : [];
|
|
58
|
+
const hits = [];
|
|
59
|
+
let query;
|
|
60
|
+
for (const row of rows) {
|
|
61
|
+
query ??= parseGeneRef(row.gene);
|
|
62
|
+
const target = parseGeneRef(row.target_gene);
|
|
63
|
+
if (target && (row.ortholog === 'LDO' || row.ortholog === 'O')) {
|
|
64
|
+
hits.push({
|
|
65
|
+
...target,
|
|
66
|
+
symbol: row.target_gene_symbol === undefined
|
|
67
|
+
? undefined
|
|
68
|
+
: String(row.target_gene_symbol),
|
|
69
|
+
type: row.ortholog,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { unmapped: !!mapping?.unmapped_ids, query, hits };
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* One hit per organism, in first-seen order: the LDO where PANTHER named one,
|
|
77
|
+
* else the first other ortholog it listed. A many-to-many family (the Hox
|
|
78
|
+
* genes) has no LDO at all, so dropping to "first O" is what keeps those
|
|
79
|
+
* species in the alignment.
|
|
80
|
+
*/
|
|
81
|
+
export function pickOnePerGenome(hits) {
|
|
82
|
+
const byCode = new Map();
|
|
83
|
+
for (const hit of hits) {
|
|
84
|
+
const current = byCode.get(hit.code);
|
|
85
|
+
if (!current || (current.type === 'O' && hit.type === 'LDO')) {
|
|
86
|
+
byCode.set(hit.code, hit);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return [...byCode.values()];
|
|
90
|
+
}
|
|
91
|
+
/** `uniprotkb/accessions` -> accession -> sequence */
|
|
92
|
+
export function parseSequences(json) {
|
|
93
|
+
const map = new Map();
|
|
94
|
+
for (const r of json.results ?? []) {
|
|
95
|
+
if (r.primaryAccession && r.sequence?.value) {
|
|
96
|
+
map.set(r.primaryAccession, r.sequence.value);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return map;
|
|
100
|
+
}
|
|
101
|
+
let genomes;
|
|
102
|
+
/** The proteome list, fetched once per page and forgotten on failure. */
|
|
103
|
+
export function fetchGenomes() {
|
|
104
|
+
genomes ??= jsonfetch(`${PANTHER}/supportedgenomes`)
|
|
105
|
+
.then(parseGenomes)
|
|
106
|
+
.catch((e) => {
|
|
107
|
+
genomes = undefined;
|
|
108
|
+
throw e;
|
|
109
|
+
});
|
|
110
|
+
return genomes;
|
|
111
|
+
}
|
|
112
|
+
// UniProt caps one `accessions` call at 100 ids
|
|
113
|
+
const UNIPROT_CHUNK = 100;
|
|
114
|
+
async function fetchSequences(accessions) {
|
|
115
|
+
const map = new Map();
|
|
116
|
+
for (let i = 0; i < accessions.length; i += UNIPROT_CHUNK) {
|
|
117
|
+
const chunk = accessions.slice(i, i + UNIPROT_CHUNK);
|
|
118
|
+
const json = await jsonfetch(`${UNIPROT}/accessions?accessions=${chunk.join(',')}&fields=accession,sequence&format=json`);
|
|
119
|
+
for (const [acc, seq] of parseSequences(json)) {
|
|
120
|
+
map.set(acc, seq);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return map;
|
|
124
|
+
}
|
|
125
|
+
// strip a version suffix (NM_000546.6) and any GFF ID prefix (gene:TP53), as
|
|
126
|
+
// resolveGeneId does for NCBI
|
|
127
|
+
function cleanCandidate(raw) {
|
|
128
|
+
return raw
|
|
129
|
+
.trim()
|
|
130
|
+
.replace(/^\w+:/, '')
|
|
131
|
+
.replace(/\.\d+$/, '');
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* One `matchortho` per candidate until PANTHER maps one. A JBrowse feature
|
|
135
|
+
* carries whatever its GFF/BigBed had — `id()`, `name`, `gene_name` — and only
|
|
136
|
+
* some of those are names PANTHER knows. `targets` omitted asks for every
|
|
137
|
+
* genome PANTHER has, which is one call rather than one per genome.
|
|
138
|
+
*/
|
|
139
|
+
async function matchOrthologs(candidates, taxId, targets) {
|
|
140
|
+
let matched;
|
|
141
|
+
for (const raw of candidates) {
|
|
142
|
+
const query = cleanCandidate(raw);
|
|
143
|
+
if (!query) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const params = new URLSearchParams({
|
|
147
|
+
geneInputList: query,
|
|
148
|
+
organism: String(taxId),
|
|
149
|
+
orthologType: 'all',
|
|
150
|
+
});
|
|
151
|
+
if (targets) {
|
|
152
|
+
params.set('targetOrganism', targets.map(t => t.taxId).join(','));
|
|
153
|
+
}
|
|
154
|
+
const parsed = parseMatches(await jsonfetch(`${PANTHER}/ortholog/matchortho?${params.toString()}`));
|
|
155
|
+
if (!parsed.unmapped) {
|
|
156
|
+
matched ??= query;
|
|
157
|
+
if (parsed.query) {
|
|
158
|
+
return { ...parsed, matched: query };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return matched === undefined
|
|
163
|
+
? undefined
|
|
164
|
+
: { matched, hits: [], query: undefined };
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* The whole PANTHER half of the pipeline: gene -> ortholog rows carrying
|
|
168
|
+
* labels, accessions and sequences, plus the query gene's own protein for the
|
|
169
|
+
* query row. Two lookups (genomes, orthologs), one taxonomy batch for the
|
|
170
|
+
* labels, one UniProt batch for the sequences.
|
|
171
|
+
*
|
|
172
|
+
* `taxa`, `exclude` and `limit` mean what they mean for fetchOrthologRows:
|
|
173
|
+
* `taxa` narrows the targets (omitted, every genome PANTHER has, in its order),
|
|
174
|
+
* `exclude` drops the query taxon, `limit` caps the rows before their
|
|
175
|
+
* sequences are fetched.
|
|
176
|
+
*/
|
|
177
|
+
export async function fetchPantherOrthologs({ candidates, taxId, taxa, exclude, limit = defaultMaxSpecies, onProgress, }) {
|
|
178
|
+
const all = await fetchGenomes();
|
|
179
|
+
const byTaxId = new Map(all.map(g => [g.taxId, g]));
|
|
180
|
+
const byCode = new Map(all.map(g => [g.code, g]));
|
|
181
|
+
const queryGenome = byTaxId.get(taxId);
|
|
182
|
+
if (!queryGenome) {
|
|
183
|
+
throw new Error(`PANTHER has no reference proteome for taxon ${taxId}. NCBI orthologs cover vertebrates and insects; try that source.`);
|
|
184
|
+
}
|
|
185
|
+
const targets = taxa
|
|
186
|
+
? [...taxa].flatMap(t => {
|
|
187
|
+
const g = byTaxId.get(t);
|
|
188
|
+
return g && t !== exclude ? [g] : [];
|
|
189
|
+
})
|
|
190
|
+
: undefined;
|
|
191
|
+
onProgress('Matching orthologs at PANTHER...');
|
|
192
|
+
const match = await matchOrthologs(candidates, taxId, targets);
|
|
193
|
+
if (!match) {
|
|
194
|
+
throw new Error(`PANTHER has no entry for ${candidates.join(', ')} in ${queryGenome.longName}. Try the NCBI BLAST tab, which needs no gene identifier.`);
|
|
195
|
+
}
|
|
196
|
+
const rank = new Map(targets?.map((t, i) => [t.taxId, i]));
|
|
197
|
+
const picks = pickOnePerGenome(match.hits)
|
|
198
|
+
.map(hit => ({ hit, genome: byCode.get(hit.code) }))
|
|
199
|
+
.filter((p) => !!p.genome &&
|
|
200
|
+
p.genome.taxId !== exclude &&
|
|
201
|
+
(targets ? rank.has(p.genome.taxId) : true))
|
|
202
|
+
.sort((a, b) => targets ? rank.get(a.genome.taxId) - rank.get(b.genome.taxId) : 0)
|
|
203
|
+
.slice(0, limit);
|
|
204
|
+
if (picks.length < 2) {
|
|
205
|
+
throw new Error(`Only ${picks.length} PANTHER ortholog(s) found for ${match.matched} — not enough to align`);
|
|
206
|
+
}
|
|
207
|
+
onProgress(`Fetching ${picks.length} protein sequences from UniProt...`);
|
|
208
|
+
const [names, sequences] = await Promise.all([
|
|
209
|
+
taxonomyNames(picks.map(p => p.genome.taxId)),
|
|
210
|
+
fetchSequences([
|
|
211
|
+
...(match.query ? [match.query.accession] : []),
|
|
212
|
+
...picks.map(p => p.hit.accession),
|
|
213
|
+
]),
|
|
214
|
+
]);
|
|
215
|
+
const described = picks.map(({ hit, genome }) => {
|
|
216
|
+
const info = names.get(genome.taxId);
|
|
217
|
+
return {
|
|
218
|
+
hit,
|
|
219
|
+
genome,
|
|
220
|
+
name: info ? (info.commonName ?? info.sciname) : genome.name,
|
|
221
|
+
scientificName: info?.sciname || genome.longName,
|
|
222
|
+
commonName: info?.commonName,
|
|
223
|
+
};
|
|
224
|
+
});
|
|
225
|
+
const labels = dedupeLabels(described.map(d => d.name));
|
|
226
|
+
const rows = described
|
|
227
|
+
.map(({ hit, genome, scientificName, commonName }, i) => ({
|
|
228
|
+
taxId: genome.taxId,
|
|
229
|
+
label: labels[i],
|
|
230
|
+
scientificName,
|
|
231
|
+
commonName,
|
|
232
|
+
geneId: hit.geneRef,
|
|
233
|
+
protein: hit.accession,
|
|
234
|
+
sequence: sequences.get(hit.accession) ?? '',
|
|
235
|
+
}))
|
|
236
|
+
.filter(r => r.sequence);
|
|
237
|
+
if (rows.length < 2) {
|
|
238
|
+
throw new Error('Could not fetch protein sequences for the orthologs');
|
|
239
|
+
}
|
|
240
|
+
const querySequence = match.query && sequences.get(match.query.accession);
|
|
241
|
+
return {
|
|
242
|
+
matched: match.matched,
|
|
243
|
+
query: match.query && querySequence
|
|
244
|
+
? { ...match.query, sequence: querySequence }
|
|
245
|
+
: undefined,
|
|
246
|
+
rows,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* NCBI's names for the taxa, so PANTHER rows are labelled exactly as NCBI rows
|
|
251
|
+
* are. A failed lookup only costs the labels, which fall back to PANTHER's own
|
|
252
|
+
* short names, so it is logged rather than thrown.
|
|
253
|
+
*/
|
|
254
|
+
async function taxonomyNames(taxIds) {
|
|
255
|
+
try {
|
|
256
|
+
return await fetchTaxonomyInfo(taxIds);
|
|
257
|
+
}
|
|
258
|
+
catch (e) {
|
|
259
|
+
console.warn('[msaview-orthologs] taxonomy name lookup failed:', e);
|
|
260
|
+
return new Map();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { AbstractSessionModel } from '@jbrowse/core/util';
|
|
2
|
+
/**
|
|
3
|
+
* Where a launched MSA view lands. Every launch names one of these and stops
|
|
4
|
+
* there, so a host that arranges views differently is one function to teach.
|
|
5
|
+
*
|
|
6
|
+
* stack append below whatever is on screen
|
|
7
|
+
* splitRight its own cell to the right, beside the view it is connected to
|
|
8
|
+
* newTab its own tab in the current cell
|
|
9
|
+
*/
|
|
10
|
+
export type MsaViewPlacement = 'stack' | 'splitRight' | 'newTab';
|
|
11
|
+
/**
|
|
12
|
+
* What the dialog does unasked. Side-by-side, because a launch from a gene
|
|
13
|
+
* feature sets `connectedViewId`: the pair shares a hover and a highlight, and
|
|
14
|
+
* reads as a split. A session spec defaults to `stack` instead.
|
|
15
|
+
*/
|
|
16
|
+
export declare const DEFAULT_LAUNCH_PLACEMENT: MsaViewPlacement;
|
|
17
|
+
export declare const LAUNCH_PLACEMENT_KEY = "msaView-launchPlacement";
|
|
18
|
+
export declare function resetWorkspacesWarning(): void;
|
|
19
|
+
/**
|
|
20
|
+
* Whether this host can honor anything other than `stack`. Silent: the dialog
|
|
21
|
+
* asks on every render, and only a launch is worth warning about.
|
|
22
|
+
*/
|
|
23
|
+
export declare function sessionSupportsPlacement(session: AbstractSessionModel): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Put a freshly added view where the launch said to. `stack` is a placement
|
|
26
|
+
* rather than the absence of one, so no caller has to ask what host it is on.
|
|
27
|
+
*/
|
|
28
|
+
export declare function placeMsaView(session: AbstractSessionModel, viewId: string, placement: MsaViewPlacement): void;
|
|
29
|
+
/**
|
|
30
|
+
* The dialog's own remembered choice — not the host's preferences system, which
|
|
31
|
+
* records whether the user likes workspaces and does not exist everywhere.
|
|
32
|
+
*/
|
|
33
|
+
export declare function readLaunchPlacement(): MsaViewPlacement;
|
|
34
|
+
export declare function writeLaunchPlacement(placement: MsaViewPlacement): void;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const PLACEMENTS = ['stack', 'splitRight', 'newTab'];
|
|
2
|
+
/**
|
|
3
|
+
* What the dialog does unasked. Side-by-side, because a launch from a gene
|
|
4
|
+
* feature sets `connectedViewId`: the pair shares a hover and a highlight, and
|
|
5
|
+
* reads as a split. A session spec defaults to `stack` instead.
|
|
6
|
+
*/
|
|
7
|
+
export const DEFAULT_LAUNCH_PLACEMENT = 'splitRight';
|
|
8
|
+
export const LAUNCH_PLACEMENT_KEY = 'msaView-launchPlacement';
|
|
9
|
+
function hasAction(session, name) {
|
|
10
|
+
return (name in session &&
|
|
11
|
+
typeof session[name] === 'function');
|
|
12
|
+
}
|
|
13
|
+
// Warned at most once. This is a property of the host, so the answer is the
|
|
14
|
+
// same on every launch and a dialog the user reopens should not stack up noise.
|
|
15
|
+
let warnedPartial = false;
|
|
16
|
+
export function resetWorkspacesWarning() {
|
|
17
|
+
warnedPartial = false;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Whether this host can honor anything other than `stack`. Silent: the dialog
|
|
21
|
+
* asks on every render, and only a launch is worth warning about.
|
|
22
|
+
*/
|
|
23
|
+
export function sessionSupportsPlacement(session) {
|
|
24
|
+
return (hasAction(session, 'setUseWorkspaces') &&
|
|
25
|
+
hasAction(session, 'setPendingMove'));
|
|
26
|
+
}
|
|
27
|
+
function isSessionWithWorkspaces(session) {
|
|
28
|
+
const canEnable = hasAction(session, 'setUseWorkspaces');
|
|
29
|
+
const canPlace = hasAction(session, 'setPendingMove');
|
|
30
|
+
// Missing BOTH is an embedded session: it has no workspaces, there is nothing
|
|
31
|
+
// to ask for, and silence is the right answer.
|
|
32
|
+
//
|
|
33
|
+
// Missing ONE is a host that has workspaces but places views some other way,
|
|
34
|
+
// and silence there is how the same feature broke in jbrowse-plugin-protein3d
|
|
35
|
+
// — jbrowse-web folded `setPendingMove` into its layout `init`, the guard went
|
|
36
|
+
// false, and the plugin simply stopped asking: no error, no missing feature,
|
|
37
|
+
// two views quietly stacking, nobody noticed for weeks. Feature detection
|
|
38
|
+
// cannot ask a host to announce a change, but it can tell "not supported here"
|
|
39
|
+
// from "supported, and gone".
|
|
40
|
+
//
|
|
41
|
+
// Two very different hosts produce this one shape and nothing on the session
|
|
42
|
+
// tells them apart, so the message carries both rather than a guess:
|
|
43
|
+
//
|
|
44
|
+
// - releases through v4.3.0, where placement is `setPendingMoveToSplitRight`,
|
|
45
|
+
// a module function in @jbrowse/app-core rather than a session action.
|
|
46
|
+
// Nothing is wrong and nothing needs fixing
|
|
47
|
+
// - a newer host that moved the action out from under us, which is the
|
|
48
|
+
// regression this warning exists to catch
|
|
49
|
+
//
|
|
50
|
+
// Do not quiet the first case by sniffing the version. The alarm is only worth
|
|
51
|
+
// having if it fires on a shape it cannot explain, and these two are identical.
|
|
52
|
+
if (canEnable !== canPlace && !warnedPartial) {
|
|
53
|
+
warnedPartial = true;
|
|
54
|
+
console.warn(`jbrowse-plugin-msaview: this session supports workspaces but not ` +
|
|
55
|
+
`${canPlace ? 'setUseWorkspaces' : 'setPendingMove'}, so the MSA view ` +
|
|
56
|
+
`was stacked instead of tiled. Expected on releases through v4.3.0, ` +
|
|
57
|
+
`which place views through @jbrowse/app-core instead; on a newer host ` +
|
|
58
|
+
`it means the session API moved and the plugin needs updating to match.`);
|
|
59
|
+
}
|
|
60
|
+
return canEnable && canPlace;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Put a freshly added view where the launch said to. `stack` is a placement
|
|
64
|
+
* rather than the absence of one, so no caller has to ask what host it is on.
|
|
65
|
+
*/
|
|
66
|
+
export function placeMsaView(session, viewId, placement) {
|
|
67
|
+
if (placement === 'stack' || !isSessionWithWorkspaces(session)) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
session.setPendingMove({ type: placement, viewId });
|
|
71
|
+
// Session-scoped: turning workspaces on for this session leaves the user's
|
|
72
|
+
// own default alone, which is what `setUseWorkspaces` (as against
|
|
73
|
+
// `setUseWorkspacesPreference`) is for.
|
|
74
|
+
session.setUseWorkspaces(true);
|
|
75
|
+
}
|
|
76
|
+
function isPlacement(value) {
|
|
77
|
+
return PLACEMENTS.includes(value);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The dialog's own remembered choice — not the host's preferences system, which
|
|
81
|
+
* records whether the user likes workspaces and does not exist everywhere.
|
|
82
|
+
*/
|
|
83
|
+
export function readLaunchPlacement() {
|
|
84
|
+
try {
|
|
85
|
+
const stored = globalThis.localStorage.getItem(LAUNCH_PLACEMENT_KEY);
|
|
86
|
+
return isPlacement(stored) ? stored : DEFAULT_LAUNCH_PLACEMENT;
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
console.error(error);
|
|
90
|
+
return DEFAULT_LAUNCH_PLACEMENT;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export function writeLaunchPlacement(placement) {
|
|
94
|
+
try {
|
|
95
|
+
globalThis.localStorage.setItem(LAUNCH_PLACEMENT_KEY, placement);
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
console.error(error);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
|
2
|
+
import { launchMsaView } from './launchMsaView';
|
|
3
|
+
import { DEFAULT_LAUNCH_PLACEMENT, LAUNCH_PLACEMENT_KEY, placeMsaView, readLaunchPlacement, resetWorkspacesWarning, sessionSupportsPlacement, writeLaunchPlacement, } from './workspaces';
|
|
4
|
+
function makeSession({ canPlace = true, canEnable = true, } = {}) {
|
|
5
|
+
const recorded = { moves: [], workspaces: [], added: [] };
|
|
6
|
+
const session = {
|
|
7
|
+
addView(type, snapshot) {
|
|
8
|
+
recorded.added.push({ type, snapshot });
|
|
9
|
+
return { id: `view-${recorded.added.length}` };
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
if (canPlace) {
|
|
13
|
+
session.setPendingMove = (move) => recorded.moves.push(move);
|
|
14
|
+
}
|
|
15
|
+
if (canEnable) {
|
|
16
|
+
session.setUseWorkspaces = (on) => recorded.workspaces.push(on);
|
|
17
|
+
}
|
|
18
|
+
return { session: session, recorded };
|
|
19
|
+
}
|
|
20
|
+
// node has no localStorage, and the plugin runs in a browser -- a Map-backed
|
|
21
|
+
// stub keeps read/write round-tripping without pulling in jsdom
|
|
22
|
+
function stubStorage() {
|
|
23
|
+
const store = new Map();
|
|
24
|
+
vi.stubGlobal('localStorage', {
|
|
25
|
+
getItem: (key) => store.get(key) ?? null,
|
|
26
|
+
setItem: (key, value) => store.set(key, value),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
resetWorkspacesWarning();
|
|
31
|
+
stubStorage();
|
|
32
|
+
});
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
vi.restoreAllMocks();
|
|
35
|
+
vi.unstubAllGlobals();
|
|
36
|
+
});
|
|
37
|
+
test('stack places nothing, on a host that could tile', () => {
|
|
38
|
+
const { session, recorded } = makeSession();
|
|
39
|
+
placeMsaView(session, 'view-1', 'stack');
|
|
40
|
+
expect(recorded.moves).toEqual([]);
|
|
41
|
+
expect(recorded.workspaces).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
test('splitRight asks for the move, then turns workspaces on', () => {
|
|
44
|
+
const { session, recorded } = makeSession();
|
|
45
|
+
placeMsaView(session, 'view-1', 'splitRight');
|
|
46
|
+
expect(recorded.moves).toEqual([{ type: 'splitRight', viewId: 'view-1' }]);
|
|
47
|
+
expect(recorded.workspaces).toEqual([true]);
|
|
48
|
+
});
|
|
49
|
+
test('newTab is the same path with the other move type', () => {
|
|
50
|
+
const { session, recorded } = makeSession();
|
|
51
|
+
placeMsaView(session, 'view-1', 'newTab');
|
|
52
|
+
expect(recorded.moves).toEqual([{ type: 'newTab', viewId: 'view-1' }]);
|
|
53
|
+
});
|
|
54
|
+
// an embedded session has no workspaces at all, so there is nothing to report
|
|
55
|
+
test('a session with neither action is a silent no-op', () => {
|
|
56
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
57
|
+
const { session, recorded } = makeSession({
|
|
58
|
+
canPlace: false,
|
|
59
|
+
canEnable: false,
|
|
60
|
+
});
|
|
61
|
+
placeMsaView(session, 'view-1', 'splitRight');
|
|
62
|
+
expect(recorded.moves).toEqual([]);
|
|
63
|
+
expect(warn).not.toHaveBeenCalled();
|
|
64
|
+
expect(sessionSupportsPlacement(session)).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
// the shape that broke jbrowse-plugin-protein3d silently: workspaces are there,
|
|
67
|
+
// the action this plugin reaches for is not
|
|
68
|
+
test('a half-supported host warns once and stacks', () => {
|
|
69
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
70
|
+
const { session, recorded } = makeSession({ canPlace: false });
|
|
71
|
+
placeMsaView(session, 'view-1', 'splitRight');
|
|
72
|
+
placeMsaView(session, 'view-2', 'splitRight');
|
|
73
|
+
expect(recorded.moves).toEqual([]);
|
|
74
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
75
|
+
expect(warn.mock.calls[0]?.[0]).toContain('setPendingMove');
|
|
76
|
+
});
|
|
77
|
+
test('the dialog default is side-by-side, and a junk value falls back to it', () => {
|
|
78
|
+
expect(DEFAULT_LAUNCH_PLACEMENT).toBe('splitRight');
|
|
79
|
+
expect(readLaunchPlacement()).toBe('splitRight');
|
|
80
|
+
localStorage.setItem(LAUNCH_PLACEMENT_KEY, 'sideways');
|
|
81
|
+
expect(readLaunchPlacement()).toBe('splitRight');
|
|
82
|
+
writeLaunchPlacement('stack');
|
|
83
|
+
expect(readLaunchPlacement()).toBe('stack');
|
|
84
|
+
});
|
|
85
|
+
test('launchMsaView defaults to stack, so a spec written before placement existed is unchanged', () => {
|
|
86
|
+
const { session, recorded } = makeSession();
|
|
87
|
+
launchMsaView(session, { data: { msa: '>a\nAC' } });
|
|
88
|
+
expect(recorded.added).toEqual([
|
|
89
|
+
{ type: 'MsaView', snapshot: { type: 'MsaView', data: { msa: '>a\nAC' } } },
|
|
90
|
+
]);
|
|
91
|
+
expect(recorded.moves).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
// placement is a launch instruction, not view state: MST would drop it from the
|
|
94
|
+
// snapshot without a word, and the view would land stacked with nothing said
|
|
95
|
+
test('launchMsaView keeps placement out of the view snapshot', () => {
|
|
96
|
+
const { session, recorded } = makeSession();
|
|
97
|
+
launchMsaView(session, { placement: 'splitRight', colWidth: 10 });
|
|
98
|
+
expect(recorded.added[0]?.snapshot).toEqual({ type: 'MsaView', colWidth: 10 });
|
|
99
|
+
expect(recorded.moves).toEqual([{ type: 'splitRight', viewId: 'view-1' }]);
|
|
100
|
+
});
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const version = "3.
|
|
1
|
+
export declare const version = "3.3.0";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const version = '3.
|
|
1
|
+
export const version = '3.3.0';
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "3.
|
|
2
|
+
"version": "3.3.0",
|
|
3
3
|
"license": "MIT",
|
|
4
4
|
"name": "jbrowse-plugin-msaview",
|
|
5
5
|
"repository": {
|
|
@@ -32,6 +32,8 @@
|
|
|
32
32
|
"@mui/material": "^9.2.0",
|
|
33
33
|
"@mui/system": "^9.2.0",
|
|
34
34
|
"@mui/x-data-grid": "^9.10.0",
|
|
35
|
+
"@testing-library/dom": "^10.4.1",
|
|
36
|
+
"@testing-library/react": "^16.3.2",
|
|
35
37
|
"@types/node": "^26.1.1",
|
|
36
38
|
"@types/react": "^19.2.17",
|
|
37
39
|
"esbuild": "^0.28.1",
|
|
@@ -41,6 +43,7 @@
|
|
|
41
43
|
"eslint-plugin-react-hooks": "^7.1.1",
|
|
42
44
|
"eslint-plugin-unicorn": "^72.0.0",
|
|
43
45
|
"git-cliff": "^2.13.1",
|
|
46
|
+
"jsdom": "^30.0.1",
|
|
44
47
|
"mobx": "^6.16.1",
|
|
45
48
|
"mobx-react": "^9.2.2",
|
|
46
49
|
"msa-parsers": "^6.0.0",
|