@jbrowse/modifications-utils 4.1.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.
package/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [2020] [Evolutionary Software Foundation]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,192 @@
1
+ # @jbrowse/modifications-utils
2
+
3
+ Pure MM/ML base-modification tag parsers (methylation, etc.)
4
+
5
+ <!-- API_DOCS_START -->
6
+
7
+ ## API
8
+
9
+ Auto-generated from `#api` JSDoc tags in this package. Do not edit by hand.
10
+
11
+ ### getMethBins
12
+
13
+ Bins per-read base modifications and their probabilities onto reference
14
+ positions, returning typed arrays for methylated/unmethylated calls. Only
15
+ cytosines in `context` are considered (default CpG); plants also use CHG/CHH.
16
+
17
+ ```js
18
+ // type signature
19
+ ({…}: ParsedModData, context?: CytosineContext) => { methBins: number[]; hydroxyMethBins: number[]; methProbs: number[]; hydroxyMethProbs: number[]; }
20
+ ```
21
+
22
+ [Source code](https://github.com/GMOD/jbrowse-components/blob/main/packages/modifications-utils/src/getMethBins.ts)
23
+
24
+ ### getModPositions
25
+
26
+ Parse MM tag to extract modification positions on the read sequence.
27
+
28
+ ```js
29
+ // type signature
30
+ (mm: string, fseq: string, fstrand: number) => ModWithPositions[]
31
+ ```
32
+
33
+ [Source code](https://github.com/GMOD/jbrowse-components/blob/main/packages/modifications-utils/src/getModPositions.ts)
34
+
35
+ ### getModProbabilities
36
+
37
+ Reads the ML tag from a feature and returns per-call modification probabilities
38
+ scaled to 0..1.
39
+
40
+ ```js
41
+ // type signature
42
+ (feature: Feature) => number[] | undefined
43
+ ```
44
+
45
+ [Source code](https://github.com/GMOD/jbrowse-components/blob/main/packages/modifications-utils/src/getModProbabilities.ts)
46
+
47
+ ### getModProbabilityBytes
48
+
49
+ The ML tag as its raw 0..255 bytes, without the scaling `getModProbabilities`
50
+ applies.
51
+
52
+ The byte is a LOSSLESS stand-in for the probability — every value on this path
53
+ is exactly `(N + 0.5) / 256` — and it is monotonic in it, so anything that only
54
+ compares probabilities (picking the most likely call at a position, testing a
55
+ threshold) can work in bytes and divide once, at the end, for the few calls that
56
+ survive. A caller that needs the numbers themselves still wants
57
+ `getModProbabilities`.
58
+
59
+ ```js
60
+ // type signature
61
+ (feature: Feature) => ArrayLike<number> | undefined
62
+ ```
63
+
64
+ [Source code](https://github.com/GMOD/jbrowse-components/blob/main/packages/modifications-utils/src/getModProbabilities.ts)
65
+
66
+ ### getModTypes
67
+
68
+ The modification types an MM tag declares, from its headers alone.
69
+
70
+ `getModPositions` answers this too, but on the way to placing every call: it
71
+ walks the delta list against the read sequence, which is the expensive half and
72
+ is only needed to DRAW marks. Anything that just wants to know what is in the
73
+ file — which types to offer in a menu, whether a track carries modifications at
74
+ all — wants this instead, and pays neither the walk nor the sequence decode that
75
+ feeds it.
76
+
77
+ ```js
78
+ // type signature
79
+ (mm: string) => ModTypeHeader[]
80
+ ```
81
+
82
+ [Source code](https://github.com/GMOD/jbrowse-components/blob/main/packages/modifications-utils/src/getModTypes.ts)
83
+
84
+ ### getTag
85
+
86
+ Read a single tag by name, using the feature's targeted tag accessor when it has
87
+ one (BAM) and the full tags object otherwise (CRAM/synteny).
88
+
89
+ ```js
90
+ // type signature
91
+ (feature: Feature, tag: string) => unknown
92
+ ```
93
+
94
+ [Source code](https://github.com/GMOD/jbrowse-components/blob/main/packages/modifications-utils/src/getTagAlt.ts)
95
+
96
+ ### getTagAlt
97
+
98
+ Read a tag by its canonical name, falling back to a lowercase-suffixed alias
99
+ (e.g. MM/Mm, ML/Ml) as emitted by some aligners.
100
+
101
+ Prefers the feature's own one-pass alias lookup when it has one. The plain
102
+ `getTag(tag) ?? getTag(alt)` form walks the record's whole tag block TWICE
103
+ whenever neither name is present — which is every read in a file without base
104
+ modifications, and this is called per read on every render. On jb2bench's
105
+ 1000x.shortread that pair of walks was 12.9% of the whole BAM query, more than
106
+ the CIGAR/SEQ/MD reads the pileup actually uses.
107
+
108
+ ```js
109
+ // type signature
110
+ (feature: Feature, tag: string, alt: string) => unknown
111
+ ```
112
+
113
+ [Source code](https://github.com/GMOD/jbrowse-components/blob/main/packages/modifications-utils/src/getTagAlt.ts)
114
+
115
+ ### isMethylationFillType
116
+
117
+ The modification types the methylation walk below paints — 5mC and 5hmC, the
118
+ pair that compete for one cytosine.
119
+
120
+ Stated once because two functions have to agree on it and they are in different
121
+ packages: this walk claims those types, and `extractModifications` has to skip
122
+ exactly them in the fill view so a cytosine gets one mark rather than two. A
123
+ second spelling would either double-paint or, as it did, drop every OTHER type
124
+ the read declares.
125
+
126
+ ```js
127
+ // type signature
128
+ (type: string) => type is "h" | "m"
129
+ ```
130
+
131
+ [Source code](https://github.com/GMOD/jbrowse-components/blob/main/packages/modifications-utils/src/getMethBins.ts)
132
+
133
+ ### matchesCytosineContext
134
+
135
+ Whether the cytosine at read position `pos` sits in the given context.
136
+
137
+ The pattern is defined on the template (the strand the C is on), read 5'->3'.
138
+ For forward reads the stored sequence IS the template, so we read forward from
139
+ `pos`. getModPositions works reverse-strand reads in stored-sequence space,
140
+ where the template runs backwards and complemented, so we read backwards from
141
+ `pos` and complement each base before matching.
142
+
143
+ **Char codes, not characters, and that is the whole shape of this function.**
144
+ Folding case with `& ~0x20` on the code and comparing numbers keeps a probe to
145
+ arithmetic, where lower-casing the character and the pattern base beside it is
146
+ two string operations per base. It is asked at that volume: the fill-unmarked
147
+ methylation walk asks up to twice for every aligned base of every read
148
+ (getMethBins), and bisulfite asks at every candidate cytosine. What it is worth
149
+ in the phase it sits in — which is not what it is worth on its own — is
150
+ `plugins/alignments/benches/modFillView.bench.ts`.
151
+
152
+ `charCodeAt` past either end of the string is NaN and `NaN & ~0x20` is 0 — an
153
+ index no pattern base equals and the complement table holds -1 at — so the walk
154
+ runs off the read as a non-match with no bounds test of its own.
155
+ `features/modCoverage/readBaseCounts.ts` folds case the same way and says so.
156
+
157
+ ```js
158
+ // type signature
159
+ (seq: string, pos: number, isReverse: boolean, context: CytosineContext) => boolean
160
+ ```
161
+
162
+ [Source code](https://github.com/GMOD/jbrowse-components/blob/main/packages/modifications-utils/src/cytosineContext.ts)
163
+
164
+ ### modProbAt
165
+
166
+ Returns the probability value from the flat ML array for a modification's
167
+ position. `idx` is the position's index within the mod's stored `positions`
168
+ array; we recover its MM-tag order (reverse-strand reads store positions in
169
+ descending order) and step into ML by `probStart + mmOrder * probStride`.
170
+ `probStride` is >1 for combined codes (e.g. 'C+mh'), where ML values are
171
+ interleaved per position.
172
+
173
+ ```js
174
+ // type signature
175
+ (probabilities: number[] | undefined, probStart: number, probStride: number, isReverse: boolean, idx: number, posLen: number) => number
176
+ ```
177
+
178
+ [Source code](https://github.com/GMOD/jbrowse-components/blob/main/packages/modifications-utils/src/getModProbabilities.ts)
179
+
180
+ ### parseModHeader
181
+
182
+ Parses one MM-tag modification header (e.g. `C+m`) into its base, strand, type
183
+ string, and modification code.
184
+
185
+ ```js
186
+ // type signature
187
+ (basemod: string, fullmod: string) => { base: string; strand: string; typestr: string; mod: string; }
188
+ ```
189
+
190
+ [Source code](https://github.com/GMOD/jbrowse-components/blob/main/packages/modifications-utils/src/consts.ts)
191
+
192
+ <!-- API_DOCS_END -->
@@ -0,0 +1,12 @@
1
+ export declare const modificationRegex: RegExp;
2
+ /**
3
+ * #api
4
+ * Parses one MM-tag modification header (e.g. `C+m`) into its base, strand,
5
+ * type string, and modification code.
6
+ */
7
+ export declare function parseModHeader(basemod: string, fullmod: string): {
8
+ base: string;
9
+ strand: string;
10
+ typestr: string;
11
+ mod: string;
12
+ };
package/esm/consts.js ADDED
@@ -0,0 +1,42 @@
1
+ // Regex for MM:Z tag format based on SAM/BAM spec (hts-specs):
2
+ // Spec: ([ACGTUN][-+]([a-z]+|[0-9]+)[.?]?(,[0-9]+)*;)*
3
+ //
4
+ // Our implementation diverges intentionally from the spec:
5
+ // 1. Capture strand as group 2: ([-+]) instead of [-+]
6
+ // - Needed to extract strand info for downstream processing
7
+ // 2. Accept uppercase single letters: ([a-z]+|[A-Z]|[0-9]+) instead of ([a-z]+|[0-9]+)
8
+ // - The spec PROSE defines uppercase ambiguity codes A/C/G/T/U and N (an
9
+ // unspecified modification of the respective canonical base), but the
10
+ // spec's own grammar regex omits them — a known inconsistency in the spec.
11
+ // - Ambiguity codes are always a single uppercase letter (never multi-char
12
+ // or mixed with lowercase), hence [A-Z] rather than [A-Z]+.
13
+ // 3. Capture modifier flag as group 4: ([.?]?) instead of non-capturing
14
+ // - Per spec: '?' = modification status of skipped bases is unknown
15
+ // - Per spec: '.' or absent = skipped bases should be assumed low probability of modification
16
+ // - We capture this to support correct interpretation of skipped positions
17
+ // 4. Don't match delta values or semicolon
18
+ // - We extract just the base modification header (e.g., "C+m?" from "C+m?,2,1;")
19
+ // - Caller splits by ";" and "," to get this header before passing to regex
20
+ //
21
+ // Real-world examples with modifier flags:
22
+ // C+m → skipped bases = low probability modification (default)
23
+ // C+m. → skipped bases = low probability modification (explicit)
24
+ // C+m? → skipped bases = unknown modification status
25
+ export const modificationRegex = /([ACGTUN])([-+])([a-z]+|[A-Z]|[0-9]+)([.?]?)/;
26
+ /**
27
+ * #api
28
+ * Parses one MM-tag modification header (e.g. `C+m`) into its base, strand,
29
+ * type string, and modification code.
30
+ */
31
+ export function parseModHeader(basemod, fullmod) {
32
+ const matches = modificationRegex.exec(basemod);
33
+ if (!matches) {
34
+ throw new Error(`bad format for MM tag: "${fullmod}"`);
35
+ }
36
+ return {
37
+ base: matches[1],
38
+ strand: matches[2],
39
+ typestr: matches[3],
40
+ mod: matches[4] !== '' ? matches[4] : '.',
41
+ };
42
+ }
@@ -0,0 +1,26 @@
1
+ export type CytosineContext = 'CG' | 'CHG' | 'CHH' | 'all';
2
+ /**
3
+ * #api
4
+ * Whether the cytosine at read position `pos` sits in the given context.
5
+ *
6
+ * The pattern is defined on the template (the strand the C is on), read 5'->3'.
7
+ * For forward reads the stored sequence IS the template, so we read forward from
8
+ * `pos`. getModPositions works reverse-strand reads in stored-sequence space,
9
+ * where the template runs backwards and complemented, so we read backwards from
10
+ * `pos` and complement each base before matching.
11
+ *
12
+ * **Char codes, not characters, and that is the whole shape of this function.**
13
+ * Folding case with `& ~0x20` on the code and comparing numbers keeps a probe
14
+ * to arithmetic, where lower-casing the character and the pattern base beside
15
+ * it is two string operations per base. It is asked at that volume: the
16
+ * fill-unmarked methylation walk asks up to twice for every aligned base of
17
+ * every read (getMethBins), and bisulfite asks at every candidate cytosine.
18
+ * What it is worth in the phase it sits in — which is not what it is worth on
19
+ * its own — is `plugins/alignments/benches/modFillView.bench.ts`.
20
+ *
21
+ * `charCodeAt` past either end of the string is NaN and `NaN & ~0x20` is 0 — an
22
+ * index no pattern base equals and the complement table holds -1 at — so the
23
+ * walk runs off the read as a non-match with no bounds test of its own.
24
+ * `features/modCoverage/readBaseCounts.ts` folds case the same way and says so.
25
+ */
26
+ export declare function matchesCytosineContext(seq: string, pos: number, isReverse: boolean, context: CytosineContext): boolean;
@@ -0,0 +1,62 @@
1
+ const A = 65;
2
+ const C = 67;
3
+ const G = 71;
4
+ const T = 84;
5
+ // The IUPAC A/C/T ambiguity code, which is what the H of CHG/CHH is.
6
+ const H = 72;
7
+ // Template-strand base pattern per context, 5'->3', as upper-case char codes.
8
+ // 'all' constrains only the cytosine itself.
9
+ const CONTEXT_PATTERN = {
10
+ CG: Uint8Array.of(C, G),
11
+ CHG: Uint8Array.of(C, H, G),
12
+ CHH: Uint8Array.of(C, H, H),
13
+ all: Uint8Array.of(C),
14
+ };
15
+ const COMPLEMENT_CODE = new Int16Array(128).fill(-1);
16
+ COMPLEMENT_CODE[A] = T;
17
+ COMPLEMENT_CODE[T] = A;
18
+ COMPLEMENT_CODE[C] = G;
19
+ COMPLEMENT_CODE[G] = C;
20
+ /**
21
+ * #api
22
+ * Whether the cytosine at read position `pos` sits in the given context.
23
+ *
24
+ * The pattern is defined on the template (the strand the C is on), read 5'->3'.
25
+ * For forward reads the stored sequence IS the template, so we read forward from
26
+ * `pos`. getModPositions works reverse-strand reads in stored-sequence space,
27
+ * where the template runs backwards and complemented, so we read backwards from
28
+ * `pos` and complement each base before matching.
29
+ *
30
+ * **Char codes, not characters, and that is the whole shape of this function.**
31
+ * Folding case with `& ~0x20` on the code and comparing numbers keeps a probe
32
+ * to arithmetic, where lower-casing the character and the pattern base beside
33
+ * it is two string operations per base. It is asked at that volume: the
34
+ * fill-unmarked methylation walk asks up to twice for every aligned base of
35
+ * every read (getMethBins), and bisulfite asks at every candidate cytosine.
36
+ * What it is worth in the phase it sits in — which is not what it is worth on
37
+ * its own — is `plugins/alignments/benches/modFillView.bench.ts`.
38
+ *
39
+ * `charCodeAt` past either end of the string is NaN and `NaN & ~0x20` is 0 — an
40
+ * index no pattern base equals and the complement table holds -1 at — so the
41
+ * walk runs off the read as a non-match with no bounds test of its own.
42
+ * `features/modCoverage/readBaseCounts.ts` folds case the same way and says so.
43
+ */
44
+ export function matchesCytosineContext(seq, pos, isReverse, context) {
45
+ const pattern = CONTEXT_PATTERN[context];
46
+ for (let i = 0, len = pattern.length; i < len; i++) {
47
+ const code = seq.charCodeAt(isReverse ? pos - i : pos + i) & ~0x20;
48
+ // A non-ASCII sequence character folds to something past the complement
49
+ // table rather than into it, and matches no pattern base either way.
50
+ if (code > 127) {
51
+ return false;
52
+ }
53
+ const actual = isReverse ? COMPLEMENT_CODE[code] : code;
54
+ const expected = pattern[i];
55
+ if (expected === H
56
+ ? actual !== A && actual !== C && actual !== T
57
+ : actual !== expected) {
58
+ return false;
59
+ }
60
+ }
61
+ return true;
62
+ }
@@ -0,0 +1,21 @@
1
+ import type { ModificationType } from './types.ts';
2
+ type ModificationTypeList = readonly ModificationType[];
3
+ /**
4
+ * Which modification types were basecalled on only ONE strand ("simplex") vs
5
+ * both ("duplex"). The MM tag reports each mod against a strand, e.g. `C+m` (5mC
6
+ * on the read's strand) or `G-m` (5mC on the opposite strand, reading as a G).
7
+ * A type on '+' with no '-' partner anywhere → simplex; both present → duplex.
8
+ *
9
+ * Callers use this to pick the modification-coverage denominator (see
10
+ * calculateModificationCounts): duplex counts every read with the base, simplex
11
+ * only reads on the examined strand, else the modified fraction is understated.
12
+ *
13
+ * Run over the WHOLE dataset, not one read: simplex-ness is a protocol property,
14
+ * shared by all reads, and a single read can lack the '-' call just by covering
15
+ * no eligible base — a per-read check would misclassify duplex data as simplex.
16
+ *
17
+ * Mirrors IGV: AlignmentDataManager builds the set globally,
18
+ * BaseModificationCoverageRenderer uses it for the detectable count.
19
+ */
20
+ export declare function detectSimplexModifications(modifications: ModificationTypeList): ReadonlySet<string>;
21
+ export {};
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Which modification types were basecalled on only ONE strand ("simplex") vs
3
+ * both ("duplex"). The MM tag reports each mod against a strand, e.g. `C+m` (5mC
4
+ * on the read's strand) or `G-m` (5mC on the opposite strand, reading as a G).
5
+ * A type on '+' with no '-' partner anywhere → simplex; both present → duplex.
6
+ *
7
+ * Callers use this to pick the modification-coverage denominator (see
8
+ * calculateModificationCounts): duplex counts every read with the base, simplex
9
+ * only reads on the examined strand, else the modified fraction is understated.
10
+ *
11
+ * Run over the WHOLE dataset, not one read: simplex-ness is a protocol property,
12
+ * shared by all reads, and a single read can lack the '-' call just by covering
13
+ * no eligible base — a per-read check would misclassify duplex data as simplex.
14
+ *
15
+ * Mirrors IGV: AlignmentDataManager builds the set globally,
16
+ * BaseModificationCoverageRenderer uses it for the detectable count.
17
+ */
18
+ export function detectSimplexModifications(modifications) {
19
+ // types seen on the '-' strand anywhere → those are duplex
20
+ const minusTypes = new Set();
21
+ for (const { strand, type } of modifications) {
22
+ if (strand === '-') {
23
+ minusTypes.add(type);
24
+ }
25
+ }
26
+ // a '+' type with no '-' partner was only examined on one strand → simplex
27
+ const simplex = new Set();
28
+ for (const { strand, type } of modifications) {
29
+ if (strand === '+' && !minusTypes.has(type)) {
30
+ simplex.add(type);
31
+ }
32
+ }
33
+ return simplex;
34
+ }
@@ -0,0 +1,2 @@
1
+ import type { ModWithPositions } from './getModPositions.ts';
2
+ export declare function forEachModRefPos(modifications: readonly ModWithPositions[], probabilities: number[] | undefined, cigarOps: ArrayLike<number>, isReverse: boolean, cb: (mod: ModWithPositions, ref: number, idx: number, prob: number) => void): void;
@@ -0,0 +1,15 @@
1
+ import { getNextRefPos } from '@jbrowse/cigar-utils';
2
+ import { modProbAt } from './getModProbabilities.js';
3
+ // Walk every modification's read-sequence positions, mapping each to its
4
+ // reference position via the CIGAR and recovering its ML probability. The
5
+ // ML-array offset (probStart/probStride) is precomputed per modification by
6
+ // getModPositions, so this no longer tracks a running index by hand.
7
+ export function forEachModRefPos(modifications, probabilities, cigarOps, isReverse, cb) {
8
+ for (const mod of modifications) {
9
+ const { positions, probStart, probStride } = mod;
10
+ getNextRefPos(cigarOps, positions, (ref, idx) => {
11
+ const prob = modProbAt(probabilities, probStart, probStride, isReverse, idx, positions.length);
12
+ cb(mod, ref, idx, prob);
13
+ });
14
+ }
15
+ }
@@ -0,0 +1,34 @@
1
+ import type { CytosineContext } from './cytosineContext.ts';
2
+ import type { ModWithPositions } from './getModPositions.ts';
3
+ export interface ParsedModData {
4
+ modifications: ModWithPositions[];
5
+ probabilities: number[] | undefined;
6
+ cigarOps: ArrayLike<number>;
7
+ seq: string;
8
+ fstrand: -1 | 0 | 1;
9
+ flen: number;
10
+ }
11
+ /**
12
+ * #api
13
+ * The modification types the methylation walk below paints — 5mC and 5hmC, the
14
+ * pair that compete for one cytosine.
15
+ *
16
+ * Stated once because two functions have to agree on it and they are in
17
+ * different packages: this walk claims those types, and
18
+ * `extractModifications` has to skip exactly them in the fill view so a
19
+ * cytosine gets one mark rather than two. A second spelling would either
20
+ * double-paint or, as it did, drop every OTHER type the read declares.
21
+ */
22
+ export declare function isMethylationFillType(type: string): type is "h" | "m";
23
+ /**
24
+ * #api
25
+ * Bins per-read base modifications and their probabilities onto reference
26
+ * positions, returning typed arrays for methylated/unmethylated calls. Only
27
+ * cytosines in `context` are considered (default CpG); plants also use CHG/CHH.
28
+ */
29
+ export declare function getMethBins({ modifications, probabilities, cigarOps, seq, fstrand, flen }: ParsedModData, context?: CytosineContext): {
30
+ methBins: number[];
31
+ hydroxyMethBins: number[];
32
+ methProbs: number[];
33
+ hydroxyMethProbs: number[];
34
+ };
@@ -0,0 +1,92 @@
1
+ import { CIGAR_D, CIGAR_EQ, CIGAR_I, CIGAR_M, CIGAR_N, CIGAR_S, CIGAR_X, } from '@jbrowse/cigar-utils';
2
+ import { matchesCytosineContext } from './cytosineContext.js';
3
+ import { forEachModRefPos } from './forEachModRefPos.js';
4
+ /**
5
+ * #api
6
+ * The modification types the methylation walk below paints — 5mC and 5hmC, the
7
+ * pair that compete for one cytosine.
8
+ *
9
+ * Stated once because two functions have to agree on it and they are in
10
+ * different packages: this walk claims those types, and
11
+ * `extractModifications` has to skip exactly them in the fill view so a
12
+ * cytosine gets one mark rather than two. A second spelling would either
13
+ * double-paint or, as it did, drop every OTHER type the read declares.
14
+ */
15
+ export function isMethylationFillType(type) {
16
+ return type === 'm' || type === 'h';
17
+ }
18
+ /**
19
+ * #api
20
+ * Bins per-read base modifications and their probabilities onto reference
21
+ * positions, returning typed arrays for methylated/unmethylated calls. Only
22
+ * cytosines in `context` are considered (default CpG); plants also use CHG/CHH.
23
+ */
24
+ export function getMethBins({ modifications, probabilities, cigarOps, seq, fstrand, flen }, context = 'CG') {
25
+ const isReverse = fstrand === -1;
26
+ const methBins = [];
27
+ const hydroxyMethBins = [];
28
+ const methProbs = [];
29
+ const hydroxyMethProbs = [];
30
+ forEachModRefPos(modifications, probabilities, cigarOps, isReverse, ({ type, strand, positions }, ref, idx, prob) => {
31
+ const isMeth = isMethylationFillType(type);
32
+ if (isMeth &&
33
+ ref >= 0 &&
34
+ ref < flen &&
35
+ matchesCytosineContext(seq, positions[idx], isReverse !== (strand === '-'), context)) {
36
+ if (type === 'm') {
37
+ methBins[ref] = 1;
38
+ methProbs[ref] = prob;
39
+ }
40
+ else {
41
+ hydroxyMethBins[ref] = 1;
42
+ hydroxyMethProbs[ref] = prob;
43
+ }
44
+ }
45
+ });
46
+ // Only fill undetected sites as unmethylated when the read actually carries a
47
+ // 5mC ('m') call. A read with only e.g. 6mA modifications never assayed
48
+ // methylation, so inventing unmethylated cytosines for it would be wrong.
49
+ // Additionally, per SAMtags the '?' flag means the status of bases not listed
50
+ // in the MM tag is unknown, so we must NOT assume them unmethylated; only the
51
+ // '.'/absent flag (low probability for skipped bases) lets us fill them in.
52
+ const methMods = modifications.filter(m => m.type === 'm');
53
+ const fillUnmethylated = methMods.length > 0 && !methMods.some(m => m.unknownSkip);
54
+ // A '-' MM group assays the cytosines of the strand OPPOSITE the read, so a
55
+ // both-strand modBAM ('C+m;G-m') has its uncalled sites in both directions.
56
+ const fillForward = methMods.some(m => isReverse === (m.strand === '-'));
57
+ const fillReverse = methMods.some(m => isReverse !== (m.strand === '-'));
58
+ // Scan the full read sequence for every cytosine in `context` and mark any not
59
+ // already detected from the MM tag as unmethylated (prob=0).
60
+ if (fillUnmethylated) {
61
+ let readPos = 0;
62
+ let refPos = 0;
63
+ for (let i = 0, l = cigarOps.length; i < l; i++) {
64
+ const packed = cigarOps[i];
65
+ const len = packed >>> 4;
66
+ const op = packed & 0xf;
67
+ if (op === CIGAR_S || op === CIGAR_I) {
68
+ readPos += len;
69
+ }
70
+ else if (op === CIGAR_D || op === CIGAR_N) {
71
+ refPos += len;
72
+ }
73
+ else if (op === CIGAR_M || op === CIGAR_X || op === CIGAR_EQ) {
74
+ for (let j = 0; j < len; j++) {
75
+ const rp = readPos + j;
76
+ const rf = refPos + j;
77
+ if (rf >= 0 &&
78
+ rf < flen &&
79
+ !methBins[rf] &&
80
+ ((fillForward && matchesCytosineContext(seq, rp, false, context)) ||
81
+ (fillReverse && matchesCytosineContext(seq, rp, true, context)))) {
82
+ methBins[rf] = 1;
83
+ methProbs[rf] = 0;
84
+ }
85
+ }
86
+ readPos += len;
87
+ refPos += len;
88
+ }
89
+ }
90
+ }
91
+ return { methBins, hydroxyMethBins, methProbs, hydroxyMethProbs };
92
+ }
@@ -0,0 +1,19 @@
1
+ export interface ModWithPositions {
2
+ type: string;
3
+ base: string;
4
+ strand: string;
5
+ unknownSkip: boolean;
6
+ positions: number[];
7
+ probStart: number;
8
+ probStride: number;
9
+ }
10
+ /**
11
+ * #api
12
+ * Parse MM tag to extract modification positions on the read sequence.
13
+ *
14
+ * @param mm - MM tag string (e.g., "C+m,2,2,1;A+a,0,3")
15
+ * @param fseq - Read sequence
16
+ * @param fstrand - Read strand (-1, 0, or 1)
17
+ * @returns Array of modification objects with positions
18
+ */
19
+ export declare function getModPositions(mm: string, fseq: string, fstrand: number): ModWithPositions[];
@@ -0,0 +1,211 @@
1
+ import { parseModHeader } from './consts.js';
2
+ import { isSingleModType } from './getModTypes.js';
3
+ const COMPLEMENT_CODE = {
4
+ 65: 84, // A->T
5
+ 84: 65, // T->A
6
+ 67: 71, // C->G
7
+ 71: 67, // G->C
8
+ 78: 78, // N->N
9
+ };
10
+ /**
11
+ * #api
12
+ * Parse MM tag to extract modification positions on the read sequence.
13
+ *
14
+ * @param mm - MM tag string (e.g., "C+m,2,2,1;A+a,0,3")
15
+ * @param fseq - Read sequence
16
+ * @param fstrand - Read strand (-1, 0, or 1)
17
+ * @returns Array of modification objects with positions
18
+ */
19
+ export function getModPositions(mm, fseq, fstrand) {
20
+ const seqLength = fseq.length;
21
+ const isRev = fstrand === -1;
22
+ const mods = mm.split(';');
23
+ const result = [];
24
+ // Running offset into the flat ML probabilities array. Each group consumes
25
+ // (numPositions * numTypes) values, interleaved per position.
26
+ let mlBase = 0;
27
+ // Groups already walked, so a later group calling the same positions can reuse
28
+ // the array instead of walking the sequence again for it. Dorado's 5mCG_5hmCG
29
+ // model emits `C+h?;C+m?` — two groups, one canonical base, identical delta
30
+ // lists — so on real ONT output this drops one of every three sequence walks.
31
+ //
32
+ // The key is what the walk below READS: the canonical base and the delta list
33
+ // (`fseq` and `fstrand` are per read, not per group). It also carries the MM
34
+ // strand, which the walk does not currently read — deliberately stronger than
35
+ // needed, so the test cannot go quietly wrong if the walk ever becomes
36
+ // strand-aware.
37
+ //
38
+ // Parallel arrays rather than a Map: a read carries one to four groups, so a
39
+ // linear scan of char compares beats hashing a multi-kilobyte delta string,
40
+ // and they are allocated on the first walk so a single-group read builds none.
41
+ // `benches/sameBaseMerge.bench.ts` prices the whole test at inside the control
42
+ // on every fixture where it cannot fire, including one where every compare is
43
+ // forced to run to its final byte and then fail.
44
+ let seenKeys;
45
+ let seenDeltas;
46
+ let seenPositions;
47
+ for (const mod of mods) {
48
+ if (mod === '') {
49
+ continue;
50
+ }
51
+ const split = mod.split(',');
52
+ const basemod = split[0];
53
+ const { base, strand, typestr, mod: skipFlag, } = parseModHeader(basemod, mod);
54
+ const unknownSkip = skipFlag === '?';
55
+ // typestr can be multi-char lowercase e.g. 'mh' (5mC + 5hmC at same
56
+ // positions) or a ChEBI code e.g. '16061'. The rule is `isSingleModType`,
57
+ // shared with `getModTypes` so the two cannot disagree about what a tag
58
+ // declares.
59
+ const isSingleType = isSingleModType(typestr);
60
+ const nTypes = isSingleType ? 1 : typestr.length;
61
+ // ONE walk per GROUP, not per type. Every type in a combined code is called
62
+ // at the same positions — only probStart differs — so the walk below and the
63
+ // array it fills are shared, and the entries pushed after it point at the
64
+ // same array. `C+mh` used to walk the read sequence twice and allocate two
65
+ // identical arrays; `benches/modCombinedCode.bench.ts` (in the alignments
66
+ // plugin, where the fixture lives) prices that at **2.07x** on a synthesized
67
+ // `C+mh`, and at **1.16x** even on a single-type tag, where nothing is
68
+ // deduplicated and the win is the per-group closure this loop replaced.
69
+ //
70
+ // A combined code is the RARER of the two shapes this deduplicates, though.
71
+ // The reuse test below is the common one — see `seenKeys`.
72
+ //
73
+ // this logic based on parse_mm.pl from hts-specs
74
+ const splitLength = split.length;
75
+ const nPositions = splitLength - 1;
76
+ // Everything after the header — `,2,2,1`. V8 slices a long string in O(1),
77
+ // and the compare rejects on length before it reads a byte.
78
+ const deltas = mod.slice(basemod.length);
79
+ const key = base + strand;
80
+ let positions;
81
+ if (seenKeys !== undefined) {
82
+ for (let s = 0, n = seenKeys.length; s < n; s++) {
83
+ if (seenKeys[s] === key && seenDeltas[s] === deltas) {
84
+ positions = seenPositions[s];
85
+ break;
86
+ }
87
+ }
88
+ }
89
+ if (positions === undefined) {
90
+ const isN = base === 'N';
91
+ let currPos = 0;
92
+ // An MM tag may declare more calls of a base than the read has left. When
93
+ // that happens the walk has nowhere to put them, and every emitted value
94
+ // still has to be a valid index into the read — `getMethBins` indexes the
95
+ // sequence with these, and the CIGAR walk requires them ascending, so a
96
+ // position outside the read is read as a real one somewhere wrong rather
97
+ // than dropped. So the exhausted case clamps to the nearest valid index
98
+ // and stays there.
99
+ const endClamp = isRev ? 0 : Math.max(0, seqLength - 1);
100
+ // Pre-allocate and fill backwards on reverse strand to avoid a final
101
+ // reverse(). Forward stays a growing literal on purpose: filling
102
+ // `new Array(n)` leaves holey elements, and this array is read in the
103
+ // CIGAR walk's inner loop.
104
+ positions = isRev ? new Array(nPositions) : [];
105
+ let writeIndex = isRev ? nPositions - 1 : 0;
106
+ if (isRev) {
107
+ // Avoid revcom(fseq) by reading fseq from the back and complementing the
108
+ // expected char-code.
109
+ const baseCode = base.charCodeAt(0);
110
+ const targetCode = COMPLEMENT_CODE[baseCode] ?? baseCode;
111
+ for (let i = 1; i < splitLength; i++) {
112
+ if (currPos >= seqLength) {
113
+ positions[writeIndex--] = endClamp;
114
+ continue;
115
+ }
116
+ let delta = +split[i];
117
+ do {
118
+ if (isN ||
119
+ fseq.charCodeAt(seqLength - 1 - currPos) === targetCode) {
120
+ delta--;
121
+ }
122
+ currPos++;
123
+ } while (delta >= 0 && currPos < seqLength);
124
+ positions[writeIndex--] = seqLength - currPos;
125
+ }
126
+ }
127
+ else {
128
+ // **Forward jumps, reverse steps, and the asymmetry is measured rather
129
+ // than assumed.** `indexOf` for a single character is a native scan, so
130
+ // finding the (delta+1)-th occurrence is delta+1 searches instead of one
131
+ // step per base — 1.560x on the sparse fixture and 1.247x on the dense
132
+ // one, parse phase, in `benches/mmDeltaJump.bench.ts`.
133
+ //
134
+ // `lastIndexOf` is NOT the mirror image: the same change on reverse
135
+ // reads measures **0.786x**, i.e. materially slower than stepping, so
136
+ // reverse keeps the loop above. Applying it to both, which is the
137
+ // obvious version, nets 1.054x where branching on strand nets 1.263x.
138
+ for (let i = 1; i < splitLength; i++) {
139
+ const delta = +split[i];
140
+ let at = -1;
141
+ if (isN) {
142
+ // 'N' matches every base, so the (delta+1)-th is delta ahead and
143
+ // there is nothing to search for.
144
+ at = currPos + delta;
145
+ if (at >= seqLength) {
146
+ at = -1;
147
+ }
148
+ }
149
+ else {
150
+ for (let k = 0; k <= delta; k++) {
151
+ at = fseq.indexOf(base, currPos);
152
+ if (at < 0) {
153
+ break;
154
+ }
155
+ currPos = at + 1;
156
+ }
157
+ }
158
+ if (at < 0) {
159
+ currPos = seqLength;
160
+ positions[writeIndex++] = endClamp;
161
+ }
162
+ else {
163
+ currPos = at + 1;
164
+ positions[writeIndex++] = at;
165
+ }
166
+ }
167
+ }
168
+ if (seenKeys === undefined) {
169
+ seenKeys = [key];
170
+ seenDeltas = [deltas];
171
+ seenPositions = [positions];
172
+ }
173
+ else {
174
+ seenKeys.push(key);
175
+ seenDeltas.push(deltas);
176
+ seenPositions.push(positions);
177
+ }
178
+ }
179
+ if (isSingleType) {
180
+ result.push({
181
+ type: typestr,
182
+ base,
183
+ strand,
184
+ unknownSkip,
185
+ positions,
186
+ probStart: mlBase,
187
+ probStride: 1,
188
+ });
189
+ }
190
+ else {
191
+ // Multi-char lowercase: each character is a separate type
192
+ for (let j = 0, len = typestr.length; j < len; j++) {
193
+ result.push({
194
+ type: typestr[j],
195
+ base,
196
+ strand,
197
+ unknownSkip,
198
+ positions,
199
+ probStart: mlBase + j,
200
+ probStride: nTypes,
201
+ });
202
+ }
203
+ }
204
+ // Unaffected by the reuse above. Two separate groups' ML values are
205
+ // consecutive, not interleaved, so each group consumes its own
206
+ // nPositions * nTypes whether or not it walked for them — which is why
207
+ // sharing the array leaves probStart/probStride alone.
208
+ mlBase += nPositions * nTypes;
209
+ }
210
+ return result;
211
+ }
@@ -0,0 +1,30 @@
1
+ import type { Feature } from '@jbrowse/core/util';
2
+ /**
3
+ * #api
4
+ * Returns the probability value from the flat ML array for a modification's
5
+ * position. `idx` is the position's index within the mod's stored `positions`
6
+ * array; we recover its MM-tag order (reverse-strand reads store positions in
7
+ * descending order) and step into ML by `probStart + mmOrder * probStride`.
8
+ * `probStride` is >1 for combined codes (e.g. 'C+mh'), where ML values are
9
+ * interleaved per position.
10
+ */
11
+ export declare function modProbAt(probabilities: number[] | undefined, probStart: number, probStride: number, isReverse: boolean, idx: number, posLen: number): number;
12
+ /**
13
+ * #api
14
+ * Reads the ML tag from a feature and returns per-call modification
15
+ * probabilities scaled to 0..1.
16
+ */
17
+ export declare function getModProbabilities(feature: Feature): number[] | undefined;
18
+ /**
19
+ * #api
20
+ * The ML tag as its raw 0..255 bytes, without the scaling `getModProbabilities`
21
+ * applies.
22
+ *
23
+ * The byte is a LOSSLESS stand-in for the probability — every value on this
24
+ * path is exactly `(N + 0.5) / 256` — and it is monotonic in it, so anything
25
+ * that only compares probabilities (picking the most likely call at a
26
+ * position, testing a threshold) can work in bytes and divide once, at the end,
27
+ * for the few calls that survive. A caller that needs the numbers themselves
28
+ * still wants `getModProbabilities`.
29
+ */
30
+ export declare function getModProbabilityBytes(feature: Feature): ArrayLike<number> | undefined;
@@ -0,0 +1,63 @@
1
+ import { getTagAlt } from './getTagAlt.js';
2
+ /**
3
+ * #api
4
+ * Returns the probability value from the flat ML array for a modification's
5
+ * position. `idx` is the position's index within the mod's stored `positions`
6
+ * array; we recover its MM-tag order (reverse-strand reads store positions in
7
+ * descending order) and step into ML by `probStart + mmOrder * probStride`.
8
+ * `probStride` is >1 for combined codes (e.g. 'C+mh'), where ML values are
9
+ * interleaved per position.
10
+ */
11
+ export function modProbAt(probabilities, probStart, probStride, isReverse, idx, posLen) {
12
+ const mmOrder = isReverse ? posLen - 1 - idx : idx;
13
+ return probabilities?.[probStart + mmOrder * probStride] ?? 0;
14
+ }
15
+ /**
16
+ * #api
17
+ * Reads the ML tag from a feature and returns per-call modification
18
+ * probabilities scaled to 0..1.
19
+ */
20
+ export function getModProbabilities(feature) {
21
+ // ML is an 8-bit scaled probability. Per SAMtags, integer N covers the
22
+ // continuous range N/256..(N+1)/256, so the representative value is the
23
+ // midpoint (N + 0.5) / 256.
24
+ const ml = getTagAlt(feature, 'ML', 'Ml');
25
+ if (ml === undefined) {
26
+ return undefined;
27
+ }
28
+ // BAM returns ML:B:C as a Uint8Array; mapping with TypedArray.prototype.map
29
+ // would coerce each float result back to a uint8 (truncating every value to
30
+ // 0). Array.from(values, fn) always produces a plain number[]. A string ML
31
+ // (htsget/SAM text) is split on commas first.
32
+ const values = typeof ml === 'string' ? ml.split(',') : ml;
33
+ return Array.from(values, v => (+v + 0.5) / 256);
34
+ }
35
+ /**
36
+ * #api
37
+ * The ML tag as its raw 0..255 bytes, without the scaling `getModProbabilities`
38
+ * applies.
39
+ *
40
+ * The byte is a LOSSLESS stand-in for the probability — every value on this
41
+ * path is exactly `(N + 0.5) / 256` — and it is monotonic in it, so anything
42
+ * that only compares probabilities (picking the most likely call at a
43
+ * position, testing a threshold) can work in bytes and divide once, at the end,
44
+ * for the few calls that survive. A caller that needs the numbers themselves
45
+ * still wants `getModProbabilities`.
46
+ */
47
+ export function getModProbabilityBytes(feature) {
48
+ const ml = getTagAlt(feature, 'ML', 'Ml');
49
+ if (ml === undefined) {
50
+ return undefined;
51
+ }
52
+ if (typeof ml !== 'string') {
53
+ return ml;
54
+ }
55
+ // htsget/SAM text gives a comma-separated string; parse it once into bytes
56
+ // rather than making every read of it coerce.
57
+ const parts = ml.split(',');
58
+ const out = new Uint8Array(parts.length);
59
+ for (let i = 0; i < parts.length; i++) {
60
+ out[i] = +parts[i];
61
+ }
62
+ return out;
63
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * How one MM header's type string splits into modification types.
3
+ *
4
+ * A multi-character LOWERCASE type string is a combined code — `C+mh` is 5mC
5
+ * and 5hmC called at the same positions — so each character is its own type. A
6
+ * single character, or anything starting outside a-z (a ChEBI number like
7
+ * `C+16061`, an uppercase ambiguity code like `C+C`), is one type whole.
8
+ *
9
+ * Stated once because `getModPositions` and `getModTypes` both have to split
10
+ * the same way: the second exists to answer "which types are here" without the
11
+ * first's work, and a disagreement would show as a modification the menu offers
12
+ * and the display cannot draw.
13
+ */
14
+ export declare function isSingleModType(typestr: string): boolean;
15
+ export interface ModTypeHeader {
16
+ type: string;
17
+ base: string;
18
+ strand: string;
19
+ }
20
+ /**
21
+ * #api
22
+ * The modification types an MM tag declares, from its headers alone.
23
+ *
24
+ * `getModPositions` answers this too, but on the way to placing every call: it
25
+ * walks the delta list against the read sequence, which is the expensive half
26
+ * and is only needed to DRAW marks. Anything that just wants to know what is in
27
+ * the file — which types to offer in a menu, whether a track carries
28
+ * modifications at all — wants this instead, and pays neither the walk nor the
29
+ * sequence decode that feeds it.
30
+ */
31
+ export declare function getModTypes(mm: string): ModTypeHeader[];
@@ -0,0 +1,48 @@
1
+ import { parseModHeader } from './consts.js';
2
+ /**
3
+ * How one MM header's type string splits into modification types.
4
+ *
5
+ * A multi-character LOWERCASE type string is a combined code — `C+mh` is 5mC
6
+ * and 5hmC called at the same positions — so each character is its own type. A
7
+ * single character, or anything starting outside a-z (a ChEBI number like
8
+ * `C+16061`, an uppercase ambiguity code like `C+C`), is one type whole.
9
+ *
10
+ * Stated once because `getModPositions` and `getModTypes` both have to split
11
+ * the same way: the second exists to answer "which types are here" without the
12
+ * first's work, and a disagreement would show as a modification the menu offers
13
+ * and the display cannot draw.
14
+ */
15
+ export function isSingleModType(typestr) {
16
+ return typestr.charCodeAt(0) < 97 || typestr.length === 1;
17
+ }
18
+ /**
19
+ * #api
20
+ * The modification types an MM tag declares, from its headers alone.
21
+ *
22
+ * `getModPositions` answers this too, but on the way to placing every call: it
23
+ * walks the delta list against the read sequence, which is the expensive half
24
+ * and is only needed to DRAW marks. Anything that just wants to know what is in
25
+ * the file — which types to offer in a menu, whether a track carries
26
+ * modifications at all — wants this instead, and pays neither the walk nor the
27
+ * sequence decode that feeds it.
28
+ */
29
+ export function getModTypes(mm) {
30
+ const out = [];
31
+ for (const group of mm.split(';')) {
32
+ if (group === '') {
33
+ continue;
34
+ }
35
+ const comma = group.indexOf(',');
36
+ const basemod = comma === -1 ? group : group.slice(0, comma);
37
+ const { base, strand, typestr } = parseModHeader(basemod, group);
38
+ if (isSingleModType(typestr)) {
39
+ out.push({ type: typestr, base, strand });
40
+ }
41
+ else {
42
+ for (let i = 0, l = typestr.length; i < l; i++) {
43
+ out.push({ type: typestr[i], base, strand });
44
+ }
45
+ }
46
+ }
47
+ return out;
48
+ }
@@ -0,0 +1,20 @@
1
+ import type { Feature } from '@jbrowse/core/util';
2
+ /**
3
+ * #api
4
+ * Read a single tag by name, using the feature's targeted tag accessor when it
5
+ * has one (BAM) and the full tags object otherwise (CRAM/synteny).
6
+ */
7
+ export declare function getTag(feature: Feature, tag: string): unknown;
8
+ /**
9
+ * #api
10
+ * Read a tag by its canonical name, falling back to a lowercase-suffixed alias
11
+ * (e.g. MM/Mm, ML/Ml) as emitted by some aligners.
12
+ *
13
+ * Prefers the feature's own one-pass alias lookup when it has one. The plain
14
+ * `getTag(tag) ?? getTag(alt)` form walks the record's whole tag block TWICE
15
+ * whenever neither name is present — which is every read in a file without base
16
+ * modifications, and this is called per read on every render. On jb2bench's
17
+ * 1000x.shortread that pair of walks was 12.9% of the whole BAM query, more
18
+ * than the CIGAR/SEQ/MD reads the pileup actually uses.
19
+ */
20
+ export declare function getTagAlt(feature: Feature, tag: string, alt: string): unknown;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * #api
3
+ * Read a single tag by name, using the feature's targeted tag accessor when it
4
+ * has one (BAM) and the full tags object otherwise (CRAM/synteny).
5
+ */
6
+ export function getTag(feature, tag) {
7
+ const getter = feature.getTag;
8
+ if (getter) {
9
+ return getter.call(feature, tag);
10
+ }
11
+ return feature.get('tags')?.[tag];
12
+ }
13
+ /**
14
+ * #api
15
+ * Read a tag by its canonical name, falling back to a lowercase-suffixed alias
16
+ * (e.g. MM/Mm, ML/Ml) as emitted by some aligners.
17
+ *
18
+ * Prefers the feature's own one-pass alias lookup when it has one. The plain
19
+ * `getTag(tag) ?? getTag(alt)` form walks the record's whole tag block TWICE
20
+ * whenever neither name is present — which is every read in a file without base
21
+ * modifications, and this is called per read on every render. On jb2bench's
22
+ * 1000x.shortread that pair of walks was 12.9% of the whole BAM query, more
23
+ * than the CIGAR/SEQ/MD reads the pileup actually uses.
24
+ */
25
+ export function getTagAlt(feature, tag, alt) {
26
+ const both = feature.getTagAlt;
27
+ if (both) {
28
+ return both.call(feature, tag, alt);
29
+ }
30
+ return getTag(feature, tag) ?? getTag(feature, alt);
31
+ }
package/esm/index.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ export { modificationRegex, parseModHeader } from './consts.ts';
2
+ export { matchesCytosineContext } from './cytosineContext.ts';
3
+ export type { CytosineContext } from './cytosineContext.ts';
4
+ export { detectSimplexModifications } from './detectSimplexModifications.ts';
5
+ export { forEachModRefPos } from './forEachModRefPos.ts';
6
+ export { getMethBins, isMethylationFillType } from './getMethBins.ts';
7
+ export type { ParsedModData } from './getMethBins.ts';
8
+ export { getModPositions } from './getModPositions.ts';
9
+ export type { ModWithPositions } from './getModPositions.ts';
10
+ export { getModTypes, isSingleModType } from './getModTypes.ts';
11
+ export type { ModTypeHeader } from './getModTypes.ts';
12
+ export { getModProbabilities, getModProbabilityBytes, modProbAt, } from './getModProbabilities.ts';
13
+ export { getTag, getTagAlt } from './getTagAlt.ts';
14
+ export type { ModificationType } from './types.ts';
package/esm/index.js ADDED
@@ -0,0 +1,9 @@
1
+ export { modificationRegex, parseModHeader } from './consts.js';
2
+ export { matchesCytosineContext } from './cytosineContext.js';
3
+ export { detectSimplexModifications } from './detectSimplexModifications.js';
4
+ export { forEachModRefPos } from './forEachModRefPos.js';
5
+ export { getMethBins, isMethylationFillType } from './getMethBins.js';
6
+ export { getModPositions } from './getModPositions.js';
7
+ export { getModTypes, isSingleModType } from './getModTypes.js';
8
+ export { getModProbabilities, getModProbabilityBytes, modProbAt, } from './getModProbabilities.js';
9
+ export { getTag, getTagAlt } from './getTagAlt.js';
package/esm/types.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ export interface ModificationType {
2
+ type: string;
3
+ base: string;
4
+ strand: string;
5
+ }
package/esm/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@jbrowse/modifications-utils",
3
+ "version": "4.1.14",
4
+ "type": "module",
5
+ "description": "Pure MM/ML base-modification tag parsers (methylation, etc.)",
6
+ "keywords": [
7
+ "jbrowse",
8
+ "jbrowse2",
9
+ "modifications",
10
+ "methylation",
11
+ "sam",
12
+ "bam"
13
+ ],
14
+ "license": "Apache-2.0",
15
+ "homepage": "https://jbrowse.org",
16
+ "bugs": "https://github.com/GMOD/jbrowse-components/issues",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/GMOD/jbrowse-components.git",
20
+ "directory": "packages/modifications-utils"
21
+ },
22
+ "author": "JBrowse Team",
23
+ "main": "esm/index.js",
24
+ "files": [
25
+ "esm"
26
+ ],
27
+ "dependencies": {
28
+ "@jbrowse/cigar-utils": "^4.1.14",
29
+ "@jbrowse/core": "^4.3.0"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "sideEffects": false,
35
+ "scripts": {
36
+ "build:esm": "node ../../node_modules/typescript7/bin/tsc --build tsconfig.build.esm.json",
37
+ "build": "pnpm build:esm",
38
+ "test": "cd ../..; jest --passWithNoTests packages/modifications-utils",
39
+ "clean": "rimraf esm *.tsbuildinfo",
40
+ "prebuild": "pnpm clean"
41
+ },
42
+ "types": "esm/index.d.ts",
43
+ "exports": {
44
+ ".": "./esm/index.js"
45
+ }
46
+ }