@orbat-mapper/tactical-draw 0.2.0-alpha.22 → 0.2.0-alpha.24

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
@@ -264,6 +264,12 @@ interface MapAdapter {
264
264
  */
265
265
  onViewChange(handler: () => void): void;
266
266
  offViewChange(handler: () => void): void;
267
+ /**
268
+ * The engine evaluates the meters→pixels clamp for ground-sized point
269
+ * symbols inside its own style layer, so meters-sized point symbols don't
270
+ * need view-change re-renders for size.
271
+ */
272
+ readonly scalesGroundPointSymbolsNatively?: boolean;
267
273
  /**
268
274
  * Construct an {@link EditPointerDriver} for `TacticalDraw`'s edit
269
275
  * controller. The controller subscribes to pointer events on the returned
@@ -465,7 +471,9 @@ type PointSymbolSize = {
465
471
  unit: "pixels";
466
472
  } | {
467
473
  value: number;
468
- unit: "meters";
474
+ unit: "meters"; /** Floor for the on-screen projection of the ground-based size; none when omitted. */
475
+ minPixels?: number; /** Cap on the on-screen projection of the ground-based size — typically the pixel size the symbol had when anchored to ground; unclamped when omitted. */
476
+ maxPixels?: number;
469
477
  };
470
478
  interface PointSymbolGraphicModifiers {
471
479
  /** Radians clockwise, relative to the point symbol's own orientation. */
@@ -1184,6 +1192,55 @@ interface DrawSession {
1184
1192
  onSettled(handler: (reason: SettleReason) => void): () => void;
1185
1193
  }
1186
1194
  //#endregion
1195
+ //#region src/tactical-draw/snapping.d.ts
1196
+ type SnapInteraction = "draw" | "reshape" | "translate" | "label-drag";
1197
+ interface SnapCandidateRequest {
1198
+ coordinate: Position;
1199
+ pixel: PixelCoordinate;
1200
+ interaction: SnapInteraction;
1201
+ graphicId?: string;
1202
+ controlPointIndex?: number;
1203
+ originalEvent?: unknown;
1204
+ }
1205
+ interface SnapCandidate {
1206
+ id: string;
1207
+ coordinate: Position;
1208
+ kind: string;
1209
+ graphicId?: string;
1210
+ controlPointIndex?: number;
1211
+ priority?: number;
1212
+ }
1213
+ type SnapCandidateProvider = (request: SnapCandidateRequest) => readonly SnapCandidate[];
1214
+ interface SnapResult {
1215
+ coordinate: Position;
1216
+ pixel: PixelCoordinate;
1217
+ candidate: SnapCandidate;
1218
+ distancePixels: number;
1219
+ }
1220
+ interface GraphicGeometrySnappingOptions {
1221
+ /** Snap to the nearest point along generated linework and polygon boundaries. Default `true`. */
1222
+ edges?: boolean;
1223
+ /** Snap to generated geometry vertices. Default `false`. */
1224
+ vertices?: boolean;
1225
+ }
1226
+ interface SnappingOptions {
1227
+ enabled?: boolean;
1228
+ tolerancePixels?: number;
1229
+ sources?: {
1230
+ graphics?: boolean;
1231
+ /**
1232
+ * Snap to generated control-measure geometry. `true` enables edges with
1233
+ * vertices disabled; pass an object to select either candidate type.
1234
+ * Text/label point features and the active graphic are excluded.
1235
+ */
1236
+ graphicGeometry?: boolean | GraphicGeometrySnappingOptions;
1237
+ external?: SnapCandidateProvider;
1238
+ };
1239
+ bypass?: (originalEvent: unknown) => boolean;
1240
+ /** Called for each resolution attempt; `null` clears a host-rendered indicator. */
1241
+ onSnap?: (result: SnapResult | null) => void;
1242
+ }
1243
+ //#endregion
1187
1244
  //#region src/tactical-draw/point-symbol-draw-controller.d.ts
1188
1245
  type DrawPointSymbolDraft = Pick<PointSymbol, "kind" | "sidc"> & Partial<Omit<PointSymbol, "kind" | "sidc">>;
1189
1246
  //#endregion
@@ -1234,6 +1291,8 @@ interface TacticalDrawOptions {
1234
1291
  * the draw API itself are deferred (PRD #40 user-story-5 note).
1235
1292
  */
1236
1293
  generateId?: () => string;
1294
+ /** Optional engine-level snapping for draw and edit interactions. */
1295
+ snapping?: SnappingOptions;
1237
1296
  }
1238
1297
  interface EditOptions {
1239
1298
  signal?: AbortSignal;
@@ -1382,6 +1441,8 @@ declare class TacticalDraw {
1382
1441
  private readonly defaultInteractionStyle;
1383
1442
  private readonly generateId;
1384
1443
  private readonly pointSymbols;
1444
+ private snappingOptions;
1445
+ private readonly resolveSnap;
1385
1446
  private destroyed;
1386
1447
  private hasRendered;
1387
1448
  private readonly activeInteraction;
@@ -1432,7 +1493,12 @@ declare class TacticalDraw {
1432
1493
  * surrounding collection state.
1433
1494
  */
1434
1495
  private lastRenderedMeasures;
1496
+ private snapGeometryFeatures;
1435
1497
  constructor(adapter: MapAdapter, options?: TacticalDrawOptions);
1498
+ /** Replace snapping configuration without recreating the map adapter or façade. */
1499
+ setSnappingOptions(options?: SnappingOptions): void;
1500
+ /** Clear transient host affordances when snapping or its interaction ends. */
1501
+ private clearSnapResult;
1436
1502
  /**
1437
1503
  * Render `measures` into features and write them to the graphics layer.
1438
1504
  * Routes every measure through [[renderMeasureForAdapter]] so pixel sizing is
@@ -1820,4 +1886,4 @@ declare function nearestWorldCopyLng(lng: number, refLng: number): number;
1820
1886
  /** `nearestWorldCopyLng` applied to a lon/lat position. */
1821
1887
  declare function nearestWorldCopyPosition(position: Position, refLng: number): Position;
1822
1888
  //#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 };
1889
+ 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;
@@ -4408,7 +4633,10 @@ function runGroupTransform(opts) {
4408
4633
  //#region src/tactical-draw/point-symbol-rendering.ts
4409
4634
  function effectivePointSymbolPixelSize(symbol, resolution) {
4410
4635
  if (symbol.size.unit === "pixels") return symbol.size.value;
4411
- return resolution !== void 0 && resolution > 0 ? symbol.size.value / resolution : symbol.size.value;
4636
+ if (resolution === void 0 || resolution <= 0) return symbol.size.value;
4637
+ const px = symbol.size.value / resolution;
4638
+ const { minPixels, maxPixels } = symbol.size;
4639
+ return Math.max(Number.isFinite(minPixels) && minPixels > 0 ? minPixels : 0, Math.min(Number.isFinite(maxPixels) && maxPixels > 0 ? maxPixels : Infinity, px));
4412
4640
  }
4413
4641
  function pointSymbolPixelScale(symbol, render, resolution) {
4414
4642
  if (!Number.isFinite(render.octagonSize) || render.octagonSize <= 0) throw new TypeError("PointSymbolRender.octagonSize must be a positive finite number");
@@ -4450,6 +4678,12 @@ function renderPointSymbolForAdapter(adapter, capability, symbol) {
4450
4678
  };
4451
4679
  }
4452
4680
  function pointSymbolFeature(symbol, render, resolution) {
4681
+ const groundSizeProperties = symbol.size.unit === "meters" ? {
4682
+ iconSizeMeters: symbol.size.value,
4683
+ iconOctagonSize: render.octagonSize,
4684
+ ...boundProperty("iconMinPixels", symbol.size.minPixels),
4685
+ ...boundProperty("iconMaxPixels", symbol.size.maxPixels)
4686
+ } : void 0;
4453
4687
  return {
4454
4688
  type: "Feature",
4455
4689
  id: `${symbol.id}:point-symbol`,
@@ -4464,10 +4698,14 @@ function pointSymbolFeature(symbol, render, resolution) {
4464
4698
  iconId: render.resourceId,
4465
4699
  iconScale: pointSymbolPixelScale(symbol, render, resolution),
4466
4700
  iconRotation: symbol.rotation,
4467
- iconOpacity: symbol.style?.opacity ?? 1
4701
+ iconOpacity: symbol.style?.opacity ?? 1,
4702
+ ...groundSizeProperties
4468
4703
  }
4469
4704
  };
4470
4705
  }
4706
+ function boundProperty(key, value) {
4707
+ return value !== void 0 && Number.isFinite(value) && value > 0 ? { [key]: value } : {};
4708
+ }
4471
4709
  //#endregion
4472
4710
  //#region src/tactical-draw/graphic-transform-controller.ts
4473
4711
  const HIT_TOLERANCE_PX$1 = 12;
@@ -4798,7 +5036,7 @@ var PointSymbolCapabilityRequiredError = class extends Error {
4798
5036
  //#region src/tactical-draw/point-symbol-draw-controller.ts
4799
5037
  const PREVIEW_ID = "__td_point_symbol_preview__";
4800
5038
  function runPointSymbolDraw(options) {
4801
- const { adapter, capability, draft, previewLayer, generateId, signal, onSettled } = options;
5039
+ const { adapter, capability, draft, previewLayer, generateId, signal, onSettled, snap } = options;
4802
5040
  let latestPosition = draft.position;
4803
5041
  let ctx;
4804
5042
  const savedCursor = "";
@@ -4818,13 +5056,13 @@ function runPointSymbolDraw(options) {
4818
5056
  ctx.writeLayer(previewLayer, [plan.feature]);
4819
5057
  };
4820
5058
  const handlePointerMove = (event) => {
4821
- latestPosition = event.coordinate;
5059
+ latestPosition = snappedDrawPosition(snap, event);
4822
5060
  renderPreview();
4823
5061
  };
4824
5062
  const handleClick = (event) => {
4825
- latestPosition = event.coordinate;
5063
+ latestPosition = snappedDrawPosition(snap, event);
4826
5064
  ctx.commit(() => {
4827
- const graphic = build(draft.id ?? generateId(), event.coordinate);
5065
+ const graphic = build(draft.id ?? generateId(), latestPosition);
4828
5066
  return {
4829
5067
  graphic: clonePointSymbol(graphic),
4830
5068
  render: capability.render(graphic)
@@ -4871,7 +5109,7 @@ function symbolBox(adapter, capability, symbol, padding = 0) {
4871
5109
  };
4872
5110
  }
4873
5111
  function runPointSymbolEdit(opts) {
4874
- const { adapter, capability, symbol, graphicsLayer, previewLayer, handlesLayer, signal, closeOnClickAway = true, interactionStyle, onSession, handleIdPrefix, restoreOriginalToGraphics, wasOnGraphicsLayer, onSettled, onClickAwayGraphicsPick } = opts;
5112
+ const { adapter, capability, symbol, graphicsLayer, previewLayer, handlesLayer, signal, closeOnClickAway = true, interactionStyle, onSession, handleIdPrefix, restoreOriginalToGraphics, wasOnGraphicsLayer, onSettled, onClickAwayGraphicsPick, snap } = opts;
4875
5113
  let modes = normalisePointModes(opts.modes);
4876
5114
  let working = clonePointSymbol(symbol);
4877
5115
  let dirty = false;
@@ -5021,13 +5259,22 @@ function runPointSymbolEdit(opts) {
5021
5259
  };
5022
5260
  driver.setDragPanEnabled(false);
5023
5261
  };
5024
- const transformed = (pixel, constrainAngle = false) => {
5262
+ const transformed = (pixel, info) => {
5025
5263
  if (!drag) return null;
5026
5264
  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") {
5265
+ if (drag.kind === "body") {
5266
+ const unsnappedPosition = driver.fromPixel([drag.anchor[0] + pixel[0] - drag.down[0], drag.anchor[1] + pixel[1] - drag.down[1]]);
5267
+ const anchorPixel = driver.toPixel(unsnappedPosition) ?? pixel;
5268
+ next.position = (snap?.({
5269
+ coordinate: unsnappedPosition,
5270
+ pixel: anchorPixel,
5271
+ interaction: "translate",
5272
+ graphicId: symbol.id,
5273
+ originalEvent: info?.native
5274
+ }))?.coordinate ?? unsnappedPosition;
5275
+ } else if (drag.kind === "rotate") {
5029
5276
  const rotation = drag.start.rotation + pointerAngle(pixel, drag.anchor) - drag.startPointerAngle;
5030
- next.rotation = normalizeRotation(constrainAngle ? constrainRotation(rotation) : rotation);
5277
+ next.rotation = normalizeRotation(info?.shiftKey ? constrainRotation(rotation) : rotation);
5031
5278
  } else {
5032
5279
  const distance = Math.hypot(pixel[0] - drag.anchor[0], pixel[1] - drag.anchor[1]);
5033
5280
  next.size.value = Math.max(.1, drag.start.size.value * (distance / drag.startDistance));
@@ -5035,7 +5282,7 @@ function runPointSymbolEdit(opts) {
5035
5282
  return next;
5036
5283
  };
5037
5284
  const onMove = (pixel, info) => {
5038
- const next = transformed(pixel, info?.shiftKey);
5285
+ const next = transformed(pixel, info);
5039
5286
  if (!next) return;
5040
5287
  working = next;
5041
5288
  render();
@@ -5043,7 +5290,7 @@ function runPointSymbolEdit(opts) {
5043
5290
  const onUp = (pixel, info) => {
5044
5291
  if (!drag) return;
5045
5292
  const currentDrag = drag;
5046
- const next = transformed(pixel, info?.shiftKey);
5293
+ const next = transformed(pixel, info);
5047
5294
  drag = null;
5048
5295
  suppressClickAway = true;
5049
5296
  driver.setDragPanEnabled(true);
@@ -5140,6 +5387,8 @@ var TacticalDraw = class {
5140
5387
  defaultInteractionStyle;
5141
5388
  generateId;
5142
5389
  pointSymbols;
5390
+ snappingOptions;
5391
+ resolveSnap;
5143
5392
  destroyed = false;
5144
5393
  hasRendered = false;
5145
5394
  activeInteraction = new ActiveInteractionTracker();
@@ -5190,12 +5439,15 @@ var TacticalDraw = class {
5190
5439
  * surrounding collection state.
5191
5440
  */
5192
5441
  lastRenderedMeasures = [];
5442
+ snapGeometryFeatures = [];
5193
5443
  constructor(adapter, options) {
5194
5444
  this.adapter = adapter;
5195
5445
  this.graphicsStyle = resolveStyleHints(BUILT_IN_GRAPHICS_STYLE, options?.graphicsStyle, void 0);
5196
5446
  this.defaultInteractionStyle = options?.interactionStyle;
5197
5447
  this.generateId = options?.generateId ?? (() => `td-${++this.idCounter}`);
5198
5448
  this.pointSymbols = options?.pointSymbols;
5449
+ this.snappingOptions = options?.snapping;
5450
+ this.resolveSnap = createSnapResolver(adapter, () => this.lastRenderedMeasures, () => this.snapGeometryFeatures, () => this.snappingOptions);
5199
5451
  const owned = /* @__PURE__ */ new Set();
5200
5452
  const provided = options?.layers ?? {};
5201
5453
  this.graphicsLayer = resolveSlot(adapter, provided.graphics, owned);
@@ -5209,6 +5461,16 @@ var TacticalDraw = class {
5209
5461
  this.adapter.setLayerStyle(this.guideLayer, guideToStyleOptions(this.baseGuideStyle()));
5210
5462
  this.adapter.onViewChange(this.handleViewChange);
5211
5463
  }
5464
+ /** Replace snapping configuration without recreating the map adapter or façade. */
5465
+ setSnappingOptions(options) {
5466
+ this.assertAlive();
5467
+ this.clearSnapResult();
5468
+ this.snappingOptions = options;
5469
+ }
5470
+ /** Clear transient host affordances when snapping or its interaction ends. */
5471
+ clearSnapResult() {
5472
+ this.snappingOptions?.onSnap?.(null);
5473
+ }
5212
5474
  /**
5213
5475
  * Render `measures` into features and write them to the graphics layer.
5214
5476
  * Routes every measure through [[renderMeasureForAdapter]] so pixel sizing is
@@ -5231,6 +5493,7 @@ var TacticalDraw = class {
5231
5493
  }
5232
5494
  this.adapter.reconcileLayerIconResources(this.graphicsLayer, [...resources.values()]);
5233
5495
  this.adapter.updateLayerFeatures(this.graphicsLayer, features);
5496
+ this.snapGeometryFeatures = features;
5234
5497
  }
5235
5498
  /**
5236
5499
  * Re-render the graphics layer on a view (zoom) change so pixel-denominated
@@ -5245,7 +5508,8 @@ var TacticalDraw = class {
5245
5508
  if (this.destroyed || !this.hasRendered) return;
5246
5509
  const excluded = this.excludedInteractionIds();
5247
5510
  const visible = excluded.size > 0 ? this.lastRenderedMeasures.filter((m) => !excluded.has(m.id)) : this.lastRenderedMeasures;
5248
- if (!visible.some(graphicRendersFromViewState)) return;
5511
+ const nativeGroundIconScaling = this.adapter.scalesGroundPointSymbolsNatively === true;
5512
+ if (!visible.some((g) => graphicRendersFromViewState(g, nativeGroundIconScaling))) return;
5249
5513
  this.renderMeasuresToGraphics(visible);
5250
5514
  };
5251
5515
  /**
@@ -5391,6 +5655,7 @@ var TacticalDraw = class {
5391
5655
  };
5392
5656
  const onSettled = () => {
5393
5657
  clearSlot();
5658
+ this.clearSnapResult();
5394
5659
  if (extraSettle) extraSettle();
5395
5660
  };
5396
5661
  this.activeInteraction.begin();
@@ -5444,7 +5709,8 @@ var TacticalDraw = class {
5444
5709
  previewLayer: this.previewLayer,
5445
5710
  generateId: this.generateId,
5446
5711
  signal: options?.signal,
5447
- onSettled
5712
+ onSettled,
5713
+ snap: this.resolveSnap
5448
5714
  }), null);
5449
5715
  }
5450
5716
  const measureDraft = draft;
@@ -5476,7 +5742,8 @@ var TacticalDraw = class {
5476
5742
  measureTextAmplifiers: measureDraft.textAmplifiers,
5477
5743
  guide: options?.guide,
5478
5744
  sizeAnchor: options?.sizeAnchor,
5479
- onSettled
5745
+ onSettled,
5746
+ snap: this.resolveSnap
5480
5747
  }) : runVariableLengthDraw({
5481
5748
  adapter: this.adapter,
5482
5749
  kind,
@@ -5493,7 +5760,8 @@ var TacticalDraw = class {
5493
5760
  measureTextAmplifiers: measureDraft.textAmplifiers,
5494
5761
  guide: options?.guide,
5495
5762
  sizeAnchor: options?.sizeAnchor,
5496
- onSettled
5763
+ onSettled,
5764
+ snap: this.resolveSnap
5497
5765
  }), null, extraSettle);
5498
5766
  }
5499
5767
  edit(measure, options) {
@@ -5522,7 +5790,8 @@ var TacticalDraw = class {
5522
5790
  restoreOriginalToGraphics,
5523
5791
  wasOnGraphicsLayer,
5524
5792
  onSettled,
5525
- onClickAwayGraphicsPick: (event) => this.dispatchHostPick(this.graphicsLayer, event)
5793
+ onClickAwayGraphicsPick: (event) => this.dispatchHostPick(this.graphicsLayer, event),
5794
+ snap: this.resolveSnap
5526
5795
  }), (handle) => {
5527
5796
  this.activeEdit = handle;
5528
5797
  return () => {
@@ -5562,7 +5831,8 @@ var TacticalDraw = class {
5562
5831
  restoreOriginalToGraphics,
5563
5832
  wasOnGraphicsLayer,
5564
5833
  onSettled,
5565
- onClickAwayGraphicsPick
5834
+ onClickAwayGraphicsPick,
5835
+ snap: this.resolveSnap
5566
5836
  }), (handle) => {
5567
5837
  this.activeEdit = handle;
5568
5838
  return () => {
@@ -5950,8 +6220,8 @@ var TacticalDraw = class {
5950
6220
  if (this.destroyed) throw new TacticalDrawDestroyedError();
5951
6221
  }
5952
6222
  };
5953
- function graphicRendersFromViewState(graphic) {
5954
- return isPointSymbol(graphic) ? graphic.size.unit === "meters" : measureRendersFromViewState(graphic);
6223
+ function graphicRendersFromViewState(graphic, nativeGroundIconScaling) {
6224
+ return isPointSymbol(graphic) ? graphic.size.unit === "meters" && !nativeGroundIconScaling : measureRendersFromViewState(graphic);
5955
6225
  }
5956
6226
  function normalisePointEditModes(modes) {
5957
6227
  for (const mode of modes ?? ["transform"]) if (mode !== "transform" && mode !== "delete") throw new TypeError(`Point-symbol editing does not support mode "${mode}"`);
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.24",
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",