@nebula.gl/layers
Version:
A suite of 3D-enabled data editing layers, suitable for deck.gl
496 lines (431 loc) • 15.6 kB
text/typescript
/* 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;
}
}