From c6a32081dc77fc8c6a8c52a8fa85191bb6b67fbe Mon Sep 17 00:00:00 2001 From: Garrett Stevens Date: Fri, 8 Nov 2024 23:16:59 +0000 Subject: [PATCH 1/7] Add transcriptParts to feature model --- .../apollo-mst/src/AnnotationFeatureModel.ts | 95 ++++++++++++++++--- 1 file changed, 83 insertions(+), 12 deletions(-) diff --git a/packages/apollo-mst/src/AnnotationFeatureModel.ts b/packages/apollo-mst/src/AnnotationFeatureModel.ts index dc093d34f..49ce671ac 100644 --- a/packages/apollo-mst/src/AnnotationFeatureModel.ts +++ b/packages/apollo-mst/src/AnnotationFeatureModel.ts @@ -17,6 +17,24 @@ const LateAnnotationFeature = types.late( (): IAnyModelType => AnnotationFeatureModel, ) +export interface TranscriptPartLocation { + min: number + max: number +} + +export interface TranscriptPartNonCoding extends TranscriptPartLocation { + type: 'fivePrimeUTR' | 'threePrimeUTR' | 'intron' +} + +export interface TranscriptPartCoding extends TranscriptPartLocation { + type: 'CDS' + phase: 0 | 1 | 2 +} + +export type TranscriptPart = TranscriptPartCoding | TranscriptPartNonCoding + +type TranscriptParts = TranscriptPart[] + export const AnnotationFeatureModel = types .model('AnnotationFeatureModel', { _id: types.identifier, @@ -108,7 +126,7 @@ export const AnnotationFeatureModel = types } return false }, - get cdsLocations(): { min: number; max: number; phase: 0 | 1 | 2 }[][] { + get transcriptParts(): TranscriptParts[] { if (self.type !== 'mRNA') { throw new Error( 'Only features of type "mRNA" or equivalent can calculate CDS locations', @@ -124,14 +142,22 @@ export const AnnotationFeatureModel = types if (cdsChildren.length === 0) { throw new Error('no CDS in mRNA') } - const cdsLocations: { min: number; max: number; phase: 0 | 1 | 2 }[][] = - [] + const transcriptParts: TranscriptParts[] = [] for (const cds of cdsChildren) { const { max: cdsMax, min: cdsMin } = cds - const locs: { min: number; max: number }[] = [] + const parts: TranscriptParts = [] + let hasIntersected = false + const exonLocations: TranscriptPartLocation[] = [] for (const [, child] of children) { - if (child.type !== 'exon') { - continue + if (child.type === 'exon') { + exonLocations.push({ min: child.min, max: child.max }) + } + } + exonLocations.sort(({ min: a }, { min: b }) => a - b) + for (const child of exonLocations) { + const lastPart = parts.at(-1) + if (lastPart) { + parts.push({ min: lastPart.max, max: child.min, type: 'intron' }) } const [start, end] = intersection2( cdsMin, @@ -139,16 +165,53 @@ export const AnnotationFeatureModel = types child.min, child.max, ) + let utrType: 'fivePrimeUTR' | 'threePrimeUTR' + if (hasIntersected) { + utrType = self.strand === 1 ? 'threePrimeUTR' : 'fivePrimeUTR' + } else { + utrType = self.strand === 1 ? 'fivePrimeUTR' : 'threePrimeUTR' + } if (start !== undefined && end !== undefined) { - locs.push({ min: start, max: end }) + hasIntersected = true + if (start === child.min && end === child.max) { + parts.push({ min: start, max: end, phase: 0, type: 'CDS' }) + } else if (start === child.min) { + parts.push( + { min: start, max: end, phase: 0, type: 'CDS' }, + { min: end, max: child.max, type: utrType }, + ) + } else if (end === child.max) { + parts.push( + { min: child.min, max: start, type: utrType }, + { min: start, max: end, phase: 0, type: 'CDS' }, + ) + } else { + parts.push( + { min: child.min, max: start, type: utrType }, + { min: start, max: end, phase: 0, type: 'CDS' }, + { + min: end, + max: child.max, + type: + utrType === 'fivePrimeUTR' + ? 'threePrimeUTR' + : 'fivePrimeUTR', + }, + ) + } + } else { + parts.push({ min: child.min, max: child.max, type: utrType }) } } - locs.sort(({ min: a }, { min: b }) => a - b) + parts.sort(({ min: a }, { min: b }) => a - b) if (self.strand === -1) { - locs.reverse() + parts.reverse() } let nextPhase: 0 | 1 | 2 = 0 - const phasedLocs = locs.map((loc) => { + const phasedParts = parts.map((loc) => { + if (loc.type !== 'CDS') { + return loc + } const phase = nextPhase nextPhase = ((3 - ((loc.max - loc.min - phase + 3) % 3)) % 3) as | 0 @@ -156,9 +219,17 @@ export const AnnotationFeatureModel = types | 2 return { ...loc, phase } }) - cdsLocations.push(phasedLocs) + transcriptParts.push(phasedParts) } - return cdsLocations + return transcriptParts + }, + })) + .views((self) => ({ + get cdsLocations(): TranscriptPartCoding[][] { + const { transcriptParts } = self + return transcriptParts.map((transcript) => + transcript.filter((transcriptPart) => transcriptPart.type === 'CDS'), + ) }, })) .actions((self) => ({ From 14479104f3e79d6c2fb2024a8e12a8523edf7fb5 Mon Sep 17 00:00:00 2001 From: Garrett Stevens Date: Fri, 8 Nov 2024 23:17:25 +0000 Subject: [PATCH 2/7] Use transcriptParts in transcript sequence panel --- .../FeatureDetailsWidget/TranscriptBasic.tsx | 13 +- .../TranscriptSequence.tsx | 457 +++++++----------- 2 files changed, 185 insertions(+), 285 deletions(-) diff --git a/packages/jbrowse-plugin-apollo/src/FeatureDetailsWidget/TranscriptBasic.tsx b/packages/jbrowse-plugin-apollo/src/FeatureDetailsWidget/TranscriptBasic.tsx index acc7db878..5ae04b990 100644 --- a/packages/jbrowse-plugin-apollo/src/FeatureDetailsWidget/TranscriptBasic.tsx +++ b/packages/jbrowse-plugin-apollo/src/FeatureDetailsWidget/TranscriptBasic.tsx @@ -9,9 +9,20 @@ import { observer } from 'mobx-react' import React from 'react' import { ApolloSessionModel } from '../session' -import { CDSInfo } from './TranscriptSequence' import { NumberTextField } from './NumberTextField' +interface CDSInfo { + id: string + type: string + strand: number + min: number + oldMin: number + max: number + oldMax: number + startSeq: string + endSeq: string +} + interface ExonInfo { min: number max: number diff --git a/packages/jbrowse-plugin-apollo/src/FeatureDetailsWidget/TranscriptSequence.tsx b/packages/jbrowse-plugin-apollo/src/FeatureDetailsWidget/TranscriptSequence.tsx index 4497be0da..c429e98b5 100644 --- a/packages/jbrowse-plugin-apollo/src/FeatureDetailsWidget/TranscriptSequence.tsx +++ b/packages/jbrowse-plugin-apollo/src/FeatureDetailsWidget/TranscriptSequence.tsx @@ -1,131 +1,140 @@ -import { AnnotationFeature, ApolloRefSeqI } from '@apollo-annotation/mst' +import { AnnotationFeature } from '@apollo-annotation/mst' import { splitStringIntoChunks } from '@apollo-annotation/shared' import { revcom } from '@jbrowse/core/util' import { Button, MenuItem, + Paper, Select, SelectChangeEvent, Typography, + useTheme, } from '@mui/material' import { observer } from 'mobx-react' -import React, { useState } from 'react' +import React, { useRef, useState } from 'react' import { ApolloSessionModel } from '../session' -export interface CDSInfo { - id: string - type: string - strand: number - min: number - oldMin: number - max: number - oldMax: number - startSeq: string - endSeq: string +const SEQUENCE_WRAP_LENGTH = 60 + +type SegmentType = 'upOrDownstream' | 'UTR' | 'CDS' | 'intron' | 'protein' +type SegmentListType = 'CDS' | 'cDNA' | 'genomic' + +interface SequenceSegment { + type: SegmentType + sequenceLines: string[] } -const getCDSInfo = ( +function getSequenceSegments( + segmentType: SegmentListType, feature: AnnotationFeature, - refData: ApolloRefSeqI, -): CDSInfo[] => { - const CDSresult: CDSInfo[] = [] - const traverse = ( - currentFeature: AnnotationFeature, - isParentMRNA: boolean, - ) => { - if ( - isParentMRNA && - (currentFeature.type === 'CDS' || - currentFeature.type === 'three_prime_UTR' || - currentFeature.type === 'five_prime_UTR') - ) { - let startSeq = refData.getSequence( - Number(currentFeature.min) - 2, - Number(currentFeature.min), - ) - let endSeq = refData.getSequence( - Number(currentFeature.max), - Number(currentFeature.max) + 2, - ) - - if (currentFeature.strand === -1 && startSeq && endSeq) { - startSeq = revcom(startSeq) - endSeq = revcom(endSeq) - } - const oneCDS: CDSInfo = { - id: currentFeature._id, - type: currentFeature.type, - strand: Number(currentFeature.strand), - min: currentFeature.min + 1, - max: currentFeature.max + 1, - oldMin: currentFeature.min + 1, - oldMax: currentFeature.max + 1, - startSeq: startSeq || '', - endSeq: endSeq || '', + getSequence: (min: number, max: number) => string, +) { + const segments: SequenceSegment[] = [] + const { cdsLocations, strand, transcriptParts } = feature + switch (segmentType) { + case 'genomic': + case 'cDNA': { + const [firstLocation] = transcriptParts + for (const loc of firstLocation) { + if (segmentType === 'cDNA' && loc.type === 'intron') { + continue + } + let sequence = getSequence(loc.min, loc.max) + if (strand === -1) { + sequence = revcom(sequence) + } + const type: SegmentType = + loc.type === 'fivePrimeUTR' || loc.type === 'threePrimeUTR' + ? 'UTR' + : loc.type + const previousSegment = segments.at(-1) + if (!previousSegment) { + const sequenceLines = splitStringIntoChunks( + sequence, + SEQUENCE_WRAP_LENGTH, + ) + segments.push({ type, sequenceLines }) + continue + } + if (previousSegment.type === type) { + const [previousSegmentFirstLine, ...previousSegmentFollowingLines] = + previousSegment.sequenceLines + const newSequence = previousSegmentFollowingLines.join('') + sequence + previousSegment.sequenceLines = [ + previousSegmentFirstLine, + ...splitStringIntoChunks(newSequence, SEQUENCE_WRAP_LENGTH), + ] + } else { + const count = segments.reduce( + (accumulator, currentSegment) => + accumulator + + currentSegment.sequenceLines.reduce( + (subAccumulator, currentLine) => + subAccumulator + currentLine.length, + 0, + ), + 0, + ) + const previousLineLength = count % SEQUENCE_WRAP_LENGTH + const newSegmentFirstLineLength = + SEQUENCE_WRAP_LENGTH - previousLineLength + const newSegmentFirstLine = sequence.slice( + 0, + newSegmentFirstLineLength, + ) + const newSegmentRemainderLines = splitStringIntoChunks( + sequence.slice(newSegmentFirstLineLength), + SEQUENCE_WRAP_LENGTH, + ) + segments.push({ + type, + sequenceLines: [newSegmentFirstLine, ...newSegmentRemainderLines], + }) + } } - CDSresult.push(oneCDS) + return segments } - if (currentFeature.children) { - for (const child of currentFeature.children) { - traverse(child[1], feature.type === 'mRNA') + case 'CDS': { + let wholeSequence = '' + const [firstLocation] = cdsLocations + for (const loc of firstLocation) { + let sequence = getSequence(loc.min, loc.max) + if (strand === -1) { + sequence = revcom(sequence) + } + wholeSequence += sequence } + const sequenceLines = splitStringIntoChunks( + wholeSequence, + SEQUENCE_WRAP_LENGTH, + ) + segments.push({ type: 'CDS', sequenceLines }) + return segments } } - traverse(feature, feature.type === 'mRNA') - CDSresult.sort((a, b) => { - return Number(a.min) - Number(b.min) - }) - if (CDSresult.length > 0) { - CDSresult[0].startSeq = '' - - // eslint-disable-next-line unicorn/prefer-at - CDSresult[CDSresult.length - 1].endSeq = '' +} - // Loop through the array and clear "startSeq" or "endSeq" based on the conditions - for (let i = 0; i < CDSresult.length; i++) { - if (i > 0 && CDSresult[i].min === CDSresult[i - 1].max) { - // Clear "startSeq" if the current item's "start" is equal to the previous item's "end" - CDSresult[i].startSeq = '' - } - if ( - i < CDSresult.length - 1 && - CDSresult[i].max === CDSresult[i + 1].min - ) { - // Clear "endSeq" if the next item's "start" is equal to the current item's "end" - CDSresult[i].endSeq = '' - } +function getSegmentColor(type: SegmentType) { + switch (type) { + case 'upOrDownstream': { + return 'rgb(255,255,255)' + } + case 'UTR': { + return 'rgb(194,106,119)' + } + case 'CDS': { + return 'rgb(93,168,153)' + } + case 'intron': { + return 'rgb(187,187,187)' + } + case 'protein': { + return 'rgb(148,203,236)' } } - return CDSresult } -interface Props { - textSegments: { text: string; color: string }[] -} - -function formatSequence( - seq: string, - refName: string, - start: number, - end: number, - wrap?: number, -) { - const header = `>${refName}:${start + 1}–${end}\n` - const body = - wrap === undefined ? seq : splitStringIntoChunks(seq, wrap).join('\n') - return `${header}${body}` -} - -export const intronColor = 'rgb(120,120,120)' // Slightly brighter gray -export const utrColor = 'rgb(20,200,200)' // Slightly brighter cyan -export const proteinColor = 'rgb(220,70,220)' // Slightly brighter magenta -export const cdsColor = 'rgb(240,200,20)' // Slightly brighter yellow -export const updownstreamColor = 'rgb(255,130,130)' // Slightly brighter red -export const genomeColor = 'rgb(20,230,20)' // Slightly brighter green - -let textSegments = [{ text: '', color: '' }] - export const TranscriptSequence = observer(function TranscriptSequence({ assembly, feature, @@ -140,7 +149,9 @@ export const TranscriptSequence = observer(function TranscriptSequence({ const currentAssembly = session.apolloDataStore.assemblies.get(assembly) const refData = currentAssembly?.getByRefName(refName) const [showSequence, setShowSequence] = useState(false) - const [selectedOption, setSelectedOption] = useState('Select') + const [selectedOption, setSelectedOption] = useState('CDS') + const theme = useTheme() + const seqRef = useRef(null) if (!(currentAssembly && refData)) { return null @@ -149,215 +160,93 @@ export const TranscriptSequence = observer(function TranscriptSequence({ if (!refSeq) { return null } - const transcriptItems = getCDSInfo(feature, refData) - const { max, min } = feature - let sequence = '' - if (showSequence) { - getSequenceAsString(min, max) - } - - function getSequenceAsString(start: number, end: number): string { - sequence = refSeq?.getSequence(start, end) ?? '' - if (sequence === '') { - void session.apolloDataStore.loadRefSeq([ - { assemblyName: assembly, refName, start, end }, - ]) - } else { - sequence = formatSequence(sequence, refName, start, end) - } - getSequenceAsTextSegment(selectedOption) // For color coded sequence - return sequence + if (feature.type !== 'mRNA') { + return null } const handleSeqButtonClick = () => { setShowSequence(!showSequence) } - function getSequenceAsTextSegment(option: string) { - let seqData = '' - textSegments = [] - if (!refData) { - return - } - switch (option) { - case 'CDS': { - textSegments.push({ text: `>${refName} : CDS\n`, color: 'black' }) - for (const item of transcriptItems) { - if (item.type === 'CDS') { - const refSeq: string = refData.getSequence( - Number(item.min + 1), - Number(item.max), - ) - seqData += item.strand === -1 && refSeq ? revcom(refSeq) : refSeq - textSegments.push({ text: seqData, color: cdsColor }) - } - } - break - } - case 'cDNA': { - textSegments.push({ text: `>${refName} : cDNA\n`, color: 'black' }) - for (const item of transcriptItems) { - if ( - item.type === 'CDS' || - item.type === 'three_prime_UTR' || - item.type === 'five_prime_UTR' - ) { - const refSeq: string = refData.getSequence( - Number(item.min + 1), - Number(item.max), - ) - seqData += item.strand === -1 && refSeq ? revcom(refSeq) : refSeq - if (item.type === 'CDS') { - textSegments.push({ text: seqData, color: cdsColor }) - } else { - textSegments.push({ text: seqData, color: utrColor }) - } - } - } - break - } - case 'Full': { - textSegments.push({ - text: `>${refName} : Full genomic\n`, - color: 'black', - }) - let lastEnd = 0 - let count = 0 - for (const item of transcriptItems) { - count++ - if ( - lastEnd != 0 && - lastEnd != Number(item.min) && - count != transcriptItems.length - ) { - // Intron etc. between CDS/UTRs. No need to check this on very last item - const refSeq: string = refData.getSequence( - lastEnd + 1, - Number(item.min) - 1, - ) - seqData += item.strand === -1 && refSeq ? revcom(refSeq) : refSeq - textSegments.push({ text: seqData, color: 'black' }) - } - if ( - item.type === 'CDS' || - item.type === 'three_prime_UTR' || - item.type === 'five_prime_UTR' - ) { - const refSeq: string = refData.getSequence( - Number(item.min + 1), - Number(item.max), - ) - seqData += item.strand === -1 && refSeq ? revcom(refSeq) : refSeq - switch (item.type) { - case 'CDS': { - textSegments.push({ text: seqData, color: cdsColor }) - break - } - case 'three_prime_UTR': { - textSegments.push({ text: seqData, color: utrColor }) - break - } - case 'five_prime_UTR': { - textSegments.push({ text: seqData, color: utrColor }) - break - } - default: { - textSegments.push({ text: seqData, color: 'black' }) - break - } - } - } - lastEnd = Number(item.max) - } - break - } - } - } - function handleChangeSeqOption(e: SelectChangeEvent) { const option = e.target.value - setSelectedOption(option) - getSequenceAsTextSegment(option) + setSelectedOption(option as SegmentListType) } // Function to copy text to clipboard const copyToClipboard = () => { - const textToCopy = textSegments.map((segment) => segment.text).join('') - - if (textToCopy) { - navigator.clipboard - .writeText(textToCopy) - .then(() => { - // console.log('Text copied to clipboard!') - }) - .catch((error: unknown) => { - console.error('Failed to copy text to clipboard', error) - }) + const seqDiv = seqRef.current + if (!seqDiv) { + return } + const textBlob = new Blob([seqDiv.outerText], { type: 'text/plain' }) + const htmlBlob = new Blob([seqDiv.outerHTML], { type: 'text/html' }) + const clipboardItem = new ClipboardItem({ + [textBlob.type]: textBlob, + [htmlBlob.type]: htmlBlob, + }) + void navigator.clipboard.write([clipboardItem]) } - const ColoredText: React.FC = ({ textSegments }) => { - return ( -
- {textSegments.map((segment, index) => ( - - {splitStringIntoChunks(segment.text, 150).join('\n')} - - ))} -
- ) - } + const sequenceSegments = showSequence + ? getSequenceSegments(selectedOption, feature, (min: number, max: number) => + refData.getSequence(min, max), + ) + : [] return ( <> - - Sequence - + Sequence
-
-
- {showSequence && ( + {showSequence && ( + <> - )} -
-
- {showSequence && } -
- {showSequence && ( - + + {sequenceSegments.map((segment, index) => ( + + {segment.sequenceLines.map((sequenceLine, idx) => ( + + {sequenceLine} + {idx === segment.sequenceLines.length - 1 && + sequenceLine.length !== SEQUENCE_WRAP_LENGTH ? null : ( +
+ )} +
+ ))} +
+ ))} +
+ + )} ) From 94f47509f45fe5c283ccff7a0b67d5a05ea61a17 Mon Sep 17 00:00:00 2001 From: Garrett Stevens Date: Mon, 11 Nov 2024 19:14:44 +0000 Subject: [PATCH 3/7] Add transcript widget to right-click menu --- .../LinearApolloDisplay/glyphs/BoxGlyph.ts | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/jbrowse-plugin-apollo/src/LinearApolloDisplay/glyphs/BoxGlyph.ts b/packages/jbrowse-plugin-apollo/src/LinearApolloDisplay/glyphs/BoxGlyph.ts index df190ad8b..0e8e5acf5 100644 --- a/packages/jbrowse-plugin-apollo/src/LinearApolloDisplay/glyphs/BoxGlyph.ts +++ b/packages/jbrowse-plugin-apollo/src/LinearApolloDisplay/glyphs/BoxGlyph.ts @@ -2,7 +2,11 @@ import { AnnotationFeature } from '@apollo-annotation/mst' import { Theme, alpha } from '@mui/material' import { MenuItem } from '@jbrowse/core/ui' -import { AbstractSessionModel, SessionWithWidgets } from '@jbrowse/core/util' +import { + AbstractSessionModel, + isSessionModelWithWidgets, + SessionWithWidgets, +} from '@jbrowse/core/util' import { AddChildFeature, @@ -385,6 +389,24 @@ function getContextMenuItems( }, }, ) + if (sourceFeature.type === 'mRNA' && isSessionModelWithWidgets(session)) { + menuItems.push({ + label: 'Edit transcript details', + onClick: () => { + const apolloTranscriptWidget = session.addWidget( + 'ApolloTranscriptDetails', + 'apolloTranscriptDetails', + { + feature: sourceFeature, + assembly: currentAssemblyId, + changeManager, + refName: region.refName, + }, + ) + session.showWidget(apolloTranscriptWidget) + }, + }) + } return menuItems } From 4bb6b8b1b19ecc3de58dd655a2a4c98eee90a164 Mon Sep 17 00:00:00 2001 From: Garrett Stevens Date: Mon, 11 Nov 2024 19:15:29 +0000 Subject: [PATCH 4/7] Use transcriptParts in transcript widget structure --- .../src/FeatureDetailsWidget/Attributes.tsx | 7 +- .../FeatureDetailsWidget/TranscriptBasic.tsx | 507 ++++-------------- 2 files changed, 115 insertions(+), 399 deletions(-) diff --git a/packages/jbrowse-plugin-apollo/src/FeatureDetailsWidget/Attributes.tsx b/packages/jbrowse-plugin-apollo/src/FeatureDetailsWidget/Attributes.tsx index f47c6ec5d..d0a956def 100644 --- a/packages/jbrowse-plugin-apollo/src/FeatureDetailsWidget/Attributes.tsx +++ b/packages/jbrowse-plugin-apollo/src/FeatureDetailsWidget/Attributes.tsx @@ -242,12 +242,7 @@ export const Attributes = observer(function Attributes({ return ( <> - - Attributes - + Attributes {Object.entries(attributes).map(([key, value]) => { if (key === '') { diff --git a/packages/jbrowse-plugin-apollo/src/FeatureDetailsWidget/TranscriptBasic.tsx b/packages/jbrowse-plugin-apollo/src/FeatureDetailsWidget/TranscriptBasic.tsx index 5ae04b990..a3ff17a09 100644 --- a/packages/jbrowse-plugin-apollo/src/FeatureDetailsWidget/TranscriptBasic.tsx +++ b/packages/jbrowse-plugin-apollo/src/FeatureDetailsWidget/TranscriptBasic.tsx @@ -4,81 +4,21 @@ import { LocationStartChange, } from '@apollo-annotation/shared' import { AbstractSessionModel, revcom } from '@jbrowse/core/util' -import { Typography } from '@mui/material' +import { + Paper, + Typography, + Table, + TableBody, + TableCell, + TableContainer, + TableRow, +} from '@mui/material' import { observer } from 'mobx-react' import React from 'react' import { ApolloSessionModel } from '../session' import { NumberTextField } from './NumberTextField' -interface CDSInfo { - id: string - type: string - strand: number - min: number - oldMin: number - max: number - oldMax: number - startSeq: string - endSeq: string -} - -interface ExonInfo { - min: number - max: number -} - -/** - * Get single feature by featureId - * @param feature - - * @param featureId - - * @returns - */ -function getFeatureFromId( - feature: AnnotationFeature, - featureId: string, -): AnnotationFeature | undefined { - if (feature._id === featureId) { - return feature - } - // Check if there is also childFeatures in parent feature and it's not empty - // Let's get featureId from recursive method - if (!feature.children) { - return - } - for (const [, childFeature] of feature.children) { - const subFeature = getFeatureFromId(childFeature, featureId) - if (subFeature) { - return subFeature - } - } - return -} - -function findExonInRange( - exons: ExonInfo[], - pairStart: number, - pairEnd: number, -): ExonInfo | null { - for (const exon of exons) { - if (Number(exon.min) <= pairStart && Number(exon.max) >= pairEnd) { - return exon - } - } - return null -} - -function removeMatchingExon( - exons: ExonInfo[], - matchStart: number, - matchEnd: number, -): ExonInfo[] { - // Filter the array to remove elements matching the specified start and end - return exons.filter( - (exon) => !(exon.min === matchStart && exon.max === matchEnd), - ) -} - export const TranscriptBasicInformation = observer( function TranscriptBasicInformation({ assembly, @@ -96,361 +36,142 @@ export const TranscriptBasicInformation = observer( const refData = currentAssembly?.getByRefName(refName) const { changeManager } = session.apolloDataStore - function handleStartChange( - newStart: number, - featureId: string, - oldStart: number, + function handleLocationChange( + oldLocation: number, + newLocation: number, + feature: AnnotationFeature, + isMin: boolean, ) { - newStart-- - oldStart-- - if (newStart < feature.min) { - notify('Feature start cannot be less than parent starts', 'error') - return - } - const subFeature = getFeatureFromId(feature, featureId) - if (!subFeature?.children) { - return + if (!feature.children) { + throw new Error('Transcript should have child features') } - // Let's check CDS start and end values. And possibly update those too - for (const child of subFeature.children) { - if ( - (child[1].type === 'CDS' || child[1].type === 'exon') && - child[1].min === oldStart - ) { + for (const [, child] of feature.children) { + if (isMin && oldLocation - 1 === child.min) { const change = new LocationStartChange({ typeName: 'LocationStartChange', - changedIds: [child[1]._id], - featureId, - oldStart, - newStart, + changedIds: [child._id], + featureId: feature._id, + oldStart: oldLocation - 1, + newStart: newLocation - 1, assembly, }) changeManager.submit(change).catch(() => { notify('Error updating feature start position', 'error') }) + return } - } - return - } - - function handleEndChange( - newEnd: number, - featureId: string, - oldEnd: number, - ) { - const subFeature = getFeatureFromId(feature, featureId) - if (newEnd > feature.max) { - notify('Feature start cannot be greater than parent end', 'error') - return - } - if (!subFeature?.children) { - return - } - // Let's check CDS start and end values. And possibly update those too - for (const child of subFeature.children) { - if ( - (child[1].type === 'CDS' || child[1].type === 'exon') && - child[1].max === oldEnd - ) { + if (!isMin && newLocation === child.max) { const change = new LocationEndChange({ typeName: 'LocationEndChange', - changedIds: [child[1]._id], - featureId, - oldEnd, - newEnd, + changedIds: [child._id], + featureId: feature._id, + oldEnd: child.max, + newEnd: newLocation, assembly, }) changeManager.submit(change).catch(() => { - notify('Error updating feature end position', 'error') + notify('Error updating feature start position', 'error') }) + return } } - return } - const featureNew = feature - let exonsArray: ExonInfo[] = [] - const traverse = (currentFeature: AnnotationFeature) => { - if (currentFeature.type === 'exon') { - exonsArray.push({ - min: currentFeature.min + 1, - max: currentFeature.max, - }) - } - if (currentFeature.children) { - for (const child of currentFeature.children) { - traverse(child[1]) - } - } + if (!refData) { + return null } - traverse(featureNew) - - const CDSresult: CDSInfo[] = [] - const CDSData = featureNew.cdsLocations - if (refData) { - for (const CDSDatum of CDSData) { - for (const dataPoint of CDSDatum) { - let startSeq = refData.getSequence( - Number(dataPoint.min) - 2, - Number(dataPoint.min), - ) - let endSeq = refData.getSequence( - Number(dataPoint.max), - Number(dataPoint.max) + 2, - ) - - if (featureNew.strand === -1 && startSeq && endSeq) { - startSeq = revcom(startSeq) - endSeq = revcom(endSeq) - } - const oneCDS: CDSInfo = { - id: featureNew._id, - type: 'CDS', - strand: Number(featureNew.strand), - min: dataPoint.min + 1, - max: dataPoint.max, - oldMin: dataPoint.min + 1, - oldMax: dataPoint.max, - startSeq, - endSeq, - } - // CDSresult.push(oneCDS) - // Check if there is already an object with the same start and end - const exists = CDSresult.some( - (obj) => - obj.min === oneCDS.min && - obj.max === oneCDS.max && - obj.type === oneCDS.type, - ) - // If no such object exists, add the new object to the array - if (!exists) { - CDSresult.push(oneCDS) - } - - // Add possible UTRs - const foundExon = findExonInRange( - exonsArray, - dataPoint.min + 1, - dataPoint.max, - ) - if (foundExon && Number(foundExon.min) < dataPoint.min) { - if (feature.strand === 1) { - const oneCDS: CDSInfo = { - id: feature._id, - type: 'five_prime_UTR', - strand: Number(feature.strand), - min: foundExon.min, - max: dataPoint.min, - oldMin: foundExon.min, - oldMax: dataPoint.min, - startSeq: '', - endSeq: '', - } - CDSresult.push(oneCDS) - } else { - const oneCDS: CDSInfo = { - id: feature._id, - type: 'three_prime_UTR', - strand: Number(feature.strand), - min: dataPoint.min + 1, - max: foundExon.min + 1, - oldMin: dataPoint.min + 1, - oldMax: foundExon.min + 1, - startSeq: '', - endSeq: '', - } - CDSresult.push(oneCDS) + const { strand, transcriptParts } = feature + const [firstLocation] = transcriptParts + + const locationData = firstLocation + .map((loc, idx) => { + const { max, min, type } = loc + let label: string = type + if (label === 'threePrimeUTR') { + label = '3` UTR' + } else if (label === 'fivePrimeUTR') { + label = '5` UTR' + } + let fivePrimeSpliceSite + let threePrimeSpliceSite + if (type === 'CDS') { + const previousLoc = firstLocation.at(idx - 1) + const nextLoc = firstLocation.at(idx + 1) + if (strand === 1) { + if (previousLoc?.type === 'intron') { + fivePrimeSpliceSite = refData.getSequence(min - 2, min) } - exonsArray = removeMatchingExon( - exonsArray, - foundExon.min, - foundExon.max, - ) - } - if (foundExon && Number(foundExon.max) > dataPoint.max) { - if (feature.strand === 1) { - const oneCDS: CDSInfo = { - id: feature._id, - type: 'three_prime_UTR', - strand: Number(feature.strand), - min: dataPoint.max + 1, - max: foundExon.max, - oldMin: dataPoint.max + 1, - oldMax: foundExon.max, - startSeq: '', - endSeq: '', - } - CDSresult.push(oneCDS) - } else { - const oneCDS: CDSInfo = { - id: feature._id, - type: 'five_prime_UTR', - strand: Number(feature.strand), - min: dataPoint.min + 1, - max: foundExon.max, - oldMin: dataPoint.min + 1, - oldMax: foundExon.max, - startSeq: '', - endSeq: '', - } - CDSresult.push(oneCDS) + if (nextLoc?.type === 'intron') { + threePrimeSpliceSite = refData.getSequence(max, max + 2) + } + } else { + if (previousLoc?.type === 'intron') { + fivePrimeSpliceSite = revcom(refData.getSequence(max, max + 2)) + } + if (nextLoc?.type === 'intron') { + threePrimeSpliceSite = revcom(refData.getSequence(min - 2, min)) } - exonsArray = removeMatchingExon( - exonsArray, - foundExon.min, - foundExon.max, - ) - } - if ( - dataPoint.min + 1 === foundExon?.min && - dataPoint.max === foundExon.max - ) { - exonsArray = removeMatchingExon( - exonsArray, - foundExon.min, - foundExon.max, - ) - } - } - } - } - - // Add remaining UTRs if any - if (exonsArray.length > 0) { - // eslint-disable-next-line unicorn/no-array-for-each - exonsArray.forEach((element: ExonInfo) => { - if (featureNew.strand === 1) { - const oneCDS: CDSInfo = { - id: featureNew._id, - type: 'five_prime_UTR', - strand: Number(featureNew.strand), - min: element.min + 1, - max: element.max, - oldMin: element.min + 1, - oldMax: element.max, - startSeq: '', - endSeq: '', - } - CDSresult.push(oneCDS) - } else { - const oneCDS: CDSInfo = { - id: featureNew._id, - type: 'three_prime_UTR', - strand: Number(featureNew.strand), - min: element.min + 1, - max: element.max + 1, - oldMin: element.min + 1, - oldMax: element.max + 1, - startSeq: '', - endSeq: '', } - CDSresult.push(oneCDS) } - exonsArray = removeMatchingExon(exonsArray, element.min, element.max) + return { min, max, label, fivePrimeSpliceSite, threePrimeSpliceSite } }) - } - - CDSresult.sort((a, b) => { - // Primary sorting by 'start' property - const startDifference = Number(a.min) - Number(b.min) - if (startDifference !== 0) { - return startDifference - } - return Number(a.max) - Number(b.max) - }) - if (CDSresult.length > 0) { - CDSresult[0].startSeq = '' - - // eslint-disable-next-line unicorn/prefer-at - CDSresult[CDSresult.length - 1].endSeq = '' - - // Loop through the array and clear "startSeq" or "endSeq" based on the conditions - for (let i = 0; i < CDSresult.length; i++) { - if (i > 0 && CDSresult[i].min === CDSresult[i - 1].max) { - // Clear "startSeq" if the current item's "start" is equal to the previous item's "end" - CDSresult[i].startSeq = '' - } - if ( - i < CDSresult.length - 1 && - CDSresult[i].max === CDSresult[i + 1].min - ) { - // Clear "endSeq" if the next item's "start" is equal to the current item's "end" - CDSresult[i].endSeq = '' - } - } - } - - const transcriptItems = CDSresult + .filter((loc) => loc.label !== 'intron') return ( <> - - CDS and UTRs + Structure + + {strand === 1 ? 'Forward' : 'Reverse'} strand -
- {transcriptItems.map((item, index) => ( -
- - {item.type === 'three_prime_UTR' - ? '3 UTR' - : // eslint-disable-next-line unicorn/no-nested-ternary - item.type === 'five_prime_UTR' - ? '5 UTR' - : 'CDS'} - - - {item.startSeq} - - { - handleStartChange(newStart, item.id, Number(item.oldMin)) - }} - /> - - {/* eslint-disable-next-line unicorn/no-nested-ternary */} - {item.strand === -1 ? '-' : item.strand === 1 ? '+' : ''} - - { - handleEndChange(newEnd, item.id, Number(item.oldMax)) - }} - /> - - {item.endSeq} - -
- ))} -
+ + + + {locationData.map((loc) => ( + + + {loc.label} + + {loc.fivePrimeSpliceSite ?? ''} + + { + handleLocationChange( + strand === 1 ? loc.min + 1 : loc.max, + newLocation, + feature, + strand === 1, + ) + }} + /> + {/* {strand === 1 ? loc.min : loc.max} */} + + + { + handleLocationChange( + strand === 1 ? loc.max : loc.min + 1, + newLocation, + feature, + strand !== 1, + ) + }} + /> + {/* {strand === 1 ? loc.max : loc.min} */} + + {loc.threePrimeSpliceSite ?? ''} + + ))} + +
+
) }, From 135288515fa7b218ea267b1b86fe611649889f0f Mon Sep 17 00:00:00 2001 From: Garrett Stevens Date: Mon, 11 Nov 2024 19:25:09 +0000 Subject: [PATCH 5/7] Add frame color to transcript widget structure --- .../FeatureDetailsWidget/TranscriptBasic.tsx | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/jbrowse-plugin-apollo/src/FeatureDetailsWidget/TranscriptBasic.tsx b/packages/jbrowse-plugin-apollo/src/FeatureDetailsWidget/TranscriptBasic.tsx index a3ff17a09..bcceb1fc4 100644 --- a/packages/jbrowse-plugin-apollo/src/FeatureDetailsWidget/TranscriptBasic.tsx +++ b/packages/jbrowse-plugin-apollo/src/FeatureDetailsWidget/TranscriptBasic.tsx @@ -3,7 +3,7 @@ import { LocationEndChange, LocationStartChange, } from '@apollo-annotation/shared' -import { AbstractSessionModel, revcom } from '@jbrowse/core/util' +import { AbstractSessionModel, getFrame, revcom } from '@jbrowse/core/util' import { Paper, Typography, @@ -12,6 +12,7 @@ import { TableCell, TableContainer, TableRow, + useTheme, } from '@mui/material' import { observer } from 'mobx-react' import React from 'react' @@ -35,6 +36,7 @@ export const TranscriptBasicInformation = observer( const currentAssembly = session.apolloDataStore.assemblies.get(assembly) const refData = currentAssembly?.getByRefName(refName) const { changeManager } = session.apolloDataStore + const theme = useTheme() function handleLocationChange( oldLocation: number, @@ -95,7 +97,11 @@ export const TranscriptBasicInformation = observer( } let fivePrimeSpliceSite let threePrimeSpliceSite + let frameColor if (type === 'CDS') { + const { phase } = loc + const frame = getFrame(min, max, strand ?? 1, phase) + frameColor = theme.palette.framesCDS.at(frame)?.main const previousLoc = firstLocation.at(idx - 1) const nextLoc = firstLocation.at(idx + 1) if (strand === 1) { @@ -114,7 +120,14 @@ export const TranscriptBasicInformation = observer( } } } - return { min, max, label, fivePrimeSpliceSite, threePrimeSpliceSite } + return { + min, + max, + label, + fivePrimeSpliceSite, + threePrimeSpliceSite, + frameColor, + } }) .filter((loc) => loc.label !== 'intron') @@ -129,7 +142,11 @@ export const TranscriptBasicInformation = observer( {locationData.map((loc) => ( - + {loc.label} {loc.fivePrimeSpliceSite ?? ''} From e0f6628c2d6a478b4d3bca3c851dc454743c3dc9 Mon Sep 17 00:00:00 2001 From: Garrett Stevens Date: Mon, 11 Nov 2024 19:26:16 +0000 Subject: [PATCH 6/7] Use custom framesCDS in theme --- .../src/jbrowse/jbrowse.service.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/apollo-collaboration-server/src/jbrowse/jbrowse.service.ts b/packages/apollo-collaboration-server/src/jbrowse/jbrowse.service.ts index bdff3138f..5c6f81582 100644 --- a/packages/apollo-collaboration-server/src/jbrowse/jbrowse.service.ts +++ b/packages/apollo-collaboration-server/src/jbrowse/jbrowse.service.ts @@ -58,6 +58,15 @@ export class JBrowseService { quaternary: { main: '#571AA3', }, + framesCDS: [ + null, + { main: 'rgb(204,121,167)' }, + { main: 'rgb(230,159,0)' }, + { main: 'rgb(240,228,66)' }, + { main: 'rgb(86,180,233)' }, + { main: 'rgb(0,114,178)' }, + { main: 'rgb(0,158,115)' }, + ], }, }, ApolloPlugin: { From fe6d77c4a6825cb257ff8d5d2c533bf66e779443 Mon Sep 17 00:00:00 2001 From: Garrett Stevens Date: Wed, 13 Nov 2024 03:57:08 +0000 Subject: [PATCH 7/7] Add header to transcript sequence --- .../TranscriptSequence.tsx | 44 +++++++++++++++++-- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/packages/jbrowse-plugin-apollo/src/FeatureDetailsWidget/TranscriptSequence.tsx b/packages/jbrowse-plugin-apollo/src/FeatureDetailsWidget/TranscriptSequence.tsx index c429e98b5..4cbe5485a 100644 --- a/packages/jbrowse-plugin-apollo/src/FeatureDetailsWidget/TranscriptSequence.tsx +++ b/packages/jbrowse-plugin-apollo/src/FeatureDetailsWidget/TranscriptSequence.tsx @@ -23,6 +23,7 @@ type SegmentListType = 'CDS' | 'cDNA' | 'genomic' interface SequenceSegment { type: SegmentType sequenceLines: string[] + locs: { min: number; max: number }[] } function getSequenceSegments( @@ -54,7 +55,11 @@ function getSequenceSegments( sequence, SEQUENCE_WRAP_LENGTH, ) - segments.push({ type, sequenceLines }) + segments.push({ + type, + sequenceLines, + locs: [{ min: loc.min, max: loc.max }], + }) continue } if (previousSegment.type === type) { @@ -65,6 +70,7 @@ function getSequenceSegments( previousSegmentFirstLine, ...splitStringIntoChunks(newSequence, SEQUENCE_WRAP_LENGTH), ] + previousSegment.locs.push({ min: loc.min, max: loc.max }) } else { const count = segments.reduce( (accumulator, currentSegment) => @@ -90,6 +96,7 @@ function getSequenceSegments( segments.push({ type, sequenceLines: [newSegmentFirstLine, ...newSegmentRemainderLines], + locs: [{ min: loc.min, max: loc.max }], }) } } @@ -98,18 +105,20 @@ function getSequenceSegments( case 'CDS': { let wholeSequence = '' const [firstLocation] = cdsLocations + const locs: { min: number; max: number }[] = [] for (const loc of firstLocation) { let sequence = getSequence(loc.min, loc.max) if (strand === -1) { sequence = revcom(sequence) } wholeSequence += sequence + locs.push({ min: loc.min, max: loc.max }) } const sequenceLines = splitStringIntoChunks( wholeSequence, SEQUENCE_WRAP_LENGTH, ) - segments.push({ type: 'CDS', sequenceLines }) + segments.push({ type: 'CDS', sequenceLines, locs }) return segments } } @@ -193,6 +202,23 @@ export const TranscriptSequence = observer(function TranscriptSequence({ refData.getSequence(min, max), ) : [] + const locationIntervals: { min: number; max: number }[] = [] + if (showSequence) { + const allLocs = sequenceSegments.flatMap((segment) => segment.locs) + let [previous] = allLocs + for (let i = 1; i < allLocs.length; i++) { + if (previous.min === allLocs[i].max || previous.max === allLocs[i].min) { + previous = { + min: Math.min(previous.min, allLocs[i].min), + max: Math.max(previous.max, allLocs[i].max), + } + } else { + locationIntervals.push(previous) + previous = allLocs[i] + } + } + locationIntervals.push(previous) + } return ( <> @@ -210,8 +236,8 @@ export const TranscriptSequence = observer(function TranscriptSequence({ onChange={handleChangeSeqOption} > CDS - cDNA - Genomic + cDNA + Genomic + >{refSeq.name}: + {locationIntervals + .map((interval) => + feature.strand === 1 + ? `${interval.min + 1}-${interval.max}` + : `${interval.max}-${interval.min + 1}`, + ) + .join(';')} + ({feature.strand === 1 ? '+' : '-'}) +
{sequenceSegments.map((segment, index) => (