higlass
Version:
HiGlass Hi-C / genomic / large data viewer
578 lines (481 loc) • 15.3 kB
JSX
// @ts-nocheck
import clsx from 'clsx';
import { queue } from 'd3-queue';
import { select } from 'd3-selection';
import PropTypes from 'prop-types';
import React from 'react';
import slugid from 'slugid';
import Autocomplete from './Autocomplete';
import ChromosomeInfo from './ChromosomeInfo';
import PopupMenu from './PopupMenu';
import SearchField from './SearchField';
import { THEME_DARK, ZOOM_TRANSITION_DURATION } from './configs';
// Services
import { tileProxy } from './services';
import withPubSub from './hocs/with-pub-sub';
// Utils
import { scalesCenterAndK } from './utils';
import { SearchIcon } from './icons';
// Styles
import styles from '../styles/GenomePositionSearchBox.module.scss';
class GenomePositionSearchBox extends React.Component {
constructor(props) {
super(props);
this.mounted = false;
this.uid = slugid.nice();
this.chromInfo = null;
this.searchField = null;
this.autocompleteMenu = null;
this.xScale = null;
this.yScale = null;
this.prevParts = [];
this.props.registerViewportChangedListener(this.scalesChanged.bind(this));
this.menuPosition = { left: 0, top: 0 };
this.currentChromInfoServer = this.props.chromInfoServer;
this.currentChromInfoId = this.props.chromInfoId;
// the position text is maintained both here and in
// in state.value so that it can be quickly updated in
// response to zoom events
// this.positionText = 'no chromosome track present';
this.positionText = props.error;
this.state = {
value: this.positionText,
loading: false,
menuPosition: [0, 0],
genes: [],
isFocused: false,
menuOpened: false,
availableAssemblies: [],
selectedAssembly: null,
};
this.styles = {
item: {
padding: '2px 6px',
cursor: 'default',
},
highlightedItem: {
color: 'white',
background: 'hsl(200, 50%, 50%)',
padding: '2px 6px',
cursor: 'default',
},
menu: {
border: 'solid 1px #ccc',
},
};
}
componentDidMount() {
this.mounted = true;
// we want to catch keypresses so we can get that enter
select(this.autocompleteMenu.inputEl).on(
'keypress',
this.autocompleteKeyPress.bind(this),
);
this.fetchChromInfo(this.props.chromInfoServer, this.props.chromInfoId);
this.setPositionText();
}
componentWillUnmount() {
this.mounted = false;
this.props.removeViewportChangedListener();
}
/**
* The user has selected an assembly to use for the coordinate search box
*
* @param {string} chromInfoServer
* @param {string} chromInfoId - The name of the chromosome info set to use
*
* @returns {void} Once the appropriate ChromInfo file is fetched, it is stored locally
*/
fetchChromInfo(chromInfoServer, chromInfoId) {
if (!chromInfoId) {
this.positionText = 'no chromosome track present';
this.setState({
value: this.positionText,
});
return;
}
if (!this.mounted)
// component is probably about to be unmounted
return;
this.setState({
autocompleteServer: chromInfoServer,
});
ChromosomeInfo(
`${chromInfoServer}/chrom-sizes/?id=${chromInfoId}`,
(newChromInfo) => {
this.chromInfo = newChromInfo;
this.searchField = new SearchField(this.chromInfo);
this.setPositionText();
},
);
}
scalesChanged(xScale, yScale) {
this.xScale = xScale;
this.yScale = yScale;
// make sure that this component is loaded first
this.setPositionText();
}
setPositionText() {
if (!this.mounted) {
return;
}
if (!this.searchField) {
return;
}
const positionString = this.searchField.scalesToPositionText(
this.xScale,
this.yScale,
this.props.twoD,
);
// used for autocomplete
this.prevParts = positionString.split(/[ -]/);
if (this.gpsbForm) {
this.positionText = positionString;
this.autocompleteMenu.inputEl.value = positionString;
}
}
autocompleteKeyPress(event) {
const ENTER_KEY_CODE = 13;
if (event.keyCode === ENTER_KEY_CODE) {
this.buttonClick();
}
}
replaceGenesWithLoadedPositions(genePositions) {
// iterate over all non-position oriented words and try
// to replace them with the positions loaded from the suggestions
// database
const origSearchText = this.positionText;
const spaceParts = origSearchText.split(' ');
let foundGeneSymbol = false;
for (let i = 0; i < spaceParts.length; i++) {
const dashParts = spaceParts[i].split('-');
// check if this "word" is a gene symbol which can be replaced
// iterate over chunks, checking what the maximum replaceable
// unit is
let j = 0;
let k = 0;
let spacePart = '';
while (j < dashParts.length) {
k = dashParts.length;
while (k > j) {
const dashChunk = dashParts.slice(j, k).join('-');
if (genePositions[dashChunk.toLowerCase()]) {
const genePosition = genePositions[dashChunk.toLowerCase()];
const extension = Math.floor(
(genePosition.txEnd - genePosition.txStart) / 4,
);
if (j === 0 && k < dashParts.length) {
// there's more parts so this is the first part
spacePart = `${genePosition.chr}:${
genePosition.txStart - extension
}`;
} else if (j === 0 && k === dashParts.length) {
// there's only one part so this is a position
spacePart = `${genePosition.chr}:${
genePosition.txStart - extension
}-${genePosition.txEnd + extension}`;
} else {
spacePart += `- ${genePosition.chr}:${
genePosition.txEnd + extension
}`;
// it's the last part of a range
}
foundGeneSymbol = true; // we found a gene symbol
break;
}
if (k === j + 1) {
if (spacePart.length) {
spacePart += '-';
}
spacePart += dashChunk;
}
k -= 1;
}
j = k + 1;
}
spaceParts[i] = spacePart;
}
const newValue = spaceParts.join(' ');
this.prevParts = newValue.split(/[ -]/);
this.positionText = newValue;
this.setState({
value: newValue,
});
// return the original keyword that a user searched if we found a gene symbol from it
return foundGeneSymbol ? origSearchText : null;
}
replaceGenesWithPositions(finished) {
// replace any gene names in the input with their corresponding positions
const valueParts = this.positionText.split(/[ -]/);
let q = queue();
for (let i = 0; i < valueParts.length; i++) {
if (valueParts[i].length === 0) {
continue;
}
const [, , retPos] = this.searchField.parsePosition(valueParts[i]);
if (retPos == null || Number.isNaN(retPos)) {
// not a chromsome position, let's see if it's a gene name
const url = `${this.props.autocompleteServer}/suggest/?d=${
this.props.autocompleteId
}&ac=${valueParts[i].toLowerCase()}`;
const fetchJson = (callback) => {
tileProxy.json(url, callback, this.props.pubSub);
};
q = q.defer(fetchJson);
}
}
q.awaitAll((error, files) => {
if (files) {
const genePositions = {};
// extract the position of the top match from the list of files
for (let i = 0; i < files.length; i++) {
if (!files[i][0]) {
continue;
}
for (let j = 0; j < files[i].length; j++) {
genePositions[files[i][j].geneName.toLowerCase()] = files[i][j];
}
}
this.replaceGenesWithLoadedPositions(genePositions);
finished();
}
});
}
buttonClick() {
this.setState({
genes: [],
}); // no menu should be open
this.replaceGenesWithPositions(() => {
const searchFieldValue = this.positionText;
if (this.searchField != null) {
let [range1, range2] =
this.searchField.searchPosition(searchFieldValue);
if (
(range1 && (Number.isNaN(range1[0]) || Number.isNaN(range1[1]))) ||
(range2 && (Number.isNaN(range2[0]) || Number.isNaN(range2[1])))
) {
return;
}
if (!range2) {
range2 = range1;
}
const newXScale = this.xScale.copy();
const newYScale = this.yScale.copy();
// If someone doesn't enter anything in the searcbar then
// range1 will be empty. In that case, we'll just stay in the
// current position
if (range1) {
newXScale.domain(range1);
newYScale.domain(range1);
}
const [centerX, centerY, k] = scalesCenterAndK(newXScale, newYScale);
this.props.setCenters(centerX, centerY, k, ZOOM_TRANSITION_DURATION);
}
});
}
searchFieldSubmit() {
this.buttonClick();
}
pathJoin(parts, sep) {
const separator = sep || '/';
const replace = new RegExp(`${separator}{1,}`, 'g');
return parts.join(separator).replace(replace, separator);
}
onAutocompleteChange(event, value) {
this.positionText = value;
this.setState({
value,
loading: true,
});
const parts = value.split(/[ -]/);
this.changedPart = null;
for (let i = 0; i < parts.length; i++) {
if (i === this.prevParts.length) {
// new part added
this.changedPart = i;
break;
}
if (parts[i] !== this.prevParts[i]) {
this.changedPart = i;
break;
}
}
this.prevParts = parts;
// no autocomplete repository is provided, so we don't try to autcomplete anything
if (!(this.props.autocompleteServer && this.props.autocompleteId)) {
return;
}
if (this.changedPart != null) {
// if something has changed in the input text
this.setState({ loading: true });
// spend out a request for the autcomplete suggestions
const url = `${this.props.autocompleteServer}/suggest/?d=${
this.props.autocompleteId
}&ac=${parts[this.changedPart].toLowerCase()}`;
tileProxy.json(
url,
(error, data) => {
if (error) {
this.setState({
loading: false,
genes: [],
});
return;
}
// we've received a list of autocomplete suggestions
this.setState({
loading: false,
genes: data,
});
},
this.props.pubSub,
);
}
}
geneSelected(value, objct) {
const parts = this.positionText.split(' ');
let partCount = this.changedPart;
// change the part that was selected
for (let i = 0; i < parts.length; i++) {
const dashParts = parts[i].split('-');
if (partCount > dashParts.length - 1) {
partCount -= dashParts.length;
} else {
dashParts[partCount] = objct.geneName;
parts[i] = dashParts.join('-');
break;
}
}
/*
let new_dash_parts = dash_parts.slice(0, dash_parts.length-1);
new_dash_parts = new_dash_parts.concat(objct.geneName).join('-');
let new_parts = parts.splice(0, parts.length-1);
new_parts = new_parts.concat(new_dash_parts).join(' ');
*/
this.prevParts = parts.join(' ').split(/[ -]/);
this.positionText = parts.join(' ');
this.setState({
value: parts.join(' '),
genes: [],
});
}
handleMenuVisibilityChange(isOpen, inputEl) {
const box = inputEl.getBoundingClientRect();
this.menuPosition = {
left: box.left,
top: box.top + box.height,
};
this.setState({
menuOpened: isOpen,
});
}
handleRenderMenu(items) {
return (
<PopupMenu>
<div
style={{
left: this.menuPosition.left,
top: this.menuPosition.top,
}}
className={styles['genome-position-search-bar-suggestions']}
>
{items}
</div>
</PopupMenu>
);
}
handleAssemblySelect(evt) {
this.fetchChromInfo(evt);
this.setState({
selectedAssembly: evt,
});
}
focusHandler(isFocused) {
this.setState({
isFocused,
});
}
UNSAFE_componentWillReceiveProps(nextProps) {
if (
nextProps.chromInfoId !== this.currentChromInfoId ||
nextProps.chromInfoServer !== this.currentChromInfoServer
) {
this.currentChromInfoId = nextProps.chromInfoId;
this.currentChromInfoServer = nextProps.chromInfoServer;
this.fetchChromInfo(nextProps.chromInfoServer, nextProps.chromInfoId);
}
}
render() {
return (
<div
ref={(c) => {
this.gpsbForm = c;
}}
className={clsx({
[styles['genome-position-search-focus']]: this.state.isFocused,
[styles['genome-position-search']]: !this.state.isFocused,
[styles['genome-position-search-dark']]:
this.props.theme === THEME_DARK,
})}
>
<Autocomplete
ref={(c) => {
this.autocompleteMenu = c;
}}
getItemValue={(item) => item.geneName}
inputProps={{
className: styles['genome-position-search-bar'],
}}
items={this.state.genes}
menuStyle={{
position: 'absolute',
left: this.menuPosition.left,
top: this.menuPosition.top,
border: '1px solid black',
}}
onChange={this.onAutocompleteChange.bind(this)}
onFocus={this.focusHandler.bind(this)}
onMenuVisibilityChange={this.handleMenuVisibilityChange.bind(this)}
onSelect={(value, objct) => this.geneSelected(value, objct)}
onSubmit={this.searchFieldSubmit.bind(this)}
renderItem={(item, isHighlighted) => (
<div
key={item.geneName}
id={item.geneName}
style={
isHighlighted ? this.styles.highlightedItem : this.styles.item
}
>
{item.geneName}
</div>
)}
renderMenu={this.handleRenderMenu.bind(this)}
value={this.props.error || this.positionText}
wrapperStyle={{
width: '100%',
}}
/>
<SearchIcon
onClick={this.buttonClick.bind(this)}
theStyle="multitrack-header-icon"
/>
</div>
);
}
}
GenomePositionSearchBox.propTypes = {
autocompleteId: PropTypes.string,
autocompleteServer: PropTypes.string,
chromInfoId: PropTypes.string,
chromInfoServer: PropTypes.string,
isFocused: PropTypes.bool,
onFocus: PropTypes.func,
onSelectedAssemblyChanged: PropTypes.func,
registerViewportChangedListener: PropTypes.func,
removeViewportChangedListener: PropTypes.func,
setCenters: PropTypes.func,
theme: PropTypes.string,
trackSourceServers: PropTypes.array,
twoD: PropTypes.bool,
};
export default withPubSub(GenomePositionSearchBox);