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
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { getSession } from '@jbrowse/core/util'
|
|
2
2
|
|
|
3
|
+
import { launchMsaView } from '../../../utils/launchMsaView'
|
|
4
|
+
import { readLaunchPlacement } from '../../../utils/workspaces'
|
|
5
|
+
|
|
3
6
|
import type { Feature, FileLocation } from '@jbrowse/core/util'
|
|
4
7
|
import type { LinearGenomeViewModel } from '@jbrowse/plugin-linear-genome-view'
|
|
5
8
|
|
|
@@ -23,8 +26,8 @@ export function launchView({
|
|
|
23
26
|
tree?: string
|
|
24
27
|
}
|
|
25
28
|
}) {
|
|
26
|
-
getSession(view)
|
|
27
|
-
|
|
29
|
+
launchMsaView(getSession(view), {
|
|
30
|
+
placement: readLaunchPlacement(),
|
|
28
31
|
displayName: newViewTitle,
|
|
29
32
|
connectedViewId: view.id,
|
|
30
33
|
connectedFeature: feature.toJSON(),
|
|
@@ -98,9 +98,9 @@ const OrthologPanel = observer(function ({
|
|
|
98
98
|
</div>
|
|
99
99
|
|
|
100
100
|
<TranscriptSelector feature={feature} {...transcriptSelection} />
|
|
101
|
-
|
|
102
101
|
</LaunchPanelContent>
|
|
103
102
|
<SubmitCancelActions
|
|
103
|
+
model={model}
|
|
104
104
|
submitDisabled={!proteinSequence || !rowCountValid}
|
|
105
105
|
onSubmit={() => {
|
|
106
106
|
try {
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { getSession } from '@jbrowse/core/util'
|
|
2
2
|
|
|
3
|
+
import { launchMsaView } from '../../../utils/launchMsaView'
|
|
4
|
+
import { readLaunchPlacement } from '../../../utils/workspaces'
|
|
5
|
+
|
|
3
6
|
import type { OrthologParams } from '../../../MsaViewPanel/model'
|
|
4
7
|
import type { Feature } from '@jbrowse/core/util'
|
|
5
8
|
import type { LinearGenomeViewModel } from '@jbrowse/plugin-linear-genome-view'
|
|
@@ -15,8 +18,8 @@ export function orthologLaunchView({
|
|
|
15
18
|
feature: Feature
|
|
16
19
|
orthologParams: OrthologParams
|
|
17
20
|
}) {
|
|
18
|
-
getSession(view)
|
|
19
|
-
|
|
21
|
+
launchMsaView(getSession(view), {
|
|
22
|
+
placement: readLaunchPlacement(),
|
|
20
23
|
displayName: newViewTitle,
|
|
21
24
|
connectedViewId: view.id,
|
|
22
25
|
connectedFeature: feature.toJSON(),
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { getSession } from '@jbrowse/core/util'
|
|
2
2
|
|
|
3
|
+
import { launchMsaView } from '../../../utils/launchMsaView'
|
|
4
|
+
import { readLaunchPlacement } from '../../../utils/workspaces'
|
|
5
|
+
|
|
3
6
|
import type { Feature } from '@jbrowse/core/util'
|
|
4
7
|
import type { LinearGenomeViewModel } from '@jbrowse/plugin-linear-genome-view'
|
|
5
8
|
|
|
@@ -16,8 +19,8 @@ export function preCalculatedLaunchView({
|
|
|
16
19
|
feature: Feature
|
|
17
20
|
querySeqName: string
|
|
18
21
|
}) {
|
|
19
|
-
getSession(view)
|
|
20
|
-
|
|
22
|
+
launchMsaView(getSession(view), {
|
|
23
|
+
placement: readLaunchPlacement(),
|
|
21
24
|
displayName: newViewTitle,
|
|
22
25
|
treeAreaWidth: 200,
|
|
23
26
|
querySeqName,
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
import { Alert, MenuItem } from '@mui/material'
|
|
4
|
+
import { makeStyles } from 'tss-react/mui'
|
|
5
|
+
|
|
6
|
+
import TextField2 from '../../components/TextField2'
|
|
7
|
+
|
|
8
|
+
import type { QueryRowMatch } from '../detectQueryRow'
|
|
9
|
+
|
|
10
|
+
const useStyles = makeStyles()({
|
|
11
|
+
field: {
|
|
12
|
+
marginTop: 20,
|
|
13
|
+
},
|
|
14
|
+
alert: {
|
|
15
|
+
marginTop: 10,
|
|
16
|
+
},
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Which MSA row corresponds to the selected transcript. Clicking and hovering in
|
|
21
|
+
* the alignment reach the genome only through this name, and a wrong one fails
|
|
22
|
+
* silently -- the view opens, renders, and never navigates -- so the field fills
|
|
23
|
+
* itself in from the pasted alignment and offers that alignment's own row names
|
|
24
|
+
* rather than a free text box the user can typo.
|
|
25
|
+
*/
|
|
26
|
+
export default function QueryRowSelector({
|
|
27
|
+
names,
|
|
28
|
+
detected,
|
|
29
|
+
querySeqName,
|
|
30
|
+
setQuerySeqName,
|
|
31
|
+
isAutoDetected,
|
|
32
|
+
}: {
|
|
33
|
+
names: string[]
|
|
34
|
+
detected?: QueryRowMatch
|
|
35
|
+
querySeqName: string
|
|
36
|
+
setQuerySeqName: (arg: string) => void
|
|
37
|
+
isAutoDetected: boolean
|
|
38
|
+
}) {
|
|
39
|
+
const { classes } = useStyles()
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<>
|
|
43
|
+
{names.length > 0 ? (
|
|
44
|
+
<TextField2
|
|
45
|
+
variant="outlined"
|
|
46
|
+
label="MSA row matching the selected transcript"
|
|
47
|
+
select
|
|
48
|
+
fullWidth
|
|
49
|
+
className={classes.field}
|
|
50
|
+
value={names.includes(querySeqName) ? querySeqName : ''}
|
|
51
|
+
onChange={event => {
|
|
52
|
+
setQuerySeqName(event.target.value)
|
|
53
|
+
}}
|
|
54
|
+
>
|
|
55
|
+
{names.map(name => (
|
|
56
|
+
<MenuItem value={name} key={name}>
|
|
57
|
+
{name}
|
|
58
|
+
{detected?.name === name ? ' — matches your protein' : ''}
|
|
59
|
+
</MenuItem>
|
|
60
|
+
))}
|
|
61
|
+
</TextField2>
|
|
62
|
+
) : (
|
|
63
|
+
<TextField2
|
|
64
|
+
variant="outlined"
|
|
65
|
+
label="MSA row matching the selected transcript"
|
|
66
|
+
fullWidth
|
|
67
|
+
className={classes.field}
|
|
68
|
+
helperText="Paste an alignment above and this fills in on its own"
|
|
69
|
+
value={querySeqName}
|
|
70
|
+
onChange={event => {
|
|
71
|
+
setQuerySeqName(event.target.value)
|
|
72
|
+
}}
|
|
73
|
+
/>
|
|
74
|
+
)}
|
|
75
|
+
|
|
76
|
+
{isAutoDetected && detected ? (
|
|
77
|
+
<Alert severity="success" className={classes.alert}>
|
|
78
|
+
Matched <strong>{detected.name}</strong> to your protein sequence
|
|
79
|
+
{detected.quality === 'exact'
|
|
80
|
+
? ''
|
|
81
|
+
: `, covering ${Math.round(detected.identity * 100)}% of it`}
|
|
82
|
+
. Clicking the alignment will navigate the genome view.
|
|
83
|
+
</Alert>
|
|
84
|
+
) : names.length > 0 && !querySeqName ? (
|
|
85
|
+
<Alert severity="warning" className={classes.alert}>
|
|
86
|
+
No row matched your protein sequence — pick the one for your gene
|
|
87
|
+
above. Without it the alignment still renders, but clicking it will
|
|
88
|
+
not navigate the genome view.
|
|
89
|
+
</Alert>
|
|
90
|
+
) : null}
|
|
91
|
+
</>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react'
|
|
3
|
+
|
|
4
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
5
|
+
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
|
|
6
|
+
|
|
7
|
+
import SubmitCancelActions from './SubmitCancelActions'
|
|
8
|
+
import { LAUNCH_PLACEMENT_KEY } from '../../utils/workspaces'
|
|
9
|
+
|
|
10
|
+
import type { AbstractTrackModel } from '@jbrowse/core/util'
|
|
11
|
+
|
|
12
|
+
// getSession walks the MST tree, and this component only wants the two actions
|
|
13
|
+
// off the far end of that walk
|
|
14
|
+
vi.mock('@jbrowse/core/util', () => ({
|
|
15
|
+
getSession: (model: { session: unknown }) => model.session,
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
function trackModel(session: Record<string, unknown>) {
|
|
19
|
+
return { session } as unknown as AbstractTrackModel
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const tiling = { setUseWorkspaces() {}, setPendingMove() {} }
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
localStorage.clear()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
cleanup()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
function toggle() {
|
|
33
|
+
return screen.queryByRole('checkbox')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test('a host that can tile offers the choice, checked by default', () => {
|
|
37
|
+
render(
|
|
38
|
+
<SubmitCancelActions
|
|
39
|
+
model={trackModel(tiling)}
|
|
40
|
+
onSubmit={() => {}}
|
|
41
|
+
onCancel={() => {}}
|
|
42
|
+
/>,
|
|
43
|
+
)
|
|
44
|
+
expect(toggle()).toBeTruthy()
|
|
45
|
+
expect((toggle() as HTMLInputElement).checked).toBe(true)
|
|
46
|
+
expect(localStorage.getItem(LAUNCH_PLACEMENT_KEY)).toBeNull()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// the box would do nothing on an embedded session, and a control that silently
|
|
50
|
+
// does nothing is worse than one that is not there
|
|
51
|
+
test('a host that cannot tile does not offer it', () => {
|
|
52
|
+
render(
|
|
53
|
+
<SubmitCancelActions
|
|
54
|
+
model={trackModel({})}
|
|
55
|
+
onSubmit={() => {}}
|
|
56
|
+
onCancel={() => {}}
|
|
57
|
+
/>,
|
|
58
|
+
)
|
|
59
|
+
expect(toggle()).toBeNull()
|
|
60
|
+
expect(screen.getByText('Submit')).toBeTruthy()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('a panel that launches nothing passes no model, and gets no box', () => {
|
|
64
|
+
render(<SubmitCancelActions onSubmit={() => {}} onCancel={() => {}} />)
|
|
65
|
+
expect(toggle()).toBeNull()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('clicking it writes the placement the next launch will read', () => {
|
|
69
|
+
render(
|
|
70
|
+
<SubmitCancelActions
|
|
71
|
+
model={trackModel(tiling)}
|
|
72
|
+
onSubmit={() => {}}
|
|
73
|
+
onCancel={() => {}}
|
|
74
|
+
/>,
|
|
75
|
+
)
|
|
76
|
+
toggle()!.click()
|
|
77
|
+
expect(localStorage.getItem(LAUNCH_PLACEMENT_KEY)).toBe('stack')
|
|
78
|
+
expect((toggle() as HTMLInputElement).checked).toBe(false)
|
|
79
|
+
|
|
80
|
+
toggle()!.click()
|
|
81
|
+
expect(localStorage.getItem(LAUNCH_PLACEMENT_KEY)).toBe('splitRight')
|
|
82
|
+
expect((toggle() as HTMLInputElement).checked).toBe(true)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('a stored choice is what the box opens on', () => {
|
|
86
|
+
localStorage.setItem(LAUNCH_PLACEMENT_KEY, 'stack')
|
|
87
|
+
render(
|
|
88
|
+
<SubmitCancelActions
|
|
89
|
+
model={trackModel(tiling)}
|
|
90
|
+
onSubmit={() => {}}
|
|
91
|
+
onCancel={() => {}}
|
|
92
|
+
/>,
|
|
93
|
+
)
|
|
94
|
+
expect((toggle() as HTMLInputElement).checked).toBe(false)
|
|
95
|
+
})
|
|
@@ -1,6 +1,53 @@
|
|
|
1
|
-
import React from 'react'
|
|
1
|
+
import React, { useState } from 'react'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { getSession } from '@jbrowse/core/util'
|
|
4
|
+
import {
|
|
5
|
+
Button,
|
|
6
|
+
Checkbox,
|
|
7
|
+
DialogActions,
|
|
8
|
+
FormControlLabel,
|
|
9
|
+
} from '@mui/material'
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
readLaunchPlacement,
|
|
13
|
+
sessionSupportsPlacement,
|
|
14
|
+
writeLaunchPlacement,
|
|
15
|
+
} from '../../utils/workspaces'
|
|
16
|
+
|
|
17
|
+
import type { AbstractTrackModel } from '@jbrowse/core/util'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Where the launch puts the view, offered wherever a launch is submitted.
|
|
21
|
+
*
|
|
22
|
+
* A checkbox rather than a menu of the three placements: the choice a reader
|
|
23
|
+
* has at this point is "beside the genome view or under it", and `newTab` is a
|
|
24
|
+
* spec's to state, not a thing to pick before you have seen the alignment.
|
|
25
|
+
*
|
|
26
|
+
* Absent entirely on a host that cannot tile — an embedded session, or a
|
|
27
|
+
* release that places views its own way — because the box would do nothing
|
|
28
|
+
* there and every launch would quietly ignore it.
|
|
29
|
+
*/
|
|
30
|
+
function PlacementToggle({ model }: { model: AbstractTrackModel }) {
|
|
31
|
+
const session = getSession(model)
|
|
32
|
+
const [sideBySide, setSideBySide] = useState(
|
|
33
|
+
() => readLaunchPlacement() === 'splitRight',
|
|
34
|
+
)
|
|
35
|
+
return sessionSupportsPlacement(session) ? (
|
|
36
|
+
<FormControlLabel
|
|
37
|
+
label="Open beside the genome view"
|
|
38
|
+
control={
|
|
39
|
+
<Checkbox
|
|
40
|
+
checked={sideBySide}
|
|
41
|
+
onChange={event => {
|
|
42
|
+
const { checked } = event.target
|
|
43
|
+
setSideBySide(checked)
|
|
44
|
+
writeLaunchPlacement(checked ? 'splitRight' : 'stack')
|
|
45
|
+
}}
|
|
46
|
+
/>
|
|
47
|
+
}
|
|
48
|
+
/>
|
|
49
|
+
) : null
|
|
50
|
+
}
|
|
4
51
|
|
|
5
52
|
export default function SubmitCancelActions({
|
|
6
53
|
onSubmit,
|
|
@@ -8,34 +55,45 @@ export default function SubmitCancelActions({
|
|
|
8
55
|
submitDisabled,
|
|
9
56
|
submitLabel = 'Submit',
|
|
10
57
|
cancelLabel = 'Cancel',
|
|
58
|
+
model,
|
|
11
59
|
}: {
|
|
12
60
|
onSubmit: () => void
|
|
13
61
|
onCancel: () => void
|
|
14
62
|
submitDisabled?: boolean
|
|
15
63
|
submitLabel?: string
|
|
16
64
|
cancelLabel?: string
|
|
65
|
+
/** omitted by a panel that submits something other than a view launch */
|
|
66
|
+
model?: AbstractTrackModel
|
|
17
67
|
}) {
|
|
18
68
|
return (
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
69
|
+
// The buttons are one child rather than two, so a dialog too narrow for
|
|
70
|
+
// the whole row wraps them together underneath the option instead of
|
|
71
|
+
// breaking Cancel away from Submit or shrinking both out of shape.
|
|
72
|
+
<DialogActions sx={{ flexWrap: 'wrap', rowGap: 1 }}>
|
|
73
|
+
{model ? <PlacementToggle model={model} /> : null}
|
|
74
|
+
<div style={{ display: 'flex', gap: 8, marginLeft: 'auto' }}>
|
|
75
|
+
<Button
|
|
76
|
+
sx={{ flexShrink: 0 }}
|
|
77
|
+
color="primary"
|
|
78
|
+
variant="contained"
|
|
79
|
+
disabled={submitDisabled}
|
|
80
|
+
onClick={() => {
|
|
81
|
+
onSubmit()
|
|
82
|
+
}}
|
|
83
|
+
>
|
|
84
|
+
{submitLabel}
|
|
85
|
+
</Button>
|
|
86
|
+
<Button
|
|
87
|
+
sx={{ flexShrink: 0 }}
|
|
88
|
+
color="secondary"
|
|
89
|
+
variant="contained"
|
|
90
|
+
onClick={() => {
|
|
91
|
+
onCancel()
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
94
|
+
{cancelLabel}
|
|
95
|
+
</Button>
|
|
96
|
+
</div>
|
|
39
97
|
</DialogActions>
|
|
40
98
|
)
|
|
41
99
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { detectQueryRow, getMsaRowNames } from './detectQueryRow'
|
|
4
|
+
|
|
5
|
+
const protein = 'MKWVTFISLLLLFSSAYSRGVFRRDTHKSEIAHRFKDLGEEHFKGLVLIAFSQYLQQCPFD'
|
|
6
|
+
|
|
7
|
+
// COBALT renames the query `Query_1`, so only the residues identify it
|
|
8
|
+
const clustal = `CLUSTAL W (1.81) multiple sequence alignment
|
|
9
|
+
|
|
10
|
+
Query_1 MKWVTFISLLLLFSSAYSRGVFRRDTHKSEIAHRFKDLGEEHFKGLVLIAFSQYLQQCPFD
|
|
11
|
+
sp|P02769|ALBU MKWVTFISLLLLFSSAYSRGVFRRDTHKSEIAHRFKDLGEEHFKGLVLIAFSQYLQQCPYD
|
|
12
|
+
sp|Q5XLE4|OTHE MKWVTFISLLLLFSSAYSRGVFRRDTHKSEIAHRFKDLGEEHFKGLVLIAFSQYLWWCPFD
|
|
13
|
+
`
|
|
14
|
+
|
|
15
|
+
const fasta = `>Query_1
|
|
16
|
+
MKWVTFISLLLLFSSAYSRGVFRRDTHKSEIAHRFKDLGEEHFKGLVLIAFSQYLQQCPFD
|
|
17
|
+
>sp|P02769|ALBU_BOVIN
|
|
18
|
+
MKWVTFISLLLLFSSAYSRG--RRDTHKSEIAHRFKDLGEEHFKGLVLIAFSQYLQQCPYD
|
|
19
|
+
`
|
|
20
|
+
|
|
21
|
+
describe('detectQueryRow', () => {
|
|
22
|
+
test('finds the query by sequence when the aligner renamed it', () => {
|
|
23
|
+
expect(detectQueryRow(clustal, protein)).toMatchObject({
|
|
24
|
+
name: 'Query_1',
|
|
25
|
+
quality: 'exact',
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('ignores gaps in the aligned row', () => {
|
|
30
|
+
expect(detectQueryRow(fasta, protein)?.name).toBe('Query_1')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('tolerates the trailing stop codon the translation carries', () => {
|
|
34
|
+
expect(detectQueryRow(clustal, `${protein}*`)?.name).toBe('Query_1')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('matches a row that is the query trimmed to the aligned region', () => {
|
|
38
|
+
const trimmed = `>hit_one\nWRONGWRONGWRONGWRONG\n>aligned_query\n${protein.slice(5, 40)}\n`
|
|
39
|
+
expect(detectQueryRow(trimmed, protein)).toMatchObject({
|
|
40
|
+
name: 'aligned_query',
|
|
41
|
+
quality: 'partial',
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// the failure that matters: silently wiring the view to a homolog would look
|
|
46
|
+
// like it worked, and every navigation afterwards would land in the wrong place
|
|
47
|
+
test('returns nothing when only diverged homologs are present', () => {
|
|
48
|
+
const homologsOnly = `>hit_one
|
|
49
|
+
MKWVTFISLLLLFSSAYSRGVFRRDTHKSEIAHRFKDLGEEHFKGLVLIAFSQYLQQCPFD
|
|
50
|
+
>hit_two
|
|
51
|
+
MKWVTFISLLLLFSSAYSRGVFRRDTHKSEIAHRFKDLGEEHFKGLVLIAFSQYLQQCPFD
|
|
52
|
+
`
|
|
53
|
+
expect(
|
|
54
|
+
detectQueryRow(homologsOnly, 'WWWWWWWWWWWWWWWWWWWWWWWWWWWWWW'),
|
|
55
|
+
).toBeUndefined()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('returns nothing rather than throwing on a half-pasted alignment', () => {
|
|
59
|
+
expect(detectQueryRow('>partial\nMKWV', protein)).toBeUndefined()
|
|
60
|
+
expect(detectQueryRow('not an alignment at all', protein)).toBeUndefined()
|
|
61
|
+
expect(detectQueryRow('', protein)).toBeUndefined()
|
|
62
|
+
expect(detectQueryRow(clustal, '')).toBeUndefined()
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('getMsaRowNames', () => {
|
|
67
|
+
test('lists the rows for the override dropdown', () => {
|
|
68
|
+
expect(getMsaRowNames(clustal)).toEqual([
|
|
69
|
+
'Query_1',
|
|
70
|
+
'sp|P02769|ALBU',
|
|
71
|
+
'sp|Q5XLE4|OTHE',
|
|
72
|
+
])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('is empty rather than throwing while the user is still pasting', () => {
|
|
76
|
+
expect(getMsaRowNames('CLUSTAL W')).toEqual([])
|
|
77
|
+
expect(getMsaRowNames('')).toEqual([])
|
|
78
|
+
})
|
|
79
|
+
})
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { getUngappedSequence, parseMSA } from 'msa-parsers'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which row of a pasted alignment is the gene the user launched from.
|
|
5
|
+
*
|
|
6
|
+
* The MsaView needs that row name to tie alignment columns back to genome
|
|
7
|
+
* coordinates, and until now the user typed it. Nothing validates a typo: the
|
|
8
|
+
* view opens, renders, and simply never navigates or highlights, which reads as
|
|
9
|
+
* a broken feature rather than a wrong field. Meanwhile the plugin already
|
|
10
|
+
* knows the protein sequence it sent to BLAST, so it can find the row by
|
|
11
|
+
* sequence instead of asking.
|
|
12
|
+
*
|
|
13
|
+
* NCBI and EBI both rename the query on the way through -- COBALT emits
|
|
14
|
+
* `Query_1`, EBI's aligners carry the accession -- so the name is no help. The
|
|
15
|
+
* residues are, and they survive every rename.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type MatchQuality = 'exact' | 'partial' | 'similar'
|
|
19
|
+
|
|
20
|
+
export interface QueryRowMatch {
|
|
21
|
+
name: string
|
|
22
|
+
quality: MatchQuality
|
|
23
|
+
/** identity over the compared region, 0-1 */
|
|
24
|
+
identity: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A stop codon is present in the transcript's translation and absent from
|
|
29
|
+
* anything an aligner returns, and case is not meaningful in either.
|
|
30
|
+
*/
|
|
31
|
+
function normalize(seq: string) {
|
|
32
|
+
return seq
|
|
33
|
+
.replaceAll('*', '')
|
|
34
|
+
.replaceAll('-', '')
|
|
35
|
+
.replaceAll('.', '')
|
|
36
|
+
.toUpperCase()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function identityOverOverlap(a: string, b: string) {
|
|
40
|
+
const len = Math.min(a.length, b.length)
|
|
41
|
+
if (len === 0) {
|
|
42
|
+
return 0
|
|
43
|
+
}
|
|
44
|
+
let same = 0
|
|
45
|
+
for (let i = 0; i < len; i++) {
|
|
46
|
+
if (a[i] === b[i]) {
|
|
47
|
+
same++
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return same / len
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Below this, a "best" row is not a match at all -- an alignment of homologs is
|
|
55
|
+
* full of rows in the 40-70% range, and picking the top one would silently wire
|
|
56
|
+
* the view to a paralog from another species.
|
|
57
|
+
*/
|
|
58
|
+
const SIMILARITY_FLOOR = 0.9
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* How much of the query a contained row has to cover. A short fragment is a
|
|
62
|
+
* substring of almost any protein, so without a floor the first few residues of
|
|
63
|
+
* a half-pasted alignment match the query and the field fills in with a row the
|
|
64
|
+
* user is still typing.
|
|
65
|
+
*/
|
|
66
|
+
const PARTIAL_COVERAGE_FLOOR = 0.5
|
|
67
|
+
|
|
68
|
+
export function detectQueryRow(
|
|
69
|
+
msaText: string,
|
|
70
|
+
proteinSequence: string,
|
|
71
|
+
): QueryRowMatch | undefined {
|
|
72
|
+
const query = normalize(proteinSequence)
|
|
73
|
+
if (!query || !msaText.trim()) {
|
|
74
|
+
return undefined
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let names: string[]
|
|
78
|
+
let parsed: { getRow: (name: string) => string }
|
|
79
|
+
try {
|
|
80
|
+
const msa = parseMSA(msaText)
|
|
81
|
+
names = msa.getNames()
|
|
82
|
+
parsed = msa
|
|
83
|
+
} catch {
|
|
84
|
+
// a half-pasted alignment throws here on every keystroke; the caller shows
|
|
85
|
+
// the field rather than an error
|
|
86
|
+
return undefined
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const candidates: QueryRowMatch[] = []
|
|
90
|
+
for (const name of names) {
|
|
91
|
+
const row = normalize(getUngappedSequence(parsed.getRow(name)))
|
|
92
|
+
if (!row) {
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
if (row === query) {
|
|
96
|
+
// nothing beats an exact match, and a second one would be a duplicate row
|
|
97
|
+
return { name, quality: 'exact', identity: 1 }
|
|
98
|
+
}
|
|
99
|
+
// BLAST reports the aligned region, so the row is often the query trimmed
|
|
100
|
+
// at one or both ends rather than the whole protein
|
|
101
|
+
if (query.includes(row) || row.includes(query)) {
|
|
102
|
+
const coverage =
|
|
103
|
+
Math.min(row.length, query.length) / Math.max(row.length, query.length)
|
|
104
|
+
if (coverage >= PARTIAL_COVERAGE_FLOOR) {
|
|
105
|
+
candidates.push({ name, quality: 'partial', identity: coverage })
|
|
106
|
+
}
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
const identity = identityOverOverlap(row, query)
|
|
110
|
+
if (identity >= SIMILARITY_FLOOR) {
|
|
111
|
+
candidates.push({ name, quality: 'similar', identity })
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const order: MatchQuality[] = ['exact', 'partial', 'similar']
|
|
116
|
+
return candidates.sort(
|
|
117
|
+
(a, b) =>
|
|
118
|
+
order.indexOf(a.quality) - order.indexOf(b.quality) ||
|
|
119
|
+
b.identity - a.identity,
|
|
120
|
+
)[0]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function getMsaRowNames(msaText: string): string[] {
|
|
124
|
+
if (!msaText.trim()) {
|
|
125
|
+
return []
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
return parseMSA(msaText).getNames()
|
|
129
|
+
} catch {
|
|
130
|
+
return []
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useMemo, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import { detectQueryRow, getMsaRowNames } from './detectQueryRow'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The MSA row name to launch with, found by sequence rather than typed.
|
|
7
|
+
*
|
|
8
|
+
* Only the user's override is state. The detected name is derived from the
|
|
9
|
+
* pasted text during render, so pasting a new alignment re-detects without an
|
|
10
|
+
* effect writing back into state, and an override survives later edits to the
|
|
11
|
+
* alignment because it is the one thing actually stored.
|
|
12
|
+
*/
|
|
13
|
+
export function useQueryRowName(msaText: string, proteinSequence: string) {
|
|
14
|
+
const [override, setOverride] = useState<string>()
|
|
15
|
+
|
|
16
|
+
// parsing runs on every keystroke in the paste box otherwise, and an
|
|
17
|
+
// alignment of a few hundred rows is not free
|
|
18
|
+
const { detected, names } = useMemo(
|
|
19
|
+
() => ({
|
|
20
|
+
detected: detectQueryRow(msaText, proteinSequence),
|
|
21
|
+
names: getMsaRowNames(msaText),
|
|
22
|
+
}),
|
|
23
|
+
[msaText, proteinSequence],
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
detected,
|
|
28
|
+
names,
|
|
29
|
+
querySeqName: override ?? detected?.name ?? '',
|
|
30
|
+
setQuerySeqName: setOverride,
|
|
31
|
+
isAutoDetected: override === undefined && !!detected,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { launchMsaView } from '../utils/launchMsaView'
|
|
2
|
+
|
|
1
3
|
import type { OrthologParams } from '../MsaViewPanel/model'
|
|
4
|
+
import type { MsaViewPlacement } from '../utils/workspaces'
|
|
2
5
|
import type PluginManager from '@jbrowse/core/PluginManager'
|
|
3
6
|
import type { AbstractSessionModel } from '@jbrowse/core/util'
|
|
4
7
|
|
|
@@ -39,6 +42,23 @@ interface LaunchMsaViewArgs {
|
|
|
39
42
|
* snapshot property passes through the same way.
|
|
40
43
|
*/
|
|
41
44
|
allowedGappyness?: number
|
|
45
|
+
/**
|
|
46
|
+
* Where the view lands: `stack` (default), `splitRight` or `newTab`. The
|
|
47
|
+
* declarative half of the launch — a link states the arrangement it wants
|
|
48
|
+
* instead of the reader dragging the view into place.
|
|
49
|
+
*
|
|
50
|
+
* The default is `stack` and has to stay `stack`: it is what every link
|
|
51
|
+
* written before this key existed already does, and the only thing an
|
|
52
|
+
* embedded session can do. The launch DIALOG defaults to `splitRight`
|
|
53
|
+
* instead (see `DEFAULT_LAUNCH_PLACEMENT`), because a launch from a gene
|
|
54
|
+
* feature is a connected pair and reads as a split.
|
|
55
|
+
*
|
|
56
|
+
* A spec that arranges more than these two views should use the host's own
|
|
57
|
+
* `layout` key instead, which states the whole tree and is applied after
|
|
58
|
+
* every view in the spec has launched. Both may be given; `layout` wins,
|
|
59
|
+
* being the later and more specific statement.
|
|
60
|
+
*/
|
|
61
|
+
placement?: MsaViewPlacement
|
|
42
62
|
}
|
|
43
63
|
|
|
44
64
|
export default function LaunchMsaViewExtensionPointF(
|
|
@@ -76,8 +96,7 @@ export default function LaunchMsaViewExtensionPointF(
|
|
|
76
96
|
// directly, and so is orthologParams (the model's own autorun picks it up).
|
|
77
97
|
// Only sources needing launch-time resolution go through `init`: msaUrl
|
|
78
98
|
// (AlphaFold sniff) and the name-indexed bgzip block (no native loader).
|
|
79
|
-
session
|
|
80
|
-
type: 'MsaView',
|
|
99
|
+
launchMsaView(session, {
|
|
81
100
|
...rest,
|
|
82
101
|
data,
|
|
83
102
|
...(treeFileLocation
|