@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/LICENSE +21 -0
- package/README.md +95 -0
- package/dist/index.d.mts +950 -0
- package/dist/index.mjs +2855 -0
- package/package.json +58 -0
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 };
|