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 a Polygon 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 EditController to explain the interaction, but the CreateController interaction is similar. Just replace EditController with CreateController and Editor.getEditHandles with Editor.getCreateHandle.

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):

circlebycenterpoint handles
Figure 1. The default handles of a 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 the Editor can edit the shape. For example, a CircleByCenterPointEditor returns true if the shape is a circle-by-center-point.

  • getEditHandles: returns a list of EditHandle used during editing. It’s mainly used by the EditController.

  • createTranslateHandle: creates a handle to move the entire shape, typically a ShapeTranslateHandle. This method is called by getEditHandles. The translate handle is typically included in the list of edit handles.

  • getCreateHandle: returns an EditHandle used during creation. It’s used by the CreateController.

In the API, you can find editors for each shape type in LuciadRIA:

Table 1. LuciadRIA shape editors
ShapeType Editor

Point

PointEditor

Bounds

BoundsEditor

Polyline

PointListEditor

Polygon

PointListEditor

Arc

ArcEditor

ArcBand

ArcBandEditor

CircleByCenterPoint

CircleByCenterPointEditor

CircleBy3Points

CircleBy3PointsEditor

CircularArcBy3Points

CircularArcBy3PointsEditor

CircularArcByBulge

CircularArcByBulgeEditor

CircularArcByCenterPoint

CircularArcByCenterPointEditor

Ellipse

EllipseEditor

GeoBuffer

GeoBufferEditor

Sector

SectorEditor

ComplexPolygon

ComplexPolygonEditor

ExtrudedShape

ExtrudedShapeEditor

ShapeList

ShapeListEditor

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:

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 event GestureEvent. Usually, the shape also gets changed at this point.

  • onDraw: called when the handle must draw shapes on the Map. You can use it to draw handle icons or helper shapes, see EditHandleStyles.

  • onDrawLabel: called when the handle must draw labels on the Map.

  • 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 type PointDragHandle.

  • ShapeTranslateHandle: moves the shape when the user drags the shape. It calls Shape.translate2D(), which works for all shapes in the API.

  • The point list handles: work on point lists, such as a Polyline or Polygon. They call the insert2DPoint, move2DPoint and remove2DPoint methods on Polyline and Polygon.

    • PointListTranslateHandle: allows the user to move points in a point list. It moves those points with move2DPoint.

    • PointListInsertHandle: allows the user to insert points in a point list. It inserts points with insert2DPoint.

    • PointListDeleteHandle: allows the user to remove points from a point list. It removes points with remove2DPoint.

  • ThreeStepEditHandle: tracks when a handle activates, is active, and de-activates. Most handle implementations extend from ThreeStepEditHandle. For example, a PointDragHandle 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:

    1. The user clicks once and a circle appears at the clicked location.

    2. The user can then edit the center and radius of the circle.

    3. 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. It accepts a list of EditHandle objects to delegate to. Examples of CompositeEditHandle in the API are the point list handles and the CreateByTemplateHandle. Check the CompositeEditHandle 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:

show edit bounds
Figure 2. A custom handle, showing a helper shape for the bounds while editing.