@jbrowse/plugin-gtf 4.2.1 → 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/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
- child_features?: FeatureLoc[][];
9
- data: unknown;
10
- derived_features: unknown;
11
- attributes: Record<string, unknown[]>;
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 f = { ...data };
3
- f.start -= 1;
4
- f.strand = { '+': 1, '-': -1, '.': 0, '?': undefined }[data.strand];
5
- f.phase = Number(data.frame);
6
- f.refName = data.seq_name;
7
- if (data.score === null) {
8
- f.score = undefined;
9
- }
10
- if (data.frame === null) {
11
- f.score = undefined;
12
- }
13
- const defaultFields = new Set([
14
- 'start',
15
- 'end',
16
- 'seq_name',
17
- 'score',
18
- 'featureType',
19
- 'source',
20
- 'frame',
21
- 'strand',
22
- ]);
23
- for (const a of Object.keys(data.attributes)) {
24
- let b = a.toLowerCase();
25
- if (defaultFields.has(b)) {
26
- b += '2';
27
- }
28
- if (data.attributes[a]) {
29
- let attr = data.attributes[a];
30
- if (Array.isArray(attr) && attr.length === 1) {
31
- attr = attr[0].replaceAll(/^"|"$/g, '');
32
- }
33
- f[b] = attr;
34
- }
35
- }
36
- f.refName = f.seq_name;
37
- f.type = f.featureType;
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
- f.uniqueId = id;
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 f;
468
+ return out;
56
469
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jbrowse/plugin-gtf",
3
- "version": "4.2.1",
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
- "@flatten-js/interval-tree": "^2.0.3",
25
- "@gmod/bgzf-filehandle": "^6.0.18",
26
- "@jbrowse/mobx-state-tree": "^5.6.0",
27
- "@mui/material": "^7.3.10",
28
- "gtf-nostream": "^1.3.4",
29
- "mobx": "^6.15.0",
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/plugin-linear-genome-view": "^4.2.1",
33
- "@jbrowse/core": "^4.2.1"
27
+ "@jbrowse/core": "^5.0.0-beta.1"
34
28
  },
35
29
  "peerDependencies": {
36
- "react": ">=18.0.0"
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 --passWithNoTests",
38
+ "test": "cd ../..; jest --passWithNoTests plugins/gtf",
45
39
  "prebuild": "pnpm clean",
46
- "build:esm": "tsc -p tsconfig.build.esm.json",
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
  }
@@ -1,2 +0,0 @@
1
- import type PluginManager from '@jbrowse/core/PluginManager';
2
- export default function GuessAdapterF(pluginManager: PluginManager): void;