diff --git a/README.md b/README.md index cec262ac8..f1db306b7 100644 --- a/README.md +++ b/README.md @@ -170,12 +170,12 @@ 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 is optional for the handle. If the empry string ("") is provided as the name, the handle will not be rendered. ```js translations = [ - { start: 0, end: 90, direction: 1 }, // [0, 90) - { start: 191, end: 522, direction: -1 }, + { start: 0, end: 90, direction: 1, name: "ORF 1", color: "#FAA887" }, // [0, 90) + { start: 191, end: 522, direction: -1, name: "" }, ]; ``` diff --git a/demo/lib/App.tsx b/demo/lib/App.tsx index 6d041eccc..f9013723e 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; } @@ -97,9 +97,9 @@ export default class App extends React.Component { showSelectionMeta: false, showSidebar: false, translations: [ - { direction: -1, end: 630, start: 6 }, - { end: 1147, start: 736 }, - { end: 1885, start: 1165 }, + { color: chooseRandomColor(), direction: -1, end: 630, name: "ORF 1", start: 6 }, + { end: 1147, name: "", start: 736 }, + { 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..64c6193e1 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 }; } @@ -165,7 +165,7 @@ export default class Linear extends React.Component { blockHeight += lineHeight; // another for index row } if (translationRows[i].length) { - blockHeight += translationRows[i].length * elementHeight; + blockHeight += translationRows[i].length * elementHeight * 2; // * 2 to account for the translation handle } if (annotationRows[i].length) { blockHeight += annotationRows[i].length * elementHeight; diff --git a/src/Linear/SeqBlock.tsx b/src/Linear/SeqBlock.tsx index e7fd375b1..8b179d169 100644 --- a/src/Linear/SeqBlock.tsx +++ b/src/Linear/SeqBlock.tsx @@ -279,8 +279,13 @@ 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. If no name, don't show the handle const translationYDiff = primerRevYDiff + primerRevHeight; - const translationHeight = elementHeight * translationRows.length; + let translationHeight = 0; + for (let i = 0; i < translationRows.length; i++) { + const multiplier = translationRows[i][0]["name"] ? 2 : 1; + translationHeight += elementHeight * multiplier; + } // height and yDiff of annotations const annYDiff = translationYDiff + translationHeight; @@ -424,6 +429,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..334325f11 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, @@ -38,23 +48,33 @@ export const TranslationRows = ({ yDiff, }: TranslationRowsProps) => ( - {translationRows.map((translations, i) => ( - - ))} + {translationRows.map((translations, i) => { + // Add up the previous translation heights, taking into account if they have a handle or not + let currentElementY = yDiff; + for (let j = 0; j < i; j += 1) { + const multiplier = translationRows[j][0]["name"] ? 2 : 1; + currentElementY += elementHeight * multiplier; + } + return ( + + ); + })} ); @@ -65,7 +85,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 +100,27 @@ const TranslationRow = (props: { }) => ( <> {props.translations.map((t, i) => ( - + <> + + {t.name && ( + + )} + ))} ); -interface SingleNamedElementProps { +interface SingleNamedElementAminoacidsProps { bpsPerBlock: number; charWidth: number; findXAndWidth: FindXAndWidthType; @@ -106,7 +139,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. @@ -167,6 +200,8 @@ class SingleNamedElement extends React.PureComponent { type: "AMINOACID", viewer: "LINEAR", })} + className="la-vz-linear-aa-translation" + data-testid="la-vz-linear-aa-translation" id={id} transform={`translate(0, ${y})`} > @@ -268,3 +303,118 @@ 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 = 9; + 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 && 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 nameHandleLeftMargin = 10; + const nameHandleWidth = displayName.length * characterWidth + nameHandleLeftMargin * 2; + + const x = origX; + const w = width; + const height = props.height; + const marginBottom = 2; + const marginTop = 2; + + let linePath = ""; + linePath += `M 0 ${marginTop} + L ${nameHandleWidth} ${marginTop} + L ${nameHandleWidth} ${height / 4 - marginBottom / 2 + marginTop / 2} + L ${w} ${height / 4 - marginBottom / 2 + marginTop / 2} + L ${w} ${(3 * height) / 4 - marginBottom / 2 + marginTop / 2} + L ${nameHandleWidth} ${(3 * height) / 4 - marginBottom / 2 + marginTop / 2} + L ${nameHandleWidth} ${height - marginBottom} + L 0 ${height - marginBottom} + Z`; + + 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 dc4982bad..35f1cfa70 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"; @@ -146,7 +147,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"; @@ -423,7 +424,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. @@ -451,11 +452,16 @@ 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 } => ({ - 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, - })), + 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, + }) + ), viewer: this.props.viewer || "both", zoom: { circular: typeof zoom?.circular == "number" ? Math.min(Math.max(zoom.circular, 0), 100) : 0, diff --git a/src/elements.ts b/src/elements.ts index c81b327e9..93943dcc6 100644 --- a/src/elements.ts +++ b/src/elements.ts @@ -21,6 +21,15 @@ export interface AnnotationProp { start: number; } +/** TranslationProp is an translation provided to SeqViz via the translation 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/index.test.tsx b/src/index.test.tsx index 9b7e35261..fa891b9bc 100644 --- a/src/index.test.tsx +++ b/src/index.test.tsx @@ -70,7 +70,7 @@ describe("SeqViz rendering (React)", () => { expect(getByTestId("la-vz-viewer-linear")).toBeTruthy(); expect(getAllByTestId("la-vz-viewer-linear")).toHaveLength(1); - const seqs = getAllByTestId("la-vz-linear-translation"); + const seqs = getAllByTestId("la-vz-linear-aa-translation"); const seq = seqs.map(s => s.textContent).join(""); expect(seq.length).toBeGreaterThan(0); expect(aaSeq).toContain(seq); diff --git a/src/selectionContext.ts b/src/selectionContext.ts index 1f957800c..7da082d85 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..5968ca6c3 100644 --- a/src/style.ts +++ b/src/style.ts @@ -105,6 +105,22 @@ 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)", + fontSize: "9", + fontWeight: 400, + shapeRendering: "geometricPrecision", + strokeLinejoin: "round", + textRendering: "optimizeLegibility", +}; + export const translationAminoAcidLabel: CSS.Properties = { ...svgText, color: "rgb(42, 42, 42)",