diff --git a/docs/api-reference/react-map-gl-draw/react-map-gl-draw.md b/docs/api-reference/react-map-gl-draw/react-map-gl-draw.md index c5734b8fb..8ee1edcb0 100644 --- a/docs/api-reference/react-map-gl-draw/react-map-gl-draw.md +++ b/docs/api-reference/react-map-gl-draw/react-map-gl-draw.md @@ -1,23 +1,34 @@ # React Map GL Draw +`react-map-gl-draw` is a react based drawing library tailored for [`react-map-gl`](https://github.com/uber/react-map-gl). + ## Options - `mode` (String, Optional) - `EditorModes.READ_ONLY` - Not interactive. This is the default mode. - - `EditorModes.SELECT_FEATURE` - Lets you select, delete, and drag features. - - `EditorModes.EDIT_VERTEX` - Lets you select, delete, and drag vertices; and drag features. + - `EditorModes.SELECT` - Lets you select, delete, and drag features. + - `EditorModes.EDITTING` - Lets you select, delete, and drag vertices; and drag features. - `EditorModes.DRAW_PATH` - Lets you draw a GeoJson `LineString` feature. - `EditorModes.DRAW_POLYGON` - Lets you draw a GeoJson `Polygon` feature. - `EditorModes.DRAW_POINT` - Lets you draw a GeoJson `Point` feature. - `EditorModes.DRAW_RECTANGLE` - Lets you draw a `Rectangle` (represented as GeoJson `Polygon` feature). -- `selectedFeatureId` (String, Optional) - id of the selected feature. `EditorModes` assigns a unique id to each feature which is stored in `feature.properties.id`. -- `clickRadius` (Number, optional) - Radius to detect features around a hovered or clicked point. Default value is `0` - -- `onSelect` (Function, Required) - callback when a feature is selected. Receives an object containing `selectedFeatureId`. -- `onUpdate` (Function, Required) - callback when anything is updated. Receives one argument `features` that is the updated list of GeoJSON features. +- `features` (Feature[], Optional) - List of features in GeoJson format. If `features` are provided from users, then `react-map-gl-draw` respect the users' input, and therefore ignore any internal `features`. But if `features` are not provided, then `react-map-gl-draw` manages `features` internally, and users can access and manipulate the features by calling `getFeatures`, `addFeatures`, and `deleteFeatures`. +- `selectedFeatureIndex` (String, Optional) - Index of the selected feature. +- `clickRadius` (Number, Optional) - Radius to detect features around a hovered or clicked point. Default value is `0` -Feature object structure: -`react-map-gl-draw` is stateful component. +- `onSelect` (Function, Optional) - callback when clicking a position under `SELECT` and `EDITTING` mode. Receives an object containing the following parameters + - `selectedFeature`: selected feature. `null` if clicked an empty space. + - `selectedFeatureIndex`: selected feature index.`null` if clicked an empty space. + - `editHandleIndex`: selected editHandle index. `null` if clicked an empty space. + - `screenCoords`: screen coordinates of the clicked position. + - `mapCoords`: map coordinates of the clicked position. + +- `onUpdate` (Function, Optional) - callback when anything is updated. Receives an object containing the following parameters + - `features` (Feature[]) - the updated list of GeoJSON features. + - `editType` (String) - `addFeature`, `addPosition`, `finishMovePosition` + - `editContext` (Array) - list of edit objects, depend on `editType`, each object may contain `featureIndexes`, `editHandleIndexes`, `screenCoords`, `mapCoords`. + +**Feature object structure:** ```js { id, // an unique identified generated inside react-map-gl-draw library @@ -32,24 +43,31 @@ Feature object structure: } ``` -### Styling related Options -- `getFeatureStyle` (Function, Optional) : Object - A function to style a feature, function parameters are - - `feature`: feature to style . - - `state`: one of `SELECTED`, `HOVERED`, `INACTIVE`, `UNCOMMITTED`. +### Styling related options +- `featureStyle` (Object|Function, Optional) : Object - Either a [style objects](https://reactjs.org/docs/dom-elements.html#style) or a function to style a feature, function parameters are + - `feature`: feature to style. + - `index`: index of the feature. + - `state`: one of `SELECTED`, `HOVERED`, `INACTIVE`, `UNCOMMITTED`, `CLOSING`. Returns is a map of [style objects](https://reactjs.org/docs/dom-elements.html#style) passed to SVG `path` elements. -- `getEditHandleStyle` (Function, Optional) : Object - A function to style an `editHandle, function parameters are +- `featureShape` (String|Function, Optional): if is a string, should be one of `rect` or `circle`. If is a function, will receive the following parameters + - `feature`: feature to style. + - `index`: index of the feature. + - `state`: one of `SELECTED`, `HOVERED`, `INACTIVE`, `UNCOMMITTED`, `CLOSING`. + +- `editHandleStyle` (Object|Function, Optional) : Object - Either a [style objects](https://reactjs.org/docs/dom-elements.html#style) or a function to style an `editHandle, function parameters are - `feature`: feature to style. - `index`: index of the editHandle vertex in the feature. - - `state`: one of `SELECTED`, `HOVERED`, `INACTIVE`, `UNCOMMITTED`. + - `state`: one of `SELECTED`, `HOVERED`, `INACTIVE`, `UNCOMMITTED`, `CLOSING`. + - `shape`: shape resolved from `editHandleShape`. Returns is a map of [style objects](https://reactjs.org/docs/dom-elements.html#style) passed to SVG `circle` or `rect` elements. -- `getEditHandleShape` (String|Function, Optional): if is a string, should be one of `rect` or `circle`. If is a function, will receive the following parameters +- `editHandleShape` (String|Function, Optional): if is a string, should be one of `rect` or `circle`. If is a function, will receive the following parameters - `feature`: feature to style. - `index`: index of the editHandle vertex in the feature. - - `state`: one of `SELECTED`, `HOVERED`, `INACTIVE`, `UNCOMMITTED`. + - `state`: one of `SELECTED`, `HOVERED`, `INACTIVE`, `UNCOMMITTED`, `CLOSING`. ## Explanations - `Feature`: any drawn shape, one of point, line, polygon or rectangle. @@ -60,78 +78,70 @@ Returns is a map of [style objects](https://reactjs.org/docs/dom-elements.html#s - `SELECTED`: being clicked or dragged. - `HOVERED`: hovered over by the mouse pointer. - `UNCOMMITTED`: in the middle of drawing, not yet added to the feature being edited. +- `CLOSING`: closing a polygon. ### Styling based on `state`: -![img](https://raw.githubusercontent.com/uber-common/deck.gl-data/master/nebula.gl/edit-handle.png) +![img](https://raw.githubusercontent.com/uber-common/deck.gl-data/master/nebula.gl/react-map-gl-draw.png) As shown in the above image, for the feature currently being edited, -- `getFeatureStyle({feature, state: SELECTED})` will be applied to the committed parts of the feature. (Green strokes) -- `getEditHandleStyle({state: SELECTED})` will be applied to the committed editHandle vertices. (Vertices with black stroke) -- `getFeatureStyle({feature, state: UNCOMMITTED})` will be applied to the uncommitted parts of the feature. (Gray stroke) -- `getEditHandleStyle({state: UNCOMMITTED})` will be applied to the uncommitted editHandle vertex. (Gray vertex) +- `featureStyle({feature, state: SELECTED})` will be applied to the committed parts of the feature. (Green strokes) +- `editHandleStyle({state: SELECTED})` will be applied to the committed editHandle vertices. (Vertices with black stroke) +- `featureStyle({feature, state: UNCOMMITTED})` will be applied to the uncommitted parts of the feature. (Gray stroke) +- `editHandleStyle({state: UNCOMMITTED})` will be applied to the uncommitted editHandle vertex. (Gray vertex) + +## Methods + +##### `getFeatures` +- Return a list of finished GeoJson features. + +##### `addFeatures` (Feature | Feature[]) + +- Add a single or multiple GeoJson features to editor. + +##### `deleteDeatures` (Feature | Feature[]) + +- Delete a single or multiple GeoJson features to editor. ## Code Example ```js import React, { Component } from 'react'; -import MapGL, {_MapContext as MapContext} from 'react-map-gl'; -import MapGLDraw, { EditorModes } from 'react-map-gl-draw'; +import MapGL from 'react-map-gl'; +import { Editor, EditorModes } from 'react-map-gl-draw'; const MODES = [ - { id: EditorModes.EDIT_VERTEX, text: 'Select and Edit Feature'}, + { id: EditorModes.EDITING, text: 'Select and Edit Feature'}, { id: EditorModes.DRAW_POINT, text: 'Draw Point'}, { id: EditorModes.DRAW_PATH, text: 'Draw Polyline'}, { id: EditorModes.DRAW_POLYGON, text: 'Draw Polygon'}, { id: EditorModes.DRAW_RECTANGLE, text: 'Draw Rectangle'} ]; +const DEFAULT_VIEWPORT = { + width: 800, + height: 600, + longitude: -122.45, + latitude: 37.78, + zoom: 14 +}; + class App extends Component { - constructor(props) { - super(props); - this.state = { - viewport: { - width: 800, - height: 600, - longitude: -122.45, - latitude: 37.78, - zoom: 14 - }, - selectedMode: EditorModes.READ_ONLY, - features: [], - selectedFeatureId: null - }; - } - - componentDidMount() { - // add features - const initialFeatures = [{...}]; - this._mapRef.add(initialFeatures); - } - - _updateViewport = (viewport) => { - this.setState({viewport}); - } - - _onSelect = ({ selectedFeatureId }) => { - this.setState({ selectedFeatureId }); - }; - - _onUpdate = features => { - this.setState({ - features - }); + state = { + // map + viewport: DEFAULT_VIEWPORT, + // editor + selectedMode: EditorModes.READ_ONLY }; _switchMode = evt => { - const selectedMode = evt.target.id === this.state.selectedMode ? EditorModes.READ_ONLY : evt.target.id; + const selectedMode = evt.target.id; this.setState({ - selectedMode, - selectedFeatureId: null + selectedMode: selectedMode === this.state.selectedMode ? null : selectedMode }); }; - - _renderControlPanel = () => { + + _renderToolbar = () => { return (
); - } - - _getEditHandleStyle = ({feature, featureState, vertexIndex, vertexState}) => { - return { - fill: vertexState === `SELECTED` ? '#000' : '#aaa', - stroke: vertexState === `SELECTED` ? '#000' : 'none' - } - } - - _getFeatureStyle = ({feature, featureState}) => { - return { - stroke: featureState === `SELECTED` ? '#000' : 'none', - fill: featureState === `SELECTED` ? '#080' : 'none', - fillOpacity: 0.8 - } - } + }; render() { - const { viewport, selectedMode, selectedFeatureId, features } = this.state; + const { viewport, selectedMode } = this.state; return ( - this._drawRef = _} + - {this._renderControlPanel()} + {this._renderToolbar()} ); } diff --git a/examples/react-map-gl-draw/app.js b/examples/react-map-gl-draw/app.js index 4579e9926..e01b88225 100644 --- a/examples/react-map-gl-draw/app.js +++ b/examples/react-map-gl-draw/app.js @@ -1,4 +1,3 @@ -/* global window */ import React, { Component } from 'react'; import { render } from 'react-dom'; import MapGL from 'react-map-gl'; @@ -9,65 +8,36 @@ import Toolbar from './toolbar'; // eslint-disable-next-line no-process-env, no-undef const MAP_STYLE = process.env.MapStyle || 'mapbox://styles/mapbox/light-v9'; +const DEFAULT_VIEWPORT = { + width: 800, + height: 600, + longitude: -122.45, + latitude: 37.78, + zoom: 14 +}; + export default class App extends Component { constructor(props) { super(props); this.state = { - viewport: { - width: 800, - height: 600, - longitude: -122.45, - latitude: 37.78, - zoom: 14 - }, + // map + viewport: DEFAULT_VIEWPORT, + // editor selectedMode: EditorModes.READ_ONLY, features: [], - selectedFeatureId: null + selectedFeatureIndex: null }; this._mapRef = null; + this._editorRef = null; } - componentDidMount() { - window.addEventListener('keydown', this._onKeydown); - } - - componentWillUnmount() { - window.removeEventListener('keydown', this._onKeydown); - } - - _onKeydown = evt => { - if (evt.keyCode === 27) { - // esc key - this.setState({ selectedFeatureId: null }); - } - }; - - _updateViewport = viewport => { - this.setState({ viewport }); - }; - - _onSelect = ({ selectedFeatureId }) => { - this.setState({ selectedFeatureId }); - }; - _onDelete = () => { - const { selectedFeatureId } = this.state; - if (selectedFeatureId === null || selectedFeatureId === undefined) { + const { selectedFeatureIndex } = this.state; + if (selectedFeatureIndex === null || selectedFeatureIndex === undefined) { return; } - const selectedIndex = this.state.features.findIndex(f => f.properties.id === selectedFeatureId); - if (selectedIndex >= 0) { - const newFeatures = [...this.state.features]; - newFeatures.splice(selectedIndex, 1); - this.setState({ features: newFeatures, selectedFeatureId: null }); - } - }; - - _onUpdate = features => { - this.setState({ - features - }); + this._editorRef.deleteFeatures(selectedFeatureIndex); }; _switchMode = evt => { @@ -76,10 +46,11 @@ export default class App extends Component { selectedMode = null; } - this.setState({ - selectedMode, - selectedFeatureId: null - }); + this.setState({ selectedMode }); + }; + + _updateViewport = viewport => { + this.setState({ viewport }); }; _renderToolbar = () => { @@ -92,12 +63,8 @@ export default class App extends Component { ); }; - _getEditHandleShape = ({ feature }) => { - return feature.properties.renderType === 'Point' ? 'circle' : 'rect'; - }; - render() { - const { viewport, selectedMode, selectedFeatureId, features } = this.state; + const { viewport, selectedMode } = this.state; return ( (this._editorRef = _)} clickRadius={12} + onSelect={selected => { + this.setState({ selectedFeatureIndex: selected && selected.selectedFeatureIndex }); + }} mode={selectedMode} - features={features} - selectedFeatureId={selectedFeatureId} - onSelect={this._onSelect} - onUpdate={this._onUpdate} - getEditHandleShape={this._getEditHandleShape} /> {this._renderToolbar()} diff --git a/examples/react-map-gl-draw/toolbar.js b/examples/react-map-gl-draw/toolbar.js index fc168a2c7..a56da789f 100644 --- a/examples/react-map-gl-draw/toolbar.js +++ b/examples/react-map-gl-draw/toolbar.js @@ -4,7 +4,7 @@ import styled from 'styled-components'; import { EditorModes } from 'react-map-gl-draw'; const MODES = [ - { id: EditorModes.EDITING, text: 'Edit Feature', icon: 'icon-select.svg' }, + { id: EditorModes.SELECT, text: 'Edit Feature', icon: 'icon-select.svg' }, { id: EditorModes.DRAW_POINT, text: 'Draw Point', icon: 'icon-point.svg' }, { id: EditorModes.DRAW_PATH, text: 'Draw Polyline', icon: 'icon-path.svg' }, { id: EditorModes.DRAW_POLYGON, text: 'Draw Polygon', icon: 'icon-polygon.svg' }, diff --git a/modules/edit-modes/src/lib/immutable-feature-collection.js b/modules/edit-modes/src/lib/immutable-feature-collection.js index b80724d30..4a897c69b 100644 --- a/modules/edit-modes/src/lib/immutable-feature-collection.js +++ b/modules/edit-modes/src/lib/immutable-feature-collection.js @@ -133,7 +133,7 @@ export class ImmutableFeatureCollection { * Works with MultiPoint, LineString, MultiLineString, Polygon, and MultiPolygon. * * @param featureIndex The index of the feature to update - * @param positionIndexes An array containing the indexes of the postion that will preceed the new position + * @param positionIndexes An array containing the indexes of the position that will proceed the new position * @param positionToAdd The new position to place in the result (i.e. [lng, lat]) * * @returns A new `ImmutableFeatureCollection` with the given coordinate removed. Does not modify this `ImmutableFeatureCollection`. @@ -182,9 +182,35 @@ export class ImmutableFeatureCollection { } addFeature(feature: Feature): ImmutableFeatureCollection { + return this.addFeatures([feature]); + } + + addFeatures(features: Feature[]): ImmutableFeatureCollection { + const updatedFeatureCollection = { + ...this.featureCollection, + features: [...this.featureCollection.features, ...features] + }; + + return new ImmutableFeatureCollection(updatedFeatureCollection); + } + + deleteFeature(featureIndex: number) { + return this.deleteFeatures([featureIndex]); + } + + deleteFeatures(featureIndexes: number[]) { + const features = [...this.featureCollection.features]; + featureIndexes.sort(); + for (let i = featureIndexes.length - 1; i >= 0; i--) { + const featureIndex = featureIndexes[i]; + if (featureIndex >= 0 && featureIndex < features.length) { + features.splice(featureIndex, 1); + } + } + const updatedFeatureCollection = { ...this.featureCollection, - features: [...this.featureCollection.features, feature] + features }; return new ImmutableFeatureCollection(updatedFeatureCollection); diff --git a/modules/edit-modes/test/immutable-feature-collection.test.js b/modules/edit-modes/test/immutable-feature-collection.test.js index 10fd1c344..78a12ff8d 100644 --- a/modules/edit-modes/test/immutable-feature-collection.test.js +++ b/modules/edit-modes/test/immutable-feature-collection.test.js @@ -519,6 +519,137 @@ describe('addFeature()', () => { }); }); +describe('addFeatures()', () => { + it(`doesn't mutate original`, () => { + const features = new ImmutableFeatureCollection({ + type: 'FeatureCollection', + features: [] + }); + features.addFeatures([multiPointFeature, pointFeature]); + + expect(features.getObject().features.length).toEqual(0); + }); + + it('adds features to empty array', () => { + const features = new ImmutableFeatureCollection({ + type: 'FeatureCollection', + features: [] + }); + const actualFeatures = features.addFeatures([multiPointFeature, pointFeature]).getObject(); + + const expectedFeatures = { + type: 'FeatureCollection', + features: [multiPointFeature, pointFeature] + }; + + expect(actualFeatures).toEqual(expectedFeatures); + }); + + it('adds features to end of array', () => { + const features = new ImmutableFeatureCollection({ + type: 'FeatureCollection', + features: [multiPointFeature] + }); + const actualFeatures = features.addFeatures([multiLineStringFeature]).getObject(); + + const expectedFeatures = { + type: 'FeatureCollection', + features: [multiPointFeature, multiLineStringFeature] + }; + + expect(actualFeatures).toEqual(expectedFeatures); + }); +}); + +describe('deleteFeature()', () => { + it(`Do nothing when empty array`, () => { + const features = new ImmutableFeatureCollection({ + type: 'FeatureCollection', + features: [] + }); + features.deleteFeature(0); + + expect(features.getObject().features.length).toEqual(0); + }); + + it(`doesn't mutate original`, () => { + const features = new ImmutableFeatureCollection({ + type: 'FeatureCollection', + features: [multiPointFeature] + }); + features.deleteFeature(0); + + expect(features.getObject().features.length).toEqual(1); + }); + + it('delete feature', () => { + const features = new ImmutableFeatureCollection({ + type: 'FeatureCollection', + features: [multiPointFeature, multiLineStringFeature] + }); + const actualFeatures = features.deleteFeature(1).getObject(); + + const expectedFeatures = { + type: 'FeatureCollection', + features: [multiPointFeature] + }; + + expect(actualFeatures).toEqual(expectedFeatures); + }); +}); + +describe('deleteFeatures()', () => { + it(`Do nothing when empty array`, () => { + const features = new ImmutableFeatureCollection({ + type: 'FeatureCollection', + features: [] + }); + features.deleteFeatures([0, 1]); + + expect(features.getObject().features.length).toEqual(0); + }); + + it(`doesn't mutate original`, () => { + const features = new ImmutableFeatureCollection({ + type: 'FeatureCollection', + features: [multiPointFeature] + }); + features.deleteFeatures([0]); + + expect(features.getObject().features.length).toEqual(1); + }); + + it('delete single feature', () => { + const features = new ImmutableFeatureCollection({ + type: 'FeatureCollection', + features: [multiPointFeature, multiLineStringFeature] + }); + const actualFeatures = features.deleteFeatures([1]).getObject(); + + const expectedFeatures = { + type: 'FeatureCollection', + features: [multiPointFeature] + }; + + expect(actualFeatures).toEqual(expectedFeatures); + }); + + it('delete multiple features', () => { + const features = new ImmutableFeatureCollection({ + type: 'FeatureCollection', + features: [multiPointFeature, multiLineStringFeature] + }); + const actualFeatures = features.deleteFeatures([0, 1]).getObject(); + + const expectedFeatures = { + type: 'FeatureCollection', + features: [] + }; + + expect(actualFeatures).toEqual(expectedFeatures); + }); +}); + describe('replacePosition() with elevation', () => { it('replaces position in Point', () => { const elevatedPointFeature = { diff --git a/modules/react-map-gl-draw/README.md b/modules/react-map-gl-draw/README.md index ce7ad203a..8ee1edcb0 100644 --- a/modules/react-map-gl-draw/README.md +++ b/modules/react-map-gl-draw/README.md @@ -1,47 +1,37 @@ -[![docs](https://i.imgur.com/bRDL1oh.gif)](https://nebula.gl) +# React Map GL Draw -`react-map-gl-draw` is a drawing library tailored for [`react-map-gl`](https://github.com/uber/react-map-gl). +`react-map-gl-draw` is a react based drawing library tailored for [`react-map-gl`](https://github.com/uber/react-map-gl). -# Getting started - -## Running the example - -1. `git clone git@github.com:uber/nebula.gl.git` -2. `cd nebula.gl` -3. `yarn` -4. `cd examples/react-map-gl-draw` -5. `yarn` -6. `export MapboxAccessToken=''` -7. `yarn start-local` -8. You can view/edit geometry. - -## Installation - -``` -yarn add react-map-gl-draw -``` - -## Options +## Options - `mode` (String, Optional) - `EditorModes.READ_ONLY` - Not interactive. This is the default mode. - - `EditorModes.SELECT_FEATURE` - Lets you select, delete, and drag features. - - `EditorModes.EDIT_VERTEX` - Lets you select, delete, and drag vertices; and drag features. + - `EditorModes.SELECT` - Lets you select, delete, and drag features. + - `EditorModes.EDITTING` - Lets you select, delete, and drag vertices; and drag features. - `EditorModes.DRAW_PATH` - Lets you draw a GeoJson `LineString` feature. - `EditorModes.DRAW_POLYGON` - Lets you draw a GeoJson `Polygon` feature. - `EditorModes.DRAW_POINT` - Lets you draw a GeoJson `Point` feature. - `EditorModes.DRAW_RECTANGLE` - Lets you draw a `Rectangle` (represented as GeoJson `Polygon` feature). -- `selectedFeatureId` (String, Optional) - id of the selected feature. `EditorModes` assigns a unique id to each feature which is stored in `feature.properties.id`. -- `clickRadius` (Number, optional) - Radius to detect features around a hovered or clicked point. Default value is `0` - -- `onSelect` (Function, Required) - callback when a feature is selected. Receives an object containing `selectedFeatureId`. -- `onUpdate` (Function, Required) - callback when anything is updated. Receives one argument `features` that is the updated list of GeoJSON features. - -Feature object structure: -`react-map-gl-draw` is stateful component. +- `features` (Feature[], Optional) - List of features in GeoJson format. If `features` are provided from users, then `react-map-gl-draw` respect the users' input, and therefore ignore any internal `features`. But if `features` are not provided, then `react-map-gl-draw` manages `features` internally, and users can access and manipulate the features by calling `getFeatures`, `addFeatures`, and `deleteFeatures`. +- `selectedFeatureIndex` (String, Optional) - Index of the selected feature. +- `clickRadius` (Number, Optional) - Radius to detect features around a hovered or clicked point. Default value is `0` + +- `onSelect` (Function, Optional) - callback when clicking a position under `SELECT` and `EDITTING` mode. Receives an object containing the following parameters + - `selectedFeature`: selected feature. `null` if clicked an empty space. + - `selectedFeatureIndex`: selected feature index.`null` if clicked an empty space. + - `editHandleIndex`: selected editHandle index. `null` if clicked an empty space. + - `screenCoords`: screen coordinates of the clicked position. + - `mapCoords`: map coordinates of the clicked position. + +- `onUpdate` (Function, Optional) - callback when anything is updated. Receives an object containing the following parameters + - `features` (Feature[]) - the updated list of GeoJSON features. + - `editType` (String) - `addFeature`, `addPosition`, `finishMovePosition` + - `editContext` (Array) - list of edit objects, depend on `editType`, each object may contain `featureIndexes`, `editHandleIndexes`, `screenCoords`, `mapCoords`. + +**Feature object structure:** ```js { - id, // an unique identified generated inside react-map-gl-draw library + id, // an unique identified generated inside react-map-gl-draw library geometry: { coordinates, // latitude longitude pairs of the geometry points type // geojson type, one of `Point`, `LineString`, or `Polygon` @@ -53,26 +43,31 @@ Feature object structure: } ``` -### Styling related Options -- `style` (Object, optional) - Customized css [style objects](https://reactjs.org/docs/dom-elements.html#style) apply to the editor. Default style includes width and height from current viewport. - -- `getFeatureStyle` (Function, Optional) : Object - A function to style a feature, function parameters are - - `feature`: feature to style . - - `state`: one of `SELECTED`, `HOVERED`, `INACTIVE`, `UNCOMMITTED`. - +### Styling related options +- `featureStyle` (Object|Function, Optional) : Object - Either a [style objects](https://reactjs.org/docs/dom-elements.html#style) or a function to style a feature, function parameters are + - `feature`: feature to style. + - `index`: index of the feature. + - `state`: one of `SELECTED`, `HOVERED`, `INACTIVE`, `UNCOMMITTED`, `CLOSING`. + Returns is a map of [style objects](https://reactjs.org/docs/dom-elements.html#style) passed to SVG `path` elements. -- `getEditHandleStyle` (Function, Optional) : Object - A function to style an `editHandle, function parameters are +- `featureShape` (String|Function, Optional): if is a string, should be one of `rect` or `circle`. If is a function, will receive the following parameters - `feature`: feature to style. - - `index`: index of the editHandle vertex in the feature. - - `state`: one of `SELECTED`, `HOVERED`, `INACTIVE`, `UNCOMMITTED`. + - `index`: index of the feature. + - `state`: one of `SELECTED`, `HOVERED`, `INACTIVE`, `UNCOMMITTED`, `CLOSING`. +- `editHandleStyle` (Object|Function, Optional) : Object - Either a [style objects](https://reactjs.org/docs/dom-elements.html#style) or a function to style an `editHandle, function parameters are + - `feature`: feature to style. + - `index`: index of the editHandle vertex in the feature. + - `state`: one of `SELECTED`, `HOVERED`, `INACTIVE`, `UNCOMMITTED`, `CLOSING`. + - `shape`: shape resolved from `editHandleShape`. + Returns is a map of [style objects](https://reactjs.org/docs/dom-elements.html#style) passed to SVG `circle` or `rect` elements. -- `getEditHandleShape` (String|Function, Optional): if is a string, should be one of `rect` or `circle`. If is a function, will receive the following parameters +- `editHandleShape` (String|Function, Optional): if is a string, should be one of `rect` or `circle`. If is a function, will receive the following parameters - `feature`: feature to style. - `index`: index of the editHandle vertex in the feature. - - `state`: one of `SELECTED`, `HOVERED`, `INACTIVE`, `UNCOMMITTED`. + - `state`: one of `SELECTED`, `HOVERED`, `INACTIVE`, `UNCOMMITTED`, `CLOSING`. ## Explanations - `Feature`: any drawn shape, one of point, line, polygon or rectangle. @@ -80,81 +75,73 @@ Returns is a map of [style objects](https://reactjs.org/docs/dom-elements.html#s ### State related concepts: - `INACTIVE`: neither selected nor hovered, default state of a complete `feature` or `editHandle`. -- `SELECTED`: being clicked or dragged. +- `SELECTED`: being clicked or dragged. - `HOVERED`: hovered over by the mouse pointer. - `UNCOMMITTED`: in the middle of drawing, not yet added to the feature being edited. +- `CLOSING`: closing a polygon. ### Styling based on `state`: -![img](https://raw.githubusercontent.com/uber-common/deck.gl-data/master/nebula.gl/edit-handle.png) +![img](https://raw.githubusercontent.com/uber-common/deck.gl-data/master/nebula.gl/react-map-gl-draw.png) + +As shown in the above image, for the feature currently being edited, +- `featureStyle({feature, state: SELECTED})` will be applied to the committed parts of the feature. (Green strokes) +- `editHandleStyle({state: SELECTED})` will be applied to the committed editHandle vertices. (Vertices with black stroke) +- `featureStyle({feature, state: UNCOMMITTED})` will be applied to the uncommitted parts of the feature. (Gray stroke) +- `editHandleStyle({state: UNCOMMITTED})` will be applied to the uncommitted editHandle vertex. (Gray vertex) + +## Methods + +##### `getFeatures` + +- Return a list of finished GeoJson features. -As shown in the above image, for the feature currently being edited, -- `getFeatureStyle({feature, state: SELECTED})` will be applied to the committed parts of the feature. (Green strokes) -- `getEditHandleStyle({state: SELECTED})` will be applied to the committed editHandle vertices. (Vertices with black stroke) -- `getFeatureStyle({feature, state: UNCOMMITTED})` will be applied to the uncommitted parts of the feature. (Gray stroke) -- `getEditHandleStyle({state: UNCOMMITTED})` will be applied to the uncommitted editHandle vertex. (Gray vertex) +##### `addFeatures` (Feature | Feature[]) +- Add a single or multiple GeoJson features to editor. + +##### `deleteDeatures` (Feature | Feature[]) + +- Delete a single or multiple GeoJson features to editor. ## Code Example ```js import React, { Component } from 'react'; -import MapGL, {_MapContext as MapContext} from 'react-map-gl'; -import MapGLDraw, { EditorModes } from 'react-map-gl-draw'; +import MapGL from 'react-map-gl'; +import { Editor, EditorModes } from 'react-map-gl-draw'; const MODES = [ - { id: EditorModes.EDIT_VERTEX, text: 'Select and Edit Feature'}, + { id: EditorModes.EDITING, text: 'Select and Edit Feature'}, { id: EditorModes.DRAW_POINT, text: 'Draw Point'}, { id: EditorModes.DRAW_PATH, text: 'Draw Polyline'}, { id: EditorModes.DRAW_POLYGON, text: 'Draw Polygon'}, { id: EditorModes.DRAW_RECTANGLE, text: 'Draw Rectangle'} ]; -class App extends Component { - constructor(props) { - super(props); - this.state = { - viewport: { - width: 800, - height: 600, - longitude: -122.45, - latitude: 37.78, - zoom: 14 - }, - selectedMode: EditorModes.READ_ONLY, - features: [], - selectedFeatureId: null - }; - } - - componentDidMount() { - // add features - const initialFeatures = [{...}]; - this._mapRef.add(initialFeatures); - } - - _updateViewport = (viewport) => { - this.setState({viewport}); - } - - _onSelect = ({ selectedFeatureId }) => { - this.setState({ selectedFeatureId }); - }; +const DEFAULT_VIEWPORT = { + width: 800, + height: 600, + longitude: -122.45, + latitude: 37.78, + zoom: 14 +}; - _onUpdate = features => { - this.setState({ - features - }); +class App extends Component { + state = { + // map + viewport: DEFAULT_VIEWPORT, + // editor + selectedMode: EditorModes.READ_ONLY }; _switchMode = evt => { - const selectedMode = evt.target.id === this.state.selectedMode ? EditorModes.READ_ONLY : evt.target.id; + const selectedMode = evt.target.id; this.setState({ - selectedMode, - selectedFeatureId: null + selectedMode: selectedMode === this.state.selectedMode ? null : selectedMode }); }; - _renderControlPanel = () => { + _renderToolbar = () => { return (
); - } - - _getEditHandleStyle = ({feature, featureState, vertexIndex, vertexState}) => { - return { - fill: vertexState === `SELECTED` ? '#000' : '#aaa', - stroke: vertexState === `SELECTED` ? '#000' : 'none' - } - } - - _getFeatureStyle = ({feature, featureState}) => { - return { - stroke: featureState === `SELECTED` ? '#000' : 'none', - fill: featureState === `SELECTED` ? '#080' : 'none', - fillOpacity: 0.8 - } - } + }; render() { - const { viewport, selectedMode, selectedFeatureId, features } = this.state; + const { viewport, selectedMode } = this.state; return ( - this._drawRef = _} + - {this._renderControlPanel()} + {this._renderToolbar()} ); } diff --git a/modules/react-map-gl-draw/src/edit-modes/draw-line-string-mode.js b/modules/react-map-gl-draw/src/edit-modes/draw-line-string-mode.js index 1fa0ddc9e..a34ce3495 100644 --- a/modules/react-map-gl-draw/src/edit-modes/draw-line-string-mode.js +++ b/modules/react-map-gl-draw/src/edit-modes/draw-line-string-mode.js @@ -28,10 +28,14 @@ export default class DrawLineStringMode extends BaseMode { props.onEdit({ editType: EDIT_TYPE.ADD_POSITION, updatedData, - editContext: { - positionIndexes, - position: event.mapCoords - } + editContext: [ + { + featureIndex: selectedFeatureIndex, + editHandleIndex: positionIndexes[0], + screenCoords: event.screenCoords, + mapCoords: event.mapCoords + } + ] }); // commit tentativeFeature to featureCollection diff --git a/modules/react-map-gl-draw/src/edit-modes/editing-mode.js b/modules/react-map-gl-draw/src/edit-modes/editing-mode.js index 267b07b4a..01bc6f7c2 100644 --- a/modules/react-map-gl-draw/src/edit-modes/editing-mode.js +++ b/modules/react-map-gl-draw/src/edit-modes/editing-mode.js @@ -51,10 +51,14 @@ export default class EditingMode extends BaseMode { props.onEdit({ editType: EDIT_TYPE.ADD_POSITION, updatedData, - editContext: { - positionIndexes, - position: insertMapCoords - } + editContext: [ + { + featureIndex, + editHandleIndex: insertIndex, + screenCoords: props.viewport && props.viewport.project(insertMapCoords), + mapCoords: insertMapCoords + } + ] }); } }; diff --git a/modules/react-map-gl-draw/src/edit-modes/index.js b/modules/react-map-gl-draw/src/edit-modes/index.js index 7e174e644..4ca70f375 100644 --- a/modules/react-map-gl-draw/src/edit-modes/index.js +++ b/modules/react-map-gl-draw/src/edit-modes/index.js @@ -1,3 +1,4 @@ +export { default as SelectMode } from './select-mode'; export { default as EditingMode } from './editing-mode'; export { default as BaseMode } from './base-mode'; export { default as DrawPointMode } from './draw-point-mode'; diff --git a/modules/react-map-gl-draw/src/edit-modes/select-mode.js b/modules/react-map-gl-draw/src/edit-modes/select-mode.js new file mode 100644 index 000000000..bff7996e6 --- /dev/null +++ b/modules/react-map-gl-draw/src/edit-modes/select-mode.js @@ -0,0 +1,152 @@ +// @flow + +import type { FeatureCollection, StopDraggingEvent, PointerMoveEvent } from '@nebula.gl/edit-modes'; +import type { ModeProps } from '../types'; + +import { EDIT_TYPE, ELEMENT_TYPE, GEOJSON_TYPE } from '../constants'; +import BaseMode from './base-mode'; +import { getFeatureCoordinates, isNumeric, updateRectanglePosition } from './utils'; + +export default class SelectMode extends BaseMode { + handleStopDragging(event: StopDraggingEvent, props: ModeProps) { + // replace point + const pickedObject = event.picks && event.picks[0] && event.picks[0].object; + if (!pickedObject || !isNumeric(pickedObject.featureIndex)) { + return; + } + + switch (pickedObject.type) { + case ELEMENT_TYPE.FEATURE: + case ELEMENT_TYPE.EDIT_HANDLE: + this._handleDragging(event, props); + break; + default: + } + } + + _handleDragging = ( + event: PointerMoveEvent | StopDraggingEvent, + props: ModeProps + ) => { + const { onEdit } = props; + // nothing clicked + const { isDragging, pointerDownPicks, screenCoords } = event; + const { lastPointerMoveEvent } = props; + + const clickedObject = pointerDownPicks && pointerDownPicks[0] && pointerDownPicks[0].object; + if (!clickedObject || !isNumeric(clickedObject.featureIndex)) { + return; + } + + // not dragging + let updatedData = null; + const editType = isDragging ? EDIT_TYPE.MOVE_POSITION : EDIT_TYPE.FINISH_MOVE_POSITION; + + switch (clickedObject.type) { + case ELEMENT_TYPE.FEATURE: + case ELEMENT_TYPE.FILL: + case ELEMENT_TYPE.SEGMENT: + case ELEMENT_TYPE.EDIT_HANDLE: + // dragging feature + const dx = screenCoords[0] - lastPointerMoveEvent.screenCoords[0]; + const dy = screenCoords[1] - lastPointerMoveEvent.screenCoords[1]; + updatedData = this._updateFeature(props, 'feature', { dx, dy }); + onEdit({ + editType, + updatedData, + editContext: null + }); + break; + + default: + } + }; + + handlePointerMove = (event: PointerMoveEvent, props: ModeProps) => { + // no selected feature + const selectedFeature = this.getSelectedFeature(props); + if (!selectedFeature) { + return; + } + + if (!event.isDragging) { + return; + } + + this._handleDragging(event, props); + }; + + // TODO - refactor + _updateFeature = (props: ModeProps, type: string, options: any = {}) => { + const { data, selectedIndexes, viewport } = props; + + const featureIndex = selectedIndexes && selectedIndexes[0]; + const feature = this.getSelectedFeature(props, featureIndex); + + let geometry = null; + const coordinates = getFeatureCoordinates(feature); + if (!coordinates) { + return null; + } + + let newCoordinates = [...coordinates]; + + switch (type) { + case 'feature': + const { dx, dy } = options; + newCoordinates = newCoordinates + .map(mapCoords => { + const pixels = viewport && viewport.project(mapCoords); + if (pixels) { + pixels[0] += dx; + pixels[1] += dy; + return viewport && viewport.unproject(pixels); + } + return null; + }) + .filter(Boolean); + + geometry = { + type: feature.geometry.type, + coordinates: + feature.geometry.type === GEOJSON_TYPE.POLYGON ? [newCoordinates] : newCoordinates + }; + + return data.replaceGeometry(featureIndex, geometry).getObject(); + + case 'rectangle': + // moved editHandleIndex and destination mapCoords + newCoordinates = updateRectanglePosition( + feature, + options.editHandleIndex, + options.mapCoords + ); + + geometry = { + type: GEOJSON_TYPE.POLYGON, + coordinates: newCoordinates + }; + + return data.replaceGeometry(featureIndex, geometry).getObject(); + + default: + return data && data.getObject(); + } + }; + + getGuides = (props: ModeProps) => { + const selectedFeature = this.getSelectedFeature(props); + const selectedFeatureIndex = props.selectedIndexes && props.selectedIndexes[0]; + + if (!selectedFeature || selectedFeature.geometry.type === GEOJSON_TYPE.POINT) { + return null; + } + + // feature editHandles + const editHandles = this.getEditHandlesFromFeature(selectedFeature, selectedFeatureIndex) || []; + + return { + editHandles: editHandles.length ? editHandles : null + }; + }; +} diff --git a/modules/react-map-gl-draw/src/editor.js b/modules/react-map-gl-draw/src/editor.js index 59251be59..65a3ad3dc 100644 --- a/modules/react-map-gl-draw/src/editor.js +++ b/modules/react-map-gl-draw/src/editor.js @@ -9,7 +9,23 @@ import { RENDER_STATE, RENDER_TYPE, GEOJSON_TYPE, GUIDE_TYPE, ELEMENT_TYPE } fro import ModeHandler from './mode-handler'; import { getFeatureCoordinates } from './edit-modes/utils'; +import { + editHandleStyle as defaultEditHandleStyle, + featureStyle as defaultFeatureStyle +} from './style'; + +const defaultProps = { + ...ModeHandler.defaultProps, + clickRadius: 0, + featureShape: 'circle', + editHandleShape: 'rect', + editHandleStyle: defaultEditHandleStyle, + featureStyle: defaultFeatureStyle +}; + export default class Editor extends ModeHandler { + static defaultProps = defaultProps; + /* HELPERS */ _getPathInScreenCoords(coordinates: any, type: GeoJsonType) { if (coordinates.length === 0) { @@ -54,15 +70,26 @@ export default class Editor extends ModeHandler { return RENDER_STATE.SELECTED; } - if (hovered && hovered.type === ELEMENT_TYPE.EDIT_HANDLE && hovered.index === editHandleIndex) { - return RENDER_STATE.HOVERED; + if (hovered && hovered.type === ELEMENT_TYPE.EDIT_HANDLE) { + if (hovered.index === editHandleIndex) { + return RENDER_STATE.HOVERED; + } + + // cursor hovered on first vertex when drawing polygon + if ( + hovered.index === 0 && + editHandle.properties.guideType === GUIDE_TYPE.CURSOR_EDIT_HANDLE + ) { + return RENDER_STATE.CLOSING; + } } return RENDER_STATE.INACTIVE; }; _getFeatureRenderState = (index: number, renderState: ?RenderState) => { - const { selectedFeatureIndex, hovered } = this.state; + const { hovered } = this.state; + const selectedFeatureIndex = this._getSelectedFeatureIndex(); if (renderState) { return renderState; } @@ -78,6 +105,10 @@ export default class Editor extends ModeHandler { return RENDER_STATE.INACTIVE; }; + _getStyleProp = (styleProp: any, params: any) => { + return typeof styleProp === 'function' ? styleProp(params) : styleProp; + }; + /* RENDER */ /* eslint-disable max-params */ _renderEditHandle = (editHandle: Feature, feature: Feature) => { @@ -91,22 +122,22 @@ export default class Editor extends ModeHandler { const { properties: { featureIndex, positionIndexes } } = editHandle; - const { clickRadius, getEditHandleShape, getEditHandleStyle } = this.props; + const { clickRadius, editHandleShape, editHandleStyle } = this.props; const index = positionIndexes[0]; - const shape = - typeof getEditHandleShape === 'function' - ? getEditHandleShape({ - feature: feature || editHandle, - index, - featureIndex - }) - : getEditHandleShape; + const shape = this._getStyleProp(editHandleShape, { + feature: feature || editHandle, + index, + featureIndex, + state: this._getEditHandleState(editHandle) + }); - let style = getEditHandleStyle({ + let style = this._getStyleProp(editHandleStyle, { feature: feature || editHandle, index, + featureIndex, + shape, state: this._getEditHandleState(editHandle) }); @@ -114,6 +145,7 @@ export default class Editor extends ModeHandler { if (editHandle.properties.guideType === GUIDE_TYPE.CURSOR_EDIT_HANDLE) { style = { ...style, + // disable pointer events for cursor pointerEvents: 'none' }; } @@ -233,7 +265,7 @@ export default class Editor extends ModeHandler { }; _renderTentativeFeature = (feature: Feature, cursorEditHandle: Feature) => { - const { getFeatureStyle } = this.props; + const { featureStyle } = this.props; const { geometry: { coordinates }, properties: { renderType } @@ -246,7 +278,11 @@ export default class Editor extends ModeHandler { // >= 2 coordinates const firstCoords = coordinates[0]; const lastCoords = coordinates[coordinates.length - 1]; - const uncommittedStyle = getFeatureStyle({ feature, state: RENDER_STATE.UNCOMMITTED }); + const uncommittedStyle = this._getStyleProp(featureStyle, { + feature, + index: null, + state: RENDER_STATE.UNCOMMITTED + }); let committedPath; let uncommittedPath; @@ -256,7 +292,11 @@ export default class Editor extends ModeHandler { switch (renderType) { case RENDER_TYPE.LINE_STRING: case RENDER_TYPE.POLYGON: - const committedStyle = getFeatureStyle({ feature, state: RENDER_STATE.SELECTED }); + const committedStyle = this._getStyleProp(featureStyle, { + feature, + state: RENDER_STATE.SELECTED + }); + if (cursorEditHandle) { const cursorCoords = coordinates[coordinates.length - 2]; committedPath = this._renderSegments( @@ -275,7 +315,12 @@ export default class Editor extends ModeHandler { } if (renderType === RENDER_TYPE.POLYGON) { - const closingStyle = getFeatureStyle({ feature, state: RENDER_STATE.CLOSING }); + const closingStyle = this._getStyleProp(featureStyle, { + feature, + index: null, + state: RENDER_STATE.CLOSING + }); + closingPath = this._renderSegment( 'tentative-closing', coordinates.length - 1, @@ -320,12 +365,9 @@ export default class Editor extends ModeHandler { _renderPoint = (feature: Feature, index: number, path: string) => { const renderState = this._getFeatureRenderState(index); - const { getFeatureStyle, getFeatureShape, clickRadius } = this.props; - const style = getFeatureStyle({ feature, state: renderState }); - const shape = - typeof getFeatureShape === 'function' - ? getFeatureShape({ feature, state: renderState }) - : getFeatureShape; + const { featureStyle, featureShape, clickRadius } = this.props; + const shape = this._getStyleProp(featureShape, { feature, index, state: renderState }); + const style = this._getStyleProp(featureStyle, { feature, index, state: renderState }); const elemKey = `feature.${index}`; if (shape === 'rect') { @@ -380,11 +422,11 @@ export default class Editor extends ModeHandler { }; _renderPath = (feature: Feature, index: number, path: string) => { - const { getFeatureStyle, clickRadius } = this.props; - const { selectedFeatureIndex } = this.state; + const { featureStyle, clickRadius } = this.props; + const selectedFeatureIndex = this._getSelectedFeatureIndex(); const selected = index === selectedFeatureIndex; const renderState = this._getFeatureRenderState(index); - const style = getFeatureStyle({ feature, state: renderState }); + const style = this._getStyleProp(featureStyle, { feature, index, state: renderState }); const elemKey = `feature.${index}`; if (selected) { @@ -419,12 +461,12 @@ export default class Editor extends ModeHandler { }; _renderPolygon = (feature: Feature, index: number, path: string) => { - const { getFeatureStyle } = this.props; - const { selectedFeatureIndex } = this.state; + const { featureStyle } = this.props; + const selectedFeatureIndex = this._getSelectedFeatureIndex(); const selected = index === selectedFeatureIndex; const renderState = this._getFeatureRenderState(index); - const style = getFeatureStyle({ feature, state: renderState }); + const style = this._getStyleProp(featureStyle, { feature, index, state: renderState }); const elemKey = `feature.${index}`; if (selected) { diff --git a/modules/react-map-gl-draw/src/memoize.js b/modules/react-map-gl-draw/src/memoize.js new file mode 100644 index 000000000..7ca30757d --- /dev/null +++ b/modules/react-map-gl-draw/src/memoize.js @@ -0,0 +1,45 @@ +// @flow +// port from @deck.gl/core + +function isEqual(a: any, b: any) { + if (a === b) { + return true; + } + if (Array.isArray(a)) { + // Special treatment for arrays: compare 1-level deep + // This is to support equality of matrix/coordinate props + const len = a.length; + if (!b || b.length !== len) { + return false; + } + + for (let i = 0; i < len; i++) { + if (a[i] !== b[i]) { + return false; + } + } + return true; + } + return false; +} + +/** + * Speed up consecutive function calls by caching the result of calls with identical input + * https://en.wikipedia.org/wiki/Memoization + * @param {function} compute - the function to be memoized + */ +export default function memoize(compute: Function) { + let cachedArgs = {}; + let cachedResult; + + return (args: any) => { + for (const key in args) { + if (!isEqual(args[key], cachedArgs[key])) { + cachedResult = compute(args); + cachedArgs = args; + break; + } + } + return cachedResult; + }; +} diff --git a/modules/react-map-gl-draw/src/mode-handler.js b/modules/react-map-gl-draw/src/mode-handler.js index 0ff9c9443..790d551b7 100644 --- a/modules/react-map-gl-draw/src/mode-handler.js +++ b/modules/react-map-gl-draw/src/mode-handler.js @@ -1,30 +1,27 @@ // @flow import { _MapContext as MapContext } from 'react-map-gl'; -import React, { Component } from 'react'; +import React, { PureComponent } from 'react'; import { ImmutableFeatureCollection } from '@nebula.gl/edit-modes'; -import type { Position, EditAction } from '@nebula.gl/edit-modes'; +import type { Feature, Position, EditAction } from '@nebula.gl/edit-modes'; import type { MjolnirEvent } from 'mjolnir.js'; -import type { BaseEvent, EditorProps, EditorState, Mode } from './types'; +import type { BaseEvent, EditorProps, EditorState, SelectAction } from './types'; +import memoize from './memoize'; -import { DRAWING_MODE, EDIT_TYPE, MODES } from './constants'; +import { DRAWING_MODE, EDIT_TYPE, ELEMENT_TYPE, MODES } from './constants'; import { getScreenCoords, isNumeric, parseEventElement } from './edit-modes/utils'; import { - BaseMode, + SelectMode, EditingMode, DrawPointMode, DrawLineStringMode, DrawRectangleMode, DrawPolygonMode } from './edit-modes'; -import { - getEditHandleStyle as defaultGetEditHandleStyle, - getFeatureStyle as defaultGetFeatureStyle -} from './style'; const MODE_TO_HANDLER = Object.freeze({ [MODES.READ_ONLY]: null, - [MODES.SELECT]: BaseMode, + [MODES.SELECT]: SelectMode, [MODES.EDITING]: EditingMode, [MODES.DRAW_POINT]: DrawPointMode, [MODES.DRAW_PATH]: DrawLineStringMode, @@ -34,13 +31,9 @@ const MODE_TO_HANDLER = Object.freeze({ const defaultProps = { mode: MODES.READ_ONLY, - selectedFeatureId: null, - clickRadius: 0, - getEditHandleStyle: defaultGetEditHandleStyle, - getFeatureStyle: defaultGetFeatureStyle, - getFeatureShape: 'circle', - getEditHandleShape: 'circle', - onSelect: () => {} + features: null, + onSelect: null, + onUpdate: null }; const defaultState = { @@ -50,7 +43,6 @@ const defaultState = { }), selectedFeatureIndex: null, - selectedFeatureId: null, // index, isGuide, mapCoords, screenCoords hovered: null, @@ -65,7 +57,7 @@ const defaultState = { pointerDownMapCoords: null }; -export default class ModeHandler extends Component { +export default class ModeHandler extends PureComponent { static defaultProps = defaultProps; constructor() { @@ -85,47 +77,10 @@ export default class ModeHandler extends Component { }; } - componentWillReceiveProps(nextProps: EditorProps, nextContext: any) { - if (this.props.mode !== nextProps.mode) { + componentDidUpdate(prevProps: EditorProps) { + if (prevProps.mode !== this.props.mode) { this._clearEditingState(); - - if (this._eventsRegistered && (!nextProps.mode || nextProps.mode === MODES.READ_ONLY)) { - this._degregisterEvents(); - } - - if (!this._eventsRegistered && nextProps.mode && nextProps.mode !== MODES.READ_ONLY) { - this._registerEvents(); - } - - this._setupModeHandler(nextProps.mode); - } - - if (this.props.features !== nextProps.features) { - let featureCollection = nextProps.features; - - if (nextProps.features && Array.isArray(nextProps.features)) { - featureCollection = { - type: 'FeatureCollection', - features: nextProps.features - }; - } - - featureCollection = new ImmutableFeatureCollection(featureCollection); - - this.setState({ - featureCollection - }); - } - - if (this.props.selectedFeatureId !== nextProps.selectedFeatureId) { - this._clearEditingState(); - const features = this.getFeatures(); - const selectedFeatureIndex = - features && features.findIndex(f => f.properties.id === nextProps.selectedFeatureId); - this.setState({ - selectedFeatureId: nextProps.selectedFeatureId, - selectedFeatureIndex: isNumeric(selectedFeatureIndex) ? selectedFeatureIndex : null - }); + this._setupModeHandler(); } } @@ -140,13 +95,44 @@ export default class ModeHandler extends Component { _containerRef: ?HTMLElement; getFeatures = () => { - let featureCollection = this.state.featureCollection; + let featureCollection = this._getFeatureCollection(); featureCollection = featureCollection && featureCollection.getObject(); return featureCollection && featureCollection.features; }; + addFeatures = (features: Feature | Feature[]) => { + let featureCollection = this._getFeatureCollection(); + if (featureCollection) { + if (!Array.isArray(features)) { + features = [features]; + } + + featureCollection = featureCollection.addFeatures(features); + this.setState({ featureCollection }); + } + }; + + deleteFeatures = (featureIndexes: number | number[]) => { + let featureCollection = this._getFeatureCollection(); + const selectedFeatureIndex = this._getSelectedFeatureIndex(); + if (featureCollection) { + if (!Array.isArray(featureIndexes)) { + featureIndexes = [featureIndexes]; + } + featureCollection = featureCollection.deleteFeatures(featureIndexes); + const newState: any = { featureCollection }; + if (featureIndexes.findIndex(index => selectedFeatureIndex === index) >= 0) { + newState.selectedFeatureIndex = null; + } + this.setState(newState); + } + }; + getModeProps() { - const { selectedFeatureIndex, lastPointerMoveEvent, featureCollection } = this.state; + const featureCollection = this._getFeatureCollection(); + + const { lastPointerMoveEvent } = this.state; + const selectedFeatureIndex = this._getSelectedFeatureIndex(); const viewport = this._context && this._context.viewport; return { @@ -158,20 +144,54 @@ export default class ModeHandler extends Component { }; } - _setupModeHandler = (mode: Mode) => { - if (mode === MODES.READ_ONLY) { + /* MEMORIZERS */ + _getMemorizedFeatureCollection = memoize(({ propsFeatures, stateFeatures }: any) => { + const features = propsFeatures || stateFeatures; + // Any changes in ImmutableFeatureCollection will create a new object + if (features instanceof ImmutableFeatureCollection) { + return features; + } + + if (features && features.type === 'FeatureCollection') { + return new ImmutableFeatureCollection({ + type: 'FeatureCollection', + features: features.features + }); + } + + return new ImmutableFeatureCollection({ + type: 'FeatureCollection', + features: features || [] + }); + }); + + _getFeatureCollection = () => { + return this._getMemorizedFeatureCollection({ + propsFeatures: this.props.features, + stateFeatures: this.state.featureCollection + }); + }; + + _setupModeHandler = () => { + const mode = this.props.mode; + + if (!mode || mode === MODES.READ_ONLY) { + this._degregisterEvents(); this._modeHandler = null; return; } + this._registerEvents(); + const HandlerClass = MODE_TO_HANDLER[mode]; - if (HandlerClass) { - this._modeHandler = new HandlerClass(); - } + this._modeHandler = HandlerClass ? new HandlerClass() : null; }; + /* EDITING OPERATIONS */ _clearEditingState = () => { this.setState({ + selectedFeatureIndex: null, + hovered: null, pointerDownPicks: null, @@ -183,40 +203,67 @@ export default class ModeHandler extends Component { }); }; + _getSelectedFeatureIndex = () => { + return isNumeric(this.props.selectedFeatureIndex) + ? this.props.selectedFeatureIndex + : this.state.selectedFeatureIndex; + }; + + _getSelectedFeature = (featureIndex: ?number) => { + const features = this.getFeatures(); + featureIndex = isNumeric(featureIndex) ? featureIndex : this._getSelectedFeatureIndex(); + return features[featureIndex]; + }; + + _onSelect = (selected: SelectAction) => { + this.setState({ selectedFeatureIndex: selected && selected.selectedFeatureIndex }); + if (this.props.onSelect) { + this.props.onSelect(selected); + } + }; + + _onUpdate = (editAction: EditAction, isInternal: ?boolean) => { + const { editType, updatedData, editContext } = editAction; + this.setState({ featureCollection: new ImmutableFeatureCollection(updatedData) }); + if (this.props.onUpdate && !isInternal) { + this.props.onUpdate({ + data: updatedData && updatedData.features, + editType, + editContext + }); + } + }; + _onEdit = (editAction: EditAction) => { - const { mode, onSelect, onUpdate } = this.props; + const { mode } = this.props; const { editType, updatedData } = editAction; switch (editType) { case EDIT_TYPE.MOVE_POSITION: // intermediate feature, do not need forward to application - // update editor state - this.setState({ - featureCollection: new ImmutableFeatureCollection(updatedData) - }); + // only need update editor internal state + this._onUpdate(editAction, true); break; case EDIT_TYPE.ADD_FEATURE: - onUpdate(updatedData && updatedData.features); + this._onUpdate(editAction); if (mode === MODES.DRAW_PATH) { + const context = (editAction.editContext && editAction.editContext[0]) || {}; + const { screenCoords, mapCoords } = context; const featureIndex = updatedData.features.length - 1; - const feature = updatedData.features[featureIndex]; - - // TODO deprecate selectedFeatureId - onSelect({ - selectedFeatureId: feature.properties.id, - selectedFeatureIndex: featureIndex - }); - } else { - onSelect({ - selectedFeatureId: null, - selectedFeatureIndex: null + const selectedFeature = this._getSelectedFeature(featureIndex); + this._onSelect({ + selectedFeature, + selectedFeatureIndex: featureIndex, + selectedEditHandleIndex: null, + screenCoords, + mapCoords }); } break; case EDIT_TYPE.ADD_POSITION: case EDIT_TYPE.REMOVE_POSITION: case EDIT_TYPE.FINISH_MOVE_POSITION: - onUpdate(updatedData && updatedData.features); + this._onUpdate(editAction); break; default: @@ -229,8 +276,11 @@ export default class ModeHandler extends Component { if (!this._events || !eventManager) { return; } - eventManager.off(this._events); - this._eventsRegistered = false; + + if (this._eventsRegistered) { + eventManager.off(this._events); + this._eventsRegistered = false; + } }; _registerEvents = () => { @@ -239,6 +289,11 @@ export default class ModeHandler extends Component { if (!this._events || !ref || !eventManager) { return; } + + if (this._eventsRegistered) { + return; + } + eventManager.on(this._events, ref); this._eventsRegistered = true; }; @@ -255,19 +310,26 @@ export default class ModeHandler extends Component { _onClick = (event: BaseEvent) => { const { mode } = this.props; if (mode === MODES.SELECT || mode === MODES.EDITING) { - const { onSelect } = this.props; + const { mapCoords, screenCoords } = event; const pickedObject = event.picks && event.picks[0] && event.picks[0].object; if (pickedObject && isNumeric(pickedObject.featureIndex)) { - const features = this.getFeatures(); - const feature = features && features[pickedObject.featureIndex]; - onSelect({ - selectedFeatureIndex: pickedObject.featureIndex, - selectedFeatureId: feature && feature.properties.id + const selectedFeatureIndex = pickedObject.featureIndex; + const selectedFeature = this._getSelectedFeature(selectedFeatureIndex); + this._onSelect({ + selectedFeature, + selectedFeatureIndex, + selectedEditHandleIndex: + pickedObject.type === ELEMENT_TYPE.EDIT_HANDLE ? pickedObject.index : null, + mapCoords, + screenCoords }); - } else if (this.state.selectedFeatureId) { - onSelect({ + } else { + this._onSelect({ + selectedFeature: null, selectedFeatureIndex: null, - selectedFeatureId: null + selectedEditHandleIndex: null, + mapCoords, + screenCoords }); } } @@ -388,11 +450,16 @@ export default class ModeHandler extends Component { } _getHoverState = (event: BaseEvent) => { - if (this._isDrawing()) { + const object = event.picks && event.picks[0] && event.picks[0].object; + if (!object) { return null; } - return event.picks && event.picks[0] && event.picks[0].object; + return { + screenCoords: event.screenCoords, + mapCoords: event.mapCoords, + ...object + }; }; _isDrawing() { diff --git a/modules/react-map-gl-draw/src/style.js b/modules/react-map-gl-draw/src/style.js index ff9e02037..08ece4f04 100644 --- a/modules/react-map-gl-draw/src/style.js +++ b/modules/react-map-gl-draw/src/style.js @@ -9,15 +9,6 @@ const RECT_STYLE = { width: 12 }; -const CLOSING_RECT_STYLE = { - stroke: '#7ac943', - strokeWidth: 2, - x: -10, - y: -10, - height: 20, - width: 20 -}; - const CIRCLE_RADIUS = 8; const SELECTED_STYLE = { @@ -50,7 +41,7 @@ const DEFAULT_STYLE = { fillOpacity: 0.1 }; -export function getFeatureStyle({ feature, state }) { +export function featureStyle({ feature, state }) { const renderType = feature.properties.renderType; let style = null; @@ -99,9 +90,7 @@ export function getFeatureStyle({ feature, state }) { return style; } -export function getEditHandleStyle({ feature, index, state }) { - const renderType = feature.properties.renderType; - +export function editHandleStyle({ feature, shape, index, state }) { let style = {}; switch (state) { case RENDER_STATE.SELECTED: @@ -125,19 +114,12 @@ export function getEditHandleStyle({ feature, index, state }) { style = { ...DEFAULT_STYLE }; } - switch (renderType) { - case RENDER_TYPE.POINT: + switch (shape) { + case 'circle': style.r = CIRCLE_RADIUS; break; - - case RENDER_TYPE.LINE_STRING: - case RENDER_TYPE.POLYGON: - case RENDER_TYPE.RECTANGLE: - if (state === RENDER_STATE.CLOSING) { - style = { ...style, ...CLOSING_RECT_STYLE }; - } else { - style = { ...style, ...RECT_STYLE }; - } + case 'rect': + style = { ...style, ...RECT_STYLE }; break; default: } diff --git a/modules/react-map-gl-draw/src/types.js b/modules/react-map-gl-draw/src/types.js index ee5d72baa..2dfca7b85 100644 --- a/modules/react-map-gl-draw/src/types.js +++ b/modules/react-map-gl-draw/src/types.js @@ -11,12 +11,11 @@ import type { ScreenCoordinates } from '@nebula.gl/edit-modes'; -import { RENDER_STATE, RENDER_TYPE, MODES, GEOJSON_TYPE } from './constants'; +import { RENDER_STATE, MODES, GEOJSON_TYPE } from './constants'; export type Id = string | number; export type Mode = $Keys; -export type RenderType = $Values; export type RenderState = $Values; export type GeoJsonType = $Values; @@ -50,12 +49,12 @@ export type EditorProps = { mode: Mode, style: ?Object, features: ?(Feature[]), - selectedFeatureId: ?Id, + selectedFeatureIndex: ?number, clickRadius: number, - getFeatureShape: Function | string, - getEditHandleShape: Function | string, - getEditHandleStyle: Function, - getFeatureStyle: Function, + featureShape: Function | string, + editHandleShape: Function | string, + editHandleStyle: Function | any, + featureStyle: Function | any, onUpdate: Function, onSelect: Function }; @@ -64,8 +63,6 @@ export type EditorState = { featureCollection: ?ImmutableFeatureCollection, selectedFeatureIndex: ?number, - // TODO deprecate selectedFeatureId - selectedFeatureId: ?Id, hovered: ?Pick, lastPointerMoveEvent: PointerMoveEvent, @@ -79,3 +76,11 @@ export type EditorState = { }; export type BaseEvent = ClickEvent; + +export type SelectAction = { + selectedFeature: ?Feature, + selectedFeatureIndex?: ?number, + selectedEditHandleIndex?: ?number, + screenCoords: ?ScreenCoordinates, + mapCoords: ?Position +};