From 73593c4b68198975f37b5ee64e3197b1e5579a59 Mon Sep 17 00:00:00 2001 From: Garrett Stevens Date: Wed, 13 Nov 2024 15:44:22 -0700 Subject: [PATCH] Transcript widget upates (#474) * Add transcriptParts to feature model * Use transcriptParts in transcript sequence panel * Add transcript widget to right-click menu * Use transcriptParts in transcript widget structure * Add frame color to transcript widget structure * Use custom framesCDS in theme * Add header to transcript sequence --- .../src/jbrowse/jbrowse.service.ts | 9 + .../apollo-mst/src/AnnotationFeatureModel.ts | 95 +++- .../src/FeatureDetailsWidget/Attributes.tsx | 7 +- .../FeatureDetailsWidget/TranscriptBasic.tsx | 513 +++++------------- .../TranscriptSequence.tsx | 493 +++++++---------- .../LinearApolloDisplay/glyphs/BoxGlyph.ts | 24 +- 6 files changed, 456 insertions(+), 685 deletions(-) diff --git a/packages/apollo-collaboration-server/src/jbrowse/jbrowse.service.ts b/packages/apollo-collaboration-server/src/jbrowse/jbrowse.service.ts index bdff3138..5c6f8158 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: { diff --git a/packages/apollo-mst/src/AnnotationFeatureModel.ts b/packages/apollo-mst/src/AnnotationFeatureModel.ts index dc093d34..49ce671a 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) => ({ diff --git a/packages/jbrowse-plugin-apollo/src/FeatureDetailsWidget/Attributes.tsx b/packages/jbrowse-plugin-apollo/src/FeatureDetailsWidget/Attributes.tsx index f47c6ec5..d0a956de 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 acc7db87..bcceb1fc 100644 --- a/packages/jbrowse-plugin-apollo/src/FeatureDetailsWidget/TranscriptBasic.tsx +++ b/packages/jbrowse-plugin-apollo/src/FeatureDetailsWidget/TranscriptBasic.tsx @@ -3,71 +3,23 @@ import { LocationEndChange, LocationStartChange, } from '@apollo-annotation/shared' -import { AbstractSessionModel, revcom } from '@jbrowse/core/util' -import { Typography } from '@mui/material' +import { AbstractSessionModel, getFrame, revcom } from '@jbrowse/core/util' +import { + Paper, + Typography, + Table, + TableBody, + TableCell, + TableContainer, + TableRow, + useTheme, +} from '@mui/material' import { observer } from 'mobx-react' import React from 'react' import { ApolloSessionModel } from '../session' -import { CDSInfo } from './TranscriptSequence' import { NumberTextField } from './NumberTextField' -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, @@ -84,362 +36,159 @@ export const TranscriptBasicInformation = observer( const currentAssembly = session.apolloDataStore.assemblies.get(assembly) const refData = currentAssembly?.getByRefName(refName) const { changeManager } = session.apolloDataStore + const theme = useTheme() - 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 + 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) { + 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) + return { + min, + max, + label, + fivePrimeSpliceSite, + threePrimeSpliceSite, + frameColor, } - exonsArray = removeMatchingExon(exonsArray, element.min, element.max) }) - } - - 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 ?? ''} + + ))} + +
+
) }, diff --git a/packages/jbrowse-plugin-apollo/src/FeatureDetailsWidget/TranscriptSequence.tsx b/packages/jbrowse-plugin-apollo/src/FeatureDetailsWidget/TranscriptSequence.tsx index 4497be0d..4cbe5485 100644 --- a/packages/jbrowse-plugin-apollo/src/FeatureDetailsWidget/TranscriptSequence.tsx +++ b/packages/jbrowse-plugin-apollo/src/FeatureDetailsWidget/TranscriptSequence.tsx @@ -1,131 +1,149 @@ -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[] + locs: { min: number; max: number }[] } -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, + locs: [{ min: loc.min, max: loc.max }], + }) + continue + } + if (previousSegment.type === type) { + const [previousSegmentFirstLine, ...previousSegmentFollowingLines] = + previousSegment.sequenceLines + const newSequence = previousSegmentFollowingLines.join('') + sequence + previousSegment.sequenceLines = [ + previousSegmentFirstLine, + ...splitStringIntoChunks(newSequence, SEQUENCE_WRAP_LENGTH), + ] + previousSegment.locs.push({ min: loc.min, max: loc.max }) + } 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], + locs: [{ min: loc.min, max: loc.max }], + }) + } } - 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 + 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, locs }) + 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 +158,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 +169,120 @@ 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), + ) + : [] + 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 ( <> - - Sequence - + Sequence
-
-
- {showSequence && ( + {showSequence && ( + <> - )} -
-
- {showSequence && } -
- {showSequence && ( - + + >{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) => ( + + {segment.sequenceLines.map((sequenceLine, idx) => ( + + {sequenceLine} + {idx === segment.sequenceLines.length - 1 && + sequenceLine.length !== SEQUENCE_WRAP_LENGTH ? null : ( +
+ )} +
+ ))} +
+ ))} +
+ + )} ) diff --git a/packages/jbrowse-plugin-apollo/src/LinearApolloDisplay/glyphs/BoxGlyph.ts b/packages/jbrowse-plugin-apollo/src/LinearApolloDisplay/glyphs/BoxGlyph.ts index df190ad8..0e8e5acf 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 }