@gmod/hic 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/LICENSE +25 -0
  2. package/README.md +91 -0
  3. package/dist/binWindow.d.ts +24 -0
  4. package/dist/binWindow.js +32 -0
  5. package/dist/binWindow.js.map +1 -0
  6. package/dist/binary.d.ts +15 -0
  7. package/dist/binary.js +67 -0
  8. package/dist/binary.js.map +1 -0
  9. package/dist/bufferedFile.d.ts +13 -0
  10. package/dist/bufferedFile.js +78 -0
  11. package/dist/bufferedFile.js.map +1 -0
  12. package/dist/contactRecords.d.ts +25 -0
  13. package/dist/contactRecords.js +3 -0
  14. package/dist/contactRecords.js.map +1 -0
  15. package/dist/hicFile.d.ts +94 -0
  16. package/dist/hicFile.js +769 -0
  17. package/dist/hicFile.js.map +1 -0
  18. package/dist/index.d.ts +5 -0
  19. package/dist/index.js +9 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/lru.d.ts +29 -0
  22. package/dist/lru.js +55 -0
  23. package/dist/lru.js.map +1 -0
  24. package/dist/matrix.d.ts +9 -0
  25. package/dist/matrix.js +41 -0
  26. package/dist/matrix.js.map +1 -0
  27. package/dist/matrixZoomData.d.ts +14 -0
  28. package/dist/matrixZoomData.js +96 -0
  29. package/dist/matrixZoomData.js.map +1 -0
  30. package/dist/normalizationVector.d.ts +10 -0
  31. package/dist/normalizationVector.js +51 -0
  32. package/dist/normalizationVector.js.map +1 -0
  33. package/dist/package.json +1 -0
  34. package/dist/reader.d.ts +3 -0
  35. package/dist/reader.js +12 -0
  36. package/dist/reader.js.map +1 -0
  37. package/dist/types.d.ts +35 -0
  38. package/dist/types.js +3 -0
  39. package/dist/types.js.map +1 -0
  40. package/esm/binWindow.d.ts +24 -0
  41. package/esm/binWindow.js +29 -0
  42. package/esm/binWindow.js.map +1 -0
  43. package/esm/binary.d.ts +15 -0
  44. package/esm/binary.js +64 -0
  45. package/esm/binary.js.map +1 -0
  46. package/esm/bufferedFile.d.ts +13 -0
  47. package/esm/bufferedFile.js +75 -0
  48. package/esm/bufferedFile.js.map +1 -0
  49. package/esm/contactRecords.d.ts +25 -0
  50. package/esm/contactRecords.js +2 -0
  51. package/esm/contactRecords.js.map +1 -0
  52. package/esm/hicFile.d.ts +94 -0
  53. package/esm/hicFile.js +762 -0
  54. package/esm/hicFile.js.map +1 -0
  55. package/esm/index.d.ts +5 -0
  56. package/esm/index.js +3 -0
  57. package/esm/index.js.map +1 -0
  58. package/esm/lru.d.ts +29 -0
  59. package/esm/lru.js +52 -0
  60. package/esm/lru.js.map +1 -0
  61. package/esm/matrix.d.ts +9 -0
  62. package/esm/matrix.js +35 -0
  63. package/esm/matrix.js.map +1 -0
  64. package/esm/matrixZoomData.d.ts +14 -0
  65. package/esm/matrixZoomData.js +93 -0
  66. package/esm/matrixZoomData.js.map +1 -0
  67. package/esm/normalizationVector.d.ts +10 -0
  68. package/esm/normalizationVector.js +45 -0
  69. package/esm/normalizationVector.js.map +1 -0
  70. package/esm/reader.d.ts +3 -0
  71. package/esm/reader.js +9 -0
  72. package/esm/reader.js.map +1 -0
  73. package/esm/types.d.ts +35 -0
  74. package/esm/types.js +2 -0
  75. package/esm/types.js.map +1 -0
  76. package/package.json +73 -0
  77. package/src/binWindow.ts +30 -0
  78. package/src/binary.ts +72 -0
  79. package/src/bufferedFile.ts +74 -0
  80. package/src/contactRecords.ts +25 -0
  81. package/src/hicFile.ts +943 -0
  82. package/src/index.ts +12 -0
  83. package/src/lru.ts +77 -0
  84. package/src/matrix.ts +43 -0
  85. package/src/matrixZoomData.ts +133 -0
  86. package/src/normalizationVector.ts +58 -0
  87. package/src/reader.ts +11 -0
  88. package/src/types.ts +40 -0
package/src/hicFile.ts ADDED
@@ -0,0 +1,943 @@
1
+ import { LocalFile } from 'generic-filehandle2'
2
+ import { inflate } from 'pako-esm2'
3
+
4
+ import { binWindow } from './binWindow.ts'
5
+ import BinaryParser from './binary.ts'
6
+ import BufferedFile from './bufferedFile.ts'
7
+ import LRU from './lru.ts'
8
+ import Matrix from './matrix.ts'
9
+ import NormalizationVector from './normalizationVector.ts'
10
+ import { readerFromFilehandle } from './reader.ts'
11
+
12
+ import type { ContactRecords } from './contactRecords.ts'
13
+ import type MatrixZoomData from './matrixZoomData.ts'
14
+ import type {
15
+ BlockIndexEntry,
16
+ Chromosome,
17
+ HicMetadata,
18
+ HicRegion,
19
+ Reader,
20
+ } from './types.ts'
21
+ import type { GenericFilehandle } from 'generic-filehandle2'
22
+
23
+ /**
24
+ * Prefix of the error thrown when a region pair has no matrix at the requested
25
+ * binsize. A caller fetching many region pairs at once will want to drop that
26
+ * pair rather than fail the whole fetch, and it can only tell this apart from a
27
+ * real failure by the message — so it is exported instead of hand-copied.
28
+ */
29
+ export const NO_DATA_FOR_RESOLUTION = 'No data available for resolution'
30
+
31
+ const Short_MIN_VALUE = -32768
32
+ const DOUBLE = 8
33
+ const FLOAT = 4
34
+ const INT = 4
35
+
36
+ interface MasterIndexEntry {
37
+ start: number
38
+ size: number
39
+ }
40
+
41
+ export interface HicConfig {
42
+ /** a `generic-filehandle2` handle, e.g. `RemoteFile` for a URL */
43
+ filehandle?: GenericFilehandle
44
+ /** a local path, node only */
45
+ path?: string
46
+ /** any object that can read `length` bytes at `position` */
47
+ reader?: Reader
48
+ /**
49
+ * `"position,size"` of the normalization vector index, if it is known. Skips
50
+ * the walk through the expected-value vectors that locating it otherwise
51
+ * costs on a pre-v9 file.
52
+ */
53
+ nvi?: string
54
+ }
55
+
56
+ // Cached blocks are the decompressed records and nothing else. hic-straw also
57
+ // hung the MatrixZoomData and the block-index entry off each Block; neither was
58
+ // ever read back, and the zoom data pinned a whole `blockIndex` record per
59
+ // cached block for the lifetime of the LRU.
60
+ interface Block {
61
+ blockNumber: number
62
+ records: ContactRecords
63
+ }
64
+
65
+ /**
66
+ * Copy a partially-filled decode buffer down to its true length, or hand it
67
+ * back untouched when the filter dropped nothing. A `subarray` would be free
68
+ * but keeps the whole oversized buffer reachable, and these land in a cache
69
+ * that outlives the fetch.
70
+ */
71
+ function truncateRecords(records: ContactRecords, n: number): ContactRecords {
72
+ return n === records.bin1.length
73
+ ? records
74
+ : {
75
+ bin1: records.bin1.slice(0, n),
76
+ bin2: records.bin2.slice(0, n),
77
+ counts: records.counts.slice(0, n),
78
+ }
79
+ }
80
+
81
+ // Every cache here is sized against the same thing, and it is worth stating
82
+ // once: a fetch's working set is a function of the DISPLAYED REGION COUNT, and
83
+ // two of the three grow with its square.
84
+ //
85
+ // A multi-region view queries every `(i, j)` pair with `i <= j`, so N displayed
86
+ // regions is N(N+1)/2 pairs — a whole-genome human view is 24 regions and 300
87
+ // pairs. Each pair needs one matrix and its blocks; each region needs one
88
+ // normalization vector per chromosome. Sized for one region, these caches don't
89
+ // merely underperform, they invert: the entries a fetch will need again are
90
+ // evicted by the same fetch, so the cache costs eviction bookkeeping and
91
+ // returns nothing.
92
+ //
93
+ // Measured on `test/data/test.hic` (hg19, 2.5 Mb, 24 regions / 300 pairs),
94
+ // range reads for a fetch and then an identical repeat fetch — which is what
95
+ // every pan issues:
96
+ //
97
+ // N pairs | before | after
98
+ // 4 10 | 29 / 0 | 29 / 0
99
+ // 5 15 | 40 / 15 | 40 / 0
100
+ // 10 55 | 130 / 110 | 130 / 0
101
+ // 24 300 | 1106 /1106 | 648 / 0
102
+ //
103
+ // The cliffs are exactly these three capacities. Note the first column: 41% of a
104
+ // cold whole-genome fetch's reads were re-reads of normalization vectors the
105
+ // same fetch had already fetched.
106
+ const BLOCK_CACHE_MAX_ENTRIES = 1024
107
+ // The bound the entry cap was standing in for. Blocks became struct-of-arrays
108
+ // (see contactRecords.ts), so a cached contact is 12 bytes of untraced
109
+ // ArrayBuffer rather than ~55 on the GC-traced heap — but a block still holds
110
+ // every contact in its bin square, which is the one thing here that can hold
111
+ // real memory, and block sizes vary by more than an order of magnitude with
112
+ // binsize. Measured on the file above: 0.05 MB at 2.5 Mb, 0.23 MB at 100 kb. So
113
+ // the entry cap now tracks the working set and this tracks the memory, instead
114
+ // of one number answering both badly. See LRU's WeightOpts.
115
+ const BLOCK_CACHE_MAX_BYTES = 128 * 1024 * 1024
116
+ // One entry per (chrIdx1, chrIdx2), so the working set is the pair count.
117
+ // 512 covers a 31-region view; human whole-genome is 300. A matrix holds the
118
+ // block index for each of its BP zoom levels, which is small beside a block.
119
+ const MATRIX_CACHE_SIZE = 512
120
+ // One entry per (type, chrIdx, unit, binsize), so the working set is the
121
+ // displayed CHROMOSOME count, not the pair count — 64 covers a whole-genome
122
+ // human view twice over, which is the headroom a zoom step needs (binsize is in
123
+ // the key, so every entry misses at a new resolution). Entries are cheap:
124
+ // `NormalizationVector` retains only the queried slice ±1000 values.
125
+ const NORM_VECTOR_CACHE_SIZE = 64
126
+
127
+ // Keyed by `${zd.getKey()}_${blockNumber}`, which already carries the binsize,
128
+ // so no separate resolution generation is needed to keep entries apart —
129
+ // hic-straw's extra `resolution` field only made the cache single-resolution
130
+ // (every zoom step threw the previous level away).
131
+ function blockCacheWeight(block: Block) {
132
+ const { bin1, bin2, counts } = block.records
133
+ return bin1.byteLength + bin2.byteLength + counts.byteLength
134
+ }
135
+
136
+ function getNormalizationVectorKey(
137
+ type: string,
138
+ chrIdx: number,
139
+ unit: string,
140
+ resolution: number,
141
+ ) {
142
+ return `${type}_${chrIdx}_${unit}_${resolution}`
143
+ }
144
+
145
+ export class HicFile {
146
+ private config: HicConfig
147
+ private file: Reader
148
+
149
+ // Both caches hold the in-flight PROMISE, not the resolved value. A
150
+ // multi-region fetch runs its region pairs concurrently and they share
151
+ // chromosomes, so caching only the result had every concurrent pair miss and
152
+ // re-issue the same reads — measured +12 range requests on a 6-pair fetch.
153
+ // Same shape as `initPromise`/`normVectorIndexP`, rejection eviction included.
154
+ private normVectorCache = new LRU<
155
+ string,
156
+ Promise<NormalizationVector | undefined>
157
+ >(NORM_VECTOR_CACHE_SIZE)
158
+ private normalizationTypes = ['NONE']
159
+ private matrixCache = new LRU<string, Promise<Matrix | undefined>>(
160
+ MATRIX_CACHE_SIZE,
161
+ )
162
+ private blockCache = new LRU<string, Block>(BLOCK_CACHE_MAX_ENTRIES, {
163
+ maxBytes: BLOCK_CACHE_MAX_BYTES,
164
+ weigh: blockCacheWeight,
165
+ })
166
+ private normVectorIndexPosition = -1
167
+ private normVectorIndexSize = -1
168
+
169
+ private initPromise: Promise<void> | undefined
170
+ private normVectorIndexP: Promise<void> | undefined
171
+ private version = 0
172
+ private genomeId = ''
173
+ private footerPosition = 0
174
+ // Where the file's normalization data begins, and so also where a file
175
+ // carrying none of it ends. Public so a test can cut a real file there to
176
+ // make that second shape.
177
+ normExpectedValueVectorsPosition: number | undefined
178
+ private normVectorIndex: Record<string, BlockIndexEntry> | undefined
179
+
180
+ private chromosomes: Chromosome[] = []
181
+ private chromosomeIndexMap: Record<string, number> = {}
182
+ private chrAliasTable: Record<string, string> = {}
183
+ private bpResolutions: number[] = []
184
+ private masterIndex: Record<string, MasterIndexEntry> = {}
185
+ private meta: HicMetadata | undefined
186
+
187
+ constructor(config: HicConfig) {
188
+ const { filehandle, path, reader } = config
189
+ this.config = config
190
+ if (reader) {
191
+ this.file = reader
192
+ } else if (filehandle) {
193
+ this.file = readerFromFilehandle(filehandle)
194
+ } else if (path) {
195
+ this.file = readerFromFilehandle(new LocalFile(path))
196
+ } else {
197
+ throw new Error('must supply filehandle, path, or reader')
198
+ }
199
+ }
200
+
201
+ async init() {
202
+ // Memoize the promise, not a boolean flag: two concurrent callers must
203
+ // share one header parse rather than both racing readHeaderAndFooter. Clear
204
+ // it on failure so a later call retries instead of caching a rejected
205
+ // promise forever.
206
+ this.initPromise ??= this.readHeaderAndFooter().catch((e: unknown) => {
207
+ this.initPromise = undefined
208
+ throw e
209
+ })
210
+ return this.initPromise
211
+ }
212
+
213
+ async getMetaData() {
214
+ await this.init()
215
+ return this.meta!
216
+ }
217
+
218
+ async readHeaderAndFooter() {
219
+ // `init` clears its memoized promise on failure so a transient read error
220
+ // retries, and this parse appends as it goes — so a retry has to start from
221
+ // empty or a failure part-way through the chromosome loop leaves the second
222
+ // run appending to the first's partial output (duplicate refNames out of
223
+ // `getRefNames`, a duplicated binsize list out of `getMetaData`).
224
+ this.chromosomes = []
225
+ this.chromosomeIndexMap = {}
226
+ this.chrAliasTable = {}
227
+ this.bpResolutions = []
228
+ this.masterIndex = {}
229
+
230
+ // Read initial fields magic, version, and footer position
231
+ let data = await this.file.read(0, 16)
232
+ if (data.byteLength === 0) {
233
+ throw new Error('File content is empty')
234
+ }
235
+ let binaryParser = new BinaryParser(new DataView(data))
236
+ binaryParser.getString() // magic
237
+ this.version = binaryParser.getInt()
238
+ if (this.version < 5) {
239
+ throw new Error(`Unsupported hic version: ${this.version}`)
240
+ }
241
+ this.footerPosition = binaryParser.getLong()
242
+
243
+ // Read footer to determine file position of body section (end of header)
244
+ await this.readFooter()
245
+
246
+ const bodyPosition = Object.values(this.masterIndex).reduce(
247
+ (min, entry) => Math.min(min, entry.start),
248
+ Number.MAX_VALUE,
249
+ )
250
+
251
+ data = await this.file.read(16, bodyPosition - 16)
252
+ binaryParser = new BinaryParser(new DataView(data))
253
+
254
+ this.genomeId = binaryParser.getString()
255
+
256
+ if (this.version >= 9) {
257
+ this.normVectorIndexPosition = binaryParser.getLong()
258
+ this.normVectorIndexSize = binaryParser.getLong()
259
+ }
260
+
261
+ let nAttributes = binaryParser.getInt()
262
+ while (nAttributes-- > 0) {
263
+ binaryParser.getString() // attribute key
264
+ binaryParser.getString() // attribute value
265
+ }
266
+
267
+ let nChrs = binaryParser.getInt()
268
+ let i = 0
269
+ while (nChrs-- > 0) {
270
+ const chr = {
271
+ index: i,
272
+ name: binaryParser.getString(),
273
+ size: this.version < 9 ? binaryParser.getInt() : binaryParser.getLong(),
274
+ }
275
+ this.chromosomes.push(chr)
276
+ this.chromosomeIndexMap[chr.name] = chr.index
277
+ i++
278
+ }
279
+
280
+ let nBpResolutions = binaryParser.getInt()
281
+ while (nBpResolutions-- > 0) {
282
+ this.bpResolutions.push(binaryParser.getInt())
283
+ }
284
+
285
+ // Build lookup table for well-known chr aliases
286
+ for (const chrName of Object.keys(this.chromosomeIndexMap)) {
287
+ if (chrName.startsWith('chr')) {
288
+ this.chrAliasTable[chrName.slice(3)] = chrName
289
+ } else if (chrName === 'MT') {
290
+ this.chrAliasTable.chrM = chrName
291
+ } else {
292
+ this.chrAliasTable[`chr${chrName}`] = chrName
293
+ }
294
+ }
295
+
296
+ this.meta = {
297
+ version: this.version,
298
+ genome: this.genomeId,
299
+ chromosomes: this.chromosomes,
300
+ resolutions: this.bpResolutions,
301
+ }
302
+ }
303
+
304
+ async readFooter() {
305
+ const skip = this.version < 9 ? 8 : 12
306
+ let data = await this.file.read(this.footerPosition, skip)
307
+
308
+ let binaryParser = new BinaryParser(new DataView(data))
309
+ // Total size, master index + expected values
310
+ const nBytes =
311
+ this.version < 9 ? binaryParser.getInt() : binaryParser.getLong()
312
+ let nEntries = binaryParser.getInt()
313
+
314
+ // Estimate the size of the master index. String key length is unknown, be
315
+ // conservative (100 bytes).
316
+ const miSize = nEntries * (100 + 64 + 32)
317
+ data = await this.file.read(
318
+ this.footerPosition + skip,
319
+ Math.min(miSize, nBytes),
320
+ )
321
+ binaryParser = new BinaryParser(new DataView(data))
322
+
323
+ while (nEntries-- > 0) {
324
+ const key = binaryParser.getString()
325
+ const pos = binaryParser.getLong()
326
+ const size = binaryParser.getInt()
327
+ this.masterIndex[key] = { start: pos, size }
328
+ }
329
+
330
+ // Normalized expected values start after the expected values
331
+ if (this.version > 5) {
332
+ const s = this.version < 9 ? 4 : 8
333
+ this.normExpectedValueVectorsPosition = this.footerPosition + s + nBytes
334
+ }
335
+ }
336
+
337
+ async getMatrix(chrIdx1: number, chrIdx2: number) {
338
+ const key = Matrix.getKey(chrIdx1, chrIdx2)
339
+ let p = this.matrixCache.get(key)
340
+ if (!p) {
341
+ p = this.readMatrix(chrIdx1, chrIdx2).catch((e: unknown) => {
342
+ this.matrixCache.delete(key)
343
+ throw e
344
+ })
345
+ this.matrixCache.set(key, p)
346
+ }
347
+ return p
348
+ }
349
+
350
+ async readMatrix(chrIdx1: number, chrIdx2: number) {
351
+ await this.init()
352
+
353
+ const idx = this.masterIndex[Matrix.getKey(chrIdx1, chrIdx2)]
354
+ let matrix: Matrix | undefined
355
+ if (idx) {
356
+ const data = await this.file.read(idx.start, idx.size)
357
+ matrix = Matrix.parseMatrix(data, this.chromosomes)
358
+ }
359
+ return matrix
360
+ }
361
+
362
+ async getContactRecords(
363
+ normalization: string,
364
+ region1: HicRegion,
365
+ region2: HicRegion,
366
+ units: string,
367
+ binsize: number,
368
+ ) {
369
+ await this.init()
370
+
371
+ const idx1 = this.chromosomeIndexMap[this.getFileChrName(region1.chr)]
372
+ const idx2 = this.chromosomeIndexMap[this.getFileChrName(region2.chr)]
373
+
374
+ // A `.hic` stores only `bin1 <= bin2`, so a query whose x window sits to the
375
+ // right of its y window has to be swapped or it asks for the half of the
376
+ // matrix that does not exist. The same-chromosome test compares **starts**,
377
+ // not this region's start against the other's end: upstream hic-straw used
378
+ // `region1.start >= region2.end`, which catches a reversed pair only while
379
+ // the two are disjoint, and a multi-region view is free to display two
380
+ // *overlapping* regions of one chromosome right-to-left. Measured on
381
+ // chr1 at 2.5 Mb, `(100-200Mb, 50-150Mb)` returned 78 contacts against 901
382
+ // for the same pair in genomic order — everything but the overlap sliver
383
+ // silently missing, which renders as a sparse off-diagonal block rather
384
+ // than as an error. Comparing starts fires on both the disjoint and the
385
+ // overlapping case and leaves forward order (and an identical pair) alone.
386
+ const transpose =
387
+ idx1 !== undefined &&
388
+ idx2 !== undefined &&
389
+ (idx1 > idx2 || (idx1 === idx2 && region1.start > region2.start))
390
+ const r1 = transpose ? region2 : region1
391
+ const r2 = transpose ? region1 : region2
392
+
393
+ const [x1, x2] = binWindow(r1, binsize)
394
+ const [y1, y2] = binWindow(r2, binsize)
395
+
396
+ // Two independent read chains, so they run concurrently rather than in
397
+ // sequence. The normalization vectors are keyed on (type, chr, unit,
398
+ // binsize) and the blocks on the region pair; neither reads anything the
399
+ // other produces, and awaiting them in order made every pair pay the SUM of
400
+ // their round-trip depths instead of the deeper of the two.
401
+ //
402
+ // Both are two hops — norm-vector header then its values, matrix header then
403
+ // its blocks — so a pair went 4 sequential waves deep where 2 will do
404
+ // (`readChainDepth.test.ts` measures this against the real file). That is
405
+ // the everyday single-region fetch; a multi-region view then runs several
406
+ // pairs at a time on top of it. This loop is latency-bound, not CPU-bound.
407
+ //
408
+ // Vectors are loop-invariant across blocks either way, so they are still
409
+ // resolved once per pair rather than per block, each paired with the bin
410
+ // offset its values start at.
411
+ const [norm, blocks] = await Promise.all([
412
+ this.getNormVectors(normalization, r1, r2, units, binsize),
413
+ this.getBlocks(r1, r2, binsize),
414
+ ])
415
+
416
+ // Sum of the blocks' record counts bounds the survivors, so the output is
417
+ // allocated once and filled by a write cursor. Blocks overlap the window
418
+ // rather than nest in it, so the true count isn't known without either this
419
+ // upper bound or a counting pre-pass over the same data.
420
+ let capacity = 0
421
+ for (const block of blocks) {
422
+ if (block) {
423
+ capacity += block.records.bin1.length
424
+ }
425
+ }
426
+ const outBin1 = new Int32Array(capacity)
427
+ const outBin2 = new Int32Array(capacity)
428
+ const outCounts = new Float32Array(capacity)
429
+ let n = 0
430
+
431
+ for (const block of blocks) {
432
+ // An undefined block is most likely a base-pair range outside the
433
+ // chromosome
434
+ if (!block) {
435
+ continue
436
+ }
437
+ const { bin1, bin2, counts } = block.records
438
+ const len = bin1.length
439
+ // `norm` is loop-invariant, so it selects the loop rather than being
440
+ // retested per record — which also hoists the vector/offset reads
441
+ if (norm) {
442
+ const { v1, v2, offset1, offset2 } = norm
443
+ for (let i = 0; i < len; i++) {
444
+ const b1 = bin1[i]!
445
+ const b2 = bin2[i]!
446
+ if (b1 >= x1 && b1 < x2 && b2 >= y1 && b2 < y2) {
447
+ const nvnv = v1[b1 - offset1]! * v2[b2 - offset2]!
448
+ if (nvnv !== 0 && !Number.isNaN(nvnv)) {
449
+ outBin1[n] = b1
450
+ outBin2[n] = b2
451
+ outCounts[n] = counts[i]! / nvnv
452
+ n++
453
+ }
454
+ }
455
+ }
456
+ } else {
457
+ for (let i = 0; i < len; i++) {
458
+ const b1 = bin1[i]!
459
+ const b2 = bin2[i]!
460
+ if (b1 >= x1 && b1 < x2 && b2 >= y1 && b2 < y2) {
461
+ outBin1[n] = b1
462
+ outBin2[n] = b2
463
+ outCounts[n] = counts[i]!
464
+ n++
465
+ }
466
+ }
467
+ }
468
+ }
469
+
470
+ // Views, not copies: a caller concatenating several pairs' results copies
471
+ // into its own exactly-sized arrays anyway.
472
+ const contactRecords: ContactRecords = {
473
+ bin1: outBin1.subarray(0, n),
474
+ bin2: outBin2.subarray(0, n),
475
+ counts: outCounts.subarray(0, n),
476
+ }
477
+
478
+ // What was actually applied, which is not always what was asked for:
479
+ // normalization vectors are stored per (type, chr, unit, binsize), so a file
480
+ // can offer KR at 5kb and nothing at 2.5Mb. hic-straw warns to the console
481
+ // and silently hands back raw counts; reporting it lets a display tell the
482
+ // user which scheme they are actually looking at.
483
+ //
484
+ // `transposed` says the query was swapped, so `bin1` runs along `region2`.
485
+ // Reported rather than left for the caller to re-derive: it is decided here
486
+ // from this file's own alias table and chromosome indices, and a caller
487
+ // re-deriving it off a divergent chr-name scheme would silently un-swap the
488
+ // wrong axis.
489
+ return {
490
+ records: contactRecords,
491
+ appliedNormalization: norm ? normalization : 'NONE',
492
+ transposed: transpose,
493
+ }
494
+ }
495
+
496
+ private async getNormVectors(
497
+ normalization: string,
498
+ r1: HicRegion,
499
+ r2: HicRegion,
500
+ units: string,
501
+ binsize: number,
502
+ ) {
503
+ let result:
504
+ | {
505
+ v1: Float64Array
506
+ v2: Float64Array
507
+ offset1: number
508
+ offset2: number
509
+ }
510
+ | undefined
511
+ if (normalization && normalization !== 'NONE') {
512
+ const chr1 = this.getFileChrName(r1.chr)
513
+ const chr2 = this.getFileChrName(r2.chr)
514
+ const offset1 = Math.floor(r1.start / binsize)
515
+ const offset2 = Math.floor(r2.start / binsize)
516
+ const nv1 = await this.getNormalizationVector(
517
+ normalization,
518
+ chr1,
519
+ units,
520
+ binsize,
521
+ )
522
+ const nv2 =
523
+ chr1 === chr2
524
+ ? nv1
525
+ : await this.getNormalizationVector(
526
+ normalization,
527
+ chr2,
528
+ units,
529
+ binsize,
530
+ )
531
+ if (nv1 && nv2) {
532
+ result = {
533
+ v1: await nv1.getValues(offset1, Math.ceil(r1.end / binsize)),
534
+ v2: await nv2.getValues(offset2, Math.ceil(r2.end / binsize)),
535
+ offset1,
536
+ offset2,
537
+ }
538
+ }
539
+ }
540
+ return result
541
+ }
542
+
543
+ async getBlocks(region1: HicRegion, region2: HicRegion, binSize: number) {
544
+ const blockKey = (blockNumber: number, zd: MatrixZoomData) =>
545
+ `${zd.getKey()}_${blockNumber}`
546
+
547
+ await this.init()
548
+ const chr1 = this.getFileChrName(region1.chr)
549
+ const chr2 = this.getFileChrName(region2.chr)
550
+ const idx1 = this.chromosomeIndexMap[chr1]
551
+ const idx2 = this.chromosomeIndexMap[chr2]
552
+
553
+ let blocks: (Block | undefined)[] = []
554
+ if (idx1 === undefined) {
555
+ console.warn(`No chromosome named: ${region1.chr}`)
556
+ } else if (idx2 === undefined) {
557
+ console.warn(`No chromosome named: ${region2.chr}`)
558
+ } else {
559
+ // A chr pair with no matrix at all is routine, not an anomaly: plenty of
560
+ // .hic files store no inter-chromosomal maps, and a multi-region view asks
561
+ // for every pair. Answering with no blocks, rather than warning once per
562
+ // pair per fetch, leaves that for the caller to notice or ignore.
563
+ const matrix = await this.getMatrix(idx1, idx2)
564
+ if (matrix) {
565
+ const zd = matrix.getZoomData(binSize)
566
+ if (!zd) {
567
+ throw new Error(
568
+ `${NO_DATA_FOR_RESOLUTION}: ${binSize} for map ${region1.chr}-${region2.chr}`,
569
+ )
570
+ }
571
+
572
+ const blockNumbers = zd.getBlockNumbers(region1, region2, this.version)
573
+ const blockNumbersToQuery: number[] = []
574
+ for (const num of blockNumbers) {
575
+ const cached = this.blockCache.get(blockKey(num, zd))
576
+ if (cached) {
577
+ blocks.push(cached)
578
+ } else {
579
+ blockNumbersToQuery.push(num)
580
+ }
581
+ }
582
+
583
+ const newBlocks = await Promise.all(
584
+ blockNumbersToQuery.map(blockNumber =>
585
+ this.readBlock(blockNumber, zd),
586
+ ),
587
+ )
588
+ for (const block of newBlocks) {
589
+ if (block) {
590
+ this.blockCache.set(blockKey(block.blockNumber, zd), block)
591
+ }
592
+ }
593
+ blocks = blocks.concat(newBlocks)
594
+ }
595
+ }
596
+ return blocks
597
+ }
598
+
599
+ async readBlock(blockNumber: number, zd: MatrixZoomData) {
600
+ const idx = zd.blockIndex[blockNumber]
601
+
602
+ let block: Block | undefined
603
+ if (idx) {
604
+ const data = await this.file.read(idx.filePosition, idx.size)
605
+ const plain = inflate(new Uint8Array(data), {}) as Uint8Array
606
+ const parser = new BinaryParser(
607
+ new DataView(plain.buffer, plain.byteOffset, plain.byteLength),
608
+ )
609
+ // Total records in the block, whatever encoding follows — so every branch
610
+ // below knows its exact (or, for the dense encoding, upper-bound) size
611
+ // before it starts reading and never has to grow an array.
612
+ const nRecords = parser.getInt()
613
+ block = { blockNumber, records: this.parseBlockRecords(parser, nRecords) }
614
+ }
615
+ return block
616
+ }
617
+
618
+ /**
619
+ * Decode one block's records straight into typed arrays.
620
+ *
621
+ * Every encoding here knows its length up front, so each array is allocated
622
+ * once and filled by a write cursor. Where a filter can drop records (the
623
+ * dense encoding's empty cells) the arrays are sized to the upper bound and
624
+ * copied down to the true length at the end — blocks are cached for the life
625
+ * of the session, so it is worth one memcpy not to pin an oversized buffer.
626
+ */
627
+ private parseBlockRecords(
628
+ parser: BinaryParser,
629
+ nRecords: number,
630
+ ): ContactRecords {
631
+ if (this.version < 7) {
632
+ const bin1 = new Int32Array(nRecords)
633
+ const bin2 = new Int32Array(nRecords)
634
+ const counts = new Float32Array(nRecords)
635
+ for (let i = 0; i < nRecords; i++) {
636
+ bin1[i] = parser.getInt()
637
+ bin2[i] = parser.getInt()
638
+ counts[i] = parser.getFloat()
639
+ }
640
+ return { bin1, bin2, counts }
641
+ }
642
+
643
+ const binXOffset = parser.getInt()
644
+ const binYOffset = parser.getInt()
645
+
646
+ const useFloatContact = parser.getByte() === 1
647
+ const useIntXPos = this.version < 9 ? false : parser.getByte() === 1
648
+ const useIntYPos = this.version < 9 ? false : parser.getByte() === 1
649
+ const type = parser.getByte()
650
+
651
+ if (type === 1) {
652
+ // List-of-rows representation. The rows partition the block's records, so
653
+ // `nRecords` sizes the arrays exactly; the overflow check is a
654
+ // corrupt-file guard, not a growth path.
655
+ const bin1 = new Int32Array(nRecords)
656
+ const bin2 = new Int32Array(nRecords)
657
+ const counts = new Float32Array(nRecords)
658
+ let n = 0
659
+ const rowCount = useIntYPos ? parser.getInt() : parser.getShort()
660
+ for (let i = 0; i < rowCount; i++) {
661
+ const dy = useIntYPos ? parser.getInt() : parser.getShort()
662
+ const binY = binYOffset + dy
663
+ const colCount = useIntXPos ? parser.getInt() : parser.getShort()
664
+ if (n + colCount > nRecords) {
665
+ throw new Error(
666
+ `hic block declares ${nRecords} records but its rows hold more`,
667
+ )
668
+ }
669
+ for (let j = 0; j < colCount; j++) {
670
+ bin1[n] =
671
+ binXOffset + (useIntXPos ? parser.getInt() : parser.getShort())
672
+ bin2[n] = binY
673
+ counts[n] = useFloatContact ? parser.getFloat() : parser.getShort()
674
+ n++
675
+ }
676
+ }
677
+ return truncateRecords({ bin1, bin2, counts }, n)
678
+ }
679
+
680
+ if (type === 2) {
681
+ // Dense representation: `nPts` counts every cell of the w-wide rectangle,
682
+ // empty ones included, so it is an upper bound on the surviving records.
683
+ const nPts = parser.getInt()
684
+ const w = parser.getShort()
685
+ const bin1 = new Int32Array(nPts)
686
+ const bin2 = new Int32Array(nPts)
687
+ const counts = new Float32Array(nPts)
688
+ let n = 0
689
+ for (let i = 0; i < nPts; i++) {
690
+ // read unconditionally: the parser advances a fixed stride per cell
691
+ // whether or not the cell holds a value
692
+ const c = useFloatContact ? parser.getFloat() : parser.getShort()
693
+ // NaN (float) and Short_MIN_VALUE (int) are the "no value" markers
694
+ if (useFloatContact ? !Number.isNaN(c) : c !== Short_MIN_VALUE) {
695
+ const row = Math.floor(i / w)
696
+ bin1[n] = binXOffset + (i - row * w)
697
+ bin2[n] = binYOffset + row
698
+ counts[n] = c
699
+ n++
700
+ }
701
+ }
702
+ return truncateRecords({ bin1, bin2, counts }, n)
703
+ }
704
+
705
+ throw new Error(`Unknown block type: ${type}`)
706
+ }
707
+
708
+ async getNormalizationVector(
709
+ type: string,
710
+ chr: string,
711
+ unit: string,
712
+ binSize: number,
713
+ ) {
714
+ await this.init()
715
+
716
+ const chrIdx = this.chromosomeIndexMap[this.getFileChrName(chr)]
717
+ if (chrIdx === undefined) {
718
+ return undefined
719
+ }
720
+ const key = getNormalizationVectorKey(type, chrIdx, unit, binSize)
721
+
722
+ // Caching the promise is what keeps concurrent region pairs sharing one
723
+ // vector: a chromosome appears in every pair it takes part in, so with a
724
+ // result-only cache each of those pairs missed while the first was still in
725
+ // flight and read the header again — and then held its own
726
+ // `NormalizationVector`, whose value cache is per instance, so the whole
727
+ // vector was re-read too.
728
+ let p = this.normVectorCache.get(key)
729
+ if (!p) {
730
+ // A file with no vectors at all, or none for this (type, chr, unit,
731
+ // binsize), simply answers undefined and the caller falls back to raw
732
+ // counts. hic-straw warns to the console here; that fires once per
733
+ // chromosome per region pair per fetch, and the console is the wrong place
734
+ // for it anyway — `getContactRecords` reports the normalization it
735
+ // actually applied instead.
736
+ p = this.readNormalizationVector(key).catch((e: unknown) => {
737
+ this.normVectorCache.delete(key)
738
+ throw e
739
+ })
740
+ this.normVectorCache.set(key, p)
741
+ }
742
+ return p
743
+ }
744
+
745
+ private async readNormalizationVector(key: string) {
746
+ const idx = (await this.getNormVectorIndex())?.[key]
747
+ if (!idx) {
748
+ return undefined
749
+ }
750
+ const data = await this.file.read(idx.filePosition, 8)
751
+ const parser = new BinaryParser(new DataView(data))
752
+ const nValues = this.version < 9 ? parser.getInt() : parser.getLong()
753
+ const dataType = this.version < 9 ? DOUBLE : FLOAT
754
+ const filePosition =
755
+ this.version < 9 ? idx.filePosition + 4 : idx.filePosition + 8
756
+ return new NormalizationVector(this.file, filePosition, nValues, dataType)
757
+ }
758
+
759
+ async getNormVectorIndex() {
760
+ // Before the version gate, not after it: `version` is 0 until the header is
761
+ // parsed, so calling this first — which `getNormalizationOptions` on its own
762
+ // does — answered "no index" for every file rather than for a v5 one. A
763
+ // caller that reads metadata first never sees it.
764
+ await this.init()
765
+ if (this.version >= 6) {
766
+ // Memoize the *attempt*, not just a populated result. A legal (if
767
+ // uncommon) v8 file with no norm vectors leaves `normVectorIndex`
768
+ // undefined, and the old `!this.normVectorIndex` guard then re-ran the
769
+ // discovery on every call — two calls per region pair per fetch, each
770
+ // walking the whole normalized-expected-values section with a chain of
771
+ // sequential range reads, only to rediscover there is nothing there.
772
+ // Cleared on failure (like `init`) so a transient read error retries
773
+ // rather than caching a rejection forever.
774
+ this.normVectorIndexP ??= this.loadNormVectorIndex().catch(
775
+ (e: unknown) => {
776
+ this.normVectorIndexP = undefined
777
+ throw e
778
+ },
779
+ )
780
+ await this.normVectorIndexP
781
+ }
782
+ return this.normVectorIndex
783
+ }
784
+
785
+ private async loadNormVectorIndex() {
786
+ // If we know the position of the norm vector index, read it directly.
787
+ // This is the case for hic v9 files.
788
+ if (this.normVectorIndexPosition > 0 && this.normVectorIndexSize > 0) {
789
+ await this.readNormVectorIndex({
790
+ start: this.normVectorIndexPosition,
791
+ size: this.normVectorIndexSize,
792
+ })
793
+ } else if (this.config.nvi) {
794
+ const nviArray = decodeURIComponent(this.config.nvi).split(',')
795
+ await this.readNormVectorIndex({
796
+ start: parseInt(nviArray[0]!),
797
+ size: parseInt(nviArray[1]!),
798
+ })
799
+ } else {
800
+ try {
801
+ await this.readNormExpectedValuesAndNormVectorIndex()
802
+ } catch (e) {
803
+ // Not "expected if the file has no norm vectors" — that case never
804
+ // arrives here. hic-straw's own IO threw a 416 for a read past EOF and
805
+ // this caught it; `generic-filehandle2` deliberately turns a 416 into an
806
+ // empty read so callers detect EOF from a short buffer instead of
807
+ // needing a stat, and LocalFile/BlobFile clamp for the same reason. So
808
+ // the read side owns that case now, and anything reaching here is a real
809
+ // failure worth printing.
810
+ console.error(e)
811
+ }
812
+ }
813
+ }
814
+
815
+ async getNormalizationOptions() {
816
+ // Normalization options are computed as a side effect of loading the
817
+ // index. A bit ugly but alternatives are worse.
818
+ await this.getNormVectorIndex()
819
+ return this.normalizationTypes
820
+ }
821
+
822
+ async readNormVectorIndex(range: { start: number; size: number }) {
823
+ await this.init()
824
+ const data = await this.file.read(range.start, range.size)
825
+ const binaryParser = new BinaryParser(new DataView(data))
826
+ this.normVectorIndex = {}
827
+ let nEntries = binaryParser.getInt()
828
+ while (nEntries-- > 0) {
829
+ this.parseNormVectorEntry(binaryParser)
830
+ }
831
+ return this.normVectorIndex
832
+ }
833
+
834
+ // Used when the position of the norm vector index is unknown: read through
835
+ // the expected values to find the index.
836
+ async readNormExpectedValuesAndNormVectorIndex() {
837
+ await this.init()
838
+ if (this.normExpectedValueVectorsPosition !== undefined) {
839
+ const nviStart = await this.skipExpectedValues(
840
+ this.normExpectedValueVectorsPosition,
841
+ )
842
+ let byteCount = INT
843
+
844
+ let data = await this.file.read(nviStart, INT)
845
+ // Possible if there are no norm vectors. A legal v8 file, though uncommon.
846
+ if (data.byteLength !== 0) {
847
+ const binaryParser = new BinaryParser(new DataView(data))
848
+ const nEntries = binaryParser.getInt()
849
+ const sizeEstimate = nEntries * 30
850
+ data = await this.file.read(nviStart + byteCount, sizeEstimate)
851
+ this.normVectorIndex = {}
852
+
853
+ const processEntries = async (remaining: number, buf: ArrayBuffer) => {
854
+ const parser = new BinaryParser(new DataView(buf))
855
+ let n = remaining
856
+ while (n-- > 0) {
857
+ if (parser.available() < 100) {
858
+ n++ // Reset counter as entry is not processed
859
+ byteCount += parser.position
860
+ const est = Math.max(1000, n * 30)
861
+ const more = await this.file.read(nviStart + byteCount, est)
862
+ await processEntries(n, more)
863
+ return
864
+ }
865
+ this.parseNormVectorEntry(parser)
866
+ }
867
+ byteCount += parser.position
868
+ }
869
+
870
+ await processEntries(nEntries, data)
871
+ this.config.nvi = `${nviStart},${byteCount}`
872
+ }
873
+ }
874
+ }
875
+
876
+ // Used when the position of the norm vector index is unknown: skip the
877
+ // normalized expected values to find the index.
878
+ async skipExpectedValues(start: number) {
879
+ const version = this.version
880
+ const file = new BufferedFile({ file: this.file, size: 256000 })
881
+ const data = await file.read(start, INT)
882
+ // A file with no normalization at all ends where this section would start,
883
+ // so the count this is about to read is past EOF. That is the same "no norm
884
+ // vectors" case `readNormExpectedValuesAndNormVectorIndex` guards its own
885
+ // read for, and it has to be caught HERE too, because this runs first:
886
+ // a v9 file that records no norm-vector-index position takes the v8
887
+ // discovery path, and that path starts by skipping a section that isn't
888
+ // there. Answering `start` leaves the caller's guard to do the rest.
889
+ if (data.byteLength < INT) {
890
+ return start
891
+ }
892
+ const binaryParser = new BinaryParser(new DataView(data))
893
+ const nEntries = binaryParser.getInt() // Total # of expected value chunks
894
+
895
+ const parseNext = async (
896
+ chunkStart: number,
897
+ remaining: number,
898
+ ): Promise<number> => {
899
+ let chunkSize = 0
900
+ const p0 = chunkStart
901
+
902
+ let buf = await file.read(chunkStart, 500)
903
+ let parser = new BinaryParser(new DataView(buf))
904
+ parser.getString() // type
905
+ parser.getString() // unit
906
+ parser.getInt() // binSize
907
+ const nValues = version < 9 ? parser.getInt() : parser.getLong()
908
+ chunkSize += parser.position + nValues * (version < 9 ? DOUBLE : FLOAT)
909
+
910
+ buf = await file.read(chunkStart + chunkSize, INT)
911
+ parser = new BinaryParser(new DataView(buf))
912
+ const nChrScaleFactors = parser.getInt()
913
+ chunkSize +=
914
+ INT + nChrScaleFactors * (INT + (version < 9 ? DOUBLE : FLOAT))
915
+
916
+ return remaining - 1 === 0
917
+ ? p0 + chunkSize
918
+ : parseNext(p0 + chunkSize, remaining - 1)
919
+ }
920
+
921
+ return nEntries === 0 ? start + INT : parseNext(start + INT, nEntries)
922
+ }
923
+
924
+ parseNormVectorEntry(binaryParser: BinaryParser) {
925
+ const type = binaryParser.getString() // 15
926
+ const chrIdx = binaryParser.getInt() // 4
927
+ const unit = binaryParser.getString() // 3
928
+ const binSize = binaryParser.getInt() // 4
929
+ const filePosition = binaryParser.getLong() // 8
930
+ const sizeInBytes =
931
+ this.version < 9 ? binaryParser.getInt() : binaryParser.getLong() // 4:8
932
+ const key = `${type}_${chrIdx}_${unit}_${binSize}`
933
+
934
+ if (!this.normalizationTypes.includes(type)) {
935
+ this.normalizationTypes.push(type)
936
+ }
937
+ this.normVectorIndex![key] = { filePosition, size: sizeInBytes }
938
+ }
939
+
940
+ getFileChrName(chrAlias: string) {
941
+ return this.chrAliasTable[chrAlias] ?? chrAlias
942
+ }
943
+ }