UNPKG

@nebula.gl/layers

Version:

A suite of 3D-enabled data editing layers, suitable for deck.gl

496 lines (431 loc) 15.6 kB
/* eslint-env browser */ import { GeoJsonLayer, ScatterplotLayer, IconLayer, TextLayer } from '@deck.gl/layers'; import { ViewMode, ModifyMode, TranslateMode, ScaleMode, RotateMode, DuplicateMode, SplitPolygonMode, ExtrudeMode, ElevationMode, DrawPointMode, DrawLineStringMode, DrawPolygonMode, DrawRectangleMode, DrawSquareFromCenterMode, DrawCircleFromCenterMode, DrawCircleByDiameterMode, DrawEllipseByBoundingBoxMode, DrawRectangleUsingThreePointsMode, DrawEllipseUsingThreePointsMode, Draw90DegreePolygonMode, DrawPolygonByDraggingMode, SnappableMode, TransformMode, EditAction, ClickEvent, StartDraggingEvent, StopDraggingEvent, DraggingEvent, PointerMoveEvent, GeoJsonEditModeType, GeoJsonEditModeConstructor, FeatureCollection, } from '@nebula.gl/edit-modes'; import EditableLayer from './editable-layer'; const DEFAULT_LINE_COLOR = [0x0, 0x0, 0x0, 0x99]; const DEFAULT_FILL_COLOR = [0x0, 0x0, 0x0, 0x90]; const DEFAULT_SELECTED_LINE_COLOR = [0x0, 0x0, 0x0, 0xff]; const DEFAULT_SELECTED_FILL_COLOR = [0x0, 0x0, 0x90, 0x90]; const DEFAULT_TENTATIVE_LINE_COLOR = [0x90, 0x90, 0x90, 0xff]; const DEFAULT_TENTATIVE_FILL_COLOR = [0x90, 0x90, 0x90, 0x90]; const DEFAULT_EDITING_EXISTING_POINT_COLOR = [0xc0, 0x0, 0x0, 0xff]; const DEFAULT_EDITING_INTERMEDIATE_POINT_COLOR = [0x0, 0x0, 0x0, 0x80]; const DEFAULT_EDITING_SNAP_POINT_COLOR = [0x7c, 0x00, 0xc0, 0xff]; const DEFAULT_EDITING_POINT_OUTLINE_COLOR = [0xff, 0xff, 0xff, 0xff]; const DEFAULT_EDITING_EXISTING_POINT_RADIUS = 5; const DEFAULT_EDITING_INTERMEDIATE_POINT_RADIUS = 3; const DEFAULT_EDITING_SNAP_POINT_RADIUS = 7; const DEFAULT_EDIT_MODE = DrawPolygonMode; function guideAccessor(accessor) { if (!accessor || typeof accessor !== 'function') { return accessor; } return (guideMaybeWrapped) => accessor(unwrapGuide(guideMaybeWrapped)); } // The object handed to us from deck.gl is different depending on the version of deck.gl used, unwrap as necessary function unwrapGuide(guideMaybeWrapped) { if (guideMaybeWrapped.__source) { return guideMaybeWrapped.__source.object; } else if (guideMaybeWrapped.sourceFeature) { return guideMaybeWrapped.sourceFeature.feature; } // It is not wrapped, return as is return guideMaybeWrapped; } function getEditHandleColor(handle) { switch (handle.properties.editHandleType) { case 'existing': return DEFAULT_EDITING_EXISTING_POINT_COLOR; case 'snap-source': return DEFAULT_EDITING_SNAP_POINT_COLOR; case 'intermediate': default: return DEFAULT_EDITING_INTERMEDIATE_POINT_COLOR; } } function getEditHandleOutlineColor(handle) { return DEFAULT_EDITING_POINT_OUTLINE_COLOR; } function getEditHandleRadius(handle) { switch (handle.properties.editHandleType) { case 'existing': return DEFAULT_EDITING_EXISTING_POINT_RADIUS; case 'snap': return DEFAULT_EDITING_SNAP_POINT_RADIUS; case 'intermediate': default: return DEFAULT_EDITING_INTERMEDIATE_POINT_RADIUS; } } const defaultProps = { mode: DEFAULT_EDIT_MODE, // Edit and interaction events onEdit: () => {}, pickable: true, pickingRadius: 10, pickingDepth: 5, fp64: false, filled: true, stroked: true, lineWidthScale: 1, lineWidthMinPixels: 1, lineWidthMaxPixels: Number.MAX_SAFE_INTEGER, lineWidthUnits: 'pixels', lineJointRounded: false, lineMiterLimit: 4, pointRadiusScale: 1, pointRadiusMinPixels: 2, pointRadiusMaxPixels: Number.MAX_SAFE_INTEGER, getLineColor: (feature, isSelected, mode) => isSelected ? DEFAULT_SELECTED_LINE_COLOR : DEFAULT_LINE_COLOR, getFillColor: (feature, isSelected, mode) => isSelected ? DEFAULT_SELECTED_FILL_COLOR : DEFAULT_FILL_COLOR, getRadius: (f) => (f && f.properties && f.properties.radius) || (f && f.properties && f.properties.size) || 1, getLineWidth: (f) => (f && f.properties && f.properties.lineWidth) || 3, // Tentative feature rendering getTentativeLineColor: (f) => DEFAULT_TENTATIVE_LINE_COLOR, getTentativeFillColor: (f) => DEFAULT_TENTATIVE_FILL_COLOR, getTentativeLineWidth: (f) => (f && f.properties && f.properties.lineWidth) || 3, editHandleType: 'point', // point handles editHandlePointRadiusScale: 1, editHandlePointOutline: true, editHandlePointStrokeWidth: 2, editHandlePointRadiusMinPixels: 4, editHandlePointRadiusMaxPixels: 8, getEditHandlePointColor: getEditHandleColor, getEditHandlePointOutlineColor: getEditHandleOutlineColor, getEditHandlePointRadius: getEditHandleRadius, // icon handles editHandleIconAtlas: null, editHandleIconMapping: null, editHandleIconSizeScale: 1, getEditHandleIcon: (handle) => handle.properties.editHandleType, getEditHandleIconSize: 10, getEditHandleIconColor: getEditHandleColor, getEditHandleIconAngle: 0, // misc billboard: true, }; // Mapping of mode name to mode class (for legacy purposes) const modeNameMapping = { view: ViewMode, // Alter modes modify: ModifyMode, translate: new SnappableMode(new TranslateMode()), transform: new SnappableMode(new TransformMode()), scale: ScaleMode, rotate: RotateMode, duplicate: DuplicateMode, split: SplitPolygonMode, extrude: ExtrudeMode, elevation: ElevationMode, // Draw modes drawPoint: DrawPointMode, drawLineString: DrawLineStringMode, drawPolygon: DrawPolygonMode, drawRectangle: DrawRectangleMode, drawSquareFromCenter: DrawSquareFromCenterMode, drawCircleFromCenter: DrawCircleFromCenterMode, drawCircleByBoundingBox: DrawCircleByDiameterMode, drawEllipseByBoundingBox: DrawEllipseByBoundingBoxMode, drawRectangleUsing3Points: DrawRectangleUsingThreePointsMode, drawEllipseUsing3Points: DrawEllipseUsingThreePointsMode, draw90DegreePolygon: Draw90DegreePolygonMode, drawPolygonByDragging: DrawPolygonByDraggingMode, }; type Props = { mode: string | GeoJsonEditModeConstructor | GeoJsonEditModeType; onEdit: (arg0: EditAction<FeatureCollection>) => void; // TODO: type the rest [key: string]: any; }; // type State = { // mode: GeoJsonEditMode, // tentativeFeature: ?Feature, // editHandles: any[], // selectedFeatures: Feature[] // }; export default class EditableGeoJsonLayer extends EditableLayer { static layerName = 'EditableGeoJsonLayer'; static defaultProps = defaultProps; // props: Props; // setState: ($Shape<State>) => void; renderLayers() { const subLayerProps = this.getSubLayerProps({ id: 'geojson', // Proxy most GeoJsonLayer props as-is data: this.props.data, fp64: this.props.fp64, filled: this.props.filled, stroked: this.props.stroked, lineWidthScale: this.props.lineWidthScale, lineWidthMinPixels: this.props.lineWidthMinPixels, lineWidthMaxPixels: this.props.lineWidthMaxPixels, lineWidthUnits: this.props.lineWidthUnits, lineJointRounded: this.props.lineJointRounded, lineMiterLimit: this.props.lineMiterLimit, pointRadiusScale: this.props.pointRadiusScale, pointRadiusMinPixels: this.props.pointRadiusMinPixels, pointRadiusMaxPixels: this.props.pointRadiusMaxPixels, getLineColor: this.selectionAwareAccessor(this.props.getLineColor), getFillColor: this.selectionAwareAccessor(this.props.getFillColor), getRadius: this.selectionAwareAccessor(this.props.getRadius), getLineWidth: this.selectionAwareAccessor(this.props.getLineWidth), _subLayerProps: { 'line-strings': { billboard: this.props.billboard, }, 'polygons-stroke': { billboard: this.props.billboard, }, }, updateTriggers: { getLineColor: [this.props.selectedFeatureIndexes, this.props.mode], getFillColor: [this.props.selectedFeatureIndexes, this.props.mode], getRadius: [this.props.selectedFeatureIndexes, this.props.mode], getLineWidth: [this.props.selectedFeatureIndexes, this.props.mode], }, }); let layers: any = [new GeoJsonLayer(subLayerProps)]; layers = layers.concat(this.createGuidesLayers(), this.createTooltipsLayers()); return layers; } initializeState() { super.initializeState(); this.setState({ selectedFeatures: [], editHandles: [], }); } // TODO: is this the best way to properly update state from an outside event handler? shouldUpdateState(opts: any) { // console.log( // 'shouldUpdateState', // opts.changeFlags.propsOrDataChanged, // opts.changeFlags.stateChanged // ); return super.shouldUpdateState(opts) || opts.changeFlags.stateChanged; } updateState({ props, oldProps, changeFlags, }: { props: Props; oldProps: Props; changeFlags: any; }) { // @ts-ignore super.updateState({ oldProps, props, changeFlags }); if (changeFlags.propsOrDataChanged) { const modePropChanged = Object.keys(oldProps).length === 0 || props.mode !== oldProps.mode; if (modePropChanged) { let mode; if (typeof props.mode === 'function') { // They passed a constructor/class, so new it up const ModeConstructor = props.mode; mode = new ModeConstructor(); } else if (typeof props.mode === 'string') { // Lookup the mode based on its name (for backwards compatibility) mode = modeNameMapping[props.mode]; // eslint-disable-next-line no-console console.warn( "Deprecated use of passing `mode` as a string. Pass the mode's class constructor instead." ); } else { // Should be an instance of EditMode in this case mode = props.mode; } if (!mode) { console.warn(`No mode configured for ${String(props.mode)}`); // eslint-disable-line no-console,no-undef // Use default mode mode = new DEFAULT_EDIT_MODE(); } if (mode !== this.state.mode) { this.setState({ mode, cursor: null }); } } } let selectedFeatures = []; if (Array.isArray(props.selectedFeatureIndexes)) { // TODO: needs improved testing, i.e. checking for duplicates, NaNs, out of range numbers, ... selectedFeatures = props.selectedFeatureIndexes.map((elem) => props.data.features[elem]); } this.setState({ selectedFeatures }); } getModeProps(props: Props) { return { modeConfig: props.modeConfig, data: props.data, selectedIndexes: props.selectedFeatureIndexes, lastPointerMoveEvent: this.state.lastPointerMoveEvent, cursor: this.state.cursor, onEdit: (editAction: EditAction<FeatureCollection>) => { // Force a re-render // This supports double-click where we need to ensure that there's a re-render between the two clicks // even though the data wasn't changed, just the internal tentative feature. this.setNeedsUpdate(); props.onEdit(editAction); }, onUpdateCursor: (cursor: string | null | undefined) => { this.setState({ cursor }); }, }; } selectionAwareAccessor(accessor: any) { if (typeof accessor !== 'function') { return accessor; } return (feature: Record<string, any>) => accessor(feature, this.isFeatureSelected(feature), this.props.mode); } isFeatureSelected(feature: Record<string, any>) { if (!this.props.data || !this.props.selectedFeatureIndexes) { return false; } if (!this.props.selectedFeatureIndexes.length) { return false; } const featureIndex = this.props.data.features.indexOf(feature); return this.props.selectedFeatureIndexes.includes(featureIndex); } getPickingInfo({ info, sourceLayer }: Record<string, any>) { if (sourceLayer.id.endsWith('guides')) { // If user is picking an editing handle, add additional data to the info info.isGuide = true; } return info; } createGuidesLayers() { const mode = this.getActiveMode(); const guides: FeatureCollection = mode.getGuides(this.getModeProps(this.props)); if (!guides || !guides.features.length) { return []; } let pointLayerProps; if (this.props.editHandleType === 'icon') { pointLayerProps = { type: IconLayer, iconAtlas: this.props.editHandleIconAtlas, iconMapping: this.props.editHandleIconMapping, sizeScale: this.props.editHandleIconSizeScale, getIcon: guideAccessor(this.props.getEditHandleIcon), getSize: guideAccessor(this.props.getEditHandleIconSize), getColor: guideAccessor(this.props.getEditHandleIconColor), getAngle: guideAccessor(this.props.getEditHandleIconAngle), }; } else { pointLayerProps = { type: ScatterplotLayer, radiusScale: this.props.editHandlePointRadiusScale, stroked: this.props.editHandlePointOutline, getLineWidth: this.props.editHandlePointStrokeWidth, radiusMinPixels: this.props.editHandlePointRadiusMinPixels, radiusMaxPixels: this.props.editHandlePointRadiusMaxPixels, getRadius: guideAccessor(this.props.getEditHandlePointRadius), getFillColor: guideAccessor(this.props.getEditHandlePointColor), getLineColor: guideAccessor(this.props.getEditHandlePointOutlineColor), }; } const layer = new GeoJsonLayer( this.getSubLayerProps({ id: `guides`, data: guides, fp64: this.props.fp64, _subLayerProps: { points: pointLayerProps, }, lineWidthScale: this.props.lineWidthScale, lineWidthMinPixels: this.props.lineWidthMinPixels, lineWidthMaxPixels: this.props.lineWidthMaxPixels, lineWidthUnits: this.props.lineWidthUnits, lineJointRounded: this.props.lineJointRounded, lineMiterLimit: this.props.lineMiterLimit, getLineColor: guideAccessor(this.props.getTentativeLineColor), getLineWidth: guideAccessor(this.props.getTentativeLineWidth), getFillColor: guideAccessor(this.props.getTentativeFillColor), }) ); return [layer]; } createTooltipsLayers() { const mode = this.getActiveMode(); const tooltips = mode.getTooltips(this.getModeProps(this.props)); const layer = new TextLayer( this.getSubLayerProps({ id: `tooltips`, data: tooltips, }) ); return [layer]; } onLayerClick(event: ClickEvent) { this.getActiveMode().handleClick(event, this.getModeProps(this.props)); } onLayerKeyUp(event: KeyboardEvent) { this.getActiveMode().handleKeyUp(event, this.getModeProps(this.props)); } onStartDragging(event: StartDraggingEvent) { this.getActiveMode().handleStartDragging(event, this.getModeProps(this.props)); } onDragging(event: DraggingEvent) { this.getActiveMode().handleDragging(event, this.getModeProps(this.props)); } onStopDragging(event: StopDraggingEvent) { this.getActiveMode().handleStopDragging(event, this.getModeProps(this.props)); } onPointerMove(event: PointerMoveEvent) { this.setState({ lastPointerMoveEvent: event }); this.getActiveMode().handlePointerMove(event, this.getModeProps(this.props)); } getCursor({ isDragging }: { isDragging: boolean }) { if (this.state === null) { // Layer in 'Awaiting state' return null; } let { cursor } = this.state; if (!cursor) { // default cursor cursor = isDragging ? 'grabbing' : 'grab'; } return cursor; } getActiveMode(): GeoJsonEditModeType { return this.state.mode; } }