@orbat-mapper/tactical-draw 0.2.0-alpha.22 → 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.d.mts CHANGED
@@ -1184,6 +1184,55 @@ interface DrawSession {
1184
1184
  onSettled(handler: (reason: SettleReason) => void): () => void;
1185
1185
  }
1186
1186
  //#endregion
1187
+ //#region src/tactical-draw/snapping.d.ts
1188
+ type SnapInteraction = "draw" | "reshape" | "translate" | "label-drag";
1189
+ interface SnapCandidateRequest {
1190
+ coordinate: Position;
1191
+ pixel: PixelCoordinate;
1192
+ interaction: SnapInteraction;
1193
+ graphicId?: string;
1194
+ controlPointIndex?: number;
1195
+ originalEvent?: unknown;
1196
+ }
1197
+ interface SnapCandidate {
1198
+ id: string;
1199
+ coordinate: Position;
1200
+ kind: string;
1201
+ graphicId?: string;
1202
+ controlPointIndex?: number;
1203
+ priority?: number;
1204
+ }
1205
+ type SnapCandidateProvider = (request: SnapCandidateRequest) => readonly SnapCandidate[];
1206
+ interface SnapResult {
1207
+ coordinate: Position;
1208
+ pixel: PixelCoordinate;
1209
+ candidate: SnapCandidate;
1210
+ distancePixels: number;
1211
+ }
1212
+ interface GraphicGeometrySnappingOptions {
1213
+ /** Snap to the nearest point along generated linework and polygon boundaries. Default `true`. */
1214
+ edges?: boolean;
1215
+ /** Snap to generated geometry vertices. Default `false`. */
1216
+ vertices?: boolean;
1217
+ }
1218
+ interface SnappingOptions {
1219
+ enabled?: boolean;
1220
+ tolerancePixels?: number;
1221
+ sources?: {
1222
+ graphics?: boolean;
1223
+ /**
1224
+ * Snap to generated control-measure geometry. `true` enables edges with
1225
+ * vertices disabled; pass an object to select either candidate type.
1226
+ * Text/label point features and the active graphic are excluded.
1227
+ */
1228
+ graphicGeometry?: boolean | GraphicGeometrySnappingOptions;
1229
+ external?: SnapCandidateProvider;
1230
+ };
1231
+ bypass?: (originalEvent: unknown) => boolean;
1232
+ /** Called for each resolution attempt; `null` clears a host-rendered indicator. */
1233
+ onSnap?: (result: SnapResult | null) => void;
1234
+ }
1235
+ //#endregion
1187
1236
  //#region src/tactical-draw/point-symbol-draw-controller.d.ts
1188
1237
  type DrawPointSymbolDraft = Pick<PointSymbol, "kind" | "sidc"> & Partial<Omit<PointSymbol, "kind" | "sidc">>;
1189
1238
  //#endregion
@@ -1234,6 +1283,8 @@ interface TacticalDrawOptions {
1234
1283
  * the draw API itself are deferred (PRD #40 user-story-5 note).
1235
1284
  */
1236
1285
  generateId?: () => string;
1286
+ /** Optional engine-level snapping for draw and edit interactions. */
1287
+ snapping?: SnappingOptions;
1237
1288
  }
1238
1289
  interface EditOptions {
1239
1290
  signal?: AbortSignal;
@@ -1382,6 +1433,8 @@ declare class TacticalDraw {
1382
1433
  private readonly defaultInteractionStyle;
1383
1434
  private readonly generateId;
1384
1435
  private readonly pointSymbols;
1436
+ private snappingOptions;
1437
+ private readonly resolveSnap;
1385
1438
  private destroyed;
1386
1439
  private hasRendered;
1387
1440
  private readonly activeInteraction;
@@ -1432,7 +1485,12 @@ declare class TacticalDraw {
1432
1485
  * surrounding collection state.
1433
1486
  */
1434
1487
  private lastRenderedMeasures;
1488
+ private snapGeometryFeatures;
1435
1489
  constructor(adapter: MapAdapter, options?: TacticalDrawOptions);
1490
+ /** Replace snapping configuration without recreating the map adapter or façade. */
1491
+ setSnappingOptions(options?: SnappingOptions): void;
1492
+ /** Clear transient host affordances when snapping or its interaction ends. */
1493
+ private clearSnapResult;
1436
1494
  /**
1437
1495
  * Render `measures` into features and write them to the graphics layer.
1438
1496
  * Routes every measure through [[renderMeasureForAdapter]] so pixel sizing is
@@ -1820,4 +1878,4 @@ declare function nearestWorldCopyLng(lng: number, refLng: number): number;
1820
1878
  /** `nearestWorldCopyLng` applied to a lon/lat position. */
1821
1879
  declare function nearestWorldCopyPosition(position: Position, refLng: number): Position;
1822
1880
  //#endregion
1823
- export { BaseMapAdapter, type CombinedAbort, DEFAULT_LABEL_SIZE_PIXELS, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, type DrawMeasureDraft, type DrawOptions, type DrawPointSymbolDraft, type DrawSession, type EditChangeEvent, type EditManyOptions, type EditMode, type EditOptions, type EditPointerDriver, type EditSession, type FeatureOps, type Graphic, type GraphicEditChangeEvent, type GraphicEditSession, type GraphicRender, type GraphicSnapshot, type GraphicTransformChangeEvent, type GraphicTransformSession, type GuideStyle, type HandleStyle, type IconResource, type InteractionHit, type InteractionStyle, type LayerId, type MapAdapter, type MapCursor, type MapEvent, type MapEventHandler, type NativeEventEntry, type PickEvent, type PickHandler, type PickOptions, type PixelCoordinate, type PointSymbol, type PointSymbolAnchor, type PointSymbolCapability, PointSymbolCapabilityRequiredError, type PointSymbolColors, type PointSymbolFootprint, type PointSymbolGraphicModifiers, type PointSymbolRender, type PointSymbolSize, type PointSymbolStyle, type PointerCallback, PointerCallbackHub, type PointerEventInfo, type SessionChangeSource, type SessionHistory, type SessionHistoryState, type SettleReason, type SizeAnchor, type StyleOptions, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, type TacticalDrawAbortReason, TacticalDrawDestroyedError, type TacticalDrawLayerOptions, type TacticalDrawOptions, type TextStyleOptions, type ToPixel, type TransformBox, type TransformChangeEvent, type TransformSession, bindAbortable, clonePointSymbol, coerceHandleStyle, combineWithHostSignal, getSizeAnchor, hitTestFeature, hitTestIconFeature, ignoreAbort, isPointSymbol, isTacticalDrawAbortError, nearestWorldCopyLng, nearestWorldCopyPosition, pickHitTolerance, pointSymbolFootprint, resolveGroundTextSizePixels, tryControlMeasureIdFromFeature };
1881
+ export { BaseMapAdapter, type CombinedAbort, DEFAULT_LABEL_SIZE_PIXELS, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, type DrawMeasureDraft, type DrawOptions, type DrawPointSymbolDraft, type DrawSession, type EditChangeEvent, type EditManyOptions, type EditMode, type EditOptions, type EditPointerDriver, type EditSession, type FeatureOps, type Graphic, type GraphicEditChangeEvent, type GraphicEditSession, type GraphicGeometrySnappingOptions, type GraphicRender, type GraphicSnapshot, type GraphicTransformChangeEvent, type GraphicTransformSession, type GuideStyle, type HandleStyle, type IconResource, type InteractionHit, type InteractionStyle, type LayerId, type MapAdapter, type MapCursor, type MapEvent, type MapEventHandler, type NativeEventEntry, type PickEvent, type PickHandler, type PickOptions, type PixelCoordinate, type PointSymbol, type PointSymbolAnchor, type PointSymbolCapability, PointSymbolCapabilityRequiredError, type PointSymbolColors, type PointSymbolFootprint, type PointSymbolGraphicModifiers, type PointSymbolRender, type PointSymbolSize, type PointSymbolStyle, type PointerCallback, PointerCallbackHub, type PointerEventInfo, type SessionChangeSource, type SessionHistory, type SessionHistoryState, type SettleReason, type SizeAnchor, type SnapCandidate, type SnapCandidateProvider, type SnapCandidateRequest, type SnapInteraction, type SnapResult, type SnappingOptions, type StyleOptions, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, type TacticalDrawAbortReason, TacticalDrawDestroyedError, type TacticalDrawLayerOptions, type TacticalDrawOptions, type TextStyleOptions, type ToPixel, type TransformBox, type TransformChangeEvent, type TransformSession, bindAbortable, clonePointSymbol, coerceHandleStyle, combineWithHostSignal, getSizeAnchor, hitTestFeature, hitTestIconFeature, ignoreAbort, isPointSymbol, isTacticalDrawAbortError, nearestWorldCopyLng, nearestWorldCopyPosition, pickHitTolerance, pointSymbolFootprint, resolveGroundTextSizePixels, tryControlMeasureIdFromFeature };
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() {
@@ -2435,7 +2620,7 @@ function mergeAmplifierPlacementField(existing, incoming) {
2435
2620
  * - close / abort / signal / preempt / destroy paths
2436
2621
  */
2437
2622
  function runEdit(opts) {
2438
- 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;
2439
2624
  const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
2440
2625
  let sizeAnchor = opts.sizeAnchor ?? "ground";
2441
2626
  const metadata = getControlMeasureMetadata(measure.kind);
@@ -3169,6 +3354,38 @@ function runEdit(opts) {
3169
3354
  }
3170
3355
  };
3171
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
+ }
3172
3389
  /**
3173
3390
  * A live label *move* drag: direct label grab, the floating grip, or a body
3174
3391
  * grab inside the selected label's transform box (ADR-0017 box machinery
@@ -3370,7 +3587,8 @@ function runEdit(opts) {
3370
3587
  });
3371
3588
  activeDrag = {
3372
3589
  fsm,
3373
- preDragPoints
3590
+ preDragPoints,
3591
+ controlPointIndex: hit.kind === "midpoint" ? hit.index + 1 : hit.index
3374
3592
  };
3375
3593
  if (fsm.kind === "midpoint") {
3376
3594
  workingPoints.splice(0, workingPoints.length, ...initialPoints);
@@ -3415,17 +3633,19 @@ function runEdit(opts) {
3415
3633
  }
3416
3634
  }
3417
3635
  };
3418
- function paintDragSnapshot(pixel) {
3636
+ function paintDragSnapshot(pixel, info) {
3419
3637
  if (!activeDrag) return;
3420
- const snapshot = activeDrag.fsm.move(pixel);
3638
+ const resolvedPixel = snappedPixel(pixel, info, "reshape", activeDrag.controlPointIndex);
3639
+ const snapshot = activeDrag.fsm.move(resolvedPixel);
3421
3640
  workingPoints.splice(0, workingPoints.length, ...snapshot.points);
3422
3641
  renderPreview();
3423
3642
  renderHandlesLayer();
3424
3643
  recomputeDirty();
3425
3644
  }
3426
- function paintBoxDragSnapshot(pixel, constrainRotation = false) {
3645
+ function paintBoxDragSnapshot(pixel, info, constrainRotation = false) {
3427
3646
  if (!activeBoxDrag) return;
3428
- const snapshot = activeBoxDrag.fsm.move(pixel, { constrainRotation });
3647
+ const resolvedPixel = translatedBoxPixel(pixel, info, activeBoxDrag);
3648
+ const snapshot = activeBoxDrag.fsm.move(resolvedPixel, { constrainRotation });
3429
3649
  activeBoxDrag.lastSnapshot = snapshot;
3430
3650
  workingPoints.splice(0, workingPoints.length, ...snapshot.points);
3431
3651
  workingAmplifierPlacements = transformAmplifierPlacements(activeBoxDrag.preDragAmplifierPlacements, activeBoxDrag.preDragBox, snapshot.box, driver);
@@ -3468,8 +3688,8 @@ function runEdit(opts) {
3468
3688
  };
3469
3689
  renderPreview();
3470
3690
  renderHandlesLayer();
3471
- } else if (activeDrag) paintDragSnapshot(pixel);
3472
- else if (activeBoxDrag) paintBoxDragSnapshot(pixel, info?.shiftKey);
3691
+ } else if (activeDrag) paintDragSnapshot(pixel, info);
3692
+ else if (activeBoxDrag) paintBoxDragSnapshot(pixel, info, info?.shiftKey);
3473
3693
  };
3474
3694
  /** Reset drag-in-flight bookkeeping shared by every "gesture ended" path. */
3475
3695
  function endDragBookkeeping() {
@@ -3516,10 +3736,10 @@ function runEdit(opts) {
3516
3736
  driver.setDragPanEnabled(true);
3517
3737
  return;
3518
3738
  }
3519
- const { fsm, preDragPoints } = activeDrag;
3739
+ const { fsm, preDragPoints, controlPointIndex } = activeDrag;
3520
3740
  activeDrag = null;
3521
3741
  driver.setDragPanEnabled(true);
3522
- const next = fsm.end(pixel);
3742
+ const next = fsm.end(snappedPixel(pixel, info, "reshape", controlPointIndex));
3523
3743
  replaceWorkingPoints(preDragPoints);
3524
3744
  commitGesture(next);
3525
3745
  return;
@@ -3545,7 +3765,12 @@ function runEdit(opts) {
3545
3765
  bodyClickPending = true;
3546
3766
  return;
3547
3767
  }
3548
- const finalSnapshot = fsm.move(pixel, { constrainRotation: info?.shiftKey });
3768
+ const finalPixel = translatedBoxPixel(pixel, info, {
3769
+ fsm,
3770
+ preDragPoints,
3771
+ downPixel
3772
+ });
3773
+ const finalSnapshot = fsm.move(finalPixel, { constrainRotation: info?.shiftKey });
3549
3774
  replaceWorkingPoints(preDragPoints);
3550
3775
  workingAmplifierPlacements = preDragAmplifierPlacements;
3551
3776
  if (fsm.kind === "rotate") boxAngle += finalSnapshot.box.angle - preDragBox.angle;
@@ -4798,7 +5023,7 @@ var PointSymbolCapabilityRequiredError = class extends Error {
4798
5023
  //#region src/tactical-draw/point-symbol-draw-controller.ts
4799
5024
  const PREVIEW_ID = "__td_point_symbol_preview__";
4800
5025
  function runPointSymbolDraw(options) {
4801
- const { adapter, capability, draft, previewLayer, generateId, signal, onSettled } = options;
5026
+ const { adapter, capability, draft, previewLayer, generateId, signal, onSettled, snap } = options;
4802
5027
  let latestPosition = draft.position;
4803
5028
  let ctx;
4804
5029
  const savedCursor = "";
@@ -4818,13 +5043,13 @@ function runPointSymbolDraw(options) {
4818
5043
  ctx.writeLayer(previewLayer, [plan.feature]);
4819
5044
  };
4820
5045
  const handlePointerMove = (event) => {
4821
- latestPosition = event.coordinate;
5046
+ latestPosition = snappedDrawPosition(snap, event);
4822
5047
  renderPreview();
4823
5048
  };
4824
5049
  const handleClick = (event) => {
4825
- latestPosition = event.coordinate;
5050
+ latestPosition = snappedDrawPosition(snap, event);
4826
5051
  ctx.commit(() => {
4827
- const graphic = build(draft.id ?? generateId(), event.coordinate);
5052
+ const graphic = build(draft.id ?? generateId(), latestPosition);
4828
5053
  return {
4829
5054
  graphic: clonePointSymbol(graphic),
4830
5055
  render: capability.render(graphic)
@@ -4871,7 +5096,7 @@ function symbolBox(adapter, capability, symbol, padding = 0) {
4871
5096
  };
4872
5097
  }
4873
5098
  function runPointSymbolEdit(opts) {
4874
- 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;
4875
5100
  let modes = normalisePointModes(opts.modes);
4876
5101
  let working = clonePointSymbol(symbol);
4877
5102
  let dirty = false;
@@ -5021,13 +5246,22 @@ function runPointSymbolEdit(opts) {
5021
5246
  };
5022
5247
  driver.setDragPanEnabled(false);
5023
5248
  };
5024
- const transformed = (pixel, constrainAngle = false) => {
5249
+ const transformed = (pixel, info) => {
5025
5250
  if (!drag) return null;
5026
5251
  const next = clonePointSymbol(drag.start);
5027
- if (drag.kind === "body") next.position = driver.fromPixel([drag.anchor[0] + pixel[0] - drag.down[0], drag.anchor[1] + pixel[1] - drag.down[1]]);
5028
- else if (drag.kind === "rotate") {
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") {
5029
5263
  const rotation = drag.start.rotation + pointerAngle(pixel, drag.anchor) - drag.startPointerAngle;
5030
- next.rotation = normalizeRotation(constrainAngle ? constrainRotation(rotation) : rotation);
5264
+ next.rotation = normalizeRotation(info?.shiftKey ? constrainRotation(rotation) : rotation);
5031
5265
  } else {
5032
5266
  const distance = Math.hypot(pixel[0] - drag.anchor[0], pixel[1] - drag.anchor[1]);
5033
5267
  next.size.value = Math.max(.1, drag.start.size.value * (distance / drag.startDistance));
@@ -5035,7 +5269,7 @@ function runPointSymbolEdit(opts) {
5035
5269
  return next;
5036
5270
  };
5037
5271
  const onMove = (pixel, info) => {
5038
- const next = transformed(pixel, info?.shiftKey);
5272
+ const next = transformed(pixel, info);
5039
5273
  if (!next) return;
5040
5274
  working = next;
5041
5275
  render();
@@ -5043,7 +5277,7 @@ function runPointSymbolEdit(opts) {
5043
5277
  const onUp = (pixel, info) => {
5044
5278
  if (!drag) return;
5045
5279
  const currentDrag = drag;
5046
- const next = transformed(pixel, info?.shiftKey);
5280
+ const next = transformed(pixel, info);
5047
5281
  drag = null;
5048
5282
  suppressClickAway = true;
5049
5283
  driver.setDragPanEnabled(true);
@@ -5140,6 +5374,8 @@ var TacticalDraw = class {
5140
5374
  defaultInteractionStyle;
5141
5375
  generateId;
5142
5376
  pointSymbols;
5377
+ snappingOptions;
5378
+ resolveSnap;
5143
5379
  destroyed = false;
5144
5380
  hasRendered = false;
5145
5381
  activeInteraction = new ActiveInteractionTracker();
@@ -5190,12 +5426,15 @@ var TacticalDraw = class {
5190
5426
  * surrounding collection state.
5191
5427
  */
5192
5428
  lastRenderedMeasures = [];
5429
+ snapGeometryFeatures = [];
5193
5430
  constructor(adapter, options) {
5194
5431
  this.adapter = adapter;
5195
5432
  this.graphicsStyle = resolveStyleHints(BUILT_IN_GRAPHICS_STYLE, options?.graphicsStyle, void 0);
5196
5433
  this.defaultInteractionStyle = options?.interactionStyle;
5197
5434
  this.generateId = options?.generateId ?? (() => `td-${++this.idCounter}`);
5198
5435
  this.pointSymbols = options?.pointSymbols;
5436
+ this.snappingOptions = options?.snapping;
5437
+ this.resolveSnap = createSnapResolver(adapter, () => this.lastRenderedMeasures, () => this.snapGeometryFeatures, () => this.snappingOptions);
5199
5438
  const owned = /* @__PURE__ */ new Set();
5200
5439
  const provided = options?.layers ?? {};
5201
5440
  this.graphicsLayer = resolveSlot(adapter, provided.graphics, owned);
@@ -5209,6 +5448,16 @@ var TacticalDraw = class {
5209
5448
  this.adapter.setLayerStyle(this.guideLayer, guideToStyleOptions(this.baseGuideStyle()));
5210
5449
  this.adapter.onViewChange(this.handleViewChange);
5211
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
+ }
5212
5461
  /**
5213
5462
  * Render `measures` into features and write them to the graphics layer.
5214
5463
  * Routes every measure through [[renderMeasureForAdapter]] so pixel sizing is
@@ -5231,6 +5480,7 @@ var TacticalDraw = class {
5231
5480
  }
5232
5481
  this.adapter.reconcileLayerIconResources(this.graphicsLayer, [...resources.values()]);
5233
5482
  this.adapter.updateLayerFeatures(this.graphicsLayer, features);
5483
+ this.snapGeometryFeatures = features;
5234
5484
  }
5235
5485
  /**
5236
5486
  * Re-render the graphics layer on a view (zoom) change so pixel-denominated
@@ -5391,6 +5641,7 @@ var TacticalDraw = class {
5391
5641
  };
5392
5642
  const onSettled = () => {
5393
5643
  clearSlot();
5644
+ this.clearSnapResult();
5394
5645
  if (extraSettle) extraSettle();
5395
5646
  };
5396
5647
  this.activeInteraction.begin();
@@ -5444,7 +5695,8 @@ var TacticalDraw = class {
5444
5695
  previewLayer: this.previewLayer,
5445
5696
  generateId: this.generateId,
5446
5697
  signal: options?.signal,
5447
- onSettled
5698
+ onSettled,
5699
+ snap: this.resolveSnap
5448
5700
  }), null);
5449
5701
  }
5450
5702
  const measureDraft = draft;
@@ -5476,7 +5728,8 @@ var TacticalDraw = class {
5476
5728
  measureTextAmplifiers: measureDraft.textAmplifiers,
5477
5729
  guide: options?.guide,
5478
5730
  sizeAnchor: options?.sizeAnchor,
5479
- onSettled
5731
+ onSettled,
5732
+ snap: this.resolveSnap
5480
5733
  }) : runVariableLengthDraw({
5481
5734
  adapter: this.adapter,
5482
5735
  kind,
@@ -5493,7 +5746,8 @@ var TacticalDraw = class {
5493
5746
  measureTextAmplifiers: measureDraft.textAmplifiers,
5494
5747
  guide: options?.guide,
5495
5748
  sizeAnchor: options?.sizeAnchor,
5496
- onSettled
5749
+ onSettled,
5750
+ snap: this.resolveSnap
5497
5751
  }), null, extraSettle);
5498
5752
  }
5499
5753
  edit(measure, options) {
@@ -5522,7 +5776,8 @@ var TacticalDraw = class {
5522
5776
  restoreOriginalToGraphics,
5523
5777
  wasOnGraphicsLayer,
5524
5778
  onSettled,
5525
- onClickAwayGraphicsPick: (event) => this.dispatchHostPick(this.graphicsLayer, event)
5779
+ onClickAwayGraphicsPick: (event) => this.dispatchHostPick(this.graphicsLayer, event),
5780
+ snap: this.resolveSnap
5526
5781
  }), (handle) => {
5527
5782
  this.activeEdit = handle;
5528
5783
  return () => {
@@ -5562,7 +5817,8 @@ var TacticalDraw = class {
5562
5817
  restoreOriginalToGraphics,
5563
5818
  wasOnGraphicsLayer,
5564
5819
  onSettled,
5565
- onClickAwayGraphicsPick
5820
+ onClickAwayGraphicsPick,
5821
+ snap: this.resolveSnap
5566
5822
  }), (handle) => {
5567
5823
  this.activeEdit = handle;
5568
5824
  return () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orbat-mapper/tactical-draw",
3
- "version": "0.2.0-alpha.22",
3
+ "version": "0.2.0-alpha.23",
4
4
  "description": "Engine-agnostic draw/edit core for tactical control measures (MIL-STD-2525 / APP-6). The MapAdapter ABI and bring-your-own-engine entry point for the tactical-draw package family.",
5
5
  "license": "MIT",
6
6
  "author": "Orbat Mapper",