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

keplergl / kepler.gl / 23965535588

03 Apr 2026 11:07PM UTC coverage: 59.873% (-1.8%) from 61.699%
23965535588

push

github

web-flow
chore: deck.gl 9.2 upgrade & loaders.gl, luma.gl upgrades (#3271)

* chore: upgrade to deckgl 9.2.11

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

* fixes

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

* fix lint follow up

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

* fix blending

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

* fix geojson layer

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

* more fixes for aggregation layers

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

* fix h3 layer

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

* potential fix for issues with geoarrow in point and line layers

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

* fix postprocessing effects

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

* fixes for light and shadow effect

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

* shadow and light effect - restore uniform shadow during the nighttime

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

* don't hide line and arc layers when layer type changed

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

* restore filters for aggregation layers

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

* fix aggregation layers - hightlight outlines

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

* fixes for raster tile layer - raster pmtiles related

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

* fixes for raster tiles shader modules updates

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

* fix lint

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

* more lint

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

* try to fix CI lint

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

* try to fix CI tests

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

* install webgpu

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

* try to fix Ci test env setup

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

* cont fix setup browser env

Signed-o... (continued)

6517 of 12977 branches covered (50.22%)

Branch coverage included in aggregate %.

324 of 1031 new or added lines in 58 files covered. (31.43%)

123 existing lines in 18 files now uncovered.

13313 of 20143 relevant lines covered (66.09%)

78.43 hits per line

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

66.89
/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 {Map} from 'react-map-gl';
8
import debounce from 'lodash/debounce';
9
import {
10
  exportImageError,
11
  scaleMapStyleByResolution,
12
  getCenterAndZoomFromBounds,
13
  convertToPng,
14
  getScaleFromImageSize
15
} from '@kepler.gl/utils';
16
import {findMapBounds, areAnyDeckLayersLoading} from '@kepler.gl/reducers';
17
import MapContainerFactory from './map-container';
18
import MapsLayoutFactory from './maps-layout';
19
import {MapViewStateContextProvider} from './map-view-state-context';
20

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

123
  // Flags
124
  enableErrorNotification?: boolean;
125

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

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

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

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

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

162
    const {mapState} = mapFields;
4✔
163

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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