From fbd24c1035b64d43e39957835cca25107f635ee3 Mon Sep 17 00:00:00 2001 From: isaacguerreir <isaacguerreirocom@gmail.com> Date: Tue, 21 Nov 2023 16:21:03 -0300 Subject: [PATCH] primer svg created and rendered to linear component --- demo/lib/App.tsx | 25 +++- src/Linear/Linear.tsx | 32 ++++- src/Linear/Primers.tsx | 268 ++++++++++++++++++++++++++++++++++++++++ src/Linear/SeqBlock.tsx | 54 +++++++- 4 files changed, 372 insertions(+), 7 deletions(-) create mode 100644 src/Linear/Primers.tsx diff --git a/demo/lib/App.tsx b/demo/lib/App.tsx index b10b7dcfd..7ed198e7a 100644 --- a/demo/lib/App.tsx +++ b/demo/lib/App.tsx @@ -17,9 +17,11 @@ import seqparse from "seqparse"; import Circular from "../../src/Circular/Circular"; import Linear from "../../src/Linear/Linear"; import SeqViz from "../../src/SeqViz"; -import { AnnotationProp } from "../../src/elements"; +import { AnnotationProp, Primer } from "../../src/elements"; import Header from "./Header"; import file from "./file"; +import { Direction } from "../../src/Linear/Primers"; +import { COLORS, chooseRandomColor } from "../../src/colors"; const viewerTypeOptions = [ { key: "both", text: "Both", value: "both" }, @@ -33,6 +35,7 @@ interface AppState { customChildren: boolean; enzymes: any[]; name: string; + primers: Primer[] search: { query: string }; searchResults: any; selection: any; @@ -52,6 +55,24 @@ export default class App extends React.Component<any, AppState> { customChildren: true, enzymes: ["PstI", "EcoRI", "XbaI", "SpeI"], name: "", + primers: [ + { + color: chooseRandomColor(), + direction: Direction.FOWARD, + end: 653, + id: "527923581", + name: "pLtetO-1 fw primer", + start: 633, + }, + { + color: chooseRandomColor(), + direction: Direction.REVERSE, + end: 706, + id: "527923582", + name: "pLtetO-1 rev primer", + start: 686, + }, + ], search: { query: "ttnnnaat" }, searchResults: {}, selection: {}, @@ -73,6 +94,7 @@ export default class App extends React.Component<any, AppState> { componentDidMount = async () => { const seq = await seqparse(file); + this.setState({ annotations: seq.annotations, name: seq.name, seq: seq.seq }); }; @@ -215,6 +237,7 @@ export default class App extends React.Component<any, AppState> { // accession="MN623123" key={`${this.state.viewer}${this.state.customChildren}`} annotations={this.state.annotations} + primers={this.state.primers} enzymes={this.state.enzymes} highlights={[{ start: 0, end: 10 }]} name={this.state.name} diff --git a/src/Linear/Linear.tsx b/src/Linear/Linear.tsx index 3ad8e9e3a..d0f8a9652 100644 --- a/src/Linear/Linear.tsx +++ b/src/Linear/Linear.tsx @@ -1,12 +1,13 @@ import * as React from "react"; import { InputRefFunc } from "../SelectionHandler"; -import { Annotation, CutSite, Highlight, NameRange, Range, SeqType, Size } from "../elements"; +import { Annotation, CutSite, Highlight, NameRange, Primer, Range, SeqType, Size } from "../elements"; import { createMultiRows, createSingleRows, stackElements } from "../elementsToRows"; import { isEqual } from "../isEqual"; import { createTranslations } from "../sequence"; import { InfiniteScroll } from "./InfiniteScroll"; import { SeqBlock } from "./SeqBlock"; +import { Direction } from "./Primers"; export interface LinearProps { annotations: Annotation[]; @@ -21,6 +22,7 @@ export interface LinearProps { inputRef: InputRefFunc; lineHeight: number; onUnmount: (id: string) => void; + primers: Primer[]; search: NameRange[]; seq: string; seqFontSize: number; @@ -68,6 +70,7 @@ export default class Linear extends React.Component<LinearProps> { highlights, lineHeight, onUnmount, + primers, search, seq, seqType, @@ -99,7 +102,7 @@ export default class Linear extends React.Component<LinearProps> { /** * Vet the annotations for starts and ends at zero index */ - const vetAnnotations = (annotations: Annotation[]) => { + const vetAnnotations = (annotations: Annotation[] | Primer[]) => { annotations.forEach(ann => { if (ann.end === 0 && ann.start > ann.end) ann.end = seqLength; if (ann.start === seqLength && ann.end < ann.start) ann.start = 0; @@ -107,6 +110,23 @@ export default class Linear extends React.Component<LinearProps> { return annotations; }; + const primerFwRows = createMultiRows( + stackElements(vetAnnotations( + primers.filter((p) => p.direction == Direction.FOWARD)), + seq.length + ), + bpsPerBlock, + arrSize + ) + const primerRvRows = createMultiRows( + stackElements(vetAnnotations( + primers.filter((p) => p.direction == Direction.REVERSE)), + seq.length + ), + bpsPerBlock, + arrSize + ) + const annotationRows = createMultiRows( stackElements(vetAnnotations(annotations), seq.length), bpsPerBlock, @@ -141,6 +161,12 @@ export default class Linear extends React.Component<LinearProps> { if (zoomed) { blockHeight += showComplement ? lineHeight : 0; // double for complement + 2px margin } + if (primerFwRows[i].length) { + blockHeight += lineHeight; + } + if (primerRvRows[i].length) { + blockHeight += lineHeight; + } if (showIndex) { blockHeight += lineHeight; // another for index row } @@ -164,6 +190,8 @@ export default class Linear extends React.Component<LinearProps> { seqBlocks.push( <SeqBlock key={ids[i]} + primerFwRows={primerFwRows[i]} + primerRvRows={primerRvRows[i]} annotationRows={annotationRows[i]} blockHeight={blockHeights[i]} bpColors={this.props.bpColors} diff --git a/src/Linear/Primers.tsx b/src/Linear/Primers.tsx new file mode 100644 index 000000000..51e45a465 --- /dev/null +++ b/src/Linear/Primers.tsx @@ -0,0 +1,268 @@ + +import * as React from "react"; + +import { InputRefFunc } from "../SelectionHandler"; +import { COLOR_BORDER_MAP, darkerColor } from "../colors"; +import { NameRange } from "../elements"; +import { annotation, annotationLabel } from "../style"; +import { FindXAndWidthElementType } from "./SeqBlock"; + +const hoverOtherAnnotationRows = (className: string, opacity: number) => { + if (!document) return; + const elements = document.getElementsByClassName(className) as HTMLCollectionOf<HTMLElement>; + for (let i = 0; i < elements.length; i += 1) { + elements[i].style.fillOpacity = `${opacity}`; + } +}; + +export enum Direction { + FOWARD = 1, + REVERSE = -1 +} +/** + * Render each row of annotations into its own row. + * This is not a default export for sake of the React component displayName. + */ +const PrimeRows = (props: { + primerRows: NameRange[][]; + direction: Direction + bpsPerBlock: number; + elementHeight: number; + findXAndWidth: FindXAndWidthElementType; + firstBase: number; + fullSeq: string; + inputRef: InputRefFunc; + lastBase: number; + seqBlockRef: unknown; + width: number; + yDiff: number; +}) => ( + <g> + {props.primerRows.map((primers: NameRange[], i: number) => ( + <PrimerRow + key={`annotation-linear-row-${primers[0].id}-${props.firstBase}-${props.lastBase}`} + primers={primers} + direction={props.direction} + bpsPerBlock={props.bpsPerBlock} + findXAndWidth={props.findXAndWidth} + firstBase={props.firstBase} + fullSeq={props.fullSeq} + height={props.elementHeight} + inputRef={props.inputRef} + lastBase={props.lastBase} + seqBlockRef={props.seqBlockRef} + width={props.width} + y={props.yDiff + props.elementHeight * i} + /> + ))} + </g> +); + +export default PrimeRows; + +/** + * A single row of annotations. Multiple of these may be in one seqBlock + * vertically stacked on top of one another in non-overlapping arrays. + */ +const PrimerRow = (props: { + primers: NameRange[]; + direction: Direction; + bpsPerBlock: number; + findXAndWidth: FindXAndWidthElementType; + firstBase: number; + fullSeq: string; + height: number; + inputRef: InputRefFunc; + lastBase: number; + seqBlockRef: unknown; + width: number; + y: number; +}) => { + return ( + <g + className="la-vz-linear-annotation-row" + height={props.height * 0.8} + transform={`translate(0, ${props.y})`} + width={props.width} + > + {props.primers.filter((a) => a.direction == props.direction).map((a, i) => ( + <SingleNamedElement + {...props} // include overflowLeft in the key to avoid two split annotations in the same row from sharing a key + key={`annotation-linear-${a.id}-${i}-${props.firstBase}-${props.lastBase}`} + element={a} + elements={props.primers} + index={i} + /> + ) + )} + </g> + ); +} +/** + * 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 SingleNamedElement = (props: { + element: NameRange; + elements: NameRange[]; + findXAndWidth: FindXAndWidthElementType; + firstBase: number; + height: number; + index: number; + inputRef: InputRefFunc; + lastBase: number; +}) => { + const { element, elements, findXAndWidth, firstBase, index, inputRef, lastBase } = props; + + const { color, direction, end, name, start } = element; + const forward = direction === Direction.FOWARD; + const reverse = direction === Direction.REVERSE; + const { overflowLeft, overflowRight, width, x: origX } = findXAndWidth(index, element, elements); + const crossZero = start > end && end < firstBase; + + // does the element begin or end within this seqBlock with a directionality? + const endFWD = forward && end > firstBase && end <= lastBase; + const endREV = reverse && start >= firstBase && start <= lastBase; + + // create padding on either side, vertically, of an element + const height = props.height * 0.8; + + const cW = 4; // jagged cutoff width + const cH = height / 4; // jagged cutoff height + const [x, w] = [origX, width]; + + // create the SVG path, starting at the topLeft and working clockwise + // there is additional logic here for if the element overflows + // to the left or right of this seqBlock, where a "jagged edge" is created + const topLeft = "M 0 0"; + let topRight = endFWD ? + ` + L ${width - Math.min(8 * cW, w)} 0 + L ${width - Math.min(8 * cW, w)} ${-1 * height} + ` : + `L ${width} 0`; + + let linePath = ""; + + let bottomRight = `L ${width} ${height}`; // flat right edge + if ((overflowRight && width > 2 * cW) || crossZero) { + bottomRight = ` + L ${width - cW} ${cH} + L ${width} ${2 * cH} + L ${width - cW} ${3 * cH} + L ${width} ${4 * cH}`; // jagged right edge + } else if (endFWD) { + bottomRight = ` + L ${width} ${height}`; // arrow forward + } + + let bottomLeft = `L 0 ${height} L 0 0`; // flat left edge + if (overflowLeft && width > 2 * cW) { + bottomLeft = ` + L 0 ${height} + L ${cW} ${3 * cH} + L 0 ${2 * cH} + L ${cW} ${cH} + L 0 0`; // jagged left edge + } else if (endREV) { + bottomLeft = ` + L ${Math.min(8 * cW, w)} ${height} + L ${Math.min(8 * cW, w)} ${height * 2}`; // arrow reverse + } + + linePath = `${topLeft} ${topRight} ${bottomRight} ${bottomLeft}`; + + if ((forward && overflowRight) || (forward && crossZero)) { + // If it's less than 15 pixels the double arrow barely fits + if (width > 15) { + linePath += ` + M ${width - 3 * cW} ${cH} + L ${width - 2 * cW} ${2 * cH} + L ${width - 3 * cW} ${3 * cH} + M ${width - 4 * cW} ${cH} + L ${width - 3 * cW} ${2 * cH} + L ${width - 4 * cW} ${3 * cH}`; // add double arrow forward + } + } + if ((reverse && overflowLeft) || (reverse && crossZero)) { + // If it's less than 15 pixels the double arrow barely fits + if (width > 15) { + linePath += ` + M ${3 * cW} ${3 * cH} + L ${2 * cW} ${cH * 2} + L ${3 * cW} ${cH} + M ${4 * cW} ${3 * cH} + L ${3 * cW} ${cH * 2} + L ${4 * cW} ${cH}`; // add double forward reverse + } + } + // 0.591 is our best approximation of Roboto Mono's aspect ratio (width / height). + const fontSize = 12; + const annotationCharacterWidth = 0.591 * fontSize; + const availableCharacters = Math.floor((width - 40) / annotationCharacterWidth); + + // Ellipsize or hide the name if it's too long. + 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)}…`; + } + } + + return ( + <g id={element.id} transform={`translate(${x}, ${0.1 * height})`}> + {/* <title> provides a hover tooltip on most browsers */} + <title>{name}</title> + <path + ref={inputRef(element.id, { + end: end, + name: element.name, + ref: element.id, + start: start, + type: "ANNOTATION", + viewer: "LINEAR", + })} + className={`${element.id} la-vz-annotation`} + cursor="pointer" + d={linePath} + fill={color} + id={element.id} + stroke={color ? COLOR_BORDER_MAP[color] || darkerColor(color) : "gray"} + style={annotation} + onBlur={() => { + // do nothing + }} + onFocus={() => { + // do nothing + }} + onMouseOut={() => hoverOtherAnnotationRows(element.id, 0.7)} + onMouseOver={() => hoverOtherAnnotationRows(element.id, 1.0)} + /> + <text + className="la-vz-annotation-label" + cursor="pointer" + dominantBaseline="middle" + fontSize={fontSize} + id={element.id} + style={annotationLabel} + textAnchor="middle" + x={width / 2} + y={height / 2 + 1} + onBlur={() => { + // do nothing + }} + onFocus={() => { + // do nothing + }} + onMouseOut={() => hoverOtherAnnotationRows(element.id, 0.7)} + onMouseOver={() => hoverOtherAnnotationRows(element.id, 1.0)} + > + {displayName} + </text> + </g> + ); +}; diff --git a/src/Linear/SeqBlock.tsx b/src/Linear/SeqBlock.tsx index f1bbec3af..0a6ddfe7f 100644 --- a/src/Linear/SeqBlock.tsx +++ b/src/Linear/SeqBlock.tsx @@ -10,6 +10,7 @@ import { Highlights } from "./Highlights"; import IndexRow from "./Index"; import Selection from "./Selection"; import { TranslationRows } from "./Translations"; +import PrimeRows, { Direction } from "./Primers"; export type FindXAndWidthType = ( n1?: number | null, @@ -27,6 +28,8 @@ export type FindXAndWidthElementType = ( interface SeqBlockProps { annotationRows: Annotation[][]; + primerFwRows: Annotation[][]; + primerRvRows: Annotation[][]; blockHeight: number; bpColors?: { [key: number | string]: string }; bpsPerBlock: number; @@ -215,6 +218,8 @@ export class SeqBlock extends React.PureComponent<SeqBlockProps> { render() { const { annotationRows, + primerFwRows, + primerRvRows, blockHeight, bpsPerBlock, charWidth, @@ -257,16 +262,23 @@ export class SeqBlock extends React.PureComponent<SeqBlockProps> { const cutSiteYDiff = 0; // spacing for cutSite names const cutSiteHeight = zoomed && cutSiteRows.length ? lineHeight : 0; + //height and yDiff of foward primers + const primerFwYDiff = cutSiteYDiff + cutSiteHeight; + const primerFwHeight = primerFwRows.length ? elementHeight : 0; + // height and yDiff of the sequence strand - const indexYDiff = cutSiteYDiff + cutSiteHeight; + const indexYDiff = primerFwYDiff + primerFwHeight; const indexHeight = seqType === "aa" ? 0 : lineHeight; // if aa, no seq row is shown // height and yDiff of the complement strand const compYDiff = indexYDiff + indexHeight; const compHeight = zoomed && showComplement ? lineHeight : 0; + //height and yDiff of reverse primers + const primerRvYDiff = compYDiff + compHeight; + const primerRvHeight = primerRvRows.length ? elementHeight * 2 : 0; // height and yDiff of translations - const translationYDiff = compYDiff + compHeight; + const translationYDiff = primerRvYDiff + primerRvHeight; const translationHeight = elementHeight * translationRows.length; // height and yDiff of annotations @@ -274,12 +286,13 @@ export class SeqBlock extends React.PureComponent<SeqBlockProps> { const annHeight = elementHeight * annotationRows.length; // height and ydiff of the index row. - const elementGap = annotationRows.length + translationRows.length ? 3 : 0; + const elementGap = primerRvRows.length + primerRvRows.length + annotationRows.length + translationRows.length ? 3 : 0; + const indexRowYDiff = annYDiff + annHeight + elementGap; // calc the height necessary for the sequence selection // it starts 5 above the top of the SeqBlock - const selectHeight = cutSiteHeight + indexHeight + compHeight + translationHeight + annHeight + elementGap + 5; + const selectHeight = cutSiteHeight + indexHeight + compHeight + translationHeight + annHeight + primerFwHeight + primerRvHeight + elementGap + 5; let selectEdgeHeight = selectHeight + 9; // +9 is the height of a tick + index row // needed because otherwise the selection height is very small @@ -323,6 +336,22 @@ export class SeqBlock extends React.PureComponent<SeqBlockProps> { zoom={zoom} /> )} + {primerFwRows.length && ( + <PrimeRows + primerRows={primerFwRows} + direction={Direction.FOWARD} + bpsPerBlock={bpsPerBlock} + elementHeight={elementHeight} + findXAndWidth={this.findXAndWidthElement} + firstBase={firstBase} + fullSeq={fullSeq} + inputRef={inputRef} + lastBase={lastBase} + seqBlockRef={this} + width={size.width} + yDiff={primerFwYDiff} + /> + )} <Selection.Block findXAndWidth={this.findXAndWidth} firstBase={firstBase} @@ -362,6 +391,22 @@ export class SeqBlock extends React.PureComponent<SeqBlockProps> { listenerOnly={false} zoomed={zoomed} /> + {primerRvRows.length && ( + <PrimeRows + primerRows={primerRvRows} + direction={Direction.REVERSE} + bpsPerBlock={bpsPerBlock} + elementHeight={elementHeight} + findXAndWidth={this.findXAndWidthElement} + firstBase={firstBase} + fullSeq={fullSeq} + inputRef={inputRef} + lastBase={lastBase} + seqBlockRef={this} + width={size.width} + yDiff={primerRvYDiff} + /> + )} {translationRows.length && ( <TranslationRows bpsPerBlock={bpsPerBlock} @@ -393,6 +438,7 @@ export class SeqBlock extends React.PureComponent<SeqBlockProps> { yDiff={annYDiff} /> )} + {zoomed && seqType !== "aa" ? ( <text {...textProps}