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.
Files changed (67) hide show
  1. package/dist/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.js +1 -1
  2. package/dist/LaunchMsaView/components/BlastQuery/BlastManualPanel.d.ts +11 -0
  3. package/dist/LaunchMsaView/components/BlastQuery/BlastManualPanel.js +73 -16
  4. package/dist/LaunchMsaView/components/BlastQuery/blastLaunchView.js +6 -4
  5. package/dist/LaunchMsaView/components/BlastQuery/consts.d.ts +8 -1
  6. package/dist/LaunchMsaView/components/BlastQuery/consts.js +8 -1
  7. package/dist/LaunchMsaView/components/ManualMSALoader/ManualMSALoader.js +8 -15
  8. package/dist/LaunchMsaView/components/ManualMSALoader/launchView.js +4 -2
  9. package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +1 -1
  10. package/dist/LaunchMsaView/components/OrthologQuery/orthologLaunchView.js +4 -2
  11. package/dist/LaunchMsaView/components/PreLoadedMSA/PreLoadedMSADataPanel.js +1 -1
  12. package/dist/LaunchMsaView/components/PreLoadedMSA/preCalculatedLaunchView.js +4 -2
  13. package/dist/LaunchMsaView/components/QueryRowSelector.d.ts +16 -0
  14. package/dist/LaunchMsaView/components/QueryRowSelector.js +38 -0
  15. package/dist/LaunchMsaView/components/SubmitCancelActions.d.ts +4 -1
  16. package/dist/LaunchMsaView/components/SubmitCancelActions.js +34 -10
  17. package/dist/LaunchMsaView/components/SubmitCancelActions.test.d.ts +1 -0
  18. package/dist/LaunchMsaView/components/SubmitCancelActions.test.js +55 -0
  19. package/dist/LaunchMsaView/detectQueryRow.d.ts +23 -0
  20. package/dist/LaunchMsaView/detectQueryRow.js +94 -0
  21. package/dist/LaunchMsaView/detectQueryRow.test.d.ts +1 -0
  22. package/dist/LaunchMsaView/detectQueryRow.test.js +65 -0
  23. package/dist/LaunchMsaView/useQueryRowName.d.ts +15 -0
  24. package/dist/LaunchMsaView/useQueryRowName.js +26 -0
  25. package/dist/LaunchMsaViewExtensionPoint/index.js +2 -2
  26. package/dist/MsaViewPanel/afterCreateAutoruns.d.ts +27 -11
  27. package/dist/MsaViewPanel/afterCreateAutoruns.js +96 -47
  28. package/dist/MsaViewPanel/observeProteinHighlights.test.d.ts +1 -0
  29. package/dist/MsaViewPanel/observeProteinHighlights.test.js +209 -0
  30. package/dist/MsaViewPanel/structureConnection.d.ts +6 -0
  31. package/dist/index.js +4 -1
  32. package/dist/jbrowse-plugin-msaview.umd.production.min.js +28 -28
  33. package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
  34. package/dist/utils/launchMsaView.d.ts +19 -0
  35. package/dist/utils/launchMsaView.js +13 -0
  36. package/dist/utils/workspaces.d.ts +34 -0
  37. package/dist/utils/workspaces.js +100 -0
  38. package/dist/utils/workspaces.test.d.ts +1 -0
  39. package/dist/utils/workspaces.test.js +100 -0
  40. package/dist/version.d.ts +1 -1
  41. package/dist/version.js +1 -1
  42. package/package.json +6 -1
  43. package/src/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.tsx +1 -0
  44. package/src/LaunchMsaView/components/BlastQuery/BlastManualPanel.tsx +127 -30
  45. package/src/LaunchMsaView/components/BlastQuery/blastLaunchView.ts +7 -4
  46. package/src/LaunchMsaView/components/BlastQuery/consts.ts +8 -1
  47. package/src/LaunchMsaView/components/ManualMSALoader/ManualMSALoader.tsx +8 -37
  48. package/src/LaunchMsaView/components/ManualMSALoader/launchView.ts +5 -2
  49. package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +1 -1
  50. package/src/LaunchMsaView/components/OrthologQuery/orthologLaunchView.ts +5 -2
  51. package/src/LaunchMsaView/components/PreLoadedMSA/PreLoadedMSADataPanel.tsx +1 -0
  52. package/src/LaunchMsaView/components/PreLoadedMSA/preCalculatedLaunchView.ts +5 -2
  53. package/src/LaunchMsaView/components/QueryRowSelector.tsx +93 -0
  54. package/src/LaunchMsaView/components/SubmitCancelActions.test.tsx +95 -0
  55. package/src/LaunchMsaView/components/SubmitCancelActions.tsx +80 -22
  56. package/src/LaunchMsaView/detectQueryRow.test.ts +79 -0
  57. package/src/LaunchMsaView/detectQueryRow.ts +132 -0
  58. package/src/LaunchMsaView/useQueryRowName.ts +33 -0
  59. package/src/LaunchMsaViewExtensionPoint/index.ts +21 -2
  60. package/src/MsaViewPanel/afterCreateAutoruns.ts +106 -51
  61. package/src/MsaViewPanel/observeProteinHighlights.test.ts +264 -0
  62. package/src/MsaViewPanel/structureConnection.ts +7 -0
  63. package/src/index.ts +4 -1
  64. package/src/utils/launchMsaView.ts +30 -0
  65. package/src/utils/workspaces.test.ts +132 -0
  66. package/src/utils/workspaces.ts +146 -0
  67. 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).addView('MsaView', {
27
- type: 'MsaView',
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).addView('MsaView', {
19
- type: 'MsaView',
21
+ launchMsaView(getSession(view), {
22
+ placement: readLaunchPlacement(),
20
23
  displayName: newViewTitle,
21
24
  connectedViewId: view.id,
22
25
  connectedFeature: feature.toJSON(),
@@ -130,6 +130,7 @@ const PreLoadedMSA = observer(function ({
130
130
  </LaunchPanelContent>
131
131
 
132
132
  <SubmitCancelActions
133
+ model={model}
133
134
  submitDisabled={!selectedTranscript || !msaData?.length}
134
135
  onSubmit={() => {
135
136
  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 { 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).addView('MsaView', {
20
- type: 'MsaView',
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 { Button, DialogActions } from '@mui/material'
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
- <DialogActions>
20
- <Button
21
- color="primary"
22
- variant="contained"
23
- disabled={submitDisabled}
24
- onClick={() => {
25
- onSubmit()
26
- }}
27
- >
28
- {submitLabel}
29
- </Button>
30
- <Button
31
- color="secondary"
32
- variant="contained"
33
- onClick={() => {
34
- onCancel()
35
- }}
36
- >
37
- {cancelLabel}
38
- </Button>
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.addView('MsaView', {
80
- type: 'MsaView',
99
+ launchMsaView(session, {
81
100
  ...rest,
82
101
  data,
83
102
  ...(treeFileLocation