@orbat-mapper/tactical-draw 0.2.0-alpha.0

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.
@@ -0,0 +1,950 @@
1
+ import { ControlMeasure, ControlMeasureKind, ControlMeasureSnapshot, ControlMeasureStyle } from "@orbat-mapper/control-measures";
2
+ import { Feature, Position } from "geojson";
3
+
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
+ on(eventType: "click" | "pointermove" | "dblclick", handler: MapEventHandler): void;
170
+ off(eventType: "click" | "pointermove" | "dblclick", handler: MapEventHandler): void;
171
+ /**
172
+ * Projection / Coordinate Utilities
173
+ */
174
+ toLonLat(coordinate: number[]): Position;
175
+ fromLonLat(position: Position): number[];
176
+ getPixelFromCoordinate(position: Position): PixelCoordinate | null;
177
+ /**
178
+ * Get current viewport size in CSS pixels.
179
+ */
180
+ getViewportSize(): {
181
+ width: number;
182
+ height: number;
183
+ };
184
+ /**
185
+ * Pan the map by pixel delta.
186
+ * Positive delta means desired on-screen feature shift (right/down).
187
+ */
188
+ panByPixels(delta: PixelCoordinate, options?: {
189
+ durationMs?: number;
190
+ }): void;
191
+ /**
192
+ * Set the map viewport cursor. Pass an empty string to restore engine default styling.
193
+ */
194
+ setCursor(cursor: MapCursor): void;
195
+ /**
196
+ * Toggle the engine's native double-click-to-zoom gesture. `TacticalDraw`
197
+ * disables this for the lifetime of a variable-length draw so the commit
198
+ * `dblclick` doesn't also zoom the map.
199
+ */
200
+ setDoubleClickZoomEnabled(enabled: boolean): void;
201
+ /**
202
+ * Get current view resolution (meters per pixel at center or generally).
203
+ * returns undefined if not supported or not ready.
204
+ */
205
+ getResolution(): number | undefined;
206
+ /**
207
+ * Get current map zoom level
208
+ */
209
+ getZoom(): number | undefined;
210
+ /**
211
+ * Listen for view changes (zoom/resolution)
212
+ */
213
+ onViewChange(handler: () => void): void;
214
+ offViewChange(handler: () => void): void;
215
+ /**
216
+ * Construct an {@link EditPointerDriver} for `TacticalDraw`'s edit
217
+ * controller. The controller subscribes to pointer events on the returned
218
+ * driver to wire handle drags (vertex / midpoint / translate / rotate).
219
+ */
220
+ createEditPointerDriver(): EditPointerDriver;
221
+ /**
222
+ * Release all adapter-owned map resources. Adapters must not be used after
223
+ * destruction.
224
+ */
225
+ destroy(): void;
226
+ }
227
+ //#endregion
228
+ //#region src/base-map-adapter.d.ts
229
+ interface FeatureOps {
230
+ adds: Feature[];
231
+ updates: Feature[];
232
+ /** Features whose id was absent from the incoming set. */
233
+ removes?: Feature[];
234
+ full: Feature[];
235
+ replace?: boolean;
236
+ }
237
+ type NormalizedEventHandler = (lonLat: Position, pixel: PixelCoordinate, originalEvent: unknown) => void;
238
+ interface NativeEventEntry<TEventToken> {
239
+ type: string;
240
+ token: TEventToken;
241
+ }
242
+ /**
243
+ * Abstract base class shared by all `MapAdapter` implementations.
244
+ *
245
+ * Owns shared bookkeeping (layer registry, feature cache, event-token
246
+ * tracking) and orchestrates the public methods over a small
247
+ * protected primitive surface that subclasses implement. See
248
+ * `docs/adr/0001-map-adapter-base-class.md` for the rationale, in particular
249
+ * around the `applyFeatureOps` primitive shape.
250
+ */
251
+ declare abstract class BaseMapAdapter<TLayerHandle, TEventToken, TViewToken> implements MapAdapter {
252
+ protected layers: Map<string, TLayerHandle>;
253
+ protected eventTokens: Map<MapEventHandler, NativeEventEntry<TEventToken>>;
254
+ protected viewChangeTokens: Map<() => void, TViewToken>;
255
+ protected featureCache: Map<string, Feature<import("geojson").Geometry, import("geojson").GeoJsonProperties>[]>;
256
+ protected featuresChangedListeners: Map<string, Set<(features: Feature[]) => void>>;
257
+ private layerCounter;
258
+ private destroyed;
259
+ protected nextLayerId(): LayerId;
260
+ addVectorLayer(options?: {
261
+ style?: StyleOptions;
262
+ }): LayerId;
263
+ removeLayer(layerId: LayerId): void;
264
+ setLayerFeatures(layerId: LayerId, features: readonly Feature[]): void;
265
+ updateLayerFeatures(layerId: LayerId, features: readonly Feature[]): void;
266
+ removeFeatures(layerId: LayerId, ids: readonly string[]): void;
267
+ abstract onPick(layerId: LayerId, handler: PickHandler, opts?: PickOptions): () => void;
268
+ abstract createEditPointerDriver(): EditPointerDriver;
269
+ clearLayer(layerId: LayerId): void;
270
+ on(eventType: "click" | "pointermove" | "dblclick", handler: MapEventHandler): void;
271
+ off(eventType: "click" | "pointermove" | "dblclick", handler: MapEventHandler): void;
272
+ onViewChange(handler: () => void): void;
273
+ offViewChange(handler: () => void): void;
274
+ destroy(): void;
275
+ protected emitLayerFeaturesChanged(layerId: LayerId, features: Feature[]): void;
276
+ protected onLayerFeaturesChanged(layerId: LayerId, listener: (features: Feature[]) => void): () => void;
277
+ protected destroyEngineResources(): void;
278
+ protected abstract createLayer(layerId: LayerId, style?: StyleOptions): TLayerHandle;
279
+ protected abstract destroyLayer(handle: TLayerHandle): void;
280
+ protected abstract applyFeatureOps(layerId: LayerId, ops: FeatureOps): void;
281
+ protected abstract attachNativeEvent(type: "click" | "pointermove" | "dblclick", onEvent: NormalizedEventHandler): NativeEventEntry<TEventToken>;
282
+ protected abstract detachNativeEvent(entry: NativeEventEntry<TEventToken>): void;
283
+ protected abstract attachViewChange(handler: () => void): TViewToken;
284
+ protected abstract detachViewChange(token: TViewToken): void;
285
+ abstract setLayerStyle(layerId: LayerId, style: StyleOptions): void;
286
+ abstract toLonLat(coordinate: number[]): Position;
287
+ abstract fromLonLat(position: Position): number[];
288
+ abstract getPixelFromCoordinate(position: Position): PixelCoordinate | null;
289
+ abstract getViewportSize(): {
290
+ width: number;
291
+ height: number;
292
+ };
293
+ abstract panByPixels(delta: PixelCoordinate, options?: {
294
+ durationMs?: number;
295
+ }): void;
296
+ abstract setCursor(cursor: MapCursor): void;
297
+ abstract setDoubleClickZoomEnabled(enabled: boolean): void;
298
+ abstract getResolution(): number | undefined;
299
+ abstract getZoom(): number | undefined;
300
+ }
301
+ //#endregion
302
+ //#region src/pick-utils.d.ts
303
+ declare function pickHitTolerance(originalEvent: unknown): number;
304
+ declare function tryControlMeasureIdFromFeature(feature: Feature): string | null;
305
+ /**
306
+ * Wire a subscription to honour both an optional AbortSignal and a returned
307
+ * dispose function. `attach` runs immediately (unless the signal is already
308
+ * aborted) and must return its own teardown function. The returned dispose is
309
+ * idempotent.
310
+ */
311
+ declare function bindAbortable(signal: AbortSignal | undefined, attach: () => () => void): () => void;
312
+ //#endregion
313
+ //#region src/feature-style.d.ts
314
+ /**
315
+ * Coerce a raw `properties.style` value into a `HandleStyle` for the
316
+ * per-feature interaction-affordance overlay (ADR-0006). Returns `undefined`
317
+ * when missing or shaped wrong so callers fall through to the layer style.
318
+ */
319
+ declare function coerceHandleStyle(raw: unknown): HandleStyle | undefined;
320
+ //#endregion
321
+ //#region src/gestures/hit-test.d.ts
322
+ type ToPixel = (lonLat: Position) => PixelCoordinate | null;
323
+ /**
324
+ * Test whether `pixel` hits any feature in `features`, in pixel space.
325
+ *
326
+ * Hit semantics:
327
+ * - Point: within `hitTolerance` of the projected vertex
328
+ * - LineString / MultiLineString: within `hitTolerance` of any segment
329
+ * - Polygon / MultiPolygon: inside the projected ring, OR within
330
+ * `hitTolerance` of a ring segment (so border hits work outside fill)
331
+ *
332
+ * Returns the first matching feature in iteration order, or null.
333
+ */
334
+ declare function hitTestFeature(pixel: PixelCoordinate, features: Feature[], hitTolerance: number, toPixel: ToPixel): Feature | null;
335
+ //#endregion
336
+ //#region src/gestures/hit-tolerance.d.ts
337
+ declare const TOUCH_HIT_TOLERANCE_PX = 22;
338
+ //#endregion
339
+ //#region src/gestures/pointer-callback-hub.d.ts
340
+ /**
341
+ * The subscriber registry behind every {@link EditPointerDriver}.
342
+ *
343
+ * Each engine adapter sources pointer events differently — OpenLayers from
344
+ * unified DOM `PointerEvent`s, MapLibre and Leaflet from split mouse/touch
345
+ * streams — and each owns its own multi-touch capture FSM. What does *not*
346
+ * vary is the registry: three callback sets, the subscribe-returns-disposer
347
+ * contract, the snapshot-before-dispatch semantics, and teardown. That lived
348
+ * in four hand-rolled copies (the three adapters plus the test fake); it now
349
+ * lives here once.
350
+ *
351
+ * Adapters fan their normalized events in through {@link fireDown} /
352
+ * {@link fireMove} / {@link fireUp} and expose the `onPointer*` methods on
353
+ * their driver; teardown calls {@link clear}.
354
+ */
355
+ declare class PointerCallbackHub {
356
+ private readonly downCbs;
357
+ private readonly moveCbs;
358
+ private readonly upCbs;
359
+ onPointerDown(cb: PointerCallback): () => void;
360
+ onPointerMove(cb: PointerCallback): () => void;
361
+ onPointerUp(cb: PointerCallback): () => void;
362
+ fireDown(pixel: PixelCoordinate, info?: PointerEventInfo): void;
363
+ fireMove(pixel: PixelCoordinate, info?: PointerEventInfo): void;
364
+ fireUp(pixel: PixelCoordinate, info?: PointerEventInfo): void;
365
+ /** Drop every subscriber. Idempotent; call from the driver's `dispose`. */
366
+ clear(): void;
367
+ }
368
+ //#endregion
369
+ //#region src/tactical-draw/abort.d.ts
370
+ /**
371
+ * AbortPlumbing for the TacticalDraw façade.
372
+ *
373
+ * Owns the abort contract for `TacticalDraw` so the rest of the façade
374
+ * (controllers, sessions) does not invent ad-hoc reason strings or error
375
+ * subclasses. See issue #43 / PRD #40.
376
+ */
377
+ type TacticalDrawAbortReason = "escape" | "signal" | "preempted" | "destroyed" | "session" | "removed";
378
+ interface TacticalDrawAbortErrorOptions {
379
+ cause?: unknown;
380
+ message?: string;
381
+ }
382
+ /**
383
+ * Façade abort error. Extends `DOMException` with `name: "AbortError"` so it
384
+ * still matches platform `AbortSignal` semantics and `instanceof DOMException`.
385
+ */
386
+ declare class TacticalDrawAbortError extends DOMException {
387
+ readonly name: "AbortError";
388
+ readonly reason: TacticalDrawAbortReason;
389
+ constructor(reason: TacticalDrawAbortReason, options?: TacticalDrawAbortErrorOptions);
390
+ }
391
+ declare function isTacticalDrawAbortError(error: unknown): error is TacticalDrawAbortError;
392
+ /**
393
+ * Swallow façade `TacticalDrawAbortError` *and* plain DOM `AbortError`s;
394
+ * rethrow every other value unchanged. Designed for use as
395
+ * `promise.catch(ignoreAbort)`.
396
+ */
397
+ declare function ignoreAbort(error: unknown): void;
398
+ /**
399
+ * Combined-signal handle returned by `combineWithHostSignal`. The façade uses
400
+ * `signal` for the live interaction and `abort` for programmatic cancellation
401
+ * with a closed-enum reason. `dispose` detaches the host-signal listener;
402
+ * controllers should call it when the interaction settles.
403
+ */
404
+ interface CombinedAbort {
405
+ readonly signal: AbortSignal;
406
+ abort(reason: TacticalDrawAbortReason, cause?: unknown): void;
407
+ dispose(): void;
408
+ }
409
+ /**
410
+ * Compose the façade's internal abort controller with an optional host
411
+ * `AbortSignal`. Host aborts surface as `reason: "signal"` with the host's
412
+ * `signal.reason` on `error.cause`. An already-aborted host signal triggers
413
+ * a next-microtask abort (never synchronous), so callers can register await
414
+ * handlers before the rejection fires.
415
+ */
416
+ declare function combineWithHostSignal(hostSignal?: AbortSignal): CombinedAbort;
417
+ //#endregion
418
+ //#region src/tactical-draw/measure-options.d.ts
419
+ /**
420
+ * Where a control measure's size is anchored (ADR-0020). `ground` (the
421
+ * default) means a meter size fixed to the terrain — static, never re-rendered
422
+ * on zoom. `screen` means a pixel size held constant on screen — re-rendered
423
+ * against the live `metersPerPixel` on every view change. A draw/edit drawn in
424
+ * pixels resolves (`bakes`) to a `ground` anchor on commit unless the caller
425
+ * asks to stay `screen`.
426
+ */
427
+ type SizeAnchor = "ground" | "screen";
428
+ /**
429
+ * The [[SizeAnchor]] a measure currently encodes (ADR-0020): `screen` when it
430
+ * carries its kind's pixel-denominated size option, `ground` otherwise. Keys off
431
+ * the same `PIXEL_SIZE_OPTIONS` table as the bake, so a consumer deciding how to
432
+ * re-edit a graphic never has to re-derive the per-kind pixel-option knowledge
433
+ * (e.g. via a fragile key-name heuristic). Accepts the minimal `{ kind, options }`
434
+ * shape, mirroring [[measureUsesAdapterResolution]].
435
+ */
436
+ declare function getSizeAnchor(measure: Pick<ControlMeasure, "kind" | "options">): SizeAnchor;
437
+ //#endregion
438
+ //#region src/tactical-draw/handles.d.ts
439
+ /**
440
+ * Line-only style surface for the rubber-band guide (ADR-0004). Slot inside
441
+ * `InteractionStyle.guide`. Applied as the guide layer's layer style by the
442
+ * façade; not per-feature.
443
+ */
444
+ interface GuideStyle {
445
+ strokeColor?: string;
446
+ strokeWidth?: number;
447
+ strokeDash?: readonly number[];
448
+ }
449
+ /**
450
+ * Unified interaction-affordance style surface (ADR-0006). One option object
451
+ * for both the guide line and every handle slot. `guide` is delivered as a
452
+ * layer style; handle slots ride per-feature on `properties.style`.
453
+ */
454
+ interface InteractionStyle {
455
+ guide?: GuideStyle;
456
+ vertexHandle?: HandleStyle;
457
+ midpointHandle?: HandleStyle;
458
+ translateHandle?: HandleStyle;
459
+ rotateHandle?: HandleStyle;
460
+ }
461
+ /**
462
+ * Reserved enum from PRD #40. `reshape`, `translate`, and `rotate` produce
463
+ * handles. `delete` is an explicit destructive mode: it renders the vertex
464
+ * handles (so they can be tapped to remove) but no midpoint/translate/rotate
465
+ * grips, and the edit controller deletes the tapped vertex instead of dragging
466
+ * it. `scale` is reserved and throws elsewhere.
467
+ */
468
+ type EditMode = "reshape" | "translate" | "rotate" | "scale" | "delete";
469
+ //#endregion
470
+ //#region src/tactical-draw/edit-session.d.ts
471
+ /**
472
+ * Surface delivered via `td.edit(measure, opts).onSession` and synchronously
473
+ * available from `td.activeSession` while the edit is live.
474
+ *
475
+ * `measure` is the immutable edit-start input. `controlPoints` is live working
476
+ * state. After the session settles (`close` / `abort`), every method is a
477
+ * silent no-op.
478
+ */
479
+ interface EditSession {
480
+ /** Immutable edit-start input. */
481
+ readonly measure: ControlMeasure;
482
+ /** Live working state. Copy on read. */
483
+ readonly controlPoints: readonly Position[];
484
+ /** Live working options. Copy on read. */
485
+ readonly options: ControlMeasure["options"];
486
+ /** Live working style. Copy on read. */
487
+ readonly style: ControlMeasureStyle | undefined;
488
+ /** `true` after the first working-copy mutation; back to `false` within tolerance. */
489
+ readonly dirty: boolean;
490
+ readonly modes: readonly EditMode[];
491
+ /**
492
+ * Live size anchor for the measure under edit (ADR-0020). `"ground"` bakes a
493
+ * pixel size to meters on commit; `"screen"` keeps it zoom-aware. Initialised
494
+ * from `EditOptions.sizeAnchor` and mutated by [[setSizeAnchor]].
495
+ */
496
+ readonly sizeAnchor: SizeAnchor;
497
+ /**
498
+ * Resolve the outer `edit` promise with a `ControlMeasureSnapshot` of the
499
+ * current working state.
500
+ */
501
+ close(): void;
502
+ /** Reject the outer `edit` promise with reason `"session"`. */
503
+ abort(): void;
504
+ /**
505
+ * Switch the active edit-mode set. `"reshape"`, `"translate"`, and `"rotate"`
506
+ * produce handles; `"delete"` is exclusive (vertex-tap removal) and collapses
507
+ * the set to delete-only. `"scale"` is rejected with `TypeError` per PRD #40.
508
+ */
509
+ setModes(modes: readonly EditMode[]): void;
510
+ /** Shallow-merge per-measure generator options into the live working copy. */
511
+ setOptions(partial: ControlMeasure["options"]): void;
512
+ /**
513
+ * Flip the size anchor (ADR-0020). Takes effect on the next emitted snapshot
514
+ * and on `close()`. A no-op for measures with no pixel-denominated size. The
515
+ * in-flight preview is unaffected (it renders from the working options), so a
516
+ * screen-locked symbol stays screen-locked until commit.
517
+ */
518
+ setSizeAnchor(anchor: SizeAnchor): void;
519
+ /** Shallow-merge per-measure style into the live working copy. */
520
+ setStyle(partial: ControlMeasureStyle): void;
521
+ /**
522
+ * Subscribe to working-copy changes. The listener receives an
523
+ * [[EditChangeEvent]] for each completed gesture (drag end, insert/delete
524
+ * via handle) — never on rubber-band ticks. All currently-registered
525
+ * listeners receive the same event instance in registration order.
526
+ *
527
+ * Listener exceptions are collected; after every listener has run, they are
528
+ * rethrown (one if single, aggregated otherwise). `event.reject()` /
529
+ * `event.close()` / `event.abort()` take effect only after all listeners
530
+ * have run.
531
+ */
532
+ onChange(listener: (event: EditChangeEvent) => void): () => void;
533
+ }
534
+ /**
535
+ * Delivered to `EditSession.onChange` listeners once per completed gesture.
536
+ *
537
+ * Listeners see the *same* event instance in registration order. Per-gesture
538
+ * effects (`reject`, `close`, `abort`) are recorded during emission and
539
+ * applied after every listener has run, so a downstream listener cannot
540
+ * observe partial state. See PRD #40 user-stories 23–24.
541
+ */
542
+ interface EditChangeEvent {
543
+ /** Working state after the completed gesture. */
544
+ readonly measure: ControlMeasureSnapshot;
545
+ /** State immediately before this gesture. */
546
+ readonly previous: ControlMeasureSnapshot;
547
+ /** The session the event belongs to. */
548
+ readonly session: EditSession;
549
+ /**
550
+ * Roll the working preview back to `previous` after all listeners have run.
551
+ * Multiple calls collapse to a single rollback. Overridden by `close()` or
552
+ * `abort()` if either is also called during this emission.
553
+ */
554
+ reject(): void;
555
+ /** Shortcut for `event.session.close()`, deferred until the emission ends. */
556
+ close(): void;
557
+ /** Shortcut for `event.session.abort()`, deferred until the emission ends. */
558
+ abort(): void;
559
+ }
560
+ //#endregion
561
+ //#region src/tactical-draw/session.d.ts
562
+ /**
563
+ * Scoped surface delivered via `td.draw({ onSession })` for variable-length
564
+ * kinds. Also available from `td.activeSession` while the draw is live.
565
+ *
566
+ * After the draw has settled — either via `commit()` returning `true` or
567
+ * `abort()` resolving — every method is a silent no-op.
568
+ */
569
+ interface DrawSession {
570
+ /** Live snapshot of the committed control points. Copy on read. */
571
+ readonly controlPoints: readonly Position[];
572
+ /** `true` when the current point count is in `[min, max]`. */
573
+ readonly canCommit: boolean;
574
+ readonly minControlPoints: number;
575
+ readonly maxControlPoints?: number;
576
+ /**
577
+ * Commit if `canCommit` is true. Returns `false` (no-op) if not committable.
578
+ * On success resolves the outer `draw` promise on the next microtask.
579
+ */
580
+ commit(): boolean;
581
+ /** Reject the outer `draw` promise with reason `"session"`. */
582
+ abort(): void;
583
+ /**
584
+ * Subscribe to control-point add/remove changes. Rubber-band pointer ticks
585
+ * do not fire listeners. Returns an unsubscribe function. Listener errors
586
+ * are collected and rethrown after all listeners run.
587
+ */
588
+ onChange(listener: (session: DrawSession) => void): () => void;
589
+ }
590
+ //#endregion
591
+ //#region src/tactical-draw/tactical-draw.d.ts
592
+ /**
593
+ * Host-supplied layer slot ids. Any omitted slot is allocated by the façade and
594
+ * released on `destroy()`.
595
+ */
596
+ interface TacticalDrawLayerOptions {
597
+ graphics?: LayerId;
598
+ preview?: LayerId;
599
+ guide?: LayerId;
600
+ handles?: LayerId;
601
+ }
602
+ interface TacticalDrawOptions {
603
+ layers?: TacticalDrawLayerOptions;
604
+ /**
605
+ * Façade-level default style passed through to `renderControlMeasure` as the
606
+ * third (lowest priority) style layer per StyleResolver (#44). Shallow-merged
607
+ * over `BUILT_IN_GRAPHICS_STYLE` so every map engine renders the same default
608
+ * appearance when neither the host nor the measure supplies a style.
609
+ */
610
+ graphicsStyle?: ControlMeasureStyle;
611
+ /**
612
+ * Façade-level default interaction-affordance style. Per-slot, per-property
613
+ * merged with `DrawOptions.interactionStyle` / `EditOptions.interactionStyle`
614
+ * on top of built-in defaults. The `guide` slot is delivered as a layer
615
+ * style on the guide layer; handle slots ride per-feature. See ADR-0006.
616
+ */
617
+ interactionStyle?: InteractionStyle;
618
+ /**
619
+ * Optional id generator for measures produced by `draw()`. Defaults to a
620
+ * monotonically increasing `td-${n}` slug. Caller-supplied measure ids on
621
+ * the draw API itself are deferred (PRD #40 user-story-5 note).
622
+ */
623
+ generateId?: () => string;
624
+ }
625
+ interface EditOptions {
626
+ signal?: AbortSignal;
627
+ /**
628
+ * Called synchronously once when the edit starts, before any pointer event
629
+ * is processed. Lets a host wire commit / cancel buttons to the active edit.
630
+ */
631
+ onSession?: (session: EditSession) => void;
632
+ /**
633
+ * Default `true`. When `true`, a click on empty map (or on another graphics
634
+ * layer measure) closes the active edit. Clicks on preview / handle
635
+ * features are consumed and keep the edit open. Hosts that own their own
636
+ * close policy pass `false`.
637
+ */
638
+ closeOnClickAway?: boolean;
639
+ /**
640
+ * Active edit modes. Defaults to `["reshape"]`. `reshape` renders vertex /
641
+ * midpoint handles; `translate` renders a grip on the centroid pivot; and
642
+ * `rotate` renders a grip offset just outside the shape (so it has a lever
643
+ * arm and orbits with the geometry). The three can be combined freely.
644
+ * `"scale"` is reserved and throws.
645
+ */
646
+ modes?: readonly EditMode[];
647
+ /**
648
+ * Per-call interaction style override scoped to this edit. Per-slot,
649
+ * per-property merged over the façade-level default and the built-ins.
650
+ * See ADR-0006.
651
+ */
652
+ interactionStyle?: InteractionStyle;
653
+ /**
654
+ * When `true` (default), the preview layer carries a rubber-band guide
655
+ * polyline sourced from the working control points alongside the working
656
+ * geometry preview. For `geometry === "area"` kinds, a separate
657
+ * closing-segment `LineString` is also emitted once `workingPoints.length >= 3`.
658
+ * The guide is suppressed mechanically when `geometry === "point"` or
659
+ * `minCoordinates === maxCoordinates`, regardless of this flag. See ADR-0004.
660
+ */
661
+ guide?: boolean;
662
+ /**
663
+ * Size anchor for the committed measure (ADR-0020). Symmetric with
664
+ * `DrawOptions.sizeAnchor`: editing a screen-anchored graphic as `"ground"`
665
+ * bakes/freezes it to meters; editing a ground graphic whose working options
666
+ * have been switched to pixels as `"screen"` keeps the pixel size. Default
667
+ * `"ground"`. Applied to the resolved snapshot only — the in-flight preview
668
+ * stays screen-anchored throughout the edit.
669
+ */
670
+ sizeAnchor?: SizeAnchor;
671
+ }
672
+ /**
673
+ * Draft control measure handed to `TacticalDraw.draw`. `kind` is required up
674
+ * front so the façade can resolve metadata, draw rule, and controller choice
675
+ * before any pointer event is observed. Everything else is optional and is
676
+ * stamped onto the in-flight preview and final committed measure (see slices
677
+ * 2-7).
678
+ */
679
+ type DrawMeasureDraft<K extends ControlMeasureKind = ControlMeasureKind> = {
680
+ kind: K;
681
+ } & Partial<Omit<ControlMeasure<K>, "kind" | "controlPoints">> & {
682
+ controlPoints?: readonly Position[];
683
+ };
684
+ /**
685
+ * Interaction-only options for `TacticalDraw.draw`. Measure data — seed
686
+ * points, per-kind options, style, properties — lives on the `DrawMeasureDraft`
687
+ * passed as the first argument.
688
+ */
689
+ interface DrawOptions {
690
+ signal?: AbortSignal;
691
+ /**
692
+ * Called synchronously once when a variable-length draw starts, before any
693
+ * pointer event is processed. The session lets a host wire a Done/Cancel
694
+ * toolbar to the active draw. Ignored for fixed-length kinds.
695
+ */
696
+ onSession?: (session: DrawSession) => void;
697
+ /**
698
+ * When `true` (default), the preview layer carries a rubber-band guide
699
+ * polyline alongside the kind's generator preview. For `geometry === "area"`
700
+ * kinds, a separate closing-segment `LineString` is also emitted once the
701
+ * total point count (cursor included) is ≥ 3. The guide is suppressed
702
+ * mechanically when `geometry === "point"` or `minCoordinates === maxCoordinates`,
703
+ * regardless of this flag. See ADR-0004.
704
+ */
705
+ guide?: boolean;
706
+ /**
707
+ * Per-call interaction style override scoped to this draw. Only the `guide`
708
+ * slot is meaningful for draws today — handle slots are reserved for the
709
+ * edit path. See ADR-0006.
710
+ */
711
+ interactionStyle?: InteractionStyle;
712
+ /**
713
+ * Size anchor for the committed measure (ADR-0020). `"ground"` (default)
714
+ * bakes any pixel-denominated size to meters at the finishing zoom, so the
715
+ * graphic becomes static. `"screen"` keeps the pixel size, so the graphic
716
+ * holds a constant on-screen size and is re-rendered on every zoom. Only
717
+ * meaningful for pixel-sizable kinds (FLOT, fortified, antitank); a no-op
718
+ * otherwise. Not persisted — the resulting options shape encodes the anchor.
719
+ */
720
+ sizeAnchor?: SizeAnchor;
721
+ }
722
+ /**
723
+ * `TacticalDraw` façade — slice #6 (issue #45).
724
+ *
725
+ * Provides the constructor + lifecycle + `render(measures)` reconcile path.
726
+ * Interactive draw and edit live in later slices; the shape is in place so
727
+ * the surface is stable.
728
+ */
729
+ declare class TacticalDraw {
730
+ private readonly adapter;
731
+ private readonly graphicsLayer;
732
+ private readonly previewLayer;
733
+ private readonly guideLayer;
734
+ private readonly handlesLayer;
735
+ private readonly ownedLayers;
736
+ private readonly graphicsStyle;
737
+ private readonly defaultInteractionStyle;
738
+ private readonly generateId;
739
+ private destroyed;
740
+ private hasRendered;
741
+ private readonly activeInteraction;
742
+ /**
743
+ * Tracked alongside `activeInteraction` whenever the interaction is an edit. The
744
+ * `render()` path needs the edited measure's id (to exclude it from
745
+ * reconcile) and a hook to surface external style changes — both live on
746
+ * `EditHandle` so the controller stays the single owner of edit state.
747
+ */
748
+ private activeEdit;
749
+ /**
750
+ * Host pick handlers registered via `td.onPick` / `td.onMeasurePick`. The
751
+ * façade subscribes once to `adapter.onPick` per layer and routes events
752
+ * through this map so that close-then-pick ordering during an active edit can
753
+ * be enforced (issue #52).
754
+ */
755
+ private readonly hostPickHandlers;
756
+ private readonly adapterPickUnsubs;
757
+ /**
758
+ * Captured graphics-layer pick that arrived during an active edit on a
759
+ * *different* measure. The edit controller closes the active session and
760
+ * then asks the façade to re-dispatch this pick to host handlers so that
761
+ * `td.edit(otherMeasure)` from a host pick handler runs as a fresh edit,
762
+ * not as a programmatic preemption.
763
+ */
764
+ private deferredGraphicsPick;
765
+ private idCounter;
766
+ private editCounter;
767
+ /**
768
+ * Snapshot of the host's last `render()` argument. The edit slice uses it
769
+ * to recompute the graphics-layer state during a lift / restore — the
770
+ * façade owns the policy because controllers shouldn't need to know the
771
+ * surrounding collection state.
772
+ */
773
+ private lastRenderedMeasures;
774
+ constructor(adapter: MapAdapter, options?: TacticalDrawOptions);
775
+ /**
776
+ * Render `measures` into features and write them to the graphics layer.
777
+ * Routes every measure through [[renderMeasureForAdapter]] so pixel sizing is
778
+ * resolved against the live map. Callers own any edit-exclusion policy — see
779
+ * `render()` (excludes the edited measure, owned by the preview layer) versus
780
+ * `restoreOriginalToGraphics` (no exclusion, the edit is ending).
781
+ */
782
+ private renderMeasuresToGraphics;
783
+ /**
784
+ * Re-render the graphics layer on a view (zoom) change so pixel-denominated
785
+ * measures rescale live. Skipped entirely when nothing rendered carries a
786
+ * resolution-dependent size — meter geometry is zoom-independent, so a rebuild
787
+ * would be wasted layer writes. During an active edit the edited measure lives
788
+ * on the preview layer (lifted from graphics), so it is excluded here exactly
789
+ * as in `render()`; the preview's own view-change subscription rescales it.
790
+ */
791
+ private readonly handleViewChange;
792
+ /**
793
+ * Reconcile the graphics layer against the authoritative list of measures.
794
+ * Idempotent. Throws synchronously on duplicate measure ids *before*
795
+ * mutating any layer. An empty list before any prior render is a no-op.
796
+ *
797
+ * Cross-layer semantics with an active edit (issue #51):
798
+ * - The measure under edit is **excluded** from the reconcile pass — the
799
+ * preview layer owns its working copy and the lift on the graphics layer
800
+ * must stay in place.
801
+ * - If the edited measure is absent from `measures`, the pending `edit`
802
+ * promise rejects on the **next microtask** with reason `"removed"`.
803
+ * `render` itself returns normally so host collection ops stay synchronous.
804
+ * - If the edited measure is present and its `style` changed, the preview
805
+ * picks up the new style on this render. `controlPoints` changes on the
806
+ * edited measure are routed nowhere — the user's in-flight drag is never
807
+ * yanked out from under them.
808
+ *
809
+ * Cross-layer semantics with an active draw: `render` only touches the
810
+ * graphics layer, so the draw's preview is undisturbed.
811
+ *
812
+ * Reentrancy: calling `td.render(...)` from inside `EditSession.onChange` is
813
+ * safe — `render` never re-enters the active edit or its preview / handle
814
+ * layers (the only edit-facing side effect, `notifyExternalMeasure`, is a
815
+ * pure style replay).
816
+ */
817
+ render(measures: readonly ControlMeasure[]): void;
818
+ /**
819
+ * Nested-call guard + preempt, shared by `draw` and `edit`. Returns a
820
+ * pre-rejected promise when called re-entrantly from inside another
821
+ * interaction's `onSession` (the outer is still mid-construction, so the
822
+ * nested one is preempted synchronously — #52). Otherwise preempts whichever
823
+ * draw / edit is in flight and returns `null`, signalling the caller to
824
+ * proceed. One interaction at a time per PRD #40.
825
+ */
826
+ private guardAndPreempt;
827
+ /**
828
+ * Reserve the single interaction slot around `create`, shared by `draw` and
829
+ * `edit`. Reserves the active tracker (and `activeEdit` for edits) synchronously so
830
+ * `onSettled` callbacks see a coherent state; the handle's settle path calls
831
+ * the wrapped `onSettled` which clears the slot for us, avoiding the
832
+ * "promise.then(clearActive)" microtask gap that left the interaction set across
833
+ * re-dispatched pick handlers. The backstop `.then` covers a controller that
834
+ * settled synchronously (already-aborted signal returns a pre-settled handle),
835
+ * whose `onSettled` fired before `active` was assigned. `extraSettle` runs the
836
+ * caller's own teardown (guide-style revert, dbl-click-zoom re-enable).
837
+ */
838
+ private runWithSlot;
839
+ /**
840
+ * Wrap a host `onSession` callback so the session is recorded on the tracker
841
+ * (exposing it via `activeSession`) before the host's own handler runs.
842
+ */
843
+ private trackSession;
844
+ /**
845
+ * Start an interactive draw. Fixed-length kinds commit automatically on the
846
+ * last required click. Variable-length kinds surface a `DrawSession` via
847
+ * `opts.onSession` and commit on `session.commit()`, `dblclick`, or a second
848
+ * click on the last added control point. All paths reject with
849
+ * `TacticalDrawAbortError` on signal / Escape / destroy / `session.abort()`.
850
+ */
851
+ draw<K extends ControlMeasureKind>(draft: DrawMeasureDraft<K>, options?: DrawOptions): Promise<ControlMeasureSnapshot<K>>;
852
+ /**
853
+ * Start an interactive edit on a single control measure. Returns a Promise
854
+ * that resolves with a `ControlMeasureSnapshot` of the working state on
855
+ * `session.close()` and rejects with `TacticalDrawAbortError` on
856
+ * signal / Escape / destroy / preempt / `session.abort()`.
857
+ *
858
+ * Default click-away (`closeOnClickAway: true`) closes the edit on a click
859
+ * outside the preview / handle features. Hosts that own close policy pass
860
+ * `false`.
861
+ */
862
+ edit(measure: ControlMeasure, options?: EditOptions): Promise<ControlMeasureSnapshot>;
863
+ /**
864
+ * Subscribe to picks on committed control measures.
865
+ *
866
+ * The graphics layer is pre-bound, and `event.id` is the originating control
867
+ * measure id. Events retain the same close-then-pick ordering as
868
+ * {@link onPick}: when another measure is picked during an active edit, the
869
+ * current edit closes before the handler runs.
870
+ *
871
+ * Returns an idempotent unsubscribe function. An optional `signal` removes
872
+ * the handler when aborted.
873
+ */
874
+ onMeasurePick(handler: PickHandler, opts?: PickOptions): () => void;
875
+ /**
876
+ * Subscribe to pick events on `layerId`. Routed through the façade so the
877
+ * close-then-pick contract (issue #52) is observable: when an active edit
878
+ * sees a click on a *different* graphics-layer measure, the edit closes
879
+ * first and only then are pick events delivered to host handlers — letting
880
+ * a host `td.edit(other)` from a pick handler run as a fresh edit instead
881
+ * of a programmatic preemption.
882
+ *
883
+ * Returns an unsubscribe function. Optional `signal` mirrors `adapter.onPick`.
884
+ */
885
+ onPick(layerId: LayerId, handler: PickHandler, opts?: PickOptions): () => void;
886
+ /**
887
+ * Abort the in-flight draw or edit. The default `"session"` reason matches
888
+ * `DrawSession.abort()` / `EditSession.abort()`.
889
+ *
890
+ * Returns `true` when an interaction was active, otherwise `false`.
891
+ */
892
+ cancel(reason?: TacticalDrawAbortReason): boolean;
893
+ /**
894
+ * The live variable-draw or edit session, or `null` while idle and during
895
+ * fixed-length draws. Safe to read after `destroy()`.
896
+ */
897
+ get activeSession(): DrawSession | EditSession | null;
898
+ /**
899
+ * Tear down the façade. Releases only layer slots the façade created; host
900
+ * supplied layer ids are left untouched. Safe to call multiple times. After
901
+ * the first call every public method throws `TacticalDrawDestroyedError`.
902
+ */
903
+ destroy(): void;
904
+ /**
905
+ * Decide whether to deliver `event` to host pick handlers immediately or
906
+ * defer it until after the active edit closes. The "defer" branch is the
907
+ * close-then-pick path: the edit controller's click handler will close the
908
+ * session and then invoke `dispatchHostPick(graphicsLayer, event)` via
909
+ * `onClickAwayGraphicsPick` to redeliver — guaranteeing `td.edit(other)`
910
+ * from a host pick handler observes a settled session.
911
+ */
912
+ private routePick;
913
+ private dispatchHostPick;
914
+ /** @internal — exposed for tests and future controllers. */
915
+ get layerIds(): {
916
+ graphics: LayerId;
917
+ preview: LayerId;
918
+ guide: LayerId;
919
+ handles: LayerId;
920
+ };
921
+ /**
922
+ * Apply the resolved guide-slot style for an interaction and return a
923
+ * revert closure to re-apply the base (built-in + façade) style on settle.
924
+ * Per-call overrides are scoped to a single interaction (ADR-0004/0006).
925
+ */
926
+ private applyGuideStyle;
927
+ private baseGuideStyle;
928
+ /**
929
+ * Resolve handle slots across built-in → façade → per-call with per-slot,
930
+ * per-property shallow merge. Explicit `undefined` at a higher-priority
931
+ * layer unsets the corresponding lower-priority field (ADR-0006).
932
+ *
933
+ * Returns `undefined` only when no slot would carry any value — keeps the
934
+ * downstream "no style on the feature" path intact for tests that assert
935
+ * the absence of `properties.style`.
936
+ */
937
+ private resolveInteractionStyle;
938
+ private assertAlive;
939
+ }
940
+ //#endregion
941
+ //#region src/tactical-draw/errors.d.ts
942
+ /**
943
+ * Error thrown when any `TacticalDraw` method is called after `destroy()`.
944
+ * See PRD #40 / issue #45.
945
+ */
946
+ declare class TacticalDrawDestroyedError extends Error {
947
+ constructor(message?: string);
948
+ }
949
+ //#endregion
950
+ export { BaseMapAdapter, type CombinedAbort, type DrawMeasureDraft, type DrawOptions, type DrawSession, type EditChangeEvent, type EditMode, type EditOptions, type EditPointerDriver, type EditSession, type FeatureOps, type GuideStyle, type HandleStyle, type InteractionStyle, type LayerId, type MapAdapter, type MapCursor, type MapEvent, type MapEventHandler, type NativeEventEntry, type PickEvent, type PickHandler, type PickOptions, type PixelCoordinate, type PointerCallback, PointerCallbackHub, type PointerEventInfo, type SizeAnchor, type StyleOptions, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, type TacticalDrawAbortReason, TacticalDrawDestroyedError, type TacticalDrawLayerOptions, type TacticalDrawOptions, type TextStyleOptions, type ToPixel, bindAbortable, coerceHandleStyle, combineWithHostSignal, getSizeAnchor, hitTestFeature, ignoreAbort, isTacticalDrawAbortError, pickHitTolerance, tryControlMeasureIdFromFeature };