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

visgl / deck.gl / 41890986773

04 Nov 2026 06:49PM UTC coverage: 84.815% (+0.7%) from 84.074%
41890986773

push

github

web-flow
feat(layers): accept Arrow columns in ScatterplotLayer and ArcLayer (#10920)

* Use upstream batch uniforms and constant bindings for Arrow layers

* fix(layers): preserve RGB accessor colors with Arrow columns

* Fix Arrow batch bindings and accessor defaults

* Infer Arrow instance counts per draw batch

* refactor(layers): use upstream vector model for Arrow columns

* fix(layers): honor Arrow instance count limits

* test(layers): verify Arrow batch instance counts

10328 of 12689 branches covered (81.39%)

Branch coverage included in aggregate %.

101 of 112 new or added lines in 6 files covered. (90.18%)

1 existing line in 1 file now uncovered.

16828 of 19329 relevant lines covered (87.06%)

32568.87 hits per line

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

94.4
/modules/layers/src/scatterplot-layer/scatterplot-layer.ts
1
// deck.gl
2
// SPDX-License-Identifier: MIT
3
// Copyright (c) vis.gl contributors
4

5
import {
6
  Layer,
7
  color,
8
  project32,
9
  picking,
10
  UNIT,
11
  _GPUVectorModel as DeckGPUVectorModel
12
} from '@deck.gl/core';
13
import {GPUVectorModel, GPUVectorGeometry, Geometry, makeGPUVectorGeometry} from '@luma.gl/engine';
14

15
import {scatterplotUniforms, ScatterplotProps} from './scatterplot-layer-uniforms';
16
import vs from './scatterplot-layer-vertex.glsl';
17
import fs from './scatterplot-layer-fragment.glsl';
18
import {getShaderWGSL} from './scatterplot-layer.wgsl';
19
import {
20
  ScatterplotLayerColumns,
21
  hasScatterplotColumns,
22
  type ScatterplotColumnProps
23
} from './scatterplot-layer-columns';
24

25
import type {
26
  LayerProps,
27
  LayerDataSource,
28
  UpdateParameters,
29
  Accessor,
30
  Unit,
31
  Position,
32
  Color,
33
  DefaultProps
34
} from '@deck.gl/core';
35

36
const defaultPosition = (row: any) => row.position;
9,020 ✔
37

38
const DEFAULT_COLOR = [0, 0, 0, 255] as const;
4 ✔
39

40
/** All props supported by the ScatterplotLayer */
41
export type ScatterplotLayerProps<DataT = unknown> = _ScatterplotLayerProps<DataT> & LayerProps;
42

43
/** Props added by the ScatterplotLayer */
44
type _ScatterplotLayerProps<DataT> = ScatterplotColumnProps & {
45
  data: LayerDataSource<DataT>;
46
  /**
47
   * The units of the radius, one of `'meters'`, `'common'`, and `'pixels'`.
48
   * @default 'meters'
49
   */
50
  radiusUnits?: Unit;
51
  /**
52
   * Radius multiplier.
53
   * @default 1
54
   */
55
  radiusScale?: number;
56
  /**
57
   * The minimum radius in pixels. This prop can be used to prevent the circle from getting too small when zoomed out.
58
   * @default 0
59
   */
60
  radiusMinPixels?: number;
61
  /**
62
   * The maximum radius in pixels. This prop can be used to prevent the circle from getting too big when zoomed in.
63
   * @default Number.MAX_SAFE_INTEGER
64
   */
65
  radiusMaxPixels?: number;
66

67
  /**
68
   * The units of the stroke width, one of `'meters'`, `'common'`, and `'pixels'`.
69
   * @default 'meters'
70
   */
71
  lineWidthUnits?: Unit;
72
  /**
73
   * Stroke width multiplier.
74
   * @default 1
75
   */
76
  lineWidthScale?: number;
77
  /**
78
   * The minimum stroke width in pixels. This prop can be used to prevent the line from getting too thin when zoomed out.
79
   * @default 0
80
   */
81
  lineWidthMinPixels?: number;
82
  /**
83
   * The maximum stroke width in pixels. This prop can be used to prevent the circle from getting too thick when zoomed in.
84
   * @default Number.MAX_SAFE_INTEGER
85
   */
86
  lineWidthMaxPixels?: number;
87

88
  /**
89
   * Draw the outline of points.
90
   * @default false
91
   */
92
  stroked?: boolean;
93
  /**
94
   * Draw the filled area of points.
95
   * @default true
96
   */
97
  filled?: boolean;
98
  /**
99
   * If `true`, rendered circles always face the camera. If `false` circles face up (i.e. are parallel with the ground plane).
100
   * @default false
101
   */
102
  billboard?: boolean;
103
  /**
104
   * If `true`, circles are rendered with smoothed edges. If `false`, circles are rendered with rough edges. Antialiasing can cause artifacts on edges of overlapping circles.
105
   * @default true
106
   */
107
  antialiasing?: boolean;
108

109
  /**
110
   * Center position accessor.
111
   */
112
  getPosition?: Accessor<DataT, Position>;
113
  /**
114
   * Radius accessor.
115
   * @default 1
116
   */
117
  getRadius?: Accessor<DataT, number>;
118
  /**
119
   * Fill color accessor.
120
   * @default [0, 0, 0, 255]
121
   */
122
  getFillColor?: Accessor<DataT, Color>;
123
  /**
124
   * Stroke color accessor.
125
   * @default [0, 0, 0, 255]
126
   */
127
  getLineColor?: Accessor<DataT, Color>;
128
  /**
129
   * Stroke width accessor.
130
   * @default 1
131
   */
132
  getLineWidth?: Accessor<DataT, number>;
133
  /**
134
   * Pixel offset accessor, [x, y] in pixels.
135
   * @default [0, 0]
136
   */
137
  getPixelOffset?: Accessor<DataT, Readonly<[number, number]>>;
138
  /**
139
   * @deprecated Use `getLineWidth` instead
140
   */
141
  strokeWidth?: number;
142
  /**
143
   * @deprecated Use `stroked` instead
144
   */
145
  outline?: boolean;
146
  /**
147
   * @deprecated Use `getFillColor` and `getLineColor` instead
148
   */
149
  getColor?: Accessor<DataT, Color>;
150
};
151

152
const defaultProps: DefaultProps<ScatterplotLayerProps> = {
4 ✔
153
  radiusUnits: 'meters',
154
  radiusScale: {type: 'number', min: 0, value: 1},
155
  radiusMinPixels: {type: 'number', min: 0, value: 0}, //  min point radius in pixels
156
  radiusMaxPixels: {type: 'number', min: 0, value: Number.MAX_SAFE_INTEGER}, // max point radius in pixels
157

158
  lineWidthUnits: 'meters',
159
  lineWidthScale: {type: 'number', min: 0, value: 1},
160
  lineWidthMinPixels: {type: 'number', min: 0, value: 0},
161
  lineWidthMaxPixels: {type: 'number', min: 0, value: Number.MAX_SAFE_INTEGER},
162

163
  stroked: false,
164
  filled: true,
165
  billboard: false,
166
  antialiasing: true,
167

168
  getPosition: {type: 'accessor', value: defaultPosition},
169
  getRadius: {type: 'accessor', value: 1},
170
  getFillColor: {type: 'accessor', value: DEFAULT_COLOR},
171
  getLineColor: {type: 'accessor', value: DEFAULT_COLOR},
172
  getLineWidth: {type: 'accessor', value: 1},
173
  getPixelOffset: {type: 'accessor', value: [0, 0]},
174
  positions: {type: 'object', value: null, optional: true},
175
  radii: {type: 'object', value: null, optional: true},
176
  fillColors: {type: 'object', value: null, optional: true},
177
  lineColors: {type: 'object', value: null, optional: true},
178
  lineWidths: {type: 'object', value: null, optional: true},
179

180
  // deprecated
181
  strokeWidth: {deprecatedFor: 'getLineWidth'},
182
  outline: {deprecatedFor: 'stroked'},
183
  getColor: {deprecatedFor: ['getFillColor', 'getLineColor']}
184
};
185

186
/** Render circles at given coordinates. */
187
export default class ScatterplotLayer<DataT = any, ExtraPropsT extends {} = {}> extends Layer<
188
  ExtraPropsT & Required<_ScatterplotLayerProps<DataT>>
189
> {
190
  static defaultProps = defaultProps;
4 ✔
191
  static layerName: string = 'ScatterplotLayer';
4 ✔
192

193
  state!: {
194
    model?: GPUVectorModel;
195
    geometry?: GPUVectorGeometry;
196
    columns?: ScatterplotLayerColumns;
197
  };
198

199
  /** Uses converted position bounds when Arrow columns bypass the attribute manager. */
200
  getBounds(): [number[], number[]] | null {
201
    return this.state?.columns ? this.state.columns.getBounds() : super.getBounds();
35 !
202
  }
203

204
  /** Bypasses accessor attribute generation when explicit columns drive the model. */
205
  protected _getAttributeManager() {
206
    return hasScatterplotColumns(this.props) ? null : super._getAttributeManager();
217 ✔
207
  }
208

209
  /** Uses the prepared column length as the instance count in column mode. */
210
  getNumInstances(): number {
211
    return this.state?.columns && !Number.isFinite(this.props.numInstances)
1,036 ✔
212
      ? this.state.columns.length
213
      : super.getNumInstances();
214
  }
215

216
  /** Keeps the layer loading until its asynchronous column conversion finishes. */
217
  get isLoaded(): boolean {
218
    return !this.state?.columns?.loading && super.isLoaded;
360 ✔
219
  }
220

221
  /** Releases column-owned resources and invalidates pending conversions on removal. */
222
  finalizeState(context) {
223
    this.state.columns?.destroy();
214 ✔
224
    super.finalizeState(context);
214 ✔
225
    this.state.geometry?.destroy();
214 ✔
226
  }
227

228
  getShaders() {
229
    const useRowIndexes = Boolean((this.props.data as any)?.attributes?.rowIndexes);
227 ✔
230
    return super.getShaders({
227 ✔
231
      vs,
232
      fs,
233
      source: getShaderWGSL(
234
        useRowIndexes,
235
        this.context.device.type === 'webgpu' && hasScatterplotColumns(this.props)
227 !
236
      ),
237
      defines: useRowIndexes ? {USE_ROW_INDEXES: true} : {},
227 ✔
238
      modules: [project32, color, picking, scatterplotUniforms]
239
    });
240
  }
241

242
  initializeState() {
243
    if (hasScatterplotColumns(this.props)) {
217 ✔
244
      if (this.props.extensions.length)
13 !
NEW
245
        throw new Error('Scatterplot Arrow columns do not support extensions');
×
246
      return;
13 ✔
247
    }
248
    this.initializeAccessorAttributes();
204 ✔
249
  }
250

251
  /** Registers the normal accessor attributes when entering or returning to accessor mode. */
252
  private initializeAccessorAttributes() {
253
    const attributes: Record<string, any> = (this.props.data as any)?.attributes?.rowIndexes
206 ✔
254
      ? {
255
          /** Caller-provided logical picking index per point instance. */
256
          rowIndexes: {
257
            size: 1,
258
            type: 'uint32',
259
            noAlloc: true
260
          }
261
        }
262
      : {};
263
    this.getAttributeManager()!.addInstanced({
206 ✔
264
      instancePositions: {
265
        size: 3,
266
        type: 'float64',
267
        fp64: this.use64bitPositions(),
268
        transition: true,
269
        accessor: 'getPosition',
270
        ...this.usePositionTransforms()
271
      },
272
      instanceRadius: {
273
        size: 1,
274
        transition: true,
275
        accessor: 'getRadius',
276
        defaultValue: 1,
277
        bufferGroup: 'scatterplot-instance-data'
278
      },
279
      instanceFillColors: {
280
        size: this.props.colorFormat.length,
281
        transition: true,
282
        type: 'unorm8',
283
        accessor: 'getFillColor',
284
        defaultValue: [0, 0, 0, 255],
285
        bufferGroup: 'scatterplot-instance-data'
286
      },
287
      instanceLineColors: {
288
        size: this.props.colorFormat.length,
289
        transition: true,
290
        type: 'unorm8',
291
        accessor: 'getLineColor',
292
        defaultValue: [0, 0, 0, 255],
293
        bufferGroup: 'scatterplot-instance-data'
294
      },
295
      instanceLineWidths: {
296
        size: 1,
297
        transition: true,
298
        accessor: 'getLineWidth',
299
        defaultValue: 1,
300
        bufferGroup: 'scatterplot-instance-data'
301
      },
302
      instancePixelOffset: {
303
        size: 2,
304
        transition: true,
305
        accessor: 'getPixelOffset',
306
        bufferGroup: 'scatterplot-instance-data'
307
      },
308
      ...attributes
309
    });
310
  }
311

312
  updateState(params: UpdateParameters<this>) {
313
    if (hasScatterplotColumns(this.props)) {
388 ✔
314
      this.updateColumnState(params);
45 ✔
315
      return;
45 ✔
316
    }
317
    if (this.state.columns) {
343 ✔
318
      this.state.columns.destroy();
2 ✔
319
      this.state.columns = undefined;
2 ✔
320
      this.internalState!.attributeManager = super._getAttributeManager();
2 ✔
321
      this.initializeAccessorAttributes();
2 ✔
322
      this.state.model?.destroy();
2 ✔
323
      this.state.model = this._getModel();
2 ✔
324
    }
325
    super.updateState(params);
343 ✔
326

327
    if (params.changeFlags.extensionsChanged) {
343 ✔
328
      this.state.model?.destroy();
209 ✔
329
      this.state.model = this._getModel();
209 ✔
330
      this.getAttributeManager()!.invalidateAll();
209 ✔
331
    }
332
  }
333

334
  /** Updates the model and schedules conversion while Arrow columns are active. */
335
  private updateColumnState(params: UpdateParameters<this>): void {
336
    if (this.props.extensions.length)
45 ✔
337
      throw new Error('Scatterplot Arrow columns do not support extensions');
2 ✔
338
    const enteringColumns = !this.state.columns;
43 ✔
339
    if (enteringColumns) {
43 ✔
340
      this.internalState!.attributeManager?.finalize();
14 ✔
341
      this.internalState!.attributeManager = null;
14 ✔
342
      this.state.columns = new ScatterplotLayerColumns(this.context.device);
14 ✔
343
    }
344
    if (
43 ✔
345
      enteringColumns ||
101 ✔
346
      params.changeFlags.extensionsChanged ||
347
      Boolean((params.props.data as any)?.attributes?.rowIndexes) !==
348
        Boolean((params.oldProps.data as any)?.attributes?.rowIndexes)
349
    ) {
350
      this.state.model?.destroy();
16 ✔
351
      this.state.model = this._getModel();
16 ✔
352
    }
353
    void this.state.columns!.updateIfNeeded(params, {
43 ✔
354
      dataLoading: this.internalState!.isAsyncPropLoading('data'),
355
      changed: () => {
356
        this.setState({});
20 ✔
357
        this.setNeedsUpdate();
20 ✔
358
      },
359
      failed: error => this.raiseError(error, 'converting Arrow columns'),
1 ✔
360
      positionOptions: {
361
        transform: this.usePositionTransforms().transform?.bind(this),
362
        fp64: this.use64bitPositions(),
363
        requireRows: !this.props.positions && this.props.getPosition === defaultPosition
56 ✔
364
      }
365
    });
366
  }
367

368
  draw({uniforms}) {
369
    const {
370
      radiusUnits,
371
      radiusScale,
372
      radiusMinPixels,
373
      radiusMaxPixels,
374
      stroked,
375
      filled,
376
      billboard,
377
      antialiasing,
378
      lineWidthUnits,
379
      lineWidthScale,
380
      lineWidthMinPixels,
381
      lineWidthMaxPixels
382
    } = this.props;
614 ✔
383
    const scatterplotProps: ScatterplotProps = {
614 ✔
384
      stroked,
385
      filled,
386
      billboard,
387
      antialiasing,
388
      radiusUnits: UNIT[radiusUnits],
389
      radiusScale,
390
      radiusMinPixels,
391
      radiusMaxPixels,
392
      lineWidthUnits: UNIT[lineWidthUnits],
393
      lineWidthScale,
394
      lineWidthMinPixels,
395
      lineWidthMaxPixels,
396
      rowIndexOffset: 0
397
    };
398
    const model = this.state.model;
614 ✔
399
    if (!model) return;
614 !
400
    if (
614 ✔
401
      this.state.columns &&
648 ✔
402
      this.state.columns.hasRowIndexes !== Boolean((this.props.data as any)?.attributes?.rowIndexes)
403
    )
404
      return;
7 ✔
405
    model.shaderInputs.setProps({scatterplot: scatterplotProps});
607 ✔
406
    if (this.state.columns) {
607 ✔
407
      this.state.columns.draw(model, this.context.renderPass, offset => {
27 ✔
408
        model.setInstanceCount(
15 ✔
409
          Math.min(model.instanceCount, Math.max(0, this.getNumInstances() - offset))
410
        );
411
        model.shaderInputs.setProps({scatterplot: {rowIndexOffset: offset}});
15 ✔
412
      });
413
    } else {
414
      model.draw(this.context.renderPass);
580 ✔
415
    }
416
  }
417

418
  protected _getModel() {
419
    // a square that minimally cover the unit circle
420
    const positions = [-1, -1, 0, 1, -1, 0, -1, 1, 0, 1, 1, 0];
227 ✔
421
    this.state.geometry?.destroy();
227 ✔
422
    this.state.geometry = undefined;
227 ✔
423
    const geometry = new Geometry({
227 ✔
424
      topology: 'triangle-strip',
425
      attributes: {positions: {size: 3, value: new Float32Array(positions)}}
426
    });
427
    const options = {
227 ✔
428
      ...this.getShaders(),
429
      id: this.props.id,
430
      bufferLayout:
431
        this.state.columns?.getBufferLayouts(
438 ✔
432
          Boolean((this.props.data as any)?.attributes?.rowIndexes)
433
        ) ?? this.getAttributeManager()!.getBufferLayouts(),
434
      isInstanced: true
435
    };
436
    if (this.state.columns) {
227 ✔
437
      if (this.context.device.type === 'webgpu') {
16 !
NEW
438
        return new GPUVectorModel(this.context.device, {
×
439
          ...options,
440
          topology: 'triangle-strip',
441
          vertexCount: 4,
442
          inferDrawCount: 'instance'
443
        });
444
      }
445
      const vectorGeometry = makeGPUVectorGeometry(this.context.device, geometry);
226 ✔
446
      this.state.geometry = vectorGeometry;
16 ✔
447
      return new GPUVectorModel(this.context.device, {
16 ✔
448
        ...options,
449
        geometry: vectorGeometry,
450
        inferDrawCount: 'instance'
451
      });
452
    }
453
    return new DeckGPUVectorModel(this.context.device, {
211 ✔
454
      ...options,
455
      geometry,
456
      enforceMatchingVectorLengths: false,
457
      inferDrawCount: 'no'
458
    });
459
  }
460
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc