From ff19e08bbd9be7a91d0fa3b618a232b3ff19e59f Mon Sep 17 00:00:00 2001 From: Guzman Date: Mon, 8 Jan 2024 15:40:44 -0300 Subject: [PATCH] Added handle for translation in linear view --- README.md | 4 +- demo/lib/App.tsx | 8 +- src/Linear/Linear.tsx | 4 +- src/Linear/SeqBlock.tsx | 4 +- src/Linear/Translations.tsx | 159 +++++++++++++++++++++++++++++++++--- src/SelectionHandler.tsx | 1 + src/SeqViewerContainer.tsx | 2 +- src/SeqViz.tsx | 10 ++- src/elements.ts | 9 ++ src/selectionContext.ts | 1 + src/sequence.ts | 6 +- src/style.ts | 15 ++++ 12 files changed, 195 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 7377fc72a..687d5098c 100644 --- a/README.md +++ b/README.md @@ -168,11 +168,11 @@ In the example above, the forward and reverse primers of LacZ are define by the #### `translations (=[])` -An array of `translations`: sequence ranges to translate and render as amino acids sequences. Requires 0-based `start` (inclusive) and `end` (exclusive) indexes relative the DNA sequence. A direction is required: `1` (FWD) or `-1` (REV). +An array of `translations`: sequence ranges to translate and render as amino acids sequences. Requires 0-based `start` (inclusive) and `end` (exclusive) indexes relative the DNA sequence. A direction is required: `1` (FWD) or `-1` (REV). It will also render a handle to select the entire range. A color and a name are optional for the handle. If no name is provided, start and end indices will be used as the name. ```js translations = [ - { start: 0, end: 90, direction: 1 }, // [0, 90) + { start: 0, end: 90, direction: 1, name: "ORF 1", color: "#FAA887" }, // [0, 90) { start: 191, end: 522, direction: -1 }, ]; ``` diff --git a/demo/lib/App.tsx b/demo/lib/App.tsx index c787f118b..b76703ab0 100644 --- a/demo/lib/App.tsx +++ b/demo/lib/App.tsx @@ -18,7 +18,7 @@ import Circular from "../../src/Circular/Circular"; import Linear from "../../src/Linear/Linear"; import SeqViz from "../../src/SeqViz"; import { chooseRandomColor } from "../../src/colors"; -import { AnnotationProp, Primer } from "../../src/elements"; +import { AnnotationProp, Primer, TranslationProp } from "../../src/elements"; import Header from "./Header"; import file from "./file"; @@ -43,7 +43,7 @@ interface AppState { showIndex: boolean; showSelectionMeta: boolean; showSidebar: boolean; - translations: { direction?: 1 | -1; end: number; start: number }[]; + translations: TranslationProp[]; viewer: string; zoom: number; } @@ -105,9 +105,9 @@ export default class App extends React.Component { showSelectionMeta: false, showSidebar: false, translations: [ - { direction: -1, end: 630, start: 6 }, + { color: chooseRandomColor(), direction: -1, end: 630, name: "ORF 1", start: 6 }, { end: 1147, start: 736 }, - { end: 1885, start: 1165 }, + { end: 1885, name: "ORF 2", start: 1165 }, ], viewer: "both", zoom: 50, diff --git a/src/Linear/Linear.tsx b/src/Linear/Linear.tsx index 41fd28032..64e17dc53 100644 --- a/src/Linear/Linear.tsx +++ b/src/Linear/Linear.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import { InputRefFunc } from "../SelectionHandler"; -import { Annotation, CutSite, Highlight, NameRange, Primer, Range, SeqType, Size } from "../elements"; +import { Annotation, CutSite, Highlight, NameRange, Primer, SeqType, Size } from "../elements"; import { createMultiRows, createSingleRows, stackElements } from "../elementsToRows"; import { isEqual } from "../isEqual"; import { createTranslations } from "../sequence"; @@ -29,7 +29,7 @@ export interface LinearProps { showComplement: boolean; showIndex: boolean; size: Size; - translations: Range[]; + translations: NameRange[]; zoom: { linear: number }; } diff --git a/src/Linear/SeqBlock.tsx b/src/Linear/SeqBlock.tsx index e7fd375b1..a04c2e5b6 100644 --- a/src/Linear/SeqBlock.tsx +++ b/src/Linear/SeqBlock.tsx @@ -279,8 +279,9 @@ export class SeqBlock extends React.PureComponent { const primerRevHeight = primerRevRows.length ? elementHeight * primerRevRows.length : 0; // height and yDiff of translations + // elementHeight * 2 is to account for the translation handle const translationYDiff = primerRevYDiff + primerRevHeight; - const translationHeight = elementHeight * translationRows.length; + const translationHeight = elementHeight * 2 * translationRows.length; // height and yDiff of annotations const annYDiff = translationYDiff + translationHeight; @@ -424,6 +425,7 @@ export class SeqBlock extends React.PureComponent { charWidth={charWidth} elementHeight={elementHeight} findXAndWidth={this.findXAndWidth} + findXAndWidthElement={this.findXAndWidthElement} firstBase={firstBase} fullSeq={fullSeq} inputRef={inputRef} diff --git a/src/Linear/Translations.tsx b/src/Linear/Translations.tsx index 384db0675..884ba6ecc 100644 --- a/src/Linear/Translations.tsx +++ b/src/Linear/Translations.tsx @@ -2,16 +2,25 @@ import * as React from "react"; import { InputRefFunc } from "../SelectionHandler"; import { borderColorByIndex, colorByIndex } from "../colors"; -import { SeqType, Translation } from "../elements"; +import { NameRange, SeqType, Translation } from "../elements"; import { randomID } from "../sequence"; -import { translationAminoAcidLabel } from "../style"; -import { FindXAndWidthType } from "./SeqBlock"; +import { translationAminoAcidLabel, translationHandle, translationHandleLabel } from "../style"; +import { FindXAndWidthElementType, FindXAndWidthType } from "./SeqBlock"; + +const hoverOtherTranshlationHandleRows = (className: string, opacity: number) => { + if (!document) return; + const elements = document.getElementsByClassName(className) as HTMLCollectionOf; + for (let i = 0; i < elements.length; i += 1) { + elements[i].style.fillOpacity = `${opacity}`; + } +}; interface TranslationRowsProps { bpsPerBlock: number; charWidth: number; elementHeight: number; findXAndWidth: FindXAndWidthType; + findXAndWidthElement: FindXAndWidthElementType; firstBase: number; fullSeq: string; inputRef: InputRefFunc; @@ -28,6 +37,7 @@ export const TranslationRows = ({ charWidth, elementHeight, findXAndWidth, + findXAndWidthElement, firstBase, fullSeq, inputRef, @@ -43,7 +53,9 @@ export const TranslationRows = ({ key={`i-${firstBase}`} bpsPerBlock={bpsPerBlock} charWidth={charWidth} + elementHeight={elementHeight} findXAndWidth={findXAndWidth} + findXAndWidthElement={findXAndWidthElement} firstBase={firstBase} fullSeq={fullSeq} height={elementHeight * 0.9} @@ -51,7 +63,7 @@ export const TranslationRows = ({ lastBase={lastBase} seqType={seqType} translations={translations} - y={yDiff + elementHeight * i} + y={yDiff + elementHeight * 2 * i} // * 2 because we have two elements per row, the aminoacids and the handle onUnmount={onUnmount} /> ))} @@ -65,7 +77,9 @@ export const TranslationRows = ({ const TranslationRow = (props: { bpsPerBlock: number; charWidth: number; + elementHeight: number; findXAndWidth: FindXAndWidthType; + findXAndWidthElement: FindXAndWidthElementType; firstBase: number; fullSeq: string; height: number; @@ -78,16 +92,26 @@ const TranslationRow = (props: { }) => ( <> {props.translations.map((t, i) => ( - + <> + + + + ))} ); -interface SingleNamedElementProps { +interface SingleNamedElementAminoacidsProps { bpsPerBlock: number; charWidth: number; findXAndWidth: FindXAndWidthType; @@ -106,7 +130,7 @@ interface SingleNamedElementProps { * A single row for translations of DNA into Amino Acid sequences so a user can * see the resulting protein or peptide sequence in the viewer */ -class SingleNamedElement extends React.PureComponent { +class SingleNamedElementAminoacids extends React.PureComponent { AAs: string[] = []; // on unmount, clear all AA references. @@ -268,3 +292,116 @@ class SingleNamedElement extends React.PureComponent { ); } } + + +/** + * SingleNamedElement is a single rectangular element in the SeqBlock. + * It does a bunch of stuff to avoid edge-cases from wrapping around the 0-index, edge of blocks, etc. + */ +const SingleNamedElementHandle = (props: { + element: NameRange; + elementHeight: number; + elements: NameRange[]; + findXAndWidthElement: FindXAndWidthElementType; + height: number; + index: number; + inputRef: InputRefFunc; + y: number; +}) => { + const { element, elementHeight, elements, findXAndWidthElement, index, inputRef, y } = props; + + const { color, end, name, start } = element; + const { width, x: origX } = findXAndWidthElement(index, element, elements); + + + // 0.591 is our best approximation of Roboto Mono's aspect ratio (width / height). + const fontSize = 12; + const characterWidth = 0.591 * fontSize; + // Use at most 1/4 of the width for the name handle. + const availableCharacters = Math.floor((width / 4) / characterWidth); + + let displayName = name; + if (name.length > availableCharacters) { + const charactersToShow = availableCharacters - 1; + if (charactersToShow < 3) { + // If we can't show at least three characters, don't show any. + displayName = ""; + } else { + displayName = `${name.slice(0, charactersToShow)}…`; + } + } + + + + // What's needed for the display + margin at the start + margin at the end + const nameHandleMargin = 10 + const nameHandleWidth = displayName.length * characterWidth + nameHandleMargin * 2 + + const x = origX; + const w = width; + const height = props.height; + + + let linePath = "" + // First rectangle that contains the name and has the whole height + linePath += `M 0 0 L ${nameHandleWidth} 0 L ${nameHandleWidth} ${height} L 0 ${height}`; + // Second rectangle with half the height and centered + linePath += `M ${nameHandleWidth} ${height / 4} L ${w} ${height / 4} L ${w} ${3 * height / 4} L ${nameHandleWidth} ${3 * height / 4}`; + + return ( + + + {/* provides a hover tooltip on most browsers */} + <title>{name} + { + // do nothing + }} + onFocus={() => { + // do nothing + }} + onMouseOut={() => hoverOtherTranshlationHandleRows(element.id, 0.7)} + onMouseOver={() => hoverOtherTranshlationHandleRows(element.id, 1.0)} + /> + { + // do nothing + }} + onFocus={() => { + // do nothing + }} + onMouseOut={() => hoverOtherTranshlationHandleRows(element.id, 0.7)} + onMouseOver={() => hoverOtherTranshlationHandleRows(element.id, 1.0)} + > + {displayName} + + + + ); +}; + diff --git a/src/SelectionHandler.tsx b/src/SelectionHandler.tsx index b1d53f553..b2bb238a1 100644 --- a/src/SelectionHandler.tsx +++ b/src/SelectionHandler.tsx @@ -134,6 +134,7 @@ export default class SelectionHandler extends React.PureComponent; /** testSize is a forced height/width that overwrites anything from sizeMe. For testing */ testSize?: { height: number; width: number }; - translations: Range[]; + translations: NameRange[]; viewer: "linear" | "circular" | "both" | "both_flip"; width: number; zoom: { circular: number; linear: number }; diff --git a/src/SeqViz.tsx b/src/SeqViz.tsx index 78e2034e1..b798f238d 100644 --- a/src/SeqViz.tsx +++ b/src/SeqViz.tsx @@ -15,6 +15,7 @@ import { PrimerProp, Range, SeqType, + TranslationProp, } from "./elements"; import { isEqual } from "./isEqual"; import search from "./search"; @@ -150,7 +151,7 @@ export interface SeqVizProps { style?: Record; /** ranges of sequence that should have amino acid translations shown */ - translations?: { direction?: number; end: number; start: number }[]; + translations?: TranslationProp[]; /** the orientation of the viewer(s). "both", the default, has a circular viewer on left and a linear viewer on right. */ viewer?: "linear" | "circular" | "both" | "both_flip"; @@ -427,7 +428,7 @@ export default class SeqViz extends React.Component { // If the seqType is aa, make the entire sequence the "translation" if (seqType === "aa") { // TODO: during some grand future refactor, make this cleaner and more transparent to the user - translations = [{ direction: 1, end: seq.length, start: 0 }]; + translations = [{ direction: 1, end: seq.length, start: 0, name: "translation" }]; } // Since all the props are optional, we need to parse them to defaults. @@ -455,10 +456,13 @@ export default class SeqViz extends React.Component { rotateOnScroll: !!this.props.rotateOnScroll, showComplement: (!!compSeq && (typeof showComplement !== "undefined" ? showComplement : true)) || false, showIndex: !!showIndex, - translations: (translations || []).map((t): { direction: 1 | -1; end: number; start: number } => ({ + translations: (translations || []).map((t, i): { direction: 1 | -1; end: number; start: number, color: string, id: string, name: string } => ({ direction: t.direction ? (t.direction < 0 ? -1 : 1) : 1, end: seqType === "aa" ? t.end : t.start + Math.floor((t.end - t.start) / 3) * 3, start: t.start % seq.length, + color: t.color || colorByIndex(i, COLORS), + id: `translation${t.name}${i}${t.start}${t.end}`, + name: t.name || `${t.start}-${t.end}` })), viewer: this.props.viewer || "both", zoom: { diff --git a/src/elements.ts b/src/elements.ts index c81b327e9..2b53c4cc1 100644 --- a/src/elements.ts +++ b/src/elements.ts @@ -21,6 +21,15 @@ export interface AnnotationProp { start: number; } +/** AnnotationProp is an annotation provided to SeqViz via the annotations prop. */ +export interface TranslationProp { + color?: string; + direction?: number; + end: number; + name?: string; + start: number; +} + /** Annotation is an annotation after parsing. */ export interface Annotation extends NameRange { color: string; diff --git a/src/selectionContext.ts b/src/selectionContext.ts index 0c7e10dd3..8626f94bd 100644 --- a/src/selectionContext.ts +++ b/src/selectionContext.ts @@ -4,6 +4,7 @@ type SelectionTypeEnum = | "ANNOTATION" | "FIND" | "TRANSLATION" + | "TRANSLATION_HANDLE" | "ENZYME" | "SEQ" | "AMINOACID" diff --git a/src/sequence.ts b/src/sequence.ts index 26749063b..f5147db88 100644 --- a/src/sequence.ts +++ b/src/sequence.ts @@ -1,4 +1,4 @@ -import { Range, SeqType } from "./elements"; +import { NameRange, SeqType } from "./elements"; /** * Map of nucleotide bases @@ -297,7 +297,7 @@ export const translate = (seqInput: string, seqType: SeqType): string => { /** * for each translation (range + direction) and the input sequence, convert it to a translation and amino acid sequence */ -export const createTranslations = (translations: Range[], seq: string, seqType: SeqType) => { +export const createTranslations = (translations: NameRange[], seq: string, seqType: SeqType) => { // elongate the original sequence to account for translations that cross the zero index const seqDoubled = seq + seq; const bpPerBlock = seqType === "aa" ? 1 : 3; @@ -329,8 +329,6 @@ export const createTranslations = (translations: Range[], seq: string, seqType: } return { - id: randomID(), - name: "translation", ...t, AAseq: aaSeq, end: tEnd, diff --git a/src/style.ts b/src/style.ts index 797dd2fe1..02fc0945f 100644 --- a/src/style.ts +++ b/src/style.ts @@ -105,6 +105,21 @@ export const annotationLabel: CSS.Properties = { textRendering: "optimizeLegibility", }; +export const translationHandle: CSS.Properties = { + fillOpacity: "0.7", + shapeRendering: "geometricPrecision", + strokeWidth: "0.5", +}; + +export const translationHandleLabel: CSS.Properties = { + ...svgText, + color: "rgb(42, 42, 42)", + fontWeight: 400, + shapeRendering: "geometricPrecision", + strokeLinejoin: "round", + textRendering: "optimizeLegibility", +}; + export const translationAminoAcidLabel: CSS.Properties = { ...svgText, color: "rgb(42, 42, 42)",