UNPKG

higlass

Version:

HiGlass Hi-C / genomic / large data viewer

578 lines (481 loc) 15.3 kB
// @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);