@orbat-mapper/tactical-draw 0.2.0-alpha.33 → 0.2.0-alpha.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,223 +1,4 @@
1
1
  import { applyBoxTransformOptions, canonicalTextAmplifierKey, cloneControlMeasure, controlMeasureIdFromFeature, freezeOrientationOptions, getControlMeasureMetadata, getDefaultOptions, renderControlMeasure, resolveAmplifierPlacement, 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
- iconResources = /* @__PURE__ */ new Map();
19
- layerIconResourceIds = /* @__PURE__ */ new Map();
20
- layerCounter = 0;
21
- destroyed = false;
22
- nextLayerId() {
23
- return `layer-${++this.layerCounter}`;
24
- }
25
- addVectorLayer(options) {
26
- const id = this.nextLayerId();
27
- const handle = this.createLayer(id, options?.style);
28
- this.layers.set(id, handle);
29
- this.featureCache.set(id, []);
30
- return id;
31
- }
32
- removeLayer(layerId) {
33
- const handle = this.layers.get(layerId);
34
- if (!handle) return;
35
- this.destroyLayer(handle);
36
- this.layers.delete(layerId);
37
- this.featureCache.delete(layerId);
38
- this.featuresChangedListeners.delete(layerId);
39
- this.layerIconResourceIds.delete(layerId);
40
- this.pruneIconResources();
41
- }
42
- reconcileLayerIconResources(layerId, resources) {
43
- if (!this.layers.has(layerId)) return;
44
- const ids = /* @__PURE__ */ new Set();
45
- for (const resource of resources) {
46
- ids.add(resource.id);
47
- this.iconResources.set(resource.id, resource);
48
- }
49
- this.layerIconResourceIds.set(layerId, ids);
50
- this.applyIconResources(resources);
51
- this.pruneIconResources();
52
- }
53
- setLayerFeatures(layerId, features) {
54
- if (!this.layers.has(layerId)) return;
55
- const next = [...features];
56
- this.featureCache.set(layerId, next);
57
- this.applyFeatureOps(layerId, {
58
- adds: next,
59
- updates: [],
60
- full: next,
61
- replace: true
62
- });
63
- this.emitLayerFeaturesChanged(layerId, next);
64
- }
65
- updateLayerFeatures(layerId, features) {
66
- if (!this.layers.has(layerId)) return;
67
- const existing = this.featureCache.get(layerId) ?? [];
68
- const existingIds = /* @__PURE__ */ new Set();
69
- for (const f of existing) if (f.id != null) existingIds.add(f.id);
70
- const adds = [];
71
- const updates = [];
72
- const updateById = /* @__PURE__ */ new Map();
73
- for (const f of features) {
74
- if (f.id == null) continue;
75
- if (existingIds.has(f.id)) updates.push(f);
76
- else adds.push(f);
77
- updateById.set(f.id, f);
78
- }
79
- const merged = [];
80
- const removed = [];
81
- for (const f of existing) if (f.id != null && updateById.has(f.id)) {
82
- merged.push(updateById.get(f.id));
83
- updateById.delete(f.id);
84
- } else if (f.id != null && !updateById.has(f.id)) removed.push(f);
85
- else merged.push(f);
86
- for (const f of updateById.values()) merged.push(f);
87
- this.featureCache.set(layerId, merged);
88
- this.applyFeatureOps(layerId, {
89
- adds,
90
- updates,
91
- removes: removed,
92
- full: merged,
93
- replace: false
94
- });
95
- this.emitLayerFeaturesChanged(layerId, merged);
96
- }
97
- removeFeatures(layerId, ids) {
98
- if (!this.layers.has(layerId)) return;
99
- if (ids.length === 0) return;
100
- const existing = this.featureCache.get(layerId) ?? [];
101
- const idSet = new Set(ids);
102
- const merged = [];
103
- const removed = [];
104
- for (const f of existing) if (typeof f.id === "string" && idSet.has(f.id)) removed.push(f);
105
- else merged.push(f);
106
- if (removed.length === 0) return;
107
- this.featureCache.set(layerId, merged);
108
- this.applyFeatureOps(layerId, {
109
- adds: [],
110
- updates: [],
111
- removes: removed,
112
- full: merged,
113
- replace: false
114
- });
115
- this.emitLayerFeaturesChanged(layerId, merged);
116
- }
117
- clearLayer(layerId) {
118
- if (!this.layers.has(layerId)) return;
119
- this.featureCache.set(layerId, []);
120
- this.applyFeatureOps(layerId, {
121
- adds: [],
122
- updates: [],
123
- full: [],
124
- replace: true
125
- });
126
- this.emitLayerFeaturesChanged(layerId, []);
127
- }
128
- on(eventType, handler) {
129
- const entry = this.attachNativeEvent(eventType, (lonLat, pixel, originalEvent) => {
130
- handler({
131
- type: eventType,
132
- coordinate: lonLat,
133
- pixel,
134
- originalEvent
135
- });
136
- });
137
- this.eventTokens.set(handler, entry);
138
- }
139
- off(eventType, handler) {
140
- const entry = this.eventTokens.get(handler);
141
- if (!entry) return;
142
- this.detachNativeEvent(entry);
143
- this.eventTokens.delete(handler);
144
- }
145
- onViewChange(handler) {
146
- const token = this.attachViewChange(handler);
147
- this.viewChangeTokens.set(handler, token);
148
- }
149
- offViewChange(handler) {
150
- const token = this.viewChangeTokens.get(handler);
151
- if (token === void 0) return;
152
- this.detachViewChange(token);
153
- this.viewChangeTokens.delete(handler);
154
- }
155
- destroy() {
156
- if (this.destroyed) return;
157
- try {
158
- for (const entry of this.eventTokens.values()) this.detachNativeEvent(entry);
159
- this.eventTokens.clear();
160
- for (const token of this.viewChangeTokens.values()) this.detachViewChange(token);
161
- this.viewChangeTokens.clear();
162
- for (const handle of this.layers.values()) this.destroyLayer(handle);
163
- this.layers.clear();
164
- this.featureCache.clear();
165
- this.featuresChangedListeners.clear();
166
- for (const id of this.iconResources.keys()) this.removeIconResource(id);
167
- this.iconResources.clear();
168
- this.layerIconResourceIds.clear();
169
- this.destroyEngineResources();
170
- } finally {
171
- this.destroyed = true;
172
- }
173
- }
174
- emitLayerFeaturesChanged(layerId, features) {
175
- const listeners = this.featuresChangedListeners.get(layerId);
176
- if (!listeners) return;
177
- for (const fn of listeners) fn(features);
178
- }
179
- onLayerFeaturesChanged(layerId, listener) {
180
- let listeners = this.featuresChangedListeners.get(layerId);
181
- if (!listeners) {
182
- listeners = /* @__PURE__ */ new Set();
183
- this.featuresChangedListeners.set(layerId, listeners);
184
- }
185
- listeners.add(listener);
186
- return () => {
187
- const set = this.featuresChangedListeners.get(layerId);
188
- if (!set) return;
189
- set.delete(listener);
190
- if (set.size === 0) this.featuresChangedListeners.delete(layerId);
191
- };
192
- }
193
- destroyEngineResources() {}
194
- applyIconResources(resources) {}
195
- getIconResource(id) {
196
- return this.iconResources.get(id);
197
- }
198
- removeIconResource(id) {}
199
- pruneIconResources() {
200
- const active = /* @__PURE__ */ new Set();
201
- for (const ids of this.layerIconResourceIds.values()) for (const id of ids) active.add(id);
202
- if (this.iconResources.size <= active.size + 32) return;
203
- for (const id of this.iconResources.keys()) {
204
- if (active.has(id)) continue;
205
- this.iconResources.delete(id);
206
- this.removeIconResource(id);
207
- if (this.iconResources.size <= active.size + 32) break;
208
- }
209
- }
210
- /**
211
- * Default for engines/views that don't rotate (Leaflet). Rotating engines
212
- * (MapLibre, OpenLayers) override this.
213
- */
214
- getBearing() {
215
- return 0;
216
- }
217
- getLayerFeatures(layerId) {
218
- return this.featureCache.get(layerId) ?? [];
219
- }
220
- };
221
2
  function pickPointerType(originalEvent) {
222
3
  if (typeof PointerEvent !== "undefined" && originalEvent instanceof PointerEvent) {
223
4
  if (originalEvent.pointerType === "touch") return "touch";
@@ -318,11 +99,9 @@ function mergeNativeAndCachedPickFeatures(nativeFeatures, cachedFeatures) {
318
99
  * primary candidate.
319
100
  */
320
101
  function evaluatePickCandidates(inputs, context) {
102
+ const candidates = eligibleCandidates(inputs, context);
321
103
  let best = null;
322
- for (const input of inputs) {
323
- const candidate = buildCandidate(input, context);
324
- if (candidate && (best === null || compareCandidates(candidate, best) < 0)) best = candidate;
325
- }
104
+ for (const candidate of candidates) if (best === null || compareCandidates(candidate, best) < 0) best = candidate;
326
105
  return best;
327
106
  }
328
107
  function buildCandidate(input, context) {
@@ -337,10 +116,17 @@ function buildCandidate(input, context) {
337
116
  viewport: context.viewport,
338
117
  presentation,
339
118
  visualOrder: input.visualOrder,
119
+ ...input.featureGroupOrder !== void 0 ? { featureGroupOrder: input.featureGroupOrder } : {},
340
120
  ...evaluated.iconFootprint ? { iconFootprint: evaluated.iconFootprint } : {},
341
121
  ...input.labelExtent ? { labelExtent: input.labelExtent } : {}
342
122
  };
343
123
  }
124
+ function eligibleCandidates(inputs, context) {
125
+ const candidates = inputs.map((input) => buildCandidate(input, context)).filter((candidate) => candidate !== null);
126
+ const opaqueCovers = candidates.filter(isOpaqueFillInterior);
127
+ if (opaqueCovers.length === 0) return candidates;
128
+ return candidates.filter((candidate) => !opaqueCovers.some((cover) => cover.featureGroupOrder !== void 0 && candidate.featureGroupOrder !== void 0 && cover.featureGroupOrder < candidate.featureGroupOrder));
129
+ }
344
130
  function compareCandidates(a, b) {
345
131
  const rankA = selectionSurfaceRank(a);
346
132
  const rankDifference = rankA - selectionSurfaceRank(b);
@@ -349,8 +135,13 @@ function compareCandidates(a, b) {
349
135
  const distanceDifference = a.hit.distance - b.hit.distance;
350
136
  if (distanceDifference !== 0) return distanceDifference;
351
137
  }
138
+ if (a.featureGroupOrder !== void 0 && b.featureGroupOrder !== void 0 && a.featureGroupOrder !== b.featureGroupOrder) return a.featureGroupOrder - b.featureGroupOrder;
352
139
  return a.visualOrder - b.visualOrder;
353
140
  }
141
+ function isOpaqueFillInterior(candidate) {
142
+ const fill = candidate.presentation.fill;
143
+ return candidate.hit.kind === "polygon-interior" && fill?.opacity === 1 && (fill.pattern === void 0 || fill.pattern === "solid");
144
+ }
354
145
  function selectionSurfaceRank(candidate) {
355
146
  if (candidate.hit.kind !== "polygon-interior") return 0;
356
147
  return (candidate.presentation.fill?.opacity ?? 0) > 0 ? 1 : 2;
@@ -645,6 +436,196 @@ function numberArray(value) {
645
436
  return Array.isArray(value) && value.every((item) => typeof item === "number") ? [...value] : void 0;
646
437
  }
647
438
  //#endregion
439
+ //#region src/base-map-adapter.ts
440
+ /**
441
+ * Abstract base class shared by all `MapAdapter` implementations.
442
+ *
443
+ * Owns shared bookkeeping (layer registry, feature cache, event-token
444
+ * tracking) and orchestrates the public methods over a small protected
445
+ * grouped desired-state primitive that subclasses implement.
446
+ */
447
+ var BaseMapAdapter = class {
448
+ layers = /* @__PURE__ */ new Map();
449
+ eventTokens = /* @__PURE__ */ new Map();
450
+ viewChangeTokens = /* @__PURE__ */ new Map();
451
+ featureCache = /* @__PURE__ */ new Map();
452
+ featuresChangedListeners = /* @__PURE__ */ new Map();
453
+ iconResources = /* @__PURE__ */ new Map();
454
+ featureGroupState = /* @__PURE__ */ new Map();
455
+ layerIconResourceIds = /* @__PURE__ */ new Map();
456
+ layerCounter = 0;
457
+ destroyed = false;
458
+ nextLayerId() {
459
+ return `layer-${++this.layerCounter}`;
460
+ }
461
+ addVectorLayer(options) {
462
+ const id = this.nextLayerId();
463
+ const handle = this.createLayer(id, options?.style);
464
+ this.layers.set(id, handle);
465
+ this.featureCache.set(id, []);
466
+ this.featureGroupState.set(id, {
467
+ groups: [],
468
+ orderByFeatureId: /* @__PURE__ */ new Map()
469
+ });
470
+ return id;
471
+ }
472
+ removeLayer(layerId) {
473
+ const handle = this.layers.get(layerId);
474
+ if (!handle) return;
475
+ this.destroyLayer(handle);
476
+ this.layers.delete(layerId);
477
+ this.featureCache.delete(layerId);
478
+ this.featureGroupState.delete(layerId);
479
+ this.featuresChangedListeners.delete(layerId);
480
+ this.layerIconResourceIds.delete(layerId);
481
+ this.pruneIconResources();
482
+ }
483
+ setLayerFeatureGroups(layerId, groups) {
484
+ validateFeatureGroups(groups);
485
+ if (!this.layers.has(layerId)) return;
486
+ const nextGroups = groups.map((group) => ({
487
+ id: group.id,
488
+ features: [...group.features],
489
+ iconResources: [...group.iconResources]
490
+ }));
491
+ const nextFeatures = nextGroups.flatMap((group) => group.features);
492
+ const nextResourcesById = /* @__PURE__ */ new Map();
493
+ for (const group of nextGroups) for (const resource of group.iconResources) nextResourcesById.set(resource.id, resource);
494
+ const nextResources = [...nextResourcesById.values()];
495
+ const resourceIds = new Set(nextResourcesById.keys());
496
+ for (const resource of nextResources) this.iconResources.set(resource.id, resource);
497
+ this.layerIconResourceIds.set(layerId, resourceIds);
498
+ this.applyIconResources(nextResources);
499
+ this.featureGroupState.set(layerId, {
500
+ groups: nextGroups,
501
+ orderByFeatureId: indexFeatureGroupOrder(nextGroups)
502
+ });
503
+ this.featureCache.set(layerId, nextFeatures);
504
+ this.applyFeatureGroups(layerId, nextGroups);
505
+ this.emitLayerFeaturesChanged(layerId, nextFeatures);
506
+ this.pruneIconResources(true);
507
+ }
508
+ on(eventType, handler) {
509
+ const entry = this.attachNativeEvent(eventType, (lonLat, pixel, originalEvent) => {
510
+ handler({
511
+ type: eventType,
512
+ coordinate: lonLat,
513
+ pixel,
514
+ originalEvent
515
+ });
516
+ });
517
+ this.eventTokens.set(handler, entry);
518
+ }
519
+ off(eventType, handler) {
520
+ const entry = this.eventTokens.get(handler);
521
+ if (!entry) return;
522
+ this.detachNativeEvent(entry);
523
+ this.eventTokens.delete(handler);
524
+ }
525
+ onViewChange(handler) {
526
+ const token = this.attachViewChange(handler);
527
+ this.viewChangeTokens.set(handler, token);
528
+ }
529
+ offViewChange(handler) {
530
+ const token = this.viewChangeTokens.get(handler);
531
+ if (token === void 0) return;
532
+ this.detachViewChange(token);
533
+ this.viewChangeTokens.delete(handler);
534
+ }
535
+ destroy() {
536
+ if (this.destroyed) return;
537
+ try {
538
+ for (const entry of this.eventTokens.values()) this.detachNativeEvent(entry);
539
+ this.eventTokens.clear();
540
+ for (const token of this.viewChangeTokens.values()) this.detachViewChange(token);
541
+ this.viewChangeTokens.clear();
542
+ for (const handle of this.layers.values()) this.destroyLayer(handle);
543
+ this.layers.clear();
544
+ this.featureCache.clear();
545
+ this.featureGroupState.clear();
546
+ this.featuresChangedListeners.clear();
547
+ for (const id of this.iconResources.keys()) this.removeIconResource(id);
548
+ this.iconResources.clear();
549
+ this.layerIconResourceIds.clear();
550
+ this.destroyEngineResources();
551
+ } finally {
552
+ this.destroyed = true;
553
+ }
554
+ }
555
+ emitLayerFeaturesChanged(layerId, features) {
556
+ const listeners = this.featuresChangedListeners.get(layerId);
557
+ if (!listeners) return;
558
+ for (const fn of listeners) fn(features);
559
+ }
560
+ onLayerFeaturesChanged(layerId, listener) {
561
+ let listeners = this.featuresChangedListeners.get(layerId);
562
+ if (!listeners) {
563
+ listeners = /* @__PURE__ */ new Set();
564
+ this.featuresChangedListeners.set(layerId, listeners);
565
+ }
566
+ listeners.add(listener);
567
+ return () => {
568
+ const set = this.featuresChangedListeners.get(layerId);
569
+ if (!set) return;
570
+ set.delete(listener);
571
+ if (set.size === 0) this.featuresChangedListeners.delete(layerId);
572
+ };
573
+ }
574
+ destroyEngineResources() {}
575
+ applyIconResources(resources) {}
576
+ getIconResource(id) {
577
+ return this.iconResources.get(id);
578
+ }
579
+ getFeatureGroupOrder(layerId, featureId) {
580
+ if (featureId === void 0) return void 0;
581
+ return this.featureGroupState.get(layerId)?.orderByFeatureId.get(featureId);
582
+ }
583
+ removeIconResource(id) {}
584
+ pruneIconResources(removeAllUnused = false) {
585
+ const active = /* @__PURE__ */ new Set();
586
+ for (const ids of this.layerIconResourceIds.values()) for (const id of ids) active.add(id);
587
+ if (!removeAllUnused && this.iconResources.size <= active.size + 32) return;
588
+ for (const id of this.iconResources.keys()) {
589
+ if (active.has(id)) continue;
590
+ this.iconResources.delete(id);
591
+ this.removeIconResource(id);
592
+ if (!removeAllUnused && this.iconResources.size <= active.size + 32) break;
593
+ }
594
+ }
595
+ /**
596
+ * Default for engines/views that don't rotate (Leaflet). Rotating engines
597
+ * (MapLibre, OpenLayers) override this.
598
+ */
599
+ getBearing() {
600
+ return 0;
601
+ }
602
+ getLayerFeatures(layerId) {
603
+ return this.featureCache.get(layerId) ?? [];
604
+ }
605
+ };
606
+ function indexFeatureGroupOrder(groups) {
607
+ const orderByFeatureId = /* @__PURE__ */ new Map();
608
+ groups.forEach((group, index) => {
609
+ const visualOrder = renderOrderToVisualOrder(index, groups.length);
610
+ for (const feature of group.features) if (feature.id != null) orderByFeatureId.set(feature.id, visualOrder);
611
+ });
612
+ return orderByFeatureId;
613
+ }
614
+ function validateFeatureGroups(groups) {
615
+ const groupIds = /* @__PURE__ */ new Set();
616
+ const featureIds = /* @__PURE__ */ new Set();
617
+ for (const group of groups) {
618
+ if (typeof group.id !== "string" || group.id.trim().length === 0) throw new Error("Feature group id must be a non-empty string");
619
+ if (groupIds.has(group.id)) throw new Error(`Duplicate feature group id: ${group.id}`);
620
+ groupIds.add(group.id);
621
+ for (const feature of group.features) {
622
+ if (feature.id == null) throw new Error(`Feature in group "${group.id}" is missing an id`);
623
+ if (featureIds.has(feature.id)) throw new Error(`Duplicate feature id: ${String(feature.id)}`);
624
+ featureIds.add(feature.id);
625
+ }
626
+ }
627
+ }
628
+ //#endregion
648
629
  //#region src/feature-style.ts
649
630
  /**
650
631
  * Coerce a raw `properties.style` value into a `HandleStyle` for the
@@ -700,6 +681,17 @@ function resolveTextSizePixels(props, resolution) {
700
681
  }
701
682
  return resolveGroundTextSizePixels(meters, resolution, props);
702
683
  }
684
+ /** Fallback halo color when a `textHalo` feature carries no `textHaloColor`. */
685
+ const TEXT_HALO_COLOR = "#ffffff";
686
+ /**
687
+ * Outward depth (px) of a `textHalo` halo from the glyph edge, scaled to font
688
+ * size. This is the halo's *outward* reach, not a stroke width — a canvas
689
+ * stroke straddles the glyph edge (half in, half out), so a caller drawing
690
+ * one must double this value to get the same visible depth.
691
+ */
692
+ function resolveTextHaloWidth(textSizePixels) {
693
+ return Math.max(1.5, (textSizePixels ?? 24) / 8);
694
+ }
703
695
  //#endregion
704
696
  //#region src/gestures/hit-test.ts
705
697
  /** Clamped parameter t ∈ [0, 1] of `pixel` projected onto the segment a→b in pixel space, or null for a zero-length segment. */
@@ -1251,23 +1243,15 @@ function runInteraction(spec) {
1251
1243
  resolveFn = resolve;
1252
1244
  rejectFn = reject;
1253
1245
  });
1254
- const idsByLayer = /* @__PURE__ */ new Map();
1255
- for (const layer of layers) idsByLayer.set(layer, /* @__PURE__ */ new Set());
1246
+ const ownedLayers = new Set(layers);
1256
1247
  let controllerDetach = null;
1257
1248
  let controllerOnAbort = null;
1258
- function writeLayer(layer, features) {
1259
- adapter.updateLayerFeatures(layer, features);
1260
- const set = idsByLayer.get(layer);
1261
- if (!set) return;
1262
- set.clear();
1263
- for (const f of features) if (typeof f.id === "string") set.add(f.id);
1249
+ function writeLayer(layer, groups) {
1250
+ if (!ownedLayers.has(layer)) return;
1251
+ adapter.setLayerFeatureGroups(layer, groups);
1264
1252
  }
1265
1253
  function clearLayers() {
1266
- for (const [layer, set] of idsByLayer) {
1267
- if (set.size === 0) continue;
1268
- adapter.removeFeatures(layer, [...set]);
1269
- set.clear();
1270
- }
1254
+ for (const layer of ownedLayers) adapter.setLayerFeatureGroups(layer, []);
1271
1255
  }
1272
1256
  const handleKeydown = (event) => {
1273
1257
  if (settled) return;
@@ -1805,6 +1789,7 @@ function createCommitEmitter(listenerErrorsMessage) {
1805
1789
  //#endregion
1806
1790
  //#region src/tactical-draw/draw-controller.ts
1807
1791
  const PREVIEW_CM_ID$1 = "__td_preview__";
1792
+ const GUIDE_GROUP_ID = "__td_draw_guide__";
1808
1793
  const PREVIEW_CURSOR$1 = "crosshair";
1809
1794
  /**
1810
1795
  * Drive an interactive fixed-length draw against a `MapAdapter`.
@@ -1919,16 +1904,32 @@ function runFixedLengthDraw(options) {
1919
1904
  function renderPreview() {
1920
1905
  const raw = rubberBand !== void 0 ? [...committed, rubberBand] : [...committed];
1921
1906
  if (raw.length === 0) {
1922
- ctx.writeLayer(previewLayer, []);
1923
- ctx.writeLayer(guideLayer, []);
1907
+ ctx.writeLayer(previewLayer, [{
1908
+ id: PREVIEW_CM_ID$1,
1909
+ features: [],
1910
+ iconResources: []
1911
+ }]);
1912
+ ctx.writeLayer(guideLayer, [{
1913
+ id: GUIDE_GROUP_ID,
1914
+ features: [],
1915
+ iconResources: []
1916
+ }]);
1924
1917
  return;
1925
1918
  }
1926
1919
  const previewThreshold = rule?.minimumPreviewPoints ?? required;
1927
1920
  const features = buildPreviewFeatures$1(adapter, kind, rule && raw.length >= previewThreshold ? rule.derive(raw) : raw, measureOptions, measureStyle, measureTextAmplifiers, renderOpts);
1928
- ctx.writeLayer(previewLayer, features);
1921
+ ctx.writeLayer(previewLayer, [{
1922
+ id: PREVIEW_CM_ID$1,
1923
+ features,
1924
+ iconResources: []
1925
+ }]);
1929
1926
  const guidePoints = rule?.guidePoints ? rule.guidePoints(raw) : raw;
1930
1927
  const guideFeatures = guideEnabled ? buildGuideFeatures(guidePoints, isAreaKind) : [];
1931
- ctx.writeLayer(guideLayer, guideFeatures);
1928
+ ctx.writeLayer(guideLayer, [{
1929
+ id: GUIDE_GROUP_ID,
1930
+ features: guideFeatures,
1931
+ iconResources: []
1932
+ }]);
1932
1933
  }
1933
1934
  function rescaleOnViewChange() {
1934
1935
  if (!measureRendersFromViewState({
@@ -2207,9 +2208,17 @@ function runVariableLengthDraw(options) {
2207
2208
  } else points = [...committed];
2208
2209
  if (rule) points = rule.derive(points);
2209
2210
  const previewFeatures = points.length === 0 ? [] : buildPreviewFeatures(adapter, kind, points, measureOptions, measureStyle, measureTextAmplifiers, renderOpts);
2210
- ctx.writeLayer(previewLayer, previewFeatures);
2211
+ ctx.writeLayer(previewLayer, [{
2212
+ id: "__td_variable_draw_preview__",
2213
+ features: previewFeatures,
2214
+ iconResources: []
2215
+ }]);
2211
2216
  const guideFeatures = points.length === 0 || !guideEnabled ? [] : buildGuideFeatures(points, isAreaKind, trailingFixed);
2212
- ctx.writeLayer(guideLayer, guideFeatures);
2217
+ ctx.writeLayer(guideLayer, [{
2218
+ id: "__td_variable_draw_guide__",
2219
+ features: guideFeatures,
2220
+ iconResources: []
2221
+ }]);
2213
2222
  }
2214
2223
  function rescaleOnViewChange() {
2215
2224
  if (!measureRendersFromViewState({
@@ -2375,6 +2384,7 @@ function runEditInteraction(spec) {
2375
2384
  spec.onForeignPick?.(localGraphics);
2376
2385
  return;
2377
2386
  }
2387
+ if (localGraphics && spec.ownsGraphic(localGraphics.id)) return;
2378
2388
  if (localPreview) return;
2379
2389
  if (wasBodyClick) return;
2380
2390
  spec.close();
@@ -3563,15 +3573,15 @@ const ALLOWED_EDIT_MODES = new Set([
3563
3573
  * Drive an interactive edit against a `MapAdapter`. Implements the edit
3564
3574
  * lifecycle (issue #48) plus the ADR-0017 transform box:
3565
3575
  *
3566
- * - lift-original (remove from graphics layer) + working copy
3567
- * - preview + reshape-handle rendering, and the oriented transform box
3576
+ * - working-render publication into the host-owned Graphic stack slot
3577
+ * - reshape-handle rendering and the oriented transform box
3568
3578
  * (corner scale handles, rotate grip, drag-anywhere-inside body translate)
3569
3579
  * - dirty tracking via [[DIRTY_EPSILON]] tolerance
3570
3580
  * - default click-away close policy
3571
3581
  * - close / abort / signal / preempt / destroy paths
3572
3582
  */
3573
3583
  function runEdit(opts) {
3574
- const { adapter, measure, graphicsLayer, previewLayer, guideLayer, handlesLayer, graphicsStyle, signal, closeOnClickAway = true, modes: initialModes, interactionStyle, onSession, handleIdPrefix, guide: guideOpt, settleToGraphics, wasOnGraphicsLayer, onSettled, onClickAwayGraphicsPick, snap } = opts;
3584
+ const { adapter, measure, graphicsLayer, previewLayer, guideLayer, handlesLayer, graphicsStyle, signal, closeOnClickAway = true, modes: initialModes, interactionStyle, onSession, handleIdPrefix, guide: guideOpt, settleToGraphics, publishWorkingGraphic, onSettled, onClickAwayGraphicsPick, snap } = opts;
3575
3585
  const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
3576
3586
  let sizeAnchor = opts.sizeAnchor ?? "ground";
3577
3587
  const metadata = getControlMeasureMetadata(measure.kind);
@@ -3684,8 +3694,8 @@ function runEdit(opts) {
3684
3694
  * while a box gesture (`activeBoxDrag`) is in flight.
3685
3695
  */
3686
3696
  let box = null;
3687
- /** The features written to the preview layer on the last `renderPreview()` call — reused to refit `box` without a second render pass. */
3688
- let lastPreviewFeatures = [];
3697
+ /** The last working-render features, reused to refit `box` without a second render pass. */
3698
+ let lastWorkingFeatures = [];
3689
3699
  let ctx;
3690
3700
  let driver;
3691
3701
  let shell;
@@ -3699,7 +3709,7 @@ function runEdit(opts) {
3699
3709
  resolution: adapter.getResolution(),
3700
3710
  bearing: adapter.getBearing()
3701
3711
  }),
3702
- features: () => lastPreviewFeatures,
3712
+ features: () => lastWorkingFeatures,
3703
3713
  snapshotAmplifierPlacements,
3704
3714
  handleIdPrefix,
3705
3715
  interactionStyle
@@ -3735,7 +3745,7 @@ function runEdit(opts) {
3735
3745
  });
3736
3746
  }
3737
3747
  /**
3738
- * Re-fit `box` from `lastPreviewFeatures`. A no-op while a box gesture is
3748
+ * Re-fit `box` from `lastWorkingFeatures`. A no-op while a box gesture is
3739
3749
  * in flight — that drag paints the box rigidly from its own snapshot
3740
3750
  * (`BoxDragSnapshot.box`) instead of a re-fit, so a corner/rotate/body drag
3741
3751
  * doesn't fight its own paint. Text is the exception: its box follows the
@@ -3744,14 +3754,9 @@ function runEdit(opts) {
3744
3754
  */
3745
3755
  function refitBox() {
3746
3756
  if (activeBoxDrag && !isTextGraphic) return;
3747
- box = fitBoxFromFeatures(lastPreviewFeatures);
3757
+ box = fitBoxFromFeatures(lastWorkingFeatures);
3748
3758
  }
3749
- function liftOriginalFromGraphics() {
3750
- if (!wasOnGraphicsLayer) return;
3751
- const originalFeatureIds = renderMeasureForAdapter(adapter, measure, renderOpts).features.map((f) => f.id).filter((id) => typeof id === "string");
3752
- if (originalFeatureIds.length > 0) adapter.removeFeatures(graphicsLayer, originalFeatureIds);
3753
- }
3754
- function renderPreview() {
3759
+ function renderWorkingGraphic() {
3755
3760
  if (ctx.settled) return;
3756
3761
  const working = {
3757
3762
  ...measure,
@@ -3769,19 +3774,23 @@ function runEdit(opts) {
3769
3774
  } catch {
3770
3775
  features = [];
3771
3776
  }
3772
- ctx.writeLayer(previewLayer, features);
3773
- lastPreviewFeatures = features;
3777
+ publishWorkingGraphic(working, features);
3778
+ lastWorkingFeatures = features;
3774
3779
  refitBox();
3775
3780
  const guidePoints = rule?.guidePoints ? rule.guidePoints(workingPoints) : workingPoints;
3776
3781
  const guideFeatures = guideEnabled ? buildGuideFeatures(guidePoints, isAreaKind, trailingFixedSlots) : [];
3777
- ctx.writeLayer(guideLayer, guideFeatures);
3782
+ ctx.writeLayer(guideLayer, [{
3783
+ id: `__td_edit_guide__:${measure.id}`,
3784
+ features: guideFeatures,
3785
+ iconResources: []
3786
+ }]);
3778
3787
  }
3779
3788
  function rescaleOnViewChange() {
3780
3789
  if (ctx.settled) return;
3781
3790
  if (measureRendersFromViewState({
3782
3791
  kind: measure.kind,
3783
3792
  options: workingOptions
3784
- })) renderPreview();
3793
+ })) renderWorkingGraphic();
3785
3794
  else refitBox();
3786
3795
  renderHandlesLayer();
3787
3796
  }
@@ -3800,10 +3809,14 @@ function runEdit(opts) {
3800
3809
  features.push(...toFeatures(boxToHandles(liveBox, dragProjection.fromPixel), handleIdPrefix, interactionStyle));
3801
3810
  }
3802
3811
  }
3803
- ctx.writeLayer(handlesLayer, features);
3812
+ ctx.writeLayer(handlesLayer, [{
3813
+ id: `__td_edit_handles__:${measure.id}`,
3814
+ features,
3815
+ iconResources: []
3816
+ }]);
3804
3817
  }
3805
3818
  /**
3806
- * Repaint the preview and, in lockstep, the handles. `renderPreview` re-fits
3819
+ * Repaint the preview and, in lockstep, the handles. `renderWorkingGraphic` re-fits
3807
3820
  * `box` (for Text, the footprint depends on options — size/rotation/
3808
3821
  * alignment/text), so the transform-box outline and its corner/rotate handles
3809
3822
  * must repaint to match; otherwise they stay where the old options placed them
@@ -3811,8 +3824,8 @@ function runEdit(opts) {
3811
3824
  * (#scale-handle desync). Used by the non-geometry option/style/amplifier
3812
3825
  * setters, which change what the preview renders without moving a point.
3813
3826
  */
3814
- function renderPreviewAndHandles() {
3815
- renderPreview();
3827
+ function renderWorkingGraphicAndHandles() {
3828
+ renderWorkingGraphic();
3816
3829
  renderHandlesLayer();
3817
3830
  }
3818
3831
  /**
@@ -3893,7 +3906,7 @@ function runEdit(opts) {
3893
3906
  workingAmplifierPlacements = previousAmplifierPlacements;
3894
3907
  boxAngle = previousBoxAngle;
3895
3908
  nonGeometryDirty = previousNonGeometryDirty;
3896
- renderPreview();
3909
+ renderWorkingGraphic();
3897
3910
  renderHandlesLayer();
3898
3911
  recomputeDirty();
3899
3912
  },
@@ -3932,7 +3945,7 @@ function runEdit(opts) {
3932
3945
  drainPendingModes();
3933
3946
  throw err;
3934
3947
  }
3935
- renderPreview();
3948
+ renderWorkingGraphic();
3936
3949
  renderHandlesLayer();
3937
3950
  recomputeDirty();
3938
3951
  dragInFlight = false;
@@ -3960,7 +3973,7 @@ function runEdit(opts) {
3960
3973
  replaceWorkingOptions(previousOptions);
3961
3974
  throw err;
3962
3975
  }
3963
- renderPreviewAndHandles();
3976
+ renderWorkingGraphicAndHandles();
3964
3977
  nonGeometryDirty = true;
3965
3978
  dirty = true;
3966
3979
  emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "programmatic", "Change options");
@@ -3986,7 +3999,7 @@ function runEdit(opts) {
3986
3999
  currentStyle = previousStyle;
3987
4000
  throw err;
3988
4001
  }
3989
- renderPreviewAndHandles();
4002
+ renderWorkingGraphicAndHandles();
3990
4003
  nonGeometryDirty = true;
3991
4004
  dirty = true;
3992
4005
  emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "programmatic", "Change style");
@@ -3996,7 +4009,7 @@ function runEdit(opts) {
3996
4009
  * [[setOptions]] exactly (dirty tracking, re-render, change emission) —
3997
4010
  * see that function for the shared shape. A merge value of `undefined` or
3998
4011
  * an empty/whitespace-only string deletes the key; the record collapses to
3999
- * `undefined` once empty so `renderPreview`/`rebuildRenderedWith` omit the
4012
+ * `undefined` once empty so `renderWorkingGraphic`/`rebuildRenderedWith` omit the
4000
4013
  * key rather than persist `{}`.
4001
4014
  *
4002
4015
  * Each patch key resolves to its canonical field key and evicts every
@@ -4032,7 +4045,7 @@ function runEdit(opts) {
4032
4045
  workingTextAmplifiers = previousTextAmplifiers;
4033
4046
  throw err;
4034
4047
  }
4035
- renderPreviewAndHandles();
4048
+ renderWorkingGraphicAndHandles();
4036
4049
  nonGeometryDirty = true;
4037
4050
  dirty = true;
4038
4051
  emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "programmatic", "Change text");
@@ -4059,7 +4072,7 @@ function runEdit(opts) {
4059
4072
  workingAmplifierPlacements = Object.keys(next).length > 0 ? next : void 0;
4060
4073
  if (JSON.stringify(workingAmplifierPlacements) === JSON.stringify(previousAmplifierPlacements)) return;
4061
4074
  const currentRendered = rebuildRendered();
4062
- renderPreviewAndHandles();
4075
+ renderWorkingGraphicAndHandles();
4063
4076
  nonGeometryDirty = true;
4064
4077
  dirty = true;
4065
4078
  emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, source, historyLabel);
@@ -4067,7 +4080,7 @@ function runEdit(opts) {
4067
4080
  /**
4068
4081
  * Flip the size anchor for the measure under edit (ADR-0020). Re-renders the
4069
4082
  * emitted snapshot through `rebuildRendered` (which reads `sizeAnchor` live)
4070
- * and emits a change so hosts see the re-anchored measure. The preview layer
4083
+ * and emits a change so hosts see the re-anchored measure. The working render
4071
4084
  * is unaffected — it renders from `workingOptions`, which still carry the
4072
4085
  * pixel size, so the in-flight symbol stays screen-locked until commit.
4073
4086
  */
@@ -4121,7 +4134,7 @@ function runEdit(opts) {
4121
4134
  apply(from, to, source) {
4122
4135
  const previousRendered = rebuildRendered();
4123
4136
  restoreHistory(to);
4124
- renderPreview();
4137
+ renderWorkingGraphic();
4125
4138
  renderHandlesLayer();
4126
4139
  recomputeDirty();
4127
4140
  emitChange(previousRendered, from.points, from.options, from.style, from.nonGeometryDirty, from.sizeAnchor, rebuildRendered(), from.textAmplifiers, from.amplifierPlacements, source, "Edit", from.boxAngle);
@@ -4197,7 +4210,7 @@ function runEdit(opts) {
4197
4210
  workingTextAmplifiers = next.textAmplifiers ? { ...next.textAmplifiers } : void 0;
4198
4211
  workingAmplifierPlacements = next.amplifierPlacements ? structuredClone(next.amplifierPlacements) : void 0;
4199
4212
  nonGeometryDirty = true;
4200
- renderPreview();
4213
+ renderWorkingGraphic();
4201
4214
  renderHandlesLayer();
4202
4215
  recomputeDirty();
4203
4216
  emitChange(previous, previous.graphic.controlPoints.map((point) => [...point]), previous.graphic.options, previous.graphic.style, previousNonGeometryDirty, previousSizeAnchor, rebuildRendered(), previous.graphic.textAmplifiers, previous.graphic.amplifierPlacements, "programmatic", "Update graphic");
@@ -4353,7 +4366,7 @@ function runEdit(opts) {
4353
4366
  emitTransient("start", fsm.kind === "midpoint" ? initialPoints : preDragPoints, activeDrag.controlPointIndex);
4354
4367
  if (fsm.kind === "midpoint") {
4355
4368
  workingPoints.splice(0, workingPoints.length, ...initialPoints);
4356
- renderPreview();
4369
+ renderWorkingGraphic();
4357
4370
  renderHandlesLayer();
4358
4371
  }
4359
4372
  dragInFlight = true;
@@ -4366,7 +4379,7 @@ function runEdit(opts) {
4366
4379
  const preDragOptions = { ...workingOptions ?? {} };
4367
4380
  if (boxHit.kind === "rotate") {
4368
4381
  freezeOrientationForRotate();
4369
- renderPreview();
4382
+ renderWorkingGraphic();
4370
4383
  renderHandlesLayer();
4371
4384
  }
4372
4385
  const preDragPoints = workingPoints.map((p) => [...p]);
@@ -4400,7 +4413,7 @@ function runEdit(opts) {
4400
4413
  const resolvedPixel = snappedPixel(pixel, info, "reshape", activeDrag.controlPointIndex);
4401
4414
  const snapshot = activeDrag.fsm.move(resolvedPixel);
4402
4415
  workingPoints.splice(0, workingPoints.length, ...snapshot.points);
4403
- renderPreview();
4416
+ renderWorkingGraphic();
4404
4417
  renderHandlesLayer();
4405
4418
  recomputeDirty();
4406
4419
  emitTransient("move", snapshot.points, activeDrag.controlPointIndex);
@@ -4433,7 +4446,7 @@ function runEdit(opts) {
4433
4446
  workingPoints.splice(0, workingPoints.length, ...activeBoxDrag.preDragPoints);
4434
4447
  workingOptions = foldTextTransform(activeBoxDrag.preDragBox, snapshot.box, activeBoxDrag.fsm.kind, activeBoxDrag.preDragOptions);
4435
4448
  nonGeometryDirty = true;
4436
- renderPreview();
4449
+ renderWorkingGraphic();
4437
4450
  renderHandlesLayer();
4438
4451
  recomputeDirty();
4439
4452
  emitTransient("move", activeBoxDrag.preDragPoints, null);
@@ -4441,7 +4454,7 @@ function runEdit(opts) {
4441
4454
  }
4442
4455
  workingPoints.splice(0, workingPoints.length, ...snapshot.points);
4443
4456
  workingAmplifierPlacements = transformAmplifierPlacements(activeBoxDrag.preDragAmplifierPlacements, activeBoxDrag.preDragBox, snapshot.box, driver);
4444
- renderPreview();
4457
+ renderWorkingGraphic();
4445
4458
  renderHandlesLayer();
4446
4459
  recomputeDirty();
4447
4460
  emitTransient("move", snapshot.points, null);
@@ -4456,7 +4469,7 @@ function runEdit(opts) {
4456
4469
  ...workingAmplifierPlacements ?? {},
4457
4470
  ...merged !== void 0 ? { [patch.field]: merged } : {}
4458
4471
  };
4459
- renderPreview();
4472
+ renderWorkingGraphic();
4460
4473
  renderHandlesLayer();
4461
4474
  }
4462
4475
  } else if (activeDrag) paintDragSnapshot(pixel, info);
@@ -4476,7 +4489,7 @@ function runEdit(opts) {
4476
4489
  if (labelOutcome.kind === "rotate") setAmplifierPlacements({ [labelOutcome.key]: { rotation: labelOutcome.rotation } }, "gesture", "Rotate label");
4477
4490
  else if (labelOutcome.kind === "move") setAmplifierPlacements({ [labelOutcome.field]: labelOutcome.position }, "gesture", "Move label");
4478
4491
  else {
4479
- renderPreview();
4492
+ renderWorkingGraphic();
4480
4493
  renderHandlesLayer();
4481
4494
  }
4482
4495
  shell.suppressNextClickAway();
@@ -4514,7 +4527,7 @@ function runEdit(opts) {
4514
4527
  replaceWorkingPoints(preDragPoints);
4515
4528
  workingAmplifierPlacements = preDragAmplifierPlacements;
4516
4529
  emitTransient("end", preDragPoints, null);
4517
- renderPreview();
4530
+ renderWorkingGraphic();
4518
4531
  renderHandlesLayer();
4519
4532
  recomputeDirty();
4520
4533
  endDragBookkeeping();
@@ -4544,11 +4557,7 @@ function runEdit(opts) {
4544
4557
  const core = runEditInteraction({
4545
4558
  adapter,
4546
4559
  signal,
4547
- layers: [
4548
- previewLayer,
4549
- guideLayer,
4550
- handlesLayer
4551
- ],
4560
+ layers: [guideLayer, handlesLayer],
4552
4561
  graphicsLayer,
4553
4562
  previewLayer,
4554
4563
  handlesLayer,
@@ -4582,11 +4591,7 @@ function runEdit(opts) {
4582
4591
  ctx = interaction;
4583
4592
  driver = createdDriver;
4584
4593
  shell = createdShell;
4585
- renderPreview();
4586
- if (wasOnGraphicsLayer) {
4587
- adapter.deferLayerRemovalUntilLayerUpdate?.(graphicsLayer, previewLayer);
4588
- liftOriginalFromGraphics();
4589
- }
4594
+ renderWorkingGraphic();
4590
4595
  renderHandlesLayer();
4591
4596
  recomputeDirty();
4592
4597
  if (onSession) onSession(session);
@@ -4597,6 +4602,7 @@ function runEdit(opts) {
4597
4602
  get settled() {
4598
4603
  return core.settled;
4599
4604
  },
4605
+ usesGraphicStack: true,
4600
4606
  measureId: measure.id,
4601
4607
  abort(reason, cause) {
4602
4608
  core.abort(reason, cause);
@@ -4605,7 +4611,7 @@ function runEdit(opts) {
4605
4611
  if (core.settled) return;
4606
4612
  if (stylesEqual(currentStyle, next.style)) return;
4607
4613
  currentStyle = next.style;
4608
- renderPreview();
4614
+ renderWorkingGraphic();
4609
4615
  }
4610
4616
  };
4611
4617
  }
@@ -4772,8 +4778,8 @@ function scalePointSymbolStates(pre, scale, rotation) {
4772
4778
  * position, scales its size value, and rotates its rotation — its persisted
4773
4779
  * size unit is never converted.
4774
4780
  *
4775
- * - lift every member off the graphics layer + per-member working copies
4776
- * - render every member's preview to the shared preview layer each paint
4781
+ * - replace each host-provided Graphic slot with its per-member working copy
4782
+ * - use the preview layer only for members that have no host-provided slot
4777
4783
  * - group box = fit over the union of every member's rendered pixel footprint
4778
4784
  * at the session `boxAngle`; each member also gets its own thin outline
4779
4785
  * - dirty tracking, close/abort/signal/preempt/destroy, click-away — same
@@ -4782,7 +4788,7 @@ function scalePointSymbolStates(pre, scale, rotation) {
4782
4788
  * a live session without a session boundary
4783
4789
  */
4784
4790
  function runGroupTransform(opts) {
4785
- const { adapter, capability, graphics, graphicsLayer, previewLayer, handlesLayer, graphicsStyle, signal, closeOnClickAway = true, interactionStyle, onSession, handleIdPrefix, restoreOriginalToGraphics, isOnGraphicsLayer, onSettled, onClickAwayGraphicsPick } = opts;
4791
+ const { adapter, capability, graphics, graphicsLayer, previewLayer, handlesLayer, graphicsStyle, signal, closeOnClickAway = true, interactionStyle, onSession, handleIdPrefix, publishWorkingGraphics, settleWorkingGraphics, isOnGraphicsLayer, onSettled, onClickAwayGraphicsPick } = opts;
4786
4792
  assertCapabilityFor(graphics);
4787
4793
  const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
4788
4794
  let sizeAnchor = opts.sizeAnchor ?? "ground";
@@ -4808,7 +4814,8 @@ function runGroupTransform(opts) {
4808
4814
  workingAmplifierPlacements: void 0,
4809
4815
  optionsOverride: null,
4810
4816
  box: null,
4811
- lastBoxPixels: []
4817
+ lastBoxPixels: [],
4818
+ usesGraphicStack: isOnGraphicsLayer(graphic.id)
4812
4819
  };
4813
4820
  }
4814
4821
  const editStartPoints = graphic.controlPoints.map((p) => [...p]);
@@ -4823,17 +4830,12 @@ function runGroupTransform(opts) {
4823
4830
  workingAmplifierPlacements: graphic.amplifierPlacements ? structuredClone(graphic.amplifierPlacements) : void 0,
4824
4831
  optionsOverride: null,
4825
4832
  box: null,
4826
- lastBoxPixels: []
4833
+ lastBoxPixels: [],
4834
+ usesGraphicStack: isOnGraphicsLayer(graphic.id)
4827
4835
  };
4828
4836
  }
4829
4837
  /** The single mutable source of truth for the current member set. */
4830
4838
  let members = graphics.map(createMemberState);
4831
- /**
4832
- * `true` once any member has been lifted off the graphics layer (initial or a
4833
- * mid-session `setGraphics` add). Gates the abort-path restore so a session
4834
- * that never touched the graphics layer doesn't rewrite it on abort.
4835
- */
4836
- let anyLiftedFromGraphics = false;
4837
4839
  /** A member's own options merged with an override patch (a null override leaves them unchanged). */
4838
4840
  function mergeOptions(base, override) {
4839
4841
  if (!override) return base;
@@ -4882,7 +4884,7 @@ function runGroupTransform(opts) {
4882
4884
  render: renderMeasureForAdapter(adapter, committed, renderOpts)
4883
4885
  };
4884
4886
  }
4885
- /** The working graphic for member `i` (measure or point symbol), for preview and `workingGraphics`. */
4887
+ /** The working graphic for member `i` (measure or point symbol), for paint and `workingGraphics`. */
4886
4888
  function workingGraphicFor(i) {
4887
4889
  const member = members[i];
4888
4890
  if (isPointSymbolMember(member)) return buildWorkingPointSymbol(member);
@@ -4937,20 +4939,6 @@ function runGroupTransform(opts) {
4937
4939
  toPixel: (lonLat) => driver.toPixel(lonLat),
4938
4940
  fromPixel: (pixel) => driver.fromPixel(pixel)
4939
4941
  };
4940
- /**
4941
- * Lift `member` off the graphics layer when the host reports it currently
4942
- * there, flipping `anyLiftedFromGraphics` so the abort path knows to restore.
4943
- * Used for both initial and mid-session-added members.
4944
- */
4945
- function liftMemberFromGraphics(member) {
4946
- if (!isOnGraphicsLayer(member.graphic.id)) return;
4947
- if (isPointSymbol(member.graphic)) adapter.removeFeatures(graphicsLayer, [`${member.graphic.id}:point-symbol`]);
4948
- else {
4949
- const ids = renderMeasureForAdapter(adapter, member.graphic, renderOpts).features.map((f) => f.id).filter((id) => typeof id === "string");
4950
- if (ids.length > 0) adapter.removeFeatures(graphicsLayer, ids);
4951
- }
4952
- anyLiftedFromGraphics = true;
4953
- }
4954
4942
  function pixelsFromFeatures(features) {
4955
4943
  const positions = [];
4956
4944
  for (const f of features) if (f.geometry) collectFeaturePositions(f.geometry, positions);
@@ -5020,16 +5008,17 @@ function runGroupTransform(opts) {
5020
5008
  }
5021
5009
  function renderAllPreviews() {
5022
5010
  if (ctx.settled) return;
5023
- const allFeatures = [];
5024
- const resources = /* @__PURE__ */ new Map();
5011
+ const previewGroups = [];
5012
+ const workingGroups = [];
5025
5013
  members.forEach((member, i) => {
5026
5014
  let features;
5015
+ let iconResources = [];
5027
5016
  let boxPixels = [];
5028
5017
  try {
5029
5018
  if (isPointSymbolMember(member)) {
5030
5019
  const symbol = buildWorkingPointSymbol(member);
5031
5020
  const plan = renderPointSymbolForAdapter(adapter, capability, symbol);
5032
- resources.set(plan.resource.id, plan.resource);
5021
+ iconResources = [plan.resource];
5033
5022
  features = [plan.feature];
5034
5023
  const footprint = pointSymbolFootprint(symbol, plan.render, adapter);
5035
5024
  if (footprint) boxPixels = footprint.corners;
@@ -5043,10 +5032,19 @@ function runGroupTransform(opts) {
5043
5032
  boxPixels = [];
5044
5033
  }
5045
5034
  member.lastBoxPixels = boxPixels;
5046
- allFeatures.push(...features);
5035
+ if (member.usesGraphicStack) workingGroups.push({
5036
+ id: member.graphic.id,
5037
+ features,
5038
+ iconResources
5039
+ });
5040
+ else previewGroups.push({
5041
+ id: member.graphic.id,
5042
+ features,
5043
+ iconResources
5044
+ });
5047
5045
  });
5048
- adapter.reconcileLayerIconResources(previewLayer, [...resources.values()]);
5049
- ctx.writeLayer(previewLayer, allFeatures);
5046
+ publishWorkingGraphics(workingGroups);
5047
+ ctx.writeLayer(previewLayer, previewGroups);
5050
5048
  refitBoxes();
5051
5049
  }
5052
5050
  function rescaleOnViewChange() {
@@ -5067,7 +5065,11 @@ function runGroupTransform(opts) {
5067
5065
  features.push(...toFeatures(boxToHandles(liveGroupBox, dragProjection.fromPixel), handleIdPrefix, interactionStyle));
5068
5066
  }
5069
5067
  features.push(...memberOutlineFeatures(members.flatMap((member) => member.box ? [[member.graphic.id, member.box]] : []), dragProjection.fromPixel, handleIdPrefix, interactionStyle?.boxOutline));
5070
- ctx.writeLayer(handlesLayer, features);
5068
+ ctx.writeLayer(handlesLayer, [{
5069
+ id: "__td_transform_handles__",
5070
+ features,
5071
+ iconResources: []
5072
+ }]);
5071
5073
  }
5072
5074
  /**
5073
5075
  * Builds every member's committed snapshot against a given override array
@@ -5300,13 +5302,13 @@ function runGroupTransform(opts) {
5300
5302
  for (const graphic of next) {
5301
5303
  const existing = currentById.get(graphic.id);
5302
5304
  if (existing) nextMembers.push(existing);
5303
- else {
5304
- const member = createMemberState(graphic);
5305
- liftMemberFromGraphics(member);
5306
- nextMembers.push(member);
5307
- }
5305
+ else nextMembers.push(createMemberState(graphic));
5308
5306
  }
5309
5307
  members = nextMembers;
5308
+ if (removedSnapshots.length > 0) settleWorkingGraphics(removedSnapshots.map((snapshot) => ({
5309
+ id: snapshot.graphic.id,
5310
+ committed: snapshot.graphic
5311
+ })));
5310
5312
  renderAllPreviews();
5311
5313
  renderHandlesLayer();
5312
5314
  recomputeDirty();
@@ -5325,6 +5327,28 @@ function runGroupTransform(opts) {
5325
5327
  if (notificationErrors.length > 1) throw new AggregateError(notificationErrors, "TransformSession.setGraphics notification errors");
5326
5328
  return removedSnapshots;
5327
5329
  }
5330
+ function syncHostGraphics(hostGraphics) {
5331
+ if (ctx.settled) return;
5332
+ const hostIds = new Set(hostGraphics.map((graphic) => graphic.id));
5333
+ const withdrawnIds = members.filter((member) => !hostIds.has(member.graphic.id)).map((member) => member.graphic.id);
5334
+ const membershipChanged = withdrawnIds.length > 0;
5335
+ const surfaceChanged = members.some((member) => member.usesGraphicStack !== hostIds.has(member.graphic.id));
5336
+ if (!membershipChanged && !surfaceChanged) return;
5337
+ cancelActiveBoxDrag();
5338
+ if (withdrawnIds.length > 0) {
5339
+ settleWorkingGraphics(withdrawnIds.map((id) => ({ id })));
5340
+ members = members.filter((member) => hostIds.has(member.graphic.id));
5341
+ }
5342
+ for (const member of members) member.usesGraphicStack = true;
5343
+ if (members.length === 0) {
5344
+ ctx.fail(new TacticalDrawAbortError("removed"));
5345
+ return;
5346
+ }
5347
+ renderAllPreviews();
5348
+ renderHandlesLayer();
5349
+ recomputeDirty();
5350
+ if (membershipChanged) history.reset(captureHistory());
5351
+ }
5328
5352
  function close() {
5329
5353
  return commitClose();
5330
5354
  }
@@ -5367,6 +5391,7 @@ function runGroupTransform(opts) {
5367
5391
  setSizeAnchor(next);
5368
5392
  },
5369
5393
  setGraphics,
5394
+ syncHostGraphics,
5370
5395
  close,
5371
5396
  abort() {
5372
5397
  if (ctx.settled) return;
@@ -5487,6 +5512,12 @@ function runGroupTransform(opts) {
5487
5512
  handlesLayer,
5488
5513
  closeOnClickAway,
5489
5514
  onViewChange: rescaleOnViewChange,
5515
+ onBeforeTeardown: (settlement) => {
5516
+ settleWorkingGraphics(settlement.status === "committed" ? settlement.value.map((snapshot) => ({
5517
+ id: snapshot.graphic.id,
5518
+ committed: snapshot.graphic
5519
+ })) : members.map((member) => ({ id: member.graphic.id })));
5520
+ },
5490
5521
  onDown: onDriverDown,
5491
5522
  onMove: onDriverMove,
5492
5523
  onUp: onDriverUp,
@@ -5506,16 +5537,11 @@ function runGroupTransform(opts) {
5506
5537
  activeBoxDrag = null;
5507
5538
  dragInFlight = false;
5508
5539
  }
5509
- adapter.reconcileLayerIconResources(previewLayer, []);
5510
- },
5511
- onAbort: () => {
5512
- if (anyLiftedFromGraphics) restoreOriginalToGraphics();
5513
5540
  },
5514
5541
  attach(interaction, createdDriver, createdShell) {
5515
5542
  ctx = interaction;
5516
5543
  driver = createdDriver;
5517
5544
  shell = createdShell;
5518
- for (const member of members) liftMemberFromGraphics(member);
5519
5545
  renderAllPreviews();
5520
5546
  renderHandlesLayer();
5521
5547
  recomputeDirty();
@@ -5532,7 +5558,8 @@ function runGroupTransform(opts) {
5532
5558
  },
5533
5559
  abort(reason, cause) {
5534
5560
  core.abort(reason, cause);
5535
- }
5561
+ },
5562
+ syncHostGraphics
5536
5563
  };
5537
5564
  }
5538
5565
  //#endregion
@@ -5555,8 +5582,11 @@ function runPointSymbolDraw(options) {
5555
5582
  const renderPreview = () => {
5556
5583
  if (!latestPosition) return;
5557
5584
  const plan = renderPointSymbolForAdapter(adapter, capability, build(PREVIEW_ID, latestPosition));
5558
- adapter.reconcileLayerIconResources(previewLayer, [plan.resource]);
5559
- ctx.writeLayer(previewLayer, [plan.feature]);
5585
+ ctx.writeLayer(previewLayer, [{
5586
+ id: PREVIEW_ID,
5587
+ features: [plan.feature],
5588
+ iconResources: [plan.resource]
5589
+ }]);
5560
5590
  };
5561
5591
  const handlePointerMove = (event) => {
5562
5592
  latestPosition = snappedDrawPosition(snap, event);
@@ -5583,7 +5613,6 @@ function runPointSymbolDraw(options) {
5583
5613
  interaction.onTeardown(() => {
5584
5614
  adapter.off("pointermove", handlePointerMove);
5585
5615
  adapter.off("click", handleClick);
5586
- adapter.reconcileLayerIconResources(previewLayer, []);
5587
5616
  adapter.setCursor(savedCursor);
5588
5617
  });
5589
5618
  adapter.setCursor("crosshair");
@@ -5600,8 +5629,8 @@ function normalisePointModes(input) {
5600
5629
  for (const mode of modes) if (mode !== "transform" && mode !== "delete") throw new TypeError(`Point-symbol editing does not support mode "${mode}"`);
5601
5630
  return modes.includes("delete") ? ["delete"] : [...new Set(modes)];
5602
5631
  }
5603
- function symbolBox(adapter, capability, symbol, padding = 0) {
5604
- const footprint = pointSymbolFootprint(symbol, capability.render(symbol), adapter);
5632
+ function symbolBox(adapter, symbol, rendered, padding = 0) {
5633
+ const footprint = pointSymbolFootprint(symbol, rendered, adapter);
5605
5634
  if (!footprint) return null;
5606
5635
  return {
5607
5636
  ...footprint.box,
@@ -5610,8 +5639,9 @@ function symbolBox(adapter, capability, symbol, padding = 0) {
5610
5639
  };
5611
5640
  }
5612
5641
  function runPointSymbolEdit(opts) {
5613
- const { adapter, capability, symbol, graphicsLayer, previewLayer, handlesLayer, signal, closeOnClickAway = true, interactionStyle, onSession, handleIdPrefix, settleToGraphics, wasOnGraphicsLayer, onSettled, onClickAwayGraphicsPick, snap } = opts;
5642
+ const { adapter, capability, symbol, graphicsLayer, previewLayer, handlesLayer, signal, closeOnClickAway = true, interactionStyle, onSession, handleIdPrefix, settleToGraphics, publishWorkingGraphic, wasOnGraphicsLayer, onSettled, onClickAwayGraphicsPick, snap } = opts;
5614
5643
  let modes = normalisePointModes(opts.modes);
5644
+ let usesGraphicStack = wasOnGraphicsLayer;
5615
5645
  let working = clonePointSymbol(symbol);
5616
5646
  let dirty = false;
5617
5647
  let box = null;
@@ -5636,15 +5666,29 @@ function runPointSymbolEdit(opts) {
5636
5666
  const render = () => {
5637
5667
  if (ctx.settled) return;
5638
5668
  const plan = renderPointSymbolForAdapter(adapter, capability, working);
5639
- adapter.reconcileLayerIconResources(previewLayer, [plan.resource]);
5640
- ctx.writeLayer(previewLayer, [plan.feature]);
5641
- box = symbolBox(adapter, capability, working, interactionStyle?.boxPadding);
5669
+ const nextBox = symbolBox(adapter, working, plan.render, interactionStyle?.boxPadding);
5642
5670
  const handles = [];
5643
- if (modes.includes("transform") && box) {
5644
- handles.push(boxOutlineFeature(box, (pixel) => driver.fromPixel(pixel), `${handleIdPrefix}:box-outline`, interactionStyle?.boxOutline));
5645
- handles.push(...toFeatures(boxToHandles(box, (pixel) => driver.fromPixel(pixel)), handleIdPrefix, interactionStyle));
5646
- }
5647
- ctx.writeLayer(handlesLayer, handles);
5671
+ if (modes.includes("transform") && nextBox) {
5672
+ handles.push(boxOutlineFeature(nextBox, (pixel) => driver.fromPixel(pixel), `${handleIdPrefix}:box-outline`, interactionStyle?.boxOutline));
5673
+ handles.push(...toFeatures(boxToHandles(nextBox, (pixel) => driver.fromPixel(pixel)), handleIdPrefix, interactionStyle));
5674
+ }
5675
+ publishWorkingGraphic({
5676
+ id: working.id,
5677
+ features: [plan.feature],
5678
+ iconResources: [plan.resource]
5679
+ });
5680
+ if (usesGraphicStack) ctx.writeLayer(previewLayer, []);
5681
+ else ctx.writeLayer(previewLayer, [{
5682
+ id: `__td_point_symbol_edit_preview__:${working.id}`,
5683
+ features: [plan.feature],
5684
+ iconResources: [plan.resource]
5685
+ }]);
5686
+ box = nextBox;
5687
+ ctx.writeLayer(handlesLayer, [{
5688
+ id: `__td_point_symbol_edit_handles__:${working.id}`,
5689
+ features: handles,
5690
+ iconResources: []
5691
+ }]);
5648
5692
  };
5649
5693
  const emitChange = (previousState, source, onAccepted) => {
5650
5694
  const previous = {
@@ -5809,16 +5853,11 @@ function runPointSymbolEdit(opts) {
5809
5853
  historySettle: () => history.settle(),
5810
5854
  facadeOnSettled: onSettled,
5811
5855
  emitSettle: (settlement) => settleFromInteraction(settlement, commitEmitter, settleEmitter),
5812
- extraTeardown: () => adapter.reconcileLayerIconResources(previewLayer, []),
5813
5856
  attach(interaction, createdDriver, createdShell) {
5814
5857
  ctx = interaction;
5815
5858
  driver = createdDriver;
5816
5859
  shell = createdShell;
5817
5860
  render();
5818
- if (wasOnGraphicsLayer) {
5819
- adapter.deferLayerRemovalUntilLayerUpdate?.(graphicsLayer, previewLayer);
5820
- adapter.removeFeatures(graphicsLayer, [`${symbol.id}:point-symbol`]);
5821
- }
5822
5861
  onSession?.(session);
5823
5862
  }
5824
5863
  });
@@ -5827,11 +5866,55 @@ function runPointSymbolEdit(opts) {
5827
5866
  get settled() {
5828
5867
  return handle.settled;
5829
5868
  },
5869
+ get usesGraphicStack() {
5870
+ return usesGraphicStack;
5871
+ },
5830
5872
  measureId: symbol.id,
5831
- abort: handle.abort
5873
+ abort: handle.abort,
5874
+ syncHostMembership(present) {
5875
+ if (handle.settled || usesGraphicStack === present) return;
5876
+ const previous = usesGraphicStack;
5877
+ usesGraphicStack = present;
5878
+ try {
5879
+ render();
5880
+ } catch (error) {
5881
+ usesGraphicStack = previous;
5882
+ throw error;
5883
+ }
5884
+ }
5832
5885
  };
5833
5886
  }
5834
5887
  //#endregion
5888
+ //#region src/tactical-draw/graphic-stack-coordinator.ts
5889
+ /**
5890
+ * Combines host-owned Graphic order with live or settled render overrides.
5891
+ *
5892
+ * The host always owns membership and order. A working override only replaces
5893
+ * the contents of its matching host slot; a committed override survives until
5894
+ * the host next acknowledges that Graphic in `render()`.
5895
+ */
5896
+ var GraphicStackCoordinator = class {
5897
+ overrides = /* @__PURE__ */ new Map();
5898
+ acknowledgeHostRender(graphics) {
5899
+ for (const graphic of graphics) if (this.overrides.get(graphic.id)?.settled) this.overrides.delete(graphic.id);
5900
+ }
5901
+ publishWorking(group) {
5902
+ this.overrides.set(group.id, {
5903
+ group,
5904
+ settled: false
5905
+ });
5906
+ }
5907
+ settle(graphicId, committed) {
5908
+ const override = this.overrides.get(graphicId);
5909
+ if (!override) return;
5910
+ if (committed) override.settled = true;
5911
+ else this.overrides.delete(graphicId);
5912
+ }
5913
+ desiredGroups(graphics, renderHostGraphic) {
5914
+ return graphics.map((graphic) => this.overrides.get(graphic.id)?.group ?? renderHostGraphic(graphic));
5915
+ }
5916
+ };
5917
+ //#endregion
5835
5918
  //#region src/tactical-draw/tactical-draw.ts
5836
5919
  /**
5837
5920
  * `TacticalDraw` façade — slice #6 (issue #45).
@@ -5854,6 +5937,7 @@ var TacticalDraw = class {
5854
5937
  pointSymbols;
5855
5938
  snappingOptions;
5856
5939
  resolveSnap;
5940
+ graphicStack = new GraphicStackCoordinator();
5857
5941
  destroyed = false;
5858
5942
  hasRendered = false;
5859
5943
  activeInteraction = new ActiveInteractionTracker();
@@ -5897,12 +5981,7 @@ var TacticalDraw = class {
5897
5981
  idCounter = 0;
5898
5982
  editCounter = 0;
5899
5983
  transformCounter = 0;
5900
- /**
5901
- * Snapshot of the host's last `render()` argument. The edit slice uses it
5902
- * to recompute the graphics-layer state during a lift / restore — the
5903
- * façade owns the policy because controllers shouldn't need to know the
5904
- * surrounding collection state.
5905
- */
5984
+ /** Snapshot of the host's latest authoritative Graphic membership and order. */
5906
5985
  lastRenderedMeasures = [];
5907
5986
  snapGeometryFeatures = [];
5908
5987
  /**
@@ -5952,53 +6031,65 @@ var TacticalDraw = class {
5952
6031
  clearSnapResult() {
5953
6032
  this.snappingOptions?.onSnap?.(null);
5954
6033
  }
5955
- /**
5956
- * Render `measures` into features and write them to the graphics layer.
5957
- * Routes every measure through [[renderMeasureForAdapter]] so pixel sizing is
5958
- * resolved against the live map. Callers own any edit-exclusion policy — see
5959
- * `render()` (excludes the edited measure, owned by the preview layer) versus
5960
- * `settleToGraphics` (no exclusion, the edit is ending).
5961
- */
5962
- renderMeasuresToGraphics(graphics) {
5963
- const features = [];
5964
- const resources = /* @__PURE__ */ new Map();
6034
+ /** Render one atomic Graphic group for the engine-neutral graphics stack. */
6035
+ renderGraphicGroup(graphic) {
5965
6036
  const renderOpts = { graphicsStyle: this.graphicsStyle };
5966
- for (const graphic of graphics) if (isPointSymbol(graphic)) {
6037
+ if (isPointSymbol(graphic)) {
5967
6038
  if (!this.pointSymbols) throw new PointSymbolCapabilityRequiredError();
5968
6039
  const plan = renderPointSymbolForAdapter(this.adapter, this.pointSymbols, graphic);
5969
- resources.set(plan.resource.id, plan.resource);
5970
- features.push(plan.feature);
5971
- } else {
5972
- const render = renderMeasureForAdapter(this.adapter, graphic, renderOpts);
5973
- for (const feature of render.features) features.push(feature);
6040
+ return {
6041
+ id: graphic.id,
6042
+ features: [plan.feature],
6043
+ iconResources: [plan.resource]
6044
+ };
6045
+ }
6046
+ const render = renderMeasureForAdapter(this.adapter, graphic, renderOpts);
6047
+ return {
6048
+ id: graphic.id,
6049
+ features: render.features,
6050
+ iconResources: []
6051
+ };
6052
+ }
6053
+ /** Submit one complete grouped desired state for the host stack plus overrides. */
6054
+ reconcileGraphicsStack(graphics = this.lastRenderedMeasures) {
6055
+ const groups = this.graphicStack.desiredGroups(graphics, (graphic) => this.renderGraphicGroup(graphic));
6056
+ this.adapter.setLayerFeatureGroups(this.graphicsLayer, groups);
6057
+ this.snapGeometryFeatures = groups.flatMap((group) => group.features);
6058
+ }
6059
+ /** Atomically publish several live renders into their host-owned Graphic slots. */
6060
+ publishWorkingGraphics(groups) {
6061
+ for (const group of groups) this.graphicStack.publishWorking(group);
6062
+ this.reconcileGraphicsStack();
6063
+ }
6064
+ /** Settle several stack overrides in one graphics-layer reconciliation. */
6065
+ settleWorkingGraphics(settlements) {
6066
+ for (const { id, committed } of settlements) {
6067
+ if (committed) this.graphicStack.publishWorking(this.renderGraphicGroup(committed));
6068
+ this.graphicStack.settle(id, committed !== void 0);
5974
6069
  }
5975
- this.adapter.reconcileLayerIconResources(this.graphicsLayer, [...resources.values()]);
5976
- this.adapter.updateLayerFeatures(this.graphicsLayer, features);
5977
- this.snapGeometryFeatures = features;
6070
+ this.reconcileGraphicsStack();
5978
6071
  }
5979
6072
  /**
5980
6073
  * Re-render the graphics layer on a view (zoom) change so pixel-denominated
5981
6074
  * and not-yet-committed label-sized measures rescale live. Skipped entirely
5982
6075
  * when nothing rendered still reads live view state — meter geometry with a
5983
6076
  * captured label size is zoom-independent, so a rebuild would be wasted
5984
- * layer writes. During an active edit the edited measure lives on the
5985
- * preview layer (lifted from graphics), so it is excluded here exactly as
5986
- * in `render()`; the preview's own view-change subscription rescales it.
6077
+ * layer writes. A single-control-measure edit publishes through the stack
6078
+ * coordinator; point symbols and transform members already present in the
6079
+ * host stack do the same. Graphics without a host slot remain on preview.
5987
6080
  */
5988
6081
  handleViewChange = () => {
5989
6082
  if (this.destroyed || !this.hasRendered) return;
5990
6083
  this.renderHighlights();
5991
- const excluded = this.excludedInteractionIds();
6084
+ const excluded = this.graphicsStackExcludedIds();
5992
6085
  const visible = excluded.size > 0 ? this.lastRenderedMeasures.filter((m) => !excluded.has(m.id)) : this.lastRenderedMeasures;
5993
6086
  const nativeGroundIconScaling = this.adapter.scalesGroundPointSymbolsNatively === true;
5994
6087
  if (!visible.some((g) => graphicRendersFromViewState(g, nativeGroundIconScaling))) return;
5995
- this.renderMeasuresToGraphics(visible);
6088
+ this.reconcileGraphicsStack(visible);
5996
6089
  };
5997
6090
  /**
5998
- * Every measure id currently lifted onto the preview layer by an active
5999
- * edit or group transform — excluded from `render()` / view-change
6000
- * reconciliation because the preview layer (not graphics) owns their
6001
- * working copy. Empty while idle.
6091
+ * Every Graphic id owned by an active interaction. Used for highlight and
6092
+ * pick routing; not every such Graphic is excluded from stack reconciliation.
6002
6093
  */
6003
6094
  excludedInteractionIds() {
6004
6095
  const ids = /* @__PURE__ */ new Set();
@@ -6006,6 +6097,13 @@ var TacticalDraw = class {
6006
6097
  for (const id of this.transformMemberIds() ?? []) ids.add(id);
6007
6098
  return ids;
6008
6099
  }
6100
+ /** Interaction ids still rendered on the legacy preview surface. */
6101
+ graphicsStackExcludedIds() {
6102
+ const ids = this.excludedInteractionIds();
6103
+ if (this.activeEdit?.usesGraphicStack) ids.delete(this.activeEdit.measureId);
6104
+ for (const id of this.transformMemberIds() ?? []) ids.delete(id);
6105
+ return ids;
6106
+ }
6009
6107
  /**
6010
6108
  * Live member ids of the active group transform, or `undefined` when none.
6011
6109
  * Falls back to the session ref because `onSession` fires from inside the
@@ -6024,24 +6122,24 @@ var TacticalDraw = class {
6024
6122
  * Idempotent. Throws synchronously on duplicate measure ids *before*
6025
6123
  * mutating any layer. An empty list before any prior render is a no-op.
6026
6124
  *
6027
- * Cross-layer semantics with an active edit (issue #51):
6028
- * - The measure under edit is **excluded** from the reconcile pass — the
6029
- * preview layer owns its working copy and the lift on the graphics layer
6030
- * must stay in place.
6125
+ * Cross-layer semantics with an active edit:
6126
+ * - The stack coordinator replaces the edited Graphic's host-owned slot
6127
+ * with its working render; membership and order remain host-authoritative.
6031
6128
  * - If the edited measure is absent from `measures`, the pending `edit`
6032
6129
  * promise rejects on the **next microtask** with reason `"removed"`.
6033
6130
  * `render` itself returns normally so host collection ops stay synchronous.
6034
- * - If the edited measure is present and its `style` changed, the preview
6035
- * picks up the new style on this render. `controlPoints` changes on the
6131
+ * - If the edited measure is present and its `style` changed, its working
6132
+ * group picks up the new style on this render. `controlPoints` changes on the
6036
6133
  * edited measure are routed nowhere — the user's in-flight drag is never
6037
- * yanked out from under them.
6134
+ * yanked out from under them. A point symbol's complete working group remains
6135
+ * authoritative until settle, including any changed icon resource.
6038
6136
  *
6039
6137
  * Cross-layer semantics with an active draw: `render` only touches the
6040
6138
  * graphics layer, so the draw's preview is undisturbed.
6041
6139
  *
6042
6140
  * Reentrancy: calling `td.render(...)` from inside `EditSession.onChange` is
6043
- * safe — `render` never re-enters the active edit or its preview / handle
6044
- * layers (the only edit-facing side effect, `notifyExternalMeasure`, is a
6141
+ * safe — `render` never re-enters the active edit or its handle layer (the
6142
+ * only edit-facing side effect, `notifyExternalMeasure`, is a
6045
6143
  * pure style replay).
6046
6144
  */
6047
6145
  render(measures) {
@@ -6051,39 +6149,36 @@ var TacticalDraw = class {
6051
6149
  if (seen.has(m.id)) throw new Error(`TacticalDraw.render: duplicate graphic id "${m.id}"`);
6052
6150
  seen.add(m.id);
6053
6151
  }
6054
- const editedId = this.activeEdit?.measureId;
6152
+ const activeEdit = this.activeEdit;
6153
+ const constructingSession = activeEdit ? null : this.activeInteraction.session;
6154
+ const constructingGraphic = constructingSession && "workingGraphic" in constructingSession ? constructingSession.workingGraphic : void 0;
6155
+ const editedId = activeEdit?.measureId ?? constructingGraphic?.id;
6055
6156
  let externalEdited;
6056
6157
  let editedRemoved = false;
6057
6158
  if (editedId !== void 0) {
6058
6159
  const candidate = measures.find((m) => m.id === editedId);
6059
- externalEdited = candidate && !isPointSymbol(candidate) ? candidate : void 0;
6060
- editedRemoved = externalEdited === void 0;
6160
+ if (activeEdit && "notifyExternalMeasure" in activeEdit || constructingGraphic !== void 0 && !isPointSymbol(constructingGraphic)) {
6161
+ externalEdited = candidate && !isPointSymbol(candidate) ? candidate : void 0;
6162
+ editedRemoved = externalEdited === void 0;
6163
+ } else editedRemoved = candidate === void 0 || !isPointSymbol(candidate);
6061
6164
  }
6062
- const transformIds = this.transformMemberIds();
6063
- const transformMemberRemoved = transformIds ? transformIds.some((id) => !seen.has(id)) : false;
6064
- const excluded = this.excludedInteractionIds();
6165
+ this.lastRenderedMeasures = measures.slice();
6166
+ this.graphicStack.acknowledgeHostRender(measures);
6167
+ if (activeEdit && "syncHostMembership" in activeEdit && measures.some((graphic) => graphic.id === activeEdit.measureId && isPointSymbol(graphic))) activeEdit.syncHostMembership(true);
6168
+ (this.activeTransform ?? this.activeTransformSessionRef)?.syncHostGraphics(measures);
6169
+ const excluded = this.graphicsStackExcludedIds();
6065
6170
  const visible = excluded.size > 0 ? measures.filter((m) => !excluded.has(m.id)) : measures;
6066
6171
  if (!(visible.length === 0 && !this.hasRendered)) {
6067
- this.renderMeasuresToGraphics(visible);
6172
+ this.reconcileGraphicsStack(visible);
6068
6173
  this.hasRendered = true;
6069
6174
  }
6070
- this.lastRenderedMeasures = measures.slice();
6071
6175
  if (externalEdited && this.activeEdit && "notifyExternalMeasure" in this.activeEdit) this.activeEdit.notifyExternalMeasure(externalEdited);
6072
- if (editedRemoved && this.activeEdit) {
6176
+ if (editedRemoved) {
6073
6177
  const editHandle = this.activeEdit;
6074
- queueMicrotask(() => {
6178
+ if (editHandle) queueMicrotask(() => {
6075
6179
  editHandle.abort("removed");
6076
6180
  });
6077
- }
6078
- if (transformMemberRemoved) {
6079
- const sessionRef = this.activeTransformSessionRef;
6080
- queueMicrotask(() => {
6081
- const handle = this.activeTransform;
6082
- if (!handle || handle.settled) return;
6083
- if (this.activeTransformSessionRef !== sessionRef) return;
6084
- const rendered = new Set(this.lastRenderedMeasures.map((m) => m.id));
6085
- if (handle.graphicIds.some((id) => !rendered.has(id))) handle.abort("removed");
6086
- });
6181
+ else if (this.activeInteraction.isStarting) this.activeInteraction.cancel("removed");
6087
6182
  }
6088
6183
  this.renderHighlights();
6089
6184
  }
@@ -6092,7 +6187,7 @@ var TacticalDraw = class {
6092
6187
  * idempotent (mirrors {@link render}): a non-interactive outline box is drawn
6093
6188
  * around each still-rendered graphic on the dedicated highlight layer — no
6094
6189
  * draw / edit / transform session, no promise, no pick subscription. Passing
6095
- * `[]` clears every highlight. Unknown ids and ids currently lifted into an
6190
+ * `[]` clears every highlight. Unknown ids and ids currently active in an
6096
6191
  * active edit / transform session are skipped (the live session's own visuals
6097
6192
  * supersede the passive outline). Hosts use it for Excalidraw-style
6098
6193
  * "locked selection" chrome.
@@ -6105,9 +6200,9 @@ var TacticalDraw = class {
6105
6200
  /**
6106
6201
  * Project the current {@link highlightedIds} into one outline `LineString`
6107
6202
  * per graphic and reconcile them onto the highlight layer. Idempotent and
6108
- * id-keyed via `adapter.updateLayerFeatures`, so a set-replace removes stale
6109
- * outlines and an unchanged set is a cheap no-op write. Skips ids that are
6110
- * unknown (not in the last render) or lifted into an active session, and
6203
+ * grouped by graphic id, so desired-state replacement removes stale outlines
6204
+ * and an unchanged set is a cheap no-op write. Skips ids that are
6205
+ * unknown (not in the last render) or active in an interaction session, and
6111
6206
  * bails gracefully when the adapter cannot invert pixels to coordinates
6112
6207
  * (`getCoordinateFromPixel` is optional in the MapAdapter ABI).
6113
6208
  */
@@ -6140,7 +6235,11 @@ var TacticalDraw = class {
6140
6235
  const feature = boxOutlineFeature(box, invert, `__td_highlight__:${id}`, style);
6141
6236
  if (!invertFailed) features.push(feature);
6142
6237
  }
6143
- this.adapter.updateLayerFeatures(this.highlightLayer, features);
6238
+ this.adapter.setLayerFeatureGroups(this.highlightLayer, features.map((feature) => ({
6239
+ id: String(feature.id),
6240
+ features: [feature],
6241
+ iconResources: []
6242
+ })));
6144
6243
  this.hasHighlightOutlines = features.length > 0;
6145
6244
  }
6146
6245
  /**
@@ -6342,10 +6441,10 @@ var TacticalDraw = class {
6342
6441
  if (preempt) return preempt;
6343
6442
  const wasOnGraphicsLayer = this.lastRenderedMeasures.some((m) => m.id === measure.id);
6344
6443
  const settleToGraphics = (settledSymbol) => {
6345
- if (!wasOnGraphicsLayer) return;
6346
- const graphics = settledSymbol ? this.lastRenderedMeasures.map((graphic) => graphic.id === measure.id ? settledSymbol : graphic) : this.lastRenderedMeasures;
6347
- this.renderMeasuresToGraphics(graphics);
6348
- this.adapter.deferLayerRemovalUntilLayerUpdate?.(this.previewLayer, this.graphicsLayer);
6444
+ this.settleWorkingGraphics([{
6445
+ id: measure.id,
6446
+ committed: settledSymbol
6447
+ }]);
6349
6448
  };
6350
6449
  const handleIdPrefix = `__td_edit_${++this.editCounter}__`;
6351
6450
  return this.runWithSlot((onSettled) => runPointSymbolEdit({
@@ -6359,14 +6458,16 @@ var TacticalDraw = class {
6359
6458
  closeOnClickAway: options?.closeOnClickAway,
6360
6459
  modes: options?.modes,
6361
6460
  interactionStyle: this.resolveInteractionStyle(options?.interactionStyle),
6362
- onSession: options?.onSession,
6461
+ onSession: this.trackSession(options?.onSession),
6363
6462
  handleIdPrefix,
6364
6463
  settleToGraphics,
6464
+ publishWorkingGraphic: (group) => this.publishWorkingGraphics([group]),
6365
6465
  wasOnGraphicsLayer,
6366
6466
  onSettled,
6367
6467
  onClickAwayGraphicsPick: (event) => this.dispatchHostPick(this.graphicsLayer, event),
6368
6468
  snap: this.resolveSnap
6369
6469
  }), (handle) => {
6470
+ handle.syncHostMembership(this.lastRenderedMeasures.some((graphic) => graphic.id === handle.measureId && isPointSymbol(graphic)));
6370
6471
  this.activeEdit = handle;
6371
6472
  return () => {
6372
6473
  if (this.activeEdit === handle) this.activeEdit = null;
@@ -6376,12 +6477,11 @@ var TacticalDraw = class {
6376
6477
  normaliseModes(options?.modes);
6377
6478
  const preempt = this.guardAndPreempt();
6378
6479
  if (preempt) return preempt;
6379
- const wasOnGraphicsLayer = this.lastRenderedMeasures.some((m) => m.id === measure.id);
6380
6480
  const settleToGraphics = (settledMeasure) => {
6381
- if (!wasOnGraphicsLayer) return;
6382
- const graphics = settledMeasure ? this.lastRenderedMeasures.map((graphic) => graphic.id === measure.id ? settledMeasure : graphic) : this.lastRenderedMeasures;
6383
- this.renderMeasuresToGraphics(graphics);
6384
- this.adapter.deferLayerRemovalUntilLayerUpdate?.(this.previewLayer, this.graphicsLayer);
6481
+ this.settleWorkingGraphics([{
6482
+ id: measure.id,
6483
+ committed: settledMeasure
6484
+ }]);
6385
6485
  };
6386
6486
  const handleIdPrefix = `__td_edit_${++this.editCounter}__`;
6387
6487
  const mergedInteractionStyle = this.resolveInteractionStyle(options?.interactionStyle);
@@ -6406,7 +6506,11 @@ var TacticalDraw = class {
6406
6506
  guide: options?.guide,
6407
6507
  sizeAnchor: options?.sizeAnchor,
6408
6508
  settleToGraphics,
6409
- wasOnGraphicsLayer,
6509
+ publishWorkingGraphic: (graphic, features) => this.publishWorkingGraphics([{
6510
+ id: graphic.id,
6511
+ features,
6512
+ iconResources: []
6513
+ }]),
6410
6514
  onSettled,
6411
6515
  onClickAwayGraphicsPick,
6412
6516
  snap: this.resolveSnap
@@ -6429,9 +6533,6 @@ var TacticalDraw = class {
6429
6533
  const preempt = this.guardAndPreempt();
6430
6534
  if (preempt) return preempt;
6431
6535
  const isOnGraphicsLayer = (id) => this.lastRenderedMeasures.some((m) => m.id === id);
6432
- const restoreOriginalToGraphics = () => {
6433
- this.renderMeasuresToGraphics(this.lastRenderedMeasures);
6434
- };
6435
6536
  const handleIdPrefix = `__td_transform_${++this.transformCounter}__`;
6436
6537
  const mergedInteractionStyle = this.resolveInteractionStyle(options?.interactionStyle);
6437
6538
  const onClickAwayGraphicsPick = (event) => {
@@ -6454,7 +6555,8 @@ var TacticalDraw = class {
6454
6555
  onSession: this.trackTransformSession(options?.onSession),
6455
6556
  handleIdPrefix,
6456
6557
  sizeAnchor: options?.sizeAnchor,
6457
- restoreOriginalToGraphics,
6558
+ publishWorkingGraphics: (groups) => this.publishWorkingGraphics(groups),
6559
+ settleWorkingGraphics: (settlements) => this.settleWorkingGraphics(settlements),
6458
6560
  isOnGraphicsLayer,
6459
6561
  onSettled,
6460
6562
  onClickAwayGraphicsPick
@@ -6736,7 +6838,8 @@ var TacticalDraw = class {
6736
6838
  */
6737
6839
  routePick(layerId, event) {
6738
6840
  const excluded = this.excludedInteractionIds();
6739
- if (layerId === this.graphicsLayer && excluded.size > 0 && !excluded.has(event.id)) {
6841
+ if (layerId === this.graphicsLayer && excluded.size > 0) {
6842
+ if (excluded.has(event.id)) return;
6740
6843
  this.deferredGraphicsPick = event;
6741
6844
  return;
6742
6845
  }
@@ -7091,4 +7194,4 @@ function nearestWorldCopyPosition(position, refLng) {
7091
7194
  return [nearestWorldCopyLng(position[0], refLng), ...position.slice(1)];
7092
7195
  }
7093
7196
  //#endregion
7094
- export { BaseMapAdapter, DEFAULT_LABEL_SIZE_PIXELS, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, HATCH_PATTERNS, PointSymbolCapabilityRequiredError, PointerCallbackHub, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, TacticalDrawDestroyedError, bindAbortable, clonePointSymbol, coerceHandleStyle, combineWithHostSignal, effectivePointSymbolPixelSize, evaluatePickCandidates, fillPatternImageDimension, fillPatternImageId, getSizeAnchor, hitTestFeature, hitTestIconFeature, ignoreAbort, isPointSymbol, isTacticalDrawAbortError, makeFillPatternImage, mergeNativeAndCachedPickFeatures, nearestWorldCopyLng, nearestWorldCopyPosition, pickHitTolerance, pickPointerType, pointSymbolFootprint, renderOrderToVisualOrder, resizePointSymbolSize, resolveGroundTextSizePixels, resolvePickPresentation, resolveTextSizePixels, tryControlMeasureIdFromFeature };
7197
+ export { BaseMapAdapter, DEFAULT_LABEL_SIZE_PIXELS, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, HATCH_PATTERNS, PointSymbolCapabilityRequiredError, PointerCallbackHub, TEXT_HALO_COLOR, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, TacticalDrawDestroyedError, bindAbortable, clonePointSymbol, coerceHandleStyle, combineWithHostSignal, effectivePointSymbolPixelSize, evaluatePickCandidates, fillPatternImageDimension, fillPatternImageId, getSizeAnchor, hitTestFeature, hitTestIconFeature, ignoreAbort, isPointSymbol, isTacticalDrawAbortError, makeFillPatternImage, mergeNativeAndCachedPickFeatures, nearestWorldCopyLng, nearestWorldCopyPosition, pickHitTolerance, pickPointerType, pointSymbolFootprint, renderOrderToVisualOrder, resizePointSymbolSize, resolveGroundTextSizePixels, resolvePickPresentation, resolveTextHaloWidth, resolveTextSizePixels, tryControlMeasureIdFromFeature };