diff --git a/src/common.mjs b/src/common.mjs index 4b429d1c..e350fa2a 100644 --- a/src/common.mjs +++ b/src/common.mjs @@ -26,6 +26,8 @@ const removeCommas = (input) => { // or // { contig, start, distance } // +// a region string could look like: "17:1-100" +// // For distance, + is used as the coordinate separator. For start/end ranges, - is used. // The coordinates are set off from the contig by the last colon. // Commas in coordinates are removed. diff --git a/src/components/DataPositionFormRow.js b/src/components/DataPositionFormRow.js index d0f278c6..627e1af3 100644 --- a/src/components/DataPositionFormRow.js +++ b/src/components/DataPositionFormRow.js @@ -84,6 +84,7 @@ class DataPositionFormRow extends Component { color="primary" id="goLeftButton" onClick={this.props.handleGoLeft} + disabled={this.props.uploadInProgress || !this.props.canGoLeft} > @@ -97,6 +98,7 @@ class DataPositionFormRow extends Component { color="primary" id="goRightButton" onClick={this.props.handleGoRight} + disabled={this.props.uploadInProgress || !this.props.canGoRight} > @@ -125,6 +127,8 @@ DataPositionFormRow.propTypes = { uploadInProgress: PropTypes.bool.isRequired, getCurrentViewTarget: PropTypes.func.isRequired, viewTargetHasChange: PropTypes.bool.isRequired, + canGoLeft: PropTypes.bool.isRequired, + canGoRight: PropTypes.bool.isRequired, }; export default DataPositionFormRow; diff --git a/src/components/HeaderForm.js b/src/components/HeaderForm.js index 5a1b837f..14bf7b8a 100644 --- a/src/components/HeaderForm.js +++ b/src/components/HeaderForm.js @@ -44,8 +44,25 @@ const CLEAR_STATE = { // Description for the selected region, is not displayed when empty desc: "", - // This tracks several arrays of BED region data, stored by data type, with + // This tracks several arrays (desc, chr, start, end) of BED region data, with // one entry in each array per region. + // desc: description of region, i.e. "region with no source graph available" + // chr: path in graph where the region is on, i.e. in ref:2000-3000, "ref" is the chr + // start: start of the region, i.e. in ref:2000-3000, 2000 is the start + // end: end of the region, i.e. in ref:2000-3000, 3000 is the end + // chunk: url/directory for preexisting cached chunk, or empty string if not available + // tracks: object full of tracks to apply when user selects region, or null + // so regionInfo might look like: + /* + { + chr: [ '17', '17' ], + start: [ '1', '1000' ], + end: [ '100', '1200' ], + desc: [ '17_1_100', '17_1000_1200' ], + chunk: [ '', '' ], + tracks: [ null, null ] + } + */ regionInfo: {}, pathNames: [], @@ -171,6 +188,77 @@ function viewTargetsEqual(currViewTarget, nextViewTarget) { return true; } +/* determine the current region: accepts a region string and returns the region index + + example of regionInfo: + { + chr: [ '17', '17' ], + start: [ '1', '1000' ], + end: [ '100', '1200' ], + desc: [ '17_1_100', '17_1000_1200' ], + chunk: [ '', '' ], + tracks: [ null, null ] + } + + examples: + if the regionString is "17:1-100", it would be parsed into {contig: "17", start: 1, end: 100} -> 0 + if the regionString is "17:1000-1200", it would be parsed into {contig: "17", start: 1000, end: 1200} -> 1 + if the regionString is "17:2000-3000", it cannot be found - return null + + The function uses this approach to find the regionIndex given regionString and regionInfo: + function (region string){ + parse(region string) -> return {contig, start, end} + loop over chr in region info + determine if contig, start, end are present at the current index + if present: return index + return null + } +*/ +export const determineRegionIndex = (regionString, regionInfo) => { + let parsedRegion; + try { + parsedRegion = parseRegion(regionString); + } catch(error) { + return null; + } + if (!regionInfo["chr"]){ + return null; + } + for (let i = 0; i < regionInfo["chr"].length; i++){ + if ((parseInt(regionInfo["start"][i]) === parsedRegion.start) + && (parseInt(regionInfo["end"][i]) === parsedRegion.end) + && (regionInfo["chr"][i] === parsedRegion.contig)){ + return i; + } + } + return null; +} + +/* + This function takes in a regionIndex and regionInfo, and reconstructs a regionString from them + assumes that index is valid in regionInfo + + example of regionInfo: + { + chr: [ '17', '17' ], + start: [ '1', '1000' ], + end: [ '100', '1200' ], + desc: [ '17_1_100', '17_1000_1200' ], + chunk: [ '', '' ], + tracks: [ null, null ] + } + + example of regionIndex: 0 + + example of regionString: "17:1-100" +*/ +export const regionStringFromRegionIndex = (regionIndex, regionInfo) => { + let regionStart = regionInfo["start"][regionIndex]; + let regionEnd = regionInfo["end"][regionIndex]; + let regionContig = regionInfo["chr"][regionIndex]; + return regionContig + ":" + regionStart + "-" + regionEnd; +} + class HeaderForm extends Component { state = EMPTY_STATE; componentDidMount() { @@ -504,6 +592,9 @@ class HeaderForm extends Component { return regionString; }; + + + // In addition to a new region value, also takes tracks and chunk associated with the region // Update current track if the new tracks are valid // Otherwise check if the current bed file is a url, and if tracks can be fetched from said url @@ -627,12 +718,57 @@ class HeaderForm extends Component { ); } + + /* Offset the region left or right by the given negative or positive fraction*/ + // offset: +1 or -1 + jumpRegion(offset) { + let regionIndex = determineRegionIndex(this.state.region, this.state.regionInfo) ?? 0; + if ((offset === -1 && this.canGoLeft(regionIndex)) || (offset === 1 && this.canGoRight(regionIndex))){ + regionIndex += offset; + } + let regionString = regionStringFromRegionIndex(regionIndex, this.state.regionInfo); + this.setState( + (state) => ({ + region: regionString, + }), + () => this.handleGoButton() + ); + } + + canGoLeft = (regionIndex) => { + if (this.state.bedFile){ + return (regionIndex > 0); + } else { + return true; + } + } + + canGoRight = (regionIndex) => { + if (this.state.bedFile){ + if (!this.state.regionInfo["chr"]){ + return false; + } + return (regionIndex < ((this.state.regionInfo["chr"].length) - 1)); + } else { + return true; + } + } + + handleGoRight = () => { - this.budgeRegion(0.5); + if (this.state.bedFile){ + this.jumpRegion(1); + } else { + this.budgeRegion(0.5); + } }; handleGoLeft = () => { - this.budgeRegion(-0.5); + if (this.state.bedFile){ + this.jumpRegion(-1); + } else { + this.budgeRegion(-0.5); + } }; showFileSizeAlert = () => { @@ -736,9 +872,13 @@ class HeaderForm extends Component { uploadInProgress={this.state.uploadInProgress} getCurrentViewTarget={this.props.getCurrentViewTarget} viewTargetHasChange={viewTargetHasChange} + canGoLeft={this.canGoLeft(determineRegionIndex(this.state.region, this.state.regionInfo))} + canGoRight={this.canGoRight(determineRegionIndex(this.state.region, this.state.regionInfo))} /> ); + + return (
diff --git a/src/components/HeaderForm.test.js b/src/components/HeaderForm.test.js new file mode 100644 index 00000000..a3e570e3 --- /dev/null +++ b/src/components/HeaderForm.test.js @@ -0,0 +1,64 @@ +import { determineRegionIndex, regionStringFromRegionIndex } from "./HeaderForm.js"; + + +// test for determineRegionIndex and regionStringFromRegionIndex +describe("determine regionIndex and corresponding region strings for various region inputs", () => { + // TEST #1 + it("determine regionIndex and regionString for smaller regionInfo lists", async () => { + let regionInfo = { + chr: [ 'ref', 'ref' ], + start: [ '1000', '2000' ], + end: [ '2000', '3000' ], + desc: [ '17_1_100', 'ref_2000_3000' ], + chunk: [ '', '' ], + tracks: [ null, null ] + } + let regionString = "ref:2000-3000"; + let regionIndex = determineRegionIndex(regionString, regionInfo); + expect(regionIndex).toBe(1); + expect(regionStringFromRegionIndex(regionIndex, regionInfo)).toBe("ref:2000-3000"); + }) + // TEST #2 + it("determine regionIndex and regionString for larger regionInfo lists", async () => { + let regionInfo = { + chr: [ '17', 'ref', '17', 'ref', '17', 'ref' ], + start: [ '100', '200', '2000', '3000', '4000', '5000' ], + end: [ '200', '300', '3000', '4000', '5000', '6000' ], + desc: [ '17_100_200', '17_200_300', 'ref_2000_3000', 'ref_3000_4000', 'ref_4000_5000', 'ref_5000_6000' ], + chunk: [ '', '', '', '', '', '', '' ], + tracks: [ null, null, null, null, null, null ] + } + let regionString = "17:4000-5000"; + let regionIndex = determineRegionIndex(regionString, regionInfo); + expect(regionIndex).toBe(4); + expect(regionStringFromRegionIndex(regionIndex, regionInfo)).toBe("17:4000-5000"); + }) + // TEST #3 + it("determine regionIndex to be null for input of region not found in regionInfo", async () => { + let regionInfo = { + chr: [ '17', 'ref', '17', 'ref', '17', 'ref' ], + start: [ '100', '200', '2000', '3000', '4000', '5000' ], + end: [ '200', '300', '3000', '4000', '5000', '6000' ], + desc: [ '17_100_200', '17_200_300', 'ref_2000_3000', 'ref_3000_4000', 'ref_4000_5000', 'ref_5000_6000' ], + chunk: [ '', '', '', '', '', '', '' ], + tracks: [ null, null, null, null, null, null ] + } + let regionString = "17:5000-7000"; + let regionIndex = determineRegionIndex(regionString, regionInfo); + expect(regionIndex).toBe(null); + }) + // TEST #4 + it("determine regionIndex and regionString to be null given empty regionInfo", async () => { + let regionInfo = { + chr: [], + start: [], + end: [], + desc: [], + chunk: [], + tracks: [] + } + let regionString = "17:5000-7000"; + let regionIndex = determineRegionIndex(regionString, regionInfo); + expect(regionIndex).toBe(null); + }) +});