diff --git a/README.md b/README.md index 5f303a8fb..8235ebbbf 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ export default () => { #### `viewer (='both')` -The type and orientation of the sequence viewers. One of `"linear" | "circular" | "both" | "both_flip"`. `both` means the circular viewer fills the left side of SeqViz, and the linear viewer fills the right. `both_flip` is the opposite: the linear viewer is on the left, and the circular viewer is on the right. +The type and orientation of the sequence viewers. One of `"linear" | "circular" | "both" | "both_flip" | "linear_one_row"`. `both` means the circular viewer fills the left side of SeqViz, and the linear viewer fills the right. `both_flip` is the opposite: the linear viewer is on the left, and the circular viewer is on the right. `linear_one_row` will render the entire linear sequence in a single row. #### `name (='')` diff --git a/demo/lib/App.tsx b/demo/lib/App.tsx index b10b7dcfd..bd7860346 100644 --- a/demo/lib/App.tsx +++ b/demo/lib/App.tsx @@ -26,6 +26,7 @@ const viewerTypeOptions = [ { key: "circular", text: "Circular", value: "circular" }, { key: "linear", text: "Linear", value: "linear" }, { key: "both_flip", text: "Both Flip", value: "both_flip" }, + { key: "linear_one_row", text: "Linear One Row", value: "linear_one_row" }, ]; interface AppState { @@ -216,9 +217,8 @@ export default class App extends React.Component { key={`${this.state.viewer}${this.state.customChildren}`} annotations={this.state.annotations} enzymes={this.state.enzymes} - highlights={[{ start: 0, end: 10 }]} + highlights={[{ end: 10, start: 0 }]} name={this.state.name} - onSelection={selection => this.setState({ selection })} refs={{ circular: this.circularRef, linear: this.linearRef }} search={this.state.search} selection={this.state.selection} @@ -226,8 +226,9 @@ export default class App extends React.Component { showComplement={this.state.showComplement} showIndex={this.state.showIndex} translations={this.state.translations} - viewer={this.state.viewer as "linear" | "circular"} + viewer={this.state.viewer as "linear" | "linear_one_row" | "circular"} zoom={{ linear: this.state.zoom }} + onSelection={selection => this.setState({ selection })} > {customChildren} diff --git a/src/Circular/Circular.tsx b/src/Circular/Circular.tsx index c3908c2fb..7d432aac6 100644 --- a/src/Circular/Circular.tsx +++ b/src/Circular/Circular.tsx @@ -43,7 +43,7 @@ export interface CircularProps { center: { x: number; y: number }; compSeq: string; cutSites: CutSite[]; - handleMouseEvent: (e: any) => void; + handleMouseEvent: (e: React.MouseEvent) => void; highlights: Highlight[]; inputRef: InputRefFunc; name: string; diff --git a/src/Circular/Labels.tsx b/src/Circular/Labels.tsx index 85d981aca..1416c27cf 100644 --- a/src/Circular/Labels.tsx +++ b/src/Circular/Labels.tsx @@ -19,7 +19,7 @@ export interface GroupedLabelsWithCoors { labels: ILabel[]; lineCoor: Coor; name: string; - overflow: unknown; + overflow: boolean; textAnchor: "start" | "end"; textCoor: Coor; } diff --git a/src/EventHandler.tsx b/src/EventHandler.tsx index d9fc69986..9349719ff 100644 --- a/src/EventHandler.tsx +++ b/src/EventHandler.tsx @@ -8,7 +8,7 @@ export interface EventsHandlerProps { bpsPerBlock: number; children: React.ReactNode; copyEvent: (e: React.KeyboardEvent) => boolean; - handleMouseEvent: (e: any) => void; + handleMouseEvent: (e: React.MouseEvent) => void; selection: Selection; seq: string; setSelection: (selection: Selection) => void; diff --git a/src/Linear/Annotations.tsx b/src/Linear/Annotations.tsx index 581e7e277..180005782 100644 --- a/src/Linear/Annotations.tsx +++ b/src/Linear/Annotations.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { InputRefFunc } from "../SelectionHandler"; import { COLOR_BORDER_MAP, darkerColor } from "../colors"; -import { NameRange } from "../elements"; +import { Annotation, NameRange } from "../elements"; import { annotation, annotationLabel } from "../style"; import { FindXAndWidthElementType } from "./SeqBlock"; @@ -27,7 +27,9 @@ const AnnotationRows = (props: { fullSeq: string; inputRef: InputRefFunc; lastBase: number; + oneRow: boolean; seqBlockRef: unknown; + stackedPositions: Annotation[][]; width: number; yDiff: number; }) => ( @@ -43,9 +45,20 @@ const AnnotationRows = (props: { height={props.elementHeight} inputRef={props.inputRef} lastBase={props.lastBase} + oneRow={props.oneRow} seqBlockRef={props.seqBlockRef} width={props.width} - y={props.yDiff + props.elementHeight * i} + y={ + props.yDiff + + props.elementHeight * + (props.oneRow + ? Math.max( + ...anns.map( + ann => props.stackedPositions.findIndex(row => row.some(item => item.id === ann.id)) as number + ) + ) + : i) + } /> ))} @@ -66,6 +79,7 @@ const AnnotationRow = (props: { height: number; inputRef: InputRefFunc; lastBase: number; + oneRow: boolean; seqBlockRef: unknown; width: number; y: number; @@ -102,8 +116,9 @@ const SingleNamedElement = (props: { index: number; inputRef: InputRefFunc; lastBase: number; + oneRow: boolean; }) => { - const { element, elements, findXAndWidth, firstBase, index, inputRef, lastBase } = props; + const { element, elements, findXAndWidth, firstBase, index, inputRef, lastBase, oneRow } = props; const { color, direction, end, name, start } = element; const forward = direction === 1; @@ -130,7 +145,7 @@ const SingleNamedElement = (props: { let linePath = ""; let bottomRight = `L ${width} ${height}`; // flat right edge - if ((overflowRight && width > 2 * cW) || crossZero) { + if ((overflowRight && width > 2 * cW && !oneRow) || crossZero) { bottomRight = ` L ${width - cW} ${cH} L ${width} ${2 * cH} @@ -143,7 +158,7 @@ const SingleNamedElement = (props: { } let bottomLeft = `L 0 ${height} L 0 0`; // flat left edge - if (overflowLeft && width > 2 * cW) { + if (overflowLeft && width > 2 * cW && !oneRow) { bottomLeft = ` L 0 ${height} L ${cW} ${3 * cH} diff --git a/src/Linear/InfiniteHorizontalScroll.tsx b/src/Linear/InfiniteHorizontalScroll.tsx new file mode 100644 index 000000000..909e0ca05 --- /dev/null +++ b/src/Linear/InfiniteHorizontalScroll.tsx @@ -0,0 +1,318 @@ +import * as React from "react"; + +import CentralIndexContext from "../centralIndexContext"; +import { Size } from "../elements"; +import { isEqual } from "../isEqual"; +import { linearOneRowScroller } from "../style"; + +interface InfiniteHorizontalScrollProps { + blockWidths: number[]; + bpsPerBlock: number; + seqBlocks: JSX.Element[]; + size: Size; + totalWidth: number; +} + +interface InfiniteHorizontalScrollState { + centralIndex: number; + visibleBlocks: number[]; +} + +/** + * InfiniteHorizontalScroll is a wrapper around the seqBlocks. Renders only the seqBlocks that are + * within the range of the current dom viewerport + * + * This component should sense scroll events and, during one, recheck which sequences are shown. + */ +export class InfiniteHorizontalScroll extends React.PureComponent< + InfiniteHorizontalScrollProps, + InfiniteHorizontalScrollState +> { + static contextType = CentralIndexContext; + static context: React.ContextType; + declare context: React.ContextType; + + scroller: React.RefObject = React.createRef(); // ref to a div for scrolling + insideDOM: React.RefObject = React.createRef(); // ref to a div inside the scroller div + timeoutID; + + constructor(props: InfiniteHorizontalScrollProps) { + super(props); + + this.state = { + centralIndex: 0, + // start off with first 1 blocks shown + visibleBlocks: new Array(Math.min(1, props.seqBlocks.length)).fill(null).map((_, i) => i), + }; + } + + componentDidMount = () => { + this.handleScrollOrResize(); // ref should now be set + window.addEventListener("resize", this.handleScrollOrResize); + }; + + componentDidUpdate = ( + prevProps: InfiniteHorizontalScrollProps, + prevState: InfiniteHorizontalScrollState, + snapshot: { blockIndex: number; blockX: number } + ) => { + if (!this.scroller.current) { + // scroller not mounted yet + return; + } + + const { seqBlocks, size } = this.props; + const { centralIndex, visibleBlocks } = this.state; + + if (this.context && centralIndex !== this.context.linear) { + this.scrollToCentralIndex(); + } else if (!isEqual(prevProps.size, size) || seqBlocks.length !== prevProps.seqBlocks.length) { + this.handleScrollOrResize(); // reset + } else if (isEqual(prevState.visibleBlocks, visibleBlocks)) { + this.restoreSnapshot(snapshot); // something, like ORFs or index view, has changed + } + }; + + componentWillUnmount = () => { + window.removeEventListener("resize", this.handleScrollOrResize); + }; + + /** + * more info at: https://reactjs.org/docs/react-component.html#getsnapshotbeforeupdate + */ + getSnapshotBeforeUpdate = (prevProps: InfiniteHorizontalScrollProps) => { + // find the current left block + const left = this.scroller.current ? this.scroller.current.scrollLeft : 0; + + // find out 1) which block this is at the edge of the left + // and 2) how far from the left of that block we are right now + const { blockWidths } = prevProps; + let blockIndex = 0; + let accumulatedX = 0; + do { + accumulatedX += blockWidths[blockIndex]; + blockIndex += 1; + } while (accumulatedX + blockWidths[blockIndex] < left && blockIndex < blockWidths.length); + + const blockX = left - accumulatedX; // last extra distance + return { blockIndex, blockX }; + }; + + /** + * Scroll to centralIndex. Likely from circular clicking on an element + * that should then be scrolled to in linear + */ + scrollToCentralIndex = () => { + if (!this.scroller.current) { + return; + } + + const { + blockWidths, + bpsPerBlock, + seqBlocks, + size: { width }, + totalWidth, + } = this.props; + const { visibleBlocks } = this.state; + const { clientWidth, scrollWidth } = this.scroller.current; + const centralIndex = this.context.linear; + + // find the first block that contains the new central index + const centerBlockIndex = seqBlocks.findIndex( + block => block.props.firstBase <= centralIndex && block.props.firstBase + bpsPerBlock >= centralIndex + ); + + // build up the list of blocks that are visible just after this first block + let newVisibleBlocks: number[] = []; + if (scrollWidth <= clientWidth) { + newVisibleBlocks = visibleBlocks; + } else if (centerBlockIndex > -1) { + const centerBlock = seqBlocks[centerBlockIndex]; + + // create some padding to the left of the new center block + const leftAdjust = centerBlockIndex > 0 ? blockWidths[centerBlockIndex - 1] : 0; + let left = centerBlock.props.x - leftAdjust; + let right = left + width; + if (right > totalWidth) { + right = totalWidth; + left = totalWidth - width; + } + + blockWidths.reduce((total, w, i) => { + if (total >= left && total <= right) { + newVisibleBlocks.push(i); + } + return total + w; + }, 0); + + this.scroller.current.scrollLeft = centerBlock.props.x; + } + + if (newVisibleBlocks.length && !isEqual(newVisibleBlocks, visibleBlocks)) { + this.setState({ + centralIndex: centralIndex, + visibleBlocks: newVisibleBlocks, + }); + } + }; + + /** + * the component has mounted to the DOM or updated, and the window should be scrolled + * so that the central index is visible + */ + restoreSnapshot = snapshot => { + if (!this.scroller.current) { + return; + } + + const { blockWidths } = this.props; + const { blockIndex, blockX } = snapshot; + + const scrollLeft = blockWidths.slice(0, blockIndex).reduce((acc, w) => acc + w, 0) + blockX; + + this.scroller.current.scrollLeft = scrollLeft; + }; + + /** + * check whether the blocks that should be visible have changed from what's in state, + * update if so + */ + handleScrollOrResize = () => { + if (!this.scroller.current || !this.insideDOM.current) { + return; + } + + const { + blockWidths, + size: { width }, + totalWidth, + } = this.props; + const { visibleBlocks } = this.state; + + const newVisibleBlocks: number[] = []; + + let left = 0; + if (this.scroller && this.insideDOM) { + const { left: parentLeft } = this.scroller.current.getBoundingClientRect(); + const { left: childLeft } = this.insideDOM.current.getBoundingClientRect(); + left = childLeft - parentLeft; + } + + left = -left + 35; + left = Math.max(0, left); // don't go too left + left = Math.min(totalWidth - width, left); // don't go too right + const right = left + blockWidths[0]; // width; + left -= blockWidths[0]; // add one block padding on left + blockWidths.reduce((total, w, i) => { + if (total >= left && total <= right) { + newVisibleBlocks.push(i); + } + return total + w; + }, 0); + + if (!isEqual(newVisibleBlocks, visibleBlocks)) { + this.setState({ visibleBlocks: newVisibleBlocks }); + } + }; + + incrementScroller = incAmount => { + this.stopIncrementingScroller(); + this.timeoutID = setTimeout(() => { + if (!this.scroller.current) { + return; + } + + this.scroller.current.scrollLeft += incAmount; + this.incrementScroller(incAmount); + }, 5); + }; + + stopIncrementingScroller = () => { + if (this.timeoutID) { + clearTimeout(this.timeoutID); + this.timeoutID = null; + } + }; + + /** + * handleMouseOver is for detecting when the user is performing a drag event + * at the very left or the very right of DIV. If they are, this starts + * a incrementing the div's scrollLeft (ie a horizontal scroll event) that's + * terminated by the user leaving the scroll area + * + * The rate of the scrollLeft is proportional to how far from the left or the + * bottom the user is (within [-40, 0] for left, and [0, 40] for right) + */ + handleMouseOver = (e: React.MouseEvent) => { + if (!this.scroller.current) { + return; + } + + // not relevant, some other type of event, not a selection drag + if (e.buttons !== 1) { + if (this.timeoutID) { + this.stopIncrementingScroller(); + } + return; + } + + // check whether the current drag position is near the right + // of the viewer and, if it is, try and increment the current + // centralIndex (triggering a right scroll event) + const scrollerBlock = this.scroller.current.getBoundingClientRect(); + let scrollRatio = (e.clientX - scrollerBlock.left) / scrollerBlock.width; + if (scrollRatio > 0.9) { + scrollRatio = Math.min(1, scrollRatio); + let scalingRatio = scrollRatio - 0.9; + scalingRatio *= 10; + const scaledScroll = 15 * scalingRatio; + + this.incrementScroller(scaledScroll); + } else if (scrollRatio < 0.1) { + scrollRatio = 0.1 - Math.max(0, scrollRatio); + const scalingRatio = 10 * scrollRatio; + const scaledScroll = -15 * scalingRatio; + + this.incrementScroller(scaledScroll); + } else { + this.stopIncrementingScroller(); + } + }; + + render() { + const { + blockWidths, + seqBlocks, + size: { height }, + totalWidth: width, + } = this.props; + const { visibleBlocks } = this.state; + + // find the width of the empty div needed to correctly position the rest + const [firstRendered] = visibleBlocks; + const spaceLeft = blockWidths.slice(0, firstRendered).reduce((acc, w) => acc + w, 0); + return ( +
{ + // do nothing + }} + onMouseOver={this.handleMouseOver} + onScroll={this.handleScrollOrResize} + > +
+
+ {visibleBlocks.map(i => seqBlocks[i])} +
+
+ ); + } +} diff --git a/src/Linear/Linear.tsx b/src/Linear/Linear.tsx index 3ad8e9e3a..106384bbb 100644 --- a/src/Linear/Linear.tsx +++ b/src/Linear/Linear.tsx @@ -1,10 +1,11 @@ import * as React from "react"; import { InputRefFunc } from "../SelectionHandler"; -import { Annotation, CutSite, Highlight, NameRange, Range, SeqType, Size } from "../elements"; +import { Annotation, CutSite, Highlight, NameRange, SeqType, Size } from "../elements"; import { createMultiRows, createSingleRows, stackElements } from "../elementsToRows"; import { isEqual } from "../isEqual"; import { createTranslations } from "../sequence"; +import { InfiniteHorizontalScroll } from "./InfiniteHorizontalScroll"; import { InfiniteScroll } from "./InfiniteScroll"; import { SeqBlock } from "./SeqBlock"; @@ -21,6 +22,7 @@ export interface LinearProps { inputRef: InputRefFunc; lineHeight: number; onUnmount: (id: string) => void; + oneRow: boolean; search: NameRange[]; seq: string; seqFontSize: number; @@ -28,7 +30,7 @@ export interface LinearProps { showComplement: boolean; showIndex: boolean; size: Size; - translations: Range[]; + translations: NameRange[]; zoom: { linear: number }; } @@ -62,12 +64,14 @@ export default class Linear extends React.Component { const { annotations, bpsPerBlock, + charWidth, compSeq, cutSites, elementHeight, highlights, lineHeight, onUnmount, + oneRow, search, seq, seqType, @@ -82,7 +86,7 @@ export default class Linear extends React.Component { const zoomed = zoom.linear > 10; // the actual fragmenting of the sequence into subblocks. generates all info that will be needed - // including sequence blocks, complement blocks, annotations, blockHeights + // including sequence blocks, complement blocks, annotations, blockHeights, blockWidths const seqLength = seq.length; let arrSize = Math.round(Math.ceil(seqLength / bpsPerBlock)); if (arrSize === Number.POSITIVE_INFINITY) arrSize = 1; @@ -91,6 +95,7 @@ export default class Linear extends React.Component { const seqs = new Array(arrSize); // arrays for sequences... const compSeqs = new Array(arrSize); // complements... const blockHeights = new Array(arrSize); // block heights... + const blockWidths = new Array(arrSize); // block widths... const cutSiteRows = cutSites.length ? createSingleRows(cutSites, bpsPerBlock, arrSize) @@ -107,6 +112,9 @@ export default class Linear extends React.Component { return annotations; }; + const stackedAnnotations = stackElements(vetAnnotations(annotations), seq.length); + const stackedTranslations = stackElements(createTranslations(translations, seq, seqType), seq.length); + const annotationRows = createMultiRows( stackElements(vetAnnotations(annotations), seq.length), bpsPerBlock, @@ -119,7 +127,7 @@ export default class Linear extends React.Component { const highlightRows = createSingleRows(highlights, bpsPerBlock, arrSize); const translationRows = translations.length - ? createMultiRows(stackElements(createTranslations(translations, seq, seqType), seq.length), bpsPerBlock, arrSize) + ? createMultiRows(stackedTranslations, bpsPerBlock, arrSize) : new Array(arrSize).fill([]); for (let i = 0; i < arrSize; i += 1) { @@ -130,6 +138,7 @@ export default class Linear extends React.Component { seqs[i] = seq.substring(firstBase, lastBase); compSeqs[i] = compSeq.substring(firstBase, lastBase); + const blockWidth = seqs[i].length * charWidth; // store a unique id from the block ids[i] = seqs[i] + String(i); @@ -155,10 +164,12 @@ export default class Linear extends React.Component { } blockHeights[i] = blockHeight; + blockWidths[i] = blockWidth; } const seqBlocks: JSX.Element[] = []; let yDiff = 0; + let xDiff = 0; for (let i = 0; i < arrSize; i += 1) { const firstBase = i * bpsPerBlock; seqBlocks.push( @@ -166,9 +177,10 @@ export default class Linear extends React.Component { key={ids[i]} annotationRows={annotationRows[i]} blockHeight={blockHeights[i]} + blockWidth={blockWidths[i]} bpColors={this.props.bpColors} bpsPerBlock={bpsPerBlock} - charWidth={this.props.charWidth} + charWidth={charWidth} compSeq={compSeqs[i]} cutSiteRows={cutSiteRows[i]} elementHeight={elementHeight} @@ -179,6 +191,7 @@ export default class Linear extends React.Component { id={ids[i]} inputRef={this.props.inputRef} lineHeight={lineHeight} + oneRow={oneRow} searchRows={searchRows[i]} seq={seqs[i]} seqFontSize={this.props.seqFontSize} @@ -186,18 +199,31 @@ export default class Linear extends React.Component { showComplement={showComplement} showIndex={showIndex} size={size} + stackedAnnotations={stackedAnnotations} + stackedTranslations={stackedTranslations} translationRows={translationRows[i]} + x={xDiff} y={yDiff} zoom={zoom} zoomed={zoomed} onUnmount={onUnmount} /> ); + xDiff += blockWidths[i]; yDiff += blockHeights[i]; } return ( - seqBlocks.length && ( + seqBlocks.length > 0 && + (oneRow ? ( + acc + w, 0)} + /> + ) : ( { size={size} totalHeight={blockHeights.reduce((acc, h) => acc + h, 0)} /> - ) + )) ); } } diff --git a/src/Linear/SeqBlock.test.tsx b/src/Linear/SeqBlock.test.tsx index 14aa1a928..e6933bbf1 100644 --- a/src/Linear/SeqBlock.test.tsx +++ b/src/Linear/SeqBlock.test.tsx @@ -35,6 +35,8 @@ const defaultProps = { showComplement: true, showIndex: true, size: { height: 600, width: 1200 }, + stackedAnnotations: [], + stackedTranslations: [], translationRows: [], y: 0, zoom: { linear: 50 }, diff --git a/src/Linear/SeqBlock.tsx b/src/Linear/SeqBlock.tsx index f1bbec3af..2131fd8ae 100644 --- a/src/Linear/SeqBlock.tsx +++ b/src/Linear/SeqBlock.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { InputRefFunc } from "../SelectionHandler"; import { Annotation, CutSite, Highlight, NameRange, Range, SeqType, Size, Translation } from "../elements"; -import { seqBlock, svgText } from "../style"; +import { linearOneRowSeqBlock, seqBlock, svgText } from "../style"; import AnnotationRows from "./Annotations"; import { CutSites } from "./CutSites"; import Find from "./Find"; @@ -28,6 +28,7 @@ export type FindXAndWidthElementType = ( interface SeqBlockProps { annotationRows: Annotation[][]; blockHeight: number; + blockWidth: number; bpColors?: { [key: number | string]: string }; bpsPerBlock: number; charWidth: number; @@ -43,6 +44,7 @@ interface SeqBlockProps { key: string; lineHeight: number; onUnmount: (a: string) => void; + oneRow: boolean; searchRows: Range[]; seq: string; seqFontSize: number; @@ -50,7 +52,10 @@ interface SeqBlockProps { showComplement: boolean; showIndex: boolean; size: Size; + stackedAnnotations: Annotation[][]; + stackedTranslations: NameRange[][]; translationRows: Translation[][]; + x: number; y: number; zoom: { linear: number }; zoomed: boolean; @@ -216,6 +221,7 @@ export class SeqBlock extends React.PureComponent { const { annotationRows, blockHeight, + blockWidth, bpsPerBlock, charWidth, compSeq, @@ -229,6 +235,7 @@ export class SeqBlock extends React.PureComponent { inputRef, lineHeight, onUnmount, + oneRow, searchRows, seq, seqFontSize, @@ -236,6 +243,8 @@ export class SeqBlock extends React.PureComponent { showComplement, showIndex, size, + stackedAnnotations, + stackedTranslations, translationRows, zoom, zoomed, @@ -255,7 +264,7 @@ export class SeqBlock extends React.PureComponent { // height and yDiff of cut sites const cutSiteYDiff = 0; // spacing for cutSite names - const cutSiteHeight = zoomed && cutSiteRows.length ? lineHeight : 0; + const cutSiteHeight = zoomed && (cutSiteRows.length || oneRow) ? lineHeight : 0; // height and yDiff of the sequence strand const indexYDiff = cutSiteYDiff + cutSiteHeight; @@ -267,14 +276,14 @@ export class SeqBlock extends React.PureComponent { // height and yDiff of translations const translationYDiff = compYDiff + compHeight; - const translationHeight = elementHeight * translationRows.length; + const translationHeight = elementHeight * (oneRow ? stackedTranslations.length : translationRows.length); // height and yDiff of annotations const annYDiff = translationYDiff + translationHeight; - const annHeight = elementHeight * annotationRows.length; + const annHeight = elementHeight * (oneRow ? stackedAnnotations.length : 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 = translationHeight || annHeight ? 3 : 0; const indexRowYDiff = annYDiff + annHeight + elementGap; // calc the height necessary for the sequence selection @@ -303,8 +312,8 @@ export class SeqBlock extends React.PureComponent { height={blockHeight} id={id} overflow="visible" - style={seqBlock} - width={size.width >= 0 ? size.width : 0} + style={oneRow ? linearOneRowSeqBlock : seqBlock} + width={oneRow ? blockWidth : size.width} onMouseDown={handleMouseEvent} onMouseMove={handleMouseEvent} onMouseUp={handleMouseEvent} @@ -372,7 +381,9 @@ export class SeqBlock extends React.PureComponent { fullSeq={fullSeq} inputRef={inputRef} lastBase={lastBase} + oneRow={oneRow} seqType={seqType} + stackedPositions={stackedTranslations} translationRows={translationRows} yDiff={translationYDiff} onUnmount={onUnmount} @@ -388,7 +399,9 @@ export class SeqBlock extends React.PureComponent { fullSeq={fullSeq} inputRef={inputRef} lastBase={lastBase} + oneRow={oneRow} seqBlockRef={this} + stackedPositions={stackedAnnotations} width={size.width} yDiff={annYDiff} /> diff --git a/src/Linear/Translations.tsx b/src/Linear/Translations.tsx index 384db0675..434997d6b 100644 --- a/src/Linear/Translations.tsx +++ b/src/Linear/Translations.tsx @@ -2,7 +2,7 @@ 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"; @@ -17,7 +17,9 @@ interface TranslationRowsProps { inputRef: InputRefFunc; lastBase: number; onUnmount: (a: unknown) => void; + oneRow: boolean; seqType: SeqType; + stackedPositions: NameRange[][]; translationRows: Translation[][]; yDiff: number; } @@ -33,14 +35,16 @@ export const TranslationRows = ({ inputRef, lastBase, onUnmount, + oneRow, seqType, + stackedPositions, translationRows, yDiff, }: TranslationRowsProps) => ( {translationRows.map((translations, i) => ( stackedPositions.findIndex(row => row.some(item => item.id === t.id)) as number + ) + ) + : i) + } onUnmount={onUnmount} /> ))} diff --git a/src/SelectionHandler.tsx b/src/SelectionHandler.tsx index 938d0210c..ad3d489c0 100644 --- a/src/SelectionHandler.tsx +++ b/src/SelectionHandler.tsx @@ -6,7 +6,7 @@ interface RefSelection extends Selection { viewer: "LINEAR" | "CIRCULAR"; } -export type InputRefFunc = (id: string, ref: RefSelection) => any; +export type InputRefFunc = (id: string, ref: RefSelection) => React.LegacyRef | undefined; export type SeqVizMouseEvent = React.MouseEvent & { target: { id: string }; @@ -96,6 +96,7 @@ export default class SelectionHandler extends React.PureComponent { this.idToRange.set(ref, { ref, ...selectRange }); + return undefined; }; /** diff --git a/src/SeqViewerContainer.tsx b/src/SeqViewerContainer.tsx index d1a46a41a..9011f45ff 100644 --- a/src/SeqViewerContainer.tsx +++ b/src/SeqViewerContainer.tsx @@ -6,7 +6,7 @@ import { EventHandler } from "./EventHandler"; import Linear, { LinearProps } from "./Linear/Linear"; import SelectionHandler, { InputRefFunc } from "./SelectionHandler"; import CentralIndexContext from "./centralIndexContext"; -import { Annotation, CutSite, Highlight, NameRange, Range, SeqType } from "./elements"; +import { Annotation, CutSite, Highlight, NameRange, SeqType } from "./elements"; import { isEqual } from "./isEqual"; import SelectionContext, { Selection, defaultSelection } from "./selectionContext"; @@ -56,8 +56,8 @@ interface SeqViewerContainerProps { targetRef: React.LegacyRef; /** testSize is a forced height/width that overwrites anything from sizeMe. For testing */ testSize?: { height: number; width: number }; - translations: Range[]; - viewer: "linear" | "circular" | "both" | "both_flip"; + translations: NameRange[]; + viewer: "linear" | "circular" | "both" | "both_flip" | "linear_one_row"; width: number; zoom: { circular: number; linear: number }; } @@ -90,7 +90,7 @@ class SeqViewerContainer extends React.Component + shouldComponentUpdate = (nextProps: SeqViewerContainerProps, nextState: SeqViewerContainerState) => !isEqual(nextProps, this.props) || !isEqual(nextState, this.state); /** @@ -152,6 +152,8 @@ class SeqViewerContainer extends React.Component {/* TODO: this sucks, some breaking refactor in future should get rid of it SeqViewer */} - {viewer === "linear" && ( + {(viewer === "linear" || viewer === "linear_one_row") && ( ; + style?: React.CSSProperties; /** ranges of sequence that should have amino acid translations shown */ translations?: { direction?: number; end: number; start: number }[]; /** 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"; + viewer?: "linear" | "circular" | "both" | "both_flip" | "linear_one_row"; /** how large to make the sequence and elements [0,100]. A larger zoom increases the size of text and elements for that viewer. */ zoom?: { @@ -429,11 +429,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: t.start + Math.floor((t.end - t.start) / 3) * 3, - start: t.start % seq.length, - })), + translations: (translations || []).map( + t => + ({ + direction: t.direction ? (t.direction < 0 ? -1 : 1) : 1, + end: t.start + Math.floor((t.end - t.start) / 3) * 3, + id: randomID(), + name: randomID(), + start: t.start % seq.length, + } as NameRange) + ), viewer: this.props.viewer || "both", zoom: { circular: typeof zoom?.circular == "number" ? Math.min(Math.max(zoom.circular, 0), 100) : 0, diff --git a/src/style.ts b/src/style.ts index abb0a84e2..8989e0a78 100644 --- a/src/style.ts +++ b/src/style.ts @@ -142,7 +142,7 @@ export const linearScroller: CSS.Properties = { outline: "none !important", overflowX: "hidden", overflowY: "scroll", - padding: "10", + padding: "10px", position: "relative", }; @@ -151,3 +151,18 @@ export const seqBlock: CSS.Properties = { padding: 0, width: "100%", }; + +export const linearOneRowScroller: CSS.Properties = { + cursor: "text", + fontWeight: 300, + height: "100%", + outline: "none !important", + overflowX: "auto", + overflowY: "hidden", + padding: "10px", + position: "relative", +}; + +export const linearOneRowSeqBlock: CSS.Properties = { + padding: 0, +}; diff --git a/tsconfig.json b/tsconfig.json index 0cd5728ab..3207750d8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "allowJs": false, // allow a partial TypeScript and JavaScript codebase "allowUnreachableCode": false, "declaration": true, + "downlevelIteration": true, "jsx": "react", // use typescript to transpile jsx to js "lib": ["es2015", "dom"], // https://marcobotto.com/blog/compiling-and-bundling-typescript-libraries-with-webpack/ "module": "commonjs", // specify module code generation