@jbrowse/plugin-blat 4.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/LICENSE +201 -0
- package/esm/BlatDialog.d.ts +6 -0
- package/esm/BlatDialog.js +93 -0
- package/esm/IsPcrDialog.d.ts +6 -0
- package/esm/IsPcrDialog.js +101 -0
- package/esm/MonospaceTextField.d.ts +2 -0
- package/esm/MonospaceTextField.js +8 -0
- package/esm/UcscQueryActions.d.ts +8 -0
- package/esm/UcscQueryActions.js +19 -0
- package/esm/UcscQueryFields.d.ts +8 -0
- package/esm/UcscQueryFields.js +31 -0
- package/esm/UcscQueryStatus.d.ts +5 -0
- package/esm/UcscQueryStatus.js +15 -0
- package/esm/UcscResultsWidget/UcscResultsWidget.d.ts +5 -0
- package/esm/UcscResultsWidget/UcscResultsWidget.js +23 -0
- package/esm/UcscResultsWidget/configSchema.d.ts +1 -0
- package/esm/UcscResultsWidget/configSchema.js +2 -0
- package/esm/UcscResultsWidget/hitSummary.d.ts +9 -0
- package/esm/UcscResultsWidget/hitSummary.js +31 -0
- package/esm/UcscResultsWidget/index.d.ts +2 -0
- package/esm/UcscResultsWidget/index.js +13 -0
- package/esm/UcscResultsWidget/stateModel.d.ts +42 -0
- package/esm/UcscResultsWidget/stateModel.js +41 -0
- package/esm/blatQuery.d.ts +75 -0
- package/esm/blatQuery.js +245 -0
- package/esm/desktopBlat.d.ts +9 -0
- package/esm/desktopBlat.js +22 -0
- package/esm/index.d.ts +7 -0
- package/esm/index.js +37 -0
- package/esm/ispcrQuery.d.ts +59 -0
- package/esm/ispcrQuery.js +160 -0
- package/esm/ispcrToSam.d.ts +9 -0
- package/esm/ispcrToSam.js +86 -0
- package/esm/pslToSam.d.ts +54 -0
- package/esm/pslToSam.js +153 -0
- package/esm/ucscDbMap.d.ts +2 -0
- package/esm/ucscDbMap.js +25 -0
- package/esm/ucscShared.d.ts +40 -0
- package/esm/ucscShared.js +116 -0
- package/esm/useUcscQuery.d.ts +40 -0
- package/esm/useUcscQuery.js +199 -0
- package/package.json +54 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { challengeError, isChallengePage } from './blatQuery.js';
|
|
2
|
+
// UCSC hgPcr (In-Silico PCR). Unlike hgBlat there is no output=json mode; a
|
|
3
|
+
// successful query returns an HTML page whose <PRE> block holds FASTA amplicons
|
|
4
|
+
// with headers like:
|
|
5
|
+
// >chr9:132576352+132576623 272bp GTGACGTCG... CCTAGGTTG...
|
|
6
|
+
// where the sign between the two 1-based coordinates is the product strand and
|
|
7
|
+
// the two trailing tokens are the forward and reverse primers as submitted.
|
|
8
|
+
//
|
|
9
|
+
// Not anchored to the start of a line. The whole shape — a '>', a locus with a
|
|
10
|
+
// strand sign inside it, a bp count, two primers — is specific enough on its
|
|
11
|
+
// own, and the anchor was a standing bet that markup never precedes the '>' on
|
|
12
|
+
// its line. `<PRE>` opening on the same line as the first amplicon is all it
|
|
13
|
+
// would take to lose that product, and this parser has already shipped one
|
|
14
|
+
// silent zero-products bug of exactly that kind (see the anchored-header test).
|
|
15
|
+
// A '>' still may not reach across a newline to claim a header below it.
|
|
16
|
+
const AMPLICON_HEADER = />[^\S\n]*(\S+):(\d+)([+-])(\d+)\s+(\d+)bp\s+(\S+)\s+(\S+)/g;
|
|
17
|
+
export const UCSC_ISPCR_URL = 'https://genome.ucsc.edu/cgi-bin/hgPcr';
|
|
18
|
+
// The jbrowse.org proxy, for the same reasons as DEFAULT_BLAT_URL: it holds the
|
|
19
|
+
// apiKey, so a first query costs neither a key nor a CAPTCHA, and desktop keeps
|
|
20
|
+
// UCSC_ISPCR_URL in reserve for when the proxy cannot serve.
|
|
21
|
+
export const DEFAULT_ISPCR_URL = 'https://api.jbrowse.org/ucsc/v1/ispcr';
|
|
22
|
+
// hgPcr's default cap; also the server-side max the CGI accepts without error
|
|
23
|
+
export const DEFAULT_MAX_PRODUCT_SIZE = 4000;
|
|
24
|
+
// primers shorter than this are rejected by hgPcr as too unspecific
|
|
25
|
+
export const MINIMUM_PRIMER_LENGTH = 15;
|
|
26
|
+
/**
|
|
27
|
+
* The two primer footprints, low coordinate first.
|
|
28
|
+
*
|
|
29
|
+
* A primer pair converges: the footprint at the low coordinate reads rightward
|
|
30
|
+
* and the one at the high coordinate reads leftward, whichever of the two the
|
|
31
|
+
* submitter called forward. Only *which* is which follows the product strand —
|
|
32
|
+
* on a plus product the forward primer sits at the low end, on a minus product
|
|
33
|
+
* it sits at the high end.
|
|
34
|
+
*
|
|
35
|
+
* Each footprint carries the primer's own bases, which is what lets a mismatch
|
|
36
|
+
* against the template be drawn rather than a perfect anneal assumed. They are
|
|
37
|
+
* as submitted, i.e. 5'->3' on the strand that primer anneals to, so a footprint
|
|
38
|
+
* at the low end reads on the reference's plus strand and one at the high end
|
|
39
|
+
* has to be reverse-complemented to sit on it ({@link ispcrToSam} does that).
|
|
40
|
+
*/
|
|
41
|
+
export function productFootprints(product) {
|
|
42
|
+
const { start, end, strand, forwardPrimer, reversePrimer } = product;
|
|
43
|
+
const lowIsForward = strand === 1;
|
|
44
|
+
const lowPrimer = lowIsForward ? forwardPrimer : reversePrimer;
|
|
45
|
+
const highPrimer = lowIsForward ? reversePrimer : forwardPrimer;
|
|
46
|
+
return {
|
|
47
|
+
low: {
|
|
48
|
+
name: lowIsForward ? 'forward primer' : 'reverse primer',
|
|
49
|
+
role: lowIsForward ? 'fwd' : 'rev',
|
|
50
|
+
start,
|
|
51
|
+
end: start + lowPrimer.length,
|
|
52
|
+
primer: lowPrimer,
|
|
53
|
+
},
|
|
54
|
+
high: {
|
|
55
|
+
name: lowIsForward ? 'reverse primer' : 'forward primer',
|
|
56
|
+
role: lowIsForward ? 'rev' : 'fwd',
|
|
57
|
+
start: end - highPrimer.length,
|
|
58
|
+
end,
|
|
59
|
+
primer: highPrimer,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export function parseIsPcrProducts(text) {
|
|
64
|
+
// a successful result is HTML (FASTA inside <PRE>), and so are the "no
|
|
65
|
+
// results" and Cloudflare Turnstile pages, so we can't reject on the leading
|
|
66
|
+
// '<' the way BLAT's JSON path does. Scan for amplicon headers first; only if
|
|
67
|
+
// none are found do we distinguish a challenge from a genuine empty result,
|
|
68
|
+
// which avoids mistaking incidental "cf-"/"challenge" markup on a real result
|
|
69
|
+
// page for a CAPTCHA
|
|
70
|
+
//
|
|
71
|
+
// hgPcr links each amplicon's position to the browser, so a real header is
|
|
72
|
+
// ><A HREF="../cgi-bin/hgTracks?...">chr17:7676521+7676667</A> 147bp ...
|
|
73
|
+
// and the position never followed the '>' directly. Strip markup before
|
|
74
|
+
// decoding entities, in that order: tags first, so escaped text can't be read
|
|
75
|
+
// as a tag, then '>' becomes the '>' the header starts with.
|
|
76
|
+
const decoded = text
|
|
77
|
+
.replaceAll(/<[^>]*>/g, '')
|
|
78
|
+
.replaceAll('>', '>')
|
|
79
|
+
.replaceAll('<', '<');
|
|
80
|
+
const products = [];
|
|
81
|
+
for (const match of decoded.matchAll(AMPLICON_HEADER)) {
|
|
82
|
+
const [, refName, startStr, sign, endStr, sizeStr, fwd, rev] = match;
|
|
83
|
+
products.push({
|
|
84
|
+
refName: refName,
|
|
85
|
+
// headers are 1-based inclusive; convert to interbase half-open
|
|
86
|
+
start: Number(startStr) - 1,
|
|
87
|
+
end: Number(endStr),
|
|
88
|
+
strand: sign === '-' ? -1 : 1,
|
|
89
|
+
size: Number(sizeStr),
|
|
90
|
+
forwardPrimer: fwd,
|
|
91
|
+
reversePrimer: rev,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
// the raw text, not `decoded`: a challenge page IS markup, so stripping tags
|
|
95
|
+
// would throw away the very thing that identifies it
|
|
96
|
+
if (!products.length && isChallengePage(text)) {
|
|
97
|
+
throw challengeError();
|
|
98
|
+
}
|
|
99
|
+
return products;
|
|
100
|
+
}
|
|
101
|
+
export function pcrProductsToFeatures(products) {
|
|
102
|
+
return products.map(product => {
|
|
103
|
+
const { refName, start, end, strand, size } = product;
|
|
104
|
+
const uniqueId = `ispcr-${refName}-${start}-${end}-${strand}`;
|
|
105
|
+
const { low, high } = productFootprints(product);
|
|
106
|
+
return {
|
|
107
|
+
uniqueId,
|
|
108
|
+
refName,
|
|
109
|
+
start,
|
|
110
|
+
end,
|
|
111
|
+
strand,
|
|
112
|
+
type: 'PCR_product',
|
|
113
|
+
name: `${size} bp`,
|
|
114
|
+
forwardPrimer: product.forwardPrimer,
|
|
115
|
+
reversePrimer: product.reversePrimer,
|
|
116
|
+
// the footprints converge, so their strands are +1 and -1 whatever the
|
|
117
|
+
// product's is: a primer's direction is its own, not the amplicon's
|
|
118
|
+
subfeatures: [
|
|
119
|
+
{
|
|
120
|
+
uniqueId: `${uniqueId}-${low.role}`,
|
|
121
|
+
name: low.name,
|
|
122
|
+
refName,
|
|
123
|
+
start: low.start,
|
|
124
|
+
end: low.end,
|
|
125
|
+
strand: 1,
|
|
126
|
+
type: 'primer',
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
uniqueId: `${uniqueId}-${high.role}`,
|
|
130
|
+
name: high.name,
|
|
131
|
+
refName,
|
|
132
|
+
start: high.start,
|
|
133
|
+
end: high.end,
|
|
134
|
+
strand: -1,
|
|
135
|
+
type: 'primer',
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
export function parseIsPcrResponse(text) {
|
|
142
|
+
return pcrProductsToFeatures(parseIsPcrProducts(text));
|
|
143
|
+
}
|
|
144
|
+
export function buildIsPcrBody({ db, forwardPrimer, reversePrimer, maxProductSize = DEFAULT_MAX_PRODUCT_SIZE, apiKey, }) {
|
|
145
|
+
const params = new URLSearchParams({
|
|
146
|
+
db,
|
|
147
|
+
wp_target: 'genome',
|
|
148
|
+
wp_f: forwardPrimer,
|
|
149
|
+
wp_r: reversePrimer,
|
|
150
|
+
wp_size: String(maxProductSize),
|
|
151
|
+
wp_perfect: '15',
|
|
152
|
+
wp_good: '15',
|
|
153
|
+
'boolshad.wp_flipReverse': '0',
|
|
154
|
+
});
|
|
155
|
+
// as with hgBlat, an account apiKey bypasses the Cloudflare Turnstile
|
|
156
|
+
if (apiKey) {
|
|
157
|
+
params.set('apiKey', apiKey);
|
|
158
|
+
}
|
|
159
|
+
return params.toString();
|
|
160
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { PcrProduct } from './ispcrQuery.ts';
|
|
2
|
+
/**
|
|
3
|
+
* A set of hgPcr products as SAM text, ready to hand a SamAdapter as `samText`.
|
|
4
|
+
*
|
|
5
|
+
* No `@SQ` lines: hgPcr's page states no chromosome sizes, and inventing one
|
|
6
|
+
* would be a lie the adapter then reports as the reference length. A headerless
|
|
7
|
+
* SAM takes its reference names from the records instead.
|
|
8
|
+
*/
|
|
9
|
+
export declare function ispcrToSam(products: PcrProduct[]): string;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { revcom } from '@jbrowse/core/util';
|
|
2
|
+
import { productFootprints } from './ispcrQuery.js';
|
|
3
|
+
/**
|
|
4
|
+
* A PCR product has the same shape as a paired-end read: two short oriented
|
|
5
|
+
* footprints pointing at each other, with an unsequenced insert between them.
|
|
6
|
+
* So each product becomes a read pair — the primers are the mates, the amplicon
|
|
7
|
+
* interior is the insert, and TLEN is the product size, which is the band the
|
|
8
|
+
* bench sees on a gel. Shown in an alignments track with view-as-pairs on, that
|
|
9
|
+
* draws the arrows-facing-inward glyph a primer pair is always drawn as, and it
|
|
10
|
+
* comes with the pairing machinery (insert-size sort and color) already attached.
|
|
11
|
+
*
|
|
12
|
+
* The other thing the pair buys is primer *quality*: hgPcr accepts imperfect
|
|
13
|
+
* matches, and a mismatch near a primer's 3' end is a real reason an
|
|
14
|
+
* amplification fails. SEQ carries the primer's own bases in reference
|
|
15
|
+
* orientation, so the pileup marks every base that disagrees with the template
|
|
16
|
+
* rather than leaving the reader to assume a perfect anneal.
|
|
17
|
+
*/
|
|
18
|
+
// SAM FLAG bits
|
|
19
|
+
const PAIRED = 1;
|
|
20
|
+
const PROPER = 2;
|
|
21
|
+
const REVERSE = 16;
|
|
22
|
+
const MATE_REVERSE = 32;
|
|
23
|
+
const FIRST = 64;
|
|
24
|
+
const SECOND = 128;
|
|
25
|
+
// BLAT and hgPcr both report a match, not a mapping probability, so a MAPQ here
|
|
26
|
+
// would be invented; 255 is the spec's "unavailable".
|
|
27
|
+
const MAPQ = 255;
|
|
28
|
+
function samLine(fields) {
|
|
29
|
+
return fields.join('\t');
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The two mates of one product. Names are per-product and unique: two products
|
|
33
|
+
* sharing a QNAME would be read as one pair spanning both of them.
|
|
34
|
+
*/
|
|
35
|
+
function productToSamLines(product) {
|
|
36
|
+
const { refName, start, end, strand } = product;
|
|
37
|
+
const { low, high } = productFootprints(product);
|
|
38
|
+
const size = end - start;
|
|
39
|
+
const lowPos = low.start + 1;
|
|
40
|
+
const highPos = high.start + 1;
|
|
41
|
+
const qName = `${refName}:${lowPos}-${end}_${size}bp`;
|
|
42
|
+
// the forward primer is read 1, which is at the low end of a plus product and
|
|
43
|
+
// the high end of a minus one
|
|
44
|
+
const lowIsForward = strand === 1;
|
|
45
|
+
return [
|
|
46
|
+
samLine([
|
|
47
|
+
qName,
|
|
48
|
+
PAIRED | PROPER | MATE_REVERSE | (lowIsForward ? FIRST : SECOND),
|
|
49
|
+
refName,
|
|
50
|
+
lowPos,
|
|
51
|
+
MAPQ,
|
|
52
|
+
`${low.primer.length}M`,
|
|
53
|
+
'=',
|
|
54
|
+
highPos,
|
|
55
|
+
size,
|
|
56
|
+
// a footprint at the low end matches the plus strand as submitted
|
|
57
|
+
low.primer,
|
|
58
|
+
'*',
|
|
59
|
+
]),
|
|
60
|
+
samLine([
|
|
61
|
+
qName,
|
|
62
|
+
PAIRED | PROPER | REVERSE | (lowIsForward ? SECOND : FIRST),
|
|
63
|
+
refName,
|
|
64
|
+
highPos,
|
|
65
|
+
MAPQ,
|
|
66
|
+
`${high.primer.length}M`,
|
|
67
|
+
'=',
|
|
68
|
+
lowPos,
|
|
69
|
+
-size,
|
|
70
|
+
// SEQ is always reference-forward, and the high footprint's primer anneals
|
|
71
|
+
// to the other strand, so it is stored reverse-complemented
|
|
72
|
+
revcom(high.primer),
|
|
73
|
+
'*',
|
|
74
|
+
]),
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* A set of hgPcr products as SAM text, ready to hand a SamAdapter as `samText`.
|
|
79
|
+
*
|
|
80
|
+
* No `@SQ` lines: hgPcr's page states no chromosome sizes, and inventing one
|
|
81
|
+
* would be a lie the adapter then reports as the reference length. A headerless
|
|
82
|
+
* SAM takes its reference names from the records instead.
|
|
83
|
+
*/
|
|
84
|
+
export function ispcrToSam(products) {
|
|
85
|
+
return ['@HD\tVN:1.6', ...products.flatMap(productToSamLines), ''].join('\n');
|
|
86
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { PslRow } from './blatQuery.ts';
|
|
2
|
+
/**
|
|
3
|
+
* PSL states a hit as a list of aligned blocks in both query and target
|
|
4
|
+
* coordinates, which is exactly the information a CIGAR encodes: each block is
|
|
5
|
+
* an M run, the target gap between two blocks is a D, the query gap an I, and
|
|
6
|
+
* the unaligned query ends are soft clips.
|
|
7
|
+
*
|
|
8
|
+
* D rather than N for every target gap: a DNA BLAT hit cannot tell an intron
|
|
9
|
+
* from a deletion (a spliced-mRNA query would want N, a diverged genomic query
|
|
10
|
+
* D), and psl2sam takes the same position. The gap is drawn either way.
|
|
11
|
+
*
|
|
12
|
+
* Coordinates are the ones PSL states, which for a `-` strand hit means
|
|
13
|
+
* `blockSizes`/`qStarts` are in reverse-complement query space — the same space
|
|
14
|
+
* as the SEQ a reverse-strand SAM record carries. That correspondence is what
|
|
15
|
+
* makes the soft clips and the per-base mismatches land in the right place, so
|
|
16
|
+
* {@link pslQuerySeq} reverse-complements to match.
|
|
17
|
+
*/
|
|
18
|
+
export declare function pslToCigar({ blockSizes, qStarts, tStarts, qSize, }: {
|
|
19
|
+
blockSizes: number[];
|
|
20
|
+
qStarts: number[];
|
|
21
|
+
tStarts: number[];
|
|
22
|
+
qSize: number;
|
|
23
|
+
}): string;
|
|
24
|
+
/**
|
|
25
|
+
* The submitted query in the orientation the hit aligned it, which is what SAM's
|
|
26
|
+
* SEQ column holds. Answers "do I have the bases this row describes", so it
|
|
27
|
+
* checks the length the row states as well as the name: the CIGAR spans `qSize`
|
|
28
|
+
* query bases by construction, and bases of any other length would sit out of
|
|
29
|
+
* register against the reference, drawing mismatches the whole way along a hit
|
|
30
|
+
* BLAT scored as near-perfect. Returns undefined for both misses, and the record
|
|
31
|
+
* then carries `*` and renders its block structure without per-base mismatches.
|
|
32
|
+
*/
|
|
33
|
+
export declare function pslQuerySeq(row: PslRow, querySequences: Map<string, string>): string | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* A PSL result set as SAM text, ready to hand to a SamAdapter as `samText`.
|
|
36
|
+
* `@SQ` lines come from the target sizes PSL already states, so the track knows
|
|
37
|
+
* its reference names without consulting the assembly.
|
|
38
|
+
*
|
|
39
|
+
* Rows arrive best-first (pslToFeatures sorts by score), which is what decides
|
|
40
|
+
* each query's primary alignment.
|
|
41
|
+
*/
|
|
42
|
+
export declare function pslToSam(rows: PslRow[], querySequences: Map<string, string>): string;
|
|
43
|
+
/**
|
|
44
|
+
* The submitted query text as name -> residues. hgBlat places each FASTA record
|
|
45
|
+
* separately and labels its hits with that record's name, so this is the map
|
|
46
|
+
* from a hit back to the bases it was made of.
|
|
47
|
+
*
|
|
48
|
+
* Keeps only letters, which is what Kent's FASTA reader counts: a pasted
|
|
49
|
+
* sequence carrying line numbers, or alignment-gap dashes, is those bases to
|
|
50
|
+
* BLAT and so `qSize` excludes them. Stripping only whitespace left our text
|
|
51
|
+
* longer than the hit it describes, which is exactly the off-by-N that puts
|
|
52
|
+
* every base out of register.
|
|
53
|
+
*/
|
|
54
|
+
export declare function parseQuerySequences(text: string): Map<string, string>;
|
package/esm/pslToSam.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { revcom } from '@jbrowse/core/util';
|
|
2
|
+
/**
|
|
3
|
+
* PSL states a hit as a list of aligned blocks in both query and target
|
|
4
|
+
* coordinates, which is exactly the information a CIGAR encodes: each block is
|
|
5
|
+
* an M run, the target gap between two blocks is a D, the query gap an I, and
|
|
6
|
+
* the unaligned query ends are soft clips.
|
|
7
|
+
*
|
|
8
|
+
* D rather than N for every target gap: a DNA BLAT hit cannot tell an intron
|
|
9
|
+
* from a deletion (a spliced-mRNA query would want N, a diverged genomic query
|
|
10
|
+
* D), and psl2sam takes the same position. The gap is drawn either way.
|
|
11
|
+
*
|
|
12
|
+
* Coordinates are the ones PSL states, which for a `-` strand hit means
|
|
13
|
+
* `blockSizes`/`qStarts` are in reverse-complement query space — the same space
|
|
14
|
+
* as the SEQ a reverse-strand SAM record carries. That correspondence is what
|
|
15
|
+
* makes the soft clips and the per-base mismatches land in the right place, so
|
|
16
|
+
* {@link pslQuerySeq} reverse-complements to match.
|
|
17
|
+
*/
|
|
18
|
+
export function pslToCigar({ blockSizes, qStarts, tStarts, qSize, }) {
|
|
19
|
+
// a row stating no blocks describes no alignment; '*' is SAM's word for that,
|
|
20
|
+
// where the empty string the loop would otherwise build is an unparseable
|
|
21
|
+
// CIGAR column
|
|
22
|
+
if (!blockSizes.length) {
|
|
23
|
+
return '*';
|
|
24
|
+
}
|
|
25
|
+
const ops = [];
|
|
26
|
+
const leadingClip = qStarts[0] ?? 0;
|
|
27
|
+
if (leadingClip > 0) {
|
|
28
|
+
ops.push(`${leadingClip}S`);
|
|
29
|
+
}
|
|
30
|
+
for (let i = 0; i < blockSizes.length; i++) {
|
|
31
|
+
if (i > 0) {
|
|
32
|
+
const queryGap = qStarts[i] - (qStarts[i - 1] + blockSizes[i - 1]);
|
|
33
|
+
const targetGap = tStarts[i] - (tStarts[i - 1] + blockSizes[i - 1]);
|
|
34
|
+
if (queryGap > 0) {
|
|
35
|
+
ops.push(`${queryGap}I`);
|
|
36
|
+
}
|
|
37
|
+
if (targetGap > 0) {
|
|
38
|
+
ops.push(`${targetGap}D`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
ops.push(`${blockSizes[i]}M`);
|
|
42
|
+
}
|
|
43
|
+
const last = blockSizes.length - 1;
|
|
44
|
+
const trailingClip = qSize - (qStarts[last] + blockSizes[last]);
|
|
45
|
+
if (trailingClip > 0) {
|
|
46
|
+
ops.push(`${trailingClip}S`);
|
|
47
|
+
}
|
|
48
|
+
return ops.join('');
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The submitted query in the orientation the hit aligned it, which is what SAM's
|
|
52
|
+
* SEQ column holds. Answers "do I have the bases this row describes", so it
|
|
53
|
+
* checks the length the row states as well as the name: the CIGAR spans `qSize`
|
|
54
|
+
* query bases by construction, and bases of any other length would sit out of
|
|
55
|
+
* register against the reference, drawing mismatches the whole way along a hit
|
|
56
|
+
* BLAT scored as near-perfect. Returns undefined for both misses, and the record
|
|
57
|
+
* then carries `*` and renders its block structure without per-base mismatches.
|
|
58
|
+
*/
|
|
59
|
+
export function pslQuerySeq(row, querySequences) {
|
|
60
|
+
const seq = querySequences.get(row.qName) ??
|
|
61
|
+
// hgBlat labels a headerless query "YourSeq"; a single submitted record is
|
|
62
|
+
// unambiguously the one every hit came from whatever it ended up named
|
|
63
|
+
(querySequences.size === 1 ? [...querySequences.values()][0] : undefined);
|
|
64
|
+
return seq?.length === row.qSize
|
|
65
|
+
? row.strand < 0
|
|
66
|
+
? revcom(seq)
|
|
67
|
+
: seq
|
|
68
|
+
: undefined;
|
|
69
|
+
}
|
|
70
|
+
// Kent's psl2sam: the edit distance is the mismatched bases plus the bases
|
|
71
|
+
// inserted on either side.
|
|
72
|
+
function editDistance(row) {
|
|
73
|
+
return row.misMatches + row.qBaseInsert + row.tBaseInsert;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* One SAM alignment line per PSL row. `bestOf` names the queries whose leading
|
|
77
|
+
* hit has already been emitted: BLAT returns every placement of a query, and
|
|
78
|
+
* all but its best are secondary alignments (0x100) — competing mappings of one
|
|
79
|
+
* sequence, which is what keeps them off the best hit's row in the pileup.
|
|
80
|
+
* MAPQ is 255, the spec's "unavailable": BLAT reports identity, not a mapping
|
|
81
|
+
* probability, so any number here would be invented.
|
|
82
|
+
*/
|
|
83
|
+
function pslRowToSamLine(row, querySequences, bestOf) {
|
|
84
|
+
const secondary = bestOf.has(row.qName);
|
|
85
|
+
bestOf.add(row.qName);
|
|
86
|
+
const seq = pslQuerySeq(row, querySequences);
|
|
87
|
+
return [
|
|
88
|
+
row.qName,
|
|
89
|
+
(row.strand < 0 ? 16 : 0) | (secondary ? 256 : 0),
|
|
90
|
+
row.tName,
|
|
91
|
+
row.tStart + 1,
|
|
92
|
+
255,
|
|
93
|
+
pslToCigar(row),
|
|
94
|
+
'*',
|
|
95
|
+
0,
|
|
96
|
+
0,
|
|
97
|
+
seq ?? '*',
|
|
98
|
+
'*',
|
|
99
|
+
`NM:i:${editDistance(row)}`,
|
|
100
|
+
`AS:i:${row.score}`,
|
|
101
|
+
].join('\t');
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* A PSL result set as SAM text, ready to hand to a SamAdapter as `samText`.
|
|
105
|
+
* `@SQ` lines come from the target sizes PSL already states, so the track knows
|
|
106
|
+
* its reference names without consulting the assembly.
|
|
107
|
+
*
|
|
108
|
+
* Rows arrive best-first (pslToFeatures sorts by score), which is what decides
|
|
109
|
+
* each query's primary alignment.
|
|
110
|
+
*/
|
|
111
|
+
export function pslToSam(rows, querySequences) {
|
|
112
|
+
const targetSizes = new Map(rows.map(row => [row.tName, row.tSize]));
|
|
113
|
+
const bestOf = new Set();
|
|
114
|
+
return [
|
|
115
|
+
'@HD\tVN:1.6',
|
|
116
|
+
...[...targetSizes].map(([name, size]) => `@SQ\tSN:${name}\tLN:${size}`),
|
|
117
|
+
...rows.map(row => pslRowToSamLine(row, querySequences, bestOf)),
|
|
118
|
+
'',
|
|
119
|
+
].join('\n');
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The submitted query text as name -> residues. hgBlat places each FASTA record
|
|
123
|
+
* separately and labels its hits with that record's name, so this is the map
|
|
124
|
+
* from a hit back to the bases it was made of.
|
|
125
|
+
*
|
|
126
|
+
* Keeps only letters, which is what Kent's FASTA reader counts: a pasted
|
|
127
|
+
* sequence carrying line numbers, or alignment-gap dashes, is those bases to
|
|
128
|
+
* BLAT and so `qSize` excludes them. Stripping only whitespace left our text
|
|
129
|
+
* longer than the hit it describes, which is exactly the off-by-N that puts
|
|
130
|
+
* every base out of register.
|
|
131
|
+
*/
|
|
132
|
+
export function parseQuerySequences(text) {
|
|
133
|
+
const sequences = new Map();
|
|
134
|
+
let name = 'YourSeq';
|
|
135
|
+
let residues = [];
|
|
136
|
+
const flush = () => {
|
|
137
|
+
if (residues.length) {
|
|
138
|
+
sequences.set(name, residues.join(''));
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
for (const line of text.split('\n')) {
|
|
142
|
+
if (line.startsWith('>')) {
|
|
143
|
+
flush();
|
|
144
|
+
name = /^>\s*(\S+)/.exec(line)?.[1] ?? 'YourSeq';
|
|
145
|
+
residues = [];
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
residues.push(line.replaceAll(/[^A-Za-z]/g, ''));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
flush();
|
|
152
|
+
return sequences;
|
|
153
|
+
}
|
package/esm/ucscDbMap.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// jb2hubs assemblies are frequently already named with UCSC db ids (hg38, mm39,
|
|
2
|
+
// …) so identity is the default. This covers the common GRC/T2T aliases that
|
|
3
|
+
// aren't literal UCSC db names.
|
|
4
|
+
const aliases = {
|
|
5
|
+
GRCh38: 'hg38',
|
|
6
|
+
GRCh37: 'hg19',
|
|
7
|
+
GRCm39: 'mm39',
|
|
8
|
+
GRCm38: 'mm10',
|
|
9
|
+
'T2T-CHM13v2.0': 'hs1',
|
|
10
|
+
'T2T-CHM13': 'hs1',
|
|
11
|
+
chm13: 'hs1',
|
|
12
|
+
};
|
|
13
|
+
export function assemblyToUcscDb(name) {
|
|
14
|
+
return aliases[name] ?? name;
|
|
15
|
+
}
|
|
16
|
+
// UCSC db names are a lowercase organism prefix plus a version (hg38, mm39,
|
|
17
|
+
// danRer11, hs1), and GenArk accessions resolve on hgBlat directly. Neither
|
|
18
|
+
// shape proves UCSC hosts the db, but a name outside both — a genome opened
|
|
19
|
+
// from local files, "my_assembly" — certainly isn't one, and that is worth
|
|
20
|
+
// saying before a query goes out rather than after it comes back empty.
|
|
21
|
+
const UCSC_DB = /^[a-z][A-Za-z]*\d+$/;
|
|
22
|
+
const GENARK_ACCESSION = /^GC[AF]_\d+\.\d+$/;
|
|
23
|
+
export function looksLikeUcscDb(db) {
|
|
24
|
+
return UCSC_DB.test(db) || GENARK_ACCESSION.test(db);
|
|
25
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { AbstractViewContainer, AssemblyHost, DialogHost, NotificationSink, SimpleFeatureSerialized } from '@jbrowse/core/util';
|
|
2
|
+
/**
|
|
3
|
+
* What this plugin asks of the host it is installed into: an assembly to
|
|
4
|
+
* resolve a UCSC db against, views to navigate, somewhere to report a hit it
|
|
5
|
+
* could not reach, and somewhere to put its two dialogs. Everything else it
|
|
6
|
+
* needs — adding the result track, opening the results panel — it asks for with
|
|
7
|
+
* a guard, because a host may not offer it.
|
|
8
|
+
*/
|
|
9
|
+
export interface UcscHost extends AbstractViewContainer, AssemblyHost, NotificationSink, DialogHost {
|
|
10
|
+
}
|
|
11
|
+
export declare function ucscDbStamp(session: UcscHost, name: string): string | undefined;
|
|
12
|
+
export declare function resolveUcscDb(session: UcscHost, name: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* Whether this host can draw a hit as an alignment rather than as plain blocks.
|
|
15
|
+
*
|
|
16
|
+
* Results are added as an AlignmentsTrack over a SamAdapter, which is what draws
|
|
17
|
+
* the blocks, the indels, the soft-clipped query ends and the per-base
|
|
18
|
+
* mismatches. SamAdapter arrived in JBrowse after v4.3.0, and an external plugin
|
|
19
|
+
* is loaded by whatever host a config points it at — including releases older
|
|
20
|
+
* than the plugin itself. Asking the host is the difference between quietly
|
|
21
|
+
* falling back to the block-structure track BLAT results always used to be, and
|
|
22
|
+
* adding a track whose adapter cannot resolve.
|
|
23
|
+
*/
|
|
24
|
+
export declare function canRenderAlignments(session: UcscHost): boolean;
|
|
25
|
+
export declare function featureLocString(feature: SimpleFeatureSerialized): string;
|
|
26
|
+
export declare function navToFeature(session: UcscHost, assembly: string, feature: SimpleFeatureSerialized): Promise<void>;
|
|
27
|
+
export interface ResultTrackConf {
|
|
28
|
+
type: string;
|
|
29
|
+
adapter: Record<string, unknown>;
|
|
30
|
+
displayDefaults?: Record<string, unknown>;
|
|
31
|
+
}
|
|
32
|
+
export declare function addResultTrack({ session, assembly, features, trackIdPrefix, trackName, trackConf, resultNoun, }: {
|
|
33
|
+
session: UcscHost;
|
|
34
|
+
assembly: string;
|
|
35
|
+
features: SimpleFeatureSerialized[];
|
|
36
|
+
trackIdPrefix: string;
|
|
37
|
+
trackName: string;
|
|
38
|
+
trackConf?: ResultTrackConf;
|
|
39
|
+
resultNoun?: 'hit' | 'product';
|
|
40
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { getConf } from '@jbrowse/core/configuration';
|
|
2
|
+
import { getEnv, isSessionModelWithWidgets, isSessionWithAddSessionTrack, } from '@jbrowse/core/util';
|
|
3
|
+
import { assemblyToUcscDb } from './ucscDbMap.js';
|
|
4
|
+
function isNavigableView(view) {
|
|
5
|
+
return ('showTrack' in view &&
|
|
6
|
+
typeof view.showTrack === 'function' &&
|
|
7
|
+
'navToLocString' in view &&
|
|
8
|
+
typeof view.navToLocString === 'function');
|
|
9
|
+
}
|
|
10
|
+
// jb2hubs stamps the UCSC db on the assembly's sequence.metadata. Its presence
|
|
11
|
+
// is also the one positive proof that UCSC can search the assembly, so the
|
|
12
|
+
// dialogs read it directly to decide whether to warn.
|
|
13
|
+
export function ucscDbStamp(session, name) {
|
|
14
|
+
const assembly = session.assemblyManager.get(name);
|
|
15
|
+
const stamped = assembly
|
|
16
|
+
? getConf(assembly, ['sequence', 'metadata', 'blatDb'])
|
|
17
|
+
: undefined;
|
|
18
|
+
return stamped;
|
|
19
|
+
}
|
|
20
|
+
// Prefers the stamp, falling back to the static alias map for assemblies whose
|
|
21
|
+
// configs predate it. Shared by the BLAT and in-silico PCR dialogs.
|
|
22
|
+
export function resolveUcscDb(session, name) {
|
|
23
|
+
const stamped = ucscDbStamp(session, name);
|
|
24
|
+
return stamped ? stamped : assemblyToUcscDb(name);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Whether this host can draw a hit as an alignment rather than as plain blocks.
|
|
28
|
+
*
|
|
29
|
+
* Results are added as an AlignmentsTrack over a SamAdapter, which is what draws
|
|
30
|
+
* the blocks, the indels, the soft-clipped query ends and the per-base
|
|
31
|
+
* mismatches. SamAdapter arrived in JBrowse after v4.3.0, and an external plugin
|
|
32
|
+
* is loaded by whatever host a config points it at — including releases older
|
|
33
|
+
* than the plugin itself. Asking the host is the difference between quietly
|
|
34
|
+
* falling back to the block-structure track BLAT results always used to be, and
|
|
35
|
+
* adding a track whose adapter cannot resolve.
|
|
36
|
+
*/
|
|
37
|
+
export function canRenderAlignments(session) {
|
|
38
|
+
const { pluginManager } = getEnv(session);
|
|
39
|
+
return (pluginManager.adapterTypes.has('SamAdapter') &&
|
|
40
|
+
pluginManager.trackTypes.has('AlignmentsTrack'));
|
|
41
|
+
}
|
|
42
|
+
// Builds a locstring from a feature. Feature coordinates are interbase, so the
|
|
43
|
+
// start needs +1 to become the 1-based coordinate a locstring names.
|
|
44
|
+
export function featureLocString(feature) {
|
|
45
|
+
return `${feature.refName}:${feature.start + 1}-${feature.end}`;
|
|
46
|
+
}
|
|
47
|
+
function findNavigableView(session, assembly) {
|
|
48
|
+
const view = session.views.find(v => v.type === 'LinearGenomeView' && !!v.assemblyNames?.includes(assembly));
|
|
49
|
+
return view && isNavigableView(view) ? view : undefined;
|
|
50
|
+
}
|
|
51
|
+
// Navigates an open view to a hit, growing the window so it lands with flanking
|
|
52
|
+
// context rather than filling the viewport edge to edge. Shared by the
|
|
53
|
+
// post-query navigation and the results widget's per-hit links.
|
|
54
|
+
export async function navToFeature(session, assembly, feature) {
|
|
55
|
+
const view = findNavigableView(session, assembly);
|
|
56
|
+
const loc = featureLocString(feature);
|
|
57
|
+
if (view) {
|
|
58
|
+
try {
|
|
59
|
+
await view.navToLocString(loc, assembly, 0.2);
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
// a hit on a refName the loaded assembly doesn't know (the UCSC db and
|
|
63
|
+
// the loaded assembly aren't the same build, or lack an alias) still
|
|
64
|
+
// belongs in the track — report the coordinates rather than dropping them
|
|
65
|
+
session.notify(`Could not navigate to ${loc}: ${e}`, 'warning');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
session.notify(`No open view displays ${assembly}`, 'warning');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Turns a UCSC query result into an on-the-fly track, shows it, and opens the
|
|
73
|
+
// result list in the drawer. Deliberately does NOT navigate: "first" is the
|
|
74
|
+
// server's ordering, not the user's intent, so jumping there picks a result for
|
|
75
|
+
// them — an off-target product can take the view — and it throws away wherever
|
|
76
|
+
// they were when they asked. The drawer lists every result as a link, so going to
|
|
77
|
+
// one is a click, and which one is theirs to choose. Callers pass `features`
|
|
78
|
+
// best-first (pslToFeatures sorts by score; hgPcr returns its products in order).
|
|
79
|
+
export async function addResultTrack({ session, assembly, features, trackIdPrefix, trackName, trackConf, resultNoun, }) {
|
|
80
|
+
if (!isSessionWithAddSessionTrack(session)) {
|
|
81
|
+
throw new Error("Can't add tracks to this session");
|
|
82
|
+
}
|
|
83
|
+
const trackId = `${trackIdPrefix}-${Date.now()}`;
|
|
84
|
+
const resultTrack = trackConf ?? {
|
|
85
|
+
type: 'FeatureTrack',
|
|
86
|
+
adapter: { type: 'FromConfigAdapter', features },
|
|
87
|
+
};
|
|
88
|
+
session.addSessionTrackConf({
|
|
89
|
+
...resultTrack,
|
|
90
|
+
trackId,
|
|
91
|
+
name: trackName,
|
|
92
|
+
assemblyNames: [assembly],
|
|
93
|
+
});
|
|
94
|
+
const view = findNavigableView(session, assembly);
|
|
95
|
+
if (view) {
|
|
96
|
+
view.showTrack(trackId);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
session.notify(`Added track "${trackId}" but no open view displays ${assembly}`, 'warning');
|
|
100
|
+
}
|
|
101
|
+
// #region showWidget
|
|
102
|
+
// addWidget constructs the widget and returns it; showWidget is what puts it
|
|
103
|
+
// in the drawer. The third argument is the initial state, so its keys are the
|
|
104
|
+
// properties UcscResultsWidget's state model declares. Not every session has
|
|
105
|
+
// a drawer — an embedded component may be built without one — so the guard is
|
|
106
|
+
// not optional.
|
|
107
|
+
if (isSessionModelWithWidgets(session)) {
|
|
108
|
+
session.showWidget(session.addWidget('UcscResultsWidget', 'ucscResults', {
|
|
109
|
+
features,
|
|
110
|
+
assembly,
|
|
111
|
+
trackName,
|
|
112
|
+
resultNoun,
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
// #endregion
|
|
116
|
+
}
|