@orbat-mapper/tactical-draw 0.2.0-alpha.3 → 0.2.0-alpha.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { ControlMeasure, ControlMeasureKind, ControlMeasureSnapshot, ControlMeasureStyle } from "@orbat-mapper/control-measures";
1
+ import { AmplifierPlacement, AmplifierPlacements, ControlMeasure, ControlMeasureKind, ControlMeasureRender, ControlMeasureStyle, FillPattern, TextAmplifiers } from "@orbat-mapper/control-measures";
2
2
  import { Feature, Position } from "geojson";
3
3
 
4
4
  //#region src/gestures/types.d.ts
@@ -12,6 +12,11 @@ interface PointerEventInfo {
12
12
  * mouse paths populate it; touch leaves it `undefined`.
13
13
  */
14
14
  altKey?: boolean;
15
+ /**
16
+ * Whether Shift was held for this pointer event. Rotation gestures use the
17
+ * live value to constrain transform-box angles to 15-degree increments.
18
+ */
19
+ shiftKey?: boolean;
15
20
  /** Engine-native event, for drivers that need to call preventDefault etc. */
16
21
  native?: unknown;
17
22
  }
@@ -35,6 +40,52 @@ interface EditPointerDriver {
35
40
  dispose(): void;
36
41
  }
37
42
  //#endregion
43
+ //#region src/fill-pattern.d.ts
44
+ /**
45
+ * Shared hatch fill-pattern tiles for area control-measure fills (ADR-0035
46
+ * `FillPattern`, excluding `"solid"` which every adapter already renders as a
47
+ * flat color). Both the MapLibre adapter (raster `addImage` pattern) and the
48
+ * OpenLayers adapter (canvas `Pattern` fill) need the *same* diagonal-hachure
49
+ * texture, so it is rasterized once here in pure math — no `HTMLCanvasElement`
50
+ * — so it works in adapter code and in jsdom/node tests alike.
51
+ */
52
+ type HatchPattern = Exclude<FillPattern, "solid">;
53
+ /**
54
+ * Every non-solid `FillPattern` as a runtime list — the value-level companion
55
+ * to {@link HatchPattern}, so adapters can test membership and build filter
56
+ * expressions without re-spelling the pattern vocabulary.
57
+ */
58
+ declare const HATCH_PATTERNS: readonly HatchPattern[];
59
+ /**
60
+ * Stable id for a rasterized tile, keyed by pattern and color. Adapters use
61
+ * this as the image-registry key (MapLibre `addImage` id, OpenLayers pattern
62
+ * cache key) so the same color+pattern never gets rasterized twice. Color is
63
+ * lowercased so `"#FF0000"` and `"#ff0000"` share one texture.
64
+ */
65
+ declare function fillPatternImageId(color: string, pattern: HatchPattern): string;
66
+ /**
67
+ * Tile edge length in device pixels for a given `pixelRatio`. Must be a power
68
+ * of two — MapLibre requires POT dimensions for seamless WebGL texture wrap —
69
+ * and scales linearly with `pixelRatio` for the ratios that matter in
70
+ * practice (1x/2x displays: 32 -> 64, both already POT). Non-POT products
71
+ * (e.g. a 3x ratio, 32*3=96) round up to the next POT rather than losing the
72
+ * power-of-two guarantee.
73
+ */
74
+ declare function fillPatternImageDimension(pixelRatio: number): number;
75
+ /**
76
+ * Rasterize one seamless hatch tile as a straight RGBA pixel buffer — no
77
+ * canvas involved, so this runs identically in a browser adapter and in
78
+ * jsdom/node tests. Diagonal lines are snapped to slope ±1 (45°) so the
79
+ * mod-based coverage math in {@link lineCoverage} tiles exactly. `"hachure"`
80
+ * draws the "/" diagonal, `"backhachure"` its "\" mirror, and `"cross"` unions
81
+ * both via `Math.max` coverage.
82
+ */
83
+ declare function makeFillPatternImage(color: string, pattern: HatchPattern, pixelRatio?: number): {
84
+ width: number;
85
+ height: number;
86
+ data: Uint8ClampedArray;
87
+ };
88
+ //#endregion
38
89
  //#region src/map-adapter.d.ts
39
90
  type PixelCoordinate = [number, number];
40
91
  type MapCursor = "" | "default" | "crosshair" | "grab" | "grabbing" | "pointer";
@@ -54,6 +105,16 @@ interface StyleOptions {
54
105
  pointRadius?: number;
55
106
  text?: TextStyleOptions;
56
107
  }
108
+ /**
109
+ * Text (label) style for a layer. When `labelProperty` is set, adapters also
110
+ * read per-feature label properties: `rotation` (radians, rendered as
111
+ * `π − rotation`), the size anchor's `textSizeMeters` (ground) or
112
+ * `textSizePixels` (screen) (ADR-0028), and `textAnchor` (`"start"` = text
113
+ * begins at the label point, `"end"` = text ends there; absent = centered).
114
+ * A ground-anchored label renders at
115
+ * `clamp(textSizeMeters / resolution, textSizeMinPixels, textSizeMaxPixels)`;
116
+ * a screen-anchored one at a constant `textSizePixels`, unclamped.
117
+ */
57
118
  interface TextStyleOptions {
58
119
  text?: string;
59
120
  labelProperty?: string;
@@ -62,6 +123,18 @@ interface TextStyleOptions {
62
123
  strokeColor?: string;
63
124
  strokeWidth?: number;
64
125
  rotation?: number;
126
+ /**
127
+ * Lower edge of the ground-anchored label clamp band (ADR-0028): text never
128
+ * renders below this pixel size, however far the view zooms out.
129
+ * @default 8
130
+ */
131
+ textSizeMinPixels?: number;
132
+ /**
133
+ * Upper edge of the ground-anchored label clamp band (ADR-0028): text never
134
+ * renders above this pixel size, however far the view zooms in.
135
+ * @default 24
136
+ */
137
+ textSizeMaxPixels?: number;
65
138
  }
66
139
  /**
67
140
  * Per-feature style for edit handles (vertex / midpoint / translate / rotate).
@@ -75,9 +148,25 @@ interface HandleStyle {
75
148
  strokeWidth?: number;
76
149
  strokeDash?: readonly number[];
77
150
  fillColor?: string;
151
+ /**
152
+ * How `fillColor` paints a filled part's interior. Absent or `"solid"` is
153
+ * today's flat fill; `"hachure"`/`"backhachure"`/`"cross"` request the shared hatch texture
154
+ * ({@link makeFillPatternImage}). Alpha rides in `fillColor` — there is no
155
+ * separate opacity field. Adapters that cannot rasterize a pattern degrade
156
+ * to solid. Per-feature only: layer-level `StyleOptions` has no equivalent.
157
+ */
158
+ fillPattern?: "solid" | HatchPattern;
78
159
  pointRadius?: number;
79
160
  }
80
161
  type LayerId = string;
162
+ /** Canonical SVG resource shared by point features through `iconId`. */
163
+ interface IconResource {
164
+ id: string;
165
+ svg: string;
166
+ width: number;
167
+ height: number;
168
+ anchor: [number, number];
169
+ }
81
170
  /**
82
171
  * Click-style hit event delivered by {@link MapAdapter.onPick}.
83
172
  *
@@ -115,6 +204,8 @@ interface MapAdapter {
115
204
  * Set the style for a specific layer.
116
205
  */
117
206
  setLayerStyle(layerId: LayerId, style: StyleOptions): void;
207
+ /** Reconcile the layer's desired shared icon resources. */
208
+ reconcileLayerIconResources(layerId: LayerId, resources: readonly IconResource[]): void;
118
209
  /**
119
210
  * Set features on a vector layer.
120
211
  * Features should be standard GeoJSON Features.
@@ -143,6 +234,12 @@ interface MapAdapter {
143
234
  * silently ignored.
144
235
  */
145
236
  removeFeatures(layerId: LayerId, ids: readonly string[]): void;
237
+ /**
238
+ * Defer an upcoming removal from `layerId` until the most recent update to
239
+ * `updatedLayerId` has completed in the engine. Optional because synchronous
240
+ * adapters already make cross-layer write order visible atomically.
241
+ */
242
+ deferLayerRemovalUntilLayerUpdate?(layerId: LayerId, updatedLayerId: LayerId): void;
146
243
  /**
147
244
  * Subscribe to click-style "pick" events for a specific layer. Basemap and
148
245
  * other-layer clicks never fire the handler.
@@ -165,6 +262,15 @@ interface MapAdapter {
165
262
  onPick(layerId: LayerId, handler: PickHandler, opts?: PickOptions): () => void;
166
263
  /**
167
264
  * Map Event Listeners
265
+ *
266
+ * Ordering contract: a `"click"` handler and the `onPick` handlers for the
267
+ * same user click MUST observe the pick first — adapters deliver picks and
268
+ * `"click"` from the same underlying engine event, in registration order.
269
+ * The draw/edit controllers register their pick subscriptions before their
270
+ * click handler and read the stashed pick inside it (click-away close /
271
+ * close-then-pick re-dispatch); an adapter that fires `"click"` from an
272
+ * earlier engine event than its picks breaks that contract (e.g. OL
273
+ * `click` vs `singleclick`).
168
274
  */
169
275
  on(eventType: "click" | "pointermove" | "dblclick", handler: MapEventHandler): void;
170
276
  off(eventType: "click" | "pointermove" | "dblclick", handler: MapEventHandler): void;
@@ -174,6 +280,11 @@ interface MapAdapter {
174
280
  toLonLat(coordinate: number[]): Position;
175
281
  fromLonLat(position: Position): number[];
176
282
  getPixelFromCoordinate(position: Position): PixelCoordinate | null;
283
+ /**
284
+ * Convert a viewport pixel to longitude/latitude. Optional so third-party
285
+ * adapters compiled against an older ABI remain valid.
286
+ */
287
+ getCoordinateFromPixel?(pixel: PixelCoordinate): Position | null;
177
288
  /**
178
289
  * Get current viewport size in CSS pixels.
179
290
  */
@@ -207,11 +318,23 @@ interface MapAdapter {
207
318
  * Get current map zoom level
208
319
  */
209
320
  getZoom(): number | undefined;
321
+ /**
322
+ * Get current view bearing, radians — the clockwise-from-north compass
323
+ * direction that screen-up points to (MapLibre-bearing semantics). `0` for
324
+ * engines/views that don't rotate.
325
+ */
326
+ getBearing(): number;
210
327
  /**
211
328
  * Listen for view changes (zoom/resolution)
212
329
  */
213
330
  onViewChange(handler: () => void): void;
214
331
  offViewChange(handler: () => void): void;
332
+ /**
333
+ * The engine evaluates the meters→pixels clamp for ground-sized point
334
+ * symbols inside its own style layer, so meters-sized point symbols don't
335
+ * need view-change re-renders for size.
336
+ */
337
+ readonly scalesGroundPointSymbolsNatively?: boolean;
215
338
  /**
216
339
  * Construct an {@link EditPointerDriver} for `TacticalDraw`'s edit
217
340
  * controller. The controller subscribes to pointer events on the returned
@@ -223,6 +346,13 @@ interface MapAdapter {
223
346
  * destruction.
224
347
  */
225
348
  destroy(): void;
349
+ /**
350
+ * Returns the features currently set on the layer — the adapter-side cache
351
+ * of the last `setLayerFeatures`/`updateLayerFeatures`/`removeFeatures`/
352
+ * `clearLayer` state. Empty array for an unknown layer. Callers must not
353
+ * mutate the returned array or features.
354
+ */
355
+ getLayerFeatures(layerId: LayerId): readonly Feature[];
226
356
  }
227
357
  //#endregion
228
358
  //#region src/base-map-adapter.d.ts
@@ -254,6 +384,8 @@ declare abstract class BaseMapAdapter<TLayerHandle, TEventToken, TViewToken> imp
254
384
  protected viewChangeTokens: Map<() => void, TViewToken>;
255
385
  protected featureCache: Map<string, Feature<import("geojson").Geometry, import("geojson").GeoJsonProperties>[]>;
256
386
  protected featuresChangedListeners: Map<string, Set<(features: Feature[]) => void>>;
387
+ protected iconResources: Map<string, IconResource>;
388
+ private layerIconResourceIds;
257
389
  private layerCounter;
258
390
  private destroyed;
259
391
  protected nextLayerId(): LayerId;
@@ -261,6 +393,7 @@ declare abstract class BaseMapAdapter<TLayerHandle, TEventToken, TViewToken> imp
261
393
  style?: StyleOptions;
262
394
  }): LayerId;
263
395
  removeLayer(layerId: LayerId): void;
396
+ reconcileLayerIconResources(layerId: LayerId, resources: readonly IconResource[]): void;
264
397
  setLayerFeatures(layerId: LayerId, features: readonly Feature[]): void;
265
398
  updateLayerFeatures(layerId: LayerId, features: readonly Feature[]): void;
266
399
  removeFeatures(layerId: LayerId, ids: readonly string[]): void;
@@ -275,6 +408,10 @@ declare abstract class BaseMapAdapter<TLayerHandle, TEventToken, TViewToken> imp
275
408
  protected emitLayerFeaturesChanged(layerId: LayerId, features: Feature[]): void;
276
409
  protected onLayerFeaturesChanged(layerId: LayerId, listener: (features: Feature[]) => void): () => void;
277
410
  protected destroyEngineResources(): void;
411
+ protected applyIconResources(resources: readonly IconResource[]): void;
412
+ protected getIconResource(id: string): IconResource | undefined;
413
+ protected removeIconResource(id: string): void;
414
+ private pruneIconResources;
278
415
  protected abstract createLayer(layerId: LayerId, style?: StyleOptions): TLayerHandle;
279
416
  protected abstract destroyLayer(handle: TLayerHandle): void;
280
417
  protected abstract applyFeatureOps(layerId: LayerId, ops: FeatureOps): void;
@@ -297,6 +434,12 @@ declare abstract class BaseMapAdapter<TLayerHandle, TEventToken, TViewToken> imp
297
434
  abstract setDoubleClickZoomEnabled(enabled: boolean): void;
298
435
  abstract getResolution(): number | undefined;
299
436
  abstract getZoom(): number | undefined;
437
+ /**
438
+ * Default for engines/views that don't rotate (Leaflet). Rotating engines
439
+ * (MapLibre, OpenLayers) override this.
440
+ */
441
+ getBearing(): number;
442
+ getLayerFeatures(layerId: LayerId): readonly Feature[];
300
443
  }
301
444
  //#endregion
302
445
  //#region src/pick-utils.d.ts
@@ -317,6 +460,20 @@ declare function bindAbortable(signal: AbortSignal | undefined, attach: () => ()
317
460
  * when missing or shaped wrong so callers fall through to the layer style.
318
461
  */
319
462
  declare function coerceHandleStyle(raw: unknown): HandleStyle | undefined;
463
+ /** Default lower edge of the ground-anchored label clamp band (ADR-0028). */
464
+ declare const DEFAULT_TEXT_SIZE_MIN_PIXELS = 8;
465
+ /** Default upper edge of the ground-anchored label clamp band (ADR-0028). */
466
+ declare const DEFAULT_TEXT_SIZE_MAX_PIXELS = 24;
467
+ /**
468
+ * Rendered pixel size of a ground-anchored label (ADR-0028):
469
+ * `textSizeMeters / resolution`, clamped to the layer's text-style band.
470
+ * Within the band the text scales with its graphic; past either edge it pins
471
+ * to the bound, so zooming out never yields unreadable smudges and zooming in
472
+ * never yields screen-covering text. Shared by the adapters that resolve text
473
+ * size in code (OpenLayers, Leaflet); the MapLibre adapter evaluates the same
474
+ * clamp inside its per-zoom-stop style expression.
475
+ */
476
+ declare function resolveGroundTextSizePixels(textSizeMeters: number, resolution: number, text?: Pick<TextStyleOptions, "textSizeMinPixels" | "textSizeMaxPixels">): number;
320
477
  //#endregion
321
478
  //#region src/gestures/hit-test.d.ts
322
479
  type ToPixel = (lonLat: Position) => PixelCoordinate | null;
@@ -331,7 +488,14 @@ type ToPixel = (lonLat: Position) => PixelCoordinate | null;
331
488
  *
332
489
  * Returns the first matching feature in iteration order, or null.
333
490
  */
334
- declare function hitTestFeature(pixel: PixelCoordinate, features: Feature[], hitTolerance: number, toPixel: ToPixel): Feature | null;
491
+ declare function hitTestFeature(pixel: PixelCoordinate, features: readonly Feature[], hitTolerance: number, toPixel: ToPixel): Feature | null;
492
+ /**
493
+ * Test a rendered icon-backed Point feature against its actual pixel footprint.
494
+ * The feature carries the adapter-neutral `iconScale` / `iconRotation` values
495
+ * emitted by TacticalDraw; `resource.anchor` is the placement anchor within the
496
+ * unscaled SVG viewport. A positive tolerance pads all four sides.
497
+ */
498
+ declare function hitTestIconFeature(pixel: PixelCoordinate, feature: Feature, resource: IconResource, hitTolerance: number, toPixel: ToPixel, bearing?: number): boolean;
335
499
  //#endregion
336
500
  //#region src/gestures/hit-tolerance.d.ts
337
501
  declare const TOUCH_HIT_TOLERANCE_PX = 22;
@@ -366,6 +530,95 @@ declare class PointerCallbackHub {
366
530
  clear(): void;
367
531
  }
368
532
  //#endregion
533
+ //#region src/point-symbol.d.ts
534
+ type PointSymbolSize = {
535
+ value: number;
536
+ unit: "pixels";
537
+ } | {
538
+ value: number;
539
+ unit: "meters"; /** Floor for the on-screen projection of the ground-based size; none when omitted. */
540
+ minPixels?: number; /** Cap on the on-screen projection of the ground-based size — typically the pixel size the symbol had when anchored to ground; unclamped when omitted. */
541
+ maxPixels?: number;
542
+ };
543
+ interface PointSymbolGraphicModifiers {
544
+ /** Radians clockwise, relative to the point symbol's own orientation. */
545
+ direction?: number;
546
+ }
547
+ interface PointSymbolColors {
548
+ fill?: string;
549
+ frame?: string;
550
+ icon?: string;
551
+ info?: string;
552
+ outline?: string;
553
+ }
554
+ interface PointSymbolStyle {
555
+ colors?: PointSymbolColors;
556
+ monoColor?: string;
557
+ opacity?: number;
558
+ outlineWidth?: number;
559
+ fill?: boolean;
560
+ frame?: boolean;
561
+ icon?: boolean;
562
+ }
563
+ interface PointSymbol {
564
+ id: string;
565
+ kind: "point-symbol";
566
+ sidc: string;
567
+ position: Position;
568
+ /** Normalized radians clockwise from geographic north. */
569
+ rotation: number;
570
+ /** Milsymbol's octagon-based L dimension. */
571
+ size: PointSymbolSize;
572
+ textAmplifiers?: TextAmplifiers;
573
+ graphicModifiers?: PointSymbolGraphicModifiers;
574
+ style?: PointSymbolStyle;
575
+ rendererOptions?: {
576
+ milsymbol?: Record<string, unknown>;
577
+ };
578
+ properties?: Record<string, unknown>;
579
+ schemaVersion?: 1;
580
+ }
581
+ interface PointSymbolAnchor {
582
+ x: number;
583
+ y: number;
584
+ }
585
+ /** Canonical-size, renderer-neutral point-symbol output. */
586
+ interface PointSymbolRender {
587
+ /** Content-derived identity for adapter-wide native-resource reuse. */
588
+ resourceId: string;
589
+ svg: string;
590
+ /** Renderer-space octagon size used to interpret persisted symbol size. */
591
+ octagonSize: number;
592
+ width: number;
593
+ height: number;
594
+ anchor: PointSymbolAnchor;
595
+ octagonAnchor: PointSymbolAnchor;
596
+ valid: boolean;
597
+ validation?: unknown;
598
+ }
599
+ interface PointSymbolCapability {
600
+ readonly defaultSize: PointSymbolSize;
601
+ readonly defaultRotation: number;
602
+ render(symbol: PointSymbol): PointSymbolRender;
603
+ invalidate(): void;
604
+ }
605
+ declare function isPointSymbol(graphic: unknown): graphic is PointSymbol;
606
+ declare function clonePointSymbol(symbol: PointSymbol): PointSymbol;
607
+ /**
608
+ * Replace a point-symbol size value as a user-driven resize. Ground-size display
609
+ * bounds scale with the value so they continue to constrain zoom changes rather
610
+ * than preventing the resize itself.
611
+ */
612
+ declare function resizePointSymbolSize(size: PointSymbolSize, value: number): PointSymbolSize;
613
+ //#endregion
614
+ //#region src/graphic.d.ts
615
+ type Graphic = ControlMeasure | PointSymbol;
616
+ type GraphicRender = ControlMeasureRender | PointSymbolRender;
617
+ interface GraphicSnapshot<G extends Graphic = Graphic> {
618
+ graphic: G;
619
+ render: G extends PointSymbol ? PointSymbolRender : ControlMeasureRender;
620
+ }
621
+ //#endregion
369
622
  //#region src/tactical-draw/abort.d.ts
370
623
  /**
371
624
  * AbortPlumbing for the TacticalDraw façade.
@@ -416,6 +669,7 @@ interface CombinedAbort {
416
669
  declare function combineWithHostSignal(hostSignal?: AbortSignal): CombinedAbort;
417
670
  //#endregion
418
671
  //#region src/tactical-draw/measure-options.d.ts
672
+ declare const DEFAULT_LABEL_SIZE_PIXELS = 14;
419
673
  /**
420
674
  * Where a control measure's size is anchored (ADR-0020). `ground` (the
421
675
  * default) means a meter size fixed to the terrain — static, never re-rendered
@@ -437,9 +691,10 @@ declare function getSizeAnchor(measure: Pick<ControlMeasure, "kind" | "options">
437
691
  //#endregion
438
692
  //#region src/tactical-draw/handles.d.ts
439
693
  /**
440
- * Line-only style surface for the rubber-band guide (ADR-0004). Slot inside
441
- * `InteractionStyle.guide`. Applied as the guide layer's layer style by the
442
- * façade; not per-feature.
694
+ * Line-only style surface for the rubber-band guide (ADR-0004) and the
695
+ * transform-box outline. Slot inside `InteractionStyle.guide` /
696
+ * `InteractionStyle.boxOutline`. Applied as a layer style for the guide, and
697
+ * per-feature (`properties.style`) for the box outline — see ADR-0017.
443
698
  */
444
699
  interface GuideStyle {
445
700
  strokeColor?: string;
@@ -447,25 +702,96 @@ interface GuideStyle {
447
702
  strokeDash?: readonly number[];
448
703
  }
449
704
  /**
450
- * Unified interaction-affordance style surface (ADR-0006). One option object
451
- * for both the guide line and every handle slot. `guide` is delivered as a
452
- * layer style; handle slots ride per-feature on `properties.style`.
705
+ * Unified interaction-affordance style surface (ADR-0006, extended by
706
+ * ADR-0017 for the transform box). One option object for the guide line,
707
+ * the box outline, and every handle slot. `guide` and `boxOutline` are line
708
+ * styles; handle slots ride per-feature on `properties.style`.
453
709
  */
454
710
  interface InteractionStyle {
455
711
  guide?: GuideStyle;
456
712
  vertexHandle?: HandleStyle;
457
713
  midpointHandle?: HandleStyle;
458
- translateHandle?: HandleStyle;
714
+ /** Visible grab target for a draggable rendered text label. */
715
+ labelHandle?: HandleStyle;
716
+ scaleHandle?: HandleStyle;
459
717
  rotateHandle?: HandleStyle;
718
+ boxOutline?: GuideStyle;
719
+ /**
720
+ * Pixel padding added on every side of the fitted transform box
721
+ * (`fitTransformBox`, ADR-0017) so the box doesn't tightly hug the rendered
722
+ * geometry. Default `0`. A scalar, not a style object, but it participates
723
+ * in the same built-in → façade → per-call resolution slot system as every
724
+ * other `InteractionStyle` field (see `TacticalDraw.resolveInteractionStyle`).
725
+ */
726
+ boxPadding?: number;
460
727
  }
461
728
  /**
462
- * Reserved enum from PRD #40. `reshape`, `translate`, and `rotate` produce
463
- * handles. `delete` is an explicit destructive mode: it renders the vertex
464
- * handles (so they can be tapped to remove) but no midpoint/translate/rotate
465
- * grips, and the edit controller deletes the tapped vertex instead of dragging
466
- * it. `scale` is reserved and throws elsewhere.
729
+ * Reserved enum from PRD #40, replaced under ADR-0017 by the transform-box UI.
730
+ * `reshape` renders vertex/midpoint handles; `transform` renders the oriented
731
+ * selection box (4 corner scale handles + a rotate grip + drag-anywhere
732
+ * body translate) via `transform-box.ts`. The two can be combined freely —
733
+ * single-measure edits show both by default. `labeldrag` enables dragging
734
+ * rendered text labels. `delete` is an explicit
735
+ * destructive mode: it renders the vertex handles (so they can be tapped to
736
+ * remove) but no midpoints or box, and the edit controller deletes the tapped
737
+ * vertex instead of dragging it.
467
738
  */
468
- type EditMode = "reshape" | "translate" | "rotate" | "scale" | "delete";
739
+ type EditMode = "reshape" | "transform" | "labeldrag" | "delete";
740
+ //#endregion
741
+ //#region src/tactical-draw/session-events.d.ts
742
+ /** A gesture's phase within a stream of {@link TransientChangeEvent}s. */
743
+ type TransientPhase = "start" | "move" | "end";
744
+ /**
745
+ * An in-flight mutation of a graphic's working geometry during a gesture —
746
+ * streamed continuously while the gesture runs (`EditSession.onTransientChange`
747
+ * / `DrawSession.onTransientChange`), distinct from the once-per-completed-
748
+ * gesture `onChange`. Hosts use it for live feedback, e.g. a radius readout
749
+ * while dragging a circle handle.
750
+ */
751
+ interface TransientChangeEvent {
752
+ /** Gesture phase. `end` fires exactly once per started gesture — on commit AND on cancel/abort. */
753
+ readonly phase: TransientPhase;
754
+ /** The full working control points at this instant (lon/lat). Fresh array per emission. */
755
+ readonly points: readonly Position[];
756
+ /**
757
+ * Index of the live point in `points` — the dragged vertex in edit, the
758
+ * rubber-band point in draw — or `null` when the whole geometry moves at
759
+ * once (box translate / rotate / scale).
760
+ */
761
+ readonly pointIndex: number | null;
762
+ }
763
+ /**
764
+ * The reason a session (`TransformSession`, `EditSession`, or `DrawSession`)
765
+ * settled, delivered to that session's `onSettled` handler (#191 / #194 /
766
+ * #197). `"close"` covers every commit path of the owning session —
767
+ * `TransformSession.close()` / `EditSession.close()` / `DrawSession.commit()`,
768
+ * a click-away close, a gesture commit (dblclick, a second click/tap on the
769
+ * finishing vertex, a click on another graphics-layer measure), or
770
+ * `TransformSession.setGraphics([])`. `"error"` is the commit-time snapshot
771
+ * build throwing (the outer promise rejects with that error, not a
772
+ * `TacticalDrawAbortError`). The remaining values replay
773
+ * `TacticalDrawAbortError.reason` verbatim: `"escape"`, `"signal"`,
774
+ * `"session"` (`session.abort()` / `event.abort()`), `"preempted"`,
775
+ * `"destroyed"`, and `"removed"` (edit / transform only — a measure dropped
776
+ * from `td.render()`'s measure set).
777
+ */
778
+ type SettleReason = "close" | "error" | TacticalDrawAbortReason;
779
+ //#endregion
780
+ //#region src/tactical-draw/session-history.d.ts
781
+ type SessionChangeSource = "gesture" | "undo" | "redo" | "programmatic";
782
+ interface SessionHistoryState {
783
+ readonly canUndo: boolean;
784
+ readonly canRedo: boolean;
785
+ readonly undoLabel?: string;
786
+ readonly redoLabel?: string;
787
+ }
788
+ interface SessionHistory {
789
+ /** Current availability and optional labels. Each session retains at most 100 completed steps. */
790
+ readonly state: SessionHistoryState;
791
+ undo(): boolean;
792
+ redo(): boolean;
793
+ subscribe(listener: (state: SessionHistoryState) => void): () => void;
794
+ }
469
795
  //#endregion
470
796
  //#region src/tactical-draw/edit-session.d.ts
471
797
  /**
@@ -474,17 +800,31 @@ type EditMode = "reshape" | "translate" | "rotate" | "scale" | "delete";
474
800
  *
475
801
  * `measure` is the immutable edit-start input. `controlPoints` is live working
476
802
  * state. After the session settles (`close` / `abort`), every method is a
477
- * silent no-op.
803
+ * silent no-op (except [[onSettled]] — late registration still delivers,
804
+ * asynchronously).
478
805
  */
479
806
  interface EditSession {
807
+ /** Undo/redo for completed mutations within this live session. */
808
+ readonly history: SessionHistory;
480
809
  /** Immutable edit-start input. */
481
- readonly measure: ControlMeasure;
810
+ readonly graphic: ControlMeasure;
811
+ /** Live complete working graphic. Copy on read. */
812
+ readonly workingGraphic: ControlMeasure;
482
813
  /** Live working state. Copy on read. */
483
814
  readonly controlPoints: readonly Position[];
484
815
  /** Live working options. Copy on read. */
485
816
  readonly options: ControlMeasure["options"];
486
817
  /** Live working style. Copy on read. */
487
818
  readonly style: ControlMeasureStyle | undefined;
819
+ /**
820
+ * Live working text amplifiers (MIL-STD-2525E Appendix L Table L-II). Copy
821
+ * on read. `undefined` when the edit-start measure carried none and none
822
+ * have been set since. Keys are stored as given — canonicalisation to
823
+ * doctrinal field codes happens in the core render seam, not here.
824
+ */
825
+ readonly textAmplifiers: TextAmplifiers | undefined;
826
+ /** Live user-adjusted anchors for individually rendered text amplifiers. */
827
+ readonly amplifierPlacements: AmplifierPlacements | undefined;
488
828
  /** `true` after the first working-copy mutation; back to `false` within tolerance. */
489
829
  readonly dirty: boolean;
490
830
  readonly modes: readonly EditMode[];
@@ -495,18 +835,24 @@ interface EditSession {
495
835
  */
496
836
  readonly sizeAnchor: SizeAnchor;
497
837
  /**
498
- * Resolve the outer `edit` promise with a `ControlMeasureSnapshot` of the
499
- * current working state.
838
+ * Commit: returns the `GraphicSnapshot<ControlMeasure>` of the current working state
839
+ * and resolves the outer `edit` promise with the same value (the same
840
+ * reference — no double-bake of ground-anchored sizes). Returns `undefined`
841
+ * after the session has settled (idempotent no-op).
500
842
  */
501
- close(): void;
843
+ close(): GraphicSnapshot<ControlMeasure> | undefined;
502
844
  /** Reject the outer `edit` promise with reason `"session"`. */
503
845
  abort(): void;
504
846
  /**
505
- * Switch the active edit-mode set. `"reshape"`, `"translate"`, and `"rotate"`
506
- * produce handles; `"delete"` is exclusive (vertex-tap removal) and collapses
507
- * the set to delete-only. `"scale"` is rejected with `TypeError` per PRD #40.
847
+ * Switch the active edit-mode set. `"reshape"` produces vertex/midpoint
848
+ * handles; `"transform"` renders the oriented selection box (corner scale
849
+ * handles, rotate grip, body translate — ADR-0017); `"delete"` is exclusive
850
+ * (vertex-tap removal) and collapses the set to delete-only. An unknown mode
851
+ * is rejected with `TypeError`.
508
852
  */
509
853
  setModes(modes: readonly EditMode[]): void;
854
+ /** Atomically replace the live working graphic while preserving id and kind. */
855
+ updateGraphic(updater: (graphic: ControlMeasure) => ControlMeasure): void;
510
856
  /** Shallow-merge per-measure generator options into the live working copy. */
511
857
  setOptions(partial: ControlMeasure["options"]): void;
512
858
  /**
@@ -518,6 +864,22 @@ interface EditSession {
518
864
  setSizeAnchor(anchor: SizeAnchor): void;
519
865
  /** Shallow-merge per-measure style into the live working copy. */
520
866
  setStyle(partial: ControlMeasureStyle): void;
867
+ /**
868
+ * Shallow-merge into the live working text amplifiers. A value of
869
+ * `undefined` or an empty/whitespace-only string deletes that key; the
870
+ * working record collapses back to `undefined` once it holds no keys. A
871
+ * silent no-op after the session has settled (like [[setOptions]]).
872
+ */
873
+ setTextAmplifiers(partial: Record<string, string | undefined>): void;
874
+ /**
875
+ * Merge field-keyed label overrides (position and/or rotation). The merge is
876
+ * **field-wise per key**: a bare `Position` value means `{ position }`;
877
+ * fields absent from the incoming value (e.g. `rotation` when only
878
+ * `position` is supplied) are preserved from the existing entry; an
879
+ * explicit `undefined` value deletes the key entirely — a full reset to the
880
+ * doctrinal placement (both position and rotation).
881
+ */
882
+ setAmplifierPlacements(partial: Record<string, AmplifierPlacement | undefined>): void;
521
883
  /**
522
884
  * Subscribe to working-copy changes. The listener receives an
523
885
  * [[EditChangeEvent]] for each completed gesture (drag end, insert/delete
@@ -530,6 +892,69 @@ interface EditSession {
530
892
  * have run.
531
893
  */
532
894
  onChange(listener: (event: EditChangeEvent) => void): () => void;
895
+ /**
896
+ * Subscribe to the committed snapshot — the single delivery channel for the
897
+ * `GraphicSnapshot<ControlMeasure>` this session ever commits (#197, mirroring
898
+ * `TransformSession.onCommit` #191). Fires exactly once, synchronously from
899
+ * within the [[close]] call that produced it (`session.close()`,
900
+ * `event.close()` from [[onChange]], a click-away close, or a click on
901
+ * another graphics-layer measure), with the same reference [[close]]
902
+ * returns and the outer `edit` promise resolves with. Never fires on abort.
903
+ *
904
+ * Listener exceptions are collected; after every listener has run, they are
905
+ * rethrown (one if single, `AggregateError` otherwise) — mirrors
906
+ * [[onChange]]. Returns an unsubscribe function; registering after the
907
+ * session has settled returns a no-op unsubscribe and the listener never
908
+ * fires.
909
+ */
910
+ onCommit(handler: (snapshot: GraphicSnapshot<ControlMeasure>) => void): () => void;
911
+ /**
912
+ * Subscribe to the session's settlement — the lifecycle complement of
913
+ * [[onCommit]]: `onCommit` delivers *what* was committed, `onSettled` says
914
+ * the session is *over* and why (#197, mirroring
915
+ * `TransformSession.onSettled` #194). Fires exactly once, when the session
916
+ * settles by any path, commit or abort, with a `SettleReason`.
917
+ *
918
+ * - On the commit path it fires **after** [[onCommit]]'s emission, so a
919
+ * listener that clears selection state observes a document that already
920
+ * contains the committed snapshot.
921
+ * - On every path it fires **after** the facade has already cleared
922
+ * `td.activeSession` — so fold-then-render works from inside the handler.
923
+ * - `reason` distinguishes commit from abort but is deliberately coarse —
924
+ * no payload; the snapshot was already delivered through [[onCommit]].
925
+ *
926
+ * Registering after the session has already settled is **not** a no-op
927
+ * (unlike [[onChange]] / [[onCommit]]): the handler is invoked
928
+ * asynchronously (on a microtask) exactly once with the settle reason;
929
+ * unsubscribing before that microtask runs cancels the delivery.
930
+ *
931
+ * Listener exceptions are collected; after every listener has run, they are
932
+ * rethrown (one if single, `AggregateError` otherwise) — mirrors
933
+ * [[onChange]] / [[onCommit]]. Returns an unsubscribe function.
934
+ */
935
+ onSettled(handler: (reason: SettleReason) => void): () => void;
936
+ /**
937
+ * Subscribe to the continuous stream of in-flight geometry mutations —
938
+ * vertex/midpoint drags and box scale/rotate/translate gestures — fired
939
+ * throughout the gesture rather than once at its end (contrast
940
+ * [[onChange]]). `start` fires at gesture begin, `move` on every pointer
941
+ * tick, and `end` fires exactly once when the gesture finishes by any
942
+ * path: commit, a click-slop no-op (a box body click that never becomes a
943
+ * drag), or session abort/teardown mid-drag. `end` fires **before** the
944
+ * gesture's [[onChange]] emission (and, on the commit path, before
945
+ * [[onCommit]]/[[onSettled]] too), so a listener observing both channels
946
+ * sees the transient stream close first.
947
+ *
948
+ * Label-placement drags (`labeldrag` mode) do not fire this — they move
949
+ * amplifier placements, not control points.
950
+ *
951
+ * Listener exceptions are collected; after every listener has run, they
952
+ * are rethrown (one if single, `AggregateError` otherwise) — mirrors
953
+ * [[onChange]]. Returns an unsubscribe function; registering after the
954
+ * session has settled returns a no-op unsubscribe and the listener never
955
+ * fires.
956
+ */
957
+ onTransientChange(listener: (event: TransientChangeEvent) => void): () => void;
533
958
  }
534
959
  /**
535
960
  * Delivered to `EditSession.onChange` listeners once per completed gesture.
@@ -540,10 +965,12 @@ interface EditSession {
540
965
  * observe partial state. See PRD #40 user-stories 23–24.
541
966
  */
542
967
  interface EditChangeEvent {
968
+ /** How this working-copy transition was initiated. */
969
+ readonly source: SessionChangeSource;
543
970
  /** Working state after the completed gesture. */
544
- readonly measure: ControlMeasureSnapshot;
971
+ readonly graphic: GraphicSnapshot<ControlMeasure>;
545
972
  /** State immediately before this gesture. */
546
- readonly previous: ControlMeasureSnapshot;
973
+ readonly previous: GraphicSnapshot<ControlMeasure>;
547
974
  /** The session the event belongs to. */
548
975
  readonly session: EditSession;
549
976
  /**
@@ -558,13 +985,268 @@ interface EditChangeEvent {
558
985
  abort(): void;
559
986
  }
560
987
  //#endregion
988
+ //#region src/tactical-draw/graphic-edit-session.d.ts
989
+ interface GraphicEditChangeEvent<G extends Graphic = Graphic> {
990
+ readonly source: SessionChangeSource;
991
+ readonly graphic: GraphicSnapshot<G>;
992
+ readonly previous: GraphicSnapshot<G>;
993
+ readonly session: GraphicEditSession<G>;
994
+ reject(): void;
995
+ close(): void;
996
+ abort(): void;
997
+ }
998
+ /** Common selection/editing contract shared by control measures and point symbols. */
999
+ interface GraphicEditSession<G extends Graphic = Graphic> {
1000
+ readonly history: SessionHistory;
1001
+ /** Immutable edit-start input. */
1002
+ readonly graphic: G;
1003
+ /** Live working graphic. Copy on read. */
1004
+ readonly workingGraphic: G;
1005
+ readonly dirty: boolean;
1006
+ readonly modes: readonly EditMode[];
1007
+ close(): GraphicSnapshot<G> | undefined;
1008
+ abort(): void;
1009
+ setModes(modes: readonly EditMode[]): void;
1010
+ /** Atomically replace any part of the live working graphic. */
1011
+ updateGraphic(updater: (graphic: G) => G): void;
1012
+ onChange(listener: (event: GraphicEditChangeEvent<G>) => void): () => void;
1013
+ onCommit(handler: (snapshot: GraphicSnapshot<G>) => void): () => void;
1014
+ onSettled(handler: (reason: SettleReason) => void): () => void;
1015
+ }
1016
+ //#endregion
1017
+ //#region src/tactical-draw/transform-session.d.ts
1018
+ /**
1019
+ * Surface delivered via `td.editMany(graphics, opts).onSession` and
1020
+ * synchronously available from `td.activeTransformSession` while the group
1021
+ * transform is live. The group sibling of `EditSession` (`edit-session.ts`):
1022
+ * no reshape, no per-measure `setOptions`/`setStyle` — only the shared
1023
+ * transform box's corner-scale / rotate / body-translate gestures, applied to
1024
+ * every member at once. See the Phase 3 ("Multi-select") slice of ADR-0017.
1025
+ *
1026
+ * Typed on `Graphic` (ADR-0032): a member is either a `ControlMeasure` or a
1027
+ * point symbol, and the two coexist in one session. Hosts narrow with
1028
+ * `isPointSymbol`. `controlPointsFor` is a control-measure accessor and returns
1029
+ * `undefined` for a point-symbol id.
1030
+ *
1031
+ * The member set is mutable via [[setGraphics]] (#187): members can join or
1032
+ * leave a live session without a session boundary — point symbols exactly like
1033
+ * measures. Because membership changes over the session's lifetime, snapshot
1034
+ * arrays are keyed by **graphic id**, not by index — a snapshot's identity is
1035
+ * `snapshot.graphic.id`. `graphics` is a live view of the current members'
1036
+ * join-time inputs; a `TransformChangeEvent`'s arrays are index-aligned with
1037
+ * `session.graphics` *as of that emission*. After the session settles (`close`
1038
+ * / `abort` / an emptying `setGraphics`), every method is a silent no-op
1039
+ * (except [[onSettled]] — late registration still delivers, asynchronously).
1040
+ */
1041
+ interface TransformSession {
1042
+ /** Undo/redo for completed gestures within the current membership baseline. */
1043
+ readonly history: SessionHistory;
1044
+ /**
1045
+ * Live view of the current members' join-time input graphics — a fresh array
1046
+ * on each read, in current membership order. Grows/shrinks as [[setGraphics]]
1047
+ * adds or removes members. Not stable across membership changes: key
1048
+ * snapshots by `snapshot.graphic.id` rather than relying on index alignment.
1049
+ */
1050
+ readonly graphics: readonly Graphic[];
1051
+ /**
1052
+ * Live view of the current members' working graphics — a fresh array on each
1053
+ * read, in current membership order, reflecting every gesture applied so far.
1054
+ * Measures carry their working control points (and any transform-frozen
1055
+ * options); point symbols carry their working position, size value, and
1056
+ * rotation. Copy on read.
1057
+ */
1058
+ readonly workingGraphics: readonly Graphic[];
1059
+ /**
1060
+ * Live working control points for the control-measure member whose id is
1061
+ * `graphicId`, or `undefined` when no such member carries that id — including
1062
+ * when `graphicId` names a point symbol (which has no control points; read
1063
+ * its working `position` from [[workingGraphics]] instead). Copy on read.
1064
+ */
1065
+ controlPointsFor(graphicId: string): readonly Position[] | undefined;
1066
+ /** `true` when any member's working state differs from its edit-start input beyond tolerance. */
1067
+ readonly dirty: boolean;
1068
+ /**
1069
+ * Live size anchor, applied group-wide to the **control-measure** members
1070
+ * only (ADR-0020). `"ground"` bakes each measure's pixel size to meters on
1071
+ * commit; `"screen"` keeps it zoom-aware. A point symbol's persisted
1072
+ * pixel/meter size is its own anchor and is never rewritten by this setting.
1073
+ * Initialised from `EditManyOptions.sizeAnchor` and mutated by [[setSizeAnchor]].
1074
+ */
1075
+ readonly sizeAnchor: SizeAnchor;
1076
+ /**
1077
+ * Switch the group's size anchor (ADR-0020) for its control-measure members.
1078
+ * Takes effect on the next emitted snapshot and on `close()`. Point-symbol
1079
+ * members are unaffected.
1080
+ */
1081
+ setSizeAnchor(anchor: SizeAnchor): void;
1082
+ /**
1083
+ * Replace the member set in place — the primitive behind shift-click toggle,
1084
+ * marquee replace, and single-edit → group folding, without tearing the
1085
+ * session down (#187). Members (measures and point symbols alike) are matched
1086
+ * to the current set by graphic id:
1087
+ *
1088
+ * - **Retained** members (id in both the current and next sets) carry their
1089
+ * current working state forward; the passed graphic object is ignored for
1090
+ * them. Their `dirty` baseline stays their original join-time input, so a
1091
+ * session that has already moved things stays dirty across the call.
1092
+ * - **Removed** members (id in the current set, absent from the next) are
1093
+ * committed at their current working state.
1094
+ * - **Added** members (id only in the next set) join at their passed input
1095
+ * state; if currently on the graphics layer they are lifted off it exactly
1096
+ * like initial members.
1097
+ *
1098
+ * The transform box re-derives from the next member set at the session's
1099
+ * current box angle. Session-scoped state (`sizeAnchor`, box angle,
1100
+ * non-geometry dirty) is unchanged.
1101
+ *
1102
+ * Calling this establishes a new session-history baseline: both undo and
1103
+ * redo are cleared after retained members carry their working state
1104
+ * forward. Membership changes and already-emitted exit snapshots are not
1105
+ * undoable within the live session.
1106
+ *
1107
+ * **Return value** — the committed exit snapshots of the members removed *by
1108
+ * this call*, built exactly like [[close]]'s snapshots (a measure's pixel
1109
+ * size baked to ground unless `sizeAnchor` is `"screen"`, a point symbol's
1110
+ * size left untouched, then cloned and rendered); an empty array when nothing
1111
+ * was removed. The outer `editMany` promise resolves with the snapshots of
1112
+ * the members in the set **at close time only**. Removed members are NOT
1113
+ * written back to the graphics layer by the controller — the host folds the
1114
+ * returned snapshots into its document and calls `td.render()`, the same
1115
+ * contract as [[close]]. A removed member's exit snapshot is also delivered
1116
+ * through [[onCommit]] — the recommended channel, since it covers this return
1117
+ * value and the close-time promise resolution through a single subscription.
1118
+ * Returns `undefined` (a silent no-op) after the session has settled.
1119
+ *
1120
+ * `setGraphics([])` on a live session delegates to [[close]]: it commits every
1121
+ * current member, resolves the promise with their snapshots, and returns that
1122
+ * same array. Duplicate ids in `graphics` throw `TypeError` synchronously,
1123
+ * without mutating session state. A point-symbol member entering a session
1124
+ * whose controller was created without a point-symbol capability throws
1125
+ * `PointSymbolCapabilityRequiredError` synchronously, also without mutating
1126
+ * session state.
1127
+ */
1128
+ setGraphics(graphics: readonly Graphic[]): GraphicSnapshot[] | undefined;
1129
+ /**
1130
+ * Commit: returns the members' `GraphicSnapshot[]` (the members in the set at
1131
+ * close time) and resolves the outer `editMany` promise with the same value
1132
+ * (the same reference — no double-bake of ground-anchored measure sizes). A
1133
+ * member removed mid-session via [[setGraphics]] is not included here; its exit
1134
+ * snapshot was returned from that `setGraphics` call (and delivered to
1135
+ * [[onCommit]] at that time). Returns `undefined` after the session has
1136
+ * settled (idempotent no-op).
1137
+ */
1138
+ close(): GraphicSnapshot[] | undefined;
1139
+ /** Reject the outer `editMany` promise with reason `"session"`. */
1140
+ abort(): void;
1141
+ /**
1142
+ * Subscribe to working-copy changes. The listener receives a
1143
+ * [[TransformChangeEvent]] for each completed group gesture (corner scale,
1144
+ * rotate, or body-translate drag end) — never on rubber-band ticks. All
1145
+ * currently-registered listeners receive the same event instance, in
1146
+ * registration order.
1147
+ *
1148
+ * Listener exceptions are collected; after every listener has run, they are
1149
+ * rethrown (one if single, aggregated otherwise). `event.reject()` /
1150
+ * `event.close()` / `event.abort()` take effect only after all listeners
1151
+ * have run — mirrors `EditSession.onChange` (issue #49).
1152
+ */
1153
+ onChange(listener: (event: TransformChangeEvent) => void): () => void;
1154
+ /**
1155
+ * Subscribe to committed exit-snapshot batches — the single delivery channel
1156
+ * for every `GraphicSnapshot[]` this session ever commits (#191). Fires
1157
+ * exactly once per batch:
1158
+ *
1159
+ * - The members [[setGraphics]] removes, but only when that call actually
1160
+ * removed at least one member.
1161
+ * - The close-time members on any commit path: [[close]], `setGraphics([])`,
1162
+ * a click-away close, or `TransformChangeEvent.close()`.
1163
+ *
1164
+ * Never fires on an abort/reject path — pair it with [[onSettled]] to also
1165
+ * observe abort settles. Fires synchronously from within the
1166
+ * `setGraphics`/`close` call that produced the batch, before that call
1167
+ * returns, and the array delivered here is the same reference that call
1168
+ * returns (and, for the close batch, the same reference the `editMany`
1169
+ * promise resolves with). This is the recommended single fold path for exit
1170
+ * state — subscribe once in `onSession` and every committed snapshot, from
1171
+ * either delivery site, arrives here exactly once; the `setGraphics` return
1172
+ * value and the promise resolution are unchanged and remain valid for
1173
+ * backward compatibility.
1174
+ *
1175
+ * Listener exceptions are collected; after every listener has run, they are
1176
+ * rethrown (one if single, `AggregateError` otherwise) — mirrors
1177
+ * [[onChange]]. Returns an unsubscribe function; registering after the
1178
+ * session has settled returns a no-op unsubscribe and the listener never
1179
+ * fires.
1180
+ */
1181
+ onCommit(handler: (snapshots: GraphicSnapshot[]) => void): () => void;
1182
+ /**
1183
+ * Subscribe to the session's settlement — the lifecycle complement of
1184
+ * [[onCommit]]: `onCommit` delivers *what* was committed, `onSettled` says
1185
+ * the session is *over* and why (#194). Fires exactly once, when the
1186
+ * session settles by any path, commit or abort, with a [[SettleReason]].
1187
+ *
1188
+ * - On commit paths it fires **after** the close batch's `onCommit`
1189
+ * emission, so a listener that clears selection state observes a document
1190
+ * that already contains the exit snapshots.
1191
+ * - On every path it fires **after** the facade has already cleared its
1192
+ * slot — `td.activeTransformSession` is `null` and the members are no
1193
+ * longer excluded from `td.render()` reconciliation, so fold-then-render
1194
+ * works from inside the handler.
1195
+ * - `reason` distinguishes commit from abort but is deliberately coarse —
1196
+ * no payload; snapshots were already delivered through [[onCommit]].
1197
+ *
1198
+ * Registering after the session has already settled is **not** a no-op
1199
+ * (unlike [[onChange]] / [[onCommit]]): the handler is invoked
1200
+ * asynchronously (on a microtask) exactly once with the settle reason;
1201
+ * unsubscribing before that microtask runs cancels the delivery.
1202
+ *
1203
+ * Listener exceptions are collected; after every listener has run, they are
1204
+ * rethrown (one if single, `AggregateError` otherwise) — mirrors
1205
+ * [[onChange]] / [[onCommit]]. Returns an unsubscribe function.
1206
+ */
1207
+ onSettled(handler: (reason: SettleReason) => void): () => void;
1208
+ }
1209
+ /**
1210
+ * Delivered to `TransformSession.onChange` listeners once per completed group
1211
+ * gesture. Both `graphics` and `previous` are index-aligned with
1212
+ * `session.graphics` *as of this emission*. Key by `graphic.id` rather than
1213
+ * relying on index alignment across emissions. A `setGraphics` call from within
1214
+ * a listener takes effect immediately and supersedes this emission's
1215
+ * `reject()` (the rollback baseline no longer matches the member set);
1216
+ * `close()` and `abort()` still apply, against the new membership. See
1217
+ * `EditChangeEvent` for the identical emission contract this mirrors.
1218
+ */
1219
+ interface TransformChangeEvent {
1220
+ /** How this working-copy transition was initiated. */
1221
+ readonly source: SessionChangeSource;
1222
+ /** Working state after the completed gesture, index-aligned with `session.graphics` as of this emission. */
1223
+ readonly graphics: GraphicSnapshot[];
1224
+ /** State immediately before this gesture, index-aligned with `session.graphics` as of this emission. */
1225
+ readonly previous: GraphicSnapshot[];
1226
+ /** The session the event belongs to. */
1227
+ readonly session: TransformSession;
1228
+ /**
1229
+ * Roll every member's working preview back to `previous` after all
1230
+ * listeners have run. Multiple calls collapse to a single rollback.
1231
+ * Overridden by `close()` or `abort()` if either is also called during this
1232
+ * emission, and dropped if a listener called `session.setGraphics` (the
1233
+ * rollback baseline no longer matches the member set).
1234
+ */
1235
+ reject(): void;
1236
+ /** Shortcut for `event.session.close()`, deferred until the emission ends. */
1237
+ close(): void;
1238
+ /** Shortcut for `event.session.abort()`, deferred until the emission ends. */
1239
+ abort(): void;
1240
+ }
1241
+ //#endregion
561
1242
  //#region src/tactical-draw/session.d.ts
562
1243
  /**
563
1244
  * Scoped surface delivered via `td.draw({ onSession })` for variable-length
564
1245
  * kinds. Also available from `td.activeSession` while the draw is live.
565
1246
  *
566
1247
  * After the draw has settled — either via `commit()` returning `true` or
567
- * `abort()` resolving — every method is a silent no-op.
1248
+ * `abort()` resolving — every method is a silent no-op (except [[onSettled]]
1249
+ * — late registration still delivers, asynchronously).
568
1250
  */
569
1251
  interface DrawSession {
570
1252
  /** Live snapshot of the committed control points. Copy on read. */
@@ -586,8 +1268,117 @@ interface DrawSession {
586
1268
  * are collected and rethrown after all listeners run.
587
1269
  */
588
1270
  onChange(listener: (session: DrawSession) => void): () => void;
1271
+ /**
1272
+ * Subscribe to the committed snapshot — the single delivery channel for the
1273
+ * `GraphicSnapshot<ControlMeasure>` this session ever commits (#197, mirroring
1274
+ * `TransformSession.onCommit` #191). Fires exactly once, synchronously from
1275
+ * within the commit that produced it (`session.commit()`, dblclick, or a
1276
+ * second click/tap on the finishing vertex), with the same reference the
1277
+ * outer `draw` promise resolves with — the committed snapshot, not just the
1278
+ * measure. Never fires on abort.
1279
+ *
1280
+ * Listener exceptions are collected; after every listener has run, they are
1281
+ * rethrown (one if single, `AggregateError` otherwise) — mirrors
1282
+ * [[onChange]]. Returns an unsubscribe function; registering after the
1283
+ * session has settled returns a no-op unsubscribe and the listener never
1284
+ * fires.
1285
+ */
1286
+ onCommit(handler: (snapshot: GraphicSnapshot<ControlMeasure>) => void): () => void;
1287
+ /**
1288
+ * Subscribe to the session's settlement — the lifecycle complement of
1289
+ * [[onCommit]]: `onCommit` delivers *what* was committed, `onSettled` says
1290
+ * the session is *over* and why (#197, mirroring
1291
+ * `TransformSession.onSettled` #194). Fires exactly once, when the session
1292
+ * settles by any path, commit or abort, with a `SettleReason`.
1293
+ *
1294
+ * - On the commit path it fires **after** [[onCommit]]'s emission.
1295
+ * - On every path it fires **after** the facade has already cleared
1296
+ * `td.activeSession` — so fold-then-render works from inside the handler.
1297
+ * - `reason` distinguishes commit from abort but is deliberately coarse —
1298
+ * no payload; the snapshot was already delivered through [[onCommit]].
1299
+ *
1300
+ * Registering after the session has already settled is **not** a no-op
1301
+ * (unlike [[onChange]] / [[onCommit]]): the handler is invoked
1302
+ * asynchronously (on a microtask) exactly once with the settle reason;
1303
+ * unsubscribing before that microtask runs cancels the delivery.
1304
+ *
1305
+ * Listener exceptions are collected; after every listener has run, they are
1306
+ * rethrown (one if single, `AggregateError` otherwise) — mirrors
1307
+ * [[onChange]] / [[onCommit]]. Returns an unsubscribe function.
1308
+ */
1309
+ onSettled(handler: (reason: SettleReason) => void): () => void;
1310
+ /**
1311
+ * Subscribe to the continuous stream of rubber-band pointer ticks while the
1312
+ * draw is live — contrast [[onChange]], which fires once per committed
1313
+ * point. `start` fires on the first rubber-band tick, `move` on every
1314
+ * subsequent tick; a click that commits a point does not itself emit — the
1315
+ * next rubber-band tick after it does. `end` fires exactly once, when the
1316
+ * session settles (commit or abort).
1317
+ *
1318
+ * `points` is the committed points with the live rubber-band point spliced
1319
+ * in at its insertion index; `pointIndex` is that index.
1320
+ *
1321
+ * Listener exceptions are collected; after every listener has run, they
1322
+ * are rethrown (one if single, `AggregateError` otherwise) — mirrors
1323
+ * [[onChange]]. Returns an unsubscribe function; registering after the
1324
+ * session has settled returns a no-op unsubscribe and the listener never
1325
+ * fires.
1326
+ */
1327
+ onTransientChange(listener: (event: TransientChangeEvent) => void): () => void;
1328
+ }
1329
+ //#endregion
1330
+ //#region src/tactical-draw/snapping.d.ts
1331
+ type SnapInteraction = "draw" | "reshape" | "translate" | "label-drag";
1332
+ interface SnapCandidateRequest {
1333
+ coordinate: Position;
1334
+ pixel: PixelCoordinate;
1335
+ interaction: SnapInteraction;
1336
+ graphicId?: string;
1337
+ controlPointIndex?: number;
1338
+ originalEvent?: unknown;
1339
+ }
1340
+ interface SnapCandidate {
1341
+ id: string;
1342
+ coordinate: Position;
1343
+ kind: string;
1344
+ graphicId?: string;
1345
+ controlPointIndex?: number;
1346
+ priority?: number;
1347
+ }
1348
+ type SnapCandidateProvider = (request: SnapCandidateRequest) => readonly SnapCandidate[];
1349
+ interface SnapResult {
1350
+ coordinate: Position;
1351
+ pixel: PixelCoordinate;
1352
+ candidate: SnapCandidate;
1353
+ distancePixels: number;
1354
+ }
1355
+ interface GraphicGeometrySnappingOptions {
1356
+ /** Snap to the nearest point along generated linework and polygon boundaries. Default `true`. */
1357
+ edges?: boolean;
1358
+ /** Snap to generated geometry vertices. Default `false`. */
1359
+ vertices?: boolean;
1360
+ }
1361
+ interface SnappingOptions {
1362
+ enabled?: boolean;
1363
+ tolerancePixels?: number;
1364
+ sources?: {
1365
+ graphics?: boolean;
1366
+ /**
1367
+ * Snap to generated control-measure geometry. `true` enables edges with
1368
+ * vertices disabled; pass an object to select either candidate type.
1369
+ * Text/label point features and the active graphic are excluded.
1370
+ */
1371
+ graphicGeometry?: boolean | GraphicGeometrySnappingOptions;
1372
+ external?: SnapCandidateProvider;
1373
+ };
1374
+ bypass?: (originalEvent: unknown) => boolean;
1375
+ /** Called for each resolution attempt; `null` clears a host-rendered indicator. */
1376
+ onSnap?: (result: SnapResult | null) => void;
589
1377
  }
590
1378
  //#endregion
1379
+ //#region src/tactical-draw/point-symbol-draw-controller.d.ts
1380
+ type DrawPointSymbolDraft = Pick<PointSymbol, "kind" | "sidc"> & Partial<Omit<PointSymbol, "kind" | "sidc">>;
1381
+ //#endregion
591
1382
  //#region src/tactical-draw/tactical-draw.d.ts
592
1383
  /**
593
1384
  * Host-supplied layer slot ids. Any omitted slot is allocated by the façade and
@@ -599,8 +1390,22 @@ interface TacticalDrawLayerOptions {
599
1390
  guide?: LayerId;
600
1391
  handles?: LayerId;
601
1392
  }
1393
+ /**
1394
+ * Result of {@link TacticalDraw.ownsInteractionAt} — which layer claimed the
1395
+ * pixel and the feature that matched.
1396
+ */
1397
+ interface InteractionHit {
1398
+ /** Which TacticalDraw layer owns the pixel — topmost match wins. */
1399
+ layer: "handles" | "preview" | "graphics";
1400
+ /** The hit feature (do not mutate). */
1401
+ feature: Feature;
1402
+ /** Control-measure id when the hit feature belongs to one (graphics/preview parts). */
1403
+ measureId?: string;
1404
+ }
602
1405
  interface TacticalDrawOptions {
603
1406
  layers?: TacticalDrawLayerOptions;
1407
+ /** Optional renderer capability for first-class point symbols (ADR-0029). */
1408
+ pointSymbols?: PointSymbolCapability;
604
1409
  /**
605
1410
  * Façade-level default style passed through to `renderControlMeasure` as the
606
1411
  * third (lowest priority) style layer per StyleResolver (#44). Shallow-merged
@@ -621,6 +1426,8 @@ interface TacticalDrawOptions {
621
1426
  * the draw API itself are deferred (PRD #40 user-story-5 note).
622
1427
  */
623
1428
  generateId?: () => string;
1429
+ /** Optional engine-level snapping for draw and edit interactions. */
1430
+ snapping?: SnappingOptions;
624
1431
  }
625
1432
  interface EditOptions {
626
1433
  signal?: AbortSignal;
@@ -628,7 +1435,7 @@ interface EditOptions {
628
1435
  * Called synchronously once when the edit starts, before any pointer event
629
1436
  * is processed. Lets a host wire commit / cancel buttons to the active edit.
630
1437
  */
631
- onSession?: (session: EditSession) => void;
1438
+ onSession?: (session: EditSession | GraphicEditSession<PointSymbol>) => void;
632
1439
  /**
633
1440
  * Default `true`. When `true`, a click on empty map (or on another graphics
634
1441
  * layer measure) closes the active edit. Clicks on preview / handle
@@ -637,11 +1444,13 @@ interface EditOptions {
637
1444
  */
638
1445
  closeOnClickAway?: boolean;
639
1446
  /**
640
- * Active edit modes. Defaults to `["reshape"]`. `reshape` renders vertex /
641
- * midpoint handles; `translate` renders a grip on the centroid pivot; and
642
- * `rotate` renders a grip offset just outside the shape (so it has a lever
643
- * arm and orbits with the geometry). The three can be combined freely.
644
- * `"scale"` is reserved and throws.
1447
+ * Active edit modes. Defaults to `["reshape", "transform"]`. `reshape`
1448
+ * renders vertex / midpoint handles; `transform` renders the oriented
1449
+ * selection box — 4 corner scale handles, a rotate grip above the top edge,
1450
+ * and drag-anywhere-inside-the-box translate (ADR-0017). The two can be
1451
+ * combined freely (the default does). `"labeldrag"` opt-in enables repositioning
1452
+ * rendered text labels. `"delete"` is exclusive: it collapses
1453
+ * the set to vertex-tap removal only. An unknown mode throws `TypeError`.
645
1454
  */
646
1455
  modes?: readonly EditMode[];
647
1456
  /**
@@ -655,8 +1464,10 @@ interface EditOptions {
655
1464
  * polyline sourced from the working control points alongside the working
656
1465
  * geometry preview. For `geometry === "area"` kinds, a separate
657
1466
  * closing-segment `LineString` is also emitted once `workingPoints.length >= 3`.
658
- * The guide is suppressed mechanically when `geometry === "point"` or
659
- * `minCoordinates === maxCoordinates`, regardless of this flag. See ADR-0004.
1467
+ * The guide is suppressed mechanically when `geometry === "point"`, and for
1468
+ * fixed-length kinds (`minCoordinates === maxCoordinates` or `canonicalPointCount`
1469
+ * set) unless the kind's draw rule opts back in via `showGuide`, regardless of
1470
+ * this flag. See ADR-0004.
660
1471
  */
661
1472
  guide?: boolean;
662
1473
  /**
@@ -669,6 +1480,32 @@ interface EditOptions {
669
1480
  */
670
1481
  sizeAnchor?: SizeAnchor;
671
1482
  }
1483
+ /**
1484
+ * Options for `TacticalDraw.editMany` — the multi-select group transform
1485
+ * (ADR-0017 "Multi-select" slice). Leaner than `EditOptions`: there is no
1486
+ * `modes` (a group transform is always the oriented box — no reshape), no
1487
+ * `guide` (no rubber-band content for a group), and no per-measure `setOptions`
1488
+ * / `setStyle` surface on the resulting session.
1489
+ */
1490
+ interface EditManyOptions {
1491
+ signal?: AbortSignal;
1492
+ /**
1493
+ * Called synchronously once when the group transform starts, before any
1494
+ * pointer event is processed. Lets a host wire Done/Cancel/Delete buttons to
1495
+ * the active session.
1496
+ */
1497
+ onSession?: (session: TransformSession) => void;
1498
+ /**
1499
+ * Default `true`. When `true`, a click on empty map (or on a graphics-layer
1500
+ * measure outside the group) closes the active transform. Clicks on preview
1501
+ * / handle features are consumed and keep it open.
1502
+ */
1503
+ closeOnClickAway?: boolean;
1504
+ /** Per-call interaction style override scoped to this transform. See ADR-0006. */
1505
+ interactionStyle?: InteractionStyle;
1506
+ /** Size anchor for the committed measures (ADR-0020), applied group-wide. Default `"ground"`. */
1507
+ sizeAnchor?: SizeAnchor;
1508
+ }
672
1509
  /**
673
1510
  * Draft control measure handed to `TacticalDraw.draw`. `kind` is required up
674
1511
  * front so the façade can resolve metadata, draw rule, and controller choice
@@ -689,9 +1526,13 @@ type DrawMeasureDraft<K extends ControlMeasureKind = ControlMeasureKind> = {
689
1526
  interface DrawOptions {
690
1527
  signal?: AbortSignal;
691
1528
  /**
692
- * Called synchronously once when a variable-length draw starts, before any
693
- * pointer event is processed. The session lets a host wire a Done/Cancel
694
- * toolbar to the active draw. Ignored for fixed-length kinds.
1529
+ * Called synchronously once when a draw starts, before any pointer event
1530
+ * is processed. The session lets a host wire a Done/Cancel toolbar to the
1531
+ * active draw and subscribe to live control-point / transient-geometry
1532
+ * feedback. Surfaced for both variable- and fixed-length kinds — a
1533
+ * fixed-length session auto-commits on the last required click, so its
1534
+ * main value is `onTransientChange` (e.g. a live radius readout) and
1535
+ * `abort()`.
695
1536
  */
696
1537
  onSession?: (session: DrawSession) => void;
697
1538
  /**
@@ -699,8 +1540,10 @@ interface DrawOptions {
699
1540
  * polyline alongside the kind's generator preview. For `geometry === "area"`
700
1541
  * kinds, a separate closing-segment `LineString` is also emitted once the
701
1542
  * total point count (cursor included) is ≥ 3. The guide is suppressed
702
- * mechanically when `geometry === "point"` or `minCoordinates === maxCoordinates`,
703
- * regardless of this flag. See ADR-0004.
1543
+ * mechanically when `geometry === "point"`, and for fixed-length kinds
1544
+ * (`minCoordinates === maxCoordinates` or `canonicalPointCount` set) unless
1545
+ * the kind's draw rule opts back in via `showGuide`, regardless of this
1546
+ * flag. See ADR-0004.
704
1547
  */
705
1548
  guide?: boolean;
706
1549
  /**
@@ -736,6 +1579,9 @@ declare class TacticalDraw {
736
1579
  private readonly graphicsStyle;
737
1580
  private readonly defaultInteractionStyle;
738
1581
  private readonly generateId;
1582
+ private readonly pointSymbols;
1583
+ private snappingOptions;
1584
+ private readonly resolveSnap;
739
1585
  private destroyed;
740
1586
  private hasRendered;
741
1587
  private readonly activeInteraction;
@@ -747,7 +1593,21 @@ declare class TacticalDraw {
747
1593
  */
748
1594
  private activeEdit;
749
1595
  /**
750
- * Host pick handlers registered via `td.onPick` / `td.onMeasurePick`. The
1596
+ * Tracked alongside `activeInteraction` whenever the interaction is a group
1597
+ * transform (`editMany`). The `render()` / view-change paths need every
1598
+ * member's id (to exclude all of them from reconcile) — mirrors `activeEdit`
1599
+ * but for `TransformHandle`'s `graphicIds` array.
1600
+ */
1601
+ private activeTransform;
1602
+ /**
1603
+ * The live `TransformSession`, exposed via `activeTransformSession`. Set
1604
+ * directly by `editMany`'s `onSession` wrapper (not routed through
1605
+ * `ActiveInteractionTracker.session`, whose type is `DrawSession | EditSession`)
1606
+ * and cleared on settle.
1607
+ */
1608
+ private activeTransformSessionRef;
1609
+ /**
1610
+ * Host pick handlers registered via `td.onPick` / `td.onGraphicPick`. The
751
1611
  * façade subscribes once to `adapter.onPick` per layer and routes events
752
1612
  * through this map so that close-then-pick ordering during an active edit can
753
1613
  * be enforced (issue #52).
@@ -764,6 +1624,7 @@ declare class TacticalDraw {
764
1624
  private deferredGraphicsPick;
765
1625
  private idCounter;
766
1626
  private editCounter;
1627
+ private transformCounter;
767
1628
  /**
768
1629
  * Snapshot of the host's last `render()` argument. The edit slice uses it
769
1630
  * to recompute the graphics-layer state during a lift / restore — the
@@ -771,24 +1632,45 @@ declare class TacticalDraw {
771
1632
  * surrounding collection state.
772
1633
  */
773
1634
  private lastRenderedMeasures;
1635
+ private snapGeometryFeatures;
774
1636
  constructor(adapter: MapAdapter, options?: TacticalDrawOptions);
1637
+ /** Replace snapping configuration without recreating the map adapter or façade. */
1638
+ setSnappingOptions(options?: SnappingOptions): void;
1639
+ /** Clear transient host affordances when snapping or its interaction ends. */
1640
+ private clearSnapResult;
775
1641
  /**
776
1642
  * Render `measures` into features and write them to the graphics layer.
777
1643
  * Routes every measure through [[renderMeasureForAdapter]] so pixel sizing is
778
1644
  * resolved against the live map. Callers own any edit-exclusion policy — see
779
1645
  * `render()` (excludes the edited measure, owned by the preview layer) versus
780
- * `restoreOriginalToGraphics` (no exclusion, the edit is ending).
1646
+ * `settleToGraphics` (no exclusion, the edit is ending).
781
1647
  */
782
1648
  private renderMeasuresToGraphics;
783
1649
  /**
784
1650
  * Re-render the graphics layer on a view (zoom) change so pixel-denominated
785
- * measures rescale live. Skipped entirely when nothing rendered carries a
786
- * resolution-dependent size — meter geometry is zoom-independent, so a rebuild
787
- * would be wasted layer writes. During an active edit the edited measure lives
788
- * on the preview layer (lifted from graphics), so it is excluded here exactly
789
- * as in `render()`; the preview's own view-change subscription rescales it.
1651
+ * and not-yet-committed label-sized measures rescale live. Skipped entirely
1652
+ * when nothing rendered still reads live view state — meter geometry with a
1653
+ * captured label size is zoom-independent, so a rebuild would be wasted
1654
+ * layer writes. During an active edit the edited measure lives on the
1655
+ * preview layer (lifted from graphics), so it is excluded here exactly as
1656
+ * in `render()`; the preview's own view-change subscription rescales it.
790
1657
  */
791
1658
  private readonly handleViewChange;
1659
+ /**
1660
+ * Every measure id currently lifted onto the preview layer by an active
1661
+ * edit or group transform — excluded from `render()` / view-change
1662
+ * reconciliation because the preview layer (not graphics) owns their
1663
+ * working copy. Empty while idle.
1664
+ */
1665
+ private excludedInteractionIds;
1666
+ /**
1667
+ * Live member ids of the active group transform, or `undefined` when none.
1668
+ * Falls back to the session ref because `onSession` fires from inside the
1669
+ * controller's attach, before `runWithSlot` has assigned `activeTransform` —
1670
+ * the session ref is registered first (`trackTransformSession`), so a
1671
+ * `render()` from inside `onSession` still sees live membership.
1672
+ */
1673
+ private transformMemberIds;
792
1674
  /**
793
1675
  * Reconcile the graphics layer against the authoritative list of measures.
794
1676
  * Idempotent. Throws synchronously on duplicate measure ids *before*
@@ -814,7 +1696,7 @@ declare class TacticalDraw {
814
1696
  * layers (the only edit-facing side effect, `notifyExternalMeasure`, is a
815
1697
  * pure style replay).
816
1698
  */
817
- render(measures: readonly ControlMeasure[]): void;
1699
+ render(measures: readonly Graphic[]): void;
818
1700
  /**
819
1701
  * Nested-call guard + preempt, shared by `draw` and `edit`. Returns a
820
1702
  * pre-rejected promise when called re-entrantly from inside another
@@ -842,24 +1724,108 @@ declare class TacticalDraw {
842
1724
  */
843
1725
  private trackSession;
844
1726
  /**
845
- * Start an interactive draw. Fixed-length kinds commit automatically on the
846
- * last required click. Variable-length kinds surface a `DrawSession` via
847
- * `opts.onSession` and commit on `session.commit()`, `dblclick`, or a second
848
- * click on the last added control point. All paths reject with
849
- * `TacticalDrawAbortError` on signal / Escape / destroy / `session.abort()`.
1727
+ * Wrap a host `onSession` callback for `editMany`, recording the session on
1728
+ * `activeTransformSessionRef` (exposed via `activeTransformSession`) before
1729
+ * the host's own handler runs. Separate from `trackSession` because
1730
+ * `ActiveInteractionTracker.session` is typed `DrawSession | EditSession`.
850
1731
  */
851
- draw<K extends ControlMeasureKind>(draft: DrawMeasureDraft<K>, options?: DrawOptions): Promise<ControlMeasureSnapshot<K>>;
1732
+ private trackTransformSession;
1733
+ /**
1734
+ * Start an interactive draw. Both fixed- and variable-length kinds surface
1735
+ * a `DrawSession` via `opts.onSession`. Fixed-length kinds commit
1736
+ * automatically on the last required click; variable-length kinds commit
1737
+ * on `session.commit()`, `dblclick`, or a second click on the last added
1738
+ * control point. All paths reject with `TacticalDrawAbortError` on
1739
+ * signal / Escape / destroy / `session.abort()`.
1740
+ *
1741
+ * `DrawSession.onCommit` is the single delivery channel for the committed
1742
+ * snapshot and `DrawSession.onSettled` is the lifecycle signal for when the
1743
+ * session is over and why (#197) — together they make the returned promise
1744
+ * non-load-bearing for hosts that adopt them. `DrawSession.onTransientChange`
1745
+ * streams live rubber-band geometry while the draw is in flight, for both
1746
+ * kinds of session.
1747
+ */
1748
+ draw(draft: DrawPointSymbolDraft, options?: DrawOptions): Promise<GraphicSnapshot<PointSymbol>>;
1749
+ draw<K extends ControlMeasureKind>(draft: DrawMeasureDraft<K>, options?: DrawOptions): Promise<GraphicSnapshot<ControlMeasure<K>>>;
852
1750
  /**
853
1751
  * Start an interactive edit on a single control measure. Returns a Promise
854
- * that resolves with a `ControlMeasureSnapshot` of the working state on
1752
+ * that resolves with a `GraphicSnapshot<ControlMeasure>` of the working state on
855
1753
  * `session.close()` and rejects with `TacticalDrawAbortError` on
856
1754
  * signal / Escape / destroy / preempt / `session.abort()`.
857
1755
  *
858
1756
  * Default click-away (`closeOnClickAway: true`) closes the edit on a click
859
1757
  * outside the preview / handle features. Hosts that own close policy pass
860
1758
  * `false`.
1759
+ *
1760
+ * `EditSession.onCommit` is the single delivery channel for the committed
1761
+ * snapshot and `EditSession.onSettled` is the lifecycle signal for when the
1762
+ * session is over and why (#197) — together they make the returned promise
1763
+ * non-load-bearing for hosts that adopt them.
861
1764
  */
862
- edit(measure: ControlMeasure, options?: EditOptions): Promise<ControlMeasureSnapshot>;
1765
+ edit(graphic: PointSymbol, options?: EditOptions): Promise<GraphicSnapshot<PointSymbol>>;
1766
+ edit(measure: ControlMeasure, options?: EditOptions): Promise<GraphicSnapshot<ControlMeasure>>;
1767
+ /**
1768
+ * Start an interactive group transform (multi-select) over `measures`
1769
+ * (ADR-0017 "Multi-select" slice). Every member is lifted onto the shared
1770
+ * preview layer; the box (corner scale, rotate grip, drag-anywhere body
1771
+ * translate) applies rigidly to all of them at once. Returns a Promise that
1772
+ * resolves with `GraphicSnapshot<ControlMeasure>[]` on `session.close()` and rejects
1773
+ * with `TacticalDrawAbortError` on signal / Escape / destroy / preempt /
1774
+ * `session.abort()`. Key the resolved snapshots by `snapshot.graphic.id`:
1775
+ * the member set is mutable (`session.setGraphics`, #187) so snapshot arrays
1776
+ * are id-keyed, not index-aligned with this input. The promise resolves with
1777
+ * the members in the set at close time; a member removed mid-session had its
1778
+ * exit snapshot returned from the `setGraphics` call that removed it.
1779
+ *
1780
+ * `measures.length` must be >= 1; a single-element array behaves like an
1781
+ * `edit` with only `transform` mode. Throws `TypeError` synchronously (before
1782
+ * any preemption) for an empty array or duplicate ids.
1783
+ *
1784
+ * For a selection that changes over time (shift-click toggle, marquee
1785
+ * replace), prefer [[syncTransformGraphics]] over hand-rolling the
1786
+ * live-session-vs-fresh-`editMany` branch, and subscribe to
1787
+ * `TransformSession.onCommit` as the single fold path for exit snapshots
1788
+ * and `TransformSession.onSettled` as the lifecycle signal for when the
1789
+ * session is over and why.
1790
+ */
1791
+ editMany(measures: ControlMeasure[], options?: EditManyOptions): Promise<GraphicSnapshot<ControlMeasure>[]>;
1792
+ editMany(graphics: Graphic[], options?: EditManyOptions): Promise<GraphicSnapshot[]>;
1793
+ /**
1794
+ * One-call selection-sync: mutate a live transform's member set in place, or
1795
+ * start a fresh `editMany` when none is live (#191). Any mix of control
1796
+ * measures and point symbols is handled by the one Graphic-typed session
1797
+ * (ADR-0032): a point symbol joins or leaves the live session exactly like a
1798
+ * measure via `setGraphics`, preserving box angle, size anchor, and history
1799
+ * baseline — no preempt-and-restart. A point symbol in the selection with no
1800
+ * point-symbol capability configured throws `PointSymbolCapabilityRequiredError`
1801
+ * synchronously (from `setGraphics` on a live session, or from `editMany` on a
1802
+ * fresh one).
1803
+ *
1804
+ * The facade knows session liveness synchronously — `activeTransformSessionRef`
1805
+ * is cleared inside the settle path, before control returns to host code — so
1806
+ * unlike a host's own `activeTransformSession ? setGraphics : editMany` check,
1807
+ * this can never observe a stale ref pointing at an already-settled session.
1808
+ * The settled-race fall-through hosts had to hand-roll for that gap is owned
1809
+ * here instead: a `setGraphics` call that (implausibly) still returns
1810
+ * `undefined` falls through to a fresh `editMany` rather than silently
1811
+ * dropping the selection.
1812
+ *
1813
+ * `graphics: []` closes any live session (committing every member) and is a
1814
+ * no-op while idle. With a live session, `options` is ignored — session-scoped
1815
+ * state (`sizeAnchor`, box angle, close-on-click-away policy) is sticky per
1816
+ * `setGraphics`'s contract; `options` applies only when this call starts a
1817
+ * fresh `editMany`. Exit snapshots are delivered via the session's
1818
+ * `onCommit` — subscribe once in `options.onSession`. Session lifecycle —
1819
+ * including an Escape / preempt / destroy abort the host did not itself
1820
+ * initiate — is observed via the session's `onSettled` (#194); together
1821
+ * `onCommit` and `onSettled` make the underlying `editMany` promise
1822
+ * irrelevant to hosts. That promise is owned by the facade; its abort
1823
+ * rejections are swallowed here (non-abort errors surface as unhandled
1824
+ * rejections). Throws `TypeError` synchronously on duplicate ids, from
1825
+ * either path. Starting a fresh transform preempts any other in-flight
1826
+ * draw/edit exactly like `editMany`.
1827
+ */
1828
+ syncTransformGraphics(graphics: readonly Graphic[], options?: EditManyOptions): void;
863
1829
  /**
864
1830
  * Subscribe to picks on committed control measures.
865
1831
  *
@@ -871,7 +1837,7 @@ declare class TacticalDraw {
871
1837
  * Returns an idempotent unsubscribe function. An optional `signal` removes
872
1838
  * the handler when aborted.
873
1839
  */
874
- onMeasurePick(handler: PickHandler, opts?: PickOptions): () => void;
1840
+ onGraphicPick(handler: PickHandler, opts?: PickOptions): () => void;
875
1841
  /**
876
1842
  * Subscribe to pick events on `layerId`. Routed through the façade so the
877
1843
  * close-then-pick contract (issue #52) is observable: when an active edit
@@ -883,6 +1849,47 @@ declare class TacticalDraw {
883
1849
  * Returns an unsubscribe function. Optional `signal` mirrors `adapter.onPick`.
884
1850
  */
885
1851
  onPick(layerId: LayerId, handler: PickHandler, opts?: PickOptions): () => void;
1852
+ /**
1853
+ * Stateless hit test answering "does TacticalDraw own the pixel at
1854
+ * `pixel`?" — for hosts composing their own click-away / deselection logic
1855
+ * (issue #216) alongside `TacticalDraw`'s draw/edit sessions. Today hosts
1856
+ * hack this together from the `@internal` `layerIds` plus engine-specific
1857
+ * layer queries; this is the supported replacement.
1858
+ *
1859
+ * Tests, topmost first, the three interactive layers — `handlesLayer`,
1860
+ * then `previewLayer`, then `graphicsLayer` — against each layer's current
1861
+ * `adapter.getLayerFeatures(...)` cache. The `guideLayer` is deliberately
1862
+ * excluded: it is non-interactive draw-guide scaffolding, never a
1863
+ * legitimate "owned" click target.
1864
+ *
1865
+ * `preview` and `graphics` each get a label text-extent fallback after the
1866
+ * geometry pass misses on that layer: for every `Point` feature carrying a
1867
+ * string `properties.text`, the anchor is projected and
1868
+ * `fitLabelBoxFromFeature` (shared with the edit controller's `pickLabel`,
1869
+ * see commit f908434) estimates the rendered text's box; a pixel inside
1870
+ * that box counts as owned even though it's off the anchor point itself.
1871
+ * This matters most for the active preview, since a click can land
1872
+ * anywhere across a rotated/offset label during an edit.
1873
+ *
1874
+ * `tolerance` defaults to `mapEventHitTolerance(options?.originalEvent)` —
1875
+ * pass the host's raw click event as `originalEvent` to get the same
1876
+ * pointer-type-aware tolerance (4px mouse/pen, 12px touch) `onPick` uses;
1877
+ * pass an explicit `tolerance` to override it outright.
1878
+ *
1879
+ * `measureId` is set when the hit feature resolves to a control-measure id
1880
+ * via `tryControlMeasureIdFromFeature` — present for graphics/preview
1881
+ * parts, generally absent for handles.
1882
+ *
1883
+ * This is a pure synchronous query over adapter-cached state: it does not
1884
+ * depend on `onPick`/`"click"` dispatch having run first, so it is safe to
1885
+ * call from any host click handler regardless of ordering relative to
1886
+ * TacticalDraw's own pick/click pipeline.
1887
+ */
1888
+ ownsInteractionAt(pixel: PixelCoordinate, options?: {
1889
+ tolerance?: number;
1890
+ originalEvent?: unknown;
1891
+ }): InteractionHit | null;
1892
+ private toInteractionHit;
886
1893
  /**
887
1894
  * Abort the in-flight draw or edit. The default `"session"` reason matches
888
1895
  * `DrawSession.abort()` / `EditSession.abort()`.
@@ -894,7 +1901,14 @@ declare class TacticalDraw {
894
1901
  * The live variable-draw or edit session, or `null` while idle and during
895
1902
  * fixed-length draws. Safe to read after `destroy()`.
896
1903
  */
897
- get activeSession(): DrawSession | EditSession | null;
1904
+ get activeSession(): DrawSession | EditSession | GraphicEditSession<PointSymbol> | null;
1905
+ /**
1906
+ * The live `editMany` group-transform session, or `null` while idle (and
1907
+ * during any other interaction). Separate from `activeSession` because
1908
+ * `TransformSession` isn't part of its `DrawSession | EditSession` union —
1909
+ * see `activeTransformSessionRef`. Safe to read after `destroy()`.
1910
+ */
1911
+ get activeTransformSession(): TransformSession | null;
898
1912
  /**
899
1913
  * Tear down the façade. Releases only layer slots the façade created; host
900
1914
  * supplied layer ids are left untouched. Safe to call multiple times. After
@@ -926,9 +1940,19 @@ declare class TacticalDraw {
926
1940
  private applyGuideStyle;
927
1941
  private baseGuideStyle;
928
1942
  /**
929
- * Resolve handle slots across built-in → façade → per-call with per-slot,
930
- * per-property shallow merge. Explicit `undefined` at a higher-priority
931
- * layer unsets the corresponding lower-priority field (ADR-0006).
1943
+ * Resolve handle slots (plus `boxOutline`) across built-in → façade →
1944
+ * per-call with per-slot, per-property shallow merge. Explicit `undefined`
1945
+ * at a higher-priority layer unsets the corresponding lower-priority field
1946
+ * (ADR-0006). `boxOutline` rides this same per-feature merge path (not the
1947
+ * `guide` layer-style path) — the edit controller reads it off
1948
+ * `properties.style` on the box outline `LineString` (ADR-0017).
1949
+ *
1950
+ * `boxPadding` is a scalar, not a mergeable style object, so it resolves
1951
+ * separately with the same façade → per-call precedence and the same
1952
+ * explicit-undefined-wins rule: a `boxPadding` key present at a
1953
+ * higher-priority layer (even set to `undefined`) wins over a lower layer's
1954
+ * value. There is no built-in `boxPadding` — an absent value at every layer
1955
+ * falls through to the controllers' own `?? 0` default.
932
1956
  *
933
1957
  * Returns `undefined` only when no slot would carry any value — keeps the
934
1958
  * downstream "no style on the feature" path intact for tests that assert
@@ -946,5 +1970,65 @@ declare class TacticalDraw {
946
1970
  declare class TacticalDrawDestroyedError extends Error {
947
1971
  constructor(message?: string);
948
1972
  }
1973
+ declare class PointSymbolCapabilityRequiredError extends Error {
1974
+ constructor();
1975
+ }
1976
+ //#endregion
1977
+ //#region src/tactical-draw/transform-box.d.ts
1978
+ /**
1979
+ * The Excalidraw/Felt-style transform box: the oriented bounding box drawn
1980
+ * around a selection, with 4 corner scale handles, a rotate grip above the
1981
+ * top edge, and drag-anywhere-inside-the-box translate. This module is the
1982
+ * single source of truth for box *placement* (`fitTransformBox`,
1983
+ * `boxCorners`, `rotateGripPixel`) AND box *picking* (`pointInBox`,
1984
+ * `pickBoxHandle`) — mirroring the ADR-0016 discipline that keeps
1985
+ * `handles.ts` owning vertex/midpoint placement/pick. Everything here is pure
1986
+ * pixel-space math: no projection, no rendering, no session state.
1987
+ *
1988
+ * **Angle convention**: `angle` is in radians, measured in screen pixel space
1989
+ * (y grows downward). A positive angle rotates the box clockwise as drawn on
1990
+ * screen — the same convention `rotatePointsAround` (`handle-drag.ts`) uses,
1991
+ * so box rotation and control-point rotation stay in lockstep.
1992
+ *
1993
+ * **Corner ordering**: `boxCorners` always returns
1994
+ * `[topLeft, topRight, bottomRight, bottomLeft]` in the box's own (unrotated)
1995
+ * local frame, then rotated into place. "Top" means the smaller-y edge before
1996
+ * rotation — i.e. the edge the rotate grip floats above. The order is stable
1997
+ * across calls so a scale handle's `index` (0–3) always means the same
1998
+ * corner, and its opposite corner is always `(index + 2) % 4`.
1999
+ */
2000
+ interface TransformBox {
2001
+ /** Box center, pixel space. */
2002
+ center: PixelCoordinate;
2003
+ /** Half-width along the box's local x-axis (pre-rotation), in pixels. */
2004
+ halfWidth: number;
2005
+ /** Half-height along the box's local y-axis (pre-rotation), in pixels. */
2006
+ halfHeight: number;
2007
+ /** Radians, screen space, positive = clockwise on screen. */
2008
+ angle: number;
2009
+ }
2010
+ //#endregion
2011
+ //#region src/tactical-draw/point-symbol-rendering.d.ts
2012
+ interface PointSymbolFootprint {
2013
+ box: TransformBox;
2014
+ corners: PixelCoordinate[];
2015
+ }
2016
+ declare function effectivePointSymbolPixelSize(symbol: PointSymbol, resolution: number | undefined): number;
2017
+ /** Complete rotated logical footprint for selection and transform chrome. */
2018
+ declare function pointSymbolFootprint(symbol: PointSymbol, render: PointSymbolRender, adapter: Pick<MapAdapter, "getBearing" | "getPixelFromCoordinate" | "getResolution">): PointSymbolFootprint | null;
2019
+ //#endregion
2020
+ //#region src/world-wrap.d.ts
2021
+ /**
2022
+ * Shift `lng` by whole world widths (360°) onto the copy nearest `refLng`.
2023
+ *
2024
+ * Map engines that wrap pointer longitudes into [-180, 180] (OpenLayers'
2025
+ * `toLonLat`, MapLibre's globe unproject) make positions jump ~360° when a
2026
+ * gesture crosses the antimeridian. Adapters use this to keep delivered
2027
+ * longitudes continuous with the view center, and to project coordinates on
2028
+ * the world copy the view is actually looking at.
2029
+ */
2030
+ declare function nearestWorldCopyLng(lng: number, refLng: number): number;
2031
+ /** `nearestWorldCopyLng` applied to a lon/lat position. */
2032
+ declare function nearestWorldCopyPosition(position: Position, refLng: number): Position;
949
2033
  //#endregion
950
- export { BaseMapAdapter, type CombinedAbort, type DrawMeasureDraft, type DrawOptions, type DrawSession, type EditChangeEvent, type EditMode, type EditOptions, type EditPointerDriver, type EditSession, type FeatureOps, type GuideStyle, type HandleStyle, type InteractionStyle, type LayerId, type MapAdapter, type MapCursor, type MapEvent, type MapEventHandler, type NativeEventEntry, type PickEvent, type PickHandler, type PickOptions, type PixelCoordinate, type PointerCallback, PointerCallbackHub, type PointerEventInfo, type SizeAnchor, type StyleOptions, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, type TacticalDrawAbortReason, TacticalDrawDestroyedError, type TacticalDrawLayerOptions, type TacticalDrawOptions, type TextStyleOptions, type ToPixel, bindAbortable, coerceHandleStyle, combineWithHostSignal, getSizeAnchor, hitTestFeature, ignoreAbort, isTacticalDrawAbortError, pickHitTolerance, tryControlMeasureIdFromFeature };
2034
+ export { BaseMapAdapter, type CombinedAbort, DEFAULT_LABEL_SIZE_PIXELS, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, type DrawMeasureDraft, type DrawOptions, type DrawPointSymbolDraft, type DrawSession, type EditChangeEvent, type EditManyOptions, type EditMode, type EditOptions, type EditPointerDriver, type EditSession, type FeatureOps, type Graphic, type GraphicEditChangeEvent, type GraphicEditSession, type GraphicGeometrySnappingOptions, type GraphicRender, type GraphicSnapshot, type GuideStyle, HATCH_PATTERNS, type HandleStyle, type HatchPattern, type IconResource, type InteractionHit, type InteractionStyle, type LayerId, type MapAdapter, type MapCursor, type MapEvent, type MapEventHandler, type NativeEventEntry, type PickEvent, type PickHandler, type PickOptions, type PixelCoordinate, type PointSymbol, type PointSymbolAnchor, type PointSymbolCapability, PointSymbolCapabilityRequiredError, type PointSymbolColors, type PointSymbolFootprint, type PointSymbolGraphicModifiers, type PointSymbolRender, type PointSymbolSize, type PointSymbolStyle, type PointerCallback, PointerCallbackHub, type PointerEventInfo, type SessionChangeSource, type SessionHistory, type SessionHistoryState, type SettleReason, type SizeAnchor, type SnapCandidate, type SnapCandidateProvider, type SnapCandidateRequest, type SnapInteraction, type SnapResult, type SnappingOptions, type StyleOptions, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, type TacticalDrawAbortReason, TacticalDrawDestroyedError, type TacticalDrawLayerOptions, type TacticalDrawOptions, type TextStyleOptions, type ToPixel, type TransformBox, type TransformChangeEvent, type TransformSession, type TransientChangeEvent, type TransientPhase, bindAbortable, clonePointSymbol, coerceHandleStyle, combineWithHostSignal, effectivePointSymbolPixelSize, fillPatternImageDimension, fillPatternImageId, getSizeAnchor, hitTestFeature, hitTestIconFeature, ignoreAbort, isPointSymbol, isTacticalDrawAbortError, makeFillPatternImage, nearestWorldCopyLng, nearestWorldCopyPosition, pickHitTolerance, pointSymbolFootprint, resizePointSymbolSize, resolveGroundTextSizePixels, tryControlMeasureIdFromFeature };