@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.
package/dist/index.mjs ADDED
@@ -0,0 +1,2855 @@
1
+ import { cloneControlMeasure, controlMeasureIdFromFeature, getControlMeasureMetadata, renderControlMeasure, resolveStyleHints, roundToFixed } from "@orbat-mapper/control-measures";
2
+ //#region src/base-map-adapter.ts
3
+ /**
4
+ * Abstract base class shared by all `MapAdapter` implementations.
5
+ *
6
+ * Owns shared bookkeeping (layer registry, feature cache, event-token
7
+ * tracking) and orchestrates the public methods over a small
8
+ * protected primitive surface that subclasses implement. See
9
+ * `docs/adr/0001-map-adapter-base-class.md` for the rationale, in particular
10
+ * around the `applyFeatureOps` primitive shape.
11
+ */
12
+ var BaseMapAdapter = class {
13
+ layers = /* @__PURE__ */ new Map();
14
+ eventTokens = /* @__PURE__ */ new Map();
15
+ viewChangeTokens = /* @__PURE__ */ new Map();
16
+ featureCache = /* @__PURE__ */ new Map();
17
+ featuresChangedListeners = /* @__PURE__ */ new Map();
18
+ layerCounter = 0;
19
+ destroyed = false;
20
+ nextLayerId() {
21
+ return `layer-${++this.layerCounter}`;
22
+ }
23
+ addVectorLayer(options) {
24
+ const id = this.nextLayerId();
25
+ const handle = this.createLayer(id, options?.style);
26
+ this.layers.set(id, handle);
27
+ this.featureCache.set(id, []);
28
+ return id;
29
+ }
30
+ removeLayer(layerId) {
31
+ const handle = this.layers.get(layerId);
32
+ if (!handle) return;
33
+ this.destroyLayer(handle);
34
+ this.layers.delete(layerId);
35
+ this.featureCache.delete(layerId);
36
+ this.featuresChangedListeners.delete(layerId);
37
+ }
38
+ setLayerFeatures(layerId, features) {
39
+ if (!this.layers.has(layerId)) return;
40
+ const next = [...features];
41
+ this.featureCache.set(layerId, next);
42
+ this.applyFeatureOps(layerId, {
43
+ adds: next,
44
+ updates: [],
45
+ full: next,
46
+ replace: true
47
+ });
48
+ this.emitLayerFeaturesChanged(layerId, next);
49
+ }
50
+ updateLayerFeatures(layerId, features) {
51
+ if (!this.layers.has(layerId)) return;
52
+ const existing = this.featureCache.get(layerId) ?? [];
53
+ const existingIds = /* @__PURE__ */ new Set();
54
+ for (const f of existing) if (f.id != null) existingIds.add(f.id);
55
+ const adds = [];
56
+ const updates = [];
57
+ const updateById = /* @__PURE__ */ new Map();
58
+ for (const f of features) {
59
+ if (f.id == null) continue;
60
+ if (existingIds.has(f.id)) updates.push(f);
61
+ else adds.push(f);
62
+ updateById.set(f.id, f);
63
+ }
64
+ const merged = [];
65
+ const removed = [];
66
+ for (const f of existing) if (f.id != null && updateById.has(f.id)) {
67
+ merged.push(updateById.get(f.id));
68
+ updateById.delete(f.id);
69
+ } else if (f.id != null && !updateById.has(f.id)) removed.push(f);
70
+ else merged.push(f);
71
+ for (const f of updateById.values()) merged.push(f);
72
+ this.featureCache.set(layerId, merged);
73
+ this.applyFeatureOps(layerId, {
74
+ adds,
75
+ updates,
76
+ removes: removed,
77
+ full: merged,
78
+ replace: false
79
+ });
80
+ this.emitLayerFeaturesChanged(layerId, merged);
81
+ }
82
+ removeFeatures(layerId, ids) {
83
+ if (!this.layers.has(layerId)) return;
84
+ if (ids.length === 0) return;
85
+ const existing = this.featureCache.get(layerId) ?? [];
86
+ const idSet = new Set(ids);
87
+ const merged = [];
88
+ const removed = [];
89
+ for (const f of existing) if (typeof f.id === "string" && idSet.has(f.id)) removed.push(f);
90
+ else merged.push(f);
91
+ if (removed.length === 0) return;
92
+ this.featureCache.set(layerId, merged);
93
+ this.applyFeatureOps(layerId, {
94
+ adds: [],
95
+ updates: [],
96
+ removes: removed,
97
+ full: merged,
98
+ replace: false
99
+ });
100
+ this.emitLayerFeaturesChanged(layerId, merged);
101
+ }
102
+ clearLayer(layerId) {
103
+ if (!this.layers.has(layerId)) return;
104
+ this.featureCache.set(layerId, []);
105
+ this.applyFeatureOps(layerId, {
106
+ adds: [],
107
+ updates: [],
108
+ full: [],
109
+ replace: true
110
+ });
111
+ this.emitLayerFeaturesChanged(layerId, []);
112
+ }
113
+ on(eventType, handler) {
114
+ const entry = this.attachNativeEvent(eventType, (lonLat, pixel, originalEvent) => {
115
+ handler({
116
+ type: eventType,
117
+ coordinate: lonLat,
118
+ pixel,
119
+ originalEvent
120
+ });
121
+ });
122
+ this.eventTokens.set(handler, entry);
123
+ }
124
+ off(eventType, handler) {
125
+ const entry = this.eventTokens.get(handler);
126
+ if (!entry) return;
127
+ this.detachNativeEvent(entry);
128
+ this.eventTokens.delete(handler);
129
+ }
130
+ onViewChange(handler) {
131
+ const token = this.attachViewChange(handler);
132
+ this.viewChangeTokens.set(handler, token);
133
+ }
134
+ offViewChange(handler) {
135
+ const token = this.viewChangeTokens.get(handler);
136
+ if (token === void 0) return;
137
+ this.detachViewChange(token);
138
+ this.viewChangeTokens.delete(handler);
139
+ }
140
+ destroy() {
141
+ if (this.destroyed) return;
142
+ try {
143
+ for (const entry of this.eventTokens.values()) this.detachNativeEvent(entry);
144
+ this.eventTokens.clear();
145
+ for (const token of this.viewChangeTokens.values()) this.detachViewChange(token);
146
+ this.viewChangeTokens.clear();
147
+ for (const handle of this.layers.values()) this.destroyLayer(handle);
148
+ this.layers.clear();
149
+ this.featureCache.clear();
150
+ this.featuresChangedListeners.clear();
151
+ this.destroyEngineResources();
152
+ } finally {
153
+ this.destroyed = true;
154
+ }
155
+ }
156
+ emitLayerFeaturesChanged(layerId, features) {
157
+ const listeners = this.featuresChangedListeners.get(layerId);
158
+ if (!listeners) return;
159
+ for (const fn of listeners) fn(features);
160
+ }
161
+ onLayerFeaturesChanged(layerId, listener) {
162
+ let listeners = this.featuresChangedListeners.get(layerId);
163
+ if (!listeners) {
164
+ listeners = /* @__PURE__ */ new Set();
165
+ this.featuresChangedListeners.set(layerId, listeners);
166
+ }
167
+ listeners.add(listener);
168
+ return () => {
169
+ const set = this.featuresChangedListeners.get(layerId);
170
+ if (!set) return;
171
+ set.delete(listener);
172
+ if (set.size === 0) this.featuresChangedListeners.delete(layerId);
173
+ };
174
+ }
175
+ destroyEngineResources() {}
176
+ };
177
+ function pickHitTolerance(originalEvent) {
178
+ if (typeof PointerEvent !== "undefined" && originalEvent instanceof PointerEvent) return originalEvent.pointerType === "touch" ? 12 : 4;
179
+ if (typeof TouchEvent !== "undefined" && originalEvent instanceof TouchEvent) return 12;
180
+ return 4;
181
+ }
182
+ /**
183
+ * Resolve a pixel hit-tolerance for a `MapEvent.originalEvent` delivered by the
184
+ * `MapAdapter.on(...)` pipeline. Unlike {@link PickEvent}, that pipeline forwards
185
+ * the *engine wrapper* event (OpenLayers `MapBrowserEvent`, MapLibre
186
+ * `MapMouseEvent`, Leaflet `LeafletMouseEvent`), each of which carries the raw
187
+ * DOM event on a `.originalEvent` property. {@link pickHitTolerance} only
188
+ * recognizes a real `PointerEvent` / `TouchEvent`, so unwrap one level when the
189
+ * wrapper itself is not recognized as touch. Falls back to the mouse tolerance
190
+ * when the input type cannot be determined.
191
+ */
192
+ function mapEventHitTolerance(originalEvent) {
193
+ const direct = pickHitTolerance(originalEvent);
194
+ if (direct === 12) return direct;
195
+ if (originalEvent && typeof originalEvent === "object" && "originalEvent" in originalEvent) return pickHitTolerance(originalEvent.originalEvent);
196
+ return direct;
197
+ }
198
+ function tryControlMeasureIdFromFeature(feature) {
199
+ try {
200
+ return controlMeasureIdFromFeature(feature);
201
+ } catch {
202
+ return null;
203
+ }
204
+ }
205
+ /**
206
+ * Wire a subscription to honour both an optional AbortSignal and a returned
207
+ * dispose function. `attach` runs immediately (unless the signal is already
208
+ * aborted) and must return its own teardown function. The returned dispose is
209
+ * idempotent.
210
+ */
211
+ function bindAbortable(signal, attach) {
212
+ if (signal?.aborted) return () => {};
213
+ let disposed = false;
214
+ const teardown = attach();
215
+ const dispose = () => {
216
+ if (disposed) return;
217
+ disposed = true;
218
+ teardown();
219
+ if (signal) signal.removeEventListener("abort", dispose);
220
+ };
221
+ if (signal) signal.addEventListener("abort", dispose, { once: true });
222
+ return dispose;
223
+ }
224
+ //#endregion
225
+ //#region src/feature-style.ts
226
+ /**
227
+ * Coerce a raw `properties.style` value into a `HandleStyle` for the
228
+ * per-feature interaction-affordance overlay (ADR-0006). Returns `undefined`
229
+ * when missing or shaped wrong so callers fall through to the layer style.
230
+ */
231
+ function coerceHandleStyle(raw) {
232
+ if (!raw || typeof raw !== "object") return void 0;
233
+ return raw;
234
+ }
235
+ //#endregion
236
+ //#region src/gestures/hit-test.ts
237
+ /** Squared distance from `pixel` to the segment p1→p2 in pixel space, or null if either endpoint fails to project or the segment has zero length. */
238
+ function pixelDistanceToSegment(pixel, p1, p2, toPixel) {
239
+ const a = toPixel(p1);
240
+ const b = toPixel(p2);
241
+ if (!a || !b) return null;
242
+ const dx = b[0] - a[0];
243
+ const dy = b[1] - a[1];
244
+ const lengthSq = dx * dx + dy * dy;
245
+ if (lengthSq === 0) return null;
246
+ let t = ((pixel[0] - a[0]) * dx + (pixel[1] - a[1]) * dy) / lengthSq;
247
+ t = Math.max(0, Math.min(1, t));
248
+ const cx = a[0] + t * dx;
249
+ const cy = a[1] + t * dy;
250
+ const ddx = pixel[0] - cx;
251
+ const ddy = pixel[1] - cy;
252
+ return Math.sqrt(ddx * ddx + ddy * ddy);
253
+ }
254
+ /** Even-odd point-in-ring test in pixel space. */
255
+ function pixelPointInRing(pixel, ring, toPixel) {
256
+ const projected = [];
257
+ for (const c of ring) {
258
+ const p = toPixel(c);
259
+ if (!p) return false;
260
+ projected.push(p);
261
+ }
262
+ const [px, py] = pixel;
263
+ let inside = false;
264
+ for (let i = 0, j = projected.length - 1; i < projected.length; j = i++) {
265
+ const xi = projected[i][0], yi = projected[i][1];
266
+ const xj = projected[j][0], yj = projected[j][1];
267
+ if (yi > py !== yj > py && px < (xj - xi) * (py - yi) / (yj - yi) + xi) inside = !inside;
268
+ }
269
+ return inside;
270
+ }
271
+ function isNearAnySegment(pixel, ring, hitTolerance, toPixel) {
272
+ for (let i = 0; i < ring.length - 1; i++) {
273
+ const d = pixelDistanceToSegment(pixel, ring[i], ring[i + 1], toPixel);
274
+ if (d !== null && d <= hitTolerance) return true;
275
+ }
276
+ return false;
277
+ }
278
+ /**
279
+ * Test whether `pixel` hits any feature in `features`, in pixel space.
280
+ *
281
+ * Hit semantics:
282
+ * - Point: within `hitTolerance` of the projected vertex
283
+ * - LineString / MultiLineString: within `hitTolerance` of any segment
284
+ * - Polygon / MultiPolygon: inside the projected ring, OR within
285
+ * `hitTolerance` of a ring segment (so border hits work outside fill)
286
+ *
287
+ * Returns the first matching feature in iteration order, or null.
288
+ */
289
+ function hitTestFeature(pixel, features, hitTolerance, toPixel) {
290
+ const [px, py] = pixel;
291
+ for (const f of features) {
292
+ if (!f) continue;
293
+ const geom = f.geometry;
294
+ let hit = false;
295
+ if (geom.type === "Point") {
296
+ const p = toPixel(geom.coordinates);
297
+ if (p) {
298
+ const dx = p[0] - px;
299
+ const dy = p[1] - py;
300
+ hit = Math.sqrt(dx * dx + dy * dy) <= hitTolerance;
301
+ }
302
+ } else if (geom.type === "LineString") hit = isNearAnySegment(pixel, geom.coordinates, hitTolerance, toPixel);
303
+ else if (geom.type === "MultiLineString") hit = geom.coordinates.some((line) => isNearAnySegment(pixel, line, hitTolerance, toPixel));
304
+ else if (geom.type === "Polygon") hit = geom.coordinates.some((ring) => pixelPointInRing(pixel, ring, toPixel)) || geom.coordinates.some((ring) => isNearAnySegment(pixel, ring, hitTolerance, toPixel));
305
+ else if (geom.type === "MultiPolygon") hit = geom.coordinates.some((poly) => poly.some((ring) => pixelPointInRing(pixel, ring, toPixel))) || geom.coordinates.some((poly) => poly.some((ring) => isNearAnySegment(pixel, ring, hitTolerance, toPixel)));
306
+ if (hit) return f;
307
+ }
308
+ return null;
309
+ }
310
+ //#endregion
311
+ //#region src/gestures/hit-tolerance.ts
312
+ const TOUCH_HIT_TOLERANCE_PX = 22;
313
+ //#endregion
314
+ //#region src/gestures/pointer-callback-hub.ts
315
+ /**
316
+ * The subscriber registry behind every {@link EditPointerDriver}.
317
+ *
318
+ * Each engine adapter sources pointer events differently — OpenLayers from
319
+ * unified DOM `PointerEvent`s, MapLibre and Leaflet from split mouse/touch
320
+ * streams — and each owns its own multi-touch capture FSM. What does *not*
321
+ * vary is the registry: three callback sets, the subscribe-returns-disposer
322
+ * contract, the snapshot-before-dispatch semantics, and teardown. That lived
323
+ * in four hand-rolled copies (the three adapters plus the test fake); it now
324
+ * lives here once.
325
+ *
326
+ * Adapters fan their normalized events in through {@link fireDown} /
327
+ * {@link fireMove} / {@link fireUp} and expose the `onPointer*` methods on
328
+ * their driver; teardown calls {@link clear}.
329
+ */
330
+ var PointerCallbackHub = class {
331
+ downCbs = /* @__PURE__ */ new Set();
332
+ moveCbs = /* @__PURE__ */ new Set();
333
+ upCbs = /* @__PURE__ */ new Set();
334
+ onPointerDown(cb) {
335
+ this.downCbs.add(cb);
336
+ return () => this.downCbs.delete(cb);
337
+ }
338
+ onPointerMove(cb) {
339
+ this.moveCbs.add(cb);
340
+ return () => this.moveCbs.delete(cb);
341
+ }
342
+ onPointerUp(cb) {
343
+ this.upCbs.add(cb);
344
+ return () => this.upCbs.delete(cb);
345
+ }
346
+ fireDown(pixel, info) {
347
+ for (const cb of [...this.downCbs]) cb(pixel, info);
348
+ }
349
+ fireMove(pixel, info) {
350
+ for (const cb of [...this.moveCbs]) cb(pixel, info);
351
+ }
352
+ fireUp(pixel, info) {
353
+ for (const cb of [...this.upCbs]) cb(pixel, info);
354
+ }
355
+ /** Drop every subscriber. Idempotent; call from the driver's `dispose`. */
356
+ clear() {
357
+ this.downCbs.clear();
358
+ this.moveCbs.clear();
359
+ this.upCbs.clear();
360
+ }
361
+ };
362
+ //#endregion
363
+ //#region src/tactical-draw/active-interaction.ts
364
+ /**
365
+ * Owns the facade's public interaction state: the abort handle used by
366
+ * cancel/preempt/destroy and the optional session exposed to hosts.
367
+ */
368
+ var ActiveInteractionTracker = class {
369
+ handle = null;
370
+ currentSession = null;
371
+ starting = false;
372
+ pendingCancel = null;
373
+ begin() {
374
+ this.handle = null;
375
+ this.currentSession = null;
376
+ this.starting = true;
377
+ this.pendingCancel = null;
378
+ }
379
+ set(handle) {
380
+ if (handle.settled) {
381
+ this.clear();
382
+ return;
383
+ }
384
+ this.handle = handle;
385
+ this.starting = false;
386
+ const pendingCancel = this.pendingCancel;
387
+ this.pendingCancel = null;
388
+ if (pendingCancel) handle.abort(pendingCancel);
389
+ }
390
+ setSession(session) {
391
+ this.currentSession = session;
392
+ }
393
+ /**
394
+ * Clear only the expected interaction when a handle is supplied. This keeps
395
+ * an old promise's backstop cleanup from clearing a newer interaction.
396
+ */
397
+ clear(expected) {
398
+ if (expected && this.handle !== expected) return;
399
+ this.handle = null;
400
+ this.currentSession = null;
401
+ this.starting = false;
402
+ this.pendingCancel = null;
403
+ }
404
+ cancel(reason = "session") {
405
+ const handle = this.handle;
406
+ if (handle && !handle.settled) {
407
+ handle.abort(reason);
408
+ return true;
409
+ }
410
+ if (this.starting) {
411
+ this.pendingCancel = reason;
412
+ this.currentSession = null;
413
+ return true;
414
+ }
415
+ return false;
416
+ }
417
+ get session() {
418
+ return this.currentSession;
419
+ }
420
+ /** True while an interaction is mid-construction (between `begin` and `set`). */
421
+ get isStarting() {
422
+ return this.starting;
423
+ }
424
+ get isActive() {
425
+ return this.starting || this.handle !== null && !this.handle.settled;
426
+ }
427
+ };
428
+ //#endregion
429
+ //#region src/tactical-draw/abort.ts
430
+ const ABORT_REASONS = new Set([
431
+ "escape",
432
+ "signal",
433
+ "preempted",
434
+ "destroyed",
435
+ "session",
436
+ "removed"
437
+ ]);
438
+ /**
439
+ * Façade abort error. Extends `DOMException` with `name: "AbortError"` so it
440
+ * still matches platform `AbortSignal` semantics and `instanceof DOMException`.
441
+ */
442
+ var TacticalDrawAbortError = class TacticalDrawAbortError extends DOMException {
443
+ reason;
444
+ constructor(reason, options) {
445
+ if (!ABORT_REASONS.has(reason)) throw new TypeError(`Invalid TacticalDrawAbortReason: ${String(reason)}`);
446
+ super(options?.message ?? `TacticalDraw aborted (${reason})`, "AbortError");
447
+ Object.setPrototypeOf(this, TacticalDrawAbortError.prototype);
448
+ this.reason = reason;
449
+ if (options && "cause" in options) Object.defineProperty(this, "cause", {
450
+ value: options.cause,
451
+ writable: true,
452
+ configurable: true,
453
+ enumerable: false
454
+ });
455
+ }
456
+ };
457
+ function isTacticalDrawAbortError(error) {
458
+ return error instanceof TacticalDrawAbortError;
459
+ }
460
+ /**
461
+ * Swallow façade `TacticalDrawAbortError` *and* plain DOM `AbortError`s;
462
+ * rethrow every other value unchanged. Designed for use as
463
+ * `promise.catch(ignoreAbort)`.
464
+ */
465
+ function ignoreAbort(error) {
466
+ if (isTacticalDrawAbortError(error)) return;
467
+ if (error instanceof DOMException && error.name === "AbortError") return;
468
+ throw error;
469
+ }
470
+ /**
471
+ * Compose the façade's internal abort controller with an optional host
472
+ * `AbortSignal`. Host aborts surface as `reason: "signal"` with the host's
473
+ * `signal.reason` on `error.cause`. An already-aborted host signal triggers
474
+ * a next-microtask abort (never synchronous), so callers can register await
475
+ * handlers before the rejection fires.
476
+ */
477
+ function combineWithHostSignal(hostSignal) {
478
+ const controller = new AbortController();
479
+ let disposed = false;
480
+ const abortFromHost = () => {
481
+ if (controller.signal.aborted) return;
482
+ const cause = hostSignal?.reason;
483
+ controller.abort(new TacticalDrawAbortError("signal", { cause }));
484
+ };
485
+ const dispose = () => {
486
+ if (disposed) return;
487
+ disposed = true;
488
+ if (hostSignal) hostSignal.removeEventListener("abort", abortFromHost);
489
+ };
490
+ if (hostSignal) if (hostSignal.aborted) queueMicrotask(abortFromHost);
491
+ else hostSignal.addEventListener("abort", abortFromHost, { once: true });
492
+ return {
493
+ signal: controller.signal,
494
+ abort(reason, cause) {
495
+ if (controller.signal.aborted) {
496
+ dispose();
497
+ return;
498
+ }
499
+ const options = arguments.length > 1 ? { cause } : void 0;
500
+ controller.abort(new TacticalDrawAbortError(reason, options));
501
+ dispose();
502
+ },
503
+ dispose
504
+ };
505
+ }
506
+ /**
507
+ * Per-kind pixel-denominated size option and the meter option it bakes into —
508
+ * the single source of which option makes a measure screen-anchored. Both the
509
+ * resolution gate ([[measureUsesAdapterResolution]]) and the bake
510
+ * ([[bakePixelSizeToGround]]) derive from this table, so a new pixel-sized kind
511
+ * is one entry here (ADR-0020).
512
+ */
513
+ const PIXEL_SIZE_OPTIONS = {
514
+ flot: {
515
+ pixels: "radiusPixels",
516
+ meters: "radius"
517
+ },
518
+ "fortified-line": {
519
+ pixels: "sizePixels",
520
+ meters: "size"
521
+ },
522
+ "fortified-area": {
523
+ pixels: "sizePixels",
524
+ meters: "size"
525
+ },
526
+ "antitank-ditch-under-construction": {
527
+ pixels: "toothHeightPixels",
528
+ meters: "toothHeight"
529
+ },
530
+ "antitank-ditch-completed": {
531
+ pixels: "toothHeightPixels",
532
+ meters: "toothHeight"
533
+ },
534
+ "antitank-wall": {
535
+ pixels: "toothHeightPixels",
536
+ meters: "toothHeight"
537
+ }
538
+ };
539
+ /**
540
+ * Whether `measure` carries a pixel-denominated size option whose resolution
541
+ * to meters depends on the live map (`metersPerPixel`) — i.e. it is
542
+ * *screen-anchored*. Used both to inject the override
543
+ * ([[withAdapterMeasureOptions]]) and to let zoom-driven re-renders skip
544
+ * measures whose geometry is resolution-independent. Accepts the minimal
545
+ * `{ kind, options }` shape so in-flight draw/edit handlers can gate against
546
+ * working options without materialising a full measure.
547
+ */
548
+ function measureUsesAdapterResolution(measure) {
549
+ const spec = PIXEL_SIZE_OPTIONS[measure.kind];
550
+ if (!spec) return false;
551
+ return measure.options?.[spec.pixels] !== void 0;
552
+ }
553
+ /**
554
+ * The [[SizeAnchor]] a measure currently encodes (ADR-0020): `screen` when it
555
+ * carries its kind's pixel-denominated size option, `ground` otherwise. Keys off
556
+ * the same `PIXEL_SIZE_OPTIONS` table as the bake, so a consumer deciding how to
557
+ * re-edit a graphic never has to re-derive the per-kind pixel-option knowledge
558
+ * (e.g. via a fragile key-name heuristic). Accepts the minimal `{ kind, options }`
559
+ * shape, mirroring [[measureUsesAdapterResolution]].
560
+ */
561
+ function getSizeAnchor(measure) {
562
+ return measureUsesAdapterResolution(measure) ? "screen" : "ground";
563
+ }
564
+ /**
565
+ * Bake a screen-anchored (pixel) size to a ground-anchored (meter) size at the
566
+ * adapter's current resolution, then strip the pixel + `metersPerPixel` keys so
567
+ * the result is an ordinary meters graphic carrying no pixel intent — afterward
568
+ * indistinguishable from one drawn directly in meters (ADR-0020). Invoked on
569
+ * draw/edit commit for the default `ground` anchor. No-op when the measure
570
+ * carries no pixel size or the adapter has no usable resolution.
571
+ */
572
+ function bakePixelSizeToGround(measure, adapter) {
573
+ const spec = PIXEL_SIZE_OPTIONS[measure.kind];
574
+ if (!spec) return measure;
575
+ const options = measure.options ?? {};
576
+ const pixels = options[spec.pixels];
577
+ if (typeof pixels !== "number") return measure;
578
+ const mpp = adapter.getResolution();
579
+ if (mpp === void 0 || mpp <= 0) return measure;
580
+ const rest = {};
581
+ for (const [key, value] of Object.entries(options)) if (key !== spec.pixels && key !== "metersPerPixel") rest[key] = value;
582
+ rest[spec.meters] = pixels * mpp;
583
+ return {
584
+ ...measure,
585
+ options: rest
586
+ };
587
+ }
588
+ /**
589
+ * Stamp the adapter's current `metersPerPixel` onto a measure that sizes itself
590
+ * in pixels, so the generator resolves pixel options (`radiusPixels`,
591
+ * `sizePixels`, `toothHeightPixels`) against the live map zoom. Returns the
592
+ * measure unchanged when the adapter has no usable resolution or the
593
+ * kind/options don't opt into pixel sizing.
594
+ */
595
+ function withAdapterMeasureOptions(measure, adapter) {
596
+ const mpp = adapter.getResolution();
597
+ if (mpp === void 0 || mpp <= 0) return measure;
598
+ if (!measureUsesAdapterResolution(measure)) return measure;
599
+ return {
600
+ ...measure,
601
+ options: {
602
+ ...measure.options,
603
+ metersPerPixel: mpp
604
+ }
605
+ };
606
+ }
607
+ /**
608
+ * Capture draw-time label sizing options on commit. These are stable feature
609
+ * style inputs; adapters scale from them without TacticalDraw re-rendering on
610
+ * every zoom.
611
+ */
612
+ function withCommittedAdapterMeasureOptions(measure, adapter) {
613
+ if (!getControlMeasureMetadata(measure.kind).capturesLabelSize) return measure;
614
+ if (typeof measure.options?.labelSizePixels === "number") return measure;
615
+ const zoom = adapter.getZoom();
616
+ const resolution = adapter.getResolution();
617
+ return {
618
+ ...measure,
619
+ options: {
620
+ ...measure.options,
621
+ labelSizePixels: 14,
622
+ ...zoom !== void 0 ? { labelSizeZoom: zoom } : {},
623
+ ...resolution !== void 0 && resolution > 0 ? { labelSizeResolution: resolution } : {}
624
+ }
625
+ };
626
+ }
627
+ /**
628
+ * Render a measure with the adapter's resolution injected. The single seam for
629
+ * "turn a measure into features against the live map" — every TacticalDraw
630
+ * layer write (graphics reconcile, draw preview, edit preview, commit snapshot)
631
+ * routes through here so pixel sizing is resolved identically everywhere and no
632
+ * call site can forget the injection. See [[withAdapterMeasureOptions]].
633
+ */
634
+ function renderMeasureForAdapter(adapter, measure, opts) {
635
+ return renderControlMeasure(withAdapterMeasureOptions(measure, adapter), opts);
636
+ }
637
+ //#endregion
638
+ //#region src/tactical-draw/run-interaction.ts
639
+ function runInteraction(spec) {
640
+ const { adapter, signal, layers, onViewChange, onSettled, attach } = spec;
641
+ if (signal?.aborted) {
642
+ const error = new TacticalDrawAbortError("signal", { cause: signal.reason });
643
+ const promise = Promise.reject(error);
644
+ promise.catch(() => {});
645
+ return {
646
+ promise,
647
+ settled: true,
648
+ abort: () => {}
649
+ };
650
+ }
651
+ let settled = false;
652
+ const combined = combineWithHostSignal(signal);
653
+ let resolveFn;
654
+ let rejectFn;
655
+ const promise = new Promise((resolve, reject) => {
656
+ resolveFn = resolve;
657
+ rejectFn = reject;
658
+ });
659
+ const idsByLayer = /* @__PURE__ */ new Map();
660
+ for (const layer of layers) idsByLayer.set(layer, /* @__PURE__ */ new Set());
661
+ let controllerDetach = null;
662
+ let controllerOnAbort = null;
663
+ function writeLayer(layer, features) {
664
+ adapter.updateLayerFeatures(layer, features);
665
+ const set = idsByLayer.get(layer);
666
+ if (!set) return;
667
+ set.clear();
668
+ for (const f of features) if (typeof f.id === "string") set.add(f.id);
669
+ }
670
+ function clearLayers() {
671
+ for (const [layer, set] of idsByLayer) {
672
+ if (set.size === 0) continue;
673
+ adapter.removeFeatures(layer, [...set]);
674
+ set.clear();
675
+ }
676
+ }
677
+ const handleKeydown = (event) => {
678
+ if (settled) return;
679
+ if (event.key !== "Escape") return;
680
+ fail(new TacticalDrawAbortError("escape"));
681
+ };
682
+ function teardown() {
683
+ if (controllerDetach) controllerDetach();
684
+ if (onViewChange) adapter.offViewChange(onViewChange);
685
+ if (typeof window !== "undefined") window.removeEventListener("keydown", handleKeydown);
686
+ combined.dispose();
687
+ clearLayers();
688
+ }
689
+ function commit(produce) {
690
+ if (settled) return;
691
+ let value;
692
+ try {
693
+ value = produce();
694
+ } catch (err) {
695
+ settled = true;
696
+ teardown();
697
+ rejectFn(err);
698
+ if (onSettled) onSettled();
699
+ return;
700
+ }
701
+ settled = true;
702
+ teardown();
703
+ resolveFn(value);
704
+ if (onSettled) onSettled();
705
+ }
706
+ function fail(error) {
707
+ if (settled) return;
708
+ settled = true;
709
+ teardown();
710
+ if (controllerOnAbort) try {
711
+ controllerOnAbort();
712
+ } catch {}
713
+ rejectFn(error);
714
+ if (onSettled) onSettled();
715
+ }
716
+ const ctx = {
717
+ get settled() {
718
+ return settled;
719
+ },
720
+ commit,
721
+ fail,
722
+ writeLayer,
723
+ onTeardown(detach, onAbort) {
724
+ controllerDetach = detach;
725
+ controllerOnAbort = onAbort ?? null;
726
+ }
727
+ };
728
+ combined.signal.addEventListener("abort", () => {
729
+ if (settled) return;
730
+ const reason = combined.signal.reason;
731
+ if (reason instanceof TacticalDrawAbortError) fail(reason);
732
+ else fail(new TacticalDrawAbortError("signal", { cause: reason }));
733
+ }, { once: true });
734
+ if (onViewChange) adapter.onViewChange(onViewChange);
735
+ if (typeof window !== "undefined") window.addEventListener("keydown", handleKeydown);
736
+ attach(ctx);
737
+ return {
738
+ promise,
739
+ get settled() {
740
+ return settled;
741
+ },
742
+ abort(reason, cause) {
743
+ combined.abort(reason, cause);
744
+ }
745
+ };
746
+ }
747
+ /**
748
+ * Round every component of a single position to {@link CONTROL_POINT_PRECISION}
749
+ * places. Handles optional altitude (any trailing components) too.
750
+ */
751
+ function roundControlPoint(point) {
752
+ return point.map((v) => roundToFixed(v, 6));
753
+ }
754
+ /**
755
+ * Round every position in a control-point array, returning a fresh array of
756
+ * fresh positions. Applied at the points where TacticalDraw writes
757
+ * gesture-produced geometry into working / committed state so full-precision
758
+ * floats never reach a stored measure or an emitted snapshot.
759
+ */
760
+ function roundControlPoints(points) {
761
+ return points.map(roundControlPoint);
762
+ }
763
+ //#endregion
764
+ //#region src/tactical-draw/draw-controller.ts
765
+ const PREVIEW_CM_ID$1 = "__td_preview__";
766
+ const PREVIEW_CURSOR$1 = "crosshair";
767
+ /**
768
+ * Drive an interactive fixed-length draw against a `MapAdapter`.
769
+ *
770
+ * Caller is the `TacticalDraw` façade. The controller owns:
771
+ * - control-point accumulation via `click`
772
+ * - rubber-band preview via `pointermove`
773
+ * - Escape cancellation via `keydown`
774
+ * - cursor capture / restore for the duration of the draw
775
+ * - host `AbortSignal` plumbing and reason mapping
776
+ *
777
+ * Auto-commits when the user clicks the final required point — fixed-length
778
+ * kinds need no host UI.
779
+ */
780
+ function runFixedLengthDraw(options) {
781
+ const { adapter, kind, previewLayer, graphicsStyle, generateId, signal, measureOptions, measureStyle, measureProperties, sizeAnchor, onSettled } = options;
782
+ const metadata = getControlMeasureMetadata(kind);
783
+ const rule = metadata.rule;
784
+ const min = metadata.minCoordinates;
785
+ const max = metadata.maxCoordinates;
786
+ let required;
787
+ if (rule) required = rule.minimumUserPoints;
788
+ else if (typeof min === "number" && typeof max === "number" && min === max) required = min;
789
+ else throw new Error(`runFixedLengthDraw: kind "${kind}" is not fixed-length (min=${min}, max=${max})`);
790
+ const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
791
+ const committed = [];
792
+ let rubberBand;
793
+ let ctx;
794
+ const savedCursor = "";
795
+ const handleClick = (event) => {
796
+ if (ctx.settled) return;
797
+ committed.push(event.coordinate);
798
+ rubberBand = void 0;
799
+ if (committed.length >= required) {
800
+ ctx.commit(buildSnapshot);
801
+ return;
802
+ }
803
+ renderPreview();
804
+ };
805
+ const handlePointerMove = (event) => {
806
+ if (ctx.settled) return;
807
+ if (committed.length === 0) return;
808
+ rubberBand = event.coordinate;
809
+ renderPreview();
810
+ };
811
+ function renderPreview() {
812
+ const raw = rubberBand !== void 0 ? [...committed, rubberBand] : [...committed];
813
+ if (raw.length === 0) {
814
+ ctx.writeLayer(previewLayer, []);
815
+ return;
816
+ }
817
+ const previewThreshold = rule?.minimumPreviewPoints ?? required;
818
+ const features = buildPreviewFeatures$1(adapter, kind, rule && raw.length >= previewThreshold ? rule.derive(raw) : raw, measureOptions, measureStyle, renderOpts);
819
+ ctx.writeLayer(previewLayer, features);
820
+ }
821
+ function rescaleOnViewChange() {
822
+ if (!measureUsesAdapterResolution({
823
+ kind,
824
+ options: measureOptions
825
+ })) return;
826
+ renderPreview();
827
+ }
828
+ function buildSnapshot() {
829
+ const finalPoints = roundControlPoints(rule ? rule.derive(committed) : committed);
830
+ const captured = withCommittedAdapterMeasureOptions({
831
+ id: generateId(),
832
+ kind,
833
+ controlPoints: finalPoints,
834
+ ...measureOptions !== void 0 ? { options: measureOptions } : {},
835
+ ...measureStyle !== void 0 ? { style: measureStyle } : {},
836
+ ...measureProperties !== void 0 ? { properties: measureProperties } : {}
837
+ }, adapter);
838
+ const measure = sizeAnchor === "screen" ? captured : bakePixelSizeToGround(captured, adapter);
839
+ return {
840
+ measure: cloneControlMeasure(measure),
841
+ render: renderMeasureForAdapter(adapter, measure, renderOpts)
842
+ };
843
+ }
844
+ return runInteraction({
845
+ adapter,
846
+ signal,
847
+ layers: [previewLayer],
848
+ onViewChange: rescaleOnViewChange,
849
+ onSettled,
850
+ attach(c) {
851
+ ctx = c;
852
+ c.onTeardown(() => {
853
+ adapter.off("click", handleClick);
854
+ adapter.off("pointermove", handlePointerMove);
855
+ adapter.setCursor(savedCursor);
856
+ });
857
+ adapter.setCursor(PREVIEW_CURSOR$1);
858
+ adapter.on("click", handleClick);
859
+ adapter.on("pointermove", handlePointerMove);
860
+ }
861
+ });
862
+ }
863
+ /**
864
+ * Produce preview features for the partial geometry. Tries the full renderer
865
+ * first so the preview looks like the real symbol once enough points are in;
866
+ * falls back to a plain Point/LineString outline when the kind's generator
867
+ * cannot produce a shape yet (under-min count).
868
+ */
869
+ function buildPreviewFeatures$1(adapter, kind, points, measureOptions, measureStyle, renderOpts) {
870
+ const measure = {
871
+ id: PREVIEW_CM_ID$1,
872
+ kind,
873
+ controlPoints: points,
874
+ ...measureOptions !== void 0 ? { options: measureOptions } : {},
875
+ ...measureStyle !== void 0 ? { style: measureStyle } : {}
876
+ };
877
+ try {
878
+ return renderMeasureForAdapter(adapter, measure, {
879
+ ...renderOpts,
880
+ validationMode: "throw"
881
+ }).features;
882
+ } catch {
883
+ return outlinePreview$1(points);
884
+ }
885
+ }
886
+ function outlinePreview$1(points) {
887
+ const features = [];
888
+ for (let i = 0; i < points.length; i++) {
889
+ const pt = {
890
+ type: "Feature",
891
+ id: `${PREVIEW_CM_ID$1}:vertex:${i}`,
892
+ geometry: {
893
+ type: "Point",
894
+ coordinates: points[i]
895
+ },
896
+ properties: {
897
+ part: "vertex",
898
+ index: i
899
+ }
900
+ };
901
+ features.push(pt);
902
+ }
903
+ return features;
904
+ }
905
+ //#endregion
906
+ //#region src/tactical-draw/guide.ts
907
+ /**
908
+ * Stable preview-layer ids and emission rules for the rubber-band guide
909
+ * (ADR-0004). Shared between `td.draw` and `td.edit` so hosts can apply a
910
+ * single styling rule across both interactions.
911
+ */
912
+ const PREVIEW_GUIDE_FEATURE_PREFIX = "__td_preview__";
913
+ const PREVIEW_GUIDE_OPEN_ID = `${PREVIEW_GUIDE_FEATURE_PREFIX}:guide:open`;
914
+ const PREVIEW_GUIDE_CLOSING_ID = `${PREVIEW_GUIDE_FEATURE_PREFIX}:guide:closing`;
915
+ /**
916
+ * A kind is fixed-length when its draw rule pins the slot count
917
+ * (`canonicalPointCount`) or its metadata declares `min === max`. The
918
+ * generator preview is already authoritative for these, so the rubber-band
919
+ * guide adds no information.
920
+ */
921
+ function isFixedLength(metadata) {
922
+ if (metadata.rule?.canonicalPointCount !== void 0) return true;
923
+ const min = metadata.minCoordinates;
924
+ const max = metadata.maxCoordinates;
925
+ return typeof min === "number" && typeof max === "number" && min === max;
926
+ }
927
+ /**
928
+ * Guide is mechanically suppressed for kinds where it carries no information:
929
+ * point kinds and fixed-length kinds. Independent of the host's `guide`
930
+ * opt-out.
931
+ */
932
+ function shouldSuppressGuide(metadata) {
933
+ return metadata.geometry === "point" || isFixedLength(metadata);
934
+ }
935
+ /**
936
+ * Build the guide LineString features for a point list.
937
+ * - Open polyline through `points` once `points.length >= 2`.
938
+ * - Closing segment `[last, first]` for area kinds once `points.length >= 3`.
939
+ *
940
+ * Callers are responsible for the opt-out (`guide: false`) and the suppression
941
+ * check via [[shouldSuppressGuide]] — this function only emits geometry.
942
+ */
943
+ function buildGuideFeatures(points, isAreaKind, trailingFixedSlots = 0) {
944
+ const features = [];
945
+ const spine = trailingFixedSlots > 0 ? points.slice(0, points.length - trailingFixedSlots) : points;
946
+ if (spine.length >= 2) {
947
+ const open = {
948
+ type: "Feature",
949
+ id: PREVIEW_GUIDE_OPEN_ID,
950
+ geometry: {
951
+ type: "LineString",
952
+ coordinates: spine.slice()
953
+ },
954
+ properties: {
955
+ part: "guide",
956
+ segment: "open"
957
+ }
958
+ };
959
+ features.push(open);
960
+ }
961
+ if (isAreaKind && spine.length >= 3) {
962
+ const closing = {
963
+ type: "Feature",
964
+ id: PREVIEW_GUIDE_CLOSING_ID,
965
+ geometry: {
966
+ type: "LineString",
967
+ coordinates: [spine[spine.length - 1], spine[0]]
968
+ },
969
+ properties: {
970
+ part: "guide",
971
+ segment: "closing"
972
+ }
973
+ };
974
+ features.push(closing);
975
+ }
976
+ return features;
977
+ }
978
+ //#endregion
979
+ //#region src/tactical-draw/draw-controller-variable.ts
980
+ const PREVIEW_CM_ID = "__td_preview__";
981
+ const PREVIEW_CURSOR = "crosshair";
982
+ /**
983
+ * Drive an interactive variable-length draw against a `MapAdapter`.
984
+ *
985
+ * Surfaces a scoped `DrawSession` via `opts.onSession`. The session owns the
986
+ * commit decision (`session.commit()`), with `dblclick` and "click the last
987
+ * added point again" as additional gesture-level commit triggers.
988
+ */
989
+ function runVariableLengthDraw(options) {
990
+ const { adapter, kind, previewLayer, guideLayer, graphicsStyle, generateId, signal, onSession, seed, measureOptions, measureStyle, measureProperties, guide: guideOpt, sizeAnchor, onSettled } = options;
991
+ const metadata = getControlMeasureMetadata(kind);
992
+ if (typeof metadata.minCoordinates !== "number") throw new Error(`runVariableLengthDraw: kind "${kind}" has no minCoordinates metadata`);
993
+ const min = metadata.minCoordinates;
994
+ const max = metadata.maxCoordinates;
995
+ const rule = metadata.rule;
996
+ const trailingFixed = rule?.trailingFixedSlots ?? 0;
997
+ const ruleMin = rule?.minimumUserPoints;
998
+ const guideEnabled = (guideOpt ?? true) && !shouldSuppressGuide(metadata);
999
+ const isAreaKind = metadata.geometry === "area";
1000
+ function canonicalFormed(len) {
1001
+ if (!rule || ruleMin === void 0) return false;
1002
+ return len >= trailingFixed + ruleMin;
1003
+ }
1004
+ function insertionIndex(len) {
1005
+ return canonicalFormed(len) ? len - trailingFixed : len;
1006
+ }
1007
+ const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
1008
+ const initialPoints = [];
1009
+ if (seed) {
1010
+ for (const pos of seed) {
1011
+ if (!isValidPosition(pos)) throw new TypeError(`td.draw: seed contains invalid coordinate ${JSON.stringify(pos)}`);
1012
+ initialPoints.push([...pos]);
1013
+ }
1014
+ if (typeof max === "number" && initialPoints.length > max) throw new RangeError(`td.draw: seed has ${initialPoints.length} points but kind "${kind}" allows at most ${max}`);
1015
+ if (rule && initialPoints.length > 0) {
1016
+ const derived = rule.derive(initialPoints);
1017
+ initialPoints.splice(0, initialPoints.length, ...derived);
1018
+ }
1019
+ if (initialPoints.length >= min) renderMeasureForAdapter(adapter, {
1020
+ id: PREVIEW_CM_ID,
1021
+ kind,
1022
+ controlPoints: initialPoints.map((p) => [...p]),
1023
+ ...measureOptions !== void 0 ? { options: measureOptions } : {},
1024
+ ...measureStyle !== void 0 ? { style: measureStyle } : {}
1025
+ }, {
1026
+ ...renderOpts,
1027
+ validationMode: "throw"
1028
+ });
1029
+ }
1030
+ const committed = initialPoints;
1031
+ let rubberBand;
1032
+ let ctx;
1033
+ const savedCursor = "";
1034
+ const changeListeners = /* @__PURE__ */ new Set();
1035
+ function canCommitNow() {
1036
+ const n = committed.length;
1037
+ if (typeof max === "number" && n > max) return false;
1038
+ if (n < min) return false;
1039
+ if (rule && ruleMin !== void 0 && n - trailingFixed < ruleMin) return false;
1040
+ return true;
1041
+ }
1042
+ const session = {
1043
+ get controlPoints() {
1044
+ return committed.map((p) => [...p]);
1045
+ },
1046
+ get canCommit() {
1047
+ return !ctx.settled && canCommitNow();
1048
+ },
1049
+ get minControlPoints() {
1050
+ return min;
1051
+ },
1052
+ get maxControlPoints() {
1053
+ return max;
1054
+ },
1055
+ commit() {
1056
+ if (ctx.settled) return false;
1057
+ if (!canCommitNow()) return false;
1058
+ ctx.commit(buildSnapshot);
1059
+ return true;
1060
+ },
1061
+ abort() {
1062
+ if (ctx.settled) return;
1063
+ ctx.fail(new TacticalDrawAbortError("session"));
1064
+ },
1065
+ onChange(listener) {
1066
+ if (ctx.settled) return () => {};
1067
+ changeListeners.add(listener);
1068
+ return () => {
1069
+ changeListeners.delete(listener);
1070
+ };
1071
+ }
1072
+ };
1073
+ function fireChange() {
1074
+ if (ctx.settled) return;
1075
+ const errors = [];
1076
+ for (const listener of [...changeListeners]) try {
1077
+ listener(session);
1078
+ } catch (err) {
1079
+ errors.push(err);
1080
+ }
1081
+ if (errors.length === 1) throw errors[0];
1082
+ if (errors.length > 1) throw new AggregateError(errors, "DrawSession.onChange listener errors");
1083
+ }
1084
+ const handleClick = (event) => {
1085
+ if (ctx.settled) return;
1086
+ const pos = roundControlPoint(event.coordinate);
1087
+ const lastSpineIdx = committed.length - 1 - trailingFixed;
1088
+ for (let i = 0; i < committed.length; i++) if (positionsEqual(pos, committed[i])) {
1089
+ if (i === lastSpineIdx && canCommitNow()) ctx.commit(buildSnapshot);
1090
+ return;
1091
+ }
1092
+ if (lastSpineIdx >= 0 && canCommitNow()) {
1093
+ const lastPixel = adapter.getPixelFromCoordinate(committed[lastSpineIdx]);
1094
+ if (lastPixel) {
1095
+ const tol = mapEventHitTolerance(event.originalEvent);
1096
+ const dx = event.pixel[0] - lastPixel[0];
1097
+ const dy = event.pixel[1] - lastPixel[1];
1098
+ if (dx * dx + dy * dy <= tol * tol) {
1099
+ ctx.commit(buildSnapshot);
1100
+ return;
1101
+ }
1102
+ }
1103
+ }
1104
+ if (typeof max === "number" && committed.length >= max) return;
1105
+ const idx = insertionIndex(committed.length);
1106
+ committed.splice(idx, 0, [...pos]);
1107
+ if (rule) {
1108
+ const derived = rule.derive(committed);
1109
+ committed.splice(0, committed.length, ...derived);
1110
+ }
1111
+ committed.splice(0, committed.length, ...roundControlPoints(committed));
1112
+ rubberBand = void 0;
1113
+ renderPreview();
1114
+ try {
1115
+ fireChange();
1116
+ } catch (err) {
1117
+ ctx.fail(err);
1118
+ }
1119
+ };
1120
+ const handleDblClick = (event) => {
1121
+ if (ctx.settled) return;
1122
+ if (canCommitNow()) ctx.commit(buildSnapshot);
1123
+ };
1124
+ const handlePointerMove = (event) => {
1125
+ if (ctx.settled) return;
1126
+ if (committed.length === 0) return;
1127
+ rubberBand = event.coordinate;
1128
+ renderPreview();
1129
+ };
1130
+ function renderPreview() {
1131
+ let points;
1132
+ if (rubberBand !== void 0) {
1133
+ points = [...committed];
1134
+ const idx = insertionIndex(points.length);
1135
+ points.splice(idx, 0, rubberBand);
1136
+ } else points = [...committed];
1137
+ if (rule) points = rule.derive(points);
1138
+ const previewFeatures = points.length === 0 ? [] : buildPreviewFeatures(adapter, kind, points, measureOptions, measureStyle, renderOpts);
1139
+ ctx.writeLayer(previewLayer, previewFeatures);
1140
+ const guideFeatures = points.length === 0 || !guideEnabled ? [] : buildGuideFeatures(points, isAreaKind, trailingFixed);
1141
+ ctx.writeLayer(guideLayer, guideFeatures);
1142
+ }
1143
+ function rescaleOnViewChange() {
1144
+ if (!measureUsesAdapterResolution({
1145
+ kind,
1146
+ options: measureOptions
1147
+ })) return;
1148
+ renderPreview();
1149
+ }
1150
+ function buildSnapshot() {
1151
+ const built = {
1152
+ id: generateId(),
1153
+ kind,
1154
+ controlPoints: committed.map((p) => [...p]),
1155
+ ...measureOptions !== void 0 ? { options: measureOptions } : {},
1156
+ ...measureStyle !== void 0 ? { style: measureStyle } : {},
1157
+ ...measureProperties !== void 0 ? { properties: measureProperties } : {}
1158
+ };
1159
+ const measure = sizeAnchor === "screen" ? built : bakePixelSizeToGround(built, adapter);
1160
+ return {
1161
+ measure: cloneControlMeasure(measure),
1162
+ render: renderMeasureForAdapter(adapter, measure, renderOpts)
1163
+ };
1164
+ }
1165
+ return runInteraction({
1166
+ adapter,
1167
+ signal,
1168
+ layers: [previewLayer, guideLayer],
1169
+ onViewChange: rescaleOnViewChange,
1170
+ onSettled,
1171
+ attach(c) {
1172
+ ctx = c;
1173
+ c.onTeardown(() => {
1174
+ adapter.off("click", handleClick);
1175
+ adapter.off("pointermove", handlePointerMove);
1176
+ adapter.off("dblclick", handleDblClick);
1177
+ adapter.setCursor(savedCursor);
1178
+ });
1179
+ adapter.setCursor(PREVIEW_CURSOR);
1180
+ adapter.on("click", handleClick);
1181
+ adapter.on("pointermove", handlePointerMove);
1182
+ adapter.on("dblclick", handleDblClick);
1183
+ if (committed.length > 0) renderPreview();
1184
+ if (onSession) onSession(session);
1185
+ }
1186
+ });
1187
+ }
1188
+ function positionsEqual(a, b) {
1189
+ if (a.length !== b.length) {}
1190
+ if (a[0] !== b[0] || a[1] !== b[1]) return false;
1191
+ const az = a[2];
1192
+ const bz = b[2];
1193
+ if (az === void 0 && bz === void 0) return true;
1194
+ return az === bz;
1195
+ }
1196
+ function isValidPosition(value) {
1197
+ if (!Array.isArray(value)) return false;
1198
+ if (value.length < 2 || value.length > 3) return false;
1199
+ for (let i = 0; i < value.length; i++) {
1200
+ const n = value[i];
1201
+ if (typeof n !== "number" || !Number.isFinite(n)) return false;
1202
+ }
1203
+ return true;
1204
+ }
1205
+ function buildPreviewFeatures(adapter, kind, points, measureOptions, measureStyle, renderOpts) {
1206
+ const measure = {
1207
+ id: PREVIEW_CM_ID,
1208
+ kind,
1209
+ controlPoints: points,
1210
+ ...measureOptions !== void 0 ? { options: measureOptions } : {},
1211
+ ...measureStyle !== void 0 ? { style: measureStyle } : {}
1212
+ };
1213
+ try {
1214
+ return renderMeasureForAdapter(adapter, measure, renderOpts).features;
1215
+ } catch {
1216
+ return outlinePreview(points);
1217
+ }
1218
+ }
1219
+ function outlinePreview(points) {
1220
+ const features = [];
1221
+ for (let i = 0; i < points.length; i++) {
1222
+ const pt = {
1223
+ type: "Feature",
1224
+ id: `${PREVIEW_CM_ID}:vertex:${i}`,
1225
+ geometry: {
1226
+ type: "Point",
1227
+ coordinates: points[i]
1228
+ },
1229
+ properties: {
1230
+ part: "vertex",
1231
+ index: i
1232
+ }
1233
+ };
1234
+ features.push(pt);
1235
+ }
1236
+ return features;
1237
+ }
1238
+ //#endregion
1239
+ //#region src/tactical-draw/handles.ts
1240
+ /**
1241
+ * Compute the anchor used by translate and rotate handles. The anchor is the
1242
+ * centroid (arithmetic mean) of the control-point list — stable, kind
1243
+ * agnostic, and well-defined for any non-empty point set.
1244
+ */
1245
+ function computeHandleAnchor(controlPoints) {
1246
+ if (controlPoints.length === 0) return null;
1247
+ let sx = 0;
1248
+ let sy = 0;
1249
+ for (const p of controlPoints) {
1250
+ sx += p[0];
1251
+ sy += p[1];
1252
+ }
1253
+ return [sx / controlPoints.length, sy / controlPoints.length];
1254
+ }
1255
+ /**
1256
+ * How far past the outermost control point the rotate grip sits. `1.3` places
1257
+ * it 30% beyond the farthest vertex, clearing the geometry while staying
1258
+ * proportional to the shape's size.
1259
+ */
1260
+ const ROTATE_HANDLE_REACH_FACTOR = 1.3;
1261
+ /**
1262
+ * Position of the rotate grip. Unlike the translate handle — which sits on the
1263
+ * centroid pivot — the rotate handle needs a lever arm, so it is offset from
1264
+ * the centroid along the ray toward the control point farthest from it, just
1265
+ * outside the shape. Anchoring to a *material* point (rather than the
1266
+ * axis-aligned bbox top) keeps this stateless yet glued to the geometry: the
1267
+ * grip orbits with the shape as it rotates, since the farthest vertex rotates
1268
+ * with it. Falls back to the centroid when every point coincides (no
1269
+ * well-defined direction) or the list is empty.
1270
+ */
1271
+ function computeRotateHandlePosition(controlPoints) {
1272
+ const anchor = computeHandleAnchor(controlPoints);
1273
+ if (!anchor) return null;
1274
+ let farthest = null;
1275
+ let maxDistSq = 0;
1276
+ for (const p of controlPoints) {
1277
+ const dx = p[0] - anchor[0];
1278
+ const dy = p[1] - anchor[1];
1279
+ const distSq = dx * dx + dy * dy;
1280
+ if (distSq > maxDistSq) {
1281
+ maxDistSq = distSq;
1282
+ farthest = p;
1283
+ }
1284
+ }
1285
+ if (!farthest) return anchor;
1286
+ return [anchor[0] + (farthest[0] - anchor[0]) * ROTATE_HANDLE_REACH_FACTOR, anchor[1] + (farthest[1] - anchor[1]) * ROTATE_HANDLE_REACH_FACTOR];
1287
+ }
1288
+ /**
1289
+ * Axis-aligned bounding-box center of the control points — the midpoint of
1290
+ * `[minX, maxX] × [minY, maxY]`. Used as the rotation pivot so a shape turns
1291
+ * about its visual middle (matching mainstream drawing tools) rather than the
1292
+ * vertex mean (`computeHandleAnchor`), which is pulled off-center on asymmetric
1293
+ * measures. Returns `null` for an empty list.
1294
+ *
1295
+ * Exported because the rotation pivot is *not* a handle: it is the rotation
1296
+ * center, distinct from the rotate grip's lever-arm position. The edit
1297
+ * controller computes it directly at drag-start. See ADR-0016.
1298
+ */
1299
+ function computeBoundingBoxCenter(controlPoints) {
1300
+ if (controlPoints.length === 0) return null;
1301
+ let minX = Infinity;
1302
+ let minY = Infinity;
1303
+ let maxX = -Infinity;
1304
+ let maxY = -Infinity;
1305
+ for (const p of controlPoints) {
1306
+ if (p[0] < minX) minX = p[0];
1307
+ if (p[0] > maxX) maxX = p[0];
1308
+ if (p[1] < minY) minY = p[1];
1309
+ if (p[1] > maxY) maxY = p[1];
1310
+ }
1311
+ return [(minX + maxX) / 2, (minY + maxY) / 2];
1312
+ }
1313
+ /**
1314
+ * Midpoint of the segment between two positions, in lonLat. The single source
1315
+ * for "where a midpoint handle sits": `buildHandleSet` places the handle here
1316
+ * and the handle-drag FSM (ADR-0017) inserts the new vertex here, so a grabbed
1317
+ * midpoint and the vertex it becomes cannot drift apart.
1318
+ */
1319
+ function segmentMidpoint(a, b) {
1320
+ return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
1321
+ }
1322
+ /**
1323
+ * The single source of truth for edit-handle placement and the
1324
+ * `EditMode → which handles` gating (ADR-0016). Pure and projection-free:
1325
+ * positions are lonLat. Handles are emitted in **pick priority order** —
1326
+ * `[vertices…, midpoints…, translate, rotate]` — so `pickHandle` can return the
1327
+ * first within tolerance and preserve "vertex wins over a pixel-closer
1328
+ * midpoint." Rendering (`toFeatures`) and picking (`pickHandle`) are thin
1329
+ * projections of this set; the drag controller seeds gestures from it too.
1330
+ *
1331
+ * Emits handles only for modes present in `modes`:
1332
+ * - `reshape`: vertex handle at every control point + midpoint handle on
1333
+ * every adjacent pair (subject to `includeMidpoints` / `trailingFixedSlots`);
1334
+ * - `delete`: vertex handles only (tapped to remove), no midpoint/grips;
1335
+ * - `translate`: a single translate handle at the centroid anchor;
1336
+ * - `rotate`: a single rotate grip offset outside the shape.
1337
+ *
1338
+ * `scale` is a reserved enum value rejected at the façade boundary — no handles
1339
+ * are emitted for it here. Returns `[]` for an empty control-point list.
1340
+ */
1341
+ function buildHandleSet(controlPoints, modes, options = {}) {
1342
+ const { includeMidpoints = true, trailingFixedSlots = 0 } = options;
1343
+ const handles = [];
1344
+ const wantsReshape = modes.includes("reshape");
1345
+ if (wantsReshape || modes.includes("delete")) {
1346
+ for (let i = 0; i < controlPoints.length; i++) handles.push({
1347
+ kind: "vertex",
1348
+ index: i,
1349
+ position: [...controlPoints[i]]
1350
+ });
1351
+ if (wantsReshape && includeMidpoints) {
1352
+ const midpointLimit = controlPoints.length - 1 - trailingFixedSlots;
1353
+ for (let i = 0; i < midpointLimit; i++) handles.push({
1354
+ kind: "midpoint",
1355
+ index: i,
1356
+ position: segmentMidpoint(controlPoints[i], controlPoints[i + 1])
1357
+ });
1358
+ }
1359
+ }
1360
+ if (modes.includes("translate")) {
1361
+ const anchor = computeHandleAnchor(controlPoints);
1362
+ if (anchor) handles.push({
1363
+ kind: "translate",
1364
+ index: 0,
1365
+ position: anchor
1366
+ });
1367
+ }
1368
+ if (modes.includes("rotate")) {
1369
+ const rotatePos = computeRotateHandlePosition(controlPoints);
1370
+ if (rotatePos) handles.push({
1371
+ kind: "rotate",
1372
+ index: 0,
1373
+ position: rotatePos
1374
+ });
1375
+ }
1376
+ return handles;
1377
+ }
1378
+ function handleFeatureId(prefix, kind, index) {
1379
+ switch (kind) {
1380
+ case "vertex": return `${prefix}:handle-vertex:${index}`;
1381
+ case "midpoint": return `${prefix}:handle-midpoint:${index}`;
1382
+ case "translate": return `${prefix}:handle-translate`;
1383
+ case "rotate": return `${prefix}:handle-rotate`;
1384
+ }
1385
+ }
1386
+ function handleStyleFor(kind, interactionStyle) {
1387
+ switch (kind) {
1388
+ case "vertex": return interactionStyle?.vertexHandle;
1389
+ case "midpoint": return interactionStyle?.midpointHandle;
1390
+ case "translate": return interactionStyle?.translateHandle;
1391
+ case "rotate": return interactionStyle?.rotateHandle;
1392
+ }
1393
+ }
1394
+ /**
1395
+ * Project a handle set into the GeoJSON features that belong on the handles
1396
+ * layer — the render projection of `buildHandleSet` (ADR-0016). Stable
1397
+ * `featureIdPrefix`-namespaced ids let multiple edits share a handles layer
1398
+ * without colliding; per-slot `interactionStyle` rides on `properties.style`.
1399
+ * Pure: handle positions are copied, never aliased into the output geometry.
1400
+ */
1401
+ function toFeatures(handles, featureIdPrefix, interactionStyle) {
1402
+ return handles.map((handle) => {
1403
+ const style = handleStyleFor(handle.kind, interactionStyle);
1404
+ return {
1405
+ type: "Feature",
1406
+ id: handleFeatureId(featureIdPrefix, handle.kind, handle.index),
1407
+ geometry: {
1408
+ type: "Point",
1409
+ coordinates: [...handle.position]
1410
+ },
1411
+ properties: style ? {
1412
+ handle: handle.kind,
1413
+ index: handle.index,
1414
+ style: { ...style }
1415
+ } : {
1416
+ handle: handle.kind,
1417
+ index: handle.index
1418
+ }
1419
+ };
1420
+ });
1421
+ }
1422
+ function pixelDistance(a, b) {
1423
+ return Math.hypot(a[0] - b[0], a[1] - b[1]);
1424
+ }
1425
+ /**
1426
+ * The pick projection of `buildHandleSet` (ADR-0016): the first handle within
1427
+ * `tolerance` pixels of `pixel`, tested in the set's priority order. Because
1428
+ * `buildHandleSet` orders vertices before midpoints before translate before
1429
+ * rotate, "first within tolerance" preserves the legacy "vertex wins over a
1430
+ * pixel-closer midpoint" behaviour. Returns the full `Handle` so the drag
1431
+ * controller can seed a gesture from its `position` without recomputing
1432
+ * placement. Handles whose position fails to project (off-screen / degenerate
1433
+ * projection) are skipped. Returns `null` when nothing is within tolerance.
1434
+ */
1435
+ function pickHandle(handles, pixel, tolerance, toPixel) {
1436
+ for (const handle of handles) {
1437
+ const projected = toPixel(handle.position);
1438
+ if (projected && pixelDistance(projected, pixel) <= tolerance) return handle;
1439
+ }
1440
+ return null;
1441
+ }
1442
+ //#endregion
1443
+ //#region src/tactical-draw/handle-drag.ts
1444
+ function clonePoints(points) {
1445
+ return points.map((p) => [...p]);
1446
+ }
1447
+ /** Shift every point by `(dx, dy)`, carrying any extra (z / m) dimensions through. */
1448
+ function translatePoints(base, dx, dy) {
1449
+ return base.map((p) => {
1450
+ const out = [p[0] + dx, p[1] + dy];
1451
+ for (let k = 2; k < p.length; k++) out.push(p[k]);
1452
+ return out;
1453
+ });
1454
+ }
1455
+ function angleFromAnchor(anchor, target) {
1456
+ return Math.atan2(target[1] - anchor[1], target[0] - anchor[0]);
1457
+ }
1458
+ /**
1459
+ * Angle of `pixel` around the rotation pivot, measured in screen space so it
1460
+ * matches what the user sees and does. Falls back to lon/lat when the pivot
1461
+ * cannot be projected — exceedingly rare for an on-screen edit.
1462
+ */
1463
+ function rotateAngleAt(pivot, pivotPixel, pixel, projection) {
1464
+ if (pivotPixel) return Math.atan2(pixel[1] - pivotPixel[1], pixel[0] - pivotPixel[0]);
1465
+ return angleFromAnchor(pivot, projection.fromPixel(pixel));
1466
+ }
1467
+ /**
1468
+ * Rotate `points` by `angle` about the pivot in screen space — project each
1469
+ * point to pixels, rotate around the pivot's pixel, unproject — so the result is
1470
+ * a rigid on-screen rotation regardless of latitude / projection. Falls back to
1471
+ * a lon/lat rotation when the pivot (or a point) cannot be projected. Extra
1472
+ * coordinate dimensions (z, m) are carried through untouched.
1473
+ *
1474
+ * Exported because the edit controller's `COMMIT_ROTATE_FOR_TEST` seam commits a
1475
+ * rotate by angle without driving the pointer FSM, and so re-uses this math.
1476
+ */
1477
+ function rotatePointsAround(points, pivot, pivotPixel, angle, projection) {
1478
+ const cos = Math.cos(angle);
1479
+ const sin = Math.sin(angle);
1480
+ if (pivotPixel) return points.map((p) => {
1481
+ const px = projection.toPixel(p);
1482
+ if (!px) return [...p];
1483
+ const dx = px[0] - pivotPixel[0];
1484
+ const dy = px[1] - pivotPixel[1];
1485
+ const ll = projection.fromPixel([pivotPixel[0] + dx * cos - dy * sin, pivotPixel[1] + dx * sin + dy * cos]);
1486
+ const out = [ll[0], ll[1]];
1487
+ for (let k = 2; k < p.length; k++) out.push(p[k]);
1488
+ return out;
1489
+ });
1490
+ const [ax, ay] = pivot;
1491
+ return points.map((p) => {
1492
+ const dx = p[0] - ax;
1493
+ const dy = p[1] - ay;
1494
+ const out = [ax + dx * cos - dy * sin, ay + dx * sin + dy * cos];
1495
+ for (let k = 2; k < p.length; k++) out.push(p[k]);
1496
+ return out;
1497
+ });
1498
+ }
1499
+ /**
1500
+ * Build the drag-start result from a transition's `move` and the points to paint
1501
+ * at drag-start. `end` is derived from `move` here, so the "commit what the last
1502
+ * move would show" invariant holds by construction for every kind.
1503
+ */
1504
+ function begun(kind, initialBase, move) {
1505
+ return {
1506
+ drag: {
1507
+ kind,
1508
+ move,
1509
+ end: (pixel) => move(pixel).points
1510
+ },
1511
+ initialPoints: clonePoints(initialBase)
1512
+ };
1513
+ }
1514
+ /**
1515
+ * Begin a handle drag from a picked handle and the pre-drag control points.
1516
+ * Returns the live FSM plus the points to paint at drag-start (see
1517
+ * [[BegunHandleDrag.initialPoints]]). The grabbed handle's kind selects the
1518
+ * transition; every transition computes its geometry from a private copy of the
1519
+ * pre-drag points, so the returned `move` / `end` snapshots never alias the
1520
+ * caller's working copy.
1521
+ */
1522
+ function beginHandleDrag(args) {
1523
+ const { hit, downPixel, points, projection, rule } = args;
1524
+ const { fromPixel } = projection;
1525
+ const preDragPoints = clonePoints(points);
1526
+ const snap = (next, activePointIndex) => rule ? rule.transform({
1527
+ previous: preDragPoints,
1528
+ next,
1529
+ activePointIndex
1530
+ }) : next;
1531
+ const dragOnePoint = (base, activeIndex) => {
1532
+ const move = (pixel) => {
1533
+ return {
1534
+ points: snap(base.map((p, i) => i === activeIndex ? fromPixel(pixel) : [...p]), activeIndex),
1535
+ rotateGripOverride: null
1536
+ };
1537
+ };
1538
+ return move;
1539
+ };
1540
+ if (hit.kind === "vertex") return begun("vertex", preDragPoints, dragOnePoint(preDragPoints, hit.index));
1541
+ if (hit.kind === "midpoint") {
1542
+ const insertIndex = hit.index + 1;
1543
+ const mid = segmentMidpoint(preDragPoints[hit.index], preDragPoints[hit.index + 1]);
1544
+ const insertedBase = [
1545
+ ...preDragPoints.slice(0, insertIndex),
1546
+ mid,
1547
+ ...preDragPoints.slice(insertIndex)
1548
+ ];
1549
+ return begun("midpoint", insertedBase, dragOnePoint(insertedBase, insertIndex));
1550
+ }
1551
+ if (hit.kind === "translate") {
1552
+ const origin = fromPixel(downPixel);
1553
+ return begun("translate", preDragPoints, (pixel) => {
1554
+ const cur = fromPixel(pixel);
1555
+ return {
1556
+ points: translatePoints(preDragPoints, cur[0] - origin[0], cur[1] - origin[1]),
1557
+ rotateGripOverride: null
1558
+ };
1559
+ });
1560
+ }
1561
+ const pivot = computeBoundingBoxCenter(preDragPoints) ?? [0, 0];
1562
+ const pivotPixel = projection.toPixel(pivot);
1563
+ const startAngle = rotateAngleAt(pivot, pivotPixel, downPixel, projection);
1564
+ const startGrip = hit.position;
1565
+ return begun("rotate", preDragPoints, (pixel) => {
1566
+ const angle = rotateAngleAt(pivot, pivotPixel, pixel, projection) - startAngle;
1567
+ return {
1568
+ points: rotatePointsAround(preDragPoints, pivot, pivotPixel, angle, projection),
1569
+ rotateGripOverride: rotatePointsAround([startGrip], pivot, pivotPixel, angle, projection)[0]
1570
+ };
1571
+ });
1572
+ }
1573
+ //#endregion
1574
+ //#region src/tactical-draw/edit-session.ts
1575
+ /**
1576
+ * Internal test seam — call to simulate a completed gesture against an active
1577
+ * `EditSession`. Public drag plumbing lands in later slices; the contract
1578
+ * tested here is the listener-side emission contract from issue #49.
1579
+ *
1580
+ * @internal
1581
+ */
1582
+ const COMMIT_GESTURE_FOR_TEST = Symbol("EditSession.commitGestureForTest");
1583
+ /**
1584
+ * Internal test seam — simulate a completed translate gesture by passing a
1585
+ * `[dx, dy]` delta. Every control point shifts by the delta; one
1586
+ * `EditChangeEvent` is emitted, matching the issue #50 emission contract.
1587
+ *
1588
+ * @internal
1589
+ */
1590
+ const COMMIT_TRANSLATE_FOR_TEST = Symbol("EditSession.commitTranslateForTest");
1591
+ /**
1592
+ * Internal test seam — simulate a completed rotate gesture by passing an angle
1593
+ * in radians. Every control point rotates around the computed anchor (centroid);
1594
+ * one `EditChangeEvent` is emitted, matching the issue #50 emission contract.
1595
+ *
1596
+ * @internal
1597
+ */
1598
+ const COMMIT_ROTATE_FOR_TEST = Symbol("EditSession.commitRotateForTest");
1599
+ /**
1600
+ * Internal test seam — mark a drag as in-flight. Used to verify that
1601
+ * `setModes()` calls are queued until the drag ends. Public pointer plumbing
1602
+ * lives in later slices.
1603
+ *
1604
+ * @internal
1605
+ */
1606
+ const BEGIN_DRAG_FOR_TEST = Symbol("EditSession.beginDragForTest");
1607
+ /**
1608
+ * Internal test seam — clear the in-flight drag flag without committing. Any
1609
+ * queued `setModes()` call is applied.
1610
+ *
1611
+ * @internal
1612
+ */
1613
+ const END_DRAG_FOR_TEST = Symbol("EditSession.endDragForTest");
1614
+ //#endregion
1615
+ //#region src/tactical-draw/edit-controller.ts
1616
+ /**
1617
+ * Coordinate-space tolerance for `EditSession.dirty`. The working copy is
1618
+ * considered equivalent to the edit-start input when every control-point
1619
+ * component agrees within this threshold. PRD #40 calls out tolerance-based
1620
+ * dirty tracking; the constant lives in one place so future slices can lift
1621
+ * it to an option without touching call sites.
1622
+ */
1623
+ const DIRTY_EPSILON = 1e-9;
1624
+ const HANDLE_HIT_TOLERANCE_PX = 12;
1625
+ const ALLOWED_EDIT_MODES = new Set([
1626
+ "reshape",
1627
+ "translate",
1628
+ "rotate",
1629
+ "delete"
1630
+ ]);
1631
+ /**
1632
+ * Drive an interactive edit against a `MapAdapter`. Implements the first
1633
+ * edit slice (issue #48):
1634
+ *
1635
+ * - lift-original (remove from graphics layer) + working copy
1636
+ * - preview + handle rendering for `reshape` mode
1637
+ * - dirty tracking via [[DIRTY_EPSILON]] tolerance
1638
+ * - default click-away close policy
1639
+ * - close / abort / signal / preempt / destroy paths
1640
+ *
1641
+ * Pointer-driven vertex / midpoint drag is intentionally NOT wired here —
1642
+ * gesture binding lands with the change-event spec in #49 and the
1643
+ * translate/rotate handle set in #50.
1644
+ */
1645
+ function runEdit(opts) {
1646
+ const { adapter, measure, graphicsLayer, previewLayer, guideLayer, handlesLayer, graphicsStyle, signal, closeOnClickAway = true, modes: initialModes, interactionStyle, onSession, handleIdPrefix, guide: guideOpt, restoreOriginalToGraphics, wasOnGraphicsLayer, onSettled, onClickAwayGraphicsPick } = opts;
1647
+ const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
1648
+ let sizeAnchor = opts.sizeAnchor ?? "ground";
1649
+ const metadata = getControlMeasureMetadata(measure.kind);
1650
+ const rule = metadata.rule;
1651
+ const midpointHandlesEnabled = rule?.canonicalPointCount === void 0;
1652
+ const trailingFixedSlots = rule?.trailingFixedSlots ?? 0;
1653
+ const guideEnabled = (guideOpt ?? true) && !shouldSuppressGuide(metadata);
1654
+ const isAreaKind = metadata.geometry === "area";
1655
+ const requestedModes = normaliseModes(initialModes);
1656
+ const editStartPoints = measure.controlPoints.map((p) => [...p]);
1657
+ const workingPoints = editStartPoints.map((p) => [...p]);
1658
+ let workingOptions = { ...measure.options ?? {} };
1659
+ let modes = requestedModes;
1660
+ let dirty = false;
1661
+ let nonGeometryDirty = false;
1662
+ let dragInFlight = false;
1663
+ /**
1664
+ * Set when an Alt+click delete removes the vertex under the pointer, so the
1665
+ * `click` that follows the same pointerup does not read the now-absent handle
1666
+ * as a click-away and close the edit. Consumed by the next `handleClick`.
1667
+ */
1668
+ let suppressClickAway = false;
1669
+ /**
1670
+ * Mutable style snapshot. Initialised from the edit-start input; replaced
1671
+ * when the façade routes an external style change to us via
1672
+ * `notifyExternalMeasure`. `EditSession.measure` stays the immutable
1673
+ * edit-start input per PRD #40 user-story 30.
1674
+ */
1675
+ let currentStyle = measure.style;
1676
+ /**
1677
+ * `setModes()` called during an in-flight drag is queued here and drained
1678
+ * when the gesture ends — handles must not disappear under the pointer
1679
+ * (PRD #40 user-story-21).
1680
+ */
1681
+ let pendingModes = null;
1682
+ const changeListeners = /* @__PURE__ */ new Set();
1683
+ let ctx;
1684
+ let driver;
1685
+ const dragProjection = {
1686
+ toPixel: (lonLat) => driver.toPixel(lonLat),
1687
+ fromPixel: (pixel) => driver.fromPixel(pixel)
1688
+ };
1689
+ function liftOriginalFromGraphics() {
1690
+ if (!wasOnGraphicsLayer) return;
1691
+ const originalFeatureIds = renderMeasureForAdapter(adapter, measure, renderOpts).features.map((f) => f.id).filter((id) => typeof id === "string");
1692
+ if (originalFeatureIds.length > 0) adapter.removeFeatures(graphicsLayer, originalFeatureIds);
1693
+ }
1694
+ function renderPreview() {
1695
+ if (ctx.settled) return;
1696
+ const working = {
1697
+ ...measure,
1698
+ style: currentStyle,
1699
+ options: workingOptions,
1700
+ controlPoints: workingPoints.map((p) => [...p])
1701
+ };
1702
+ let features;
1703
+ try {
1704
+ features = renderMeasureForAdapter(adapter, working, renderOpts).features;
1705
+ } catch {
1706
+ features = [];
1707
+ }
1708
+ ctx.writeLayer(previewLayer, features);
1709
+ const guideFeatures = guideEnabled ? buildGuideFeatures(workingPoints, isAreaKind, trailingFixedSlots) : [];
1710
+ ctx.writeLayer(guideLayer, guideFeatures);
1711
+ }
1712
+ function rescaleOnViewChange() {
1713
+ if (!measureUsesAdapterResolution({
1714
+ kind: measure.kind,
1715
+ options: workingOptions
1716
+ })) return;
1717
+ renderPreview();
1718
+ }
1719
+ function renderHandlesLayer() {
1720
+ if (ctx.settled) return;
1721
+ let handles = buildHandleSet(workingPoints, modes, {
1722
+ includeMidpoints: midpointHandlesEnabled,
1723
+ trailingFixedSlots
1724
+ });
1725
+ if (activeDrag?.fsm.kind === "rotate" && rotateHandleOverride) {
1726
+ const override = rotateHandleOverride;
1727
+ handles = handles.map((h) => h.kind === "rotate" ? {
1728
+ ...h,
1729
+ position: override
1730
+ } : h);
1731
+ }
1732
+ const features = toFeatures(handles, handleIdPrefix, interactionStyle);
1733
+ ctx.writeLayer(handlesLayer, features);
1734
+ }
1735
+ function rebuildRendered() {
1736
+ const working = {
1737
+ ...measure,
1738
+ style: currentStyle,
1739
+ options: workingOptions,
1740
+ controlPoints: workingPoints.map((p) => [...p])
1741
+ };
1742
+ const committed = sizeAnchor === "screen" ? working : bakePixelSizeToGround(working, adapter);
1743
+ return {
1744
+ measure: cloneControlMeasure(committed),
1745
+ render: renderMeasureForAdapter(adapter, committed, renderOpts)
1746
+ };
1747
+ }
1748
+ function recomputeDirty() {
1749
+ dirty = nonGeometryDirty || !pointsEqualWithinTolerance(workingPoints, editStartPoints, DIRTY_EPSILON);
1750
+ }
1751
+ function replaceWorkingPoints(next) {
1752
+ workingPoints.splice(0, workingPoints.length, ...roundControlPoints(next));
1753
+ }
1754
+ function replaceWorkingOptions(next) {
1755
+ workingOptions = { ...next ?? {} };
1756
+ }
1757
+ /**
1758
+ * Emit a completed gesture to every registered listener. The pre-gesture
1759
+ * snapshot is provided as `previousRendered`; `commitGesture` has already
1760
+ * mutated `workingPoints` and re-rendered preview / handle layers before
1761
+ * this is called.
1762
+ *
1763
+ * Per issue #49:
1764
+ * - All listeners see the same event instance, in registration order.
1765
+ * - `event.reject()` / `event.close()` / `event.abort()` are recorded and
1766
+ * applied after every listener has run (and after exceptions are
1767
+ * collected). `close`/`abort` override `reject` when both are called.
1768
+ * - Listener exceptions are collected and rethrown after all listeners
1769
+ * run (single → as-is, multiple → AggregateError).
1770
+ * - Reject restores the working state to the pre-gesture snapshot and
1771
+ * recomputes `dirty`. Close/abort settle the session as normal.
1772
+ */
1773
+ function emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, previousSizeAnchor, currentRendered) {
1774
+ if (ctx.settled) return;
1775
+ let rejectRequested = false;
1776
+ let closeRequested = false;
1777
+ let abortRequested = false;
1778
+ const event = {
1779
+ measure: currentRendered,
1780
+ previous: previousRendered,
1781
+ session,
1782
+ reject() {
1783
+ rejectRequested = true;
1784
+ },
1785
+ close() {
1786
+ closeRequested = true;
1787
+ },
1788
+ abort() {
1789
+ abortRequested = true;
1790
+ }
1791
+ };
1792
+ const errors = [];
1793
+ for (const listener of [...changeListeners]) try {
1794
+ listener(event);
1795
+ } catch (err) {
1796
+ errors.push(err);
1797
+ }
1798
+ if (abortRequested) ctx.fail(new TacticalDrawAbortError("session"));
1799
+ else if (closeRequested) ctx.commit(rebuildRendered);
1800
+ else if (rejectRequested && !ctx.settled) {
1801
+ replaceWorkingPoints(previousPoints);
1802
+ replaceWorkingOptions(previousOptions);
1803
+ currentStyle = previousStyle;
1804
+ sizeAnchor = previousSizeAnchor;
1805
+ nonGeometryDirty = previousNonGeometryDirty;
1806
+ renderPreview();
1807
+ renderHandlesLayer();
1808
+ recomputeDirty();
1809
+ }
1810
+ if (errors.length === 1) throw errors[0];
1811
+ if (errors.length > 1) throw new AggregateError(errors, "EditSession.onChange listener errors");
1812
+ }
1813
+ /**
1814
+ * Apply a completed gesture: snapshot pre-state, write the new control
1815
+ * points, re-render preview / handles, recompute `dirty`, then emit. Bad
1816
+ * geometry restores the previous state and re-throws synchronously without
1817
+ * notifying listeners.
1818
+ *
1819
+ * A completed gesture also ends the in-flight drag. Any `setModes()` calls
1820
+ * queued during the drag are applied after the listener emission (so the
1821
+ * post-gesture render reflects the requested mode set).
1822
+ */
1823
+ function commitGesture(nextPoints) {
1824
+ if (ctx.settled) return;
1825
+ const previousPoints = workingPoints.map((p) => [...p]);
1826
+ const previousOptions = { ...workingOptions ?? {} };
1827
+ const previousStyle = currentStyle;
1828
+ const previousNonGeometryDirty = nonGeometryDirty;
1829
+ const previousRendered = rebuildRendered();
1830
+ replaceWorkingPoints(nextPoints);
1831
+ let currentRendered;
1832
+ try {
1833
+ currentRendered = rebuildRendered();
1834
+ } catch (err) {
1835
+ replaceWorkingPoints(previousPoints);
1836
+ dragInFlight = false;
1837
+ drainPendingModes();
1838
+ throw err;
1839
+ }
1840
+ renderPreview();
1841
+ renderHandlesLayer();
1842
+ recomputeDirty();
1843
+ dragInFlight = false;
1844
+ drainPendingModes();
1845
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered);
1846
+ }
1847
+ function setOptions(partial) {
1848
+ if (ctx.settled) return;
1849
+ const previousPoints = workingPoints.map((p) => [...p]);
1850
+ const previousOptions = { ...workingOptions ?? {} };
1851
+ const previousStyle = currentStyle;
1852
+ const previousNonGeometryDirty = nonGeometryDirty;
1853
+ const previousRendered = rebuildRendered();
1854
+ replaceWorkingOptions({
1855
+ ...workingOptions ?? {},
1856
+ ...partial ?? {}
1857
+ });
1858
+ let currentRendered;
1859
+ try {
1860
+ currentRendered = rebuildRendered();
1861
+ } catch (err) {
1862
+ replaceWorkingOptions(previousOptions);
1863
+ throw err;
1864
+ }
1865
+ renderPreview();
1866
+ nonGeometryDirty = true;
1867
+ dirty = true;
1868
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered);
1869
+ }
1870
+ function setStyle(partial) {
1871
+ if (ctx.settled) return;
1872
+ const previousPoints = workingPoints.map((p) => [...p]);
1873
+ const previousOptions = { ...workingOptions ?? {} };
1874
+ const previousStyle = currentStyle;
1875
+ const previousNonGeometryDirty = nonGeometryDirty;
1876
+ const previousRendered = rebuildRendered();
1877
+ currentStyle = {
1878
+ ...currentStyle ?? {},
1879
+ ...partial
1880
+ };
1881
+ let currentRendered;
1882
+ try {
1883
+ currentRendered = rebuildRendered();
1884
+ } catch (err) {
1885
+ currentStyle = previousStyle;
1886
+ throw err;
1887
+ }
1888
+ renderPreview();
1889
+ nonGeometryDirty = true;
1890
+ dirty = true;
1891
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered);
1892
+ }
1893
+ /**
1894
+ * Flip the size anchor for the measure under edit (ADR-0020). Re-renders the
1895
+ * emitted snapshot through `rebuildRendered` (which reads `sizeAnchor` live)
1896
+ * and emits a change so hosts see the re-anchored measure. The preview layer
1897
+ * is unaffected — it renders from `workingOptions`, which still carry the
1898
+ * pixel size, so the in-flight symbol stays screen-locked until commit.
1899
+ */
1900
+ function setSizeAnchor(next) {
1901
+ if (ctx.settled || sizeAnchor === next) return;
1902
+ const previousPoints = workingPoints.map((p) => [...p]);
1903
+ const previousOptions = { ...workingOptions ?? {} };
1904
+ const previousStyle = currentStyle;
1905
+ const previousNonGeometryDirty = nonGeometryDirty;
1906
+ const previousSizeAnchor = sizeAnchor;
1907
+ const previousRendered = rebuildRendered();
1908
+ sizeAnchor = next;
1909
+ const currentRendered = rebuildRendered();
1910
+ nonGeometryDirty = true;
1911
+ dirty = true;
1912
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, previousSizeAnchor, currentRendered);
1913
+ }
1914
+ /** Apply a translate gesture by shifting every working point by `delta`. */
1915
+ function commitTranslate(delta) {
1916
+ if (ctx.settled) return;
1917
+ const [dx, dy] = delta;
1918
+ commitGesture(workingPoints.map((p) => {
1919
+ const out = [p[0] + dx, p[1] + dy];
1920
+ for (let i = 2; i < p.length; i++) out.push(p[i]);
1921
+ return out;
1922
+ }));
1923
+ }
1924
+ /**
1925
+ * Apply a rotate gesture by rotating every working point by `angle` (radians)
1926
+ * around the bounding-box center. The rotation runs in screen space (see
1927
+ * `rotatePointsAround`) so it stays rigid and matches the pointer 1:1 at any
1928
+ * latitude.
1929
+ */
1930
+ function commitRotate(angle) {
1931
+ if (ctx.settled) return;
1932
+ const pivot = computeBoundingBoxCenter(workingPoints);
1933
+ if (!pivot) return;
1934
+ commitGesture(rotatePointsAround(workingPoints, pivot, driver.toPixel(pivot), angle, dragProjection));
1935
+ }
1936
+ function modesEqual(a, b) {
1937
+ if (a.length !== b.length) return false;
1938
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
1939
+ return true;
1940
+ }
1941
+ function applyModes(next) {
1942
+ if (modesEqual(modes, next)) return;
1943
+ modes = next;
1944
+ renderHandlesLayer();
1945
+ }
1946
+ function drainPendingModes() {
1947
+ if (pendingModes === null) return;
1948
+ const next = pendingModes;
1949
+ pendingModes = null;
1950
+ applyModes(next);
1951
+ }
1952
+ const session = {
1953
+ measure,
1954
+ get controlPoints() {
1955
+ return workingPoints.map((p) => [...p]);
1956
+ },
1957
+ get options() {
1958
+ return { ...workingOptions ?? {} };
1959
+ },
1960
+ get style() {
1961
+ return currentStyle ? { ...currentStyle } : void 0;
1962
+ },
1963
+ get dirty() {
1964
+ return dirty;
1965
+ },
1966
+ get modes() {
1967
+ return modes;
1968
+ },
1969
+ get sizeAnchor() {
1970
+ return sizeAnchor;
1971
+ },
1972
+ close() {
1973
+ if (ctx.settled) return;
1974
+ ctx.commit(rebuildRendered);
1975
+ },
1976
+ abort() {
1977
+ if (ctx.settled) return;
1978
+ ctx.fail(new TacticalDrawAbortError("session"));
1979
+ },
1980
+ setModes(next) {
1981
+ if (ctx.settled) return;
1982
+ const normalised = normaliseModes(next);
1983
+ if (dragInFlight) {
1984
+ pendingModes = normalised;
1985
+ return;
1986
+ }
1987
+ applyModes(normalised);
1988
+ },
1989
+ setOptions(partial) {
1990
+ setOptions(partial);
1991
+ },
1992
+ setStyle(partial) {
1993
+ setStyle(partial);
1994
+ },
1995
+ setSizeAnchor(next) {
1996
+ setSizeAnchor(next);
1997
+ },
1998
+ onChange(listener) {
1999
+ if (ctx.settled) return () => {};
2000
+ changeListeners.add(listener);
2001
+ return () => {
2002
+ changeListeners.delete(listener);
2003
+ };
2004
+ },
2005
+ [COMMIT_GESTURE_FOR_TEST]: (nextPoints) => {
2006
+ commitGesture(nextPoints);
2007
+ },
2008
+ [COMMIT_TRANSLATE_FOR_TEST]: (delta) => {
2009
+ commitTranslate(delta);
2010
+ },
2011
+ [COMMIT_ROTATE_FOR_TEST]: (angle) => {
2012
+ commitRotate(angle);
2013
+ },
2014
+ [BEGIN_DRAG_FOR_TEST]: () => {
2015
+ if (ctx.settled) return;
2016
+ dragInFlight = true;
2017
+ },
2018
+ [END_DRAG_FOR_TEST]: () => {
2019
+ if (ctx.settled) return;
2020
+ dragInFlight = false;
2021
+ drainPendingModes();
2022
+ }
2023
+ };
2024
+ let activeDrag = null;
2025
+ /**
2026
+ * Render-only rotate-grip override produced by the drag FSM: the start grip
2027
+ * rotated rigidly with the gesture so it tracks the pointer instead of hopping
2028
+ * between near-equidistant vertices. Cleared when the drag ends. Only
2029
+ * consulted while a rotate drag is in flight — pick is gated off for the
2030
+ * duration, so the resting handle set is never read against it (ADR-0016).
2031
+ */
2032
+ let rotateHandleOverride = null;
2033
+ /**
2034
+ * Whether the vertex at `index` may be deleted (via Alt+click or in the
2035
+ * exclusive `delete` mode). Deletion is
2036
+ * confined to variable-length measures (the same condition that enables
2037
+ * midpoint inserts — see ADR-0005) and is denied when it would:
2038
+ * - target a trailing fixed slot (e.g. Axis1's width handle), or
2039
+ * - drop the user-spine point count below the rule's `minimumUserPoints`.
2040
+ * Fixed-count measures re-pad on derive, so deleting a vertex there is moot.
2041
+ */
2042
+ function canDeleteVertex(index) {
2043
+ if (!midpointHandlesEnabled) return false;
2044
+ if (!modes.includes("reshape") && !modes.includes("delete")) return false;
2045
+ const spineCount = workingPoints.length - trailingFixedSlots;
2046
+ if (index < 0 || index >= spineCount) return false;
2047
+ const minimum = rule?.minimumUserPoints ?? (isAreaKind ? 3 : 2);
2048
+ return spineCount - 1 >= minimum;
2049
+ }
2050
+ /**
2051
+ * Remove the vertex at `index` and commit through the normal gesture path so
2052
+ * a single `EditChangeEvent` covers the delete and dirty tracking stays
2053
+ * consistent. The rule's `transform` runs with the removed index as the
2054
+ * active point so frame-relative invariants survive the delete: for Axis1
2055
+ * this re-projects the trailing width handle into the new tip→neck frame,
2056
+ * keeping the main-attack arrow's width unchanged even when the tip or neck
2057
+ * is removed. Rules without that concern (Line1) pass the points through.
2058
+ */
2059
+ function deleteVertex(index) {
2060
+ if (ctx.settled) return;
2061
+ const previous = workingPoints.map((p) => [...p]);
2062
+ const spliced = previous.filter((_, i) => i !== index);
2063
+ commitGesture(rule ? rule.transform({
2064
+ previous,
2065
+ next: spliced,
2066
+ activePointIndex: index
2067
+ }) : spliced);
2068
+ }
2069
+ const onDriverDown = (pixel, info) => {
2070
+ if (ctx.settled || dragInFlight) return;
2071
+ const tol = info?.hitTolerance ?? HANDLE_HIT_TOLERANCE_PX;
2072
+ const hit = pickHandle(buildHandleSet(workingPoints, modes, {
2073
+ includeMidpoints: midpointHandlesEnabled,
2074
+ trailingFixedSlots
2075
+ }), pixel, tol, (lonLat) => driver.toPixel(lonLat));
2076
+ if (!hit) return;
2077
+ if (modes.includes("delete")) {
2078
+ if (hit.kind === "vertex" && canDeleteVertex(hit.index)) {
2079
+ deleteVertex(hit.index);
2080
+ suppressClickAway = true;
2081
+ }
2082
+ return;
2083
+ }
2084
+ if (hit.kind === "vertex" && info?.altKey && canDeleteVertex(hit.index)) {
2085
+ deleteVertex(hit.index);
2086
+ suppressClickAway = true;
2087
+ return;
2088
+ }
2089
+ const preDragPoints = workingPoints.map((p) => [...p]);
2090
+ const { drag: fsm, initialPoints } = beginHandleDrag({
2091
+ hit,
2092
+ downPixel: pixel,
2093
+ points: preDragPoints,
2094
+ projection: dragProjection,
2095
+ rule
2096
+ });
2097
+ activeDrag = {
2098
+ fsm,
2099
+ preDragPoints
2100
+ };
2101
+ if (fsm.kind === "midpoint") {
2102
+ workingPoints.splice(0, workingPoints.length, ...initialPoints);
2103
+ renderPreview();
2104
+ renderHandlesLayer();
2105
+ }
2106
+ dragInFlight = true;
2107
+ driver.setDragPanEnabled(false);
2108
+ };
2109
+ function paintDragSnapshot(pixel) {
2110
+ if (!activeDrag) return;
2111
+ const snapshot = activeDrag.fsm.move(pixel);
2112
+ workingPoints.splice(0, workingPoints.length, ...snapshot.points);
2113
+ rotateHandleOverride = snapshot.rotateGripOverride;
2114
+ renderPreview();
2115
+ renderHandlesLayer();
2116
+ recomputeDirty();
2117
+ }
2118
+ const onDriverMove = (pixel) => {
2119
+ if (ctx.settled || !activeDrag) return;
2120
+ paintDragSnapshot(pixel);
2121
+ };
2122
+ const onDriverUp = (pixel) => {
2123
+ if (!activeDrag) return;
2124
+ if (ctx.settled) {
2125
+ activeDrag = null;
2126
+ rotateHandleOverride = null;
2127
+ dragInFlight = false;
2128
+ driver.setDragPanEnabled(true);
2129
+ return;
2130
+ }
2131
+ const { fsm, preDragPoints } = activeDrag;
2132
+ activeDrag = null;
2133
+ rotateHandleOverride = null;
2134
+ driver.setDragPanEnabled(true);
2135
+ const next = fsm.end(pixel);
2136
+ replaceWorkingPoints(preDragPoints);
2137
+ commitGesture(next);
2138
+ };
2139
+ let pickPreview;
2140
+ let pickHandles;
2141
+ let pickGraphics;
2142
+ const handlePickPreview = (e) => {
2143
+ pickPreview = e;
2144
+ };
2145
+ const handlePickHandles = (e) => {
2146
+ pickHandles = e;
2147
+ };
2148
+ const handlePickGraphics = (e) => {
2149
+ pickGraphics = e;
2150
+ };
2151
+ const handleClick = () => {
2152
+ const localPreview = pickPreview;
2153
+ const localHandles = pickHandles;
2154
+ const localGraphics = pickGraphics;
2155
+ pickPreview = void 0;
2156
+ pickHandles = void 0;
2157
+ pickGraphics = void 0;
2158
+ if (ctx.settled) return;
2159
+ if (!closeOnClickAway) return;
2160
+ if (suppressClickAway) {
2161
+ suppressClickAway = false;
2162
+ return;
2163
+ }
2164
+ if (localPreview || localHandles) return;
2165
+ if (localGraphics && localGraphics.id !== measure.id) {
2166
+ ctx.commit(rebuildRendered);
2167
+ if (onClickAwayGraphicsPick) onClickAwayGraphicsPick(localGraphics);
2168
+ return;
2169
+ }
2170
+ ctx.commit(rebuildRendered);
2171
+ };
2172
+ const core = runInteraction({
2173
+ adapter,
2174
+ signal,
2175
+ layers: [
2176
+ previewLayer,
2177
+ guideLayer,
2178
+ handlesLayer
2179
+ ],
2180
+ onViewChange: rescaleOnViewChange,
2181
+ onSettled,
2182
+ attach(c) {
2183
+ ctx = c;
2184
+ driver = adapter.createEditPointerDriver();
2185
+ liftOriginalFromGraphics();
2186
+ const unsubDown = driver.onPointerDown(onDriverDown);
2187
+ const unsubMove = driver.onPointerMove(onDriverMove);
2188
+ const unsubUp = driver.onPointerUp(onDriverUp);
2189
+ const unsubPickPreview = adapter.onPick(previewLayer, handlePickPreview);
2190
+ const unsubPickHandles = adapter.onPick(handlesLayer, handlePickHandles);
2191
+ const unsubPickGraphics = adapter.onPick(graphicsLayer, handlePickGraphics);
2192
+ adapter.on("click", handleClick);
2193
+ c.onTeardown(() => {
2194
+ adapter.off("click", handleClick);
2195
+ unsubPickPreview();
2196
+ unsubPickHandles();
2197
+ unsubPickGraphics();
2198
+ unsubDown();
2199
+ unsubMove();
2200
+ unsubUp();
2201
+ if (activeDrag !== null) {
2202
+ driver.setDragPanEnabled(true);
2203
+ activeDrag = null;
2204
+ rotateHandleOverride = null;
2205
+ dragInFlight = false;
2206
+ }
2207
+ driver.dispose();
2208
+ }, wasOnGraphicsLayer ? restoreOriginalToGraphics : void 0);
2209
+ renderPreview();
2210
+ renderHandlesLayer();
2211
+ recomputeDirty();
2212
+ if (onSession) onSession(session);
2213
+ }
2214
+ });
2215
+ return {
2216
+ promise: core.promise,
2217
+ get settled() {
2218
+ return core.settled;
2219
+ },
2220
+ measureId: measure.id,
2221
+ abort(reason, cause) {
2222
+ core.abort(reason, cause);
2223
+ },
2224
+ notifyExternalMeasure(next) {
2225
+ if (core.settled) return;
2226
+ if (stylesEqual(currentStyle, next.style)) return;
2227
+ currentStyle = next.style;
2228
+ renderPreview();
2229
+ }
2230
+ };
2231
+ }
2232
+ function normaliseModes(modes) {
2233
+ if (!modes || modes.length === 0) return ["reshape"];
2234
+ for (const m of modes) {
2235
+ if (m === "scale") throw new TypeError(`td.edit: edit mode "scale" is reserved and not yet implemented`);
2236
+ if (!ALLOWED_EDIT_MODES.has(m)) throw new TypeError(`td.edit: unknown edit mode "${String(m)}"`);
2237
+ }
2238
+ if (modes.includes("delete")) return ["delete"];
2239
+ const seen = /* @__PURE__ */ new Set();
2240
+ const out = [];
2241
+ for (const m of modes) if (!seen.has(m)) {
2242
+ seen.add(m);
2243
+ out.push(m);
2244
+ }
2245
+ return out;
2246
+ }
2247
+ function stylesEqual(a, b) {
2248
+ if (a === b) return true;
2249
+ if (!a || !b) return false;
2250
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
2251
+ for (const key of keys) if (a[key] !== b[key]) return false;
2252
+ return true;
2253
+ }
2254
+ function pointsEqualWithinTolerance(a, b, epsilon) {
2255
+ if (a.length !== b.length) return false;
2256
+ for (let i = 0; i < a.length; i++) {
2257
+ const pa = a[i];
2258
+ const pb = b[i];
2259
+ if (pa.length !== pb.length) return false;
2260
+ for (let k = 0; k < pa.length; k++) if (Math.abs((pa[k] ?? 0) - (pb[k] ?? 0)) > epsilon) return false;
2261
+ }
2262
+ return true;
2263
+ }
2264
+ //#endregion
2265
+ //#region src/tactical-draw/errors.ts
2266
+ /**
2267
+ * Error thrown when any `TacticalDraw` method is called after `destroy()`.
2268
+ * See PRD #40 / issue #45.
2269
+ */
2270
+ var TacticalDrawDestroyedError = class TacticalDrawDestroyedError extends Error {
2271
+ constructor(message = "TacticalDraw instance has been destroyed") {
2272
+ super(message);
2273
+ this.name = "TacticalDrawDestroyedError";
2274
+ Object.setPrototypeOf(this, TacticalDrawDestroyedError.prototype);
2275
+ }
2276
+ };
2277
+ //#endregion
2278
+ //#region src/tactical-draw/tactical-draw.ts
2279
+ /**
2280
+ * `TacticalDraw` façade — slice #6 (issue #45).
2281
+ *
2282
+ * Provides the constructor + lifecycle + `render(measures)` reconcile path.
2283
+ * Interactive draw and edit live in later slices; the shape is in place so
2284
+ * the surface is stable.
2285
+ */
2286
+ var TacticalDraw = class {
2287
+ adapter;
2288
+ graphicsLayer;
2289
+ previewLayer;
2290
+ guideLayer;
2291
+ handlesLayer;
2292
+ ownedLayers;
2293
+ graphicsStyle;
2294
+ defaultInteractionStyle;
2295
+ generateId;
2296
+ destroyed = false;
2297
+ hasRendered = false;
2298
+ activeInteraction = new ActiveInteractionTracker();
2299
+ /**
2300
+ * Tracked alongside `activeInteraction` whenever the interaction is an edit. The
2301
+ * `render()` path needs the edited measure's id (to exclude it from
2302
+ * reconcile) and a hook to surface external style changes — both live on
2303
+ * `EditHandle` so the controller stays the single owner of edit state.
2304
+ */
2305
+ activeEdit = null;
2306
+ /**
2307
+ * Host pick handlers registered via `td.onPick` / `td.onMeasurePick`. The
2308
+ * façade subscribes once to `adapter.onPick` per layer and routes events
2309
+ * through this map so that close-then-pick ordering during an active edit can
2310
+ * be enforced (issue #52).
2311
+ */
2312
+ hostPickHandlers = /* @__PURE__ */ new Map();
2313
+ adapterPickUnsubs = /* @__PURE__ */ new Map();
2314
+ /**
2315
+ * Captured graphics-layer pick that arrived during an active edit on a
2316
+ * *different* measure. The edit controller closes the active session and
2317
+ * then asks the façade to re-dispatch this pick to host handlers so that
2318
+ * `td.edit(otherMeasure)` from a host pick handler runs as a fresh edit,
2319
+ * not as a programmatic preemption.
2320
+ */
2321
+ deferredGraphicsPick = null;
2322
+ idCounter = 0;
2323
+ editCounter = 0;
2324
+ /**
2325
+ * Snapshot of the host's last `render()` argument. The edit slice uses it
2326
+ * to recompute the graphics-layer state during a lift / restore — the
2327
+ * façade owns the policy because controllers shouldn't need to know the
2328
+ * surrounding collection state.
2329
+ */
2330
+ lastRenderedMeasures = [];
2331
+ constructor(adapter, options) {
2332
+ this.adapter = adapter;
2333
+ this.graphicsStyle = resolveStyleHints(BUILT_IN_GRAPHICS_STYLE, options?.graphicsStyle, void 0);
2334
+ this.defaultInteractionStyle = options?.interactionStyle;
2335
+ this.generateId = options?.generateId ?? (() => `td-${++this.idCounter}`);
2336
+ const owned = /* @__PURE__ */ new Set();
2337
+ const provided = options?.layers ?? {};
2338
+ this.graphicsLayer = resolveSlot(adapter, provided.graphics, owned);
2339
+ this.previewLayer = resolveSlot(adapter, provided.preview, owned);
2340
+ this.guideLayer = resolveSlot(adapter, provided.guide, owned);
2341
+ this.handlesLayer = resolveSlot(adapter, provided.handles, owned);
2342
+ this.ownedLayers = owned;
2343
+ const graphicsLayerStyle = styleToStyleOptions(this.graphicsStyle);
2344
+ this.adapter.setLayerStyle(this.graphicsLayer, graphicsLayerStyle);
2345
+ this.adapter.setLayerStyle(this.previewLayer, graphicsLayerStyle);
2346
+ this.adapter.setLayerStyle(this.guideLayer, guideToStyleOptions(this.baseGuideStyle()));
2347
+ this.adapter.onViewChange(this.handleViewChange);
2348
+ }
2349
+ /**
2350
+ * Render `measures` into features and write them to the graphics layer.
2351
+ * Routes every measure through [[renderMeasureForAdapter]] so pixel sizing is
2352
+ * resolved against the live map. Callers own any edit-exclusion policy — see
2353
+ * `render()` (excludes the edited measure, owned by the preview layer) versus
2354
+ * `restoreOriginalToGraphics` (no exclusion, the edit is ending).
2355
+ */
2356
+ renderMeasuresToGraphics(measures) {
2357
+ const features = [];
2358
+ const renderOpts = { graphicsStyle: this.graphicsStyle };
2359
+ for (const m of measures) {
2360
+ const render = renderMeasureForAdapter(this.adapter, m, renderOpts);
2361
+ for (const f of render.features) features.push(f);
2362
+ }
2363
+ this.adapter.updateLayerFeatures(this.graphicsLayer, features);
2364
+ }
2365
+ /**
2366
+ * Re-render the graphics layer on a view (zoom) change so pixel-denominated
2367
+ * measures rescale live. Skipped entirely when nothing rendered carries a
2368
+ * resolution-dependent size — meter geometry is zoom-independent, so a rebuild
2369
+ * would be wasted layer writes. During an active edit the edited measure lives
2370
+ * on the preview layer (lifted from graphics), so it is excluded here exactly
2371
+ * as in `render()`; the preview's own view-change subscription rescales it.
2372
+ */
2373
+ handleViewChange = () => {
2374
+ if (this.destroyed || !this.hasRendered) return;
2375
+ const editedId = this.activeEdit?.measureId;
2376
+ const visible = editedId !== void 0 ? this.lastRenderedMeasures.filter((m) => m.id !== editedId) : this.lastRenderedMeasures;
2377
+ if (!visible.some(measureUsesAdapterResolution)) return;
2378
+ this.renderMeasuresToGraphics(visible);
2379
+ };
2380
+ /**
2381
+ * Reconcile the graphics layer against the authoritative list of measures.
2382
+ * Idempotent. Throws synchronously on duplicate measure ids *before*
2383
+ * mutating any layer. An empty list before any prior render is a no-op.
2384
+ *
2385
+ * Cross-layer semantics with an active edit (issue #51):
2386
+ * - The measure under edit is **excluded** from the reconcile pass — the
2387
+ * preview layer owns its working copy and the lift on the graphics layer
2388
+ * must stay in place.
2389
+ * - If the edited measure is absent from `measures`, the pending `edit`
2390
+ * promise rejects on the **next microtask** with reason `"removed"`.
2391
+ * `render` itself returns normally so host collection ops stay synchronous.
2392
+ * - If the edited measure is present and its `style` changed, the preview
2393
+ * picks up the new style on this render. `controlPoints` changes on the
2394
+ * edited measure are routed nowhere — the user's in-flight drag is never
2395
+ * yanked out from under them.
2396
+ *
2397
+ * Cross-layer semantics with an active draw: `render` only touches the
2398
+ * graphics layer, so the draw's preview is undisturbed.
2399
+ *
2400
+ * Reentrancy: calling `td.render(...)` from inside `EditSession.onChange` is
2401
+ * safe — `render` never re-enters the active edit or its preview / handle
2402
+ * layers (the only edit-facing side effect, `notifyExternalMeasure`, is a
2403
+ * pure style replay).
2404
+ */
2405
+ render(measures) {
2406
+ this.assertAlive();
2407
+ const seen = /* @__PURE__ */ new Set();
2408
+ for (const m of measures) {
2409
+ if (seen.has(m.id)) throw new Error(`TacticalDraw.render: duplicate control measure id "${m.id}"`);
2410
+ seen.add(m.id);
2411
+ }
2412
+ const editedId = this.activeEdit?.measureId;
2413
+ let externalEdited;
2414
+ let editedRemoved = false;
2415
+ if (editedId !== void 0) {
2416
+ externalEdited = measures.find((m) => m.id === editedId);
2417
+ editedRemoved = externalEdited === void 0;
2418
+ }
2419
+ const visible = editedId !== void 0 ? measures.filter((m) => m.id !== editedId) : measures;
2420
+ if (!(visible.length === 0 && !this.hasRendered)) {
2421
+ this.renderMeasuresToGraphics(visible);
2422
+ this.hasRendered = true;
2423
+ }
2424
+ this.lastRenderedMeasures = measures.slice();
2425
+ if (externalEdited && this.activeEdit) this.activeEdit.notifyExternalMeasure(externalEdited);
2426
+ if (editedRemoved && this.activeEdit) {
2427
+ const editHandle = this.activeEdit;
2428
+ queueMicrotask(() => {
2429
+ editHandle.abort("removed");
2430
+ });
2431
+ }
2432
+ }
2433
+ /**
2434
+ * Nested-call guard + preempt, shared by `draw` and `edit`. Returns a
2435
+ * pre-rejected promise when called re-entrantly from inside another
2436
+ * interaction's `onSession` (the outer is still mid-construction, so the
2437
+ * nested one is preempted synchronously — #52). Otherwise preempts whichever
2438
+ * draw / edit is in flight and returns `null`, signalling the caller to
2439
+ * proceed. One interaction at a time per PRD #40.
2440
+ */
2441
+ guardAndPreempt() {
2442
+ if (this.activeInteraction.isStarting) {
2443
+ const rejected = Promise.reject(new TacticalDrawAbortError("preempted"));
2444
+ rejected.catch(() => {});
2445
+ return rejected;
2446
+ }
2447
+ if (this.activeInteraction.isActive) {
2448
+ this.activeEdit = null;
2449
+ this.activeInteraction.cancel("preempted");
2450
+ }
2451
+ return null;
2452
+ }
2453
+ /**
2454
+ * Reserve the single interaction slot around `create`, shared by `draw` and
2455
+ * `edit`. Reserves the active tracker (and `activeEdit` for edits) synchronously so
2456
+ * `onSettled` callbacks see a coherent state; the handle's settle path calls
2457
+ * the wrapped `onSettled` which clears the slot for us, avoiding the
2458
+ * "promise.then(clearActive)" microtask gap that left the interaction set across
2459
+ * re-dispatched pick handlers. The backstop `.then` covers a controller that
2460
+ * settled synchronously (already-aborted signal returns a pre-settled handle),
2461
+ * whose `onSettled` fired before `active` was assigned. `extraSettle` runs the
2462
+ * caller's own teardown (guide-style revert, dbl-click-zoom re-enable).
2463
+ */
2464
+ runWithSlot(create, editHandleOf, extraSettle) {
2465
+ let handle;
2466
+ let editHandle = null;
2467
+ let settledDuringCreate = false;
2468
+ const clearSlot = () => {
2469
+ if (handle) {
2470
+ this.activeInteraction.clear(handle);
2471
+ if (editHandle && this.activeEdit === editHandle) this.activeEdit = null;
2472
+ } else {
2473
+ settledDuringCreate = true;
2474
+ this.activeInteraction.clear();
2475
+ }
2476
+ };
2477
+ const onSettled = () => {
2478
+ clearSlot();
2479
+ if (extraSettle) extraSettle();
2480
+ };
2481
+ this.activeInteraction.begin();
2482
+ try {
2483
+ handle = create(onSettled);
2484
+ editHandle = editHandleOf ? editHandleOf(handle) : null;
2485
+ } catch (error) {
2486
+ this.activeInteraction.clear();
2487
+ if (!settledDuringCreate && extraSettle) extraSettle();
2488
+ throw error;
2489
+ }
2490
+ if (!settledDuringCreate) {
2491
+ this.activeInteraction.set(handle);
2492
+ if (editHandle && !handle.settled) this.activeEdit = editHandle;
2493
+ }
2494
+ handle.promise.then(clearSlot, clearSlot);
2495
+ return handle.promise;
2496
+ }
2497
+ /**
2498
+ * Wrap a host `onSession` callback so the session is recorded on the tracker
2499
+ * (exposing it via `activeSession`) before the host's own handler runs.
2500
+ */
2501
+ trackSession(hostOnSession) {
2502
+ return (session) => {
2503
+ this.activeInteraction.setSession(session);
2504
+ hostOnSession?.(session);
2505
+ };
2506
+ }
2507
+ /**
2508
+ * Start an interactive draw. Fixed-length kinds commit automatically on the
2509
+ * last required click. Variable-length kinds surface a `DrawSession` via
2510
+ * `opts.onSession` and commit on `session.commit()`, `dblclick`, or a second
2511
+ * click on the last added control point. All paths reject with
2512
+ * `TacticalDrawAbortError` on signal / Escape / destroy / `session.abort()`.
2513
+ */
2514
+ draw(draft, options) {
2515
+ this.assertAlive();
2516
+ const kind = draft.kind;
2517
+ const preempt = this.guardAndPreempt();
2518
+ if (preempt) return preempt;
2519
+ const fixedLength = isFixedLength(getControlMeasureMetadata(kind));
2520
+ const suppressDblclickZoom = !fixedLength && !options?.signal?.aborted;
2521
+ if (suppressDblclickZoom) this.adapter.setDoubleClickZoomEnabled(false);
2522
+ const revertGuideStyle = this.applyGuideStyle(options?.interactionStyle?.guide);
2523
+ const extraSettle = () => {
2524
+ revertGuideStyle();
2525
+ if (suppressDblclickZoom) setTimeout(() => {
2526
+ if (this.destroyed) return;
2527
+ this.adapter.setDoubleClickZoomEnabled(true);
2528
+ }, 0);
2529
+ };
2530
+ return this.runWithSlot((onSettled) => fixedLength ? runFixedLengthDraw({
2531
+ adapter: this.adapter,
2532
+ kind,
2533
+ previewLayer: this.previewLayer,
2534
+ graphicsStyle: this.graphicsStyle,
2535
+ generateId: () => draft.id ?? this.generateId(),
2536
+ signal: options?.signal,
2537
+ measureOptions: draft.options,
2538
+ measureStyle: draft.style,
2539
+ measureProperties: draft.properties,
2540
+ guide: options?.guide,
2541
+ sizeAnchor: options?.sizeAnchor,
2542
+ onSettled
2543
+ }) : runVariableLengthDraw({
2544
+ adapter: this.adapter,
2545
+ kind,
2546
+ previewLayer: this.previewLayer,
2547
+ guideLayer: this.guideLayer,
2548
+ graphicsStyle: this.graphicsStyle,
2549
+ generateId: () => draft.id ?? this.generateId(),
2550
+ signal: options?.signal,
2551
+ onSession: this.trackSession(options?.onSession),
2552
+ seed: draft.controlPoints,
2553
+ measureOptions: draft.options,
2554
+ measureStyle: draft.style,
2555
+ measureProperties: draft.properties,
2556
+ guide: options?.guide,
2557
+ sizeAnchor: options?.sizeAnchor,
2558
+ onSettled
2559
+ }), null, extraSettle);
2560
+ }
2561
+ /**
2562
+ * Start an interactive edit on a single control measure. Returns a Promise
2563
+ * that resolves with a `ControlMeasureSnapshot` of the working state on
2564
+ * `session.close()` and rejects with `TacticalDrawAbortError` on
2565
+ * signal / Escape / destroy / preempt / `session.abort()`.
2566
+ *
2567
+ * Default click-away (`closeOnClickAway: true`) closes the edit on a click
2568
+ * outside the preview / handle features. Hosts that own close policy pass
2569
+ * `false`.
2570
+ */
2571
+ edit(measure, options) {
2572
+ this.assertAlive();
2573
+ if (options?.modes) {
2574
+ for (const m of options.modes) if (m === "scale") throw new TypeError(`td.edit: edit mode "scale" is reserved and not yet implemented`);
2575
+ }
2576
+ const preempt = this.guardAndPreempt();
2577
+ if (preempt) return preempt;
2578
+ const wasOnGraphicsLayer = this.lastRenderedMeasures.some((m) => m.id === measure.id);
2579
+ const restoreOriginalToGraphics = () => {
2580
+ this.renderMeasuresToGraphics(this.lastRenderedMeasures);
2581
+ };
2582
+ const handleIdPrefix = `__td_edit_${++this.editCounter}__`;
2583
+ const mergedInteractionStyle = this.resolveInteractionStyle(options?.interactionStyle);
2584
+ const revertGuideStyle = this.applyGuideStyle(options?.interactionStyle?.guide);
2585
+ const onClickAwayGraphicsPick = (event) => {
2586
+ this.dispatchHostPick(this.graphicsLayer, event);
2587
+ };
2588
+ return this.runWithSlot((onSettled) => runEdit({
2589
+ adapter: this.adapter,
2590
+ measure,
2591
+ graphicsLayer: this.graphicsLayer,
2592
+ previewLayer: this.previewLayer,
2593
+ guideLayer: this.guideLayer,
2594
+ handlesLayer: this.handlesLayer,
2595
+ graphicsStyle: this.graphicsStyle,
2596
+ signal: options?.signal,
2597
+ closeOnClickAway: options?.closeOnClickAway,
2598
+ modes: options?.modes,
2599
+ interactionStyle: mergedInteractionStyle,
2600
+ onSession: this.trackSession(options?.onSession),
2601
+ handleIdPrefix,
2602
+ guide: options?.guide,
2603
+ sizeAnchor: options?.sizeAnchor,
2604
+ restoreOriginalToGraphics,
2605
+ wasOnGraphicsLayer,
2606
+ onSettled,
2607
+ onClickAwayGraphicsPick
2608
+ }), (handle) => handle, revertGuideStyle);
2609
+ }
2610
+ /**
2611
+ * Subscribe to picks on committed control measures.
2612
+ *
2613
+ * The graphics layer is pre-bound, and `event.id` is the originating control
2614
+ * measure id. Events retain the same close-then-pick ordering as
2615
+ * {@link onPick}: when another measure is picked during an active edit, the
2616
+ * current edit closes before the handler runs.
2617
+ *
2618
+ * Returns an idempotent unsubscribe function. An optional `signal` removes
2619
+ * the handler when aborted.
2620
+ */
2621
+ onMeasurePick(handler, opts) {
2622
+ this.assertAlive();
2623
+ return this.onPick(this.graphicsLayer, handler, opts);
2624
+ }
2625
+ /**
2626
+ * Subscribe to pick events on `layerId`. Routed through the façade so the
2627
+ * close-then-pick contract (issue #52) is observable: when an active edit
2628
+ * sees a click on a *different* graphics-layer measure, the edit closes
2629
+ * first and only then are pick events delivered to host handlers — letting
2630
+ * a host `td.edit(other)` from a pick handler run as a fresh edit instead
2631
+ * of a programmatic preemption.
2632
+ *
2633
+ * Returns an unsubscribe function. Optional `signal` mirrors `adapter.onPick`.
2634
+ */
2635
+ onPick(layerId, handler, opts) {
2636
+ this.assertAlive();
2637
+ if (opts?.signal?.aborted) return () => {};
2638
+ let handlers = this.hostPickHandlers.get(layerId);
2639
+ if (!handlers) {
2640
+ handlers = /* @__PURE__ */ new Set();
2641
+ this.hostPickHandlers.set(layerId, handlers);
2642
+ const unsub = this.adapter.onPick(layerId, (event) => {
2643
+ this.routePick(layerId, event);
2644
+ });
2645
+ this.adapterPickUnsubs.set(layerId, unsub);
2646
+ }
2647
+ handlers.add(handler);
2648
+ let removed = false;
2649
+ const remove = () => {
2650
+ if (removed) return;
2651
+ removed = true;
2652
+ const current = this.hostPickHandlers.get(layerId);
2653
+ if (current) current.delete(handler);
2654
+ };
2655
+ opts?.signal?.addEventListener("abort", remove, { once: true });
2656
+ return remove;
2657
+ }
2658
+ /**
2659
+ * Abort the in-flight draw or edit. The default `"session"` reason matches
2660
+ * `DrawSession.abort()` / `EditSession.abort()`.
2661
+ *
2662
+ * Returns `true` when an interaction was active, otherwise `false`.
2663
+ */
2664
+ cancel(reason = "session") {
2665
+ this.assertAlive();
2666
+ return this.activeInteraction.cancel(reason);
2667
+ }
2668
+ /**
2669
+ * The live variable-draw or edit session, or `null` while idle and during
2670
+ * fixed-length draws. Safe to read after `destroy()`.
2671
+ */
2672
+ get activeSession() {
2673
+ return this.activeInteraction.session;
2674
+ }
2675
+ /**
2676
+ * Tear down the façade. Releases only layer slots the façade created; host
2677
+ * supplied layer ids are left untouched. Safe to call multiple times. After
2678
+ * the first call every public method throws `TacticalDrawDestroyedError`.
2679
+ */
2680
+ destroy() {
2681
+ if (this.destroyed) return;
2682
+ this.destroyed = true;
2683
+ if (this.activeInteraction.isActive) {
2684
+ this.activeEdit = null;
2685
+ this.activeInteraction.cancel("destroyed");
2686
+ } else this.activeInteraction.clear();
2687
+ this.adapter.offViewChange(this.handleViewChange);
2688
+ for (const unsub of this.adapterPickUnsubs.values()) unsub();
2689
+ this.adapterPickUnsubs.clear();
2690
+ this.hostPickHandlers.clear();
2691
+ this.deferredGraphicsPick = null;
2692
+ for (const id of this.ownedLayers) this.adapter.removeLayer(id);
2693
+ this.ownedLayers.clear();
2694
+ }
2695
+ /**
2696
+ * Decide whether to deliver `event` to host pick handlers immediately or
2697
+ * defer it until after the active edit closes. The "defer" branch is the
2698
+ * close-then-pick path: the edit controller's click handler will close the
2699
+ * session and then invoke `dispatchHostPick(graphicsLayer, event)` via
2700
+ * `onClickAwayGraphicsPick` to redeliver — guaranteeing `td.edit(other)`
2701
+ * from a host pick handler observes a settled session.
2702
+ */
2703
+ routePick(layerId, event) {
2704
+ if (layerId === this.graphicsLayer && this.activeEdit !== null && this.activeEdit.measureId !== event.id) {
2705
+ this.deferredGraphicsPick = event;
2706
+ return;
2707
+ }
2708
+ this.dispatchHostPick(layerId, event);
2709
+ }
2710
+ dispatchHostPick(layerId, event) {
2711
+ this.deferredGraphicsPick = null;
2712
+ const handlers = this.hostPickHandlers.get(layerId);
2713
+ if (!handlers || handlers.size === 0) return;
2714
+ for (const handler of [...handlers]) handler(event);
2715
+ }
2716
+ /** @internal — exposed for tests and future controllers. */
2717
+ get layerIds() {
2718
+ return {
2719
+ graphics: this.graphicsLayer,
2720
+ preview: this.previewLayer,
2721
+ guide: this.guideLayer,
2722
+ handles: this.handlesLayer
2723
+ };
2724
+ }
2725
+ /**
2726
+ * Apply the resolved guide-slot style for an interaction and return a
2727
+ * revert closure to re-apply the base (built-in + façade) style on settle.
2728
+ * Per-call overrides are scoped to a single interaction (ADR-0004/0006).
2729
+ */
2730
+ applyGuideStyle(perCall) {
2731
+ if (!perCall) return () => {};
2732
+ const merged = mergeSlot(this.baseGuideStyle(), perCall) ?? {};
2733
+ this.adapter.setLayerStyle(this.guideLayer, guideToStyleOptions(merged));
2734
+ return () => {
2735
+ if (this.destroyed) return;
2736
+ this.adapter.setLayerStyle(this.guideLayer, guideToStyleOptions(this.baseGuideStyle()));
2737
+ };
2738
+ }
2739
+ baseGuideStyle() {
2740
+ return mergeSlot(BUILT_IN_INTERACTION_STYLE.guide, this.defaultInteractionStyle?.guide);
2741
+ }
2742
+ /**
2743
+ * Resolve handle slots across built-in → façade → per-call with per-slot,
2744
+ * per-property shallow merge. Explicit `undefined` at a higher-priority
2745
+ * layer unsets the corresponding lower-priority field (ADR-0006).
2746
+ *
2747
+ * Returns `undefined` only when no slot would carry any value — keeps the
2748
+ * downstream "no style on the feature" path intact for tests that assert
2749
+ * the absence of `properties.style`.
2750
+ */
2751
+ resolveInteractionStyle(perCall) {
2752
+ const out = {};
2753
+ for (const slot of HANDLE_SLOTS) {
2754
+ const merged = mergeSlot(mergeSlot(BUILT_IN_INTERACTION_STYLE[slot], this.defaultInteractionStyle?.[slot]), perCall?.[slot]);
2755
+ if (merged) out[slot] = merged;
2756
+ }
2757
+ return out;
2758
+ }
2759
+ assertAlive() {
2760
+ if (this.destroyed) throw new TacticalDrawDestroyedError();
2761
+ }
2762
+ };
2763
+ /**
2764
+ * Built-in default graphics style. Ensures OL / MapLibre / Leaflet render
2765
+ * identically when neither host nor measure supplies a style — without it,
2766
+ * each engine falls through to its own native (and different) vector default.
2767
+ */
2768
+ const BUILT_IN_GRAPHICS_STYLE = {
2769
+ color: "#000000",
2770
+ strokeWidth: 2
2771
+ };
2772
+ /**
2773
+ * Built-in defaults for the interaction-affordance slots. The translate grip
2774
+ * sits on the centroid pivot and the rotate grip just outside the shape; their
2775
+ * distinct fills (blue / green) set them apart from the white vertex and gray
2776
+ * midpoint handles. See ADR-0006.
2777
+ */
2778
+ const BUILT_IN_INTERACTION_STYLE = {
2779
+ guide: {
2780
+ strokeColor: "#6b7280",
2781
+ strokeWidth: 1,
2782
+ strokeDash: [6, 4]
2783
+ },
2784
+ vertexHandle: {
2785
+ pointRadius: 6,
2786
+ fillColor: "#ffffff",
2787
+ strokeColor: "#1f2937",
2788
+ strokeWidth: 2
2789
+ },
2790
+ midpointHandle: {
2791
+ pointRadius: 4,
2792
+ fillColor: "#d1d5db",
2793
+ strokeColor: "#6b7280",
2794
+ strokeWidth: 1.5
2795
+ },
2796
+ translateHandle: {
2797
+ pointRadius: 7,
2798
+ fillColor: "#2563eb",
2799
+ strokeColor: "#ffffff"
2800
+ },
2801
+ rotateHandle: {
2802
+ pointRadius: 7,
2803
+ fillColor: "#16a34a",
2804
+ strokeColor: "#ffffff"
2805
+ }
2806
+ };
2807
+ const HANDLE_SLOTS = [
2808
+ "vertexHandle",
2809
+ "midpointHandle",
2810
+ "translateHandle",
2811
+ "rotateHandle"
2812
+ ];
2813
+ /**
2814
+ * Per-slot, per-property shallow merge with explicit-undefined-unsets
2815
+ * semantics. Either side may be undefined; the result is undefined only when
2816
+ * both are.
2817
+ */
2818
+ function mergeSlot(base, override) {
2819
+ if (!base && !override) return void 0;
2820
+ if (!override) return { ...base };
2821
+ if (!base) {
2822
+ const out = {};
2823
+ for (const [k, v] of Object.entries(override)) if (v !== void 0) out[k] = v;
2824
+ return out;
2825
+ }
2826
+ const out = { ...base };
2827
+ for (const [k, v] of Object.entries(override)) if (v === void 0) delete out[k];
2828
+ else out[k] = v;
2829
+ return out;
2830
+ }
2831
+ function styleToStyleOptions(style) {
2832
+ const out = {};
2833
+ const strokeColor = style.strokeColor ?? style.color;
2834
+ if (strokeColor !== void 0) out.strokeColor = strokeColor;
2835
+ if (style.strokeWidth !== void 0) out.strokeWidth = style.strokeWidth;
2836
+ if (style.strokeDash !== void 0) out.strokeDash = [...style.strokeDash];
2837
+ if (style.fillColor !== void 0) out.fillColor = style.fillColor;
2838
+ out.text = { labelProperty: "text" };
2839
+ return out;
2840
+ }
2841
+ function guideToStyleOptions(style) {
2842
+ const out = {};
2843
+ if (style.strokeColor !== void 0) out.strokeColor = style.strokeColor;
2844
+ if (style.strokeWidth !== void 0) out.strokeWidth = style.strokeWidth;
2845
+ if (style.strokeDash !== void 0) out.strokeDash = [...style.strokeDash];
2846
+ return out;
2847
+ }
2848
+ function resolveSlot(adapter, supplied, owned) {
2849
+ if (supplied !== void 0) return supplied;
2850
+ const id = adapter.addVectorLayer();
2851
+ owned.add(id);
2852
+ return id;
2853
+ }
2854
+ //#endregion
2855
+ export { BaseMapAdapter, PointerCallbackHub, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, TacticalDrawDestroyedError, bindAbortable, coerceHandleStyle, combineWithHostSignal, getSizeAnchor, hitTestFeature, ignoreAbort, isTacticalDrawAbortError, pickHitTolerance, tryControlMeasureIdFromFeature };