• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

keplergl / kepler.gl / 21893551399

11 Feb 2026 05:11AM UTC coverage: 61.596% (-0.01%) from 61.606%
21893551399

Pull #3322

github

web-flow
Merge d9794c331 into e2f672cdc
Pull Request #3322: fix: geocoder coordinate search results not showing (#2245)

6379 of 12285 branches covered (51.93%)

Branch coverage included in aggregate %.

0 of 3 new or added lines in 1 file covered. (0.0%)

1 existing line in 1 file now uncovered.

13073 of 19295 relevant lines covered (67.75%)

81.45 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

40.3
/src/components/src/geocoder/geocoder.tsx
1
// SPDX-License-Identifier: MIT
2
// Copyright contributors to the kepler.gl project
3

4
import React, {useCallback, useMemo, useState} from 'react';
5
import styled from 'styled-components';
6
import classnames from 'classnames';
7
import geocoderService from '@mapbox/mapbox-sdk/services/geocoding';
8
import {injectIntl, IntlShape} from 'react-intl';
9
import {WebMercatorViewport} from 'viewport-mercator-project';
10
import {KeyEvent} from '@kepler.gl/constants';
11
import {Input} from '../common/styled-components';
12
import {Search, Delete} from '../common/icons';
13
import {Viewport} from '@kepler.gl/types';
14
import {isTest} from '@kepler.gl/utils';
15

16
type StyledContainerProps = {
17
  width?: number;
18
};
19

20
// matches only valid coordinates
21
const COORDINATE_REGEX_STRING =
22
  '(^[-+]?(?:[1-8]?\\d(?:\\.\\d+)?|90(?:\\.0+)?)),\\s*([-+]?(?:180(?:\\.0+)?|(?:(?:1[0-7]\\d)|(?:[1-9]?\\d))(?:\\.\\d+)?))$';
7✔
23

24
const COORDINATE_REGEX = RegExp(COORDINATE_REGEX_STRING);
7✔
25

26
const PLACEHOLDER = 'Enter an address or coordinates, ex 37.79,-122.40';
7✔
27

28
let debounceTimeout: NodeJS.Timeout | null = null;
7✔
29

30
/**
31
 * Tests if a given query string contains valid coordinates.
32
 * @param query The input string to test for coordinates.
33
 * @returns A tuple where:
34
 *   - If valid, returns `[true, longitude, latitude]`.
35
 *   - If invalid, returns `[false, query]`.
36
 */
37
export const testForCoordinates = (query: string): [true, number, number] | [false, string] => {
7✔
38
  const isValid = COORDINATE_REGEX.test(query.trim());
6✔
39

40
  if (!isValid) {
6✔
41
    return [isValid, query];
4✔
42
  }
43

44
  const tokens = query.trim().split(',');
2✔
45
  const latitude = Number(tokens[0]);
2✔
46
  const longitude = Number(tokens[1]);
2✔
47

48
  return [isValid, longitude, latitude];
2✔
49
};
50

51
const StyledContainer = styled.div<StyledContainerProps>`
7✔
52
  position: relative;
53
  color: ${props => props.theme.textColor};
1✔
54

55
  .geocoder-input {
56
    box-shadow: ${props => props.theme.boxShadow};
1✔
57

58
    .geocoder-input__search {
59
      position: absolute;
60
      height: ${props => props.theme.geocoderInputHeight}px;
1✔
61
      width: 30px;
62
      padding-left: 6px;
63
      display: flex;
64
      align-items: center;
65
      justify-content: center;
66
      color: ${props => props.theme.subtextColor};
1✔
67
    }
68

69
    input {
70
      padding: 4px 36px;
71
      height: ${props => props.theme.geocoderInputHeight}px;
1✔
72
      caret-color: unset;
73
    }
74
  }
75

76
  .geocoder-results {
77
    box-shadow: ${props => props.theme.boxShadow};
1✔
78
    background-color: ${props => props.theme.panelBackground};
1✔
79
    position: absolute;
80
    width: ${props => (Number.isFinite(props.width) ? props.width : props.theme.geocoderWidth)}px;
1!
81
    margin-top: ${props => props.theme.dropdownWapperMargin}px;
1✔
82
  }
83

84
  .geocoder-item {
85
    ${props => props.theme.dropdownListItem};
1✔
86
    ${props => props.theme.textTruncate};
1✔
87

88
    &.active {
89
      background-color: ${props => props.theme.dropdownListHighlightBg};
1✔
90
    }
91
  }
92

93
  .remove-result {
94
    position: absolute;
95
    right: 16px;
96
    top: 0px;
97
    height: ${props => props.theme.geocoderInputHeight}px;
1✔
98
    display: flex;
99
    align-items: center;
100

101
    &:hover {
102
      cursor: pointer;
103
      color: ${props => props.theme.textColorHl};
1✔
104
    }
105
  }
106
`;
107

108
export interface Result {
109
  center: [number, number];
110
  place_name: string;
111
  bbox?: [number, number, number, number];
112
  text?: string;
113
}
114

115
export type Results = ReadonlyArray<Result>;
116

117
type GeocoderProps = {
118
  mapboxApiAccessToken: string;
119
  className?: string;
120
  limit?: number;
121
  timeout?: number;
122
  formatItem?: (item: Result) => string;
123
  viewport?: Viewport;
124
  onSelected: (viewport: Viewport | null, item: Result) => void;
125
  onDeleteMarker?: () => void;
126
  transitionDuration?: number;
127
  pointZoom?: number;
128
  width?: number;
129
};
130

131
type IntlProps = {
132
  intl: IntlShape;
133
};
134

135
const GeoCoder: React.FC<GeocoderProps & IntlProps> = ({
7✔
136
  mapboxApiAccessToken,
137
  className = '',
1✔
138
  limit = 5,
1✔
139
  timeout = 300,
1✔
140
  formatItem = item => item.place_name,
✔
141
  viewport,
142
  onSelected,
143
  onDeleteMarker,
144
  transitionDuration,
145
  pointZoom,
146
  width,
147
  intl
148
}) => {
149
  const [inputValue, setInputValue] = useState('');
1✔
150
  const [showResults, setShowResults] = useState(false);
1✔
151
  const [showDelete, setShowDelete] = useState(false);
1✔
152
  const initialResults: Result[] = [];
1✔
153
  const [results, setResults] = useState(initialResults);
1✔
154
  const [selectedIndex, setSelectedIndex] = useState(0);
1✔
155

156
  const client = useMemo(
1✔
157
    () => (isTest() ? null : geocoderService({accessToken: mapboxApiAccessToken})),
1!
158
    [mapboxApiAccessToken]
159
  );
160

161
  const onChange = useCallback(
1✔
162
    event => {
163
      const queryString = event.target.value;
×
164
      setInputValue(queryString);
×
165
      const resultCoordinates = testForCoordinates(queryString);
×
166
      if (resultCoordinates[0]) {
×
NEW
167
        if (debounceTimeout) {
×
NEW
168
          clearTimeout(debounceTimeout);
×
169
        }
170
        const [_isValid, longitude, latitude] = resultCoordinates;
×
NEW
171
        setShowResults(true);
×
UNCOV
172
        setResults([{center: [longitude, latitude], place_name: queryString}]);
×
173
      } else {
174
        if (debounceTimeout) {
×
175
          clearTimeout(debounceTimeout);
×
176
        }
177
        debounceTimeout = setTimeout(async () => {
×
178
          if (limit > 0 && Boolean(queryString)) {
×
179
            try {
×
180
              const response = await client
×
181
                .forwardGeocode({
182
                  query: queryString,
183
                  limit
184
                })
185
                .send();
186
              if (response.body.features) {
×
187
                setShowResults(true);
×
188
                setResults(response.body.features);
×
189
              }
190
            } catch (e) {
191
              // TODO: show geocode error
192
              // eslint-disable-next-line no-console
193
              console.log(e);
×
194
            }
195
          }
196
        }, timeout);
197
      }
198
    },
199
    [client, limit, timeout, setResults, setShowResults]
200
  );
201

202
  const onBlur = useCallback(() => {
1✔
203
    setTimeout(() => {
×
204
      setShowResults(false);
×
205
    }, timeout);
206
  }, [setShowResults, timeout]);
207

208
  const onFocus = useCallback(() => setShowResults(true), [setShowResults]);
1✔
209

210
  const onItemSelected = useCallback(
1✔
211
    item => {
212
      const newViewport = new WebMercatorViewport(viewport);
×
213
      const {bbox, center} = item;
×
214

215
      const gotoViewport = bbox
×
216
        ? newViewport.fitBounds([
217
            [bbox[0], bbox[1]],
218
            [bbox[2], bbox[3]]
219
          ])
220
        : {
221
            longitude: center[0],
222
            latitude: center[1],
223
            zoom: pointZoom
224
          };
225

226
      const {longitude, latitude, zoom} = gotoViewport;
×
227

228
      onSelected({...viewport, ...{longitude, latitude, zoom, transitionDuration}}, item);
×
229

230
      setShowResults(false);
×
231
      setInputValue(formatItem(item));
×
232
      setShowDelete(true);
×
233
    },
234
    [viewport, onSelected, transitionDuration, pointZoom, formatItem]
235
  );
236

237
  const onMarkDeleted = useCallback(() => {
1✔
238
    setShowDelete(false);
×
239
    setInputValue('');
×
240
    onDeleteMarker?.();
×
241
  }, [onDeleteMarker]);
242

243
  const onKeyDown = useCallback(
1✔
244
    e => {
245
      if (!results || results.length === 0) {
×
246
        return;
×
247
      }
248
      switch (e.keyCode) {
×
249
        case KeyEvent.DOM_VK_UP:
250
          setSelectedIndex(selectedIndex > 0 ? selectedIndex - 1 : selectedIndex);
×
251
          break;
×
252
        case KeyEvent.DOM_VK_DOWN:
253
          setSelectedIndex(selectedIndex < results.length - 1 ? selectedIndex + 1 : selectedIndex);
×
254
          break;
×
255
        case KeyEvent.DOM_VK_ENTER:
256
        case KeyEvent.DOM_VK_RETURN:
257
          if (results[selectedIndex]) {
×
258
            onItemSelected(results[selectedIndex]);
×
259
          }
260
          break;
×
261
        default:
262
          break;
×
263
      }
264
    },
265
    [results, selectedIndex, setSelectedIndex, onItemSelected]
266
  );
267

268
  return (
1✔
269
    <StyledContainer className={className} width={width}>
270
      <div className="geocoder-input">
271
        <div className="geocoder-input__search">
272
          <Search height="20px" />
273
        </div>
274
        <Input
275
          type="text"
276
          onChange={onChange}
277
          onBlur={onBlur}
278
          onFocus={onFocus}
279
          onKeyDown={onKeyDown}
280
          value={inputValue}
281
          placeholder={
282
            intl
1!
283
              ? intl.formatMessage({id: 'geocoder.title', defaultMessage: PLACEHOLDER})
284
              : PLACEHOLDER
285
          }
286
        />
287
        {showDelete ? (
1!
288
          <div className="remove-result">
289
            <Delete height="16px" onClick={onMarkDeleted} />
290
          </div>
291
        ) : null}
292
      </div>
293

294
      {showResults ? (
1!
295
        <div className="geocoder-results">
296
          {results.map((item, index) => (
297
            <div
×
298
              key={index}
299
              className={classnames('geocoder-item', {active: selectedIndex === index})}
300
              onClick={() => onItemSelected(item)}
×
301
            >
302
              {formatItem(item)}
303
            </div>
304
          ))}
305
        </div>
306
      ) : null}
307
    </StyledContainer>
308
  );
309
};
310

311
export default injectIntl(GeoCoder);
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc