diff --git a/demo/lib/App.tsx b/demo/lib/App.tsx index a60a37d25..e300cf6d9 100644 --- a/demo/lib/App.tsx +++ b/demo/lib/App.tsx @@ -23,6 +23,8 @@ const viewerTypeOptions = [ { key: "both", text: "Both", value: "both" }, { 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 { @@ -38,7 +40,7 @@ interface AppState { showIndex: boolean; showSelectionMeta: boolean; showSidebar: boolean; - translations: { end: number; start: number; direction?: 1 | -1 }[]; + translations: { direction?: 1 | -1, end: number; start: number; }[]; viewer: string; zoom: number; } @@ -58,7 +60,7 @@ export default class App extends React.Component { showSelectionMeta: false, showSidebar: false, translations: [ - { end: 630, start: 6, direction: -1 }, + { direction: -1, end: 630, start: 6 }, { end: 1147, start: 736 }, { end: 1885, start: 1165 }, ], @@ -153,13 +155,13 @@ export default class App extends React.Component { enzymes={this.state.enzymes} name={this.state.name} search={this.state.search} + selection={this.state.selection} seq={this.state.seq} showComplement={this.state.showComplement} showIndex={this.state.showIndex} translations={this.state.translations} viewer={this.state.viewer as "linear" | "circular"} zoom={{ linear: this.state.zoom }} - selection={this.state.selection} onSelection={selection => this.setState({ selection })} /> )} diff --git a/src/Linear/InfiniteHorizontalScroll.tsx b/src/Linear/InfiniteHorizontalScroll.tsx new file mode 100644 index 000000000..79ffe58fb --- /dev/null +++ b/src/Linear/InfiniteHorizontalScroll.tsx @@ -0,0 +1,312 @@ +import * as React from "react"; + +import CentralIndexContext from "../centralIndexContext"; +import { Size } from "../elements"; +import { isEqual } from "../isEqual"; + +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: any + ) => { + 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 (!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 df022dd62..d9ff9e7d1 100644 --- a/src/Linear/Linear.tsx +++ b/src/Linear/Linear.tsx @@ -5,6 +5,7 @@ import { Annotation, CutSite, Highlight, NameRange, Range, SeqType, Size } from 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; @@ -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) @@ -122,6 +127,10 @@ export default class Linear extends React.Component { ? createSingleRows(createTranslations(translations, seq, seqType), bpsPerBlock, arrSize) : new Array(arrSize).fill([]); + let maxBlockHeight = 0; + let maxTranslationRowSize = 0; + let maxAnnotationRowSize = 0; + for (let i = 0; i < arrSize; i += 1) { const firstBase = i * bpsPerBlock; const lastBase = firstBase + bpsPerBlock; @@ -130,6 +139,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 +165,27 @@ export default class Linear extends React.Component { } blockHeights[i] = blockHeight; + blockWidths[i] = blockWidth; + if (blockHeight > maxBlockHeight) { + maxBlockHeight = blockHeight; + } + if (maxTranslationRowSize < translationRows[i].length) { + maxTranslationRowSize = translationRows[i].length; + } + if (maxAnnotationRowSize < annotationRows[i].length) { + maxAnnotationRowSize = annotationRows[i].length; + } + } + + if (oneRow) { + for (let i = 0; i < arrSize; i += 1) { + blockHeights[i] = maxBlockHeight; + } } 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 +193,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 +207,9 @@ export default class Linear extends React.Component { id={ids[i]} inputRef={this.props.inputRef} lineHeight={lineHeight} + maxAnnotationRowSize={maxAnnotationRowSize} + maxTranslationRowSize={maxTranslationRowSize} + oneRow={oneRow} searchRows={searchRows[i]} seq={seqs[i]} seqFontSize={this.props.seqFontSize} @@ -187,17 +218,28 @@ export default class Linear extends React.Component { showIndex={showIndex} size={size} translations={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.tsx b/src/Linear/SeqBlock.tsx index 800b41a98..cc0401a15 100644 --- a/src/Linear/SeqBlock.tsx +++ b/src/Linear/SeqBlock.tsx @@ -27,6 +27,7 @@ export type FindXAndWidthElementType = ( interface SeqBlockProps { annotationRows: Annotation[][]; blockHeight: number; + blockWidth: number; bpColors?: { [key: number | string]: string }; bpsPerBlock: number; charWidth: number; @@ -41,7 +42,10 @@ interface SeqBlockProps { inputRef: InputRefFunc; key: string; lineHeight: number; + maxAnnotationRowSize: number; + maxTranslationRowSize: number; onUnmount: (a: string) => void; + oneRow: boolean; searchRows: Range[]; seq: string; seqFontSize: number; @@ -50,6 +54,7 @@ interface SeqBlockProps { showIndex: boolean; size: Size; translations: Translation[]; + x: number; y: number; zoom: { linear: number }; zoomed: boolean; @@ -215,6 +220,7 @@ export class SeqBlock extends React.PureComponent { const { annotationRows, blockHeight, + blockWidth, bpsPerBlock, charWidth, compSeq, @@ -227,7 +233,10 @@ export class SeqBlock extends React.PureComponent { id, inputRef, lineHeight, + maxAnnotationRowSize, + maxTranslationRowSize, onUnmount, + oneRow, searchRows, seq, seqFontSize, @@ -254,7 +263,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; @@ -266,14 +275,14 @@ export class SeqBlock extends React.PureComponent { // height and yDiff of translations const translationYDiff = compYDiff + compHeight; - const translationHeight = elementHeight * translations.length; + const translationHeight = elementHeight * (oneRow ? maxTranslationRowSize : translations.length); // height and yDiff of annotations const annYDiff = translationYDiff + translationHeight; - const annHeight = elementHeight * annotationRows.length; + const annHeight = elementHeight * (oneRow ? maxAnnotationRowSize : annotationRows.length); // height and ydiff of the index row. - const elementGap = annotationRows.length + translations.length ? 3 : 0; + const elementGap = annotationRows.length || translations.length || oneRow ? 3 : 0; const indexRowYDiff = annYDiff + annHeight + elementGap; // calc the height necessary for the sequence selection @@ -295,13 +304,13 @@ export class SeqBlock extends React.PureComponent { type: "SEQ", viewer: "LINEAR", })} - className="la-vz-seqblock" + className={oneRow ? "la-vz-linear-one-row-seqblock" : "la-vz-seqblock"} cursor="text" data-testid="la-vz-seqblock" display="block" height={blockHeight} id={id} - width={size.width >= 0 ? size.width : 0} + width={oneRow ? blockWidth : size.width} onMouseDown={handleMouseEvent} onMouseMove={handleMouseEvent} onMouseUp={handleMouseEvent} diff --git a/src/SeqViewerContainer.tsx b/src/SeqViewerContainer.tsx index a60379a83..2ace278a9 100644 --- a/src/SeqViewerContainer.tsx +++ b/src/SeqViewerContainer.tsx @@ -42,7 +42,7 @@ interface SeqViewerContainerProps { /** 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"; + viewer: "linear" | "circular" | "both" | "both_flip" | "linear_one_row"; width: number; zoom: { circular: number; linear: number }; } @@ -134,6 +134,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") && (