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

keplergl / kepler.gl / 21753299196

06 Feb 2026 02:03PM UTC coverage: 61.651% (-0.01%) from 61.661%
21753299196

Pull #3300

github

web-flow
Merge 81a1520f1 into cbb3204cf
Pull Request #3300: rollback change, and truncate tooltip

6375 of 12268 branches covered (51.96%)

Branch coverage included in aggregate %.

2 of 3 new or added lines in 1 file covered. (66.67%)

19 existing lines in 1 file now uncovered.

13060 of 19256 relevant lines covered (67.82%)

81.6 hits per line

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

43.48
/src/components/src/map/layer-hover-info.tsx
1
// SPDX-License-Identifier: MIT
2
// Copyright contributors to the kepler.gl project
3

4
import React, {useMemo} from 'react';
5
import styled from 'styled-components';
6
import truncate from 'lodash/truncate';
7
import {CompareType, Field, Merge, TooltipField} from '@kepler.gl/types';
8
import {CenterFlexbox} from '../common/styled-components';
9
import {Layers} from '../common/icons';
10
import PropTypes from 'prop-types';
11
import {notNullorUndefined} from '@kepler.gl/common-utils';
12
import {DataRow} from '@kepler.gl/utils';
13
import {Layer} from '@kepler.gl/layers';
14
import {
15
  AggregationLayerHoverData,
16
  LayerHoverProp,
17
  getTooltipDisplayDeltaValue,
18
  getTooltipDisplayValue
19
} from '@kepler.gl/reducers';
20
import {useIntl} from 'react-intl';
21
import {VisState} from '@kepler.gl/schemas';
22
import {capitalizeFirstLetter} from '@kepler.gl/utils';
23

24
export const StyledLayerName = styled(CenterFlexbox)`
7✔
25
  color: ${props => props.theme.textColorHl};
9✔
26
  font-size: 12px;
27
  letter-spacing: 0.43px;
28
  text-transform: capitalize;
29

30
  svg {
31
    margin-right: 4px;
32
  }
33
`;
34

35
const StyledTable = styled.table`
7✔
36
  & .row__delta-value {
37
    text-align: right;
38
    margin-left: 6px;
39

40
    &.positive {
41
      color: ${props => props.theme.notificationColors.success};
5✔
42
    }
43

44
    &.negative {
45
      color: ${props => props.theme.negativeBtnActBgd};
5✔
46
    }
47
  }
48
  & .row__value,
49
  & .row__name {
50
    overflow: hidden;
51
    text-overflow: ellipsis;
52
    white-space: no-wrap;
53
  }
54
`;
55

56
const StyledDivider = styled.div`
7✔
57
  // offset divider to reach popover edge
58
  margin-left: -14px;
59
  margin-right: -14px;
60
  border-bottom: 1px solid ${props => props.theme.panelBorderColor};
10✔
61
`;
62

63
interface RowProps {
64
  name: string;
65
  value: string;
66
  deltaValue?: string | null;
67
  url?: string;
68
}
69

70
/** Max length before truncation is applied */
71
const TOOLTIP_VALUE_MAX_LENGTH = 60;
7✔
72
/** Length to truncate to (including ellipsis) */
73
const TOOLTIP_VALUE_TRUNCATE_LENGTH = 30;
7✔
74

75
const Row: React.FC<RowProps> = ({name, value, deltaValue, url}) => {
7✔
76
  // Set 'url' to 'value' if it looks like a url
77
  if (!url && value && typeof value === 'string' && value.match(/^http/)) {
25!
NEW
78
    url = value;
×
79
  }
80

81
  const displayValue =
82
    typeof value === 'string' && value.length > TOOLTIP_VALUE_MAX_LENGTH
25!
83
      ? truncate(value, {length: TOOLTIP_VALUE_TRUNCATE_LENGTH})
84
      : value;
85

86
  const asImg = /<img>/.test(name);
25✔
87
  return (
25✔
88
    <tr className="layer-hover-info__row" key={name}>
89
      <td className="row__name">{asImg ? name.replace('<img>', '') : name}</td>
25!
90
      <td className="row__value">
91
        {asImg ? (
25!
92
          <img src={value} />
93
        ) : url ? (
25!
94
          <a target="_blank" rel="noopener noreferrer" href={url}>
95
            {displayValue}
96
          </a>
97
        ) : (
98
          <>
99
            <span>{displayValue}</span>
100
            {notNullorUndefined(deltaValue) ? (
25!
101
              <span
102
                className={`row__delta-value ${
103
                  deltaValue?.toString().charAt(0) === '+' ? 'positive' : 'negative'
×
104
                }`}
105
              >
106
                {deltaValue}
107
              </span>
108
            ) : null}
109
          </>
110
        )}
111
      </td>
112
    </tr>
113
  );
114
};
115

116
export type EntryInfoProps = Merge<LayerHoverProp, {fieldsToShow: TooltipField[]}>;
117

118
const EntryInfo: React.FC<EntryInfoProps> = ({fieldsToShow, ...props}) => (
7✔
119
  <tbody>
5✔
120
    {fieldsToShow.map(item => (
121
      <EntryInfoRow key={item.name} item={item} {...props} />
25✔
122
    ))}
123
  </tbody>
124
);
125

126
export type EntryInfoRowProps = {
127
  data: LayerHoverProp['data'];
128
  fields: Field[];
129
  layer: Layer;
130
  primaryData?: LayerHoverProp['primaryData'];
131
  compareType?: CompareType;
132
  currentTime?: VisState['animationConfig']['currentTime'];
133
  item: TooltipField;
134
};
135

136
const EntryInfoRow: React.FC<EntryInfoRowProps> = ({
7✔
137
  layer,
138
  item,
139
  fields,
140
  data,
141
  primaryData,
142
  compareType,
143
  currentTime
144
}) => {
145
  const fieldIdx = fields.findIndex(f => f.name === item.name);
109✔
146
  if (fieldIdx < 0) {
25!
UNCOV
147
    return null;
×
148
  }
149
  const field = fields[fieldIdx];
25✔
150
  const fieldValueAccessor = layer.accessVSFieldValue(field, currentTime);
25✔
151
  const value = fieldValueAccessor(field, data instanceof DataRow ? {index: data._rowIndex} : data);
25!
152

153
  // Handle WMS layer data in comparison mode - WMS layers don't have comparable field data
154
  let primaryValue = null;
25✔
155
  let displayDeltaValue: string | null = null;
25✔
156

157
  if (primaryData) {
25!
UNCOV
158
    try {
×
159
      // Only calculate primary value if primaryData has a compatible structure
UNCOV
160
      if (
×
161
        primaryData instanceof DataRow ||
×
162
        (primaryData && typeof primaryData === 'object' && 'index' in primaryData)
163
      ) {
UNCOV
164
        primaryValue = fieldValueAccessor(
×
165
          field,
166
          primaryData instanceof DataRow ? {index: primaryData._rowIndex} : primaryData
×
167
        );
168

UNCOV
169
        displayDeltaValue = getTooltipDisplayDeltaValue({
×
170
          field,
171
          value,
172
          primaryValue,
173
          compareType
174
        });
175
      }
176
    } catch (error) {
177
      // If there's an error accessing primaryData (e.g., WMS layer data), skip comparison
UNCOV
178
      primaryValue = null;
×
179
    }
180
  }
181

182
  const displayValue = getTooltipDisplayValue({item, field, value});
25✔
183

184
  return (
25✔
185
    <Row
186
      name={field.displayName || field.name}
25!
187
      value={displayValue}
188
      deltaValue={displayDeltaValue}
189
    />
190
  );
191
};
192

193
// TODO: supporting comparative value for aggregated cells as well
194
const CellInfo = ({
7✔
195
  fieldsToShow,
196
  data,
197
  layer
198
}: {
199
  data: AggregationLayerHoverData;
200
  fieldsToShow: TooltipField[];
201
  layer: Layer;
202
}) => {
203
  const {colorField, sizeField} = layer.config as any;
×
204

205
  const colorValue = useMemo(() => {
×
UNCOV
206
    if (colorField && layer.visualChannels.color) {
×
207
      const item = fieldsToShow.find(field => field.name === colorField.name);
×
UNCOV
208
      return getTooltipDisplayValue({item, field: colorField, value: data.colorValue});
×
209
    }
210
    return null;
×
211
  }, [fieldsToShow, colorField, layer, data.colorValue]);
212

213
  const elevationValue = useMemo(() => {
×
UNCOV
214
    if (sizeField && layer.visualChannels.size) {
×
215
      const item = fieldsToShow.find(field => field.name === sizeField.name);
×
UNCOV
216
      return getTooltipDisplayValue({item, field: sizeField, value: data.elevationValue});
×
217
    }
218
    return null;
×
219
  }, [fieldsToShow, sizeField, layer, data.elevationValue]);
220

221
  const aggregatedData = useMemo(() => {
×
222
    if (data.aggregatedData && fieldsToShow) {
×
223
      return fieldsToShow.reduce((acc, field) => {
×
UNCOV
224
        const dataForField = data.aggregatedData?.[field.name];
×
UNCOV
225
        if (dataForField?.measure && field.name !== colorField?.name) {
×
UNCOV
226
          acc.push({
×
227
            name: `${capitalizeFirstLetter(dataForField.measure)} of ${field.name}`,
228
            value: dataForField.value
229
          });
230
        }
231
        return acc;
×
232
      }, [] as {name: string; value?: string}[]);
233
    }
234
    return [];
×
235
  }, [data.aggregatedData, fieldsToShow, colorField?.name]);
236

UNCOV
237
  const colorMeasure = layer.getVisualChannelDescription('color').measure;
×
UNCOV
238
  const sizeMeasure = layer.getVisualChannelDescription('size').measure;
×
UNCOV
239
  return (
×
240
    <tbody>
241
      <Row name={'total points'} key="count" value={String(data.points && data.points.length)} />
×
242
      {colorField && layer.visualChannels.color && colorMeasure ? (
×
243
        <Row name={colorMeasure} key="color" value={colorValue || 'N/A'} />
×
244
      ) : null}
245
      {sizeField && layer.visualChannels.size && sizeMeasure ? (
×
246
        <Row name={sizeMeasure} key="size" value={elevationValue || 'N/A'} />
×
247
      ) : null}
248
      {aggregatedData.map((dataForField, idx) => (
UNCOV
249
        <Row name={dataForField.name} key={`data_${idx}`} value={dataForField.value || 'N/A'} />
×
250
      ))}
251
    </tbody>
252
  );
253
};
254

255
const LayerHoverInfoFactory = () => {
7✔
256
  const LayerHoverInfo = props => {
14✔
257
    const {data, layer} = props;
12✔
258
    const intl = useIntl();
12✔
259
    if (!data || !layer) {
12✔
260
      return null;
7✔
261
    }
262

263
    const hasFieldsToShow =
264
      (data.fieldValues && Object.keys(data.fieldValues).length > 0) ||
5!
265
      (data.wmsFeatureData && data.wmsFeatureData.length > 0) ||
266
      (props.fieldsToShow && props.fieldsToShow.length > 0);
267

268
    return (
5✔
269
      <div className="map-popover__layer-info">
270
        <StyledLayerName className="map-popover__layer-name">
271
          <Layers height="12px" />
272
          {props.layer.config.label}
273
        </StyledLayerName>
274
        {hasFieldsToShow && <StyledDivider />}
10✔
275
        <StyledTable>
276
          {data.wmsFeatureData ? (
5!
277
            <tbody>
278
              {data.wmsFeatureData.map(({name, value}, i) => (
UNCOV
279
                <Row key={i} name={name} value={value} />
×
280
              ))}
281
            </tbody>
282
          ) : data.fieldValues ? (
5!
283
            <tbody>
284
              {data.fieldValues.map(({labelMessage, value}, i) => (
UNCOV
285
                <Row key={i} name={intl.formatMessage({id: labelMessage})} value={value} />
×
286
              ))}
287
            </tbody>
288
          ) : props.layer.isAggregated ? (
5!
289
            <CellInfo {...props} />
290
          ) : (
291
            <EntryInfo {...props} />
292
          )}
293
        </StyledTable>
294
        {hasFieldsToShow && <StyledDivider />}
10✔
295
      </div>
296
    );
297
  };
298

299
  LayerHoverInfo.propTypes = {
14✔
300
    fields: PropTypes.arrayOf(PropTypes.any),
301
    fieldsToShow: PropTypes.arrayOf(PropTypes.any),
302
    layer: PropTypes.object,
303
    data: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.any), PropTypes.object])
304
  };
305
  return LayerHoverInfo;
14✔
306
};
307

308
export default LayerHoverInfoFactory;
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