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

keplergl / kepler.gl / 25196904117

01 May 2026 12:52AM UTC coverage: 59.169% (-0.005%) from 59.174%
25196904117

push

github

web-flow
chore: fix tests (#3403)

* chore: fix tests

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

* add RTLTextPlugin prop to kepler

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>

6923 of 14060 branches covered (49.24%)

Branch coverage included in aggregate %.

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

42 existing lines in 5 files now uncovered.

14276 of 21768 relevant lines covered (65.58%)

80.03 hits per line

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

67.11
/src/components/src/plot-container.tsx
1
// SPDX-License-Identifier: MIT
2
// Copyright contributors to the kepler.gl project
3

4
// libraries
5
import React, {useRef, useEffect, useState, useCallback, useMemo} from 'react';
6
import styled from 'styled-components';
7
import debounce from 'lodash/debounce';
8
import {
9
  exportImageError,
10
  scaleMapStyleByResolution,
11
  getCenterAndZoomFromBounds,
12
  convertToPng,
13
  getScaleFromImageSize
14
} from '@kepler.gl/utils';
15
import {findMapBounds, areAnyDeckLayersLoading} from '@kepler.gl/reducers';
16
import MapContainerFactory from './map-container';
17
import MapsLayoutFactory from './maps-layout';
18
import {MapViewStateContextProvider} from './map-view-state-context';
19

20
import {GEOCODER_LAYER_ID} from '@kepler.gl/constants';
21
import {Effect, SplitMap, ExportImage} from '@kepler.gl/types';
22
import {
23
  ActionHandler,
24
  addNotification,
25
  setExportImageDataUri,
26
  setExportImageError,
27
  setExportImageSetting
28
} from '@kepler.gl/actions';
29
import {mapFieldsSelector} from './kepler-gl';
30

31
const CLASS_FILTER = [
7✔
32
  'maplibregl-control-container',
33
  'mapboxgl-control-container',
34
  'attrition-link',
35
  'attrition-logo',
36
  'map-control__panel-split-viewport-tools'
37
];
38
const DOM_FILTER_FUNC = node => !CLASS_FILTER.includes(node.className);
7✔
39
const OUT_OF_SCREEN_POSITION = -9999;
7✔
40

41
/**
42
 * Calculates legend zoom based on export height
43
 * Maps export height to a zoom factor between 0.5 and 3
44
 * @param height - export height in pixels
45
 * @returns zoom factor for legend panel
46
 */
47
function calculateLegendZoom(height: number): number {
48
  const baseHeight = 1080; // 1x zoom reference height
4✔
49
  const minZoom = 0.5;
4✔
50
  const maxZoom = 3;
4✔
51

52
  // For initial/invalid heights, avoid shrinking the legend; use no scaling.
53
  if (!Number.isFinite(height) || height <= 0) {
4✔
54
    return 1;
2✔
55
  }
56

57
  // Linear mapping: height / baseHeight gives us the scale
58
  const zoomFactor = height / baseHeight;
2✔
59
  // Clamp between min and max
60
  return Math.max(minZoom, Math.min(maxZoom, zoomFactor));
2✔
61
}
62

63
PlotContainerFactory.deps = [MapContainerFactory, MapsLayoutFactory];
7✔
64

65
// Remove mapbox logo in exported map, because it contains non-ascii characters
66
// Remove split viewport UI controls from exported images when the legend is shown
67
interface StyledPlotContainerProps {
68
  legendZoom?: number;
69
}
70

71
const StyledPlotContainer = styled.div<StyledPlotContainerProps>`
7✔
72
  .maplibregl-ctrl-bottom-left,
73
  .maplibregl-ctrl-bottom-right,
74
  .maplibre-attribution-container,
75
  .mapboxgl-ctrl-bottom-left,
76
  .mapboxgl-ctrl-bottom-right,
77
  .mapbox-attribution-container,
78
  .map-control__panel-split-viewport-tools {
79
    display: none;
80
  }
81

82
  position: absolute;
83
  top: ${OUT_OF_SCREEN_POSITION}px;
84
  left: ${OUT_OF_SCREEN_POSITION}px;
85

86
  /* Apply zoom to legend panel based on export height */
87
  .map-control-panel {
88
    zoom: ${props => props.legendZoom || 1} !important;
4!
89
  }
90
`;
91

92
interface StyledMapContainerProps {
93
  width?: number;
94
  height?: number;
95
}
96

97
const StyledMapContainer = styled.div<StyledMapContainerProps>`
7✔
98
  width: ${props => props.width}px;
4✔
99
  height: ${props => props.height}px;
4✔
100
  display: flex;
101
`;
102

103
interface PlotContainerProps {
104
  // Image export settings
105
  ratio?: string;
106
  resolution?: string;
107
  legend?: boolean;
108
  center?: boolean;
109
  imageSize: ExportImage['imageSize'];
110
  escapeXhtmlForWebpack?: boolean;
111

112
  // Map settings
113
  mapFields: ReturnType<typeof mapFieldsSelector>;
114
  splitMaps?: SplitMap[];
115

116
  // Callbacks
117
  setExportImageSetting: typeof setExportImageSetting;
118
  setExportImageDataUri: typeof setExportImageDataUri;
119
  setExportImageError: typeof setExportImageError;
120
  addNotification: ActionHandler<typeof addNotification>;
121

122
  // Flags
123
  enableErrorNotification?: boolean;
124

125
  // Optional: override legend header logo during export
126
  logoComponent?: React.ReactNode;
127
}
128

129
export default function PlotContainerFactory(
130
  MapContainer: ReturnType<typeof MapContainerFactory>,
131
  MapsLayout: ReturnType<typeof MapsLayoutFactory>
132
): React.ComponentType<PlotContainerProps> {
133
  function PlotContainer({
134
    // Image export settings
135
    ratio,
136
    resolution,
137
    legend = false,
×
138
    center,
139
    imageSize,
140
    escapeXhtmlForWebpack,
141

142
    // Map settings
143
    mapFields,
144
    splitMaps = [],
×
145

146
    // Callbacks
147
    setExportImageSetting,
148
    setExportImageDataUri,
149
    setExportImageError,
150
    addNotification,
151

152
    // Flags
153
    enableErrorNotification,
154
    logoComponent
155
  }: PlotContainerProps) {
156
    const plottingAreaRef = useRef<HTMLDivElement>(null);
4✔
157
    const [plotEffects] = useState<Effect[]>(() =>
4✔
158
      mapFields.visState.effects.map(effect => effect.clone())
4✔
159
    );
160

161
    const {mapState} = mapFields;
4✔
162

163
    const deckLayersLoadingRef = useRef(true);
4✔
164
    const mapStyleLoadedRef = useRef(false);
4✔
165
    const screenshotTakenRef = useRef(false);
4✔
166

167
    // Memoize the scale calculation
168
    const scale = useMemo(() => {
4✔
169
      if (imageSize.scale) {
4!
170
        return imageSize.scale;
4✔
171
      }
172

173
      const calculatedScale = getScaleFromImageSize(
×
174
        imageSize.imageW,
175
        imageSize.imageH,
176
        mapState.width * (mapState.isSplit ? 2 : 1),
×
177
        mapState.height
178
      );
179

180
      return calculatedScale > 0 ? calculatedScale : 1;
×
181
    }, [
182
      imageSize.scale,
183
      imageSize.imageW,
184
      imageSize.imageH,
185
      mapState.width,
186
      mapState.height,
187
      mapState.isSplit
188
    ]);
189

190
    // Memoize the legend zoom calculation based on export height
191
    const legendZoom = useMemo(() => {
4✔
192
      return calculateLegendZoom(imageSize.imageH);
4✔
193
    }, [imageSize.imageH]);
194

195
    // Memoize the map style
196
    const scaledMapStyle = useMemo(() => {
4✔
197
      const mapStyle = mapFields.mapStyle;
4✔
198
      return {
4✔
199
        ...mapStyle,
200
        bottomMapStyle: scaleMapStyleByResolution(mapStyle.bottomMapStyle, scale),
201
        topMapStyle: scaleMapStyleByResolution(mapStyle.topMapStyle, scale)
202
      };
203
    }, [mapFields.mapStyle, scale]);
204

205
    // Memoize the retrieveNewScreenshot callback
206
    const debouncedScreenshot = useMemo(
4✔
207
      () =>
208
        debounce(() => {
4✔
209
          if (plottingAreaRef.current) {
×
210
            convertToPng(plottingAreaRef.current, {
×
211
              filter: DOM_FILTER_FUNC,
212
              width: imageSize.imageW,
213
              height: imageSize.imageH,
214
              escapeXhtmlForWebpack
215
            })
216
              .then(setExportImageDataUri)
217
              .catch(err => {
218
                setExportImageError(err);
×
219
                if (enableErrorNotification) {
×
220
                  addNotification(exportImageError({err}));
×
221
                }
222
              });
223
          }
224
        }, 500),
225
      [
226
        imageSize.imageW,
227
        imageSize.imageH,
228
        escapeXhtmlForWebpack,
229
        setExportImageDataUri,
230
        setExportImageError,
231
        enableErrorNotification,
232
        addNotification
233
      ]
234
    );
235

236
    const retrieveNewScreenshot = useCallback(debouncedScreenshot, [debouncedScreenshot]);
4✔
237

238
    const tryScreenshot = useCallback(() => {
4✔
239
      if (mapStyleLoadedRef.current && !deckLayersLoadingRef.current) {
8!
240
        screenshotTakenRef.current = true;
×
241
        retrieveNewScreenshot();
×
242
      }
243
    }, [retrieveNewScreenshot]);
244

245
    // Fallback: if layers never finish loading, capture after timeout
246
    useEffect(() => {
4✔
247
      const timer = setTimeout(() => {
4✔
248
        if (!screenshotTakenRef.current) {
4!
249
          deckLayersLoadingRef.current = false;
4✔
250
          tryScreenshot();
4✔
251
        }
252
      }, 30000);
253
      return () => clearTimeout(timer);
4✔
254
    }, [tryScreenshot]);
255

256
    // Memoize the onMapRender callback
257
    const debouncedMapRender = useMemo(
4✔
258
      () =>
259
        debounce(map => {
4✔
260
          if (map.isStyleLoaded()) {
×
261
            mapStyleLoadedRef.current = true;
×
262
            tryScreenshot();
×
263
          }
264
        }, 500),
265
      [tryScreenshot]
266
    );
267

268
    const onMapRender = useCallback(debouncedMapRender, [debouncedMapRender]);
4✔
269

270
    const deckRenderCallbacks = useMemo(
4✔
271
      () => ({
4✔
272
        onDeckAfterRender: (deckProps: Record<string, unknown>) => {
273
          const layers = deckProps.layers as any[];
×
274
          if (!layers) return;
×
275
          const stillLoading = areAnyDeckLayersLoading(layers);
×
276
          if (deckLayersLoadingRef.current && !stillLoading) {
×
277
            deckLayersLoadingRef.current = false;
×
278
            tryScreenshot();
×
279
          }
280
          deckLayersLoadingRef.current = stillLoading;
×
281
        }
282
      }),
283
      [tryScreenshot]
284
    );
285

286
    // Initial setup effect
287
    useEffect(() => {
4✔
288
      setExportImageSetting({processing: true});
4✔
289
    }, [setExportImageSetting]);
290

291
    // Screenshot update effect
292
    useEffect(() => {
4✔
293
      if (ratio !== undefined || resolution !== undefined || legend !== undefined) {
4!
294
        setExportImageSetting({processing: true});
4✔
295
        tryScreenshot();
4✔
296
      }
297
    }, [ratio, resolution, legend, setExportImageSetting, tryScreenshot]);
298

299
    // Memoize size calculations
300
    const {size, width, height} = useMemo(() => {
4✔
301
      const size = {
4✔
302
        width: imageSize.imageW || 1,
6✔
303
        height: imageSize.imageH || 1
6✔
304
      };
305
      const isSplit = splitMaps.length > 1;
4✔
306
      return {
4✔
307
        size,
308
        width: size.width / (isSplit ? 2 : 1),
4!
309
        height: size.height
310
      };
311
    }, [imageSize.imageW, imageSize.imageH, splitMaps.length]);
312

313
    // Memoize map state
314
    const newMapState = useMemo(() => {
4✔
315
      const zoomOffset = Math.log2(scale) || 0;
4✔
316
      const baseMapState = {
4✔
317
        ...mapState,
318
        width,
319
        height,
320
        zoom: mapState.zoom + zoomOffset,
321
        zoomOffset
322
      };
323

324
      if (center) {
4✔
325
        const renderedLayers = mapFields.visState.layers.filter(
1✔
326
          (layer, idx) =>
327
            layer.id !== GEOCODER_LAYER_ID &&
2✔
328
            layer.shouldRenderLayer(mapFields.visState.layerData[idx])
329
        );
330
        const bounds = findMapBounds(renderedLayers);
1✔
331
        const centerAndZoom = getCenterAndZoomFromBounds(bounds, {width, height});
1✔
332
        if (centerAndZoom) {
1!
333
          const zoom = Number.isFinite(centerAndZoom.zoom) ? centerAndZoom.zoom : mapState.zoom;
1!
334
          return {
1✔
335
            ...baseMapState,
336
            longitude: centerAndZoom.center[0],
337
            latitude: centerAndZoom.center[1],
338
            zoom: zoom + Number(Math.log2(scale) || 0)
2✔
339
          };
340
        }
341
      }
342

343
      return baseMapState;
3✔
344
    }, [mapState, width, height, scale, center, mapFields.visState]);
345

346
    // Memoize map props
347
    const mapProps = useMemo(
4✔
348
      () => ({
4✔
349
        ...mapFields,
350
        mapStyle: scaledMapStyle,
351
        mapState: newMapState,
352
        mapControls: {
353
          mapLegend: {
354
            show: Boolean(legend),
355
            active: true,
356
            settings: mapFields.mapControls?.mapLegend?.settings
357
          }
358
        },
359
        onMapRender,
360
        isExport: true,
361
        deckGlProps: {
362
          ...mapFields.deckGlProps,
363
          _isExport: true,
364
          useDevicePixels: false,
365
          deviceProps: {
366
            webgl: {
367
              preserveDrawingBuffer: true
368
            }
369
          }
370
        },
371
        deckRenderCallbacks,
372
        visState: {
373
          ...mapFields.visState,
374
          effects: plotEffects
375
        },
376
        // allow overriding the legend panel logo in export
377
        logoComponent
378
      }),
379
      [
380
        mapFields,
381
        scaledMapStyle,
382
        newMapState,
383
        legend,
384
        onMapRender,
385
        deckRenderCallbacks,
386
        plotEffects,
387
        logoComponent
388
      ]
389
    );
390

391
    const isSplit = splitMaps.length > 1;
4✔
392
    const mapContainers = !isSplit ? (
4!
393
      <MapContainer index={0} primary={true} {...mapProps} />
394
    ) : (
395
      <MapsLayout className="plot-container-maps" mapState={newMapState}>
396
        {splitMaps.map((settings, index) => (
UNCOV
397
          <MapContainer key={index} index={index} primary={index === 1} {...mapProps} />
×
398
        ))}
399
      </MapsLayout>
400
    );
401

402
    return (
4✔
403
      <StyledPlotContainer className="export-map-instance" legendZoom={legendZoom}>
404
        <StyledMapContainer ref={plottingAreaRef} width={size.width} height={size.height}>
405
          <MapViewStateContextProvider mapState={newMapState}>
406
            {mapContainers}
407
          </MapViewStateContextProvider>
408
        </StyledMapContainer>
409
      </StyledPlotContainer>
410
    );
411
  }
412

413
  return React.memo(PlotContainer);
14✔
414
}
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