Skip to content

Commit 7a0f15f

Browse files
author
Jack Zhao
committed
implement one row linear viewer
1 parent 7242368 commit 7a0f15f

File tree

7 files changed

+403
-15
lines changed

7 files changed

+403
-15
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: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
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+
// Don't scroll exactly to centralIndex because most of the time
149+
// item of interest is at centralIndex and if this is at the left
150+
// it can be obscured by things
151+
this.scroller.current.scrollLeft = centerBlock.props.x - blockWidths[0] / 2;
152+
}
153+
154+
if (!isEqual(newVisibleBlocks, visibleBlocks)) {
155+
this.setState({
156+
centralIndex: centralIndex,
157+
visibleBlocks: newVisibleBlocks,
158+
});
159+
}
160+
};
161+
162+
/**
163+
* the component has mounted to the DOM or updated, and the window should be scrolled
164+
* so that the central index is visible
165+
*/
166+
restoreSnapshot = snapshot => {
167+
if (!this.scroller.current) {
168+
return;
169+
}
170+
171+
const { blockWidths } = this.props;
172+
const { blockIndex, blockX } = snapshot;
173+
174+
const scrollLeft = blockWidths.slice(0, blockIndex).reduce((acc, w) => acc + w, 0) + blockX;
175+
176+
this.scroller.current.scrollLeft = scrollLeft;
177+
};
178+
179+
/**
180+
* check whether the blocks that should be visible have changed from what's in state,
181+
* update if so
182+
*/
183+
handleScrollOrResize = () => {
184+
if (!this.scroller.current || !this.insideDOM.current) {
185+
return;
186+
}
187+
188+
const {
189+
blockWidths,
190+
size: { width },
191+
totalWidth,
192+
} = this.props;
193+
const { visibleBlocks } = this.state;
194+
195+
const newVisibleBlocks: number[] = [];
196+
197+
let left = 0;
198+
if (this.scroller && this.insideDOM) {
199+
const { left: parentLeft } = this.scroller.current.getBoundingClientRect();
200+
const { left: childLeft } = this.insideDOM.current.getBoundingClientRect();
201+
left = childLeft - parentLeft;
202+
}
203+
204+
left = -left + 35;
205+
left = Math.max(0, left); // don't go too left
206+
left = Math.min(totalWidth - width, left); // don't go too right
207+
const right = left + blockWidths[0]; // width;
208+
left -= blockWidths[0]; // add one block padding on left
209+
blockWidths.reduce((total, w, i) => {
210+
if (total >= left && total <= right) {
211+
newVisibleBlocks.push(i);
212+
}
213+
return total + w;
214+
}, 0);
215+
216+
if (!isEqual(newVisibleBlocks, visibleBlocks)) {
217+
this.setState({ visibleBlocks: newVisibleBlocks });
218+
}
219+
};
220+
221+
incrementScroller = incAmount => {
222+
this.stopIncrementingScroller();
223+
this.timeoutID = setTimeout(() => {
224+
if (!this.scroller.current) {
225+
return;
226+
}
227+
228+
this.scroller.current.scrollLeft += incAmount;
229+
this.incrementScroller(incAmount);
230+
}, 5);
231+
};
232+
233+
stopIncrementingScroller = () => {
234+
if (this.timeoutID) {
235+
clearTimeout(this.timeoutID);
236+
this.timeoutID = null;
237+
}
238+
};
239+
240+
/**
241+
* handleMouseOver is for detecting when the user is performing a drag event
242+
* at the very left or the very right of DIV. If they are, this starts
243+
* a incrementing the div's scrollLeft (ie a horizontal scroll event) that's
244+
* terminated by the user leaving the scroll area
245+
*
246+
* The rate of the scrollLeft is proportional to how far from the left or the
247+
* bottom the user is (within [-40, 0] for left, and [0, 40] for right)
248+
*/
249+
handleMouseOver = (e: React.MouseEvent<HTMLDivElement>) => {
250+
if (!this.scroller.current) {
251+
return;
252+
}
253+
254+
// not relevant, some other type of event, not a selection drag
255+
if (e.buttons !== 1) {
256+
if (this.timeoutID) {
257+
this.stopIncrementingScroller();
258+
}
259+
return;
260+
}
261+
262+
// check whether the current drag position is near the right
263+
// of the viewer and, if it is, try and increment the current
264+
// centralIndex (triggering a right scroll event)
265+
const scrollerBlock = this.scroller.current.getBoundingClientRect();
266+
let scrollRatio = (e.clientX - scrollerBlock.left) / scrollerBlock.width;
267+
if (scrollRatio > 0.9) {
268+
scrollRatio = Math.min(1, scrollRatio);
269+
let scalingRatio = scrollRatio - 0.9;
270+
scalingRatio *= 10;
271+
const scaledScroll = 15 * scalingRatio;
272+
273+
this.incrementScroller(scaledScroll);
274+
} else if (scrollRatio < 0.1) {
275+
scrollRatio = 0.1 - Math.max(0, scrollRatio);
276+
const scalingRatio = 10 * scrollRatio;
277+
const scaledScroll = -15 * scalingRatio;
278+
279+
this.incrementScroller(scaledScroll);
280+
} else {
281+
this.stopIncrementingScroller();
282+
}
283+
};
284+
285+
render() {
286+
const {
287+
blockWidths,
288+
seqBlocks,
289+
size: { height },
290+
totalWidth: width,
291+
} = this.props;
292+
const { visibleBlocks } = this.state;
293+
294+
// find the width of the empty div needed to correctly position the rest
295+
const [firstRendered] = visibleBlocks;
296+
const spaceLeft = blockWidths.slice(0, firstRendered).reduce((acc, w) => acc + w, 0);
297+
return (
298+
<div
299+
ref={this.scroller}
300+
className="la-vz-linear-one-row-scroller"
301+
data-testid="la-vz-viewer-linear"
302+
onFocus={() => {
303+
// do nothing
304+
}}
305+
onMouseOver={this.handleMouseOver}
306+
onScroll={this.handleScrollOrResize}
307+
>
308+
<div ref={this.insideDOM} className="la-vz-linear-one-row-seqblock-container" style={{ width }}>
309+
<div className="la-vz-seqblock-padding-left" style={{ height: height || 0, width: spaceLeft }} />
310+
{visibleBlocks.map(i => seqBlocks[i])}
311+
</div>
312+
</div>
313+
);
314+
}
315+
}

0 commit comments

Comments
 (0)