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

keplergl / kepler.gl / 25285671777

03 May 2026 05:19PM UTC coverage: 59.07% (-0.06%) from 59.129%
25285671777

push

github

web-flow
feat: limit geocoder search area to map viewport (#3405)

* feat: limit geocoder search area to map viewport

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* fix tests

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* fix for pitch and bearing

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* fix tests

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* minor follow up

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

---------

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>
Co-authored-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

6932 of 14100 branches covered (49.16%)

Branch coverage included in aggregate %.

11 of 35 new or added lines in 4 files covered. (31.43%)

7 existing lines in 1 file now uncovered.

14287 of 21822 relevant lines covered (65.47%)

79.85 hits per line

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

31.61
/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, MapState} 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 EDGE_MERIDIAN = 180;
7✔
52

53
export function getViewportBbox(
NEW
54
  mapState: MapState
×
NEW
55
): [number, number, number, number] | null {
×
56
  if (!mapState.width || !mapState.height) {
57
    return null;
NEW
58
  }
×
59

60
  const vp = new WebMercatorViewport({
61
    width: mapState.width,
62
    height: mapState.height,
63
    longitude: mapState.longitude,
64
    latitude: mapState.latitude,
×
65
    zoom: mapState.zoom,
×
66
    bearing: mapState.bearing ?? 0,
67
    pitch: mapState.pitch ?? 0
NEW
68
  });
×
69

70
  const corners = [
71
    vp.unproject([0, 0]),
72
    vp.unproject([mapState.width, 0]),
73
    vp.unproject([mapState.width, mapState.height]),
74
    vp.unproject([0, mapState.height])
NEW
75
  ];
×
NEW
76

×
77
  const lngs = corners.map(c => c[0]);
NEW
78
  const lats = corners.map(c => c[1]);
×
NEW
79

×
80
  const minLng = Math.min(...lngs);
NEW
81
  const maxLng = Math.max(...lngs);
×
NEW
82

×
83
  if (maxLng - minLng >= 360) {
84
    return null;
NEW
85
  }
×
NEW
86

×
87
  const clampedMinLng = Math.max(minLng, -EDGE_MERIDIAN);
NEW
88
  const clampedMaxLng = Math.min(maxLng, EDGE_MERIDIAN);
×
NEW
89

×
90
  if (clampedMinLng >= clampedMaxLng) {
91
    return null;
NEW
92
  }
×
93

94
  return [
95
    clampedMinLng,
96
    Math.max(Math.min(...lats), -90),
97
    clampedMaxLng,
98
    Math.min(Math.max(...lats), 90)
99
  ];
100
}
7✔
101

102
const StyledContainer = styled.div<StyledContainerProps>`
1✔
103
  position: relative;
104
  color: ${props => props.theme.textColor};
105

1✔
106
  .geocoder-input {
107
    box-shadow: ${props => props.theme.boxShadow};
108

109
    .geocoder-input__search {
1✔
110
      position: absolute;
111
      height: ${props => props.theme.geocoderInputHeight}px;
112
      width: 30px;
113
      padding-left: 6px;
114
      display: flex;
115
      align-items: center;
1✔
116
      justify-content: center;
117
      color: ${props => props.theme.subtextColor};
118
    }
119

120
    input {
1✔
121
      padding: 4px 36px;
122
      height: ${props => props.theme.geocoderInputHeight}px;
123
      caret-color: unset;
124
    }
125
  }
126

1✔
127
  .geocoder-results {
1✔
128
    box-shadow: ${props => props.theme.boxShadow};
129
    background-color: ${props => props.theme.panelBackground};
1!
130
    position: absolute;
1✔
131
    width: ${props => (Number.isFinite(props.width) ? props.width : props.theme.geocoderWidth)}px;
132
    margin-top: ${props => props.theme.dropdownWapperMargin}px;
133
  }
134

1✔
135
  .geocoder-item {
1✔
136
    ${props => props.theme.dropdownListItem};
137
    ${props => props.theme.textTruncate};
138

1✔
139
    &.active {
140
      background-color: ${props => props.theme.dropdownListHighlightBg};
141
    }
142
  }
143

144
  .remove-result {
145
    position: absolute;
146
    right: 16px;
1✔
147
    top: 0px;
148
    height: ${props => props.theme.geocoderInputHeight}px;
149
    display: flex;
150
    align-items: center;
151

152
    &:hover {
1✔
153
      cursor: pointer;
154
      color: ${props => props.theme.textColorHl};
155
    }
156
  }
157
`;
158

159
export interface Result {
160
  center: [number, number];
161
  place_name: string;
162
  bbox?: [number, number, number, number];
163
  text?: string;
164
}
165

166
export type Results = ReadonlyArray<Result>;
167

168
type GeocoderProps = {
169
  mapboxApiAccessToken: string;
170
  className?: string;
171
  limit?: number;
172
  timeout?: number;
173
  formatItem?: (item: Result) => string;
174
  viewport?: Viewport;
175
  mapState?: MapState;
176
  limitSearch?: boolean;
177
  onSelected: (viewport: Viewport | null, item: Result) => void;
178
  onDeleteMarker?: () => void;
179
  transitionDuration?: number;
180
  pointZoom?: number;
181
  width?: number;
182
};
183

184
type IntlProps = {
185
  intl: IntlShape;
186
};
7✔
187

188
const GeoCoder: React.FC<GeocoderProps & IntlProps> = ({
1✔
189
  mapboxApiAccessToken,
1✔
190
  className = '',
1✔
UNCOV
191
  limit = 5,
✔
192
  timeout = 300,
193
  formatItem = item => item.place_name,
194
  viewport,
×
195
  mapState,
196
  limitSearch = false,
197
  onSelected,
198
  onDeleteMarker,
199
  transitionDuration,
200
  pointZoom,
201
  width,
202
  intl
1✔
203
}) => {
1✔
204
  const [inputValue, setInputValue] = useState('');
1✔
205
  const [showResults, setShowResults] = useState(false);
1✔
206
  const [showDelete, setShowDelete] = useState(false);
1✔
207
  const initialResults: Result[] = [];
1✔
208
  const [results, setResults] = useState(initialResults);
209
  const [selectedIndex, setSelectedIndex] = useState(0);
1✔
210

1!
211
  const client = useMemo(
212
    () => (isTest() ? null : geocoderService({accessToken: mapboxApiAccessToken})),
213
    [mapboxApiAccessToken]
214
  );
1✔
215

UNCOV
216
  const onChange = useCallback(
×
UNCOV
217
    event => {
×
218
      const queryString = event.target.value;
×
219
      setInputValue(queryString);
×
220
      const resultCoordinates = testForCoordinates(queryString);
×
221
      if (resultCoordinates[0]) {
×
222
        if (debounceTimeout) {
223
          clearTimeout(debounceTimeout);
×
UNCOV
224
        }
×
225
        const [_isValid, longitude, latitude] = resultCoordinates;
×
226
        setShowResults(true);
227
        setResults([{center: [longitude, latitude], place_name: queryString}]);
×
UNCOV
228
      } else {
×
229
        if (debounceTimeout) {
230
          clearTimeout(debounceTimeout);
×
UNCOV
231
        }
×
232
        debounceTimeout = setTimeout(async () => {
×
233
          if (limit > 0 && Boolean(queryString)) {
234
            try {
235
              const geocodeParams: {query: string; limit: number; bbox?: [number, number, number, number]} = {
236
                query: queryString,
NEW
237
                limit
×
238
              };
239
              if (limitSearch && mapState) {
240
                const bbox = getViewportBbox(mapState);
NEW
241
                if (bbox) {
×
NEW
242
                  geocodeParams.bbox = bbox;
×
NEW
243
                }
×
NEW
244
              }
×
245
              const response = await client
246
                .forwardGeocode(geocodeParams)
UNCOV
247
                .send();
×
248
              if (response.body.features) {
×
249
                setShowResults(true);
×
250
                setResults(response.body.features);
×
251
              }
252
            } catch (e) {
253
              // TODO: show geocode error
254
              // eslint-disable-next-line no-console
255
              console.log(e);
×
256
            }
257
          }
258
        }, timeout);
259
      }
260
    },
261
    [client, limit, timeout, setResults, setShowResults, limitSearch, mapState]
262
  );
263

264
  const onBlur = useCallback(() => {
1✔
265
    setTimeout(() => {
×
266
      setShowResults(false);
×
267
    }, timeout);
268
  }, [setShowResults, timeout]);
269

270
  const onFocus = useCallback(() => setShowResults(true), [setShowResults]);
1✔
271

272
  const onItemSelected = useCallback(
1✔
273
    item => {
274
      const newViewport = new WebMercatorViewport(viewport);
×
275
      const {bbox, center} = item;
×
276

277
      const gotoViewport = bbox
×
278
        ? newViewport.fitBounds([
279
            [bbox[0], bbox[1]],
280
            [bbox[2], bbox[3]]
281
          ])
282
        : {
283
            longitude: center[0],
284
            latitude: center[1],
285
            zoom: pointZoom
286
          };
287

288
      const {longitude, latitude, zoom} = gotoViewport;
×
289

290
      onSelected({...viewport, ...{longitude, latitude, zoom, transitionDuration}}, item);
×
291

292
      setShowResults(false);
×
293
      setInputValue(formatItem(item));
×
294
      setShowDelete(true);
×
295
    },
296
    [viewport, onSelected, transitionDuration, pointZoom, formatItem]
297
  );
298

299
  const onMarkDeleted = useCallback(() => {
1✔
300
    setShowDelete(false);
×
301
    setInputValue('');
×
302
    onDeleteMarker?.();
×
303
  }, [onDeleteMarker]);
304

305
  const onKeyDown = useCallback(
1✔
306
    e => {
307
      if (!results || results.length === 0) {
×
308
        return;
×
309
      }
310
      switch (e.keyCode) {
×
311
        case KeyEvent.DOM_VK_UP:
312
          setSelectedIndex(selectedIndex > 0 ? selectedIndex - 1 : selectedIndex);
×
313
          break;
×
314
        case KeyEvent.DOM_VK_DOWN:
315
          setSelectedIndex(selectedIndex < results.length - 1 ? selectedIndex + 1 : selectedIndex);
×
316
          break;
×
317
        case KeyEvent.DOM_VK_ENTER:
318
        case KeyEvent.DOM_VK_RETURN:
319
          if (results[selectedIndex]) {
×
320
            onItemSelected(results[selectedIndex]);
×
321
          }
322
          break;
×
323
        default:
324
          break;
×
325
      }
326
    },
327
    [results, selectedIndex, setSelectedIndex, onItemSelected]
328
  );
329

330
  return (
1✔
331
    <StyledContainer className={className} width={width}>
332
      <div className="geocoder-input">
333
        <div className="geocoder-input__search">
334
          <Search height="20px" />
335
        </div>
336
        <Input
337
          type="text"
338
          onChange={onChange}
339
          onBlur={onBlur}
340
          onFocus={onFocus}
341
          onKeyDown={onKeyDown}
342
          value={inputValue}
343
          placeholder={
344
            intl
1!
345
              ? intl.formatMessage({id: 'geocoder.title', defaultMessage: PLACEHOLDER})
346
              : PLACEHOLDER
347
          }
348
        />
349
        {showDelete ? (
1!
350
          <div className="remove-result">
351
            <Delete height="16px" onClick={onMarkDeleted} />
352
          </div>
353
        ) : null}
354
      </div>
355

356
      {showResults ? (
1!
357
        <div className="geocoder-results">
358
          {results.map((item, index) => (
359
            <div
×
360
              key={index}
361
              className={classnames('geocoder-item', {active: selectedIndex === index})}
362
              onClick={() => onItemSelected(item)}
×
363
            >
364
              {formatItem(item)}
365
            </div>
366
          ))}
367
        </div>
368
      ) : null}
369
    </StyledContainer>
370
  );
371
};
372

373
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