@orbat-mapper/tactical-draw 0.2.0-alpha.1 → 0.2.0-alpha.10

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.
Files changed (3) hide show
  1. package/dist/index.d.mts +450 -32
  2. package/dist/index.mjs +1806 -505
  3. package/package.json +2 -2
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { cloneControlMeasure, controlMeasureIdFromFeature, getControlMeasureMetadata, renderControlMeasure, resolveStyleHints, roundToFixed } from "@orbat-mapper/control-measures";
1
+ import { cloneControlMeasure, controlMeasureIdFromFeature, freezeOrientationOptions, getControlMeasureMetadata, renderControlMeasure, resolveStyleHints, roundToFixed } from "@orbat-mapper/control-measures";
2
2
  //#region src/base-map-adapter.ts
3
3
  /**
4
4
  * Abstract base class shared by all `MapAdapter` implementations.
@@ -519,17 +519,21 @@ const PIXEL_SIZE_OPTIONS = {
519
519
  pixels: "echelonSizePixels",
520
520
  meters: "echelonSize"
521
521
  },
522
+ "strong-point": {
523
+ pixels: "echelonSizePixels",
524
+ meters: "echelonSize"
525
+ },
522
526
  flot: {
523
527
  pixels: "radiusPixels",
524
528
  meters: "radius"
525
529
  },
526
530
  "fortified-line": {
527
- pixels: "sizePixels",
528
- meters: "size"
531
+ pixels: "toothSizePixels",
532
+ meters: "toothSize"
529
533
  },
530
534
  "fortified-area": {
531
- pixels: "sizePixels",
532
- meters: "size"
535
+ pixels: "toothSizePixels",
536
+ meters: "toothSize"
533
537
  },
534
538
  "antitank-ditch-under-construction": {
535
539
  pixels: "toothHeightPixels",
@@ -596,7 +600,7 @@ function bakePixelSizeToGround(measure, adapter) {
596
600
  /**
597
601
  * Stamp the adapter's current `metersPerPixel` onto a measure that sizes itself
598
602
  * in pixels, so the generator resolves pixel options (`radiusPixels`,
599
- * `sizePixels`, `toothHeightPixels`) against the live map zoom. Returns the
603
+ * `toothSizePixels`, `toothHeightPixels`) against the live map zoom. Returns the
600
604
  * measure unchanged when the adapter has no usable resolution or the
601
605
  * kind/options don't opt into pixel sizing.
602
606
  */
@@ -695,7 +699,7 @@ function runInteraction(spec) {
695
699
  clearLayers();
696
700
  }
697
701
  function commit(produce) {
698
- if (settled) return;
702
+ if (settled) return void 0;
699
703
  let value;
700
704
  try {
701
705
  value = produce();
@@ -703,13 +707,20 @@ function runInteraction(spec) {
703
707
  settled = true;
704
708
  teardown();
705
709
  rejectFn(err);
706
- if (onSettled) onSettled();
710
+ if (onSettled) onSettled({
711
+ status: "rejected",
712
+ error: err
713
+ });
707
714
  return;
708
715
  }
709
716
  settled = true;
710
717
  teardown();
711
718
  resolveFn(value);
712
- if (onSettled) onSettled();
719
+ if (onSettled) onSettled({
720
+ status: "committed",
721
+ value
722
+ });
723
+ return value;
713
724
  }
714
725
  function fail(error) {
715
726
  if (settled) return;
@@ -719,7 +730,10 @@ function runInteraction(spec) {
719
730
  controllerOnAbort();
720
731
  } catch {}
721
732
  rejectFn(error);
722
- if (onSettled) onSettled();
733
+ if (onSettled) onSettled({
734
+ status: "rejected",
735
+ error
736
+ });
723
737
  }
724
738
  const ctx = {
725
739
  get settled() {
@@ -769,6 +783,82 @@ function roundControlPoints(points) {
769
783
  return points.map(roundControlPoint);
770
784
  }
771
785
  //#endregion
786
+ //#region src/tactical-draw/guide.ts
787
+ /**
788
+ * Stable preview-layer ids and emission rules for the rubber-band guide
789
+ * (ADR-0004). Shared between `td.draw` and `td.edit` so hosts can apply a
790
+ * single styling rule across both interactions.
791
+ */
792
+ const PREVIEW_GUIDE_FEATURE_PREFIX = "__td_preview__";
793
+ const PREVIEW_GUIDE_OPEN_ID = `${PREVIEW_GUIDE_FEATURE_PREFIX}:guide:open`;
794
+ const PREVIEW_GUIDE_CLOSING_ID = `${PREVIEW_GUIDE_FEATURE_PREFIX}:guide:closing`;
795
+ /**
796
+ * A kind is fixed-length when its draw rule pins the slot count
797
+ * (`canonicalPointCount`) or its metadata declares `min === max`. The
798
+ * generator preview is already authoritative for these, so the rubber-band
799
+ * guide adds no information.
800
+ */
801
+ function isFixedLength(metadata) {
802
+ if (metadata.rule?.canonicalPointCount !== void 0) return true;
803
+ const min = metadata.minCoordinates;
804
+ const max = metadata.maxCoordinates;
805
+ return typeof min === "number" && typeof max === "number" && min === max;
806
+ }
807
+ /**
808
+ * Guide is mechanically suppressed for kinds where it carries no information:
809
+ * point kinds and fixed-length kinds. Independent of the host's `guide`
810
+ * opt-out.
811
+ */
812
+ function shouldSuppressGuide(metadata) {
813
+ if (metadata.geometry === "point") return true;
814
+ if (metadata.rule?.showGuide === true) return false;
815
+ return isFixedLength(metadata);
816
+ }
817
+ /**
818
+ * Build the guide LineString features for a point list.
819
+ * - Open polyline through `points` once `points.length >= 2`.
820
+ * - Closing segment `[last, first]` for area kinds once `points.length >= 3`.
821
+ *
822
+ * Callers are responsible for the opt-out (`guide: false`) and the suppression
823
+ * check via [[shouldSuppressGuide]] — this function only emits geometry.
824
+ */
825
+ function buildGuideFeatures(points, isAreaKind, trailingFixedSlots = 0) {
826
+ const features = [];
827
+ const spine = trailingFixedSlots > 0 ? points.slice(0, points.length - trailingFixedSlots) : points;
828
+ if (spine.length >= 2) {
829
+ const open = {
830
+ type: "Feature",
831
+ id: PREVIEW_GUIDE_OPEN_ID,
832
+ geometry: {
833
+ type: "LineString",
834
+ coordinates: spine.slice()
835
+ },
836
+ properties: {
837
+ part: "guide",
838
+ segment: "open"
839
+ }
840
+ };
841
+ features.push(open);
842
+ }
843
+ const alreadyClosed = spine.length >= 2 && spine[0][0] === spine[spine.length - 1][0] && spine[0][1] === spine[spine.length - 1][1];
844
+ if (isAreaKind && spine.length >= 3 && !alreadyClosed) {
845
+ const closing = {
846
+ type: "Feature",
847
+ id: PREVIEW_GUIDE_CLOSING_ID,
848
+ geometry: {
849
+ type: "LineString",
850
+ coordinates: [spine[spine.length - 1], spine[0]]
851
+ },
852
+ properties: {
853
+ part: "guide",
854
+ segment: "closing"
855
+ }
856
+ };
857
+ features.push(closing);
858
+ }
859
+ return features;
860
+ }
861
+ //#endregion
772
862
  //#region src/tactical-draw/draw-controller.ts
773
863
  const PREVIEW_CM_ID$1 = "__td_preview__";
774
864
  const PREVIEW_CURSOR$1 = "crosshair";
@@ -786,7 +876,7 @@ const PREVIEW_CURSOR$1 = "crosshair";
786
876
  * kinds need no host UI.
787
877
  */
788
878
  function runFixedLengthDraw(options) {
789
- const { adapter, kind, previewLayer, graphicsStyle, generateId, signal, measureOptions, measureStyle, measureProperties, sizeAnchor, onSettled } = options;
879
+ const { adapter, kind, previewLayer, guideLayer, graphicsStyle, generateId, signal, measureOptions, measureStyle, measureProperties, guide: guideOpt, sizeAnchor, onSettled } = options;
790
880
  const metadata = getControlMeasureMetadata(kind);
791
881
  const rule = metadata.rule;
792
882
  const min = metadata.minCoordinates;
@@ -796,6 +886,8 @@ function runFixedLengthDraw(options) {
796
886
  else if (typeof min === "number" && typeof max === "number" && min === max) required = min;
797
887
  else throw new Error(`runFixedLengthDraw: kind "${kind}" is not fixed-length (min=${min}, max=${max})`);
798
888
  const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
889
+ const guideEnabled = (guideOpt ?? true) && !shouldSuppressGuide(metadata);
890
+ const isAreaKind = metadata.geometry === "area";
799
891
  const committed = [];
800
892
  let rubberBand;
801
893
  let ctx;
@@ -820,11 +912,15 @@ function runFixedLengthDraw(options) {
820
912
  const raw = rubberBand !== void 0 ? [...committed, rubberBand] : [...committed];
821
913
  if (raw.length === 0) {
822
914
  ctx.writeLayer(previewLayer, []);
915
+ ctx.writeLayer(guideLayer, []);
823
916
  return;
824
917
  }
825
918
  const previewThreshold = rule?.minimumPreviewPoints ?? required;
826
919
  const features = buildPreviewFeatures$1(adapter, kind, rule && raw.length >= previewThreshold ? rule.derive(raw) : raw, measureOptions, measureStyle, renderOpts);
827
920
  ctx.writeLayer(previewLayer, features);
921
+ const guidePoints = rule?.guidePoints ? rule.guidePoints(raw) : raw;
922
+ const guideFeatures = guideEnabled ? buildGuideFeatures(guidePoints, isAreaKind) : [];
923
+ ctx.writeLayer(guideLayer, guideFeatures);
828
924
  }
829
925
  function rescaleOnViewChange() {
830
926
  if (!measureUsesAdapterResolution({
@@ -852,7 +948,7 @@ function runFixedLengthDraw(options) {
852
948
  return runInteraction({
853
949
  adapter,
854
950
  signal,
855
- layers: [previewLayer],
951
+ layers: [previewLayer, guideLayer],
856
952
  onViewChange: rescaleOnViewChange,
857
953
  onSettled,
858
954
  attach(c) {
@@ -911,79 +1007,6 @@ function outlinePreview$1(points) {
911
1007
  return features;
912
1008
  }
913
1009
  //#endregion
914
- //#region src/tactical-draw/guide.ts
915
- /**
916
- * Stable preview-layer ids and emission rules for the rubber-band guide
917
- * (ADR-0004). Shared between `td.draw` and `td.edit` so hosts can apply a
918
- * single styling rule across both interactions.
919
- */
920
- const PREVIEW_GUIDE_FEATURE_PREFIX = "__td_preview__";
921
- const PREVIEW_GUIDE_OPEN_ID = `${PREVIEW_GUIDE_FEATURE_PREFIX}:guide:open`;
922
- const PREVIEW_GUIDE_CLOSING_ID = `${PREVIEW_GUIDE_FEATURE_PREFIX}:guide:closing`;
923
- /**
924
- * A kind is fixed-length when its draw rule pins the slot count
925
- * (`canonicalPointCount`) or its metadata declares `min === max`. The
926
- * generator preview is already authoritative for these, so the rubber-band
927
- * guide adds no information.
928
- */
929
- function isFixedLength(metadata) {
930
- if (metadata.rule?.canonicalPointCount !== void 0) return true;
931
- const min = metadata.minCoordinates;
932
- const max = metadata.maxCoordinates;
933
- return typeof min === "number" && typeof max === "number" && min === max;
934
- }
935
- /**
936
- * Guide is mechanically suppressed for kinds where it carries no information:
937
- * point kinds and fixed-length kinds. Independent of the host's `guide`
938
- * opt-out.
939
- */
940
- function shouldSuppressGuide(metadata) {
941
- return metadata.geometry === "point" || isFixedLength(metadata);
942
- }
943
- /**
944
- * Build the guide LineString features for a point list.
945
- * - Open polyline through `points` once `points.length >= 2`.
946
- * - Closing segment `[last, first]` for area kinds once `points.length >= 3`.
947
- *
948
- * Callers are responsible for the opt-out (`guide: false`) and the suppression
949
- * check via [[shouldSuppressGuide]] — this function only emits geometry.
950
- */
951
- function buildGuideFeatures(points, isAreaKind, trailingFixedSlots = 0) {
952
- const features = [];
953
- const spine = trailingFixedSlots > 0 ? points.slice(0, points.length - trailingFixedSlots) : points;
954
- if (spine.length >= 2) {
955
- const open = {
956
- type: "Feature",
957
- id: PREVIEW_GUIDE_OPEN_ID,
958
- geometry: {
959
- type: "LineString",
960
- coordinates: spine.slice()
961
- },
962
- properties: {
963
- part: "guide",
964
- segment: "open"
965
- }
966
- };
967
- features.push(open);
968
- }
969
- if (isAreaKind && spine.length >= 3) {
970
- const closing = {
971
- type: "Feature",
972
- id: PREVIEW_GUIDE_CLOSING_ID,
973
- geometry: {
974
- type: "LineString",
975
- coordinates: [spine[spine.length - 1], spine[0]]
976
- },
977
- properties: {
978
- part: "guide",
979
- segment: "closing"
980
- }
981
- };
982
- features.push(closing);
983
- }
984
- return features;
985
- }
986
- //#endregion
987
1010
  //#region src/tactical-draw/draw-controller-variable.ts
988
1011
  const PREVIEW_CM_ID = "__td_preview__";
989
1012
  const PREVIEW_CURSOR = "crosshair";
@@ -1002,6 +1025,7 @@ function runVariableLengthDraw(options) {
1002
1025
  const max = metadata.maxCoordinates;
1003
1026
  const rule = metadata.rule;
1004
1027
  const trailingFixed = rule?.trailingFixedSlots ?? 0;
1028
+ const closedRing = rule?.closedRing ?? false;
1005
1029
  const ruleMin = rule?.minimumUserPoints;
1006
1030
  const guideEnabled = (guideOpt ?? true) && !shouldSuppressGuide(metadata);
1007
1031
  const isAreaKind = metadata.geometry === "area";
@@ -1093,20 +1117,20 @@ function runVariableLengthDraw(options) {
1093
1117
  if (ctx.settled) return;
1094
1118
  const pos = roundControlPoint(event.coordinate);
1095
1119
  const lastSpineIdx = committed.length - 1 - trailingFixed;
1120
+ const finishIndices = closedRing && lastSpineIdx > 0 ? [lastSpineIdx, 0] : [lastSpineIdx];
1096
1121
  for (let i = 0; i < committed.length; i++) if (positionsEqual(pos, committed[i])) {
1097
- if (i === lastSpineIdx && canCommitNow()) ctx.commit(buildSnapshot);
1122
+ if (finishIndices.includes(i) && canCommitNow()) ctx.commit(buildSnapshot);
1098
1123
  return;
1099
1124
  }
1100
- if (lastSpineIdx >= 0 && canCommitNow()) {
1101
- const lastPixel = adapter.getPixelFromCoordinate(committed[lastSpineIdx]);
1102
- if (lastPixel) {
1103
- const tol = mapEventHitTolerance(event.originalEvent);
1104
- const dx = event.pixel[0] - lastPixel[0];
1105
- const dy = event.pixel[1] - lastPixel[1];
1106
- if (dx * dx + dy * dy <= tol * tol) {
1107
- ctx.commit(buildSnapshot);
1108
- return;
1109
- }
1125
+ if (lastSpineIdx >= 0 && canCommitNow()) for (const finishIndex of finishIndices) {
1126
+ const finishPixel = adapter.getPixelFromCoordinate(committed[finishIndex]);
1127
+ if (!finishPixel) continue;
1128
+ const tol = mapEventHitTolerance(event.originalEvent);
1129
+ const dx = event.pixel[0] - finishPixel[0];
1130
+ const dy = event.pixel[1] - finishPixel[1];
1131
+ if (dx * dx + dy * dy <= tol * tol) {
1132
+ ctx.commit(buildSnapshot);
1133
+ return;
1110
1134
  }
1111
1135
  }
1112
1136
  if (typeof max === "number" && committed.length >= max) return;
@@ -1246,63 +1270,14 @@ function outlinePreview(points) {
1246
1270
  //#endregion
1247
1271
  //#region src/tactical-draw/handles.ts
1248
1272
  /**
1249
- * Compute the anchor used by translate and rotate handles. The anchor is the
1250
- * centroid (arithmetic mean) of the control-point list — stable, kind
1251
- * agnostic, and well-defined for any non-empty point set.
1252
- */
1253
- function computeHandleAnchor(controlPoints) {
1254
- if (controlPoints.length === 0) return null;
1255
- let sx = 0;
1256
- let sy = 0;
1257
- for (const p of controlPoints) {
1258
- sx += p[0];
1259
- sy += p[1];
1260
- }
1261
- return [sx / controlPoints.length, sy / controlPoints.length];
1262
- }
1263
- /**
1264
- * How far past the outermost control point the rotate grip sits. `1.3` places
1265
- * it 30% beyond the farthest vertex, clearing the geometry while staying
1266
- * proportional to the shape's size.
1267
- */
1268
- const ROTATE_HANDLE_REACH_FACTOR = 1.3;
1269
- /**
1270
- * Position of the rotate grip. Unlike the translate handle — which sits on the
1271
- * centroid pivot — the rotate handle needs a lever arm, so it is offset from
1272
- * the centroid along the ray toward the control point farthest from it, just
1273
- * outside the shape. Anchoring to a *material* point (rather than the
1274
- * axis-aligned bbox top) keeps this stateless yet glued to the geometry: the
1275
- * grip orbits with the shape as it rotates, since the farthest vertex rotates
1276
- * with it. Falls back to the centroid when every point coincides (no
1277
- * well-defined direction) or the list is empty.
1278
- */
1279
- function computeRotateHandlePosition(controlPoints) {
1280
- const anchor = computeHandleAnchor(controlPoints);
1281
- if (!anchor) return null;
1282
- let farthest = null;
1283
- let maxDistSq = 0;
1284
- for (const p of controlPoints) {
1285
- const dx = p[0] - anchor[0];
1286
- const dy = p[1] - anchor[1];
1287
- const distSq = dx * dx + dy * dy;
1288
- if (distSq > maxDistSq) {
1289
- maxDistSq = distSq;
1290
- farthest = p;
1291
- }
1292
- }
1293
- if (!farthest) return anchor;
1294
- return [anchor[0] + (farthest[0] - anchor[0]) * ROTATE_HANDLE_REACH_FACTOR, anchor[1] + (farthest[1] - anchor[1]) * ROTATE_HANDLE_REACH_FACTOR];
1295
- }
1296
- /**
1297
1273
  * Axis-aligned bounding-box center of the control points — the midpoint of
1298
- * `[minX, maxX] × [minY, maxY]`. Used as the rotation pivot so a shape turns
1299
- * about its visual middle (matching mainstream drawing tools) rather than the
1300
- * vertex mean (`computeHandleAnchor`), which is pulled off-center on asymmetric
1301
- * measures. Returns `null` for an empty list.
1274
+ * `[minX, maxX] × [minY, maxY]`. Used as the fallback rotation pivot for the
1275
+ * `COMMIT_ROTATE_FOR_TEST` seam when no transform box exists yet (e.g. before
1276
+ * the first paint), matching mainstream drawing tools' "turn about the visual
1277
+ * middle" convention. Returns `null` for an empty list.
1302
1278
  *
1303
- * Exported because the rotation pivot is *not* a handle: it is the rotation
1304
- * center, distinct from the rotate grip's lever-arm position. The edit
1305
- * controller computes it directly at drag-start. See ADR-0016.
1279
+ * Exported because the rotation pivot is *not* a handle — the edit controller
1280
+ * consults it directly. See ADR-0016/ADR-0017.
1306
1281
  */
1307
1282
  function computeBoundingBoxCenter(controlPoints) {
1308
1283
  if (controlPoints.length === 0) return null;
@@ -1328,26 +1303,26 @@ function segmentMidpoint(a, b) {
1328
1303
  return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
1329
1304
  }
1330
1305
  /**
1331
- * The single source of truth for edit-handle placement and the
1332
- * `EditMode → which handles` gating (ADR-0016). Pure and projection-free:
1333
- * positions are lonLat. Handles are emitted in **pick priority order** —
1334
- * `[vertices…, midpoints…, translate, rotate]` — so `pickHandle` can return the
1335
- * first within tolerance and preserve "vertex wins over a pixel-closer
1306
+ * The single source of truth for reshape-handle placement and the
1307
+ * `EditMode → which handles` gating (ADR-0016, narrowed by ADR-0017). Pure and
1308
+ * projection-free: positions are lonLat. Handles are emitted in **pick
1309
+ * priority order** — `[vertices…, midpoints…]` — so `pickHandle` can return
1310
+ * the first within tolerance and preserve "vertex wins over a pixel-closer
1336
1311
  * midpoint." Rendering (`toFeatures`) and picking (`pickHandle`) are thin
1337
1312
  * projections of this set; the drag controller seeds gestures from it too.
1338
1313
  *
1339
1314
  * Emits handles only for modes present in `modes`:
1340
1315
  * - `reshape`: vertex handle at every control point + midpoint handle on
1341
1316
  * every adjacent pair (subject to `includeMidpoints` / `trailingFixedSlots`);
1342
- * - `delete`: vertex handles only (tapped to remove), no midpoint/grips;
1343
- * - `translate`: a single translate handle at the centroid anchor;
1344
- * - `rotate`: a single rotate grip offset outside the shape.
1317
+ * - `delete`: vertex handles only (tapped to remove), no midpoints.
1345
1318
  *
1346
- * `scale` is a reserved enum value rejected at the façade boundary — no handles
1347
- * are emitted for it here. Returns `[]` for an empty control-point list.
1319
+ * `transform` mode never contributes handles here — the box's corner/rotate
1320
+ * grips are pixel-space and placed by `transform-box.ts`, then rendered as
1321
+ * `Handle`s of kind `scale`/`rotate` by the edit controller. Returns `[]` for
1322
+ * an empty control-point list.
1348
1323
  */
1349
1324
  function buildHandleSet(controlPoints, modes, options = {}) {
1350
- const { includeMidpoints = true, trailingFixedSlots = 0 } = options;
1325
+ const { includeMidpoints = true, trailingFixedSlots = 0, closedRing = false } = options;
1351
1326
  const handles = [];
1352
1327
  const wantsReshape = modes.includes("reshape");
1353
1328
  if (wantsReshape || modes.includes("delete")) {
@@ -1357,37 +1332,24 @@ function buildHandleSet(controlPoints, modes, options = {}) {
1357
1332
  position: [...controlPoints[i]]
1358
1333
  });
1359
1334
  if (wantsReshape && includeMidpoints) {
1360
- const midpointLimit = controlPoints.length - 1 - trailingFixedSlots;
1361
- for (let i = 0; i < midpointLimit; i++) handles.push({
1362
- kind: "midpoint",
1363
- index: i,
1364
- position: segmentMidpoint(controlPoints[i], controlPoints[i + 1])
1365
- });
1335
+ const edgeCount = closedRing && trailingFixedSlots === 0 && controlPoints.length >= 3 ? controlPoints.length : controlPoints.length - 1 - trailingFixedSlots;
1336
+ for (let i = 0; i < edgeCount; i++) {
1337
+ const end = (i + 1) % controlPoints.length;
1338
+ handles.push({
1339
+ kind: "midpoint",
1340
+ index: i,
1341
+ position: segmentMidpoint(controlPoints[i], controlPoints[end])
1342
+ });
1343
+ }
1366
1344
  }
1367
1345
  }
1368
- if (modes.includes("translate")) {
1369
- const anchor = computeHandleAnchor(controlPoints);
1370
- if (anchor) handles.push({
1371
- kind: "translate",
1372
- index: 0,
1373
- position: anchor
1374
- });
1375
- }
1376
- if (modes.includes("rotate")) {
1377
- const rotatePos = computeRotateHandlePosition(controlPoints);
1378
- if (rotatePos) handles.push({
1379
- kind: "rotate",
1380
- index: 0,
1381
- position: rotatePos
1382
- });
1383
- }
1384
1346
  return handles;
1385
1347
  }
1386
1348
  function handleFeatureId(prefix, kind, index) {
1387
1349
  switch (kind) {
1388
1350
  case "vertex": return `${prefix}:handle-vertex:${index}`;
1389
1351
  case "midpoint": return `${prefix}:handle-midpoint:${index}`;
1390
- case "translate": return `${prefix}:handle-translate`;
1352
+ case "scale": return `${prefix}:handle-scale:${index}`;
1391
1353
  case "rotate": return `${prefix}:handle-rotate`;
1392
1354
  }
1393
1355
  }
@@ -1395,16 +1357,18 @@ function handleStyleFor(kind, interactionStyle) {
1395
1357
  switch (kind) {
1396
1358
  case "vertex": return interactionStyle?.vertexHandle;
1397
1359
  case "midpoint": return interactionStyle?.midpointHandle;
1398
- case "translate": return interactionStyle?.translateHandle;
1360
+ case "scale": return interactionStyle?.scaleHandle;
1399
1361
  case "rotate": return interactionStyle?.rotateHandle;
1400
1362
  }
1401
1363
  }
1402
1364
  /**
1403
1365
  * Project a handle set into the GeoJSON features that belong on the handles
1404
- * layer — the render projection of `buildHandleSet` (ADR-0016). Stable
1405
- * `featureIdPrefix`-namespaced ids let multiple edits share a handles layer
1406
- * without colliding; per-slot `interactionStyle` rides on `properties.style`.
1407
- * Pure: handle positions are copied, never aliased into the output geometry.
1366
+ * layer — the render projection of `buildHandleSet` (ADR-0016), also reused by
1367
+ * the edit controller to render the transform box's corner/rotate `Handle`s
1368
+ * (ADR-0017). Stable `featureIdPrefix`-namespaced ids let multiple edits share
1369
+ * a handles layer without colliding; per-slot `interactionStyle` rides on
1370
+ * `properties.style`. Pure: handle positions are copied, never aliased into
1371
+ * the output geometry.
1408
1372
  */
1409
1373
  function toFeatures(handles, featureIdPrefix, interactionStyle) {
1410
1374
  return handles.map((handle) => {
@@ -1427,24 +1391,140 @@ function toFeatures(handles, featureIdPrefix, interactionStyle) {
1427
1391
  };
1428
1392
  });
1429
1393
  }
1430
- function pixelDistance(a, b) {
1394
+ function pixelDistance$2(a, b) {
1431
1395
  return Math.hypot(a[0] - b[0], a[1] - b[1]);
1432
1396
  }
1433
1397
  /**
1434
1398
  * The pick projection of `buildHandleSet` (ADR-0016): the first handle within
1435
1399
  * `tolerance` pixels of `pixel`, tested in the set's priority order. Because
1436
- * `buildHandleSet` orders vertices before midpoints before translate before
1437
- * rotate, "first within tolerance" preserves the legacy "vertex wins over a
1438
- * pixel-closer midpoint" behaviour. Returns the full `Handle` so the drag
1439
- * controller can seed a gesture from its `position` without recomputing
1440
- * placement. Handles whose position fails to project (off-screen / degenerate
1441
- * projection) are skipped. Returns `null` when nothing is within tolerance.
1400
+ * `buildHandleSet` orders vertices before midpoints, "first within tolerance"
1401
+ * preserves the legacy "vertex wins over a pixel-closer midpoint" behaviour.
1402
+ * Returns the full `Handle` so the drag controller can seed a gesture from its
1403
+ * `position` without recomputing placement. Handles whose position fails to
1404
+ * project (off-screen / degenerate projection) are skipped. Returns `null`
1405
+ * when nothing is within tolerance.
1442
1406
  */
1443
1407
  function pickHandle(handles, pixel, tolerance, toPixel) {
1444
1408
  for (const handle of handles) {
1445
1409
  const projected = toPixel(handle.position);
1446
- if (projected && pixelDistance(projected, pixel) <= tolerance) return handle;
1410
+ if (projected && pixelDistance$2(projected, pixel) <= tolerance) return handle;
1411
+ }
1412
+ return null;
1413
+ }
1414
+ function rotate(x, y, angle) {
1415
+ const cos = Math.cos(angle);
1416
+ const sin = Math.sin(angle);
1417
+ return [x * cos - y * sin, x * sin + y * cos];
1418
+ }
1419
+ /**
1420
+ * Fit an oriented `TransformBox` around `pixels` at a caller-chosen `angle`.
1421
+ * Rotates the points by `-angle` about their centroid, takes the axis-aligned
1422
+ * bounding box in that de-rotated frame, then re-expresses the result as an
1423
+ * oriented box at `angle` — so the box always has exactly the requested tilt,
1424
+ * sized to just contain the (de-rotated) points. Half-dimensions are floored
1425
+ * at `MIN_HALF_SIZE_PX` so a degenerate selection still yields a drawable,
1426
+ * pickable box. Returns `null` for an empty list — there is nothing to fit.
1427
+ *
1428
+ * `padding` (pixels, default `0`) is added to `halfWidth`/`halfHeight` *after*
1429
+ * the `MIN_HALF_SIZE_PX` floor, on every side, without moving `center` — the
1430
+ * `InteractionStyle.boxPadding` knob (ADR-0017) so the box doesn't tightly hug
1431
+ * the rendered geometry. Everything downstream that reads a fitted box
1432
+ * (`boxCorners`, `rotateGripPixel`, `pointInBox`, `pickBoxHandle`) derives from
1433
+ * `halfWidth`/`halfHeight` alone, so the padding flows through to corner
1434
+ * placement, the rotate grip, and picking automatically — no other function
1435
+ * needs to know about it.
1436
+ */
1437
+ function fitTransformBox(pixels, angle, padding = 0) {
1438
+ if (pixels.length === 0) return null;
1439
+ let cx = 0;
1440
+ let cy = 0;
1441
+ for (const p of pixels) {
1442
+ cx += p[0];
1443
+ cy += p[1];
1444
+ }
1445
+ cx /= pixels.length;
1446
+ cy /= pixels.length;
1447
+ let minX = Infinity;
1448
+ let minY = Infinity;
1449
+ let maxX = -Infinity;
1450
+ let maxY = -Infinity;
1451
+ for (const p of pixels) {
1452
+ const [rx, ry] = rotate(p[0] - cx, p[1] - cy, -angle);
1453
+ if (rx < minX) minX = rx;
1454
+ if (rx > maxX) maxX = rx;
1455
+ if (ry < minY) minY = ry;
1456
+ if (ry > maxY) maxY = ry;
1447
1457
  }
1458
+ const localCenterX = (minX + maxX) / 2;
1459
+ const localCenterY = (minY + maxY) / 2;
1460
+ const halfWidth = Math.max((maxX - minX) / 2, 1) + padding;
1461
+ const halfHeight = Math.max((maxY - minY) / 2, 1) + padding;
1462
+ const [ox, oy] = rotate(localCenterX, localCenterY, angle);
1463
+ return {
1464
+ center: [cx + ox, cy + oy],
1465
+ halfWidth,
1466
+ halfHeight,
1467
+ angle
1468
+ };
1469
+ }
1470
+ /**
1471
+ * The box's 4 corners in screen pixels: `[topLeft, topRight, bottomRight,
1472
+ * bottomLeft]` in the box's local (pre-rotation) frame, then rotated by
1473
+ * `box.angle` about `box.center`. Corner index is stable — `pickBoxHandle`
1474
+ * and the scale-drag transition (`handle-drag.ts`) key off it, and the scale
1475
+ * anchor is always the opposite corner, `(index + 2) % 4`.
1476
+ */
1477
+ function boxCorners(box) {
1478
+ const { center, halfWidth, halfHeight, angle } = box;
1479
+ return [
1480
+ [-halfWidth, -halfHeight],
1481
+ [halfWidth, -halfHeight],
1482
+ [halfWidth, halfHeight],
1483
+ [-halfWidth, halfHeight]
1484
+ ].map(([x, y]) => {
1485
+ const [rx, ry] = rotate(x, y, angle);
1486
+ return [center[0] + rx, center[1] + ry];
1487
+ });
1488
+ }
1489
+ /**
1490
+ * Where the rotate grip sits: `ROTATE_GRIP_OFFSET_PX` above the top-edge
1491
+ * midpoint, along the box's local "up" direction, so the grip stays glued to
1492
+ * the box (rotates with it) rather than floating at a fixed screen offset.
1493
+ */
1494
+ function rotateGripPixel(box) {
1495
+ const { center, halfHeight, angle } = box;
1496
+ const [rx, ry] = rotate(0, -(halfHeight + 28), angle);
1497
+ return [center[0] + rx, center[1] + ry];
1498
+ }
1499
+ /**
1500
+ * Point-in-oriented-rectangle test, used to start a body (translate) drag
1501
+ * when the pointer goes down inside the box but not on a corner/rotate grip.
1502
+ * De-rotates `pixel` into the box's local frame about `box.center` and
1503
+ * compares against the half-extents.
1504
+ */
1505
+ function pointInBox(box, pixel) {
1506
+ const { center, halfWidth, halfHeight, angle } = box;
1507
+ const [lx, ly] = rotate(pixel[0] - center[0], pixel[1] - center[1], -angle);
1508
+ return Math.abs(lx) <= halfWidth && Math.abs(ly) <= halfHeight;
1509
+ }
1510
+ function pixelDistance$1(a, b) {
1511
+ return Math.hypot(a[0] - b[0], a[1] - b[1]);
1512
+ }
1513
+ /**
1514
+ * Pick what part of the box, if any, `pixel` hits — corners first, then the
1515
+ * rotate grip, then plain containment (body drag). Corners and the rotate
1516
+ * grip use `tolerance` pixels; body containment is exact (`pointInBox`), so a
1517
+ * click just outside the box never starts a body drag. Returns `null` when
1518
+ * nothing matches.
1519
+ */
1520
+ function pickBoxHandle(box, pixel, tolerance) {
1521
+ const corners = boxCorners(box);
1522
+ for (let i = 0; i < corners.length; i++) if (pixelDistance$1(corners[i], pixel) <= tolerance) return {
1523
+ kind: "scale",
1524
+ index: i
1525
+ };
1526
+ if (pixelDistance$1(rotateGripPixel(box), pixel) <= tolerance) return { kind: "rotate" };
1527
+ if (pointInBox(box, pixel)) return { kind: "body" };
1448
1528
  return null;
1449
1529
  }
1450
1530
  //#endregion
@@ -1460,17 +1540,8 @@ function translatePoints(base, dx, dy) {
1460
1540
  return out;
1461
1541
  });
1462
1542
  }
1463
- function angleFromAnchor(anchor, target) {
1464
- return Math.atan2(target[1] - anchor[1], target[0] - anchor[0]);
1465
- }
1466
- /**
1467
- * Angle of `pixel` around the rotation pivot, measured in screen space so it
1468
- * matches what the user sees and does. Falls back to lon/lat when the pivot
1469
- * cannot be projected — exceedingly rare for an on-screen edit.
1470
- */
1471
- function rotateAngleAt(pivot, pivotPixel, pixel, projection) {
1472
- if (pivotPixel) return Math.atan2(pixel[1] - pivotPixel[1], pixel[0] - pivotPixel[0]);
1473
- return angleFromAnchor(pivot, projection.fromPixel(pixel));
1543
+ function pixelDistance(a, b) {
1544
+ return Math.hypot(a[0] - b[0], a[1] - b[1]);
1474
1545
  }
1475
1546
  /**
1476
1547
  * Rotate `points` by `angle` about the pivot in screen space — project each
@@ -1528,7 +1599,7 @@ function begun(kind, initialBase, move) {
1528
1599
  * caller's working copy.
1529
1600
  */
1530
1601
  function beginHandleDrag(args) {
1531
- const { hit, downPixel, points, projection, rule } = args;
1602
+ const { hit, points, projection, rule } = args;
1532
1603
  const { fromPixel } = projection;
1533
1604
  const preDragPoints = clonePoints(points);
1534
1605
  const snap = (next, activePointIndex) => rule ? rule.transform({
@@ -1538,45 +1609,103 @@ function beginHandleDrag(args) {
1538
1609
  }) : next;
1539
1610
  const dragOnePoint = (base, activeIndex) => {
1540
1611
  const move = (pixel) => {
1541
- return {
1542
- points: snap(base.map((p, i) => i === activeIndex ? fromPixel(pixel) : [...p]), activeIndex),
1543
- rotateGripOverride: null
1544
- };
1612
+ return { points: snap(base.map((p, i) => i === activeIndex ? fromPixel(pixel) : [...p]), activeIndex) };
1545
1613
  };
1546
1614
  return move;
1547
1615
  };
1548
1616
  if (hit.kind === "vertex") return begun("vertex", preDragPoints, dragOnePoint(preDragPoints, hit.index));
1549
- if (hit.kind === "midpoint") {
1550
- const insertIndex = hit.index + 1;
1551
- const mid = segmentMidpoint(preDragPoints[hit.index], preDragPoints[hit.index + 1]);
1552
- const insertedBase = [
1553
- ...preDragPoints.slice(0, insertIndex),
1554
- mid,
1555
- ...preDragPoints.slice(insertIndex)
1556
- ];
1557
- return begun("midpoint", insertedBase, dragOnePoint(insertedBase, insertIndex));
1558
- }
1559
- if (hit.kind === "translate") {
1560
- const origin = fromPixel(downPixel);
1561
- return begun("translate", preDragPoints, (pixel) => {
1562
- const cur = fromPixel(pixel);
1617
+ const insertIndex = hit.index + 1;
1618
+ const segmentEndIndex = (hit.index + 1) % preDragPoints.length;
1619
+ const mid = segmentMidpoint(preDragPoints[hit.index], preDragPoints[segmentEndIndex]);
1620
+ const insertedBase = [
1621
+ ...preDragPoints.slice(0, insertIndex),
1622
+ mid,
1623
+ ...preDragPoints.slice(insertIndex)
1624
+ ];
1625
+ return begun("midpoint", insertedBase, dragOnePoint(insertedBase, insertIndex));
1626
+ }
1627
+ /** No corner drag may scale below this factor — guards against reflection/collapse through the anchor. */
1628
+ const MIN_SCALE_FACTOR = .05;
1629
+ /**
1630
+ * Begin a transform-box drag: uniform corner scale, box-center rotate, or
1631
+ * body translate. Mirrors `beginHandleDrag`'s shape (a `move`/`end` FSM seeded
1632
+ * from the pre-drag points) but keyed on `BoxDragKind` instead of `HandleKind`,
1633
+ * and additionally threading the box itself through so the caller can paint
1634
+ * the live box without re-fitting from projected geometry every frame.
1635
+ */
1636
+ function beginBoxDrag(args) {
1637
+ const { kind, downPixel, points, projection, box, cornerIndex } = args;
1638
+ const { toPixel, fromPixel } = projection;
1639
+ const preDragPoints = clonePoints(points);
1640
+ if (kind === "scale") {
1641
+ const index = cornerIndex ?? 0;
1642
+ const anchor = boxCorners(box)[(index + 2) % 4];
1643
+ const downDist = pixelDistance(downPixel, anchor);
1644
+ const move = (pixel) => {
1645
+ const dist = pixelDistance(pixel, anchor);
1646
+ const rawFactor = downDist === 0 ? 1 : dist / downDist;
1647
+ const factor = Math.max(rawFactor, MIN_SCALE_FACTOR);
1563
1648
  return {
1564
- points: translatePoints(preDragPoints, cur[0] - origin[0], cur[1] - origin[1]),
1565
- rotateGripOverride: null
1649
+ points: preDragPoints.map((p) => {
1650
+ const px = toPixel(p);
1651
+ if (!px) return [...p];
1652
+ const ll = fromPixel([anchor[0] + (px[0] - anchor[0]) * factor, anchor[1] + (px[1] - anchor[1]) * factor]);
1653
+ const out = [ll[0], ll[1]];
1654
+ for (let k = 2; k < p.length; k++) out.push(p[k]);
1655
+ return out;
1656
+ }),
1657
+ box: {
1658
+ center: [anchor[0] + (box.center[0] - anchor[0]) * factor, anchor[1] + (box.center[1] - anchor[1]) * factor],
1659
+ halfWidth: box.halfWidth * factor,
1660
+ halfHeight: box.halfHeight * factor,
1661
+ angle: box.angle
1662
+ }
1566
1663
  };
1567
- });
1664
+ };
1665
+ return {
1666
+ kind,
1667
+ move,
1668
+ end: (pixel) => move(pixel).points
1669
+ };
1568
1670
  }
1569
- const pivot = computeBoundingBoxCenter(preDragPoints) ?? [0, 0];
1570
- const pivotPixel = projection.toPixel(pivot);
1571
- const startAngle = rotateAngleAt(pivot, pivotPixel, downPixel, projection);
1572
- const startGrip = hit.position;
1573
- return begun("rotate", preDragPoints, (pixel) => {
1574
- const angle = rotateAngleAt(pivot, pivotPixel, pixel, projection) - startAngle;
1671
+ if (kind === "rotate") {
1672
+ const pivotPixel = box.center;
1673
+ const pivot = fromPixel(pivotPixel);
1674
+ const startAngle = Math.atan2(downPixel[1] - pivotPixel[1], downPixel[0] - pivotPixel[0]);
1675
+ const move = (pixel) => {
1676
+ const angle = Math.atan2(pixel[1] - pivotPixel[1], pixel[0] - pivotPixel[0]) - startAngle;
1677
+ return {
1678
+ points: rotatePointsAround(preDragPoints, pivot, pivotPixel, angle, projection),
1679
+ box: {
1680
+ center: [...pivotPixel],
1681
+ halfWidth: box.halfWidth,
1682
+ halfHeight: box.halfHeight,
1683
+ angle: box.angle + angle
1684
+ }
1685
+ };
1686
+ };
1575
1687
  return {
1576
- points: rotatePointsAround(preDragPoints, pivot, pivotPixel, angle, projection),
1577
- rotateGripOverride: rotatePointsAround([startGrip], pivot, pivotPixel, angle, projection)[0]
1688
+ kind,
1689
+ move,
1690
+ end: (pixel) => move(pixel).points
1578
1691
  };
1579
- });
1692
+ }
1693
+ const origin = fromPixel(downPixel);
1694
+ const move = (pixel) => {
1695
+ const cur = fromPixel(pixel);
1696
+ return {
1697
+ points: translatePoints(preDragPoints, cur[0] - origin[0], cur[1] - origin[1]),
1698
+ box: {
1699
+ ...box,
1700
+ center: [box.center[0] + (pixel[0] - downPixel[0]), box.center[1] + (pixel[1] - downPixel[1])]
1701
+ }
1702
+ };
1703
+ };
1704
+ return {
1705
+ kind,
1706
+ move,
1707
+ end: (pixel) => move(pixel).points
1708
+ };
1580
1709
  }
1581
1710
  //#endregion
1582
1711
  //#region src/tactical-draw/edit-session.ts
@@ -1598,8 +1727,10 @@ const COMMIT_GESTURE_FOR_TEST = Symbol("EditSession.commitGestureForTest");
1598
1727
  const COMMIT_TRANSLATE_FOR_TEST = Symbol("EditSession.commitTranslateForTest");
1599
1728
  /**
1600
1729
  * Internal test seam — simulate a completed rotate gesture by passing an angle
1601
- * in radians. Every control point rotates around the computed anchor (centroid);
1602
- * one `EditChangeEvent` is emitted, matching the issue #50 emission contract.
1730
+ * in radians. Every control point rotates around the transform box's center
1731
+ * (falling back to the control-points' bounding-box center when no box has
1732
+ * been fitted yet); one `EditChangeEvent` is emitted, and `boxAngle` absorbs
1733
+ * the same delta a live rotate box-drag would (ADR-0017).
1603
1734
  *
1604
1735
  * @internal
1605
1736
  */
@@ -1620,35 +1751,112 @@ const BEGIN_DRAG_FOR_TEST = Symbol("EditSession.beginDragForTest");
1620
1751
  */
1621
1752
  const END_DRAG_FOR_TEST = Symbol("EditSession.endDragForTest");
1622
1753
  //#endregion
1623
- //#region src/tactical-draw/edit-controller.ts
1754
+ //#region src/tactical-draw/box-features.ts
1624
1755
  /**
1625
- * Coordinate-space tolerance for `EditSession.dirty`. The working copy is
1626
- * considered equivalent to the edit-start input when every control-point
1627
- * component agrees within this threshold. PRD #40 calls out tolerance-based
1628
- * dirty tracking; the constant lives in one place so future slices can lift
1629
- * it to an option without touching call sites.
1756
+ * Rendering helpers shared by the single-measure edit controller
1757
+ * (`edit-controller.ts`) and the group transform controller
1758
+ * (`transform-controller.ts`): both paint the same `TransformBox` shape —
1759
+ * corner scale handles, a rotate grip, and a closed-ring outline — so the
1760
+ * projection from pure box geometry (`transform-box.ts`) to lonLat `Handle`s
1761
+ * / GeoJSON features lives in one place instead of two copies drifting apart.
1762
+ * See ADR-0017.
1630
1763
  */
1631
- const DIRTY_EPSILON = 1e-9;
1632
- const HANDLE_HIT_TOLERANCE_PX = 12;
1633
- const ALLOWED_EDIT_MODES = new Set([
1634
- "reshape",
1635
- "translate",
1636
- "rotate",
1764
+ /** Walk a GeoJSON `Geometry`'s coordinate arrays, pushing every `Position` onto `out`. */
1765
+ function collectFeaturePositions(geometry, out) {
1766
+ switch (geometry.type) {
1767
+ case "Point":
1768
+ out.push(geometry.coordinates);
1769
+ break;
1770
+ case "MultiPoint":
1771
+ case "LineString":
1772
+ for (const p of geometry.coordinates) out.push(p);
1773
+ break;
1774
+ case "MultiLineString":
1775
+ case "Polygon":
1776
+ for (const ring of geometry.coordinates) for (const p of ring) out.push(p);
1777
+ break;
1778
+ case "MultiPolygon":
1779
+ for (const poly of geometry.coordinates) for (const ring of poly) for (const p of ring) out.push(p);
1780
+ break;
1781
+ case "GeometryCollection":
1782
+ for (const g of geometry.geometries) collectFeaturePositions(g, out);
1783
+ break;
1784
+ }
1785
+ }
1786
+ /**
1787
+ * Project a `TransformBox`'s 4 corner scale handles and rotate grip into
1788
+ * lonLat `Handle`s via `fromPixel` — the render projection of the box's
1789
+ * pixel-space geometry (`boxCorners` / `rotateGripPixel`), reused by both
1790
+ * controllers so box-handle placement can't drift between single-measure and
1791
+ * group transforms.
1792
+ */
1793
+ function boxToHandles(box, fromPixel) {
1794
+ const handles = boxCorners(box).map((corner, index) => ({
1795
+ kind: "scale",
1796
+ index,
1797
+ position: fromPixel(corner)
1798
+ }));
1799
+ handles.push({
1800
+ kind: "rotate",
1801
+ index: 0,
1802
+ position: fromPixel(rotateGripPixel(box))
1803
+ });
1804
+ return handles;
1805
+ }
1806
+ /**
1807
+ * Build the closed `LineString` outline feature for `box`, styled from
1808
+ * `style` (delivered per-feature via `properties.style`, as the adapters
1809
+ * already honor for line features). `featureId` is the caller's stable id —
1810
+ * `${prefix}:box-outline` for a single-measure or group box, or
1811
+ * `${prefix}:member-outline:{measureId}` for a group member's own outline.
1812
+ */
1813
+ function boxOutlineFeature(box, fromPixel, featureId, style) {
1814
+ const corners = boxCorners(box).map((c) => fromPixel(c));
1815
+ return {
1816
+ type: "Feature",
1817
+ id: featureId,
1818
+ geometry: {
1819
+ type: "LineString",
1820
+ coordinates: [...corners, corners[0]]
1821
+ },
1822
+ properties: style ? { style: { ...style } } : {}
1823
+ };
1824
+ }
1825
+ //#endregion
1826
+ //#region src/tactical-draw/edit-controller.ts
1827
+ /**
1828
+ * Coordinate-space tolerance for `EditSession.dirty`. The working copy is
1829
+ * considered equivalent to the edit-start input when every control-point
1830
+ * component agrees within this threshold. PRD #40 calls out tolerance-based
1831
+ * dirty tracking; the constant lives in one place so future slices can lift
1832
+ * it to an option without touching call sites.
1833
+ */
1834
+ const DIRTY_EPSILON$1 = 1e-9;
1835
+ const HANDLE_HIT_TOLERANCE_PX$1 = 12;
1836
+ /**
1837
+ * A body drag (drag-anywhere-inside-the-box translate) whose release pixel
1838
+ * lands within this many pixels of the down pixel is treated as a plain
1839
+ * click, not a gesture: it is not committed (geometry restored, no
1840
+ * `EditChangeEvent`, `dirty` untouched) but the click-away listener is
1841
+ * suppressed so the click doesn't close the edit. Corner/rotate drags commit
1842
+ * even at zero movement (matches the pre-ADR-0017 translate/rotate seams).
1843
+ */
1844
+ const CLICK_SLOP_PX$1 = 3;
1845
+ const ALLOWED_EDIT_MODES = new Set([
1846
+ "reshape",
1847
+ "transform",
1637
1848
  "delete"
1638
1849
  ]);
1639
1850
  /**
1640
- * Drive an interactive edit against a `MapAdapter`. Implements the first
1641
- * edit slice (issue #48):
1851
+ * Drive an interactive edit against a `MapAdapter`. Implements the edit
1852
+ * lifecycle (issue #48) plus the ADR-0017 transform box:
1642
1853
  *
1643
1854
  * - lift-original (remove from graphics layer) + working copy
1644
- * - preview + handle rendering for `reshape` mode
1855
+ * - preview + reshape-handle rendering, and the oriented transform box
1856
+ * (corner scale handles, rotate grip, drag-anywhere-inside body translate)
1645
1857
  * - dirty tracking via [[DIRTY_EPSILON]] tolerance
1646
1858
  * - default click-away close policy
1647
1859
  * - close / abort / signal / preempt / destroy paths
1648
- *
1649
- * Pointer-driven vertex / midpoint drag is intentionally NOT wired here —
1650
- * gesture binding lands with the change-event spec in #49 and the
1651
- * translate/rotate handle set in #50.
1652
1860
  */
1653
1861
  function runEdit(opts) {
1654
1862
  const { adapter, measure, graphicsLayer, previewLayer, guideLayer, handlesLayer, graphicsStyle, signal, closeOnClickAway = true, modes: initialModes, interactionStyle, onSession, handleIdPrefix, guide: guideOpt, restoreOriginalToGraphics, wasOnGraphicsLayer, onSettled, onClickAwayGraphicsPick } = opts;
@@ -1658,6 +1866,7 @@ function runEdit(opts) {
1658
1866
  const rule = metadata.rule;
1659
1867
  const midpointHandlesEnabled = rule?.canonicalPointCount === void 0;
1660
1868
  const trailingFixedSlots = rule?.trailingFixedSlots ?? 0;
1869
+ const closedRing = rule?.closedRing ?? false;
1661
1870
  const guideEnabled = (guideOpt ?? true) && !shouldSuppressGuide(metadata);
1662
1871
  const isAreaKind = metadata.geometry === "area";
1663
1872
  const requestedModes = normaliseModes(initialModes);
@@ -1688,12 +1897,55 @@ function runEdit(opts) {
1688
1897
  */
1689
1898
  let pendingModes = null;
1690
1899
  const changeListeners = /* @__PURE__ */ new Set();
1900
+ /**
1901
+ * The transform box's rotation, in radians, session-scoped (ADR-0017 decision
1902
+ * #2): starts at `0` (axis-aligned) and accumulates every committed rotate
1903
+ * gesture's delta, so the box stays glued to the shape across gestures. Never
1904
+ * persisted on the measure; a fresh edit always starts back at `0`.
1905
+ */
1906
+ let boxAngle = 0;
1907
+ /**
1908
+ * The transform box fitted around the *rendered* working-copy geometry
1909
+ * (pixel space), or `null` when nothing projected this frame. Re-fitted
1910
+ * after every committed gesture, every reshape-drag paint, and on
1911
+ * `onViewChange`; painted rigidly from the live drag's own snapshot instead
1912
+ * while a box gesture (`activeBoxDrag`) is in flight.
1913
+ */
1914
+ let box = null;
1915
+ /** The features written to the preview layer on the last `renderPreview()` call — reused to refit `box` without a second render pass. */
1916
+ let lastPreviewFeatures = [];
1691
1917
  let ctx;
1692
1918
  let driver;
1693
1919
  const dragProjection = {
1694
1920
  toPixel: (lonLat) => driver.toPixel(lonLat),
1695
1921
  fromPixel: (pixel) => driver.fromPixel(pixel)
1696
1922
  };
1923
+ /**
1924
+ * Fit the transform box around `features`' rendered geometry: every
1925
+ * coordinate is projected to pixels via the pointer driver and
1926
+ * `fitTransformBox`'d at the current `boxAngle`. `null` when nothing
1927
+ * projects (e.g. the working copy renders no features this frame).
1928
+ */
1929
+ function fitBoxFromFeatures(features) {
1930
+ const positions = [];
1931
+ for (const f of features) if (f.geometry) collectFeaturePositions(f.geometry, positions);
1932
+ const pixels = [];
1933
+ for (const p of positions) {
1934
+ const px = driver.toPixel(p);
1935
+ if (px) pixels.push(px);
1936
+ }
1937
+ return fitTransformBox(pixels, boxAngle, interactionStyle?.boxPadding ?? 0);
1938
+ }
1939
+ /**
1940
+ * Re-fit `box` from `lastPreviewFeatures`. A no-op while a box gesture is
1941
+ * in flight — that drag paints the box rigidly from its own snapshot
1942
+ * (`BoxDragSnapshot.box`) instead of a re-fit, so a corner/rotate/body drag
1943
+ * doesn't fight its own paint.
1944
+ */
1945
+ function refitBox() {
1946
+ if (activeBoxDrag) return;
1947
+ box = fitBoxFromFeatures(lastPreviewFeatures);
1948
+ }
1697
1949
  function liftOriginalFromGraphics() {
1698
1950
  if (!wasOnGraphicsLayer) return;
1699
1951
  const originalFeatureIds = renderMeasureForAdapter(adapter, measure, renderOpts).features.map((f) => f.id).filter((id) => typeof id === "string");
@@ -1714,37 +1966,48 @@ function runEdit(opts) {
1714
1966
  features = [];
1715
1967
  }
1716
1968
  ctx.writeLayer(previewLayer, features);
1717
- const guideFeatures = guideEnabled ? buildGuideFeatures(workingPoints, isAreaKind, trailingFixedSlots) : [];
1969
+ lastPreviewFeatures = features;
1970
+ refitBox();
1971
+ const guidePoints = rule?.guidePoints ? rule.guidePoints(workingPoints) : workingPoints;
1972
+ const guideFeatures = guideEnabled ? buildGuideFeatures(guidePoints, isAreaKind, trailingFixedSlots) : [];
1718
1973
  ctx.writeLayer(guideLayer, guideFeatures);
1719
1974
  }
1720
1975
  function rescaleOnViewChange() {
1721
- if (!measureUsesAdapterResolution({
1976
+ if (ctx.settled) return;
1977
+ if (measureUsesAdapterResolution({
1722
1978
  kind: measure.kind,
1723
1979
  options: workingOptions
1724
- })) return;
1725
- renderPreview();
1980
+ })) renderPreview();
1981
+ else refitBox();
1982
+ renderHandlesLayer();
1726
1983
  }
1727
1984
  function renderHandlesLayer() {
1728
1985
  if (ctx.settled) return;
1729
- let handles = buildHandleSet(workingPoints, modes, {
1986
+ const features = toFeatures(buildHandleSet(workingPoints, modes, {
1730
1987
  includeMidpoints: midpointHandlesEnabled,
1731
- trailingFixedSlots
1732
- });
1733
- if (activeDrag?.fsm.kind === "rotate" && rotateHandleOverride) {
1734
- const override = rotateHandleOverride;
1735
- handles = handles.map((h) => h.kind === "rotate" ? {
1736
- ...h,
1737
- position: override
1738
- } : h);
1739
- }
1740
- const features = toFeatures(handles, handleIdPrefix, interactionStyle);
1988
+ trailingFixedSlots,
1989
+ closedRing
1990
+ }), handleIdPrefix, interactionStyle);
1991
+ if (modes.includes("transform")) {
1992
+ const liveBox = activeBoxDrag ? activeBoxDrag.lastSnapshot.box : box;
1993
+ if (liveBox) {
1994
+ features.push(boxOutlineFeature(liveBox, dragProjection.fromPixel, `${handleIdPrefix}:box-outline`, interactionStyle?.boxOutline));
1995
+ features.push(...toFeatures(boxToHandles(liveBox, dragProjection.fromPixel), handleIdPrefix, interactionStyle));
1996
+ }
1997
+ }
1741
1998
  ctx.writeLayer(handlesLayer, features);
1742
1999
  }
1743
- function rebuildRendered() {
2000
+ /**
2001
+ * Builds the committed snapshot against explicit `options` rather than the
2002
+ * live `workingOptions` — used to render a gesture's "previous" snapshot
2003
+ * against pre-freeze options when a rotate has already merged its
2004
+ * orientation freeze into `workingOptions` by the time the commit runs.
2005
+ */
2006
+ function rebuildRenderedWith(options) {
1744
2007
  const working = {
1745
2008
  ...measure,
1746
2009
  style: currentStyle,
1747
- options: workingOptions,
2010
+ options,
1748
2011
  controlPoints: workingPoints.map((p) => [...p])
1749
2012
  };
1750
2013
  const committed = sizeAnchor === "screen" ? working : bakePixelSizeToGround(working, adapter);
@@ -1753,8 +2016,11 @@ function runEdit(opts) {
1753
2016
  render: renderMeasureForAdapter(adapter, committed, renderOpts)
1754
2017
  };
1755
2018
  }
2019
+ function rebuildRendered() {
2020
+ return rebuildRenderedWith(workingOptions);
2021
+ }
1756
2022
  function recomputeDirty() {
1757
- dirty = nonGeometryDirty || !pointsEqualWithinTolerance(workingPoints, editStartPoints, DIRTY_EPSILON);
2023
+ dirty = nonGeometryDirty || !pointsEqualWithinTolerance(workingPoints, editStartPoints, DIRTY_EPSILON$1);
1758
2024
  }
1759
2025
  function replaceWorkingPoints(next) {
1760
2026
  workingPoints.splice(0, workingPoints.length, ...roundControlPoints(next));
@@ -1763,6 +2029,23 @@ function runEdit(opts) {
1763
2029
  workingOptions = { ...next ?? {} };
1764
2030
  }
1765
2031
  /**
2032
+ * Pin orientation-derived option defaults (e.g. Battle Position's echelon
2033
+ * anchor) to their current concrete values, merging any patch onto
2034
+ * `workingOptions` in place. Called the moment a rotate gesture starts (a
2035
+ * box-drag rotate hit, or the `commitRotate` test seam) — a rigid rotation
2036
+ * of `workingPoints` follows immediately after, so the freeze must land
2037
+ * first or the orientation-relative default would re-derive against the
2038
+ * rotated geometry (ADR-0023). No dirty/change-event side effects of its
2039
+ * own — the rotate gesture's own commit carries the frozen options.
2040
+ */
2041
+ function freezeOrientationForRotate() {
2042
+ const patch = freezeOrientationOptions(measure.kind, workingPoints.map((p) => [...p]), workingOptions);
2043
+ if (patch) workingOptions = {
2044
+ ...workingOptions ?? {},
2045
+ ...patch
2046
+ };
2047
+ }
2048
+ /**
1766
2049
  * Emit a completed gesture to every registered listener. The pre-gesture
1767
2050
  * snapshot is provided as `previousRendered`; `commitGesture` has already
1768
2051
  * mutated `workingPoints` and re-rendered preview / handle layers before
@@ -1828,19 +2111,20 @@ function runEdit(opts) {
1828
2111
  * queued during the drag are applied after the listener emission (so the
1829
2112
  * post-gesture render reflects the requested mode set).
1830
2113
  */
1831
- function commitGesture(nextPoints) {
2114
+ function commitGesture(nextPoints, preGestureOptions) {
1832
2115
  if (ctx.settled) return;
1833
2116
  const previousPoints = workingPoints.map((p) => [...p]);
1834
- const previousOptions = { ...workingOptions ?? {} };
2117
+ const previousOptions = { ...preGestureOptions ?? workingOptions ?? {} };
1835
2118
  const previousStyle = currentStyle;
1836
2119
  const previousNonGeometryDirty = nonGeometryDirty;
1837
- const previousRendered = rebuildRendered();
2120
+ const previousRendered = rebuildRenderedWith(previousOptions);
1838
2121
  replaceWorkingPoints(nextPoints);
1839
2122
  let currentRendered;
1840
2123
  try {
1841
2124
  currentRendered = rebuildRendered();
1842
2125
  } catch (err) {
1843
2126
  replaceWorkingPoints(previousPoints);
2127
+ if (preGestureOptions !== void 0) replaceWorkingOptions(previousOptions);
1844
2128
  dragInFlight = false;
1845
2129
  drainPendingModes();
1846
2130
  throw err;
@@ -1930,16 +2214,23 @@ function runEdit(opts) {
1930
2214
  }));
1931
2215
  }
1932
2216
  /**
1933
- * Apply a rotate gesture by rotating every working point by `angle` (radians)
1934
- * around the bounding-box center. The rotation runs in screen space (see
2217
+ * Test seam for a rotate gesture: rotate every working point by `angle`
2218
+ * (radians) around the transform box's center — falling back to the
2219
+ * control-points' bounding-box center when no box has been fitted yet (e.g.
2220
+ * before the first paint). The rotation runs in screen space (see
1935
2221
  * `rotatePointsAround`) so it stays rigid and matches the pointer 1:1 at any
1936
- * latitude.
2222
+ * latitude, and `boxAngle` absorbs the same delta a live rotate box-drag
2223
+ * would (ADR-0017), so the box stays glued to the shape across seam calls.
1937
2224
  */
1938
2225
  function commitRotate(angle) {
1939
2226
  if (ctx.settled) return;
1940
- const pivot = computeBoundingBoxCenter(workingPoints);
2227
+ const preFreezeOptions = { ...workingOptions ?? {} };
2228
+ freezeOrientationForRotate();
2229
+ const pivotPixel = box ? box.center : driver.toPixel(computeBoundingBoxCenter(workingPoints) ?? [0, 0]);
2230
+ const pivot = box ? driver.fromPixel(box.center) : computeBoundingBoxCenter(workingPoints);
1941
2231
  if (!pivot) return;
1942
- commitGesture(rotatePointsAround(workingPoints, pivot, driver.toPixel(pivot), angle, dragProjection));
2232
+ boxAngle += angle;
2233
+ commitGesture(rotatePointsAround(workingPoints, pivot, pivotPixel, angle, dragProjection), preFreezeOptions);
1943
2234
  }
1944
2235
  function modesEqual(a, b) {
1945
2236
  if (a.length !== b.length) return false;
@@ -1965,44 +2256,894 @@ function runEdit(opts) {
1965
2256
  get options() {
1966
2257
  return { ...workingOptions ?? {} };
1967
2258
  },
1968
- get style() {
1969
- return currentStyle ? { ...currentStyle } : void 0;
2259
+ get style() {
2260
+ return currentStyle ? { ...currentStyle } : void 0;
2261
+ },
2262
+ get dirty() {
2263
+ return dirty;
2264
+ },
2265
+ get modes() {
2266
+ return modes;
2267
+ },
2268
+ get sizeAnchor() {
2269
+ return sizeAnchor;
2270
+ },
2271
+ close() {
2272
+ return ctx.commit(rebuildRendered);
2273
+ },
2274
+ abort() {
2275
+ if (ctx.settled) return;
2276
+ ctx.fail(new TacticalDrawAbortError("session"));
2277
+ },
2278
+ setModes(next) {
2279
+ if (ctx.settled) return;
2280
+ const normalised = normaliseModes(next);
2281
+ if (dragInFlight) {
2282
+ pendingModes = normalised;
2283
+ return;
2284
+ }
2285
+ applyModes(normalised);
2286
+ },
2287
+ setOptions(partial) {
2288
+ setOptions(partial);
2289
+ },
2290
+ setStyle(partial) {
2291
+ setStyle(partial);
2292
+ },
2293
+ setSizeAnchor(next) {
2294
+ setSizeAnchor(next);
2295
+ },
2296
+ onChange(listener) {
2297
+ if (ctx.settled) return () => {};
2298
+ changeListeners.add(listener);
2299
+ return () => {
2300
+ changeListeners.delete(listener);
2301
+ };
2302
+ },
2303
+ [COMMIT_GESTURE_FOR_TEST]: (nextPoints) => {
2304
+ commitGesture(nextPoints);
2305
+ },
2306
+ [COMMIT_TRANSLATE_FOR_TEST]: (delta) => {
2307
+ commitTranslate(delta);
2308
+ },
2309
+ [COMMIT_ROTATE_FOR_TEST]: (angle) => {
2310
+ commitRotate(angle);
2311
+ },
2312
+ [BEGIN_DRAG_FOR_TEST]: () => {
2313
+ if (ctx.settled) return;
2314
+ dragInFlight = true;
2315
+ },
2316
+ [END_DRAG_FOR_TEST]: () => {
2317
+ if (ctx.settled) return;
2318
+ dragInFlight = false;
2319
+ drainPendingModes();
2320
+ }
2321
+ };
2322
+ let activeDrag = null;
2323
+ /**
2324
+ * A live transform-box gesture (corner scale, box rotate, or body
2325
+ * translate — ADR-0017). `lastSnapshot` starts as the pre-drag points/box
2326
+ * and is replaced on every `move()` tick; `renderHandlesLayer` paints
2327
+ * `lastSnapshot.box` instead of re-fitting while this is non-null, and
2328
+ * `preDragBox` lets a committed rotate compute the angle delta to fold into
2329
+ * `boxAngle`.
2330
+ */
2331
+ let activeBoxDrag = null;
2332
+ /**
2333
+ * Whether the vertex at `index` may be deleted (via Alt+click or in the
2334
+ * exclusive `delete` mode). Deletion is
2335
+ * confined to variable-length measures (the same condition that enables
2336
+ * midpoint inserts — see ADR-0005) and is denied when it would:
2337
+ * - target a trailing fixed slot (e.g. Axis1's width handle), or
2338
+ * - drop the user-spine point count below the rule's `minimumUserPoints`.
2339
+ * Fixed-count measures re-pad on derive, so deleting a vertex there is moot.
2340
+ */
2341
+ function canDeleteVertex(index) {
2342
+ if (!midpointHandlesEnabled) return false;
2343
+ if (!modes.includes("reshape") && !modes.includes("delete")) return false;
2344
+ const spineCount = workingPoints.length - trailingFixedSlots;
2345
+ if (index < 0 || index >= spineCount) return false;
2346
+ const minimum = rule?.minimumUserPoints ?? (isAreaKind ? 3 : 2);
2347
+ return spineCount - 1 >= minimum;
2348
+ }
2349
+ /**
2350
+ * Remove the vertex at `index` and commit through the normal gesture path so
2351
+ * a single `EditChangeEvent` covers the delete and dirty tracking stays
2352
+ * consistent. The rule's `transform` runs with the removed index as the
2353
+ * active point so frame-relative invariants survive the delete: for Axis1
2354
+ * this re-projects the trailing width handle into the new tip→neck frame,
2355
+ * keeping the main-attack arrow's width unchanged even when the tip or neck
2356
+ * is removed. Rules without that concern (Line1) pass the points through.
2357
+ */
2358
+ function deleteVertex(index) {
2359
+ if (ctx.settled) return;
2360
+ const previous = workingPoints.map((p) => [...p]);
2361
+ const spliced = previous.filter((_, i) => i !== index);
2362
+ commitGesture(rule ? rule.transform({
2363
+ previous,
2364
+ next: spliced,
2365
+ activePointIndex: index
2366
+ }) : spliced);
2367
+ }
2368
+ const onDriverDown = (pixel, info) => {
2369
+ if (ctx.settled || dragInFlight) return;
2370
+ const tol = info?.hitTolerance ?? HANDLE_HIT_TOLERANCE_PX$1;
2371
+ const hit = pickHandle(buildHandleSet(workingPoints, modes, {
2372
+ includeMidpoints: midpointHandlesEnabled,
2373
+ trailingFixedSlots,
2374
+ closedRing
2375
+ }), pixel, tol, (lonLat) => driver.toPixel(lonLat));
2376
+ if (hit) {
2377
+ if (modes.includes("delete")) {
2378
+ if (hit.kind === "vertex" && canDeleteVertex(hit.index)) {
2379
+ deleteVertex(hit.index);
2380
+ suppressClickAway = true;
2381
+ }
2382
+ return;
2383
+ }
2384
+ if (hit.kind === "vertex" && info?.altKey && canDeleteVertex(hit.index)) {
2385
+ deleteVertex(hit.index);
2386
+ suppressClickAway = true;
2387
+ return;
2388
+ }
2389
+ const preDragPoints = workingPoints.map((p) => [...p]);
2390
+ const { drag: fsm, initialPoints } = beginHandleDrag({
2391
+ hit,
2392
+ downPixel: pixel,
2393
+ points: preDragPoints,
2394
+ projection: dragProjection,
2395
+ rule
2396
+ });
2397
+ activeDrag = {
2398
+ fsm,
2399
+ preDragPoints
2400
+ };
2401
+ if (fsm.kind === "midpoint") {
2402
+ workingPoints.splice(0, workingPoints.length, ...initialPoints);
2403
+ renderPreview();
2404
+ renderHandlesLayer();
2405
+ }
2406
+ dragInFlight = true;
2407
+ driver.setDragPanEnabled(false);
2408
+ return;
2409
+ }
2410
+ if (modes.includes("transform") && box) {
2411
+ const boxHit = pickBoxHandle(box, pixel, tol);
2412
+ if (boxHit) {
2413
+ const preDragOptions = { ...workingOptions ?? {} };
2414
+ if (boxHit.kind === "rotate") {
2415
+ freezeOrientationForRotate();
2416
+ renderPreview();
2417
+ renderHandlesLayer();
2418
+ }
2419
+ const preDragPoints = workingPoints.map((p) => [...p]);
2420
+ activeBoxDrag = {
2421
+ fsm: beginBoxDrag({
2422
+ kind: boxHit.kind,
2423
+ downPixel: pixel,
2424
+ points: preDragPoints,
2425
+ projection: dragProjection,
2426
+ box,
2427
+ cornerIndex: boxHit.kind === "scale" ? boxHit.index : void 0
2428
+ }),
2429
+ preDragPoints,
2430
+ preDragBox: box,
2431
+ downPixel: pixel,
2432
+ lastSnapshot: {
2433
+ points: preDragPoints,
2434
+ box
2435
+ },
2436
+ preDragOptions
2437
+ };
2438
+ dragInFlight = true;
2439
+ driver.setDragPanEnabled(false);
2440
+ }
2441
+ }
2442
+ };
2443
+ function paintDragSnapshot(pixel) {
2444
+ if (!activeDrag) return;
2445
+ const snapshot = activeDrag.fsm.move(pixel);
2446
+ workingPoints.splice(0, workingPoints.length, ...snapshot.points);
2447
+ renderPreview();
2448
+ renderHandlesLayer();
2449
+ recomputeDirty();
2450
+ }
2451
+ function paintBoxDragSnapshot(pixel) {
2452
+ if (!activeBoxDrag) return;
2453
+ const snapshot = activeBoxDrag.fsm.move(pixel);
2454
+ activeBoxDrag.lastSnapshot = snapshot;
2455
+ workingPoints.splice(0, workingPoints.length, ...snapshot.points);
2456
+ renderPreview();
2457
+ renderHandlesLayer();
2458
+ recomputeDirty();
2459
+ }
2460
+ const onDriverMove = (pixel) => {
2461
+ if (ctx.settled) return;
2462
+ if (activeDrag) paintDragSnapshot(pixel);
2463
+ else if (activeBoxDrag) paintBoxDragSnapshot(pixel);
2464
+ };
2465
+ /** Reset drag-in-flight bookkeeping shared by every "gesture ended" path. */
2466
+ function endDragBookkeeping() {
2467
+ driver.setDragPanEnabled(true);
2468
+ dragInFlight = false;
2469
+ drainPendingModes();
2470
+ }
2471
+ const onDriverUp = (pixel) => {
2472
+ if (activeDrag) {
2473
+ if (ctx.settled) {
2474
+ activeDrag = null;
2475
+ dragInFlight = false;
2476
+ driver.setDragPanEnabled(true);
2477
+ return;
2478
+ }
2479
+ const { fsm, preDragPoints } = activeDrag;
2480
+ activeDrag = null;
2481
+ driver.setDragPanEnabled(true);
2482
+ const next = fsm.end(pixel);
2483
+ replaceWorkingPoints(preDragPoints);
2484
+ commitGesture(next);
2485
+ return;
2486
+ }
2487
+ if (activeBoxDrag) {
2488
+ if (ctx.settled) {
2489
+ activeBoxDrag = null;
2490
+ dragInFlight = false;
2491
+ driver.setDragPanEnabled(true);
2492
+ return;
2493
+ }
2494
+ const { fsm, preDragPoints, preDragBox, downPixel, preDragOptions } = activeBoxDrag;
2495
+ activeBoxDrag = null;
2496
+ driver.setDragPanEnabled(true);
2497
+ const distance = Math.hypot(pixel[0] - downPixel[0], pixel[1] - downPixel[1]);
2498
+ if (fsm.kind === "body" && distance <= CLICK_SLOP_PX$1) {
2499
+ replaceWorkingPoints(preDragPoints);
2500
+ renderPreview();
2501
+ renderHandlesLayer();
2502
+ recomputeDirty();
2503
+ endDragBookkeeping();
2504
+ suppressClickAway = true;
2505
+ return;
2506
+ }
2507
+ const finalSnapshot = fsm.move(pixel);
2508
+ replaceWorkingPoints(preDragPoints);
2509
+ if (fsm.kind === "rotate") boxAngle += finalSnapshot.box.angle - preDragBox.angle;
2510
+ commitGesture(finalSnapshot.points, preDragOptions);
2511
+ return;
2512
+ }
2513
+ };
2514
+ let pickPreview;
2515
+ let pickHandles;
2516
+ let pickGraphics;
2517
+ const handlePickPreview = (e) => {
2518
+ pickPreview = e;
2519
+ };
2520
+ const handlePickHandles = (e) => {
2521
+ pickHandles = e;
2522
+ };
2523
+ const handlePickGraphics = (e) => {
2524
+ pickGraphics = e;
2525
+ };
2526
+ const handleClick = () => {
2527
+ const localPreview = pickPreview;
2528
+ const localHandles = pickHandles;
2529
+ const localGraphics = pickGraphics;
2530
+ pickPreview = void 0;
2531
+ pickHandles = void 0;
2532
+ pickGraphics = void 0;
2533
+ if (ctx.settled) return;
2534
+ if (!closeOnClickAway) return;
2535
+ if (suppressClickAway) {
2536
+ suppressClickAway = false;
2537
+ return;
2538
+ }
2539
+ if (localPreview || localHandles) return;
2540
+ if (localGraphics && localGraphics.id !== measure.id) {
2541
+ ctx.commit(rebuildRendered);
2542
+ if (onClickAwayGraphicsPick) onClickAwayGraphicsPick(localGraphics);
2543
+ return;
2544
+ }
2545
+ ctx.commit(rebuildRendered);
2546
+ };
2547
+ const core = runInteraction({
2548
+ adapter,
2549
+ signal,
2550
+ layers: [
2551
+ previewLayer,
2552
+ guideLayer,
2553
+ handlesLayer
2554
+ ],
2555
+ onViewChange: rescaleOnViewChange,
2556
+ onSettled,
2557
+ attach(c) {
2558
+ ctx = c;
2559
+ driver = adapter.createEditPointerDriver();
2560
+ liftOriginalFromGraphics();
2561
+ const unsubDown = driver.onPointerDown(onDriverDown);
2562
+ const unsubMove = driver.onPointerMove(onDriverMove);
2563
+ const unsubUp = driver.onPointerUp(onDriverUp);
2564
+ const unsubPickPreview = adapter.onPick(previewLayer, handlePickPreview);
2565
+ const unsubPickHandles = adapter.onPick(handlesLayer, handlePickHandles);
2566
+ const unsubPickGraphics = adapter.onPick(graphicsLayer, handlePickGraphics);
2567
+ adapter.on("click", handleClick);
2568
+ c.onTeardown(() => {
2569
+ adapter.off("click", handleClick);
2570
+ unsubPickPreview();
2571
+ unsubPickHandles();
2572
+ unsubPickGraphics();
2573
+ unsubDown();
2574
+ unsubMove();
2575
+ unsubUp();
2576
+ if (activeDrag !== null || activeBoxDrag !== null) {
2577
+ driver.setDragPanEnabled(true);
2578
+ activeDrag = null;
2579
+ activeBoxDrag = null;
2580
+ dragInFlight = false;
2581
+ }
2582
+ driver.dispose();
2583
+ }, wasOnGraphicsLayer ? restoreOriginalToGraphics : void 0);
2584
+ renderPreview();
2585
+ renderHandlesLayer();
2586
+ recomputeDirty();
2587
+ if (onSession) onSession(session);
2588
+ }
2589
+ });
2590
+ return {
2591
+ promise: core.promise,
2592
+ get settled() {
2593
+ return core.settled;
2594
+ },
2595
+ measureId: measure.id,
2596
+ abort(reason, cause) {
2597
+ core.abort(reason, cause);
2598
+ },
2599
+ notifyExternalMeasure(next) {
2600
+ if (core.settled) return;
2601
+ if (stylesEqual(currentStyle, next.style)) return;
2602
+ currentStyle = next.style;
2603
+ renderPreview();
2604
+ }
2605
+ };
2606
+ }
2607
+ /**
2608
+ * Validate and normalise an `EditOptions.modes` / `EditSession.setModes` input:
2609
+ * throws `TypeError` synchronously for an unknown mode, collapses `delete` to
2610
+ * an exclusive set, dedupes, and defaults to `["reshape", "transform"]`.
2611
+ * Exported so the façade can hoist this validation *before* `guardAndPreempt`
2612
+ * — an invalid mode must not tear down whatever edit/draw is already active
2613
+ * (see the `td.edit` regression test for issue tracked alongside ADR-0017).
2614
+ */
2615
+ function normaliseModes(modes) {
2616
+ if (!modes || modes.length === 0) return ["reshape", "transform"];
2617
+ for (const m of modes) if (!ALLOWED_EDIT_MODES.has(m)) throw new TypeError(`td.edit: unknown edit mode "${String(m)}"`);
2618
+ if (modes.includes("delete")) return ["delete"];
2619
+ const seen = /* @__PURE__ */ new Set();
2620
+ const out = [];
2621
+ for (const m of modes) if (!seen.has(m)) {
2622
+ seen.add(m);
2623
+ out.push(m);
2624
+ }
2625
+ return out;
2626
+ }
2627
+ function stylesEqual(a, b) {
2628
+ if (a === b) return true;
2629
+ if (!a || !b) return false;
2630
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
2631
+ for (const key of keys) if (a[key] !== b[key]) return false;
2632
+ return true;
2633
+ }
2634
+ function pointsEqualWithinTolerance(a, b, epsilon) {
2635
+ if (a.length !== b.length) return false;
2636
+ for (let i = 0; i < a.length; i++) {
2637
+ const pa = a[i];
2638
+ const pb = b[i];
2639
+ if (pa.length !== pb.length) return false;
2640
+ for (let k = 0; k < pa.length; k++) if (Math.abs((pa[k] ?? 0) - (pb[k] ?? 0)) > epsilon) return false;
2641
+ }
2642
+ return true;
2643
+ }
2644
+ //#endregion
2645
+ //#region src/tactical-draw/transform-controller.ts
2646
+ /**
2647
+ * Coordinate-space tolerance for `TransformSession.dirty` — mirrors
2648
+ * `edit-controller.ts`'s `DIRTY_EPSILON` (kept as a sibling constant rather
2649
+ * than a shared import since the two controllers' dirty definitions may
2650
+ * diverge later: a group's dirty is "any member moved").
2651
+ */
2652
+ const DIRTY_EPSILON = 1e-9;
2653
+ const HANDLE_HIT_TOLERANCE_PX = 12;
2654
+ /**
2655
+ * A body drag (drag-anywhere-inside-the-box translate) whose release pixel
2656
+ * lands within this many pixels of the down pixel is treated as a plain
2657
+ * click, not a gesture — same rationale as `edit-controller.ts`'s constant of
2658
+ * the same name.
2659
+ */
2660
+ const CLICK_SLOP_PX = 3;
2661
+ /**
2662
+ * Drive an interactive group transform against a `MapAdapter` — the leaner,
2663
+ * multi-select sibling of `runEdit` (ADR-0017 "Multi-select" slice). No
2664
+ * reshape, no per-vertex handles, no guide layer, no per-measure
2665
+ * `setOptions`/`setStyle`: only the shared oriented box (corner scale, rotate
2666
+ * grip, drag-anywhere body translate), applied rigidly to every member's
2667
+ * control points at once.
2668
+ *
2669
+ * - lift every member off the graphics layer + per-member working copies
2670
+ * - render every member's preview to the shared preview layer each paint
2671
+ * - group box = fit over the union of every member's rendered pixel
2672
+ * coordinates at the session `boxAngle`; each member also gets its own
2673
+ * thin outline (its own fit at the same angle)
2674
+ * - dirty tracking, close/abort/signal/preempt/destroy, click-away — same
2675
+ * contracts as `runEdit`, scoped to the group
2676
+ * - membership is mutable via `session.setMembers` (#187): members join/leave
2677
+ * a live session without a session boundary
2678
+ */
2679
+ function runGroupTransform(opts) {
2680
+ const { adapter, measures, graphicsLayer, previewLayer, handlesLayer, graphicsStyle, signal, closeOnClickAway = true, interactionStyle, onSession, handleIdPrefix, restoreOriginalToGraphics, isOnGraphicsLayer, onSettled, onClickAwayGraphicsPick } = opts;
2681
+ const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
2682
+ let sizeAnchor = opts.sizeAnchor ?? "ground";
2683
+ /** Build a fresh working record for `measure`, snapshotting its edit-start points. */
2684
+ function createMemberState(measure) {
2685
+ const editStartPoints = measure.controlPoints.map((p) => [...p]);
2686
+ return {
2687
+ measure,
2688
+ editStartPoints,
2689
+ workingPoints: editStartPoints.map((p) => [...p]),
2690
+ optionsOverride: null,
2691
+ box: null,
2692
+ lastPreviewFeatures: []
2693
+ };
2694
+ }
2695
+ /** The single mutable source of truth for the current member set. */
2696
+ let members = measures.map(createMemberState);
2697
+ /**
2698
+ * `true` once any member has been lifted off the graphics layer (initial or a
2699
+ * mid-session `setMembers` add). Gates the abort-path restore so a session
2700
+ * that never touched the graphics layer doesn't rewrite it on abort.
2701
+ */
2702
+ let anyLiftedFromGraphics = false;
2703
+ /** A member's own options merged with an override patch (a null override leaves them unchanged). */
2704
+ function mergeOptions(base, override) {
2705
+ if (!override) return base;
2706
+ return {
2707
+ ...base ?? {},
2708
+ ...override
2709
+ };
2710
+ }
2711
+ /** Effective options for member `i`: its own options merged with its *live* override. */
2712
+ function effectiveOptions(i) {
2713
+ const member = members[i];
2714
+ return mergeOptions(member.measure.options, member.optionsOverride);
2715
+ }
2716
+ /** Build a member's committed snapshot with a given override, baking pixel size to ground unless "screen". */
2717
+ function buildMemberSnapshot(member, override) {
2718
+ const working = {
2719
+ ...member.measure,
2720
+ options: mergeOptions(member.measure.options, override),
2721
+ controlPoints: member.workingPoints.map((p) => [...p])
2722
+ };
2723
+ const committed = sizeAnchor === "screen" ? working : bakePixelSizeToGround(working, adapter);
2724
+ return {
2725
+ measure: cloneControlMeasure(committed),
2726
+ render: renderMeasureForAdapter(adapter, committed, renderOpts)
2727
+ };
2728
+ }
2729
+ /** The working measure for member `i`: original measure, live effective options, live working points. */
2730
+ function workingMeasureFor(i) {
2731
+ const member = members[i];
2732
+ return {
2733
+ ...member.measure,
2734
+ options: effectiveOptions(i),
2735
+ controlPoints: member.workingPoints.map((p) => [...p])
2736
+ };
2737
+ }
2738
+ let dirty = false;
2739
+ /**
2740
+ * Set by non-geometry mutations (`setSizeAnchor`) so `dirty` reports them
2741
+ * even when every control point still matches edit-start — mirrors
2742
+ * `edit-controller.ts`'s flag of the same name.
2743
+ */
2744
+ let nonGeometryDirty = false;
2745
+ let dragInFlight = false;
2746
+ /** Same click-away suppression role as `edit-controller.ts`'s flag of the same name. */
2747
+ let suppressClickAway = false;
2748
+ const changeListeners = /* @__PURE__ */ new Set();
2749
+ const commitListeners = /* @__PURE__ */ new Set();
2750
+ const settleListeners = /* @__PURE__ */ new Set();
2751
+ /** Set exactly once, by `emitSettled` — also the "has settled been emitted" latch and the reason replayed to late registrations. */
2752
+ let settledReason = null;
2753
+ /** Session-scoped box rotation (ADR-0017 decision #2), shared by the whole group. */
2754
+ let boxAngle = 0;
2755
+ /** The group box fitted over every member's rendered geometry, or `null` when nothing projects this frame. */
2756
+ let groupBox = null;
2757
+ let ctx;
2758
+ let driver;
2759
+ const dragProjection = {
2760
+ toPixel: (lonLat) => driver.toPixel(lonLat),
2761
+ fromPixel: (pixel) => driver.fromPixel(pixel)
2762
+ };
2763
+ /**
2764
+ * Lift `member` off the graphics layer when the host reports it currently
2765
+ * there, flipping `anyLiftedFromGraphics` so the abort path knows to restore.
2766
+ * Used for both initial and mid-session-added members.
2767
+ */
2768
+ function liftMemberFromGraphics(member) {
2769
+ if (!isOnGraphicsLayer(member.measure.id)) return;
2770
+ const ids = renderMeasureForAdapter(adapter, member.measure, renderOpts).features.map((f) => f.id).filter((id) => typeof id === "string");
2771
+ if (ids.length > 0) adapter.removeFeatures(graphicsLayer, ids);
2772
+ anyLiftedFromGraphics = true;
2773
+ }
2774
+ function pixelsFor(features) {
2775
+ const positions = [];
2776
+ for (const f of features) if (f.geometry) collectFeaturePositions(f.geometry, positions);
2777
+ const pixels = [];
2778
+ for (const p of positions) {
2779
+ const px = driver.toPixel(p);
2780
+ if (px) pixels.push(px);
2781
+ }
2782
+ return pixels;
2783
+ }
2784
+ /** The live box angle: the in-flight drag's rigidly-transformed angle, or the committed `boxAngle`. */
2785
+ function currentAngle() {
2786
+ return activeBoxDrag ? activeBoxDrag.lastSnapshot.box.angle : boxAngle;
2787
+ }
2788
+ /**
2789
+ * Re-fit `groupBox` and every member's `box` from their `lastPreviewFeatures`.
2790
+ * While a box gesture is in flight, `groupBox` itself is not touched here —
2791
+ * `renderHandlesLayer` paints the drag's own rigidly-transformed box instead
2792
+ * (matching `edit-controller.ts`'s `refitBox`) — but the member outlines are
2793
+ * still re-fitted every paint at the drag's live angle, since they are a
2794
+ * derived display only (no self-fight risk).
2795
+ */
2796
+ function refitBoxes() {
2797
+ const angle = currentAngle();
2798
+ const padding = interactionStyle?.boxPadding ?? 0;
2799
+ const allPixels = [];
2800
+ for (const member of members) {
2801
+ const pixels = pixelsFor(member.lastPreviewFeatures);
2802
+ allPixels.push(...pixels);
2803
+ member.box = fitTransformBox(pixels, angle, padding);
2804
+ }
2805
+ if (activeBoxDrag) return;
2806
+ groupBox = fitTransformBox(allPixels, boxAngle, padding);
2807
+ }
2808
+ function renderAllPreviews() {
2809
+ if (ctx.settled) return;
2810
+ const allFeatures = [];
2811
+ members.forEach((member, i) => {
2812
+ const working = workingMeasureFor(i);
2813
+ let features;
2814
+ try {
2815
+ features = renderMeasureForAdapter(adapter, working, renderOpts).features;
2816
+ } catch {
2817
+ features = [];
2818
+ }
2819
+ member.lastPreviewFeatures = features;
2820
+ allFeatures.push(...features);
2821
+ });
2822
+ ctx.writeLayer(previewLayer, allFeatures);
2823
+ refitBoxes();
2824
+ }
2825
+ function rescaleOnViewChange() {
2826
+ if (ctx.settled) return;
2827
+ if (members.some((m) => measureUsesAdapterResolution({
2828
+ kind: m.measure.kind,
2829
+ options: m.measure.options
2830
+ }))) renderAllPreviews();
2831
+ else refitBoxes();
2832
+ renderHandlesLayer();
2833
+ }
2834
+ /**
2835
+ * The group's outline style: the resolved `boxOutline` plus `[4, 4]` dash
2836
+ * so the group reads as dashed per the reference design — unless the host
2837
+ * explicitly set their own `strokeDash`, which wins.
2838
+ */
2839
+ function groupOutlineStyle() {
2840
+ const base = interactionStyle?.boxOutline;
2841
+ if (base?.strokeDash !== void 0) return base;
2842
+ return {
2843
+ ...base ?? {},
2844
+ strokeDash: [4, 4]
2845
+ };
2846
+ }
2847
+ function renderHandlesLayer() {
2848
+ if (ctx.settled) return;
2849
+ const features = [];
2850
+ const liveGroupBox = activeBoxDrag ? activeBoxDrag.lastSnapshot.box : groupBox;
2851
+ if (liveGroupBox) {
2852
+ features.push(boxOutlineFeature(liveGroupBox, dragProjection.fromPixel, `${handleIdPrefix}:box-outline`, groupOutlineStyle()));
2853
+ features.push(...toFeatures(boxToHandles(liveGroupBox, dragProjection.fromPixel), handleIdPrefix, interactionStyle));
2854
+ }
2855
+ for (const member of members) {
2856
+ const memberBox = member.box;
2857
+ if (!memberBox) continue;
2858
+ features.push(boxOutlineFeature(memberBox, dragProjection.fromPixel, `${handleIdPrefix}:member-outline:${member.measure.id}`, interactionStyle?.boxOutline));
2859
+ }
2860
+ ctx.writeLayer(handlesLayer, features);
2861
+ }
2862
+ /**
2863
+ * Builds every member's committed snapshot against a given override array
2864
+ * rather than the live per-member override — used to render a gesture's
2865
+ * pre-freeze "previous" snapshot when a rotate has already mutated the live
2866
+ * overrides by the time the commit runs. Index-aligned with `members`, which
2867
+ * is sound because membership cannot change within a gesture emission.
2868
+ */
2869
+ function rebuildRenderedAllWith(overrides) {
2870
+ return members.map((member, i) => buildMemberSnapshot(member, overrides[i] ?? null));
2871
+ }
2872
+ function rebuildRenderedAll() {
2873
+ return members.map((member) => buildMemberSnapshot(member, member.optionsOverride));
2874
+ }
2875
+ function recomputeDirty() {
2876
+ dirty = nonGeometryDirty || members.some((m) => !pointsEqualWithinTolerance(m.workingPoints, m.editStartPoints, DIRTY_EPSILON));
2877
+ }
2878
+ /** Split `points` (the concatenation of every member's control points) back per member. */
2879
+ function applyConcatenatedPoints(points) {
2880
+ const rounded = roundControlPoints(points);
2881
+ let offset = 0;
2882
+ for (const member of members) {
2883
+ const c = member.workingPoints.length;
2884
+ const slice = rounded.slice(offset, offset + c);
2885
+ member.workingPoints.splice(0, member.workingPoints.length, ...slice);
2886
+ offset += c;
2887
+ }
2888
+ }
2889
+ function concatWorkingPoints() {
2890
+ const out = [];
2891
+ for (const member of members) out.push(...member.workingPoints.map((p) => [...p]));
2892
+ return out;
2893
+ }
2894
+ function clonePointsByMember() {
2895
+ return members.map((m) => m.workingPoints.map((p) => [...p]));
2896
+ }
2897
+ function restorePointsByMember(snapshot) {
2898
+ members.forEach((member, i) => {
2899
+ member.workingPoints.splice(0, member.workingPoints.length, ...snapshot[i]);
2900
+ });
2901
+ }
2902
+ function cloneOptionsOverrideByMember() {
2903
+ return members.map((m) => m.optionsOverride ? { ...m.optionsOverride } : null);
2904
+ }
2905
+ /** Write a per-member override snapshot back onto the live members (index-aligned; gesture-scoped). */
2906
+ function applyOptionsOverrides(overrides) {
2907
+ members.forEach((member, i) => {
2908
+ const o = overrides[i];
2909
+ member.optionsOverride = o ? { ...o } : null;
2910
+ });
2911
+ }
2912
+ /**
2913
+ * Pin orientation-derived option defaults (e.g. Battle Position's echelon
2914
+ * anchor) for every member, the moment a group rotate gesture starts —
2915
+ * mirrors `edit-controller.ts`'s `freezeOrientationForRotate`, applied
2916
+ * per-member since the group has no single working-options set.
2917
+ */
2918
+ function freezeOrientationForRotate() {
2919
+ members.forEach((member, i) => {
2920
+ const patch = freezeOrientationOptions(member.measure.kind, member.workingPoints.map((p) => [...p]), effectiveOptions(i));
2921
+ if (patch) member.optionsOverride = {
2922
+ ...member.optionsOverride ?? {},
2923
+ ...patch
2924
+ };
2925
+ });
2926
+ }
2927
+ /**
2928
+ * Emit a completed gesture to every registered listener — the group mirror
2929
+ * of `edit-controller.ts`'s `emitChange` (issue #49 contract): same
2930
+ * instance to all listeners in order, deferred `reject`/`close`/`abort`
2931
+ * (close/abort override reject), exception collection with `AggregateError`.
2932
+ */
2933
+ function emitChange(previousRendered, previousPointsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered) {
2934
+ if (ctx.settled) return;
2935
+ let rejectRequested = false;
2936
+ let closeRequested = false;
2937
+ let abortRequested = false;
2938
+ const event = {
2939
+ measures: currentRendered,
2940
+ previous: previousRendered,
2941
+ session,
2942
+ reject() {
2943
+ rejectRequested = true;
2944
+ },
2945
+ close() {
2946
+ closeRequested = true;
2947
+ },
2948
+ abort() {
2949
+ abortRequested = true;
2950
+ }
2951
+ };
2952
+ const membersAtEmission = members;
2953
+ const errors = [];
2954
+ for (const listener of [...changeListeners]) try {
2955
+ listener(event);
2956
+ } catch (err) {
2957
+ errors.push(err);
2958
+ }
2959
+ if (abortRequested) ctx.fail(new TacticalDrawAbortError("session"));
2960
+ else if (closeRequested) commitClose();
2961
+ else if (rejectRequested && !ctx.settled && members === membersAtEmission) {
2962
+ restorePointsByMember(previousPointsByMember);
2963
+ sizeAnchor = previousSizeAnchor;
2964
+ nonGeometryDirty = previousNonGeometryDirty;
2965
+ applyOptionsOverrides(previousOptionsOverrideByMember);
2966
+ renderAllPreviews();
2967
+ renderHandlesLayer();
2968
+ recomputeDirty();
2969
+ }
2970
+ if (errors.length === 1) throw errors[0];
2971
+ if (errors.length > 1) throw new AggregateError(errors, "TransformSession.onChange listener errors");
2972
+ }
2973
+ /**
2974
+ * Deliver a committed exit-snapshot batch to every `onCommit` listener — the
2975
+ * single delivery channel for both the `setMembers`-removed batch and the
2976
+ * close-time batch (#191). Same exception-collection contract as `emitChange`.
2977
+ */
2978
+ function emitCommit(snapshots) {
2979
+ const errors = [];
2980
+ for (const listener of [...commitListeners]) try {
2981
+ listener(snapshots);
2982
+ } catch (err) {
2983
+ errors.push(err);
2984
+ }
2985
+ if (errors.length === 1) throw errors[0];
2986
+ if (errors.length > 1) throw new AggregateError(errors, "TransformSession.onCommit listener errors");
2987
+ }
2988
+ /**
2989
+ * Map a settle-time error to its `SettleReason`: a `TacticalDrawAbortError`
2990
+ * replays its own `reason` verbatim; anything else (a `produce` throw from
2991
+ * `ctx.commit`) is the close-time snapshot build failing, reported as
2992
+ * `"error"`.
2993
+ */
2994
+ function settleReasonFromError(error) {
2995
+ return isTacticalDrawAbortError(error) ? error.reason : "error";
2996
+ }
2997
+ /**
2998
+ * Deliver the session's settlement to every `onSettled` listener — fires
2999
+ * exactly once, whichever path settles first wins the reason. Mirrors
3000
+ * `emitCommit`'s exception-collection contract (one throw if single,
3001
+ * `AggregateError` otherwise), so a throwing listener cannot suppress a
3002
+ * later one nor leave `settledReason` unset.
3003
+ */
3004
+ function emitSettled(reason) {
3005
+ if (settledReason !== null) return;
3006
+ settledReason = reason;
3007
+ const listeners = [...settleListeners];
3008
+ settleListeners.clear();
3009
+ const errors = [];
3010
+ for (const listener of listeners) try {
3011
+ listener(reason);
3012
+ } catch (err) {
3013
+ errors.push(err);
3014
+ }
3015
+ if (errors.length === 1) throw errors[0];
3016
+ if (errors.length > 1) throw new AggregateError(errors, "TransformSession.onSettled listener errors");
3017
+ }
3018
+ /**
3019
+ * Commit via `ctx.commit` and emit the close batch to `onCommit` listeners,
3020
+ * then emit the settle to `onSettled` listeners (#194) — in a try/finally so
3021
+ * a throwing `onCommit` listener cannot starve the settle emission. The
3022
+ * emission happens after the interaction core has settled (teardown and
3023
+ * `onSettled` already ran inside `ctx.commit`), so a listener that calls
3024
+ * `td.render()` sees these members no longer excluded from reconcile — the
3025
+ * fold-then-render pattern works from inside the handler. `undefined` covers
3026
+ * both an already-settled session and a `produce` throw (the promise
3027
+ * rejects); neither emits here — a `produce` throw already emitted the
3028
+ * settle with reason `"error"` via the wrapped `onSettled` passed to
3029
+ * `runInteraction`.
3030
+ */
3031
+ function commitClose() {
3032
+ const snapshots = ctx.commit(rebuildRenderedAll);
3033
+ if (snapshots !== void 0) try {
3034
+ emitCommit(snapshots);
3035
+ } finally {
3036
+ emitSettled("close");
3037
+ }
3038
+ return snapshots;
3039
+ }
3040
+ function commitGesture(nextConcatPoints, preGestureOptionsOverrideByMember) {
3041
+ if (ctx.settled) return;
3042
+ const previousPointsByMember = clonePointsByMember();
3043
+ const previousOptionsOverrideByMember = preGestureOptionsOverrideByMember ?? cloneOptionsOverrideByMember();
3044
+ const previousRendered = rebuildRenderedAllWith(previousOptionsOverrideByMember);
3045
+ applyConcatenatedPoints(nextConcatPoints);
3046
+ let currentRendered;
3047
+ try {
3048
+ currentRendered = rebuildRenderedAll();
3049
+ } catch (err) {
3050
+ restorePointsByMember(previousPointsByMember);
3051
+ dragInFlight = false;
3052
+ throw err;
3053
+ }
3054
+ renderAllPreviews();
3055
+ renderHandlesLayer();
3056
+ recomputeDirty();
3057
+ dragInFlight = false;
3058
+ emitChange(previousRendered, previousPointsByMember, sizeAnchor, nonGeometryDirty, previousOptionsOverrideByMember, currentRendered);
3059
+ }
3060
+ function setSizeAnchor(next) {
3061
+ if (ctx.settled || sizeAnchor === next) return;
3062
+ const previousPointsByMember = clonePointsByMember();
3063
+ const previousSizeAnchor = sizeAnchor;
3064
+ const previousNonGeometryDirty = nonGeometryDirty;
3065
+ const previousOptionsOverrideByMember = cloneOptionsOverrideByMember();
3066
+ const previousRendered = rebuildRenderedAll();
3067
+ sizeAnchor = next;
3068
+ const currentRendered = rebuildRenderedAll();
3069
+ nonGeometryDirty = true;
3070
+ dirty = true;
3071
+ emitChange(previousRendered, previousPointsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered);
3072
+ }
3073
+ /**
3074
+ * Cancel an in-flight box drag without committing it: restore the pre-drag
3075
+ * geometry and option overrides, re-enable drag pan, and clear the drag. Used
3076
+ * by `setMembers` so a membership change never bakes a half-finished drag; the
3077
+ * caller is responsible for the follow-up repaint (`renderAllPreviews` /
3078
+ * `renderHandlesLayer` / `recomputeDirty`), so this leaves the paint to it
3079
+ * rather than rendering the outgoing member set only to redo it immediately.
3080
+ */
3081
+ function cancelActiveBoxDrag() {
3082
+ if (!activeBoxDrag) return;
3083
+ const { preDragPoints, preDragOptionsOverrideByMember } = activeBoxDrag;
3084
+ activeBoxDrag = null;
3085
+ driver.setDragPanEnabled(true);
3086
+ applyConcatenatedPoints(preDragPoints);
3087
+ applyOptionsOverrides(preDragOptionsOverrideByMember);
3088
+ dragInFlight = false;
3089
+ }
3090
+ function setMembers(next) {
3091
+ if (ctx.settled) return void 0;
3092
+ const seen = /* @__PURE__ */ new Set();
3093
+ for (const m of next) {
3094
+ if (seen.has(m.id)) throw new TypeError(`TransformSession.setMembers: duplicate control measure id "${m.id}"`);
3095
+ seen.add(m.id);
3096
+ }
3097
+ if (next.length === 0) return close();
3098
+ cancelActiveBoxDrag();
3099
+ const nextIds = new Set(next.map((m) => m.id));
3100
+ const currentById = new Map(members.map((m) => [m.measure.id, m]));
3101
+ const removedSnapshots = [];
3102
+ for (const member of members) if (!nextIds.has(member.measure.id)) removedSnapshots.push(buildMemberSnapshot(member, member.optionsOverride));
3103
+ const nextMembers = [];
3104
+ for (const measure of next) {
3105
+ const existing = currentById.get(measure.id);
3106
+ if (existing) nextMembers.push(existing);
3107
+ else {
3108
+ const member = createMemberState(measure);
3109
+ liftMemberFromGraphics(member);
3110
+ nextMembers.push(member);
3111
+ }
3112
+ }
3113
+ members = nextMembers;
3114
+ renderAllPreviews();
3115
+ renderHandlesLayer();
3116
+ recomputeDirty();
3117
+ if (removedSnapshots.length > 0) emitCommit(removedSnapshots);
3118
+ return removedSnapshots;
3119
+ }
3120
+ function close() {
3121
+ return commitClose();
3122
+ }
3123
+ const session = {
3124
+ get measures() {
3125
+ return members.map((m) => m.measure);
3126
+ },
3127
+ controlPointsFor(measureId) {
3128
+ const member = members.find((m) => m.measure.id === measureId);
3129
+ if (!member) return void 0;
3130
+ return member.workingPoints.map((p) => [...p]);
1970
3131
  },
1971
3132
  get dirty() {
1972
3133
  return dirty;
1973
3134
  },
1974
- get modes() {
1975
- return modes;
1976
- },
1977
3135
  get sizeAnchor() {
1978
3136
  return sizeAnchor;
1979
3137
  },
1980
- close() {
1981
- if (ctx.settled) return;
1982
- ctx.commit(rebuildRendered);
3138
+ setSizeAnchor(next) {
3139
+ setSizeAnchor(next);
1983
3140
  },
3141
+ setMembers,
3142
+ close,
1984
3143
  abort() {
1985
3144
  if (ctx.settled) return;
1986
3145
  ctx.fail(new TacticalDrawAbortError("session"));
1987
3146
  },
1988
- setModes(next) {
1989
- if (ctx.settled) return;
1990
- const normalised = normaliseModes(next);
1991
- if (dragInFlight) {
1992
- pendingModes = normalised;
1993
- return;
1994
- }
1995
- applyModes(normalised);
1996
- },
1997
- setOptions(partial) {
1998
- setOptions(partial);
1999
- },
2000
- setStyle(partial) {
2001
- setStyle(partial);
2002
- },
2003
- setSizeAnchor(next) {
2004
- setSizeAnchor(next);
2005
- },
2006
3147
  onChange(listener) {
2007
3148
  if (ctx.settled) return () => {};
2008
3149
  changeListeners.add(listener);
@@ -2010,139 +3151,103 @@ function runEdit(opts) {
2010
3151
  changeListeners.delete(listener);
2011
3152
  };
2012
3153
  },
2013
- [COMMIT_GESTURE_FOR_TEST]: (nextPoints) => {
2014
- commitGesture(nextPoints);
2015
- },
2016
- [COMMIT_TRANSLATE_FOR_TEST]: (delta) => {
2017
- commitTranslate(delta);
2018
- },
2019
- [COMMIT_ROTATE_FOR_TEST]: (angle) => {
2020
- commitRotate(angle);
2021
- },
2022
- [BEGIN_DRAG_FOR_TEST]: () => {
2023
- if (ctx.settled) return;
2024
- dragInFlight = true;
3154
+ onCommit(listener) {
3155
+ if (ctx.settled) return () => {};
3156
+ commitListeners.add(listener);
3157
+ return () => {
3158
+ commitListeners.delete(listener);
3159
+ };
2025
3160
  },
2026
- [END_DRAG_FOR_TEST]: () => {
2027
- if (ctx.settled) return;
2028
- dragInFlight = false;
2029
- drainPendingModes();
3161
+ onSettled(handler) {
3162
+ if (settledReason !== null) {
3163
+ const reason = settledReason;
3164
+ let cancelled = false;
3165
+ queueMicrotask(() => {
3166
+ if (!cancelled) handler(reason);
3167
+ });
3168
+ return () => {
3169
+ cancelled = true;
3170
+ };
3171
+ }
3172
+ settleListeners.add(handler);
3173
+ return () => {
3174
+ settleListeners.delete(handler);
3175
+ };
2030
3176
  }
2031
3177
  };
2032
- let activeDrag = null;
2033
- /**
2034
- * Render-only rotate-grip override produced by the drag FSM: the start grip
2035
- * rotated rigidly with the gesture so it tracks the pointer instead of hopping
2036
- * between near-equidistant vertices. Cleared when the drag ends. Only
2037
- * consulted while a rotate drag is in flight — pick is gated off for the
2038
- * duration, so the resting handle set is never read against it (ADR-0016).
2039
- */
2040
- let rotateHandleOverride = null;
2041
- /**
2042
- * Whether the vertex at `index` may be deleted (via Alt+click or in the
2043
- * exclusive `delete` mode). Deletion is
2044
- * confined to variable-length measures (the same condition that enables
2045
- * midpoint inserts — see ADR-0005) and is denied when it would:
2046
- * - target a trailing fixed slot (e.g. Axis1's width handle), or
2047
- * - drop the user-spine point count below the rule's `minimumUserPoints`.
2048
- * Fixed-count measures re-pad on derive, so deleting a vertex there is moot.
2049
- */
2050
- function canDeleteVertex(index) {
2051
- if (!midpointHandlesEnabled) return false;
2052
- if (!modes.includes("reshape") && !modes.includes("delete")) return false;
2053
- const spineCount = workingPoints.length - trailingFixedSlots;
2054
- if (index < 0 || index >= spineCount) return false;
2055
- const minimum = rule?.minimumUserPoints ?? (isAreaKind ? 3 : 2);
2056
- return spineCount - 1 >= minimum;
2057
- }
2058
- /**
2059
- * Remove the vertex at `index` and commit through the normal gesture path so
2060
- * a single `EditChangeEvent` covers the delete and dirty tracking stays
2061
- * consistent. The rule's `transform` runs with the removed index as the
2062
- * active point so frame-relative invariants survive the delete: for Axis1
2063
- * this re-projects the trailing width handle into the new tip→neck frame,
2064
- * keeping the main-attack arrow's width unchanged even when the tip or neck
2065
- * is removed. Rules without that concern (Line1) pass the points through.
2066
- */
2067
- function deleteVertex(index) {
2068
- if (ctx.settled) return;
2069
- const previous = workingPoints.map((p) => [...p]);
2070
- const spliced = previous.filter((_, i) => i !== index);
2071
- commitGesture(rule ? rule.transform({
2072
- previous,
2073
- next: spliced,
2074
- activePointIndex: index
2075
- }) : spliced);
2076
- }
3178
+ let activeBoxDrag = null;
2077
3179
  const onDriverDown = (pixel, info) => {
2078
3180
  if (ctx.settled || dragInFlight) return;
3181
+ if (!groupBox) return;
2079
3182
  const tol = info?.hitTolerance ?? HANDLE_HIT_TOLERANCE_PX;
2080
- const hit = pickHandle(buildHandleSet(workingPoints, modes, {
2081
- includeMidpoints: midpointHandlesEnabled,
2082
- trailingFixedSlots
2083
- }), pixel, tol, (lonLat) => driver.toPixel(lonLat));
2084
- if (!hit) return;
2085
- if (modes.includes("delete")) {
2086
- if (hit.kind === "vertex" && canDeleteVertex(hit.index)) {
2087
- deleteVertex(hit.index);
2088
- suppressClickAway = true;
2089
- }
2090
- return;
2091
- }
2092
- if (hit.kind === "vertex" && info?.altKey && canDeleteVertex(hit.index)) {
2093
- deleteVertex(hit.index);
2094
- suppressClickAway = true;
2095
- return;
3183
+ const boxHit = pickBoxHandle(groupBox, pixel, tol);
3184
+ if (!boxHit) return;
3185
+ const preDragOptionsOverrideByMember = cloneOptionsOverrideByMember();
3186
+ if (boxHit.kind === "rotate") {
3187
+ freezeOrientationForRotate();
3188
+ renderAllPreviews();
3189
+ renderHandlesLayer();
2096
3190
  }
2097
- const preDragPoints = workingPoints.map((p) => [...p]);
2098
- const { drag: fsm, initialPoints } = beginHandleDrag({
2099
- hit,
3191
+ const preDragPoints = concatWorkingPoints();
3192
+ activeBoxDrag = {
3193
+ fsm: beginBoxDrag({
3194
+ kind: boxHit.kind,
3195
+ downPixel: pixel,
3196
+ points: preDragPoints,
3197
+ projection: dragProjection,
3198
+ box: groupBox,
3199
+ cornerIndex: boxHit.kind === "scale" ? boxHit.index : void 0
3200
+ }),
3201
+ preDragPoints,
3202
+ preDragBox: groupBox,
2100
3203
  downPixel: pixel,
2101
- points: preDragPoints,
2102
- projection: dragProjection,
2103
- rule
2104
- });
2105
- activeDrag = {
2106
- fsm,
2107
- preDragPoints
3204
+ lastSnapshot: {
3205
+ points: preDragPoints,
3206
+ box: groupBox
3207
+ },
3208
+ preDragOptionsOverrideByMember
2108
3209
  };
2109
- if (fsm.kind === "midpoint") {
2110
- workingPoints.splice(0, workingPoints.length, ...initialPoints);
2111
- renderPreview();
2112
- renderHandlesLayer();
2113
- }
2114
3210
  dragInFlight = true;
2115
3211
  driver.setDragPanEnabled(false);
2116
3212
  };
2117
- function paintDragSnapshot(pixel) {
2118
- if (!activeDrag) return;
2119
- const snapshot = activeDrag.fsm.move(pixel);
2120
- workingPoints.splice(0, workingPoints.length, ...snapshot.points);
2121
- rotateHandleOverride = snapshot.rotateGripOverride;
2122
- renderPreview();
3213
+ function paintBoxDragSnapshot(pixel) {
3214
+ if (!activeBoxDrag) return;
3215
+ const snapshot = activeBoxDrag.fsm.move(pixel);
3216
+ activeBoxDrag.lastSnapshot = snapshot;
3217
+ applyConcatenatedPoints(snapshot.points);
3218
+ renderAllPreviews();
2123
3219
  renderHandlesLayer();
2124
3220
  recomputeDirty();
2125
3221
  }
2126
3222
  const onDriverMove = (pixel) => {
2127
- if (ctx.settled || !activeDrag) return;
2128
- paintDragSnapshot(pixel);
3223
+ if (ctx.settled) return;
3224
+ if (activeBoxDrag) paintBoxDragSnapshot(pixel);
2129
3225
  };
2130
3226
  const onDriverUp = (pixel) => {
2131
- if (!activeDrag) return;
3227
+ if (!activeBoxDrag) return;
2132
3228
  if (ctx.settled) {
2133
- activeDrag = null;
2134
- rotateHandleOverride = null;
3229
+ activeBoxDrag = null;
2135
3230
  dragInFlight = false;
2136
3231
  driver.setDragPanEnabled(true);
2137
3232
  return;
2138
3233
  }
2139
- const { fsm, preDragPoints } = activeDrag;
2140
- activeDrag = null;
2141
- rotateHandleOverride = null;
3234
+ const { fsm, preDragPoints, preDragBox, downPixel, preDragOptionsOverrideByMember } = activeBoxDrag;
3235
+ activeBoxDrag = null;
2142
3236
  driver.setDragPanEnabled(true);
2143
- const next = fsm.end(pixel);
2144
- replaceWorkingPoints(preDragPoints);
2145
- commitGesture(next);
3237
+ const distance = Math.hypot(pixel[0] - downPixel[0], pixel[1] - downPixel[1]);
3238
+ if (fsm.kind === "body" && distance <= CLICK_SLOP_PX) {
3239
+ applyConcatenatedPoints(preDragPoints);
3240
+ renderAllPreviews();
3241
+ renderHandlesLayer();
3242
+ recomputeDirty();
3243
+ dragInFlight = false;
3244
+ suppressClickAway = true;
3245
+ return;
3246
+ }
3247
+ const finalSnapshot = fsm.move(pixel);
3248
+ applyConcatenatedPoints(preDragPoints);
3249
+ if (fsm.kind === "rotate") boxAngle += finalSnapshot.box.angle - preDragBox.angle;
3250
+ commitGesture(finalSnapshot.points, preDragOptionsOverrideByMember);
2146
3251
  };
2147
3252
  let pickPreview;
2148
3253
  let pickHandles;
@@ -2170,27 +3275,27 @@ function runEdit(opts) {
2170
3275
  return;
2171
3276
  }
2172
3277
  if (localPreview || localHandles) return;
2173
- if (localGraphics && localGraphics.id !== measure.id) {
2174
- ctx.commit(rebuildRendered);
3278
+ const isMember = localGraphics ? members.some((m) => m.measure.id === localGraphics.id) : false;
3279
+ if (localGraphics && !isMember) {
3280
+ commitClose();
2175
3281
  if (onClickAwayGraphicsPick) onClickAwayGraphicsPick(localGraphics);
2176
3282
  return;
2177
3283
  }
2178
- ctx.commit(rebuildRendered);
3284
+ commitClose();
2179
3285
  };
2180
3286
  const core = runInteraction({
2181
3287
  adapter,
2182
3288
  signal,
2183
- layers: [
2184
- previewLayer,
2185
- guideLayer,
2186
- handlesLayer
2187
- ],
3289
+ layers: [previewLayer, handlesLayer],
2188
3290
  onViewChange: rescaleOnViewChange,
2189
- onSettled,
3291
+ onSettled: (settlement) => {
3292
+ if (onSettled) onSettled();
3293
+ if (settlement.status === "rejected") emitSettled(settleReasonFromError(settlement.error));
3294
+ },
2190
3295
  attach(c) {
2191
3296
  ctx = c;
2192
3297
  driver = adapter.createEditPointerDriver();
2193
- liftOriginalFromGraphics();
3298
+ for (const member of members) liftMemberFromGraphics(member);
2194
3299
  const unsubDown = driver.onPointerDown(onDriverDown);
2195
3300
  const unsubMove = driver.onPointerMove(onDriverMove);
2196
3301
  const unsubUp = driver.onPointerUp(onDriverUp);
@@ -2206,15 +3311,16 @@ function runEdit(opts) {
2206
3311
  unsubDown();
2207
3312
  unsubMove();
2208
3313
  unsubUp();
2209
- if (activeDrag !== null) {
3314
+ if (activeBoxDrag !== null) {
2210
3315
  driver.setDragPanEnabled(true);
2211
- activeDrag = null;
2212
- rotateHandleOverride = null;
3316
+ activeBoxDrag = null;
2213
3317
  dragInFlight = false;
2214
3318
  }
2215
3319
  driver.dispose();
2216
- }, wasOnGraphicsLayer ? restoreOriginalToGraphics : void 0);
2217
- renderPreview();
3320
+ }, () => {
3321
+ if (anyLiftedFromGraphics) restoreOriginalToGraphics();
3322
+ });
3323
+ renderAllPreviews();
2218
3324
  renderHandlesLayer();
2219
3325
  recomputeDirty();
2220
3326
  if (onSession) onSession(session);
@@ -2225,50 +3331,14 @@ function runEdit(opts) {
2225
3331
  get settled() {
2226
3332
  return core.settled;
2227
3333
  },
2228
- measureId: measure.id,
3334
+ get measureIds() {
3335
+ return members.map((m) => m.measure.id);
3336
+ },
2229
3337
  abort(reason, cause) {
2230
3338
  core.abort(reason, cause);
2231
- },
2232
- notifyExternalMeasure(next) {
2233
- if (core.settled) return;
2234
- if (stylesEqual(currentStyle, next.style)) return;
2235
- currentStyle = next.style;
2236
- renderPreview();
2237
3339
  }
2238
3340
  };
2239
3341
  }
2240
- function normaliseModes(modes) {
2241
- if (!modes || modes.length === 0) return ["reshape"];
2242
- for (const m of modes) {
2243
- if (m === "scale") throw new TypeError(`td.edit: edit mode "scale" is reserved and not yet implemented`);
2244
- if (!ALLOWED_EDIT_MODES.has(m)) throw new TypeError(`td.edit: unknown edit mode "${String(m)}"`);
2245
- }
2246
- if (modes.includes("delete")) return ["delete"];
2247
- const seen = /* @__PURE__ */ new Set();
2248
- const out = [];
2249
- for (const m of modes) if (!seen.has(m)) {
2250
- seen.add(m);
2251
- out.push(m);
2252
- }
2253
- return out;
2254
- }
2255
- function stylesEqual(a, b) {
2256
- if (a === b) return true;
2257
- if (!a || !b) return false;
2258
- const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
2259
- for (const key of keys) if (a[key] !== b[key]) return false;
2260
- return true;
2261
- }
2262
- function pointsEqualWithinTolerance(a, b, epsilon) {
2263
- if (a.length !== b.length) return false;
2264
- for (let i = 0; i < a.length; i++) {
2265
- const pa = a[i];
2266
- const pb = b[i];
2267
- if (pa.length !== pb.length) return false;
2268
- for (let k = 0; k < pa.length; k++) if (Math.abs((pa[k] ?? 0) - (pb[k] ?? 0)) > epsilon) return false;
2269
- }
2270
- return true;
2271
- }
2272
3342
  //#endregion
2273
3343
  //#region src/tactical-draw/errors.ts
2274
3344
  /**
@@ -2312,6 +3382,20 @@ var TacticalDraw = class {
2312
3382
  */
2313
3383
  activeEdit = null;
2314
3384
  /**
3385
+ * Tracked alongside `activeInteraction` whenever the interaction is a group
3386
+ * transform (`editMany`). The `render()` / view-change paths need every
3387
+ * member's id (to exclude all of them from reconcile) — mirrors `activeEdit`
3388
+ * but for `TransformHandle`'s `measureIds` array.
3389
+ */
3390
+ activeTransform = null;
3391
+ /**
3392
+ * The live `TransformSession`, exposed via `activeTransformSession`. Set
3393
+ * directly by `editMany`'s `onSession` wrapper (not routed through
3394
+ * `ActiveInteractionTracker.session`, whose type is `DrawSession | EditSession`)
3395
+ * and cleared on settle.
3396
+ */
3397
+ activeTransformSessionRef = null;
3398
+ /**
2315
3399
  * Host pick handlers registered via `td.onPick` / `td.onMeasurePick`. The
2316
3400
  * façade subscribes once to `adapter.onPick` per layer and routes events
2317
3401
  * through this map so that close-then-pick ordering during an active edit can
@@ -2329,6 +3413,7 @@ var TacticalDraw = class {
2329
3413
  deferredGraphicsPick = null;
2330
3414
  idCounter = 0;
2331
3415
  editCounter = 0;
3416
+ transformCounter = 0;
2332
3417
  /**
2333
3418
  * Snapshot of the host's last `render()` argument. The edit slice uses it
2334
3419
  * to recompute the graphics-layer state during a lift / restore — the
@@ -2380,12 +3465,35 @@ var TacticalDraw = class {
2380
3465
  */
2381
3466
  handleViewChange = () => {
2382
3467
  if (this.destroyed || !this.hasRendered) return;
2383
- const editedId = this.activeEdit?.measureId;
2384
- const visible = editedId !== void 0 ? this.lastRenderedMeasures.filter((m) => m.id !== editedId) : this.lastRenderedMeasures;
3468
+ const excluded = this.excludedInteractionIds();
3469
+ const visible = excluded.size > 0 ? this.lastRenderedMeasures.filter((m) => !excluded.has(m.id)) : this.lastRenderedMeasures;
2385
3470
  if (!visible.some(measureUsesAdapterResolution)) return;
2386
3471
  this.renderMeasuresToGraphics(visible);
2387
3472
  };
2388
3473
  /**
3474
+ * Every measure id currently lifted onto the preview layer by an active
3475
+ * edit or group transform — excluded from `render()` / view-change
3476
+ * reconciliation because the preview layer (not graphics) owns their
3477
+ * working copy. Empty while idle.
3478
+ */
3479
+ excludedInteractionIds() {
3480
+ const ids = /* @__PURE__ */ new Set();
3481
+ if (this.activeEdit) ids.add(this.activeEdit.measureId);
3482
+ for (const id of this.transformMemberIds() ?? []) ids.add(id);
3483
+ return ids;
3484
+ }
3485
+ /**
3486
+ * Live member ids of the active group transform, or `undefined` when none.
3487
+ * Falls back to the session ref because `onSession` fires from inside the
3488
+ * controller's attach, before `runWithSlot` has assigned `activeTransform` —
3489
+ * the session ref is registered first (`trackTransformSession`), so a
3490
+ * `render()` from inside `onSession` still sees live membership.
3491
+ */
3492
+ transformMemberIds() {
3493
+ if (this.activeTransform) return this.activeTransform.measureIds;
3494
+ return this.activeTransformSessionRef?.measures.map((m) => m.id);
3495
+ }
3496
+ /**
2389
3497
  * Reconcile the graphics layer against the authoritative list of measures.
2390
3498
  * Idempotent. Throws synchronously on duplicate measure ids *before*
2391
3499
  * mutating any layer. An empty list before any prior render is a no-op.
@@ -2424,7 +3532,10 @@ var TacticalDraw = class {
2424
3532
  externalEdited = measures.find((m) => m.id === editedId);
2425
3533
  editedRemoved = externalEdited === void 0;
2426
3534
  }
2427
- const visible = editedId !== void 0 ? measures.filter((m) => m.id !== editedId) : measures;
3535
+ const transformIds = this.transformMemberIds();
3536
+ const transformMemberRemoved = transformIds ? transformIds.some((id) => !seen.has(id)) : false;
3537
+ const excluded = this.excludedInteractionIds();
3538
+ const visible = excluded.size > 0 ? measures.filter((m) => !excluded.has(m.id)) : measures;
2428
3539
  if (!(visible.length === 0 && !this.hasRendered)) {
2429
3540
  this.renderMeasuresToGraphics(visible);
2430
3541
  this.hasRendered = true;
@@ -2437,6 +3548,16 @@ var TacticalDraw = class {
2437
3548
  editHandle.abort("removed");
2438
3549
  });
2439
3550
  }
3551
+ if (transformMemberRemoved) {
3552
+ const sessionRef = this.activeTransformSessionRef;
3553
+ queueMicrotask(() => {
3554
+ const handle = this.activeTransform;
3555
+ if (!handle || handle.settled) return;
3556
+ if (this.activeTransformSessionRef !== sessionRef) return;
3557
+ const rendered = new Set(this.lastRenderedMeasures.map((m) => m.id));
3558
+ if (handle.measureIds.some((id) => !rendered.has(id))) handle.abort("removed");
3559
+ });
3560
+ }
2440
3561
  }
2441
3562
  /**
2442
3563
  * Nested-call guard + preempt, shared by `draw` and `edit`. Returns a
@@ -2454,6 +3575,8 @@ var TacticalDraw = class {
2454
3575
  }
2455
3576
  if (this.activeInteraction.isActive) {
2456
3577
  this.activeEdit = null;
3578
+ this.activeTransform = null;
3579
+ this.activeTransformSessionRef = null;
2457
3580
  this.activeInteraction.cancel("preempted");
2458
3581
  }
2459
3582
  return null;
@@ -2469,14 +3592,17 @@ var TacticalDraw = class {
2469
3592
  * whose `onSettled` fired before `active` was assigned. `extraSettle` runs the
2470
3593
  * caller's own teardown (guide-style revert, dbl-click-zoom re-enable).
2471
3594
  */
2472
- runWithSlot(create, editHandleOf, extraSettle) {
3595
+ runWithSlot(create, activate, extraSettle) {
2473
3596
  let handle;
2474
- let editHandle = null;
3597
+ let deactivate = null;
2475
3598
  let settledDuringCreate = false;
2476
3599
  const clearSlot = () => {
2477
3600
  if (handle) {
2478
3601
  this.activeInteraction.clear(handle);
2479
- if (editHandle && this.activeEdit === editHandle) this.activeEdit = null;
3602
+ if (deactivate) {
3603
+ deactivate();
3604
+ deactivate = null;
3605
+ }
2480
3606
  } else {
2481
3607
  settledDuringCreate = true;
2482
3608
  this.activeInteraction.clear();
@@ -2489,7 +3615,6 @@ var TacticalDraw = class {
2489
3615
  this.activeInteraction.begin();
2490
3616
  try {
2491
3617
  handle = create(onSettled);
2492
- editHandle = editHandleOf ? editHandleOf(handle) : null;
2493
3618
  } catch (error) {
2494
3619
  this.activeInteraction.clear();
2495
3620
  if (!settledDuringCreate && extraSettle) extraSettle();
@@ -2497,7 +3622,7 @@ var TacticalDraw = class {
2497
3622
  }
2498
3623
  if (!settledDuringCreate) {
2499
3624
  this.activeInteraction.set(handle);
2500
- if (editHandle && !handle.settled) this.activeEdit = editHandle;
3625
+ if (activate && !handle.settled) deactivate = activate(handle) ?? null;
2501
3626
  }
2502
3627
  handle.promise.then(clearSlot, clearSlot);
2503
3628
  return handle.promise;
@@ -2513,6 +3638,18 @@ var TacticalDraw = class {
2513
3638
  };
2514
3639
  }
2515
3640
  /**
3641
+ * Wrap a host `onSession` callback for `editMany`, recording the session on
3642
+ * `activeTransformSessionRef` (exposed via `activeTransformSession`) before
3643
+ * the host's own handler runs. Separate from `trackSession` because
3644
+ * `ActiveInteractionTracker.session` is typed `DrawSession | EditSession`.
3645
+ */
3646
+ trackTransformSession(hostOnSession) {
3647
+ return (session) => {
3648
+ this.activeTransformSessionRef = session;
3649
+ hostOnSession?.(session);
3650
+ };
3651
+ }
3652
+ /**
2516
3653
  * Start an interactive draw. Fixed-length kinds commit automatically on the
2517
3654
  * last required click. Variable-length kinds surface a `DrawSession` via
2518
3655
  * `opts.onSession` and commit on `session.commit()`, `dblclick`, or a second
@@ -2539,6 +3676,7 @@ var TacticalDraw = class {
2539
3676
  adapter: this.adapter,
2540
3677
  kind,
2541
3678
  previewLayer: this.previewLayer,
3679
+ guideLayer: this.guideLayer,
2542
3680
  graphicsStyle: this.graphicsStyle,
2543
3681
  generateId: () => draft.id ?? this.generateId(),
2544
3682
  signal: options?.signal,
@@ -2578,9 +3716,7 @@ var TacticalDraw = class {
2578
3716
  */
2579
3717
  edit(measure, options) {
2580
3718
  this.assertAlive();
2581
- if (options?.modes) {
2582
- for (const m of options.modes) if (m === "scale") throw new TypeError(`td.edit: edit mode "scale" is reserved and not yet implemented`);
2583
- }
3719
+ normaliseModes(options?.modes);
2584
3720
  const preempt = this.guardAndPreempt();
2585
3721
  if (preempt) return preempt;
2586
3722
  const wasOnGraphicsLayer = this.lastRenderedMeasures.some((m) => m.id === measure.id);
@@ -2613,7 +3749,121 @@ var TacticalDraw = class {
2613
3749
  wasOnGraphicsLayer,
2614
3750
  onSettled,
2615
3751
  onClickAwayGraphicsPick
2616
- }), (handle) => handle, revertGuideStyle);
3752
+ }), (handle) => {
3753
+ this.activeEdit = handle;
3754
+ return () => {
3755
+ if (this.activeEdit === handle) this.activeEdit = null;
3756
+ };
3757
+ }, revertGuideStyle);
3758
+ }
3759
+ /**
3760
+ * Start an interactive group transform (multi-select) over `measures`
3761
+ * (ADR-0017 "Multi-select" slice). Every member is lifted onto the shared
3762
+ * preview layer; the box (corner scale, rotate grip, drag-anywhere body
3763
+ * translate) applies rigidly to all of them at once. Returns a Promise that
3764
+ * resolves with `ControlMeasureSnapshot[]` on `session.close()` and rejects
3765
+ * with `TacticalDrawAbortError` on signal / Escape / destroy / preempt /
3766
+ * `session.abort()`. Key the resolved snapshots by `snapshot.measure.id`:
3767
+ * the member set is mutable (`session.setMembers`, #187) so snapshot arrays
3768
+ * are id-keyed, not index-aligned with this input. The promise resolves with
3769
+ * the members in the set at close time; a member removed mid-session had its
3770
+ * exit snapshot returned from the `setMembers` call that removed it.
3771
+ *
3772
+ * `measures.length` must be >= 1; a single-element array behaves like an
3773
+ * `edit` with only `transform` mode. Throws `TypeError` synchronously (before
3774
+ * any preemption) for an empty array or duplicate ids.
3775
+ *
3776
+ * For a selection that changes over time (shift-click toggle, marquee
3777
+ * replace), prefer [[syncTransformMembers]] over hand-rolling the
3778
+ * live-session-vs-fresh-`editMany` branch, and subscribe to
3779
+ * `TransformSession.onCommit` as the single fold path for exit snapshots
3780
+ * and `TransformSession.onSettled` as the lifecycle signal for when the
3781
+ * session is over and why.
3782
+ */
3783
+ editMany(measures, options) {
3784
+ this.assertAlive();
3785
+ if (measures.length === 0) throw new TypeError("td.editMany: measures must not be empty");
3786
+ const seenIds = /* @__PURE__ */ new Set();
3787
+ for (const m of measures) {
3788
+ if (seenIds.has(m.id)) throw new TypeError(`td.editMany: duplicate control measure id "${m.id}"`);
3789
+ seenIds.add(m.id);
3790
+ }
3791
+ const preempt = this.guardAndPreempt();
3792
+ if (preempt) return preempt;
3793
+ const isOnGraphicsLayer = (id) => this.lastRenderedMeasures.some((m) => m.id === id);
3794
+ const restoreOriginalToGraphics = () => {
3795
+ this.renderMeasuresToGraphics(this.lastRenderedMeasures);
3796
+ };
3797
+ const handleIdPrefix = `__td_transform_${++this.transformCounter}__`;
3798
+ const mergedInteractionStyle = this.resolveInteractionStyle(options?.interactionStyle);
3799
+ const onClickAwayGraphicsPick = (event) => {
3800
+ this.dispatchHostPick(this.graphicsLayer, event);
3801
+ };
3802
+ const extraSettle = () => {
3803
+ this.activeTransformSessionRef = null;
3804
+ };
3805
+ return this.runWithSlot((onSettled) => runGroupTransform({
3806
+ adapter: this.adapter,
3807
+ measures,
3808
+ graphicsLayer: this.graphicsLayer,
3809
+ previewLayer: this.previewLayer,
3810
+ handlesLayer: this.handlesLayer,
3811
+ graphicsStyle: this.graphicsStyle,
3812
+ signal: options?.signal,
3813
+ closeOnClickAway: options?.closeOnClickAway,
3814
+ interactionStyle: mergedInteractionStyle,
3815
+ onSession: this.trackTransformSession(options?.onSession),
3816
+ handleIdPrefix,
3817
+ sizeAnchor: options?.sizeAnchor,
3818
+ restoreOriginalToGraphics,
3819
+ isOnGraphicsLayer,
3820
+ onSettled,
3821
+ onClickAwayGraphicsPick
3822
+ }), (handle) => {
3823
+ this.activeTransform = handle;
3824
+ return () => {
3825
+ if (this.activeTransform === handle) this.activeTransform = null;
3826
+ };
3827
+ }, extraSettle);
3828
+ }
3829
+ /**
3830
+ * One-call selection-sync: mutate the live group transform's member set in
3831
+ * place, or start a fresh `editMany` when no transform is live (#191). This
3832
+ * owns the "live session → `setMembers`, else → `editMany`" branch that every
3833
+ * host otherwise hand-rolls against `activeTransformSession`.
3834
+ *
3835
+ * The facade knows session liveness synchronously — `activeTransformSessionRef`
3836
+ * is cleared inside the settle path, before control returns to host code — so
3837
+ * unlike a host's own `activeTransformSession ? setMembers : editMany` check,
3838
+ * this can never observe a stale ref pointing at an already-settled session.
3839
+ * The settled-race fall-through hosts had to hand-roll for that gap is owned
3840
+ * here instead: a `setMembers` call that (implausibly) still returns
3841
+ * `undefined` falls through to a fresh `editMany` rather than silently
3842
+ * dropping the selection.
3843
+ *
3844
+ * `measures: []` closes any live session (committing every member) and is a
3845
+ * no-op while idle. With a live session, `options` is ignored — session-scoped
3846
+ * state (`sizeAnchor`, box angle, close-on-click-away policy) is sticky per
3847
+ * `setMembers`'s contract; `options` applies only when this call starts a
3848
+ * fresh `editMany`. Exit snapshots are delivered via the session's
3849
+ * `onCommit` — subscribe once in `options.onSession`. Session lifecycle —
3850
+ * including an Escape / preempt / destroy abort the host did not itself
3851
+ * initiate — is observed via the session's `onSettled` (#194); together
3852
+ * `onCommit` and `onSettled` make the underlying `editMany` promise
3853
+ * irrelevant to hosts. That promise is owned by the facade; its abort
3854
+ * rejections are swallowed here (non-abort errors surface as unhandled
3855
+ * rejections). Throws `TypeError` synchronously on duplicate ids, from
3856
+ * either path. Starting a fresh transform preempts any other in-flight
3857
+ * draw/edit exactly like `editMany`.
3858
+ */
3859
+ syncTransformMembers(measures, options) {
3860
+ this.assertAlive();
3861
+ const session = this.activeTransformSessionRef;
3862
+ if (session) {
3863
+ if (session.setMembers(measures) !== void 0) return;
3864
+ }
3865
+ if (measures.length === 0) return;
3866
+ this.editMany([...measures], options).catch(ignoreAbort);
2617
3867
  }
2618
3868
  /**
2619
3869
  * Subscribe to picks on committed control measures.
@@ -2681,6 +3931,15 @@ var TacticalDraw = class {
2681
3931
  return this.activeInteraction.session;
2682
3932
  }
2683
3933
  /**
3934
+ * The live `editMany` group-transform session, or `null` while idle (and
3935
+ * during any other interaction). Separate from `activeSession` because
3936
+ * `TransformSession` isn't part of its `DrawSession | EditSession` union —
3937
+ * see `activeTransformSessionRef`. Safe to read after `destroy()`.
3938
+ */
3939
+ get activeTransformSession() {
3940
+ return this.activeTransformSessionRef;
3941
+ }
3942
+ /**
2684
3943
  * Tear down the façade. Releases only layer slots the façade created; host
2685
3944
  * supplied layer ids are left untouched. Safe to call multiple times. After
2686
3945
  * the first call every public method throws `TacticalDrawDestroyedError`.
@@ -2690,6 +3949,8 @@ var TacticalDraw = class {
2690
3949
  this.destroyed = true;
2691
3950
  if (this.activeInteraction.isActive) {
2692
3951
  this.activeEdit = null;
3952
+ this.activeTransform = null;
3953
+ this.activeTransformSessionRef = null;
2693
3954
  this.activeInteraction.cancel("destroyed");
2694
3955
  } else this.activeInteraction.clear();
2695
3956
  this.adapter.offViewChange(this.handleViewChange);
@@ -2709,7 +3970,8 @@ var TacticalDraw = class {
2709
3970
  * from a host pick handler observes a settled session.
2710
3971
  */
2711
3972
  routePick(layerId, event) {
2712
- if (layerId === this.graphicsLayer && this.activeEdit !== null && this.activeEdit.measureId !== event.id) {
3973
+ const excluded = this.excludedInteractionIds();
3974
+ if (layerId === this.graphicsLayer && excluded.size > 0 && !excluded.has(event.id)) {
2713
3975
  this.deferredGraphicsPick = event;
2714
3976
  return;
2715
3977
  }
@@ -2748,9 +4010,19 @@ var TacticalDraw = class {
2748
4010
  return mergeSlot(BUILT_IN_INTERACTION_STYLE.guide, this.defaultInteractionStyle?.guide);
2749
4011
  }
2750
4012
  /**
2751
- * Resolve handle slots across built-in → façade → per-call with per-slot,
2752
- * per-property shallow merge. Explicit `undefined` at a higher-priority
2753
- * layer unsets the corresponding lower-priority field (ADR-0006).
4013
+ * Resolve handle slots (plus `boxOutline`) across built-in → façade →
4014
+ * per-call with per-slot, per-property shallow merge. Explicit `undefined`
4015
+ * at a higher-priority layer unsets the corresponding lower-priority field
4016
+ * (ADR-0006). `boxOutline` rides this same per-feature merge path (not the
4017
+ * `guide` layer-style path) — the edit controller reads it off
4018
+ * `properties.style` on the box outline `LineString` (ADR-0017).
4019
+ *
4020
+ * `boxPadding` is a scalar, not a mergeable style object, so it resolves
4021
+ * separately with the same façade → per-call precedence and the same
4022
+ * explicit-undefined-wins rule: a `boxPadding` key present at a
4023
+ * higher-priority layer (even set to `undefined`) wins over a lower layer's
4024
+ * value. There is no built-in `boxPadding` — an absent value at every layer
4025
+ * falls through to the controllers' own `?? 0` default.
2754
4026
  *
2755
4027
  * Returns `undefined` only when no slot would carry any value — keeps the
2756
4028
  * downstream "no style on the feature" path intact for tests that assert
@@ -2762,6 +4034,10 @@ var TacticalDraw = class {
2762
4034
  const merged = mergeSlot(mergeSlot(BUILT_IN_INTERACTION_STYLE[slot], this.defaultInteractionStyle?.[slot]), perCall?.[slot]);
2763
4035
  if (merged) out[slot] = merged;
2764
4036
  }
4037
+ const boxOutline = mergeSlot(mergeSlot(BUILT_IN_INTERACTION_STYLE.boxOutline, this.defaultInteractionStyle?.boxOutline), perCall?.boxOutline);
4038
+ if (boxOutline) out.boxOutline = boxOutline;
4039
+ const boxPadding = perCall && "boxPadding" in perCall ? perCall.boxPadding : this.defaultInteractionStyle && "boxPadding" in this.defaultInteractionStyle ? this.defaultInteractionStyle.boxPadding : void 0;
4040
+ if (typeof boxPadding === "number") out.boxPadding = boxPadding;
2765
4041
  return out;
2766
4042
  }
2767
4043
  assertAlive() {
@@ -2778,10 +4054,11 @@ const BUILT_IN_GRAPHICS_STYLE = {
2778
4054
  strokeWidth: 2
2779
4055
  };
2780
4056
  /**
2781
- * Built-in defaults for the interaction-affordance slots. The translate grip
2782
- * sits on the centroid pivot and the rotate grip just outside the shape; their
2783
- * distinct fills (blue / green) set them apart from the white vertex and gray
2784
- * midpoint handles. See ADR-0006.
4057
+ * Built-in defaults for the interaction-affordance slots (ADR-0006, extended
4058
+ * for the ADR-0017 transform box). The scale (corner) grips and the rotate
4059
+ * grip are white-filled with the same blue stroke as the box outline, so the
4060
+ * box reads as one coherent affordance. Vertex/midpoint keep their existing
4061
+ * white/gray family.
2785
4062
  */
2786
4063
  const BUILT_IN_INTERACTION_STYLE = {
2787
4064
  guide: {
@@ -2789,6 +4066,10 @@ const BUILT_IN_INTERACTION_STYLE = {
2789
4066
  strokeWidth: 1,
2790
4067
  strokeDash: [6, 4]
2791
4068
  },
4069
+ boxOutline: {
4070
+ strokeColor: "#4f7cff",
4071
+ strokeWidth: 1
4072
+ },
2792
4073
  vertexHandle: {
2793
4074
  pointRadius: 6,
2794
4075
  fillColor: "#ffffff",
@@ -2801,21 +4082,23 @@ const BUILT_IN_INTERACTION_STYLE = {
2801
4082
  strokeColor: "#6b7280",
2802
4083
  strokeWidth: 1.5
2803
4084
  },
2804
- translateHandle: {
2805
- pointRadius: 7,
2806
- fillColor: "#2563eb",
2807
- strokeColor: "#ffffff"
4085
+ scaleHandle: {
4086
+ pointRadius: 5,
4087
+ fillColor: "#ffffff",
4088
+ strokeColor: "#4f7cff",
4089
+ strokeWidth: 2
2808
4090
  },
2809
4091
  rotateHandle: {
2810
- pointRadius: 7,
2811
- fillColor: "#16a34a",
2812
- strokeColor: "#ffffff"
4092
+ pointRadius: 5,
4093
+ fillColor: "#ffffff",
4094
+ strokeColor: "#4f7cff",
4095
+ strokeWidth: 2
2813
4096
  }
2814
4097
  };
2815
4098
  const HANDLE_SLOTS = [
2816
4099
  "vertexHandle",
2817
4100
  "midpointHandle",
2818
- "translateHandle",
4101
+ "scaleHandle",
2819
4102
  "rotateHandle"
2820
4103
  ];
2821
4104
  /**
@@ -2860,4 +4143,22 @@ function resolveSlot(adapter, supplied, owned) {
2860
4143
  return id;
2861
4144
  }
2862
4145
  //#endregion
2863
- export { BaseMapAdapter, PointerCallbackHub, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, TacticalDrawDestroyedError, bindAbortable, coerceHandleStyle, combineWithHostSignal, getSizeAnchor, hitTestFeature, ignoreAbort, isTacticalDrawAbortError, pickHitTolerance, tryControlMeasureIdFromFeature };
4146
+ //#region src/world-wrap.ts
4147
+ /**
4148
+ * Shift `lng` by whole world widths (360°) onto the copy nearest `refLng`.
4149
+ *
4150
+ * Map engines that wrap pointer longitudes into [-180, 180] (OpenLayers'
4151
+ * `toLonLat`, MapLibre's globe unproject) make positions jump ~360° when a
4152
+ * gesture crosses the antimeridian. Adapters use this to keep delivered
4153
+ * longitudes continuous with the view center, and to project coordinates on
4154
+ * the world copy the view is actually looking at.
4155
+ */
4156
+ function nearestWorldCopyLng(lng, refLng) {
4157
+ return lng + 360 * Math.round((refLng - lng) / 360);
4158
+ }
4159
+ /** `nearestWorldCopyLng` applied to a lon/lat position. */
4160
+ function nearestWorldCopyPosition(position, refLng) {
4161
+ return [nearestWorldCopyLng(position[0], refLng), ...position.slice(1)];
4162
+ }
4163
+ //#endregion
4164
+ export { BaseMapAdapter, PointerCallbackHub, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, TacticalDrawDestroyedError, bindAbortable, coerceHandleStyle, combineWithHostSignal, getSizeAnchor, hitTestFeature, ignoreAbort, isTacticalDrawAbortError, nearestWorldCopyLng, nearestWorldCopyPosition, pickHitTolerance, tryControlMeasureIdFromFeature };