@orbat-mapper/tactical-draw 0.2.0-alpha.4 → 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 +1359 -335
  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";
@@ -1251,63 +1260,14 @@ function outlinePreview(points) {
1251
1260
  //#endregion
1252
1261
  //#region src/tactical-draw/handles.ts
1253
1262
  /**
1254
- * Compute the anchor used by translate and rotate handles. The anchor is the
1255
- * centroid (arithmetic mean) of the control-point list — stable, kind
1256
- * agnostic, and well-defined for any non-empty point set.
1257
- */
1258
- function computeHandleAnchor(controlPoints) {
1259
- if (controlPoints.length === 0) return null;
1260
- let sx = 0;
1261
- let sy = 0;
1262
- for (const p of controlPoints) {
1263
- sx += p[0];
1264
- sy += p[1];
1265
- }
1266
- return [sx / controlPoints.length, sy / controlPoints.length];
1267
- }
1268
- /**
1269
- * How far past the outermost control point the rotate grip sits. `1.3` places
1270
- * it 30% beyond the farthest vertex, clearing the geometry while staying
1271
- * proportional to the shape's size.
1272
- */
1273
- const ROTATE_HANDLE_REACH_FACTOR = 1.3;
1274
- /**
1275
- * Position of the rotate grip. Unlike the translate handle — which sits on the
1276
- * centroid pivot — the rotate handle needs a lever arm, so it is offset from
1277
- * the centroid along the ray toward the control point farthest from it, just
1278
- * outside the shape. Anchoring to a *material* point (rather than the
1279
- * axis-aligned bbox top) keeps this stateless yet glued to the geometry: the
1280
- * grip orbits with the shape as it rotates, since the farthest vertex rotates
1281
- * with it. Falls back to the centroid when every point coincides (no
1282
- * well-defined direction) or the list is empty.
1283
- */
1284
- function computeRotateHandlePosition(controlPoints) {
1285
- const anchor = computeHandleAnchor(controlPoints);
1286
- if (!anchor) return null;
1287
- let farthest = null;
1288
- let maxDistSq = 0;
1289
- for (const p of controlPoints) {
1290
- const dx = p[0] - anchor[0];
1291
- const dy = p[1] - anchor[1];
1292
- const distSq = dx * dx + dy * dy;
1293
- if (distSq > maxDistSq) {
1294
- maxDistSq = distSq;
1295
- farthest = p;
1296
- }
1297
- }
1298
- if (!farthest) return anchor;
1299
- return [anchor[0] + (farthest[0] - anchor[0]) * ROTATE_HANDLE_REACH_FACTOR, anchor[1] + (farthest[1] - anchor[1]) * ROTATE_HANDLE_REACH_FACTOR];
1300
- }
1301
- /**
1302
1263
  * Axis-aligned bounding-box center of the control points — the midpoint of
1303
- * `[minX, maxX] × [minY, maxY]`. Used as the rotation pivot so a shape turns
1304
- * about its visual middle (matching mainstream drawing tools) rather than the
1305
- * vertex mean (`computeHandleAnchor`), which is pulled off-center on asymmetric
1306
- * 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.
1307
1268
  *
1308
- * Exported because the rotation pivot is *not* a handle: it is the rotation
1309
- * center, distinct from the rotate grip's lever-arm position. The edit
1310
- * 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.
1311
1271
  */
1312
1272
  function computeBoundingBoxCenter(controlPoints) {
1313
1273
  if (controlPoints.length === 0) return null;
@@ -1333,23 +1293,23 @@ function segmentMidpoint(a, b) {
1333
1293
  return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
1334
1294
  }
1335
1295
  /**
1336
- * The single source of truth for edit-handle placement and the
1337
- * `EditMode → which handles` gating (ADR-0016). Pure and projection-free:
1338
- * positions are lonLat. Handles are emitted in **pick priority order** —
1339
- * `[vertices…, midpoints…, translate, rotate]` — so `pickHandle` can return the
1340
- * 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
1341
1301
  * midpoint." Rendering (`toFeatures`) and picking (`pickHandle`) are thin
1342
1302
  * projections of this set; the drag controller seeds gestures from it too.
1343
1303
  *
1344
1304
  * Emits handles only for modes present in `modes`:
1345
1305
  * - `reshape`: vertex handle at every control point + midpoint handle on
1346
1306
  * every adjacent pair (subject to `includeMidpoints` / `trailingFixedSlots`);
1347
- * - `delete`: vertex handles only (tapped to remove), no midpoint/grips;
1348
- * - `translate`: a single translate handle at the centroid anchor;
1349
- * - `rotate`: a single rotate grip offset outside the shape.
1307
+ * - `delete`: vertex handles only (tapped to remove), no midpoints.
1350
1308
  *
1351
- * `scale` is a reserved enum value rejected at the façade boundary — no handles
1352
- * 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.
1353
1313
  */
1354
1314
  function buildHandleSet(controlPoints, modes, options = {}) {
1355
1315
  const { includeMidpoints = true, trailingFixedSlots = 0, closedRing = false } = options;
@@ -1373,29 +1333,13 @@ function buildHandleSet(controlPoints, modes, options = {}) {
1373
1333
  }
1374
1334
  }
1375
1335
  }
1376
- if (modes.includes("translate")) {
1377
- const anchor = computeHandleAnchor(controlPoints);
1378
- if (anchor) handles.push({
1379
- kind: "translate",
1380
- index: 0,
1381
- position: anchor
1382
- });
1383
- }
1384
- if (modes.includes("rotate")) {
1385
- const rotatePos = computeRotateHandlePosition(controlPoints);
1386
- if (rotatePos) handles.push({
1387
- kind: "rotate",
1388
- index: 0,
1389
- position: rotatePos
1390
- });
1391
- }
1392
1336
  return handles;
1393
1337
  }
1394
1338
  function handleFeatureId(prefix, kind, index) {
1395
1339
  switch (kind) {
1396
1340
  case "vertex": return `${prefix}:handle-vertex:${index}`;
1397
1341
  case "midpoint": return `${prefix}:handle-midpoint:${index}`;
1398
- case "translate": return `${prefix}:handle-translate`;
1342
+ case "scale": return `${prefix}:handle-scale:${index}`;
1399
1343
  case "rotate": return `${prefix}:handle-rotate`;
1400
1344
  }
1401
1345
  }
@@ -1403,16 +1347,18 @@ function handleStyleFor(kind, interactionStyle) {
1403
1347
  switch (kind) {
1404
1348
  case "vertex": return interactionStyle?.vertexHandle;
1405
1349
  case "midpoint": return interactionStyle?.midpointHandle;
1406
- case "translate": return interactionStyle?.translateHandle;
1350
+ case "scale": return interactionStyle?.scaleHandle;
1407
1351
  case "rotate": return interactionStyle?.rotateHandle;
1408
1352
  }
1409
1353
  }
1410
1354
  /**
1411
1355
  * Project a handle set into the GeoJSON features that belong on the handles
1412
- * layer — the render projection of `buildHandleSet` (ADR-0016). Stable
1413
- * `featureIdPrefix`-namespaced ids let multiple edits share a handles layer
1414
- * without colliding; per-slot `interactionStyle` rides on `properties.style`.
1415
- * 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.
1416
1362
  */
1417
1363
  function toFeatures(handles, featureIdPrefix, interactionStyle) {
1418
1364
  return handles.map((handle) => {
@@ -1435,26 +1381,142 @@ function toFeatures(handles, featureIdPrefix, interactionStyle) {
1435
1381
  };
1436
1382
  });
1437
1383
  }
1438
- function pixelDistance(a, b) {
1384
+ function pixelDistance$2(a, b) {
1439
1385
  return Math.hypot(a[0] - b[0], a[1] - b[1]);
1440
1386
  }
1441
1387
  /**
1442
1388
  * The pick projection of `buildHandleSet` (ADR-0016): the first handle within
1443
1389
  * `tolerance` pixels of `pixel`, tested in the set's priority order. Because
1444
- * `buildHandleSet` orders vertices before midpoints before translate before
1445
- * rotate, "first within tolerance" preserves the legacy "vertex wins over a
1446
- * pixel-closer midpoint" behaviour. Returns the full `Handle` so the drag
1447
- * controller can seed a gesture from its `position` without recomputing
1448
- * placement. Handles whose position fails to project (off-screen / degenerate
1449
- * 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.
1450
1396
  */
1451
1397
  function pickHandle(handles, pixel, tolerance, toPixel) {
1452
1398
  for (const handle of handles) {
1453
1399
  const projected = toPixel(handle.position);
1454
- if (projected && pixelDistance(projected, pixel) <= tolerance) return handle;
1400
+ if (projected && pixelDistance$2(projected, pixel) <= tolerance) return handle;
1455
1401
  }
1456
1402
  return null;
1457
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
+ }
1458
1520
  //#endregion
1459
1521
  //#region src/tactical-draw/handle-drag.ts
1460
1522
  function clonePoints(points) {
@@ -1468,17 +1530,8 @@ function translatePoints(base, dx, dy) {
1468
1530
  return out;
1469
1531
  });
1470
1532
  }
1471
- function angleFromAnchor(anchor, target) {
1472
- return Math.atan2(target[1] - anchor[1], target[0] - anchor[0]);
1473
- }
1474
- /**
1475
- * Angle of `pixel` around the rotation pivot, measured in screen space so it
1476
- * matches what the user sees and does. Falls back to lon/lat when the pivot
1477
- * cannot be projected — exceedingly rare for an on-screen edit.
1478
- */
1479
- function rotateAngleAt(pivot, pivotPixel, pixel, projection) {
1480
- if (pivotPixel) return Math.atan2(pixel[1] - pivotPixel[1], pixel[0] - pivotPixel[0]);
1481
- return angleFromAnchor(pivot, projection.fromPixel(pixel));
1533
+ function pixelDistance(a, b) {
1534
+ return Math.hypot(a[0] - b[0], a[1] - b[1]);
1482
1535
  }
1483
1536
  /**
1484
1537
  * Rotate `points` by `angle` about the pivot in screen space — project each
@@ -1536,7 +1589,7 @@ function begun(kind, initialBase, move) {
1536
1589
  * caller's working copy.
1537
1590
  */
1538
1591
  function beginHandleDrag(args) {
1539
- const { hit, downPixel, points, projection, rule } = args;
1592
+ const { hit, points, projection, rule } = args;
1540
1593
  const { fromPixel } = projection;
1541
1594
  const preDragPoints = clonePoints(points);
1542
1595
  const snap = (next, activePointIndex) => rule ? rule.transform({
@@ -1546,46 +1599,103 @@ function beginHandleDrag(args) {
1546
1599
  }) : next;
1547
1600
  const dragOnePoint = (base, activeIndex) => {
1548
1601
  const move = (pixel) => {
1549
- return {
1550
- points: snap(base.map((p, i) => i === activeIndex ? fromPixel(pixel) : [...p]), activeIndex),
1551
- rotateGripOverride: null
1552
- };
1602
+ return { points: snap(base.map((p, i) => i === activeIndex ? fromPixel(pixel) : [...p]), activeIndex) };
1553
1603
  };
1554
1604
  return move;
1555
1605
  };
1556
1606
  if (hit.kind === "vertex") return begun("vertex", preDragPoints, dragOnePoint(preDragPoints, hit.index));
1557
- if (hit.kind === "midpoint") {
1558
- const insertIndex = hit.index + 1;
1559
- const segmentEndIndex = (hit.index + 1) % preDragPoints.length;
1560
- const mid = segmentMidpoint(preDragPoints[hit.index], preDragPoints[segmentEndIndex]);
1561
- const insertedBase = [
1562
- ...preDragPoints.slice(0, insertIndex),
1563
- mid,
1564
- ...preDragPoints.slice(insertIndex)
1565
- ];
1566
- return begun("midpoint", insertedBase, dragOnePoint(insertedBase, insertIndex));
1567
- }
1568
- if (hit.kind === "translate") {
1569
- const origin = fromPixel(downPixel);
1570
- return begun("translate", preDragPoints, (pixel) => {
1571
- 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);
1572
1638
  return {
1573
- points: translatePoints(preDragPoints, cur[0] - origin[0], cur[1] - origin[1]),
1574
- 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
+ }
1575
1653
  };
1576
- });
1654
+ };
1655
+ return {
1656
+ kind,
1657
+ move,
1658
+ end: (pixel) => move(pixel).points
1659
+ };
1577
1660
  }
1578
- const pivot = computeBoundingBoxCenter(preDragPoints) ?? [0, 0];
1579
- const pivotPixel = projection.toPixel(pivot);
1580
- const startAngle = rotateAngleAt(pivot, pivotPixel, downPixel, projection);
1581
- const startGrip = hit.position;
1582
- return begun("rotate", preDragPoints, (pixel) => {
1583
- const angle = rotateAngleAt(pivot, pivotPixel, pixel, projection) - startAngle;
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
+ };
1584
1677
  return {
1585
- points: rotatePointsAround(preDragPoints, pivot, pivotPixel, angle, projection),
1586
- rotateGripOverride: rotatePointsAround([startGrip], pivot, pivotPixel, angle, projection)[0]
1678
+ kind,
1679
+ move,
1680
+ end: (pixel) => move(pixel).points
1587
1681
  };
1588
- });
1682
+ }
1683
+ const origin = fromPixel(downPixel);
1684
+ const move = (pixel) => {
1685
+ const cur = fromPixel(pixel);
1686
+ return {
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
+ }
1692
+ };
1693
+ };
1694
+ return {
1695
+ kind,
1696
+ move,
1697
+ end: (pixel) => move(pixel).points
1698
+ };
1589
1699
  }
1590
1700
  //#endregion
1591
1701
  //#region src/tactical-draw/edit-session.ts
@@ -1607,8 +1717,10 @@ const COMMIT_GESTURE_FOR_TEST = Symbol("EditSession.commitGestureForTest");
1607
1717
  const COMMIT_TRANSLATE_FOR_TEST = Symbol("EditSession.commitTranslateForTest");
1608
1718
  /**
1609
1719
  * Internal test seam — simulate a completed rotate gesture by passing an angle
1610
- * in radians. Every control point rotates around the computed anchor (centroid);
1611
- * 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).
1612
1724
  *
1613
1725
  * @internal
1614
1726
  */
@@ -1629,6 +1741,78 @@ const BEGIN_DRAG_FOR_TEST = Symbol("EditSession.beginDragForTest");
1629
1741
  */
1630
1742
  const END_DRAG_FOR_TEST = Symbol("EditSession.endDragForTest");
1631
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
1632
1816
  //#region src/tactical-draw/edit-controller.ts
1633
1817
  /**
1634
1818
  * Coordinate-space tolerance for `EditSession.dirty`. The working copy is
@@ -1637,27 +1821,32 @@ const END_DRAG_FOR_TEST = Symbol("EditSession.endDragForTest");
1637
1821
  * dirty tracking; the constant lives in one place so future slices can lift
1638
1822
  * it to an option without touching call sites.
1639
1823
  */
1640
- const DIRTY_EPSILON = 1e-9;
1641
- 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;
1642
1835
  const ALLOWED_EDIT_MODES = new Set([
1643
1836
  "reshape",
1644
- "translate",
1645
- "rotate",
1837
+ "transform",
1646
1838
  "delete"
1647
1839
  ]);
1648
1840
  /**
1649
- * Drive an interactive edit against a `MapAdapter`. Implements the first
1650
- * 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:
1651
1843
  *
1652
1844
  * - lift-original (remove from graphics layer) + working copy
1653
- * - 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)
1654
1847
  * - dirty tracking via [[DIRTY_EPSILON]] tolerance
1655
1848
  * - default click-away close policy
1656
1849
  * - close / abort / signal / preempt / destroy paths
1657
- *
1658
- * Pointer-driven vertex / midpoint drag is intentionally NOT wired here —
1659
- * gesture binding lands with the change-event spec in #49 and the
1660
- * translate/rotate handle set in #50.
1661
1850
  */
1662
1851
  function runEdit(opts) {
1663
1852
  const { adapter, measure, graphicsLayer, previewLayer, guideLayer, handlesLayer, graphicsStyle, signal, closeOnClickAway = true, modes: initialModes, interactionStyle, onSession, handleIdPrefix, guide: guideOpt, restoreOriginalToGraphics, wasOnGraphicsLayer, onSettled, onClickAwayGraphicsPick } = opts;
@@ -1698,12 +1887,55 @@ function runEdit(opts) {
1698
1887
  */
1699
1888
  let pendingModes = null;
1700
1889
  const changeListeners = /* @__PURE__ */ new Set();
1701
- let ctx;
1702
- let driver;
1703
- const dragProjection = {
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 = [];
1907
+ let ctx;
1908
+ let driver;
1909
+ const dragProjection = {
1704
1910
  toPixel: (lonLat) => driver.toPixel(lonLat),
1705
1911
  fromPixel: (pixel) => driver.fromPixel(pixel)
1706
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
+ }
1707
1939
  function liftOriginalFromGraphics() {
1708
1940
  if (!wasOnGraphicsLayer) return;
1709
1941
  const originalFeatureIds = renderMeasureForAdapter(adapter, measure, renderOpts).features.map((f) => f.id).filter((id) => typeof id === "string");
@@ -1724,38 +1956,48 @@ function runEdit(opts) {
1724
1956
  features = [];
1725
1957
  }
1726
1958
  ctx.writeLayer(previewLayer, features);
1727
- 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) : [];
1728
1963
  ctx.writeLayer(guideLayer, guideFeatures);
1729
1964
  }
1730
1965
  function rescaleOnViewChange() {
1731
- if (!measureUsesAdapterResolution({
1966
+ if (ctx.settled) return;
1967
+ if (measureUsesAdapterResolution({
1732
1968
  kind: measure.kind,
1733
1969
  options: workingOptions
1734
- })) return;
1735
- renderPreview();
1970
+ })) renderPreview();
1971
+ else refitBox();
1972
+ renderHandlesLayer();
1736
1973
  }
1737
1974
  function renderHandlesLayer() {
1738
1975
  if (ctx.settled) return;
1739
- let handles = buildHandleSet(workingPoints, modes, {
1976
+ const features = toFeatures(buildHandleSet(workingPoints, modes, {
1740
1977
  includeMidpoints: midpointHandlesEnabled,
1741
1978
  trailingFixedSlots,
1742
1979
  closedRing
1743
- });
1744
- if (activeDrag?.fsm.kind === "rotate" && rotateHandleOverride) {
1745
- const override = rotateHandleOverride;
1746
- handles = handles.map((h) => h.kind === "rotate" ? {
1747
- ...h,
1748
- position: override
1749
- } : h);
1750
- }
1751
- const features = toFeatures(handles, handleIdPrefix, interactionStyle);
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
+ }
1752
1988
  ctx.writeLayer(handlesLayer, features);
1753
1989
  }
1754
- 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) {
1755
1997
  const working = {
1756
1998
  ...measure,
1757
1999
  style: currentStyle,
1758
- options: workingOptions,
2000
+ options,
1759
2001
  controlPoints: workingPoints.map((p) => [...p])
1760
2002
  };
1761
2003
  const committed = sizeAnchor === "screen" ? working : bakePixelSizeToGround(working, adapter);
@@ -1764,8 +2006,11 @@ function runEdit(opts) {
1764
2006
  render: renderMeasureForAdapter(adapter, committed, renderOpts)
1765
2007
  };
1766
2008
  }
2009
+ function rebuildRendered() {
2010
+ return rebuildRenderedWith(workingOptions);
2011
+ }
1767
2012
  function recomputeDirty() {
1768
- dirty = nonGeometryDirty || !pointsEqualWithinTolerance(workingPoints, editStartPoints, DIRTY_EPSILON);
2013
+ dirty = nonGeometryDirty || !pointsEqualWithinTolerance(workingPoints, editStartPoints, DIRTY_EPSILON$1);
1769
2014
  }
1770
2015
  function replaceWorkingPoints(next) {
1771
2016
  workingPoints.splice(0, workingPoints.length, ...roundControlPoints(next));
@@ -1774,6 +2019,23 @@ function runEdit(opts) {
1774
2019
  workingOptions = { ...next ?? {} };
1775
2020
  }
1776
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
+ /**
1777
2039
  * Emit a completed gesture to every registered listener. The pre-gesture
1778
2040
  * snapshot is provided as `previousRendered`; `commitGesture` has already
1779
2041
  * mutated `workingPoints` and re-rendered preview / handle layers before
@@ -1839,19 +2101,20 @@ function runEdit(opts) {
1839
2101
  * queued during the drag are applied after the listener emission (so the
1840
2102
  * post-gesture render reflects the requested mode set).
1841
2103
  */
1842
- function commitGesture(nextPoints) {
2104
+ function commitGesture(nextPoints, preGestureOptions) {
1843
2105
  if (ctx.settled) return;
1844
2106
  const previousPoints = workingPoints.map((p) => [...p]);
1845
- const previousOptions = { ...workingOptions ?? {} };
2107
+ const previousOptions = { ...preGestureOptions ?? workingOptions ?? {} };
1846
2108
  const previousStyle = currentStyle;
1847
2109
  const previousNonGeometryDirty = nonGeometryDirty;
1848
- const previousRendered = rebuildRendered();
2110
+ const previousRendered = rebuildRenderedWith(previousOptions);
1849
2111
  replaceWorkingPoints(nextPoints);
1850
2112
  let currentRendered;
1851
2113
  try {
1852
2114
  currentRendered = rebuildRendered();
1853
2115
  } catch (err) {
1854
2116
  replaceWorkingPoints(previousPoints);
2117
+ if (preGestureOptions !== void 0) replaceWorkingOptions(previousOptions);
1855
2118
  dragInFlight = false;
1856
2119
  drainPendingModes();
1857
2120
  throw err;
@@ -1941,16 +2204,23 @@ function runEdit(opts) {
1941
2204
  }));
1942
2205
  }
1943
2206
  /**
1944
- * Apply a rotate gesture by rotating every working point by `angle` (radians)
1945
- * 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
1946
2211
  * `rotatePointsAround`) so it stays rigid and matches the pointer 1:1 at any
1947
- * 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.
1948
2214
  */
1949
2215
  function commitRotate(angle) {
1950
2216
  if (ctx.settled) return;
1951
- 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);
1952
2221
  if (!pivot) return;
1953
- commitGesture(rotatePointsAround(workingPoints, pivot, driver.toPixel(pivot), angle, dragProjection));
2222
+ boxAngle += angle;
2223
+ commitGesture(rotatePointsAround(workingPoints, pivot, pivotPixel, angle, dragProjection), preFreezeOptions);
1954
2224
  }
1955
2225
  function modesEqual(a, b) {
1956
2226
  if (a.length !== b.length) return false;
@@ -2042,13 +2312,14 @@ function runEdit(opts) {
2042
2312
  };
2043
2313
  let activeDrag = null;
2044
2314
  /**
2045
- * Render-only rotate-grip override produced by the drag FSM: the start grip
2046
- * rotated rigidly with the gesture so it tracks the pointer instead of hopping
2047
- * between near-equidistant vertices. Cleared when the drag ends. Only
2048
- * consulted while a rotate drag is in flight — pick is gated off for the
2049
- * 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`.
2050
2321
  */
2051
- let rotateHandleOverride = null;
2322
+ let activeBoxDrag = null;
2052
2323
  /**
2053
2324
  * Whether the vertex at `index` may be deleted (via Alt+click or in the
2054
2325
  * exclusive `delete` mode). Deletion is
@@ -2087,74 +2358,149 @@ function runEdit(opts) {
2087
2358
  }
2088
2359
  const onDriverDown = (pixel, info) => {
2089
2360
  if (ctx.settled || dragInFlight) return;
2090
- const tol = info?.hitTolerance ?? HANDLE_HIT_TOLERANCE_PX;
2361
+ const tol = info?.hitTolerance ?? HANDLE_HIT_TOLERANCE_PX$1;
2091
2362
  const hit = pickHandle(buildHandleSet(workingPoints, modes, {
2092
2363
  includeMidpoints: midpointHandlesEnabled,
2093
2364
  trailingFixedSlots,
2094
2365
  closedRing
2095
2366
  }), pixel, tol, (lonLat) => driver.toPixel(lonLat));
2096
- if (!hit) return;
2097
- if (modes.includes("delete")) {
2098
- 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)) {
2099
2376
  deleteVertex(hit.index);
2100
2377
  suppressClickAway = true;
2378
+ return;
2101
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);
2102
2399
  return;
2103
2400
  }
2104
- if (hit.kind === "vertex" && info?.altKey && canDeleteVertex(hit.index)) {
2105
- deleteVertex(hit.index);
2106
- suppressClickAway = true;
2107
- return;
2108
- }
2109
- const preDragPoints = workingPoints.map((p) => [...p]);
2110
- const { drag: fsm, initialPoints } = beginHandleDrag({
2111
- hit,
2112
- downPixel: pixel,
2113
- points: preDragPoints,
2114
- projection: dragProjection,
2115
- rule
2116
- });
2117
- activeDrag = {
2118
- fsm,
2119
- preDragPoints
2120
- };
2121
- if (fsm.kind === "midpoint") {
2122
- workingPoints.splice(0, workingPoints.length, ...initialPoints);
2123
- renderPreview();
2124
- 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
+ }
2125
2432
  }
2126
- dragInFlight = true;
2127
- driver.setDragPanEnabled(false);
2128
2433
  };
2129
2434
  function paintDragSnapshot(pixel) {
2130
2435
  if (!activeDrag) return;
2131
2436
  const snapshot = activeDrag.fsm.move(pixel);
2132
2437
  workingPoints.splice(0, workingPoints.length, ...snapshot.points);
2133
- 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);
2134
2447
  renderPreview();
2135
2448
  renderHandlesLayer();
2136
2449
  recomputeDirty();
2137
2450
  }
2138
2451
  const onDriverMove = (pixel) => {
2139
- if (ctx.settled || !activeDrag) return;
2140
- paintDragSnapshot(pixel);
2452
+ if (ctx.settled) return;
2453
+ if (activeDrag) paintDragSnapshot(pixel);
2454
+ else if (activeBoxDrag) paintBoxDragSnapshot(pixel);
2141
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
+ }
2142
2462
  const onDriverUp = (pixel) => {
2143
- if (!activeDrag) return;
2144
- 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;
2145
2471
  activeDrag = null;
2146
- rotateHandleOverride = null;
2147
- dragInFlight = false;
2148
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);
2149
2502
  return;
2150
2503
  }
2151
- const { fsm, preDragPoints } = activeDrag;
2152
- activeDrag = null;
2153
- rotateHandleOverride = null;
2154
- driver.setDragPanEnabled(true);
2155
- const next = fsm.end(pixel);
2156
- replaceWorkingPoints(preDragPoints);
2157
- commitGesture(next);
2158
2504
  };
2159
2505
  let pickPreview;
2160
2506
  let pickHandles;
@@ -2218,10 +2564,10 @@ function runEdit(opts) {
2218
2564
  unsubDown();
2219
2565
  unsubMove();
2220
2566
  unsubUp();
2221
- if (activeDrag !== null) {
2567
+ if (activeDrag !== null || activeBoxDrag !== null) {
2222
2568
  driver.setDragPanEnabled(true);
2223
2569
  activeDrag = null;
2224
- rotateHandleOverride = null;
2570
+ activeBoxDrag = null;
2225
2571
  dragInFlight = false;
2226
2572
  }
2227
2573
  driver.dispose();
@@ -2249,12 +2595,17 @@ function runEdit(opts) {
2249
2595
  }
2250
2596
  };
2251
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
+ */
2252
2606
  function normaliseModes(modes) {
2253
- if (!modes || modes.length === 0) return ["reshape"];
2254
- for (const m of modes) {
2255
- if (m === "scale") throw new TypeError(`td.edit: edit mode "scale" is reserved and not yet implemented`);
2256
- if (!ALLOWED_EDIT_MODES.has(m)) throw new TypeError(`td.edit: unknown edit mode "${String(m)}"`);
2257
- }
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)}"`);
2258
2609
  if (modes.includes("delete")) return ["delete"];
2259
2610
  const seen = /* @__PURE__ */ new Set();
2260
2611
  const out = [];
@@ -2282,6 +2633,531 @@ function pointsEqualWithinTolerance(a, b, epsilon) {
2282
2633
  return true;
2283
2634
  }
2284
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
2285
3161
  //#region src/tactical-draw/errors.ts
2286
3162
  /**
2287
3163
  * Error thrown when any `TacticalDraw` method is called after `destroy()`.
@@ -2324,6 +3200,20 @@ var TacticalDraw = class {
2324
3200
  */
2325
3201
  activeEdit = null;
2326
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
+ /**
2327
3217
  * Host pick handlers registered via `td.onPick` / `td.onMeasurePick`. The
2328
3218
  * façade subscribes once to `adapter.onPick` per layer and routes events
2329
3219
  * through this map so that close-then-pick ordering during an active edit can
@@ -2341,6 +3231,7 @@ var TacticalDraw = class {
2341
3231
  deferredGraphicsPick = null;
2342
3232
  idCounter = 0;
2343
3233
  editCounter = 0;
3234
+ transformCounter = 0;
2344
3235
  /**
2345
3236
  * Snapshot of the host's last `render()` argument. The edit slice uses it
2346
3237
  * to recompute the graphics-layer state during a lift / restore — the
@@ -2392,12 +3283,24 @@ var TacticalDraw = class {
2392
3283
  */
2393
3284
  handleViewChange = () => {
2394
3285
  if (this.destroyed || !this.hasRendered) return;
2395
- const editedId = this.activeEdit?.measureId;
2396
- 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;
2397
3288
  if (!visible.some(measureUsesAdapterResolution)) return;
2398
3289
  this.renderMeasuresToGraphics(visible);
2399
3290
  };
2400
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
+ /**
2401
3304
  * Reconcile the graphics layer against the authoritative list of measures.
2402
3305
  * Idempotent. Throws synchronously on duplicate measure ids *before*
2403
3306
  * mutating any layer. An empty list before any prior render is a no-op.
@@ -2436,7 +3339,10 @@ var TacticalDraw = class {
2436
3339
  externalEdited = measures.find((m) => m.id === editedId);
2437
3340
  editedRemoved = externalEdited === void 0;
2438
3341
  }
2439
- 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;
2440
3346
  if (!(visible.length === 0 && !this.hasRendered)) {
2441
3347
  this.renderMeasuresToGraphics(visible);
2442
3348
  this.hasRendered = true;
@@ -2449,6 +3355,12 @@ var TacticalDraw = class {
2449
3355
  editHandle.abort("removed");
2450
3356
  });
2451
3357
  }
3358
+ if (transformMemberRemoved && this.activeTransform) {
3359
+ const transformHandle = this.activeTransform;
3360
+ queueMicrotask(() => {
3361
+ transformHandle.abort("removed");
3362
+ });
3363
+ }
2452
3364
  }
2453
3365
  /**
2454
3366
  * Nested-call guard + preempt, shared by `draw` and `edit`. Returns a
@@ -2466,6 +3378,8 @@ var TacticalDraw = class {
2466
3378
  }
2467
3379
  if (this.activeInteraction.isActive) {
2468
3380
  this.activeEdit = null;
3381
+ this.activeTransform = null;
3382
+ this.activeTransformSessionRef = null;
2469
3383
  this.activeInteraction.cancel("preempted");
2470
3384
  }
2471
3385
  return null;
@@ -2481,14 +3395,17 @@ var TacticalDraw = class {
2481
3395
  * whose `onSettled` fired before `active` was assigned. `extraSettle` runs the
2482
3396
  * caller's own teardown (guide-style revert, dbl-click-zoom re-enable).
2483
3397
  */
2484
- runWithSlot(create, editHandleOf, extraSettle) {
3398
+ runWithSlot(create, activate, extraSettle) {
2485
3399
  let handle;
2486
- let editHandle = null;
3400
+ let deactivate = null;
2487
3401
  let settledDuringCreate = false;
2488
3402
  const clearSlot = () => {
2489
3403
  if (handle) {
2490
3404
  this.activeInteraction.clear(handle);
2491
- if (editHandle && this.activeEdit === editHandle) this.activeEdit = null;
3405
+ if (deactivate) {
3406
+ deactivate();
3407
+ deactivate = null;
3408
+ }
2492
3409
  } else {
2493
3410
  settledDuringCreate = true;
2494
3411
  this.activeInteraction.clear();
@@ -2501,7 +3418,6 @@ var TacticalDraw = class {
2501
3418
  this.activeInteraction.begin();
2502
3419
  try {
2503
3420
  handle = create(onSettled);
2504
- editHandle = editHandleOf ? editHandleOf(handle) : null;
2505
3421
  } catch (error) {
2506
3422
  this.activeInteraction.clear();
2507
3423
  if (!settledDuringCreate && extraSettle) extraSettle();
@@ -2509,7 +3425,7 @@ var TacticalDraw = class {
2509
3425
  }
2510
3426
  if (!settledDuringCreate) {
2511
3427
  this.activeInteraction.set(handle);
2512
- if (editHandle && !handle.settled) this.activeEdit = editHandle;
3428
+ if (activate && !handle.settled) deactivate = activate(handle) ?? null;
2513
3429
  }
2514
3430
  handle.promise.then(clearSlot, clearSlot);
2515
3431
  return handle.promise;
@@ -2525,6 +3441,18 @@ var TacticalDraw = class {
2525
3441
  };
2526
3442
  }
2527
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
+ /**
2528
3456
  * Start an interactive draw. Fixed-length kinds commit automatically on the
2529
3457
  * last required click. Variable-length kinds surface a `DrawSession` via
2530
3458
  * `opts.onSession` and commit on `session.commit()`, `dblclick`, or a second
@@ -2551,6 +3479,7 @@ var TacticalDraw = class {
2551
3479
  adapter: this.adapter,
2552
3480
  kind,
2553
3481
  previewLayer: this.previewLayer,
3482
+ guideLayer: this.guideLayer,
2554
3483
  graphicsStyle: this.graphicsStyle,
2555
3484
  generateId: () => draft.id ?? this.generateId(),
2556
3485
  signal: options?.signal,
@@ -2590,9 +3519,7 @@ var TacticalDraw = class {
2590
3519
  */
2591
3520
  edit(measure, options) {
2592
3521
  this.assertAlive();
2593
- if (options?.modes) {
2594
- for (const m of options.modes) if (m === "scale") throw new TypeError(`td.edit: edit mode "scale" is reserved and not yet implemented`);
2595
- }
3522
+ normaliseModes(options?.modes);
2596
3523
  const preempt = this.guardAndPreempt();
2597
3524
  if (preempt) return preempt;
2598
3525
  const wasOnGraphicsLayer = this.lastRenderedMeasures.some((m) => m.id === measure.id);
@@ -2625,7 +3552,71 @@ var TacticalDraw = class {
2625
3552
  wasOnGraphicsLayer,
2626
3553
  onSettled,
2627
3554
  onClickAwayGraphicsPick
2628
- }), (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);
2629
3620
  }
2630
3621
  /**
2631
3622
  * Subscribe to picks on committed control measures.
@@ -2693,6 +3684,15 @@ var TacticalDraw = class {
2693
3684
  return this.activeInteraction.session;
2694
3685
  }
2695
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
+ /**
2696
3696
  * Tear down the façade. Releases only layer slots the façade created; host
2697
3697
  * supplied layer ids are left untouched. Safe to call multiple times. After
2698
3698
  * the first call every public method throws `TacticalDrawDestroyedError`.
@@ -2702,6 +3702,8 @@ var TacticalDraw = class {
2702
3702
  this.destroyed = true;
2703
3703
  if (this.activeInteraction.isActive) {
2704
3704
  this.activeEdit = null;
3705
+ this.activeTransform = null;
3706
+ this.activeTransformSessionRef = null;
2705
3707
  this.activeInteraction.cancel("destroyed");
2706
3708
  } else this.activeInteraction.clear();
2707
3709
  this.adapter.offViewChange(this.handleViewChange);
@@ -2721,7 +3723,8 @@ var TacticalDraw = class {
2721
3723
  * from a host pick handler observes a settled session.
2722
3724
  */
2723
3725
  routePick(layerId, event) {
2724
- 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)) {
2725
3728
  this.deferredGraphicsPick = event;
2726
3729
  return;
2727
3730
  }
@@ -2760,9 +3763,19 @@ var TacticalDraw = class {
2760
3763
  return mergeSlot(BUILT_IN_INTERACTION_STYLE.guide, this.defaultInteractionStyle?.guide);
2761
3764
  }
2762
3765
  /**
2763
- * Resolve handle slots across built-in → façade → per-call with per-slot,
2764
- * per-property shallow merge. Explicit `undefined` at a higher-priority
2765
- * 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.
2766
3779
  *
2767
3780
  * Returns `undefined` only when no slot would carry any value — keeps the
2768
3781
  * downstream "no style on the feature" path intact for tests that assert
@@ -2774,6 +3787,10 @@ var TacticalDraw = class {
2774
3787
  const merged = mergeSlot(mergeSlot(BUILT_IN_INTERACTION_STYLE[slot], this.defaultInteractionStyle?.[slot]), perCall?.[slot]);
2775
3788
  if (merged) out[slot] = merged;
2776
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;
2777
3794
  return out;
2778
3795
  }
2779
3796
  assertAlive() {
@@ -2790,10 +3807,12 @@ const BUILT_IN_GRAPHICS_STYLE = {
2790
3807
  strokeWidth: 2
2791
3808
  };
2792
3809
  /**
2793
- * Built-in defaults for the interaction-affordance slots. The translate grip
2794
- * sits on the centroid pivot and the rotate grip just outside the shape; their
2795
- * distinct fills (blue / green) set them apart from the white vertex and gray
2796
- * 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.
2797
3816
  */
2798
3817
  const BUILT_IN_INTERACTION_STYLE = {
2799
3818
  guide: {
@@ -2801,6 +3820,10 @@ const BUILT_IN_INTERACTION_STYLE = {
2801
3820
  strokeWidth: 1,
2802
3821
  strokeDash: [6, 4]
2803
3822
  },
3823
+ boxOutline: {
3824
+ strokeColor: "#4f7cff",
3825
+ strokeWidth: 1
3826
+ },
2804
3827
  vertexHandle: {
2805
3828
  pointRadius: 6,
2806
3829
  fillColor: "#ffffff",
@@ -2813,13 +3836,14 @@ const BUILT_IN_INTERACTION_STYLE = {
2813
3836
  strokeColor: "#6b7280",
2814
3837
  strokeWidth: 1.5
2815
3838
  },
2816
- translateHandle: {
2817
- pointRadius: 7,
2818
- fillColor: "#2563eb",
2819
- strokeColor: "#ffffff"
3839
+ scaleHandle: {
3840
+ pointRadius: 5,
3841
+ fillColor: "#ffffff",
3842
+ strokeColor: "#4f7cff",
3843
+ strokeWidth: 2
2820
3844
  },
2821
3845
  rotateHandle: {
2822
- pointRadius: 7,
3846
+ pointRadius: 5,
2823
3847
  fillColor: "#16a34a",
2824
3848
  strokeColor: "#ffffff"
2825
3849
  }
@@ -2827,7 +3851,7 @@ const BUILT_IN_INTERACTION_STYLE = {
2827
3851
  const HANDLE_SLOTS = [
2828
3852
  "vertexHandle",
2829
3853
  "midpointHandle",
2830
- "translateHandle",
3854
+ "scaleHandle",
2831
3855
  "rotateHandle"
2832
3856
  ];
2833
3857
  /**