From eaaef94ab191370076d037e65c7c6fde824e51c6 Mon Sep 17 00:00:00 2001 From: Isaac Guerreiro Date: Wed, 22 Nov 2023 14:45:44 -0300 Subject: [PATCH] Introduce a Primer element (#242) --- README.md | 13 ++ demo/lib/App.tsx | 48 ++++++- src/Linear/Linear.tsx | 32 ++++- src/Linear/Primers.tsx | 266 +++++++++++++++++++++++++++++++++++ src/Linear/SeqBlock.test.tsx | 8 +- src/Linear/SeqBlock.tsx | 69 ++++++++- src/SelectionHandler.tsx | 1 + src/SeqViewerContainer.tsx | 3 +- src/SeqViz.tsx | 8 +- src/elements.ts | 12 +- src/selectionContext.ts | 11 +- 11 files changed, 453 insertions(+), 18 deletions(-) create mode 100644 src/Linear/Primers.tsx diff --git a/README.md b/README.md index f4ce17126..97dda0112 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,19 @@ annotations = [ In the example above, the "Strong promoter" would span the first to twenty-second base pair. +#### `primers (=[])` + +An array of `Primer`s to render. Each `Primer` requires 0-based start (inclusive) and end (exclusive) indexes. `name`s are rendered on top of the primers. Set the primer's direction to `1` for forward primer and `-1` for reverse primer. + +```js +primers = [ + { start: 33, end: 53, name: "LacZ Foward Primer", direction: 1 }, + { start: 3098, end: 3128, name: "LacZ Reverse Primer", direction: -1, color: "#FAA887" }, +]; +``` + +In the example above, the forward and reverse primers of LacZ are define by the direction parameter. Notice that color could be used optionally. + #### `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). diff --git a/demo/lib/App.tsx b/demo/lib/App.tsx index b10b7dcfd..c787f118b 100644 --- a/demo/lib/App.tsx +++ b/demo/lib/App.tsx @@ -17,7 +17,8 @@ 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 { chooseRandomColor } from "../../src/colors"; +import { AnnotationProp, Primer } from "../../src/elements"; import Header from "./Header"; import file from "./file"; @@ -33,6 +34,7 @@ interface AppState { customChildren: boolean; enzymes: any[]; name: string; + primers: Primer[]; search: { query: string }; searchResults: any; selection: any; @@ -52,6 +54,48 @@ export default class App extends React.Component { customChildren: true, enzymes: ["PstI", "EcoRI", "XbaI", "SpeI"], name: "", + primers: [ + { + color: chooseRandomColor(), + direction: 1, + end: 653, + id: "527923581", + name: "pLtetO-1 fw primer", + start: 633, + }, + { + color: chooseRandomColor(), + direction: -1, + end: 706, + id: "5279asdf582", + name: "pLtetO-1 rev primer", + start: 686, + }, + { + color: chooseRandomColor(), + direction: 1, + end: 535, + id: "5279fd582", + name: "pLtetO-1 fwd primer", + start: 512, + }, + { + color: chooseRandomColor(), + direction: -1, + end: 535, + id: "527923dfd582", + name: "pLtetO-1 rev primer", + start: 512, + }, + { + color: chooseRandomColor(), + direction: -1, + end: 535, + id: "52792saf3582", + name: "pLtetO-1 rev primer", + start: 512, + }, + ], search: { query: "ttnnnaat" }, searchResults: {}, selection: {}, @@ -73,6 +117,7 @@ export default class App extends React.Component { componentDidMount = async () => { const seq = await seqparse(file); + this.setState({ annotations: seq.annotations, name: seq.name, seq: seq.seq }); }; @@ -215,6 +260,7 @@ export default class App extends React.Component { // 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..41fd28032 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, 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"; @@ -21,6 +21,7 @@ export interface LinearProps { inputRef: InputRefFunc; lineHeight: number; onUnmount: (id: string) => void; + primers: Primer[]; search: NameRange[]; seq: string; seqFontSize: number; @@ -35,13 +36,14 @@ export interface LinearProps { /** * A linear sequence viewer. * - * Comprised of SeqBlock(s), which are themselves comprised of: + * Comprised of SeqBlock(s) which are comprised of: * text (seq) * Index (axis) * Annotations * Finds * Translations * Selections + * Primers */ export default class Linear extends React.Component { /** @@ -68,6 +70,7 @@ export default class Linear extends React.Component { highlights, lineHeight, onUnmount, + primers, search, seq, seqType, @@ -97,15 +100,26 @@ export default class Linear extends React.Component { : new Array(arrSize).fill([]); /** - * Vet the annotations for starts and ends at zero index + * Mutate elements that start or end at zero index */ - const vetAnnotations = (annotations: Annotation[]) => { + function vetAnnotations(annotations: T[]): T[] { 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; }); return annotations; - }; + } + + const primerFwdRows = createMultiRows( + stackElements(vetAnnotations(primers.filter(p => p.direction === 1)), seq.length), + bpsPerBlock, + arrSize + ); + const primerRevRows = createMultiRows( + stackElements(vetAnnotations(primers.filter(p => p.direction === -1)), seq.length), + bpsPerBlock, + arrSize + ); const annotationRows = createMultiRows( stackElements(vetAnnotations(annotations), seq.length), @@ -141,6 +155,12 @@ export default class Linear extends React.Component { if (zoomed) { blockHeight += showComplement ? lineHeight : 0; // double for complement + 2px margin } + if (primerFwdRows[i].length) { + blockHeight += primerFwdRows[i].length * lineHeight; + } + if (primerRevRows[i].length) { + blockHeight += primerRevRows[i].length * lineHeight; + } if (showIndex) { blockHeight += lineHeight; // another for index row } @@ -179,6 +199,8 @@ export default class Linear extends React.Component { id={ids[i]} inputRef={this.props.inputRef} lineHeight={lineHeight} + primerFwdRows={primerFwdRows[i]} + primerRevRows={primerRevRows[i]} searchRows={searchRows[i]} seq={seqs[i]} seqFontSize={this.props.seqFontSize} diff --git a/src/Linear/Primers.tsx b/src/Linear/Primers.tsx new file mode 100644 index 000000000..7ca914c44 --- /dev/null +++ b/src/Linear/Primers.tsx @@ -0,0 +1,266 @@ +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 hoverOtherPrimerRows = (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}`; + } +}; + +/** + * 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: { + bpsPerBlock: number; + direction: 1 | -1; + elementHeight: number; + findXAndWidth: FindXAndWidthElementType; + firstBase: number; + fullSeq: string; + inputRef: InputRefFunc; + lastBase: number; + primerRows: NameRange[][]; + seqBlockRef: unknown; + width: number; + yDiff: number; +}) => ( + + {props.primerRows.map((primers: NameRange[], i: number) => ( + + ))} + +); + +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: { + bpsPerBlock: number; + direction: 1 | -1; + findXAndWidth: FindXAndWidthElementType; + firstBase: number; + fullSeq: string; + height: number; + inputRef: InputRefFunc; + lastBase: number; + primers: NameRange[]; + seqBlockRef: unknown; + width: number; + y: number; +}) => { + return ( + + {props.primers + .filter(a => a.direction == props.direction) + .map((a, i) => ( + + ))} + + ); +}; + +/** + * 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 === 1; + const reverse = direction === -1; + 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.7; + + const cW = 4; // jagged cutoff width + const cH = height / 4; // jagged cutoff height + const aH = 3; // arrow height at edges of primers + 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"; + const topRight = endFWD + ? ` + L ${width - Math.min(8 * cW, w)} 0 + L ${width - Math.min(8 * cW, w)} ${-aH} + ` + : `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 + aH}`; // 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 ( + + {/* provides a hover tooltip on most browsers */} + <title>{name} + { + // do nothing + }} + onFocus={() => { + // do nothing + }} + onMouseOut={() => hoverOtherPrimerRows(element.id, 0.7)} + onMouseOver={() => hoverOtherPrimerRows(element.id, 1.0)} + /> + { + // do nothing + }} + onFocus={() => { + // do nothing + }} + onMouseOut={() => hoverOtherPrimerRows(element.id, 0.7)} + onMouseOver={() => hoverOtherPrimerRows(element.id, 1.0)} + > + {displayName} + + + ); +}; diff --git a/src/Linear/SeqBlock.test.tsx b/src/Linear/SeqBlock.test.tsx index 14aa1a928..a39a70613 100644 --- a/src/Linear/SeqBlock.test.tsx +++ b/src/Linear/SeqBlock.test.tsx @@ -3,6 +3,7 @@ import { cleanup, render, screen } from "@testing-library/react"; import * as React from "react"; +import { SeqType } from "../elements"; import { SeqBlock } from "./SeqBlock"; const defaultProps = { @@ -13,6 +14,7 @@ const defaultProps = { elementHeight: 16, firstBase: 0, forwardPrimerRows: [], + handleMouseEvent: () => {}, highlightedRegions: [], highlights: [], id: "", @@ -28,10 +30,13 @@ const defaultProps = { onUnmount: () => { // do nothing }, - reversePrimerRows: [], + primerFwdRows: [], + primerRevRows: [], + primers: [], searchRows: [], selection: {}, seqFontSize: 12, + seqType: "dna" as SeqType, showComplement: true, showIndex: true, size: { height: 600, width: 1200 }, @@ -52,7 +57,6 @@ describe("SeqBlock", () => { it("renders with a single block", async () => { const seq = "gcgaaaaatcaataaggaggcaacaagatgtgcgaaaaacatcttaatcatgcggtggagggtttctaatg"; render( - // @ts-ignore void; + primerFwdRows: Primer[][]; + primerRevRows: Primer[][]; searchRows: Range[]; seq: string; seqFontSize: number; @@ -229,6 +232,8 @@ export class SeqBlock extends React.PureComponent { inputRef, lineHeight, onUnmount, + primerFwdRows: primerFwdRows, + primerRevRows: primerRevRows, searchRows, seq, seqFontSize, @@ -253,8 +258,12 @@ export class SeqBlock extends React.PureComponent { const lastBase = firstBase + seq.length; + // height and yDiff of forward primers + const primerFwdYDiff = 0; + const primerFwdHeight = primerFwdRows.length ? elementHeight * primerFwdRows.length : 0; + // height and yDiff of cut sites - const cutSiteYDiff = 0; // spacing for cutSite names + const cutSiteYDiff = primerFwdYDiff + primerFwdHeight; // spacing for cutSite names const cutSiteHeight = zoomed && cutSiteRows.length ? lineHeight : 0; // height and yDiff of the sequence strand @@ -265,21 +274,36 @@ export class SeqBlock extends React.PureComponent { const compYDiff = indexYDiff + indexHeight; const compHeight = zoomed && showComplement ? lineHeight : 0; + // height and yDiff of reverse primers + const primerRevYDiff = compYDiff + compHeight; + const primerRevHeight = primerRevRows.length ? elementHeight * primerRevRows.length : 0; + // height and yDiff of translations - const translationYDiff = compYDiff + compHeight; + const translationYDiff = primerRevYDiff + primerRevHeight; const translationHeight = elementHeight * translationRows.length; // height and yDiff of annotations const annYDiff = translationYDiff + translationHeight; const annHeight = elementHeight * annotationRows.length; - // height and ydiff of the index row. - const elementGap = annotationRows.length + translationRows.length ? 3 : 0; + // height and ydiff of the index row + const elementGap = + primerRevRows.length + primerRevRows.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 = + primerFwdHeight + + cutSiteHeight + + indexHeight + + compHeight + + translationHeight + + annHeight + + primerRevHeight + + elementGap + + 5; let selectEdgeHeight = selectHeight + 9; // +9 is the height of a tick + index row // needed because otherwise the selection height is very small @@ -331,6 +355,22 @@ export class SeqBlock extends React.PureComponent { selectHeight={selectHeight} onUnmount={onUnmount} /> + {primerFwdRows.length && ( + + )} { listenerOnly={false} zoomed={zoomed} /> + {primerRevRows.length && ( + + )} {translationRows.length && ( { yDiff={annYDiff} /> )} + {zoomed && seqType !== "aa" ? ( void; + primers: Primer[]; refs?: SeqVizChildRefs; rotateOnScroll: boolean; search: NameRange[]; diff --git a/src/SeqViz.tsx b/src/SeqViz.tsx index 2b1af072f..c182b1c8b 100644 --- a/src/SeqViz.tsx +++ b/src/SeqViz.tsx @@ -12,6 +12,7 @@ import { Highlight, HighlightProp, NameRange, + PrimerProp, Range, SeqType, } from "./elements"; @@ -98,6 +99,9 @@ export interface SeqVizProps { /** a callback that's executed on each click of the sequence viewer. Selection includes meta about the selected element */ onSelection?: (selection: Selection) => void; + /** a list of primers to render above or below the sequences. At the time of writing, only the Linear viewer is supported. */ + primers: PrimerProp[]; + /** Refs associated with custom children. */ refs?: SeqVizChildRefs; @@ -193,6 +197,7 @@ export default class SeqViz extends React.Component { name: "", onSearch: (_: Range[]) => null, onSelection: (_: Selection) => null, + primers: [], rotateOnScroll: true, search: { mismatch: 0, query: "" }, selectAllEvent: e => e.key === "a" && (e.metaKey || e.ctrlKey), @@ -412,7 +417,7 @@ export default class SeqViz extends React.Component { })); render() { - const { highlightedRegions, highlights, showComplement, showIndex, style, zoom } = this.props; + const { highlightedRegions, highlights, primers, showComplement, showIndex, style, zoom } = this.props; let { translations } = this.props; const { compSeq, seq, seqType } = this.state; @@ -446,6 +451,7 @@ export default class SeqViz extends React.Component { (() => { // do nothing }), + primers: primers.map((p, i) => ({ color: colorByIndex(i), id: `primer${p.name}${i}${p.start}${p.end}`, ...p })), rotateOnScroll: !!this.props.rotateOnScroll, showComplement: (!!compSeq && (typeof showComplement !== "undefined" ? showComplement : true)) || false, showIndex: !!showIndex, diff --git a/src/elements.ts b/src/elements.ts index 9a0b9ada9..c81b327e9 100644 --- a/src/elements.ts +++ b/src/elements.ts @@ -32,7 +32,17 @@ export interface Translation extends NameRange { direction: -1 | 1; } -/** Primer is a single primer for PCR. Not visualized right now. */ +/** PrimerProp is a single primer to visualize above/below the linear viewer. */ +export interface PrimerProp { + color?: string; + direction: 1 | -1; + end: number; + id?: string; + name: string; + start: number; +} + +/** Primer is a single primer for PCR. */ export interface Primer extends NameRange { color: string; direction: 1 | -1; diff --git a/src/selectionContext.ts b/src/selectionContext.ts index 7551f7768..0c7e10dd3 100644 --- a/src/selectionContext.ts +++ b/src/selectionContext.ts @@ -1,6 +1,15 @@ import * as React from "react"; -type SelectionTypeEnum = "ANNOTATION" | "FIND" | "TRANSLATION" | "ENZYME" | "SEQ" | "AMINOACID" | "HIGHLIGHT" | ""; +type SelectionTypeEnum = + | "ANNOTATION" + | "FIND" + | "TRANSLATION" + | "ENZYME" + | "SEQ" + | "AMINOACID" + | "HIGHLIGHT" + | "PRIMER" + | ""; /* Selection holds meta about the viewer(s) active selection. */ export interface Selection {