@orbat-mapper/tactical-draw 0.2.0-alpha.34 → 0.2.0-alpha.36

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,219 +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 = [...updateById.values()];
80
- const removed = [];
81
- for (const f of existing) if (f.id == null) merged.push(f);
82
- else if (!updateById.has(f.id)) removed.push(f);
83
- this.featureCache.set(layerId, merged);
84
- this.applyFeatureOps(layerId, {
85
- adds,
86
- updates,
87
- removes: removed,
88
- full: merged,
89
- replace: false
90
- });
91
- this.emitLayerFeaturesChanged(layerId, merged);
92
- }
93
- removeFeatures(layerId, ids) {
94
- if (!this.layers.has(layerId)) return;
95
- if (ids.length === 0) return;
96
- const existing = this.featureCache.get(layerId) ?? [];
97
- const idSet = new Set(ids);
98
- const merged = [];
99
- const removed = [];
100
- for (const f of existing) if (typeof f.id === "string" && idSet.has(f.id)) removed.push(f);
101
- else merged.push(f);
102
- if (removed.length === 0) return;
103
- this.featureCache.set(layerId, merged);
104
- this.applyFeatureOps(layerId, {
105
- adds: [],
106
- updates: [],
107
- removes: removed,
108
- full: merged,
109
- replace: false
110
- });
111
- this.emitLayerFeaturesChanged(layerId, merged);
112
- }
113
- clearLayer(layerId) {
114
- if (!this.layers.has(layerId)) return;
115
- this.featureCache.set(layerId, []);
116
- this.applyFeatureOps(layerId, {
117
- adds: [],
118
- updates: [],
119
- full: [],
120
- replace: true
121
- });
122
- this.emitLayerFeaturesChanged(layerId, []);
123
- }
124
- on(eventType, handler) {
125
- const entry = this.attachNativeEvent(eventType, (lonLat, pixel, originalEvent) => {
126
- handler({
127
- type: eventType,
128
- coordinate: lonLat,
129
- pixel,
130
- originalEvent
131
- });
132
- });
133
- this.eventTokens.set(handler, entry);
134
- }
135
- off(eventType, handler) {
136
- const entry = this.eventTokens.get(handler);
137
- if (!entry) return;
138
- this.detachNativeEvent(entry);
139
- this.eventTokens.delete(handler);
140
- }
141
- onViewChange(handler) {
142
- const token = this.attachViewChange(handler);
143
- this.viewChangeTokens.set(handler, token);
144
- }
145
- offViewChange(handler) {
146
- const token = this.viewChangeTokens.get(handler);
147
- if (token === void 0) return;
148
- this.detachViewChange(token);
149
- this.viewChangeTokens.delete(handler);
150
- }
151
- destroy() {
152
- if (this.destroyed) return;
153
- try {
154
- for (const entry of this.eventTokens.values()) this.detachNativeEvent(entry);
155
- this.eventTokens.clear();
156
- for (const token of this.viewChangeTokens.values()) this.detachViewChange(token);
157
- this.viewChangeTokens.clear();
158
- for (const handle of this.layers.values()) this.destroyLayer(handle);
159
- this.layers.clear();
160
- this.featureCache.clear();
161
- this.featuresChangedListeners.clear();
162
- for (const id of this.iconResources.keys()) this.removeIconResource(id);
163
- this.iconResources.clear();
164
- this.layerIconResourceIds.clear();
165
- this.destroyEngineResources();
166
- } finally {
167
- this.destroyed = true;
168
- }
169
- }
170
- emitLayerFeaturesChanged(layerId, features) {
171
- const listeners = this.featuresChangedListeners.get(layerId);
172
- if (!listeners) return;
173
- for (const fn of listeners) fn(features);
174
- }
175
- onLayerFeaturesChanged(layerId, listener) {
176
- let listeners = this.featuresChangedListeners.get(layerId);
177
- if (!listeners) {
178
- listeners = /* @__PURE__ */ new Set();
179
- this.featuresChangedListeners.set(layerId, listeners);
180
- }
181
- listeners.add(listener);
182
- return () => {
183
- const set = this.featuresChangedListeners.get(layerId);
184
- if (!set) return;
185
- set.delete(listener);
186
- if (set.size === 0) this.featuresChangedListeners.delete(layerId);
187
- };
188
- }
189
- destroyEngineResources() {}
190
- applyIconResources(resources) {}
191
- getIconResource(id) {
192
- return this.iconResources.get(id);
193
- }
194
- removeIconResource(id) {}
195
- pruneIconResources() {
196
- const active = /* @__PURE__ */ new Set();
197
- for (const ids of this.layerIconResourceIds.values()) for (const id of ids) active.add(id);
198
- if (this.iconResources.size <= active.size + 32) return;
199
- for (const id of this.iconResources.keys()) {
200
- if (active.has(id)) continue;
201
- this.iconResources.delete(id);
202
- this.removeIconResource(id);
203
- if (this.iconResources.size <= active.size + 32) break;
204
- }
205
- }
206
- /**
207
- * Default for engines/views that don't rotate (Leaflet). Rotating engines
208
- * (MapLibre, OpenLayers) override this.
209
- */
210
- getBearing() {
211
- return 0;
212
- }
213
- getLayerFeatures(layerId) {
214
- return this.featureCache.get(layerId) ?? [];
215
- }
216
- };
217
2
  function pickPointerType(originalEvent) {
218
3
  if (typeof PointerEvent !== "undefined" && originalEvent instanceof PointerEvent) {
219
4
  if (originalEvent.pointerType === "touch") return "touch";
@@ -314,11 +99,9 @@ function mergeNativeAndCachedPickFeatures(nativeFeatures, cachedFeatures) {
314
99
  * primary candidate.
315
100
  */
316
101
  function evaluatePickCandidates(inputs, context) {
102
+ const candidates = eligibleCandidates(inputs, context);
317
103
  let best = null;
318
- for (const input of inputs) {
319
- const candidate = buildCandidate(input, context);
320
- if (candidate && (best === null || compareCandidates(candidate, best) < 0)) best = candidate;
321
- }
104
+ for (const candidate of candidates) if (best === null || compareCandidates(candidate, best) < 0) best = candidate;
322
105
  return best;
323
106
  }
324
107
  function buildCandidate(input, context) {
@@ -333,10 +116,17 @@ function buildCandidate(input, context) {
333
116
  viewport: context.viewport,
334
117
  presentation,
335
118
  visualOrder: input.visualOrder,
119
+ ...input.featureGroupOrder !== void 0 ? { featureGroupOrder: input.featureGroupOrder } : {},
336
120
  ...evaluated.iconFootprint ? { iconFootprint: evaluated.iconFootprint } : {},
337
121
  ...input.labelExtent ? { labelExtent: input.labelExtent } : {}
338
122
  };
339
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
+ }
340
130
  function compareCandidates(a, b) {
341
131
  const rankA = selectionSurfaceRank(a);
342
132
  const rankDifference = rankA - selectionSurfaceRank(b);
@@ -345,8 +135,13 @@ function compareCandidates(a, b) {
345
135
  const distanceDifference = a.hit.distance - b.hit.distance;
346
136
  if (distanceDifference !== 0) return distanceDifference;
347
137
  }
138
+ if (a.featureGroupOrder !== void 0 && b.featureGroupOrder !== void 0 && a.featureGroupOrder !== b.featureGroupOrder) return a.featureGroupOrder - b.featureGroupOrder;
348
139
  return a.visualOrder - b.visualOrder;
349
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
+ }
350
145
  function selectionSurfaceRank(candidate) {
351
146
  if (candidate.hit.kind !== "polygon-interior") return 0;
352
147
  return (candidate.presentation.fill?.opacity ?? 0) > 0 ? 1 : 2;
@@ -641,6 +436,196 @@ function numberArray(value) {
641
436
  return Array.isArray(value) && value.every((item) => typeof item === "number") ? [...value] : void 0;
642
437
  }
643
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
644
629
  //#region src/feature-style.ts
645
630
  /**
646
631
  * Coerce a raw `properties.style` value into a `HandleStyle` for the
@@ -1258,23 +1243,15 @@ function runInteraction(spec) {
1258
1243
  resolveFn = resolve;
1259
1244
  rejectFn = reject;
1260
1245
  });
1261
- const idsByLayer = /* @__PURE__ */ new Map();
1262
- for (const layer of layers) idsByLayer.set(layer, /* @__PURE__ */ new Set());
1246
+ const ownedLayers = new Set(layers);
1263
1247
  let controllerDetach = null;
1264
1248
  let controllerOnAbort = null;
1265
- function writeLayer(layer, features) {
1266
- adapter.updateLayerFeatures(layer, features);
1267
- const set = idsByLayer.get(layer);
1268
- if (!set) return;
1269
- set.clear();
1270
- 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);
1271
1252
  }
1272
1253
  function clearLayers() {
1273
- for (const [layer, set] of idsByLayer) {
1274
- if (set.size === 0) continue;
1275
- adapter.removeFeatures(layer, [...set]);
1276
- set.clear();
1277
- }
1254
+ for (const layer of ownedLayers) adapter.setLayerFeatureGroups(layer, []);
1278
1255
  }
1279
1256
  const handleKeydown = (event) => {
1280
1257
  if (settled) return;
@@ -1812,6 +1789,7 @@ function createCommitEmitter(listenerErrorsMessage) {
1812
1789
  //#endregion
1813
1790
  //#region src/tactical-draw/draw-controller.ts
1814
1791
  const PREVIEW_CM_ID$1 = "__td_preview__";
1792
+ const GUIDE_GROUP_ID = "__td_draw_guide__";
1815
1793
  const PREVIEW_CURSOR$1 = "crosshair";
1816
1794
  /**
1817
1795
  * Drive an interactive fixed-length draw against a `MapAdapter`.
@@ -1926,16 +1904,32 @@ function runFixedLengthDraw(options) {
1926
1904
  function renderPreview() {
1927
1905
  const raw = rubberBand !== void 0 ? [...committed, rubberBand] : [...committed];
1928
1906
  if (raw.length === 0) {
1929
- ctx.writeLayer(previewLayer, []);
1930
- 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
+ }]);
1931
1917
  return;
1932
1918
  }
1933
1919
  const previewThreshold = rule?.minimumPreviewPoints ?? required;
1934
1920
  const features = buildPreviewFeatures$1(adapter, kind, rule && raw.length >= previewThreshold ? rule.derive(raw) : raw, measureOptions, measureStyle, measureTextAmplifiers, renderOpts);
1935
- ctx.writeLayer(previewLayer, features);
1921
+ ctx.writeLayer(previewLayer, [{
1922
+ id: PREVIEW_CM_ID$1,
1923
+ features,
1924
+ iconResources: []
1925
+ }]);
1936
1926
  const guidePoints = rule?.guidePoints ? rule.guidePoints(raw) : raw;
1937
1927
  const guideFeatures = guideEnabled ? buildGuideFeatures(guidePoints, isAreaKind) : [];
1938
- ctx.writeLayer(guideLayer, guideFeatures);
1928
+ ctx.writeLayer(guideLayer, [{
1929
+ id: GUIDE_GROUP_ID,
1930
+ features: guideFeatures,
1931
+ iconResources: []
1932
+ }]);
1939
1933
  }
1940
1934
  function rescaleOnViewChange() {
1941
1935
  if (!measureRendersFromViewState({
@@ -2214,9 +2208,17 @@ function runVariableLengthDraw(options) {
2214
2208
  } else points = [...committed];
2215
2209
  if (rule) points = rule.derive(points);
2216
2210
  const previewFeatures = points.length === 0 ? [] : buildPreviewFeatures(adapter, kind, points, measureOptions, measureStyle, measureTextAmplifiers, renderOpts);
2217
- ctx.writeLayer(previewLayer, previewFeatures);
2211
+ ctx.writeLayer(previewLayer, [{
2212
+ id: "__td_variable_draw_preview__",
2213
+ features: previewFeatures,
2214
+ iconResources: []
2215
+ }]);
2218
2216
  const guideFeatures = points.length === 0 || !guideEnabled ? [] : buildGuideFeatures(points, isAreaKind, trailingFixed);
2219
- ctx.writeLayer(guideLayer, guideFeatures);
2217
+ ctx.writeLayer(guideLayer, [{
2218
+ id: "__td_variable_draw_guide__",
2219
+ features: guideFeatures,
2220
+ iconResources: []
2221
+ }]);
2220
2222
  }
2221
2223
  function rescaleOnViewChange() {
2222
2224
  if (!measureRendersFromViewState({
@@ -2382,6 +2384,7 @@ function runEditInteraction(spec) {
2382
2384
  spec.onForeignPick?.(localGraphics);
2383
2385
  return;
2384
2386
  }
2387
+ if (localGraphics && spec.ownsGraphic(localGraphics.id)) return;
2385
2388
  if (localPreview) return;
2386
2389
  if (wasBodyClick) return;
2387
2390
  spec.close();
@@ -3570,15 +3573,15 @@ const ALLOWED_EDIT_MODES = new Set([
3570
3573
  * Drive an interactive edit against a `MapAdapter`. Implements the edit
3571
3574
  * lifecycle (issue #48) plus the ADR-0017 transform box:
3572
3575
  *
3573
- * - lift-original (remove from graphics layer) + working copy
3574
- * - 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
3575
3578
  * (corner scale handles, rotate grip, drag-anywhere-inside body translate)
3576
3579
  * - dirty tracking via [[DIRTY_EPSILON]] tolerance
3577
3580
  * - default click-away close policy
3578
3581
  * - close / abort / signal / preempt / destroy paths
3579
3582
  */
3580
3583
  function runEdit(opts) {
3581
- 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;
3582
3585
  const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
3583
3586
  let sizeAnchor = opts.sizeAnchor ?? "ground";
3584
3587
  const metadata = getControlMeasureMetadata(measure.kind);
@@ -3691,8 +3694,8 @@ function runEdit(opts) {
3691
3694
  * while a box gesture (`activeBoxDrag`) is in flight.
3692
3695
  */
3693
3696
  let box = null;
3694
- /** The features written to the preview layer on the last `renderPreview()` call — reused to refit `box` without a second render pass. */
3695
- let lastPreviewFeatures = [];
3697
+ /** The last working-render features, reused to refit `box` without a second render pass. */
3698
+ let lastWorkingFeatures = [];
3696
3699
  let ctx;
3697
3700
  let driver;
3698
3701
  let shell;
@@ -3706,7 +3709,7 @@ function runEdit(opts) {
3706
3709
  resolution: adapter.getResolution(),
3707
3710
  bearing: adapter.getBearing()
3708
3711
  }),
3709
- features: () => lastPreviewFeatures,
3712
+ features: () => lastWorkingFeatures,
3710
3713
  snapshotAmplifierPlacements,
3711
3714
  handleIdPrefix,
3712
3715
  interactionStyle
@@ -3742,7 +3745,7 @@ function runEdit(opts) {
3742
3745
  });
3743
3746
  }
3744
3747
  /**
3745
- * 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
3746
3749
  * in flight — that drag paints the box rigidly from its own snapshot
3747
3750
  * (`BoxDragSnapshot.box`) instead of a re-fit, so a corner/rotate/body drag
3748
3751
  * doesn't fight its own paint. Text is the exception: its box follows the
@@ -3751,14 +3754,9 @@ function runEdit(opts) {
3751
3754
  */
3752
3755
  function refitBox() {
3753
3756
  if (activeBoxDrag && !isTextGraphic) return;
3754
- box = fitBoxFromFeatures(lastPreviewFeatures);
3757
+ box = fitBoxFromFeatures(lastWorkingFeatures);
3755
3758
  }
3756
- function liftOriginalFromGraphics() {
3757
- if (!wasOnGraphicsLayer) return;
3758
- const originalFeatureIds = renderMeasureForAdapter(adapter, measure, renderOpts).features.map((f) => f.id).filter((id) => typeof id === "string");
3759
- if (originalFeatureIds.length > 0) adapter.removeFeatures(graphicsLayer, originalFeatureIds);
3760
- }
3761
- function renderPreview() {
3759
+ function renderWorkingGraphic() {
3762
3760
  if (ctx.settled) return;
3763
3761
  const working = {
3764
3762
  ...measure,
@@ -3776,19 +3774,23 @@ function runEdit(opts) {
3776
3774
  } catch {
3777
3775
  features = [];
3778
3776
  }
3779
- ctx.writeLayer(previewLayer, features);
3780
- lastPreviewFeatures = features;
3777
+ publishWorkingGraphic(working, features);
3778
+ lastWorkingFeatures = features;
3781
3779
  refitBox();
3782
3780
  const guidePoints = rule?.guidePoints ? rule.guidePoints(workingPoints) : workingPoints;
3783
3781
  const guideFeatures = guideEnabled ? buildGuideFeatures(guidePoints, isAreaKind, trailingFixedSlots) : [];
3784
- ctx.writeLayer(guideLayer, guideFeatures);
3782
+ ctx.writeLayer(guideLayer, [{
3783
+ id: `__td_edit_guide__:${measure.id}`,
3784
+ features: guideFeatures,
3785
+ iconResources: []
3786
+ }]);
3785
3787
  }
3786
3788
  function rescaleOnViewChange() {
3787
3789
  if (ctx.settled) return;
3788
3790
  if (measureRendersFromViewState({
3789
3791
  kind: measure.kind,
3790
3792
  options: workingOptions
3791
- })) renderPreview();
3793
+ })) renderWorkingGraphic();
3792
3794
  else refitBox();
3793
3795
  renderHandlesLayer();
3794
3796
  }
@@ -3807,10 +3809,14 @@ function runEdit(opts) {
3807
3809
  features.push(...toFeatures(boxToHandles(liveBox, dragProjection.fromPixel), handleIdPrefix, interactionStyle));
3808
3810
  }
3809
3811
  }
3810
- ctx.writeLayer(handlesLayer, features);
3812
+ ctx.writeLayer(handlesLayer, [{
3813
+ id: `__td_edit_handles__:${measure.id}`,
3814
+ features,
3815
+ iconResources: []
3816
+ }]);
3811
3817
  }
3812
3818
  /**
3813
- * Repaint the preview and, in lockstep, the handles. `renderPreview` re-fits
3819
+ * Repaint the preview and, in lockstep, the handles. `renderWorkingGraphic` re-fits
3814
3820
  * `box` (for Text, the footprint depends on options — size/rotation/
3815
3821
  * alignment/text), so the transform-box outline and its corner/rotate handles
3816
3822
  * must repaint to match; otherwise they stay where the old options placed them
@@ -3818,8 +3824,8 @@ function runEdit(opts) {
3818
3824
  * (#scale-handle desync). Used by the non-geometry option/style/amplifier
3819
3825
  * setters, which change what the preview renders without moving a point.
3820
3826
  */
3821
- function renderPreviewAndHandles() {
3822
- renderPreview();
3827
+ function renderWorkingGraphicAndHandles() {
3828
+ renderWorkingGraphic();
3823
3829
  renderHandlesLayer();
3824
3830
  }
3825
3831
  /**
@@ -3900,7 +3906,7 @@ function runEdit(opts) {
3900
3906
  workingAmplifierPlacements = previousAmplifierPlacements;
3901
3907
  boxAngle = previousBoxAngle;
3902
3908
  nonGeometryDirty = previousNonGeometryDirty;
3903
- renderPreview();
3909
+ renderWorkingGraphic();
3904
3910
  renderHandlesLayer();
3905
3911
  recomputeDirty();
3906
3912
  },
@@ -3939,7 +3945,7 @@ function runEdit(opts) {
3939
3945
  drainPendingModes();
3940
3946
  throw err;
3941
3947
  }
3942
- renderPreview();
3948
+ renderWorkingGraphic();
3943
3949
  renderHandlesLayer();
3944
3950
  recomputeDirty();
3945
3951
  dragInFlight = false;
@@ -3967,7 +3973,7 @@ function runEdit(opts) {
3967
3973
  replaceWorkingOptions(previousOptions);
3968
3974
  throw err;
3969
3975
  }
3970
- renderPreviewAndHandles();
3976
+ renderWorkingGraphicAndHandles();
3971
3977
  nonGeometryDirty = true;
3972
3978
  dirty = true;
3973
3979
  emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "programmatic", "Change options");
@@ -3993,7 +3999,7 @@ function runEdit(opts) {
3993
3999
  currentStyle = previousStyle;
3994
4000
  throw err;
3995
4001
  }
3996
- renderPreviewAndHandles();
4002
+ renderWorkingGraphicAndHandles();
3997
4003
  nonGeometryDirty = true;
3998
4004
  dirty = true;
3999
4005
  emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "programmatic", "Change style");
@@ -4003,7 +4009,7 @@ function runEdit(opts) {
4003
4009
  * [[setOptions]] exactly (dirty tracking, re-render, change emission) —
4004
4010
  * see that function for the shared shape. A merge value of `undefined` or
4005
4011
  * an empty/whitespace-only string deletes the key; the record collapses to
4006
- * `undefined` once empty so `renderPreview`/`rebuildRenderedWith` omit the
4012
+ * `undefined` once empty so `renderWorkingGraphic`/`rebuildRenderedWith` omit the
4007
4013
  * key rather than persist `{}`.
4008
4014
  *
4009
4015
  * Each patch key resolves to its canonical field key and evicts every
@@ -4039,7 +4045,7 @@ function runEdit(opts) {
4039
4045
  workingTextAmplifiers = previousTextAmplifiers;
4040
4046
  throw err;
4041
4047
  }
4042
- renderPreviewAndHandles();
4048
+ renderWorkingGraphicAndHandles();
4043
4049
  nonGeometryDirty = true;
4044
4050
  dirty = true;
4045
4051
  emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "programmatic", "Change text");
@@ -4066,7 +4072,7 @@ function runEdit(opts) {
4066
4072
  workingAmplifierPlacements = Object.keys(next).length > 0 ? next : void 0;
4067
4073
  if (JSON.stringify(workingAmplifierPlacements) === JSON.stringify(previousAmplifierPlacements)) return;
4068
4074
  const currentRendered = rebuildRendered();
4069
- renderPreviewAndHandles();
4075
+ renderWorkingGraphicAndHandles();
4070
4076
  nonGeometryDirty = true;
4071
4077
  dirty = true;
4072
4078
  emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, source, historyLabel);
@@ -4074,7 +4080,7 @@ function runEdit(opts) {
4074
4080
  /**
4075
4081
  * Flip the size anchor for the measure under edit (ADR-0020). Re-renders the
4076
4082
  * emitted snapshot through `rebuildRendered` (which reads `sizeAnchor` live)
4077
- * 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
4078
4084
  * is unaffected — it renders from `workingOptions`, which still carry the
4079
4085
  * pixel size, so the in-flight symbol stays screen-locked until commit.
4080
4086
  */
@@ -4128,7 +4134,7 @@ function runEdit(opts) {
4128
4134
  apply(from, to, source) {
4129
4135
  const previousRendered = rebuildRendered();
4130
4136
  restoreHistory(to);
4131
- renderPreview();
4137
+ renderWorkingGraphic();
4132
4138
  renderHandlesLayer();
4133
4139
  recomputeDirty();
4134
4140
  emitChange(previousRendered, from.points, from.options, from.style, from.nonGeometryDirty, from.sizeAnchor, rebuildRendered(), from.textAmplifiers, from.amplifierPlacements, source, "Edit", from.boxAngle);
@@ -4204,7 +4210,7 @@ function runEdit(opts) {
4204
4210
  workingTextAmplifiers = next.textAmplifiers ? { ...next.textAmplifiers } : void 0;
4205
4211
  workingAmplifierPlacements = next.amplifierPlacements ? structuredClone(next.amplifierPlacements) : void 0;
4206
4212
  nonGeometryDirty = true;
4207
- renderPreview();
4213
+ renderWorkingGraphic();
4208
4214
  renderHandlesLayer();
4209
4215
  recomputeDirty();
4210
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");
@@ -4360,7 +4366,7 @@ function runEdit(opts) {
4360
4366
  emitTransient("start", fsm.kind === "midpoint" ? initialPoints : preDragPoints, activeDrag.controlPointIndex);
4361
4367
  if (fsm.kind === "midpoint") {
4362
4368
  workingPoints.splice(0, workingPoints.length, ...initialPoints);
4363
- renderPreview();
4369
+ renderWorkingGraphic();
4364
4370
  renderHandlesLayer();
4365
4371
  }
4366
4372
  dragInFlight = true;
@@ -4373,7 +4379,7 @@ function runEdit(opts) {
4373
4379
  const preDragOptions = { ...workingOptions ?? {} };
4374
4380
  if (boxHit.kind === "rotate") {
4375
4381
  freezeOrientationForRotate();
4376
- renderPreview();
4382
+ renderWorkingGraphic();
4377
4383
  renderHandlesLayer();
4378
4384
  }
4379
4385
  const preDragPoints = workingPoints.map((p) => [...p]);
@@ -4407,7 +4413,7 @@ function runEdit(opts) {
4407
4413
  const resolvedPixel = snappedPixel(pixel, info, "reshape", activeDrag.controlPointIndex);
4408
4414
  const snapshot = activeDrag.fsm.move(resolvedPixel);
4409
4415
  workingPoints.splice(0, workingPoints.length, ...snapshot.points);
4410
- renderPreview();
4416
+ renderWorkingGraphic();
4411
4417
  renderHandlesLayer();
4412
4418
  recomputeDirty();
4413
4419
  emitTransient("move", snapshot.points, activeDrag.controlPointIndex);
@@ -4440,7 +4446,7 @@ function runEdit(opts) {
4440
4446
  workingPoints.splice(0, workingPoints.length, ...activeBoxDrag.preDragPoints);
4441
4447
  workingOptions = foldTextTransform(activeBoxDrag.preDragBox, snapshot.box, activeBoxDrag.fsm.kind, activeBoxDrag.preDragOptions);
4442
4448
  nonGeometryDirty = true;
4443
- renderPreview();
4449
+ renderWorkingGraphic();
4444
4450
  renderHandlesLayer();
4445
4451
  recomputeDirty();
4446
4452
  emitTransient("move", activeBoxDrag.preDragPoints, null);
@@ -4448,7 +4454,7 @@ function runEdit(opts) {
4448
4454
  }
4449
4455
  workingPoints.splice(0, workingPoints.length, ...snapshot.points);
4450
4456
  workingAmplifierPlacements = transformAmplifierPlacements(activeBoxDrag.preDragAmplifierPlacements, activeBoxDrag.preDragBox, snapshot.box, driver);
4451
- renderPreview();
4457
+ renderWorkingGraphic();
4452
4458
  renderHandlesLayer();
4453
4459
  recomputeDirty();
4454
4460
  emitTransient("move", snapshot.points, null);
@@ -4463,7 +4469,7 @@ function runEdit(opts) {
4463
4469
  ...workingAmplifierPlacements ?? {},
4464
4470
  ...merged !== void 0 ? { [patch.field]: merged } : {}
4465
4471
  };
4466
- renderPreview();
4472
+ renderWorkingGraphic();
4467
4473
  renderHandlesLayer();
4468
4474
  }
4469
4475
  } else if (activeDrag) paintDragSnapshot(pixel, info);
@@ -4483,7 +4489,7 @@ function runEdit(opts) {
4483
4489
  if (labelOutcome.kind === "rotate") setAmplifierPlacements({ [labelOutcome.key]: { rotation: labelOutcome.rotation } }, "gesture", "Rotate label");
4484
4490
  else if (labelOutcome.kind === "move") setAmplifierPlacements({ [labelOutcome.field]: labelOutcome.position }, "gesture", "Move label");
4485
4491
  else {
4486
- renderPreview();
4492
+ renderWorkingGraphic();
4487
4493
  renderHandlesLayer();
4488
4494
  }
4489
4495
  shell.suppressNextClickAway();
@@ -4521,7 +4527,7 @@ function runEdit(opts) {
4521
4527
  replaceWorkingPoints(preDragPoints);
4522
4528
  workingAmplifierPlacements = preDragAmplifierPlacements;
4523
4529
  emitTransient("end", preDragPoints, null);
4524
- renderPreview();
4530
+ renderWorkingGraphic();
4525
4531
  renderHandlesLayer();
4526
4532
  recomputeDirty();
4527
4533
  endDragBookkeeping();
@@ -4551,11 +4557,7 @@ function runEdit(opts) {
4551
4557
  const core = runEditInteraction({
4552
4558
  adapter,
4553
4559
  signal,
4554
- layers: [
4555
- previewLayer,
4556
- guideLayer,
4557
- handlesLayer
4558
- ],
4560
+ layers: [guideLayer, handlesLayer],
4559
4561
  graphicsLayer,
4560
4562
  previewLayer,
4561
4563
  handlesLayer,
@@ -4589,11 +4591,7 @@ function runEdit(opts) {
4589
4591
  ctx = interaction;
4590
4592
  driver = createdDriver;
4591
4593
  shell = createdShell;
4592
- renderPreview();
4593
- if (wasOnGraphicsLayer) {
4594
- adapter.deferLayerRemovalUntilLayerUpdate?.(graphicsLayer, previewLayer);
4595
- liftOriginalFromGraphics();
4596
- }
4594
+ renderWorkingGraphic();
4597
4595
  renderHandlesLayer();
4598
4596
  recomputeDirty();
4599
4597
  if (onSession) onSession(session);
@@ -4604,6 +4602,7 @@ function runEdit(opts) {
4604
4602
  get settled() {
4605
4603
  return core.settled;
4606
4604
  },
4605
+ usesGraphicStack: true,
4607
4606
  measureId: measure.id,
4608
4607
  abort(reason, cause) {
4609
4608
  core.abort(reason, cause);
@@ -4612,7 +4611,7 @@ function runEdit(opts) {
4612
4611
  if (core.settled) return;
4613
4612
  if (stylesEqual(currentStyle, next.style)) return;
4614
4613
  currentStyle = next.style;
4615
- renderPreview();
4614
+ renderWorkingGraphic();
4616
4615
  }
4617
4616
  };
4618
4617
  }
@@ -4779,8 +4778,8 @@ function scalePointSymbolStates(pre, scale, rotation) {
4779
4778
  * position, scales its size value, and rotates its rotation — its persisted
4780
4779
  * size unit is never converted.
4781
4780
  *
4782
- * - lift every member off the graphics layer + per-member working copies
4783
- * - 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
4784
4783
  * - group box = fit over the union of every member's rendered pixel footprint
4785
4784
  * at the session `boxAngle`; each member also gets its own thin outline
4786
4785
  * - dirty tracking, close/abort/signal/preempt/destroy, click-away — same
@@ -4789,7 +4788,7 @@ function scalePointSymbolStates(pre, scale, rotation) {
4789
4788
  * a live session without a session boundary
4790
4789
  */
4791
4790
  function runGroupTransform(opts) {
4792
- 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;
4793
4792
  assertCapabilityFor(graphics);
4794
4793
  const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
4795
4794
  let sizeAnchor = opts.sizeAnchor ?? "ground";
@@ -4815,7 +4814,8 @@ function runGroupTransform(opts) {
4815
4814
  workingAmplifierPlacements: void 0,
4816
4815
  optionsOverride: null,
4817
4816
  box: null,
4818
- lastBoxPixels: []
4817
+ lastBoxPixels: [],
4818
+ usesGraphicStack: isOnGraphicsLayer(graphic.id)
4819
4819
  };
4820
4820
  }
4821
4821
  const editStartPoints = graphic.controlPoints.map((p) => [...p]);
@@ -4830,17 +4830,12 @@ function runGroupTransform(opts) {
4830
4830
  workingAmplifierPlacements: graphic.amplifierPlacements ? structuredClone(graphic.amplifierPlacements) : void 0,
4831
4831
  optionsOverride: null,
4832
4832
  box: null,
4833
- lastBoxPixels: []
4833
+ lastBoxPixels: [],
4834
+ usesGraphicStack: isOnGraphicsLayer(graphic.id)
4834
4835
  };
4835
4836
  }
4836
4837
  /** The single mutable source of truth for the current member set. */
4837
4838
  let members = graphics.map(createMemberState);
4838
- /**
4839
- * `true` once any member has been lifted off the graphics layer (initial or a
4840
- * mid-session `setGraphics` add). Gates the abort-path restore so a session
4841
- * that never touched the graphics layer doesn't rewrite it on abort.
4842
- */
4843
- let anyLiftedFromGraphics = false;
4844
4839
  /** A member's own options merged with an override patch (a null override leaves them unchanged). */
4845
4840
  function mergeOptions(base, override) {
4846
4841
  if (!override) return base;
@@ -4889,7 +4884,7 @@ function runGroupTransform(opts) {
4889
4884
  render: renderMeasureForAdapter(adapter, committed, renderOpts)
4890
4885
  };
4891
4886
  }
4892
- /** 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`. */
4893
4888
  function workingGraphicFor(i) {
4894
4889
  const member = members[i];
4895
4890
  if (isPointSymbolMember(member)) return buildWorkingPointSymbol(member);
@@ -4944,20 +4939,6 @@ function runGroupTransform(opts) {
4944
4939
  toPixel: (lonLat) => driver.toPixel(lonLat),
4945
4940
  fromPixel: (pixel) => driver.fromPixel(pixel)
4946
4941
  };
4947
- /**
4948
- * Lift `member` off the graphics layer when the host reports it currently
4949
- * there, flipping `anyLiftedFromGraphics` so the abort path knows to restore.
4950
- * Used for both initial and mid-session-added members.
4951
- */
4952
- function liftMemberFromGraphics(member) {
4953
- if (!isOnGraphicsLayer(member.graphic.id)) return;
4954
- if (isPointSymbol(member.graphic)) adapter.removeFeatures(graphicsLayer, [`${member.graphic.id}:point-symbol`]);
4955
- else {
4956
- const ids = renderMeasureForAdapter(adapter, member.graphic, renderOpts).features.map((f) => f.id).filter((id) => typeof id === "string");
4957
- if (ids.length > 0) adapter.removeFeatures(graphicsLayer, ids);
4958
- }
4959
- anyLiftedFromGraphics = true;
4960
- }
4961
4942
  function pixelsFromFeatures(features) {
4962
4943
  const positions = [];
4963
4944
  for (const f of features) if (f.geometry) collectFeaturePositions(f.geometry, positions);
@@ -5027,16 +5008,17 @@ function runGroupTransform(opts) {
5027
5008
  }
5028
5009
  function renderAllPreviews() {
5029
5010
  if (ctx.settled) return;
5030
- const allFeatures = [];
5031
- const resources = /* @__PURE__ */ new Map();
5011
+ const previewGroups = [];
5012
+ const workingGroups = [];
5032
5013
  members.forEach((member, i) => {
5033
5014
  let features;
5015
+ let iconResources = [];
5034
5016
  let boxPixels = [];
5035
5017
  try {
5036
5018
  if (isPointSymbolMember(member)) {
5037
5019
  const symbol = buildWorkingPointSymbol(member);
5038
5020
  const plan = renderPointSymbolForAdapter(adapter, capability, symbol);
5039
- resources.set(plan.resource.id, plan.resource);
5021
+ iconResources = [plan.resource];
5040
5022
  features = [plan.feature];
5041
5023
  const footprint = pointSymbolFootprint(symbol, plan.render, adapter);
5042
5024
  if (footprint) boxPixels = footprint.corners;
@@ -5050,10 +5032,19 @@ function runGroupTransform(opts) {
5050
5032
  boxPixels = [];
5051
5033
  }
5052
5034
  member.lastBoxPixels = boxPixels;
5053
- 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
+ });
5054
5045
  });
5055
- adapter.reconcileLayerIconResources(previewLayer, [...resources.values()]);
5056
- ctx.writeLayer(previewLayer, allFeatures);
5046
+ publishWorkingGraphics(workingGroups);
5047
+ ctx.writeLayer(previewLayer, previewGroups);
5057
5048
  refitBoxes();
5058
5049
  }
5059
5050
  function rescaleOnViewChange() {
@@ -5074,7 +5065,11 @@ function runGroupTransform(opts) {
5074
5065
  features.push(...toFeatures(boxToHandles(liveGroupBox, dragProjection.fromPixel), handleIdPrefix, interactionStyle));
5075
5066
  }
5076
5067
  features.push(...memberOutlineFeatures(members.flatMap((member) => member.box ? [[member.graphic.id, member.box]] : []), dragProjection.fromPixel, handleIdPrefix, interactionStyle?.boxOutline));
5077
- ctx.writeLayer(handlesLayer, features);
5068
+ ctx.writeLayer(handlesLayer, [{
5069
+ id: "__td_transform_handles__",
5070
+ features,
5071
+ iconResources: []
5072
+ }]);
5078
5073
  }
5079
5074
  /**
5080
5075
  * Builds every member's committed snapshot against a given override array
@@ -5307,13 +5302,13 @@ function runGroupTransform(opts) {
5307
5302
  for (const graphic of next) {
5308
5303
  const existing = currentById.get(graphic.id);
5309
5304
  if (existing) nextMembers.push(existing);
5310
- else {
5311
- const member = createMemberState(graphic);
5312
- liftMemberFromGraphics(member);
5313
- nextMembers.push(member);
5314
- }
5305
+ else nextMembers.push(createMemberState(graphic));
5315
5306
  }
5316
5307
  members = nextMembers;
5308
+ if (removedSnapshots.length > 0) settleWorkingGraphics(removedSnapshots.map((snapshot) => ({
5309
+ id: snapshot.graphic.id,
5310
+ committed: snapshot.graphic
5311
+ })));
5317
5312
  renderAllPreviews();
5318
5313
  renderHandlesLayer();
5319
5314
  recomputeDirty();
@@ -5332,6 +5327,28 @@ function runGroupTransform(opts) {
5332
5327
  if (notificationErrors.length > 1) throw new AggregateError(notificationErrors, "TransformSession.setGraphics notification errors");
5333
5328
  return removedSnapshots;
5334
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
+ }
5335
5352
  function close() {
5336
5353
  return commitClose();
5337
5354
  }
@@ -5374,6 +5391,7 @@ function runGroupTransform(opts) {
5374
5391
  setSizeAnchor(next);
5375
5392
  },
5376
5393
  setGraphics,
5394
+ syncHostGraphics,
5377
5395
  close,
5378
5396
  abort() {
5379
5397
  if (ctx.settled) return;
@@ -5494,6 +5512,12 @@ function runGroupTransform(opts) {
5494
5512
  handlesLayer,
5495
5513
  closeOnClickAway,
5496
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
+ },
5497
5521
  onDown: onDriverDown,
5498
5522
  onMove: onDriverMove,
5499
5523
  onUp: onDriverUp,
@@ -5513,16 +5537,11 @@ function runGroupTransform(opts) {
5513
5537
  activeBoxDrag = null;
5514
5538
  dragInFlight = false;
5515
5539
  }
5516
- adapter.reconcileLayerIconResources(previewLayer, []);
5517
- },
5518
- onAbort: () => {
5519
- if (anyLiftedFromGraphics) restoreOriginalToGraphics();
5520
5540
  },
5521
5541
  attach(interaction, createdDriver, createdShell) {
5522
5542
  ctx = interaction;
5523
5543
  driver = createdDriver;
5524
5544
  shell = createdShell;
5525
- for (const member of members) liftMemberFromGraphics(member);
5526
5545
  renderAllPreviews();
5527
5546
  renderHandlesLayer();
5528
5547
  recomputeDirty();
@@ -5539,7 +5558,8 @@ function runGroupTransform(opts) {
5539
5558
  },
5540
5559
  abort(reason, cause) {
5541
5560
  core.abort(reason, cause);
5542
- }
5561
+ },
5562
+ syncHostGraphics
5543
5563
  };
5544
5564
  }
5545
5565
  //#endregion
@@ -5562,8 +5582,11 @@ function runPointSymbolDraw(options) {
5562
5582
  const renderPreview = () => {
5563
5583
  if (!latestPosition) return;
5564
5584
  const plan = renderPointSymbolForAdapter(adapter, capability, build(PREVIEW_ID, latestPosition));
5565
- adapter.reconcileLayerIconResources(previewLayer, [plan.resource]);
5566
- ctx.writeLayer(previewLayer, [plan.feature]);
5585
+ ctx.writeLayer(previewLayer, [{
5586
+ id: PREVIEW_ID,
5587
+ features: [plan.feature],
5588
+ iconResources: [plan.resource]
5589
+ }]);
5567
5590
  };
5568
5591
  const handlePointerMove = (event) => {
5569
5592
  latestPosition = snappedDrawPosition(snap, event);
@@ -5590,7 +5613,6 @@ function runPointSymbolDraw(options) {
5590
5613
  interaction.onTeardown(() => {
5591
5614
  adapter.off("pointermove", handlePointerMove);
5592
5615
  adapter.off("click", handleClick);
5593
- adapter.reconcileLayerIconResources(previewLayer, []);
5594
5616
  adapter.setCursor(savedCursor);
5595
5617
  });
5596
5618
  adapter.setCursor("crosshair");
@@ -5607,8 +5629,8 @@ function normalisePointModes(input) {
5607
5629
  for (const mode of modes) if (mode !== "transform" && mode !== "delete") throw new TypeError(`Point-symbol editing does not support mode "${mode}"`);
5608
5630
  return modes.includes("delete") ? ["delete"] : [...new Set(modes)];
5609
5631
  }
5610
- function symbolBox(adapter, capability, symbol, padding = 0) {
5611
- const footprint = pointSymbolFootprint(symbol, capability.render(symbol), adapter);
5632
+ function symbolBox(adapter, symbol, rendered, padding = 0) {
5633
+ const footprint = pointSymbolFootprint(symbol, rendered, adapter);
5612
5634
  if (!footprint) return null;
5613
5635
  return {
5614
5636
  ...footprint.box,
@@ -5617,8 +5639,9 @@ function symbolBox(adapter, capability, symbol, padding = 0) {
5617
5639
  };
5618
5640
  }
5619
5641
  function runPointSymbolEdit(opts) {
5620
- 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;
5621
5643
  let modes = normalisePointModes(opts.modes);
5644
+ let usesGraphicStack = wasOnGraphicsLayer;
5622
5645
  let working = clonePointSymbol(symbol);
5623
5646
  let dirty = false;
5624
5647
  let box = null;
@@ -5643,15 +5666,29 @@ function runPointSymbolEdit(opts) {
5643
5666
  const render = () => {
5644
5667
  if (ctx.settled) return;
5645
5668
  const plan = renderPointSymbolForAdapter(adapter, capability, working);
5646
- adapter.reconcileLayerIconResources(previewLayer, [plan.resource]);
5647
- ctx.writeLayer(previewLayer, [plan.feature]);
5648
- box = symbolBox(adapter, capability, working, interactionStyle?.boxPadding);
5669
+ const nextBox = symbolBox(adapter, working, plan.render, interactionStyle?.boxPadding);
5649
5670
  const handles = [];
5650
- if (modes.includes("transform") && box) {
5651
- handles.push(boxOutlineFeature(box, (pixel) => driver.fromPixel(pixel), `${handleIdPrefix}:box-outline`, interactionStyle?.boxOutline));
5652
- handles.push(...toFeatures(boxToHandles(box, (pixel) => driver.fromPixel(pixel)), handleIdPrefix, interactionStyle));
5653
- }
5654
- 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
+ }]);
5655
5692
  };
5656
5693
  const emitChange = (previousState, source, onAccepted) => {
5657
5694
  const previous = {
@@ -5816,16 +5853,11 @@ function runPointSymbolEdit(opts) {
5816
5853
  historySettle: () => history.settle(),
5817
5854
  facadeOnSettled: onSettled,
5818
5855
  emitSettle: (settlement) => settleFromInteraction(settlement, commitEmitter, settleEmitter),
5819
- extraTeardown: () => adapter.reconcileLayerIconResources(previewLayer, []),
5820
5856
  attach(interaction, createdDriver, createdShell) {
5821
5857
  ctx = interaction;
5822
5858
  driver = createdDriver;
5823
5859
  shell = createdShell;
5824
5860
  render();
5825
- if (wasOnGraphicsLayer) {
5826
- adapter.deferLayerRemovalUntilLayerUpdate?.(graphicsLayer, previewLayer);
5827
- adapter.removeFeatures(graphicsLayer, [`${symbol.id}:point-symbol`]);
5828
- }
5829
5861
  onSession?.(session);
5830
5862
  }
5831
5863
  });
@@ -5834,11 +5866,55 @@ function runPointSymbolEdit(opts) {
5834
5866
  get settled() {
5835
5867
  return handle.settled;
5836
5868
  },
5869
+ get usesGraphicStack() {
5870
+ return usesGraphicStack;
5871
+ },
5837
5872
  measureId: symbol.id,
5838
- 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
+ }
5839
5885
  };
5840
5886
  }
5841
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
5842
5918
  //#region src/tactical-draw/tactical-draw.ts
5843
5919
  /**
5844
5920
  * `TacticalDraw` façade — slice #6 (issue #45).
@@ -5861,6 +5937,7 @@ var TacticalDraw = class {
5861
5937
  pointSymbols;
5862
5938
  snappingOptions;
5863
5939
  resolveSnap;
5940
+ graphicStack = new GraphicStackCoordinator();
5864
5941
  destroyed = false;
5865
5942
  hasRendered = false;
5866
5943
  activeInteraction = new ActiveInteractionTracker();
@@ -5904,12 +5981,7 @@ var TacticalDraw = class {
5904
5981
  idCounter = 0;
5905
5982
  editCounter = 0;
5906
5983
  transformCounter = 0;
5907
- /**
5908
- * Snapshot of the host's last `render()` argument. The edit slice uses it
5909
- * to recompute the graphics-layer state during a lift / restore — the
5910
- * façade owns the policy because controllers shouldn't need to know the
5911
- * surrounding collection state.
5912
- */
5984
+ /** Snapshot of the host's latest authoritative Graphic membership and order. */
5913
5985
  lastRenderedMeasures = [];
5914
5986
  snapGeometryFeatures = [];
5915
5987
  /**
@@ -5959,53 +6031,65 @@ var TacticalDraw = class {
5959
6031
  clearSnapResult() {
5960
6032
  this.snappingOptions?.onSnap?.(null);
5961
6033
  }
5962
- /**
5963
- * Render `measures` into features and write them to the graphics layer.
5964
- * Routes every measure through [[renderMeasureForAdapter]] so pixel sizing is
5965
- * resolved against the live map. Callers own any edit-exclusion policy — see
5966
- * `render()` (excludes the edited measure, owned by the preview layer) versus
5967
- * `settleToGraphics` (no exclusion, the edit is ending).
5968
- */
5969
- renderMeasuresToGraphics(graphics) {
5970
- const features = [];
5971
- const resources = /* @__PURE__ */ new Map();
6034
+ /** Render one atomic Graphic group for the engine-neutral graphics stack. */
6035
+ renderGraphicGroup(graphic) {
5972
6036
  const renderOpts = { graphicsStyle: this.graphicsStyle };
5973
- for (const graphic of graphics) if (isPointSymbol(graphic)) {
6037
+ if (isPointSymbol(graphic)) {
5974
6038
  if (!this.pointSymbols) throw new PointSymbolCapabilityRequiredError();
5975
6039
  const plan = renderPointSymbolForAdapter(this.adapter, this.pointSymbols, graphic);
5976
- resources.set(plan.resource.id, plan.resource);
5977
- features.push(plan.feature);
5978
- } else {
5979
- const render = renderMeasureForAdapter(this.adapter, graphic, renderOpts);
5980
- 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);
5981
6069
  }
5982
- this.adapter.reconcileLayerIconResources(this.graphicsLayer, [...resources.values()]);
5983
- this.adapter.updateLayerFeatures(this.graphicsLayer, features);
5984
- this.snapGeometryFeatures = features;
6070
+ this.reconcileGraphicsStack();
5985
6071
  }
5986
6072
  /**
5987
6073
  * Re-render the graphics layer on a view (zoom) change so pixel-denominated
5988
6074
  * and not-yet-committed label-sized measures rescale live. Skipped entirely
5989
6075
  * when nothing rendered still reads live view state — meter geometry with a
5990
6076
  * captured label size is zoom-independent, so a rebuild would be wasted
5991
- * layer writes. During an active edit the edited measure lives on the
5992
- * preview layer (lifted from graphics), so it is excluded here exactly as
5993
- * 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.
5994
6080
  */
5995
6081
  handleViewChange = () => {
5996
6082
  if (this.destroyed || !this.hasRendered) return;
5997
6083
  this.renderHighlights();
5998
- const excluded = this.excludedInteractionIds();
6084
+ const excluded = this.graphicsStackExcludedIds();
5999
6085
  const visible = excluded.size > 0 ? this.lastRenderedMeasures.filter((m) => !excluded.has(m.id)) : this.lastRenderedMeasures;
6000
6086
  const nativeGroundIconScaling = this.adapter.scalesGroundPointSymbolsNatively === true;
6001
6087
  if (!visible.some((g) => graphicRendersFromViewState(g, nativeGroundIconScaling))) return;
6002
- this.renderMeasuresToGraphics(visible);
6088
+ this.reconcileGraphicsStack(visible);
6003
6089
  };
6004
6090
  /**
6005
- * Every measure id currently lifted onto the preview layer by an active
6006
- * edit or group transform — excluded from `render()` / view-change
6007
- * reconciliation because the preview layer (not graphics) owns their
6008
- * 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.
6009
6093
  */
6010
6094
  excludedInteractionIds() {
6011
6095
  const ids = /* @__PURE__ */ new Set();
@@ -6013,6 +6097,13 @@ var TacticalDraw = class {
6013
6097
  for (const id of this.transformMemberIds() ?? []) ids.add(id);
6014
6098
  return ids;
6015
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
+ }
6016
6107
  /**
6017
6108
  * Live member ids of the active group transform, or `undefined` when none.
6018
6109
  * Falls back to the session ref because `onSession` fires from inside the
@@ -6031,24 +6122,24 @@ var TacticalDraw = class {
6031
6122
  * Idempotent. Throws synchronously on duplicate measure ids *before*
6032
6123
  * mutating any layer. An empty list before any prior render is a no-op.
6033
6124
  *
6034
- * Cross-layer semantics with an active edit (issue #51):
6035
- * - The measure under edit is **excluded** from the reconcile pass — the
6036
- * preview layer owns its working copy and the lift on the graphics layer
6037
- * 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.
6038
6128
  * - If the edited measure is absent from `measures`, the pending `edit`
6039
6129
  * promise rejects on the **next microtask** with reason `"removed"`.
6040
6130
  * `render` itself returns normally so host collection ops stay synchronous.
6041
- * - If the edited measure is present and its `style` changed, the preview
6042
- * 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
6043
6133
  * edited measure are routed nowhere — the user's in-flight drag is never
6044
- * 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.
6045
6136
  *
6046
6137
  * Cross-layer semantics with an active draw: `render` only touches the
6047
6138
  * graphics layer, so the draw's preview is undisturbed.
6048
6139
  *
6049
6140
  * Reentrancy: calling `td.render(...)` from inside `EditSession.onChange` is
6050
- * safe — `render` never re-enters the active edit or its preview / handle
6051
- * 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
6052
6143
  * pure style replay).
6053
6144
  */
6054
6145
  render(measures) {
@@ -6058,39 +6149,36 @@ var TacticalDraw = class {
6058
6149
  if (seen.has(m.id)) throw new Error(`TacticalDraw.render: duplicate graphic id "${m.id}"`);
6059
6150
  seen.add(m.id);
6060
6151
  }
6061
- 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;
6062
6156
  let externalEdited;
6063
6157
  let editedRemoved = false;
6064
6158
  if (editedId !== void 0) {
6065
6159
  const candidate = measures.find((m) => m.id === editedId);
6066
- externalEdited = candidate && !isPointSymbol(candidate) ? candidate : void 0;
6067
- 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);
6068
6164
  }
6069
- const transformIds = this.transformMemberIds();
6070
- const transformMemberRemoved = transformIds ? transformIds.some((id) => !seen.has(id)) : false;
6071
- 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();
6072
6170
  const visible = excluded.size > 0 ? measures.filter((m) => !excluded.has(m.id)) : measures;
6073
6171
  if (!(visible.length === 0 && !this.hasRendered)) {
6074
- this.renderMeasuresToGraphics(visible);
6172
+ this.reconcileGraphicsStack(visible);
6075
6173
  this.hasRendered = true;
6076
6174
  }
6077
- this.lastRenderedMeasures = measures.slice();
6078
6175
  if (externalEdited && this.activeEdit && "notifyExternalMeasure" in this.activeEdit) this.activeEdit.notifyExternalMeasure(externalEdited);
6079
- if (editedRemoved && this.activeEdit) {
6176
+ if (editedRemoved) {
6080
6177
  const editHandle = this.activeEdit;
6081
- queueMicrotask(() => {
6178
+ if (editHandle) queueMicrotask(() => {
6082
6179
  editHandle.abort("removed");
6083
6180
  });
6084
- }
6085
- if (transformMemberRemoved) {
6086
- const sessionRef = this.activeTransformSessionRef;
6087
- queueMicrotask(() => {
6088
- const handle = this.activeTransform;
6089
- if (!handle || handle.settled) return;
6090
- if (this.activeTransformSessionRef !== sessionRef) return;
6091
- const rendered = new Set(this.lastRenderedMeasures.map((m) => m.id));
6092
- if (handle.graphicIds.some((id) => !rendered.has(id))) handle.abort("removed");
6093
- });
6181
+ else if (this.activeInteraction.isStarting) this.activeInteraction.cancel("removed");
6094
6182
  }
6095
6183
  this.renderHighlights();
6096
6184
  }
@@ -6099,7 +6187,7 @@ var TacticalDraw = class {
6099
6187
  * idempotent (mirrors {@link render}): a non-interactive outline box is drawn
6100
6188
  * around each still-rendered graphic on the dedicated highlight layer — no
6101
6189
  * draw / edit / transform session, no promise, no pick subscription. Passing
6102
- * `[]` clears every highlight. Unknown ids and ids currently lifted into an
6190
+ * `[]` clears every highlight. Unknown ids and ids currently active in an
6103
6191
  * active edit / transform session are skipped (the live session's own visuals
6104
6192
  * supersede the passive outline). Hosts use it for Excalidraw-style
6105
6193
  * "locked selection" chrome.
@@ -6112,9 +6200,9 @@ var TacticalDraw = class {
6112
6200
  /**
6113
6201
  * Project the current {@link highlightedIds} into one outline `LineString`
6114
6202
  * per graphic and reconcile them onto the highlight layer. Idempotent and
6115
- * id-keyed via `adapter.updateLayerFeatures`, so a set-replace removes stale
6116
- * outlines and an unchanged set is a cheap no-op write. Skips ids that are
6117
- * 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
6118
6206
  * bails gracefully when the adapter cannot invert pixels to coordinates
6119
6207
  * (`getCoordinateFromPixel` is optional in the MapAdapter ABI).
6120
6208
  */
@@ -6147,7 +6235,11 @@ var TacticalDraw = class {
6147
6235
  const feature = boxOutlineFeature(box, invert, `__td_highlight__:${id}`, style);
6148
6236
  if (!invertFailed) features.push(feature);
6149
6237
  }
6150
- 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
+ })));
6151
6243
  this.hasHighlightOutlines = features.length > 0;
6152
6244
  }
6153
6245
  /**
@@ -6349,10 +6441,10 @@ var TacticalDraw = class {
6349
6441
  if (preempt) return preempt;
6350
6442
  const wasOnGraphicsLayer = this.lastRenderedMeasures.some((m) => m.id === measure.id);
6351
6443
  const settleToGraphics = (settledSymbol) => {
6352
- if (!wasOnGraphicsLayer) return;
6353
- const graphics = settledSymbol ? this.lastRenderedMeasures.map((graphic) => graphic.id === measure.id ? settledSymbol : graphic) : this.lastRenderedMeasures;
6354
- this.renderMeasuresToGraphics(graphics);
6355
- this.adapter.deferLayerRemovalUntilLayerUpdate?.(this.previewLayer, this.graphicsLayer);
6444
+ this.settleWorkingGraphics([{
6445
+ id: measure.id,
6446
+ committed: settledSymbol
6447
+ }]);
6356
6448
  };
6357
6449
  const handleIdPrefix = `__td_edit_${++this.editCounter}__`;
6358
6450
  return this.runWithSlot((onSettled) => runPointSymbolEdit({
@@ -6366,14 +6458,16 @@ var TacticalDraw = class {
6366
6458
  closeOnClickAway: options?.closeOnClickAway,
6367
6459
  modes: options?.modes,
6368
6460
  interactionStyle: this.resolveInteractionStyle(options?.interactionStyle),
6369
- onSession: options?.onSession,
6461
+ onSession: this.trackSession(options?.onSession),
6370
6462
  handleIdPrefix,
6371
6463
  settleToGraphics,
6464
+ publishWorkingGraphic: (group) => this.publishWorkingGraphics([group]),
6372
6465
  wasOnGraphicsLayer,
6373
6466
  onSettled,
6374
6467
  onClickAwayGraphicsPick: (event) => this.dispatchHostPick(this.graphicsLayer, event),
6375
6468
  snap: this.resolveSnap
6376
6469
  }), (handle) => {
6470
+ handle.syncHostMembership(this.lastRenderedMeasures.some((graphic) => graphic.id === handle.measureId && isPointSymbol(graphic)));
6377
6471
  this.activeEdit = handle;
6378
6472
  return () => {
6379
6473
  if (this.activeEdit === handle) this.activeEdit = null;
@@ -6383,12 +6477,11 @@ var TacticalDraw = class {
6383
6477
  normaliseModes(options?.modes);
6384
6478
  const preempt = this.guardAndPreempt();
6385
6479
  if (preempt) return preempt;
6386
- const wasOnGraphicsLayer = this.lastRenderedMeasures.some((m) => m.id === measure.id);
6387
6480
  const settleToGraphics = (settledMeasure) => {
6388
- if (!wasOnGraphicsLayer) return;
6389
- const graphics = settledMeasure ? this.lastRenderedMeasures.map((graphic) => graphic.id === measure.id ? settledMeasure : graphic) : this.lastRenderedMeasures;
6390
- this.renderMeasuresToGraphics(graphics);
6391
- this.adapter.deferLayerRemovalUntilLayerUpdate?.(this.previewLayer, this.graphicsLayer);
6481
+ this.settleWorkingGraphics([{
6482
+ id: measure.id,
6483
+ committed: settledMeasure
6484
+ }]);
6392
6485
  };
6393
6486
  const handleIdPrefix = `__td_edit_${++this.editCounter}__`;
6394
6487
  const mergedInteractionStyle = this.resolveInteractionStyle(options?.interactionStyle);
@@ -6413,7 +6506,11 @@ var TacticalDraw = class {
6413
6506
  guide: options?.guide,
6414
6507
  sizeAnchor: options?.sizeAnchor,
6415
6508
  settleToGraphics,
6416
- wasOnGraphicsLayer,
6509
+ publishWorkingGraphic: (graphic, features) => this.publishWorkingGraphics([{
6510
+ id: graphic.id,
6511
+ features,
6512
+ iconResources: []
6513
+ }]),
6417
6514
  onSettled,
6418
6515
  onClickAwayGraphicsPick,
6419
6516
  snap: this.resolveSnap
@@ -6436,9 +6533,6 @@ var TacticalDraw = class {
6436
6533
  const preempt = this.guardAndPreempt();
6437
6534
  if (preempt) return preempt;
6438
6535
  const isOnGraphicsLayer = (id) => this.lastRenderedMeasures.some((m) => m.id === id);
6439
- const restoreOriginalToGraphics = () => {
6440
- this.renderMeasuresToGraphics(this.lastRenderedMeasures);
6441
- };
6442
6536
  const handleIdPrefix = `__td_transform_${++this.transformCounter}__`;
6443
6537
  const mergedInteractionStyle = this.resolveInteractionStyle(options?.interactionStyle);
6444
6538
  const onClickAwayGraphicsPick = (event) => {
@@ -6461,7 +6555,8 @@ var TacticalDraw = class {
6461
6555
  onSession: this.trackTransformSession(options?.onSession),
6462
6556
  handleIdPrefix,
6463
6557
  sizeAnchor: options?.sizeAnchor,
6464
- restoreOriginalToGraphics,
6558
+ publishWorkingGraphics: (groups) => this.publishWorkingGraphics(groups),
6559
+ settleWorkingGraphics: (settlements) => this.settleWorkingGraphics(settlements),
6465
6560
  isOnGraphicsLayer,
6466
6561
  onSettled,
6467
6562
  onClickAwayGraphicsPick
@@ -6743,7 +6838,8 @@ var TacticalDraw = class {
6743
6838
  */
6744
6839
  routePick(layerId, event) {
6745
6840
  const excluded = this.excludedInteractionIds();
6746
- 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;
6747
6843
  this.deferredGraphicsPick = event;
6748
6844
  return;
6749
6845
  }