higlass
Version:
HiGlass Hi-C / genomic / large data viewer
877 lines (738 loc) • 25.2 kB
JSX
// @ts-nocheck
import clsx from 'clsx';
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 withPubSub from './hocs/with-pub-sub';
// Services
import { tileProxy } from './services';
// Utils
import { dictKeys, scalesCenterAndK, toVoid } from './utils';
// Configs
import { THEME_DARK, ZOOM_TRANSITION_DURATION } from './configs';
// HOCS
import withTheme from './hocs/with-theme';
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.props.zoomDispatch.on('zoom.' + this.uid, this.zoomed.bind(this))
/*
this.xOrigScale = scaleLinear().domain(this.props.xDomain)
.range(this.props.xRange);
this.yOrigScale = scaleLinear().domain(this.props.yDomain)
.range(this.props.yRange);
this.zoomedXScale = this.xOrigScale.copy();
this.zoomedYScale = this.yOrigScale.copy();
*/
this.prevParts = [];
this.props.registerViewportChangedListener(this.scalesChanged.bind(this));
this.menuPosition = { left: 0, top: 0 };
// 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 = 'chr4:190,998,876-191,000,255';
this.state = {
genes: [],
isFocused: false,
autocompleteServer: this.props.autocompleteServer,
autocompleteId: this.props.autocompleteId,
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',
},
};
this.availableAutocompletes = {};
if (this.props.autocompleteId) {
this.availableAutocompletes[this.props.chromInfoId] = new Set([
{
server: this.props.autocompleteServer,
acId: this.props.autocompleteId,
},
]);
}
this.availableChromSizes = {};
/*
if (this.props.chromInfoServer && this.props.chromInfoId) {
// if we've been passed a server and chromInfo ID we trust that it exists
// and use that
this.availableChromSizes[this.props.chromInfoId] =
new Set([{server: this.props.chromInfoServer, uuid: this.props.chromInfoId} ]);
this.fetchChromInfo(this.props.chromInfoId);
}
*/
}
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.findAvailableAutocompleteSources();
this.findAvailableChromSizes();
if (this.props.chromInfoPath) {
this.searchPosition = true;
ChromosomeInfo(this.props.chromInfoPath, (chromInfo) => {
if (!chromInfo) {
this.searchPosition = null;
return;
}
this.chromInfo = chromInfo;
this.searchField = new SearchField(this.chromInfo);
this.setPositionText();
});
}
this.setPositionText();
}
componentWillUnmount() {
this.mounted = false;
this.props.removeViewportChangedListener();
}
onAutocompleteChange(evt, value) {
this.positionText = value;
this.setState({
value,
loading: true,
});
this.changedPart = null;
const spaceParts = value.split(/ /);
let partIndex = 0;
const newParts = [];
let changedAtStartOfWord = false;
for (let j = 0; j < spaceParts.length; j++) {
const parts = spaceParts[j].split(/-/);
for (let i = 0; i < parts.length; i++) {
partIndex += 1;
newParts.push(parts[i]);
if (i === 0) changedAtStartOfWord = true;
else changedAtStartOfWord = false;
if (i === this.prevParts.length) {
// new part added
this.changedPart = partIndex - 1;
break;
}
if (parts[i] !== this.prevParts[i]) {
this.changedPart = partIndex - 1;
break;
}
}
}
this.prevParts = newParts;
// no autocomplete repository is provided, so we don't try to autcomplete anything
if (!(this.state.autocompleteServer && this.state.autocompleteId)) {
return;
}
if (this.changedPart !== null) {
// if something has changed in the input text
this.setState({
loading: true,
});
// send out a request for the autcomplete suggestions
let url = `${this.state.autocompleteServer}/suggest/`;
url += `?d=${this.state.autocompleteId}&ac=${newParts[
this.changedPart
].toLowerCase()}`;
tileProxy.json(
url,
(error, data) => {
if (error) {
this.setState({
loading: false,
genes: [],
});
} else if (this.changedPart > 0 && !changedAtStartOfWord) {
// send out another request for genes with dashes in them
// but we need to distinguish things that have a dash in front
// from things that just have a space in front
const url1 =
`${this.state.autocompleteServer}/suggest/` +
`?d=${this.state.autocompleteId}` +
`&ac=${newParts[this.changedPart - 1].toLowerCase()}` +
`-${newParts[this.changedPart].toLowerCase()}`;
tileProxy.json(
url1,
(error1, data1) => {
if (error1) {
this.setState({
loading: false,
genes: data,
});
} else {
this.setState({
loading: false,
genes: data1.concat(data),
});
}
},
this.props.pubSub,
);
} else {
// we've received a list of autocomplete suggestions
this.setState({ loading: false, genes: data });
}
},
this.props.pubSub,
);
}
}
setAvailableAssemblies() {
const chromsizeKeys = new Set(dictKeys(this.availableChromSizes));
const commonKeys = new Set([...chromsizeKeys]);
if (this.gpsbForm) {
// only set the state if this comonent is mounted
this.setState({
availableAssemblies: [...commonKeys],
});
}
}
setSelectedAssembly(assemblyName) {
// component is probably about to be unmounted
if (!this.mounted) return;
if (!this.availableChromSizes[assemblyName]) return;
// we don't know of any available chromosome sizes so just ignore
// this function call (usually called from the constructor)
// use the first available server that we have on record for this chromInfoId
const serverAndChromInfoToUse = [
...this.availableChromSizes[assemblyName],
][0];
this.setState({
autocompleteServer: serverAndChromInfoToUse.server,
});
const { server } = serverAndChromInfoToUse;
// we need to set a an autocompleteId that matches the chromInfo
// that was received, but if none has been retrieved yet...
if (this.availableAutocompletes[assemblyName]) {
const newAcId = [...this.availableAutocompletes[assemblyName]][0].acId;
this.props.onSelectedAssemblyChanged(assemblyName, newAcId, server);
if (this.gpsbForm) {
this.setState({
autocompleteId: newAcId,
});
}
} else {
this.props.onSelectedAssemblyChanged(assemblyName, null, server);
if (this.gpsbForm) {
this.setState({
autocompleteId: null,
});
}
}
this.fetchChromInfo(
serverAndChromInfoToUse.uuid,
serverAndChromInfoToUse.server,
);
}
setPositionText() {
if (!this.mounted) {
return;
}
if (!this.searchField) {
return;
}
const positionString = this.searchField.scalesToPositionText(
this.xScale,
this.yScale,
this.props.twoD,
);
// ReactDOM.findDOMNode( this.refs.searchFieldText).value = positionString;
// used for autocomplete
this.prevParts = positionString.split(/[ -]/);
if (this.gpsbForm) {
this.positionText = positionString;
// this.origPositionText is used to reset the text if somebody clicks submit
// on an empty field
this.origPositionText = positionString;
this.autocompleteMenu.inputEl.value = positionString;
// this.setState({ value: positionString });
}
}
scalesChanged(xScale, yScale) {
this.xScale = xScale;
this.yScale = yScale;
// make sure that this component is loaded first
this.setPositionText();
}
findAvailableChromSizes() {
if (!this.props.trackSourceServers) {
// if we don't know where to look for track source servers then
// just give up
return;
}
this.props.trackSourceServers.forEach((sourceServer) => {
tileProxy.json(
`${sourceServer}/available-chrom-sizes/`,
(error, data) => {
if (error) {
console.error(error);
} else {
data.results.forEach((x) => {
if (!(x.coordSystem in this.availableChromSizes)) {
this.availableChromSizes[x.coordSystem] = new Set();
}
this.availableChromSizes[x.coordSystem].add({
server: sourceServer,
uuid: x.uuid,
});
this.setAvailableAssemblies();
});
// we haven't set an assembly yet so set it now
// props.chromInfoId will be set to the suggested assembly (e.g. "hg19")
// this will be mapped to an available chromSize (with its own unique uuid)
if (!this.searchField) {
// only fetch chromsizes if there isn't a specified chromInfoServer
this.fetchChromInfo(
this.props.chromInfoId in this.availableChromSizes
? [...this.availableChromSizes[this.props.chromInfoId]][0]
.uuid
: this.props.chromInfoId,
this.props.chromInfoId in this.availableChromSizes
? [...this.availableChromSizes[this.props.chromInfoId]][0]
.server
: this.props.chromInfoServer,
);
}
}
},
this.props.pubSub,
);
});
}
findAvailableAutocompleteSources() {
if (!this.props.trackSourceServers) {
// if there's no available track source servers
// we can't search for autocomplete sources
return;
}
this.props.trackSourceServers.forEach((sourceServer) => {
tileProxy.json(
`${sourceServer}/tilesets/?limit=100&dt=gene-annotation`,
(error, data) => {
if (error) {
console.error(error);
} else {
data.results.forEach((x) => {
if (!(x.coordSystem in this.availableAutocompletes)) {
this.availableAutocompletes[x.coordSystem] = new Set();
}
this.availableAutocompletes[x.coordSystem].add({
server: sourceServer,
acId: x.uuid,
});
this.setAvailableAssemblies();
});
if (!this.state.autocompleteId) {
// We don't have an autocomplete source yet, so set the one matching the current
// assembly
if (this.gpsbForm) {
// only set the state if this component is mounted
if (this.availableAutocompletes[this.props.chromInfoId]) {
this.setState({
autocompleteId: [
...this.availableAutocompletes[this.props.chromInfoId],
][0].acId,
});
}
}
}
}
},
this.props.pubSub,
);
});
}
/**
* The user has selected an assembly to use for the coordinate search box.
* Once the appropriate ChromInfo file is fetched, it is stored locally
*
* @param {string} chromInfoId The name of the chromosome info set to use
*/
fetchChromInfo(chromInfoId, server) {
ChromosomeInfo(
`${server}/chrom-sizes/?id=${chromInfoId}`,
(newChromInfo) => {
if (!newChromInfo) {
return;
}
tileProxy.json(
`${server}/tileset_info/?d=${chromInfoId}`,
(error2, tilesetInfo) => {
if (error2) {
return;
}
if (this.gpsbForm) {
// only set the state if this component is mounted
this.setState({
selectedAssembly: tilesetInfo[chromInfoId].coordSystem,
});
}
},
this.props.pubSub,
);
this.chromInfo = newChromInfo;
this.searchField = new SearchField(this.chromInfo);
this.setPositionText();
},
this.props.pubSub,
);
}
autocompleteKeyPress(event) {
const ENTER_KEY_CODE = 13;
if (event.keyCode === ENTER_KEY_CODE) {
this.buttonClick();
}
}
genePositionToSearchBarText(genePosition) {}
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(/[ -]/);
const requests = [];
for (let i = 0; i < valueParts.length; i++) {
if (valueParts[i].length === 0) {
continue;
}
const retPos = this.searchField.parsePosition(valueParts[i])[2];
if (retPos === null || Number.isNaN(+retPos)) {
// not a chromsome position, let's see if it's a gene name
const url = `${this.state.autocompleteServer}/suggest/?d=${
this.state.autocompleteId
}&ac=${valueParts[i].toLowerCase()}`;
requests.push(tileProxy.json(url, toVoid, this.props.pubSub));
}
}
Promise.all(requests)
.then((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];
}
}
const geneSymbol =
this.replaceGenesWithLoadedPositions(genePositions);
finished(geneSymbol);
}
})
.catch((error) => console.error(error));
}
buttonClick() {
this.setState({ genes: [] }); // no menu should be open
this.replaceGenesWithPositions((geneSymbol) => {
const searchFieldValue = this.positionText;
if (this.searchField !== null) {
const rangePair = this.searchField.searchPosition(searchFieldValue);
const range1 = rangePair[0];
let range2 = rangePair[1];
if (!range1) {
this.setPositionText();
return;
}
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().domain(range1);
const newYScale = this.yScale.copy().domain(range2);
const [centerX, centerY, k] = scalesCenterAndK(newXScale, newYScale);
if (geneSymbol) {
// call the callback function of the `onGeneSearch` api if a user searched for a gene symbol
this.props.onGeneSearch({
geneSymbol,
range: range1,
centerX,
centerY,
});
}
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);
}
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('-');
const geneParts = objct.geneName.split('-');
if (partCount > dashParts.length - 1) {
partCount -= dashParts.length;
} else {
dashParts[partCount] = objct.geneName;
if (
geneParts.length === 2 &&
partCount > 0 &&
dashParts[partCount - 1].toLowerCase() === geneParts[0].toLowerCase()
) {
// the gene to be added contains a dash and is
// meant to replace a part of the previous gene
// e.g. SOX2-O should be replaced with SOX2-OT
const newDashParts = dashParts.slice(0, partCount - 1);
newDashParts.push(geneParts.join('-'));
if (partCount < dashParts.length - 1) {
newDashParts.push(dashParts.slice(partCount + 1));
}
parts[i] = newDashParts.join('-');
} else {
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
className={styles['genome-position-search-bar-suggestions']}
style={{
left: this.menuPosition.left,
top: this.menuPosition.top,
}}
>
{items}
</div>
</PopupMenu>
);
}
handleAssemblySelectEvt(evt) {
this.handleAssemblySelect(evt.target.value);
}
handleAssemblySelect(assembly) {
this.setSelectedAssembly(assembly);
this.setState({
selectedAssembly: assembly,
});
}
focusHandler(isFocused) {
this.setState({
isFocused,
});
}
render() {
const assemblyMenuItems = this.state.availableAssemblies.map((x) => (
<option key={x} value={x}>
{x}
</option>
));
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,
})}
>
{!this.props.hideAvailableAssemblies && (
<select
ref={(c) => {
this.assemblyPickButton = c;
}}
className={styles['genome-position-search-bar-button']}
id={this.uid}
onChange={this.handleAssemblySelectEvt.bind(this)}
value={this.state.selectedAssembly || undefined}
>
{assemblyMenuItems}
</select>
)}
<Autocomplete
ref={(c) => {
this.autocompleteMenu = c;
}}
getItemValue={(item) => item.geneName}
inputProps={{
className: styles['genome-position-search-bar'],
title:
'Current location: enter a symbol or location to change the position of the current view',
}}
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.refseqid}
id={item.refseqid}
style={
isHighlighted ? this.styles.highlightedItem : this.styles.item
}
>
{item.geneName}
</div>
)}
renderMenu={this.handleRenderMenu.bind(this)}
value={
this.state.selectedAssembly
? this.positionText
: 'No valid assembly selected'
}
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,
chromInfoPath: PropTypes.string,
hideAvailableAssemblies: PropTypes.bool,
isFocused: PropTypes.bool,
pubSub: PropTypes.object,
onFocus: PropTypes.func,
onGeneSearch: PropTypes.func,
onSelectedAssemblyChanged: PropTypes.func,
registerViewportChangedListener: PropTypes.func,
removeViewportChangedListener: PropTypes.func,
setCenters: PropTypes.func,
theme: PropTypes.symbol.isRequired,
trackSourceServers: PropTypes.array,
twoD: PropTypes.bool,
};
export default withPubSub(withTheme(GenomePositionSearchBox));