jbrowse-plugin-msaview 3.0.0 → 3.2.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.
- package/dist/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.js +1 -1
- package/dist/LaunchMsaView/components/BlastQuery/BlastManualPanel.d.ts +11 -0
- package/dist/LaunchMsaView/components/BlastQuery/BlastManualPanel.js +73 -16
- package/dist/LaunchMsaView/components/BlastQuery/blastLaunchView.js +6 -4
- package/dist/LaunchMsaView/components/BlastQuery/consts.d.ts +8 -1
- package/dist/LaunchMsaView/components/BlastQuery/consts.js +8 -1
- package/dist/LaunchMsaView/components/ManualMSALoader/ManualMSALoader.js +8 -15
- package/dist/LaunchMsaView/components/ManualMSALoader/launchView.js +4 -2
- package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +1 -1
- package/dist/LaunchMsaView/components/OrthologQuery/orthologLaunchView.js +4 -2
- package/dist/LaunchMsaView/components/PreLoadedMSA/PreLoadedMSADataPanel.js +1 -1
- package/dist/LaunchMsaView/components/PreLoadedMSA/preCalculatedLaunchView.js +4 -2
- package/dist/LaunchMsaView/components/QueryRowSelector.d.ts +16 -0
- package/dist/LaunchMsaView/components/QueryRowSelector.js +38 -0
- package/dist/LaunchMsaView/components/SubmitCancelActions.d.ts +4 -1
- package/dist/LaunchMsaView/components/SubmitCancelActions.js +34 -10
- package/dist/LaunchMsaView/components/SubmitCancelActions.test.d.ts +1 -0
- package/dist/LaunchMsaView/components/SubmitCancelActions.test.js +55 -0
- package/dist/LaunchMsaView/detectQueryRow.d.ts +23 -0
- package/dist/LaunchMsaView/detectQueryRow.js +94 -0
- package/dist/LaunchMsaView/detectQueryRow.test.d.ts +1 -0
- package/dist/LaunchMsaView/detectQueryRow.test.js +65 -0
- package/dist/LaunchMsaView/useQueryRowName.d.ts +15 -0
- package/dist/LaunchMsaView/useQueryRowName.js +26 -0
- package/dist/LaunchMsaViewExtensionPoint/index.js +2 -2
- package/dist/MsaViewPanel/afterCreateAutoruns.d.ts +27 -11
- package/dist/MsaViewPanel/afterCreateAutoruns.js +96 -47
- package/dist/MsaViewPanel/observeProteinHighlights.test.d.ts +1 -0
- package/dist/MsaViewPanel/observeProteinHighlights.test.js +209 -0
- package/dist/MsaViewPanel/structureConnection.d.ts +6 -0
- package/dist/index.js +4 -1
- package/dist/jbrowse-plugin-msaview.umd.production.min.js +28 -28
- package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
- package/dist/utils/launchMsaView.d.ts +19 -0
- package/dist/utils/launchMsaView.js +13 -0
- package/dist/utils/workspaces.d.ts +34 -0
- package/dist/utils/workspaces.js +100 -0
- package/dist/utils/workspaces.test.d.ts +1 -0
- package/dist/utils/workspaces.test.js +100 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +6 -1
- package/src/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.tsx +1 -0
- package/src/LaunchMsaView/components/BlastQuery/BlastManualPanel.tsx +127 -30
- package/src/LaunchMsaView/components/BlastQuery/blastLaunchView.ts +7 -4
- package/src/LaunchMsaView/components/BlastQuery/consts.ts +8 -1
- package/src/LaunchMsaView/components/ManualMSALoader/ManualMSALoader.tsx +8 -37
- package/src/LaunchMsaView/components/ManualMSALoader/launchView.ts +5 -2
- package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +1 -1
- package/src/LaunchMsaView/components/OrthologQuery/orthologLaunchView.ts +5 -2
- package/src/LaunchMsaView/components/PreLoadedMSA/PreLoadedMSADataPanel.tsx +1 -0
- package/src/LaunchMsaView/components/PreLoadedMSA/preCalculatedLaunchView.ts +5 -2
- package/src/LaunchMsaView/components/QueryRowSelector.tsx +93 -0
- package/src/LaunchMsaView/components/SubmitCancelActions.test.tsx +95 -0
- package/src/LaunchMsaView/components/SubmitCancelActions.tsx +80 -22
- package/src/LaunchMsaView/detectQueryRow.test.ts +79 -0
- package/src/LaunchMsaView/detectQueryRow.ts +132 -0
- package/src/LaunchMsaView/useQueryRowName.ts +33 -0
- package/src/LaunchMsaViewExtensionPoint/index.ts +21 -2
- package/src/MsaViewPanel/afterCreateAutoruns.ts +106 -51
- package/src/MsaViewPanel/observeProteinHighlights.test.ts +264 -0
- package/src/MsaViewPanel/structureConnection.ts +7 -0
- package/src/index.ts +4 -1
- package/src/utils/launchMsaView.ts +30 -0
- package/src/utils/workspaces.test.ts +132 -0
- package/src/utils/workspaces.ts +146 -0
- package/src/version.ts +1 -1
|
@@ -223,68 +223,123 @@ export function syncGenomeHoverToMsaColumn(self: JBrowsePluginMsaViewModel) {
|
|
|
223
223
|
}
|
|
224
224
|
|
|
225
225
|
/**
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
|
|
226
|
+
* Translate genome regions published by a 3D protein view into this MSA's
|
|
227
|
+
* visible columns. The genome is the only coordinate space the two plugins
|
|
228
|
+
* share, so the hops are genome coord -> protein position (the transcript's g2p
|
|
229
|
+
* map) -> global alignment column -> visible column.
|
|
230
|
+
*/
|
|
231
|
+
function genomeHighlightsToVisibleColumns(
|
|
232
|
+
self: JBrowsePluginMsaViewModel,
|
|
233
|
+
field: 'hoverGenomeHighlights' | 'clickGenomeHighlights',
|
|
234
|
+
) {
|
|
235
|
+
const { connectedViewId, transcriptToMsaMap, querySeqName } = self
|
|
236
|
+
if (!transcriptToMsaMap) {
|
|
237
|
+
return []
|
|
238
|
+
}
|
|
239
|
+
const { g2p } = transcriptToMsaMap
|
|
240
|
+
const columns = new Set<number>()
|
|
241
|
+
|
|
242
|
+
for (const view of getProteinViews(getSession(self).views)) {
|
|
243
|
+
for (const structure of view.structures) {
|
|
244
|
+
if (structure.connectedViewId !== connectedViewId) {
|
|
245
|
+
continue
|
|
246
|
+
}
|
|
247
|
+
for (const highlight of structure[field] ?? []) {
|
|
248
|
+
for (let coord = highlight.start; coord < highlight.end; coord++) {
|
|
249
|
+
const proteinPos = g2p[coord]
|
|
250
|
+
if (proteinPos !== undefined) {
|
|
251
|
+
columns.add(self.seqPosToGlobalCol(querySeqName, proteinPos))
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return [...columns]
|
|
259
|
+
.map(col => self.globalColToVisibleCol(col))
|
|
260
|
+
.filter((col): col is number => col !== undefined)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function sameColumns(a: number[] | undefined, b: number[] | undefined) {
|
|
264
|
+
if (!a || !b) {
|
|
265
|
+
return a === b
|
|
266
|
+
}
|
|
267
|
+
return a.length === b.length && a.every((col, i) => col === b[i])
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Mirror a connected 3D protein view's highlights onto the MSA's highlighted
|
|
272
|
+
* columns, from either of the two channels protein3d publishes:
|
|
273
|
+
*
|
|
274
|
+
* - `hoverGenomeHighlights` — the residue under the pointer, transient.
|
|
275
|
+
* - `clickGenomeHighlights` — the domain the user clicked, persistent. Also
|
|
276
|
+
* what protein3d's declarative `initialSelection` lights on load, so a session
|
|
277
|
+
* spec that pre-selects a domain in the structure now lands in the alignment
|
|
278
|
+
* too, instead of the caller having to author the same range a second time as
|
|
279
|
+
* the MSA's own `highlightColumns`.
|
|
280
|
+
*
|
|
281
|
+
* Highest-priority non-empty source wins: a hover reads as a transient probe on
|
|
282
|
+
* top of the standing selection, and letting it win means moving the pointer
|
|
283
|
+
* over the structure previews a residue without destroying what was selected.
|
|
284
|
+
* Releasing the hover falls back to the click selection, then to the declarative
|
|
285
|
+
* `highlightColumns` seed.
|
|
231
286
|
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
* the bug that made the BRAF/TP53
|
|
237
|
-
*
|
|
287
|
+
* Resolving the seed as the last rung of that stack is what replaced a
|
|
288
|
+
* `proteinDriven` flag this function used to carry. The flag existed because the
|
|
289
|
+
* body could not otherwise tell "no protein highlight, leave the seed alone"
|
|
290
|
+
* from "the protein highlight ended, restore the seed", and getting that wrong
|
|
291
|
+
* wiped the seed on the very first run — the bug that made the BRAF/TP53
|
|
292
|
+
* genome-browser links open with no V600/R248 column lit. Now every source is in
|
|
293
|
+
* one expression, so the result depends only on what the sources currently say
|
|
294
|
+
* and there is no ordering to get wrong.
|
|
295
|
+
*
|
|
296
|
+
* A closure remains, but it decides nothing: `written` only suppresses a
|
|
297
|
+
* redundant redraw. Delete it and the highlight is identical, just recomputed
|
|
298
|
+
* more often — where deleting the old flag changed which columns lit.
|
|
238
299
|
*/
|
|
239
300
|
export function observeProteinHighlights(self: JBrowsePluginMsaViewModel) {
|
|
240
|
-
|
|
301
|
+
// The columns this reaction last wrote, kept to skip a write that would not
|
|
302
|
+
// change anything: protein3d recomputes hoverGenomeHighlights on every mouse
|
|
303
|
+
// move over the structure, and moving within one codon yields a fresh array of
|
|
304
|
+
// the same columns, which would redraw the overlay canvas for nothing.
|
|
305
|
+
//
|
|
306
|
+
// Deliberately a closure rather than a read of `self.highlightedColumns` --
|
|
307
|
+
// reading it would put this reaction's own output in its dependency set, so
|
|
308
|
+
// every write would re-trigger it. It converges, but the dependencies should be
|
|
309
|
+
// the sources the highlight derives FROM, not the highlight itself.
|
|
310
|
+
let written: number[] | undefined
|
|
241
311
|
return () => {
|
|
242
|
-
const { connectedViewId, transcriptToMsaMap
|
|
312
|
+
const { connectedViewId, transcriptToMsaMap } = self
|
|
243
313
|
|
|
244
314
|
if (!connectedViewId || !transcriptToMsaMap) {
|
|
245
315
|
return
|
|
246
316
|
}
|
|
247
317
|
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
const col = self.seqPosToGlobalCol(querySeqName, proteinPos)
|
|
267
|
-
columns.add(col)
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
}
|
|
318
|
+
const hover = genomeHighlightsToVisibleColumns(
|
|
319
|
+
self,
|
|
320
|
+
'hoverGenomeHighlights',
|
|
321
|
+
)
|
|
322
|
+
// Skipping the click channel while hovering is worth the subtlety it costs:
|
|
323
|
+
// a hover recomputes on every mouse move, and a clicked domain can be
|
|
324
|
+
// hundreds of residues, so translating a selection that cannot win would
|
|
325
|
+
// walk thousands of genome coordinates per pointer move.
|
|
326
|
+
//
|
|
327
|
+
// The subtlety is that not reading clickGenomeHighlights leaves it out of
|
|
328
|
+
// this reaction's dependencies until the hover clears. Changing the
|
|
329
|
+
// selection mid-hover therefore does not re-run us -- which is harmless,
|
|
330
|
+
// because the hover would have outranked it anyway, and releasing the hover
|
|
331
|
+
// re-runs and picks up whatever the selection now says.
|
|
332
|
+
const click = hover.length
|
|
333
|
+
? []
|
|
334
|
+
: genomeHighlightsToVisibleColumns(self, 'clickGenomeHighlights')
|
|
335
|
+
const seed = self.highlightColumns ?? []
|
|
273
336
|
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
.filter((col): col is number => col !== undefined)
|
|
337
|
+
const winner = hover.length ? hover : click.length ? click : seed
|
|
338
|
+
const next = winner.length > 0 ? winner : undefined
|
|
277
339
|
|
|
278
|
-
if (
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
} else if (proteinDriven) {
|
|
282
|
-
// our protein-hover highlight ended — fall back to the declarative seed
|
|
283
|
-
// instead of wiping a column the URL/user asked to keep lit
|
|
284
|
-
self.setHighlightedColumns(
|
|
285
|
-
self.highlightColumns?.length ? self.highlightColumns : undefined,
|
|
286
|
-
)
|
|
287
|
-
proteinDriven = false
|
|
340
|
+
if (!sameColumns(written, next)) {
|
|
341
|
+
written = next
|
|
342
|
+
self.setHighlightedColumns(next)
|
|
288
343
|
}
|
|
289
344
|
}
|
|
290
345
|
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { getSession } from '@jbrowse/core/util'
|
|
2
|
+
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { observeProteinHighlights } from './afterCreateAutoruns'
|
|
5
|
+
|
|
6
|
+
import type { JBrowsePluginMsaViewModel } from './model'
|
|
7
|
+
|
|
8
|
+
// Mock only getSession; keep the rest of the util module real so the
|
|
9
|
+
// afterCreateAutoruns import graph still loads.
|
|
10
|
+
vi.mock('@jbrowse/core/util', async importOriginal => ({
|
|
11
|
+
...(await importOriginal<Record<string, unknown>>()),
|
|
12
|
+
getSession: vi.fn(),
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
const mockGetSession = vi.mocked(getSession)
|
|
16
|
+
|
|
17
|
+
const CONNECTED = 'lgv-1'
|
|
18
|
+
|
|
19
|
+
interface Range {
|
|
20
|
+
start: number
|
|
21
|
+
end: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A model with an identity genome->protein->column mapping, so an asserted
|
|
26
|
+
* column equals the genome coordinate that produced it and the test reads as
|
|
27
|
+
* "these genome coords lit these columns".
|
|
28
|
+
*/
|
|
29
|
+
function makeModel({ highlightColumns }: { highlightColumns?: number[] } = {}) {
|
|
30
|
+
const calls: (number[] | undefined)[] = []
|
|
31
|
+
const model = {
|
|
32
|
+
querySeqName: 'query',
|
|
33
|
+
connectedViewId: CONNECTED,
|
|
34
|
+
// g2p is indexed by genome coord; identity keeps the arithmetic out of the way
|
|
35
|
+
transcriptToMsaMap: {
|
|
36
|
+
g2p: Object.fromEntries([...Array(200).keys()].map(i => [i, i])),
|
|
37
|
+
},
|
|
38
|
+
highlightColumns,
|
|
39
|
+
highlightedColumns: undefined as number[] | undefined,
|
|
40
|
+
seqPosToGlobalCol: (_name: string, pos: number) => pos,
|
|
41
|
+
globalColToVisibleCol: (col: number) => col,
|
|
42
|
+
setHighlightedColumns: (cols?: number[]) => {
|
|
43
|
+
calls.push(cols)
|
|
44
|
+
model.highlightedColumns = cols
|
|
45
|
+
},
|
|
46
|
+
} as unknown as JBrowsePluginMsaViewModel & {
|
|
47
|
+
highlightedColumns: number[] | undefined
|
|
48
|
+
}
|
|
49
|
+
return { model, calls }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** publish highlight channels on a ProteinView structure in the session */
|
|
53
|
+
function session({
|
|
54
|
+
hover,
|
|
55
|
+
click,
|
|
56
|
+
connectedViewId = CONNECTED,
|
|
57
|
+
}: {
|
|
58
|
+
hover?: Range[]
|
|
59
|
+
click?: Range[]
|
|
60
|
+
connectedViewId?: string
|
|
61
|
+
}) {
|
|
62
|
+
mockGetSession.mockReturnValue({
|
|
63
|
+
views: [
|
|
64
|
+
{
|
|
65
|
+
type: 'ProteinView',
|
|
66
|
+
id: 'pv-1',
|
|
67
|
+
structures: [
|
|
68
|
+
{
|
|
69
|
+
connectedViewId,
|
|
70
|
+
hoverGenomeHighlights: hover,
|
|
71
|
+
clickGenomeHighlights: click,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
} as unknown as ReturnType<typeof getSession>)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function noProteinView() {
|
|
80
|
+
mockGetSession.mockReturnValue({
|
|
81
|
+
views: [{ type: 'LinearGenomeView', id: CONNECTED }],
|
|
82
|
+
} as unknown as ReturnType<typeof getSession>)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
beforeEach(() => {
|
|
86
|
+
vi.clearAllMocks()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('the hover channel', () => {
|
|
90
|
+
test('a hovered residue lights its column', () => {
|
|
91
|
+
const { model, calls } = makeModel()
|
|
92
|
+
const run = observeProteinHighlights(model)
|
|
93
|
+
|
|
94
|
+
session({ hover: [{ start: 10, end: 13 }] })
|
|
95
|
+
run()
|
|
96
|
+
expect(calls).toEqual([[10, 11, 12]])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('releasing the hover clears the highlight', () => {
|
|
100
|
+
const { model, calls } = makeModel()
|
|
101
|
+
const run = observeProteinHighlights(model)
|
|
102
|
+
|
|
103
|
+
session({ hover: [{ start: 10, end: 12 }] })
|
|
104
|
+
run()
|
|
105
|
+
session({ hover: [] })
|
|
106
|
+
run()
|
|
107
|
+
expect(calls).toEqual([[10, 11], undefined])
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('the click channel', () => {
|
|
112
|
+
test('a clicked domain lights its columns', () => {
|
|
113
|
+
const { model, calls } = makeModel()
|
|
114
|
+
const run = observeProteinHighlights(model)
|
|
115
|
+
|
|
116
|
+
session({ click: [{ start: 30, end: 34 }] })
|
|
117
|
+
run()
|
|
118
|
+
expect(calls).toEqual([[30, 31, 32, 33]])
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('a hover wins over the standing click selection', () => {
|
|
122
|
+
const { model, calls } = makeModel()
|
|
123
|
+
const run = observeProteinHighlights(model)
|
|
124
|
+
|
|
125
|
+
session({ click: [{ start: 30, end: 32 }], hover: [{ start: 5, end: 6 }] })
|
|
126
|
+
run()
|
|
127
|
+
expect(calls).toEqual([[5]])
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('a selection changed during a hover is picked up when the hover releases', () => {
|
|
131
|
+
const { model, calls } = makeModel()
|
|
132
|
+
const run = observeProteinHighlights(model)
|
|
133
|
+
|
|
134
|
+
// the reaction skips the click channel while hovering, so it is not watching
|
|
135
|
+
// it; this pins that releasing the hover still lands on the CURRENT selection
|
|
136
|
+
// rather than on the one that was standing when the hover began
|
|
137
|
+
session({ click: [{ start: 30, end: 32 }], hover: [{ start: 5, end: 6 }] })
|
|
138
|
+
run()
|
|
139
|
+
session({ click: [{ start: 60, end: 62 }], hover: [{ start: 5, end: 6 }] })
|
|
140
|
+
run()
|
|
141
|
+
session({ click: [{ start: 60, end: 62 }] })
|
|
142
|
+
run()
|
|
143
|
+
expect(calls).toEqual([[5], [60, 61]])
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('releasing the hover falls back to the click selection, not to nothing', () => {
|
|
147
|
+
const { model, calls } = makeModel()
|
|
148
|
+
const run = observeProteinHighlights(model)
|
|
149
|
+
|
|
150
|
+
// this is the whole point of the two channels: previewing a residue must not
|
|
151
|
+
// destroy the domain the user selected
|
|
152
|
+
session({ click: [{ start: 30, end: 32 }] })
|
|
153
|
+
run()
|
|
154
|
+
session({ click: [{ start: 30, end: 32 }], hover: [{ start: 5, end: 6 }] })
|
|
155
|
+
run()
|
|
156
|
+
session({ click: [{ start: 30, end: 32 }] })
|
|
157
|
+
run()
|
|
158
|
+
expect(calls).toEqual([[30, 31], [5], [30, 31]])
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
describe('the declarative highlightColumns seed', () => {
|
|
163
|
+
// the regression these guard: the observer used to compute zero columns on its
|
|
164
|
+
// first run and wipe the seed MSAModelF.afterCreate had just applied, which is
|
|
165
|
+
// what made the BRAF/TP53 links open with no V600/R248 column lit
|
|
166
|
+
test('a first run with no protein view leaves the seed alone', () => {
|
|
167
|
+
const { model, calls } = makeModel({ highlightColumns: [77] })
|
|
168
|
+
const run = observeProteinHighlights(model)
|
|
169
|
+
|
|
170
|
+
noProteinView()
|
|
171
|
+
run()
|
|
172
|
+
expect(calls).toEqual([[77]])
|
|
173
|
+
expect(model.highlightedColumns).toEqual([77])
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('repeated runs never clobber the seed', () => {
|
|
177
|
+
const { model } = makeModel({ highlightColumns: [77] })
|
|
178
|
+
const run = observeProteinHighlights(model)
|
|
179
|
+
|
|
180
|
+
noProteinView()
|
|
181
|
+
run()
|
|
182
|
+
run()
|
|
183
|
+
run()
|
|
184
|
+
expect(model.highlightedColumns).toEqual([77])
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
test('a hover overrides the seed, and releasing it restores the seed', () => {
|
|
188
|
+
const { model, calls } = makeModel({ highlightColumns: [77] })
|
|
189
|
+
const run = observeProteinHighlights(model)
|
|
190
|
+
|
|
191
|
+
session({ hover: [{ start: 1, end: 2 }] })
|
|
192
|
+
run()
|
|
193
|
+
session({ hover: [] })
|
|
194
|
+
run()
|
|
195
|
+
expect(calls).toEqual([[1], [77]])
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
test('a click selection outranks the seed', () => {
|
|
199
|
+
const { model, calls } = makeModel({ highlightColumns: [77] })
|
|
200
|
+
const run = observeProteinHighlights(model)
|
|
201
|
+
|
|
202
|
+
session({ click: [{ start: 40, end: 42 }] })
|
|
203
|
+
run()
|
|
204
|
+
expect(calls).toEqual([[40, 41]])
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('with no seed and no protein highlight, nothing is written at all', () => {
|
|
208
|
+
const { model, calls } = makeModel()
|
|
209
|
+
const run = observeProteinHighlights(model)
|
|
210
|
+
|
|
211
|
+
noProteinView()
|
|
212
|
+
run()
|
|
213
|
+
run()
|
|
214
|
+
expect(calls).toEqual([])
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
describe('scope and redundant writes', () => {
|
|
219
|
+
test('a structure connected to a different view is ignored', () => {
|
|
220
|
+
const { model, calls } = makeModel()
|
|
221
|
+
const run = observeProteinHighlights(model)
|
|
222
|
+
|
|
223
|
+
session({
|
|
224
|
+
hover: [{ start: 10, end: 12 }],
|
|
225
|
+
connectedViewId: 'some-other-view',
|
|
226
|
+
})
|
|
227
|
+
run()
|
|
228
|
+
expect(calls).toEqual([])
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('an unchanged highlight is not rewritten, so the overlay does not redraw', () => {
|
|
232
|
+
const { model, calls } = makeModel()
|
|
233
|
+
const run = observeProteinHighlights(model)
|
|
234
|
+
|
|
235
|
+
session({ hover: [{ start: 10, end: 12 }] })
|
|
236
|
+
run()
|
|
237
|
+
run()
|
|
238
|
+
run()
|
|
239
|
+
expect(calls).toEqual([[10, 11]])
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test('a genome coord with no protein position contributes no column', () => {
|
|
243
|
+
const { model, calls } = makeModel()
|
|
244
|
+
const run = observeProteinHighlights(model)
|
|
245
|
+
|
|
246
|
+
// 500 is past the end of the identity g2p map built above
|
|
247
|
+
session({ hover: [{ start: 500, end: 503 }] })
|
|
248
|
+
run()
|
|
249
|
+
expect(calls).toEqual([])
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
test('nothing happens until the view is connected and mapped', () => {
|
|
253
|
+
const { calls } = makeModel()
|
|
254
|
+
const bare = {
|
|
255
|
+
connectedViewId: undefined,
|
|
256
|
+
transcriptToMsaMap: undefined,
|
|
257
|
+
} as unknown as JBrowsePluginMsaViewModel
|
|
258
|
+
const run = observeProteinHighlights(bare)
|
|
259
|
+
|
|
260
|
+
session({ hover: [{ start: 10, end: 12 }] })
|
|
261
|
+
run()
|
|
262
|
+
expect(calls).toEqual([])
|
|
263
|
+
})
|
|
264
|
+
})
|
|
@@ -1,9 +1,16 @@
|
|
|
1
|
+
// Structurally typed against jbrowse-plugin-protein3d rather than imported from
|
|
2
|
+
// it: the two plugins ship independently, so a field the installed protein3d
|
|
3
|
+
// lacks reads as undefined instead of failing to resolve. Both highlight
|
|
4
|
+
// channels have been on its structure model since v0.4.0.
|
|
1
5
|
export interface ProteinViewStructure {
|
|
2
6
|
url?: string
|
|
3
7
|
connectedViewId?: string
|
|
4
8
|
uniprotId?: string
|
|
5
9
|
structureSequences?: string[]
|
|
10
|
+
/** the residue under the pointer, transient */
|
|
6
11
|
hoverGenomeHighlights?: { start: number; end: number }[]
|
|
12
|
+
/** the clicked domain, persistent; also what `initialSelection` lights */
|
|
13
|
+
clickGenomeHighlights?: { start: number; end: number }[]
|
|
7
14
|
}
|
|
8
15
|
|
|
9
16
|
export interface ProteinView {
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ import BgzipFastaMsaAdapterF from './BgzipFastaMsaAdapter'
|
|
|
9
9
|
import LaunchMsaViewF from './LaunchMsaView'
|
|
10
10
|
import LaunchMsaViewExtensionPointF from './LaunchMsaViewExtensionPoint'
|
|
11
11
|
import MsaViewF from './MsaViewPanel'
|
|
12
|
+
import { launchMsaView } from './utils/launchMsaView'
|
|
12
13
|
import { version } from './version'
|
|
13
14
|
|
|
14
15
|
import type PluginManager from '@jbrowse/core/PluginManager'
|
|
@@ -32,7 +33,9 @@ export default class MsaViewPlugin extends Plugin {
|
|
|
32
33
|
label: 'Multiple sequence alignment view',
|
|
33
34
|
icon: GridOn,
|
|
34
35
|
onClick: (session: AbstractSessionModel) => {
|
|
35
|
-
|
|
36
|
+
// stacked, by default: nothing was launched from, so there is no
|
|
37
|
+
// connected view for it to sit beside
|
|
38
|
+
launchMsaView(session, {})
|
|
36
39
|
},
|
|
37
40
|
})
|
|
38
41
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { placeMsaView } from './workspaces'
|
|
2
|
+
|
|
3
|
+
import type { MsaViewPlacement } from './workspaces'
|
|
4
|
+
import type { AbstractSessionModel } from '@jbrowse/core/util'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A launch, stated: what the view is, and where it goes. Everything but
|
|
8
|
+
* `placement` is a react-msaview or plugin-model snapshot property, passed
|
|
9
|
+
* through untouched so this never becomes a list that has to grow.
|
|
10
|
+
*/
|
|
11
|
+
export interface MsaViewLaunchSpec extends Record<string, unknown> {
|
|
12
|
+
/** default `stack`, the only thing an embedded session can do */
|
|
13
|
+
placement?: MsaViewPlacement
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The one place a launch adds an MSA view -- the dialog's four tabs, the Add
|
|
18
|
+
* menu, and the `LaunchView-MsaView` extension point a session spec arrives on
|
|
19
|
+
* all come through here. Each of them used to run its own `addView` and none
|
|
20
|
+
* placed the result, which is how a launch from a gene feature landed stacked
|
|
21
|
+
* under the very genome view it was connected to.
|
|
22
|
+
*/
|
|
23
|
+
export function launchMsaView(
|
|
24
|
+
session: AbstractSessionModel,
|
|
25
|
+
{ placement = 'stack', ...snapshot }: MsaViewLaunchSpec,
|
|
26
|
+
) {
|
|
27
|
+
const view = session.addView('MsaView', { type: 'MsaView', ...snapshot })
|
|
28
|
+
placeMsaView(session, view.id, placement)
|
|
29
|
+
return view
|
|
30
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { launchMsaView } from './launchMsaView'
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_LAUNCH_PLACEMENT,
|
|
6
|
+
LAUNCH_PLACEMENT_KEY,
|
|
7
|
+
placeMsaView,
|
|
8
|
+
readLaunchPlacement,
|
|
9
|
+
resetWorkspacesWarning,
|
|
10
|
+
sessionSupportsPlacement,
|
|
11
|
+
writeLaunchPlacement,
|
|
12
|
+
} from './workspaces'
|
|
13
|
+
|
|
14
|
+
import type { AbstractSessionModel } from '@jbrowse/core/util'
|
|
15
|
+
|
|
16
|
+
interface Recorded {
|
|
17
|
+
moves: unknown[]
|
|
18
|
+
workspaces: boolean[]
|
|
19
|
+
added: { type: string; snapshot: Record<string, unknown> }[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function makeSession({
|
|
23
|
+
canPlace = true,
|
|
24
|
+
canEnable = true,
|
|
25
|
+
}: { canPlace?: boolean; canEnable?: boolean } = {}) {
|
|
26
|
+
const recorded: Recorded = { moves: [], workspaces: [], added: [] }
|
|
27
|
+
const session: Record<string, unknown> = {
|
|
28
|
+
addView(type: string, snapshot: Record<string, unknown>) {
|
|
29
|
+
recorded.added.push({ type, snapshot })
|
|
30
|
+
return { id: `view-${recorded.added.length}` }
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
if (canPlace) {
|
|
34
|
+
session.setPendingMove = (move: unknown) => recorded.moves.push(move)
|
|
35
|
+
}
|
|
36
|
+
if (canEnable) {
|
|
37
|
+
session.setUseWorkspaces = (on: boolean) => recorded.workspaces.push(on)
|
|
38
|
+
}
|
|
39
|
+
return { session: session as unknown as AbstractSessionModel, recorded }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// node has no localStorage, and the plugin runs in a browser -- a Map-backed
|
|
43
|
+
// stub keeps read/write round-tripping without pulling in jsdom
|
|
44
|
+
function stubStorage() {
|
|
45
|
+
const store = new Map<string, string>()
|
|
46
|
+
vi.stubGlobal('localStorage', {
|
|
47
|
+
getItem: (key: string) => store.get(key) ?? null,
|
|
48
|
+
setItem: (key: string, value: string) => store.set(key, value),
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
resetWorkspacesWarning()
|
|
54
|
+
stubStorage()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
vi.restoreAllMocks()
|
|
59
|
+
vi.unstubAllGlobals()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('stack places nothing, on a host that could tile', () => {
|
|
63
|
+
const { session, recorded } = makeSession()
|
|
64
|
+
placeMsaView(session, 'view-1', 'stack')
|
|
65
|
+
expect(recorded.moves).toEqual([])
|
|
66
|
+
expect(recorded.workspaces).toEqual([])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('splitRight asks for the move, then turns workspaces on', () => {
|
|
70
|
+
const { session, recorded } = makeSession()
|
|
71
|
+
placeMsaView(session, 'view-1', 'splitRight')
|
|
72
|
+
expect(recorded.moves).toEqual([{ type: 'splitRight', viewId: 'view-1' }])
|
|
73
|
+
expect(recorded.workspaces).toEqual([true])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('newTab is the same path with the other move type', () => {
|
|
77
|
+
const { session, recorded } = makeSession()
|
|
78
|
+
placeMsaView(session, 'view-1', 'newTab')
|
|
79
|
+
expect(recorded.moves).toEqual([{ type: 'newTab', viewId: 'view-1' }])
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// an embedded session has no workspaces at all, so there is nothing to report
|
|
83
|
+
test('a session with neither action is a silent no-op', () => {
|
|
84
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
85
|
+
const { session, recorded } = makeSession({
|
|
86
|
+
canPlace: false,
|
|
87
|
+
canEnable: false,
|
|
88
|
+
})
|
|
89
|
+
placeMsaView(session, 'view-1', 'splitRight')
|
|
90
|
+
expect(recorded.moves).toEqual([])
|
|
91
|
+
expect(warn).not.toHaveBeenCalled()
|
|
92
|
+
expect(sessionSupportsPlacement(session)).toBe(false)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// the shape that broke jbrowse-plugin-protein3d silently: workspaces are there,
|
|
96
|
+
// the action this plugin reaches for is not
|
|
97
|
+
test('a half-supported host warns once and stacks', () => {
|
|
98
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
99
|
+
const { session, recorded } = makeSession({ canPlace: false })
|
|
100
|
+
placeMsaView(session, 'view-1', 'splitRight')
|
|
101
|
+
placeMsaView(session, 'view-2', 'splitRight')
|
|
102
|
+
expect(recorded.moves).toEqual([])
|
|
103
|
+
expect(warn).toHaveBeenCalledTimes(1)
|
|
104
|
+
expect(warn.mock.calls[0]?.[0]).toContain('setPendingMove')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('the dialog default is side-by-side, and a junk value falls back to it', () => {
|
|
108
|
+
expect(DEFAULT_LAUNCH_PLACEMENT).toBe('splitRight')
|
|
109
|
+
expect(readLaunchPlacement()).toBe('splitRight')
|
|
110
|
+
localStorage.setItem(LAUNCH_PLACEMENT_KEY, 'sideways')
|
|
111
|
+
expect(readLaunchPlacement()).toBe('splitRight')
|
|
112
|
+
writeLaunchPlacement('stack')
|
|
113
|
+
expect(readLaunchPlacement()).toBe('stack')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('launchMsaView defaults to stack, so a spec written before placement existed is unchanged', () => {
|
|
117
|
+
const { session, recorded } = makeSession()
|
|
118
|
+
launchMsaView(session, { data: { msa: '>a\nAC' } })
|
|
119
|
+
expect(recorded.added).toEqual([
|
|
120
|
+
{ type: 'MsaView', snapshot: { type: 'MsaView', data: { msa: '>a\nAC' } } },
|
|
121
|
+
])
|
|
122
|
+
expect(recorded.moves).toEqual([])
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// placement is a launch instruction, not view state: MST would drop it from the
|
|
126
|
+
// snapshot without a word, and the view would land stacked with nothing said
|
|
127
|
+
test('launchMsaView keeps placement out of the view snapshot', () => {
|
|
128
|
+
const { session, recorded } = makeSession()
|
|
129
|
+
launchMsaView(session, { placement: 'splitRight', colWidth: 10 })
|
|
130
|
+
expect(recorded.added[0]?.snapshot).toEqual({ type: 'MsaView', colWidth: 10 })
|
|
131
|
+
expect(recorded.moves).toEqual([{ type: 'splitRight', viewId: 'view-1' }])
|
|
132
|
+
})
|