From dd6d6e230eb5c4946e99870419274b45b6324594 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Fri, 9 May 2025 12:57:18 -0400 Subject: [PATCH] feat(widgets): SelectionWidget --- modules/widgets/src/index.ts | 2 + modules/widgets/src/selection-widget.tsx | 116 ++++++++++++++++++ test/apps/widgets-example-9.2/app.ts | 30 ++++- test/apps/widgets-example-9.2/package.json | 5 +- test/modules/widgets/selection-widget.spec.ts | 24 ++++ 5 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 modules/widgets/src/selection-widget.tsx create mode 100644 test/modules/widgets/selection-widget.spec.ts diff --git a/modules/widgets/src/index.ts b/modules/widgets/src/index.ts index 789b9fff591..07818fac9d1 100644 --- a/modules/widgets/src/index.ts +++ b/modules/widgets/src/index.ts @@ -15,6 +15,7 @@ export {GeolocateWidget as _GeolocateWidget} from './geolocate-widget'; // Utility widgets export {FullscreenWidget} from './fullscreen-widget'; export {ScreenshotWidget} from './screenshot-widget'; +export {SelectionWidget} from './selection-widget'; export {LoadingWidget as _LoadingWidget} from './loading-widget'; export {FpsWidget as _FpsWidget} from './fps-widget'; export {ThemeWidget as _ThemeWidget} from './theme-widget'; @@ -29,6 +30,7 @@ export type {FullscreenWidgetProps} from './fullscreen-widget'; export type {CompassWidgetProps} from './compass-widget'; export type {ZoomWidgetProps} from './zoom-widget'; export type {ScreenshotWidgetProps} from './screenshot-widget'; +export type {SelectionWidgetProps} from './selection-widget'; export type {ResetViewWidgetProps} from './reset-view-widget'; export type {GeolocateWidgetProps} from './geolocate-widget'; export type {LoadingWidgetProps} from './loading-widget'; diff --git a/modules/widgets/src/selection-widget.tsx b/modules/widgets/src/selection-widget.tsx new file mode 100644 index 00000000000..f5097b3562f --- /dev/null +++ b/modules/widgets/src/selection-widget.tsx @@ -0,0 +1,116 @@ +import {Widget, WidgetProps, type WidgetPlacement, type Layer} from '@deck.gl/core'; +import {render} from 'preact'; +import {IconButton} from './lib/components/icon-button'; + +export type SelectionWidgetProps = WidgetProps & { + /** Widget positioning within the view. Default 'top-left'. */ + placement?: WidgetPlacement; + /** Tooltip message */ + label?: string; + /** Type of selection interaction. Defaults to 'rectangle'. */ + selectionType?: 'rectangle' | 'lasso'; + layerIds?: string[]; + /** Called when the selection is completed. */ + onSelect?: (objects: any[], polygon?: number[][]) => void; + SelectionLayer: Layer; +}; + +/** + * Adds a button that lets the user draw a selection region within the view. + * Once a selection is made the layer is removed and {@link onSelect} is called. + */ +export class SelectionWidget extends Widget { + static defaultProps: Required = { + ...Widget.defaultProps, + id: 'selection', + placement: 'top-left', + label: 'Select', + selectionType: 'rectangle', + onSelect: undefined!, + SelectionLayer: undefined! + }; + + className = 'deck-widget-selection'; + placement: WidgetPlacement = 'top-left'; + private active = false; + private selectionLayer: Layer | null = null; + + constructor(props: SelectionWidgetProps) { + super(props, SelectionWidget.defaultProps); + this.placement = props.placement ?? this.placement; + } + + setProps(props: Partial): void { + this.placement = props.placement ?? this.placement; + super.setProps(props); + } + + onAdd(): void { + this.updateHTML(); + } + + onRemove(): void { + this.stopSelection(); + } + + onRenderHTML(rootElement: HTMLElement): void { + render( + , + rootElement + ); + } + + private handleClick = (): void => { + if (this.active) { + this.stopSelection(); + } else { + this.startSelection(); + } + }; + + private startSelection(): void { + const deck = this.deck; + if (!deck || this.active) { + return; + } + + this.active = true; + const layerId = `${this.id}-layer`; + + // @ts-expect-error + this.selectionLayer = new this.props.SelectionLayer({ + id: layerId, + selectionType: this.props.selectionType, + layerIds: this.props.layerIds, + onSelect: (info: any) => { + this.props.onSelect?.(info, info.polygon); + this.stopSelection(); + } + }); + + deck.setProps({ + layers: [...(deck.props.layers || []), this.selectionLayer] + }); + + this.updateHTML(); + } + + private stopSelection(): void { + const deck = this.deck; + if (!deck || !this.selectionLayer) { + this.active = false; + this.updateHTML(); + return; + } + + const layers = (deck.props.layers || []).filter(l => l !== this.selectionLayer); + deck.setProps({layers}); + this.selectionLayer = null; + this.active = false; + this.updateHTML(); + } +} diff --git a/test/apps/widgets-example-9.2/app.ts b/test/apps/widgets-example-9.2/app.ts index cbb88071c5c..6b6931781d6 100644 --- a/test/apps/widgets-example-9.2/app.ts +++ b/test/apps/widgets-example-9.2/app.ts @@ -25,6 +25,9 @@ import { } from '@deck.gl/widgets'; import '@deck.gl/widgets/stylesheet.css'; +import {SelectionWidget} from '@deck.gl/widgets'; +import {SelectionLayer} from '@deck.gl-community/editable-layers'; + // source: Natural Earth http://www.naturalearthdata.com/ via geojson.xyz const COUNTRIES = 'https://d2ad6b4ur7yvpq.cloudfront.net/naturalearth-3.3.0/ne_50m_admin_0_scale_rank.geojson'; //eslint-disable-line @@ -138,7 +141,32 @@ const deck = new Deck({ orientation: 'vertical', onChange: ratio => deck.setProps({views: getViewsForSplit(ratio * 100)}) }), - new _StatsWidget({type: 'luma'}) + new _StatsWidget({type: 'luma'}), + new SelectionWidget({ + viewId: 'left-map', + SelectionLayer, + layerIds: ['airports'], + onSelect: (info: PickingInfo) => { + console.log('Selected:', info); + // if (info.layer?.id === 'airports' && info.object) { + // return { + // id: info.object.properties.abbrev, + // name: info.object.properties.name, + // type: info.object.properties.type, + // featureclass: info.object.properties.featureclass, + // location: info.object.properties.location + // }; + // } + // return null; + } + // onSelectionChange: (info: PickingInfo) => { + // if (info.layer?.id === 'airports' && info.object) { + // console.log('Selected:', info.object.properties.name); + // } else { + // console.log('Selection cleared'); + // } + // } + }) ] }); diff --git a/test/apps/widgets-example-9.2/package.json b/test/apps/widgets-example-9.2/package.json index a8221a459f1..81951fd2bb5 100644 --- a/test/apps/widgets-example-9.2/package.json +++ b/test/apps/widgets-example-9.2/package.json @@ -10,9 +10,12 @@ "build": "vite build" }, "dependencies": { + "@deck.gl-community/editable-layers": "^9.0.3", + "@deck.gl-community/layers": "^9.0.3", "@deck.gl/core": "^9.1.0", "@deck.gl/layers": "^9.1.0", - "@deck.gl/widgets": "^9.1.0" + "@deck.gl/widgets": "^9.1.0", + "preact": "^10.17.0" }, "devDependencies": { "vite": "^4.0.0" diff --git a/test/modules/widgets/selection-widget.spec.ts b/test/modules/widgets/selection-widget.spec.ts new file mode 100644 index 00000000000..3c3d0a80f4a --- /dev/null +++ b/test/modules/widgets/selection-widget.spec.ts @@ -0,0 +1,24 @@ +import test from 'tape-promise/tape'; +import {SelectionWidget} from '@deck.gl/widgets'; + +class DummyDeck { + props: {layers: any[]} = {layers: []}; + setProps(props: any) { + this.props = {...this.props, ...props}; + } +} + +test('SelectionWidget adds and removes SelectionLayer', t => { + const deck = new DummyDeck(); + const widget = new SelectionWidget({id: 'sel', label: 'Select'}); + widget.onAdd({deck: deck as any, viewId: null}); + + (widget as any).startSelection(); + t.ok((widget as any).selectionLayer, 'layer is created'); + t.is(deck.props.layers.includes((widget as any).selectionLayer), true, 'layer added'); + + (widget as any).stopSelection(); + t.is((widget as any).selectionLayer, null, 'layer cleared'); + t.is(deck.props.layers.includes((widget as any).selectionLayer), false, 'layer removed'); + t.end(); +});