@jbrowse/plugin-gtf 4.3.0 → 5.0.0-beta.1
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/esm/GtfAdapter/GtfAdapter.d.ts +3 -20
- package/esm/GtfAdapter/GtfAdapter.js +38 -143
- package/esm/GtfAdapter/configSchema.d.ts +36 -10
- package/esm/GtfAdapter/configSchema.js +48 -13
- package/esm/GtfAdapter/index.js +3 -2
- package/esm/GtfTabixAdapter/GtfTabixAdapter.d.ts +12 -0
- package/esm/GtfTabixAdapter/GtfTabixAdapter.js +68 -0
- package/esm/GtfTabixAdapter/configSchema.d.ts +76 -0
- package/esm/GtfTabixAdapter/configSchema.js +86 -0
- package/esm/GtfTabixAdapter/index.d.ts +2 -0
- package/esm/GtfTabixAdapter/index.js +11 -0
- package/esm/index.js +3 -3
- package/esm/util.d.ts +75 -5
- package/esm/util.js +464 -51
- package/package.json +8 -17
- package/esm/GuessAdapter/index.d.ts +0 -2
- package/esm/GuessAdapter/index.js +0 -15
package/esm/util.d.ts
CHANGED
|
@@ -1,13 +1,83 @@
|
|
|
1
|
+
import type { SimpleFeatureSerialized } from '@jbrowse/core/util';
|
|
1
2
|
export type Strand = '+' | '-' | '.' | '?';
|
|
2
3
|
export interface FeatureLoc {
|
|
3
|
-
[key: string]: unknown;
|
|
4
4
|
start: number;
|
|
5
5
|
end: number;
|
|
6
6
|
strand: Strand;
|
|
7
7
|
seq_name: string;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
featureType?: string | null;
|
|
9
|
+
source?: string | null;
|
|
10
|
+
score?: number | null;
|
|
11
|
+
frame?: string | null;
|
|
12
|
+
child_features?: FeatureLoc[];
|
|
13
|
+
attributes: Record<string, string[]>;
|
|
12
14
|
}
|
|
13
15
|
export declare function featureData(data: FeatureLoc, id?: string): Record<string, unknown>;
|
|
16
|
+
/** A raw GTF feature line; callers may extend it with their own identity. */
|
|
17
|
+
export interface GtfLineRecord {
|
|
18
|
+
line: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A top-level parsed feature paired with the record it came from. The parser
|
|
22
|
+
* stamps no identity onto the feature; callers that need a stable per-feature
|
|
23
|
+
* id (e.g. a tabix byte offset) read it off their own `record`. Mirrors
|
|
24
|
+
* gff-nostream's `ParsedRecord`.
|
|
25
|
+
*/
|
|
26
|
+
export interface ParsedGtfRecord<R extends GtfLineRecord = GtfLineRecord> {
|
|
27
|
+
feature: FeatureLoc;
|
|
28
|
+
record: R;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Parse an array of records wrapping raw GTF lines into top-level features.
|
|
32
|
+
* Lines sharing a `transcript_id` are grouped under a transcript feature
|
|
33
|
+
* (synthesized if the file has no explicit `transcript` line, per the
|
|
34
|
+
* Cufflinks/StringTie convention); the transcript is spanned to cover its
|
|
35
|
+
* children. Lines without a `transcript_id` (e.g. a `gene` line) pass through
|
|
36
|
+
* as standalone features. Each top-level feature is returned paired with the
|
|
37
|
+
* record that defined it (the explicit line, or a synthesized transcript's
|
|
38
|
+
* first child), so callers can attach a stable id.
|
|
39
|
+
*/
|
|
40
|
+
export declare function parseGtf<R extends GtfLineRecord>(records: readonly R[]): ParsedGtfRecord<R>[];
|
|
41
|
+
/**
|
|
42
|
+
* Parse GTF records and serialize each top-level feature. Shared by the
|
|
43
|
+
* plain-text and tabix adapters, which differ only in how they source records
|
|
44
|
+
* and derive the uniqueId (parse index vs. tabix byte offset).
|
|
45
|
+
*/
|
|
46
|
+
export declare function parseGtfToFeatures<R extends GtfLineRecord>(records: readonly R[], makeUniqueId: (record: R, index: number) => string): SimpleFeatureSerialized[];
|
|
47
|
+
/**
|
|
48
|
+
* GTF has no spanning gene line, so a gene is synthesized by grouping related
|
|
49
|
+
* transcript features and spanning them. An explicit `gene` line is held aside
|
|
50
|
+
* rather than aggregated — the parser hangs exons off the transcript, so a gene
|
|
51
|
+
* line is childless and the synthesized parent supersedes it — and emitted only
|
|
52
|
+
* if no synthesized gene claimed its `gene_id`, which is what keeps a file of
|
|
53
|
+
* nothing but `gene` rows from rendering as an empty track. A gene line with no
|
|
54
|
+
* `gene_id` at all (AUGUSTUS writes a bare `g1` in column 9, which parses to no
|
|
55
|
+
* attributes) has no key to be superseded by, and is dropped.
|
|
56
|
+
*
|
|
57
|
+
* Grouping is keyed by `gene_id` where a transcript carries one, falling back to
|
|
58
|
+
* the aggregate value; `aggregateField` supplies the gene's display `name`. The
|
|
59
|
+
* two are separate because neither alone works:
|
|
60
|
+
*
|
|
61
|
+
* - `gene_name` (the default aggregate field, and the only one suitable as a
|
|
62
|
+
* label) is not unique within a reference sequence. A GENCODE chromosome holds
|
|
63
|
+
* hundreds of separate genes named `U6`, `Y_RNA` or `5S_rRNA`, and keying on
|
|
64
|
+
* the name alone fused all of them into one feature spanning the whole
|
|
65
|
+
* chromosome. `gene_id` is unique per gene by the GTF spec, so it splits those
|
|
66
|
+
* back apart while still merging the transcripts that really do share a gene.
|
|
67
|
+
* - Plenty of GTFs carry `gene_id` and no `gene_name` at all — UCSC's
|
|
68
|
+
* `genePredToGtf` emits `gene_id "TP53"; transcript_id "NM_000546";` and
|
|
69
|
+
* nothing else, and AUGUSTUS is the same shape. Keying only on the aggregate
|
|
70
|
+
* value left those with no gene model whatsoever: every transcript passed
|
|
71
|
+
* through bare. Keying on `gene_id` gives them genes, named by the `gene_id`
|
|
72
|
+
* when there's no better label.
|
|
73
|
+
*
|
|
74
|
+
* A feature with neither key still passes through unchanged.
|
|
75
|
+
*/
|
|
76
|
+
export declare function aggregateGtfFeatures({ feats, aggregateField, refName, regionStart, regionEnd, idPrefix, }: {
|
|
77
|
+
feats: SimpleFeatureSerialized[];
|
|
78
|
+
aggregateField: string;
|
|
79
|
+
refName: string;
|
|
80
|
+
regionStart: number;
|
|
81
|
+
regionEnd: number;
|
|
82
|
+
idPrefix: string;
|
|
83
|
+
}): SimpleFeatureSerialized[];
|
package/esm/util.js
CHANGED
|
@@ -1,56 +1,469 @@
|
|
|
1
|
+
import { doesIntersect2 } from '@jbrowse/core/util';
|
|
2
|
+
const strandMap = { '+': 1, '-': -1, '.': 0, '?': undefined };
|
|
3
|
+
// structural output fields an attribute must not overwrite; a clashing
|
|
4
|
+
// attribute key gets a `2` suffix instead (compared lowercase, so camelCase
|
|
5
|
+
// output fields like refName never collide). `subfeatures` is the one that
|
|
6
|
+
// matters beyond tidiness: on a feature with no children nothing overwrites it
|
|
7
|
+
// back, so an attribute of that name would leave a string where every consumer
|
|
8
|
+
// expects the child array
|
|
9
|
+
const reservedFields = new Set([
|
|
10
|
+
'type',
|
|
11
|
+
'source',
|
|
12
|
+
'start',
|
|
13
|
+
'end',
|
|
14
|
+
'strand',
|
|
15
|
+
'score',
|
|
16
|
+
'phase',
|
|
17
|
+
'subfeatures',
|
|
18
|
+
]);
|
|
1
19
|
export function featureData(data, id) {
|
|
2
|
-
const
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
for (const a
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (data.child_features && data.child_features.length > 0) {
|
|
39
|
-
f.subfeatures = data.child_features.flatMap(childLocs => childLocs.map(childLoc => featureData(childLoc)));
|
|
40
|
-
}
|
|
41
|
-
f.child_features = undefined;
|
|
42
|
-
f.data = undefined;
|
|
43
|
-
f.derived_features = undefined;
|
|
44
|
-
f._linehash = undefined;
|
|
45
|
-
f.attributes = undefined;
|
|
46
|
-
f.seq_name = undefined;
|
|
47
|
-
f.featureType = undefined;
|
|
48
|
-
f.frame = undefined;
|
|
49
|
-
if (f.transcript_id) {
|
|
50
|
-
f.name = f.transcript_id;
|
|
20
|
+
const subfeatures = data.child_features?.length
|
|
21
|
+
? data.child_features.map(childLoc => featureData(childLoc))
|
|
22
|
+
: undefined;
|
|
23
|
+
// build the output explicitly rather than spreading `data` and clearing its
|
|
24
|
+
// raw parser fields, which would leave them as `undefined`-valued keys
|
|
25
|
+
const out = {
|
|
26
|
+
refName: data.seq_name,
|
|
27
|
+
type: data.featureType,
|
|
28
|
+
source: data.source,
|
|
29
|
+
start: data.start - 1,
|
|
30
|
+
end: data.end,
|
|
31
|
+
strand: strandMap[data.strand],
|
|
32
|
+
score: data.score ?? undefined,
|
|
33
|
+
phase: data.frame ? Number(data.frame) : undefined,
|
|
34
|
+
};
|
|
35
|
+
// lowercase keys, suffix clashes with reserved fields, unwrap single-element
|
|
36
|
+
// arrays (quotes were already stripped at parse time). Assigned onto the
|
|
37
|
+
// output rather than built into an intermediate object and spread: the
|
|
38
|
+
// spread's `Object.entries`/`.map`/`Object.fromEntries` allocated four
|
|
39
|
+
// throwaway arrays plus a second object per feature, on a path that runs once
|
|
40
|
+
// per line of the file
|
|
41
|
+
for (const a in data.attributes) {
|
|
42
|
+
const vals = data.attributes[a];
|
|
43
|
+
const lower = a.toLowerCase();
|
|
44
|
+
out[reservedFields.has(lower) ? `${lower}2` : lower] =
|
|
45
|
+
vals.length === 1 ? vals[0] : vals;
|
|
46
|
+
}
|
|
47
|
+
if (subfeatures !== undefined) {
|
|
48
|
+
out.subfeatures = subfeatures;
|
|
49
|
+
}
|
|
50
|
+
// after the attributes, so an attribute that happens to be called `name`
|
|
51
|
+
// loses to the transcript_id the renderer labels with — as it did when this
|
|
52
|
+
// was a spread in this order
|
|
53
|
+
const transcriptId = out.transcript_id;
|
|
54
|
+
if (transcriptId) {
|
|
55
|
+
out.name = transcriptId;
|
|
51
56
|
}
|
|
52
57
|
if (id !== undefined) {
|
|
53
|
-
|
|
58
|
+
out.uniqueId = id;
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
function toStrand(s) {
|
|
63
|
+
return s === '+' || s === '-' || s === '?' ? s : '.';
|
|
64
|
+
}
|
|
65
|
+
function nullIfDot(s) {
|
|
66
|
+
return s === undefined || s === '.' ? null : s;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* True when `[from, to)` opens a double quote it never closes, meaning the ';'
|
|
70
|
+
* that ended it fell inside the value instead of ending the entry. A GTF value
|
|
71
|
+
* is either bare or wholly quoted, so a second quote settles it — no need to
|
|
72
|
+
* scan the rest of the entry.
|
|
73
|
+
*/
|
|
74
|
+
function hasUnclosedQuote(s, from, to) {
|
|
75
|
+
const first = s.indexOf('"', from);
|
|
76
|
+
if (first === -1 || first >= to) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
const second = s.indexOf('"', first + 1);
|
|
80
|
+
return second === -1 || second >= to;
|
|
81
|
+
}
|
|
82
|
+
// ASCII whitespace, which is what `String.prototype.trim` strips that can occur
|
|
83
|
+
// in a GTF attribute column: space plus \t \n \v \f \r. GTF is an ASCII format,
|
|
84
|
+
// so the Unicode space separators trim also handles cannot appear as padding
|
|
85
|
+
// here — and if one did it would stay in the key or value rather than corrupt
|
|
86
|
+
// the parse
|
|
87
|
+
function isSpace(c) {
|
|
88
|
+
return c === 32 || (c >= 9 && c <= 13);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Parse the GTF 9th column (`gene_id "X"; transcript_id "Y";`). Each `key
|
|
92
|
+
* "value"` entry contributes one value; GTF expresses multiple values per key
|
|
93
|
+
* via repeated keys (`tag "A"; tag "B"`), not comma separation, so the value is
|
|
94
|
+
* taken whole (a comma inside it, e.g. `note "a, b"`, stays intact). Surrounding
|
|
95
|
+
* double-quotes are stripped here.
|
|
96
|
+
*
|
|
97
|
+
* A quoted value may also contain the ';' the entries are split on
|
|
98
|
+
* (`note "a; b"`), so an entry is extended past that ';' before the value is
|
|
99
|
+
* read. Without that the value is silently truncated at the semicolon, and the
|
|
100
|
+
* remainder — having no `key value` shape — is dropped rather than erroring.
|
|
101
|
+
*
|
|
102
|
+
* Scanned over the string by index rather than `split(';')`-then-trim-then-
|
|
103
|
+
* `replaceAll(/^"|"$/g)`. This is the GTF load path's single hottest function —
|
|
104
|
+
* on a GENCODE-shaped 26,870-line file it was more of the profile than every
|
|
105
|
+
* other function combined, plus the GC for the two intermediate strings and the
|
|
106
|
+
* regex match it produced per attribute. Only the key and the value are
|
|
107
|
+
* allocated now, which are the two strings that get kept.
|
|
108
|
+
*/
|
|
109
|
+
function parseGtfAttributes(s, from, to) {
|
|
110
|
+
const attrs = {};
|
|
111
|
+
if (from >= to || (to - from === 1 && s.charCodeAt(from) === 46) /* . */) {
|
|
112
|
+
return attrs;
|
|
113
|
+
}
|
|
114
|
+
let i = from;
|
|
115
|
+
while (i < to) {
|
|
116
|
+
let end = s.indexOf(';', i);
|
|
117
|
+
if (end === -1 || end >= to) {
|
|
118
|
+
end = to;
|
|
119
|
+
}
|
|
120
|
+
// an unclosed quote means this ';' was inside the value: take the next one
|
|
121
|
+
// instead. A trailing entry with no closing quote has nothing to extend
|
|
122
|
+
// into and is read as-is
|
|
123
|
+
while (end < to && hasUnclosedQuote(s, i, end)) {
|
|
124
|
+
const next = s.indexOf(';', end + 1);
|
|
125
|
+
end = next === -1 || next >= to ? to : next;
|
|
126
|
+
}
|
|
127
|
+
// trim the entry by index
|
|
128
|
+
let es = i;
|
|
129
|
+
let ee = end;
|
|
130
|
+
while (es < ee && isSpace(s.charCodeAt(es))) {
|
|
131
|
+
es++;
|
|
132
|
+
}
|
|
133
|
+
while (ee > es && isSpace(s.charCodeAt(ee - 1))) {
|
|
134
|
+
ee--;
|
|
135
|
+
}
|
|
136
|
+
// `key value`, split at the first space — a tab does not separate them, as
|
|
137
|
+
// it did not when this read `trimmed.indexOf(' ')`
|
|
138
|
+
const sp = s.indexOf(' ', es);
|
|
139
|
+
if (sp !== -1 && sp < ee) {
|
|
140
|
+
let vs = sp + 1;
|
|
141
|
+
while (vs < ee && isSpace(s.charCodeAt(vs))) {
|
|
142
|
+
vs++;
|
|
143
|
+
}
|
|
144
|
+
let ve = ee;
|
|
145
|
+
// strip one leading and one trailing quote, independently: `"x"` and a
|
|
146
|
+
// half-quoted `"x` both yield `x`, and a bare `""` yields the empty
|
|
147
|
+
// string that is then dropped
|
|
148
|
+
if (vs < ve && s.charCodeAt(vs) === 34) {
|
|
149
|
+
vs++;
|
|
150
|
+
}
|
|
151
|
+
if (ve > vs && s.charCodeAt(ve - 1) === 34) {
|
|
152
|
+
ve--;
|
|
153
|
+
}
|
|
154
|
+
if (ve > vs) {
|
|
155
|
+
const key = s.slice(es, sp);
|
|
156
|
+
(attrs[key] ??= []).push(s.slice(vs, ve));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
i = end + 1;
|
|
160
|
+
}
|
|
161
|
+
return attrs;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* A line with fewer than nine columns, which is malformed. Split rather than
|
|
165
|
+
* scanned, so its absent fields and NaN coordinates come out exactly as they
|
|
166
|
+
* did when every line was split — the caller drops it on those NaNs.
|
|
167
|
+
*/
|
|
168
|
+
function parseShortGtfLine(line) {
|
|
169
|
+
const c = line.split('\t');
|
|
170
|
+
const score = c[5];
|
|
171
|
+
// a line this short has no ninth column at all, but read it if one is
|
|
172
|
+
// somehow there rather than differing from the old behaviour twice over
|
|
173
|
+
const attrs = c[8] ?? '';
|
|
174
|
+
return {
|
|
175
|
+
seq_name: c[0] ?? '',
|
|
176
|
+
source: nullIfDot(c[1]),
|
|
177
|
+
featureType: nullIfDot(c[2]),
|
|
178
|
+
start: Number(c[3]),
|
|
179
|
+
end: Number(c[4]),
|
|
180
|
+
score: score === undefined || score === '.' ? null : Number(score),
|
|
181
|
+
strand: toStrand(c[6]),
|
|
182
|
+
frame: nullIfDot(c[7]),
|
|
183
|
+
attributes: parseGtfAttributes(attrs, 0, attrs.length),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function parseGtfLine(line) {
|
|
187
|
+
// Scan for the eight tabs bounding the nine columns rather than splitting on
|
|
188
|
+
// them. Splitting materialized column 9 — by far the longest part of an
|
|
189
|
+
// annotation-grade line — as its own string, only for the attribute parser to
|
|
190
|
+
// read it and throw it away; the parser now reads it in place, so the biggest
|
|
191
|
+
// allocation per line is gone along with the array and the three numeric
|
|
192
|
+
// columns' strings.
|
|
193
|
+
//
|
|
194
|
+
// Taking column 9 as everything after the eighth tab also stops a tab inside
|
|
195
|
+
// it from truncating the attributes. `split('\t')` put the remainder in a
|
|
196
|
+
// tenth element that nothing read, so every attribute past such a tab was
|
|
197
|
+
// silently dropped.
|
|
198
|
+
const at = [0, 0, 0, 0, 0, 0, 0, 0];
|
|
199
|
+
let p = -1;
|
|
200
|
+
for (let i = 0; i < 8; i++) {
|
|
201
|
+
p = line.indexOf('\t', p + 1);
|
|
202
|
+
if (p === -1) {
|
|
203
|
+
return parseShortGtfLine(line);
|
|
204
|
+
}
|
|
205
|
+
at[i] = p;
|
|
206
|
+
}
|
|
207
|
+
const score = line.slice(at[4] + 1, at[5]);
|
|
208
|
+
return {
|
|
209
|
+
seq_name: line.slice(0, at[0]),
|
|
210
|
+
source: nullIfDot(line.slice(at[0] + 1, at[1])),
|
|
211
|
+
featureType: nullIfDot(line.slice(at[1] + 1, at[2])),
|
|
212
|
+
start: Number(line.slice(at[2] + 1, at[3])),
|
|
213
|
+
end: Number(line.slice(at[3] + 1, at[4])),
|
|
214
|
+
score: score === '.' ? null : Number(score),
|
|
215
|
+
strand: toStrand(line.slice(at[5] + 1, at[6])),
|
|
216
|
+
frame: nullIfDot(line.slice(at[6] + 1, at[7])),
|
|
217
|
+
attributes: parseGtfAttributes(line, at[7] + 1, line.length),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
function synthesizeTranscript(child) {
|
|
221
|
+
return {
|
|
222
|
+
seq_name: child.seq_name,
|
|
223
|
+
source: child.source,
|
|
224
|
+
featureType: 'transcript',
|
|
225
|
+
start: child.start,
|
|
226
|
+
end: child.end,
|
|
227
|
+
score: null,
|
|
228
|
+
strand: child.strand,
|
|
229
|
+
frame: null,
|
|
230
|
+
// seeded from the first child, then narrowed by narrowToCommonAttributes as
|
|
231
|
+
// the rest arrive
|
|
232
|
+
attributes: { ...child.attributes },
|
|
233
|
+
child_features: [],
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Keep only the attributes a synthesized transcript's children agree on,
|
|
238
|
+
* dropping any key that is missing from `child` or carries a different value
|
|
239
|
+
* there. Transcript- and gene-level tags (`transcript_id`, `gene_id`,
|
|
240
|
+
* `gene_name`, biotypes, StringTie's `ref_gene_name`, …) repeat identically on
|
|
241
|
+
* every line of a transcript and survive; per-line tags (`exon_number`,
|
|
242
|
+
* `exon_id`, a CDS-only `protein_id`) differ or are absent and are dropped.
|
|
243
|
+
*
|
|
244
|
+
* Deriving the set this way rather than from a fixed whitelist is what lets any
|
|
245
|
+
* `aggregateField` work: a hardcoded list silently failed to aggregate whenever
|
|
246
|
+
* the configured field wasn't one of the three names it happened to enumerate.
|
|
247
|
+
*/
|
|
248
|
+
function narrowToCommonAttributes(attributes, child) {
|
|
249
|
+
for (const key of Object.keys(attributes)) {
|
|
250
|
+
const mine = attributes[key];
|
|
251
|
+
const theirs = child.attributes[key];
|
|
252
|
+
if (theirs === undefined ||
|
|
253
|
+
theirs.length !== mine.length ||
|
|
254
|
+
theirs.some((v, i) => v !== mine[i])) {
|
|
255
|
+
delete attributes[key];
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Parse an array of records wrapping raw GTF lines into top-level features.
|
|
261
|
+
* Lines sharing a `transcript_id` are grouped under a transcript feature
|
|
262
|
+
* (synthesized if the file has no explicit `transcript` line, per the
|
|
263
|
+
* Cufflinks/StringTie convention); the transcript is spanned to cover its
|
|
264
|
+
* children. Lines without a `transcript_id` (e.g. a `gene` line) pass through
|
|
265
|
+
* as standalone features. Each top-level feature is returned paired with the
|
|
266
|
+
* record that defined it (the explicit line, or a synthesized transcript's
|
|
267
|
+
* first child), so callers can attach a stable id.
|
|
268
|
+
*/
|
|
269
|
+
export function parseGtf(records) {
|
|
270
|
+
const topLevel = [];
|
|
271
|
+
const byTranscript = new Map();
|
|
272
|
+
// transcripts with no explicit line of their own, whose attributes are still
|
|
273
|
+
// being narrowed down to what their children agree on
|
|
274
|
+
const synthesized = new Set();
|
|
275
|
+
for (const record of records) {
|
|
276
|
+
// defensive: both of today's line sources already drop a CRLF terminator
|
|
277
|
+
// (@gmod/tabix trims it in its line reader "matching htslib's", and the
|
|
278
|
+
// plaintext path's parseLineByLine trims each line), so this normally does
|
|
279
|
+
// nothing. It stays because an untrimmed \r is silent — on a line with no
|
|
280
|
+
// trailing ';' it lands inside the last attribute value, corrupting the
|
|
281
|
+
// transcript_id that drives both grouping and the feature name
|
|
282
|
+
const line = record.line.endsWith('\r')
|
|
283
|
+
? record.line.slice(0, -1)
|
|
284
|
+
: record.line;
|
|
285
|
+
if (line.length === 0 || line.startsWith('#')) {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
const feature = parseGtfLine(line);
|
|
289
|
+
// a line whose coordinate columns aren't numbers is not a feature, and
|
|
290
|
+
// letting its NaN through is not a local failure: Math.min/Math.max against
|
|
291
|
+
// NaN poisons the transcript spanning it, and a NaN-bounded transcript then
|
|
292
|
+
// fails every intersection test, so one truncated line silently removes a
|
|
293
|
+
// whole gene rather than itself
|
|
294
|
+
if (Number.isNaN(feature.start) || Number.isNaN(feature.end)) {
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
const transcriptId = feature.attributes.transcript_id?.[0];
|
|
298
|
+
if (transcriptId === undefined) {
|
|
299
|
+
topLevel.push({ feature, record });
|
|
300
|
+
}
|
|
301
|
+
else if (feature.featureType === 'transcript') {
|
|
302
|
+
const existing = byTranscript.get(transcriptId);
|
|
303
|
+
if (existing) {
|
|
304
|
+
// explicit transcript line seen after its children: keep the
|
|
305
|
+
// collected children, but use the explicit line as the container
|
|
306
|
+
feature.child_features = existing.feature.child_features;
|
|
307
|
+
feature.start = Math.min(feature.start, existing.feature.start);
|
|
308
|
+
feature.end = Math.max(feature.end, existing.feature.end);
|
|
309
|
+
existing.feature = feature;
|
|
310
|
+
existing.record = record;
|
|
311
|
+
synthesized.delete(transcriptId);
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
feature.child_features = [];
|
|
315
|
+
const parsed = { feature, record };
|
|
316
|
+
topLevel.push(parsed);
|
|
317
|
+
byTranscript.set(transcriptId, parsed);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
let transcript = byTranscript.get(transcriptId);
|
|
322
|
+
if (transcript) {
|
|
323
|
+
// an explicit transcript line's own attributes are authoritative, so
|
|
324
|
+
// only a synthesized one gets narrowed against this child
|
|
325
|
+
if (synthesized.has(transcriptId)) {
|
|
326
|
+
narrowToCommonAttributes(transcript.feature.attributes, feature);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
transcript = { feature: synthesizeTranscript(feature), record };
|
|
331
|
+
topLevel.push(transcript);
|
|
332
|
+
byTranscript.set(transcriptId, transcript);
|
|
333
|
+
synthesized.add(transcriptId);
|
|
334
|
+
}
|
|
335
|
+
transcript.feature.child_features.push(feature);
|
|
336
|
+
transcript.feature.start = Math.min(transcript.feature.start, feature.start);
|
|
337
|
+
transcript.feature.end = Math.max(transcript.feature.end, feature.end);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return topLevel;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Parse GTF records and serialize each top-level feature. Shared by the
|
|
344
|
+
* plain-text and tabix adapters, which differ only in how they source records
|
|
345
|
+
* and derive the uniqueId (parse index vs. tabix byte offset).
|
|
346
|
+
*/
|
|
347
|
+
export function parseGtfToFeatures(records, makeUniqueId) {
|
|
348
|
+
return parseGtf(records).map(({ feature, record }, i) => featureData(feature, makeUniqueId(record, i)));
|
|
349
|
+
}
|
|
350
|
+
/** The value if it is a non-empty string, otherwise undefined. */
|
|
351
|
+
function nonEmptyString(value) {
|
|
352
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* GTF has no spanning gene line, so a gene is synthesized by grouping related
|
|
356
|
+
* transcript features and spanning them. An explicit `gene` line is held aside
|
|
357
|
+
* rather than aggregated — the parser hangs exons off the transcript, so a gene
|
|
358
|
+
* line is childless and the synthesized parent supersedes it — and emitted only
|
|
359
|
+
* if no synthesized gene claimed its `gene_id`, which is what keeps a file of
|
|
360
|
+
* nothing but `gene` rows from rendering as an empty track. A gene line with no
|
|
361
|
+
* `gene_id` at all (AUGUSTUS writes a bare `g1` in column 9, which parses to no
|
|
362
|
+
* attributes) has no key to be superseded by, and is dropped.
|
|
363
|
+
*
|
|
364
|
+
* Grouping is keyed by `gene_id` where a transcript carries one, falling back to
|
|
365
|
+
* the aggregate value; `aggregateField` supplies the gene's display `name`. The
|
|
366
|
+
* two are separate because neither alone works:
|
|
367
|
+
*
|
|
368
|
+
* - `gene_name` (the default aggregate field, and the only one suitable as a
|
|
369
|
+
* label) is not unique within a reference sequence. A GENCODE chromosome holds
|
|
370
|
+
* hundreds of separate genes named `U6`, `Y_RNA` or `5S_rRNA`, and keying on
|
|
371
|
+
* the name alone fused all of them into one feature spanning the whole
|
|
372
|
+
* chromosome. `gene_id` is unique per gene by the GTF spec, so it splits those
|
|
373
|
+
* back apart while still merging the transcripts that really do share a gene.
|
|
374
|
+
* - Plenty of GTFs carry `gene_id` and no `gene_name` at all — UCSC's
|
|
375
|
+
* `genePredToGtf` emits `gene_id "TP53"; transcript_id "NM_000546";` and
|
|
376
|
+
* nothing else, and AUGUSTUS is the same shape. Keying only on the aggregate
|
|
377
|
+
* value left those with no gene model whatsoever: every transcript passed
|
|
378
|
+
* through bare. Keying on `gene_id` gives them genes, named by the `gene_id`
|
|
379
|
+
* when there's no better label.
|
|
380
|
+
*
|
|
381
|
+
* A feature with neither key still passes through unchanged.
|
|
382
|
+
*/
|
|
383
|
+
export function aggregateGtfFeatures({ feats, aggregateField, refName, regionStart, regionEnd, idPrefix, }) {
|
|
384
|
+
const out = [];
|
|
385
|
+
const parentAggregation = new Map();
|
|
386
|
+
const explicitGenes = new Map();
|
|
387
|
+
for (const feat of feats) {
|
|
388
|
+
const geneId = nonEmptyString(feat.gene_id);
|
|
389
|
+
if (feat.type === 'gene') {
|
|
390
|
+
if (geneId !== undefined) {
|
|
391
|
+
explicitGenes.set(geneId, feat);
|
|
392
|
+
}
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
const name = nonEmptyString(feat[aggregateField]);
|
|
396
|
+
const key = geneId ?? name;
|
|
397
|
+
if (key !== undefined) {
|
|
398
|
+
let group = parentAggregation.get(key);
|
|
399
|
+
if (group) {
|
|
400
|
+
// a transcript that carries the aggregate value names the gene even
|
|
401
|
+
// when the first one grouped under this key didn't
|
|
402
|
+
group.name ??= name;
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
group = { name, subfeatures: [] };
|
|
406
|
+
parentAggregation.set(key, group);
|
|
407
|
+
}
|
|
408
|
+
group.subfeatures.push(feat);
|
|
409
|
+
}
|
|
410
|
+
else if (
|
|
411
|
+
// a childless transcript with nothing to group on is a stray container
|
|
412
|
+
// line rather than a feature — AUGUSTUS's bare `transcript` line, whose
|
|
413
|
+
// real model arrives as the exon/CDS lines below it. One carrying a
|
|
414
|
+
// gene_id is a real transcript and was aggregated above; dropping those
|
|
415
|
+
// too made a transcript-only GTF render nothing at all
|
|
416
|
+
(feat.type !== 'transcript' || feat.subfeatures?.length) &&
|
|
417
|
+
doesIntersect2(feat.start, feat.end, regionStart, regionEnd)) {
|
|
418
|
+
// passthrough features (neither a gene_id nor an aggregate value) must
|
|
419
|
+
// be clipped to the original query too, else a redispatch's expanded
|
|
420
|
+
// fetch leaks features outside the view (aggregated genes are already
|
|
421
|
+
// intersection-checked)
|
|
422
|
+
out.push(feat);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
for (const [geneId, feat] of explicitGenes) {
|
|
426
|
+
if (!parentAggregation.has(geneId) &&
|
|
427
|
+
doesIntersect2(feat.start, feat.end, regionStart, regionEnd)) {
|
|
428
|
+
// labeled the way a synthesized gene is: featureData names a feature from
|
|
429
|
+
// its transcript_id, which a gene line hasn't got
|
|
430
|
+
out.push({
|
|
431
|
+
...feat,
|
|
432
|
+
name: nonEmptyString(feat.name) ??
|
|
433
|
+
nonEmptyString(feat[aggregateField]) ??
|
|
434
|
+
geneId,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
for (const [key, { name, subfeatures }] of parentAggregation) {
|
|
439
|
+
let start = Number.POSITIVE_INFINITY;
|
|
440
|
+
let end = Number.NEGATIVE_INFINITY;
|
|
441
|
+
for (const f of subfeatures) {
|
|
442
|
+
if (f.start < start) {
|
|
443
|
+
start = f.start;
|
|
444
|
+
}
|
|
445
|
+
if (f.end > end) {
|
|
446
|
+
end = f.end;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (doesIntersect2(start, end, regionStart, regionEnd)) {
|
|
450
|
+
out.push({
|
|
451
|
+
type: 'gene',
|
|
452
|
+
subfeatures,
|
|
453
|
+
strand: subfeatures[0].strand,
|
|
454
|
+
// falls back to the grouping key, so a gene_id-only file (UCSC,
|
|
455
|
+
// AUGUSTUS) still gets a labeled gene rather than an unnamed one
|
|
456
|
+
name: name ?? key,
|
|
457
|
+
start,
|
|
458
|
+
end,
|
|
459
|
+
refName,
|
|
460
|
+
// stable across fetch windows: the same gene keeps one id while panning,
|
|
461
|
+
// unlike a subfeature-derived id where the "first" transcript (and thus
|
|
462
|
+
// the id) shifts with whatever the current window happened to pull in.
|
|
463
|
+
// the grouping key is unique within a ref, so refName+key is unique
|
|
464
|
+
uniqueId: `${idPrefix}-${refName}-gene-${key}`,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
54
467
|
}
|
|
55
|
-
return
|
|
468
|
+
return out;
|
|
56
469
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jbrowse/plugin-gtf",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.0-beta.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "JBrowse 2 gtf feature adapter",
|
|
6
6
|
"keywords": [
|
|
@@ -21,19 +21,13 @@
|
|
|
21
21
|
"esm"
|
|
22
22
|
],
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@
|
|
25
|
-
"@
|
|
26
|
-
"@jbrowse/mobx-state-tree": "^5.6.0",
|
|
27
|
-
"@mui/material": "^7.3.11",
|
|
28
|
-
"gtf-nostream": "^1.3.4",
|
|
29
|
-
"mobx": "^6.15.3",
|
|
30
|
-
"mobx-react": "^9.2.1",
|
|
24
|
+
"@gmod/tabix": "^3.8.2",
|
|
25
|
+
"@jbrowse/mobx-state-tree": "^6.5.1",
|
|
31
26
|
"rxjs": "^7.8.2",
|
|
32
|
-
"@jbrowse/core": "^
|
|
33
|
-
"@jbrowse/plugin-linear-genome-view": "^4.3.0"
|
|
27
|
+
"@jbrowse/core": "^5.0.0-beta.1"
|
|
34
28
|
},
|
|
35
29
|
"peerDependencies": {
|
|
36
|
-
"react": ">=
|
|
30
|
+
"react": ">=19.0.0"
|
|
37
31
|
},
|
|
38
32
|
"publishConfig": {
|
|
39
33
|
"access": "public"
|
|
@@ -41,16 +35,13 @@
|
|
|
41
35
|
"sideEffects": false,
|
|
42
36
|
"scripts": {
|
|
43
37
|
"build": "pnpm run /^build:/",
|
|
44
|
-
"test": "cd ../..; jest --passWithNoTests plugins/gtf
|
|
38
|
+
"test": "cd ../..; jest --passWithNoTests plugins/gtf",
|
|
45
39
|
"prebuild": "pnpm clean",
|
|
46
|
-
"build:esm": "tsc
|
|
40
|
+
"build:esm": "node ../../node_modules/typescript7/bin/tsc --build tsconfig.build.esm.json",
|
|
47
41
|
"clean": "rimraf esm *.tsbuildinfo"
|
|
48
42
|
},
|
|
49
43
|
"types": "esm/index.d.ts",
|
|
50
44
|
"exports": {
|
|
51
|
-
".":
|
|
52
|
-
"types": "./esm/index.d.ts",
|
|
53
|
-
"import": "./esm/index.js"
|
|
54
|
-
}
|
|
45
|
+
".": "./esm/index.js"
|
|
55
46
|
}
|
|
56
47
|
}
|