jbrowse-plugin-protein3d 0.4.11 → 0.4.13

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 (52) hide show
  1. package/dist/LaunchProteinView/components/LaunchSettingsDialog.d.ts +5 -0
  2. package/dist/LaunchProteinView/components/LaunchSettingsDialog.js +23 -0
  3. package/dist/LaunchProteinView/components/ProteinViewActions.js +13 -2
  4. package/dist/LaunchProteinView/hooks/useAlphaFoldDBSearch.js +12 -3
  5. package/dist/LaunchProteinView/hooks/useAlphaFoldData.d.ts +1 -0
  6. package/dist/LaunchProteinView/hooks/useAlphaFoldData.js +2 -1
  7. package/dist/LaunchProteinView/hooks/useAlphaFoldSequenceSearch.d.ts +1 -0
  8. package/dist/LaunchProteinView/hooks/useAlphaFoldSequenceSearch.js +2 -1
  9. package/dist/LaunchProteinView/hooks/useFoldseekSearch.js +47 -12
  10. package/dist/LaunchProteinView/hooks/useStructureFileSequence.d.ts +1 -0
  11. package/dist/LaunchProteinView/hooks/useStructureFileSequence.js +5 -2
  12. package/dist/LaunchProteinView/services/foldseekApi.d.ts +23 -5
  13. package/dist/LaunchProteinView/services/foldseekApi.js +21 -13
  14. package/dist/LaunchProteinView/utils/launchViewUtils.d.ts +2 -1
  15. package/dist/LaunchProteinView/utils/launchViewUtils.js +7 -2
  16. package/dist/LaunchProteinView/utils/sideBySide.d.ts +11 -0
  17. package/dist/LaunchProteinView/utils/sideBySide.js +33 -0
  18. package/dist/LaunchProteinViewExtensionPoint/index.js +9 -2
  19. package/dist/ProteinView/applyColorTheme.d.ts +1 -1
  20. package/dist/ProteinView/loadStructureData.d.ts +18 -0
  21. package/dist/ProteinView/loadStructureData.js +22 -0
  22. package/dist/ProteinView/model.d.ts +2 -2
  23. package/dist/ProteinView/model.js +6 -36
  24. package/dist/ProteinView/structureLoader.d.ts +30 -0
  25. package/dist/ProteinView/structureLoader.js +58 -0
  26. package/dist/config.json +1 -1
  27. package/dist/fetchUtils.d.ts +1 -1
  28. package/dist/fetchUtils.js +18 -2
  29. package/dist/jbrowse-plugin-protein3d.umd.production.min.js +15 -15
  30. package/dist/jbrowse-plugin-protein3d.umd.production.min.js.map +4 -4
  31. package/dist/molstar-chunk.js.map +1 -1
  32. package/dist/version.d.ts +1 -1
  33. package/dist/version.js +1 -1
  34. package/package.json +4 -2
  35. package/src/LaunchProteinView/components/LaunchSettingsDialog.tsx +63 -0
  36. package/src/LaunchProteinView/components/ProteinViewActions.tsx +21 -1
  37. package/src/LaunchProteinView/hooks/useAlphaFoldDBSearch.ts +13 -1
  38. package/src/LaunchProteinView/hooks/useAlphaFoldData.ts +3 -1
  39. package/src/LaunchProteinView/hooks/useAlphaFoldSequenceSearch.ts +12 -10
  40. package/src/LaunchProteinView/hooks/useFoldseekSearch.ts +49 -12
  41. package/src/LaunchProteinView/hooks/useStructureFileSequence.ts +5 -2
  42. package/src/LaunchProteinView/services/foldseekApi.ts +57 -23
  43. package/src/LaunchProteinView/utils/launchViewUtils.ts +10 -1
  44. package/src/LaunchProteinView/utils/sideBySide.ts +55 -0
  45. package/src/LaunchProteinViewExtensionPoint/index.ts +17 -1
  46. package/src/ProteinView/loadStructureData.ts +36 -0
  47. package/src/ProteinView/model.ts +6 -47
  48. package/src/ProteinView/structureLoader.test.ts +102 -0
  49. package/src/ProteinView/structureLoader.ts +74 -0
  50. package/src/fetchUtils.test.ts +27 -0
  51. package/src/fetchUtils.ts +22 -2
  52. package/src/version.ts +1 -1
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const version = "0.4.11";
1
+ export declare const version = "0.4.13";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const version = '0.4.11';
1
+ export const version = '0.4.13';
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.11",
2
+ "version": "0.4.13",
3
3
  "name": "jbrowse-plugin-protein3d",
4
4
  "repository": {
5
5
  "type": "git",
@@ -51,6 +51,8 @@
51
51
  "jsdom": "^29.1.1",
52
52
  "mobx": "^6.15.4",
53
53
  "mobx-react": "^9.2.2",
54
+ "pixelmatch": "^7.2.0",
55
+ "pngjs": "^7.0.0",
54
56
  "prettier": "^3.8.3",
55
57
  "pretty-bytes": "^7.1.0",
56
58
  "puppeteer": "^25.1.0",
@@ -86,7 +88,7 @@
86
88
  "lint": "eslint src --report-unused-disable-directives --max-warnings 0",
87
89
  "pretest": "test -d .test-jbrowse-nightly || jbrowse create .test-jbrowse-nightly --nightly",
88
90
  "preversion": "pnpm lint",
89
- "version": "node -e \"console.log('export const version = \\'' + require('./package.json').version + '\\'')\" > src/version.ts && git add src/version.ts",
91
+ "version": "node scripts/sync-version.mjs && git add src/version.ts",
90
92
  "postversion": "git push --follow-tags"
91
93
  }
92
94
  }
@@ -0,0 +1,63 @@
1
+ import React, { useState } from 'react'
2
+
3
+ import {
4
+ Button,
5
+ Checkbox,
6
+ Dialog,
7
+ DialogActions,
8
+ DialogContent,
9
+ DialogTitle,
10
+ FormControlLabel,
11
+ FormGroup,
12
+ Typography,
13
+ } from '@mui/material'
14
+
15
+ import { getLaunchSideBySide, setLaunchSideBySide } from '../utils/sideBySide'
16
+
17
+ // Small, self-contained launch settings (NOT the global preferences dialog):
18
+ // just the options that affect how this protein view opens.
19
+ export default function LaunchSettingsDialog({
20
+ open,
21
+ onClose,
22
+ }: {
23
+ open: boolean
24
+ onClose: () => void
25
+ }) {
26
+ const [sideBySide, setSideBySide] = useState(() => getLaunchSideBySide())
27
+ return (
28
+ <Dialog
29
+ open={open}
30
+ onClose={() => {
31
+ onClose()
32
+ }}
33
+ >
34
+ <DialogTitle>Launch settings</DialogTitle>
35
+ <DialogContent>
36
+ <FormGroup>
37
+ <FormControlLabel
38
+ control={<Checkbox checked={sideBySide} />}
39
+ label="Open protein view side-by-side with the genome view"
40
+ onChange={(_, checked) => {
41
+ setSideBySide(checked)
42
+ setLaunchSideBySide(checked)
43
+ }}
44
+ />
45
+ </FormGroup>
46
+ <Typography variant="body2" color="text.secondary">
47
+ When enabled, launching a protein view places it to the right of the
48
+ connected genome view in a split layout instead of stacking it below.
49
+ </Typography>
50
+ </DialogContent>
51
+ <DialogActions>
52
+ <Button
53
+ variant="contained"
54
+ onClick={() => {
55
+ onClose()
56
+ }}
57
+ >
58
+ Close
59
+ </Button>
60
+ </DialogActions>
61
+ </Dialog>
62
+ )
63
+ }
@@ -2,9 +2,11 @@ import React, { useState } from 'react'
2
2
 
3
3
  import { ErrorMessage } from '@jbrowse/core/ui'
4
4
  import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'
5
- import { Button, ButtonGroup, Typography } from '@mui/material'
5
+ import SettingsIcon from '@mui/icons-material/Settings'
6
+ import { Button, ButtonGroup, IconButton, Tooltip, Typography } from '@mui/material'
6
7
 
7
8
  import LaunchOptionsDialog from './LaunchOptionsDialog'
9
+ import LaunchSettingsDialog from './LaunchSettingsDialog'
8
10
  import SequenceMismatchNotice from './SequenceMismatchNotice'
9
11
  import { getLaunchMissingReasons, safeLaunch } from '../utils/launchHelpers'
10
12
  import {
@@ -58,6 +60,7 @@ export default function ProteinViewActions({
58
60
  error,
59
61
  }: ProteinViewActionsProps) {
60
62
  const [dialogOpen, setDialogOpen] = useState(false)
63
+ const [settingsOpen, setSettingsOpen] = useState(false)
61
64
  const [launchError, setLaunchError] = useState<unknown>()
62
65
  // Disable launch while loading — SWR's keepPreviousData would otherwise let
63
66
  // a user click Launch on stale results (wrong UniProt ID) during a refetch.
@@ -155,6 +158,17 @@ export default function ProteinViewActions({
155
158
  onAlignmentAlgorithmChange={onAlignmentAlgorithmChange}
156
159
  />
157
160
  ) : null}
161
+ <Tooltip title="Launch settings">
162
+ <IconButton
163
+ size="small"
164
+ aria-label="Launch settings"
165
+ onClick={() => {
166
+ setSettingsOpen(true)
167
+ }}
168
+ >
169
+ <SettingsIcon fontSize="small" />
170
+ </IconButton>
171
+ </Tooltip>
158
172
  <Button
159
173
  variant="contained"
160
174
  color="secondary"
@@ -189,6 +203,12 @@ export default function ProteinViewActions({
189
203
  onClose={closeMenu}
190
204
  options={launchOptions}
191
205
  />
206
+ <LaunchSettingsDialog
207
+ open={settingsOpen}
208
+ onClose={() => {
209
+ setSettingsOpen(false)
210
+ }}
211
+ />
192
212
  </>
193
213
  )
194
214
  }
@@ -63,6 +63,7 @@ export default function useAlphaFoldDBSearch({
63
63
 
64
64
  const {
65
65
  isLoading: isAlphaFoldLoading,
66
+ isValidating: isAlphaFoldValidating,
66
67
  error: alphaFoldError,
67
68
  url: alphaFoldUrl,
68
69
  confidenceUrl: alphaFoldConfidenceUrl,
@@ -93,6 +94,7 @@ export default function useAlphaFoldDBSearch({
93
94
  plddtDocUrl: seqSearchConfidenceUrl,
94
95
  structureSequence: seqSearchStructureSequence,
95
96
  isLoading: isSequenceSearchLoading,
97
+ isValidating: isSequenceSearchValidating,
96
98
  error: sequenceSearchError,
97
99
  } = useAlphaFoldSequenceSearch({
98
100
  sequence: userSelectedProteinSequence?.seq,
@@ -110,6 +112,14 @@ export default function useAlphaFoldDBSearch({
110
112
  : alphaFoldStructureSequence
111
113
  const finalUniprotId = isSequenceMode ? seqSearchUniprotId : uniprotId
112
114
 
115
+ // While a structure fetch is in flight, finalStructureSequence may still be
116
+ // the previous selection's sequence (keepPreviousData). Comparing that stale
117
+ // sequence to the freshly-selected transcript would give a wrong match, so
118
+ // the match is treated as unknown until the fetch settles.
119
+ const isStructureValidating = isSequenceMode
120
+ ? isSequenceSearchValidating
121
+ : isAlphaFoldValidating
122
+
113
123
  const loadingStatuses = [
114
124
  isLookupLoading && 'Looking up UniProt ID',
115
125
  isIsoformLoading && 'Loading protein sequences from transcript isoforms',
@@ -167,7 +177,9 @@ export default function useAlphaFoldDBSearch({
167
177
  !!selectedTranscript &&
168
178
  (isSequenceMode || !!(finalStructureSequence && finalUniprotId)),
169
179
  sequencesMatch:
170
- userSelectedProteinSequence?.seq && finalStructureSequence
180
+ !isStructureValidating &&
181
+ userSelectedProteinSequence?.seq &&
182
+ finalStructureSequence
171
183
  ? stripStopCodon(userSelectedProteinSequence.seq) ===
172
184
  finalStructureSequence
173
185
  : undefined,
@@ -14,10 +14,12 @@ export default function useAlphaFoldData({
14
14
  ? getAlphaFoldConfidenceUrl(uniprotId)
15
15
  : undefined
16
16
 
17
- const { sequences, isLoading, error } = useStructureFileSequence({ url })
17
+ const { sequences, isLoading, isValidating, error } =
18
+ useStructureFileSequence({ url })
18
19
 
19
20
  return {
20
21
  isLoading,
22
+ isValidating,
21
23
  error,
22
24
  url,
23
25
  confidenceUrl,
@@ -41,19 +41,21 @@ export default function useAlphaFoldSequenceSearch({
41
41
  return searchType === 'md5' ? md5(cleanSeq) : cleanSeq
42
42
  }, [sequence, searchType])
43
43
 
44
- const { data, error, isLoading } = useSWR<SequenceSummaryResponse>(
45
- enabled && searchValue
46
- ? `https://alphafold.ebi.ac.uk/api/sequence/summary?id=${encodeURIComponent(searchValue)}&type=${searchType}`
47
- : null,
48
- jsonfetch,
49
- {
50
- ...STATIC_SWR_OPTIONS,
51
- keepPreviousData: true,
52
- },
53
- )
44
+ const { data, error, isLoading, isValidating } =
45
+ useSWR<SequenceSummaryResponse>(
46
+ enabled && searchValue
47
+ ? `https://alphafold.ebi.ac.uk/api/sequence/summary?id=${encodeURIComponent(searchValue)}&type=${searchType}`
48
+ : null,
49
+ jsonfetch,
50
+ {
51
+ ...STATIC_SWR_OPTIONS,
52
+ keepPreviousData: true,
53
+ },
54
+ )
54
55
 
55
56
  return {
56
57
  isLoading,
58
+ isValidating,
57
59
  result: data,
58
60
  uniprotId: data?.uniprotAccession,
59
61
  cifUrl: data?.cifUrl,
@@ -1,4 +1,4 @@
1
- import { useState } from 'react'
1
+ import { useEffect, useRef, useState } from 'react'
2
2
 
3
3
  import {
4
4
  DEFAULT_DATABASES,
@@ -23,21 +23,43 @@ export default function useFoldseekSearch() {
23
23
  const [error, setError] = useState<unknown>()
24
24
  const [statusMessage, setStatusMessage] = useState('')
25
25
 
26
+ // Aborts the in-flight request (3Di prediction or the up-to-3-minute Foldseek
27
+ // poll) when the dialog closes/unmounts, so it stops hitting the external API
28
+ // and stops updating dead state.
29
+ const abortRef = useRef<AbortController | null>(null)
30
+ useEffect(() => {
31
+ return () => {
32
+ abortRef.current?.abort()
33
+ }
34
+ }, [])
35
+
36
+ const startOperation = () => {
37
+ abortRef.current?.abort()
38
+ const controller = new AbortController()
39
+ abortRef.current = controller
40
+ return controller.signal
41
+ }
42
+
26
43
  const predictStructure = async (aaSequence: string) => {
44
+ const signal = startOperation()
27
45
  setIsPredicting(true)
28
46
  setError(undefined)
29
47
  setStatusMessage('Predicting 3Di structure...')
30
48
  try {
31
- const result = await predict3Di(aaSequence)
49
+ const result = await predict3Di({ aaSequence, signal })
32
50
  setPredictData(result)
33
51
  return result
34
52
  } catch (e) {
35
- console.error(e)
36
- setError(e)
53
+ if (!signal.aborted) {
54
+ console.error(e)
55
+ setError(e)
56
+ }
37
57
  return undefined
38
58
  } finally {
39
- setIsPredicting(false)
40
- setStatusMessage('')
59
+ if (!signal.aborted) {
60
+ setIsPredicting(false)
61
+ setStatusMessage('')
62
+ }
41
63
  }
42
64
  }
43
65
 
@@ -46,25 +68,40 @@ export default function useFoldseekSearch() {
46
68
  di3Seq: string,
47
69
  databases: FoldseekDatabaseId[] = DEFAULT_DATABASES,
48
70
  ) => {
71
+ const signal = startOperation()
49
72
  setIsLoading(true)
50
73
  setError(undefined)
51
74
  setStatusMessage('Submitting search...')
52
75
  try {
53
- const ticket = await submitFoldseekSearch(aaSeq, di3Seq, databases)
54
- const result = await waitForFoldseekResults(ticket.id, setStatusMessage)
76
+ const ticket = await submitFoldseekSearch({
77
+ aaSequence: aaSeq,
78
+ di3Sequence: di3Seq,
79
+ databases,
80
+ signal,
81
+ })
82
+ const result = await waitForFoldseekResults({
83
+ ticketId: ticket.id,
84
+ onStatusChange: setStatusMessage,
85
+ signal,
86
+ })
55
87
  setResults(result)
56
88
  return result
57
89
  } catch (e) {
58
- console.error(e)
59
- setError(e)
90
+ if (!signal.aborted) {
91
+ console.error(e)
92
+ setError(e)
93
+ }
60
94
  return undefined
61
95
  } finally {
62
- setIsLoading(false)
63
- setStatusMessage('')
96
+ if (!signal.aborted) {
97
+ setIsLoading(false)
98
+ setStatusMessage('')
99
+ }
64
100
  }
65
101
  }
66
102
 
67
103
  const reset = () => {
104
+ abortRef.current?.abort()
68
105
  setResults(undefined)
69
106
  setPredictData(undefined)
70
107
  setError(undefined)
@@ -45,7 +45,7 @@ export default function useStructureFileSequence({
45
45
  : url
46
46
  ? (['structure-url', url] as const)
47
47
  : null
48
- const { data, error, isLoading } = useSWR<string[] | undefined>(
48
+ const { data, error, isLoading, isValidating } = useSWR<string[] | undefined>(
49
49
  key,
50
50
  async () => {
51
51
  const seq = await fetchSequences({ file, url })
@@ -60,5 +60,8 @@ export default function useStructureFileSequence({
60
60
  },
61
61
  )
62
62
 
63
- return { error, isLoading, sequences: data }
63
+ // isValidating distinguishes "fetching for the current key" from the stale
64
+ // data keepPreviousData keeps around during a key change. Consumers comparing
65
+ // this sequence against another need it to avoid matching against stale data.
66
+ return { error, isLoading, isValidating, sequences: data }
64
67
  }
@@ -61,7 +61,13 @@ export interface FoldseekResult {
61
61
  results: FoldseekDatabaseResult[]
62
62
  }
63
63
 
64
- export async function predict3Di(aaSequence: string) {
64
+ export async function predict3Di({
65
+ aaSequence,
66
+ signal,
67
+ }: {
68
+ aaSequence: string
69
+ signal?: AbortSignal
70
+ }) {
65
71
  // Clean the sequence - remove FASTA header, whitespace, stop codons, and non-AA chars
66
72
  const cleanSequence = aaSequence
67
73
  .split('\n')
@@ -74,6 +80,7 @@ export async function predict3Di(aaSequence: string) {
74
80
 
75
81
  const response = await fetch(
76
82
  `https://3di.foldseek.com/predict/${encodeURIComponent(cleanSequence)}`,
83
+ { signal },
77
84
  )
78
85
  if (!response.ok) {
79
86
  throw new Error(
@@ -89,11 +96,17 @@ export async function predict3Di(aaSequence: string) {
89
96
  return { aaSequence: cleanSequence, di3Sequence: cleanDi3 }
90
97
  }
91
98
 
92
- export async function submitFoldseekSearch(
93
- aaSequence: string,
94
- di3Sequence: string,
95
- databases: FoldseekDatabaseId[],
96
- ) {
99
+ export async function submitFoldseekSearch({
100
+ aaSequence,
101
+ di3Sequence,
102
+ databases,
103
+ signal,
104
+ }: {
105
+ aaSequence: string
106
+ di3Sequence: string
107
+ databases: FoldseekDatabaseId[]
108
+ signal?: AbortSignal
109
+ }) {
97
110
  // Submit both AA and 3Di sequences (with trailing newline like working example)
98
111
  const fastaContent = `>query\n${aaSequence}\n>3DI\n${di3Sequence}\n`
99
112
  const params = new URLSearchParams()
@@ -110,20 +123,27 @@ export async function submitFoldseekSearch(
110
123
  'Content-Type': 'application/x-www-form-urlencoded',
111
124
  },
112
125
  body: params,
126
+ signal,
113
127
  })
114
128
 
115
- const responseData = await response.json()
116
-
129
+ // Read the body as text first so a non-JSON error page (e.g. a gateway/500
130
+ // HTML response) surfaces the real status instead of an opaque JSON
131
+ // SyntaxError that hides it.
132
+ const text = await response.text()
117
133
  if (!response.ok) {
118
- throw new Error(
119
- `Foldseek submission failed: ${response.status} ${JSON.stringify(responseData)}`,
120
- )
134
+ throw new Error(`Foldseek submission failed: ${response.status} ${text}`)
121
135
  }
122
136
 
123
- return responseData as FoldseekTicketResponse
137
+ return JSON.parse(text) as FoldseekTicketResponse
124
138
  }
125
139
 
126
- export async function pollFoldseekStatus(ticketId: string) {
140
+ export async function pollFoldseekStatus({
141
+ ticketId,
142
+ signal,
143
+ }: {
144
+ ticketId: string
145
+ signal?: AbortSignal
146
+ }) {
127
147
  // Use the /tickets endpoint (plural) with POST
128
148
  const params = new URLSearchParams()
129
149
  params.append('tickets[]', ticketId)
@@ -134,6 +154,7 @@ export async function pollFoldseekStatus(ticketId: string) {
134
154
  'Content-Type': 'application/x-www-form-urlencoded',
135
155
  },
136
156
  body: params,
157
+ signal,
137
158
  })
138
159
 
139
160
  if (!response.ok) {
@@ -160,23 +181,36 @@ interface FoldseekApiResponse {
160
181
  }[]
161
182
  }
162
183
 
163
- export async function getFoldseekResults(
164
- ticketId: string,
165
- ): Promise<FoldseekApiResponse> {
184
+ export async function getFoldseekResults({
185
+ ticketId,
186
+ signal,
187
+ }: {
188
+ ticketId: string
189
+ signal?: AbortSignal
190
+ }): Promise<FoldseekApiResponse> {
166
191
  return jsonfetch<FoldseekApiResponse>(
167
192
  `https://search.foldseek.com/api/result/${ticketId}/0`,
193
+ { signal },
168
194
  )
169
195
  }
170
196
 
171
- export async function waitForFoldseekResults(
172
- ticketId: string,
173
- onStatusChange?: (status: string) => void,
174
- ) {
197
+ export async function waitForFoldseekResults({
198
+ ticketId,
199
+ onStatusChange,
200
+ signal,
201
+ }: {
202
+ ticketId: string
203
+ onStatusChange?: (status: string) => void
204
+ signal?: AbortSignal
205
+ }) {
175
206
  const maxAttempts = 180
176
207
  let attempts = 0
177
208
 
178
209
  while (attempts < maxAttempts) {
179
- const status = await pollFoldseekStatus(ticketId)
210
+ if (signal?.aborted) {
211
+ throw signal.reason
212
+ }
213
+ const status = await pollFoldseekStatus({ ticketId, signal })
180
214
 
181
215
  if (status.status === 'ERROR') {
182
216
  console.error('[Foldseek] Search error:', status)
@@ -187,7 +221,7 @@ export async function waitForFoldseekResults(
187
221
 
188
222
  if (status.status === 'COMPLETE') {
189
223
  onStatusChange?.('Fetching results...')
190
- const apiResponse = await getFoldseekResults(ticketId)
224
+ const apiResponse = await getFoldseekResults({ ticketId, signal })
191
225
 
192
226
  // Transform API response to our format
193
227
  const results: FoldseekResult = {
@@ -204,7 +238,7 @@ export async function waitForFoldseekResults(
204
238
  onStatusChange?.(
205
239
  `Search ${status.status.toLowerCase()}... (${attempts + 1}s)`,
206
240
  )
207
- await timeout(1000)
241
+ await timeout(1000, signal)
208
242
  attempts++
209
243
  }
210
244
 
@@ -6,6 +6,7 @@ declare global {
6
6
  }
7
7
  }
8
8
 
9
+ import { getLaunchSideBySide, launchViewSideBySide } from './sideBySide'
9
10
  import { getGeneDisplayName, getTranscriptDisplayName } from './util'
10
11
  import { launchProteinAnnotationView } from '../components/launchProteinAnnotationView'
11
12
 
@@ -113,6 +114,7 @@ export function launch3DProteinView({
113
114
  alignmentAlgorithm,
114
115
  displayName,
115
116
  connectedMsaViewId,
117
+ sideBySide,
116
118
  }: LaunchViewParams & {
117
119
  url?: string
118
120
  data?: string
@@ -120,6 +122,9 @@ export function launch3DProteinView({
120
122
  alignmentAlgorithm?: string
121
123
  displayName?: string
122
124
  connectedMsaViewId?: string
125
+ // explicit override; when undefined the launch-dialog localStorage preference
126
+ // decides (left genome | right protein)
127
+ sideBySide?: boolean
123
128
  }) {
124
129
  const snap = {
125
130
  type: 'ProteinView',
@@ -138,7 +143,11 @@ export function launch3DProteinView({
138
143
  displayName ??
139
144
  formatViewName('Protein view', feature, selectedTranscript, uniprotId),
140
145
  }
141
- return session.addView('ProteinView', snap)
146
+ const proteinView = session.addView('ProteinView', snap)
147
+ if (sideBySide ?? getLaunchSideBySide()) {
148
+ launchViewSideBySide(session, proteinView.id)
149
+ }
150
+ return proteinView
142
151
  }
143
152
 
144
153
  export async function launch1DProteinView({
@@ -0,0 +1,55 @@
1
+ import type { AbstractSessionModel } from '@jbrowse/core/util'
2
+
3
+ // Self-contained launch preference (NOT the global/core preferences system):
4
+ // whether a protein view launched from a genome feature opens side-by-side with
5
+ // its connected genome view (left genome | right protein) instead of stacked.
6
+ const SIDE_BY_SIDE_KEY = 'proteinView-launchSideBySide'
7
+
8
+ // Default to side-by-side: a connected genome+protein pair reads best as a
9
+ // left/right split. Users can turn it off in the launch dialog's settings.
10
+ const DEFAULT_SIDE_BY_SIDE = true
11
+
12
+ export function getLaunchSideBySide() {
13
+ const stored = localStorage.getItem(SIDE_BY_SIDE_KEY)
14
+ return stored === null ? DEFAULT_SIDE_BY_SIDE : stored === 'true'
15
+ }
16
+
17
+ export function setLaunchSideBySide(value: boolean) {
18
+ localStorage.setItem(SIDE_BY_SIDE_KEY, value ? 'true' : 'false')
19
+ }
20
+
21
+ // The workspaces split is driven by two session actions that only exist on the
22
+ // web/desktop session (MultipleViews + DockviewLayout mixins). Embedded sessions
23
+ // lack them, so feature-detect before using.
24
+ interface SessionWithWorkspaces {
25
+ setUseWorkspaces: (useWorkspaces: boolean) => void
26
+ setPendingMove: (move: { type: 'splitRight'; viewId: string }) => void
27
+ }
28
+
29
+ function isSessionWithWorkspaces(
30
+ session: AbstractSessionModel,
31
+ ): session is AbstractSessionModel & SessionWithWorkspaces {
32
+ return (
33
+ 'setUseWorkspaces' in session &&
34
+ typeof session.setUseWorkspaces === 'function' &&
35
+ 'setPendingMove' in session &&
36
+ typeof session.setPendingMove === 'function'
37
+ )
38
+ }
39
+
40
+ /**
41
+ * Place a freshly-added view to the right of the others in a workspaces (tiled)
42
+ * layout. Mirrors the "Move to split view" view-menu action: queue a splitRight
43
+ * pending move for this view, then enable workspaces so TiledViewsContainer
44
+ * consumes the move on mount (other views land in the left panel, this one in a
45
+ * new right panel). No-op on sessions without workspaces support.
46
+ */
47
+ export function launchViewSideBySide(
48
+ session: AbstractSessionModel,
49
+ viewId: string,
50
+ ) {
51
+ if (isSessionWithWorkspaces(session)) {
52
+ session.setPendingMove({ type: 'splitRight', viewId })
53
+ session.setUseWorkspaces(true)
54
+ }
55
+ }
@@ -1,4 +1,8 @@
1
1
  import { type ConnectedViewSpec, resolveShortLaunch } from './resolveShortLaunch'
2
+ import {
3
+ getLaunchSideBySide,
4
+ launchViewSideBySide,
5
+ } from '../LaunchProteinView/utils/sideBySide'
2
6
 
3
7
  import type PluginManager from '@jbrowse/core/PluginManager'
4
8
  import type { AbstractSessionModel } from '@jbrowse/core/util'
@@ -28,6 +32,7 @@ export default function LaunchProteinViewExtensionPointF(
28
32
  showControls,
29
33
  showHighlight,
30
34
  zoomToBaseLevel,
35
+ sideBySide,
31
36
  }: {
32
37
  session: AbstractSessionModel
33
38
  url?: string
@@ -43,6 +48,10 @@ export default function LaunchProteinViewExtensionPointF(
43
48
  showControls?: boolean
44
49
  showHighlight?: boolean
45
50
  zoomToBaseLevel?: boolean
51
+ // when this launch creates its own connected genome view, place the
52
+ // protein view side-by-side (left genome | right protein). Explicit
53
+ // override; falls back to the launch-dialog localStorage preference.
54
+ sideBySide?: boolean
46
55
  }) => {
47
56
  // Short-URL form: `uniprotId` + `transcriptId` + `connectedView` (no
48
57
  // explicit `url`/`feature`/sequence). Derive the structure URL, the
@@ -77,6 +86,9 @@ export default function LaunchProteinViewExtensionPointF(
77
86
  // `connectedView` is supplied we create the LinearGenomeView here and wire
78
87
  // its id, letting a single spec entry produce a connected genome+protein
79
88
  // pair (e.g. hover a variant to highlight the residue).
89
+ // a connected view this launch created itself can be split beside the
90
+ // protein view; a pre-existing connectedViewId is left in place
91
+ const ownsConnectedView = !connectedViewId && !!connectedView
80
92
  const resolvedConnectedViewId =
81
93
  connectedViewId ??
82
94
  (connectedView
@@ -86,7 +98,7 @@ export default function LaunchProteinViewExtensionPointF(
86
98
  }).id
87
99
  : undefined)
88
100
 
89
- session.addView('ProteinView', {
101
+ const proteinView = session.addView('ProteinView', {
90
102
  type: 'ProteinView',
91
103
  alignmentAlgorithm,
92
104
  displayName,
@@ -106,6 +118,10 @@ export default function LaunchProteinViewExtensionPointF(
106
118
  },
107
119
  ],
108
120
  })
121
+
122
+ if (ownsConnectedView && (sideBySide ?? getLaunchSideBySide())) {
123
+ launchViewSideBySide(session, proteinView.id)
124
+ }
109
125
  },
110
126
  )
111
127
  }