jbrowse-plugin-graphgenomeviewer 7.4.2 → 7.4.4

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 (122) hide show
  1. package/README.md +53 -24
  2. package/dist/chunks/{AddTrackWorkflow-LK25EC3H.js → AddTrackWorkflow-NLSZYK56.js} +2 -2
  3. package/dist/chunks/{GbzBaseSyntenyAdapter-BVLBK4OO.js → GbzBaseSyntenyAdapter-A5MNPCKX.js} +2 -2
  4. package/dist/chunks/{GbzBaseSyntenyAdapter-BVLBK4OO.js.map → GbzBaseSyntenyAdapter-A5MNPCKX.js.map} +1 -1
  5. package/dist/chunks/{GraphGenomeView-UCOAMRF7.js → GraphGenomeView-SEAPDRXS.js} +2 -2
  6. package/dist/chunks/{GraphTrackSettingsDialog-MR7X75EY.js → GraphTrackSettingsDialog-EVIIGC5G.js} +2 -2
  7. package/dist/chunks/{HighlightColorDialog-ZBAZDYIR.js → HighlightColorDialog-QAYWLO5I.js} +2 -2
  8. package/dist/chunks/{LinearGraphDisplay-AJ5OZN27.js → LinearGraphDisplay-ZC4BQ27J.js} +2 -2
  9. package/dist/chunks/MinigraphBubbleAdapter-3QM4XV47.js +2 -0
  10. package/dist/chunks/{MinigraphBubbleAdapter-PEQK5NII.js.map → MinigraphBubbleAdapter-3QM4XV47.js.map} +1 -1
  11. package/dist/chunks/{RgfaTabixAdapter-Q63YLPK4.js → RgfaTabixAdapter-XSOIEWBK.js} +2 -2
  12. package/dist/chunks/WalkFormsAdapter-FT2SW4H6.js +2 -0
  13. package/dist/chunks/WalkFormsAdapter-FT2SW4H6.js.map +7 -0
  14. package/dist/chunks/{WalkTabixSyntenyAdapter-N5PFI4LS.js → WalkTabixSyntenyAdapter-3WRVNDLB.js} +2 -2
  15. package/dist/chunks/{chunk-2XZYJLCE.js → chunk-2KVE7YAS.js} +1 -1
  16. package/dist/chunks/{chunk-2XZYJLCE.js.map → chunk-2KVE7YAS.js.map} +1 -1
  17. package/dist/chunks/{chunk-RCXOOBLX.js → chunk-6SWXMRAV.js} +1 -1
  18. package/dist/chunks/{chunk-RCXOOBLX.js.map → chunk-6SWXMRAV.js.map} +1 -1
  19. package/dist/chunks/{chunk-WY5W5X62.js → chunk-6VNU7SD4.js} +2 -2
  20. package/dist/chunks/{chunk-WY5W5X62.js.map → chunk-6VNU7SD4.js.map} +1 -1
  21. package/dist/chunks/{chunk-DXNE2CFX.js → chunk-6WUWQX2P.js} +2 -2
  22. package/dist/chunks/{chunk-DXNE2CFX.js.map → chunk-6WUWQX2P.js.map} +1 -1
  23. package/dist/chunks/{chunk-ZG2CQXOW.js → chunk-7RJMG6I2.js} +2 -2
  24. package/dist/chunks/{chunk-ZG2CQXOW.js.map → chunk-7RJMG6I2.js.map} +1 -1
  25. package/dist/chunks/chunk-FTXDTU27.js +3 -0
  26. package/dist/chunks/chunk-FTXDTU27.js.map +7 -0
  27. package/dist/chunks/chunk-G53BI7VK.js +2 -0
  28. package/dist/chunks/{chunk-MEMUEG5Z.js.map → chunk-G53BI7VK.js.map} +4 -4
  29. package/dist/chunks/chunk-GRTN25B6.js +3 -0
  30. package/dist/chunks/chunk-GRTN25B6.js.map +7 -0
  31. package/dist/chunks/{chunk-62AHPP6H.js → chunk-HCDKP3IP.js} +2 -2
  32. package/dist/chunks/{chunk-62AHPP6H.js.map → chunk-HCDKP3IP.js.map} +1 -1
  33. package/dist/chunks/{chunk-HI3YK2SO.js → chunk-HF4H6LQI.js} +2 -2
  34. package/dist/chunks/{chunk-MVVXRZXV.js → chunk-JRJHXLGP.js} +2 -2
  35. package/dist/chunks/{chunk-MVVXRZXV.js.map → chunk-JRJHXLGP.js.map} +2 -2
  36. package/dist/chunks/{chunk-LFTZNUXB.js → chunk-KIYRMZWP.js} +2 -2
  37. package/dist/chunks/{chunk-LFTZNUXB.js.map → chunk-KIYRMZWP.js.map} +1 -1
  38. package/dist/chunks/{chunk-WJGCVNGH.js → chunk-LE43I4PT.js} +2 -2
  39. package/dist/chunks/{chunk-U5PIXAF2.js → chunk-LE7FZM6Y.js} +2 -2
  40. package/dist/chunks/{chunk-U5PIXAF2.js.map → chunk-LE7FZM6Y.js.map} +1 -1
  41. package/dist/chunks/chunk-MUONTMYZ.js +2 -0
  42. package/dist/chunks/chunk-MUONTMYZ.js.map +7 -0
  43. package/dist/chunks/{chunk-SFYSQ52W.js → chunk-OJWBLPPH.js} +2 -2
  44. package/dist/chunks/{chunk-JSRGX4TC.js → chunk-P3FBI5ML.js} +1 -1
  45. package/dist/chunks/{chunk-JSRGX4TC.js.map → chunk-P3FBI5ML.js.map} +1 -1
  46. package/dist/chunks/chunk-QV7SSOMJ.js +2 -0
  47. package/dist/chunks/chunk-S333HOG5.js +2 -0
  48. package/dist/chunks/chunk-S333HOG5.js.map +7 -0
  49. package/dist/chunks/{chunk-QJ43NDYS.js → chunk-TYV6UXJA.js} +2 -2
  50. package/dist/chunks/chunk-VZQ6VOUP.js +2 -0
  51. package/dist/chunks/chunk-VZQ6VOUP.js.map +7 -0
  52. package/dist/chunks/chunk-WLRRI35A.js +3 -0
  53. package/dist/chunks/chunk-WLRRI35A.js.map +7 -0
  54. package/dist/chunks/{chunk-PUREF4BM.js → chunk-XXBE4IFZ.js} +2 -2
  55. package/dist/chunks/{chunk-PUREF4BM.js.map → chunk-XXBE4IFZ.js.map} +1 -1
  56. package/dist/chunks/{renderSvg-Z7YIXVQ5.js → renderSvg-ZIUOPWJQ.js} +2 -2
  57. package/dist/jbrowse-plugin-graphgenomeviewer.esm.js +3 -4
  58. package/dist/jbrowse-plugin-graphgenomeviewer.esm.js.map +4 -4
  59. package/package.json +3 -3
  60. package/src/GraphGenomeView/model.test.ts +2 -109
  61. package/src/GraphGenomeView/pane/fitViews.ts +0 -109
  62. package/src/GraphGenomeView/pane/graphViews.ts +0 -4
  63. package/src/GraphGenomeView/pane/launchActions.ts +0 -22
  64. package/src/GraphGenomeView/pane/loadActions.ts +1 -1
  65. package/src/GraphGenomeView/pane/paneBase.ts +0 -7
  66. package/src/GraphGenomeView/viewModel.ts +0 -3
  67. package/src/RgfaTabixAdapter/configSchema.ts +3 -3
  68. package/src/RgfaTabixAdapter/walkFiles.test.ts +6 -6
  69. package/src/WalkFormsAdapter/WalkFormsAdapter.test.ts +204 -0
  70. package/src/WalkFormsAdapter/WalkFormsAdapter.ts +232 -0
  71. package/src/WalkFormsAdapter/configSchema.ts +127 -0
  72. package/src/WalkFormsAdapter/formRows.test.ts +258 -0
  73. package/src/WalkFormsAdapter/formRows.ts +231 -0
  74. package/src/WalkFormsAdapter/formsMenu.test.ts +237 -0
  75. package/src/WalkFormsAdapter/formsMenu.ts +202 -0
  76. package/src/WalkFormsAdapter/index.ts +21 -0
  77. package/src/WalkFormsAdapter/pinned.ts +33 -0
  78. package/src/WalkTabixSyntenyAdapter/WalkTabixSyntenyAdapter.test.ts +5 -5
  79. package/src/WalkTabixSyntenyAdapter/configSchema.ts +2 -2
  80. package/src/index.ts +4 -0
  81. package/src/panSNAliases/panSNAliases.test.ts +2 -2
  82. package/src/version.ts +1 -1
  83. package/dist/chunks/MinigraphBubbleAdapter-PEQK5NII.js +0 -2
  84. package/dist/chunks/chunk-55DIX25J.js +0 -3
  85. package/dist/chunks/chunk-55DIX25J.js.map +0 -7
  86. package/dist/chunks/chunk-AWGMTI73.js +0 -3
  87. package/dist/chunks/chunk-AWGMTI73.js.map +0 -7
  88. package/dist/chunks/chunk-FWVXBJ64.js +0 -3
  89. package/dist/chunks/chunk-FWVXBJ64.js.map +0 -7
  90. package/dist/chunks/chunk-J3AXN7JQ.js +0 -2
  91. package/dist/chunks/chunk-MEMUEG5Z.js +0 -2
  92. package/dist/chunks/chunk-Q3BXNKEL.js +0 -2
  93. package/dist/chunks/chunk-Q3BXNKEL.js.map +0 -7
  94. package/dist/chunks/chunk-S75MFZDE.js +0 -2
  95. package/dist/chunks/chunk-S75MFZDE.js.map +0 -7
  96. /package/dist/chunks/{AddTrackWorkflow-LK25EC3H.js.map → AddTrackWorkflow-NLSZYK56.js.map} +0 -0
  97. /package/dist/chunks/{GraphGenomeView-UCOAMRF7.js.map → GraphGenomeView-SEAPDRXS.js.map} +0 -0
  98. /package/dist/chunks/{GraphTrackSettingsDialog-MR7X75EY.js.map → GraphTrackSettingsDialog-EVIIGC5G.js.map} +0 -0
  99. /package/dist/chunks/{HighlightColorDialog-ZBAZDYIR.js.map → HighlightColorDialog-QAYWLO5I.js.map} +0 -0
  100. /package/dist/chunks/{LinearGraphDisplay-AJ5OZN27.js.map → LinearGraphDisplay-ZC4BQ27J.js.map} +0 -0
  101. /package/dist/chunks/{RgfaTabixAdapter-Q63YLPK4.js.map → RgfaTabixAdapter-XSOIEWBK.js.map} +0 -0
  102. /package/dist/chunks/{WalkTabixSyntenyAdapter-N5PFI4LS.js.map → WalkTabixSyntenyAdapter-3WRVNDLB.js.map} +0 -0
  103. /package/dist/chunks/{chunk-HI3YK2SO.js.map → chunk-HF4H6LQI.js.map} +0 -0
  104. /package/dist/chunks/{chunk-WJGCVNGH.js.map → chunk-LE43I4PT.js.map} +0 -0
  105. /package/dist/chunks/{chunk-SFYSQ52W.js.map → chunk-OJWBLPPH.js.map} +0 -0
  106. /package/dist/chunks/{chunk-J3AXN7JQ.js.map → chunk-QV7SSOMJ.js.map} +0 -0
  107. /package/dist/chunks/{chunk-QJ43NDYS.js.map → chunk-TYV6UXJA.js.map} +0 -0
  108. /package/dist/chunks/{renderSvg-Z7YIXVQ5.js.map → renderSvg-ZIUOPWJQ.js.map} +0 -0
  109. /package/src/RgfaTabixAdapter/test_data/{walks4_follow.GRCh38.walks.bed.gz → walks5_follow.GRCh38.walks.bed.gz} +0 -0
  110. /package/src/RgfaTabixAdapter/test_data/{walks4_follow.GRCh38.walks.bed.gz.tbi → walks5_follow.GRCh38.walks.bed.gz.tbi} +0 -0
  111. /package/src/RgfaTabixAdapter/test_data/{walks4_header.GRCh38.walks.bed.gz → walks5_header.GRCh38.walks.bed.gz} +0 -0
  112. /package/src/RgfaTabixAdapter/test_data/{walks4_header.GRCh38.walks.bed.gz.tbi → walks5_header.GRCh38.walks.bed.gz.tbi} +0 -0
  113. /package/src/RgfaTabixAdapter/test_data/{walks4_inversion.GRCh38.walks.bed.gz → walks5_inversion.GRCh38.walks.bed.gz} +0 -0
  114. /package/src/RgfaTabixAdapter/test_data/{walks4_inversion.GRCh38.walks.bed.gz.tbi → walks5_inversion.GRCh38.walks.bed.gz.tbi} +0 -0
  115. /package/src/RgfaTabixAdapter/test_data/{walks4_lanepairs.GRCh38.walks.bed.gz → walks5_lanepairs.GRCh38.walks.bed.gz} +0 -0
  116. /package/src/RgfaTabixAdapter/test_data/{walks4_lanepairs.GRCh38.walks.bed.gz.tbi → walks5_lanepairs.GRCh38.walks.bed.gz.tbi} +0 -0
  117. /package/src/RgfaTabixAdapter/test_data/{walks4_maxnode.GRCh38.walks.bed.gz → walks5_maxnode.GRCh38.walks.bed.gz} +0 -0
  118. /package/src/RgfaTabixAdapter/test_data/{walks4_maxnode.GRCh38.walks.bed.gz.tbi → walks5_maxnode.GRCh38.walks.bed.gz.tbi} +0 -0
  119. /package/src/RgfaTabixAdapter/test_data/{walks4_rejoin.GRCh38.walks.bed.gz → walks5_rejoin.GRCh38.walks.bed.gz} +0 -0
  120. /package/src/RgfaTabixAdapter/test_data/{walks4_rejoin.GRCh38.walks.bed.gz.tbi → walks5_rejoin.GRCh38.walks.bed.gz.tbi} +0 -0
  121. /package/src/RgfaTabixAdapter/test_data/{walks4_twopart.GRCh38.walks.bed.gz → walks5_twopart.GRCh38.walks.bed.gz} +0 -0
  122. /package/src/RgfaTabixAdapter/test_data/{walks4_twopart.GRCh38.walks.bed.gz.tbi → walks5_twopart.GRCh38.walks.bed.gz.tbi} +0 -0
@@ -0,0 +1,232 @@
1
+ import { BaseFeatureDataAdapter } from '@jbrowse/core/data_adapters/BaseAdapter'
2
+ import { SimpleFeature, updateStatus } from '@jbrowse/core/util'
3
+ import { ObservableCreate } from '@jbrowse/core/util/rxjs'
4
+ import {
5
+ ExpansionTooLargeError,
6
+ RegionTooLargeError,
7
+ WalkReader,
8
+ bpString,
9
+ formsCut,
10
+ panSNContig,
11
+ panSNMatchesPrefix,
12
+ structuralForms,
13
+ } from '@jbrowse/graph-walks'
14
+
15
+ import {
16
+ formSources,
17
+ formVariants,
18
+ formWarnings,
19
+ referenceGenotypes,
20
+ } from './formRows.ts'
21
+ import { floorOf, pinnedLocus, pinnedOf, warningPrefix } from './pinned.ts'
22
+ import { openTabixSlot } from '../panSNTabix.ts'
23
+
24
+ import type { WalkFormsAdapterConfig } from './configSchema.ts'
25
+ import type { Pinned } from './pinned.ts'
26
+ import type { BaseOptions } from '@jbrowse/core/data_adapters/BaseAdapter'
27
+ import type { Feature } from '@jbrowse/core/util'
28
+ import type { Region } from '@jbrowse/core/util/types'
29
+ import type { HaplotypeDepartures, StructuralForms } from '@jbrowse/graph-walks'
30
+
31
+ const megabytes = (bytes: number) => `${(bytes / 1_000_000).toFixed(1)} MB`
32
+
33
+ const millions = (n: number) => `${(n / 1_000_000).toFixed(1)} M`
34
+
35
+ // A zoom-in notice would mislead, since the view's zoom does not move the
36
+ // pinned window
37
+ function pinnedTooLarge(error: unknown, pinned: Pinned) {
38
+ if (!(error instanceof RegionTooLargeError)) {
39
+ return error
40
+ }
41
+ const window = pinnedLocus(pinned)
42
+ if (error instanceof ExpansionTooLargeError) {
43
+ return new Error(
44
+ `The pinned window ${window} is too large to read every walk in full for structural forms: its walks take ${millions(error.steps)} steps, over the ${millions(error.limit)} step budget, and about ${bpString(error.fitsBp)} of it fits. Pin a narrower window`,
45
+ )
46
+ }
47
+ const budget = `walkByteBudget, ${megabytes(error.limit)}`
48
+ return new Error(
49
+ `The pinned window ${window} is too large to cut for structural forms: ${
50
+ error.zoomHelps
51
+ ? `about ${bpString(error.fitsBp)} of it fits in ${budget}. Pin a narrower window or raise walkByteBudget`
52
+ : `its cut fetches ${megabytes(error.bytes ?? 0)}, over ${budget}. Raise walkByteBudget`
53
+ }`,
54
+ )
55
+ }
56
+
57
+ // The departures of each pinned window, shared across adapter instances:
58
+ // dataAdapterCache keys an adapter on its whole config, so a new floor or
59
+ // exclude list makes a new adapter, which regroups these without a new cut.
60
+ // A cut at the MHC takes 8 s.
61
+ const CUTS_KEPT = 8
62
+ const cuts = new Map<string, Promise<HaplotypeDepartures[]>>()
63
+
64
+ function cachedCut(key: string, cut: () => Promise<HaplotypeDepartures[]>) {
65
+ const kept = cuts.get(key)
66
+ const read = kept ?? cut()
67
+ cuts.delete(key)
68
+ cuts.set(key, read)
69
+ if (!kept) {
70
+ read.catch(() => {
71
+ if (cuts.get(key) === read) {
72
+ cuts.delete(key)
73
+ }
74
+ })
75
+ for (const old of cuts.keys()) {
76
+ if (cuts.size <= CUTS_KEPT) {
77
+ break
78
+ }
79
+ cuts.delete(old)
80
+ }
81
+ }
82
+ return read
83
+ }
84
+
85
+ export default class WalkFormsAdapter extends BaseFeatureDataAdapter<WalkFormsAdapterConfig> {
86
+ public static capabilities = ['getFeatures', 'getRefNames']
87
+
88
+ private readonly reader = new WalkReader(
89
+ openTabixSlot(this, ['walksLocation'], ['walksIndex']),
90
+ )
91
+
92
+ private formsRead: Promise<StructuralForms> | undefined
93
+
94
+ private async referenceSample(opts: BaseOptions) {
95
+ const { references } = await this.reader.header(opts)
96
+ return references[0] ?? ''
97
+ }
98
+
99
+ // The reference sample's contigs, as the assembly names them
100
+ async getRefNames(opts: BaseOptions = {}) {
101
+ const names = await this.reader.referenceSequences(opts)
102
+ const sample = await this.referenceSample(opts)
103
+ const contigs = names
104
+ .filter(n => panSNMatchesPrefix(n, sample))
105
+ .map(n => panSNContig(n))
106
+ return contigs.length > 0 ? [...new Set(contigs)] : names
107
+ }
108
+
109
+ private pinned() {
110
+ const pinned = pinnedOf(this.getConf('pinned'))
111
+ if (!pinned) {
112
+ throw new Error(
113
+ 'WalkFormsAdapter needs `pinned`, the window its forms come from: { refName, start, end }',
114
+ )
115
+ }
116
+ return pinned
117
+ }
118
+
119
+ // Read once per adapter, and so once per floor and exclude list. The cut
120
+ // takes no caller's signal, since every later caller shares it. A partial
121
+ // expansion would leave some walks as net changes over a whole chunk, so a
122
+ // window too large to read them all errors instead.
123
+ private forms(opts: BaseOptions) {
124
+ if (!this.formsRead) {
125
+ const pinned = this.pinned()
126
+ const key = `${JSON.stringify(this.getConf('walksLocation'))}\t${pinned.refName}:${pinned.start}-${pinned.end}`
127
+ const byteBudget = this.getConf('walkByteBudget')
128
+ const read = cachedCut(`${key}\t${byteBudget}`, () =>
129
+ updateStatus('Cutting walks', opts.statusCallback, () =>
130
+ formsCut(this.reader, pinned, { byteBudget, partial: false }).catch(
131
+ (error: unknown) => {
132
+ throw pinnedTooLarge(error, pinned)
133
+ },
134
+ ),
135
+ ),
136
+ ).then(departures =>
137
+ structuralForms(departures, {
138
+ window: pinned,
139
+ floorBp: floorOf(this.getConf('floorBp')),
140
+ exclude: this.getConf('exclude'),
141
+ }),
142
+ )
143
+ read.catch(() => {
144
+ if (this.formsRead === read) {
145
+ this.formsRead = undefined
146
+ }
147
+ })
148
+ this.formsRead = read
149
+ }
150
+ return this.formsRead
151
+ }
152
+
153
+ private maxForms() {
154
+ return this.getConf('maxForms')
155
+ }
156
+
157
+ /**
158
+ * One source per form up to maxForms, which core's multi-sample variant
159
+ * display draws as a row, and warnings naming the haplotypes left
160
+ * abridged, unplaced or in the "other forms" row
161
+ */
162
+ async getSourcesAndWarnings(opts: BaseOptions = {}) {
163
+ const result = await this.forms(opts)
164
+ const prefix = warningPrefix(this.pinned())
165
+ return {
166
+ sources: formSources(result, this.maxForms()),
167
+ warnings: formWarnings(result, this.maxForms()).map(w => prefix + w),
168
+ }
169
+ }
170
+
171
+ async getSources(_regions: Region[], opts: BaseOptions = {}) {
172
+ return formSources(await this.forms(opts), this.maxForms())
173
+ }
174
+
175
+ private async onPinnedSequence(refName: string, opts: BaseOptions) {
176
+ const sample = await this.referenceSample(opts)
177
+ const [query, pinned] = await Promise.all([
178
+ this.reader.sequenceFor(sample, refName, opts),
179
+ this.reader.sequenceFor(sample, this.pinned().refName, opts),
180
+ ])
181
+ return query !== undefined && query === pinned
182
+ }
183
+
184
+ getFeatures(query: Region, opts: BaseOptions = {}) {
185
+ return ObservableCreate<Feature>(async observer => {
186
+ const result = await this.forms(opts)
187
+ const variants = (await this.onPinnedSequence(query.refName, opts))
188
+ ? formVariants(result, this.maxForms()).filter(
189
+ v => v.start < query.end && v.end > query.start,
190
+ )
191
+ : []
192
+ for (const v of variants) {
193
+ observer.next(
194
+ new SimpleFeature({
195
+ uniqueId: `forms-${result.floorBp}-${v.id}`,
196
+ refName: query.refName,
197
+ start: v.start,
198
+ end: v.end,
199
+ name: v.name,
200
+ description: v.description,
201
+ type: v.type,
202
+ svType: v.svType,
203
+ consequence: v.consequence,
204
+ REF: 'N',
205
+ ALT: [v.alt],
206
+ INFO: { SVTYPE: [v.svType], SVLEN: [v.mark.delta] },
207
+ mark: v.mark,
208
+ genotypes: v.genotypes,
209
+ }),
210
+ )
211
+ }
212
+ // Core before 5.0.0-beta.14 (1845c280a9) sends no payload for a block
213
+ // with no records, which leaves the display on "Loading". The reference
214
+ // placeholder draws nothing; drop it once the host floor reaches beta.14
215
+ if (variants.length === 0) {
216
+ const start = Math.floor(query.start)
217
+ observer.next(
218
+ new SimpleFeature({
219
+ uniqueId: `forms-reference-${query.refName}-${start}`,
220
+ refName: query.refName,
221
+ start,
222
+ end: start + 1,
223
+ REF: 'N',
224
+ ALT: ['.'],
225
+ genotypes: referenceGenotypes(result, this.maxForms()),
226
+ }),
227
+ )
228
+ }
229
+ observer.complete()
230
+ }, opts.signal)
231
+ }
232
+ }
@@ -0,0 +1,127 @@
1
+ import { ConfigurationSchema } from '@jbrowse/core/configuration'
2
+
3
+ import { DEFAULT_MAX_FORMS } from './formRows.ts'
4
+ import {
5
+ fileLocationSlot,
6
+ tabixIndexSchema,
7
+ walksLocations,
8
+ } from '../panSNTabix.ts'
9
+
10
+ import type { Instance } from '@jbrowse/mobx-state-tree'
11
+
12
+ /**
13
+ * #config WalkFormsAdapter
14
+ * #trackType VariantTrack
15
+ * #fileFormat variant | Walk-file structural forms | Built by `gfa-to-tabix --walks`; one row per group of haplotypes making the same structural changes
16
+ * Groups the haplotypes of a
17
+ * [gfa-to-tabix](https://github.com/GMOD/gfa-to-tabix) `--walks` file by the
18
+ * structural changes their walks make over one pinned window, through
19
+ * @jbrowse/graph-walks' `formsCut` and `structuralForms`. Each group is a
20
+ * form, which `LinearMultiSampleVariantDisplay` draws as one row, and each
21
+ * change a variant whose genotype is `1` in the forms that make it.
22
+ *
23
+ * - The adapter cuts the pinned window once, whatever the view shows; outside
24
+ * it the rows stay and draw empty. A window whose abridged walks are too
25
+ * many to read in full errors instead
26
+ * - The largest `maxForms` forms get a row each, labelled by share and largest
27
+ * change ("66% · del 18.4kb"); the rest share one empty "other forms" row
28
+ * - A variant's `consequence` is `loss`, `gain`, `shift`, `inversion` or
29
+ * `net`, for colouring the cells; `svType` and `type` follow core's names
30
+ * - `walksUri` takes the prefix of one reference's walk file and resolves
31
+ * `<walksUri>.walks.bed.gz` and its `.tbi`, as the other walk adapters do
32
+ *
33
+ * #example
34
+ * ```js
35
+ * {
36
+ * type: 'WalkFormsAdapter',
37
+ * walksUri: 'https://jbrowse.org/demos/hprc/hprc-v2.1-mc-grch38-f5.GRCh38',
38
+ * pinned: { refName: 'chr1', start: 109680000, end: 109715000 },
39
+ * exclude: ['CHM13'],
40
+ * }
41
+ * ```
42
+ */
43
+ const WalkFormsAdapter = ConfigurationSchema(
44
+ 'WalkFormsAdapter',
45
+ {
46
+ /**
47
+ * #slot
48
+ * a `gfa-to-tabix --walks` file in walk format 5
49
+ */
50
+ walksLocation: fileLocationSlot('/path/to/graph.walks.bed.gz'),
51
+ /**
52
+ * #slot
53
+ */
54
+ walksIndex: tabixIndexSchema('WalkFormsWalksIndex'),
55
+ /**
56
+ * #slot
57
+ * The window the forms come from, `{ refName, start, end }`, 0-based and
58
+ * half-open. `refName` is the contig as the walk file's reference sample
59
+ * names it (`chr1`), or its full PanSN name (`GRCh38#0#chr1`).
60
+ */
61
+ pinned: {
62
+ type: 'frozen',
63
+ defaultValue: {},
64
+ },
65
+ /**
66
+ * #slot
67
+ * The smallest change that tells two forms apart: `auto`, which follows
68
+ * the pinned window's width (50 bp under 25 kb, 1 kb to 300 kb, 10 kb
69
+ * past it), or 50, 1000 or 10000. A change of floor regroups the same
70
+ * cut.
71
+ */
72
+ floorBp: {
73
+ type: 'frozen',
74
+ defaultValue: 'auto',
75
+ },
76
+ /**
77
+ * #slot
78
+ * Samples or `sample#haplotype`s left out of every form, such as a second
79
+ * reference the walk file carries as a haplotype (`CHM13` in HPRC's
80
+ * GRCh38 file)
81
+ */
82
+ exclude: {
83
+ type: 'stringArray',
84
+ defaultValue: [],
85
+ },
86
+ /**
87
+ * #slot
88
+ * The most forms drawn as rows of their own, largest first. The rest
89
+ * share one empty "other forms" row, and a warning counts them.
90
+ */
91
+ maxForms: {
92
+ type: 'integer',
93
+ defaultValue: DEFAULT_MAX_FORMS,
94
+ },
95
+ /**
96
+ * #slot
97
+ * The most compressed bytes the forms cut fetches from the walk file, as
98
+ * its Tabix index estimates it before any row is read. A pinned window
99
+ * over it shows an error instead. 1 MB holds about 1 Mb of the MHC, whose
100
+ * cut takes 13 s and 0.8 GB.
101
+ */
102
+ walkByteBudget: {
103
+ type: 'integer',
104
+ defaultValue: 1_000_000,
105
+ },
106
+ },
107
+ {
108
+ explicitlyTyped: true,
109
+
110
+ /**
111
+ * #preProcessSnapshot
112
+ * preprocessor to allow the minimal config
113
+ * ```json
114
+ * {
115
+ * "type": "WalkFormsAdapter",
116
+ * "walksUri": "chr22.GRCh38",
117
+ * "pinned": { "refName": "chr22", "start": 0, "end": 50000 }
118
+ * }
119
+ * ```
120
+ */
121
+ preProcessSnapshot: walksLocations,
122
+ },
123
+ )
124
+
125
+ export type WalkFormsAdapterConfig = Instance<typeof WalkFormsAdapter>
126
+
127
+ export default WalkFormsAdapter
@@ -0,0 +1,258 @@
1
+ import { structuralForms } from '@jbrowse/graph-walks'
2
+
3
+ import {
4
+ OTHER_FORMS,
5
+ formName,
6
+ formSources,
7
+ formVariants,
8
+ formWarnings,
9
+ referenceGenotypes,
10
+ } from './formRows.ts'
11
+
12
+ import type { Departure, HaplotypeDepartures } from '@jbrowse/graph-walks'
13
+
14
+ const window = { start: 0, end: 24_000 }
15
+
16
+ const departure = (
17
+ refStart: number,
18
+ refEnd: number,
19
+ altLength: number,
20
+ extra: Partial<Departure> = {},
21
+ ): Departure => ({
22
+ refStart,
23
+ refEnd,
24
+ altLength,
25
+ invertedBp: 0,
26
+ repeatedBp: 0,
27
+ ...extra,
28
+ })
29
+
30
+ const haplotype = (
31
+ name: string,
32
+ departures: Departure[],
33
+ covered: [number, number][] = [[0, 30_000]],
34
+ ): HaplotypeDepartures => ({
35
+ haplotype: name,
36
+ covered,
37
+ departures,
38
+ open: false,
39
+ })
40
+
41
+ const deletion = departure(1000, 3000, 0)
42
+ const insertion = departure(5000, 5000, 800)
43
+
44
+ // One haplotype or a few per mark kind, so every state is common and every
45
+ // site tells forms apart
46
+ const departures = [
47
+ haplotype('A#1', [deletion, insertion]),
48
+ haplotype('A#2', [deletion, insertion]),
49
+ haplotype('B#1', [
50
+ deletion,
51
+ departure(10_000, 11_000, 1000, { invertedBp: 1000 }),
52
+ ]),
53
+ haplotype('B#2', [deletion]),
54
+ haplotype('C#1', [insertion]),
55
+ haplotype('C#2', [
56
+ departure(8000, 8000, 1500, { repeatedBp: 1500, copied: [6500, 8000] }),
57
+ ]),
58
+ haplotype('D#1', [
59
+ departure(8000, 8000, 1500, { repeatedBp: 1500, copied: [6500, 8000] }),
60
+ ]),
61
+ haplotype('D#2', [departure(10_000, 11_000, 1000, { invertedBp: 1000 })]),
62
+ haplotype('E#1', [departure(13_000, 13_600, 2000)]),
63
+ haplotype('E#2', [departure(15_000, 17_000, 500)]),
64
+ haplotype('F#1', [
65
+ departure(18_000, 19_500, 0),
66
+ departure(19_600, 19_600, 1480),
67
+ ]),
68
+ haplotype('F#2', [departure(21_000, 21_500, 3000, { abridged: true })]),
69
+ haplotype('G#1', []),
70
+ haplotype('G#2', [], [[0, 12_000]]),
71
+ haplotype('H#1', [], []),
72
+ ]
73
+
74
+ const result = structuralForms(departures, { window })
75
+ const variants = formVariants(result, Infinity)
76
+
77
+ // The form a haplotype fell into
78
+ const formOf = (name: string) =>
79
+ formName(result.forms.findIndex(f => f.members.includes(name)))
80
+
81
+ const variantAt = (start: number) => {
82
+ const v = variants.find(v => v.start === start)
83
+ if (!v) {
84
+ throw new Error(`no variant at ${start}`)
85
+ }
86
+ return v
87
+ }
88
+
89
+ test('each mark kind names its consequence, svType and SO type', () => {
90
+ expect(
91
+ variants.map(v => [v.start, v.end, v.consequence, v.svType, v.type, v.alt]),
92
+ ).toEqual([
93
+ [1000, 3000, 'loss', 'DEL', 'deletion', '<DEL>'],
94
+ [5000, 5001, 'gain', 'INS', 'insertion', '<INS>'],
95
+ [6500, 8000, 'gain', 'DUP', 'tandem_duplication', '<DUP:TANDEM>'],
96
+ [10_000, 11_000, 'inversion', 'INV', 'inversion', '<INV>'],
97
+ [13_000, 13_600, 'gain', 'CPX', 'complex_substitution', '<CPX>'],
98
+ [15_000, 17_000, 'loss', 'CPX', 'complex_substitution', '<CPX>'],
99
+ [18_000, 19_600, 'shift', 'CPX', 'complex_structural_alteration', '<CPX>'],
100
+ [21_000, 21_500, 'net', 'CPX', 'complex_structural_alteration', '<CPX>'],
101
+ ])
102
+ })
103
+
104
+ test('a replacement is one variant over its REF span', () => {
105
+ const v = variantAt(13_000)
106
+ expect(v.mark).toEqual(
107
+ expect.objectContaining({
108
+ kind: 'replacement',
109
+ refEnd: 13_600,
110
+ delta: 1400,
111
+ }),
112
+ )
113
+ expect(v.name).toBe('repl 600bp->2kb')
114
+ })
115
+
116
+ test('a lopsided replacement colours as the side it nearly is', () => {
117
+ const forms = structuralForms(
118
+ [
119
+ haplotype('A#1', [departure(2000, 8460, 94)]),
120
+ haplotype('B#1', [departure(12_000, 12_178, 13_900)]),
121
+ haplotype('C#1', [departure(16_000, 16_600, 500)]),
122
+ haplotype('D#1', []),
123
+ ],
124
+ { window },
125
+ )
126
+ expect(
127
+ formVariants(forms).map(v => [
128
+ v.name,
129
+ v.mark.kind,
130
+ v.mark.lopsided,
131
+ v.consequence,
132
+ ]),
133
+ ).toEqual([
134
+ ['del 6.46kb->94bp', 'replacement', 'deletion', 'loss'],
135
+ ['ins 178bp->13.9kb', 'replacement', 'insertion', 'gain'],
136
+ ['repl 600bp->500bp', 'replacement', undefined, 'loss'],
137
+ ])
138
+ })
139
+
140
+ test('genotypes are 1 in the forms carrying a state, 0 in the rest', () => {
141
+ const { genotypes, description } = variantAt(1000)
142
+ const carriers = ['A#1', 'A#2', 'B#1', 'B#2'].map(formOf)
143
+ for (const [form, genotype] of Object.entries(genotypes)) {
144
+ expect([form, genotype]).toEqual([
145
+ form,
146
+ carriers.includes(form) ? '1' : '0',
147
+ ])
148
+ }
149
+ expect(description).toBe('4 of 14 haplotypes')
150
+ })
151
+
152
+ test('a form whose walks do not span a site has no call there', () => {
153
+ expect(variantAt(1000).genotypes[formOf('G#2')]).toBe('0')
154
+ expect(variantAt(13_000).genotypes[formOf('G#2')]).toBe('.')
155
+ expect(variantAt(13_000).genotypes[formOf('E#1')]).toBe('1')
156
+ })
157
+
158
+ test('sources are the forms, labelled by share and largest change', () => {
159
+ const sources = formSources(result, Infinity)
160
+ expect(sources).toHaveLength(result.forms.length)
161
+ expect(sources[0]).toEqual({
162
+ name: 'F1',
163
+ label: '14% · del 2kb +1',
164
+ haplotypes: 2,
165
+ forms: 1,
166
+ })
167
+ expect(sources.find(s => s.name === formOf('G#1'))?.label).toBe('7% · ref')
168
+ })
169
+
170
+ test('past maxForms the smaller forms share one empty row', () => {
171
+ expect(result.forms).toHaveLength(12)
172
+ const sources = formSources(result, 4)
173
+ expect(sources.map(s => s.name)).toEqual(['F1', 'F2', 'F3', 'F4', 'other'])
174
+ expect(sources.at(-1)).toEqual({
175
+ name: OTHER_FORMS,
176
+ label: 'other forms · 57%',
177
+ haplotypes: 8,
178
+ forms: 8,
179
+ })
180
+ expect(formWarnings(result, 4)[0]).toBe(
181
+ '8 smaller forms, 8 haplotypes, share the empty "other forms" row; maxForms sets how many forms get a row of their own',
182
+ )
183
+ const capped = formVariants(result, 4)
184
+ expect(capped.every(v => Object.keys(v.genotypes).length === 4)).toBe(true)
185
+ expect(capped.every(v => Object.values(v.genotypes).includes('1'))).toBe(true)
186
+ expect(capped.length).toBeLessThan(variants.length)
187
+ expect(variantAt(1000).description).toBe(
188
+ capped.find(v => v.start === 1000)?.description,
189
+ )
190
+ })
191
+
192
+ test('reference genotypes cover the forms with a row of their own', () => {
193
+ expect(referenceGenotypes(result, 2)).toEqual({ F1: '0', F2: '0' })
194
+ expect(Object.keys(referenceGenotypes(result, Infinity))).toHaveLength(12)
195
+ })
196
+
197
+ test('one form past maxForms keeps its own row', () => {
198
+ expect(formSources(result, 11).map(s => s.name)).not.toContain(OTHER_FORMS)
199
+ expect(formSources(result, 10).at(-1)?.name).toBe(OTHER_FORMS)
200
+ })
201
+
202
+ test('warnings name the abridged and unplaced haplotypes', () => {
203
+ expect(formWarnings(result, Infinity)).toEqual([
204
+ "1 haplotype kept an abridged walk past the forms cut's step budget, which shows as a net change over its span: F#2",
205
+ '1 haplotype has no walk over the pinned window, so no form: H#1',
206
+ ])
207
+ })
208
+
209
+ test('a change every form shares is a variant carried by all of them', () => {
210
+ const shared = departure(2000, 2500, 0)
211
+ const forms = structuralForms(
212
+ [
213
+ haplotype('A#1', [shared, insertion]),
214
+ haplotype('A#2', [shared, insertion]),
215
+ haplotype('B#1', [shared]),
216
+ ],
217
+ { window },
218
+ )
219
+ expect(formSources(forms).map(s => s.label)).toEqual([
220
+ '67% · ins 800bp',
221
+ '33% · shared',
222
+ ])
223
+ expect(
224
+ formVariants(forms).map(v => [v.start, v.consequence, v.genotypes]),
225
+ ).toEqual([
226
+ [2000, 'loss', { F1: '1', F2: '1' }],
227
+ [5000, 'gain', { F1: '1', F2: '0' }],
228
+ ])
229
+ })
230
+
231
+ // a site over 1 kb wide keeps each mark apart; graph-walks sums the marks of a
232
+ // narrower one into a single change
233
+ test('a state of several marks is a variant per mark, carried alike', () => {
234
+ const forms = structuralForms(
235
+ [
236
+ haplotype('A#1', [departure(2000, 2600, 0), departure(2660, 2660, 900)]),
237
+ haplotype('B#1', [departure(1900, 3200, 0)]),
238
+ haplotype('C#1', []),
239
+ ],
240
+ { window },
241
+ )
242
+ const carriedBy = (name: string) =>
243
+ Object.fromEntries(
244
+ forms.forms.map((f, i) => [
245
+ formName(i),
246
+ f.members.includes(name) ? '1' : '0',
247
+ ]),
248
+ )
249
+ expect(
250
+ formVariants(forms)
251
+ .map(v => [v.start, v.end, v.name, v.genotypes])
252
+ .sort((a, b) => Number(a[0]) - Number(b[0])),
253
+ ).toEqual([
254
+ [1900, 3200, 'del 1.3kb', carriedBy('B#1')],
255
+ [2000, 2600, 'del 600bp', carriedBy('A#1')],
256
+ [2660, 2661, 'ins 900bp', carriedBy('A#1')],
257
+ ])
258
+ })