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

keplergl / kepler.gl / 27615979164

16 Jun 2026 12:00PM UTC coverage: 57.181% (-0.2%) from 57.356%
27615979164

push

github

web-flow
feat: swipe view mode (#3487)

* feat: swipe view mode

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

* adjust swipe style

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

* ensures pointer capture is always set/released

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

* fixes

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

* fix tests

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

* fix tests

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>

7544 of 15807 branches covered (47.73%)

Branch coverage included in aggregate %.

95 of 202 new or added lines in 8 files covered. (47.03%)

11 existing lines in 4 files now uncovered.

15211 of 23988 relevant lines covered (63.41%)

76.47 hits per line

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

69.23
/src/components/src/map/split-map-button.tsx
1
// SPDX-License-Identifier: MIT
2
// Copyright contributors to the kepler.gl project
3

4
import React, {ComponentType, useCallback, useMemo, useState, useRef, useEffect} from 'react';
5
import classnames from 'classnames';
6
import styled from 'styled-components';
7
import {MapControlButton} from '../common/styled-components';
8
import {Delete, Split} from '../common/icons';
9
import MapControlTooltipFactory from './map-control-tooltip';
10
import {MapControlItem, MapControls, MapState} from '@kepler.gl/types';
11
import {MapSplitMode} from '@kepler.gl/constants';
12

13
SplitMapButtonFactory.deps = [MapControlTooltipFactory];
7✔
14

15
interface SplitMapButtonIcons {
16
  delete: ComponentType<any>;
17
  split: ComponentType<any>;
18
}
19

20
export type SplitMapButtonProps = {
21
  isSplit: boolean;
22
  mapIndex: number;
23
  onToggleSplitMap: (index?: number) => void;
24
  onSetMapSplitMode?: (payload: {mapSplitMode: MapSplitMode}) => void;
25
  actionIcons: SplitMapButtonIcons;
26
  readOnly: boolean;
27
  mapControls: MapControls;
28
  mapState?: MapState;
29
};
30

31
const StyledSplitModeMenu = styled.div`
7✔
32
  position: absolute;
33
  top: 0;
34
  left: -160px;
35
  background: ${props => props.theme.dropdownListBgd || '#3A414C'};
1!
36
  border-radius: 4px;
37
  box-shadow: 0 6px 12px 0 rgba(0, 0, 0, 0.16);
38
  padding: 4px 0;
39
  z-index: 1000;
40
  min-width: 140px;
41
`;
42

43
const StyledMenuItem = styled.div<{$active?: boolean}>`
7✔
44
  padding: 8px 16px;
45
  color: ${props =>
46
    props.$active
3!
47
      ? props.theme.activeColor || '#1FBAD6'
48
      : props.theme.textColor || '#A0A7B4'};
49
  cursor: pointer;
3✔
50
  font-size: 12px;
51
  font-weight: ${props => (props.$active ? 500 : 400)};
3!
52
  &:hover {
3!
53
    background: ${props => props.theme.dropdownListHighlightBg || '#4B5464'};
54
    color: ${props => props.theme.textColorHl || '#FFFFFF'};
55
  }
56
`;
7!
NEW
57

×
58
const SwipeCompareIcon: React.FC<{height?: string}> = ({height = '18px'}) => (
59
  <svg height={height} viewBox="0 0 16 16" fill="currentColor">
60
    <path
61
      fillRule="evenodd"
62
      clipRule="evenodd"
63
      d="M2 2h12v12H2V2zm1 1v10h10V3H3z"
64
    />
65
    <path d="M7.5 3v10h1V3z" />
7✔
66
    <path d="M5 8l2-2v4l-2-2z" />
67
    <path d="M11 8l-2-2v4l2-2z" />
68
  </svg>
69
);
70

71
const SPLIT_MODE_OPTIONS = [
72
  {id: MapSplitMode.SINGLE_MAP, label: 'Single'},
14✔
73
  {id: MapSplitMode.DUAL_MAP, label: 'Dual'},
74
  {id: MapSplitMode.SWIPE_COMPARE, label: 'Swipe'}
75
];
76

77
function SplitMapButtonFactory(MapControlTooltip) {
78
  const defaultActionIcons = {
14✔
79
    delete: Delete,
80
    split: Split
81
  };
82

83
  /** @type {import('./split-map-button').SplitMapButtonComponent} */
28✔
84
  const SplitMapButton: React.FC<SplitMapButtonProps> = ({
85
    isSplit,
86
    mapIndex,
87
    onToggleSplitMap,
88
    onSetMapSplitMode,
28✔
89
    actionIcons = defaultActionIcons,
28✔
90
    mapControls,
28✔
91
    readOnly,
92
    mapState
28!
93
  }) => {
94
    const splitMap = mapControls?.splitMap || ({} as MapControlItem);
28✔
95
    const [menuOpen, setMenuOpen] = useState(false);
96
    const menuRef = useRef<HTMLDivElement>(null);
1✔
97

1!
98
    const currentMode = mapState?.mapSplitMode || MapSplitMode.SINGLE_MAP;
1✔
99

UNCOV
100
    const onClick = useCallback(
×
101
      event => {
102
        event.preventDefault();
103
        if (onSetMapSplitMode) {
104
          setMenuOpen(prev => !prev);
105
        } else {
106
          onToggleSplitMap(isSplit ? mapIndex : undefined);
28✔
107
        }
UNCOV
108
      },
×
NEW
109
      [isSplit, mapIndex, onToggleSplitMap, onSetMapSplitMode]
×
110
    );
UNCOV
111

×
112
    const handleModeSelect = useCallback(
113
      (mode: string) => {
114
        if (onSetMapSplitMode) {
115
          onSetMapSplitMode({mapSplitMode: mode as MapSplitMode});
116
        }
28✔
117
        setMenuOpen(false);
26✔
NEW
118
      },
×
NEW
119
      [onSetMapSplitMode]
×
120
    );
121

122
    useEffect(() => {
26✔
123
      const handleClickOutside = (event: MouseEvent) => {
1✔
124
        if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
125
          setMenuOpen(false);
26✔
126
        }
3✔
127
      };
128
      if (menuOpen) {
129
        document.addEventListener('mousedown', handleClickOutside);
130
      }
28✔
131
      return () => {
132
        document.removeEventListener('mousedown', handleClickOutside);
28✔
133
      };
5✔
134
    }, [menuOpen]);
135

23✔
136
    const isVisible = useMemo(() => splitMap.show && readOnly !== true, [splitMap.show, readOnly]);
137

138
    if (!splitMap.show) {
139
      return null;
140
    }
22!
141
    return isVisible ? (
142
      <div style={{position: 'relative'}} ref={menuRef}>
×
143
        <MapControlTooltip
144
          id="action-toggle"
145
          message={
146
            onSetMapSplitMode
147
              ? 'tooltip.selectSplitMode'
148
              : isSplit
149
              ? 'tooltip.closePanel'
150
              : 'tooltip.switchToDualView'
151
          }
152
        >
22!
153
          <MapControlButton
154
            active={isSplit}
22✔
155
            onClick={onClick}
156
            className={classnames('map-control-button', 'split-map', {'close-map': isSplit})}
157
          >
158
            {currentMode === MapSplitMode.SWIPE_COMPARE ? (
159
              <SwipeCompareIcon height="18px" />
160
            ) : isSplit ? (
161
              <actionIcons.delete height="18px" />
24✔
162
            ) : (
163
              <actionIcons.split height="18px" />
164
            )}
3✔
165
          </MapControlButton>
166
        </MapControlTooltip>
NEW
167
        {menuOpen && onSetMapSplitMode && (
×
168
          <StyledSplitModeMenu>
169
            {SPLIT_MODE_OPTIONS.map(option => (
170
              <StyledMenuItem
171
                key={option.id}
172
                $active={currentMode === option.id}
173
                onClick={() => handleModeSelect(option.id)}
174
              >
175
                {option.label}
176
              </StyledMenuItem>
177
            ))}
178
          </StyledSplitModeMenu>
14✔
179
        )}
14✔
180
      </div>
181
    ) : null;
182
  };
183

184
  SplitMapButton.displayName = 'SplitMapButton';
185
  return React.memo(SplitMapButton);
186
}
187

188
export default SplitMapButtonFactory;
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