The default CreateController
and EditController
in the API give you out-of-the-box support for creating and editing shapes.
Sometimes though, you want to change the behavior of creation and editing.
For example, you want to:
-
Prevent users from moving an entire polygon but still let them move individual points, or the other way round. See Disabling handles for an example.
-
Prevent users from changing the start and end point of a polyline, but still let them change the intermediate points. See Removing point list handles for an example.
-
Restrict the radius of a circle to a minimum or maximum. See Adding custom constraints for an example.
-
Show helper shapes while editing. See Drawing helper shapes for an example.
-
Simulate a custom shape type. You could simulate an arrow shape, for example. You could make this a
Feature
with aPolygon
shape that always looks like an arrow. You could then let the user move only the arrow’s start point, end point and width, which would update the polygon accordingly.
Controllers, Editors and EditHandles
This section explains the interaction between controllers, editors, and handles. Those are the 3 main concepts in LuciadRIA’s editing and creation API.
Editing and creation start with the EditController
and CreateController
.
This article uses |
When you create an EditController
, you specify an Editor
and some settings on the controller.
const editController = new EditController(layer, feature, { editor: new ShapeEditor(), handleIconStyle: { image: createSquare({ width: 8, height: 8, fill: "white", stroke: "black", strokeWidth: 1 }) } }); editController.setPointCount(2, 10); map.controller = editController;
The EditController
asks the Editor
for a list of EditHandle
objects, as in Editor.getEditHandles
.
This happens when you assign EditController
to map.controller
, and it becomes active on the Map
.
Afterward, the EditController
forwards input events and other Controller
calls, such as onDraw
, to these handles.
You can think of EditHandle
objects as mini Controllers. They consume input events, draw shapes on the map, and change
the edited shape, or a part of that shape.
The settings you configured on the EditController
, such as its handleIconStyle
and minimumPointCount
, are combined in
EditController.getSettings
and then forwarded to the editor and handles through EditContext.settings
.
The EditContext
combines all settings, and also provides access to the Map
, FeatureLayer
, Feature
and edited Shape
. It gets passed into all Editor
calls, and in many EditHandle
hooks.
Working with editors and handles
To customize the way creation and editing works, you implement a custom Editor
.
An editor creates a number of EditHandles
.
Handles change the feature or shape in response to input events.
For example, the default CircleByCenterPointEditor
creates 1 handle for the center point (A), and 1 handle to change the radius (B):
CircleByCenterPointEditor
It also creates a handle to move the entire circle, which isn’t represented by a point. It activates whenever users drag the circle shape itself.
Using editors
The Editor
base class has these methods:
-
canEdit
: indicates whether theEditor
can edit the shape. For example, aCircleByCenterPointEditor
returnstrue
if the shape is a circle-by-center-point. -
getEditHandles
: returns a list ofEditHandle
used during editing. It’s mainly used by theEditController
. -
createTranslateHandle
: creates a handle to move the entire shape, typically aShapeTranslateHandle
. This method is called bygetEditHandles
. The translate handle is typically included in the list of edit handles. -
getCreateHandle
: returns anEditHandle
used during creation. It’s used by theCreateController
.
In the API, you can find editors for each shape type in LuciadRIA:
ShapeType |
Editor |
---|---|
The default shape editor used by EditController
and CreateController
is the ShapeEditor
.
This is a CompositeEditor
that combines all the editors listed in Table 1, “LuciadRIA shape editors”:
export class ShapeEditor extends CompositeEditor { constructor() { super([]); this.delegates = [ new PointEditor(), new BoundsEditor(), new PointListEditor(), new ArcEditor(), new ArcBandEditor(), new CircleByCenterPointEditor(), new CircleBy3PointsEditor(), new CircularArcBy3PointsEditor(), new CircularArcByBulgeEditor(), new CircularArcByCenterPointEditor(), new EllipseEditor(), new GeoBufferEditor(this), new SectorEditor(), new ComplexPolygonEditor(this), new ExtrudedShapeEditor(this), new ShapeListEditor(this) ] } }
You can use the preceding code snippet to create a custom composite of default editors.
You can also use ShapeEditor
in your own composite if you want to fall back to default shape editing:
// we have some custom editors in our application class CustomEditor1 extends Editor { } class CustomEditor2 extends Editor { } class CustomEditor3 extends Editor { } /** * An editor that can edit custom features + default RIA shapes */ class MyAppShapeEditor extends CompositeEditor { constructor() { super([]); this.delegates = [ // the custom editors are put first in the list, so they have priority over the default editors new CustomEditor1(), new CustomEditor2(), new CustomEditor3(), // if the custom editors can't edit the feature, fallback to RIA's default editing // alternatively, you can define a custom list of editors here // cf. ShapeEditor's class documentation new ShapeEditor() ] } } const customEditController = new EditController(layer, feature, { editor: new MyAppShapeEditor() }); map.controller = customEditController;
Every Editor
of a specific shape provides hooks to customize its shape-specific handles and its translate handle.
For example, the CircleByCenterPointEditor
provides these hooks:
-
createCenterHandle
: creates a handle to move the center point of the circle. See label (A) in Figure 1, “The default handles of aCircleByCenterPointEditor
”. -
createRadiusHandle
: creates a handle to change the radius of the circle. See label (B) in Figure 1, “The default handles of aCircleByCenterPointEditor
”. -
createTranslateHandle
: creates a handle to move the entire circle.
Using handles
An EditHandle
handles input events and changes the shape that you’re creating or editing.
These methods are the most important:
-
onGestureEvent
: called to handle the input eventGestureEvent
. Usually, the shape also gets changed at this point. -
onDraw
: called when the handle must draw shapes on theMap
. You can use it to draw handle icons or helper shapes, seeEditHandleStyles
. -
onDrawLabel
: called when the handle must draw labels on theMap
. -
getCursor
: called to retrieve the current mouse cursor for the handle. -
onCreateContextMenu
:called when a context menu needs to be populated.
In @luciad/ria/view/editor/handles
, you can find a number of EditHandle
implementations.
Editing handles
For Editor.getEditHandles
, these edit handles are common:
-
PointDragHandle
: represents a point on the shape. When a user drags the point, the shape changes. For example, the center point and radius handles of the circle are of the typePointDragHandle
. -
ShapeTranslateHandle
: moves the shape when the user drags the shape. It callsShape.translate2D()
, which works for all shapes in the API. -
The point list handles: work on point lists, such as a
Polyline
orPolygon
. They call theinsert2DPoint
,move2DPoint
andremove2DPoint
methods onPolyline
andPolygon
.-
PointListTranslateHandle
: allows the user to move points in a point list. It moves those points withmove2DPoint
. -
PointListInsertHandle
: allows the user to insert points in a point list. It inserts points withinsert2DPoint
. -
PointListDeleteHandle
: allows the user to remove points from a point list. It removes points withremove2DPoint
.
-
-
ThreeStepEditHandle
: tracks when a handle activates, is active, and de-activates. Most handle implementations extend fromThreeStepEditHandle
. For example, aPointDragHandle
activates when users start dragging the point with the mouse. It remains active during the drag and when the drag ends, it deactivates.
You can find other, more specific edit handles in @luciad/ria/view/editor/handles
.
Creation handles
For Editor.getCreateHandle
, these creation handles are common:
-
CreateByTemplateHandle
: with this handle, the user clicks / taps once on the map to place a complete shape at the mouse cursor / finger. The handle then uses the editing handles of the editor to finish creating the shape. For example, the circle editors use this sequence:-
The user clicks once and a circle appears at the clicked location.
-
The user can then edit the center and radius of the circle.
-
When the user double-clicks outside the circle, the creation is completed.
-
-
PointListCreateHandle
: with this handle, the user clicks / taps multiple times on the map to place a list of points. When the user double-clicks, the point list is created. Point lists serve to create polylines and polygons.
You can find other, more specific creation handles in @luciad/ria/view/editor/handles
.
Composing multiple handles
Sometimes, you want to combine multiple EditHandle
objects into 1 EditHandle
.
You can do so with a CompositeEditHandle
or a CascadingEditHandle
.
These accept a list of EditHandle
objects to delegate to.
CompositeEditHandle
combines handles that appear simultaneously on the map, typically edit handles (e.g. PointListTranslateHandle
).
CascadingEditHandle
combines handles that becomes active one after the other, for example creation that happens in multiple steps (e.g. CreateByTemplateHandle
).
Check the API documentation for more details.
Customizing an Editor
This section highlights common cases of Editor
customization.
Disabling handles
Sometimes, you want to disable specific handles of an existing Editor
.
For example, you want to prevent the user from moving the entire shape.
You can do so by overriding createTranslateHandle
and returning null
.
/** * A ShapeEditor without the shape translate handle. * The user is only able to change the shape by its shape-specific handles. * It's not possible to move the entire shape at once. */ export class NoObjectTranslationEditor extends ShapeEditor { createTranslateHandle(context: EditContext): EditHandle | null { // called by getEditHandles(). If this returns null, a shape cannot be translated. return null; } } const noMoveEditController = new EditController(layer, feature, { editor: new NoObjectTranslationEditor() }); map.controller = noMoveEditController;
You can do the opposite too: allow users to move the entire shape only, and prevent shape-specific changes.
/** * A ShapeEditor without the shape specific handles. * It's only possible to move the shape in its entirety. * The user cannot change the individual points of the shape. */ export class OnlyMoveShapeEditor extends ShapeEditor { getEditHandles(context: EditContext): EditHandle[] { // only a translate handle, no other shape edit handles const translateHandle = this.createTranslateHandle(context) if (translateHandle) { return [translateHandle] } return []; } } const onlyMoveEditController = new EditController(layer, feature, { editor: new OnlyMoveShapeEditor() }); map.controller = onlyMoveEditController;
Removing point list handles
This example shows you how to customize point list handles. Imagine we have a trajectory with a start point and end point. These are fixed points. Users can’t move or delete them. We still want to add, move, and remove intermediate points, but not the start or end points.
You can implement this restriction as follows:
export interface TrajectoryFeature extends Feature { shape: Polyline; } /** * An editor for trajectory features. * * The start and end point of the polyline cannot be moved or deleted. * It only allows movement and deletion of intermediate points. */ export class TrajectoryEditor extends Editor { canEdit(context: EditContext): boolean { return !!feature.shape && ShapeType.contains(feature.shape.type, ShapeType.POLYLINE); } getEditHandles(context: EditContext): EditHandle[] { const polyline = context.shape as Polyline; return [ new IntermediatePointListTranslateHandle(polyline), new IntermediatePointListDeleteHandle(polyline, context.settings.minimumPointCount), new PointListInsertHandle(polyline, context.settings.maximumPointCount) ]; } getCreateHandle(context: EditContext): EditHandle | null { return null; // creation not supported } } class IntermediatePointListTranslateHandle extends PointListTranslateHandle { shouldUpdateHandles(): boolean { return this.handles.length !== this.pointList.pointCount - 2; } createTranslateHandles(): SinglePointTranslateHandle[] { const handles: SinglePointTranslateHandle[] = []; for (let i = 1; i < this.pointList.pointCount - 1; i++) { handles.push(new SinglePointTranslateHandle(this.pointList, i, {handleIconStyle: this.handleIconStyle})); } return handles; } } class IntermediatePointListDeleteHandle extends PointListDeleteHandle { shouldUpdateHandles(): boolean { return this.handles.length !== this.pointList.pointCount - 2; } createDeleteHandles(): SinglePointDeleteHandle[] { const handles: SinglePointDeleteHandle[] = []; for (let i = 1; i < this.pointList.pointCount - 1; i++) { handles.push(new SinglePointDeleteHandle(this.pointList, i, this.handleIconStyle)); } return handles; } } const trajectoryEditController = new EditController(layer, feature, { editor: new TrajectoryEditor() }); map.controller = trajectoryEditController;
Adding custom constraints
You can implement custom constraints by passing extra properties in a custom handle. In the implementation of the handle, make sure that the constraints aren’t violated when the shape changes.
For example, we can customize the CircleByCenterPointEditor
so that the user can change the radius of the circle within a certain range only.
interface ConstrainedCircleEditorSettings { minRadius: number; maxRadius: number; } export class ConstrainedCircleEditor extends CircleByCenterPointEditor { private readonly _settings: ConstrainedCircleEditorSettings; constructor(settings?: ConstrainedCircleEditorSettings) { super(); this._settings = settings ?? { minRadius: 5e3, maxRadius: 50e3 } } createRadiusHandle(context: EditContext): EditHandle { const circle = context.feature.shape as CircleByCenterPoint; const geodesy = (circle.reference?.referenceType === ReferenceType.GEODETIC) ? createEllipsoidalGeodesy(circle.reference) : createCartesianGeodesy(circle.reference!); let radiusPointAzimuth = 90; return new PointDragHandle( (): Point => geodesy.interpolate(circle.center, circle.radius, radiusPointAzimuth), (point: Point): void => { const distance = geodesy.distance(circle.center, point); circle.radius = Math.max(this._settings.minRadius, Math.min(this._settings.maxRadius, distance)); radiusPointAzimuth = geodesy.forwardAzimuth(circle.center, point); }); } } const constrainedCircleEditController = new EditController(layer, feature, { editor: new ConstrainedCircleEditor() }); map.controller = constrainedCircleEditController;
Optionally, you can sub-class EditController
and override EditController.getSettings
, so that you can define the
minimum and maximum radius for constructing an EditController
. LuciadRIA adds these extra settings
to EditContext.settings
, which you can access in the Editor
.
interface ConstrainedCircleEditorSettings2 extends EditSettings { minRadius: number; maxRadius: number; } class ConstrainedCircleEditController extends EditController { private readonly _minRadius: number; private readonly _maxRadius: number; constructor(layer: FeatureLayer, feature: Feature, minRadius: number, maxRadius: number) { super(layer, feature, {editor: new ConstrainedCircleEditor2()}); this._minRadius = minRadius; this._maxRadius = maxRadius; } getSettings(): ConstrainedCircleEditorSettings2 { return { ...super.getSettings(), minRadius: this._minRadius, maxRadius: this._maxRadius }; } } class ConstrainedCircleEditor2 extends CircleByCenterPointEditor { createRadiusHandle(context: EditContext): EditHandle { const settings = context.settings as ConstrainedCircleEditorSettings2; const circle = context.feature.shape as CircleByCenterPoint; const geodesy = (circle.reference?.referenceType === ReferenceType.GEODETIC) ? createEllipsoidalGeodesy(circle.reference) : createCartesianGeodesy(circle.reference!); let radiusPointAzimuth = 90; return new PointDragHandle( (): Point => geodesy.interpolate(circle.center, circle.radius, radiusPointAzimuth), (point: Point): void => { const distance = geodesy.distance(circle.center, point); circle.radius = Math.max(settings.minRadius, Math.min(settings.maxRadius, distance)); radiusPointAzimuth = geodesy.forwardAzimuth(circle.center, point); }); } } const MIN_RADIUS = 1e3; const MAX_RADIUS = 5e3; map.controller = new ConstrainedCircleEditController(layer, feature, MIN_RADIUS, MAX_RADIUS);
Drawing helper shapes
You draw helper shapes in EditHandle.onDraw
.
You can use EditHandleStyles.helperStyle
as a style for these shapes.
This ensures that helper shapes have consistent styling across different editors.
You can draw helper shapes by overriding EditHandle.onDraw
, or by adding handles in Editor.getEditHandles
.
For example, if you want to show the bounds as a helper shape while editing, you can do that as follows:
/** * A handle that draws the shape's bounds with the helper style */ class BoundsHelperHandle extends HelperHandle { private readonly _shape: Shape; constructor(shape: Shape, helperStyle?: ShapeStyle | null) { super(helperStyle); this._shape = shape; } onDraw(geoCanvas: GeoCanvas, context: EditContext) { const helperStyle = this.getHelperStyle(context); if (this._shape.bounds && helperStyle) { geoCanvas.drawShape(this._shape.bounds, helperStyle); } } } /** * A ShapeEditor that shows helper lines for the bounds of edited shapes */ export class ShowBoundsEditor extends ShapeEditor { canEdit(context: EditContext): boolean { return !!(context.feature.shape); } getEditHandles(context: EditContext): EditHandle[] { return [ ...super.getEditHandles(context), this.createBoundsHelperHandle(context) ] } createBoundsHelperHandle(context: EditContext): EditHandle { return new BoundsHelperHandle(context.feature.shape!); } } const showBoundsEditController = new EditController(layer, feature, { editor: new ShowBoundsEditor() }); map.controller = showBoundsEditController;
As a result, the bounds for the edited feature are visible: