UNPKG

higlass

Version:

HiGlass Hi-C / genomic / large data viewer

877 lines (738 loc) 25.2 kB
// @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));