UNPKG

kepler.gl

Version:

kepler.gl is a webgl based application to visualize large scale location data in the browser

273 lines (240 loc) 8.36 kB
// Copyright (c) 2021 Uber Technologies, Inc. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. import React, {useCallback, useMemo, useState} from 'react'; import styled from 'styled-components'; import classnames from 'classnames'; import MapboxClient from 'mapbox'; import {injectIntl} from 'react-intl'; import {WebMercatorViewport} from 'viewport-mercator-project'; import KeyEvent from 'constants/keyevent'; import {Input} from 'components/common/styled-components'; import {Search, Delete} from 'components/common/icons'; // matches only valid coordinates const COORDINATE_REGEX_STRING = '^[-+]?([1-8]?\\d(\\.\\d+)?|90(\\.0+)?),\\s*[-+]?(180(\\.0+)?|((1[0-7]\\d)|([1-9]?\\d))(\\.\\d+)?)'; const COORDINATE_REGEX = RegExp(COORDINATE_REGEX_STRING); const PLACEHOLDER = 'Enter an address or coordinates, ex 37.79,-122.40'; let debounceTimeout = null; export const testForCoordinates = query => { const isValid = COORDINATE_REGEX.test(query.trim()); if (!isValid) { return [isValid, query]; } const tokens = query.trim().split(','); return [isValid, Number(tokens[0]), Number(tokens[1])]; }; const StyledContainer = styled.div` position: relative; color: ${props => props.theme.textColor}; .geocoder-input { box-shadow: ${props => props.theme.boxShadow}; .geocoder-input__search { position: absolute; height: ${props => props.theme.geocoderInputHeight}px; width: 30px; padding-left: 6px; display: flex; align-items: center; justify-content: center; color: ${props => props.theme.subtextColor}; } input { padding: 4px 36px; height: ${props => props.theme.geocoderInputHeight}px; caret-color: unset; } } .geocoder-results { box-shadow: ${props => props.theme.boxShadow}; background-color: ${props => props.theme.panelBackground}; position: absolute; width: ${props => (Number.isFinite(props.width) ? props.width : props.theme.geocoderWidth)}px; margin-top: ${props => props.theme.dropdownWapperMargin}px; } .geocoder-item { ${props => props.theme.dropdownListItem}; ${props => props.theme.textTruncate}; &.active { background-color: ${props => props.theme.dropdownListHighlightBg}; } } .remove-result { position: absolute; right: 16px; top: 0px; height: ${props => props.theme.geocoderInputHeight}px; display: flex; align-items: center; :hover { cursor: pointer; color: ${props => props.theme.textColorHl}; } } `; /** @type {import('./geocoder').GeocoderComponent} */ const GeoCoder = ({ mapboxApiAccessToken, className = '', limit = 5, timeout = 300, formatItem = item => item.place_name, viewport, onSelected, onDeleteMarker, transitionDuration, pointZoom, width, intl }) => { const [inputValue, setInputValue] = useState(''); const [showResults, setShowResults] = useState(false); const [showDelete, setShowDelete] = useState(false); /** @type {import('./geocoder').Results} */ const initialResults = []; const [results, setResults] = useState(initialResults); const [selectedIndex, setSelectedIndex] = useState(0); const client = useMemo(() => new MapboxClient(mapboxApiAccessToken), [mapboxApiAccessToken]); const onChange = useCallback( event => { const queryString = event.target.value; setInputValue(queryString); const [hasValidCoordinates, longitude, latitude] = testForCoordinates(queryString); if (hasValidCoordinates) { setResults([{center: [latitude, longitude], place_name: queryString}]); } else { clearTimeout(debounceTimeout); debounceTimeout = setTimeout(async () => { if (limit > 0 && Boolean(queryString)) { try { const response = await client.geocodeForward(queryString, {limit}); if (response.entity.features) { setShowResults(true); setResults(response.entity.features); } } catch (e) { // TODO: show geocode error // eslint-disable-next-line no-console console.log(e); } } }, timeout); } }, [client, limit, timeout, setResults, setShowResults] ); const onBlur = useCallback(() => { setTimeout(() => { setShowResults(false); }, timeout); }, [setShowResults, timeout]); const onFocus = useCallback(() => setShowResults(true), [setShowResults]); const onItemSelected = useCallback( item => { let newViewport = new WebMercatorViewport(viewport); const {bbox, center} = item; newViewport = bbox ? newViewport.fitBounds([ [bbox[0], bbox[1]], [bbox[2], bbox[3]] ]) : { longitude: center[0], latitude: center[1], zoom: pointZoom }; const {longitude, latitude, zoom} = newViewport; onSelected({...viewport, ...{longitude, latitude, zoom, transitionDuration}}, item); setShowResults(false); setInputValue(formatItem(item)); setShowDelete(true); }, [viewport, onSelected, transitionDuration, pointZoom, formatItem] ); const onMarkDeleted = useCallback(() => { setShowDelete(false); setInputValue(''); onDeleteMarker(); }, [onDeleteMarker]); const onKeyDown = useCallback( e => { if (!results || results.length === 0) { return; } switch (e.keyCode) { case KeyEvent.DOM_VK_UP: setSelectedIndex(selectedIndex > 0 ? selectedIndex - 1 : selectedIndex); break; case KeyEvent.DOM_VK_DOWN: setSelectedIndex(selectedIndex < results.length - 1 ? selectedIndex + 1 : selectedIndex); break; case KeyEvent.DOM_VK_ENTER: case KeyEvent.DOM_VK_RETURN: if (results[selectedIndex]) { onItemSelected(results[selectedIndex]); } break; default: break; } }, [results, selectedIndex, setSelectedIndex, onItemSelected] ); return ( <StyledContainer className={className} width={width}> <div className="geocoder-input"> <div className="geocoder-input__search"> <Search height="20px" /> </div> <Input type="text" onChange={onChange} onBlur={onBlur} onFocus={onFocus} onKeyDown={onKeyDown} value={inputValue} placeholder={ intl ? intl.formatMessage({id: 'geocoder.title', defaultMessage: PLACEHOLDER}) : PLACEHOLDER } /> {showDelete ? ( <div className="remove-result"> <Delete height="12px" onClick={onMarkDeleted} /> </div> ) : null} </div> {showResults ? ( <div className="geocoder-results"> {results.map((item, index) => ( <div key={index} className={classnames('geocoder-item', {active: selectedIndex === index})} onClick={() => onItemSelected(item)} > {formatItem(item)} </div> ))} </div> ) : null} </StyledContainer> ); }; export default injectIntl(GeoCoder);