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,231 @@
1
+ import {
2
+ NO_CALL_STATE,
3
+ RARER_STATE,
4
+ REFERENCE_STATE,
5
+ formStateAt,
6
+ formTag,
7
+ markTag,
8
+ } from '@jbrowse/graph-walks'
9
+
10
+ import type { Mark, MarkKind, StructuralForms } from '@jbrowse/graph-walks'
11
+
12
+ export const CONSEQUENCES = [
13
+ 'loss',
14
+ 'gain',
15
+ 'shift',
16
+ 'inversion',
17
+ 'net',
18
+ ] as const
19
+
20
+ export type Consequence = (typeof CONSEQUENCES)[number]
21
+
22
+ // `svType` in core's vocabulary (UNIVERSAL_FIELD_PRESETS) and `type` as a
23
+ // Sequence Ontology term, which VcfFeature gives `<DUP:TANDEM>` too
24
+ const KINDS: Record<
25
+ MarkKind,
26
+ { svType: string; type: string; alt: string; consequence?: Consequence }
27
+ > = {
28
+ deletion: { svType: 'DEL', type: 'deletion', alt: '<DEL>' },
29
+ insertion: { svType: 'INS', type: 'insertion', alt: '<INS>' },
30
+ duplication: {
31
+ svType: 'DUP',
32
+ type: 'tandem_duplication',
33
+ alt: '<DUP:TANDEM>',
34
+ },
35
+ replacement: { svType: 'CPX', type: 'complex_substitution', alt: '<CPX>' },
36
+ inversion: {
37
+ svType: 'INV',
38
+ type: 'inversion',
39
+ alt: '<INV>',
40
+ consequence: 'inversion',
41
+ },
42
+ shift: {
43
+ svType: 'CPX',
44
+ type: 'complex_structural_alteration',
45
+ alt: '<CPX>',
46
+ consequence: 'shift',
47
+ },
48
+ net: {
49
+ svType: 'CPX',
50
+ type: 'complex_structural_alteration',
51
+ alt: '<CPX>',
52
+ consequence: 'net',
53
+ },
54
+ }
55
+
56
+ // A lopsided replacement colours by the side graph-walks names, which its net
57
+ // sign agrees with; any other change by its net sign
58
+ const sideOf = (m: Mark) =>
59
+ m.lopsided ?? (m.delta < 0 ? 'deletion' : 'insertion')
60
+
61
+ const consequenceOf = (m: Mark): Consequence =>
62
+ KINDS[m.kind].consequence ?? (sideOf(m) === 'deletion' ? 'loss' : 'gain')
63
+
64
+ export const formName = (i: number) => `F${i + 1}`
65
+
66
+ export const OTHER_FORMS = 'other'
67
+
68
+ export const DEFAULT_MAX_FORMS = 8
69
+
70
+ function percent(n: number, of: number) {
71
+ const p = (100 * n) / of
72
+ return p > 0 && p < 1 ? '<1%' : `${Math.round(p)}%`
73
+ }
74
+
75
+ // The forms drawn as rows of their own, largest first; one more than
76
+ // `maxForms` gets its own row rather than an "other forms" row
77
+ const shownCount = ({ forms }: StructuralForms, maxForms: number) =>
78
+ forms.length > maxForms + 1 ? Math.max(0, maxForms) : forms.length
79
+
80
+ const sum = (forms: StructuralForms['forms']) =>
81
+ forms.reduce((n, f) => n + f.members.length, 0)
82
+
83
+ /**
84
+ * One row per form, largest first, labelled "66% · del 18.4kb". Past
85
+ * `maxForms` the rest share one empty "other forms" row. A row's `forms`
86
+ * counts the forms it holds, so the rows sum to the window's forms.
87
+ */
88
+ export function formSources(
89
+ result: StructuralForms,
90
+ maxForms = DEFAULT_MAX_FORMS,
91
+ ) {
92
+ const shown = shownCount(result, maxForms)
93
+ const rest = result.forms.slice(shown)
94
+ return [
95
+ ...result.forms.slice(0, shown).map((f, i) => ({
96
+ name: formName(i),
97
+ label: `${percent(f.members.length, result.placed)} · ${formTag(f, result)}`,
98
+ haplotypes: f.members.length,
99
+ forms: 1,
100
+ })),
101
+ ...(rest.length > 0
102
+ ? [
103
+ {
104
+ name: OTHER_FORMS,
105
+ label: `other forms · ${percent(sum(rest), result.placed)}`,
106
+ haplotypes: sum(rest),
107
+ forms: rest.length,
108
+ },
109
+ ]
110
+ : []),
111
+ ]
112
+ }
113
+
114
+ const LISTED_NAMES = 5
115
+
116
+ function names(haplotypes: string[]) {
117
+ const listed = haplotypes.slice(0, LISTED_NAMES).join(', ')
118
+ const more = haplotypes.length - LISTED_NAMES
119
+ return more > 0 ? `${listed} and ${more} more` : listed
120
+ }
121
+
122
+ const haplotypeCount = (n: number) => `${n} haplotype${n === 1 ? '' : 's'}`
123
+
124
+ export function formWarnings(
125
+ result: StructuralForms,
126
+ maxForms = DEFAULT_MAX_FORMS,
127
+ ) {
128
+ const { abridged, unplaced, forms } = result
129
+ const shown = shownCount(result, maxForms)
130
+ const rest = forms.slice(shown)
131
+ return [
132
+ ...(rest.length > 0
133
+ ? [
134
+ `${rest.length} smaller forms, ${haplotypeCount(sum(rest))}, share the empty "other forms" row; maxForms sets how many forms get a row of their own`,
135
+ ]
136
+ : []),
137
+ ...(abridged.length > 0
138
+ ? [
139
+ `${haplotypeCount(abridged.length)} kept an abridged walk past the forms cut's step budget, which shows as a net change over its span: ${names(abridged)}`,
140
+ ]
141
+ : []),
142
+ ...(unplaced.length > 0
143
+ ? [
144
+ `${haplotypeCount(unplaced.length)} ${unplaced.length === 1 ? 'has' : 'have'} no walk over the pinned window, so no form: ${names(unplaced)}`,
145
+ ]
146
+ : []),
147
+ ]
148
+ }
149
+
150
+ // Genotype `0` in every form with a row of its own
151
+ export function referenceGenotypes(
152
+ result: StructuralForms,
153
+ maxForms = DEFAULT_MAX_FORMS,
154
+ ): Record<string, string> {
155
+ return Object.fromEntries(
156
+ result.forms
157
+ .slice(0, shownCount(result, maxForms))
158
+ .map((_, j) => [formName(j), '0']),
159
+ )
160
+ }
161
+
162
+ export interface FormVariant {
163
+ id: string
164
+ start: number
165
+ end: number
166
+ name: string
167
+ description: string
168
+ type: string
169
+ svType: string
170
+ consequence: Consequence
171
+ alt: string
172
+ mark: Mark
173
+ genotypes: Record<string, string>
174
+ }
175
+
176
+ // A duplication draws over the stretch it copies rather than its zero-width
177
+ // junction, as a VCF `<DUP>` spans its copy
178
+ function drawnSpan({ copied, refStart, refEnd }: Mark) {
179
+ const [start, end] = copied ?? [refStart, refEnd]
180
+ return { start, end: Math.max(end, start + 1) }
181
+ }
182
+
183
+ const genotypeOf = (formState: string | undefined, state: string) =>
184
+ formState === state
185
+ ? '1'
186
+ : formState === NO_CALL_STATE || formState === RARER_STATE
187
+ ? '.'
188
+ : '0'
189
+
190
+ /**
191
+ * The marks of each changed state at a site that is not an edge, one variant
192
+ * each, with genotype `1` in the forms in that state, `0` in the others and
193
+ * `.` where a form has no call or only a rarer change. Only the forms with a
194
+ * row of their own get a genotype, so the "other forms" row draws empty, and
195
+ * a state none of them carries gives no variant.
196
+ */
197
+ export function formVariants(
198
+ result: StructuralForms,
199
+ maxForms = DEFAULT_MAX_FORMS,
200
+ ): FormVariant[] {
201
+ const { sites, forms, placed } = result
202
+ const shown = shownCount(result, maxForms)
203
+ return sites.flatMap((site, s) =>
204
+ site.edge
205
+ ? []
206
+ : site.states.flatMap(state => {
207
+ if (state.key === REFERENCE_STATE) {
208
+ return []
209
+ }
210
+ const states = forms.map(f => formStateAt(result, f, s))
211
+ const genotypes = Object.fromEntries(
212
+ states
213
+ .slice(0, shown)
214
+ .map((f, j) => [formName(j), genotypeOf(f, state.key)]),
215
+ )
216
+ const carriers = sum(forms.filter((_, j) => states[j] === state.key))
217
+ return Object.values(genotypes).includes('1')
218
+ ? state.marks.map((mark, k) => ({
219
+ id: `${s}:${state.key}:${k}`,
220
+ ...drawnSpan(mark),
221
+ name: markTag(mark),
222
+ description: `${carriers} of ${haplotypeCount(placed)}`,
223
+ ...KINDS[mark.kind],
224
+ consequence: consequenceOf(mark),
225
+ mark,
226
+ genotypes,
227
+ }))
228
+ : []
229
+ }),
230
+ )
231
+ }
@@ -0,0 +1,237 @@
1
+ import { types } from '@jbrowse/mobx-state-tree'
2
+
3
+ import { FLOORS, pinnedLabel, withFormsMenu } from './formsMenu.ts'
4
+
5
+ import type { MenuItem } from '@jbrowse/core/ui'
6
+
7
+ const LPA = { refName: 'chr6', start: 160_525_000, end: 160_655_000 }
8
+
9
+ const FakeDisplay = types
10
+ .model('FakeDisplay', { adapter: types.frozen<Record<string, unknown>>() })
11
+ .volatile(() => ({
12
+ sourcesBase: undefined as Record<string, unknown>[] | undefined,
13
+ }))
14
+ .views(self => ({
15
+ get parentTrack() {
16
+ return { configuration: { adapter: self.adapter } }
17
+ },
18
+ trackMenuItems(): MenuItem[] {
19
+ return [{ label: 'Core item', onClick: () => {} }]
20
+ },
21
+ }))
22
+ .actions(self => ({
23
+ setRows(rows: Record<string, unknown>[]) {
24
+ self.sourcesBase = rows
25
+ },
26
+ setSources(rows: Record<string, unknown>[]) {
27
+ self.sourcesBase = rows
28
+ },
29
+ }))
30
+
31
+ const block = (start: number, end: number) => ({
32
+ refName: 'chr6',
33
+ assemblyName: 'hg38',
34
+ start,
35
+ end,
36
+ offsetPx: 0,
37
+ displayedRegionIndex: 0,
38
+ })
39
+
40
+ const FakeView = types
41
+ .model('FakeView', {
42
+ display: withFormsMenu(FakeDisplay),
43
+ start: types.number,
44
+ end: types.number,
45
+ })
46
+ .volatile(() => ({
47
+ width: 1000,
48
+ bpPerPx: 1,
49
+ offsetPx: 0,
50
+ displayedRegions: [{ start: 0, end: 170_805_979 }],
51
+ coarseDynamicBlocks: [],
52
+ }))
53
+ .views(self => ({
54
+ get dynamicBlocks() {
55
+ return { contentBlocks: [block(self.start, self.end)] }
56
+ },
57
+ horizontalScroll: (d: number) => d,
58
+ zoomTo: (bpPerPx: number) => bpPerPx,
59
+ }))
60
+ .actions(self => ({
61
+ setWidth() {},
62
+ pan(start: number, end: number) {
63
+ self.start = start
64
+ self.end = end
65
+ },
66
+ }))
67
+
68
+ const FakeSession = types
69
+ .model('FakeSession', { view: FakeView })
70
+ .volatile(() => ({
71
+ rpcManager: {},
72
+ configuration: {},
73
+ snackbarMessages: [] as { message: string }[],
74
+ }))
75
+ .actions(self => ({
76
+ notify(message: string) {
77
+ self.snackbarMessages.push({ message })
78
+ },
79
+ removeSnackbarMessage(message: string) {
80
+ self.snackbarMessages = self.snackbarMessages.filter(
81
+ m => m.message !== message,
82
+ )
83
+ },
84
+ }))
85
+
86
+ function makeSession(adapter: Record<string, unknown>) {
87
+ return FakeSession.create({
88
+ view: { display: { adapter }, start: 160_600_000.4, end: 160_700_000.6 },
89
+ })
90
+ }
91
+
92
+ const makeView = (adapter: Record<string, unknown>) => makeSession(adapter).view
93
+
94
+ const formsAdapter = {
95
+ type: 'WalkFormsAdapter',
96
+ walksLocation: { uri: 'x.walks.bed.gz', locationType: 'UriLocation' },
97
+ pinned: LPA,
98
+ }
99
+
100
+ interface Item {
101
+ label?: unknown
102
+ onClick: () => void
103
+ disabled?: boolean
104
+ checked?: boolean
105
+ subMenu?: Item[]
106
+ }
107
+
108
+ const labels = (items: MenuItem[]) =>
109
+ (items as unknown as Item[]).map(i => i.label)
110
+
111
+ function item(items: MenuItem[], label: string) {
112
+ const found = (items as unknown as Item[]).find(i => i.label === label)
113
+ if (!found) {
114
+ throw new Error(`no menu item ${label}`)
115
+ }
116
+ return found
117
+ }
118
+
119
+ test('another adapter gets no forms items, and its config unchanged', () => {
120
+ const { display } = makeView({ type: 'VcfTabixAdapter' })
121
+ expect(labels(display.trackMenuItems())).toEqual(['Core item'])
122
+ expect(display.adapterConfig).toEqual({ type: 'VcfTabixAdapter' })
123
+ })
124
+
125
+ test('a WalkFormsAdapter track gets the pin, re-pin and floor items', () => {
126
+ const { display } = makeView(formsAdapter)
127
+ expect(labels(display.trackMenuItems())).toEqual([
128
+ 'Core item',
129
+ 'Pinned to chr6:160,525,001-160,655,000',
130
+ 'Re-pin to visible region',
131
+ 'Hide changes under',
132
+ ])
133
+ expect(
134
+ labels(
135
+ item(display.trackMenuItems(), 'Hide changes under')
136
+ .subMenu as unknown as MenuItem[],
137
+ ),
138
+ ).toEqual(['Auto', '50 bp', '1 kb', '10 kb'])
139
+ })
140
+
141
+ test('the pinned label counts the forms the rows hold', () => {
142
+ const { display } = makeView(formsAdapter)
143
+ display.setRows([
144
+ ...Array.from({ length: 8 }, (_, i) => ({ name: `F${i + 1}`, forms: 1 })),
145
+ { name: 'other', forms: 18 },
146
+ ])
147
+ const [label] = display.formsMenuItems()
148
+ expect(label).toMatchObject({
149
+ label: 'Pinned to chr6:160,525,001-160,655,000 · 26 forms',
150
+ disabled: true,
151
+ })
152
+ expect(pinnedLabel(LPA, 1)).toBe(
153
+ 'Pinned to chr6:160,525,001-160,655,000 · 1 form',
154
+ )
155
+ expect(pinnedLabel(undefined)).toBe('Not pinned')
156
+ })
157
+
158
+ test('re-pin sets the window to the visible region, in whole bases', () => {
159
+ const view = makeView(formsAdapter)
160
+ const { display } = view
161
+ item(display.trackMenuItems(), 'Re-pin to visible region').onClick()
162
+ expect(display.formsPinned).toEqual({
163
+ refName: 'chr6',
164
+ start: 160_600_000,
165
+ end: 160_700_001,
166
+ })
167
+ expect(display.adapterConfig.pinned).toEqual(display.formsPinned)
168
+ expect(display.adapterConfig.walksLocation).toEqual(
169
+ formsAdapter.walksLocation,
170
+ )
171
+ expect(
172
+ item(display.trackMenuItems(), 'Re-pin to visible region').disabled,
173
+ ).toBe(true)
174
+
175
+ view.pan(160_800_000, 160_900_000)
176
+ expect(display.adapterConfig.pinned).toEqual({
177
+ refName: 'chr6',
178
+ start: 160_600_000,
179
+ end: 160_700_001,
180
+ })
181
+ expect(
182
+ item(display.trackMenuItems(), 'Re-pin to visible region').disabled,
183
+ ).toBe(false)
184
+ })
185
+
186
+ test('the floor items set floorBp and check the one in force', () => {
187
+ const { display } = makeView({ ...formsAdapter, floorBp: 1000 })
188
+ const checked = () =>
189
+ item(display.trackMenuItems(), 'Hide changes under')
190
+ .subMenu!.filter(i => i.checked)
191
+ .map(i => i.label)
192
+ expect(checked()).toEqual(['1 kb'])
193
+ item(display.trackMenuItems(), 'Hide changes under')
194
+ .subMenu!.find(i => i.label === '10 kb')!
195
+ .onClick()
196
+ expect(display.formsFloorBp).toBe(10_000)
197
+ expect(display.adapterConfig.floorBp).toBe(10_000)
198
+ expect(display.adapterConfig.pinned).toEqual(LPA)
199
+ expect(checked()).toEqual(['10 kb'])
200
+ expect(FLOORS.map(f => f.value)).toEqual(['auto', 50, 1000, 10_000])
201
+ })
202
+
203
+ test('the floor defaults to auto when the config leaves it unset', () => {
204
+ const { display } = makeView(formsAdapter)
205
+ expect(display.formsFloor).toBe('auto')
206
+ expect(display.adapterConfig).toEqual(formsAdapter)
207
+ })
208
+
209
+ test("a floor change or new rows take down the old window's warnings", () => {
210
+ const session = makeSession(formsAdapter)
211
+ const { display } = session.view
212
+ const lpa = 'Forms of chr6:160,525,001-160,655,000: 3 smaller forms'
213
+ session.notify(lpa)
214
+ session.notify('Another track: a warning of its own')
215
+ item(display.trackMenuItems(), 'Hide changes under')
216
+ .subMenu!.find(i => i.label === '10 kb')!
217
+ .onClick()
218
+ expect(session.snackbarMessages.map(m => m.message)).toEqual([
219
+ 'Another track: a warning of its own',
220
+ ])
221
+
222
+ session.notify(lpa)
223
+ display.setSources([{ name: 'F1' }])
224
+ expect(display.sourcesBase).toEqual([{ name: 'F1' }])
225
+ expect(session.snackbarMessages).toHaveLength(1)
226
+ })
227
+
228
+ test("a re-pin takes down the old window's warnings", () => {
229
+ const session = makeSession(formsAdapter)
230
+ const { display } = session.view
231
+ display.setSources([{ name: 'F1' }])
232
+ session.notify('Forms of chr6:160,525,001-160,655,000: 3 smaller forms')
233
+ item(display.trackMenuItems(), 'Re-pin to visible region').onClick()
234
+ session.notify('Forms of chr6:160,600,001-160,700,001: 2 smaller forms')
235
+ display.setSources([{ name: 'F1' }, { name: 'F2' }])
236
+ expect(session.snackbarMessages).toEqual([])
237
+ })
@@ -0,0 +1,202 @@
1
+ import { readConfObject } from '@jbrowse/core/configuration'
2
+ import { DisplayType } from '@jbrowse/core/pluggableElementTypes'
3
+ import { getContainingView, getSession } from '@jbrowse/core/util'
4
+ import { types } from '@jbrowse/mobx-state-tree'
5
+
6
+ import { floorOf, pinnedLocus, pinnedOf, warningPrefix } from './pinned.ts'
7
+ import { hostWindow, isLinearHost } from '../GraphGenomeView/host.ts'
8
+
9
+ import type { FloorBp, Pinned } from './pinned.ts'
10
+ import type PluginManager from '@jbrowse/core/PluginManager'
11
+ import type { AnyConfigurationModel } from '@jbrowse/core/configuration'
12
+ import type { MenuItem } from '@jbrowse/core/ui'
13
+ import type { IAnyModelType, IAnyStateTreeNode } from '@jbrowse/mobx-state-tree'
14
+
15
+ export const FORMS_DISPLAYS = ['LinearMultiSampleVariantDisplay']
16
+
17
+ export const FLOORS: { label: string; value: FloorBp }[] = [
18
+ { label: 'Auto', value: 'auto' },
19
+ { label: '50 bp', value: 50 },
20
+ { label: '1 kb', value: 1000 },
21
+ { label: '10 kb', value: 10_000 },
22
+ ]
23
+
24
+ export function pinnedLabel(pinned: Pinned | undefined, forms?: number) {
25
+ return pinned
26
+ ? `Pinned to ${pinnedLocus(pinned)}${forms === undefined ? '' : ` · ${forms} form${forms === 1 ? '' : 's'}`}`
27
+ : 'Not pinned'
28
+ }
29
+
30
+ interface FormsHost {
31
+ parentTrack: { configuration: AnyConfigurationModel }
32
+ sourcesBase?: Record<string, unknown>[]
33
+ trackMenuItems: () => MenuItem[]
34
+ setSources?: (sources: unknown, adapterConfig?: unknown) => void
35
+ }
36
+
37
+ interface Snackbar {
38
+ snackbarMessages?: { message: string }[]
39
+ removeSnackbarMessage?: (message: string) => void
40
+ }
41
+
42
+ function clearWarnings(
43
+ node: IAnyStateTreeNode,
44
+ windows: (Pinned | undefined)[],
45
+ ) {
46
+ const session = getSession(node) as unknown as Snackbar
47
+ const prefixes = windows.flatMap(w => (w ? [warningPrefix(w)] : []))
48
+ for (const { message } of [...(session.snackbarMessages ?? [])]) {
49
+ if (prefixes.some(p => message.startsWith(p))) {
50
+ session.removeSnackbarMessage?.(message)
51
+ }
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Gives a WalkFormsAdapter track's display a pinned window and floor of its
57
+ * own, which the display stores in the session and sends in the adapter config
58
+ * with each request. A config edit would change the track's RPC session id
59
+ * and move the requests to a worker without the cut; display state leaves it.
60
+ */
61
+ export function withFormsMenu(model: IAnyModelType) {
62
+ return model
63
+ .props({
64
+ formsPinned: types.maybe(types.frozen<Pinned>()),
65
+ formsFloorBp: types.maybe(types.frozen<FloorBp>()),
66
+ })
67
+ .views(self => ({
68
+ get configuredAdapter(): Record<string, unknown> {
69
+ return readConfObject(
70
+ (self as unknown as FormsHost).parentTrack.configuration,
71
+ 'adapter',
72
+ ) as Record<string, unknown>
73
+ },
74
+ get walkForms() {
75
+ return this.configuredAdapter.type === 'WalkFormsAdapter'
76
+ },
77
+ }))
78
+ .views(self => ({
79
+ get adapterConfig(): Record<string, unknown> {
80
+ const conf = self.configuredAdapter
81
+ return self.walkForms
82
+ ? {
83
+ ...conf,
84
+ ...(self.formsPinned ? { pinned: self.formsPinned } : {}),
85
+ ...(self.formsFloorBp === undefined
86
+ ? {}
87
+ : { floorBp: self.formsFloorBp }),
88
+ }
89
+ : conf
90
+ },
91
+ get formsWindow() {
92
+ return pinnedOf(this.adapterConfig.pinned)
93
+ },
94
+ get formsFloor() {
95
+ return floorOf(this.adapterConfig.floorBp)
96
+ },
97
+ get formsCount() {
98
+ return (self as unknown as FormsHost).sourcesBase?.reduce(
99
+ (n, row) => n + (Number(row.forms) || 1),
100
+ 0,
101
+ )
102
+ },
103
+ get visibleWindow(): Pinned | undefined {
104
+ const view: unknown = getContainingView(self)
105
+ const window = isLinearHost(view) ? hostWindow(view) : undefined
106
+ return window && window.end > window.start
107
+ ? {
108
+ refName: window.refName,
109
+ start: Math.max(0, Math.floor(window.start)),
110
+ end: Math.ceil(window.end),
111
+ }
112
+ : undefined
113
+ },
114
+ }))
115
+ .volatile(() => ({
116
+ warnedWindow: undefined as Pinned | undefined,
117
+ }))
118
+ .actions(self => {
119
+ const { setSources: superSetSources } = self as unknown as FormsHost
120
+ const clearFormsWarnings = () => {
121
+ clearWarnings(self, [self.warnedWindow, self.formsWindow])
122
+ }
123
+ return {
124
+ setFormsPinned(pinned: Pinned) {
125
+ clearFormsWarnings()
126
+ self.formsPinned = pinned
127
+ },
128
+ setFormsFloorBp(floorBp: FloorBp) {
129
+ clearFormsWarnings()
130
+ self.formsFloorBp = floorBp
131
+ },
132
+ // Core posts the new forms' warnings right after this, so the old
133
+ // ones come down first
134
+ setSources(sources: unknown, adapterConfig?: unknown) {
135
+ if (self.walkForms) {
136
+ clearFormsWarnings()
137
+ self.warnedWindow = self.formsWindow
138
+ }
139
+ superSetSources?.(sources, adapterConfig)
140
+ },
141
+ }
142
+ })
143
+ .views(self => {
144
+ const { trackMenuItems: superTrackMenuItems } =
145
+ self as unknown as FormsHost
146
+ return {
147
+ formsMenuItems(): MenuItem[] {
148
+ const pinned = self.formsWindow
149
+ const visible = self.visibleWindow
150
+ return [
151
+ {
152
+ label: pinnedLabel(pinned, self.formsCount),
153
+ disabled: true,
154
+ onClick: () => {},
155
+ },
156
+ {
157
+ label: 'Re-pin to visible region',
158
+ helpText:
159
+ 'Cut the forms from the region the view shows. Panning and zooming leave the forms alone',
160
+ disabled:
161
+ !visible ||
162
+ (pinned !== undefined &&
163
+ pinnedLocus(visible) === pinnedLocus(pinned)),
164
+ onClick: () => {
165
+ if (visible) {
166
+ self.setFormsPinned(visible)
167
+ }
168
+ },
169
+ },
170
+ {
171
+ label: 'Hide changes under',
172
+ subMenu: FLOORS.map(({ label, value }) => ({
173
+ label,
174
+ type: 'radio' as const,
175
+ checked: self.formsFloor === value,
176
+ onClick: () => {
177
+ self.setFormsFloorBp(value)
178
+ },
179
+ })),
180
+ },
181
+ ]
182
+ },
183
+ trackMenuItems(): MenuItem[] {
184
+ return self.walkForms
185
+ ? [...superTrackMenuItems(), ...this.formsMenuItems()]
186
+ : superTrackMenuItems()
187
+ },
188
+ }
189
+ })
190
+ }
191
+
192
+ export default function WalkFormsMenuF(pluginManager: PluginManager) {
193
+ pluginManager.addToExtensionPoint('Core-extendPluggableElement', element => {
194
+ if (
195
+ element instanceof DisplayType &&
196
+ FORMS_DISPLAYS.includes(element.name)
197
+ ) {
198
+ element.extendStateModel(withFormsMenu)
199
+ }
200
+ return element
201
+ })
202
+ }
@@ -0,0 +1,21 @@
1
+ import AdapterType from '@jbrowse/core/pluggableElementTypes/AdapterType'
2
+
3
+ import configSchema from './configSchema.ts'
4
+
5
+ import type PluginManager from '@jbrowse/core/PluginManager'
6
+
7
+ export default function WalkFormsAdapterF(pluginManager: PluginManager) {
8
+ pluginManager.addAdapterType(
9
+ () =>
10
+ new AdapterType({
11
+ name: 'WalkFormsAdapter',
12
+ displayName: 'Walk-file structural forms adapter',
13
+ configSchema,
14
+ adapterMetadata: {
15
+ category: 'Variant adapters',
16
+ },
17
+ getAdapterClass: () =>
18
+ import('./WalkFormsAdapter.ts').then(r => r.default),
19
+ }),
20
+ )
21
+ }