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

keplergl / kepler.gl / 25830234009

13 May 2026 10:31PM UTC coverage: 57.668% (-1.0%) from 58.644%
25830234009

Pull #3434

github

web-flow
Merge b10a56eba into b4790f0f5
Pull Request #3434: feat: basic annotations

7143 of 14848 branches covered (48.11%)

Branch coverage included in aggregate %.

216 of 732 new or added lines in 25 files covered. (29.51%)

74 existing lines in 3 files now uncovered.

14551 of 22771 relevant lines covered (63.9%)

77.31 hits per line

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

18.32
/src/components/src/annotations/annotation-panel.tsx
1
// SPDX-License-Identifier: MIT
2
// Copyright contributors to the kepler.gl project
3

4
import React, {useCallback} from 'react';
5
import classnames from 'classnames';
6
import styled from 'styled-components';
7
import {injectIntl, IntlShape} from 'react-intl';
8
import {
9
  addAnnotation,
10
  removeAnnotation,
11
  updateAnnotation,
12
  duplicateAnnotation,
13
  setSelectedAnnotation,
14
  ActionHandler
15
} from '@kepler.gl/actions';
16
import {visStateLens, mapStateLens} from '@kepler.gl/reducers';
17
import {Annotation} from '@kepler.gl/types';
18
import {
19
  AnnotationKind,
20
  ANNOTATION_KINDS,
21
  ANNOTATION_LINE_WIDTH_OPTIONS
22
} from '@kepler.gl/constants';
23
import {VisState} from '@kepler.gl/schemas';
24
import {MapState} from '@kepler.gl/types';
25

26
import {withState} from '../injector';
27
import {Add, ArrowDown, ArrowDownSmall, Copy, EyeSeen, EyeUnseen, Trash} from '../common/icons';
28
import {StyledPanelHeader, Tooltip, Button} from '../common/styled-components';
29
import Portaled from '../common/portaled';
30
import SingleColorPalette from '../side-panel/layer-panel/single-color-palette';
31
import {hexToRgb, rgbToHex} from '@kepler.gl/utils';
32
import {FormattedMessage} from '@kepler.gl/localization';
33

34
// Styled components
35

36
const StyledAnnotationPanelContainer = styled.div`
7✔
37
  display: flex;
38
  flex-direction: column;
39
  pointer-events: none !important;
40
  flex-grow: 1;
41
  justify-content: space-between;
42
  overflow: hidden;
43

44
  & > * {
45
    pointer-events: all;
46
  }
47
`;
48

49
const StyledAnnotationPanel = styled.div`
7✔
50
  top: 0;
NEW
51
  background-color: ${props => props.theme.sidePanelBg};
×
52
  display: flex;
53
  flex-direction: column;
54
  flex-grow: 1;
55
  overflow: hidden;
56
`;
57

58
const StyledAnnotationPanelHeader = styled.div`
7✔
59
  padding: ${({theme}) =>
NEW
60
    `${theme.effectPanelPaddingTop || 8}px ${theme.effectPanelPaddingSide || 16}px 4px ${
×
61
      theme.effectPanelPaddingSide || 16
×
62
    }px`};
NEW
63
  border-bottom: 1px solid ${props => props.theme.borderColor};
×
NEW
64
  min-width: ${({theme}) => theme.effectPanelWidth}px;
×
65
`;
66

67
const StyledPanelHeaderRow = styled.div`
7✔
68
  display: flex;
69
  justify-content: space-between;
70
  align-items: center;
71
  margin-bottom: 8px;
72
`;
73

74
const StyledPanelTitle = styled.div`
7✔
NEW
75
  color: ${props => props.theme.titleTextColor};
×
NEW
76
  font-size: ${props => props.theme.sidePanelTitleFontsize};
×
NEW
77
  line-height: ${props => props.theme.sidePanelTitleLineHeight};
×
78
  font-weight: 400;
79
  letter-spacing: 1.25px;
80
`;
81

82
const StyledAddButton = styled.div`
7✔
83
  display: flex;
84
  align-items: center;
NEW
85
  height: ${props => props.theme.inputBoxHeight};
×
86
  width: 100px;
87
  padding: 4px 10px;
NEW
88
  background-color: ${props => props.theme.secondaryBtnBgd};
×
NEW
89
  border-radius: ${props => props.theme.primaryBtnRadius};
×
NEW
90
  font-size: ${props => props.theme.primaryBtnFontSizeDefault};
×
NEW
91
  color: ${props => props.theme.secondaryBtnActColor};
×
92
  cursor: pointer;
93
  font-weight: 500;
94
  letter-spacing: 0.3px;
95
  border: none;
96
  &:hover {
NEW
97
    background-color: ${props => props.theme.secondaryBtnBgdHover};
×
98
  }
99
`;
100

101
const StyledAddIcon = styled(Add)`
7✔
102
  margin-right: 8px;
103
  height: 16px;
104
`;
105

106
const StyledAnnotationPanelContent = styled.div`
7✔
NEW
107
  ${props => props.theme.sidePanelScrollBar};
×
108
  padding: 10px 0;
109
  overflow-y: auto;
110
  display: flex;
111
  flex-direction: column;
112
  flex-grow: 1;
113
`;
114

115
const StyledAnnotationItemWrapper = styled.div`
7✔
116
  font-size: 12px;
117
  border-radius: 1px;
118
  margin: 3px 16px;
119
`;
120

121
const StyledAnnotationItemHeader = styled(StyledPanelHeader)`
7✔
NEW
122
  height: ${props => props.theme.effectPanelHeaderHeight}px;
×
123
  position: relative;
124
  align-items: stretch;
125

126
  &:hover {
127
    cursor: pointer;
NEW
128
    background-color: ${props => props.theme.panelBackgroundHover};
×
129
  }
130

NEW
131
  border-left: 3px solid ${props => props.theme.panelBackgroundHover};
×
132
`;
133

134
const HeaderLabelSection = styled.div`
7✔
135
  margin-left: 10px;
136
  flex-grow: 1;
137
  display: flex;
138
  flex-direction: column;
139
  justify-content: center;
140
  overflow: hidden;
141
`;
142

143
const StyledAnnotationLabel = styled.div`
7✔
144
  font-size: 12px;
NEW
145
  color: ${props => props.theme.textColor};
×
146
  overflow: hidden;
147
  text-overflow: ellipsis;
148
  white-space: nowrap;
149
`;
150

151
const StyledAnnotationKind = styled.div`
7✔
152
  font-size: 10px;
NEW
153
  color: ${props => props.theme.subtextColor};
×
154
  margin-top: 2px;
155
`;
156

157
const HeaderActionSection = styled.div`
7✔
158
  display: flex;
159
  position: absolute;
160
  height: 100%;
161
  align-items: stretch;
162
  right: 10px;
163
  &:hover {
164
    .annotation-panel__header__actions__hidden {
165
      opacity: 1;
NEW
166
      background-color: ${props => props.theme.panelBackgroundHover};
×
167
    }
168
  }
169
`;
170

171
type StyledHiddenActionsProps = {$isConfigActive: boolean};
172
const StyledPanelHeaderHiddenActions = styled.div.attrs({
7✔
173
  className: 'annotation-panel__header__actions__hidden'
174
})<StyledHiddenActionsProps>`
175
  opacity: 0;
176
  display: flex;
177
  align-items: center;
178
  transition: opacity 0.4s ease, background-color 0.4s ease;
179
  background-color: ${props =>
NEW
180
    props.$isConfigActive ? props.theme.panelBackgroundHover : props.theme.panelBackground};
×
181

182
  &:hover {
183
    opacity: 1;
184
  }
185
`;
186

187
const HeaderActionWrapper = styled.div<{$active?: boolean; $hoverColor?: string}>`
7✔
188
  margin-left: 8px;
189
  display: flex;
190
  align-items: center;
191
  color: ${props =>
NEW
192
    props.$active ? props.theme.panelHeaderIconActive : props.theme.panelHeaderIcon};
×
193
  cursor: pointer;
194

195
  &:hover {
196
    color: ${props =>
NEW
197
      props.$hoverColor ? props.theme[props.$hoverColor] : props.theme.panelHeaderIconHover};
×
198
  }
199
`;
200

201
const StyledConfigSection = styled.div`
7✔
202
  position: relative;
NEW
203
  margin: ${props => props.theme.effectConfiguratorMargin};
×
NEW
204
  padding: ${props => props.theme.effectConfiguratorPadding};
×
205
`;
206

207
const StyledConfigRow = styled.div`
7✔
208
  display: flex;
209
  justify-content: space-between;
210
  align-items: center;
211
  margin-bottom: 9px;
212
`;
213

214
const StyledConfigLabel = styled.div`
7✔
NEW
215
  font-size: ${props => props.theme.inputFontSize};
×
NEW
216
  color: ${props => props.theme.effectPanelTextSecondary1};
×
217
  text-transform: capitalize;
218
`;
219

220
const StyledSelect = styled.select`
7✔
NEW
221
  background: ${props => props.theme.inputBgd};
×
NEW
222
  color: ${props => props.theme.inputColor};
×
NEW
223
  border: 1px solid ${props => props.theme.inputBorderColor || 'transparent'};
×
224
  border-radius: 4px;
225
  padding: 4px 8px;
226
  font-size: 12px;
227
  cursor: pointer;
228
  width: 100px;
229
`;
230

231
const StyledColorButtonWrapper = styled.div`
7✔
232
  width: 100px;
233
  .button {
NEW
234
    color: ${props => props.theme.effectPanelTextSecondary2};
×
235
    display: flex;
236
    gap: 5px;
237
    border: none;
238
    transition: background 0.2s;
NEW
239
    background-color: ${props => props.theme.inputBgd};
×
240
    padding: 8px 5px 8px 10px;
241
    &:active {
NEW
242
      color: ${props => props.theme.effectPanelTextMain};
×
NEW
243
      background-color: ${props => props.theme.inputBgdHover};
×
244
    }
245
    &:hover {
NEW
246
      color: ${props => props.theme.effectPanelTextMain};
×
NEW
247
      background-color: ${props => props.theme.inputBgdHover};
×
248
    }
249
    & > svg {
250
      margin-right: 0;
251
    }
252
  }
253
`;
254

255
const StyledColorDropdown = styled.div`
7✔
NEW
256
  ${props => props.theme.panelDropdownScrollBar}
×
NEW
257
  background-color: ${props => props.theme.panelBackground};
×
NEW
258
  box-shadow: ${props => props.theme.panelBoxShadow};
×
NEW
259
  border-radius: ${props => props.theme.panelBorderRadius};
×
260
  overflow-y: auto;
261
  max-height: 500px;
262
  position: relative;
263
  z-index: 999;
264
  width: 220px;
265
`;
266

267
const InlineColorPicker: React.FC<{
268
  color: [number, number, number];
269
  onSetColor: (value: [number, number, number]) => void;
270
}> = ({color, onSetColor}) => {
7✔
NEW
271
  const [isOpen, setIsOpen] = React.useState(false);
×
NEW
272
  const hexColor = React.useMemo(() => rgbToHex(color), [color]);
×
NEW
273
  const colorBlockStyle = React.useMemo(
×
NEW
274
    () => ({width: 16, height: 16, backgroundColor: hexColor, borderRadius: 2}),
×
275
    [hexColor]
276
  );
NEW
277
  const handleSelectColor = React.useCallback(
×
278
    (v: any) => {
NEW
279
      onSetColor(v);
×
280
    },
281
    [onSetColor]
282
  );
283

NEW
284
  return (
×
285
    <StyledColorButtonWrapper>
NEW
286
      <Button onClick={() => setIsOpen(!isOpen)}>
×
287
        <div style={colorBlockStyle} />
288
        <ArrowDownSmall />
289
      </Button>
NEW
290
      <Portaled top={0} left={0} isOpened={isOpen} onClose={() => setIsOpen(false)}>
×
291
        <StyledColorDropdown>
292
          <SingleColorPalette selectedColor={hexColor} onSelectColor={handleSelectColor} />
293
        </StyledColorDropdown>
294
      </Portaled>
295
    </StyledColorButtonWrapper>
296
  );
297
};
298

299
// Types
300
export type AnnotationManagerState = {
301
  visState: VisState;
302
  mapState: MapState;
303
  visStateActions: {
304
    addAnnotation: ActionHandler<typeof addAnnotation>;
305
    removeAnnotation: ActionHandler<typeof removeAnnotation>;
306
    updateAnnotation: ActionHandler<typeof updateAnnotation>;
307
    duplicateAnnotation: ActionHandler<typeof duplicateAnnotation>;
308
    setSelectedAnnotation: ActionHandler<typeof setSelectedAnnotation>;
309
  };
310
};
311

312
export type AnnotationManagerProps = {
313
  intl: IntlShape;
314
} & AnnotationManagerState;
315

316
AnnotationManagerFactory.deps = [];
7✔
317

318
export default function AnnotationManagerFactory(): React.FC<any> {
NEW
319
  const AnnotationManager: React.FC<AnnotationManagerProps> = ({
×
320
    intl,
321
    visState,
322
    mapState,
323
    visStateActions
324
  }) => {
NEW
325
    const {annotations, selectedAnnotationId} = visState;
×
326

NEW
327
    const handleAddAnnotation = useCallback(() => {
×
NEW
328
      visStateActions.addAnnotation({
×
329
        anchorPoint: [mapState.longitude, mapState.latitude]
330
      });
331
    }, [visStateActions, mapState.longitude, mapState.latitude]);
332

NEW
333
    const handleSelectAnnotation = useCallback(
×
334
      (id: string) => {
NEW
335
        visStateActions.setSelectedAnnotation(id === selectedAnnotationId ? null : id, false);
×
336
      },
337
      [visStateActions, selectedAnnotationId]
338
    );
339

NEW
340
    const handleRemoveAnnotation = useCallback(
×
341
      (e: React.MouseEvent, id: string) => {
NEW
342
        e.stopPropagation();
×
NEW
343
        visStateActions.removeAnnotation(id);
×
344
      },
345
      [visStateActions]
346
    );
347

NEW
348
    const handleDuplicateAnnotation = useCallback(
×
349
      (e: React.MouseEvent, id: string) => {
NEW
350
        e.stopPropagation();
×
NEW
351
        visStateActions.duplicateAnnotation(id);
×
352
      },
353
      [visStateActions]
354
    );
355

NEW
356
    const handleToggleVisibility = useCallback(
×
357
      (e: React.MouseEvent, annotation: Annotation) => {
NEW
358
        e.stopPropagation();
×
NEW
359
        visStateActions.updateAnnotation(annotation.id, {isVisible: !annotation.isVisible});
×
360
      },
361
      [visStateActions]
362
    );
363

NEW
364
    const handleChangeKind = useCallback(
×
365
      (id: string, kind: AnnotationKind) => {
NEW
366
        visStateActions.updateAnnotation(id, {kind});
×
367
      },
368
      [visStateActions]
369
    );
370

NEW
371
    const handleChangeLineWidth = useCallback(
×
372
      (id: string, lineWidth: number) => {
NEW
373
        visStateActions.updateAnnotation(id, {lineWidth});
×
374
      },
375
      [visStateActions]
376
    );
377

NEW
378
    const handleChangeLineColor = useCallback(
×
379
      (id: string, rgb: [number, number, number]) => {
NEW
380
        const lineColor = rgbToHex(rgb);
×
NEW
381
        visStateActions.updateAnnotation(id, {lineColor});
×
382
      },
383
      [visStateActions]
384
    );
385

NEW
386
    return (
×
387
      <StyledAnnotationPanelContainer className="annotation-manager">
388
        <StyledAnnotationPanel>
389
          <StyledAnnotationPanelHeader>
390
            <StyledPanelHeaderRow>
391
              <StyledPanelTitle>
392
                {intl.formatMessage({id: 'annotationManager.title', defaultMessage: 'Annotations'})}
393
              </StyledPanelTitle>
394
              <StyledAddButton onClick={handleAddAnnotation}>
395
                <StyledAddIcon />
396
                {intl.formatMessage({id: 'annotationManager.addAnnotation', defaultMessage: 'Add'})}
397
              </StyledAddButton>
398
            </StyledPanelHeaderRow>
399
          </StyledAnnotationPanelHeader>
400
          <StyledAnnotationPanelContent>
401
            {annotations.map(annotation => {
NEW
402
              const isSelected = annotation.id === selectedAnnotationId;
×
NEW
403
              return (
×
404
                <StyledAnnotationItemWrapper key={annotation.id}>
405
                  <StyledAnnotationItemHeader
406
                    active={isSelected}
NEW
407
                    onClick={() => handleSelectAnnotation(annotation.id)}
×
408
                  >
409
                    <HeaderLabelSection>
410
                      <StyledAnnotationLabel>{annotation.label}</StyledAnnotationLabel>
411
                      <StyledAnnotationKind>{annotation.kind}</StyledAnnotationKind>
412
                    </HeaderLabelSection>
413

414
                    <HeaderActionSection className="annotation-panel__header__actions">
415
                      <StyledPanelHeaderHiddenActions $isConfigActive={isSelected}>
416
                        <HeaderActionWrapper
417
                          $hoverColor="negative"
418
                          data-tip
419
                          data-for={`remove-annotation_${annotation.id}`}
NEW
420
                          onClick={e => handleRemoveAnnotation(e, annotation.id)}
×
421
                        >
422
                          <Trash height="16px" />
423
                        </HeaderActionWrapper>
424
                        <Tooltip
425
                          id={`remove-annotation_${annotation.id}`}
426
                          effect="solid"
427
                          delayShow={500}
428
                          type="error"
429
                        >
430
                          <span>
431
                            <FormattedMessage id="tooltip.removeAnnotation" />
432
                          </span>
433
                        </Tooltip>
434

435
                        <HeaderActionWrapper
436
                          data-tip
437
                          data-for={`duplicate-annotation_${annotation.id}`}
NEW
438
                          onClick={e => handleDuplicateAnnotation(e, annotation.id)}
×
439
                        >
440
                          <Copy height="16px" />
441
                        </HeaderActionWrapper>
442
                        <Tooltip
443
                          id={`duplicate-annotation_${annotation.id}`}
444
                          effect="solid"
445
                          delayShow={500}
446
                        >
447
                          <span>
448
                            <FormattedMessage id="tooltip.duplicateAnnotation" />
449
                          </span>
450
                        </Tooltip>
451
                      </StyledPanelHeaderHiddenActions>
452

453
                      <HeaderActionWrapper
454
                        data-tip
455
                        data-for={`visibility-annotation_${annotation.id}`}
NEW
456
                        onClick={e => handleToggleVisibility(e, annotation)}
×
457
                      >
458
                        {annotation.isVisible ? (
×
459
                          <EyeSeen height="16px" />
460
                        ) : (
461
                          <EyeUnseen height="16px" />
462
                        )}
463
                      </HeaderActionWrapper>
464
                      <Tooltip
465
                        id={`visibility-annotation_${annotation.id}`}
466
                        effect="solid"
467
                        delayShow={500}
468
                      >
469
                        <span>
470
                          <FormattedMessage
471
                            id={
472
                              annotation.isVisible
×
473
                                ? 'tooltip.hideAnnotation'
474
                                : 'tooltip.showAnnotation'
475
                            }
476
                          />
477
                        </span>
478
                      </Tooltip>
479

480
                      <HeaderActionWrapper
481
                        $active={isSelected}
482
                        className={classnames('annotation__enable-config', {
483
                          'is-open': isSelected
484
                        })}
485
                        data-tip
486
                        data-for={`config-annotation_${annotation.id}`}
NEW
487
                        onClick={() => handleSelectAnnotation(annotation.id)}
×
488
                      >
489
                        <ArrowDown height="16px" />
490
                      </HeaderActionWrapper>
491
                      <Tooltip
492
                        id={`config-annotation_${annotation.id}`}
493
                        effect="solid"
494
                        delayShow={500}
495
                      >
496
                        <span>
497
                          <FormattedMessage id="tooltip.annotationSettings" />
498
                        </span>
499
                      </Tooltip>
500
                    </HeaderActionSection>
501
                  </StyledAnnotationItemHeader>
502

503
                  {isSelected ? (
×
504
                    <StyledConfigSection>
505
                      <StyledConfigRow>
506
                        <StyledConfigLabel>Type</StyledConfigLabel>
507
                        <StyledSelect
508
                          value={annotation.kind}
509
                          onChange={e =>
NEW
510
                            handleChangeKind(annotation.id, e.target.value as AnnotationKind)
×
511
                          }
512
                        >
513
                          {ANNOTATION_KINDS.map(k => (
NEW
514
                            <option key={k.id} value={k.id}>
×
515
                              {k.label}
516
                            </option>
517
                          ))}
518
                        </StyledSelect>
519
                      </StyledConfigRow>
520
                      <StyledConfigRow>
521
                        <StyledConfigLabel>Line Width</StyledConfigLabel>
522
                        <StyledSelect
523
                          value={annotation.lineWidth}
524
                          onChange={e =>
NEW
525
                            handleChangeLineWidth(annotation.id, Number(e.target.value))
×
526
                          }
527
                        >
528
                          {ANNOTATION_LINE_WIDTH_OPTIONS.map(w => (
NEW
529
                            <option key={w} value={w}>
×
530
                              {w}px
531
                            </option>
532
                          ))}
533
                        </StyledSelect>
534
                      </StyledConfigRow>
535
                      <StyledConfigRow>
536
                        <StyledConfigLabel>Color</StyledConfigLabel>
537
                        <InlineColorPicker
538
                          color={hexToRgb(annotation.lineColor)}
NEW
539
                          onSetColor={rgb => handleChangeLineColor(annotation.id, rgb)}
×
540
                        />
541
                      </StyledConfigRow>
542
                    </StyledConfigSection>
543
                  ) : null}
544
                </StyledAnnotationItemWrapper>
545
              );
546
            })}
547
          </StyledAnnotationPanelContent>
548
        </StyledAnnotationPanel>
549
      </StyledAnnotationPanelContainer>
550
    );
551
  };
552

NEW
553
  return withState([visStateLens, mapStateLens], state => state, {
×
554
    visStateActions: {
555
      addAnnotation,
556
      removeAnnotation,
557
      updateAnnotation,
558
      duplicateAnnotation,
559
      setSelectedAnnotation
560
    }
561
  })(injectIntl(AnnotationManager)) as React.FC<any>;
562
}
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