diff --git a/docs/api-reference/modes/overview.md b/docs/api-reference/modes/overview.md index f8cb38ff7..4b30add25 100644 --- a/docs/api-reference/modes/overview.md +++ b/docs/api-reference/modes/overview.md @@ -91,6 +91,13 @@ User can draw a new `Polygon` feature with 90 degree corners (right angle) by cl User can draw a new rectangular `Polygon` feature by clicking two opposing corners of the rectangle. +### ModeConfig + +The following options can be provided in the `modeConfig` object: + +* `dragToDraw` (optional): `boolean` + * If `true`, user can click and drag instead of clicking twice. Note however, that the user will not be able to pan the map while drawing. + ## [DrawRectangleUsingThreePointsMode](https://github.com/uber/nebula.gl/blob/master/modules/edit-modes/src/lib/draw-rectangle-using-three-points-mode.js) User can draw a new rectangular `Polygon` feature by clicking three corners of the rectangle. @@ -105,6 +112,8 @@ The following options can be provided in the `modeConfig` object: * `steps` (optional): `x ` * If steps: `x` means the circle will be drawn using `x` number of points. +* `dragToDraw` (optional): `boolean` + * If `true`, user can click and drag instead of clicking twice. Note however, that the user will not be able to pan the map while drawing. ## [DrawCircleByDiameterMode](https://github.com/uber/nebula.gl/blob/master/modules/edit-modes/src/lib/draw-circle-by-diameter-mode.js) @@ -116,11 +125,20 @@ The following options can be provided in the `modeConfig` object: * `steps` (optional): `x ` * If steps: `x` means the circle will be drawn using `x` number of points. +* `dragToDraw` (optional): `boolean` + * If `true`, user can click and drag instead of clicking twice. Note however, that the user will not be able to pan the map while drawing. ## [DrawEllipseByBoundingBoxMode](https://github.com/uber/nebula.gl/blob/master/modules/edit-modes/src/lib/draw-ellipse-by-bounding-box-mode.js) User can draw a new ellipse shape `Polygon` feature by clicking two corners of bounding box. +### ModeConfig + +The following options can be provided in the `modeConfig` object: + +* `dragToDraw` (optional): `boolean` + * If `true`, user can click and drag instead of clicking twice. Note however, that the user will not be able to pan the map while drawing. + ## [DrawEllipseUsingThreePointsMode](https://github.com/uber/nebula.gl/blob/master/modules/edit-modes/src/lib/draw-ellipse-using-three-points-mode.js) User can draw a new ellipse shape `Polygon` feature by clicking center and two corners of the ellipse. diff --git a/examples/advanced/example.js b/examples/advanced/example.js index e42e759e1..41fc9d038 100644 --- a/examples/advanced/example.js +++ b/examples/advanced/example.js @@ -129,6 +129,13 @@ const POLYGON_DRAWING_MODES = [ DrawEllipseUsingThreePointsMode ]; +const TWO_CLICK_POLYGON_MODES = [ + DrawRectangleMode, + DrawCircleFromCenterMode, + DrawCircleByDiameterMode, + DrawEllipseByBoundingBoxMode +]; + const EMPTY_FEATURE_COLLECTION = { type: 'FeatureCollection', features: [] @@ -439,9 +446,13 @@ export default class Example extends Component< } onClick={() => { if (this.state.modeConfig && this.state.modeConfig.booleanOperation === operation) { - this.setState({ modeConfig: null }); + this.setState({ + modeConfig: { ...(this.state.modeConfig || {}), booleanOperation: null } + }); } else { - this.setState({ modeConfig: { booleanOperation: operation } }); + this.setState({ + modeConfig: { ...(this.state.modeConfig || {}), booleanOperation: operation } + }); } }} > @@ -453,6 +464,28 @@ export default class Example extends Component< ); } + _renderTwoClickPolygonControls() { + return ( + + Drag to draw + + + this.setState({ + modeConfig: { + ...(this.state.modeConfig || {}), + dragToDraw: Boolean(event.target.checked) + } + }) + } + /> + + + ); + } + _renderDrawLineStringModeControls() { return ( @@ -561,6 +594,9 @@ export default class Example extends Component< if (POLYGON_DRAWING_MODES.indexOf(this.state.mode) > -1) { controls.push(this._renderBooleanOperationControls()); } + if (TWO_CLICK_POLYGON_MODES.indexOf(this.state.mode) > -1) { + controls.push(this._renderTwoClickPolygonControls()); + } if (this.state.mode === DrawLineStringMode) { controls.push(this._renderDrawLineStringModeControls()); } diff --git a/modules/edit-modes/src/lib/two-click-polygon-mode.js b/modules/edit-modes/src/lib/two-click-polygon-mode.js index 45fbdd0d6..82f9bc612 100644 --- a/modules/edit-modes/src/lib/two-click-polygon-mode.js +++ b/modules/edit-modes/src/lib/two-click-polygon-mode.js @@ -1,12 +1,49 @@ // @flow -import type { ClickEvent, PointerMoveEvent, ModeProps, GuideFeatureCollection } from '../types.js'; +import type { + ClickEvent, + StartDraggingEvent, + StopDraggingEvent, + PointerMoveEvent, + ModeProps, + GuideFeatureCollection +} from '../types.js'; import type { Polygon, FeatureCollection, FeatureOf, Position } from '../geojson-types.js'; import { BaseGeoJsonEditMode } from './geojson-edit-mode.js'; export class TwoClickPolygonMode extends BaseGeoJsonEditMode { handleClick(event: ClickEvent, props: ModeProps) { + if (props.modeConfig && props.modeConfig.dragToDraw) { + // handled in drag handlers + return; + } + + this.addClickSequence(event); + + this.checkAndFinishPolygon(props); + } + + handleStartDragging(event: StartDraggingEvent, props: ModeProps): void { + if (!props.modeConfig || !props.modeConfig.dragToDraw) { + // handled in click handlers + return; + } + this.addClickSequence(event); + event.cancelPan(); + } + + handleStopDragging(event: StopDraggingEvent, props: ModeProps): void { + if (!props.modeConfig || !props.modeConfig.dragToDraw) { + // handled in click handlers + return; + } + this.addClickSequence(event); + + this.checkAndFinishPolygon(props); + } + + checkAndFinishPolygon(props: ModeProps) { const clickSequence = this.getClickSequence(); const tentativeFeature = this.getTentativeGuide(props); diff --git a/modules/edit-modes/test/lib/draw-rectangle-mode.test.js b/modules/edit-modes/test/lib/draw-rectangle-mode.test.js new file mode 100644 index 000000000..0a86457dd --- /dev/null +++ b/modules/edit-modes/test/lib/draw-rectangle-mode.test.js @@ -0,0 +1,237 @@ +// @flow +/* eslint-env jest */ + +import turfArea from '@turf/area'; +import type { Feature, FeatureCollection } from '@nebula.gl/edit-modes'; +import { DrawRectangleMode } from '../../src/lib/draw-rectangle-mode.js'; +import { + createFeatureCollectionProps, + createFeatureCollection, + createClickEvent, + createPointerMoveEvent, + createStartDraggingEvent, + createStopDraggingEvent +} from '../test-utils.js'; +import type { GeoJsonEditAction } from '../../src/lib/geojson-edit-mode.js'; + +let featureCollection: FeatureCollection; +let polygonFeature: Feature; +let polygonFeatureIndex: number; + +let warnBefore; +beforeEach(() => { + warnBefore = console.warn; // eslint-disable-line + // $FlowFixMe + console.warn = function() {}; // eslint-disable-line + + featureCollection = createFeatureCollection(); + + const makeFlowHappy = featureCollection.features.find(f => f.geometry.type === 'Polygon'); + if (!makeFlowHappy) { + throw new Error(`Need a Polygon in my setup`); + } + polygonFeature = makeFlowHappy; + polygonFeatureIndex = featureCollection.features.indexOf(polygonFeature); +}); + +afterEach(() => { + // $FlowFixMe + console.warn = warnBefore; // eslint-disable-line +}); + +describe('dragToDraw=false', () => { + it('sets tentative feature to a Polygon after first click', () => { + const mode = new DrawRectangleMode(); + + const props = createFeatureCollectionProps(); + props.lastPointerMoveEvent = createPointerMoveEvent([1, 2]); + mode.handleClick(createClickEvent([1, 2]), props); + props.lastPointerMoveEvent = createPointerMoveEvent([2, 3]); + + const tentativeFeature = mode.getTentativeGuide(props); + + if (!tentativeFeature) { + throw new Error('Should have tentative feature'); + } + + expect(tentativeFeature.geometry).toEqual({ + type: 'Polygon', + coordinates: [[[1, 2], [2, 2], [2, 3], [1, 3], [1, 2]]] + }); + }); + + it('adds a new feature after two clicks', () => { + const mode = new DrawRectangleMode(); + + const props = createFeatureCollectionProps(); + props.lastPointerMoveEvent = createPointerMoveEvent([1, 2]); + mode.handleClick(createClickEvent([1, 2]), props); + props.lastPointerMoveEvent = createPointerMoveEvent([2, 3]); + mode.handleClick(createClickEvent([2, 3]), props); + + const expectedAction2: GeoJsonEditAction = { + editType: 'addFeature', + updatedData: { + ...props.data, + features: [ + ...props.data.features, + { + type: 'Feature', + properties: {}, + geometry: { + type: 'Polygon', + coordinates: [[[1, 2], [2, 2], [2, 3], [1, 3], [1, 2]]] + } + } + ] + }, + editContext: { + featureIndexes: [featureCollection.features.length] + } + }; + + expect(props.onEdit).toHaveBeenCalledTimes(1); + expect(props.onEdit.mock.calls[0][0]).toEqual(expectedAction2); + }); +}); + +describe('dragToDraw=true', () => { + it('sets tentative feature to a Polygon after start dragging', () => { + const mode = new DrawRectangleMode(); + + const props = createFeatureCollectionProps({ + modeConfig: { + dragToDraw: true + } + }); + props.lastPointerMoveEvent = createPointerMoveEvent([1, 2]); + mode.handleStartDragging(createStartDraggingEvent([1, 2], [1, 2]), props); + props.lastPointerMoveEvent = createPointerMoveEvent([2, 3]); + + const tentativeFeature = mode.getTentativeGuide(props); + + if (!tentativeFeature) { + throw new Error('Should have tentative feature'); + } + + expect(tentativeFeature.geometry).toEqual({ + type: 'Polygon', + coordinates: [[[1, 2], [2, 2], [2, 3], [1, 3], [1, 2]]] + }); + }); + + it('adds a new feature after stop dragging', () => { + const mode = new DrawRectangleMode(); + + const props = createFeatureCollectionProps({ + modeConfig: { + dragToDraw: true + } + }); + props.lastPointerMoveEvent = createPointerMoveEvent([1, 2]); + mode.handleStartDragging(createStartDraggingEvent([1, 2], [1, 2]), props); + props.lastPointerMoveEvent = createPointerMoveEvent([2, 3]); + mode.handleStopDragging(createStopDraggingEvent([2, 3], [2, 3]), props); + + const expectedAction2: GeoJsonEditAction = { + editType: 'addFeature', + updatedData: { + ...props.data, + features: [ + ...props.data.features, + { + type: 'Feature', + properties: {}, + geometry: { + type: 'Polygon', + coordinates: [[[1, 2], [2, 2], [2, 3], [1, 3], [1, 2]]] + } + } + ] + }, + editContext: { + featureIndexes: [featureCollection.features.length] + } + }; + + expect(props.onEdit).toHaveBeenCalledTimes(1); + expect(props.onEdit.mock.calls[0][0]).toEqual(expectedAction2); + }); +}); + +describe('modeConfig.booleanOperation', () => { + describe('union', () => { + test('unions shapes', () => { + const mode = new DrawRectangleMode(); + + const props = createFeatureCollectionProps({ + selectedIndexes: [polygonFeatureIndex], + modeConfig: { booleanOperation: 'union' } + }); + + const areaBefore = turfArea(featureCollection.features[polygonFeatureIndex]); + + props.lastPointerMoveEvent = createPointerMoveEvent([0, 0]); + mode.handleClick(createClickEvent([0, 0]), props); + props.lastPointerMoveEvent = createPointerMoveEvent([2, 2]); + mode.handleClick(createClickEvent([2, 2]), props); + + expect(props.onEdit).toHaveBeenCalledTimes(1); + + const action = props.onEdit.mock.calls[0][0]; + const areaAfter = turfArea(action.updatedData.features[polygonFeatureIndex]); + + expect(areaAfter).toBeGreaterThan(areaBefore); + }); + }); + + describe('difference', () => { + test('subtracts geometry', () => { + const mode = new DrawRectangleMode(); + + const props = createFeatureCollectionProps({ + selectedIndexes: [polygonFeatureIndex], + modeConfig: { booleanOperation: 'difference' } + }); + + const areaBefore = turfArea(featureCollection.features[polygonFeatureIndex]); + + props.lastPointerMoveEvent = createPointerMoveEvent([0, 0]); + mode.handleClick(createClickEvent([0, 0]), props); + props.lastPointerMoveEvent = createPointerMoveEvent([2, 2]); + mode.handleClick(createClickEvent([2, 2]), props); + + expect(props.onEdit).toHaveBeenCalledTimes(1); + + const action = props.onEdit.mock.calls[0][0]; + const areaAfter = turfArea(action.updatedData.features[polygonFeatureIndex]); + + expect(areaAfter).toBeLessThan(areaBefore); + }); + }); + + describe('intersection', () => { + test('subtracts geometry', () => { + const mode = new DrawRectangleMode(); + + const props = createFeatureCollectionProps({ + selectedIndexes: [polygonFeatureIndex], + modeConfig: { booleanOperation: 'intersection' } + }); + + const areaBefore = turfArea(featureCollection.features[polygonFeatureIndex]); + + props.lastPointerMoveEvent = createPointerMoveEvent([0, 0]); + mode.handleClick(createClickEvent([0, 0]), props); + props.lastPointerMoveEvent = createPointerMoveEvent([2, 2]); + mode.handleClick(createClickEvent([2, 2]), props); + + expect(props.onEdit).toHaveBeenCalledTimes(1); + + const action = props.onEdit.mock.calls[0][0]; + const areaAfter = turfArea(action.updatedData.features[polygonFeatureIndex]); + + expect(areaAfter).toBeLessThan(areaBefore); + }); + }); +}); diff --git a/modules/edit-modes/test/test-utils.js b/modules/edit-modes/test/test-utils.js index 21233cff3..19ea01baf 100644 --- a/modules/edit-modes/test/test-utils.js +++ b/modules/edit-modes/test/test-utils.js @@ -6,6 +6,7 @@ import type { ModeProps, ClickEvent, PointerMoveEvent, + StartDraggingEvent, StopDraggingEvent, Pick } from '../src/types.js'; @@ -258,7 +259,24 @@ export function createClickEvent(mapCoords: Position, picks: Pick[] = []): Click }; } -export function createPointerDragEvent( +export function createStartDraggingEvent( + mapCoords: Position, + pointerDownMapCoords: Position, + picks: Pick[] = [] +): StartDraggingEvent { + return { + screenCoords: [-1, -1], + mapCoords, + picks, + pointerDownPicks: null, + pointerDownScreenCoords: [-1, -1], + pointerDownMapCoords, + cancelPan: jest.fn(), + sourceEvent: null + }; +} + +export function createStopDraggingEvent( mapCoords: Position, pointerDownMapCoords: Position, picks: Pick[] = [] diff --git a/modules/layers/src/layers/selection-layer.js b/modules/layers/src/layers/selection-layer.js index 3db49e89b..af31e2c8e 100644 --- a/modules/layers/src/layers/selection-layer.js +++ b/modules/layers/src/layers/selection-layer.js @@ -5,6 +5,7 @@ import { PolygonLayer } from '@deck.gl/layers'; import { polygon } from '@turf/helpers'; import turfBuffer from '@turf/buffer'; import turfDifference from '@turf/difference'; +import { DrawRectangleMode, DrawPolygonMode, ViewMode } from '@nebula.gl/edit-modes'; import EditableGeoJsonLayer from './editable-geojson-layer'; @@ -14,6 +15,15 @@ export const SELECTION_TYPE = { POLYGON: 'polygon' }; +const MODE_MAP = { + [SELECTION_TYPE.RECTANGLE]: DrawRectangleMode, + [SELECTION_TYPE.POLYGON]: DrawPolygonMode +}; + +const MODE_CONFIG_MAP = { + [SELECTION_TYPE.RECTANGLE]: { dragToDraw: true } +}; + const defaultProps = { selectionType: SELECTION_TYPE.RECTANGLE, layerIds: [], @@ -122,11 +132,8 @@ export default class SelectionLayer extends CompositeLayer { renderLayers() { const { pendingPolygonSelection } = this.state; - const mode = - { - [SELECTION_TYPE.RECTANGLE]: 'drawRectangle', - [SELECTION_TYPE.POLYGON]: 'drawPolygon' - }[this.props.selectionType] || 'view'; + const mode = MODE_MAP[this.props.selectionType] || ViewMode; + const modeConfig = MODE_CONFIG_MAP[this.props.selectionType]; const inheritedProps = {}; PASS_THROUGH_PROPS.forEach(p => { @@ -139,6 +146,7 @@ export default class SelectionLayer extends CompositeLayer { id: LAYER_ID_GEOJSON, pickable: true, mode, + modeConfig, selectedFeatureIndexes: [], data: EMPTY_DATA, onEdit: ({ updatedData, editType }) => {