From fc05cf82e9f880270d79afbccf7c9a42d77fd030 Mon Sep 17 00:00:00 2001 From: Bailey Lissington Date: Wed, 13 May 2026 00:20:30 -0600 Subject: [PATCH] fix(editable-layers): correct SelectionLayer polygon picking for elongated lassos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The polygon selection path used `deck.pickObjects()` over the lasso's bounding box, masked by a "blocker" PolygonLayer rendered as a 50 km (`EXPANSION_KM`) buffer around the lasso. When the lasso's bbox extended more than 50 km past its edges (elongated or diagonal lassos), the blocker left gaps in the picking framebuffer and objects in those gaps were incorrectly returned as selected. The approach also depended on a `setTimeout(250)` to wait for the blocker to render and dropped overlapping objects at the same pixel. Replace the GPU-pick + blocker mechanism with a CPU point-in-polygon test: iterate the data of every layer named in `layerIds`, resolve each datum's position via `getPosition` (with `position` / `coordinates` fallbacks), and emit pickingInfos for matches via `@turf/boolean-point-in-polygon`. The test is exact, synchronous, and unconstrained by buffer distance or framebuffer resolution. Removes `EXPANSION_KM`, the blocker `PolygonLayer`, the `pendingPolygonSelection` state, the `setTimeout`, and the `@turf/buffer` / `@turf/difference` dependencies. Selection now supports only layers exposing per-datum positions (e.g. `ScatterplotLayer`, `IconLayer`, `ColumnLayer`) — matching the layer types in the docs example and the repo's own usages. Also addresses the long-standing concave-lasso bug filed as nebula.gl#132. --- .../src/editable-layers/selection-layer.ts | 136 +++++++----------- 1 file changed, 54 insertions(+), 82 deletions(-) diff --git a/modules/editable-layers/src/editable-layers/selection-layer.ts b/modules/editable-layers/src/editable-layers/selection-layer.ts index a7db438b..e3169ac2 100644 --- a/modules/editable-layers/src/editable-layers/selection-layer.ts +++ b/modules/editable-layers/src/editable-layers/selection-layer.ts @@ -4,17 +4,15 @@ /* eslint-env browser */ -import type {CompositeLayerProps, DefaultProps} from '@deck.gl/core'; +import type {CompositeLayerProps, DefaultProps, Layer, PickingInfo} from '@deck.gl/core'; import {CompositeLayer} from '@deck.gl/core'; -import {PolygonLayer} from '@deck.gl/layers'; -import {featureCollection, polygon} from '@turf/helpers'; -import turfBuffer from '@turf/buffer'; -import turfDifference from '@turf/difference'; +import booleanPointInPolygon from '@turf/boolean-point-in-polygon'; +import {point, polygon} from '@turf/helpers'; -import {EditableGeoJsonLayer} from './editable-geojson-layer'; -import {DrawRectangleMode} from '../edit-modes/draw-rectangle-mode'; import {DrawPolygonMode} from '../edit-modes/draw-polygon-mode'; +import {DrawRectangleMode} from '../edit-modes/draw-rectangle-mode'; import {ViewMode} from '../edit-modes/view-mode'; +import {EditableGeoJsonLayer} from './editable-geojson-layer'; export const SELECTION_TYPE = { NONE: null, @@ -49,9 +47,7 @@ const EMPTY_DATA = { features: [] }; -const EXPANSION_KM = 50; const LAYER_ID_GEOJSON = 'selection-geojson'; -const LAYER_ID_BLOCKER = 'selection-blocker'; const PASS_THROUGH_PROPS = [ 'lineWidthScale', @@ -75,18 +71,13 @@ const PASS_THROUGH_PROPS = [ 'getTentativeFillColor', 'getTentativeLineWidth' ]; + export class SelectionLayer extends CompositeLayer< ExtraPropsT & Required> > { static layerName = 'SelectionLayer'; static defaultProps = defaultProps; - state: { - pendingPolygonSelection: { - bigPolygon: ReturnType; - }; - } = undefined!; - _selectRectangleObjects(coordinates: any) { const {layerIds, onSelect} = this.props; const [x1, y1] = this.context.viewport.project(coordinates[0][0]); @@ -104,57 +95,26 @@ export class SelectionLayer extends CompositeLayer< _selectPolygonObjects(coordinates: any) { const {layerIds, onSelect} = this.props; - const mousePoints = coordinates[0].map(c => this.context.viewport.project(c)); - - const allX = mousePoints.map(mousePoint => mousePoint[0]); - const allY = mousePoints.map(mousePoint => mousePoint[1]); - const x = Math.min(...allX); - const y = Math.min(...allY); - const maxX = Math.max(...allX); - const maxY = Math.max(...allY); - - // Use a polygon to hide the outside, because pickObjects() - // does not support polygons - const landPointsPoly = polygon(coordinates); - const bigBuffer = turfBuffer(landPointsPoly, EXPANSION_KM); - let bigPolygon; - try { - // turfDifference throws an exception if the polygon - // intersects with itself (TODO: check if true in all versions) - bigPolygon = turfDifference(featureCollection([bigBuffer, landPointsPoly])); - } catch (e) { - // invalid selection polygon - console.log('turfDifference() error', e); // eslint-disable-line - return; - } - - this.setState({ - pendingPolygonSelection: { - bigPolygon - } - }); - - const blockerId = `${this.props.id}-${LAYER_ID_BLOCKER}`; - - // HACK, find a better way - setTimeout(() => { - const pickingInfos = this.context.deck.pickObjects({ - x, - y, - width: maxX - x, - height: maxY - y, - layerIds: [blockerId, ...layerIds] + const selectionPolygon = polygon(coordinates); + + const pickingInfos: SelectionPickingInfo[] = this.context.layerManager + .getLayers() + .filter(layer => layerIds.includes(layer.id)) + .flatMap(layer => { + const data = layer.props.data; + if (!Array.isArray(data)) return []; + return data.flatMap((object, index): SelectionPickingInfo[] => { + const position = extractPosition(layer, object, index, data); + if (position === null) return []; + if (!booleanPointInPolygon(point(position), selectionPolygon)) return []; + return [{object, layer, index}]; + }); }); - onSelect({ - pickingInfos: pickingInfos.filter(item => item.layer.id !== this.props.id) - }); - }, 250); + onSelect({pickingInfos}); } renderLayers() { - const {pendingPolygonSelection} = this.state; - const mode = MODE_MAP[this.props.selectionType] || ViewMode; const modeConfig = MODE_CONFIG_MAP[this.props.selectionType]; @@ -163,7 +123,7 @@ export class SelectionLayer extends CompositeLayer< if (this.props[p] !== undefined) inheritedProps[p] = this.props[p]; }); - const layers: any[] = [ + return [ new EditableGeoJsonLayer( this.getSubLayerProps({ id: LAYER_ID_GEOJSON, @@ -187,29 +147,41 @@ export class SelectionLayer extends CompositeLayer< }) ) ]; - - if (pendingPolygonSelection) { - const {bigPolygon} = pendingPolygonSelection as any; - layers.push( - new PolygonLayer( - this.getSubLayerProps({ - id: LAYER_ID_BLOCKER, - pickable: true, - stroked: false, - opacity: 1.0, - data: [bigPolygon], - getLineColor: _obj => [0, 0, 0, 1], - getFillColor: _obj => [0, 0, 0, 1], - getPolygon: o => o.geometry.coordinates - }) - ) - ); - } - - return layers; } shouldUpdateState({changeFlags: {stateChanged, propsOrDataChanged}}: Record) { return stateChanged || propsOrDataChanged; } } + +type SelectionPickingInfo = Pick; + +function extractPosition( + layer: Layer, + object: unknown, + index: number, + data: unknown +): [number, number] | null { + const props = layer.props; + if ('getPosition' in props && typeof props.getPosition === 'function') { + const result = props.getPosition(object, {index, data, target: []}); + if (Array.isArray(result) && typeof result[0] === 'number' && typeof result[1] === 'number') { + return [result[0], result[1]]; + } + } + if (typeof object === 'object' && object !== null) { + if ('position' in object) { + const p = object.position; + if (Array.isArray(p) && typeof p[0] === 'number' && typeof p[1] === 'number') { + return [p[0], p[1]]; + } + } + if ('coordinates' in object) { + const c = object.coordinates; + if (Array.isArray(c) && typeof c[0] === 'number' && typeof c[1] === 'number') { + return [c[0], c[1]]; + } + } + } + return null; +}