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

keplergl / kepler.gl / 28945169591

08 Jul 2026 01:09PM UTC coverage: 56.147% (-0.1%) from 56.292%
28945169591

push

github

web-flow
fix: bitmap layer fixes (#3474)

* fix bitmap layer opacity

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

* fix: bitmap layer fixes

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

* add 3-point alignment

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

* fixes

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

* fixes

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>

7723 of 16445 branches covered (46.96%)

Branch coverage included in aggregate %.

19 of 103 new or added lines in 3 files covered. (18.45%)

17 existing lines in 2 files now uncovered.

15551 of 25007 relevant lines covered (62.19%)

73.72 hits per line

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

50.53
/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, ScatterplotLayer} 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
  alignMode: VisConfigBoolean;
36
  boundsWest: VisConfigNumber;
37
  boundsSouth: VisConfigNumber;
38
  boundsEast: VisConfigNumber;
39
  boundsNorth: VisConfigNumber;
40
};
41

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

111
export type AlignControlPoint = {
112
  uv: [number, number]; // normalized image coordinates (0-1)
113
  geo: [number, number]; // [lng, lat]
114
};
115

116
export default class BitmapOverlayLayer extends Layer {
117
  declare visConfigSettings: BitmapLayerVisConfigSettings;
118
  private _editFeatureCollection: any = null;
41✔
119
  private _prevDataBoundsKey: string = '';
41✔
120
  private _onRedrawNeeded: (() => void) | undefined;
121
  private _rafId: number | undefined;
122

123
  // Alignment mode state
124
  alignControlPoints: AlignControlPoint[] = [];
41✔
125
  alignWaitingForMap: boolean = false;
41✔
126
  private _pendingUV: [number, number] | null = null;
41✔
127

128
  constructor(props: {dataId: string; visConfig?: Record<string, any>} & Record<string, any>) {
129
    super(props);
41✔
130
    this.registerVisConfig(bitmapVisConfigs);
41✔
131
    if (props.visConfig) {
41✔
132
      const {alignMode: _, editBounds: __, ...persistedVisConfig} = props.visConfig;
5✔
133
      super.updateLayerVisConfig(persistedVisConfig);
5✔
134
    }
135
    this.meta = {};
41✔
136
  }
137

138
  validateVisConfig(_dataset: KeplerDataset, visConfig: Record<string, any>): Record<string, any> {
NEW
139
    const {alignMode: _, editBounds: __, ...rest} = visConfig;
×
NEW
140
    return rest;
×
141
  }
142

143
  // --- Alignment mode methods ---
144

145
  onAlignImageClick(uv: [number, number]): void {
NEW
146
    this._pendingUV = uv;
×
NEW
147
    this.alignWaitingForMap = true;
×
148
  }
149

150
  onAlignMapClick(lngLat: [number, number]): void {
NEW
151
    if (!this._pendingUV) return;
×
NEW
152
    this.alignControlPoints = [
×
NEW
153
      ...this.alignControlPoints,
×
NEW
154
      {uv: this._pendingUV, geo: lngLat}
×
155
    ];
NEW
156
    this._pendingUV = null;
×
NEW
157
    this.alignWaitingForMap = false;
×
158

159
    if (this.alignControlPoints.length >= 2) {
160
      this._computeBoundsFromControlPoints();
161
    }
NEW
162
  }
×
NEW
163

×
NEW
164
  resetAlignControlPoints(): void {
×
165
    this.alignControlPoints = [];
166
    this.alignWaitingForMap = false;
167
    this._pendingUV = null;
NEW
168
  }
×
NEW
169

×
170
  private _computeBoundsFromControlPoints(): void {
171
    const pts = this.alignControlPoints;
172
    if (pts.length < 2) return;
NEW
173

×
NEW
174
    // Solve affine: lng = a * u + b, lat = c * v + d
×
NEW
175
    // From 2+ points, use least squares (for 2 points, exact solution)
×
NEW
176
    const n = pts.length;
×
NEW
177
    let sumU = 0, sumLng = 0, sumUU = 0, sumULng = 0;
×
NEW
178
    let sumV = 0, sumLat = 0, sumVV = 0, sumVLat = 0;
×
NEW
179
    for (const p of pts) {
×
NEW
180
      const [u, v] = p.uv;
×
NEW
181
      const [lng, lat] = p.geo;
×
NEW
182
      sumU += u; sumLng += lng; sumUU += u * u; sumULng += u * lng;
×
NEW
183
      sumV += v; sumLat += lat; sumVV += v * v; sumVLat += v * lat;
×
NEW
184
    }
×
NEW
185

×
NEW
186
    // lng = a*u + b (solve for a, b)
×
NEW
187
    const detU = n * sumUU - sumU * sumU;
×
NEW
188
    if (Math.abs(detU) < 1e-12) return;
×
NEW
189
    const a = (n * sumULng - sumU * sumLng) / detU;
×
NEW
190
    const b = (sumLng - a * sumU) / n;
×
NEW
191

×
NEW
192
    // lat = c*v + d (solve for c, d)
×
193
    const detV = n * sumVV - sumV * sumV;
194
    if (Math.abs(detV) < 1e-12) return;
195
    const c = (n * sumVLat - sumV * sumLat) / detV;
NEW
196
    const d = (sumLat - c * sumV) / n;
×
NEW
197

×
NEW
198
    // Bounds: image corners are (0,0), (1,0), (1,1), (0,1)
×
NEW
199
    // UV origin (0,0) = top-left of image, (1,1) = bottom-right
×
200
    const west = b;         // u=0
201
    const east = a + b;     // u=1
NEW
202
    const north = d;        // v=0 (top of image)
×
NEW
203
    const south = c + d;    // v=1 (bottom of image)
×
NEW
204

×
NEW
205
    this.updateLayerVisConfig({
×
206
      boundsWest: Math.min(west, east),
207
      boundsEast: Math.max(west, east),
208
      boundsSouth: Math.min(south, north),
NEW
209
      boundsNorth: Math.max(south, north)
×
NEW
210
    });
×
NEW
211

×
NEW
212
    this._onRedrawNeeded?.();
×
213
  }
NEW
214

×
215
  updateLayerVisConfig(newVisConfig: Record<string, any>): this {
216
    if ('alignMode' in newVisConfig && !newVisConfig.alignMode) {
217
      this.resetAlignControlPoints();
218
    }
219
    // Make alignMode and editBounds mutually exclusive
220
    if (newVisConfig.alignMode && this.config.visConfig.editBounds) {
NEW
221
      newVisConfig.editBounds = false;
×
222
    }
223
    if (newVisConfig.editBounds && this.config.visConfig.alignMode) {
224
      newVisConfig.alignMode = false;
225
      this.resetAlignControlPoints();
5!
NEW
226
    }
×
227
    super.updateLayerVisConfig(newVisConfig);
228
    return this;
229
  }
5!
NEW
230

×
231
  static findDefaultLayerProps(dataset: KeplerDataset): FindDefaultLayerPropsReturnValue {
232
    if (dataset.type !== DatasetType.BITMAP) {
5!
UNCOV
233
      return {props: []};
×
UNCOV
234
    }
×
235

236
    const metadata = (dataset.metadata || {}) as BitmapDatasetMetadata;
5✔
237
    const visConfig: Record<string, any> = {};
5✔
238

239
    if (metadata.bounds) {
240
      const metaBounds = metadata.bounds;
241
      if (Array.isArray(metaBounds) && typeof metaBounds[0] === 'number') {
98✔
242
        const [w, s, e, n] = metaBounds as [number, number, number, number];
96✔
243
        visConfig.boundsWest = w;
244
        visConfig.boundsSouth = s;
245
        visConfig.boundsEast = e;
2!
246
        visConfig.boundsNorth = n;
2✔
247
      } else if (Array.isArray(metaBounds) && Array.isArray(metaBounds[0])) {
248
        const corners = metaBounds as [
2!
249
          [number, number],
2✔
250
          [number, number],
2✔
251
          [number, number],
1✔
252
          [number, number]
1✔
253
        ];
1✔
254
        const lngs = corners.map(c => c[0]);
1✔
255
        const lats = corners.map(c => c[1]);
1✔
256
        visConfig.boundsWest = Math.min(...lngs);
1!
257
        visConfig.boundsSouth = Math.min(...lats);
1✔
258
        visConfig.boundsEast = Math.max(...lngs);
259
        visConfig.boundsNorth = Math.max(...lats);
260
      }
261
    }
262

263
    return {
4✔
264
      props: [
4✔
265
        {
1✔
266
          label: dataset.label || 'Bitmap',
1✔
267
          isVisible: true,
1✔
268
          visConfig
1✔
269
        }
270
      ]
271
    };
272
  }
2✔
273

274
  get type(): string {
275
    return LAYER_TYPES.bitmap;
2!
276
  }
277

278
  get name(): string {
279
    return 'Bitmap';
280
  }
281

282
  get requireData(): boolean {
283
    return false;
284
  }
6✔
285

286
  get requiredLayerColumns(): string[] {
287
    return [];
288
  }
28✔
289

290
  get layerIcon(): typeof BitmapLayerIcon {
291
    return BitmapLayerIcon;
292
  }
29✔
293

294
  get supportedDatasetTypes(): DatasetType[] {
295
    return [DatasetType.BITMAP];
296
  }
43✔
297

298
  get visualChannels() {
299
    return {};
300
  }
30✔
301

302
  shouldRenderLayer(): boolean {
303
    return Boolean(this.type && this.config.isVisible);
304
  }
2✔
305

306
  getHoverData(): any {
307
    return null;
308
  }
1✔
309

310
  formatLayerData(datasets: KeplerDatasets): Record<string, any> {
311
    const {dataId} = this.config;
312
    if (!dataId || !datasets[dataId]) {
2✔
313
      return {};
314
    }
315

316
    const dataset = datasets[dataId];
1✔
317
    const metadata = (dataset.metadata || {}) as BitmapDatasetMetadata;
318
    const {visConfig} = this.config;
319

320
    const bounds: BitmapBounds = [
2✔
321
      visConfig.boundsWest,
2✔
322
      visConfig.boundsSouth,
1✔
323
      visConfig.boundsEast,
324
      visConfig.boundsNorth
325
    ];
1✔
326

1!
327
    this.updateMeta({bounds});
1✔
328

329
    return {
1✔
330
      imageUrl: metadata.imageUrl,
331
      bounds
332
    };
333
  }
334

335
  updateLayerMeta(_dataset: KeplerDataset): void {
336
    // bounds are managed through visConfig, meta.bounds is set in formatLayerData
1✔
337
  }
338

1✔
339
  renderLayer(opts: any) {
340
    const {data, layerCallbacks} = opts;
341
    const {imageUrl, bounds} = data || {};
342
    if (!imageUrl || !bounds) {
343
      return [];
344
    }
345

346
    this._onRedrawNeeded = layerCallbacks?.onRedrawNeeded;
347
    const {visConfig} = this.config;
348

349
    const {visible} = this.getDefaultDeckLayerProps(opts);
5✔
350

5✔
351
    // Use live visConfig bounds (handles direct mutations from alignment mode)
5✔
352
    // rather than the potentially stale cached data.bounds from formatLayerData
2✔
353
    const west = visConfig.boundsWest ?? bounds[0];
354
    const south = visConfig.boundsSouth ?? bounds[1];
355
    const east = visConfig.boundsEast ?? bounds[2];
3✔
356
    const north = visConfig.boundsNorth ?? bounds[3];
3✔
357
    const dataBoundsKey = `${west},${south},${east},${north}`;
358

3✔
359
    // Rebuild the editable feature collection only when data.bounds changes
360
    // (i.e., formatLayerData was re-run due to slider change or other external update).
361
    // During editing, data.bounds stays stale (cached), so this won't trigger.
362
    if (dataBoundsKey !== this._prevDataBoundsKey) {
3!
363
      this._prevDataBoundsKey = dataBoundsKey;
3!
364
      this._editFeatureCollection = {
3!
365
        type: 'FeatureCollection',
3!
366
        features: [
3✔
367
          {
368
            type: 'Feature',
369
            geometry: {
370
              type: 'Polygon',
371
              coordinates: [
3!
372
                [
3✔
373
                  [west, north],
3✔
374
                  [east, north],
375
                  [east, south],
376
                  [west, south],
377
                  [west, north]
378
                ]
379
              ]
380
            },
381
            properties: {shape: 'Rectangle'}
382
          }
383
        ]
384
      };
385
    }
386

387
    // Read current bounds from the edit feature collection (source of truth during editing)
388
    const editCoords =
389
      this._editFeatureCollection?.features?.[0]?.geometry?.coordinates?.[0];
390
    let activeBounds: [number, number, number, number];
391
    if (editCoords && editCoords.length >= 4) {
392
      // Use all vertices (excluding closing point which duplicates the first)
393
      const pts = editCoords.slice(0, -1);
394
      const lngs = pts.map((c: number[]) => c[0]);
395
      const lats = pts.map((c: number[]) => c[1]);
396
      activeBounds = [
397
        Math.min(...lngs),
3✔
398
        Math.min(...lats),
399
        Math.max(...lngs),
3!
400
        Math.max(...lats)
401
      ];
3✔
402
    } else {
12✔
403
      activeBounds = [west, south, east, north];
12✔
404
    }
3✔
405

NEW
406
    const isAligning = visConfig.alignMode;
×
407

408
    const layers: any[] = [
409
      new DeckBitmapLayer({
3✔
410
        id: this.id,
411
        image: imageUrl,
3✔
412
        bounds: activeBounds,
413
        opacity: visConfig.opacity ?? 1,
414
        pickable: isAligning,
415
        visible,
416
        onClick: isAligning
3!
417
          ? (info: any): boolean | undefined => {
418
              if (!this.alignWaitingForMap && info.bitmap?.uv) {
419
                this.onAlignImageClick(info.bitmap.uv as [number, number]);
3!
420
                this._onRedrawNeeded?.();
NEW
421
                return true;
×
NEW
422
              }
×
NEW
423
              return undefined;
×
NEW
424
            }
×
425
          : undefined
UNCOV
426
      })
×
427
    ];
428

429
    // Visualize alignment control points
430
    if (visConfig.alignMode && this.alignControlPoints.length > 0) {
431
      layers.push(
432
        new ScatterplotLayer({
433
          id: `${this.id}-align-pts`,
3!
NEW
434
          data: this.alignControlPoints,
×
435
          getPosition: (d: AlignControlPoint) => d.geo,
436
          getFillColor: [255, 100, 100, 220],
437
          getRadius: 6,
NEW
438
          radiusUnits: 'pixels',
×
439
          pickable: false,
440
          visible,
441
          updateTriggers: {
442
            getPosition: this.alignControlPoints.length
443
          }
444
        })
445
      );
446
    }
447

448
    // Visualize pending point (waiting for map click)
449
    if (visConfig.alignMode && this._pendingUV) {
450
      const [u, v] = this._pendingUV;
451
      const [aW, aS, aE, aN] = activeBounds;
452
      const pendingLng = aW + u * (aE - aW);
3!
NEW
453
      const pendingLat = aN + v * (aS - aN);
×
NEW
454
      layers.push(
×
NEW
455
        new ScatterplotLayer({
×
NEW
456
          id: `${this.id}-align-pending`,
×
NEW
457
          data: [{position: [pendingLng, pendingLat]}],
×
458
          getPosition: (d: any) => d.position,
459
          getFillColor: [100, 200, 255, 220],
460
          getLineColor: [255, 255, 255, 255],
NEW
461
          getRadius: 8,
×
462
          radiusUnits: 'pixels',
463
          stroked: true,
464
          lineWidthMinPixels: 2,
465
          pickable: false,
466
          visible
467
        })
468
      );
469
    }
470

471
    if (visConfig.showBounds && !visConfig.editBounds) {
472
      // Static boundary outline (no interaction)
473
      const [aW, aS, aE, aN] = activeBounds;
474
      layers.push(
3✔
475
        new PathLayer({
476
          id: `${this.id}-bounds`,
1✔
477
          data: [
1✔
478
            [
479
              [aW, aN],
480
              [aE, aN],
481
              [aE, aS],
482
              [aW, aS],
483
              [aW, aN]
484
            ]
485
          ],
486
          getPath: (d: any) => d,
487
          getColor: [255, 255, 255, 200],
488
          getWidth: 2,
UNCOV
489
          widthUnits: 'pixels',
×
490
          pickable: false,
491
          visible
492
        })
493
      );
494
    }
495

496
    if (visConfig.editBounds) {
497
      // @ts-ignore - EditableGeoJsonLayer types are loose
498
      layers.push(
499
        new EditableGeoJsonLayer({
3✔
500
          id: `${this.id}-edit`,
501
          // @ts-ignore
1✔
502
          data: this._editFeatureCollection,
503
          mode: EDIT_MODE,
504
          selectedFeatureIndexes: [0],
505
          pickable: true,
506
          pickingRadius: 12,
507
          visible,
508
          modeConfig: {
509
            lockRectangles: true
510
          },
511
          filled: false,
512
          stroked: true,
513
          getLineColor: [255, 255, 255, 200],
514
          getLineWidth: 2,
515
          lineWidthUnits: 'pixels',
516
          getEditHandlePointColor: [255, 200, 0, 255],
517
          getEditHandlePointRadius: 6,
518
          editHandlePointRadiusUnits: 'pixels',
519
          onEdit: ({updatedData, editType}) => {
520
            this._editFeatureCollection = updatedData;
521

522
            const isFinal =
523
              editType === 'finishMovePosition' ||
×
524
              editType === 'translated' ||
525
              editType === 'addPosition';
526

×
527
            if (isFinal) {
528
              // Sync sliders on gesture end
529
              const geom = updatedData?.features?.[0]?.geometry;
UNCOV
530
              const coords =
×
531
                geom && 'coordinates' in geom ? (geom as any).coordinates[0] : null;
UNCOV
532
              if (coords && coords.length >= 5) {
×
UNCOV
533
                const pts = coords.slice(0, -1);
×
UNCOV
534
                const lngs = pts.map((c: number[]) => c[0]);
×
UNCOV
535
                const lats = pts.map((c: number[]) => c[1]);
×
536
                this.updateLayerVisConfig({
×
537
                  boundsWest: Math.min(...lngs),
×
538
                  boundsSouth: Math.min(...lats),
×
539
                  boundsEast: Math.max(...lngs),
540
                  boundsNorth: Math.max(...lats)
541
                });
542
              }
543
              if (this._rafId) {
544
                cancelAnimationFrame(this._rafId);
545
                this._rafId = undefined;
×
UNCOV
546
              }
×
UNCOV
547
              this._onRedrawNeeded?.();
×
548
            } else if (!this._rafId) {
UNCOV
549
              // Throttle live redraws to animation frame rate
×
UNCOV
550
              this._rafId = requestAnimationFrame(() => {
×
551
                this._rafId = undefined;
UNCOV
552
                this._onRedrawNeeded?.();
×
UNCOV
553
              });
×
UNCOV
554
            }
×
555
          }
556
        })
557
      );
558
    }
559

560
    return layers;
561
  }
562

3✔
563
}
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