🚧 These docs are a work in progress β€” content is incomplete and may change without notice.
Skip to content

Edit a control measure ​

edit(graphic, options?) edits one graphic. Closing the session commits the working state and resolves with its GraphicSnapshot; aborting rejects the promise. The session closes when the user clicks elsewhere on the map, picks another graphic, or your code calls session.close(). Escape aborts it.

Draw an ambush, then select it to edit. Drag a vertex to reshape it, or use the box corners, rotation handle, and box interior to scale, rotate, and move it. Select elsewhere on the map to commit.

Edit modes:

Loading map…

ts
const { graphic: next } = await td.edit(measure, {
  modes: ["reshape", "transform"],
});

Modes ​

ModeBehavior
"reshape" (default)Shows a vertex handle at each control point and midpoint handles on editable segments. Drag a midpoint to insert a point. For a variable-length control measure, press Alt/Option and select a vertex to delete it.
"transform" (default)Shows an oriented box with four scale corners, a rotation handle, and a draggable interior. See The transform box.
"labeldrag"Lets the user select a label, drag it, and rotate it with its own small box. The result is stored in amplifierPlacements. Opt-in.
"delete"Exclusive: if present, the active set becomes ["delete"]. Shows only vertex handles; select one to remove it. A fixed trailing slot or the minimum point count prevents removal.
"offset"Point symbols only. Drag the symbol body away from its location. See Offset placement.

reshape and transform can run in the same session and are both enabled by default for control measures. Add "labeldrag" to let users move labels too:

ts
await td.edit(measure, { modes: ["reshape", "transform", "labeldrag"] });

Change modes during a session with session.setModes(modes). A change requested mid-drag applies when the drag ends. An unknown mode throws a TypeError.

Some kinds also show option handles in reshape mode: extra grips that adjust a generator option directly, such as the rear width of a flared attack arrow. Style them with the optionHandle slot of interactionStyle. Press Alt/Option and select an option handle to reset its option, when the kind supports that. With several parts of a graphic selected, the kind may apply the drag or reset to all of them, as one undo step (see selecting several flows).

Sector and Radar Search Doctrine also show a rotate grip in reshape mode, on the outer arc halfway through the sweep. Dragging it turns the whole sector about its anchor and keeps both ranges and the sweep. Hold Shift to snap the left edge to 5Β° steps. Style it with the geometryHandle slot.

Width grips ​

Variable-width arrows also have detail handles: a left and a right width grip beside each shaft vertex, plus a left grip at the rear. They are hidden by default. Show them with detailHandles: true, or bind a toggle to the session:

ts
await td.edit(measure, { detailHandles: true });

// Later, from a toolbar button:
session.setDetailHandles(!session.detailHandles);

Toggling the grips is view state: it records no undo step and emits no change event. Dragging a grip sets that side's width at that vertex. A grip whose width is interpolated renders with the unsetOptionHandle slot (a hollow ring by default); press Alt/Option and select a grip to clear its width. Inserting or deleting a vertex keeps every width on its vertex, in the same undo step. See Variable-width arrows for the options behind the grips.

Point symbols and Images accept fewer modes:

GraphicAllowed modesDefault
Point symbol"transform", "offset", "delete"["transform"], plus "offset" if it has one
Image"transform", "delete"["transform"]

The transform box ​

"transform" draws an oriented selection box around the graphic:

  • Corner handles: drag to scale uniformly around the opposite corner.
  • Rotation handle: drag the handle 28 pixels above the top edge to rotate around the box center.
  • Box interior: drag away from a handle to move the entire graphic. A 3-pixel threshold filters out accidental movement.

The box starts axis-aligned. Its angle exists only for the current session; the control measure stores rotated points, not a box angle. TacticalDraw refits the box after pan and zoom so it follows the current projection. Box gestures do not apply kind-specific draw rulesβ€”only vertex and midpoint drags in reshape mode do.

Set interactionStyle.boxPadding to add pixel spacing between the box and the rendered geometry. It defaults to 0 and is useful when handles overlap the graphic.

Modifier keys ​

Some edit gestures change while a modifier key is held. Each one has a role, and the host picks the key for that role:

RoleDefaultGesture
modifierDragCtrl or ⌘Drag from a vertex to hand the gesture to the kind, such as pulling a new Flow Arrow flow. Held on an option handle, it lets the kind widen the drag or reset, such as sizing every Split Arrow head at once.
deleteOrResetAlt/OptionSelect a vertex to delete it, or an option handle or width grip to reset its option.
constrainShiftConstrain angles while drawing or dragging a vertex, and turn the transform box, a marker or a point symbol in 15Β° steps.
extendPartSelectionOffClick a point, midpoint or the body of a graphic made of parts to add that part to the selection, or remove it (see selecting several flows). A drag with the key held is a plain drag, so the key can be Shift.

The table above, and the rest of these pages, name the default keys. Set your own with editModifiers. Each role takes "alt", "shift", "ctrl", "meta" or false to turn the gesture off; modifierDrag also takes a list, and any key in it starts the drag. Roles you leave out keep their defaults:

ts
const td = new TacticalDraw(adapter, {
  // ⌘ only on macOS, where Ctrl + click is a secondary click.
  editModifiers: { modifierDrag: "meta" },
});

// Later, from a settings menu. Applies from the next pointer event,
// also to the graphic being edited.
td.setEditModifiers({ deleteOrReset: "shift", constrain: false });

// Every role filled in, for hint text:
td.getEditModifiers();
// { modifierDrag: ["meta"], deleteOrReset: "shift", constrain: false, extendPartSelection: false }

deleteOrReset, modifierDrag and extendPartSelection all act on a vertex press, so none of them can share a key with another: the constructor and setEditModifiers throw a RangeError, and setEditModifiers leaves the keys unchanged. Any other overlap is allowed.

extendPartSelection is off until you give it a key. Shift is the natural choice, but a host that reports Shift on every pointer event to keep constraining on would then extend on every click. The point-symbol offsetDragModifier acts on a symbol body, not a vertex, and is set separately.

Map engine bindings

Map engines bind modifier drags of their own, such as Shift + drag to box zoom in all three engines and Ctrl + drag to rotate and pitch in MapLibre. TacticalDraw turns drag-pan off while it owns a drag, and the MapLibre adapter also holds drag-rotate, but other engine bindings stay as the host configured them. If an engine or host binding uses the same key and drag as an editor role, such as a Ctrl + drag marquee, keep it off edit handles with ownsInteractionAt, turn it off, or move the role to another key.

Change the graphic from your UI ​

While an edit is open, change the working copy through the session instead of starting a new edit. Each call rerenders the preview, marks the session dirty, emits onChange, and records one undo step.

MethodChanges
setOptions(partial)Shallow-merges generator options.
setStyle(partial)Shallow-merges style.
setTextAmplifiers(partial)Merges text amplifiers. undefined or an empty string removes a field.
setAmplifierPlacements(partial)Merges label placements per field. undefined removes an entry, restoring doctrinal placement.
setSizeAnchor(anchor, options?)Changes the size anchor, optionally with a size-option patch in the same step.
resetGroundSizes()Restores sizes to their default on-screen size at the current zoom, stored in meters.
updateGraphic(updater)Replaces the whole working graphic at once. The id and kind are preserved.
ts
await td.edit(measure, {
  onSession(session) {
    designationInput.oninput = () => {
      session.setTextAmplifiers({ T: designationInput.value });
    };
    resetLabelsButton.onclick = () => {
      // Drop every label adjustment and return to doctrinal placement.
      session.updateGraphic((graphic) => ({ ...graphic, amplifierPlacements: undefined }));
    };
  },
});

Point-symbol and Image sessions (GraphicEditSession) expose updateGraphic() for the same purpose.

To reset sizes without an edit session, such as across a selected group, use resetGroundSizeOptions(measure, scale). It applies the same rule as resetGroundSizes() and returns the new options, or undefined when the kind has no sizes or the resolution is not usable. scale only needs getResolution(), so pass the adapter, or wrap a fixed resolution as { getResolution: () => resolution }. canResetGroundSizes(measure, scale) reports whether a reset applies. The result is returned even when nothing changes, so compare it with the current options to detect a no-op.

ts
import { resetGroundSizeOptions } from "@orbat-mapper/tactical-draw";

const options = resetGroundSizeOptions(measure, { getResolution: () => resolution });
if (options) measure = { ...measure, options };

See Validate edits and Drive style & options from UI controls for common edit-session integrations.

onCommit and onSettled ​

session.onCommit(handler) runs synchronously once when the edit closes, whether through close(), a click away, or selection of another graphic. It receives the same snapshot returned by close() and the edit() promise, and does not run after an abort.

session.onSettled(handler) runs once after commit or abort, after onCommit and after td.activeSession becomes null. A late subscription runs asynchronously. TransformSession uses the same timing for snapshot arrays. See the reference table for all session fields.

onTransientChange ​

session.onTransientChange(listener) reports live geometry during vertex, midpoint, and transform-box drags. Events contain the working points, the active pointIndex, and a phase: "start", "move", or "end". The final phase occurs on both commit and cancel, before the gesture's onChange event.

Transform-box gestures move all points, so pointIndex is null. Label drags do not emit this event because they leave control points unchanged.

Use it for live UI feedback, such as a radius that follows a circle handle. See Show a live radius for a full example.

Undo and redo ​

Every edit or transform session has its own session.history. A completed vertex, label, translation, rotation, or scale gesture creates one step; pointer-preview frames do not. Changes to the graphic, options, style, text, label placement, and size anchor are also undoable. Interaction settings such as setModes() are not.

ts
const unsubscribe = session.history.subscribe((state) => {
  undoButton.disabled = !state.canUndo;
  redoButton.disabled = !state.canRedo;
});

undoButton.onclick = () => session.history.undo();
redoButton.onclick = () => session.history.redo();

Undo and redo update the preview and handles without settling the session. Returning to the initial state clears dirty; making a change after undo clears redo history. close() commits the current state and abort() discards it. After settlement, undo and redo return false. History is capped at 100 completed steps.

While a session is active, route your app's Undo and Redo commands to session.history and disable document-level undo, even when the session's history is empty. Otherwise a document undo could change the graphic being edited underneath the session. In group transforms, setGraphics() establishes a new baseline and clears both stacks. Membership changes and emitted exit snapshots cannot be undone within the session.

EditOptions ​

FieldDefaultWhat it does
modes["reshape", "transform"]Active edit modes (see above).
closeOnClickAwaytrueClicking outside preview/handles closes the edit.
onSessionNot setRuns once when the edit starts, before pointer events. The same session is available as td.activeSession.
signalNot setAborts the operation. The promise rejects with the reason "signal".
guidetrueOpt out of edit guide emission. Auto-suppressed for point kinds, and for fixed-length kinds unless the kind's draw rule opts back in via showGuide.
interactionStylector defaultPer-call style override (guide + handle slots), merged per-slot, per-property.
offsetDragModifierctor defaultPoint symbols only. Overrides the key that turns a body drag into an offset drag for this edit; false disables it.
detailHandlesfalseShow detail handles in reshape mode, such as the width grips on variable-width arrows. Change it during the session with session.setDetailHandles().
sizeAnchor"ground"Sets the anchor for geometry and label sizes. The "ground" value converts pixel sizes to meters at commit. The "screen" value keeps pixel sizes. The active preview stays screen-anchored. Use getSizeAnchor(measure) to get the current anchor. Use session.setSizeAnchor() to change it.

Multi-select ​

editMany(measures, options?) places one transform box around a group. Group edits support scale, rotation, and translation, but not reshape.

ts
const next = await td.editMany([measureA, measureB], {
  onSession: (session) => {
    groupSession = session;
  },
});
// Key snapshots by measure id, not by input position.
next.forEach((snapshot) => upsert(snapshot.graphic));

The function resolves with one snapshot per member still selected at close time and uses the same abort behavior as edit. Because membership can change, key snapshots by snapshot.graphic.id, never by array position.

Mutable membership ​

Use session.setGraphics(measures) to replace the members of an active group without restarting the session or flickering the box:

ts
// Add or remove members without a session boundary.
const removed = groupSession.setGraphics(nextSelection);
// Add the exit snapshots in `removed` to your document.
// Then, call td.render(...).
removed.forEach((snapshot) => upsert(snapshot.graphic));

Retained members keep their working state, removed members commit and return exit snapshots, and added members start from their input state. TacticalDraw lifts added members off the graphics layer while active, but does not put removed members back. Save returned snapshots to your document and call td.render(). Passing [] closes the session. close() follows the same write-back contract.

The new member set becomes the history baseline, clearing undo and redo while preserving current geometry.

close() returns committed snapshots synchronously, and the promise resolves with the same array. You can therefore start another interaction immediately.

Use onCommit for exit state ​

setGraphics() returns snapshots for removed members, while the editMany promise returns members present at close. Subscribe to session.onCommit(handler) to handle both through one synchronous callback.

ts
// The promise also resolves with the close-time snapshots.
// A host that uses onCommit can ignore this value.
await td.editMany([measureA, measureB], {
  onSession: (session) => {
    session.onCommit((snapshots) => {
      snapshots.forEach((snapshot) => upsert(snapshot.graphic));
      td.render(documentMeasures());
    });
  },
});

Subscribe once in onSession. The handler receives each committed snapshot once, whether caused by removal, close(), setGraphics([]), or a click away. It does not run after an abort. You may call td.render() inside it; the direct return values and promise still contain the same data.

session.onSettled(handler) runs once after commit or abort, including Escape, preemption, destroy(), and abort signals. It runs after the closing onCommit batch and after td.activeTransformSession becomes null, making it a safe place to clear selection state. Late subscriptions run asynchronously once.

The dashed group box encloses the combined rendered geometry, with a thin outline around each member. Scale, rotation, and translation apply rigidly to the whole group; individual members cannot be reshaped during the transform.

EditManyOptions resembles EditOptions but omits modes and guide because groups always use the transform box. sizeAnchor applies to the whole group. onSession receives a TransformSession, also available as td.activeTransformSession. Group sessions do not expose setOptions() or setStyle() because those changes apply to individual kinds.

A one-element array behaves like edit(measure, { modes: ["transform"] }): it shows the box without reshape handles.

Use syncTransformGraphics to synchronize a group ​

Use td.syncTransformGraphics when selection may change during a transform. It updates an active session with setGraphics() or starts editMany() when none exists, while routing exit snapshots through one subscription:

ts
function setSelection(measures: ControlMeasure[]) {
  td.syncTransformGraphics(measures, {
    onSession: (session) => {
      session.onCommit((snapshots) => {
        snapshots.forEach((snapshot) => upsert(snapshot.graphic));
        td.render(documentMeasures());
      });
    },
  });
}

// For a Shift-click, call setSelection with the new ID set.
setSelection([alpha, bravo]);
setSelection([alpha]); // bravo's exit snapshot folds in via onCommit
setSelection([]); // closes the session; alpha's exit snapshot folds in

Add session.onSettled(...) in the same callback to handle completion and aborts from Escape, preemption, or destroy(). syncTransformGraphics owns the underlying promise, so you do not need to retain it.

Options, including onSession, are read only when a new transform starts. Later calls update membership without changing sizeAnchor, box angle, or click-away behavior. Pass [] to close an active session; it is a no-op when none exists.