@jbrowse/plugin-gff3 5.0.0-beta.11 → 5.0.0-beta.14

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.
@@ -1,10 +1,11 @@
1
+ import { parseLinesLazy } from '@gmod/gff-nostream';
1
2
  import { BaseFeatureDataAdapter, cachedSetup, } from '@jbrowse/core/data_adapters/BaseAdapter';
2
3
  import { fetchAndMaybeUnzip } from '@jbrowse/core/util';
3
4
  import { openLocation } from '@jbrowse/core/util/io';
4
5
  import { groupLinesByRef, makeFeatureIntervalTreeMap, } from '@jbrowse/core/util/parseLineByLine';
5
6
  import { ObservableCreate } from '@jbrowse/core/util/rxjs';
6
- import { parseLinesLazy } from 'gff-nostream';
7
7
  import { Gff3Feature } from '../Gff3Feature.js';
8
+ import { cutAtOrigin, sequenceLengths } from '../originSpanning.js';
8
9
  export default class Gff3Adapter extends BaseFeatureDataAdapter {
9
10
  // the whole file is resident after one load, so the fetch/parse status comes
10
11
  // from inside the load itself rather than a label wrapped around it
@@ -32,7 +33,8 @@ export default class Gff3Adapter extends BaseFeatureDataAdapter {
32
33
  feature,
33
34
  uniqueId: `${this.id}-${refName}-${i}`,
34
35
  })), 'Parsing GFF data');
35
- return { header: headerLines.join('\n'), intervalTreeMap };
36
+ const header = headerLines.join('\n');
37
+ return { header, intervalTreeMap, lengths: sequenceLengths(header) };
36
38
  },
37
39
  });
38
40
  async getRefNames(opts = {}) {
@@ -48,13 +50,19 @@ export default class Gff3Adapter extends BaseFeatureDataAdapter {
48
50
  // observer.error itself
49
51
  return ObservableCreate(async (observer) => {
50
52
  const { start, end, refName } = query;
51
- const { intervalTreeMap } = await this.loadData(opts);
52
- const tree = intervalTreeMap[refName];
53
+ const { intervalTreeMap, lengths } = await this.loadData(opts);
54
+ const tree = intervalTreeMap[refName]?.(opts.statusCallback);
53
55
  if (tree) {
54
- for (const { feature, uniqueId } of tree(opts.statusCallback).search([
55
- start,
56
- end,
57
- ])) {
56
+ // a feature crossing a circular sequence's origin is stored from its
57
+ // start near the end, so a query short of the end looks there too
58
+ const length = lengths.get(refName);
59
+ const candidates = [
60
+ ...tree.search([start, end]),
61
+ ...(length !== undefined && end < length
62
+ ? tree.search([length - 1, length])
63
+ : []),
64
+ ];
65
+ for (const { feature, uniqueId } of cutAtOrigin(candidates, query, length)) {
58
66
  observer.next(new Gff3Feature(feature, uniqueId));
59
67
  }
60
68
  }
@@ -5,8 +5,8 @@ export declare function normalizeSnapshot(snap: Record<string, unknown>): Record
5
5
  * #category adapter
6
6
  * #trackType FeatureTrack
7
7
  * #fileFormat feature | GFF3 (plain) | Loaded entirely into memory; for small files
8
- * used to load plain-text GFF3 files. Loads the whole file into memory, so
9
- * prefer the Gff3TabixAdapter for large files.
8
+ * Reads plain-text GFF3 files. The adapter loads the whole file into memory,
9
+ * so use `Gff3TabixAdapter` for large files.
10
10
  *
11
11
  * #example
12
12
  * ```js
@@ -7,8 +7,8 @@ export function normalizeSnapshot(snap) {
7
7
  * #category adapter
8
8
  * #trackType FeatureTrack
9
9
  * #fileFormat feature | GFF3 (plain) | Loaded entirely into memory; for small files
10
- * used to load plain-text GFF3 files. Loads the whole file into memory, so
11
- * prefer the Gff3TabixAdapter for large files.
10
+ * Reads plain-text GFF3 files. The adapter loads the whole file into memory,
11
+ * so use `Gff3TabixAdapter` for large files.
12
12
  *
13
13
  * #example
14
14
  * ```js
@@ -33,6 +33,7 @@ const Gff3Adapter = ConfigurationSchema('Gff3Adapter', {
33
33
  },
34
34
  }, {
35
35
  explicitlyTyped: true,
36
+ closed: true,
36
37
  /**
37
38
  * #preProcessSnapshot
38
39
  *
@@ -1,5 +1,5 @@
1
+ import type { LazyGffFeature } from '@gmod/gff-nostream';
1
2
  import type { Feature, SimpleFeatureSerialized } from '@jbrowse/core/util/simpleFeature';
2
- import type { LazyGffFeature } from 'gff-nostream';
3
3
  /**
4
4
  * A {@link Feature} over a GFF3 feature whose attributes are still raw column-9
5
5
  * text, resolving each one only when something asks for it.
@@ -38,6 +38,8 @@ export declare class Gff3Feature implements Feature {
38
38
  get(name: 'subfeatures'): Feature[] | undefined;
39
39
  get(name: string): unknown;
40
40
  id(): string;
41
+ /** The feature's column 9 keyed as the file writes it (`fileAttributes`). */
42
+ fileAttributes(): Record<string, string[]>;
41
43
  parent(): Feature | undefined;
42
44
  /**
43
45
  * Wrapped lazily rather than in the constructor: a feature the layout pass
@@ -1,4 +1,5 @@
1
- import { getAttribute, getAttributes } from 'gff-nostream';
1
+ import { getAttribute, getAttributes } from '@gmod/gff-nostream';
2
+ import { fileAttributes } from './fileAttributes.js';
2
3
  /**
3
4
  * A {@link Feature} over a GFF3 feature whose attributes are still raw column-9
4
5
  * text, resolving each one only when something asks for it.
@@ -69,6 +70,10 @@ export class Gff3Feature {
69
70
  id() {
70
71
  return this.uniqueId;
71
72
  }
73
+ /** The feature's column 9 keyed as the file writes it (`fileAttributes`). */
74
+ fileAttributes() {
75
+ return fileAttributes(this.data.attributeString);
76
+ }
72
77
  parent() {
73
78
  return this.parentFeature;
74
79
  }
@@ -4,7 +4,10 @@ import type { BaseOptions } from '@jbrowse/core/data_adapters/BaseAdapter';
4
4
  import type { Feature } from '@jbrowse/core/util/simpleFeature';
5
5
  import type { Region } from '@jbrowse/core/util/types';
6
6
  export default class Gff3TabixAdapter extends BaseFeatureDataAdapter<Gff3TabixAdapterConfig> {
7
+ private file?;
8
+ freeResources(): void;
7
9
  private configure;
10
+ private lengths;
8
11
  getRefNames(opts?: BaseOptions): Promise<string[]>;
9
12
  getHeader(opts?: BaseOptions): Promise<string>;
10
13
  getRegionByteSize(regions: Region[], opts?: BaseOptions): Promise<number>;
@@ -1,14 +1,19 @@
1
+ import { hasIdAttribute, parseRecordsLazy } from '@gmod/gff-nostream';
1
2
  import { TabixIndexedFile } from '@gmod/tabix';
2
3
  import { BaseFeatureDataAdapter, cachedSetup, } from '@jbrowse/core/data_adapters/BaseAdapter';
3
4
  import { downloadStatus } from '@jbrowse/core/util';
4
5
  import { decompressedBytesBudget } from '@jbrowse/core/util/cacheBudgets';
5
6
  import { openLocation, openTabixIndexFilehandle } from '@jbrowse/core/util/io';
6
- import { doesIntersect2 } from '@jbrowse/core/util/range';
7
7
  import { ObservableCreate } from '@jbrowse/core/util/rxjs';
8
8
  import { readTabixLinesRedispatched } from '@jbrowse/core/util/tabix';
9
- import { hasIdAttribute, parseRecordsLazy } from 'gff-nostream';
10
9
  import { Gff3Feature } from '../Gff3Feature.js';
10
+ import { cutAtOrigin, sequenceLengths } from '../originSpanning.js';
11
11
  export default class Gff3TabixAdapter extends BaseFeatureDataAdapter {
12
+ // set once the file is open, for dataAdapterCache's eviction to clear
13
+ file;
14
+ freeResources() {
15
+ this.file?.clearChunkCache();
16
+ }
12
17
  configure = cachedSetup({
13
18
  label: 'Downloading index',
14
19
  setup: async (opts, onProgress) => {
@@ -17,6 +22,7 @@ export default class Gff3TabixAdapter extends BaseFeatureDataAdapter {
17
22
  ...openTabixIndexFilehandle(this.getConf(['index', 'location']), this.getConf(['index', 'indexType']), this.pluginManager),
18
23
  chunkCacheBudget: decompressedBytesBudget,
19
24
  });
25
+ this.file = gff;
20
26
  // the index is a whole-file read, so its byte ticks turn the
21
27
  // "Downloading index" label into a determinate bar
22
28
  await gff.getReferenceSequenceNames({ onProgress, signal: opts.signal });
@@ -26,6 +32,15 @@ export default class Gff3TabixAdapter extends BaseFeatureDataAdapter {
26
32
  };
27
33
  },
28
34
  });
35
+ // the header read is sized from the index, so it can start only once the
36
+ // index is in; the region read needs the same index and nothing else, so
37
+ // getFeatures runs the two together
38
+ lengths = cachedSetup({
39
+ setup: async (opts) => {
40
+ const { gff } = await this.configure(opts);
41
+ return sequenceLengths(await gff.getHeader(opts));
42
+ },
43
+ });
29
44
  async getRefNames(opts = {}) {
30
45
  const { gff } = await this.configure(opts);
31
46
  return downloadStatus('Downloading index', opts.statusCallback, onProgress => gff.getReferenceSequenceNames({ ...opts, onProgress }));
@@ -47,7 +62,7 @@ export default class Gff3TabixAdapter extends BaseFeatureDataAdapter {
47
62
  const { gff, dontRedispatchSet } = await this.configure(opts);
48
63
  // The bound exists to reach a record's children, so what disqualifies a
49
64
  // record is having none. `ID` answers that exactly — nothing can name a
50
- // parent that has no name — and gff-nostream decides it with the scan
65
+ // parent that has no name — and @gmod/gff-nostream decides it with the scan
51
66
  // its linker uses, so a record the tree would attach children to is
52
67
  // never one the bound leaves out. `dontRedispatch` can only approximate
53
68
  // the same question by type: every NCBI `GCF_*_genomic.gff.gz` opens
@@ -56,22 +71,39 @@ export default class Gff3TabixAdapter extends BaseFeatureDataAdapter {
56
71
  // record carrying an `ID` that nothing references, which hosted hg19
57
72
  // RefSeq's chromosome-long `region` is.
58
73
  const canHaveChildren = (line) => hasIdAttribute(line.line) && !dontRedispatchSet.has(line.type);
59
- const lines = await readTabixLinesRedispatched(gff, query,
74
+ const read = (region) => readTabixLinesRedispatched(gff, region,
60
75
  // A caller reading only top-level features is owed nothing by the
61
- // flanks and pays for them anyway: they complete SUBFEATURE lists, and
62
- // a top-level feature overlapping the query is in the query's own read
63
- // already, since tabix returns every overlapping line and a child is
64
- // contained in its parent. On an NCBI `GCF_*_genomic.gff.gz`, whose
65
- // chromosome-long `match` widens the bound at every window, that is
66
- // 193,008 lines parsed to keep 3 features — 2734 ms against 8 ms.
76
+ // flanks and pays for them anyway: they complete SUBFEATURE lists,
77
+ // and a top-level feature overlapping the query is in the query's
78
+ // own read already, since tabix returns every overlapping line and
79
+ // a child is contained in its parent. On an NCBI
80
+ // `GCF_*_genomic.gff.gz`, whose chromosome-long `match` widens the
81
+ // bound at every window, that is 193,008 lines parsed to keep 3
82
+ // features — 2734 ms against 8 ms.
67
83
  opts.topLevelOnly ? () => false : canHaveChildren, opts);
68
- // emit only top-level features intersecting the original query. the
69
- // byte offset stays on our own record and is used purely to mint a
70
- // stable id, so it never pollutes the feature's data
71
- for (const { feature, record } of parseRecordsLazy(lines)) {
72
- if (doesIntersect2(feature.start, feature.end, query.start, query.end)) {
73
- observer.next(new Gff3Feature(feature, `${this.id}-offset-${record.offset}`));
74
- }
84
+ // a feature crossing a circular sequence's origin is indexed from its
85
+ // start near the end, so a query short of the end reads the last base
86
+ // too, for the part such a feature carries past the origin. That read
87
+ // needs the length from the header, so it starts when the header
88
+ // arrives and the query's own read does not wait for it.
89
+ const tailRead = this.lengths(opts).then(async (lengths) => {
90
+ const length = lengths.get(query.refName);
91
+ return {
92
+ length,
93
+ lines: length !== undefined && query.end < length
94
+ ? await read({ ...query, start: length - 1, end: length })
95
+ : [],
96
+ };
97
+ });
98
+ // the byte offset stays on our own record and is used purely to mint
99
+ // a stable id, so it never pollutes the feature's data
100
+ const [lines, tail] = await Promise.all([read(query), tailRead]);
101
+ const candidates = [lines, tail.lines].flatMap(group => parseRecordsLazy(group).map(({ feature, record }) => ({
102
+ feature,
103
+ uniqueId: `${this.id}-offset-${record.offset}`,
104
+ })));
105
+ for (const { feature, uniqueId } of cutAtOrigin(candidates, query, tail.length)) {
106
+ observer.next(new Gff3Feature(feature, uniqueId));
75
107
  }
76
108
  observer.complete();
77
109
  }
@@ -5,7 +5,7 @@ export declare function normalizeSnapshot(snap: Record<string, unknown>): Record
5
5
  * #category adapter
6
6
  * #trackType FeatureTrack
7
7
  * #fileFormat feature | GFF3 (tabix)
8
- * used to load bgzip-compressed, tabix-indexed GFF3 files
8
+ * Reads bgzip-compressed, tabix-indexed GFF3 files.
9
9
  *
10
10
  * #example
11
11
  * The `uri` shorthand auto-resolves the `.tbi` index; add `csi: true` for a
@@ -18,9 +18,6 @@ export declare function normalizeSnapshot(snap: Record<string, unknown>): Record
18
18
  * ```
19
19
  */
20
20
  declare const Gff3TabixAdapter: import("@jbrowse/core/configuration/configurationSchema").ConfigurationSchemaType<{
21
- readonly densityAdapter: {
22
- type: "maybeFrozen";
23
- };
24
21
  type: {
25
22
  type: "string";
26
23
  } & {
@@ -1,5 +1,4 @@
1
1
  import { ConfigurationSchema, expandTabixShorthand, tabixIndexSchema, } from '@jbrowse/core/configuration';
2
- import { densityAdapterConfigSchemaFields } from '@jbrowse/core/data_adapters/BaseAdapter';
3
2
  export function normalizeSnapshot(snap) {
4
3
  return expandTabixShorthand(snap, 'gffGzLocation');
5
4
  }
@@ -8,7 +7,7 @@ export function normalizeSnapshot(snap) {
8
7
  * #category adapter
9
8
  * #trackType FeatureTrack
10
9
  * #fileFormat feature | GFF3 (tabix)
11
- * used to load bgzip-compressed, tabix-indexed GFF3 files
10
+ * Reads bgzip-compressed, tabix-indexed GFF3 files.
12
11
  *
13
12
  * #example
14
13
  * The `uri` shorthand auto-resolves the `.tbi` index; add `csi: true` for a
@@ -35,7 +34,7 @@ const Gff3TabixAdapter = ConfigurationSchema('Gff3TabixAdapter', {
35
34
  },
36
35
  /**
37
36
  * #slot
38
- * where the tabix index is and which kind it is. The `uri` shorthand derives
37
+ * The tabix index location and its kind. The `uri` shorthand derives
39
38
  * both, so a config using it states neither.
40
39
  */
41
40
  index: tabixIndexSchema(),
@@ -62,9 +61,9 @@ const Gff3TabixAdapter = ConfigurationSchema('Gff3TabixAdapter', {
62
61
  'scaffold',
63
62
  ],
64
63
  },
65
- ...densityAdapterConfigSchemaFields,
66
64
  }, {
67
65
  explicitlyTyped: true,
66
+ closed: true,
68
67
  /**
69
68
  * #preProcessSnapshot
70
69
  *
@@ -0,0 +1,7 @@
1
+ /**
2
+ * A GFF3 column 9 keyed as the file writes it (`ID`, `Name`, `gene_name`),
3
+ * each value the comma-separated list the column gives, %-escapes decoded.
4
+ * `@gmod/gff-nostream` lowercases the keys for JBrowse's own reads, which a
5
+ * host reading the file's fields should not see.
6
+ */
7
+ export declare function fileAttributes(column: string): Record<string, string[]>;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * A GFF3 column 9 keyed as the file writes it (`ID`, `Name`, `gene_name`),
3
+ * each value the comma-separated list the column gives, %-escapes decoded.
4
+ * `@gmod/gff-nostream` lowercases the keys for JBrowse's own reads, which a
5
+ * host reading the file's fields should not see.
6
+ */
7
+ export function fileAttributes(column) {
8
+ const out = {};
9
+ if (column === '.' || column === '') {
10
+ return out;
11
+ }
12
+ for (const entry of column.split(';')) {
13
+ const eq = entry.indexOf('=');
14
+ const key = unescape(entry.slice(0, eq).trim());
15
+ const values = entry
16
+ .slice(eq + 1)
17
+ .trimEnd()
18
+ .split(',')
19
+ .filter(v => v !== '')
20
+ .map(unescape);
21
+ if (eq > 0 && key && values.length) {
22
+ out[key] = values;
23
+ }
24
+ }
25
+ return out;
26
+ }
27
+ function unescape(s) {
28
+ return s.includes('%')
29
+ ? s.replaceAll(/(?:%[\dA-Fa-f]{2})+/g, run => {
30
+ try {
31
+ return decodeURIComponent(run);
32
+ }
33
+ catch {
34
+ return run;
35
+ }
36
+ })
37
+ : s;
38
+ }
@@ -0,0 +1,24 @@
1
+ import type { LazyGffFeature } from '@gmod/gff-nostream';
2
+ /** Each sequence's length, as the file's `##sequence-region` pragmas state it. */
3
+ export declare function sequenceLengths(header: string): Map<string, number>;
4
+ export interface Candidate {
5
+ feature: LazyGffFeature;
6
+ uniqueId: string;
7
+ }
8
+ /**
9
+ * The candidates `query` overlaps, a feature crossing the origin of a
10
+ * sequence `length` long cut there into the part before it and the part after.
11
+ *
12
+ * GFF3 writes such a feature on a circular sequence with its end past the
13
+ * sequence's length, in "virtual" coordinates, so one record covers both
14
+ * sides. Each half keeps the children that fall in it, clipped; the half
15
+ * after the origin, shifted back onto the sequence, takes `originTailId` of
16
+ * the id.
17
+ */
18
+ export declare function cutAtOrigin(candidates: Iterable<Candidate>, query: {
19
+ start: number;
20
+ end: number;
21
+ }, length: number | undefined): Generator<{
22
+ feature: LazyGffFeature;
23
+ uniqueId: string;
24
+ }, void, unknown>;
@@ -0,0 +1,59 @@
1
+ import { originTailId } from '@jbrowse/core/util/originCut';
2
+ import { doesIntersect2 } from '@jbrowse/core/util/range';
3
+ /** Each sequence's length, as the file's `##sequence-region` pragmas state it. */
4
+ export function sequenceLengths(header) {
5
+ const lengths = new Map();
6
+ for (const line of header.split('\n')) {
7
+ const match = /^##sequence-region\s+(\S+)\s+\d+\s+(\d+)/.exec(line);
8
+ if (match) {
9
+ lengths.set(match[1], Number(match[2]));
10
+ }
11
+ }
12
+ return lengths;
13
+ }
14
+ function clip(feature, start, end, shift) {
15
+ const from = Math.max(feature.start, start);
16
+ const to = Math.min(feature.end, end);
17
+ return to > from
18
+ ? {
19
+ ...feature,
20
+ start: from - shift,
21
+ end: to - shift,
22
+ subfeatures: feature.subfeatures.flatMap(child => clip(child, start, end, shift) ?? []),
23
+ }
24
+ : undefined;
25
+ }
26
+ /**
27
+ * The candidates `query` overlaps, a feature crossing the origin of a
28
+ * sequence `length` long cut there into the part before it and the part after.
29
+ *
30
+ * GFF3 writes such a feature on a circular sequence with its end past the
31
+ * sequence's length, in "virtual" coordinates, so one record covers both
32
+ * sides. Each half keeps the children that fall in it, clipped; the half
33
+ * after the origin, shifted back onto the sequence, takes `originTailId` of
34
+ * the id.
35
+ */
36
+ export function* cutAtOrigin(candidates, query, length) {
37
+ const seen = new Set();
38
+ for (const { feature, uniqueId } of candidates) {
39
+ if (seen.has(uniqueId)) {
40
+ continue;
41
+ }
42
+ seen.add(uniqueId);
43
+ const halves = length !== undefined && feature.end > length
44
+ ? [
45
+ { half: clip(feature, feature.start, length, 0), uniqueId },
46
+ {
47
+ half: clip(feature, length, feature.end, length),
48
+ uniqueId: originTailId(uniqueId),
49
+ },
50
+ ]
51
+ : [{ half: feature, uniqueId }];
52
+ for (const { half, uniqueId: id } of halves) {
53
+ if (half &&
54
+ doesIntersect2(half.start, half.end, query.start, query.end)) {
55
+ yield { feature: half, uniqueId: id };
56
+ }
57
+ }
58
+ }
59
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jbrowse/plugin-gff3",
3
- "version": "5.0.0-beta.11",
3
+ "version": "5.0.0-beta.14",
4
4
  "type": "module",
5
5
  "description": "JBrowse 2 gff3.",
6
6
  "keywords": [
@@ -21,12 +21,12 @@
21
21
  "esm"
22
22
  ],
23
23
  "dependencies": {
24
- "@gmod/tabix": "^3.9.1",
25
- "@jbrowse/mobx-state-tree": "^6.7.0",
26
- "gff-nostream": "^5.6.0",
27
- "mobx": "^7.0.5",
24
+ "@gmod/gff-nostream": "^5.6.2",
25
+ "@gmod/tabix": "^3.11.0",
26
+ "@jbrowse/mobx-state-tree": "^6.9.0",
27
+ "mobx": "^7.0.6",
28
28
  "rxjs": "^7.8.2",
29
- "@jbrowse/core": "^5.0.0-beta.11"
29
+ "@jbrowse/core": "^5.0.0-beta.14"
30
30
  },
31
31
  "peerDependencies": {
32
32
  "react": ">=19.0.0",