Skip to content

Commit 92220a5

Browse files
author
Jack Zhao
committed
implement one row linear viewer
1 parent 7242368 commit 92220a5

File tree

7 files changed

+402
-16
lines changed

7 files changed

+402
-16
lines changed

demo/lib/App.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ const viewerTypeOptions = [
2323
{ key: "both", text: "Both", value: "both" },
2424
{ key: "circular", text: "Circular", value: "circular" },
2525
{ key: "linear", text: "Linear", value: "linear" },
26+
{ key: "both_flip", text: "Both Flip", value: "both_flip" },
27+
{ key: "linear_one_row", text: "Linear One Row", value: "linear_one_row" },
2628
];
2729

2830
interface AppState {
@@ -38,7 +40,7 @@ interface AppState {
3840
showIndex: boolean;
3941
showSelectionMeta: boolean;
4042
showSidebar: boolean;
41-
translations: { end: number; start: number; direction?: 1 | -1 }[];
43+
translations: { direction?: 1 | -1, end: number; start: number; }[];
4244
viewer: string;
4345
zoom: number;
4446
}
@@ -58,7 +60,7 @@ export default class App extends React.Component<any, AppState> {
5860
showSelectionMeta: false,
5961
showSidebar: false,
6062
translations: [
61-
{ end: 630, start: 6, direction: -1 },
63+
{ direction: -1, end: 630, start: 6 },
6264
{ end: 1147, start: 736 },
6365
{ end: 1885, start: 1165 },
6466
],
@@ -153,13 +155,13 @@ export default class App extends React.Component<any, AppState> {
153155
enzymes={this.state.enzymes}
154156
name={this.state.name}
155157
search={this.state.search}
158+
selection={this.state.selection}
156159
seq={this.state.seq}
157160
showComplement={this.state.showComplement}
158161
showIndex={this.state.showIndex}
159162
translations={this.state.translations}
160163
viewer={this.state.viewer as "linear" | "circular"}
161164
zoom={{ linear: this.state.zoom }}
162-
selection={this.state.selection}
163165
onSelection={selection => this.setState({ selection })}
164166
/>
165167
)}
Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
import * as React from "react";
2+
3+
import CentralIndexContext from "../centralIndexContext";
4+
import { Size } from "../elements";
5+
import { isEqual } from "../isEqual";
6+
7+
interface InfiniteHorizontalScrollProps {
8+
blockWidths: number[];
9+
bpsPerBlock: number;
10+
seqBlocks: JSX.Element[];
11+
size: Size;
12+
totalWidth: number;
13+
}
14+
15+
interface InfiniteHorizontalScrollState {
16+
centralIndex: number;
17+
visibleBlocks: number[];
18+
}
19+
20+
/**
21+
* InfiniteHorizontalScroll is a wrapper around the seqBlocks. Renders only the seqBlocks that are
22+
* within the range of the current dom viewerport
23+
*
24+
* This component should sense scroll events and, during one, recheck which sequences are shown.
25+
*/
26+
export class InfiniteHorizontalScroll extends React.PureComponent<
27+
InfiniteHorizontalScrollProps,
28+
InfiniteHorizontalScrollState
29+
> {
30+
static contextType = CentralIndexContext;
31+
static context: React.ContextType<typeof CentralIndexContext>;
32+
declare context: React.ContextType<typeof CentralIndexContext>;
33+
34+
scroller: React.RefObject<HTMLDivElement> = React.createRef(); // ref to a div for scrolling
35+
insideDOM: React.RefObject<HTMLDivElement> = React.createRef(); // ref to a div inside the scroller div
36+
timeoutID;
37+
38+
constructor(props: InfiniteHorizontalScrollProps) {
39+
super(props);
40+
41+
this.state = {
42+
centralIndex: 0,
43+
// start off with first 1 blocks shown
44+
visibleBlocks: new Array(Math.min(1, props.seqBlocks.length)).fill(null).map((_, i) => i),
45+
};
46+
}
47+
48+
componentDidMount = () => {
49+
this.handleScrollOrResize(); // ref should now be set
50+
window.addEventListener("resize", this.handleScrollOrResize);
51+
};
52+
53+
componentDidUpdate = (
54+
prevProps: InfiniteHorizontalScrollProps,
55+
prevState: InfiniteHorizontalScrollState,
56+
snapshot: any
57+
) => {
58+
if (!this.scroller.current) {
59+
// scroller not mounted yet
60+
return;
61+
}
62+
63+
const { seqBlocks, size } = this.props;
64+
const { centralIndex, visibleBlocks } = this.state;
65+
66+
if (this.context && centralIndex !== this.context.linear) {
67+
this.scrollToCentralIndex();
68+
} else if (!isEqual(prevProps.size, size) || seqBlocks.length !== prevProps.seqBlocks.length) {
69+
this.handleScrollOrResize(); // reset
70+
} else if (isEqual(prevState.visibleBlocks, visibleBlocks)) {
71+
this.restoreSnapshot(snapshot); // something, like ORFs or index view, has changed
72+
}
73+
};
74+
75+
componentWillUnmount = () => {
76+
window.removeEventListener("resize", this.handleScrollOrResize);
77+
};
78+
79+
/**
80+
* more info at: https://reactjs.org/docs/react-component.html#getsnapshotbeforeupdate
81+
*/
82+
getSnapshotBeforeUpdate = (prevProps: InfiniteHorizontalScrollProps) => {
83+
// find the current left block
84+
const left = this.scroller.current ? this.scroller.current.scrollLeft : 0;
85+
86+
// find out 1) which block this is at the edge of the left
87+
// and 2) how far from the left of that block we are right now
88+
const { blockWidths } = prevProps;
89+
let blockIndex = 0;
90+
let accumulatedX = 0;
91+
do {
92+
accumulatedX += blockWidths[blockIndex];
93+
blockIndex += 1;
94+
} while (accumulatedX + blockWidths[blockIndex] < left && blockIndex < blockWidths.length);
95+
96+
const blockX = left - accumulatedX; // last extra distance
97+
return { blockIndex, blockX };
98+
};
99+
100+
/**
101+
* Scroll to centralIndex. Likely from circular clicking on an element
102+
* that should then be scrolled to in linear
103+
*/
104+
scrollToCentralIndex = () => {
105+
if (!this.scroller.current) {
106+
return;
107+
}
108+
109+
const {
110+
blockWidths,
111+
bpsPerBlock,
112+
seqBlocks,
113+
size: { width },
114+
totalWidth,
115+
} = this.props;
116+
const { visibleBlocks } = this.state;
117+
const { clientWidth, scrollWidth } = this.scroller.current;
118+
const centralIndex = this.context.linear;
119+
120+
// find the first block that contains the new central index
121+
const centerBlockIndex = seqBlocks.findIndex(
122+
block => block.props.firstBase <= centralIndex && block.props.firstBase + bpsPerBlock >= centralIndex
123+
);
124+
125+
// build up the list of blocks that are visible just after this first block
126+
let newVisibleBlocks: number[] = [];
127+
if (scrollWidth <= clientWidth) {
128+
newVisibleBlocks = visibleBlocks;
129+
} else if (centerBlockIndex > -1) {
130+
const centerBlock = seqBlocks[centerBlockIndex];
131+
132+
// create some padding to the left of the new center block
133+
const leftAdjust = centerBlockIndex > 0 ? blockWidths[centerBlockIndex - 1] : 0;
134+
let left = centerBlock.props.x - leftAdjust;
135+
let right = left + width;
136+
if (right > totalWidth) {
137+
right = totalWidth;
138+
left = totalWidth - width;
139+
}
140+
141+
blockWidths.reduce((total, w, i) => {
142+
if (total >= left && total <= right) {
143+
newVisibleBlocks.push(i);
144+
}
145+
return total + w;
146+
}, 0);
147+
148+
this.scroller.current.scrollLeft = centerBlock.props.x;
149+
}
150+
151+
if (!isEqual(newVisibleBlocks, visibleBlocks)) {
152+
this.setState({
153+
centralIndex: centralIndex,
154+
visibleBlocks: newVisibleBlocks,
155+
});
156+
}
157+
};
158+
159+
/**
160+
* the component has mounted to the DOM or updated, and the window should be scrolled
161+
* so that the central index is visible
162+
*/
163+
restoreSnapshot = snapshot => {
164+
if (!this.scroller.current) {
165+
return;
166+
}
167+
168+
const { blockWidths } = this.props;
169+
const { blockIndex, blockX } = snapshot;
170+
171+
const scrollLeft = blockWidths.slice(0, blockIndex).reduce((acc, w) => acc + w, 0) + blockX;
172+
173+
this.scroller.current.scrollLeft = scrollLeft;
174+
};
175+
176+
/**
177+
* check whether the blocks that should be visible have changed from what's in state,
178+
* update if so
179+
*/
180+
handleScrollOrResize = () => {
181+
if (!this.scroller.current || !this.insideDOM.current) {
182+
return;
183+
}
184+
185+
const {
186+
blockWidths,
187+
size: { width },
188+
totalWidth,
189+
} = this.props;
190+
const { visibleBlocks } = this.state;
191+
192+
const newVisibleBlocks: number[] = [];
193+
194+
let left = 0;
195+
if (this.scroller && this.insideDOM) {
196+
const { left: parentLeft } = this.scroller.current.getBoundingClientRect();
197+
const { left: childLeft } = this.insideDOM.current.getBoundingClientRect();
198+
left = childLeft - parentLeft;
199+
}
200+
201+
left = -left + 35;
202+
left = Math.max(0, left); // don't go too left
203+
left = Math.min(totalWidth - width, left); // don't go too right
204+
const right = left + blockWidths[0]; // width;
205+
left -= blockWidths[0]; // add one block padding on left
206+
blockWidths.reduce((total, w, i) => {
207+
if (total >= left && total <= right) {
208+
newVisibleBlocks.push(i);
209+
}
210+
return total + w;
211+
}, 0);
212+
213+
if (!isEqual(newVisibleBlocks, visibleBlocks)) {
214+
this.setState({ visibleBlocks: newVisibleBlocks });
215+
}
216+
};
217+
218+
incrementScroller = incAmount => {
219+
this.stopIncrementingScroller();
220+
this.timeoutID = setTimeout(() => {
221+
if (!this.scroller.current) {
222+
return;
223+
}
224+
225+
this.scroller.current.scrollLeft += incAmount;
226+
this.incrementScroller(incAmount);
227+
}, 5);
228+
};
229+
230+
stopIncrementingScroller = () => {
231+
if (this.timeoutID) {
232+
clearTimeout(this.timeoutID);
233+
this.timeoutID = null;
234+
}
235+
};
236+
237+
/**
238+
* handleMouseOver is for detecting when the user is performing a drag event
239+
* at the very left or the very right of DIV. If they are, this starts
240+
* a incrementing the div's scrollLeft (ie a horizontal scroll event) that's
241+
* terminated by the user leaving the scroll area
242+
*
243+
* The rate of the scrollLeft is proportional to how far from the left or the
244+
* bottom the user is (within [-40, 0] for left, and [0, 40] for right)
245+
*/
246+
handleMouseOver = (e: React.MouseEvent<HTMLDivElement>) => {
247+
if (!this.scroller.current) {
248+
return;
249+
}
250+
251+
// not relevant, some other type of event, not a selection drag
252+
if (e.buttons !== 1) {
253+
if (this.timeoutID) {
254+
this.stopIncrementingScroller();
255+
}
256+
return;
257+
}
258+
259+
// check whether the current drag position is near the right
260+
// of the viewer and, if it is, try and increment the current
261+
// centralIndex (triggering a right scroll event)
262+
const scrollerBlock = this.scroller.current.getBoundingClientRect();
263+
let scrollRatio = (e.clientX - scrollerBlock.left) / scrollerBlock.width;
264+
if (scrollRatio > 0.9) {
265+
scrollRatio = Math.min(1, scrollRatio);
266+
let scalingRatio = scrollRatio - 0.9;
267+
scalingRatio *= 10;
268+
const scaledScroll = 15 * scalingRatio;
269+
270+
this.incrementScroller(scaledScroll);
271+
} else if (scrollRatio < 0.1) {
272+
scrollRatio = 0.1 - Math.max(0, scrollRatio);
273+
const scalingRatio = 10 * scrollRatio;
274+
const scaledScroll = -15 * scalingRatio;
275+
276+
this.incrementScroller(scaledScroll);
277+
} else {
278+
this.stopIncrementingScroller();
279+
}
280+
};
281+
282+
render() {
283+
const {
284+
blockWidths,
285+
seqBlocks,
286+
size: { height },
287+
totalWidth: width,
288+
} = this.props;
289+
const { visibleBlocks } = this.state;
290+
291+
// find the width of the empty div needed to correctly position the rest
292+
const [firstRendered] = visibleBlocks;
293+
const spaceLeft = blockWidths.slice(0, firstRendered).reduce((acc, w) => acc + w, 0);
294+
return (
295+
<div
296+
ref={this.scroller}
297+
className="la-vz-linear-one-row-scroller"
298+
data-testid="la-vz-viewer-linear"
299+
onFocus={() => {
300+
// do nothing
301+
}}
302+
onMouseOver={this.handleMouseOver}
303+
onScroll={this.handleScrollOrResize}
304+
>
305+
<div ref={this.insideDOM} className="la-vz-linear-one-row-seqblock-container" style={{ width }}>
306+
<div className="la-vz-seqblock-padding-left" style={{ height: height || 0, width: spaceLeft }} />
307+
{visibleBlocks.map(i => seqBlocks[i])}
308+
</div>
309+
</div>
310+
);
311+
}
312+
}

0 commit comments

Comments
 (0)