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

keplergl / kepler.gl / 25884645943

14 May 2026 08:43PM UTC coverage: 57.684% (-1.0%) from 58.684%
25884645943

push

github

web-flow
feat: basic annotations (#3434)

* feat: basic annotations

Signed-off-by: Ihor Dykhta <dikhta.igor@gmail.com>

* fixes and improvements

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

* fix annotations lag

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

* tests, lint, fixes

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

* formatting/prettier

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

* update icon from target to letters

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

* fix tests

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

* fixes

Signed-off-by: Ihor Dykhta <dikhta.igor@gmail.com>

* fix dragging

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

* fixes

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

* fixes

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

* fixes

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

* follow up

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

* fixes; follow ups

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

---------

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

7158 of 14867 branches covered (48.15%)

Branch coverage included in aggregate %.

217 of 737 new or added lines in 25 files covered. (29.44%)

70 existing lines in 2 files now uncovered.

14556 of 22776 relevant lines covered (63.91%)

77.67 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(mapState: MapState): [number, number, number, number] | null {
54
  if (!mapState.width || !mapState.height) {
×
UNCOV
55
    return null;
×
56
  }
57

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

UNCOV
68
  const corners = [
×
69
    vp.unproject([0, 0]),
70
    vp.unproject([mapState.width, 0]),
71
    vp.unproject([mapState.width, mapState.height]),
72
    vp.unproject([0, mapState.height])
73
  ];
74

UNCOV
75
  const lngs = corners.map(c => c[0]);
×
76
  const lats = corners.map(c => c[1]);
×
77

UNCOV
78
  const minLng = Math.min(...lngs);
×
79
  const maxLng = Math.max(...lngs);
×
80

UNCOV
81
  if (maxLng - minLng >= 360) {
×
UNCOV
82
    return null;
×
83
  }
84

UNCOV
85
  const clampedMinLng = Math.max(minLng, -EDGE_MERIDIAN);
×
86
  const clampedMaxLng = Math.min(maxLng, EDGE_MERIDIAN);
×
87

UNCOV
88
  if (clampedMinLng >= clampedMaxLng) {
×
UNCOV
89
    return null;
×
90
  }
91

UNCOV
92
  return [
×
93
    clampedMinLng,
94
    Math.max(Math.min(...lats), -90),
95
    clampedMaxLng,
96
    Math.min(Math.max(...lats), 90)
97
  ];
98
}
99

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

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

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

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

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

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

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

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

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

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

164
export type Results = ReadonlyArray<Result>;
165

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

182
type IntlProps = {
183
  intl: IntlShape;
184
};
185

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

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

214
  const onChange = useCallback(
1✔
215
    event => {
216
      const queryString = event.target.value;
×
217
      setInputValue(queryString);
×
218
      const resultCoordinates = testForCoordinates(queryString);
×
219
      if (resultCoordinates[0]) {
×
UNCOV
220
        if (debounceTimeout) {
×
221
          clearTimeout(debounceTimeout);
×
222
        }
223
        const [_isValid, longitude, latitude] = resultCoordinates;
×
UNCOV
224
        setShowResults(true);
×
225
        setResults([{center: [longitude, latitude], place_name: queryString}]);
×
226
      } else {
UNCOV
227
        if (debounceTimeout) {
×
228
          clearTimeout(debounceTimeout);
×
229
        }
230
        debounceTimeout = setTimeout(async () => {
×
UNCOV
231
          if (limit > 0 && Boolean(queryString)) {
×
UNCOV
232
            try {
×
233
              const geocodeParams: {
234
                query: string;
235
                limit: number;
236
                bbox?: [number, number, number, number];
NEW
237
              } = {
×
238
                query: queryString,
239
                limit
240
              };
UNCOV
241
              if (limitSearch && mapState) {
×
UNCOV
242
                const bbox = getViewportBbox(mapState);
×
243
                if (bbox) {
×
244
                  geocodeParams.bbox = bbox;
×
245
                }
246
              }
NEW
247
              const response = await client.forwardGeocode(geocodeParams).send();
×
UNCOV
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