@orbat-mapper/tactical-draw 0.2.0-alpha.9 → 0.2.1

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,248 +1,8 @@
1
- import { ControlMeasure, ControlMeasureKind, ControlMeasureSnapshot, ControlMeasureStyle } from "@orbat-mapper/control-measures";
1
+ import { S as PointerEventInfo, _ as fillPatternImageDimension, a as MapAdapter, b as EditPointerDriver, c as MapEventHandler, d as PickOptions, f as PixelCoordinate, g as HatchPattern, h as HATCH_PATTERNS, i as LayerId, l as PickEvent, m as TextStyleOptions, n as HandleStyle, o as MapCursor, p as StyleOptions, r as IconResource, s as MapEvent, t as FeatureGroup, u as PickHandler, v as fillPatternImageId, x as PointerCallback, y as makeFillPatternImage } from "./map-adapter-84irgVJR.mjs";
2
+ import { AmplifierPlacement, AmplifierPlacements, ControlMeasure, ControlMeasureKind, ControlMeasureRender, ControlMeasureStyle, TextAmplifiers } from "@orbat-mapper/control-measures";
2
3
  import { Feature, Position } from "geojson";
3
4
 
4
- //#region src/gestures/types.d.ts
5
- interface PointerEventInfo {
6
- /** Per-event hit-tolerance override (e.g. larger for touch). */
7
- hitTolerance?: number;
8
- /**
9
- * Whether the Alt/Option modifier was held for this pointer event. The edit
10
- * controller reads this on pointer-down to turn a vertex hit into a delete
11
- * (the GIS-standard Alt+click-to-delete gesture) instead of a drag. Only the
12
- * mouse paths populate it; touch leaves it `undefined`.
13
- */
14
- altKey?: boolean;
15
- /** Engine-native event, for drivers that need to call preventDefault etc. */
16
- native?: unknown;
17
- }
18
- interface PointerCallback {
19
- (pixel: PixelCoordinate, info?: PointerEventInfo): void;
20
- }
21
- /**
22
- * Engine-agnostic pointer driver consumed by `TacticalDraw`'s edit controller.
23
- * The controller owns handle rendering and hit-tests handles in lonLat space;
24
- * the driver only carries raw pointer events, drag-pan toggle, and pixel
25
- * projection.
26
- */
27
- interface EditPointerDriver {
28
- onPointerDown(cb: PointerCallback): () => void;
29
- onPointerMove(cb: PointerCallback): () => void;
30
- onPointerUp(cb: PointerCallback): () => void;
31
- setDragPanEnabled(enabled: boolean): void;
32
- toPixel(lonLat: Position): PixelCoordinate | null;
33
- fromPixel(pixel: PixelCoordinate): Position;
34
- /** Idempotent. */
35
- dispose(): void;
36
- }
37
- //#endregion
38
- //#region src/map-adapter.d.ts
39
- type PixelCoordinate = [number, number];
40
- type MapCursor = "" | "default" | "crosshair" | "grab" | "grabbing" | "pointer";
41
- interface MapEvent {
42
- type: string;
43
- coordinate: Position;
44
- pixel: PixelCoordinate;
45
- originalEvent: unknown;
46
- }
47
- type MapEventHandler = (event: MapEvent) => void;
48
- interface StyleOptions {
49
- strokeColor?: string;
50
- strokeWidth?: number;
51
- strokeDash?: number[];
52
- fillColor?: string;
53
- pointFillColor?: string;
54
- pointRadius?: number;
55
- text?: TextStyleOptions;
56
- }
57
- interface TextStyleOptions {
58
- text?: string;
59
- labelProperty?: string;
60
- font?: string;
61
- fillColor?: string;
62
- strokeColor?: string;
63
- strokeWidth?: number;
64
- rotation?: number;
65
- }
66
- /**
67
- * Per-feature style for edit handles (vertex / midpoint / translate / rotate).
68
- * The producer (`TacticalDraw`) rides this on `properties.style`; the adapter
69
- * reads it back when rendering the handle, so it is part of the adapter
70
- * contract rather than the tactical-draw authoring surface. `pointRadius` is a
71
- * UI affordance not present on `ControlMeasureStyle`. See ADR-0006.
72
- */
73
- interface HandleStyle {
74
- strokeColor?: string;
75
- strokeWidth?: number;
76
- strokeDash?: readonly number[];
77
- fillColor?: string;
78
- pointRadius?: number;
79
- }
80
- type LayerId = string;
81
- /**
82
- * Click-style hit event delivered by {@link MapAdapter.onPick}.
83
- *
84
- * `id` is the control measure id, already split off the feature id by the
85
- * adapter (see `controlMeasureIdFromFeature`).
86
- *
87
- * `originalEvent` is whatever the engine surfaces — OpenLayers normalises to
88
- * `PointerEvent`, MapLibre delivers `MouseEvent`, Leaflet delivers
89
- * `MouseEvent | TouchEvent`. Do not narrow to `PointerEvent` at the consumer.
90
- */
91
- interface PickEvent {
92
- id: string;
93
- feature: Feature;
94
- pixel: PixelCoordinate;
95
- coordinate: Position;
96
- originalEvent: MouseEvent | PointerEvent | TouchEvent;
97
- }
98
- type PickHandler = (event: PickEvent) => void;
99
- interface PickOptions {
100
- signal?: AbortSignal;
101
- }
102
- interface MapAdapter {
103
- /**
104
- * Add a vector layer to the map.
105
- * @returns unique layer ID
106
- */
107
- addVectorLayer(options?: {
108
- style?: StyleOptions;
109
- }): LayerId;
110
- /**
111
- * Remove a layer from the map.
112
- */
113
- removeLayer(layerId: LayerId): void;
114
- /**
115
- * Set the style for a specific layer.
116
- */
117
- setLayerStyle(layerId: LayerId, style: StyleOptions): void;
118
- /**
119
- * Set features on a vector layer.
120
- * Features should be standard GeoJSON Features.
121
- */
122
- setLayerFeatures(layerId: LayerId, features: Feature[]): void;
123
- /**
124
- * Clear all features from a layer.
125
- */
126
- clearLayer(layerId: LayerId): void;
127
- /**
128
- * Reconcile features on a layer against the incoming set, keyed by `id`.
129
- *
130
- * Semantics:
131
- * - features with an `id` that already exists are updated in place;
132
- * - features with a fresh `id` are added;
133
- * - features in the layer whose `id` is missing from the incoming set are
134
- * removed.
135
- *
136
- * Features must carry a stable `id`. Use this when you can compute the
137
- * complete desired state of a layer in one go; for partial removals use
138
- * {@link removeFeatures}.
139
- */
140
- updateLayerFeatures(layerId: LayerId, features: readonly Feature[]): void;
141
- /**
142
- * Remove features by `id` from a layer. Ids that are not present are
143
- * silently ignored.
144
- */
145
- removeFeatures(layerId: LayerId, ids: readonly string[]): void;
146
- /**
147
- * Subscribe to click-style "pick" events for a specific layer. Basemap and
148
- * other-layer clicks never fire the handler.
149
- *
150
- * For control-measure features, adapters must set `PickEvent.id` to the
151
- * originating control-measure id (not the rendered part's feature id).
152
- * Features that do not satisfy the control-measure feature-id contract must
153
- * be ignored rather than dispatched.
154
- *
155
- * Bound to the engine's tap-suppressing click event (OL `singleclick`,
156
- * MapLibre `click`, Leaflet `click`) so the handler does not fire at the
157
- * end of a pan or pinch-zoom gesture.
158
- *
159
- * Hit tolerance is pointer-type aware: 4 px for mouse/pen, 12 px for touch.
160
- *
161
- * Returns an unsubscribe function. The returned function and the optional
162
- * `signal` are both honoured; either teardown path stops further events,
163
- * and both are idempotent.
164
- */
165
- onPick(layerId: LayerId, handler: PickHandler, opts?: PickOptions): () => void;
166
- /**
167
- * Map Event Listeners
168
- *
169
- * Ordering contract: a `"click"` handler and the `onPick` handlers for the
170
- * same user click MUST observe the pick first — adapters deliver picks and
171
- * `"click"` from the same underlying engine event, in registration order.
172
- * The draw/edit controllers register their pick subscriptions before their
173
- * click handler and read the stashed pick inside it (click-away close /
174
- * close-then-pick re-dispatch); an adapter that fires `"click"` from an
175
- * earlier engine event than its picks breaks that contract (e.g. OL
176
- * `click` vs `singleclick`).
177
- */
178
- on(eventType: "click" | "pointermove" | "dblclick", handler: MapEventHandler): void;
179
- off(eventType: "click" | "pointermove" | "dblclick", handler: MapEventHandler): void;
180
- /**
181
- * Projection / Coordinate Utilities
182
- */
183
- toLonLat(coordinate: number[]): Position;
184
- fromLonLat(position: Position): number[];
185
- getPixelFromCoordinate(position: Position): PixelCoordinate | null;
186
- /**
187
- * Get current viewport size in CSS pixels.
188
- */
189
- getViewportSize(): {
190
- width: number;
191
- height: number;
192
- };
193
- /**
194
- * Pan the map by pixel delta.
195
- * Positive delta means desired on-screen feature shift (right/down).
196
- */
197
- panByPixels(delta: PixelCoordinate, options?: {
198
- durationMs?: number;
199
- }): void;
200
- /**
201
- * Set the map viewport cursor. Pass an empty string to restore engine default styling.
202
- */
203
- setCursor(cursor: MapCursor): void;
204
- /**
205
- * Toggle the engine's native double-click-to-zoom gesture. `TacticalDraw`
206
- * disables this for the lifetime of a variable-length draw so the commit
207
- * `dblclick` doesn't also zoom the map.
208
- */
209
- setDoubleClickZoomEnabled(enabled: boolean): void;
210
- /**
211
- * Get current view resolution (meters per pixel at center or generally).
212
- * returns undefined if not supported or not ready.
213
- */
214
- getResolution(): number | undefined;
215
- /**
216
- * Get current map zoom level
217
- */
218
- getZoom(): number | undefined;
219
- /**
220
- * Listen for view changes (zoom/resolution)
221
- */
222
- onViewChange(handler: () => void): void;
223
- offViewChange(handler: () => void): void;
224
- /**
225
- * Construct an {@link EditPointerDriver} for `TacticalDraw`'s edit
226
- * controller. The controller subscribes to pointer events on the returned
227
- * driver to wire handle drags (vertex / midpoint / translate / rotate).
228
- */
229
- createEditPointerDriver(): EditPointerDriver;
230
- /**
231
- * Release all adapter-owned map resources. Adapters must not be used after
232
- * destruction.
233
- */
234
- destroy(): void;
235
- }
236
- //#endregion
237
5
  //#region src/base-map-adapter.d.ts
238
- interface FeatureOps {
239
- adds: Feature[];
240
- updates: Feature[];
241
- /** Features whose id was absent from the incoming set. */
242
- removes?: Feature[];
243
- full: Feature[];
244
- replace?: boolean;
245
- }
246
6
  type NormalizedEventHandler = (lonLat: Position, pixel: PixelCoordinate, originalEvent: unknown) => void;
247
7
  interface NativeEventEntry<TEventToken> {
248
8
  type: string;
@@ -252,10 +12,8 @@ interface NativeEventEntry<TEventToken> {
252
12
  * Abstract base class shared by all `MapAdapter` implementations.
253
13
  *
254
14
  * Owns shared bookkeeping (layer registry, feature cache, event-token
255
- * tracking) and orchestrates the public methods over a small
256
- * protected primitive surface that subclasses implement. See
257
- * `docs/adr/0001-map-adapter-base-class.md` for the rationale, in particular
258
- * around the `applyFeatureOps` primitive shape.
15
+ * tracking) and orchestrates the public methods over a small protected
16
+ * grouped desired-state primitive that subclasses implement.
259
17
  */
260
18
  declare abstract class BaseMapAdapter<TLayerHandle, TEventToken, TViewToken> implements MapAdapter {
261
19
  protected layers: Map<string, TLayerHandle>;
@@ -263,6 +21,9 @@ declare abstract class BaseMapAdapter<TLayerHandle, TEventToken, TViewToken> imp
263
21
  protected viewChangeTokens: Map<() => void, TViewToken>;
264
22
  protected featureCache: Map<string, Feature<import("geojson").Geometry, import("geojson").GeoJsonProperties>[]>;
265
23
  protected featuresChangedListeners: Map<string, Set<(features: Feature[]) => void>>;
24
+ protected iconResources: Map<string, IconResource>;
25
+ private featureGroupState;
26
+ private layerIconResourceIds;
266
27
  private layerCounter;
267
28
  private destroyed;
268
29
  protected nextLayerId(): LayerId;
@@ -270,12 +31,9 @@ declare abstract class BaseMapAdapter<TLayerHandle, TEventToken, TViewToken> imp
270
31
  style?: StyleOptions;
271
32
  }): LayerId;
272
33
  removeLayer(layerId: LayerId): void;
273
- setLayerFeatures(layerId: LayerId, features: readonly Feature[]): void;
274
- updateLayerFeatures(layerId: LayerId, features: readonly Feature[]): void;
275
- removeFeatures(layerId: LayerId, ids: readonly string[]): void;
34
+ setLayerFeatureGroups(layerId: LayerId, groups: readonly FeatureGroup[]): void;
276
35
  abstract onPick(layerId: LayerId, handler: PickHandler, opts?: PickOptions): () => void;
277
36
  abstract createEditPointerDriver(): EditPointerDriver;
278
- clearLayer(layerId: LayerId): void;
279
37
  on(eventType: "click" | "pointermove" | "dblclick", handler: MapEventHandler): void;
280
38
  off(eventType: "click" | "pointermove" | "dblclick", handler: MapEventHandler): void;
281
39
  onViewChange(handler: () => void): void;
@@ -284,9 +42,15 @@ declare abstract class BaseMapAdapter<TLayerHandle, TEventToken, TViewToken> imp
284
42
  protected emitLayerFeaturesChanged(layerId: LayerId, features: Feature[]): void;
285
43
  protected onLayerFeaturesChanged(layerId: LayerId, listener: (features: Feature[]) => void): () => void;
286
44
  protected destroyEngineResources(): void;
45
+ protected applyIconResources(resources: readonly IconResource[]): void;
46
+ protected getIconResource(id: string): IconResource | undefined;
47
+ protected getFeatureGroupOrder(layerId: LayerId, featureId: string | number | undefined): number | undefined;
48
+ protected removeIconResource(id: string): void;
49
+ private pruneIconResources;
287
50
  protected abstract createLayer(layerId: LayerId, style?: StyleOptions): TLayerHandle;
288
51
  protected abstract destroyLayer(handle: TLayerHandle): void;
289
- protected abstract applyFeatureOps(layerId: LayerId, ops: FeatureOps): void;
52
+ /** Apply one validated, complete grouped desired state. */
53
+ protected abstract applyFeatureGroups(layerId: LayerId, groups: readonly FeatureGroup[]): void;
290
54
  protected abstract attachNativeEvent(type: "click" | "pointermove" | "dblclick", onEvent: NormalizedEventHandler): NativeEventEntry<TEventToken>;
291
55
  protected abstract detachNativeEvent(entry: NativeEventEntry<TEventToken>): void;
292
56
  protected abstract attachViewChange(handler: () => void): TViewToken;
@@ -306,9 +70,17 @@ declare abstract class BaseMapAdapter<TLayerHandle, TEventToken, TViewToken> imp
306
70
  abstract setDoubleClickZoomEnabled(enabled: boolean): void;
307
71
  abstract getResolution(): number | undefined;
308
72
  abstract getZoom(): number | undefined;
73
+ /**
74
+ * Default for engines/views that don't rotate (Leaflet). Rotating engines
75
+ * (MapLibre, OpenLayers) override this.
76
+ */
77
+ getBearing(): number;
78
+ getLayerFeatures(layerId: LayerId): readonly Feature[];
309
79
  }
310
80
  //#endregion
311
81
  //#region src/pick-utils.d.ts
82
+ type PickPointerType = "mouse" | "pen" | "touch";
83
+ declare function pickPointerType(originalEvent: unknown): PickPointerType;
312
84
  declare function pickHitTolerance(originalEvent: unknown): number;
313
85
  declare function tryControlMeasureIdFromFeature(feature: Feature): string | null;
314
86
  /**
@@ -319,6 +91,126 @@ declare function tryControlMeasureIdFromFeature(feature: Feature): string | null
319
91
  */
320
92
  declare function bindAbortable(signal: AbortSignal | undefined, attach: () => () => void): () => void;
321
93
  //#endregion
94
+ //#region src/pick-candidates.d.ts
95
+ interface ScreenBounds {
96
+ minX: number;
97
+ minY: number;
98
+ maxX: number;
99
+ maxY: number;
100
+ }
101
+ interface RotatedScreenBounds {
102
+ center: PixelCoordinate;
103
+ halfWidth: number;
104
+ halfHeight: number;
105
+ angle: number;
106
+ }
107
+ type LabelExtent = ScreenBounds | RotatedScreenBounds;
108
+ interface ResolvedPickPresentation {
109
+ stroke?: {
110
+ color?: string;
111
+ width?: number;
112
+ dash?: readonly number[];
113
+ };
114
+ fill?: {
115
+ color?: string;
116
+ pattern?: string;
117
+ opacity: number;
118
+ };
119
+ point?: {
120
+ fillColor?: string;
121
+ radius?: number;
122
+ };
123
+ text?: TextStyleOptions;
124
+ }
125
+ type PickCandidateHitKind = "native" | "point" | "line" | "polygon-boundary" | "polygon-interior" | "icon" | "label";
126
+ interface PickCandidateHit {
127
+ kind: PickCandidateHitKind;
128
+ /** CSS-pixel distance to the visible mark, or null when only the engine knows the hit. */
129
+ distance: number | null;
130
+ }
131
+ /**
132
+ * Engine-neutral description of one rendered part eligible at a pointer location.
133
+ * This is deliberately richer than PickEvent but remains adapter-author plumbing:
134
+ * ordered candidates are not exposed by the public picking event.
135
+ */
136
+ interface PickCandidate {
137
+ feature: Feature;
138
+ graphicId: string | null;
139
+ renderedPartId: string | number | null;
140
+ hit: PickCandidateHit;
141
+ viewport: ScreenBounds;
142
+ presentation: ResolvedPickPresentation;
143
+ visualOrder: number;
144
+ /** Lower values mean a visually higher atomic feature group. */
145
+ featureGroupOrder?: number;
146
+ iconFootprint?: readonly PixelCoordinate[];
147
+ labelExtent?: LabelExtent;
148
+ }
149
+ /** A rendered part supplied by an adapter or TacticalDraw for shared evaluation. */
150
+ interface PickCandidateInput {
151
+ feature: Feature;
152
+ /**
153
+ * Lower values mean visually topmost. Suppliers must normalize their
154
+ * native ordering to topmost-first before calling into evaluation.
155
+ */
156
+ visualOrder: number;
157
+ /**
158
+ * Authoritative bottom-to-top group order normalized to lower = visually
159
+ * higher. Unlike `visualOrder`, native engine query order cannot override it.
160
+ */
161
+ featureGroupOrder?: number;
162
+ /** The engine already reported this part as hit; retained as a fallback for engine-only marks. */
163
+ nativeHit?: boolean;
164
+ icon?: IconResource;
165
+ labelExtent?: LabelExtent;
166
+ presentation?: ResolvedPickPresentation;
167
+ layerStyle?: StyleOptions;
168
+ }
169
+ /**
170
+ * The `visualOrder` for the feature at `index` of a render-ordered
171
+ * (bottom-first) list of `total` features: reversing the index makes the
172
+ * topmost-rendered feature the lowest, and therefore winning, order.
173
+ */
174
+ declare function renderOrderToVisualOrder(index: number, total: number): number;
175
+ interface MergedPickFeature {
176
+ feature: Feature;
177
+ visualOrder: number;
178
+ nativeHit: boolean;
179
+ }
180
+ /**
181
+ * Preserve native visual ordering/hit evidence while keeping cached source
182
+ * geometry authoritative. Native polygon geometry may be tile-clipped and
183
+ * therefore contain artificial edges that are not part of the graphic.
184
+ *
185
+ * Normalizes to the shared `visualOrder` contract (lower = visually
186
+ * topmost): native hits already arrive topmost-first from the engines and
187
+ * keep that order at the front. Cached-only features (not among the native
188
+ * hits) are appended after them, in reverse cache order — cache arrays are
189
+ * render order (bottom-first), so reversing yields topmost-first — with
190
+ * `visualOrder` numbering continuing on from the native section.
191
+ */
192
+ declare function mergeNativeAndCachedPickFeatures(nativeFeatures: readonly Feature[], cachedFeatures: readonly Feature[]): MergedPickFeature[];
193
+ interface PickEvaluationContext {
194
+ pixel: PixelCoordinate;
195
+ tolerance: number;
196
+ pointerType?: PickPointerType;
197
+ toPixel: (position: Position) => PixelCoordinate | null;
198
+ viewport: ScreenBounds;
199
+ bearing?: number;
200
+ }
201
+ /**
202
+ * Evaluate every relevant rendered part in screen space, then return the
203
+ * primary candidate under the visible-intent ranking policy: direct visible
204
+ * marks (strokes, points, icons, labels) outrank visibly filled interiors,
205
+ * which outrank inferred outline-only reach; competing direct marks rank by
206
+ * screen-space distance; visual topmost order is only the final tie-break.
207
+ * See `rankPickCandidates` for the full ordered list — that ordering stays
208
+ * internal adapter-author plumbing; the public pick event exposes only this
209
+ * primary candidate.
210
+ */
211
+ declare function evaluatePickCandidates(inputs: readonly PickCandidateInput[], context: PickEvaluationContext): PickCandidate | null;
212
+ declare function resolvePickPresentation(feature: Feature, layerStyle?: StyleOptions): ResolvedPickPresentation;
213
+ //#endregion
322
214
  //#region src/feature-style.d.ts
323
215
  /**
324
216
  * Coerce a raw `properties.style` value into a `HandleStyle` for the
@@ -326,6 +218,61 @@ declare function bindAbortable(signal: AbortSignal | undefined, attach: () => ()
326
218
  * when missing or shaped wrong so callers fall through to the layer style.
327
219
  */
328
220
  declare function coerceHandleStyle(raw: unknown): HandleStyle | undefined;
221
+ /** Default lower edge of the ground-anchored label clamp band (ADR-0028). */
222
+ declare const DEFAULT_TEXT_SIZE_MIN_PIXELS = 8;
223
+ /** Default upper edge of the ground-anchored label clamp band (ADR-0028). */
224
+ declare const DEFAULT_TEXT_SIZE_MAX_PIXELS = 24;
225
+ /**
226
+ * Rendered pixel size of a ground-anchored label (ADR-0028):
227
+ * `textSizeMeters / resolution`, clamped to the layer's text-style band.
228
+ * Within the band the text scales with its graphic; past either edge it pins
229
+ * to the bound, so zooming out never yields unreadable smudges and zooming in
230
+ * never yields screen-covering text. Shared by the adapters that resolve text
231
+ * size in code (OpenLayers, Leaflet); the MapLibre adapter evaluates the same
232
+ * clamp inside its per-zoom-stop style expression.
233
+ */
234
+ declare function resolveGroundTextSizePixels(textSizeMeters: number, resolution: number, text?: Pick<TextStyleOptions, "textSizeMinPixels" | "textSizeMaxPixels">): number;
235
+ /**
236
+ * The size properties a Text graphic's feature carries (see `text.ts` /
237
+ * `FeaturePartProps`). Mutually exclusive size anchors: `textSizePixels`
238
+ * (screen-anchored) or `textSizeMeters` (ground-anchored). `textMaxSizePixels`
239
+ * is the ADR-0037 hide threshold; the min/max band fields are the amplifier
240
+ * -label clamp bounds, applied only when no max-size threshold is present.
241
+ */
242
+ interface TextSizeProps {
243
+ textSizeMeters?: number;
244
+ textSizePixels?: number;
245
+ textMaxSizePixels?: number;
246
+ textSizeMinPixels?: number;
247
+ textSizeMaxPixels?: number;
248
+ }
249
+ /**
250
+ * Resolve a Text graphic's on-screen size in pixels, honoring the
251
+ * `textMaxSizePixels` hide threshold (ADR-0037). Returns `null` — the "hidden"
252
+ * signal — when the graphic should render no text this frame; adapters emit no
253
+ * text style for a `null`. Distinct from [[resolveGroundTextSizePixels]], which
254
+ * keeps the 8–24 px clamp band for amplifier labels: this helper is the Text
255
+ * graphic's own sizing rule.
256
+ *
257
+ * - screen-anchored (`textSizePixels`): the fixed pixel size, never scaled or
258
+ * hidden by zoom.
259
+ * - ground-anchored (`textSizeMeters`) with a `textMaxSizePixels` threshold:
260
+ * *unclamped* `meters / resolution` (the 8–24 band does NOT apply), hidden
261
+ * (`null`) once that exceeds the threshold — text shrinks freely zooming out
262
+ * and vanishes rather than clamps zooming in.
263
+ * - ground-anchored without a threshold: falls back to the clamped band.
264
+ * - no usable size or resolution: `null`.
265
+ */
266
+ declare function resolveTextSizePixels(props: TextSizeProps, resolution: number): number | null;
267
+ /** Fallback halo color when a `textHalo` feature carries no `textHaloColor`. */
268
+ declare const TEXT_HALO_COLOR = "#ffffff";
269
+ /**
270
+ * Outward depth (px) of a `textHalo` halo from the glyph edge, scaled to font
271
+ * size. This is the halo's *outward* reach, not a stroke width — a canvas
272
+ * stroke straddles the glyph edge (half in, half out), so a caller drawing
273
+ * one must double this value to get the same visible depth.
274
+ */
275
+ declare function resolveTextHaloWidth(textSizePixels?: number): number;
329
276
  //#endregion
330
277
  //#region src/gestures/hit-test.d.ts
331
278
  type ToPixel = (lonLat: Position) => PixelCoordinate | null;
@@ -340,7 +287,14 @@ type ToPixel = (lonLat: Position) => PixelCoordinate | null;
340
287
  *
341
288
  * Returns the first matching feature in iteration order, or null.
342
289
  */
343
- declare function hitTestFeature(pixel: PixelCoordinate, features: Feature[], hitTolerance: number, toPixel: ToPixel): Feature | null;
290
+ declare function hitTestFeature(pixel: PixelCoordinate, features: readonly Feature[], hitTolerance: number, toPixel: ToPixel): Feature | null;
291
+ /**
292
+ * Test a rendered icon-backed Point feature against its actual pixel footprint.
293
+ * The feature carries the adapter-neutral `iconScale` / `iconRotation` values
294
+ * emitted by TacticalDraw; `resource.anchor` is the placement anchor within the
295
+ * unscaled SVG viewport. A positive tolerance pads all four sides.
296
+ */
297
+ declare function hitTestIconFeature(pixel: PixelCoordinate, feature: Feature, resource: IconResource, hitTolerance: number, toPixel: ToPixel, bearing?: number): boolean;
344
298
  //#endregion
345
299
  //#region src/gestures/hit-tolerance.d.ts
346
300
  declare const TOUCH_HIT_TOLERANCE_PX = 22;
@@ -375,6 +329,104 @@ declare class PointerCallbackHub {
375
329
  clear(): void;
376
330
  }
377
331
  //#endregion
332
+ //#region src/point-symbol.d.ts
333
+ type PointSymbolSize = {
334
+ value: number;
335
+ unit: "pixels";
336
+ } | {
337
+ value: number;
338
+ unit: "meters"; /** Floor for the on-screen projection of the ground-based size; none when omitted. */
339
+ 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. */
340
+ maxPixels?: number;
341
+ };
342
+ interface PointSymbolGraphicModifiers {
343
+ /** Radians clockwise, relative to the point symbol's own orientation. */
344
+ direction?: number;
345
+ }
346
+ interface PointSymbolColors {
347
+ fill?: string;
348
+ frame?: string;
349
+ icon?: string;
350
+ info?: string;
351
+ outline?: string;
352
+ }
353
+ interface PointSymbolStyle {
354
+ colors?: PointSymbolColors;
355
+ monoColor?: string;
356
+ opacity?: number;
357
+ outlineWidth?: number;
358
+ fill?: boolean;
359
+ frame?: boolean;
360
+ icon?: boolean;
361
+ }
362
+ interface PointSymbol {
363
+ id: string;
364
+ kind: "point-symbol";
365
+ sidc: string;
366
+ position: Position;
367
+ /** Normalized radians clockwise from geographic north. */
368
+ rotation: number;
369
+ /** Milsymbol's octagon-based L dimension. */
370
+ size: PointSymbolSize;
371
+ textAmplifiers?: TextAmplifiers;
372
+ graphicModifiers?: PointSymbolGraphicModifiers;
373
+ style?: PointSymbolStyle;
374
+ rendererOptions?: {
375
+ milsymbol?: Record<string, unknown>;
376
+ };
377
+ properties?: Record<string, unknown>;
378
+ schemaVersion?: 1;
379
+ }
380
+ interface PointSymbolAnchor {
381
+ x: number;
382
+ y: number;
383
+ }
384
+ /** Canonical-size, renderer-neutral point-symbol output. */
385
+ interface PointSymbolRender {
386
+ /** Content-derived identity for adapter-wide native-resource reuse. */
387
+ resourceId: string;
388
+ svg: string;
389
+ /** Renderer-space octagon size used to interpret persisted symbol size. */
390
+ octagonSize: number;
391
+ width: number;
392
+ height: number;
393
+ anchor: PointSymbolAnchor;
394
+ octagonAnchor: PointSymbolAnchor;
395
+ valid: boolean;
396
+ validation?: unknown;
397
+ }
398
+ interface PointSymbolCapability {
399
+ readonly defaultSize: PointSymbolSize;
400
+ readonly defaultRotation: number;
401
+ render(symbol: PointSymbol): PointSymbolRender;
402
+ invalidate(): void;
403
+ /**
404
+ * Monotonic counter that increments on each `invalidate()` call. Consumers
405
+ * (e.g. a render cache keyed on graphic snapshot identity) may read this to
406
+ * detect that previously rendered point-symbol output is stale even though
407
+ * the graphic snapshot itself is unchanged. Optional — when absent,
408
+ * invalidation cannot be detected by cache layers, so they must not attempt
409
+ * to key on it.
410
+ */
411
+ readonly generation?: number;
412
+ }
413
+ declare function isPointSymbol(graphic: unknown): graphic is PointSymbol;
414
+ declare function clonePointSymbol(symbol: PointSymbol): PointSymbol;
415
+ /**
416
+ * Replace a point-symbol size value as a user-driven resize. Ground-size display
417
+ * bounds scale with the value so they continue to constrain zoom changes rather
418
+ * than preventing the resize itself.
419
+ */
420
+ declare function resizePointSymbolSize(size: PointSymbolSize, value: number): PointSymbolSize;
421
+ //#endregion
422
+ //#region src/graphic.d.ts
423
+ type Graphic = ControlMeasure | PointSymbol;
424
+ type GraphicRender = ControlMeasureRender | PointSymbolRender;
425
+ interface GraphicSnapshot<G extends Graphic = Graphic> {
426
+ graphic: G;
427
+ render: G extends PointSymbol ? PointSymbolRender : ControlMeasureRender;
428
+ }
429
+ //#endregion
378
430
  //#region src/tactical-draw/abort.d.ts
379
431
  /**
380
432
  * AbortPlumbing for the TacticalDraw façade.
@@ -425,6 +477,7 @@ interface CombinedAbort {
425
477
  declare function combineWithHostSignal(hostSignal?: AbortSignal): CombinedAbort;
426
478
  //#endregion
427
479
  //#region src/tactical-draw/measure-options.d.ts
480
+ declare const DEFAULT_LABEL_SIZE_PIXELS = 14;
428
481
  /**
429
482
  * Where a control measure's size is anchored (ADR-0020). `ground` (the
430
483
  * default) means a meter size fixed to the terrain — static, never re-rendered
@@ -466,9 +519,20 @@ interface InteractionStyle {
466
519
  guide?: GuideStyle;
467
520
  vertexHandle?: HandleStyle;
468
521
  midpointHandle?: HandleStyle;
522
+ /** Visible grab target for a draggable rendered text label. */
523
+ labelHandle?: HandleStyle;
469
524
  scaleHandle?: HandleStyle;
470
525
  rotateHandle?: HandleStyle;
471
526
  boxOutline?: GuideStyle;
527
+ /**
528
+ * Line style for the passive, non-interactive selection highlight rendered
529
+ * by {@link TacticalDraw.setHighlightedGraphics}. A dashed, muted variant of
530
+ * `boxOutline` so a locked/highlighted selection reads as visually distinct
531
+ * from an active edit's oriented transform box. Delivered per-feature via
532
+ * `properties.style` on the highlight outline `LineString`, resolved through
533
+ * the same built-in → façade → per-call slot system as every other field.
534
+ */
535
+ highlightOutline?: GuideStyle;
472
536
  /**
473
537
  * Pixel padding added on every side of the fitted transform box
474
538
  * (`fitTransformBox`, ADR-0017) so the box doesn't tightly hug the rendered
@@ -483,12 +547,68 @@ interface InteractionStyle {
483
547
  * `reshape` renders vertex/midpoint handles; `transform` renders the oriented
484
548
  * selection box (4 corner scale handles + a rotate grip + drag-anywhere
485
549
  * body translate) via `transform-box.ts`. The two can be combined freely —
486
- * single-measure edits show both by default. `delete` is an explicit
550
+ * single-measure edits show both by default. `labeldrag` enables dragging
551
+ * rendered text labels. `delete` is an explicit
487
552
  * destructive mode: it renders the vertex handles (so they can be tapped to
488
553
  * remove) but no midpoints or box, and the edit controller deletes the tapped
489
554
  * vertex instead of dragging it.
490
555
  */
491
- type EditMode = "reshape" | "transform" | "delete";
556
+ type EditMode = "reshape" | "transform" | "labeldrag" | "delete";
557
+ //#endregion
558
+ //#region src/tactical-draw/session-events.d.ts
559
+ /** A gesture's phase within a stream of {@link TransientChangeEvent}s. */
560
+ type TransientPhase = "start" | "move" | "end";
561
+ /**
562
+ * An in-flight mutation of a graphic's working geometry during a gesture —
563
+ * streamed continuously while the gesture runs (`EditSession.onTransientChange`
564
+ * / `DrawSession.onTransientChange`), distinct from the once-per-completed-
565
+ * gesture `onChange`. Hosts use it for live feedback, e.g. a radius readout
566
+ * while dragging a circle handle.
567
+ */
568
+ interface TransientChangeEvent {
569
+ /** Gesture phase. `end` fires exactly once per started gesture — on commit AND on cancel/abort. */
570
+ readonly phase: TransientPhase;
571
+ /** The full working control points at this instant (lon/lat). Fresh array per emission. */
572
+ readonly points: readonly Position[];
573
+ /**
574
+ * Index of the live point in `points` — the dragged vertex in edit, the
575
+ * rubber-band point in draw — or `null` when the whole geometry moves at
576
+ * once (box translate / rotate / scale).
577
+ */
578
+ readonly pointIndex: number | null;
579
+ }
580
+ /**
581
+ * The reason a session (`TransformSession`, `EditSession`, or `DrawSession`)
582
+ * settled, delivered to that session's `onSettled` handler (#191 / #194 /
583
+ * #197). `"close"` covers every commit path of the owning session —
584
+ * `TransformSession.close()` / `EditSession.close()` / `DrawSession.commit()`,
585
+ * a click-away close, a gesture commit (dblclick, a second click/tap on the
586
+ * finishing vertex, a click on another graphics-layer measure), or
587
+ * `TransformSession.setGraphics([])`. `"error"` is the commit-time snapshot
588
+ * build throwing (the outer promise rejects with that error, not a
589
+ * `TacticalDrawAbortError`). The remaining values replay
590
+ * `TacticalDrawAbortError.reason` verbatim: `"escape"`, `"signal"`,
591
+ * `"session"` (`session.abort()` / `event.abort()`), `"preempted"`,
592
+ * `"destroyed"`, and `"removed"` (edit / transform only — a measure dropped
593
+ * from `td.render()`'s measure set).
594
+ */
595
+ type SettleReason = "close" | "error" | TacticalDrawAbortReason;
596
+ //#endregion
597
+ //#region src/tactical-draw/session-history.d.ts
598
+ type SessionChangeSource = "gesture" | "undo" | "redo" | "programmatic";
599
+ interface SessionHistoryState {
600
+ readonly canUndo: boolean;
601
+ readonly canRedo: boolean;
602
+ readonly undoLabel?: string;
603
+ readonly redoLabel?: string;
604
+ }
605
+ interface SessionHistory {
606
+ /** Current availability and optional labels. Each session retains at most 100 completed steps. */
607
+ readonly state: SessionHistoryState;
608
+ undo(): boolean;
609
+ redo(): boolean;
610
+ subscribe(listener: (state: SessionHistoryState) => void): () => void;
611
+ }
492
612
  //#endregion
493
613
  //#region src/tactical-draw/edit-session.d.ts
494
614
  /**
@@ -497,17 +617,39 @@ type EditMode = "reshape" | "transform" | "delete";
497
617
  *
498
618
  * `measure` is the immutable edit-start input. `controlPoints` is live working
499
619
  * state. After the session settles (`close` / `abort`), every method is a
500
- * silent no-op.
620
+ * silent no-op (except [[onSettled]] — late registration still delivers,
621
+ * asynchronously).
501
622
  */
502
623
  interface EditSession {
624
+ /** Undo/redo for completed mutations within this live session. */
625
+ readonly history: SessionHistory;
503
626
  /** Immutable edit-start input. */
504
- readonly measure: ControlMeasure;
627
+ readonly graphic: ControlMeasure;
628
+ /**
629
+ * Live complete working graphic. Copy on read. `properties` (host-owned
630
+ * metadata, e.g. `standardIdentity`/`colorMode`) is session-owned like every
631
+ * other working field: it starts at the edit-start value, is replaced
632
+ * wholesale by [[updateGraphic]], survives every subsequent gesture/setter
633
+ * call and in-session undo/redo, and is rolled back by `event.reject()`
634
+ * (issue #354). Absent when the edit-start measure carried none and
635
+ * [[updateGraphic]] has not set any since.
636
+ */
637
+ readonly workingGraphic: ControlMeasure;
505
638
  /** Live working state. Copy on read. */
506
639
  readonly controlPoints: readonly Position[];
507
640
  /** Live working options. Copy on read. */
508
641
  readonly options: ControlMeasure["options"];
509
642
  /** Live working style. Copy on read. */
510
643
  readonly style: ControlMeasureStyle | undefined;
644
+ /**
645
+ * Live working text amplifiers (MIL-STD-2525E Appendix L Table L-II). Copy
646
+ * on read. `undefined` when the edit-start measure carried none and none
647
+ * have been set since. Keys are stored as given — canonicalisation to
648
+ * doctrinal field codes happens in the core render seam, not here.
649
+ */
650
+ readonly textAmplifiers: TextAmplifiers | undefined;
651
+ /** Live user-adjusted anchors for individually rendered text amplifiers. */
652
+ readonly amplifierPlacements: AmplifierPlacements | undefined;
511
653
  /** `true` after the first working-copy mutation; back to `false` within tolerance. */
512
654
  readonly dirty: boolean;
513
655
  readonly modes: readonly EditMode[];
@@ -518,12 +660,12 @@ interface EditSession {
518
660
  */
519
661
  readonly sizeAnchor: SizeAnchor;
520
662
  /**
521
- * Commit: returns the `ControlMeasureSnapshot` of the current working state
663
+ * Commit: returns the `GraphicSnapshot<ControlMeasure>` of the current working state
522
664
  * and resolves the outer `edit` promise with the same value (the same
523
665
  * reference — no double-bake of ground-anchored sizes). Returns `undefined`
524
666
  * after the session has settled (idempotent no-op).
525
667
  */
526
- close(): ControlMeasureSnapshot | undefined;
668
+ close(): GraphicSnapshot<ControlMeasure> | undefined;
527
669
  /** Reject the outer `edit` promise with reason `"session"`. */
528
670
  abort(): void;
529
671
  /**
@@ -534,6 +676,8 @@ interface EditSession {
534
676
  * is rejected with `TypeError`.
535
677
  */
536
678
  setModes(modes: readonly EditMode[]): void;
679
+ /** Atomically replace the live working graphic while preserving id and kind. */
680
+ updateGraphic(updater: (graphic: ControlMeasure) => ControlMeasure): void;
537
681
  /** Shallow-merge per-measure generator options into the live working copy. */
538
682
  setOptions(partial: ControlMeasure["options"]): void;
539
683
  /**
@@ -541,10 +685,36 @@ interface EditSession {
541
685
  * and on `close()`. A no-op for measures with no pixel-denominated size. The
542
686
  * in-flight preview is unaffected (it renders from the working options), so a
543
687
  * screen-locked symbol stays screen-locked until commit.
544
- */
545
- setSizeAnchor(anchor: SizeAnchor): void;
688
+ *
689
+ * `options` shallow-merges a size-option patch in the *same* mutation, as
690
+ * [[setOptions]] would. Changing a measure's size mode means moving its value
691
+ * between the pixel and meter halves of a size pair *and* re-anchoring, and
692
+ * the halves are only meaningful together: options carrying a pixel size
693
+ * under a `"ground"` anchor bake that size away on commit, so a host that
694
+ * emitted the two as separate mutations would leave an undo step parked on a
695
+ * state whose committed size mode contradicts its own options. Passed here
696
+ * they are one change event and one history entry, which undo cannot land
697
+ * inside.
698
+ */
699
+ setSizeAnchor(anchor: SizeAnchor, options?: ControlMeasure["options"]): void;
546
700
  /** Shallow-merge per-measure style into the live working copy. */
547
701
  setStyle(partial: ControlMeasureStyle): void;
702
+ /**
703
+ * Shallow-merge into the live working text amplifiers. A value of
704
+ * `undefined` or an empty/whitespace-only string deletes that key; the
705
+ * working record collapses back to `undefined` once it holds no keys. A
706
+ * silent no-op after the session has settled (like [[setOptions]]).
707
+ */
708
+ setTextAmplifiers(partial: Record<string, string | undefined>): void;
709
+ /**
710
+ * Merge field-keyed label overrides (position and/or rotation). The merge is
711
+ * **field-wise per key**: a bare `Position` value means `{ position }`;
712
+ * fields absent from the incoming value (e.g. `rotation` when only
713
+ * `position` is supplied) are preserved from the existing entry; an
714
+ * explicit `undefined` value deletes the key entirely — a full reset to the
715
+ * doctrinal placement (both position and rotation).
716
+ */
717
+ setAmplifierPlacements(partial: Record<string, AmplifierPlacement | undefined>): void;
548
718
  /**
549
719
  * Subscribe to working-copy changes. The listener receives an
550
720
  * [[EditChangeEvent]] for each completed gesture (drag end, insert/delete
@@ -557,6 +727,69 @@ interface EditSession {
557
727
  * have run.
558
728
  */
559
729
  onChange(listener: (event: EditChangeEvent) => void): () => void;
730
+ /**
731
+ * Subscribe to the committed snapshot — the single delivery channel for the
732
+ * `GraphicSnapshot<ControlMeasure>` this session ever commits (#197, mirroring
733
+ * `TransformSession.onCommit` #191). Fires exactly once, synchronously from
734
+ * within the [[close]] call that produced it (`session.close()`,
735
+ * `event.close()` from [[onChange]], a click-away close, or a click on
736
+ * another graphics-layer measure), with the same reference [[close]]
737
+ * returns and the outer `edit` promise resolves with. Never fires on abort.
738
+ *
739
+ * Listener exceptions are collected; after every listener has run, they are
740
+ * rethrown (one if single, `AggregateError` otherwise) — mirrors
741
+ * [[onChange]]. Returns an unsubscribe function; registering after the
742
+ * session has settled returns a no-op unsubscribe and the listener never
743
+ * fires.
744
+ */
745
+ onCommit(handler: (snapshot: GraphicSnapshot<ControlMeasure>) => void): () => void;
746
+ /**
747
+ * Subscribe to the session's settlement — the lifecycle complement of
748
+ * [[onCommit]]: `onCommit` delivers *what* was committed, `onSettled` says
749
+ * the session is *over* and why (#197, mirroring
750
+ * `TransformSession.onSettled` #194). Fires exactly once, when the session
751
+ * settles by any path, commit or abort, with a `SettleReason`.
752
+ *
753
+ * - On the commit path it fires **after** [[onCommit]]'s emission, so a
754
+ * listener that clears selection state observes a document that already
755
+ * contains the committed snapshot.
756
+ * - On every path it fires **after** the facade has already cleared
757
+ * `td.activeSession` — so fold-then-render works from inside the handler.
758
+ * - `reason` distinguishes commit from abort but is deliberately coarse —
759
+ * no payload; the snapshot was already delivered through [[onCommit]].
760
+ *
761
+ * Registering after the session has already settled is **not** a no-op
762
+ * (unlike [[onChange]] / [[onCommit]]): the handler is invoked
763
+ * asynchronously (on a microtask) exactly once with the settle reason;
764
+ * unsubscribing before that microtask runs cancels the delivery.
765
+ *
766
+ * Listener exceptions are collected; after every listener has run, they are
767
+ * rethrown (one if single, `AggregateError` otherwise) — mirrors
768
+ * [[onChange]] / [[onCommit]]. Returns an unsubscribe function.
769
+ */
770
+ onSettled(handler: (reason: SettleReason) => void): () => void;
771
+ /**
772
+ * Subscribe to the continuous stream of in-flight geometry mutations —
773
+ * vertex/midpoint drags and box scale/rotate/translate gestures — fired
774
+ * throughout the gesture rather than once at its end (contrast
775
+ * [[onChange]]). `start` fires at gesture begin, `move` on every pointer
776
+ * tick, and `end` fires exactly once when the gesture finishes by any
777
+ * path: commit, a click-slop no-op (a box body click that never becomes a
778
+ * drag), or session abort/teardown mid-drag. `end` fires **before** the
779
+ * gesture's [[onChange]] emission (and, on the commit path, before
780
+ * [[onCommit]]/[[onSettled]] too), so a listener observing both channels
781
+ * sees the transient stream close first.
782
+ *
783
+ * Label-placement drags (`labeldrag` mode) do not fire this — they move
784
+ * amplifier placements, not control points.
785
+ *
786
+ * Listener exceptions are collected; after every listener has run, they
787
+ * are rethrown (one if single, `AggregateError` otherwise) — mirrors
788
+ * [[onChange]]. Returns an unsubscribe function; registering after the
789
+ * session has settled returns a no-op unsubscribe and the listener never
790
+ * fires.
791
+ */
792
+ onTransientChange(listener: (event: TransientChangeEvent) => void): () => void;
560
793
  }
561
794
  /**
562
795
  * Delivered to `EditSession.onChange` listeners once per completed gesture.
@@ -567,10 +800,12 @@ interface EditSession {
567
800
  * observe partial state. See PRD #40 user-stories 23–24.
568
801
  */
569
802
  interface EditChangeEvent {
803
+ /** How this working-copy transition was initiated. */
804
+ readonly source: SessionChangeSource;
570
805
  /** Working state after the completed gesture. */
571
- readonly measure: ControlMeasureSnapshot;
806
+ readonly graphic: GraphicSnapshot<ControlMeasure>;
572
807
  /** State immediately before this gesture. */
573
- readonly previous: ControlMeasureSnapshot;
808
+ readonly previous: GraphicSnapshot<ControlMeasure>;
574
809
  /** The session the event belongs to. */
575
810
  readonly session: EditSession;
576
811
  /**
@@ -585,95 +820,157 @@ interface EditChangeEvent {
585
820
  abort(): void;
586
821
  }
587
822
  //#endregion
823
+ //#region src/tactical-draw/graphic-edit-session.d.ts
824
+ interface GraphicEditChangeEvent<G extends Graphic = Graphic> {
825
+ readonly source: SessionChangeSource;
826
+ readonly graphic: GraphicSnapshot<G>;
827
+ readonly previous: GraphicSnapshot<G>;
828
+ readonly session: GraphicEditSession<G>;
829
+ reject(): void;
830
+ close(): void;
831
+ abort(): void;
832
+ }
833
+ /** Common selection/editing contract shared by control measures and point symbols. */
834
+ interface GraphicEditSession<G extends Graphic = Graphic> {
835
+ readonly history: SessionHistory;
836
+ /** Immutable edit-start input. */
837
+ readonly graphic: G;
838
+ /** Live working graphic. Copy on read. */
839
+ readonly workingGraphic: G;
840
+ readonly dirty: boolean;
841
+ readonly modes: readonly EditMode[];
842
+ close(): GraphicSnapshot<G> | undefined;
843
+ abort(): void;
844
+ setModes(modes: readonly EditMode[]): void;
845
+ /** Atomically replace any part of the live working graphic. */
846
+ updateGraphic(updater: (graphic: G) => G): void;
847
+ onChange(listener: (event: GraphicEditChangeEvent<G>) => void): () => void;
848
+ onCommit(handler: (snapshot: GraphicSnapshot<G>) => void): () => void;
849
+ onSettled(handler: (reason: SettleReason) => void): () => void;
850
+ }
851
+ //#endregion
588
852
  //#region src/tactical-draw/transform-session.d.ts
589
853
  /**
590
- * Surface delivered via `td.editMany(measures, opts).onSession` and
854
+ * Surface delivered via `td.editMany(graphics, opts).onSession` and
591
855
  * synchronously available from `td.activeTransformSession` while the group
592
856
  * transform is live. The group sibling of `EditSession` (`edit-session.ts`):
593
857
  * no reshape, no per-measure `setOptions`/`setStyle` — only the shared
594
858
  * transform box's corner-scale / rotate / body-translate gestures, applied to
595
859
  * every member at once. See the Phase 3 ("Multi-select") slice of ADR-0017.
596
860
  *
597
- * The member set is mutable via [[setMembers]] (#187): members can join or
598
- * leave a live session without a session boundary. Because membership changes
599
- * over the session's lifetime, snapshot arrays are keyed by **measure id**, not
600
- * by index — a snapshot's identity is `snapshot.measure.id`. `measures` is a
601
- * live view of the current members' join-time inputs; a `TransformChangeEvent`'s
602
- * arrays are index-aligned with `session.measures` *as of that emission*. After
603
- * the session settles (`close` / `abort` / an emptying `setMembers`), every
604
- * method is a silent no-op.
861
+ * Typed on `Graphic` (ADR-0032): a member is either a `ControlMeasure` or a
862
+ * point symbol, and the two coexist in one session. Hosts narrow with
863
+ * `isPointSymbol`. `controlPointsFor` is a control-measure accessor and returns
864
+ * `undefined` for a point-symbol id.
865
+ *
866
+ * The member set is mutable via [[setGraphics]] (#187): members can join or
867
+ * leave a live session without a session boundary — point symbols exactly like
868
+ * measures. Because membership changes over the session's lifetime, snapshot
869
+ * arrays are keyed by **graphic id**, not by index — a snapshot's identity is
870
+ * `snapshot.graphic.id`. `graphics` is a live view of the current members'
871
+ * join-time inputs; a `TransformChangeEvent`'s arrays are index-aligned with
872
+ * `session.graphics` *as of that emission*. After the session settles (`close`
873
+ * / `abort` / an emptying `setGraphics`), every method is a silent no-op
874
+ * (except [[onSettled]] — late registration still delivers, asynchronously).
605
875
  */
606
876
  interface TransformSession {
877
+ /** Undo/redo for completed gestures within the current membership baseline. */
878
+ readonly history: SessionHistory;
607
879
  /**
608
- * Live view of the current members' join-time input measures — a fresh array
609
- * on each read, in current membership order. Grows/shrinks as [[setMembers]]
880
+ * Live view of the current members' join-time input graphics — a fresh array
881
+ * on each read, in current membership order. Grows/shrinks as [[setGraphics]]
610
882
  * adds or removes members. Not stable across membership changes: key
611
- * snapshots by `snapshot.measure.id` rather than relying on index alignment.
883
+ * snapshots by `snapshot.graphic.id` rather than relying on index alignment.
884
+ */
885
+ readonly graphics: readonly Graphic[];
886
+ /**
887
+ * Live view of the current members' working graphics — a fresh array on each
888
+ * read, in current membership order, reflecting every gesture applied so far.
889
+ * Measures carry their working control points (and any transform-frozen
890
+ * options); point symbols carry their working position, size value, and
891
+ * rotation. Copy on read.
612
892
  */
613
- readonly measures: readonly ControlMeasure[];
893
+ readonly workingGraphics: readonly Graphic[];
614
894
  /**
615
- * Live working control points for the member whose id is `measureId`, or
616
- * `undefined` when no member carries that id. Copy on read.
895
+ * Live working control points for the control-measure member whose id is
896
+ * `graphicId`, or `undefined` when no such member carries that id — including
897
+ * when `graphicId` names a point symbol (which has no control points; read
898
+ * its working `position` from [[workingGraphics]] instead). Copy on read.
617
899
  */
618
- controlPointsFor(measureId: string): readonly Position[] | undefined;
619
- /** `true` when any member's working points differ from its edit-start input beyond tolerance. */
900
+ controlPointsFor(graphicId: string): readonly Position[] | undefined;
901
+ /** `true` when any member's working state differs from its edit-start input beyond tolerance. */
620
902
  readonly dirty: boolean;
621
903
  /**
622
- * Live size anchor, applied group-wide (ADR-0020). `"ground"` bakes each
623
- * member's pixel size to meters on commit; `"screen"` keeps it zoom-aware.
904
+ * Live size anchor, applied group-wide to the **control-measure** members
905
+ * only (ADR-0020). `"ground"` bakes each measure's pixel size to meters on
906
+ * commit; `"screen"` keeps it zoom-aware. A point symbol's persisted
907
+ * pixel/meter size is its own anchor and is never rewritten by this setting.
624
908
  * Initialised from `EditManyOptions.sizeAnchor` and mutated by [[setSizeAnchor]].
625
909
  */
626
910
  readonly sizeAnchor: SizeAnchor;
627
- /** Switch the group's size anchor (ADR-0020). Takes effect on the next emitted snapshot and on `close()`. */
911
+ /**
912
+ * Switch the group's size anchor (ADR-0020) for its control-measure members.
913
+ * Takes effect on the next emitted snapshot and on `close()`. Point-symbol
914
+ * members are unaffected.
915
+ */
628
916
  setSizeAnchor(anchor: SizeAnchor): void;
629
917
  /**
630
918
  * Replace the member set in place — the primitive behind shift-click toggle,
631
919
  * marquee replace, and single-edit → group folding, without tearing the
632
- * session down (#187). Members are matched to the current set by measure id:
920
+ * session down (#187). Members (measures and point symbols alike) are matched
921
+ * to the current set by graphic id:
633
922
  *
634
923
  * - **Retained** members (id in both the current and next sets) carry their
635
- * current working state forward; the passed measure object is ignored for
924
+ * current working state forward; the passed graphic object is ignored for
636
925
  * them. Their `dirty` baseline stays their original join-time input, so a
637
926
  * session that has already moved things stays dirty across the call.
638
927
  * - **Removed** members (id in the current set, absent from the next) are
639
928
  * committed at their current working state.
640
929
  * - **Added** members (id only in the next set) join at their passed input
641
- * state; if currently on the graphics layer they are lifted off it exactly
642
- * like initial members.
930
+ * state and use their host-provided Graphic-stack slot when present.
643
931
  *
644
932
  * The transform box re-derives from the next member set at the session's
645
933
  * current box angle. Session-scoped state (`sizeAnchor`, box angle,
646
934
  * non-geometry dirty) is unchanged.
647
935
  *
936
+ * Calling this establishes a new session-history baseline: both undo and
937
+ * redo are cleared after retained members carry their working state
938
+ * forward. Membership changes and already-emitted exit snapshots are not
939
+ * undoable within the live session.
940
+ *
648
941
  * **Return value** — the committed exit snapshots of the members removed *by
649
- * this call*, built exactly like [[close]]'s snapshots (pixel size baked to
650
- * ground unless `sizeAnchor` is `"screen"`, then cloned and rendered); an
651
- * empty array when nothing was removed. The outer `editMany` promise resolves
652
- * with the snapshots of the members in the set **at close time only**.
653
- * Removed members are NOT written back to the graphics layer by the
654
- * controller — the host folds the returned snapshots into its document and
655
- * calls `td.render()`, the same contract as [[close]]. A removed member's
656
- * exit snapshot is also delivered through [[onCommit]] — the recommended
657
- * channel, since it covers this return value and the close-time promise
658
- * resolution through a single subscription. Returns `undefined` (a silent
659
- * no-op) after the session has settled.
942
+ * this call*, built exactly like [[close]]'s snapshots (a measure's pixel
943
+ * size baked to ground unless `sizeAnchor` is `"screen"`, a point symbol's
944
+ * size left untouched, then cloned and rendered); an empty array when nothing
945
+ * was removed. The outer `editMany` promise resolves with the snapshots of
946
+ * the members in the set **at close time only**. Removed members are NOT
947
+ * retained in its Graphic-stack slot until the host acknowledges it by
948
+ * folding the returned snapshot into its document and calling `td.render()`,
949
+ * the same contract as [[close]]. A removed member's exit snapshot is also
950
+ * delivered through [[onCommit]] — the recommended channel, since it covers
951
+ * this return value and the close-time promise resolution through a single
952
+ * subscription.
953
+ * Returns `undefined` (a silent no-op) after the session has settled.
660
954
  *
661
- * `setMembers([])` on a live session delegates to [[close]]: it commits every
955
+ * `setGraphics([])` on a live session delegates to [[close]]: it commits every
662
956
  * current member, resolves the promise with their snapshots, and returns that
663
- * same array. Duplicate ids in `measures` throw `TypeError` synchronously,
664
- * without mutating session state.
665
- */
666
- setMembers(measures: readonly ControlMeasure[]): ControlMeasureSnapshot[] | undefined;
667
- /**
668
- * Commit: returns the members' `ControlMeasureSnapshot[]` (the members in the
669
- * set at close time) and resolves the outer `editMany` promise with the same
670
- * value (the same reference — no double-bake of ground-anchored sizes). A
671
- * member removed mid-session via [[setMembers]] is not included here; its exit
672
- * snapshot was returned from that `setMembers` call (and delivered to
957
+ * same array. Duplicate ids in `graphics` throw `TypeError` synchronously,
958
+ * without mutating session state. A point-symbol member entering a session
959
+ * whose controller was created without a point-symbol capability throws
960
+ * `PointSymbolCapabilityRequiredError` synchronously, also without mutating
961
+ * session state.
962
+ */
963
+ setGraphics(graphics: readonly Graphic[]): GraphicSnapshot[] | undefined;
964
+ /**
965
+ * Commit: returns the members' `GraphicSnapshot[]` (the members in the set at
966
+ * close time) and resolves the outer `editMany` promise with the same value
967
+ * (the same reference — no double-bake of ground-anchored measure sizes). A
968
+ * member removed mid-session via [[setGraphics]] is not included here; its exit
969
+ * snapshot was returned from that `setGraphics` call (and delivered to
673
970
  * [[onCommit]] at that time). Returns `undefined` after the session has
674
971
  * settled (idempotent no-op).
675
972
  */
676
- close(): ControlMeasureSnapshot[] | undefined;
973
+ close(): GraphicSnapshot[] | undefined;
677
974
  /** Reject the outer `editMany` promise with reason `"session"`. */
678
975
  abort(): void;
679
976
  /**
@@ -691,21 +988,22 @@ interface TransformSession {
691
988
  onChange(listener: (event: TransformChangeEvent) => void): () => void;
692
989
  /**
693
990
  * Subscribe to committed exit-snapshot batches — the single delivery channel
694
- * for every `ControlMeasureSnapshot[]` this session ever commits (#191). Fires
991
+ * for every `GraphicSnapshot[]` this session ever commits (#191). Fires
695
992
  * exactly once per batch:
696
993
  *
697
- * - The members [[setMembers]] removes, but only when that call actually
994
+ * - The members [[setGraphics]] removes, but only when that call actually
698
995
  * removed at least one member.
699
- * - The close-time members on any commit path: [[close]], `setMembers([])`,
996
+ * - The close-time members on any commit path: [[close]], `setGraphics([])`,
700
997
  * a click-away close, or `TransformChangeEvent.close()`.
701
998
  *
702
- * Never fires on an abort/reject path. Fires synchronously from within the
703
- * `setMembers`/`close` call that produced the batch, before that call
999
+ * Never fires on an abort/reject path — pair it with [[onSettled]] to also
1000
+ * observe abort settles. Fires synchronously from within the
1001
+ * `setGraphics`/`close` call that produced the batch, before that call
704
1002
  * returns, and the array delivered here is the same reference that call
705
1003
  * returns (and, for the close batch, the same reference the `editMany`
706
1004
  * promise resolves with). This is the recommended single fold path for exit
707
1005
  * state — subscribe once in `onSession` and every committed snapshot, from
708
- * either delivery site, arrives here exactly once; the `setMembers` return
1006
+ * either delivery site, arrives here exactly once; the `setGraphics` return
709
1007
  * value and the promise resolution are unchanged and remain valid for
710
1008
  * backward compatibility.
711
1009
  *
@@ -715,30 +1013,57 @@ interface TransformSession {
715
1013
  * session has settled returns a no-op unsubscribe and the listener never
716
1014
  * fires.
717
1015
  */
718
- onCommit(handler: (snapshots: ControlMeasureSnapshot[]) => void): () => void;
1016
+ onCommit(handler: (snapshots: GraphicSnapshot[]) => void): () => void;
1017
+ /**
1018
+ * Subscribe to the session's settlement — the lifecycle complement of
1019
+ * [[onCommit]]: `onCommit` delivers *what* was committed, `onSettled` says
1020
+ * the session is *over* and why (#194). Fires exactly once, when the
1021
+ * session settles by any path, commit or abort, with a [[SettleReason]].
1022
+ *
1023
+ * - On commit paths it fires **after** the close batch's `onCommit`
1024
+ * emission, so a listener that clears selection state observes a document
1025
+ * that already contains the exit snapshots.
1026
+ * - On every path it fires **after** the facade has already cleared its
1027
+ * slot — `td.activeTransformSession` is `null`, so fold-then-render works
1028
+ * from inside the handler.
1029
+ * - `reason` distinguishes commit from abort but is deliberately coarse —
1030
+ * no payload; snapshots were already delivered through [[onCommit]].
1031
+ *
1032
+ * Registering after the session has already settled is **not** a no-op
1033
+ * (unlike [[onChange]] / [[onCommit]]): the handler is invoked
1034
+ * asynchronously (on a microtask) exactly once with the settle reason;
1035
+ * unsubscribing before that microtask runs cancels the delivery.
1036
+ *
1037
+ * Listener exceptions are collected; after every listener has run, they are
1038
+ * rethrown (one if single, `AggregateError` otherwise) — mirrors
1039
+ * [[onChange]] / [[onCommit]]. Returns an unsubscribe function.
1040
+ */
1041
+ onSettled(handler: (reason: SettleReason) => void): () => void;
719
1042
  }
720
1043
  /**
721
1044
  * Delivered to `TransformSession.onChange` listeners once per completed group
722
- * gesture. Both `measures` and `previous` are index-aligned with
723
- * `session.measures` *as of this emission*. Key by `measure.id` rather than
724
- * relying on index alignment across emissions. A `setMembers` call from within
1045
+ * gesture. Both `graphics` and `previous` are index-aligned with
1046
+ * `session.graphics` *as of this emission*. Key by `graphic.id` rather than
1047
+ * relying on index alignment across emissions. A `setGraphics` call from within
725
1048
  * a listener takes effect immediately and supersedes this emission's
726
1049
  * `reject()` (the rollback baseline no longer matches the member set);
727
1050
  * `close()` and `abort()` still apply, against the new membership. See
728
1051
  * `EditChangeEvent` for the identical emission contract this mirrors.
729
1052
  */
730
1053
  interface TransformChangeEvent {
731
- /** Working state after the completed gesture, index-aligned with `session.measures` as of this emission. */
732
- readonly measures: ControlMeasureSnapshot[];
733
- /** State immediately before this gesture, index-aligned with `session.measures` as of this emission. */
734
- readonly previous: ControlMeasureSnapshot[];
1054
+ /** How this working-copy transition was initiated. */
1055
+ readonly source: SessionChangeSource;
1056
+ /** Working state after the completed gesture, index-aligned with `session.graphics` as of this emission. */
1057
+ readonly graphics: GraphicSnapshot[];
1058
+ /** State immediately before this gesture, index-aligned with `session.graphics` as of this emission. */
1059
+ readonly previous: GraphicSnapshot[];
735
1060
  /** The session the event belongs to. */
736
1061
  readonly session: TransformSession;
737
1062
  /**
738
1063
  * Roll every member's working preview back to `previous` after all
739
1064
  * listeners have run. Multiple calls collapse to a single rollback.
740
1065
  * Overridden by `close()` or `abort()` if either is also called during this
741
- * emission, and dropped if a listener called `session.setMembers` (the
1066
+ * emission, and dropped if a listener called `session.setGraphics` (the
742
1067
  * rollback baseline no longer matches the member set).
743
1068
  */
744
1069
  reject(): void;
@@ -754,7 +1079,8 @@ interface TransformChangeEvent {
754
1079
  * kinds. Also available from `td.activeSession` while the draw is live.
755
1080
  *
756
1081
  * After the draw has settled — either via `commit()` returning `true` or
757
- * `abort()` resolving — every method is a silent no-op.
1082
+ * `abort()` resolving — every method is a silent no-op (except [[onSettled]]
1083
+ * — late registration still delivers, asynchronously).
758
1084
  */
759
1085
  interface DrawSession {
760
1086
  /** Live snapshot of the committed control points. Copy on read. */
@@ -776,8 +1102,117 @@ interface DrawSession {
776
1102
  * are collected and rethrown after all listeners run.
777
1103
  */
778
1104
  onChange(listener: (session: DrawSession) => void): () => void;
1105
+ /**
1106
+ * Subscribe to the committed snapshot — the single delivery channel for the
1107
+ * `GraphicSnapshot<ControlMeasure>` this session ever commits (#197, mirroring
1108
+ * `TransformSession.onCommit` #191). Fires exactly once, synchronously from
1109
+ * within the commit that produced it (`session.commit()`, dblclick, or a
1110
+ * second click/tap on the finishing vertex), with the same reference the
1111
+ * outer `draw` promise resolves with — the committed snapshot, not just the
1112
+ * measure. Never fires on abort.
1113
+ *
1114
+ * Listener exceptions are collected; after every listener has run, they are
1115
+ * rethrown (one if single, `AggregateError` otherwise) — mirrors
1116
+ * [[onChange]]. Returns an unsubscribe function; registering after the
1117
+ * session has settled returns a no-op unsubscribe and the listener never
1118
+ * fires.
1119
+ */
1120
+ onCommit(handler: (snapshot: GraphicSnapshot<ControlMeasure>) => void): () => void;
1121
+ /**
1122
+ * Subscribe to the session's settlement — the lifecycle complement of
1123
+ * [[onCommit]]: `onCommit` delivers *what* was committed, `onSettled` says
1124
+ * the session is *over* and why (#197, mirroring
1125
+ * `TransformSession.onSettled` #194). Fires exactly once, when the session
1126
+ * settles by any path, commit or abort, with a `SettleReason`.
1127
+ *
1128
+ * - On the commit path it fires **after** [[onCommit]]'s emission.
1129
+ * - On every path it fires **after** the facade has already cleared
1130
+ * `td.activeSession` — so fold-then-render works from inside the handler.
1131
+ * - `reason` distinguishes commit from abort but is deliberately coarse —
1132
+ * no payload; the snapshot was already delivered through [[onCommit]].
1133
+ *
1134
+ * Registering after the session has already settled is **not** a no-op
1135
+ * (unlike [[onChange]] / [[onCommit]]): the handler is invoked
1136
+ * asynchronously (on a microtask) exactly once with the settle reason;
1137
+ * unsubscribing before that microtask runs cancels the delivery.
1138
+ *
1139
+ * Listener exceptions are collected; after every listener has run, they are
1140
+ * rethrown (one if single, `AggregateError` otherwise) — mirrors
1141
+ * [[onChange]] / [[onCommit]]. Returns an unsubscribe function.
1142
+ */
1143
+ onSettled(handler: (reason: SettleReason) => void): () => void;
1144
+ /**
1145
+ * Subscribe to the continuous stream of rubber-band pointer ticks while the
1146
+ * draw is live — contrast [[onChange]], which fires once per committed
1147
+ * point. `start` fires on the first rubber-band tick, `move` on every
1148
+ * subsequent tick; a click that commits a point does not itself emit — the
1149
+ * next rubber-band tick after it does. `end` fires exactly once, when the
1150
+ * session settles (commit or abort).
1151
+ *
1152
+ * `points` is the committed points with the live rubber-band point spliced
1153
+ * in at its insertion index; `pointIndex` is that index.
1154
+ *
1155
+ * Listener exceptions are collected; after every listener has run, they
1156
+ * are rethrown (one if single, `AggregateError` otherwise) — mirrors
1157
+ * [[onChange]]. Returns an unsubscribe function; registering after the
1158
+ * session has settled returns a no-op unsubscribe and the listener never
1159
+ * fires.
1160
+ */
1161
+ onTransientChange(listener: (event: TransientChangeEvent) => void): () => void;
1162
+ }
1163
+ //#endregion
1164
+ //#region src/tactical-draw/snapping.d.ts
1165
+ type SnapInteraction = "draw" | "reshape" | "translate" | "label-drag";
1166
+ interface SnapCandidateRequest {
1167
+ coordinate: Position;
1168
+ pixel: PixelCoordinate;
1169
+ interaction: SnapInteraction;
1170
+ graphicId?: string;
1171
+ controlPointIndex?: number;
1172
+ originalEvent?: unknown;
1173
+ }
1174
+ interface SnapCandidate {
1175
+ id: string;
1176
+ coordinate: Position;
1177
+ kind: string;
1178
+ graphicId?: string;
1179
+ controlPointIndex?: number;
1180
+ priority?: number;
1181
+ }
1182
+ type SnapCandidateProvider = (request: SnapCandidateRequest) => readonly SnapCandidate[];
1183
+ interface SnapResult {
1184
+ coordinate: Position;
1185
+ pixel: PixelCoordinate;
1186
+ candidate: SnapCandidate;
1187
+ distancePixels: number;
1188
+ }
1189
+ interface GraphicGeometrySnappingOptions {
1190
+ /** Snap to the nearest point along generated linework and polygon boundaries. Default `true`. */
1191
+ edges?: boolean;
1192
+ /** Snap to generated geometry vertices. Default `false`. */
1193
+ vertices?: boolean;
1194
+ }
1195
+ interface SnappingOptions {
1196
+ enabled?: boolean;
1197
+ tolerancePixels?: number;
1198
+ sources?: {
1199
+ graphics?: boolean;
1200
+ /**
1201
+ * Snap to generated control-measure geometry. `true` enables edges with
1202
+ * vertices disabled; pass an object to select either candidate type.
1203
+ * Text/label point features and the active graphic are excluded.
1204
+ */
1205
+ graphicGeometry?: boolean | GraphicGeometrySnappingOptions;
1206
+ external?: SnapCandidateProvider;
1207
+ };
1208
+ bypass?: (originalEvent: unknown) => boolean;
1209
+ /** Called for each resolution attempt; `null` clears a host-rendered indicator. */
1210
+ onSnap?: (result: SnapResult | null) => void;
779
1211
  }
780
1212
  //#endregion
1213
+ //#region src/tactical-draw/point-symbol-draw-controller.d.ts
1214
+ type DrawPointSymbolDraft = Pick<PointSymbol, "kind" | "sidc"> & Partial<Omit<PointSymbol, "kind" | "sidc">>;
1215
+ //#endregion
781
1216
  //#region src/tactical-draw/tactical-draw.d.ts
782
1217
  /**
783
1218
  * Host-supplied layer slot ids. Any omitted slot is allocated by the façade and
@@ -785,12 +1220,32 @@ interface DrawSession {
785
1220
  */
786
1221
  interface TacticalDrawLayerOptions {
787
1222
  graphics?: LayerId;
1223
+ /**
1224
+ * Passive selection-highlight outlines ({@link TacticalDraw.setHighlightedGraphics}).
1225
+ * Resolved immediately above `graphics` so highlight boxes stack over base
1226
+ * graphics but below the preview / guide / handles interaction chrome.
1227
+ */
1228
+ highlight?: LayerId;
788
1229
  preview?: LayerId;
789
1230
  guide?: LayerId;
790
1231
  handles?: LayerId;
791
1232
  }
1233
+ /**
1234
+ * Result of {@link TacticalDraw.ownsInteractionAt} — which layer claimed the
1235
+ * pixel and the feature that matched.
1236
+ */
1237
+ interface InteractionHit {
1238
+ /** Which TacticalDraw layer owns the pixel — topmost match wins. */
1239
+ layer: "handles" | "preview" | "graphics";
1240
+ /** The hit feature (do not mutate). */
1241
+ feature: Feature;
1242
+ /** Control-measure id when the hit feature belongs to one (graphics/preview parts). */
1243
+ measureId?: string;
1244
+ }
792
1245
  interface TacticalDrawOptions {
793
1246
  layers?: TacticalDrawLayerOptions;
1247
+ /** Optional renderer capability for first-class point symbols (ADR-0029). */
1248
+ pointSymbols?: PointSymbolCapability;
794
1249
  /**
795
1250
  * Façade-level default style passed through to `renderControlMeasure` as the
796
1251
  * third (lowest priority) style layer per StyleResolver (#44). Shallow-merged
@@ -811,6 +1266,8 @@ interface TacticalDrawOptions {
811
1266
  * the draw API itself are deferred (PRD #40 user-story-5 note).
812
1267
  */
813
1268
  generateId?: () => string;
1269
+ /** Optional engine-level snapping for draw and edit interactions. */
1270
+ snapping?: SnappingOptions;
814
1271
  }
815
1272
  interface EditOptions {
816
1273
  signal?: AbortSignal;
@@ -818,7 +1275,7 @@ interface EditOptions {
818
1275
  * Called synchronously once when the edit starts, before any pointer event
819
1276
  * is processed. Lets a host wire commit / cancel buttons to the active edit.
820
1277
  */
821
- onSession?: (session: EditSession) => void;
1278
+ onSession?: (session: EditSession | GraphicEditSession<PointSymbol>) => void;
822
1279
  /**
823
1280
  * Default `true`. When `true`, a click on empty map (or on another graphics
824
1281
  * layer measure) closes the active edit. Clicks on preview / handle
@@ -831,7 +1288,8 @@ interface EditOptions {
831
1288
  * renders vertex / midpoint handles; `transform` renders the oriented
832
1289
  * selection box — 4 corner scale handles, a rotate grip above the top edge,
833
1290
  * and drag-anywhere-inside-the-box translate (ADR-0017). The two can be
834
- * combined freely (the default does). `"delete"` is exclusive: it collapses
1291
+ * combined freely (the default does). `"labeldrag"` opt-in enables repositioning
1292
+ * rendered text labels. `"delete"` is exclusive: it collapses
835
1293
  * the set to vertex-tap removal only. An unknown mode throws `TypeError`.
836
1294
  */
837
1295
  modes?: readonly EditMode[];
@@ -908,9 +1366,13 @@ type DrawMeasureDraft<K extends ControlMeasureKind = ControlMeasureKind> = {
908
1366
  interface DrawOptions {
909
1367
  signal?: AbortSignal;
910
1368
  /**
911
- * Called synchronously once when a variable-length draw starts, before any
912
- * pointer event is processed. The session lets a host wire a Done/Cancel
913
- * toolbar to the active draw. Ignored for fixed-length kinds.
1369
+ * Called synchronously once when a draw starts, before any pointer event
1370
+ * is processed. The session lets a host wire a Done/Cancel toolbar to the
1371
+ * active draw and subscribe to live control-point / transient-geometry
1372
+ * feedback. Surfaced for both variable- and fixed-length kinds — a
1373
+ * fixed-length session auto-commits on the last required click, so its
1374
+ * main value is `onTransientChange` (e.g. a live radius readout) and
1375
+ * `abort()`.
914
1376
  */
915
1377
  onSession?: (session: DrawSession) => void;
916
1378
  /**
@@ -950,6 +1412,7 @@ interface DrawOptions {
950
1412
  declare class TacticalDraw {
951
1413
  private readonly adapter;
952
1414
  private readonly graphicsLayer;
1415
+ private readonly highlightLayer;
953
1416
  private readonly previewLayer;
954
1417
  private readonly guideLayer;
955
1418
  private readonly handlesLayer;
@@ -957,6 +1420,10 @@ declare class TacticalDraw {
957
1420
  private readonly graphicsStyle;
958
1421
  private readonly defaultInteractionStyle;
959
1422
  private readonly generateId;
1423
+ private readonly pointSymbols;
1424
+ private snappingOptions;
1425
+ private readonly resolveSnap;
1426
+ private readonly graphicStack;
960
1427
  private destroyed;
961
1428
  private hasRendered;
962
1429
  private readonly activeInteraction;
@@ -971,7 +1438,7 @@ declare class TacticalDraw {
971
1438
  * Tracked alongside `activeInteraction` whenever the interaction is a group
972
1439
  * transform (`editMany`). The `render()` / view-change paths need every
973
1440
  * member's id (to exclude all of them from reconcile) — mirrors `activeEdit`
974
- * but for `TransformHandle`'s `measureIds` array.
1441
+ * but for `TransformHandle`'s `graphicIds` array.
975
1442
  */
976
1443
  private activeTransform;
977
1444
  /**
@@ -982,7 +1449,7 @@ declare class TacticalDraw {
982
1449
  */
983
1450
  private activeTransformSessionRef;
984
1451
  /**
985
- * Host pick handlers registered via `td.onPick` / `td.onMeasurePick`. The
1452
+ * Host pick handlers registered via `td.onPick` / `td.onGraphicPick`. The
986
1453
  * façade subscribes once to `adapter.onPick` per layer and routes events
987
1454
  * through this map so that close-then-pick ordering during an active edit can
988
1455
  * be enforced (issue #52).
@@ -1000,38 +1467,96 @@ declare class TacticalDraw {
1000
1467
  private idCounter;
1001
1468
  private editCounter;
1002
1469
  private transformCounter;
1470
+ /** Snapshot of the host's latest authoritative Graphic membership and order. */
1471
+ private lastRenderedMeasures;
1472
+ private snapGeometryFeatures;
1003
1473
  /**
1004
- * Snapshot of the host's last `render()` argument. The edit slice uses it
1005
- * to recompute the graphics-layer state during a lift / restore — the
1006
- * façade owns the policy because controllers shouldn't need to know the
1007
- * surrounding collection state.
1474
+ * Rendered graphics-layer groups, keyed on Graphic snapshot identity. See
1475
+ * {@link renderGraphicGroup} for the invalidation contract; swapped for a
1476
+ * fresh map (not cleared entry-by-entry) so stale entries are collectable.
1008
1477
  */
1009
- private lastRenderedMeasures;
1010
- constructor(adapter: MapAdapter, options?: TacticalDrawOptions);
1478
+ private graphicGroupCache;
1011
1479
  /**
1012
- * Render `measures` into features and write them to the graphics layer.
1013
- * Routes every measure through [[renderMeasureForAdapter]] so pixel sizing is
1014
- * resolved against the live map. Callers own any edit-exclusion policy — see
1015
- * `render()` (excludes the edited measure, owned by the preview layer) versus
1016
- * `restoreOriginalToGraphics` (no exclusion, the edit is ending).
1480
+ * Last {@link PointSymbolCapability.generation} observed from `pointSymbols`,
1481
+ * initialised from the capability at construction. Compared at the top of
1482
+ * every {@link reconcileGraphicsStack} so a host calling `invalidate()` after
1483
+ * changing renderer-global config (e.g. milsymbol options) drops the render
1484
+ * cache even though the Graphic snapshots handed to `render()` are
1485
+ * unchanged — otherwise the cache would keep serving pre-invalidation
1486
+ * output and the capability would never be re-consulted. `undefined` when
1487
+ * there is no capability, or the capability doesn't expose `generation`.
1017
1488
  */
1018
- private renderMeasuresToGraphics;
1489
+ private lastSeenPointSymbolGeneration;
1490
+ /**
1491
+ * Ids of graphics currently carrying a passive selection-highlight outline
1492
+ * ({@link setHighlightedGraphics}). Declarative set-replace, no session —
1493
+ * re-projected on every view change and re-evaluated at the end of every
1494
+ * `render()` so outlines track geometry, style, and dropped graphics.
1495
+ */
1496
+ private highlightedIds;
1497
+ /**
1498
+ * Whether the highlight layer currently holds any outline. Lets
1499
+ * `renderHighlights` skip a redundant empty write on every view change /
1500
+ * render while no highlights are set, but still emit the one clearing write
1501
+ * that removes outlines when the set transitions to empty.
1502
+ */
1503
+ private hasHighlightOutlines;
1504
+ constructor(adapter: MapAdapter, options?: TacticalDrawOptions);
1505
+ /** Replace snapping configuration without recreating the map adapter or façade. */
1506
+ setSnappingOptions(options?: SnappingOptions): void;
1507
+ /** Clear transient host affordances when snapping or its interaction ends. */
1508
+ private clearSnapResult;
1509
+ /**
1510
+ * Render one atomic Graphic group for the engine-neutral graphics stack,
1511
+ * reusing the previous render when nothing that feeds it has changed.
1512
+ *
1513
+ * The cache is keyed on the Graphic snapshot's object identity: hosts hand
1514
+ * `render()` a fresh object whenever a graphic's data changes, so a changed
1515
+ * graphic misses by construction. (A host that mutates a Graphic in place and
1516
+ * re-renders the same reference is outside the contract — `render()` has
1517
+ * always been documented as taking snapshots.) Rendered output also depends
1518
+ * on live view state and on the resolved graphics style; the style is fixed
1519
+ * at construction, and every view change drops the whole cache. Crucially,
1520
+ * the view cannot change during a box drag (drag-pan is disabled), so every
1521
+ * graphic outside the selection stays a cache hit for the whole gesture and
1522
+ * per-frame reconcile cost becomes O(selection) rather than O(document).
1523
+ */
1524
+ private renderGraphicGroup;
1525
+ /**
1526
+ * Render a Graphic group from scratch and stamp it with a fresh revision,
1527
+ * drawn from the module-scoped {@link nextGraphicGroupRevision} counter (see
1528
+ * its doc comment for why it isn't a per-instance field).
1529
+ */
1530
+ private buildGraphicGroup;
1531
+ /**
1532
+ * Drop every cached render. Called whenever an input shared by all graphic
1533
+ * renders changes — the live view state, or (via {@link reconcileGraphicsStack})
1534
+ * the point-symbol capability's `generation`.
1535
+ */
1536
+ private invalidateGraphicGroupCache;
1537
+ /** Submit one complete grouped desired state for the host stack plus overrides. */
1538
+ private reconcileGraphicsStack;
1539
+ /** Atomically publish several live renders into their host-owned Graphic slots. */
1540
+ private publishWorkingGraphics;
1541
+ /** Settle several stack overrides in one graphics-layer reconciliation. */
1542
+ private settleWorkingGraphics;
1019
1543
  /**
1020
1544
  * Re-render the graphics layer on a view (zoom) change so pixel-denominated
1021
- * measures rescale live. Skipped entirely when nothing rendered carries a
1022
- * resolution-dependent size — meter geometry is zoom-independent, so a rebuild
1023
- * would be wasted layer writes. During an active edit the edited measure lives
1024
- * on the preview layer (lifted from graphics), so it is excluded here exactly
1025
- * as in `render()`; the preview's own view-change subscription rescales it.
1545
+ * and not-yet-committed label-sized measures rescale live. Skipped entirely
1546
+ * when nothing rendered still reads live view state — meter geometry with a
1547
+ * captured label size is zoom-independent, so a rebuild would be wasted
1548
+ * layer writes. A single-control-measure edit publishes through the stack
1549
+ * coordinator; point symbols and transform members already present in the
1550
+ * host stack do the same. Graphics without a host slot remain on preview.
1026
1551
  */
1027
1552
  private readonly handleViewChange;
1028
1553
  /**
1029
- * Every measure id currently lifted onto the preview layer by an active
1030
- * edit or group transform — excluded from `render()` / view-change
1031
- * reconciliation because the preview layer (not graphics) owns their
1032
- * working copy. Empty while idle.
1554
+ * Every Graphic id owned by an active interaction. Used for highlight and
1555
+ * pick routing; not every such Graphic is excluded from stack reconciliation.
1033
1556
  */
1034
1557
  private excludedInteractionIds;
1558
+ /** Interaction ids still rendered on the legacy preview surface. */
1559
+ private graphicsStackExcludedIds;
1035
1560
  /**
1036
1561
  * Live member ids of the active group transform, or `undefined` when none.
1037
1562
  * Falls back to the session ref because `onSession` fires from inside the
@@ -1045,27 +1570,55 @@ declare class TacticalDraw {
1045
1570
  * Idempotent. Throws synchronously on duplicate measure ids *before*
1046
1571
  * mutating any layer. An empty list before any prior render is a no-op.
1047
1572
  *
1048
- * Cross-layer semantics with an active edit (issue #51):
1049
- * - The measure under edit is **excluded** from the reconcile pass — the
1050
- * preview layer owns its working copy and the lift on the graphics layer
1051
- * must stay in place.
1573
+ * Cross-layer semantics with an active edit:
1574
+ * - The stack coordinator replaces the edited Graphic's host-owned slot
1575
+ * with its working render; membership and order remain host-authoritative.
1052
1576
  * - If the edited measure is absent from `measures`, the pending `edit`
1053
1577
  * promise rejects on the **next microtask** with reason `"removed"`.
1054
1578
  * `render` itself returns normally so host collection ops stay synchronous.
1055
- * - If the edited measure is present and its `style` changed, the preview
1056
- * picks up the new style on this render. `controlPoints` changes on the
1579
+ * - If the edited measure is present and its `style` changed, its working
1580
+ * group picks up the new style on this render. `controlPoints` changes on the
1057
1581
  * edited measure are routed nowhere — the user's in-flight drag is never
1058
- * yanked out from under them.
1582
+ * yanked out from under them. A point symbol's complete working group remains
1583
+ * authoritative until settle, including any changed icon resource.
1059
1584
  *
1060
1585
  * Cross-layer semantics with an active draw: `render` only touches the
1061
1586
  * graphics layer, so the draw's preview is undisturbed.
1062
1587
  *
1063
1588
  * Reentrancy: calling `td.render(...)` from inside `EditSession.onChange` is
1064
- * safe — `render` never re-enters the active edit or its preview / handle
1065
- * layers (the only edit-facing side effect, `notifyExternalMeasure`, is a
1589
+ * safe — `render` never re-enters the active edit or its handle layer (the
1590
+ * only edit-facing side effect, `notifyExternalMeasure`, is a
1066
1591
  * pure style replay).
1067
1592
  */
1068
- render(measures: readonly ControlMeasure[]): void;
1593
+ render(measures: readonly Graphic[]): void;
1594
+ /**
1595
+ * Replace the passive selection-highlight set with `ids`. Declarative and
1596
+ * idempotent (mirrors {@link render}): a non-interactive outline box is drawn
1597
+ * around each still-rendered graphic on the dedicated highlight layer — no
1598
+ * draw / edit / transform session, no promise, no pick subscription. Passing
1599
+ * `[]` clears every highlight. Unknown ids and ids currently active in an
1600
+ * active edit / transform session are skipped (the live session's own visuals
1601
+ * supersede the passive outline). Hosts use it for Excalidraw-style
1602
+ * "locked selection" chrome.
1603
+ */
1604
+ setHighlightedGraphics(ids: readonly string[]): void;
1605
+ /**
1606
+ * Project the current {@link highlightedIds} into one outline `LineString`
1607
+ * per graphic and reconcile them onto the highlight layer. Idempotent and
1608
+ * grouped by graphic id, so desired-state replacement removes stale outlines
1609
+ * and an unchanged set is a cheap no-op write. Skips ids that are
1610
+ * unknown (not in the last render) or active in an interaction session, and
1611
+ * bails gracefully when the adapter cannot invert pixels to coordinates
1612
+ * (`getCoordinateFromPixel` is optional in the MapAdapter ABI).
1613
+ */
1614
+ private renderHighlights;
1615
+ /**
1616
+ * Fit the passive-highlight transform box for one graphic: the point-symbol
1617
+ * footprint for symbols, the snug text footprint for Text graphics, and the
1618
+ * projected-geometry bounding box (at the current bearing) for every other
1619
+ * control measure. Returns `null` when nothing projects this frame.
1620
+ */
1621
+ private highlightBoxForGraphic;
1069
1622
  /**
1070
1623
  * Nested-call guard + preempt, shared by `draw` and `edit`. Returns a
1071
1624
  * pre-rejected promise when called re-entrantly from inside another
@@ -1100,75 +1653,102 @@ declare class TacticalDraw {
1100
1653
  */
1101
1654
  private trackTransformSession;
1102
1655
  /**
1103
- * Start an interactive draw. Fixed-length kinds commit automatically on the
1104
- * last required click. Variable-length kinds surface a `DrawSession` via
1105
- * `opts.onSession` and commit on `session.commit()`, `dblclick`, or a second
1106
- * click on the last added control point. All paths reject with
1107
- * `TacticalDrawAbortError` on signal / Escape / destroy / `session.abort()`.
1656
+ * Start an interactive draw. Both fixed- and variable-length kinds surface
1657
+ * a `DrawSession` via `opts.onSession`. Fixed-length kinds commit
1658
+ * automatically on the last required click; variable-length kinds commit
1659
+ * on `session.commit()`, `dblclick`, or a second click on the last added
1660
+ * control point. All paths reject with `TacticalDrawAbortError` on
1661
+ * signal / Escape / destroy / `session.abort()`.
1662
+ *
1663
+ * `DrawSession.onCommit` is the single delivery channel for the committed
1664
+ * snapshot and `DrawSession.onSettled` is the lifecycle signal for when the
1665
+ * session is over and why (#197) — together they make the returned promise
1666
+ * non-load-bearing for hosts that adopt them. `DrawSession.onTransientChange`
1667
+ * streams live rubber-band geometry while the draw is in flight, for both
1668
+ * kinds of session.
1108
1669
  */
1109
- draw<K extends ControlMeasureKind>(draft: DrawMeasureDraft<K>, options?: DrawOptions): Promise<ControlMeasureSnapshot<K>>;
1670
+ draw(draft: DrawPointSymbolDraft, options?: DrawOptions): Promise<GraphicSnapshot<PointSymbol>>;
1671
+ draw<K extends ControlMeasureKind>(draft: DrawMeasureDraft<K>, options?: DrawOptions): Promise<GraphicSnapshot<ControlMeasure<K>>>;
1110
1672
  /**
1111
1673
  * Start an interactive edit on a single control measure. Returns a Promise
1112
- * that resolves with a `ControlMeasureSnapshot` of the working state on
1674
+ * that resolves with a `GraphicSnapshot<ControlMeasure>` of the working state on
1113
1675
  * `session.close()` and rejects with `TacticalDrawAbortError` on
1114
1676
  * signal / Escape / destroy / preempt / `session.abort()`.
1115
1677
  *
1116
1678
  * Default click-away (`closeOnClickAway: true`) closes the edit on a click
1117
1679
  * outside the preview / handle features. Hosts that own close policy pass
1118
1680
  * `false`.
1681
+ *
1682
+ * `EditSession.onCommit` is the single delivery channel for the committed
1683
+ * snapshot and `EditSession.onSettled` is the lifecycle signal for when the
1684
+ * session is over and why (#197) — together they make the returned promise
1685
+ * non-load-bearing for hosts that adopt them.
1119
1686
  */
1120
- edit(measure: ControlMeasure, options?: EditOptions): Promise<ControlMeasureSnapshot>;
1687
+ edit(graphic: PointSymbol, options?: EditOptions): Promise<GraphicSnapshot<PointSymbol>>;
1688
+ edit(measure: ControlMeasure, options?: EditOptions): Promise<GraphicSnapshot<ControlMeasure>>;
1121
1689
  /**
1122
1690
  * Start an interactive group transform (multi-select) over `measures`
1123
- * (ADR-0017 "Multi-select" slice). Every member is lifted onto the shared
1124
- * preview layer; the box (corner scale, rotate grip, drag-anywhere body
1125
- * translate) applies rigidly to all of them at once. Returns a Promise that
1126
- * resolves with `ControlMeasureSnapshot[]` on `session.close()` and rejects
1691
+ * (ADR-0017 "Multi-select" slice). Every host-provided member stays in its
1692
+ * Graphic-stack slot; only a member without a host slot uses preview. The box
1693
+ * (corner scale, rotate grip, drag-anywhere body translate) applies rigidly
1694
+ * to all of them at once. Returns a Promise that
1695
+ * resolves with `GraphicSnapshot<ControlMeasure>[]` on `session.close()` and rejects
1127
1696
  * with `TacticalDrawAbortError` on signal / Escape / destroy / preempt /
1128
- * `session.abort()`. Key the resolved snapshots by `snapshot.measure.id`:
1129
- * the member set is mutable (`session.setMembers`, #187) so snapshot arrays
1697
+ * `session.abort()`. Key the resolved snapshots by `snapshot.graphic.id`:
1698
+ * the member set is mutable (`session.setGraphics`, #187) so snapshot arrays
1130
1699
  * are id-keyed, not index-aligned with this input. The promise resolves with
1131
1700
  * the members in the set at close time; a member removed mid-session had its
1132
- * exit snapshot returned from the `setMembers` call that removed it.
1701
+ * exit snapshot returned from the `setGraphics` call that removed it.
1133
1702
  *
1134
1703
  * `measures.length` must be >= 1; a single-element array behaves like an
1135
1704
  * `edit` with only `transform` mode. Throws `TypeError` synchronously (before
1136
1705
  * any preemption) for an empty array or duplicate ids.
1137
1706
  *
1138
1707
  * For a selection that changes over time (shift-click toggle, marquee
1139
- * replace), prefer [[syncTransformMembers]] over hand-rolling the
1708
+ * replace), prefer [[syncTransformGraphics]] over hand-rolling the
1140
1709
  * live-session-vs-fresh-`editMany` branch, and subscribe to
1141
- * `TransformSession.onCommit` as the single fold path for exit snapshots.
1142
- */
1143
- editMany(measures: ControlMeasure[], options?: EditManyOptions): Promise<ControlMeasureSnapshot[]>;
1144
- /**
1145
- * One-call selection-sync: mutate the live group transform's member set in
1146
- * place, or start a fresh `editMany` when no transform is live (#191). This
1147
- * owns the "live session → `setMembers`, else → `editMany`" branch that every
1148
- * host otherwise hand-rolls against `activeTransformSession`.
1710
+ * `TransformSession.onCommit` as the single fold path for exit snapshots
1711
+ * and `TransformSession.onSettled` as the lifecycle signal for when the
1712
+ * session is over and why.
1713
+ */
1714
+ editMany(measures: ControlMeasure[], options?: EditManyOptions): Promise<GraphicSnapshot<ControlMeasure>[]>;
1715
+ editMany(graphics: Graphic[], options?: EditManyOptions): Promise<GraphicSnapshot[]>;
1716
+ /**
1717
+ * One-call selection-sync: mutate a live transform's member set in place, or
1718
+ * start a fresh `editMany` when none is live (#191). Any mix of control
1719
+ * measures and point symbols is handled by the one Graphic-typed session
1720
+ * (ADR-0032): a point symbol joins or leaves the live session exactly like a
1721
+ * measure via `setGraphics`, preserving box angle, size anchor, and history
1722
+ * baseline — no preempt-and-restart. A point symbol in the selection with no
1723
+ * point-symbol capability configured throws `PointSymbolCapabilityRequiredError`
1724
+ * synchronously (from `setGraphics` on a live session, or from `editMany` on a
1725
+ * fresh one).
1149
1726
  *
1150
1727
  * The facade knows session liveness synchronously — `activeTransformSessionRef`
1151
1728
  * is cleared inside the settle path, before control returns to host code — so
1152
- * unlike a host's own `activeTransformSession ? setMembers : editMany` check,
1729
+ * unlike a host's own `activeTransformSession ? setGraphics : editMany` check,
1153
1730
  * this can never observe a stale ref pointing at an already-settled session.
1154
1731
  * The settled-race fall-through hosts had to hand-roll for that gap is owned
1155
- * here instead: a `setMembers` call that (implausibly) still returns
1732
+ * here instead: a `setGraphics` call that (implausibly) still returns
1156
1733
  * `undefined` falls through to a fresh `editMany` rather than silently
1157
1734
  * dropping the selection.
1158
1735
  *
1159
- * `measures: []` closes any live session (committing every member) and is a
1736
+ * `graphics: []` closes any live session (committing every member) and is a
1160
1737
  * no-op while idle. With a live session, `options` is ignored — session-scoped
1161
1738
  * state (`sizeAnchor`, box angle, close-on-click-away policy) is sticky per
1162
- * `setMembers`'s contract; `options` applies only when this call starts a
1739
+ * `setGraphics`'s contract; `options` applies only when this call starts a
1163
1740
  * fresh `editMany`. Exit snapshots are delivered via the session's
1164
- * `onCommit` — subscribe once in `options.onSession`. The underlying
1165
- * `editMany` promise is owned by the facade; its abort rejections are
1166
- * swallowed here (non-abort errors surface as unhandled rejections). Throws
1167
- * `TypeError` synchronously on duplicate ids, from either path. Starting a
1168
- * fresh transform preempts any other in-flight draw/edit exactly like
1169
- * `editMany`.
1170
- */
1171
- syncTransformMembers(measures: readonly ControlMeasure[], options?: EditManyOptions): void;
1741
+ * `onCommit` — subscribe once in `options.onSession`. Session lifecycle —
1742
+ * including an Escape / preempt / destroy abort the host did not itself
1743
+ * initiate — is observed via the session's `onSettled` (#194); together
1744
+ * `onCommit` and `onSettled` make the underlying `editMany` promise
1745
+ * irrelevant to hosts. That promise is owned by the facade; its abort
1746
+ * rejections are swallowed here (non-abort errors surface as unhandled
1747
+ * rejections). Throws `TypeError` synchronously on duplicate ids, from
1748
+ * either path. Starting a fresh transform preempts any other in-flight
1749
+ * draw/edit exactly like `editMany`.
1750
+ */
1751
+ syncTransformGraphics(graphics: readonly Graphic[], options?: EditManyOptions): void;
1172
1752
  /**
1173
1753
  * Subscribe to picks on committed control measures.
1174
1754
  *
@@ -1180,7 +1760,7 @@ declare class TacticalDraw {
1180
1760
  * Returns an idempotent unsubscribe function. An optional `signal` removes
1181
1761
  * the handler when aborted.
1182
1762
  */
1183
- onMeasurePick(handler: PickHandler, opts?: PickOptions): () => void;
1763
+ onGraphicPick(handler: PickHandler, opts?: PickOptions): () => void;
1184
1764
  /**
1185
1765
  * Subscribe to pick events on `layerId`. Routed through the façade so the
1186
1766
  * close-then-pick contract (issue #52) is observable: when an active edit
@@ -1192,6 +1772,51 @@ declare class TacticalDraw {
1192
1772
  * Returns an unsubscribe function. Optional `signal` mirrors `adapter.onPick`.
1193
1773
  */
1194
1774
  onPick(layerId: LayerId, handler: PickHandler, opts?: PickOptions): () => void;
1775
+ /**
1776
+ * Stateless hit test answering "does TacticalDraw own the pixel at
1777
+ * `pixel`?" — for hosts composing their own click-away / deselection logic
1778
+ * (issue #216) alongside `TacticalDraw`'s draw/edit sessions. Today hosts
1779
+ * hack this together from the `@internal` `layerIds` plus engine-specific
1780
+ * layer queries; this is the supported replacement.
1781
+ *
1782
+ * Tests, topmost first, the three interactive layers — `handlesLayer`,
1783
+ * then `previewLayer`, then `graphicsLayer` — against each layer's current
1784
+ * `adapter.getLayerFeatures(...)` cache. The `guideLayer` is deliberately
1785
+ * excluded: it is non-interactive draw-guide scaffolding, never a
1786
+ * legitimate "owned" click target. Within each layer, candidates follow
1787
+ * the shared visible-intent ranking (direct marks, then filled interiors,
1788
+ * then inferred outline-only reach, distance within direct marks, then
1789
+ * visual topmost order) — this explicit-affordance layer order is the
1790
+ * tier above all of it.
1791
+ *
1792
+ * `preview` and `graphics` each get a label text-extent fallback after the
1793
+ * geometry pass misses on that layer: for every `Point` feature carrying a
1794
+ * string `properties.text`, the anchor is projected and
1795
+ * `fitLabelBoxFromFeature` (shared with the edit controller's `pickLabel`,
1796
+ * see commit f908434) estimates the rendered text's box; a pixel inside
1797
+ * that box counts as owned even though it's off the anchor point itself.
1798
+ * This matters most for the active preview, since a click can land
1799
+ * anywhere across a rotated/offset label during an edit.
1800
+ *
1801
+ * `tolerance` defaults to `pickHitTolerance(options?.originalEvent)` —
1802
+ * pass the host's raw click event as `originalEvent` to get the same
1803
+ * pointer-type-aware tolerance (4px mouse/pen, 12px touch) `onPick` uses;
1804
+ * pass an explicit `tolerance` to override it outright.
1805
+ *
1806
+ * `measureId` is set when the hit feature resolves to a control-measure id
1807
+ * via `tryControlMeasureIdFromFeature` — present for graphics/preview
1808
+ * parts, generally absent for handles.
1809
+ *
1810
+ * This is a pure synchronous query over adapter-cached state: it does not
1811
+ * depend on `onPick`/`"click"` dispatch having run first, so it is safe to
1812
+ * call from any host click handler regardless of ordering relative to
1813
+ * TacticalDraw's own pick/click pipeline.
1814
+ */
1815
+ ownsInteractionAt(pixel: PixelCoordinate, options?: {
1816
+ tolerance?: number;
1817
+ originalEvent?: unknown;
1818
+ }): InteractionHit | null;
1819
+ private toInteractionHit;
1195
1820
  /**
1196
1821
  * Abort the in-flight draw or edit. The default `"session"` reason matches
1197
1822
  * `DrawSession.abort()` / `EditSession.abort()`.
@@ -1203,7 +1828,7 @@ declare class TacticalDraw {
1203
1828
  * The live variable-draw or edit session, or `null` while idle and during
1204
1829
  * fixed-length draws. Safe to read after `destroy()`.
1205
1830
  */
1206
- get activeSession(): DrawSession | EditSession | null;
1831
+ get activeSession(): DrawSession | EditSession | GraphicEditSession<PointSymbol> | null;
1207
1832
  /**
1208
1833
  * The live `editMany` group-transform session, or `null` while idle (and
1209
1834
  * during any other interaction). Separate from `activeSession` because
@@ -1230,6 +1855,7 @@ declare class TacticalDraw {
1230
1855
  /** @internal — exposed for tests and future controllers. */
1231
1856
  get layerIds(): {
1232
1857
  graphics: LayerId;
1858
+ highlight: LayerId;
1233
1859
  preview: LayerId;
1234
1860
  guide: LayerId;
1235
1861
  handles: LayerId;
@@ -1241,6 +1867,7 @@ declare class TacticalDraw {
1241
1867
  */
1242
1868
  private applyGuideStyle;
1243
1869
  private baseGuideStyle;
1870
+ private baseHighlightStyle;
1244
1871
  /**
1245
1872
  * Resolve handle slots (plus `boxOutline`) across built-in → façade →
1246
1873
  * per-call with per-slot, per-property shallow merge. Explicit `undefined`
@@ -1272,6 +1899,9 @@ declare class TacticalDraw {
1272
1899
  declare class TacticalDrawDestroyedError extends Error {
1273
1900
  constructor(message?: string);
1274
1901
  }
1902
+ declare class PointSymbolCapabilityRequiredError extends Error {
1903
+ constructor();
1904
+ }
1275
1905
  //#endregion
1276
1906
  //#region src/tactical-draw/transform-box.d.ts
1277
1907
  /**
@@ -1307,6 +1937,15 @@ interface TransformBox {
1307
1937
  angle: number;
1308
1938
  }
1309
1939
  //#endregion
1940
+ //#region src/tactical-draw/point-symbol-rendering.d.ts
1941
+ interface PointSymbolFootprint {
1942
+ box: TransformBox;
1943
+ corners: PixelCoordinate[];
1944
+ }
1945
+ declare function effectivePointSymbolPixelSize(symbol: PointSymbol, resolution: number | undefined): number;
1946
+ /** Complete rotated logical footprint for selection and transform chrome. */
1947
+ declare function pointSymbolFootprint(symbol: PointSymbol, render: PointSymbolRender, adapter: Pick<MapAdapter, "getBearing" | "getPixelFromCoordinate" | "getResolution">): PointSymbolFootprint | null;
1948
+ //#endregion
1310
1949
  //#region src/world-wrap.d.ts
1311
1950
  /**
1312
1951
  * Shift `lng` by whole world widths (360°) onto the copy nearest `refLng`.
@@ -1321,4 +1960,4 @@ declare function nearestWorldCopyLng(lng: number, refLng: number): number;
1321
1960
  /** `nearestWorldCopyLng` applied to a lon/lat position. */
1322
1961
  declare function nearestWorldCopyPosition(position: Position, refLng: number): Position;
1323
1962
  //#endregion
1324
- export { BaseMapAdapter, type CombinedAbort, type DrawMeasureDraft, type DrawOptions, type DrawSession, type EditChangeEvent, type EditManyOptions, 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, type TransformBox, type TransformChangeEvent, type TransformSession, bindAbortable, coerceHandleStyle, combineWithHostSignal, getSizeAnchor, hitTestFeature, ignoreAbort, isTacticalDrawAbortError, nearestWorldCopyLng, nearestWorldCopyPosition, pickHitTolerance, tryControlMeasureIdFromFeature };
1963
+ 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 FeatureGroup, 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 LabelExtent, type LayerId, type MapAdapter, type MapCursor, type MapEvent, type MapEventHandler, type MergedPickFeature, type NativeEventEntry, type PickCandidate, type PickCandidateHit, type PickCandidateHitKind, type PickCandidateInput, type PickEvaluationContext, type PickEvent, type PickHandler, type PickOptions, type PickPointerType, 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 ResolvedPickPresentation, type RotatedScreenBounds, type ScreenBounds, type SessionChangeSource, type SessionHistory, type SessionHistoryState, type SettleReason, type SizeAnchor, type SnapCandidate, type SnapCandidateProvider, type SnapCandidateRequest, type SnapInteraction, type SnapResult, type SnappingOptions, type StyleOptions, TEXT_HALO_COLOR, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, type TacticalDrawAbortReason, TacticalDrawDestroyedError, type TacticalDrawLayerOptions, type TacticalDrawOptions, type TextSizeProps, type TextStyleOptions, type ToPixel, type TransformBox, type TransformChangeEvent, type TransformSession, type TransientChangeEvent, type TransientPhase, bindAbortable, clonePointSymbol, coerceHandleStyle, combineWithHostSignal, effectivePointSymbolPixelSize, evaluatePickCandidates, fillPatternImageDimension, fillPatternImageId, getSizeAnchor, hitTestFeature, hitTestIconFeature, ignoreAbort, isPointSymbol, isTacticalDrawAbortError, makeFillPatternImage, mergeNativeAndCachedPickFeatures, nearestWorldCopyLng, nearestWorldCopyPosition, pickHitTolerance, pickPointerType, pointSymbolFootprint, renderOrderToVisualOrder, resizePointSymbolSize, resolveGroundTextSizePixels, resolvePickPresentation, resolveTextHaloWidth, resolveTextSizePixels, tryControlMeasureIdFromFeature };