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

keplergl / kepler.gl / 26698630763

31 May 2026 12:12AM UTC coverage: 57.356% (-0.3%) from 57.636%
26698630763

push

github

web-flow
feat: bitmap layer (#3472)

* feat: bitmap layer

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

* improve

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

* opacity

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

* app config; lint

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>

7465 of 15611 branches covered (47.82%)

Branch coverage included in aggregate %.

102 of 278 new or added lines in 8 files covered. (36.69%)

135 existing lines in 3 files now uncovered.

15144 of 23808 relevant lines covered (63.61%)

76.86 hits per line

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

71.15
/src/layers/src/bitmap-layer/bitmap-layer.ts
1
// SPDX-License-Identifier: MIT
2
// Copyright contributors to the kepler.gl project
3

4
import {BitmapLayer as DeckBitmapLayer, PathLayer} from '@deck.gl/layers';
5
import {
6
  EditableGeoJsonLayer,
7
  ModifyMode,
8
  TranslateMode,
9
  CompositeMode,
10
  GeoJsonEditMode
11
} from '@deck.gl-community/editable-layers';
12

13
import Layer from '../base-layer';
14
import BitmapLayerIcon from './bitmap-layer-icon';
15
import {FindDefaultLayerPropsReturnValue} from '../layer-utils';
16
import {
17
  LAYER_VIS_CONFIGS,
18
  LAYER_TYPES,
19
  DatasetType,
20
  BitmapDatasetMetadata,
21
  BitmapBounds
22
} from '@kepler.gl/constants';
23
import {KeplerTable as KeplerDataset, Datasets as KeplerDatasets} from '@kepler.gl/table';
24
import {VisConfigNumber, VisConfigBoolean} from '@kepler.gl/types';
25

26
const EDIT_MODE = new CompositeMode([
13✔
27
  new TranslateMode() as unknown as GeoJsonEditMode,
28
  new ModifyMode() as unknown as GeoJsonEditMode
29
]);
30

31
export type BitmapLayerVisConfigSettings = {
32
  opacity: VisConfigNumber;
33
  showBounds: VisConfigBoolean;
34
  editBounds: VisConfigBoolean;
35
  boundsWest: VisConfigNumber;
36
  boundsSouth: VisConfigNumber;
37
  boundsEast: VisConfigNumber;
38
  boundsNorth: VisConfigNumber;
39
};
40

41
export const bitmapVisConfigs = {
13✔
42
  opacity: {
43
    ...LAYER_VIS_CONFIGS.opacity,
44
    defaultValue: 1,
45
    property: 'opacity'
46
  } as VisConfigNumber,
47
  showBounds: {
48
    type: 'boolean',
49
    defaultValue: true,
50
    label: 'layerVisConfigs.showBounds',
51
    group: 'display',
52
    property: 'showBounds'
53
  } as VisConfigBoolean,
54
  editBounds: {
55
    type: 'boolean',
56
    defaultValue: false,
57
    label: 'layerVisConfigs.editBounds',
58
    group: 'display',
59
    property: 'editBounds'
60
  } as VisConfigBoolean,
61
  boundsWest: {
62
    type: 'number',
63
    defaultValue: 0,
64
    label: 'layerVisConfigs.boundsWest',
65
    isRanged: false,
66
    range: [-180, 180],
67
    step: 0.0001,
68
    group: 'display',
69
    property: 'boundsWest'
70
  } as VisConfigNumber,
71
  boundsSouth: {
72
    type: 'number',
73
    defaultValue: 0,
74
    label: 'layerVisConfigs.boundsSouth',
75
    isRanged: false,
76
    range: [-90, 90],
77
    step: 0.0001,
78
    group: 'display',
79
    property: 'boundsSouth'
80
  } as VisConfigNumber,
81
  boundsEast: {
82
    type: 'number',
83
    defaultValue: 0,
84
    label: 'layerVisConfigs.boundsEast',
85
    isRanged: false,
86
    range: [-180, 180],
87
    step: 0.0001,
88
    group: 'display',
89
    property: 'boundsEast'
90
  } as VisConfigNumber,
91
  boundsNorth: {
92
    type: 'number',
93
    defaultValue: 0,
94
    label: 'layerVisConfigs.boundsNorth',
95
    isRanged: false,
96
    range: [-90, 90],
97
    step: 0.0001,
98
    group: 'display',
99
    property: 'boundsNorth'
100
  } as VisConfigNumber
101
};
102

103
export default class BitmapOverlayLayer extends Layer {
104
  declare visConfigSettings: BitmapLayerVisConfigSettings;
105
  private _editFeatureCollection: any = null;
41✔
106
  private _prevDataBoundsKey: string = '';
41✔
107
  private _onRedrawNeeded: (() => void) | undefined;
108
  private _rafId: number | undefined;
109

110
  constructor(props: {dataId: string; visConfig?: Record<string, any>} & Record<string, any>) {
111
    super(props);
41✔
112
    this.registerVisConfig(bitmapVisConfigs);
41✔
113
    // Apply visConfig from findDefaultLayerProps (e.g. bounds seeded from metadata)
114
    if (props.visConfig) {
41✔
115
      this.updateLayerVisConfig(props.visConfig);
5✔
116
    }
117
    this.meta = {};
41✔
118
  }
119

120
  static findDefaultLayerProps(dataset: KeplerDataset): FindDefaultLayerPropsReturnValue {
121
    if (dataset.type !== DatasetType.BITMAP) {
98✔
122
      return {props: []};
96✔
123
    }
124

125
    const metadata = (dataset.metadata || {}) as BitmapDatasetMetadata;
2!
126
    const visConfig: Record<string, any> = {};
2✔
127

128
    if (metadata.bounds) {
2!
129
      const metaBounds = metadata.bounds;
2✔
130
      if (Array.isArray(metaBounds) && typeof metaBounds[0] === 'number') {
2✔
131
        const [w, s, e, n] = metaBounds as [number, number, number, number];
1✔
132
        visConfig.boundsWest = w;
1✔
133
        visConfig.boundsSouth = s;
1✔
134
        visConfig.boundsEast = e;
1✔
135
        visConfig.boundsNorth = n;
1✔
136
      } else if (Array.isArray(metaBounds) && Array.isArray(metaBounds[0])) {
1!
137
        const corners = metaBounds as [
1✔
138
          [number, number],
139
          [number, number],
140
          [number, number],
141
          [number, number]
142
        ];
143
        const lngs = corners.map(c => c[0]);
4✔
144
        const lats = corners.map(c => c[1]);
4✔
145
        visConfig.boundsWest = Math.min(...lngs);
1✔
146
        visConfig.boundsSouth = Math.min(...lats);
1✔
147
        visConfig.boundsEast = Math.max(...lngs);
1✔
148
        visConfig.boundsNorth = Math.max(...lats);
1✔
149
      }
150
    }
151

152
    return {
2✔
153
      props: [
154
        {
155
          label: dataset.label || 'Bitmap',
2!
156
          isVisible: true,
157
          visConfig
158
        }
159
      ]
160
    };
161
  }
162

163
  get type(): string {
164
    return LAYER_TYPES.bitmap;
6✔
165
  }
166

167
  get name(): string {
168
    return 'Bitmap';
28✔
169
  }
170

171
  get requireData(): boolean {
172
    return false;
29✔
173
  }
174

175
  get requiredLayerColumns(): string[] {
176
    return [];
43✔
177
  }
178

179
  get layerIcon(): typeof BitmapLayerIcon {
180
    return BitmapLayerIcon;
30✔
181
  }
182

183
  get supportedDatasetTypes(): DatasetType[] {
184
    return [DatasetType.BITMAP];
2✔
185
  }
186

187
  get visualChannels() {
188
    return {};
1✔
189
  }
190

191
  shouldRenderLayer(): boolean {
192
    return Boolean(this.type && this.config.isVisible);
2✔
193
  }
194

195
  getHoverData(): any {
196
    return null;
1✔
197
  }
198

199
  formatLayerData(datasets: KeplerDatasets): Record<string, any> {
200
    const {dataId} = this.config;
2✔
201
    if (!dataId || !datasets[dataId]) {
2✔
202
      return {};
1✔
203
    }
204

205
    const dataset = datasets[dataId];
1✔
206
    const metadata = (dataset.metadata || {}) as BitmapDatasetMetadata;
1!
207
    const {visConfig} = this.config;
1✔
208

209
    const bounds: BitmapBounds = [
1✔
210
      visConfig.boundsWest,
211
      visConfig.boundsSouth,
212
      visConfig.boundsEast,
213
      visConfig.boundsNorth
214
    ];
215

216
    this.updateMeta({bounds});
1✔
217

218
    return {
1✔
219
      imageUrl: metadata.imageUrl,
220
      bounds
221
    };
222
  }
223

224
  updateLayerMeta(_dataset: KeplerDataset): void {
225
    // bounds are managed through visConfig, meta.bounds is set in formatLayerData
226
  }
227

228
  renderLayer(opts: any) {
229
    const {data, layerCallbacks} = opts;
5✔
230
    const {imageUrl, bounds} = data || {};
5✔
231
    if (!imageUrl || !bounds) {
5✔
232
      return [];
2✔
233
    }
234

235
    this._onRedrawNeeded = layerCallbacks?.onRedrawNeeded;
3✔
236
    const {visConfig} = this.config;
3✔
237

238
    const {visible} = this.getDefaultDeckLayerProps(opts);
3✔
239

240
    const [west, south, east, north] = bounds as [number, number, number, number];
3✔
241
    const dataBoundsKey = `${west},${south},${east},${north}`;
3✔
242

243
    // Rebuild the editable feature collection only when data.bounds changes
244
    // (i.e., formatLayerData was re-run due to slider change or other external update).
245
    // During editing, data.bounds stays stale (cached), so this won't trigger.
246
    if (dataBoundsKey !== this._prevDataBoundsKey) {
3!
247
      this._prevDataBoundsKey = dataBoundsKey;
3✔
248
      this._editFeatureCollection = {
3✔
249
        type: 'FeatureCollection',
250
        features: [
251
          {
252
            type: 'Feature',
253
            geometry: {
254
              type: 'Polygon',
255
              coordinates: [
256
                [
257
                  [west, north],
258
                  [east, north],
259
                  [east, south],
260
                  [west, south],
261
                  [west, north]
262
                ]
263
              ]
264
            },
265
            properties: {shape: 'Rectangle'}
266
          }
267
        ]
268
      };
269
    }
270

271
    // Read current bounds from the edit feature collection (source of truth during editing)
272
    const editCoords =
3✔
273
      this._editFeatureCollection?.features?.[0]?.geometry?.coordinates?.[0];
274
    let activeBounds: [number, number, number, number];
3!
275
    if (editCoords && editCoords.length >= 4) {
276
      // Use all vertices (excluding closing point which duplicates the first)
3✔
277
      const pts = editCoords.slice(0, -1);
12✔
278
      const lngs = pts.map((c: number[]) => c[0]);
12✔
279
      const lats = pts.map((c: number[]) => c[1]);
3✔
280
      activeBounds = [
NEW
281
        Math.min(...lngs),
×
282
        Math.min(...lats),
283
        Math.max(...lngs),
284
        Math.max(...lats)
3✔
285
      ];
286
    } else {
287
      activeBounds = [west, south, east, north];
288
    }
289

3!
290
    const layers: any[] = [
291
      new DeckBitmapLayer({
292
        id: this.id,
293
        image: imageUrl,
294
        bounds: activeBounds,
295
        opacity: visConfig.opacity ?? 1,
296
        pickable: false,
297
        visible,
298
        parameters: {
299
          blend: true,
300
          blendColorSrcFactor: 'one',
301
          blendColorDstFactor: 'one-minus-src-alpha',
302
          blendAlphaSrcFactor: 'one',
3✔
303
          blendAlphaDstFactor: 'one-minus-src-alpha'
304
        }
1✔
305
      })
1✔
306
    ];
307

308
    if (visConfig.showBounds && !visConfig.editBounds) {
309
      // Static boundary outline (no interaction)
310
      const [aW, aS, aE, aN] = activeBounds;
311
      layers.push(
312
        new PathLayer({
313
          id: `${this.id}-bounds`,
314
          data: [
315
            [
316
              [aW, aN],
NEW
317
              [aE, aN],
×
318
              [aE, aS],
319
              [aW, aS],
320
              [aW, aN]
321
            ]
322
          ],
323
          getPath: (d: any) => d,
324
          getColor: [255, 255, 255, 200],
325
          getWidth: 2,
326
          widthUnits: 'pixels',
327
          pickable: false,
3✔
328
          visible
329
        })
1✔
330
      );
331
    }
332

333
    if (visConfig.editBounds) {
334
      // @ts-ignore - EditableGeoJsonLayer types are loose
335
      layers.push(
336
        new EditableGeoJsonLayer({
337
          id: `${this.id}-edit`,
338
          // @ts-ignore
339
          data: this._editFeatureCollection,
340
          mode: EDIT_MODE,
341
          selectedFeatureIndexes: [0],
342
          pickable: true,
343
          pickingRadius: 12,
344
          visible,
345
          modeConfig: {
346
            lockRectangles: true
347
          },
348
          filled: false,
349
          stroked: true,
350
          getLineColor: [255, 255, 255, 200],
NEW
351
          getLineWidth: 2,
×
352
          lineWidthUnits: 'pixels',
353
          getEditHandlePointColor: [255, 200, 0, 255],
NEW
354
          getEditHandlePointRadius: 6,
×
355
          editHandlePointRadiusUnits: 'pixels',
356
          onEdit: ({updatedData, editType}) => {
357
            this._editFeatureCollection = updatedData;
NEW
358

×
359
            const isFinal =
NEW
360
              editType === 'finishMovePosition' ||
×
NEW
361
              editType === 'translated' ||
×
NEW
362
              editType === 'addPosition';
×
NEW
363

×
NEW
364
            if (isFinal) {
×
NEW
365
              // Sync sliders on gesture end
×
NEW
366
              const geom = updatedData?.features?.[0]?.geometry;
×
367
              const coords =
368
                geom && 'coordinates' in geom ? (geom as any).coordinates[0] : null;
369
              if (coords && coords.length >= 5) {
370
                const pts = coords.slice(0, -1);
371
                const lngs = pts.map((c: number[]) => c[0]);
372
                const lats = pts.map((c: number[]) => c[1]);
NEW
373
                this.updateLayerVisConfig({
×
NEW
374
                  boundsWest: Math.min(...lngs),
×
NEW
375
                  boundsSouth: Math.min(...lats),
×
376
                  boundsEast: Math.max(...lngs),
NEW
377
                  boundsNorth: Math.max(...lats)
×
NEW
378
                });
×
379
              }
NEW
380
              if (this._rafId) {
×
NEW
381
                cancelAnimationFrame(this._rafId);
×
NEW
382
                this._rafId = undefined;
×
383
              }
384
              this._onRedrawNeeded?.();
385
            } else if (!this._rafId) {
386
              // Throttle live redraws to animation frame rate
387
              this._rafId = requestAnimationFrame(() => {
388
                this._rafId = undefined;
389
                this._onRedrawNeeded?.();
390
              });
3✔
391
            }
392
          }
393
        })
394
      );
395
    }
396

397
    return layers;
398
  }
399

400
}
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