diff --git a/packages/jbrowse-plugin-apollo/src/LinearApolloDisplay/glyphs/TranscriptGlyph.ts b/packages/jbrowse-plugin-apollo/src/LinearApolloDisplay/glyphs/TranscriptGlyph.ts index 42795b9db..5821c26e1 100644 --- a/packages/jbrowse-plugin-apollo/src/LinearApolloDisplay/glyphs/TranscriptGlyph.ts +++ b/packages/jbrowse-plugin-apollo/src/LinearApolloDisplay/glyphs/TranscriptGlyph.ts @@ -6,7 +6,11 @@ import { } from '@jbrowse/core/util' import type { ContentBlock } from '@jbrowse/core/util/blockTypes' -import { DuplicateTranscript, MergeTranscripts } from '../../components' +import { + AddCodingSequence, + DuplicateTranscript, + MergeTranscripts, +} from '../../components' import { isCDSFeature, isExonFeature, @@ -201,7 +205,7 @@ function getLayout(display: LinearApolloDisplay, feature: AnnotationFeature) { function getContextMenuItems( display: LinearApolloDisplay, - feature: AnnotationFeature, + transcript: AnnotationFeature, ): MenuItem[] { const { changeManager, regions, selectedFeature, session } = display const [region] = regions @@ -215,7 +219,7 @@ function getContextMenuItems( 'ApolloTranscriptDetails', 'apolloTranscriptDetails', { - feature, + feature: transcript, assembly: currentAssemblyId, changeManager, refName: region.refName, @@ -238,7 +242,7 @@ function getContextMenuItems( doneCallback() }, changeManager, - sourceFeature: feature, + sourceFeature: transcript, sourceAssemblyId: currentAssemblyId, selectedFeature, setSelectedFeature: (feature?: AnnotationFeature) => { @@ -261,7 +265,7 @@ function getContextMenuItems( doneCallback() }, changeManager, - sourceFeature: feature, + sourceFeature: transcript, sourceAssemblyId: currentAssemblyId, selectedFeature, setSelectedFeature: (feature?: AnnotationFeature) => { @@ -273,6 +277,35 @@ function getContextMenuItems( }, }, ) + const { children } = transcript + if (!children) { + return [] + } + const cdsChildren = [...children.values()].filter((child) => + isCDSFeature(child, session), + ) + if (cdsChildren.length === 0) { + menuItems.push({ + label: 'Add coding sequence', + onClick: () => { + ;(session as unknown as AbstractSessionModel).queueDialog( + (doneCallback) => [ + AddCodingSequence, + { + session, + handleClose: () => { + doneCallback() + }, + changeManager, + sourceFeature: transcript, + sourceAssemblyId: currentAssemblyId, + refName: region.refName, + }, + ], + ) + }, + }) + } return menuItems } diff --git a/packages/jbrowse-plugin-apollo/src/components/AddCodingSequence.tsx b/packages/jbrowse-plugin-apollo/src/components/AddCodingSequence.tsx new file mode 100644 index 000000000..f74a792e7 --- /dev/null +++ b/packages/jbrowse-plugin-apollo/src/components/AddCodingSequence.tsx @@ -0,0 +1,255 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import type { + AnnotationFeature, + AnnotationFeatureSnapshot, +} from '@apollo-annotation/mst' +import { AddFeatureChange } from '@apollo-annotation/shared' +import type { AbstractSessionModel } from '@jbrowse/core/util' +import { revcom } from '@jbrowse/core/util' +import { + Button, + DialogActions, + DialogContent, + DialogContentText, + FormControl, + FormControlLabel, + Radio, + RadioGroup, + TextField, +} from '@mui/material' +import ObjectID from 'bson-objectid' +import React, { useState } from 'react' + +import type { ChangeManager } from '../ChangeManager' +import type { ApolloSessionModel } from '../session' +import { findLongestOrf } from '../util/sequenceUtils' + +import { Dialog } from './Dialog' + +interface AddCodingSequenceProps { + session: ApolloSessionModel + handleClose(): void + sourceFeature: AnnotationFeature + sourceAssemblyId: string + changeManager: ChangeManager + refName: string +} + +// Returns the genomic half-open boundary for stitched position `pos`. +// For strand +1: the inclusive start (min) of the nucleotide. +// For strand -1: the exclusive end (max) of the nucleotide. +// Symmetric usage: +// plus: cdsMin = helper(orf[0]), cdsMax = helper(orf[1]+3) +// minus: cdsMax = helper(orf[0]), cdsMin = helper(orf[1]+3) +function stitchedToGenomicBound( + pos: number, + exons: { min: number; max: number }[], + strand: 1 | -1, +): number { + let cum = 0 + for (const exon of exons) { + const len = exon.max - exon.min + if (pos <= cum + len) { + const off = pos - cum + return strand === 1 ? exon.min + off : exon.max - off + } + cum += len + } + const last = exons.at(-1) + if (!last) { + throw new Error('No exons found') + } + return strand === 1 ? last.max : last.min +} + +export function AddCodingSequence({ + changeManager, + handleClose, + refName, + session, + sourceAssemblyId, + sourceFeature, +}: AddCodingSequenceProps) { + const [method, setMethod] = useState<'longest-orf' | 'manual'>('longest-orf') + const [minInput, setMinInput] = useState('') + const [maxInput, setMaxInput] = useState('') + const [errorMessage, setErrorMessage] = useState('') + + async function onSubmit(event: React.FormEvent) { + event.preventDefault() + setErrorMessage('') + + let cdsMin: number + let cdsMax: number + + try { + if (method === 'longest-orf') { + const exonParts = sourceFeature.transcriptExonParts.filter( + (p) => p.type === 'exon', + ) + if (exonParts.length === 0) { + setErrorMessage('No exons found in this transcript') + return + } + + const backendDriver = + session.apolloDataStore.getBackendDriver(sourceAssemblyId) + if (!backendDriver) { + setErrorMessage('No backend driver found for this assembly') + return + } + + let stitchedSequence = '' + for (const exon of exonParts) { + const { seq } = await backendDriver.getSequence({ + assemblyName: sourceAssemblyId, + refName, + start: exon.min, + end: exon.max, + }) + stitchedSequence += sourceFeature.strand === -1 ? revcom(seq) : seq + } + + const orf = findLongestOrf(stitchedSequence) + if (!orf) { + ;(session as unknown as AbstractSessionModel).notify( + 'No open reading frame found in this transcript', + 'error', + ) + handleClose() + return + } + + const strand = sourceFeature.strand ?? 1 + if (strand === 1) { + cdsMin = stitchedToGenomicBound(orf[0], exonParts, 1) + cdsMax = stitchedToGenomicBound(orf[1] + 3, exonParts, 1) + } else { + cdsMax = stitchedToGenomicBound(orf[0], exonParts, -1) + cdsMin = stitchedToGenomicBound(orf[1] + 3, exonParts, -1) + } + } else { + cdsMin = Number(minInput) - 1 + cdsMax = Number(maxInput) + } + + const _id = new ObjectID().toHexString() + const addedFeature: AnnotationFeatureSnapshot = { + _id, + refSeq: sourceFeature.refSeq, + min: cdsMin, + max: cdsMax, + type: 'CDS', + } + if (sourceFeature.strand) { + addedFeature.strand = sourceFeature.strand + } + const change = new AddFeatureChange({ + changedIds: [sourceFeature._id], + typeName: 'AddFeatureChange', + assembly: sourceAssemblyId, + addedFeature, + parentFeatureId: sourceFeature._id, + }) + await changeManager.submit(change) + session.apolloSetSelectedFeature(_id) + handleClose() + } catch (error) { + setErrorMessage( + error instanceof Error ? error.message : 'An unknown error occurred', + ) + } + } + + const manualError = + method === 'manual' && + Boolean(minInput) && + Boolean(maxInput) && + Number(maxInput) <= Number(minInput) + + const submitDisabled = + method === 'manual' ? !(minInput && maxInput) || manualError : false + + return ( + +
{ + void onSubmit(event) + }} + > + + + { + setMethod(e.target.value as 'longest-orf' | 'manual') + }} + > + } + label="Calculate longest open reading frame" + /> + } + label="Manual" + /> + + + {method === 'manual' ? ( + <> + { + setMinInput(e.target.value) + }} + /> + { + setMaxInput(e.target.value) + }} + error={manualError} + helperText={ + manualError ? '"Max" must be greater than "Min"' : null + } + /> + + ) : null} + + + + + +
+ {errorMessage ? ( + + {errorMessage} + + ) : null} +
+ ) +} diff --git a/packages/jbrowse-plugin-apollo/src/components/index.ts b/packages/jbrowse-plugin-apollo/src/components/index.ts index a90f0e42a..b12f6de27 100644 --- a/packages/jbrowse-plugin-apollo/src/components/index.ts +++ b/packages/jbrowse-plugin-apollo/src/components/index.ts @@ -1,6 +1,7 @@ export * from './AddAssembly' export * from './AddAssemblyAliases' export * from './AddChildFeature' +export * from './AddCodingSequence' export * from './AddFeature' export * from './ColorFeature' export * from './CopyFeature' diff --git a/packages/jbrowse-plugin-apollo/src/util/sequenceUtils.ts b/packages/jbrowse-plugin-apollo/src/util/sequenceUtils.ts new file mode 100644 index 000000000..36a4aa0ec --- /dev/null +++ b/packages/jbrowse-plugin-apollo/src/util/sequenceUtils.ts @@ -0,0 +1,29 @@ +const START_CODON = 'ATG' +const STOP_CODONS = new Set(['TAA', 'TAG', 'TGA']) + +export function findLongestOrf(sequence: string) { + const starts: [number | null, number | null, number | null] = [ + null, + null, + null, + ] + let longest: [number, number] | undefined + for (let start = 0; start < sequence.length - 2; start++) { + const codon = sequence.slice(start, start + 3).toUpperCase() + const frame = (start % 3) as 0 | 1 | 2 + if (codon === START_CODON && starts[frame] === null) { + starts[frame] = start + } else if (STOP_CODONS.has(codon)) { + const inFrameStart = starts[frame] + if (inFrameStart === null) { + continue + } + const length = start - inFrameStart + if (!longest || longest[1] - longest[0] < length) { + longest = [inFrameStart, start] + } + starts[frame] = null + } + } + return longest +}