@orbat-mapper/tactical-draw 0.2.0-alpha.3 → 0.2.0-alpha.5

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 +249 -25
  2. package/dist/index.mjs +1383 -351
  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.
@@ -773,6 +773,82 @@ function roundControlPoints(points) {
773
773
  return points.map(roundControlPoint);
774
774
  }
775
775
  //#endregion
776
+ //#region src/tactical-draw/guide.ts
777
+ /**
778
+ * Stable preview-layer ids and emission rules for the rubber-band guide
779
+ * (ADR-0004). Shared between `td.draw` and `td.edit` so hosts can apply a
780
+ * single styling rule across both interactions.
781
+ */
782
+ const PREVIEW_GUIDE_FEATURE_PREFIX = "__td_preview__";
783
+ const PREVIEW_GUIDE_OPEN_ID = `${PREVIEW_GUIDE_FEATURE_PREFIX}:guide:open`;
784
+ const PREVIEW_GUIDE_CLOSING_ID = `${PREVIEW_GUIDE_FEATURE_PREFIX}:guide:closing`;
785
+ /**
786
+ * A kind is fixed-length when its draw rule pins the slot count
787
+ * (`canonicalPointCount`) or its metadata declares `min === max`. The
788
+ * generator preview is already authoritative for these, so the rubber-band
789
+ * guide adds no information.
790
+ */
791
+ function isFixedLength(metadata) {
792
+ if (metadata.rule?.canonicalPointCount !== void 0) return true;
793
+ const min = metadata.minCoordinates;
794
+ const max = metadata.maxCoordinates;
795
+ return typeof min === "number" && typeof max === "number" && min === max;
796
+ }
797
+ /**
798
+ * Guide is mechanically suppressed for kinds where it carries no information:
799
+ * point kinds and fixed-length kinds. Independent of the host's `guide`
800
+ * opt-out.
801
+ */
802
+ function shouldSuppressGuide(metadata) {
803
+ if (metadata.geometry === "point") return true;
804
+ if (metadata.rule?.showGuide === true) return false;
805
+ return isFixedLength(metadata);
806
+ }
807
+ /**
808
+ * Build the guide LineString features for a point list.
809
+ * - Open polyline through `points` once `points.length >= 2`.
810
+ * - Closing segment `[last, first]` for area kinds once `points.length >= 3`.
811
+ *
812
+ * Callers are responsible for the opt-out (`guide: false`) and the suppression
813
+ * check via [[shouldSuppressGuide]] — this function only emits geometry.
814
+ */
815
+ function buildGuideFeatures(points, isAreaKind, trailingFixedSlots = 0) {
816
+ const features = [];
817
+ const spine = trailingFixedSlots > 0 ? points.slice(0, points.length - trailingFixedSlots) : points;
818
+ if (spine.length >= 2) {
819
+ const open = {
820
+ type: "Feature",
821
+ id: PREVIEW_GUIDE_OPEN_ID,
822
+ geometry: {
823
+ type: "LineString",
824
+ coordinates: spine.slice()
825
+ },
826
+ properties: {
827
+ part: "guide",
828
+ segment: "open"
829
+ }
830
+ };
831
+ features.push(open);
832
+ }
833
+ const alreadyClosed = spine.length >= 2 && spine[0][0] === spine[spine.length - 1][0] && spine[0][1] === spine[spine.length - 1][1];
834
+ if (isAreaKind && spine.length >= 3 && !alreadyClosed) {
835
+ const closing = {
836
+ type: "Feature",
837
+ id: PREVIEW_GUIDE_CLOSING_ID,
838
+ geometry: {
839
+ type: "LineString",
840
+ coordinates: [spine[spine.length - 1], spine[0]]
841
+ },
842
+ properties: {
843
+ part: "guide",
844
+ segment: "closing"
845
+ }
846
+ };
847
+ features.push(closing);
848
+ }
849
+ return features;
850
+ }
851
+ //#endregion
776
852
  //#region src/tactical-draw/draw-controller.ts
777
853
  const PREVIEW_CM_ID$1 = "__td_preview__";
778
854
  const PREVIEW_CURSOR$1 = "crosshair";
@@ -790,7 +866,7 @@ const PREVIEW_CURSOR$1 = "crosshair";
790
866
  * kinds need no host UI.
791
867
  */
792
868
  function runFixedLengthDraw(options) {
793
- const { adapter, kind, previewLayer, graphicsStyle, generateId, signal, measureOptions, measureStyle, measureProperties, sizeAnchor, onSettled } = options;
869
+ const { adapter, kind, previewLayer, guideLayer, graphicsStyle, generateId, signal, measureOptions, measureStyle, measureProperties, guide: guideOpt, sizeAnchor, onSettled } = options;
794
870
  const metadata = getControlMeasureMetadata(kind);
795
871
  const rule = metadata.rule;
796
872
  const min = metadata.minCoordinates;
@@ -800,6 +876,8 @@ function runFixedLengthDraw(options) {
800
876
  else if (typeof min === "number" && typeof max === "number" && min === max) required = min;
801
877
  else throw new Error(`runFixedLengthDraw: kind "${kind}" is not fixed-length (min=${min}, max=${max})`);
802
878
  const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
879
+ const guideEnabled = (guideOpt ?? true) && !shouldSuppressGuide(metadata);
880
+ const isAreaKind = metadata.geometry === "area";
803
881
  const committed = [];
804
882
  let rubberBand;
805
883
  let ctx;
@@ -824,11 +902,15 @@ function runFixedLengthDraw(options) {
824
902
  const raw = rubberBand !== void 0 ? [...committed, rubberBand] : [...committed];
825
903
  if (raw.length === 0) {
826
904
  ctx.writeLayer(previewLayer, []);
905
+ ctx.writeLayer(guideLayer, []);
827
906
  return;
828
907
  }
829
908
  const previewThreshold = rule?.minimumPreviewPoints ?? required;
830
909
  const features = buildPreviewFeatures$1(adapter, kind, rule && raw.length >= previewThreshold ? rule.derive(raw) : raw, measureOptions, measureStyle, renderOpts);
831
910
  ctx.writeLayer(previewLayer, features);
911
+ const guidePoints = rule?.guidePoints ? rule.guidePoints(raw) : raw;
912
+ const guideFeatures = guideEnabled ? buildGuideFeatures(guidePoints, isAreaKind) : [];
913
+ ctx.writeLayer(guideLayer, guideFeatures);
832
914
  }
833
915
  function rescaleOnViewChange() {
834
916
  if (!measureUsesAdapterResolution({
@@ -856,7 +938,7 @@ function runFixedLengthDraw(options) {
856
938
  return runInteraction({
857
939
  adapter,
858
940
  signal,
859
- layers: [previewLayer],
941
+ layers: [previewLayer, guideLayer],
860
942
  onViewChange: rescaleOnViewChange,
861
943
  onSettled,
862
944
  attach(c) {
@@ -915,79 +997,6 @@ function outlinePreview$1(points) {
915
997
  return features;
916
998
  }
917
999
  //#endregion
918
- //#region src/tactical-draw/guide.ts
919
- /**
920
- * Stable preview-layer ids and emission rules for the rubber-band guide
921
- * (ADR-0004). Shared between `td.draw` and `td.edit` so hosts can apply a
922
- * single styling rule across both interactions.
923
- */
924
- const PREVIEW_GUIDE_FEATURE_PREFIX = "__td_preview__";
925
- const PREVIEW_GUIDE_OPEN_ID = `${PREVIEW_GUIDE_FEATURE_PREFIX}:guide:open`;
926
- const PREVIEW_GUIDE_CLOSING_ID = `${PREVIEW_GUIDE_FEATURE_PREFIX}:guide:closing`;
927
- /**
928
- * A kind is fixed-length when its draw rule pins the slot count
929
- * (`canonicalPointCount`) or its metadata declares `min === max`. The
930
- * generator preview is already authoritative for these, so the rubber-band
931
- * guide adds no information.
932
- */
933
- function isFixedLength(metadata) {
934
- if (metadata.rule?.canonicalPointCount !== void 0) return true;
935
- const min = metadata.minCoordinates;
936
- const max = metadata.maxCoordinates;
937
- return typeof min === "number" && typeof max === "number" && min === max;
938
- }
939
- /**
940
- * Guide is mechanically suppressed for kinds where it carries no information:
941
- * point kinds and fixed-length kinds. Independent of the host's `guide`
942
- * opt-out.
943
- */
944
- function shouldSuppressGuide(metadata) {
945
- return metadata.geometry === "point" || isFixedLength(metadata);
946
- }
947
- /**
948
- * Build the guide LineString features for a point list.
949
- * - Open polyline through `points` once `points.length >= 2`.
950
- * - Closing segment `[last, first]` for area kinds once `points.length >= 3`.
951
- *
952
- * Callers are responsible for the opt-out (`guide: false`) and the suppression
953
- * check via [[shouldSuppressGuide]] — this function only emits geometry.
954
- */
955
- function buildGuideFeatures(points, isAreaKind, trailingFixedSlots = 0) {
956
- const features = [];
957
- const spine = trailingFixedSlots > 0 ? points.slice(0, points.length - trailingFixedSlots) : points;
958
- if (spine.length >= 2) {
959
- const open = {
960
- type: "Feature",
961
- id: PREVIEW_GUIDE_OPEN_ID,
962
- geometry: {
963
- type: "LineString",
964
- coordinates: spine.slice()
965
- },
966
- properties: {
967
- part: "guide",
968
- segment: "open"
969
- }
970
- };
971
- features.push(open);
972
- }
973
- if (isAreaKind && spine.length >= 3) {
974
- const closing = {
975
- type: "Feature",
976
- id: PREVIEW_GUIDE_CLOSING_ID,
977
- geometry: {
978
- type: "LineString",
979
- coordinates: [spine[spine.length - 1], spine[0]]
980
- },
981
- properties: {
982
- part: "guide",
983
- segment: "closing"
984
- }
985
- };
986
- features.push(closing);
987
- }
988
- return features;
989
- }
990
- //#endregion
991
1000
  //#region src/tactical-draw/draw-controller-variable.ts
992
1001
  const PREVIEW_CM_ID = "__td_preview__";
993
1002
  const PREVIEW_CURSOR = "crosshair";
@@ -1006,6 +1015,7 @@ function runVariableLengthDraw(options) {
1006
1015
  const max = metadata.maxCoordinates;
1007
1016
  const rule = metadata.rule;
1008
1017
  const trailingFixed = rule?.trailingFixedSlots ?? 0;
1018
+ const closedRing = rule?.closedRing ?? false;
1009
1019
  const ruleMin = rule?.minimumUserPoints;
1010
1020
  const guideEnabled = (guideOpt ?? true) && !shouldSuppressGuide(metadata);
1011
1021
  const isAreaKind = metadata.geometry === "area";
@@ -1097,20 +1107,20 @@ function runVariableLengthDraw(options) {
1097
1107
  if (ctx.settled) return;
1098
1108
  const pos = roundControlPoint(event.coordinate);
1099
1109
  const lastSpineIdx = committed.length - 1 - trailingFixed;
1110
+ const finishIndices = closedRing && lastSpineIdx > 0 ? [lastSpineIdx, 0] : [lastSpineIdx];
1100
1111
  for (let i = 0; i < committed.length; i++) if (positionsEqual(pos, committed[i])) {
1101
- if (i === lastSpineIdx && canCommitNow()) ctx.commit(buildSnapshot);
1112
+ if (finishIndices.includes(i) && canCommitNow()) ctx.commit(buildSnapshot);
1102
1113
  return;
1103
1114
  }
1104
- if (lastSpineIdx >= 0 && canCommitNow()) {
1105
- const lastPixel = adapter.getPixelFromCoordinate(committed[lastSpineIdx]);
1106
- if (lastPixel) {
1107
- const tol = mapEventHitTolerance(event.originalEvent);
1108
- const dx = event.pixel[0] - lastPixel[0];
1109
- const dy = event.pixel[1] - lastPixel[1];
1110
- if (dx * dx + dy * dy <= tol * tol) {
1111
- ctx.commit(buildSnapshot);
1112
- return;
1113
- }
1115
+ if (lastSpineIdx >= 0 && canCommitNow()) for (const finishIndex of finishIndices) {
1116
+ const finishPixel = adapter.getPixelFromCoordinate(committed[finishIndex]);
1117
+ if (!finishPixel) continue;
1118
+ const tol = mapEventHitTolerance(event.originalEvent);
1119
+ const dx = event.pixel[0] - finishPixel[0];
1120
+ const dy = event.pixel[1] - finishPixel[1];
1121
+ if (dx * dx + dy * dy <= tol * tol) {
1122
+ ctx.commit(buildSnapshot);
1123
+ return;
1114
1124
  }
1115
1125
  }
1116
1126
  if (typeof max === "number" && committed.length >= max) return;
@@ -1250,63 +1260,14 @@ function outlinePreview(points) {
1250
1260
  //#endregion
1251
1261
  //#region src/tactical-draw/handles.ts
1252
1262
  /**
1253
- * Compute the anchor used by translate and rotate handles. The anchor is the
1254
- * centroid (arithmetic mean) of the control-point list — stable, kind
1255
- * agnostic, and well-defined for any non-empty point set.
1256
- */
1257
- function computeHandleAnchor(controlPoints) {
1258
- if (controlPoints.length === 0) return null;
1259
- let sx = 0;
1260
- let sy = 0;
1261
- for (const p of controlPoints) {
1262
- sx += p[0];
1263
- sy += p[1];
1264
- }
1265
- return [sx / controlPoints.length, sy / controlPoints.length];
1266
- }
1267
- /**
1268
- * How far past the outermost control point the rotate grip sits. `1.3` places
1269
- * it 30% beyond the farthest vertex, clearing the geometry while staying
1270
- * proportional to the shape's size.
1271
- */
1272
- const ROTATE_HANDLE_REACH_FACTOR = 1.3;
1273
- /**
1274
- * Position of the rotate grip. Unlike the translate handle — which sits on the
1275
- * centroid pivot — the rotate handle needs a lever arm, so it is offset from
1276
- * the centroid along the ray toward the control point farthest from it, just
1277
- * outside the shape. Anchoring to a *material* point (rather than the
1278
- * axis-aligned bbox top) keeps this stateless yet glued to the geometry: the
1279
- * grip orbits with the shape as it rotates, since the farthest vertex rotates
1280
- * with it. Falls back to the centroid when every point coincides (no
1281
- * well-defined direction) or the list is empty.
1282
- */
1283
- function computeRotateHandlePosition(controlPoints) {
1284
- const anchor = computeHandleAnchor(controlPoints);
1285
- if (!anchor) return null;
1286
- let farthest = null;
1287
- let maxDistSq = 0;
1288
- for (const p of controlPoints) {
1289
- const dx = p[0] - anchor[0];
1290
- const dy = p[1] - anchor[1];
1291
- const distSq = dx * dx + dy * dy;
1292
- if (distSq > maxDistSq) {
1293
- maxDistSq = distSq;
1294
- farthest = p;
1295
- }
1296
- }
1297
- if (!farthest) return anchor;
1298
- return [anchor[0] + (farthest[0] - anchor[0]) * ROTATE_HANDLE_REACH_FACTOR, anchor[1] + (farthest[1] - anchor[1]) * ROTATE_HANDLE_REACH_FACTOR];
1299
- }
1300
- /**
1301
1263
  * Axis-aligned bounding-box center of the control points — the midpoint of
1302
- * `[minX, maxX] × [minY, maxY]`. Used as the rotation pivot so a shape turns
1303
- * about its visual middle (matching mainstream drawing tools) rather than the
1304
- * vertex mean (`computeHandleAnchor`), which is pulled off-center on asymmetric
1305
- * measures. Returns `null` for an empty list.
1264
+ * `[minX, maxX] × [minY, maxY]`. Used as the fallback rotation pivot for the
1265
+ * `COMMIT_ROTATE_FOR_TEST` seam when no transform box exists yet (e.g. before
1266
+ * the first paint), matching mainstream drawing tools' "turn about the visual
1267
+ * middle" convention. Returns `null` for an empty list.
1306
1268
  *
1307
- * Exported because the rotation pivot is *not* a handle: it is the rotation
1308
- * center, distinct from the rotate grip's lever-arm position. The edit
1309
- * controller computes it directly at drag-start. See ADR-0016.
1269
+ * Exported because the rotation pivot is *not* a handle — the edit controller
1270
+ * consults it directly. See ADR-0016/ADR-0017.
1310
1271
  */
1311
1272
  function computeBoundingBoxCenter(controlPoints) {
1312
1273
  if (controlPoints.length === 0) return null;
@@ -1332,26 +1293,26 @@ function segmentMidpoint(a, b) {
1332
1293
  return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
1333
1294
  }
1334
1295
  /**
1335
- * The single source of truth for edit-handle placement and the
1336
- * `EditMode → which handles` gating (ADR-0016). Pure and projection-free:
1337
- * positions are lonLat. Handles are emitted in **pick priority order** —
1338
- * `[vertices…, midpoints…, translate, rotate]` — so `pickHandle` can return the
1339
- * first within tolerance and preserve "vertex wins over a pixel-closer
1296
+ * The single source of truth for reshape-handle placement and the
1297
+ * `EditMode → which handles` gating (ADR-0016, narrowed by ADR-0017). Pure and
1298
+ * projection-free: positions are lonLat. Handles are emitted in **pick
1299
+ * priority order** — `[vertices…, midpoints…]` — so `pickHandle` can return
1300
+ * the first within tolerance and preserve "vertex wins over a pixel-closer
1340
1301
  * midpoint." Rendering (`toFeatures`) and picking (`pickHandle`) are thin
1341
1302
  * projections of this set; the drag controller seeds gestures from it too.
1342
1303
  *
1343
1304
  * Emits handles only for modes present in `modes`:
1344
1305
  * - `reshape`: vertex handle at every control point + midpoint handle on
1345
1306
  * every adjacent pair (subject to `includeMidpoints` / `trailingFixedSlots`);
1346
- * - `delete`: vertex handles only (tapped to remove), no midpoint/grips;
1347
- * - `translate`: a single translate handle at the centroid anchor;
1348
- * - `rotate`: a single rotate grip offset outside the shape.
1307
+ * - `delete`: vertex handles only (tapped to remove), no midpoints.
1349
1308
  *
1350
- * `scale` is a reserved enum value rejected at the façade boundary — no handles
1351
- * are emitted for it here. Returns `[]` for an empty control-point list.
1309
+ * `transform` mode never contributes handles here — the box's corner/rotate
1310
+ * grips are pixel-space and placed by `transform-box.ts`, then rendered as
1311
+ * `Handle`s of kind `scale`/`rotate` by the edit controller. Returns `[]` for
1312
+ * an empty control-point list.
1352
1313
  */
1353
1314
  function buildHandleSet(controlPoints, modes, options = {}) {
1354
- const { includeMidpoints = true, trailingFixedSlots = 0 } = options;
1315
+ const { includeMidpoints = true, trailingFixedSlots = 0, closedRing = false } = options;
1355
1316
  const handles = [];
1356
1317
  const wantsReshape = modes.includes("reshape");
1357
1318
  if (wantsReshape || modes.includes("delete")) {
@@ -1361,37 +1322,24 @@ function buildHandleSet(controlPoints, modes, options = {}) {
1361
1322
  position: [...controlPoints[i]]
1362
1323
  });
1363
1324
  if (wantsReshape && includeMidpoints) {
1364
- const midpointLimit = controlPoints.length - 1 - trailingFixedSlots;
1365
- for (let i = 0; i < midpointLimit; i++) handles.push({
1366
- kind: "midpoint",
1367
- index: i,
1368
- position: segmentMidpoint(controlPoints[i], controlPoints[i + 1])
1369
- });
1325
+ const edgeCount = closedRing && trailingFixedSlots === 0 && controlPoints.length >= 3 ? controlPoints.length : controlPoints.length - 1 - trailingFixedSlots;
1326
+ for (let i = 0; i < edgeCount; i++) {
1327
+ const end = (i + 1) % controlPoints.length;
1328
+ handles.push({
1329
+ kind: "midpoint",
1330
+ index: i,
1331
+ position: segmentMidpoint(controlPoints[i], controlPoints[end])
1332
+ });
1333
+ }
1370
1334
  }
1371
1335
  }
1372
- if (modes.includes("translate")) {
1373
- const anchor = computeHandleAnchor(controlPoints);
1374
- if (anchor) handles.push({
1375
- kind: "translate",
1376
- index: 0,
1377
- position: anchor
1378
- });
1379
- }
1380
- if (modes.includes("rotate")) {
1381
- const rotatePos = computeRotateHandlePosition(controlPoints);
1382
- if (rotatePos) handles.push({
1383
- kind: "rotate",
1384
- index: 0,
1385
- position: rotatePos
1386
- });
1387
- }
1388
1336
  return handles;
1389
1337
  }
1390
1338
  function handleFeatureId(prefix, kind, index) {
1391
1339
  switch (kind) {
1392
1340
  case "vertex": return `${prefix}:handle-vertex:${index}`;
1393
1341
  case "midpoint": return `${prefix}:handle-midpoint:${index}`;
1394
- case "translate": return `${prefix}:handle-translate`;
1342
+ case "scale": return `${prefix}:handle-scale:${index}`;
1395
1343
  case "rotate": return `${prefix}:handle-rotate`;
1396
1344
  }
1397
1345
  }
@@ -1399,16 +1347,18 @@ function handleStyleFor(kind, interactionStyle) {
1399
1347
  switch (kind) {
1400
1348
  case "vertex": return interactionStyle?.vertexHandle;
1401
1349
  case "midpoint": return interactionStyle?.midpointHandle;
1402
- case "translate": return interactionStyle?.translateHandle;
1350
+ case "scale": return interactionStyle?.scaleHandle;
1403
1351
  case "rotate": return interactionStyle?.rotateHandle;
1404
1352
  }
1405
1353
  }
1406
1354
  /**
1407
1355
  * Project a handle set into the GeoJSON features that belong on the handles
1408
- * layer — the render projection of `buildHandleSet` (ADR-0016). Stable
1409
- * `featureIdPrefix`-namespaced ids let multiple edits share a handles layer
1410
- * without colliding; per-slot `interactionStyle` rides on `properties.style`.
1411
- * Pure: handle positions are copied, never aliased into the output geometry.
1356
+ * layer — the render projection of `buildHandleSet` (ADR-0016), also reused by
1357
+ * the edit controller to render the transform box's corner/rotate `Handle`s
1358
+ * (ADR-0017). Stable `featureIdPrefix`-namespaced ids let multiple edits share
1359
+ * a handles layer without colliding; per-slot `interactionStyle` rides on
1360
+ * `properties.style`. Pure: handle positions are copied, never aliased into
1361
+ * the output geometry.
1412
1362
  */
1413
1363
  function toFeatures(handles, featureIdPrefix, interactionStyle) {
1414
1364
  return handles.map((handle) => {
@@ -1431,26 +1381,142 @@ function toFeatures(handles, featureIdPrefix, interactionStyle) {
1431
1381
  };
1432
1382
  });
1433
1383
  }
1434
- function pixelDistance(a, b) {
1384
+ function pixelDistance$2(a, b) {
1435
1385
  return Math.hypot(a[0] - b[0], a[1] - b[1]);
1436
1386
  }
1437
1387
  /**
1438
1388
  * The pick projection of `buildHandleSet` (ADR-0016): the first handle within
1439
1389
  * `tolerance` pixels of `pixel`, tested in the set's priority order. Because
1440
- * `buildHandleSet` orders vertices before midpoints before translate before
1441
- * rotate, "first within tolerance" preserves the legacy "vertex wins over a
1442
- * pixel-closer midpoint" behaviour. Returns the full `Handle` so the drag
1443
- * controller can seed a gesture from its `position` without recomputing
1444
- * placement. Handles whose position fails to project (off-screen / degenerate
1445
- * projection) are skipped. Returns `null` when nothing is within tolerance.
1390
+ * `buildHandleSet` orders vertices before midpoints, "first within tolerance"
1391
+ * preserves the legacy "vertex wins over a pixel-closer midpoint" behaviour.
1392
+ * Returns the full `Handle` so the drag controller can seed a gesture from its
1393
+ * `position` without recomputing placement. Handles whose position fails to
1394
+ * project (off-screen / degenerate projection) are skipped. Returns `null`
1395
+ * when nothing is within tolerance.
1446
1396
  */
1447
1397
  function pickHandle(handles, pixel, tolerance, toPixel) {
1448
1398
  for (const handle of handles) {
1449
1399
  const projected = toPixel(handle.position);
1450
- if (projected && pixelDistance(projected, pixel) <= tolerance) return handle;
1400
+ if (projected && pixelDistance$2(projected, pixel) <= tolerance) return handle;
1451
1401
  }
1452
1402
  return null;
1453
1403
  }
1404
+ function rotate(x, y, angle) {
1405
+ const cos = Math.cos(angle);
1406
+ const sin = Math.sin(angle);
1407
+ return [x * cos - y * sin, x * sin + y * cos];
1408
+ }
1409
+ /**
1410
+ * Fit an oriented `TransformBox` around `pixels` at a caller-chosen `angle`.
1411
+ * Rotates the points by `-angle` about their centroid, takes the axis-aligned
1412
+ * bounding box in that de-rotated frame, then re-expresses the result as an
1413
+ * oriented box at `angle` — so the box always has exactly the requested tilt,
1414
+ * sized to just contain the (de-rotated) points. Half-dimensions are floored
1415
+ * at `MIN_HALF_SIZE_PX` so a degenerate selection still yields a drawable,
1416
+ * pickable box. Returns `null` for an empty list — there is nothing to fit.
1417
+ *
1418
+ * `padding` (pixels, default `0`) is added to `halfWidth`/`halfHeight` *after*
1419
+ * the `MIN_HALF_SIZE_PX` floor, on every side, without moving `center` — the
1420
+ * `InteractionStyle.boxPadding` knob (ADR-0017) so the box doesn't tightly hug
1421
+ * the rendered geometry. Everything downstream that reads a fitted box
1422
+ * (`boxCorners`, `rotateGripPixel`, `pointInBox`, `pickBoxHandle`) derives from
1423
+ * `halfWidth`/`halfHeight` alone, so the padding flows through to corner
1424
+ * placement, the rotate grip, and picking automatically — no other function
1425
+ * needs to know about it.
1426
+ */
1427
+ function fitTransformBox(pixels, angle, padding = 0) {
1428
+ if (pixels.length === 0) return null;
1429
+ let cx = 0;
1430
+ let cy = 0;
1431
+ for (const p of pixels) {
1432
+ cx += p[0];
1433
+ cy += p[1];
1434
+ }
1435
+ cx /= pixels.length;
1436
+ cy /= pixels.length;
1437
+ let minX = Infinity;
1438
+ let minY = Infinity;
1439
+ let maxX = -Infinity;
1440
+ let maxY = -Infinity;
1441
+ for (const p of pixels) {
1442
+ const [rx, ry] = rotate(p[0] - cx, p[1] - cy, -angle);
1443
+ if (rx < minX) minX = rx;
1444
+ if (rx > maxX) maxX = rx;
1445
+ if (ry < minY) minY = ry;
1446
+ if (ry > maxY) maxY = ry;
1447
+ }
1448
+ const localCenterX = (minX + maxX) / 2;
1449
+ const localCenterY = (minY + maxY) / 2;
1450
+ const halfWidth = Math.max((maxX - minX) / 2, 1) + padding;
1451
+ const halfHeight = Math.max((maxY - minY) / 2, 1) + padding;
1452
+ const [ox, oy] = rotate(localCenterX, localCenterY, angle);
1453
+ return {
1454
+ center: [cx + ox, cy + oy],
1455
+ halfWidth,
1456
+ halfHeight,
1457
+ angle
1458
+ };
1459
+ }
1460
+ /**
1461
+ * The box's 4 corners in screen pixels: `[topLeft, topRight, bottomRight,
1462
+ * bottomLeft]` in the box's local (pre-rotation) frame, then rotated by
1463
+ * `box.angle` about `box.center`. Corner index is stable — `pickBoxHandle`
1464
+ * and the scale-drag transition (`handle-drag.ts`) key off it, and the scale
1465
+ * anchor is always the opposite corner, `(index + 2) % 4`.
1466
+ */
1467
+ function boxCorners(box) {
1468
+ const { center, halfWidth, halfHeight, angle } = box;
1469
+ return [
1470
+ [-halfWidth, -halfHeight],
1471
+ [halfWidth, -halfHeight],
1472
+ [halfWidth, halfHeight],
1473
+ [-halfWidth, halfHeight]
1474
+ ].map(([x, y]) => {
1475
+ const [rx, ry] = rotate(x, y, angle);
1476
+ return [center[0] + rx, center[1] + ry];
1477
+ });
1478
+ }
1479
+ /**
1480
+ * Where the rotate grip sits: `ROTATE_GRIP_OFFSET_PX` above the top-edge
1481
+ * midpoint, along the box's local "up" direction, so the grip stays glued to
1482
+ * the box (rotates with it) rather than floating at a fixed screen offset.
1483
+ */
1484
+ function rotateGripPixel(box) {
1485
+ const { center, halfHeight, angle } = box;
1486
+ const [rx, ry] = rotate(0, -(halfHeight + 28), angle);
1487
+ return [center[0] + rx, center[1] + ry];
1488
+ }
1489
+ /**
1490
+ * Point-in-oriented-rectangle test, used to start a body (translate) drag
1491
+ * when the pointer goes down inside the box but not on a corner/rotate grip.
1492
+ * De-rotates `pixel` into the box's local frame about `box.center` and
1493
+ * compares against the half-extents.
1494
+ */
1495
+ function pointInBox(box, pixel) {
1496
+ const { center, halfWidth, halfHeight, angle } = box;
1497
+ const [lx, ly] = rotate(pixel[0] - center[0], pixel[1] - center[1], -angle);
1498
+ return Math.abs(lx) <= halfWidth && Math.abs(ly) <= halfHeight;
1499
+ }
1500
+ function pixelDistance$1(a, b) {
1501
+ return Math.hypot(a[0] - b[0], a[1] - b[1]);
1502
+ }
1503
+ /**
1504
+ * Pick what part of the box, if any, `pixel` hits — corners first, then the
1505
+ * rotate grip, then plain containment (body drag). Corners and the rotate
1506
+ * grip use `tolerance` pixels; body containment is exact (`pointInBox`), so a
1507
+ * click just outside the box never starts a body drag. Returns `null` when
1508
+ * nothing matches.
1509
+ */
1510
+ function pickBoxHandle(box, pixel, tolerance) {
1511
+ const corners = boxCorners(box);
1512
+ for (let i = 0; i < corners.length; i++) if (pixelDistance$1(corners[i], pixel) <= tolerance) return {
1513
+ kind: "scale",
1514
+ index: i
1515
+ };
1516
+ if (pixelDistance$1(rotateGripPixel(box), pixel) <= tolerance) return { kind: "rotate" };
1517
+ if (pointInBox(box, pixel)) return { kind: "body" };
1518
+ return null;
1519
+ }
1454
1520
  //#endregion
1455
1521
  //#region src/tactical-draw/handle-drag.ts
1456
1522
  function clonePoints(points) {
@@ -1464,17 +1530,8 @@ function translatePoints(base, dx, dy) {
1464
1530
  return out;
1465
1531
  });
1466
1532
  }
1467
- function angleFromAnchor(anchor, target) {
1468
- return Math.atan2(target[1] - anchor[1], target[0] - anchor[0]);
1469
- }
1470
- /**
1471
- * Angle of `pixel` around the rotation pivot, measured in screen space so it
1472
- * matches what the user sees and does. Falls back to lon/lat when the pivot
1473
- * cannot be projected — exceedingly rare for an on-screen edit.
1474
- */
1475
- function rotateAngleAt(pivot, pivotPixel, pixel, projection) {
1476
- if (pivotPixel) return Math.atan2(pixel[1] - pivotPixel[1], pixel[0] - pivotPixel[0]);
1477
- return angleFromAnchor(pivot, projection.fromPixel(pixel));
1533
+ function pixelDistance(a, b) {
1534
+ return Math.hypot(a[0] - b[0], a[1] - b[1]);
1478
1535
  }
1479
1536
  /**
1480
1537
  * Rotate `points` by `angle` about the pivot in screen space — project each
@@ -1532,7 +1589,7 @@ function begun(kind, initialBase, move) {
1532
1589
  * caller's working copy.
1533
1590
  */
1534
1591
  function beginHandleDrag(args) {
1535
- const { hit, downPixel, points, projection, rule } = args;
1592
+ const { hit, points, projection, rule } = args;
1536
1593
  const { fromPixel } = projection;
1537
1594
  const preDragPoints = clonePoints(points);
1538
1595
  const snap = (next, activePointIndex) => rule ? rule.transform({
@@ -1542,45 +1599,103 @@ function beginHandleDrag(args) {
1542
1599
  }) : next;
1543
1600
  const dragOnePoint = (base, activeIndex) => {
1544
1601
  const move = (pixel) => {
1545
- return {
1546
- points: snap(base.map((p, i) => i === activeIndex ? fromPixel(pixel) : [...p]), activeIndex),
1547
- rotateGripOverride: null
1548
- };
1602
+ return { points: snap(base.map((p, i) => i === activeIndex ? fromPixel(pixel) : [...p]), activeIndex) };
1549
1603
  };
1550
1604
  return move;
1551
1605
  };
1552
1606
  if (hit.kind === "vertex") return begun("vertex", preDragPoints, dragOnePoint(preDragPoints, hit.index));
1553
- if (hit.kind === "midpoint") {
1554
- const insertIndex = hit.index + 1;
1555
- const mid = segmentMidpoint(preDragPoints[hit.index], preDragPoints[hit.index + 1]);
1556
- const insertedBase = [
1557
- ...preDragPoints.slice(0, insertIndex),
1558
- mid,
1559
- ...preDragPoints.slice(insertIndex)
1560
- ];
1561
- return begun("midpoint", insertedBase, dragOnePoint(insertedBase, insertIndex));
1562
- }
1563
- if (hit.kind === "translate") {
1564
- const origin = fromPixel(downPixel);
1565
- return begun("translate", preDragPoints, (pixel) => {
1566
- const cur = fromPixel(pixel);
1607
+ const insertIndex = hit.index + 1;
1608
+ const segmentEndIndex = (hit.index + 1) % preDragPoints.length;
1609
+ const mid = segmentMidpoint(preDragPoints[hit.index], preDragPoints[segmentEndIndex]);
1610
+ const insertedBase = [
1611
+ ...preDragPoints.slice(0, insertIndex),
1612
+ mid,
1613
+ ...preDragPoints.slice(insertIndex)
1614
+ ];
1615
+ return begun("midpoint", insertedBase, dragOnePoint(insertedBase, insertIndex));
1616
+ }
1617
+ /** No corner drag may scale below this factor — guards against reflection/collapse through the anchor. */
1618
+ const MIN_SCALE_FACTOR = .05;
1619
+ /**
1620
+ * Begin a transform-box drag: uniform corner scale, box-center rotate, or
1621
+ * body translate. Mirrors `beginHandleDrag`'s shape (a `move`/`end` FSM seeded
1622
+ * from the pre-drag points) but keyed on `BoxDragKind` instead of `HandleKind`,
1623
+ * and additionally threading the box itself through so the caller can paint
1624
+ * the live box without re-fitting from projected geometry every frame.
1625
+ */
1626
+ function beginBoxDrag(args) {
1627
+ const { kind, downPixel, points, projection, box, cornerIndex } = args;
1628
+ const { toPixel, fromPixel } = projection;
1629
+ const preDragPoints = clonePoints(points);
1630
+ if (kind === "scale") {
1631
+ const index = cornerIndex ?? 0;
1632
+ const anchor = boxCorners(box)[(index + 2) % 4];
1633
+ const downDist = pixelDistance(downPixel, anchor);
1634
+ const move = (pixel) => {
1635
+ const dist = pixelDistance(pixel, anchor);
1636
+ const rawFactor = downDist === 0 ? 1 : dist / downDist;
1637
+ const factor = Math.max(rawFactor, MIN_SCALE_FACTOR);
1567
1638
  return {
1568
- points: translatePoints(preDragPoints, cur[0] - origin[0], cur[1] - origin[1]),
1569
- rotateGripOverride: null
1639
+ points: preDragPoints.map((p) => {
1640
+ const px = toPixel(p);
1641
+ if (!px) return [...p];
1642
+ const ll = fromPixel([anchor[0] + (px[0] - anchor[0]) * factor, anchor[1] + (px[1] - anchor[1]) * factor]);
1643
+ const out = [ll[0], ll[1]];
1644
+ for (let k = 2; k < p.length; k++) out.push(p[k]);
1645
+ return out;
1646
+ }),
1647
+ box: {
1648
+ center: [anchor[0] + (box.center[0] - anchor[0]) * factor, anchor[1] + (box.center[1] - anchor[1]) * factor],
1649
+ halfWidth: box.halfWidth * factor,
1650
+ halfHeight: box.halfHeight * factor,
1651
+ angle: box.angle
1652
+ }
1570
1653
  };
1571
- });
1654
+ };
1655
+ return {
1656
+ kind,
1657
+ move,
1658
+ end: (pixel) => move(pixel).points
1659
+ };
1660
+ }
1661
+ if (kind === "rotate") {
1662
+ const pivotPixel = box.center;
1663
+ const pivot = fromPixel(pivotPixel);
1664
+ const startAngle = Math.atan2(downPixel[1] - pivotPixel[1], downPixel[0] - pivotPixel[0]);
1665
+ const move = (pixel) => {
1666
+ const angle = Math.atan2(pixel[1] - pivotPixel[1], pixel[0] - pivotPixel[0]) - startAngle;
1667
+ return {
1668
+ points: rotatePointsAround(preDragPoints, pivot, pivotPixel, angle, projection),
1669
+ box: {
1670
+ center: [...pivotPixel],
1671
+ halfWidth: box.halfWidth,
1672
+ halfHeight: box.halfHeight,
1673
+ angle: box.angle + angle
1674
+ }
1675
+ };
1676
+ };
1677
+ return {
1678
+ kind,
1679
+ move,
1680
+ end: (pixel) => move(pixel).points
1681
+ };
1572
1682
  }
1573
- const pivot = computeBoundingBoxCenter(preDragPoints) ?? [0, 0];
1574
- const pivotPixel = projection.toPixel(pivot);
1575
- const startAngle = rotateAngleAt(pivot, pivotPixel, downPixel, projection);
1576
- const startGrip = hit.position;
1577
- return begun("rotate", preDragPoints, (pixel) => {
1578
- const angle = rotateAngleAt(pivot, pivotPixel, pixel, projection) - startAngle;
1683
+ const origin = fromPixel(downPixel);
1684
+ const move = (pixel) => {
1685
+ const cur = fromPixel(pixel);
1579
1686
  return {
1580
- points: rotatePointsAround(preDragPoints, pivot, pivotPixel, angle, projection),
1581
- rotateGripOverride: rotatePointsAround([startGrip], pivot, pivotPixel, angle, projection)[0]
1687
+ points: translatePoints(preDragPoints, cur[0] - origin[0], cur[1] - origin[1]),
1688
+ box: {
1689
+ ...box,
1690
+ center: [box.center[0] + (pixel[0] - downPixel[0]), box.center[1] + (pixel[1] - downPixel[1])]
1691
+ }
1582
1692
  };
1583
- });
1693
+ };
1694
+ return {
1695
+ kind,
1696
+ move,
1697
+ end: (pixel) => move(pixel).points
1698
+ };
1584
1699
  }
1585
1700
  //#endregion
1586
1701
  //#region src/tactical-draw/edit-session.ts
@@ -1602,8 +1717,10 @@ const COMMIT_GESTURE_FOR_TEST = Symbol("EditSession.commitGestureForTest");
1602
1717
  const COMMIT_TRANSLATE_FOR_TEST = Symbol("EditSession.commitTranslateForTest");
1603
1718
  /**
1604
1719
  * Internal test seam — simulate a completed rotate gesture by passing an angle
1605
- * in radians. Every control point rotates around the computed anchor (centroid);
1606
- * one `EditChangeEvent` is emitted, matching the issue #50 emission contract.
1720
+ * in radians. Every control point rotates around the transform box's center
1721
+ * (falling back to the control-points' bounding-box center when no box has
1722
+ * been fitted yet); one `EditChangeEvent` is emitted, and `boxAngle` absorbs
1723
+ * the same delta a live rotate box-drag would (ADR-0017).
1607
1724
  *
1608
1725
  * @internal
1609
1726
  */
@@ -1624,6 +1741,78 @@ const BEGIN_DRAG_FOR_TEST = Symbol("EditSession.beginDragForTest");
1624
1741
  */
1625
1742
  const END_DRAG_FOR_TEST = Symbol("EditSession.endDragForTest");
1626
1743
  //#endregion
1744
+ //#region src/tactical-draw/box-features.ts
1745
+ /**
1746
+ * Rendering helpers shared by the single-measure edit controller
1747
+ * (`edit-controller.ts`) and the group transform controller
1748
+ * (`transform-controller.ts`): both paint the same `TransformBox` shape —
1749
+ * corner scale handles, a rotate grip, and a closed-ring outline — so the
1750
+ * projection from pure box geometry (`transform-box.ts`) to lonLat `Handle`s
1751
+ * / GeoJSON features lives in one place instead of two copies drifting apart.
1752
+ * See ADR-0017.
1753
+ */
1754
+ /** Walk a GeoJSON `Geometry`'s coordinate arrays, pushing every `Position` onto `out`. */
1755
+ function collectFeaturePositions(geometry, out) {
1756
+ switch (geometry.type) {
1757
+ case "Point":
1758
+ out.push(geometry.coordinates);
1759
+ break;
1760
+ case "MultiPoint":
1761
+ case "LineString":
1762
+ for (const p of geometry.coordinates) out.push(p);
1763
+ break;
1764
+ case "MultiLineString":
1765
+ case "Polygon":
1766
+ for (const ring of geometry.coordinates) for (const p of ring) out.push(p);
1767
+ break;
1768
+ case "MultiPolygon":
1769
+ for (const poly of geometry.coordinates) for (const ring of poly) for (const p of ring) out.push(p);
1770
+ break;
1771
+ case "GeometryCollection":
1772
+ for (const g of geometry.geometries) collectFeaturePositions(g, out);
1773
+ break;
1774
+ }
1775
+ }
1776
+ /**
1777
+ * Project a `TransformBox`'s 4 corner scale handles and rotate grip into
1778
+ * lonLat `Handle`s via `fromPixel` — the render projection of the box's
1779
+ * pixel-space geometry (`boxCorners` / `rotateGripPixel`), reused by both
1780
+ * controllers so box-handle placement can't drift between single-measure and
1781
+ * group transforms.
1782
+ */
1783
+ function boxToHandles(box, fromPixel) {
1784
+ const handles = boxCorners(box).map((corner, index) => ({
1785
+ kind: "scale",
1786
+ index,
1787
+ position: fromPixel(corner)
1788
+ }));
1789
+ handles.push({
1790
+ kind: "rotate",
1791
+ index: 0,
1792
+ position: fromPixel(rotateGripPixel(box))
1793
+ });
1794
+ return handles;
1795
+ }
1796
+ /**
1797
+ * Build the closed `LineString` outline feature for `box`, styled from
1798
+ * `style` (delivered per-feature via `properties.style`, as the adapters
1799
+ * already honor for line features). `featureId` is the caller's stable id —
1800
+ * `${prefix}:box-outline` for a single-measure or group box, or
1801
+ * `${prefix}:member-outline:{measureId}` for a group member's own outline.
1802
+ */
1803
+ function boxOutlineFeature(box, fromPixel, featureId, style) {
1804
+ const corners = boxCorners(box).map((c) => fromPixel(c));
1805
+ return {
1806
+ type: "Feature",
1807
+ id: featureId,
1808
+ geometry: {
1809
+ type: "LineString",
1810
+ coordinates: [...corners, corners[0]]
1811
+ },
1812
+ properties: style ? { style: { ...style } } : {}
1813
+ };
1814
+ }
1815
+ //#endregion
1627
1816
  //#region src/tactical-draw/edit-controller.ts
1628
1817
  /**
1629
1818
  * Coordinate-space tolerance for `EditSession.dirty`. The working copy is
@@ -1632,27 +1821,32 @@ const END_DRAG_FOR_TEST = Symbol("EditSession.endDragForTest");
1632
1821
  * dirty tracking; the constant lives in one place so future slices can lift
1633
1822
  * it to an option without touching call sites.
1634
1823
  */
1635
- const DIRTY_EPSILON = 1e-9;
1636
- const HANDLE_HIT_TOLERANCE_PX = 12;
1824
+ const DIRTY_EPSILON$1 = 1e-9;
1825
+ const HANDLE_HIT_TOLERANCE_PX$1 = 12;
1826
+ /**
1827
+ * A body drag (drag-anywhere-inside-the-box translate) whose release pixel
1828
+ * lands within this many pixels of the down pixel is treated as a plain
1829
+ * click, not a gesture: it is not committed (geometry restored, no
1830
+ * `EditChangeEvent`, `dirty` untouched) but the click-away listener is
1831
+ * suppressed so the click doesn't close the edit. Corner/rotate drags commit
1832
+ * even at zero movement (matches the pre-ADR-0017 translate/rotate seams).
1833
+ */
1834
+ const CLICK_SLOP_PX$1 = 3;
1637
1835
  const ALLOWED_EDIT_MODES = new Set([
1638
1836
  "reshape",
1639
- "translate",
1640
- "rotate",
1837
+ "transform",
1641
1838
  "delete"
1642
1839
  ]);
1643
1840
  /**
1644
- * Drive an interactive edit against a `MapAdapter`. Implements the first
1645
- * edit slice (issue #48):
1841
+ * Drive an interactive edit against a `MapAdapter`. Implements the edit
1842
+ * lifecycle (issue #48) plus the ADR-0017 transform box:
1646
1843
  *
1647
1844
  * - lift-original (remove from graphics layer) + working copy
1648
- * - preview + handle rendering for `reshape` mode
1845
+ * - preview + reshape-handle rendering, and the oriented transform box
1846
+ * (corner scale handles, rotate grip, drag-anywhere-inside body translate)
1649
1847
  * - dirty tracking via [[DIRTY_EPSILON]] tolerance
1650
1848
  * - default click-away close policy
1651
1849
  * - close / abort / signal / preempt / destroy paths
1652
- *
1653
- * Pointer-driven vertex / midpoint drag is intentionally NOT wired here —
1654
- * gesture binding lands with the change-event spec in #49 and the
1655
- * translate/rotate handle set in #50.
1656
1850
  */
1657
1851
  function runEdit(opts) {
1658
1852
  const { adapter, measure, graphicsLayer, previewLayer, guideLayer, handlesLayer, graphicsStyle, signal, closeOnClickAway = true, modes: initialModes, interactionStyle, onSession, handleIdPrefix, guide: guideOpt, restoreOriginalToGraphics, wasOnGraphicsLayer, onSettled, onClickAwayGraphicsPick } = opts;
@@ -1662,6 +1856,7 @@ function runEdit(opts) {
1662
1856
  const rule = metadata.rule;
1663
1857
  const midpointHandlesEnabled = rule?.canonicalPointCount === void 0;
1664
1858
  const trailingFixedSlots = rule?.trailingFixedSlots ?? 0;
1859
+ const closedRing = rule?.closedRing ?? false;
1665
1860
  const guideEnabled = (guideOpt ?? true) && !shouldSuppressGuide(metadata);
1666
1861
  const isAreaKind = metadata.geometry === "area";
1667
1862
  const requestedModes = normaliseModes(initialModes);
@@ -1692,12 +1887,55 @@ function runEdit(opts) {
1692
1887
  */
1693
1888
  let pendingModes = null;
1694
1889
  const changeListeners = /* @__PURE__ */ new Set();
1890
+ /**
1891
+ * The transform box's rotation, in radians, session-scoped (ADR-0017 decision
1892
+ * #2): starts at `0` (axis-aligned) and accumulates every committed rotate
1893
+ * gesture's delta, so the box stays glued to the shape across gestures. Never
1894
+ * persisted on the measure; a fresh edit always starts back at `0`.
1895
+ */
1896
+ let boxAngle = 0;
1897
+ /**
1898
+ * The transform box fitted around the *rendered* working-copy geometry
1899
+ * (pixel space), or `null` when nothing projected this frame. Re-fitted
1900
+ * after every committed gesture, every reshape-drag paint, and on
1901
+ * `onViewChange`; painted rigidly from the live drag's own snapshot instead
1902
+ * while a box gesture (`activeBoxDrag`) is in flight.
1903
+ */
1904
+ let box = null;
1905
+ /** The features written to the preview layer on the last `renderPreview()` call — reused to refit `box` without a second render pass. */
1906
+ let lastPreviewFeatures = [];
1695
1907
  let ctx;
1696
1908
  let driver;
1697
1909
  const dragProjection = {
1698
1910
  toPixel: (lonLat) => driver.toPixel(lonLat),
1699
1911
  fromPixel: (pixel) => driver.fromPixel(pixel)
1700
1912
  };
1913
+ /**
1914
+ * Fit the transform box around `features`' rendered geometry: every
1915
+ * coordinate is projected to pixels via the pointer driver and
1916
+ * `fitTransformBox`'d at the current `boxAngle`. `null` when nothing
1917
+ * projects (e.g. the working copy renders no features this frame).
1918
+ */
1919
+ function fitBoxFromFeatures(features) {
1920
+ const positions = [];
1921
+ for (const f of features) if (f.geometry) collectFeaturePositions(f.geometry, positions);
1922
+ const pixels = [];
1923
+ for (const p of positions) {
1924
+ const px = driver.toPixel(p);
1925
+ if (px) pixels.push(px);
1926
+ }
1927
+ return fitTransformBox(pixels, boxAngle, interactionStyle?.boxPadding ?? 0);
1928
+ }
1929
+ /**
1930
+ * Re-fit `box` from `lastPreviewFeatures`. A no-op while a box gesture is
1931
+ * in flight — that drag paints the box rigidly from its own snapshot
1932
+ * (`BoxDragSnapshot.box`) instead of a re-fit, so a corner/rotate/body drag
1933
+ * doesn't fight its own paint.
1934
+ */
1935
+ function refitBox() {
1936
+ if (activeBoxDrag) return;
1937
+ box = fitBoxFromFeatures(lastPreviewFeatures);
1938
+ }
1701
1939
  function liftOriginalFromGraphics() {
1702
1940
  if (!wasOnGraphicsLayer) return;
1703
1941
  const originalFeatureIds = renderMeasureForAdapter(adapter, measure, renderOpts).features.map((f) => f.id).filter((id) => typeof id === "string");
@@ -1718,37 +1956,48 @@ function runEdit(opts) {
1718
1956
  features = [];
1719
1957
  }
1720
1958
  ctx.writeLayer(previewLayer, features);
1721
- const guideFeatures = guideEnabled ? buildGuideFeatures(workingPoints, isAreaKind, trailingFixedSlots) : [];
1959
+ lastPreviewFeatures = features;
1960
+ refitBox();
1961
+ const guidePoints = rule?.guidePoints ? rule.guidePoints(workingPoints) : workingPoints;
1962
+ const guideFeatures = guideEnabled ? buildGuideFeatures(guidePoints, isAreaKind, trailingFixedSlots) : [];
1722
1963
  ctx.writeLayer(guideLayer, guideFeatures);
1723
1964
  }
1724
1965
  function rescaleOnViewChange() {
1725
- if (!measureUsesAdapterResolution({
1966
+ if (ctx.settled) return;
1967
+ if (measureUsesAdapterResolution({
1726
1968
  kind: measure.kind,
1727
1969
  options: workingOptions
1728
- })) return;
1729
- renderPreview();
1970
+ })) renderPreview();
1971
+ else refitBox();
1972
+ renderHandlesLayer();
1730
1973
  }
1731
1974
  function renderHandlesLayer() {
1732
1975
  if (ctx.settled) return;
1733
- let handles = buildHandleSet(workingPoints, modes, {
1976
+ const features = toFeatures(buildHandleSet(workingPoints, modes, {
1734
1977
  includeMidpoints: midpointHandlesEnabled,
1735
- trailingFixedSlots
1736
- });
1737
- if (activeDrag?.fsm.kind === "rotate" && rotateHandleOverride) {
1738
- const override = rotateHandleOverride;
1739
- handles = handles.map((h) => h.kind === "rotate" ? {
1740
- ...h,
1741
- position: override
1742
- } : h);
1743
- }
1744
- const features = toFeatures(handles, handleIdPrefix, interactionStyle);
1978
+ trailingFixedSlots,
1979
+ closedRing
1980
+ }), handleIdPrefix, interactionStyle);
1981
+ if (modes.includes("transform")) {
1982
+ const liveBox = activeBoxDrag ? activeBoxDrag.lastSnapshot.box : box;
1983
+ if (liveBox) {
1984
+ features.push(boxOutlineFeature(liveBox, dragProjection.fromPixel, `${handleIdPrefix}:box-outline`, interactionStyle?.boxOutline));
1985
+ features.push(...toFeatures(boxToHandles(liveBox, dragProjection.fromPixel), handleIdPrefix, interactionStyle));
1986
+ }
1987
+ }
1745
1988
  ctx.writeLayer(handlesLayer, features);
1746
1989
  }
1747
- function rebuildRendered() {
1990
+ /**
1991
+ * Builds the committed snapshot against explicit `options` rather than the
1992
+ * live `workingOptions` — used to render a gesture's "previous" snapshot
1993
+ * against pre-freeze options when a rotate has already merged its
1994
+ * orientation freeze into `workingOptions` by the time the commit runs.
1995
+ */
1996
+ function rebuildRenderedWith(options) {
1748
1997
  const working = {
1749
1998
  ...measure,
1750
1999
  style: currentStyle,
1751
- options: workingOptions,
2000
+ options,
1752
2001
  controlPoints: workingPoints.map((p) => [...p])
1753
2002
  };
1754
2003
  const committed = sizeAnchor === "screen" ? working : bakePixelSizeToGround(working, adapter);
@@ -1757,8 +2006,11 @@ function runEdit(opts) {
1757
2006
  render: renderMeasureForAdapter(adapter, committed, renderOpts)
1758
2007
  };
1759
2008
  }
2009
+ function rebuildRendered() {
2010
+ return rebuildRenderedWith(workingOptions);
2011
+ }
1760
2012
  function recomputeDirty() {
1761
- dirty = nonGeometryDirty || !pointsEqualWithinTolerance(workingPoints, editStartPoints, DIRTY_EPSILON);
2013
+ dirty = nonGeometryDirty || !pointsEqualWithinTolerance(workingPoints, editStartPoints, DIRTY_EPSILON$1);
1762
2014
  }
1763
2015
  function replaceWorkingPoints(next) {
1764
2016
  workingPoints.splice(0, workingPoints.length, ...roundControlPoints(next));
@@ -1767,6 +2019,23 @@ function runEdit(opts) {
1767
2019
  workingOptions = { ...next ?? {} };
1768
2020
  }
1769
2021
  /**
2022
+ * Pin orientation-derived option defaults (e.g. Battle Position's echelon
2023
+ * anchor) to their current concrete values, merging any patch onto
2024
+ * `workingOptions` in place. Called the moment a rotate gesture starts (a
2025
+ * box-drag rotate hit, or the `commitRotate` test seam) — a rigid rotation
2026
+ * of `workingPoints` follows immediately after, so the freeze must land
2027
+ * first or the orientation-relative default would re-derive against the
2028
+ * rotated geometry (ADR-0023). No dirty/change-event side effects of its
2029
+ * own — the rotate gesture's own commit carries the frozen options.
2030
+ */
2031
+ function freezeOrientationForRotate() {
2032
+ const patch = freezeOrientationOptions(measure.kind, workingPoints.map((p) => [...p]), workingOptions);
2033
+ if (patch) workingOptions = {
2034
+ ...workingOptions ?? {},
2035
+ ...patch
2036
+ };
2037
+ }
2038
+ /**
1770
2039
  * Emit a completed gesture to every registered listener. The pre-gesture
1771
2040
  * snapshot is provided as `previousRendered`; `commitGesture` has already
1772
2041
  * mutated `workingPoints` and re-rendered preview / handle layers before
@@ -1832,19 +2101,20 @@ function runEdit(opts) {
1832
2101
  * queued during the drag are applied after the listener emission (so the
1833
2102
  * post-gesture render reflects the requested mode set).
1834
2103
  */
1835
- function commitGesture(nextPoints) {
2104
+ function commitGesture(nextPoints, preGestureOptions) {
1836
2105
  if (ctx.settled) return;
1837
2106
  const previousPoints = workingPoints.map((p) => [...p]);
1838
- const previousOptions = { ...workingOptions ?? {} };
2107
+ const previousOptions = { ...preGestureOptions ?? workingOptions ?? {} };
1839
2108
  const previousStyle = currentStyle;
1840
2109
  const previousNonGeometryDirty = nonGeometryDirty;
1841
- const previousRendered = rebuildRendered();
2110
+ const previousRendered = rebuildRenderedWith(previousOptions);
1842
2111
  replaceWorkingPoints(nextPoints);
1843
2112
  let currentRendered;
1844
2113
  try {
1845
2114
  currentRendered = rebuildRendered();
1846
2115
  } catch (err) {
1847
2116
  replaceWorkingPoints(previousPoints);
2117
+ if (preGestureOptions !== void 0) replaceWorkingOptions(previousOptions);
1848
2118
  dragInFlight = false;
1849
2119
  drainPendingModes();
1850
2120
  throw err;
@@ -1934,16 +2204,23 @@ function runEdit(opts) {
1934
2204
  }));
1935
2205
  }
1936
2206
  /**
1937
- * Apply a rotate gesture by rotating every working point by `angle` (radians)
1938
- * around the bounding-box center. The rotation runs in screen space (see
2207
+ * Test seam for a rotate gesture: rotate every working point by `angle`
2208
+ * (radians) around the transform box's center — falling back to the
2209
+ * control-points' bounding-box center when no box has been fitted yet (e.g.
2210
+ * before the first paint). The rotation runs in screen space (see
1939
2211
  * `rotatePointsAround`) so it stays rigid and matches the pointer 1:1 at any
1940
- * latitude.
2212
+ * latitude, and `boxAngle` absorbs the same delta a live rotate box-drag
2213
+ * would (ADR-0017), so the box stays glued to the shape across seam calls.
1941
2214
  */
1942
2215
  function commitRotate(angle) {
1943
2216
  if (ctx.settled) return;
1944
- const pivot = computeBoundingBoxCenter(workingPoints);
2217
+ const preFreezeOptions = { ...workingOptions ?? {} };
2218
+ freezeOrientationForRotate();
2219
+ const pivotPixel = box ? box.center : driver.toPixel(computeBoundingBoxCenter(workingPoints) ?? [0, 0]);
2220
+ const pivot = box ? driver.fromPixel(box.center) : computeBoundingBoxCenter(workingPoints);
1945
2221
  if (!pivot) return;
1946
- commitGesture(rotatePointsAround(workingPoints, pivot, driver.toPixel(pivot), angle, dragProjection));
2222
+ boxAngle += angle;
2223
+ commitGesture(rotatePointsAround(workingPoints, pivot, pivotPixel, angle, dragProjection), preFreezeOptions);
1947
2224
  }
1948
2225
  function modesEqual(a, b) {
1949
2226
  if (a.length !== b.length) return false;
@@ -2035,13 +2312,14 @@ function runEdit(opts) {
2035
2312
  };
2036
2313
  let activeDrag = null;
2037
2314
  /**
2038
- * Render-only rotate-grip override produced by the drag FSM: the start grip
2039
- * rotated rigidly with the gesture so it tracks the pointer instead of hopping
2040
- * between near-equidistant vertices. Cleared when the drag ends. Only
2041
- * consulted while a rotate drag is in flight — pick is gated off for the
2042
- * duration, so the resting handle set is never read against it (ADR-0016).
2315
+ * A live transform-box gesture (corner scale, box rotate, or body
2316
+ * translate — ADR-0017). `lastSnapshot` starts as the pre-drag points/box
2317
+ * and is replaced on every `move()` tick; `renderHandlesLayer` paints
2318
+ * `lastSnapshot.box` instead of re-fitting while this is non-null, and
2319
+ * `preDragBox` lets a committed rotate compute the angle delta to fold into
2320
+ * `boxAngle`.
2043
2321
  */
2044
- let rotateHandleOverride = null;
2322
+ let activeBoxDrag = null;
2045
2323
  /**
2046
2324
  * Whether the vertex at `index` may be deleted (via Alt+click or in the
2047
2325
  * exclusive `delete` mode). Deletion is
@@ -2080,73 +2358,149 @@ function runEdit(opts) {
2080
2358
  }
2081
2359
  const onDriverDown = (pixel, info) => {
2082
2360
  if (ctx.settled || dragInFlight) return;
2083
- const tol = info?.hitTolerance ?? HANDLE_HIT_TOLERANCE_PX;
2361
+ const tol = info?.hitTolerance ?? HANDLE_HIT_TOLERANCE_PX$1;
2084
2362
  const hit = pickHandle(buildHandleSet(workingPoints, modes, {
2085
2363
  includeMidpoints: midpointHandlesEnabled,
2086
- trailingFixedSlots
2364
+ trailingFixedSlots,
2365
+ closedRing
2087
2366
  }), pixel, tol, (lonLat) => driver.toPixel(lonLat));
2088
- if (!hit) return;
2089
- if (modes.includes("delete")) {
2090
- if (hit.kind === "vertex" && canDeleteVertex(hit.index)) {
2367
+ if (hit) {
2368
+ if (modes.includes("delete")) {
2369
+ if (hit.kind === "vertex" && canDeleteVertex(hit.index)) {
2370
+ deleteVertex(hit.index);
2371
+ suppressClickAway = true;
2372
+ }
2373
+ return;
2374
+ }
2375
+ if (hit.kind === "vertex" && info?.altKey && canDeleteVertex(hit.index)) {
2091
2376
  deleteVertex(hit.index);
2092
2377
  suppressClickAway = true;
2378
+ return;
2093
2379
  }
2380
+ const preDragPoints = workingPoints.map((p) => [...p]);
2381
+ const { drag: fsm, initialPoints } = beginHandleDrag({
2382
+ hit,
2383
+ downPixel: pixel,
2384
+ points: preDragPoints,
2385
+ projection: dragProjection,
2386
+ rule
2387
+ });
2388
+ activeDrag = {
2389
+ fsm,
2390
+ preDragPoints
2391
+ };
2392
+ if (fsm.kind === "midpoint") {
2393
+ workingPoints.splice(0, workingPoints.length, ...initialPoints);
2394
+ renderPreview();
2395
+ renderHandlesLayer();
2396
+ }
2397
+ dragInFlight = true;
2398
+ driver.setDragPanEnabled(false);
2094
2399
  return;
2095
2400
  }
2096
- if (hit.kind === "vertex" && info?.altKey && canDeleteVertex(hit.index)) {
2097
- deleteVertex(hit.index);
2098
- suppressClickAway = true;
2099
- return;
2100
- }
2101
- const preDragPoints = workingPoints.map((p) => [...p]);
2102
- const { drag: fsm, initialPoints } = beginHandleDrag({
2103
- hit,
2104
- downPixel: pixel,
2105
- points: preDragPoints,
2106
- projection: dragProjection,
2107
- rule
2108
- });
2109
- activeDrag = {
2110
- fsm,
2111
- preDragPoints
2112
- };
2113
- if (fsm.kind === "midpoint") {
2114
- workingPoints.splice(0, workingPoints.length, ...initialPoints);
2115
- renderPreview();
2116
- renderHandlesLayer();
2401
+ if (modes.includes("transform") && box) {
2402
+ const boxHit = pickBoxHandle(box, pixel, tol);
2403
+ if (boxHit) {
2404
+ const preDragOptions = { ...workingOptions ?? {} };
2405
+ if (boxHit.kind === "rotate") {
2406
+ freezeOrientationForRotate();
2407
+ renderPreview();
2408
+ renderHandlesLayer();
2409
+ }
2410
+ const preDragPoints = workingPoints.map((p) => [...p]);
2411
+ activeBoxDrag = {
2412
+ fsm: beginBoxDrag({
2413
+ kind: boxHit.kind,
2414
+ downPixel: pixel,
2415
+ points: preDragPoints,
2416
+ projection: dragProjection,
2417
+ box,
2418
+ cornerIndex: boxHit.kind === "scale" ? boxHit.index : void 0
2419
+ }),
2420
+ preDragPoints,
2421
+ preDragBox: box,
2422
+ downPixel: pixel,
2423
+ lastSnapshot: {
2424
+ points: preDragPoints,
2425
+ box
2426
+ },
2427
+ preDragOptions
2428
+ };
2429
+ dragInFlight = true;
2430
+ driver.setDragPanEnabled(false);
2431
+ }
2117
2432
  }
2118
- dragInFlight = true;
2119
- driver.setDragPanEnabled(false);
2120
2433
  };
2121
2434
  function paintDragSnapshot(pixel) {
2122
2435
  if (!activeDrag) return;
2123
2436
  const snapshot = activeDrag.fsm.move(pixel);
2124
2437
  workingPoints.splice(0, workingPoints.length, ...snapshot.points);
2125
- rotateHandleOverride = snapshot.rotateGripOverride;
2438
+ renderPreview();
2439
+ renderHandlesLayer();
2440
+ recomputeDirty();
2441
+ }
2442
+ function paintBoxDragSnapshot(pixel) {
2443
+ if (!activeBoxDrag) return;
2444
+ const snapshot = activeBoxDrag.fsm.move(pixel);
2445
+ activeBoxDrag.lastSnapshot = snapshot;
2446
+ workingPoints.splice(0, workingPoints.length, ...snapshot.points);
2126
2447
  renderPreview();
2127
2448
  renderHandlesLayer();
2128
2449
  recomputeDirty();
2129
2450
  }
2130
2451
  const onDriverMove = (pixel) => {
2131
- if (ctx.settled || !activeDrag) return;
2132
- paintDragSnapshot(pixel);
2452
+ if (ctx.settled) return;
2453
+ if (activeDrag) paintDragSnapshot(pixel);
2454
+ else if (activeBoxDrag) paintBoxDragSnapshot(pixel);
2133
2455
  };
2456
+ /** Reset drag-in-flight bookkeeping shared by every "gesture ended" path. */
2457
+ function endDragBookkeeping() {
2458
+ driver.setDragPanEnabled(true);
2459
+ dragInFlight = false;
2460
+ drainPendingModes();
2461
+ }
2134
2462
  const onDriverUp = (pixel) => {
2135
- if (!activeDrag) return;
2136
- if (ctx.settled) {
2463
+ if (activeDrag) {
2464
+ if (ctx.settled) {
2465
+ activeDrag = null;
2466
+ dragInFlight = false;
2467
+ driver.setDragPanEnabled(true);
2468
+ return;
2469
+ }
2470
+ const { fsm, preDragPoints } = activeDrag;
2137
2471
  activeDrag = null;
2138
- rotateHandleOverride = null;
2139
- dragInFlight = false;
2140
2472
  driver.setDragPanEnabled(true);
2473
+ const next = fsm.end(pixel);
2474
+ replaceWorkingPoints(preDragPoints);
2475
+ commitGesture(next);
2476
+ return;
2477
+ }
2478
+ if (activeBoxDrag) {
2479
+ if (ctx.settled) {
2480
+ activeBoxDrag = null;
2481
+ dragInFlight = false;
2482
+ driver.setDragPanEnabled(true);
2483
+ return;
2484
+ }
2485
+ const { fsm, preDragPoints, preDragBox, downPixel, preDragOptions } = activeBoxDrag;
2486
+ activeBoxDrag = null;
2487
+ driver.setDragPanEnabled(true);
2488
+ const distance = Math.hypot(pixel[0] - downPixel[0], pixel[1] - downPixel[1]);
2489
+ if (fsm.kind === "body" && distance <= CLICK_SLOP_PX$1) {
2490
+ replaceWorkingPoints(preDragPoints);
2491
+ renderPreview();
2492
+ renderHandlesLayer();
2493
+ recomputeDirty();
2494
+ endDragBookkeeping();
2495
+ suppressClickAway = true;
2496
+ return;
2497
+ }
2498
+ const finalSnapshot = fsm.move(pixel);
2499
+ replaceWorkingPoints(preDragPoints);
2500
+ if (fsm.kind === "rotate") boxAngle += finalSnapshot.box.angle - preDragBox.angle;
2501
+ commitGesture(finalSnapshot.points, preDragOptions);
2141
2502
  return;
2142
2503
  }
2143
- const { fsm, preDragPoints } = activeDrag;
2144
- activeDrag = null;
2145
- rotateHandleOverride = null;
2146
- driver.setDragPanEnabled(true);
2147
- const next = fsm.end(pixel);
2148
- replaceWorkingPoints(preDragPoints);
2149
- commitGesture(next);
2150
2504
  };
2151
2505
  let pickPreview;
2152
2506
  let pickHandles;
@@ -2210,10 +2564,10 @@ function runEdit(opts) {
2210
2564
  unsubDown();
2211
2565
  unsubMove();
2212
2566
  unsubUp();
2213
- if (activeDrag !== null) {
2567
+ if (activeDrag !== null || activeBoxDrag !== null) {
2214
2568
  driver.setDragPanEnabled(true);
2215
2569
  activeDrag = null;
2216
- rotateHandleOverride = null;
2570
+ activeBoxDrag = null;
2217
2571
  dragInFlight = false;
2218
2572
  }
2219
2573
  driver.dispose();
@@ -2241,12 +2595,17 @@ function runEdit(opts) {
2241
2595
  }
2242
2596
  };
2243
2597
  }
2598
+ /**
2599
+ * Validate and normalise an `EditOptions.modes` / `EditSession.setModes` input:
2600
+ * throws `TypeError` synchronously for an unknown mode, collapses `delete` to
2601
+ * an exclusive set, dedupes, and defaults to `["reshape", "transform"]`.
2602
+ * Exported so the façade can hoist this validation *before* `guardAndPreempt`
2603
+ * — an invalid mode must not tear down whatever edit/draw is already active
2604
+ * (see the `td.edit` regression test for issue tracked alongside ADR-0017).
2605
+ */
2244
2606
  function normaliseModes(modes) {
2245
- if (!modes || modes.length === 0) return ["reshape"];
2246
- for (const m of modes) {
2247
- if (m === "scale") throw new TypeError(`td.edit: edit mode "scale" is reserved and not yet implemented`);
2248
- if (!ALLOWED_EDIT_MODES.has(m)) throw new TypeError(`td.edit: unknown edit mode "${String(m)}"`);
2249
- }
2607
+ if (!modes || modes.length === 0) return ["reshape", "transform"];
2608
+ for (const m of modes) if (!ALLOWED_EDIT_MODES.has(m)) throw new TypeError(`td.edit: unknown edit mode "${String(m)}"`);
2250
2609
  if (modes.includes("delete")) return ["delete"];
2251
2610
  const seen = /* @__PURE__ */ new Set();
2252
2611
  const out = [];
@@ -2274,6 +2633,531 @@ function pointsEqualWithinTolerance(a, b, epsilon) {
2274
2633
  return true;
2275
2634
  }
2276
2635
  //#endregion
2636
+ //#region src/tactical-draw/transform-controller.ts
2637
+ /**
2638
+ * Coordinate-space tolerance for `TransformSession.dirty` — mirrors
2639
+ * `edit-controller.ts`'s `DIRTY_EPSILON` (kept as a sibling constant rather
2640
+ * than a shared import since the two controllers' dirty definitions may
2641
+ * diverge later: a group's dirty is "any member moved").
2642
+ */
2643
+ const DIRTY_EPSILON = 1e-9;
2644
+ const HANDLE_HIT_TOLERANCE_PX = 12;
2645
+ /**
2646
+ * A body drag (drag-anywhere-inside-the-box translate) whose release pixel
2647
+ * lands within this many pixels of the down pixel is treated as a plain
2648
+ * click, not a gesture — same rationale as `edit-controller.ts`'s constant of
2649
+ * the same name.
2650
+ */
2651
+ const CLICK_SLOP_PX = 3;
2652
+ /**
2653
+ * Drive an interactive group transform against a `MapAdapter` — the leaner,
2654
+ * multi-select sibling of `runEdit` (ADR-0017 "Multi-select" slice). No
2655
+ * reshape, no per-vertex handles, no guide layer, no per-measure
2656
+ * `setOptions`/`setStyle`: only the shared oriented box (corner scale, rotate
2657
+ * grip, drag-anywhere body translate), applied rigidly to every member's
2658
+ * control points at once.
2659
+ *
2660
+ * - lift every member off the graphics layer + per-member working copies
2661
+ * - render every member's preview to the shared preview layer each paint
2662
+ * - group box = fit over the union of every member's rendered pixel
2663
+ * coordinates at the session `boxAngle`; each member also gets its own
2664
+ * thin outline (its own fit at the same angle)
2665
+ * - dirty tracking, close/abort/signal/preempt/destroy, click-away — same
2666
+ * contracts as `runEdit`, scoped to the group
2667
+ */
2668
+ function runGroupTransform(opts) {
2669
+ const { adapter, measures, graphicsLayer, previewLayer, handlesLayer, graphicsStyle, signal, closeOnClickAway = true, interactionStyle, onSession, handleIdPrefix, restoreOriginalToGraphics, wasOnGraphicsLayer, onSettled, onClickAwayGraphicsPick } = opts;
2670
+ const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
2671
+ const memberIds = new Set(measures.map((m) => m.id));
2672
+ let sizeAnchor = opts.sizeAnchor ?? "ground";
2673
+ const editStartPointsByMember = measures.map((m) => m.controlPoints.map((p) => [...p]));
2674
+ const workingPointsByMember = editStartPointsByMember.map((pts) => pts.map((p) => [...p]));
2675
+ const counts = workingPointsByMember.map((pts) => pts.length);
2676
+ /**
2677
+ * Per-member options patch, index-aligned with `measures`, applied on top of
2678
+ * the member's own `options` when building its working/rendered measure.
2679
+ * This exists solely to freeze orientation-derived option defaults (e.g.
2680
+ * Battle Position's echelon anchor) the moment a group rotate gesture
2681
+ * starts (ADR-0023) — there is no public per-member `setOptions` here, and
2682
+ * the "no per-measure setOptions" contract for the group session still
2683
+ * holds; a host cannot reach this array.
2684
+ */
2685
+ let optionsOverrideByMember = measures.map(() => null);
2686
+ /** Effective options for member `i` against a given override array, merged onto its own options. */
2687
+ function effectiveOptionsWith(i, overrides) {
2688
+ const override = overrides[i];
2689
+ if (!override) return measures[i].options;
2690
+ return {
2691
+ ...measures[i].options ?? {},
2692
+ ...override
2693
+ };
2694
+ }
2695
+ /** Effective options for member `i`: its own options merged with the *live* override. */
2696
+ function effectiveOptions(i) {
2697
+ return effectiveOptionsWith(i, optionsOverrideByMember);
2698
+ }
2699
+ /** The working measure for member `i` against a given override array, with live working points. */
2700
+ function workingMeasureForWith(i, overrides) {
2701
+ return {
2702
+ ...measures[i],
2703
+ options: effectiveOptionsWith(i, overrides),
2704
+ controlPoints: workingPointsByMember[i].map((p) => [...p])
2705
+ };
2706
+ }
2707
+ /** The working measure for member `i`: original measure, live effective options, live working points. */
2708
+ function workingMeasureFor(i) {
2709
+ return workingMeasureForWith(i, optionsOverrideByMember);
2710
+ }
2711
+ let dirty = false;
2712
+ /**
2713
+ * Set by non-geometry mutations (`setSizeAnchor`) so `dirty` reports them
2714
+ * even when every control point still matches edit-start — mirrors
2715
+ * `edit-controller.ts`'s flag of the same name.
2716
+ */
2717
+ let nonGeometryDirty = false;
2718
+ let dragInFlight = false;
2719
+ /** Same click-away suppression role as `edit-controller.ts`'s flag of the same name. */
2720
+ let suppressClickAway = false;
2721
+ const changeListeners = /* @__PURE__ */ new Set();
2722
+ /** Session-scoped box rotation (ADR-0017 decision #2), shared by the whole group. */
2723
+ let boxAngle = 0;
2724
+ /** The group box fitted over every member's rendered geometry, or `null` when nothing projects this frame. */
2725
+ let groupBox = null;
2726
+ /** Each member's own fitted box (same angle as `groupBox`), index-aligned with `measures`. */
2727
+ let memberBoxes = measures.map(() => null);
2728
+ /** The features written to the preview layer for each member on the last paint. */
2729
+ let lastPreviewFeaturesByMember = measures.map(() => []);
2730
+ let ctx;
2731
+ let driver;
2732
+ const dragProjection = {
2733
+ toPixel: (lonLat) => driver.toPixel(lonLat),
2734
+ fromPixel: (pixel) => driver.fromPixel(pixel)
2735
+ };
2736
+ function liftOriginalsFromGraphics() {
2737
+ measures.forEach((measure, i) => {
2738
+ if (!wasOnGraphicsLayer[i]) return;
2739
+ const ids = renderMeasureForAdapter(adapter, measure, renderOpts).features.map((f) => f.id).filter((id) => typeof id === "string");
2740
+ if (ids.length > 0) adapter.removeFeatures(graphicsLayer, ids);
2741
+ });
2742
+ }
2743
+ function pixelsFor(features) {
2744
+ const positions = [];
2745
+ for (const f of features) if (f.geometry) collectFeaturePositions(f.geometry, positions);
2746
+ const pixels = [];
2747
+ for (const p of positions) {
2748
+ const px = driver.toPixel(p);
2749
+ if (px) pixels.push(px);
2750
+ }
2751
+ return pixels;
2752
+ }
2753
+ /** The live box angle: the in-flight drag's rigidly-transformed angle, or the committed `boxAngle`. */
2754
+ function currentAngle() {
2755
+ return activeBoxDrag ? activeBoxDrag.lastSnapshot.box.angle : boxAngle;
2756
+ }
2757
+ /**
2758
+ * Re-fit `groupBox` and every `memberBoxes[i]` from `lastPreviewFeaturesByMember`.
2759
+ * While a box gesture is in flight, `groupBox` itself is not touched here —
2760
+ * `renderHandlesLayer` paints the drag's own rigidly-transformed box instead
2761
+ * (matching `edit-controller.ts`'s `refitBox`) — but the member outlines are
2762
+ * still re-fitted every paint at the drag's live angle, since they are a
2763
+ * derived display only (no self-fight risk).
2764
+ */
2765
+ function refitBoxes() {
2766
+ const angle = currentAngle();
2767
+ const padding = interactionStyle?.boxPadding ?? 0;
2768
+ const allPixels = [];
2769
+ memberBoxes = lastPreviewFeaturesByMember.map((features) => {
2770
+ const pixels = pixelsFor(features);
2771
+ allPixels.push(...pixels);
2772
+ return fitTransformBox(pixels, angle, padding);
2773
+ });
2774
+ if (activeBoxDrag) return;
2775
+ groupBox = fitTransformBox(allPixels, boxAngle, padding);
2776
+ }
2777
+ function renderAllPreviews() {
2778
+ if (ctx.settled) return;
2779
+ const allFeatures = [];
2780
+ lastPreviewFeaturesByMember = measures.map((_measure, i) => {
2781
+ const working = workingMeasureFor(i);
2782
+ let features;
2783
+ try {
2784
+ features = renderMeasureForAdapter(adapter, working, renderOpts).features;
2785
+ } catch {
2786
+ features = [];
2787
+ }
2788
+ allFeatures.push(...features);
2789
+ return features;
2790
+ });
2791
+ ctx.writeLayer(previewLayer, allFeatures);
2792
+ refitBoxes();
2793
+ }
2794
+ function rescaleOnViewChange() {
2795
+ if (ctx.settled) return;
2796
+ if (measures.some((m) => measureUsesAdapterResolution({
2797
+ kind: m.kind,
2798
+ options: m.options
2799
+ }))) renderAllPreviews();
2800
+ else refitBoxes();
2801
+ renderHandlesLayer();
2802
+ }
2803
+ /**
2804
+ * The group's outline style: the resolved `boxOutline` plus `[4, 4]` dash
2805
+ * so the group reads as dashed per the reference design — unless the host
2806
+ * explicitly set their own `strokeDash`, which wins.
2807
+ */
2808
+ function groupOutlineStyle() {
2809
+ const base = interactionStyle?.boxOutline;
2810
+ if (base?.strokeDash !== void 0) return base;
2811
+ return {
2812
+ ...base ?? {},
2813
+ strokeDash: [4, 4]
2814
+ };
2815
+ }
2816
+ function renderHandlesLayer() {
2817
+ if (ctx.settled) return;
2818
+ const features = [];
2819
+ const liveGroupBox = activeBoxDrag ? activeBoxDrag.lastSnapshot.box : groupBox;
2820
+ if (liveGroupBox) {
2821
+ features.push(boxOutlineFeature(liveGroupBox, dragProjection.fromPixel, `${handleIdPrefix}:box-outline`, groupOutlineStyle()));
2822
+ features.push(...toFeatures(boxToHandles(liveGroupBox, dragProjection.fromPixel), handleIdPrefix, interactionStyle));
2823
+ }
2824
+ measures.forEach((measure, i) => {
2825
+ const memberBox = memberBoxes[i];
2826
+ if (!memberBox) return;
2827
+ features.push(boxOutlineFeature(memberBox, dragProjection.fromPixel, `${handleIdPrefix}:member-outline:${measure.id}`, interactionStyle?.boxOutline));
2828
+ });
2829
+ ctx.writeLayer(handlesLayer, features);
2830
+ }
2831
+ /**
2832
+ * Builds every member's committed snapshot against a given override array
2833
+ * rather than the live `optionsOverrideByMember` — used to render a
2834
+ * gesture's pre-freeze "previous" snapshot when a rotate has already
2835
+ * mutated the live overrides by the time the commit runs.
2836
+ */
2837
+ function rebuildRenderedAllWith(overrides) {
2838
+ return measures.map((_measure, i) => {
2839
+ const working = workingMeasureForWith(i, overrides);
2840
+ const committed = sizeAnchor === "screen" ? working : bakePixelSizeToGround(working, adapter);
2841
+ return {
2842
+ measure: cloneControlMeasure(committed),
2843
+ render: renderMeasureForAdapter(adapter, committed, renderOpts)
2844
+ };
2845
+ });
2846
+ }
2847
+ function rebuildRenderedAll() {
2848
+ return rebuildRenderedAllWith(optionsOverrideByMember);
2849
+ }
2850
+ function recomputeDirty() {
2851
+ dirty = nonGeometryDirty || workingPointsByMember.some((pts, i) => !pointsEqualWithinTolerance(pts, editStartPointsByMember[i], DIRTY_EPSILON));
2852
+ }
2853
+ /** Split `points` (the concatenation of every member's control points) back per member and write them into `workingPointsByMember`. */
2854
+ function applyConcatenatedPoints(points) {
2855
+ const rounded = roundControlPoints(points);
2856
+ let offset = 0;
2857
+ for (let i = 0; i < counts.length; i++) {
2858
+ const c = counts[i];
2859
+ const slice = rounded.slice(offset, offset + c);
2860
+ workingPointsByMember[i].splice(0, workingPointsByMember[i].length, ...slice);
2861
+ offset += c;
2862
+ }
2863
+ }
2864
+ function concatWorkingPoints() {
2865
+ const out = [];
2866
+ for (const pts of workingPointsByMember) out.push(...pts.map((p) => [...p]));
2867
+ return out;
2868
+ }
2869
+ function clonePointsByMember() {
2870
+ return workingPointsByMember.map((pts) => pts.map((p) => [...p]));
2871
+ }
2872
+ function restorePointsByMember(snapshot) {
2873
+ for (let i = 0; i < workingPointsByMember.length; i++) workingPointsByMember[i].splice(0, workingPointsByMember[i].length, ...snapshot[i]);
2874
+ }
2875
+ function cloneOptionsOverrideByMember() {
2876
+ return optionsOverrideByMember.map((o) => o ? { ...o } : null);
2877
+ }
2878
+ /**
2879
+ * Pin orientation-derived option defaults (e.g. Battle Position's echelon
2880
+ * anchor) for every member, the moment a group rotate gesture starts —
2881
+ * mirrors `edit-controller.ts`'s `freezeOrientationForRotate`, applied
2882
+ * per-member since the group has no single working-options set.
2883
+ */
2884
+ function freezeOrientationForRotate() {
2885
+ measures.forEach((measure, i) => {
2886
+ const patch = freezeOrientationOptions(measure.kind, workingPointsByMember[i].map((p) => [...p]), effectiveOptions(i));
2887
+ if (patch) optionsOverrideByMember[i] = {
2888
+ ...optionsOverrideByMember[i] ?? {},
2889
+ ...patch
2890
+ };
2891
+ });
2892
+ }
2893
+ /**
2894
+ * Emit a completed gesture to every registered listener — the group mirror
2895
+ * of `edit-controller.ts`'s `emitChange` (issue #49 contract): same
2896
+ * instance to all listeners in order, deferred `reject`/`close`/`abort`
2897
+ * (close/abort override reject), exception collection with `AggregateError`.
2898
+ */
2899
+ function emitChange(previousRendered, previousPointsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered) {
2900
+ if (ctx.settled) return;
2901
+ let rejectRequested = false;
2902
+ let closeRequested = false;
2903
+ let abortRequested = false;
2904
+ const event = {
2905
+ measures: currentRendered,
2906
+ previous: previousRendered,
2907
+ session,
2908
+ reject() {
2909
+ rejectRequested = true;
2910
+ },
2911
+ close() {
2912
+ closeRequested = true;
2913
+ },
2914
+ abort() {
2915
+ abortRequested = true;
2916
+ }
2917
+ };
2918
+ const errors = [];
2919
+ for (const listener of [...changeListeners]) try {
2920
+ listener(event);
2921
+ } catch (err) {
2922
+ errors.push(err);
2923
+ }
2924
+ if (abortRequested) ctx.fail(new TacticalDrawAbortError("session"));
2925
+ else if (closeRequested) ctx.commit(rebuildRenderedAll);
2926
+ else if (rejectRequested && !ctx.settled) {
2927
+ restorePointsByMember(previousPointsByMember);
2928
+ sizeAnchor = previousSizeAnchor;
2929
+ nonGeometryDirty = previousNonGeometryDirty;
2930
+ optionsOverrideByMember = previousOptionsOverrideByMember.map((o) => o ? { ...o } : null);
2931
+ renderAllPreviews();
2932
+ renderHandlesLayer();
2933
+ recomputeDirty();
2934
+ }
2935
+ if (errors.length === 1) throw errors[0];
2936
+ if (errors.length > 1) throw new AggregateError(errors, "TransformSession.onChange listener errors");
2937
+ }
2938
+ function commitGesture(nextConcatPoints, preGestureOptionsOverrideByMember) {
2939
+ if (ctx.settled) return;
2940
+ const previousPointsByMember = clonePointsByMember();
2941
+ const previousOptionsOverrideByMember = preGestureOptionsOverrideByMember ?? cloneOptionsOverrideByMember();
2942
+ const previousRendered = rebuildRenderedAllWith(previousOptionsOverrideByMember);
2943
+ applyConcatenatedPoints(nextConcatPoints);
2944
+ let currentRendered;
2945
+ try {
2946
+ currentRendered = rebuildRenderedAll();
2947
+ } catch (err) {
2948
+ restorePointsByMember(previousPointsByMember);
2949
+ dragInFlight = false;
2950
+ throw err;
2951
+ }
2952
+ renderAllPreviews();
2953
+ renderHandlesLayer();
2954
+ recomputeDirty();
2955
+ dragInFlight = false;
2956
+ emitChange(previousRendered, previousPointsByMember, sizeAnchor, nonGeometryDirty, previousOptionsOverrideByMember, currentRendered);
2957
+ }
2958
+ function setSizeAnchor(next) {
2959
+ if (ctx.settled || sizeAnchor === next) return;
2960
+ const previousPointsByMember = clonePointsByMember();
2961
+ const previousSizeAnchor = sizeAnchor;
2962
+ const previousNonGeometryDirty = nonGeometryDirty;
2963
+ const previousOptionsOverrideByMember = cloneOptionsOverrideByMember();
2964
+ const previousRendered = rebuildRenderedAll();
2965
+ sizeAnchor = next;
2966
+ const currentRendered = rebuildRenderedAll();
2967
+ nonGeometryDirty = true;
2968
+ dirty = true;
2969
+ emitChange(previousRendered, previousPointsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered);
2970
+ }
2971
+ const session = {
2972
+ measures,
2973
+ controlPointsFor(measureId) {
2974
+ const index = measures.findIndex((m) => m.id === measureId);
2975
+ if (index < 0) return void 0;
2976
+ return workingPointsByMember[index].map((p) => [...p]);
2977
+ },
2978
+ get dirty() {
2979
+ return dirty;
2980
+ },
2981
+ get sizeAnchor() {
2982
+ return sizeAnchor;
2983
+ },
2984
+ setSizeAnchor(next) {
2985
+ setSizeAnchor(next);
2986
+ },
2987
+ close() {
2988
+ if (ctx.settled) return;
2989
+ ctx.commit(rebuildRenderedAll);
2990
+ },
2991
+ abort() {
2992
+ if (ctx.settled) return;
2993
+ ctx.fail(new TacticalDrawAbortError("session"));
2994
+ },
2995
+ onChange(listener) {
2996
+ if (ctx.settled) return () => {};
2997
+ changeListeners.add(listener);
2998
+ return () => {
2999
+ changeListeners.delete(listener);
3000
+ };
3001
+ }
3002
+ };
3003
+ let activeBoxDrag = null;
3004
+ const onDriverDown = (pixel, info) => {
3005
+ if (ctx.settled || dragInFlight) return;
3006
+ if (!groupBox) return;
3007
+ const tol = info?.hitTolerance ?? HANDLE_HIT_TOLERANCE_PX;
3008
+ const boxHit = pickBoxHandle(groupBox, pixel, tol);
3009
+ if (!boxHit) return;
3010
+ const preDragOptionsOverrideByMember = cloneOptionsOverrideByMember();
3011
+ if (boxHit.kind === "rotate") {
3012
+ freezeOrientationForRotate();
3013
+ renderAllPreviews();
3014
+ renderHandlesLayer();
3015
+ }
3016
+ const preDragPoints = concatWorkingPoints();
3017
+ activeBoxDrag = {
3018
+ fsm: beginBoxDrag({
3019
+ kind: boxHit.kind,
3020
+ downPixel: pixel,
3021
+ points: preDragPoints,
3022
+ projection: dragProjection,
3023
+ box: groupBox,
3024
+ cornerIndex: boxHit.kind === "scale" ? boxHit.index : void 0
3025
+ }),
3026
+ preDragPoints,
3027
+ preDragBox: groupBox,
3028
+ downPixel: pixel,
3029
+ lastSnapshot: {
3030
+ points: preDragPoints,
3031
+ box: groupBox
3032
+ },
3033
+ preDragOptionsOverrideByMember
3034
+ };
3035
+ dragInFlight = true;
3036
+ driver.setDragPanEnabled(false);
3037
+ };
3038
+ function paintBoxDragSnapshot(pixel) {
3039
+ if (!activeBoxDrag) return;
3040
+ const snapshot = activeBoxDrag.fsm.move(pixel);
3041
+ activeBoxDrag.lastSnapshot = snapshot;
3042
+ applyConcatenatedPoints(snapshot.points);
3043
+ renderAllPreviews();
3044
+ renderHandlesLayer();
3045
+ recomputeDirty();
3046
+ }
3047
+ const onDriverMove = (pixel) => {
3048
+ if (ctx.settled) return;
3049
+ if (activeBoxDrag) paintBoxDragSnapshot(pixel);
3050
+ };
3051
+ const onDriverUp = (pixel) => {
3052
+ if (!activeBoxDrag) return;
3053
+ if (ctx.settled) {
3054
+ activeBoxDrag = null;
3055
+ dragInFlight = false;
3056
+ driver.setDragPanEnabled(true);
3057
+ return;
3058
+ }
3059
+ const { fsm, preDragPoints, preDragBox, downPixel, preDragOptionsOverrideByMember } = activeBoxDrag;
3060
+ activeBoxDrag = null;
3061
+ driver.setDragPanEnabled(true);
3062
+ const distance = Math.hypot(pixel[0] - downPixel[0], pixel[1] - downPixel[1]);
3063
+ if (fsm.kind === "body" && distance <= CLICK_SLOP_PX) {
3064
+ applyConcatenatedPoints(preDragPoints);
3065
+ renderAllPreviews();
3066
+ renderHandlesLayer();
3067
+ recomputeDirty();
3068
+ dragInFlight = false;
3069
+ suppressClickAway = true;
3070
+ return;
3071
+ }
3072
+ const finalSnapshot = fsm.move(pixel);
3073
+ applyConcatenatedPoints(preDragPoints);
3074
+ if (fsm.kind === "rotate") boxAngle += finalSnapshot.box.angle - preDragBox.angle;
3075
+ commitGesture(finalSnapshot.points, preDragOptionsOverrideByMember);
3076
+ };
3077
+ let pickPreview;
3078
+ let pickHandles;
3079
+ let pickGraphics;
3080
+ const handlePickPreview = (e) => {
3081
+ pickPreview = e;
3082
+ };
3083
+ const handlePickHandles = (e) => {
3084
+ pickHandles = e;
3085
+ };
3086
+ const handlePickGraphics = (e) => {
3087
+ pickGraphics = e;
3088
+ };
3089
+ const handleClick = () => {
3090
+ const localPreview = pickPreview;
3091
+ const localHandles = pickHandles;
3092
+ const localGraphics = pickGraphics;
3093
+ pickPreview = void 0;
3094
+ pickHandles = void 0;
3095
+ pickGraphics = void 0;
3096
+ if (ctx.settled) return;
3097
+ if (!closeOnClickAway) return;
3098
+ if (suppressClickAway) {
3099
+ suppressClickAway = false;
3100
+ return;
3101
+ }
3102
+ if (localPreview || localHandles) return;
3103
+ if (localGraphics && !memberIds.has(localGraphics.id)) {
3104
+ ctx.commit(rebuildRenderedAll);
3105
+ if (onClickAwayGraphicsPick) onClickAwayGraphicsPick(localGraphics);
3106
+ return;
3107
+ }
3108
+ ctx.commit(rebuildRenderedAll);
3109
+ };
3110
+ const core = runInteraction({
3111
+ adapter,
3112
+ signal,
3113
+ layers: [previewLayer, handlesLayer],
3114
+ onViewChange: rescaleOnViewChange,
3115
+ onSettled,
3116
+ attach(c) {
3117
+ ctx = c;
3118
+ driver = adapter.createEditPointerDriver();
3119
+ liftOriginalsFromGraphics();
3120
+ const unsubDown = driver.onPointerDown(onDriverDown);
3121
+ const unsubMove = driver.onPointerMove(onDriverMove);
3122
+ const unsubUp = driver.onPointerUp(onDriverUp);
3123
+ const unsubPickPreview = adapter.onPick(previewLayer, handlePickPreview);
3124
+ const unsubPickHandles = adapter.onPick(handlesLayer, handlePickHandles);
3125
+ const unsubPickGraphics = adapter.onPick(graphicsLayer, handlePickGraphics);
3126
+ adapter.on("click", handleClick);
3127
+ const anyWasOnGraphicsLayer = wasOnGraphicsLayer.some(Boolean);
3128
+ c.onTeardown(() => {
3129
+ adapter.off("click", handleClick);
3130
+ unsubPickPreview();
3131
+ unsubPickHandles();
3132
+ unsubPickGraphics();
3133
+ unsubDown();
3134
+ unsubMove();
3135
+ unsubUp();
3136
+ if (activeBoxDrag !== null) {
3137
+ driver.setDragPanEnabled(true);
3138
+ activeBoxDrag = null;
3139
+ dragInFlight = false;
3140
+ }
3141
+ driver.dispose();
3142
+ }, anyWasOnGraphicsLayer ? restoreOriginalToGraphics : void 0);
3143
+ renderAllPreviews();
3144
+ renderHandlesLayer();
3145
+ recomputeDirty();
3146
+ if (onSession) onSession(session);
3147
+ }
3148
+ });
3149
+ return {
3150
+ promise: core.promise,
3151
+ get settled() {
3152
+ return core.settled;
3153
+ },
3154
+ measureIds: measures.map((m) => m.id),
3155
+ abort(reason, cause) {
3156
+ core.abort(reason, cause);
3157
+ }
3158
+ };
3159
+ }
3160
+ //#endregion
2277
3161
  //#region src/tactical-draw/errors.ts
2278
3162
  /**
2279
3163
  * Error thrown when any `TacticalDraw` method is called after `destroy()`.
@@ -2316,6 +3200,20 @@ var TacticalDraw = class {
2316
3200
  */
2317
3201
  activeEdit = null;
2318
3202
  /**
3203
+ * Tracked alongside `activeInteraction` whenever the interaction is a group
3204
+ * transform (`editMany`). The `render()` / view-change paths need every
3205
+ * member's id (to exclude all of them from reconcile) — mirrors `activeEdit`
3206
+ * but for `TransformHandle`'s `measureIds` array.
3207
+ */
3208
+ activeTransform = null;
3209
+ /**
3210
+ * The live `TransformSession`, exposed via `activeTransformSession`. Set
3211
+ * directly by `editMany`'s `onSession` wrapper (not routed through
3212
+ * `ActiveInteractionTracker.session`, whose type is `DrawSession | EditSession`)
3213
+ * and cleared on settle.
3214
+ */
3215
+ activeTransformSessionRef = null;
3216
+ /**
2319
3217
  * Host pick handlers registered via `td.onPick` / `td.onMeasurePick`. The
2320
3218
  * façade subscribes once to `adapter.onPick` per layer and routes events
2321
3219
  * through this map so that close-then-pick ordering during an active edit can
@@ -2333,6 +3231,7 @@ var TacticalDraw = class {
2333
3231
  deferredGraphicsPick = null;
2334
3232
  idCounter = 0;
2335
3233
  editCounter = 0;
3234
+ transformCounter = 0;
2336
3235
  /**
2337
3236
  * Snapshot of the host's last `render()` argument. The edit slice uses it
2338
3237
  * to recompute the graphics-layer state during a lift / restore — the
@@ -2384,12 +3283,24 @@ var TacticalDraw = class {
2384
3283
  */
2385
3284
  handleViewChange = () => {
2386
3285
  if (this.destroyed || !this.hasRendered) return;
2387
- const editedId = this.activeEdit?.measureId;
2388
- const visible = editedId !== void 0 ? this.lastRenderedMeasures.filter((m) => m.id !== editedId) : this.lastRenderedMeasures;
3286
+ const excluded = this.excludedInteractionIds();
3287
+ const visible = excluded.size > 0 ? this.lastRenderedMeasures.filter((m) => !excluded.has(m.id)) : this.lastRenderedMeasures;
2389
3288
  if (!visible.some(measureUsesAdapterResolution)) return;
2390
3289
  this.renderMeasuresToGraphics(visible);
2391
3290
  };
2392
3291
  /**
3292
+ * Every measure id currently lifted onto the preview layer by an active
3293
+ * edit or group transform — excluded from `render()` / view-change
3294
+ * reconciliation because the preview layer (not graphics) owns their
3295
+ * working copy. Empty while idle.
3296
+ */
3297
+ excludedInteractionIds() {
3298
+ const ids = /* @__PURE__ */ new Set();
3299
+ if (this.activeEdit) ids.add(this.activeEdit.measureId);
3300
+ if (this.activeTransform) for (const id of this.activeTransform.measureIds) ids.add(id);
3301
+ return ids;
3302
+ }
3303
+ /**
2393
3304
  * Reconcile the graphics layer against the authoritative list of measures.
2394
3305
  * Idempotent. Throws synchronously on duplicate measure ids *before*
2395
3306
  * mutating any layer. An empty list before any prior render is a no-op.
@@ -2428,7 +3339,10 @@ var TacticalDraw = class {
2428
3339
  externalEdited = measures.find((m) => m.id === editedId);
2429
3340
  editedRemoved = externalEdited === void 0;
2430
3341
  }
2431
- const visible = editedId !== void 0 ? measures.filter((m) => m.id !== editedId) : measures;
3342
+ const transformIds = this.activeTransform?.measureIds;
3343
+ const transformMemberRemoved = transformIds ? transformIds.some((id) => !seen.has(id)) : false;
3344
+ const excluded = this.excludedInteractionIds();
3345
+ const visible = excluded.size > 0 ? measures.filter((m) => !excluded.has(m.id)) : measures;
2432
3346
  if (!(visible.length === 0 && !this.hasRendered)) {
2433
3347
  this.renderMeasuresToGraphics(visible);
2434
3348
  this.hasRendered = true;
@@ -2441,6 +3355,12 @@ var TacticalDraw = class {
2441
3355
  editHandle.abort("removed");
2442
3356
  });
2443
3357
  }
3358
+ if (transformMemberRemoved && this.activeTransform) {
3359
+ const transformHandle = this.activeTransform;
3360
+ queueMicrotask(() => {
3361
+ transformHandle.abort("removed");
3362
+ });
3363
+ }
2444
3364
  }
2445
3365
  /**
2446
3366
  * Nested-call guard + preempt, shared by `draw` and `edit`. Returns a
@@ -2458,6 +3378,8 @@ var TacticalDraw = class {
2458
3378
  }
2459
3379
  if (this.activeInteraction.isActive) {
2460
3380
  this.activeEdit = null;
3381
+ this.activeTransform = null;
3382
+ this.activeTransformSessionRef = null;
2461
3383
  this.activeInteraction.cancel("preempted");
2462
3384
  }
2463
3385
  return null;
@@ -2473,14 +3395,17 @@ var TacticalDraw = class {
2473
3395
  * whose `onSettled` fired before `active` was assigned. `extraSettle` runs the
2474
3396
  * caller's own teardown (guide-style revert, dbl-click-zoom re-enable).
2475
3397
  */
2476
- runWithSlot(create, editHandleOf, extraSettle) {
3398
+ runWithSlot(create, activate, extraSettle) {
2477
3399
  let handle;
2478
- let editHandle = null;
3400
+ let deactivate = null;
2479
3401
  let settledDuringCreate = false;
2480
3402
  const clearSlot = () => {
2481
3403
  if (handle) {
2482
3404
  this.activeInteraction.clear(handle);
2483
- if (editHandle && this.activeEdit === editHandle) this.activeEdit = null;
3405
+ if (deactivate) {
3406
+ deactivate();
3407
+ deactivate = null;
3408
+ }
2484
3409
  } else {
2485
3410
  settledDuringCreate = true;
2486
3411
  this.activeInteraction.clear();
@@ -2493,7 +3418,6 @@ var TacticalDraw = class {
2493
3418
  this.activeInteraction.begin();
2494
3419
  try {
2495
3420
  handle = create(onSettled);
2496
- editHandle = editHandleOf ? editHandleOf(handle) : null;
2497
3421
  } catch (error) {
2498
3422
  this.activeInteraction.clear();
2499
3423
  if (!settledDuringCreate && extraSettle) extraSettle();
@@ -2501,7 +3425,7 @@ var TacticalDraw = class {
2501
3425
  }
2502
3426
  if (!settledDuringCreate) {
2503
3427
  this.activeInteraction.set(handle);
2504
- if (editHandle && !handle.settled) this.activeEdit = editHandle;
3428
+ if (activate && !handle.settled) deactivate = activate(handle) ?? null;
2505
3429
  }
2506
3430
  handle.promise.then(clearSlot, clearSlot);
2507
3431
  return handle.promise;
@@ -2517,6 +3441,18 @@ var TacticalDraw = class {
2517
3441
  };
2518
3442
  }
2519
3443
  /**
3444
+ * Wrap a host `onSession` callback for `editMany`, recording the session on
3445
+ * `activeTransformSessionRef` (exposed via `activeTransformSession`) before
3446
+ * the host's own handler runs. Separate from `trackSession` because
3447
+ * `ActiveInteractionTracker.session` is typed `DrawSession | EditSession`.
3448
+ */
3449
+ trackTransformSession(hostOnSession) {
3450
+ return (session) => {
3451
+ this.activeTransformSessionRef = session;
3452
+ hostOnSession?.(session);
3453
+ };
3454
+ }
3455
+ /**
2520
3456
  * Start an interactive draw. Fixed-length kinds commit automatically on the
2521
3457
  * last required click. Variable-length kinds surface a `DrawSession` via
2522
3458
  * `opts.onSession` and commit on `session.commit()`, `dblclick`, or a second
@@ -2543,6 +3479,7 @@ var TacticalDraw = class {
2543
3479
  adapter: this.adapter,
2544
3480
  kind,
2545
3481
  previewLayer: this.previewLayer,
3482
+ guideLayer: this.guideLayer,
2546
3483
  graphicsStyle: this.graphicsStyle,
2547
3484
  generateId: () => draft.id ?? this.generateId(),
2548
3485
  signal: options?.signal,
@@ -2582,9 +3519,7 @@ var TacticalDraw = class {
2582
3519
  */
2583
3520
  edit(measure, options) {
2584
3521
  this.assertAlive();
2585
- if (options?.modes) {
2586
- for (const m of options.modes) if (m === "scale") throw new TypeError(`td.edit: edit mode "scale" is reserved and not yet implemented`);
2587
- }
3522
+ normaliseModes(options?.modes);
2588
3523
  const preempt = this.guardAndPreempt();
2589
3524
  if (preempt) return preempt;
2590
3525
  const wasOnGraphicsLayer = this.lastRenderedMeasures.some((m) => m.id === measure.id);
@@ -2617,7 +3552,71 @@ var TacticalDraw = class {
2617
3552
  wasOnGraphicsLayer,
2618
3553
  onSettled,
2619
3554
  onClickAwayGraphicsPick
2620
- }), (handle) => handle, revertGuideStyle);
3555
+ }), (handle) => {
3556
+ this.activeEdit = handle;
3557
+ return () => {
3558
+ if (this.activeEdit === handle) this.activeEdit = null;
3559
+ };
3560
+ }, revertGuideStyle);
3561
+ }
3562
+ /**
3563
+ * Start an interactive group transform (multi-select) over `measures`
3564
+ * (ADR-0017 "Multi-select" slice). Every member is lifted onto the shared
3565
+ * preview layer; the box (corner scale, rotate grip, drag-anywhere body
3566
+ * translate) applies rigidly to all of them at once. Returns a Promise that
3567
+ * resolves with index-aligned `ControlMeasureSnapshot[]` on
3568
+ * `session.close()` and rejects with `TacticalDrawAbortError` on
3569
+ * signal / Escape / destroy / preempt / `session.abort()`.
3570
+ *
3571
+ * `measures.length` must be >= 1; a single-element array behaves like an
3572
+ * `edit` with only `transform` mode. Throws `TypeError` synchronously (before
3573
+ * any preemption) for an empty array or duplicate ids.
3574
+ */
3575
+ editMany(measures, options) {
3576
+ this.assertAlive();
3577
+ if (measures.length === 0) throw new TypeError("td.editMany: measures must not be empty");
3578
+ const seenIds = /* @__PURE__ */ new Set();
3579
+ for (const m of measures) {
3580
+ if (seenIds.has(m.id)) throw new TypeError(`td.editMany: duplicate control measure id "${m.id}"`);
3581
+ seenIds.add(m.id);
3582
+ }
3583
+ const preempt = this.guardAndPreempt();
3584
+ if (preempt) return preempt;
3585
+ const wasOnGraphicsLayer = measures.map((m) => this.lastRenderedMeasures.some((rendered) => rendered.id === m.id));
3586
+ const restoreOriginalToGraphics = () => {
3587
+ this.renderMeasuresToGraphics(this.lastRenderedMeasures);
3588
+ };
3589
+ const handleIdPrefix = `__td_transform_${++this.transformCounter}__`;
3590
+ const mergedInteractionStyle = this.resolveInteractionStyle(options?.interactionStyle);
3591
+ const onClickAwayGraphicsPick = (event) => {
3592
+ this.dispatchHostPick(this.graphicsLayer, event);
3593
+ };
3594
+ const extraSettle = () => {
3595
+ this.activeTransformSessionRef = null;
3596
+ };
3597
+ return this.runWithSlot((onSettled) => runGroupTransform({
3598
+ adapter: this.adapter,
3599
+ measures,
3600
+ graphicsLayer: this.graphicsLayer,
3601
+ previewLayer: this.previewLayer,
3602
+ handlesLayer: this.handlesLayer,
3603
+ graphicsStyle: this.graphicsStyle,
3604
+ signal: options?.signal,
3605
+ closeOnClickAway: options?.closeOnClickAway,
3606
+ interactionStyle: mergedInteractionStyle,
3607
+ onSession: this.trackTransformSession(options?.onSession),
3608
+ handleIdPrefix,
3609
+ sizeAnchor: options?.sizeAnchor,
3610
+ restoreOriginalToGraphics,
3611
+ wasOnGraphicsLayer,
3612
+ onSettled,
3613
+ onClickAwayGraphicsPick
3614
+ }), (handle) => {
3615
+ this.activeTransform = handle;
3616
+ return () => {
3617
+ if (this.activeTransform === handle) this.activeTransform = null;
3618
+ };
3619
+ }, extraSettle);
2621
3620
  }
2622
3621
  /**
2623
3622
  * Subscribe to picks on committed control measures.
@@ -2685,6 +3684,15 @@ var TacticalDraw = class {
2685
3684
  return this.activeInteraction.session;
2686
3685
  }
2687
3686
  /**
3687
+ * The live `editMany` group-transform session, or `null` while idle (and
3688
+ * during any other interaction). Separate from `activeSession` because
3689
+ * `TransformSession` isn't part of its `DrawSession | EditSession` union —
3690
+ * see `activeTransformSessionRef`. Safe to read after `destroy()`.
3691
+ */
3692
+ get activeTransformSession() {
3693
+ return this.activeTransformSessionRef;
3694
+ }
3695
+ /**
2688
3696
  * Tear down the façade. Releases only layer slots the façade created; host
2689
3697
  * supplied layer ids are left untouched. Safe to call multiple times. After
2690
3698
  * the first call every public method throws `TacticalDrawDestroyedError`.
@@ -2694,6 +3702,8 @@ var TacticalDraw = class {
2694
3702
  this.destroyed = true;
2695
3703
  if (this.activeInteraction.isActive) {
2696
3704
  this.activeEdit = null;
3705
+ this.activeTransform = null;
3706
+ this.activeTransformSessionRef = null;
2697
3707
  this.activeInteraction.cancel("destroyed");
2698
3708
  } else this.activeInteraction.clear();
2699
3709
  this.adapter.offViewChange(this.handleViewChange);
@@ -2713,7 +3723,8 @@ var TacticalDraw = class {
2713
3723
  * from a host pick handler observes a settled session.
2714
3724
  */
2715
3725
  routePick(layerId, event) {
2716
- if (layerId === this.graphicsLayer && this.activeEdit !== null && this.activeEdit.measureId !== event.id) {
3726
+ const excluded = this.excludedInteractionIds();
3727
+ if (layerId === this.graphicsLayer && excluded.size > 0 && !excluded.has(event.id)) {
2717
3728
  this.deferredGraphicsPick = event;
2718
3729
  return;
2719
3730
  }
@@ -2752,9 +3763,19 @@ var TacticalDraw = class {
2752
3763
  return mergeSlot(BUILT_IN_INTERACTION_STYLE.guide, this.defaultInteractionStyle?.guide);
2753
3764
  }
2754
3765
  /**
2755
- * Resolve handle slots across built-in → façade → per-call with per-slot,
2756
- * per-property shallow merge. Explicit `undefined` at a higher-priority
2757
- * layer unsets the corresponding lower-priority field (ADR-0006).
3766
+ * Resolve handle slots (plus `boxOutline`) across built-in → façade →
3767
+ * per-call with per-slot, per-property shallow merge. Explicit `undefined`
3768
+ * at a higher-priority layer unsets the corresponding lower-priority field
3769
+ * (ADR-0006). `boxOutline` rides this same per-feature merge path (not the
3770
+ * `guide` layer-style path) — the edit controller reads it off
3771
+ * `properties.style` on the box outline `LineString` (ADR-0017).
3772
+ *
3773
+ * `boxPadding` is a scalar, not a mergeable style object, so it resolves
3774
+ * separately with the same façade → per-call precedence and the same
3775
+ * explicit-undefined-wins rule: a `boxPadding` key present at a
3776
+ * higher-priority layer (even set to `undefined`) wins over a lower layer's
3777
+ * value. There is no built-in `boxPadding` — an absent value at every layer
3778
+ * falls through to the controllers' own `?? 0` default.
2758
3779
  *
2759
3780
  * Returns `undefined` only when no slot would carry any value — keeps the
2760
3781
  * downstream "no style on the feature" path intact for tests that assert
@@ -2766,6 +3787,10 @@ var TacticalDraw = class {
2766
3787
  const merged = mergeSlot(mergeSlot(BUILT_IN_INTERACTION_STYLE[slot], this.defaultInteractionStyle?.[slot]), perCall?.[slot]);
2767
3788
  if (merged) out[slot] = merged;
2768
3789
  }
3790
+ const boxOutline = mergeSlot(mergeSlot(BUILT_IN_INTERACTION_STYLE.boxOutline, this.defaultInteractionStyle?.boxOutline), perCall?.boxOutline);
3791
+ if (boxOutline) out.boxOutline = boxOutline;
3792
+ const boxPadding = perCall && "boxPadding" in perCall ? perCall.boxPadding : this.defaultInteractionStyle && "boxPadding" in this.defaultInteractionStyle ? this.defaultInteractionStyle.boxPadding : void 0;
3793
+ if (typeof boxPadding === "number") out.boxPadding = boxPadding;
2769
3794
  return out;
2770
3795
  }
2771
3796
  assertAlive() {
@@ -2782,10 +3807,12 @@ const BUILT_IN_GRAPHICS_STYLE = {
2782
3807
  strokeWidth: 2
2783
3808
  };
2784
3809
  /**
2785
- * Built-in defaults for the interaction-affordance slots. The translate grip
2786
- * sits on the centroid pivot and the rotate grip just outside the shape; their
2787
- * distinct fills (blue / green) set them apart from the white vertex and gray
2788
- * midpoint handles. See ADR-0006.
3810
+ * Built-in defaults for the interaction-affordance slots (ADR-0006, extended
3811
+ * for the ADR-0017 transform box). The scale (corner) grips are white-filled
3812
+ * with the same blue stroke as the box outline, so the box reads as one
3813
+ * coherent affordance; the rotate grip keeps its distinct green fill so it
3814
+ * doesn't get mistaken for a corner. Vertex/midpoint keep their existing
3815
+ * white/gray family.
2789
3816
  */
2790
3817
  const BUILT_IN_INTERACTION_STYLE = {
2791
3818
  guide: {
@@ -2793,6 +3820,10 @@ const BUILT_IN_INTERACTION_STYLE = {
2793
3820
  strokeWidth: 1,
2794
3821
  strokeDash: [6, 4]
2795
3822
  },
3823
+ boxOutline: {
3824
+ strokeColor: "#4f7cff",
3825
+ strokeWidth: 1
3826
+ },
2796
3827
  vertexHandle: {
2797
3828
  pointRadius: 6,
2798
3829
  fillColor: "#ffffff",
@@ -2805,13 +3836,14 @@ const BUILT_IN_INTERACTION_STYLE = {
2805
3836
  strokeColor: "#6b7280",
2806
3837
  strokeWidth: 1.5
2807
3838
  },
2808
- translateHandle: {
2809
- pointRadius: 7,
2810
- fillColor: "#2563eb",
2811
- strokeColor: "#ffffff"
3839
+ scaleHandle: {
3840
+ pointRadius: 5,
3841
+ fillColor: "#ffffff",
3842
+ strokeColor: "#4f7cff",
3843
+ strokeWidth: 2
2812
3844
  },
2813
3845
  rotateHandle: {
2814
- pointRadius: 7,
3846
+ pointRadius: 5,
2815
3847
  fillColor: "#16a34a",
2816
3848
  strokeColor: "#ffffff"
2817
3849
  }
@@ -2819,7 +3851,7 @@ const BUILT_IN_INTERACTION_STYLE = {
2819
3851
  const HANDLE_SLOTS = [
2820
3852
  "vertexHandle",
2821
3853
  "midpointHandle",
2822
- "translateHandle",
3854
+ "scaleHandle",
2823
3855
  "rotateHandle"
2824
3856
  ];
2825
3857
  /**