@orbat-mapper/tactical-draw 0.2.0-alpha.21 → 0.2.0-alpha.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -298,21 +298,24 @@ function resolveGroundTextSizePixels(textSizeMeters, resolution, text) {
298
298
  }
299
299
  //#endregion
300
300
  //#region src/gestures/hit-test.ts
301
- /** Squared distance from `pixel` to the segment p1→p2 in pixel space, or null if either endpoint fails to project or the segment has zero length. */
302
- function pixelDistanceToSegment(pixel, p1, p2, toPixel) {
303
- const a = toPixel(p1);
304
- const b = toPixel(p2);
305
- if (!a || !b) return null;
301
+ /** Clamped parameter t ∈ [0, 1] of `pixel` projected onto the segment a→b in pixel space, or null for a zero-length segment. */
302
+ function clampedSegmentParameter(pixel, a, b) {
306
303
  const dx = b[0] - a[0];
307
304
  const dy = b[1] - a[1];
308
305
  const lengthSq = dx * dx + dy * dy;
309
306
  if (lengthSq === 0) return null;
310
- let t = ((pixel[0] - a[0]) * dx + (pixel[1] - a[1]) * dy) / lengthSq;
311
- t = Math.max(0, Math.min(1, t));
312
- const cx = a[0] + t * dx;
313
- const cy = a[1] + t * dy;
314
- const ddx = pixel[0] - cx;
315
- const ddy = pixel[1] - cy;
307
+ const t = ((pixel[0] - a[0]) * dx + (pixel[1] - a[1]) * dy) / lengthSq;
308
+ return Math.max(0, Math.min(1, t));
309
+ }
310
+ /** Distance from `pixel` to the segment p1→p2 in pixel space, or null if either endpoint fails to project or the segment has zero length. */
311
+ function pixelDistanceToSegment(pixel, p1, p2, toPixel) {
312
+ const a = toPixel(p1);
313
+ const b = toPixel(p2);
314
+ if (!a || !b) return null;
315
+ const t = clampedSegmentParameter(pixel, a, b);
316
+ if (t === null) return null;
317
+ const ddx = pixel[0] - (a[0] + t * (b[0] - a[0]));
318
+ const ddy = pixel[1] - (a[1] + t * (b[1] - a[1]));
316
319
  return Math.sqrt(ddx * ddx + ddy * ddy);
317
320
  }
318
321
  /** Even-odd point-in-ring test in pixel space. */
@@ -1013,6 +1016,188 @@ function buildGuideFeatures(points, isAreaKind, trailingFixedSlots = 0) {
1013
1016
  return features;
1014
1017
  }
1015
1018
  //#endregion
1019
+ //#region src/tactical-draw/snapping.ts
1020
+ /** Resolve a draw-interaction map event through `snap`, falling back to the event coordinate. */
1021
+ function snappedDrawPosition(snap, event) {
1022
+ return snap?.({
1023
+ coordinate: event.coordinate,
1024
+ pixel: event.pixel,
1025
+ interaction: "draw",
1026
+ originalEvent: event.originalEvent
1027
+ })?.coordinate ?? event.coordinate;
1028
+ }
1029
+ const DEFAULT_TOLERANCE_PIXELS = 12;
1030
+ function graphicCandidates(graphics) {
1031
+ const candidates = [];
1032
+ for (const graphic of graphics) {
1033
+ if (isPointSymbol(graphic)) {
1034
+ candidates.push({
1035
+ id: `${graphic.id}:anchor`,
1036
+ coordinate: graphic.position,
1037
+ kind: "anchor",
1038
+ graphicId: graphic.id
1039
+ });
1040
+ continue;
1041
+ }
1042
+ graphic.controlPoints.forEach((coordinate, controlPointIndex) => {
1043
+ candidates.push({
1044
+ id: `${graphic.id}:control-point:${controlPointIndex}`,
1045
+ coordinate,
1046
+ kind: "control-point",
1047
+ graphicId: graphic.id,
1048
+ controlPointIndex
1049
+ });
1050
+ });
1051
+ }
1052
+ return candidates;
1053
+ }
1054
+ function isSelfCandidate(candidate, request) {
1055
+ if (request.graphicId === void 0 || candidate.graphicId !== request.graphicId) return false;
1056
+ if (request.interaction === "reshape" && request.controlPointIndex !== void 0) return candidate.controlPointIndex === void 0 || candidate.controlPointIndex === request.controlPointIndex;
1057
+ return true;
1058
+ }
1059
+ function isLabelFeature(feature) {
1060
+ const properties = feature.properties;
1061
+ return properties?.part === "label" || typeof properties?.labelPlacementKey === "string" || typeof properties?.text === "string";
1062
+ }
1063
+ function collectGeometryPaths(geometry, featureId, graphicId, out) {
1064
+ const add = (positions, suffix) => {
1065
+ if (positions.length > 0) out.push({
1066
+ id: `${featureId}:${suffix}`,
1067
+ graphicId,
1068
+ positions
1069
+ });
1070
+ };
1071
+ switch (geometry.type) {
1072
+ case "LineString":
1073
+ add(geometry.coordinates, "line");
1074
+ break;
1075
+ case "MultiLineString":
1076
+ geometry.coordinates.forEach((line, index) => add(line, `line:${index}`));
1077
+ break;
1078
+ case "Polygon":
1079
+ geometry.coordinates.forEach((ring, index) => add(ring, `ring:${index}`));
1080
+ break;
1081
+ case "MultiPolygon":
1082
+ geometry.coordinates.forEach((polygon, polygonIndex) => polygon.forEach((ring, ringIndex) => add(ring, `ring:${polygonIndex}:${ringIndex}`)));
1083
+ break;
1084
+ case "GeometryCollection":
1085
+ geometry.geometries.forEach((child, index) => collectGeometryPaths(child, `${featureId}:geometry:${index}`, graphicId, out));
1086
+ break;
1087
+ case "Point":
1088
+ case "MultiPoint": break;
1089
+ }
1090
+ }
1091
+ function indexGeometryFeatures(features) {
1092
+ const paths = [];
1093
+ features.forEach((feature, index) => {
1094
+ if (!feature.geometry || isLabelFeature(feature)) return;
1095
+ const graphicId = tryControlMeasureIdFromFeature(feature);
1096
+ if (!graphicId) return;
1097
+ const featureId = typeof feature.id === "string" ? feature.id : `${graphicId}:feature:${index}`;
1098
+ collectGeometryPaths(feature.geometry, featureId, graphicId, paths);
1099
+ });
1100
+ return paths;
1101
+ }
1102
+ function interpolatePosition(a, b, t) {
1103
+ const dimensions = Math.min(a.length, b.length);
1104
+ const position = [];
1105
+ for (let index = 0; index < dimensions; index += 1) position.push(a[index] + (b[index] - a[index]) * t);
1106
+ return position;
1107
+ }
1108
+ function generatedGeometryCandidates(adapter, paths, request, options) {
1109
+ const resolved = options === true ? {} : options;
1110
+ const edges = resolved.edges ?? true;
1111
+ const vertices = resolved.vertices ?? false;
1112
+ const candidates = [];
1113
+ for (const path of paths) {
1114
+ if (vertices) path.positions.forEach((coordinate, index) => {
1115
+ candidates.push({
1116
+ id: `${path.id}:vertex:${index}`,
1117
+ coordinate,
1118
+ kind: "graphic-vertex",
1119
+ graphicId: path.graphicId
1120
+ });
1121
+ });
1122
+ if (!edges) continue;
1123
+ for (let index = 1; index < path.positions.length; index += 1) {
1124
+ const a = path.positions[index - 1];
1125
+ const b = path.positions[index];
1126
+ const aPixel = adapter.getPixelFromCoordinate(a);
1127
+ const bPixel = adapter.getPixelFromCoordinate(b);
1128
+ if (!aPixel || !bPixel) continue;
1129
+ const t = clampedSegmentParameter(request.pixel, aPixel, bPixel) ?? 0;
1130
+ candidates.push({
1131
+ id: `${path.id}:edge:${index - 1}`,
1132
+ coordinate: interpolatePosition(a, b, t),
1133
+ kind: "graphic-edge",
1134
+ graphicId: path.graphicId
1135
+ });
1136
+ }
1137
+ }
1138
+ return candidates;
1139
+ }
1140
+ function createSnapResolver(adapter, getGraphics, getGeometryFeatures, getOptions) {
1141
+ let indexedGraphics;
1142
+ let indexedCandidates = [];
1143
+ let indexedFeatures;
1144
+ let indexedGeometryPaths = [];
1145
+ const builtInCandidates = () => {
1146
+ const graphics = getGraphics();
1147
+ if (graphics !== indexedGraphics) {
1148
+ indexedGraphics = graphics;
1149
+ indexedCandidates = graphicCandidates(graphics);
1150
+ }
1151
+ return indexedCandidates;
1152
+ };
1153
+ const geometryPaths = () => {
1154
+ const features = getGeometryFeatures();
1155
+ if (features !== indexedFeatures) {
1156
+ indexedFeatures = features;
1157
+ indexedGeometryPaths = indexGeometryFeatures(features);
1158
+ }
1159
+ return indexedGeometryPaths;
1160
+ };
1161
+ return (request) => {
1162
+ const options = getOptions();
1163
+ const clear = () => options?.onSnap?.(null);
1164
+ if (!options?.enabled || options.bypass?.(request.originalEvent)) {
1165
+ clear();
1166
+ return null;
1167
+ }
1168
+ const tolerance = options.tolerancePixels ?? DEFAULT_TOLERANCE_PIXELS;
1169
+ if (!Number.isFinite(tolerance) || tolerance < 0) {
1170
+ clear();
1171
+ return null;
1172
+ }
1173
+ const candidateLists = [];
1174
+ if (options.sources?.graphics !== false) candidateLists.push(builtInCandidates());
1175
+ const graphicGeometry = options.sources?.graphicGeometry;
1176
+ if (graphicGeometry) candidateLists.push(generatedGeometryCandidates(adapter, geometryPaths(), request, graphicGeometry));
1177
+ const external = options.sources?.external?.(request);
1178
+ if (external) candidateLists.push(external);
1179
+ let best = null;
1180
+ for (const list of candidateLists) for (const candidate of list) {
1181
+ if (isSelfCandidate(candidate, request)) continue;
1182
+ const pixel = adapter.getPixelFromCoordinate(candidate.coordinate);
1183
+ if (!pixel) continue;
1184
+ const distancePixels = Math.hypot(pixel[0] - request.pixel[0], pixel[1] - request.pixel[1]);
1185
+ if (distancePixels > tolerance) continue;
1186
+ const priority = candidate.priority ?? 0;
1187
+ const bestPriority = best?.candidate.priority ?? 0;
1188
+ if (best && (distancePixels > best.distancePixels || distancePixels === best.distancePixels && priority <= bestPriority)) continue;
1189
+ best = {
1190
+ coordinate: [...candidate.coordinate],
1191
+ pixel: [...pixel],
1192
+ candidate,
1193
+ distancePixels
1194
+ };
1195
+ }
1196
+ options.onSnap?.(best);
1197
+ return best;
1198
+ };
1199
+ }
1200
+ //#endregion
1016
1201
  //#region src/tactical-draw/draw-controller.ts
1017
1202
  const PREVIEW_CM_ID$1 = "__td_preview__";
1018
1203
  const PREVIEW_CURSOR$1 = "crosshair";
@@ -1030,7 +1215,7 @@ const PREVIEW_CURSOR$1 = "crosshair";
1030
1215
  * kinds need no host UI.
1031
1216
  */
1032
1217
  function runFixedLengthDraw(options) {
1033
- const { adapter, kind, previewLayer, guideLayer, graphicsStyle, generateId, signal, measureOptions, measureStyle, measureProperties, measureTextAmplifiers, guide: guideOpt, sizeAnchor, onSettled } = options;
1218
+ const { adapter, kind, previewLayer, guideLayer, graphicsStyle, generateId, signal, measureOptions, measureStyle, measureProperties, measureTextAmplifiers, guide: guideOpt, sizeAnchor, onSettled, snap } = options;
1034
1219
  const metadata = getControlMeasureMetadata(kind);
1035
1220
  const rule = metadata.rule;
1036
1221
  const min = metadata.minCoordinates;
@@ -1048,7 +1233,7 @@ function runFixedLengthDraw(options) {
1048
1233
  const savedCursor = "";
1049
1234
  const handleClick = (event) => {
1050
1235
  if (ctx.settled) return;
1051
- committed.push(event.coordinate);
1236
+ committed.push(snappedDrawPosition(snap, event));
1052
1237
  rubberBand = void 0;
1053
1238
  if (committed.length >= required) {
1054
1239
  ctx.commit(buildSnapshot);
@@ -1059,7 +1244,7 @@ function runFixedLengthDraw(options) {
1059
1244
  const handlePointerMove = (event) => {
1060
1245
  if (ctx.settled) return;
1061
1246
  if (committed.length === 0) return;
1062
- rubberBand = event.coordinate;
1247
+ rubberBand = snappedDrawPosition(snap, event);
1063
1248
  renderPreview();
1064
1249
  };
1065
1250
  function renderPreview() {
@@ -1285,7 +1470,7 @@ const PREVIEW_CURSOR = "crosshair";
1285
1470
  * added point again" as additional gesture-level commit triggers.
1286
1471
  */
1287
1472
  function runVariableLengthDraw(options) {
1288
- const { adapter, kind, previewLayer, guideLayer, graphicsStyle, generateId, signal, onSession, seed, measureOptions, measureStyle, measureProperties, measureTextAmplifiers, guide: guideOpt, sizeAnchor, onSettled } = options;
1473
+ const { adapter, kind, previewLayer, guideLayer, graphicsStyle, generateId, signal, onSession, seed, measureOptions, measureStyle, measureProperties, measureTextAmplifiers, guide: guideOpt, sizeAnchor, onSettled, snap } = options;
1289
1474
  const metadata = getControlMeasureMetadata(kind);
1290
1475
  if (typeof metadata.minCoordinates !== "number") throw new Error(`runVariableLengthDraw: kind "${kind}" has no minCoordinates metadata`);
1291
1476
  const min = metadata.minCoordinates;
@@ -1392,7 +1577,7 @@ function runVariableLengthDraw(options) {
1392
1577
  }
1393
1578
  const handleClick = (event) => {
1394
1579
  if (ctx.settled) return;
1395
- const pos = roundControlPoint(event.coordinate);
1580
+ const pos = roundControlPoint(snappedDrawPosition(snap, event));
1396
1581
  const lastSpineIdx = committed.length - 1 - trailingFixed;
1397
1582
  const finishIndices = closedRing && lastSpineIdx > 0 ? [lastSpineIdx, 0] : [lastSpineIdx];
1398
1583
  for (let i = 0; i < committed.length; i++) if (positionsEqual(pos, committed[i])) {
@@ -1433,7 +1618,7 @@ function runVariableLengthDraw(options) {
1433
1618
  const handlePointerMove = (event) => {
1434
1619
  if (ctx.settled) return;
1435
1620
  if (committed.length === 0) return;
1436
- rubberBand = event.coordinate;
1621
+ rubberBand = snappedDrawPosition(snap, event);
1437
1622
  renderPreview();
1438
1623
  };
1439
1624
  function renderPreview() {
@@ -1693,6 +1878,19 @@ function pickHandle(handles, pixel, tolerance, toPixel) {
1693
1878
  }
1694
1879
  return null;
1695
1880
  }
1881
+ //#endregion
1882
+ //#region src/tactical-draw/rotation.ts
1883
+ /** Excalidraw-style rotation increment used while Shift is held. */
1884
+ const CONSTRAINED_ROTATION_STEP = Math.PI / 12;
1885
+ /** Normalize radians to the canonical clockwise [0, 2π) range. */
1886
+ function normalizeRotation(angle) {
1887
+ const turn = Math.PI * 2;
1888
+ return (angle % turn + turn) % turn;
1889
+ }
1890
+ /** Snap an absolute rotation angle to the nearest 15-degree increment. */
1891
+ function constrainRotation(angle) {
1892
+ return Math.round(angle / CONSTRAINED_ROTATION_STEP) * CONSTRAINED_ROTATION_STEP;
1893
+ }
1696
1894
  function rotate(x, y, angle) {
1697
1895
  const cos = Math.cos(angle);
1698
1896
  const sin = Math.sin(angle);
@@ -1956,8 +2154,9 @@ function beginBoxDrag(args) {
1956
2154
  const pivotPixel = box.center;
1957
2155
  const pivot = fromPixel(pivotPixel);
1958
2156
  const startAngle = Math.atan2(downPixel[1] - pivotPixel[1], downPixel[0] - pivotPixel[0]);
1959
- const move = (pixel) => {
1960
- const angle = Math.atan2(pixel[1] - pivotPixel[1], pixel[0] - pivotPixel[0]) - startAngle;
2157
+ const move = (pixel, modifiers) => {
2158
+ const rawAngle = Math.atan2(pixel[1] - pivotPixel[1], pixel[0] - pivotPixel[0]) - startAngle;
2159
+ const angle = modifiers?.constrainRotation ? constrainRotation(box.angle + rawAngle) - box.angle : rawAngle;
1961
2160
  return {
1962
2161
  points: rotatePointsAround(preDragPoints, pivot, pivotPixel, angle, projection),
1963
2162
  box: {
@@ -1971,7 +2170,7 @@ function beginBoxDrag(args) {
1971
2170
  return {
1972
2171
  kind,
1973
2172
  move,
1974
- end: (pixel) => move(pixel).points
2173
+ end: (pixel, modifiers) => move(pixel, modifiers).points
1975
2174
  };
1976
2175
  }
1977
2176
  const origin = fromPixel(downPixel);
@@ -2035,6 +2234,102 @@ const BEGIN_DRAG_FOR_TEST = Symbol("EditSession.beginDragForTest");
2035
2234
  */
2036
2235
  const END_DRAG_FOR_TEST = Symbol("EditSession.endDragForTest");
2037
2236
  //#endregion
2237
+ //#region src/tactical-draw/session-history.ts
2238
+ /** @internal Controller-owned history stack. */
2239
+ function createSessionHistory(options) {
2240
+ const limit = Math.max(1, options.limit ?? 100);
2241
+ let entries = [{ value: options.clone(options.initial) }];
2242
+ let index = 0;
2243
+ let settled = false;
2244
+ const listeners = /* @__PURE__ */ new Set();
2245
+ function currentState() {
2246
+ if (settled) return {
2247
+ canUndo: false,
2248
+ canRedo: false
2249
+ };
2250
+ return {
2251
+ canUndo: index > 0,
2252
+ canRedo: index < entries.length - 1,
2253
+ ...index > 0 && entries[index]?.label ? { undoLabel: entries[index].label } : {},
2254
+ ...index < entries.length - 1 && entries[index + 1]?.label ? { redoLabel: entries[index + 1].label } : {}
2255
+ };
2256
+ }
2257
+ function notify() {
2258
+ const state = currentState();
2259
+ const errors = [];
2260
+ for (const listener of [...listeners]) try {
2261
+ listener(state);
2262
+ } catch (error) {
2263
+ errors.push(error);
2264
+ }
2265
+ if (errors.length === 1) throw errors[0];
2266
+ if (errors.length > 1) throw new AggregateError(errors, "SessionHistory subscriber errors");
2267
+ }
2268
+ function move(nextIndex, source) {
2269
+ if (settled || nextIndex < 0 || nextIndex >= entries.length) return false;
2270
+ const from = options.clone(entries[index].value);
2271
+ const to = options.clone(entries[nextIndex].value);
2272
+ try {
2273
+ if (!options.apply(from, to, source)) return false;
2274
+ } catch (error) {
2275
+ if (!options.isCurrent?.(to)) throw error;
2276
+ index = nextIndex;
2277
+ try {
2278
+ notify();
2279
+ } catch (notifyError) {
2280
+ throw new AggregateError([error, notifyError], "SessionHistory undo/redo errors");
2281
+ }
2282
+ throw error;
2283
+ }
2284
+ index = nextIndex;
2285
+ notify();
2286
+ return true;
2287
+ }
2288
+ return {
2289
+ get state() {
2290
+ return currentState();
2291
+ },
2292
+ undo() {
2293
+ return move(index - 1, "undo");
2294
+ },
2295
+ redo() {
2296
+ return move(index + 1, "redo");
2297
+ },
2298
+ subscribe(listener) {
2299
+ listener(currentState());
2300
+ if (settled) return () => {};
2301
+ listeners.add(listener);
2302
+ return () => listeners.delete(listener);
2303
+ },
2304
+ record(value, label) {
2305
+ if (settled || options.equals(entries[index].value, value)) return false;
2306
+ entries.splice(index + 1);
2307
+ entries.push({
2308
+ value,
2309
+ label
2310
+ });
2311
+ if (entries.length > limit + 1) entries.shift();
2312
+ index = entries.length - 1;
2313
+ notify();
2314
+ return true;
2315
+ },
2316
+ reset(value) {
2317
+ if (settled) return;
2318
+ entries = [{ value: options.clone(value) }];
2319
+ index = 0;
2320
+ notify();
2321
+ },
2322
+ settle() {
2323
+ if (settled) return;
2324
+ settled = true;
2325
+ entries = [];
2326
+ index = 0;
2327
+ notify();
2328
+ listeners.clear();
2329
+ }
2330
+ };
2331
+ }
2332
+ //#endregion
2038
2333
  //#region src/tactical-draw/box-features.ts
2039
2334
  /**
2040
2335
  * Rendering helpers shared by the single-measure edit controller
@@ -2325,7 +2620,7 @@ function mergeAmplifierPlacementField(existing, incoming) {
2325
2620
  * - close / abort / signal / preempt / destroy paths
2326
2621
  */
2327
2622
  function runEdit(opts) {
2328
- const { adapter, measure, graphicsLayer, previewLayer, guideLayer, handlesLayer, graphicsStyle, signal, closeOnClickAway = true, modes: initialModes, interactionStyle, onSession, handleIdPrefix, guide: guideOpt, restoreOriginalToGraphics, wasOnGraphicsLayer, onSettled, onClickAwayGraphicsPick } = opts;
2623
+ const { adapter, measure, graphicsLayer, previewLayer, guideLayer, handlesLayer, graphicsStyle, signal, closeOnClickAway = true, modes: initialModes, interactionStyle, onSession, handleIdPrefix, guide: guideOpt, restoreOriginalToGraphics, wasOnGraphicsLayer, onSettled, onClickAwayGraphicsPick, snap } = opts;
2329
2624
  const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
2330
2625
  let sizeAnchor = opts.sizeAnchor ?? "ground";
2331
2626
  const metadata = getControlMeasureMetadata(measure.kind);
@@ -2386,6 +2681,28 @@ function runEdit(opts) {
2386
2681
  * persisted on the measure; a fresh edit always starts back at `0`.
2387
2682
  */
2388
2683
  let boxAngle = 0;
2684
+ function captureHistory() {
2685
+ return {
2686
+ points: workingPoints.map((point) => [...point]),
2687
+ options: { ...workingOptions ?? {} },
2688
+ style: currentStyle ? { ...currentStyle } : void 0,
2689
+ textAmplifiers: workingTextAmplifiers ? { ...workingTextAmplifiers } : void 0,
2690
+ amplifierPlacements: snapshotAmplifierPlacements(),
2691
+ sizeAnchor,
2692
+ nonGeometryDirty,
2693
+ boxAngle
2694
+ };
2695
+ }
2696
+ function restoreHistory(value) {
2697
+ replaceWorkingPoints(value.points);
2698
+ replaceWorkingOptions(value.options);
2699
+ currentStyle = value.style ? { ...value.style } : void 0;
2700
+ workingTextAmplifiers = value.textAmplifiers ? { ...value.textAmplifiers } : void 0;
2701
+ workingAmplifierPlacements = value.amplifierPlacements ? structuredClone(value.amplifierPlacements) : void 0;
2702
+ sizeAnchor = value.sizeAnchor;
2703
+ nonGeometryDirty = value.nonGeometryDirty;
2704
+ boxAngle = value.boxAngle;
2705
+ }
2389
2706
  /**
2390
2707
  * The transform box fitted around the *rendered* working-copy geometry
2391
2708
  * (pixel space), or `null` when nothing projected this frame. Re-fitted
@@ -2612,12 +2929,13 @@ function runEdit(opts) {
2612
2929
  * - Reject restores the working state to the pre-gesture snapshot and
2613
2930
  * recomputes `dirty`. Close/abort settle the session as normal.
2614
2931
  */
2615
- function emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, previousSizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements) {
2932
+ function emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, previousSizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, source = "gesture", historyLabel = "Edit", previousBoxAngle = boxAngle) {
2616
2933
  if (ctx.settled) return;
2617
2934
  let rejectRequested = false;
2618
2935
  let closeRequested = false;
2619
2936
  let abortRequested = false;
2620
2937
  const event = {
2938
+ source,
2621
2939
  graphic: currentRendered,
2622
2940
  previous: previousRendered,
2623
2941
  session,
@@ -2646,11 +2964,13 @@ function runEdit(opts) {
2646
2964
  sizeAnchor = previousSizeAnchor;
2647
2965
  workingTextAmplifiers = previousTextAmplifiers;
2648
2966
  workingAmplifierPlacements = previousAmplifierPlacements;
2967
+ boxAngle = previousBoxAngle;
2649
2968
  nonGeometryDirty = previousNonGeometryDirty;
2650
2969
  renderPreview();
2651
2970
  renderHandlesLayer();
2652
2971
  recomputeDirty();
2653
2972
  }
2973
+ if (!ctx.settled && !rejectRequested && (source === "gesture" || source === "programmatic")) history.record(captureHistory(), historyLabel);
2654
2974
  if (errors.length === 1) throw errors[0];
2655
2975
  if (errors.length > 1) throw new AggregateError(errors, "EditSession.onChange listener errors");
2656
2976
  }
@@ -2664,7 +2984,7 @@ function runEdit(opts) {
2664
2984
  * queued during the drag are applied after the listener emission (so the
2665
2985
  * post-gesture render reflects the requested mode set).
2666
2986
  */
2667
- function commitGesture(nextPoints, preGestureOptions, nextAmplifierPlacements) {
2987
+ function commitGesture(nextPoints, preGestureOptions, nextAmplifierPlacements, preGestureBoxAngle = boxAngle) {
2668
2988
  if (ctx.settled) return;
2669
2989
  const previousPoints = workingPoints.map((p) => [...p]);
2670
2990
  const previousOptions = { ...preGestureOptions ?? workingOptions ?? {} };
@@ -2691,7 +3011,7 @@ function runEdit(opts) {
2691
3011
  recomputeDirty();
2692
3012
  dragInFlight = false;
2693
3013
  drainPendingModes();
2694
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements);
3014
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "gesture", "Edit", preGestureBoxAngle);
2695
3015
  }
2696
3016
  function setOptions(partial) {
2697
3017
  if (ctx.settled) return;
@@ -2706,6 +3026,7 @@ function runEdit(opts) {
2706
3026
  ...workingOptions ?? {},
2707
3027
  ...partial ?? {}
2708
3028
  });
3029
+ if (JSON.stringify(workingOptions) === JSON.stringify(previousOptions)) return;
2709
3030
  let currentRendered;
2710
3031
  try {
2711
3032
  currentRendered = rebuildRendered();
@@ -2716,7 +3037,7 @@ function runEdit(opts) {
2716
3037
  renderPreview();
2717
3038
  nonGeometryDirty = true;
2718
3039
  dirty = true;
2719
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements);
3040
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "programmatic", "Change options");
2720
3041
  }
2721
3042
  function setStyle(partial) {
2722
3043
  if (ctx.settled) return;
@@ -2731,6 +3052,7 @@ function runEdit(opts) {
2731
3052
  ...currentStyle ?? {},
2732
3053
  ...partial
2733
3054
  };
3055
+ if (JSON.stringify(currentStyle) === JSON.stringify(previousStyle)) return;
2734
3056
  let currentRendered;
2735
3057
  try {
2736
3058
  currentRendered = rebuildRendered();
@@ -2741,7 +3063,7 @@ function runEdit(opts) {
2741
3063
  renderPreview();
2742
3064
  nonGeometryDirty = true;
2743
3065
  dirty = true;
2744
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements);
3066
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "programmatic", "Change style");
2745
3067
  }
2746
3068
  /**
2747
3069
  * Shallow-merge into the live working text amplifiers. Mirrors
@@ -2776,6 +3098,7 @@ function runEdit(opts) {
2776
3098
  if (value !== void 0 && value.trim() !== "") merged[canonicalKey ?? key] = value;
2777
3099
  }
2778
3100
  workingTextAmplifiers = Object.keys(merged).length > 0 ? merged : void 0;
3101
+ if (JSON.stringify(workingTextAmplifiers) === JSON.stringify(previousTextAmplifiers)) return;
2779
3102
  let currentRendered;
2780
3103
  try {
2781
3104
  currentRendered = rebuildRendered();
@@ -2786,9 +3109,9 @@ function runEdit(opts) {
2786
3109
  renderPreview();
2787
3110
  nonGeometryDirty = true;
2788
3111
  dirty = true;
2789
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements);
3112
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "programmatic", "Change text");
2790
3113
  }
2791
- function setAmplifierPlacements(partial) {
3114
+ function setAmplifierPlacements(partial, source = "programmatic", historyLabel = "Move label") {
2792
3115
  if (ctx.settled) return;
2793
3116
  const previousPoints = workingPoints.map((p) => [...p]);
2794
3117
  const previousOptions = { ...workingOptions ?? {} };
@@ -2808,11 +3131,12 @@ function runEdit(opts) {
2808
3131
  else next[field] = merged;
2809
3132
  }
2810
3133
  workingAmplifierPlacements = Object.keys(next).length > 0 ? next : void 0;
3134
+ if (JSON.stringify(workingAmplifierPlacements) === JSON.stringify(previousAmplifierPlacements)) return;
2811
3135
  const currentRendered = rebuildRendered();
2812
3136
  renderPreview();
2813
3137
  nonGeometryDirty = true;
2814
3138
  dirty = true;
2815
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements);
3139
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, source, historyLabel);
2816
3140
  }
2817
3141
  /**
2818
3142
  * Flip the size anchor for the measure under edit (ADR-0020). Re-renders the
@@ -2835,7 +3159,7 @@ function runEdit(opts) {
2835
3159
  const currentRendered = rebuildRendered();
2836
3160
  nonGeometryDirty = true;
2837
3161
  dirty = true;
2838
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, previousSizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements);
3162
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, previousSizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "programmatic", "Change size anchor");
2839
3163
  }
2840
3164
  /** Apply a translate gesture by shifting every working point by `delta`. */
2841
3165
  function commitTranslate(delta) {
@@ -2864,7 +3188,7 @@ function runEdit(opts) {
2864
3188
  const pivot = box ? driver.fromPixel(box.center) : computeBoundingBoxCenter(workingPoints);
2865
3189
  if (!pivot) return;
2866
3190
  boxAngle += angle;
2867
- commitGesture(rotatePointsAround(workingPoints, pivot, pivotPixel, angle, dragProjection), preFreezeOptions);
3191
+ commitGesture(rotatePointsAround(workingPoints, pivot, pivotPixel, angle, dragProjection), preFreezeOptions, void 0, boxAngle - angle);
2868
3192
  }
2869
3193
  function modesEqual(a, b) {
2870
3194
  if (a.length !== b.length) return false;
@@ -2883,7 +3207,32 @@ function runEdit(opts) {
2883
3207
  pendingModes = null;
2884
3208
  applyModes(next);
2885
3209
  }
3210
+ const history = createSessionHistory({
3211
+ initial: captureHistory(),
3212
+ clone: (value) => ({
3213
+ points: value.points.map((point) => [...point]),
3214
+ options: { ...value.options ?? {} },
3215
+ style: value.style ? { ...value.style } : void 0,
3216
+ textAmplifiers: value.textAmplifiers ? { ...value.textAmplifiers } : void 0,
3217
+ amplifierPlacements: value.amplifierPlacements ? structuredClone(value.amplifierPlacements) : void 0,
3218
+ sizeAnchor: value.sizeAnchor,
3219
+ nonGeometryDirty: value.nonGeometryDirty,
3220
+ boxAngle: value.boxAngle
3221
+ }),
3222
+ equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
3223
+ isCurrent: (value) => JSON.stringify(captureHistory()) === JSON.stringify(value),
3224
+ apply(from, to, source) {
3225
+ const previousRendered = rebuildRendered();
3226
+ restoreHistory(to);
3227
+ renderPreview();
3228
+ renderHandlesLayer();
3229
+ recomputeDirty();
3230
+ emitChange(previousRendered, from.points, from.options, from.style, from.nonGeometryDirty, from.sizeAnchor, rebuildRendered(), from.textAmplifiers, from.amplifierPlacements, source, "Edit", from.boxAngle);
3231
+ return !ctx.settled && JSON.stringify(captureHistory()) === JSON.stringify(to);
3232
+ }
3233
+ });
2886
3234
  const session = {
3235
+ history,
2887
3236
  graphic: measure,
2888
3237
  get workingGraphic() {
2889
3238
  return cloneControlMeasure({
@@ -2943,6 +3292,7 @@ function runEdit(opts) {
2943
3292
  const current = session.workingGraphic;
2944
3293
  const next = updater(current);
2945
3294
  if (next.id !== measure.id || next.kind !== measure.kind) throw new TypeError("updateGraphic must preserve control-measure id and kind");
3295
+ if (JSON.stringify(next) === JSON.stringify(current)) return;
2946
3296
  renderMeasureForAdapter(adapter, next, renderOpts);
2947
3297
  workingPoints.splice(0, workingPoints.length, ...next.controlPoints.map((point) => [...point]));
2948
3298
  workingOptions = { ...next.options ?? {} };
@@ -2953,7 +3303,7 @@ function runEdit(opts) {
2953
3303
  renderPreview();
2954
3304
  renderHandlesLayer();
2955
3305
  recomputeDirty();
2956
- emitChange(previous, previous.graphic.controlPoints.map((point) => [...point]), previous.graphic.options, previous.graphic.style, previousNonGeometryDirty, previousSizeAnchor, rebuildRendered(), previous.graphic.textAmplifiers, previous.graphic.amplifierPlacements);
3306
+ emitChange(previous, previous.graphic.controlPoints.map((point) => [...point]), previous.graphic.options, previous.graphic.style, previousNonGeometryDirty, previousSizeAnchor, rebuildRendered(), previous.graphic.textAmplifiers, previous.graphic.amplifierPlacements, "programmatic", "Update graphic");
2957
3307
  },
2958
3308
  setOptions(partial) {
2959
3309
  setOptions(partial);
@@ -3004,6 +3354,38 @@ function runEdit(opts) {
3004
3354
  }
3005
3355
  };
3006
3356
  let activeDrag = null;
3357
+ function snappedPixel(pixel, info, interaction, controlPointIndex) {
3358
+ if (!snap) return pixel;
3359
+ return snap({
3360
+ coordinate: driver.fromPixel(pixel),
3361
+ pixel,
3362
+ interaction,
3363
+ graphicId: measure.id,
3364
+ controlPointIndex,
3365
+ originalEvent: info?.native
3366
+ })?.pixel ?? pixel;
3367
+ }
3368
+ function translatedBoxPixel(pixel, info, drag) {
3369
+ if (!snap || drag.fsm.kind !== "body") return pixel;
3370
+ const first = drag.preDragPoints[0];
3371
+ if (!first) return pixel;
3372
+ const cursor = driver.fromPixel(pixel);
3373
+ const origin = driver.fromPixel(drag.downPixel);
3374
+ const anchor = [...first];
3375
+ anchor[0] = first[0] + cursor[0] - origin[0];
3376
+ anchor[1] = first[1] + cursor[1] - origin[1];
3377
+ const anchorPixel = driver.toPixel(anchor);
3378
+ if (!anchorPixel) return pixel;
3379
+ const result = snap({
3380
+ coordinate: anchor,
3381
+ pixel: anchorPixel,
3382
+ interaction: "translate",
3383
+ graphicId: measure.id,
3384
+ originalEvent: info?.native
3385
+ });
3386
+ if (!result) return pixel;
3387
+ return [pixel[0] + result.pixel[0] - anchorPixel[0], pixel[1] + result.pixel[1] - anchorPixel[1]];
3388
+ }
3007
3389
  /**
3008
3390
  * A live label *move* drag: direct label grab, the floating grip, or a body
3009
3391
  * grab inside the selected label's transform box (ADR-0017 box machinery
@@ -3205,7 +3587,8 @@ function runEdit(opts) {
3205
3587
  });
3206
3588
  activeDrag = {
3207
3589
  fsm,
3208
- preDragPoints
3590
+ preDragPoints,
3591
+ controlPointIndex: hit.kind === "midpoint" ? hit.index + 1 : hit.index
3209
3592
  };
3210
3593
  if (fsm.kind === "midpoint") {
3211
3594
  workingPoints.splice(0, workingPoints.length, ...initialPoints);
@@ -3250,17 +3633,19 @@ function runEdit(opts) {
3250
3633
  }
3251
3634
  }
3252
3635
  };
3253
- function paintDragSnapshot(pixel) {
3636
+ function paintDragSnapshot(pixel, info) {
3254
3637
  if (!activeDrag) return;
3255
- const snapshot = activeDrag.fsm.move(pixel);
3638
+ const resolvedPixel = snappedPixel(pixel, info, "reshape", activeDrag.controlPointIndex);
3639
+ const snapshot = activeDrag.fsm.move(resolvedPixel);
3256
3640
  workingPoints.splice(0, workingPoints.length, ...snapshot.points);
3257
3641
  renderPreview();
3258
3642
  renderHandlesLayer();
3259
3643
  recomputeDirty();
3260
3644
  }
3261
- function paintBoxDragSnapshot(pixel) {
3645
+ function paintBoxDragSnapshot(pixel, info, constrainRotation = false) {
3262
3646
  if (!activeBoxDrag) return;
3263
- const snapshot = activeBoxDrag.fsm.move(pixel);
3647
+ const resolvedPixel = translatedBoxPixel(pixel, info, activeBoxDrag);
3648
+ const snapshot = activeBoxDrag.fsm.move(resolvedPixel, { constrainRotation });
3264
3649
  activeBoxDrag.lastSnapshot = snapshot;
3265
3650
  workingPoints.splice(0, workingPoints.length, ...snapshot.points);
3266
3651
  workingAmplifierPlacements = transformAmplifierPlacements(activeBoxDrag.preDragAmplifierPlacements, activeBoxDrag.preDragBox, snapshot.box, driver);
@@ -3268,7 +3653,7 @@ function runEdit(opts) {
3268
3653
  renderHandlesLayer();
3269
3654
  recomputeDirty();
3270
3655
  }
3271
- const onDriverMove = (pixel) => {
3656
+ const onDriverMove = (pixel, info) => {
3272
3657
  if (ctx.settled) return;
3273
3658
  if (activeLabelRotateDrag) {
3274
3659
  const drag = activeLabelRotateDrag;
@@ -3303,8 +3688,8 @@ function runEdit(opts) {
3303
3688
  };
3304
3689
  renderPreview();
3305
3690
  renderHandlesLayer();
3306
- } else if (activeDrag) paintDragSnapshot(pixel);
3307
- else if (activeBoxDrag) paintBoxDragSnapshot(pixel);
3691
+ } else if (activeDrag) paintDragSnapshot(pixel, info);
3692
+ else if (activeBoxDrag) paintBoxDragSnapshot(pixel, info, info?.shiftKey);
3308
3693
  };
3309
3694
  /** Reset drag-in-flight bookkeeping shared by every "gesture ended" path. */
3310
3695
  function endDragBookkeeping() {
@@ -3312,7 +3697,7 @@ function runEdit(opts) {
3312
3697
  dragInFlight = false;
3313
3698
  drainPendingModes();
3314
3699
  }
3315
- const onDriverUp = (pixel) => {
3700
+ const onDriverUp = (pixel, info) => {
3316
3701
  if (activeLabelRotateDrag) {
3317
3702
  const drag = activeLabelRotateDrag;
3318
3703
  activeLabelRotateDrag = null;
@@ -3321,7 +3706,7 @@ function runEdit(opts) {
3321
3706
  if (Math.hypot(pixel[0] - drag.downPixel[0], pixel[1] - drag.downPixel[1]) > CLICK_SLOP_PX$3) {
3322
3707
  const delta = pointerAngleAround(pixel, drag.anchorPixel) - drag.startPointerAngle;
3323
3708
  const rotation = drag.startRotation - delta;
3324
- setAmplifierPlacements({ [drag.key]: { rotation } });
3709
+ setAmplifierPlacements({ [drag.key]: { rotation } }, "gesture", "Rotate label");
3325
3710
  } else {
3326
3711
  renderPreview();
3327
3712
  renderHandlesLayer();
@@ -3335,7 +3720,7 @@ function runEdit(opts) {
3335
3720
  const position = labelAnchorFromPointer(pixel, grabOffset);
3336
3721
  workingAmplifierPlacements = previous;
3337
3722
  endDragBookkeeping();
3338
- if (position && Math.hypot(pixel[0] - downPixel[0], pixel[1] - downPixel[1]) > CLICK_SLOP_PX$3) setAmplifierPlacements({ [field]: position });
3723
+ if (position && Math.hypot(pixel[0] - downPixel[0], pixel[1] - downPixel[1]) > CLICK_SLOP_PX$3) setAmplifierPlacements({ [field]: position }, "gesture", "Move label");
3339
3724
  else {
3340
3725
  selectedLabelKey = selectedLabelKey === field ? null : field;
3341
3726
  renderPreview();
@@ -3351,10 +3736,10 @@ function runEdit(opts) {
3351
3736
  driver.setDragPanEnabled(true);
3352
3737
  return;
3353
3738
  }
3354
- const { fsm, preDragPoints } = activeDrag;
3739
+ const { fsm, preDragPoints, controlPointIndex } = activeDrag;
3355
3740
  activeDrag = null;
3356
3741
  driver.setDragPanEnabled(true);
3357
- const next = fsm.end(pixel);
3742
+ const next = fsm.end(snappedPixel(pixel, info, "reshape", controlPointIndex));
3358
3743
  replaceWorkingPoints(preDragPoints);
3359
3744
  commitGesture(next);
3360
3745
  return;
@@ -3380,11 +3765,16 @@ function runEdit(opts) {
3380
3765
  bodyClickPending = true;
3381
3766
  return;
3382
3767
  }
3383
- const finalSnapshot = fsm.move(pixel);
3768
+ const finalPixel = translatedBoxPixel(pixel, info, {
3769
+ fsm,
3770
+ preDragPoints,
3771
+ downPixel
3772
+ });
3773
+ const finalSnapshot = fsm.move(finalPixel, { constrainRotation: info?.shiftKey });
3384
3774
  replaceWorkingPoints(preDragPoints);
3385
3775
  workingAmplifierPlacements = preDragAmplifierPlacements;
3386
3776
  if (fsm.kind === "rotate") boxAngle += finalSnapshot.box.angle - preDragBox.angle;
3387
- commitGesture(finalSnapshot.points, preDragOptions, transformAmplifierPlacements(preDragAmplifierPlacements, preDragBox, finalSnapshot.box, driver));
3777
+ commitGesture(finalSnapshot.points, preDragOptions, transformAmplifierPlacements(preDragAmplifierPlacements, preDragBox, finalSnapshot.box, driver), preDragBox.angle);
3388
3778
  return;
3389
3779
  }
3390
3780
  };
@@ -3435,8 +3825,12 @@ function runEdit(opts) {
3435
3825
  ],
3436
3826
  onViewChange: rescaleOnViewChange,
3437
3827
  onSettled: (settlement) => {
3438
- if (onSettled) onSettled();
3439
- settleFromInteraction(settlement, commitEmitter, settleEmitter);
3828
+ try {
3829
+ history.settle();
3830
+ } finally {
3831
+ if (onSettled) onSettled();
3832
+ settleFromInteraction(settlement, commitEmitter, settleEmitter);
3833
+ }
3440
3834
  },
3441
3835
  attach(c) {
3442
3836
  ctx = c;
@@ -3641,6 +4035,25 @@ function runGroupTransform(opts) {
3641
4035
  const settleEmitter = createSettleEmitter("TransformSession.onSettled listener errors");
3642
4036
  /** Session-scoped box rotation (ADR-0017 decision #2), shared by the whole group. */
3643
4037
  let boxAngle = 0;
4038
+ function captureHistory() {
4039
+ return {
4040
+ ids: members.map((member) => member.measure.id),
4041
+ points: clonePointsByMember(),
4042
+ amplifierPlacements: cloneAmplifierPlacementsByMember(),
4043
+ optionsOverrides: cloneOptionsOverrideByMember(),
4044
+ sizeAnchor,
4045
+ nonGeometryDirty,
4046
+ boxAngle
4047
+ };
4048
+ }
4049
+ function restoreHistory(value) {
4050
+ restorePointsByMember(value.points);
4051
+ applyAmplifierPlacementsByMember(value.amplifierPlacements.map((placements) => placements ? structuredClone(placements) : void 0));
4052
+ applyOptionsOverrides(value.optionsOverrides);
4053
+ sizeAnchor = value.sizeAnchor;
4054
+ nonGeometryDirty = value.nonGeometryDirty;
4055
+ boxAngle = value.boxAngle;
4056
+ }
3644
4057
  /** The group box fitted over every member's rendered geometry, or `null` when nothing projects this frame. */
3645
4058
  let groupBox = null;
3646
4059
  let ctx;
@@ -3817,12 +4230,13 @@ function runGroupTransform(opts) {
3817
4230
  * instance to all listeners in order, deferred `reject`/`close`/`abort`
3818
4231
  * (close/abort override reject), exception collection with `AggregateError`.
3819
4232
  */
3820
- function emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered) {
4233
+ function emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered, source = "gesture", historyLabel = "Transform", previousBoxAngle = boxAngle) {
3821
4234
  if (ctx.settled) return;
3822
4235
  let rejectRequested = false;
3823
4236
  let closeRequested = false;
3824
4237
  let abortRequested = false;
3825
4238
  const event = {
4239
+ source,
3826
4240
  graphics: currentRendered,
3827
4241
  previous: previousRendered,
3828
4242
  session,
@@ -3851,10 +4265,12 @@ function runGroupTransform(opts) {
3851
4265
  sizeAnchor = previousSizeAnchor;
3852
4266
  nonGeometryDirty = previousNonGeometryDirty;
3853
4267
  applyOptionsOverrides(previousOptionsOverrideByMember);
4268
+ boxAngle = previousBoxAngle;
3854
4269
  renderAllPreviews();
3855
4270
  renderHandlesLayer();
3856
4271
  recomputeDirty();
3857
4272
  }
4273
+ if (!ctx.settled && !rejectRequested && members === membersAtEmission && (source === "gesture" || source === "programmatic")) history.record(captureHistory(), historyLabel);
3858
4274
  if (errors.length === 1) throw errors[0];
3859
4275
  if (errors.length > 1) throw new AggregateError(errors, "TransformSession.onChange listener errors");
3860
4276
  }
@@ -3880,7 +4296,7 @@ function runGroupTransform(opts) {
3880
4296
  }
3881
4297
  return snapshots;
3882
4298
  }
3883
- function commitGesture(nextConcatPoints, preGestureOptionsOverrideByMember, nextAmplifierPlacementsByMember) {
4299
+ function commitGesture(nextConcatPoints, preGestureOptionsOverrideByMember, nextAmplifierPlacementsByMember, preGestureBoxAngle = boxAngle) {
3884
4300
  if (ctx.settled) return;
3885
4301
  const previousPointsByMember = clonePointsByMember();
3886
4302
  const previousAmplifierPlacementsByMember = cloneAmplifierPlacementsByMember();
@@ -3901,7 +4317,7 @@ function runGroupTransform(opts) {
3901
4317
  renderHandlesLayer();
3902
4318
  recomputeDirty();
3903
4319
  dragInFlight = false;
3904
- emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, sizeAnchor, nonGeometryDirty, previousOptionsOverrideByMember, currentRendered);
4320
+ emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, sizeAnchor, nonGeometryDirty, previousOptionsOverrideByMember, currentRendered, "gesture", "Transform", preGestureBoxAngle);
3905
4321
  }
3906
4322
  function setSizeAnchor(next) {
3907
4323
  if (ctx.settled || sizeAnchor === next) return;
@@ -3915,7 +4331,7 @@ function runGroupTransform(opts) {
3915
4331
  const currentRendered = rebuildRenderedAll();
3916
4332
  nonGeometryDirty = true;
3917
4333
  dirty = true;
3918
- emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered);
4334
+ emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered, "programmatic", "Change size anchor");
3919
4335
  }
3920
4336
  /**
3921
4337
  * Cancel an in-flight box drag without committing it: restore the pre-drag
@@ -3962,13 +4378,42 @@ function runGroupTransform(opts) {
3962
4378
  renderAllPreviews();
3963
4379
  renderHandlesLayer();
3964
4380
  recomputeDirty();
3965
- if (removedSnapshots.length > 0) commitEmitter.emit(removedSnapshots);
4381
+ const notificationErrors = [];
4382
+ try {
4383
+ history.reset(captureHistory());
4384
+ } catch (error) {
4385
+ notificationErrors.push(error);
4386
+ }
4387
+ if (removedSnapshots.length > 0) try {
4388
+ commitEmitter.emit(removedSnapshots);
4389
+ } catch (error) {
4390
+ notificationErrors.push(error);
4391
+ }
4392
+ if (notificationErrors.length === 1) throw notificationErrors[0];
4393
+ if (notificationErrors.length > 1) throw new AggregateError(notificationErrors, "TransformSession.setGraphics notification errors");
3966
4394
  return removedSnapshots;
3967
4395
  }
3968
4396
  function close() {
3969
4397
  return commitClose();
3970
4398
  }
4399
+ const history = createSessionHistory({
4400
+ initial: captureHistory(),
4401
+ clone: (value) => structuredClone(value),
4402
+ equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
4403
+ isCurrent: (value) => JSON.stringify(captureHistory()) === JSON.stringify(value),
4404
+ apply(from, to, source) {
4405
+ if (JSON.stringify(from.ids) !== JSON.stringify(to.ids)) return false;
4406
+ const previousRendered = rebuildRenderedAll();
4407
+ restoreHistory(to);
4408
+ renderAllPreviews();
4409
+ renderHandlesLayer();
4410
+ recomputeDirty();
4411
+ emitChange(previousRendered, from.points, from.amplifierPlacements, from.sizeAnchor, from.nonGeometryDirty, from.optionsOverrides, rebuildRenderedAll(), source, "Transform", from.boxAngle);
4412
+ return !ctx.settled && JSON.stringify(captureHistory()) === JSON.stringify(to);
4413
+ }
4414
+ });
3971
4415
  const session = {
4416
+ history,
3972
4417
  get graphics() {
3973
4418
  return members.map((m) => m.measure);
3974
4419
  },
@@ -4044,9 +4489,9 @@ function runGroupTransform(opts) {
4044
4489
  dragInFlight = true;
4045
4490
  driver.setDragPanEnabled(false);
4046
4491
  };
4047
- function paintBoxDragSnapshot(pixel) {
4492
+ function paintBoxDragSnapshot(pixel, constrainRotation = false) {
4048
4493
  if (!activeBoxDrag) return;
4049
- const snapshot = activeBoxDrag.fsm.move(pixel);
4494
+ const snapshot = activeBoxDrag.fsm.move(pixel, { constrainRotation });
4050
4495
  activeBoxDrag.lastSnapshot = snapshot;
4051
4496
  applyConcatenatedPoints(snapshot.points);
4052
4497
  applyAmplifierPlacementsByMember(activeBoxDrag.preDragAmplifierPlacementsByMember.map((placements) => transformAmplifierPlacements(placements, activeBoxDrag.preDragBox, snapshot.box, driver)));
@@ -4054,11 +4499,11 @@ function runGroupTransform(opts) {
4054
4499
  renderHandlesLayer();
4055
4500
  recomputeDirty();
4056
4501
  }
4057
- const onDriverMove = (pixel) => {
4502
+ const onDriverMove = (pixel, info) => {
4058
4503
  if (ctx.settled) return;
4059
- if (activeBoxDrag) paintBoxDragSnapshot(pixel);
4504
+ if (activeBoxDrag) paintBoxDragSnapshot(pixel, info?.shiftKey);
4060
4505
  };
4061
- const onDriverUp = (pixel) => {
4506
+ const onDriverUp = (pixel, info) => {
4062
4507
  if (!activeBoxDrag) return;
4063
4508
  if (ctx.settled) {
4064
4509
  activeBoxDrag = null;
@@ -4080,11 +4525,11 @@ function runGroupTransform(opts) {
4080
4525
  bodyClickPending = true;
4081
4526
  return;
4082
4527
  }
4083
- const finalSnapshot = fsm.move(pixel);
4528
+ const finalSnapshot = fsm.move(pixel, { constrainRotation: info?.shiftKey });
4084
4529
  applyConcatenatedPoints(preDragPoints);
4085
4530
  applyAmplifierPlacementsByMember(preDragAmplifierPlacementsByMember);
4086
4531
  if (fsm.kind === "rotate") boxAngle += finalSnapshot.box.angle - preDragBox.angle;
4087
- commitGesture(finalSnapshot.points, preDragOptionsOverrideByMember, preDragAmplifierPlacementsByMember.map((placements) => transformAmplifierPlacements(placements, preDragBox, finalSnapshot.box, driver)));
4532
+ commitGesture(finalSnapshot.points, preDragOptionsOverrideByMember, preDragAmplifierPlacementsByMember.map((placements) => transformAmplifierPlacements(placements, preDragBox, finalSnapshot.box, driver)), preDragBox.angle);
4088
4533
  };
4089
4534
  let pickPreview;
4090
4535
  let pickHandles;
@@ -4130,8 +4575,12 @@ function runGroupTransform(opts) {
4130
4575
  layers: [previewLayer, handlesLayer],
4131
4576
  onViewChange: rescaleOnViewChange,
4132
4577
  onSettled: (settlement) => {
4133
- if (onSettled) onSettled();
4134
- if (settlement.status === "rejected") settleEmitter.emit(settleReasonFromError(settlement.error));
4578
+ try {
4579
+ history.settle();
4580
+ } finally {
4581
+ if (onSettled) onSettled();
4582
+ if (settlement.status === "rejected") settleEmitter.emit(settleReasonFromError(settlement.error));
4583
+ }
4135
4584
  },
4136
4585
  attach(c) {
4137
4586
  ctx = c;
@@ -4245,13 +4694,6 @@ function pointSymbolFeature(symbol, render, resolution) {
4245
4694
  };
4246
4695
  }
4247
4696
  //#endregion
4248
- //#region src/tactical-draw/rotation.ts
4249
- /** Normalize radians to the canonical clockwise [0, 2π) range. */
4250
- function normalizeRotation(angle) {
4251
- const turn = Math.PI * 2;
4252
- return (angle % turn + turn) % turn;
4253
- }
4254
- //#endregion
4255
4697
  //#region src/tactical-draw/graphic-transform-controller.ts
4256
4698
  const HIT_TOLERANCE_PX$1 = 12;
4257
4699
  const CLICK_SLOP_PX$1 = 3;
@@ -4272,13 +4714,22 @@ function runGraphicTransform(opts) {
4272
4714
  const commitEmitter = createCommitEmitter("GraphicTransformSession.onCommit listener errors");
4273
4715
  const settleEmitter = createSettleEmitter("GraphicTransformSession.onSettled listener errors");
4274
4716
  const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
4275
- const snapshots = () => working.map((graphic) => isPointSymbol(graphic) ? {
4717
+ const snapshotsFor = (source) => source.map((graphic) => isPointSymbol(graphic) ? {
4276
4718
  graphic: clonePointSymbol(graphic),
4277
4719
  render: capability.render(graphic)
4278
4720
  } : {
4279
4721
  graphic: cloneControlMeasure(graphic),
4280
4722
  render: renderMeasureForAdapter(adapter, graphic, renderOpts)
4281
4723
  });
4724
+ const snapshots = () => snapshotsFor(working);
4725
+ const captureHistory = () => ({
4726
+ working: working.map(cloneGraphic),
4727
+ boxAngle
4728
+ });
4729
+ const cloneHistory = (value) => ({
4730
+ working: value.working.map(cloneGraphic),
4731
+ boxAngle: value.boxAngle
4732
+ });
4282
4733
  function renderAll() {
4283
4734
  if (ctx.settled) return;
4284
4735
  const features = [];
@@ -4345,12 +4796,14 @@ function runGraphicTransform(opts) {
4345
4796
  return next;
4346
4797
  });
4347
4798
  }
4348
- function emit(previous, previousGraphics) {
4799
+ function emit(previousState, source) {
4800
+ const previous = snapshotsFor(previousState.working);
4349
4801
  const current = snapshots();
4350
4802
  let reject = false;
4351
4803
  let close = false;
4352
4804
  let abort = false;
4353
4805
  const event = {
4806
+ source,
4354
4807
  graphics: current,
4355
4808
  previous,
4356
4809
  session,
@@ -4367,14 +4820,30 @@ function runGraphicTransform(opts) {
4367
4820
  if (abort) ctx.fail(new TacticalDrawAbortError("session"));
4368
4821
  else if (close) ctx.commit(snapshots);
4369
4822
  else if (reject && !ctx.settled) {
4370
- working = previousGraphics.map(cloneGraphic);
4823
+ working = previousState.working.map(cloneGraphic);
4824
+ boxAngle = previousState.boxAngle;
4371
4825
  dirty = JSON.stringify(working) !== JSON.stringify(original);
4372
4826
  renderAll();
4373
4827
  }
4374
4828
  if (errors.length === 1) throw errors[0];
4375
4829
  if (errors.length > 1) throw new AggregateError(errors, "GraphicTransformSession.onChange errors");
4376
- }
4830
+ return !ctx.settled && !reject;
4831
+ }
4832
+ const history = createSessionHistory({
4833
+ initial: captureHistory(),
4834
+ clone: cloneHistory,
4835
+ equals: (a, b) => a.boxAngle === b.boxAngle && JSON.stringify(a.working) === JSON.stringify(b.working),
4836
+ isCurrent: (value) => boxAngle === value.boxAngle && JSON.stringify(working) === JSON.stringify(value.working),
4837
+ apply(from, to, source) {
4838
+ working = to.working.map(cloneGraphic);
4839
+ boxAngle = to.boxAngle;
4840
+ dirty = JSON.stringify(working) !== JSON.stringify(original);
4841
+ renderAll();
4842
+ return emit(from, source);
4843
+ }
4844
+ });
4377
4845
  const session = {
4846
+ history,
4378
4847
  get graphics() {
4379
4848
  return original.map(cloneGraphic);
4380
4849
  },
@@ -4421,9 +4890,9 @@ function runGraphicTransform(opts) {
4421
4890
  };
4422
4891
  driver.setDragPanEnabled(false);
4423
4892
  };
4424
- const dragResult = (pixel) => {
4893
+ const dragResult = (pixel, constrainRotation = false) => {
4425
4894
  if (!drag) return null;
4426
- const result = drag.fsm.move(pixel);
4895
+ const result = drag.fsm.move(pixel, { constrainRotation });
4427
4896
  const scale = drag.fsm.kind === "scale" ? Math.max(result.box.halfWidth / drag.startBox.halfWidth, result.box.halfHeight / drag.startBox.halfHeight) : 1;
4428
4897
  const rotation = drag.fsm.kind === "rotate" ? result.box.angle - drag.startBox.angle : 0;
4429
4898
  return {
@@ -4431,18 +4900,18 @@ function runGraphicTransform(opts) {
4431
4900
  graphics: applyPoints(drag.start, result.points, scale, rotation)
4432
4901
  };
4433
4902
  };
4434
- const onMove = (pixel) => {
4903
+ const onMove = (pixel, info) => {
4435
4904
  const active = drag;
4436
- const next = dragResult(pixel);
4905
+ const next = dragResult(pixel, info?.shiftKey);
4437
4906
  if (!active || !next) return;
4438
4907
  working = next.graphics;
4439
4908
  active.liveBox = next.result.box;
4440
4909
  renderAll();
4441
4910
  };
4442
- const onUp = (pixel) => {
4911
+ const onUp = (pixel, info) => {
4443
4912
  if (!drag) return;
4444
4913
  const active = drag;
4445
- const next = dragResult(pixel);
4914
+ const next = dragResult(pixel, info?.shiftKey);
4446
4915
  drag = null;
4447
4916
  suppressClickAway = true;
4448
4917
  driver.setDragPanEnabled(true);
@@ -4452,13 +4921,15 @@ function runGraphicTransform(opts) {
4452
4921
  renderAll();
4453
4922
  return;
4454
4923
  }
4455
- const previous = snapshots();
4456
- const previousGraphics = working.map(cloneGraphic);
4924
+ const previousState = captureHistory();
4457
4925
  working = next.graphics;
4458
4926
  if (active.fsm.kind === "rotate") boxAngle += next.result.box.angle - active.startBox.angle;
4459
4927
  dirty = JSON.stringify(working) !== JSON.stringify(original);
4460
4928
  renderAll();
4461
- emit(previous, previousGraphics);
4929
+ if (emit(previousState, "gesture")) {
4930
+ const label = `${active.fsm.kind[0].toUpperCase()}${active.fsm.kind.slice(1)}`;
4931
+ history.record(captureHistory(), label);
4932
+ }
4462
4933
  };
4463
4934
  let previewPick;
4464
4935
  let handlesPick;
@@ -4481,8 +4952,12 @@ function runGraphicTransform(opts) {
4481
4952
  layers: [previewLayer, handlesLayer],
4482
4953
  onViewChange: renderAll,
4483
4954
  onSettled(settlement) {
4484
- onSettled?.();
4485
- settleFromInteraction(settlement, commitEmitter, settleEmitter);
4955
+ try {
4956
+ history.settle();
4957
+ } finally {
4958
+ onSettled?.();
4959
+ settleFromInteraction(settlement, commitEmitter, settleEmitter);
4960
+ }
4486
4961
  },
4487
4962
  attach(interaction) {
4488
4963
  ctx = interaction;
@@ -4548,7 +5023,7 @@ var PointSymbolCapabilityRequiredError = class extends Error {
4548
5023
  //#region src/tactical-draw/point-symbol-draw-controller.ts
4549
5024
  const PREVIEW_ID = "__td_point_symbol_preview__";
4550
5025
  function runPointSymbolDraw(options) {
4551
- const { adapter, capability, draft, previewLayer, generateId, signal, onSettled } = options;
5026
+ const { adapter, capability, draft, previewLayer, generateId, signal, onSettled, snap } = options;
4552
5027
  let latestPosition = draft.position;
4553
5028
  let ctx;
4554
5029
  const savedCursor = "";
@@ -4568,13 +5043,13 @@ function runPointSymbolDraw(options) {
4568
5043
  ctx.writeLayer(previewLayer, [plan.feature]);
4569
5044
  };
4570
5045
  const handlePointerMove = (event) => {
4571
- latestPosition = event.coordinate;
5046
+ latestPosition = snappedDrawPosition(snap, event);
4572
5047
  renderPreview();
4573
5048
  };
4574
5049
  const handleClick = (event) => {
4575
- latestPosition = event.coordinate;
5050
+ latestPosition = snappedDrawPosition(snap, event);
4576
5051
  ctx.commit(() => {
4577
- const graphic = build(draft.id ?? generateId(), event.coordinate);
5052
+ const graphic = build(draft.id ?? generateId(), latestPosition);
4578
5053
  return {
4579
5054
  graphic: clonePointSymbol(graphic),
4580
5055
  render: capability.render(graphic)
@@ -4621,7 +5096,7 @@ function symbolBox(adapter, capability, symbol, padding = 0) {
4621
5096
  };
4622
5097
  }
4623
5098
  function runPointSymbolEdit(opts) {
4624
- const { adapter, capability, symbol, graphicsLayer, previewLayer, handlesLayer, signal, closeOnClickAway = true, interactionStyle, onSession, handleIdPrefix, restoreOriginalToGraphics, wasOnGraphicsLayer, onSettled, onClickAwayGraphicsPick } = opts;
5099
+ const { adapter, capability, symbol, graphicsLayer, previewLayer, handlesLayer, signal, closeOnClickAway = true, interactionStyle, onSession, handleIdPrefix, restoreOriginalToGraphics, wasOnGraphicsLayer, onSettled, onClickAwayGraphicsPick, snap } = opts;
4625
5100
  let modes = normalisePointModes(opts.modes);
4626
5101
  let working = clonePointSymbol(symbol);
4627
5102
  let dirty = false;
@@ -4632,6 +5107,8 @@ function runPointSymbolEdit(opts) {
4632
5107
  const listeners = /* @__PURE__ */ new Set();
4633
5108
  const commitEmitter = createCommitEmitter("GraphicEditSession.onCommit listener errors");
4634
5109
  const settleEmitter = createSettleEmitter("GraphicEditSession.onSettled listener errors");
5110
+ const captureHistory = () => ({ working: clonePointSymbol(working) });
5111
+ const cloneHistory = (value) => ({ working: clonePointSymbol(value.working) });
4635
5112
  const snapshot = () => ({
4636
5113
  graphic: clonePointSymbol(working),
4637
5114
  render: capability.render(working)
@@ -4649,15 +5126,17 @@ function runPointSymbolEdit(opts) {
4649
5126
  }
4650
5127
  ctx.writeLayer(handlesLayer, handles);
4651
5128
  };
4652
- const apply = (next, previous = snapshot()) => {
4653
- working = clonePointSymbol(next);
4654
- dirty = JSON.stringify(working) !== JSON.stringify(symbol);
4655
- render();
5129
+ const emitChange = (previousState, source) => {
5130
+ const previous = {
5131
+ graphic: clonePointSymbol(previousState.working),
5132
+ render: capability.render(previousState.working)
5133
+ };
4656
5134
  const current = snapshot();
4657
5135
  let reject = false;
4658
5136
  let close = false;
4659
5137
  let abort = false;
4660
5138
  const event = {
5139
+ source,
4661
5140
  graphic: current,
4662
5141
  previous,
4663
5142
  session,
@@ -4680,14 +5159,35 @@ function runPointSymbolEdit(opts) {
4680
5159
  if (abort) ctx.fail(new TacticalDrawAbortError("session"));
4681
5160
  else if (close) ctx.commit(snapshot);
4682
5161
  else if (reject && !ctx.settled) {
4683
- working = clonePointSymbol(previous.graphic);
5162
+ working = clonePointSymbol(previousState.working);
4684
5163
  dirty = JSON.stringify(working) !== JSON.stringify(symbol);
4685
5164
  render();
4686
5165
  }
4687
5166
  if (errors.length === 1) throw errors[0];
4688
5167
  if (errors.length > 1) throw new AggregateError(errors, "GraphicEditSession.onChange errors");
5168
+ return !ctx.settled && !reject;
5169
+ };
5170
+ const history = createSessionHistory({
5171
+ initial: captureHistory(),
5172
+ clone: cloneHistory,
5173
+ equals: (a, b) => JSON.stringify(a.working) === JSON.stringify(b.working),
5174
+ isCurrent: (value) => JSON.stringify(working) === JSON.stringify(value.working),
5175
+ apply(from, to, source) {
5176
+ working = clonePointSymbol(to.working);
5177
+ dirty = JSON.stringify(working) !== JSON.stringify(symbol);
5178
+ render();
5179
+ return emitChange(from, source);
5180
+ }
5181
+ });
5182
+ const apply = (next, source = "programmatic", label = "Update graphic") => {
5183
+ const previousState = captureHistory();
5184
+ working = clonePointSymbol(next);
5185
+ dirty = JSON.stringify(working) !== JSON.stringify(symbol);
5186
+ render();
5187
+ if (emitChange(previousState, source)) history.record(captureHistory(), label);
4689
5188
  };
4690
5189
  const session = {
5190
+ history,
4691
5191
  graphic: clonePointSymbol(symbol),
4692
5192
  get workingGraphic() {
4693
5193
  return clonePointSymbol(working);
@@ -4711,11 +5211,10 @@ function runPointSymbolEdit(opts) {
4711
5211
  },
4712
5212
  updateGraphic(updater) {
4713
5213
  if (ctx.settled) return;
4714
- const previous = snapshot();
4715
5214
  const next = updater(clonePointSymbol(working));
4716
5215
  if (next.kind !== "point-symbol" || next.id !== symbol.id) throw new TypeError("updateGraphic must preserve point-symbol kind and id");
4717
5216
  capability.render(next);
4718
- apply(next, previous);
5217
+ apply(next, "programmatic", "Update graphic");
4719
5218
  },
4720
5219
  onChange(listener) {
4721
5220
  if (ctx.settled) return () => {};
@@ -4747,27 +5246,38 @@ function runPointSymbolEdit(opts) {
4747
5246
  };
4748
5247
  driver.setDragPanEnabled(false);
4749
5248
  };
4750
- const transformed = (pixel) => {
5249
+ const transformed = (pixel, info) => {
4751
5250
  if (!drag) return null;
4752
5251
  const next = clonePointSymbol(drag.start);
4753
- if (drag.kind === "body") next.position = driver.fromPixel([drag.anchor[0] + pixel[0] - drag.down[0], drag.anchor[1] + pixel[1] - drag.down[1]]);
4754
- else if (drag.kind === "rotate") next.rotation = normalizeRotation(drag.start.rotation + pointerAngle(pixel, drag.anchor) - drag.startPointerAngle);
4755
- else {
5252
+ if (drag.kind === "body") {
5253
+ const unsnappedPosition = driver.fromPixel([drag.anchor[0] + pixel[0] - drag.down[0], drag.anchor[1] + pixel[1] - drag.down[1]]);
5254
+ const anchorPixel = driver.toPixel(unsnappedPosition) ?? pixel;
5255
+ next.position = (snap?.({
5256
+ coordinate: unsnappedPosition,
5257
+ pixel: anchorPixel,
5258
+ interaction: "translate",
5259
+ graphicId: symbol.id,
5260
+ originalEvent: info?.native
5261
+ }))?.coordinate ?? unsnappedPosition;
5262
+ } else if (drag.kind === "rotate") {
5263
+ const rotation = drag.start.rotation + pointerAngle(pixel, drag.anchor) - drag.startPointerAngle;
5264
+ next.rotation = normalizeRotation(info?.shiftKey ? constrainRotation(rotation) : rotation);
5265
+ } else {
4756
5266
  const distance = Math.hypot(pixel[0] - drag.anchor[0], pixel[1] - drag.anchor[1]);
4757
5267
  next.size.value = Math.max(.1, drag.start.size.value * (distance / drag.startDistance));
4758
5268
  }
4759
5269
  return next;
4760
5270
  };
4761
- const onMove = (pixel) => {
4762
- const next = transformed(pixel);
5271
+ const onMove = (pixel, info) => {
5272
+ const next = transformed(pixel, info);
4763
5273
  if (!next) return;
4764
5274
  working = next;
4765
5275
  render();
4766
5276
  };
4767
- const onUp = (pixel) => {
5277
+ const onUp = (pixel, info) => {
4768
5278
  if (!drag) return;
4769
5279
  const currentDrag = drag;
4770
- const next = transformed(pixel);
5280
+ const next = transformed(pixel, info);
4771
5281
  drag = null;
4772
5282
  suppressClickAway = true;
4773
5283
  driver.setDragPanEnabled(true);
@@ -4777,7 +5287,7 @@ function runPointSymbolEdit(opts) {
4777
5287
  render();
4778
5288
  return;
4779
5289
  }
4780
- apply(next);
5290
+ apply(next, "gesture", `${currentDrag.kind[0].toUpperCase()}${currentDrag.kind.slice(1)}`);
4781
5291
  };
4782
5292
  let previewPick;
4783
5293
  let handlesPick;
@@ -4803,8 +5313,12 @@ function runPointSymbolEdit(opts) {
4803
5313
  layers: [previewLayer, handlesLayer],
4804
5314
  onViewChange: render,
4805
5315
  onSettled(settlement) {
4806
- onSettled?.();
4807
- settleFromInteraction(settlement, commitEmitter, settleEmitter);
5316
+ try {
5317
+ history.settle();
5318
+ } finally {
5319
+ onSettled?.();
5320
+ settleFromInteraction(settlement, commitEmitter, settleEmitter);
5321
+ }
4808
5322
  },
4809
5323
  attach(interaction) {
4810
5324
  ctx = interaction;
@@ -4860,6 +5374,8 @@ var TacticalDraw = class {
4860
5374
  defaultInteractionStyle;
4861
5375
  generateId;
4862
5376
  pointSymbols;
5377
+ snappingOptions;
5378
+ resolveSnap;
4863
5379
  destroyed = false;
4864
5380
  hasRendered = false;
4865
5381
  activeInteraction = new ActiveInteractionTracker();
@@ -4910,12 +5426,15 @@ var TacticalDraw = class {
4910
5426
  * surrounding collection state.
4911
5427
  */
4912
5428
  lastRenderedMeasures = [];
5429
+ snapGeometryFeatures = [];
4913
5430
  constructor(adapter, options) {
4914
5431
  this.adapter = adapter;
4915
5432
  this.graphicsStyle = resolveStyleHints(BUILT_IN_GRAPHICS_STYLE, options?.graphicsStyle, void 0);
4916
5433
  this.defaultInteractionStyle = options?.interactionStyle;
4917
5434
  this.generateId = options?.generateId ?? (() => `td-${++this.idCounter}`);
4918
5435
  this.pointSymbols = options?.pointSymbols;
5436
+ this.snappingOptions = options?.snapping;
5437
+ this.resolveSnap = createSnapResolver(adapter, () => this.lastRenderedMeasures, () => this.snapGeometryFeatures, () => this.snappingOptions);
4919
5438
  const owned = /* @__PURE__ */ new Set();
4920
5439
  const provided = options?.layers ?? {};
4921
5440
  this.graphicsLayer = resolveSlot(adapter, provided.graphics, owned);
@@ -4929,6 +5448,16 @@ var TacticalDraw = class {
4929
5448
  this.adapter.setLayerStyle(this.guideLayer, guideToStyleOptions(this.baseGuideStyle()));
4930
5449
  this.adapter.onViewChange(this.handleViewChange);
4931
5450
  }
5451
+ /** Replace snapping configuration without recreating the map adapter or façade. */
5452
+ setSnappingOptions(options) {
5453
+ this.assertAlive();
5454
+ this.clearSnapResult();
5455
+ this.snappingOptions = options;
5456
+ }
5457
+ /** Clear transient host affordances when snapping or its interaction ends. */
5458
+ clearSnapResult() {
5459
+ this.snappingOptions?.onSnap?.(null);
5460
+ }
4932
5461
  /**
4933
5462
  * Render `measures` into features and write them to the graphics layer.
4934
5463
  * Routes every measure through [[renderMeasureForAdapter]] so pixel sizing is
@@ -4951,6 +5480,7 @@ var TacticalDraw = class {
4951
5480
  }
4952
5481
  this.adapter.reconcileLayerIconResources(this.graphicsLayer, [...resources.values()]);
4953
5482
  this.adapter.updateLayerFeatures(this.graphicsLayer, features);
5483
+ this.snapGeometryFeatures = features;
4954
5484
  }
4955
5485
  /**
4956
5486
  * Re-render the graphics layer on a view (zoom) change so pixel-denominated
@@ -5111,6 +5641,7 @@ var TacticalDraw = class {
5111
5641
  };
5112
5642
  const onSettled = () => {
5113
5643
  clearSlot();
5644
+ this.clearSnapResult();
5114
5645
  if (extraSettle) extraSettle();
5115
5646
  };
5116
5647
  this.activeInteraction.begin();
@@ -5164,7 +5695,8 @@ var TacticalDraw = class {
5164
5695
  previewLayer: this.previewLayer,
5165
5696
  generateId: this.generateId,
5166
5697
  signal: options?.signal,
5167
- onSettled
5698
+ onSettled,
5699
+ snap: this.resolveSnap
5168
5700
  }), null);
5169
5701
  }
5170
5702
  const measureDraft = draft;
@@ -5196,7 +5728,8 @@ var TacticalDraw = class {
5196
5728
  measureTextAmplifiers: measureDraft.textAmplifiers,
5197
5729
  guide: options?.guide,
5198
5730
  sizeAnchor: options?.sizeAnchor,
5199
- onSettled
5731
+ onSettled,
5732
+ snap: this.resolveSnap
5200
5733
  }) : runVariableLengthDraw({
5201
5734
  adapter: this.adapter,
5202
5735
  kind,
@@ -5213,7 +5746,8 @@ var TacticalDraw = class {
5213
5746
  measureTextAmplifiers: measureDraft.textAmplifiers,
5214
5747
  guide: options?.guide,
5215
5748
  sizeAnchor: options?.sizeAnchor,
5216
- onSettled
5749
+ onSettled,
5750
+ snap: this.resolveSnap
5217
5751
  }), null, extraSettle);
5218
5752
  }
5219
5753
  edit(measure, options) {
@@ -5242,7 +5776,8 @@ var TacticalDraw = class {
5242
5776
  restoreOriginalToGraphics,
5243
5777
  wasOnGraphicsLayer,
5244
5778
  onSettled,
5245
- onClickAwayGraphicsPick: (event) => this.dispatchHostPick(this.graphicsLayer, event)
5779
+ onClickAwayGraphicsPick: (event) => this.dispatchHostPick(this.graphicsLayer, event),
5780
+ snap: this.resolveSnap
5246
5781
  }), (handle) => {
5247
5782
  this.activeEdit = handle;
5248
5783
  return () => {
@@ -5282,7 +5817,8 @@ var TacticalDraw = class {
5282
5817
  restoreOriginalToGraphics,
5283
5818
  wasOnGraphicsLayer,
5284
5819
  onSettled,
5285
- onClickAwayGraphicsPick
5820
+ onClickAwayGraphicsPick,
5821
+ snap: this.resolveSnap
5286
5822
  }), (handle) => {
5287
5823
  this.activeEdit = handle;
5288
5824
  return () => {