@orbat-mapper/tactical-draw 0.2.0-alpha.15 → 0.2.0-alpha.17

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
@@ -1,4 +1,4 @@
1
- import { ControlMeasure, ControlMeasureKind, ControlMeasureSnapshot, ControlMeasureStyle, TextAmplifiers } from "@orbat-mapper/control-measures";
1
+ import { AmplifierPlacements, ControlMeasure, ControlMeasureKind, ControlMeasureSnapshot, ControlMeasureStyle, TextAmplifiers } from "@orbat-mapper/control-measures";
2
2
  import { Feature, Position } from "geojson";
3
3
 
4
4
  //#region src/gestures/types.d.ts
@@ -461,6 +461,7 @@ interface CombinedAbort {
461
461
  declare function combineWithHostSignal(hostSignal?: AbortSignal): CombinedAbort;
462
462
  //#endregion
463
463
  //#region src/tactical-draw/measure-options.d.ts
464
+ declare const DEFAULT_LABEL_SIZE_PIXELS = 14;
464
465
  /**
465
466
  * Where a control measure's size is anchored (ADR-0020). `ground` (the
466
467
  * default) means a meter size fixed to the terrain — static, never re-rendered
@@ -502,6 +503,8 @@ interface InteractionStyle {
502
503
  guide?: GuideStyle;
503
504
  vertexHandle?: HandleStyle;
504
505
  midpointHandle?: HandleStyle;
506
+ /** Visible grab target for a draggable rendered text label. */
507
+ labelHandle?: HandleStyle;
505
508
  scaleHandle?: HandleStyle;
506
509
  rotateHandle?: HandleStyle;
507
510
  boxOutline?: GuideStyle;
@@ -519,12 +522,13 @@ interface InteractionStyle {
519
522
  * `reshape` renders vertex/midpoint handles; `transform` renders the oriented
520
523
  * selection box (4 corner scale handles + a rotate grip + drag-anywhere
521
524
  * body translate) via `transform-box.ts`. The two can be combined freely —
522
- * single-measure edits show both by default. `delete` is an explicit
525
+ * single-measure edits show both by default. `labeldrag` enables dragging
526
+ * rendered text labels. `delete` is an explicit
523
527
  * destructive mode: it renders the vertex handles (so they can be tapped to
524
528
  * remove) but no midpoints or box, and the edit controller deletes the tapped
525
529
  * vertex instead of dragging it.
526
530
  */
527
- type EditMode = "reshape" | "transform" | "delete";
531
+ type EditMode = "reshape" | "transform" | "labeldrag" | "delete";
528
532
  //#endregion
529
533
  //#region src/tactical-draw/session-events.d.ts
530
534
  /**
@@ -570,6 +574,8 @@ interface EditSession {
570
574
  * doctrinal field codes happens in the core render seam, not here.
571
575
  */
572
576
  readonly textAmplifiers: TextAmplifiers | undefined;
577
+ /** Live user-adjusted anchors for individually rendered text amplifiers. */
578
+ readonly amplifierPlacements: AmplifierPlacements | undefined;
573
579
  /** `true` after the first working-copy mutation; back to `false` within tolerance. */
574
580
  readonly dirty: boolean;
575
581
  readonly modes: readonly EditMode[];
@@ -614,6 +620,8 @@ interface EditSession {
614
620
  * silent no-op after the session has settled (like [[setOptions]]).
615
621
  */
616
622
  setTextAmplifiers(partial: Record<string, string | undefined>): void;
623
+ /** Merge field-keyed label anchors; `undefined` resets a field to its doctrinal position. */
624
+ setAmplifierPlacements(partial: Record<string, Position | undefined>): void;
617
625
  /**
618
626
  * Subscribe to working-copy changes. The listener receives an
619
627
  * [[EditChangeEvent]] for each completed gesture (drag end, insert/delete
@@ -1009,7 +1017,8 @@ interface EditOptions {
1009
1017
  * renders vertex / midpoint handles; `transform` renders the oriented
1010
1018
  * selection box — 4 corner scale handles, a rotate grip above the top edge,
1011
1019
  * and drag-anywhere-inside-the-box translate (ADR-0017). The two can be
1012
- * combined freely (the default does). `"delete"` is exclusive: it collapses
1020
+ * combined freely (the default does). `"labeldrag"` opt-in enables repositioning
1021
+ * rendered text labels. `"delete"` is exclusive: it collapses
1013
1022
  * the set to vertex-tap removal only. An unknown mode throws `TypeError`.
1014
1023
  */
1015
1024
  modes?: readonly EditMode[];
@@ -1516,4 +1525,4 @@ declare function nearestWorldCopyLng(lng: number, refLng: number): number;
1516
1525
  /** `nearestWorldCopyLng` applied to a lon/lat position. */
1517
1526
  declare function nearestWorldCopyPosition(position: Position, refLng: number): Position;
1518
1527
  //#endregion
1519
- export { BaseMapAdapter, type CombinedAbort, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, type DrawMeasureDraft, type DrawOptions, type DrawSession, type EditChangeEvent, type EditManyOptions, type EditMode, type EditOptions, type EditPointerDriver, type EditSession, type FeatureOps, type GuideStyle, type HandleStyle, type InteractionStyle, type LayerId, type MapAdapter, type MapCursor, type MapEvent, type MapEventHandler, type NativeEventEntry, type PickEvent, type PickHandler, type PickOptions, type PixelCoordinate, type PointerCallback, PointerCallbackHub, type PointerEventInfo, 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, coerceHandleStyle, combineWithHostSignal, getSizeAnchor, hitTestFeature, ignoreAbort, isTacticalDrawAbortError, nearestWorldCopyLng, nearestWorldCopyPosition, pickHitTolerance, resolveGroundTextSizePixels, tryControlMeasureIdFromFeature };
1528
+ export { BaseMapAdapter, type CombinedAbort, DEFAULT_LABEL_SIZE_PIXELS, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, type DrawMeasureDraft, type DrawOptions, type DrawSession, type EditChangeEvent, type EditManyOptions, type EditMode, type EditOptions, type EditPointerDriver, type EditSession, type FeatureOps, type GuideStyle, type HandleStyle, type InteractionStyle, type LayerId, type MapAdapter, type MapCursor, type MapEvent, type MapEventHandler, type NativeEventEntry, type PickEvent, type PickHandler, type PickOptions, type PixelCoordinate, type PointerCallback, PointerCallbackHub, type PointerEventInfo, 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, coerceHandleStyle, combineWithHostSignal, getSizeAnchor, hitTestFeature, ignoreAbort, isTacticalDrawAbortError, nearestWorldCopyLng, nearestWorldCopyPosition, pickHitTolerance, resolveGroundTextSizePixels, tryControlMeasureIdFromFeature };
package/dist/index.mjs CHANGED
@@ -521,6 +521,9 @@ function combineWithHostSignal(hostSignal) {
521
521
  dispose
522
522
  };
523
523
  }
524
+ //#endregion
525
+ //#region src/tactical-draw/measure-options.ts
526
+ const DEFAULT_LABEL_SIZE_PIXELS = 14;
524
527
  /**
525
528
  * Per-kind pixel-denominated *geometry* size option and the meter option it
526
529
  * bakes into — the single source of which geometry option makes a measure
@@ -2021,6 +2024,31 @@ function boxOutlineFeature(box, fromPixel, featureId, style) {
2021
2024
  };
2022
2025
  }
2023
2026
  //#endregion
2027
+ //#region src/tactical-draw/transform-amplifier-placements.ts
2028
+ /**
2029
+ * Carry user-adjusted label anchors through a transform-box gesture. Anchors
2030
+ * are transformed in pixel space alongside the control points so the result
2031
+ * remains visually rigid across map projections.
2032
+ */
2033
+ function transformAmplifierPlacements(placements, from, to, projection) {
2034
+ if (!placements) return void 0;
2035
+ const scaleX = to.halfWidth / from.halfWidth;
2036
+ const scaleY = to.halfHeight / from.halfHeight;
2037
+ const transformed = {};
2038
+ for (const [field, position] of Object.entries(placements)) {
2039
+ if (position === void 0) continue;
2040
+ const pixel = projection.toPixel(position);
2041
+ if (!pixel) {
2042
+ transformed[field] = [...position];
2043
+ continue;
2044
+ }
2045
+ const [localX, localY] = rotate(pixel[0] - from.center[0], pixel[1] - from.center[1], -from.angle);
2046
+ const [outX, outY] = rotate(localX * scaleX, localY * scaleY, to.angle);
2047
+ transformed[field] = projection.fromPixel([to.center[0] + outX, to.center[1] + outY]);
2048
+ }
2049
+ return transformed;
2050
+ }
2051
+ //#endregion
2024
2052
  //#region src/tactical-draw/edit-controller.ts
2025
2053
  /**
2026
2054
  * Coordinate-space tolerance for `EditSession.dirty`. The working copy is
@@ -2031,6 +2059,8 @@ function boxOutlineFeature(box, fromPixel, featureId, style) {
2031
2059
  */
2032
2060
  const DIRTY_EPSILON$1 = 1e-9;
2033
2061
  const HANDLE_HIT_TOLERANCE_PX$1 = 12;
2062
+ /** Screen-space clearance between a label's anchor and its visible drag grip. */
2063
+ const LABEL_HANDLE_OFFSET_PX = 14;
2034
2064
  /**
2035
2065
  * A body drag (drag-anywhere-inside-the-box translate) whose release pixel
2036
2066
  * lands within this many pixels of the down pixel is treated as a plain
@@ -2045,6 +2075,7 @@ const CLICK_SLOP_PX$1 = 3;
2045
2075
  const ALLOWED_EDIT_MODES = new Set([
2046
2076
  "reshape",
2047
2077
  "transform",
2078
+ "labeldrag",
2048
2079
  "delete"
2049
2080
  ]);
2050
2081
  /**
@@ -2074,6 +2105,10 @@ function runEdit(opts) {
2074
2105
  const workingPoints = editStartPoints.map((p) => [...p]);
2075
2106
  let workingOptions = { ...measure.options ?? {} };
2076
2107
  let workingTextAmplifiers = measure.textAmplifiers && Object.keys(measure.textAmplifiers).length > 0 ? { ...measure.textAmplifiers } : void 0;
2108
+ let workingAmplifierPlacements = measure.amplifierPlacements && Object.keys(measure.amplifierPlacements).length > 0 ? structuredClone(measure.amplifierPlacements) : void 0;
2109
+ function snapshotAmplifierPlacements() {
2110
+ return workingAmplifierPlacements ? structuredClone(workingAmplifierPlacements) : void 0;
2111
+ }
2077
2112
  let modes = requestedModes;
2078
2113
  let dirty = false;
2079
2114
  let nonGeometryDirty = false;
@@ -2173,6 +2208,8 @@ function runEdit(opts) {
2173
2208
  };
2174
2209
  if (workingTextAmplifiers) working.textAmplifiers = workingTextAmplifiers;
2175
2210
  else delete working.textAmplifiers;
2211
+ if (workingAmplifierPlacements) working.amplifierPlacements = workingAmplifierPlacements;
2212
+ else delete working.amplifierPlacements;
2176
2213
  let features;
2177
2214
  try {
2178
2215
  features = renderMeasureForAdapter(adapter, working, renderOpts).features;
@@ -2202,6 +2239,31 @@ function runEdit(opts) {
2202
2239
  trailingFixedSlots,
2203
2240
  closedRing
2204
2241
  }), handleIdPrefix, interactionStyle);
2242
+ if (modes.includes("labeldrag")) {
2243
+ const style = interactionStyle?.labelHandle;
2244
+ let index = 0;
2245
+ for (const feature of lastPreviewFeatures) {
2246
+ const key = feature.properties?.labelPlacementKey;
2247
+ if (typeof key !== "string" || feature.geometry?.type !== "Point") continue;
2248
+ const pixel = driver.toPixel(feature.geometry.coordinates);
2249
+ if (!pixel) continue;
2250
+ features.push({
2251
+ type: "Feature",
2252
+ id: `${handleIdPrefix}:label-handle:${index}`,
2253
+ geometry: {
2254
+ type: "Point",
2255
+ coordinates: driver.fromPixel([pixel[0], pixel[1] - LABEL_HANDLE_OFFSET_PX])
2256
+ },
2257
+ properties: {
2258
+ handle: "label",
2259
+ index,
2260
+ labelPlacementKey: key,
2261
+ ...style ? { style: { ...style } } : {}
2262
+ }
2263
+ });
2264
+ index += 1;
2265
+ }
2266
+ }
2205
2267
  if (modes.includes("transform")) {
2206
2268
  const liveBox = activeBoxDrag ? activeBoxDrag.lastSnapshot.box : box;
2207
2269
  if (liveBox) {
@@ -2226,6 +2288,8 @@ function runEdit(opts) {
2226
2288
  };
2227
2289
  if (workingTextAmplifiers) working.textAmplifiers = workingTextAmplifiers;
2228
2290
  else delete working.textAmplifiers;
2291
+ if (workingAmplifierPlacements) working.amplifierPlacements = workingAmplifierPlacements;
2292
+ else delete working.amplifierPlacements;
2229
2293
  const committed = sizeAnchor === "screen" ? working : bakePixelSizeToGround(working, adapter);
2230
2294
  return {
2231
2295
  measure: cloneControlMeasure(committed),
@@ -2277,7 +2341,7 @@ function runEdit(opts) {
2277
2341
  * - Reject restores the working state to the pre-gesture snapshot and
2278
2342
  * recomputes `dirty`. Close/abort settle the session as normal.
2279
2343
  */
2280
- function emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, previousSizeAnchor, currentRendered, previousTextAmplifiers) {
2344
+ function emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, previousSizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements) {
2281
2345
  if (ctx.settled) return;
2282
2346
  let rejectRequested = false;
2283
2347
  let closeRequested = false;
@@ -2310,6 +2374,7 @@ function runEdit(opts) {
2310
2374
  currentStyle = previousStyle;
2311
2375
  sizeAnchor = previousSizeAnchor;
2312
2376
  workingTextAmplifiers = previousTextAmplifiers;
2377
+ workingAmplifierPlacements = previousAmplifierPlacements;
2313
2378
  nonGeometryDirty = previousNonGeometryDirty;
2314
2379
  renderPreview();
2315
2380
  renderHandlesLayer();
@@ -2328,20 +2393,23 @@ function runEdit(opts) {
2328
2393
  * queued during the drag are applied after the listener emission (so the
2329
2394
  * post-gesture render reflects the requested mode set).
2330
2395
  */
2331
- function commitGesture(nextPoints, preGestureOptions) {
2396
+ function commitGesture(nextPoints, preGestureOptions, nextAmplifierPlacements) {
2332
2397
  if (ctx.settled) return;
2333
2398
  const previousPoints = workingPoints.map((p) => [...p]);
2334
2399
  const previousOptions = { ...preGestureOptions ?? workingOptions ?? {} };
2335
2400
  const previousStyle = currentStyle;
2336
2401
  const previousNonGeometryDirty = nonGeometryDirty;
2337
2402
  const previousTextAmplifiers = workingTextAmplifiers;
2403
+ const previousAmplifierPlacements = snapshotAmplifierPlacements();
2338
2404
  const previousRendered = rebuildRenderedWith(previousOptions);
2339
2405
  replaceWorkingPoints(nextPoints);
2406
+ if (nextAmplifierPlacements !== void 0) workingAmplifierPlacements = structuredClone(nextAmplifierPlacements);
2340
2407
  let currentRendered;
2341
2408
  try {
2342
2409
  currentRendered = rebuildRendered();
2343
2410
  } catch (err) {
2344
2411
  replaceWorkingPoints(previousPoints);
2412
+ workingAmplifierPlacements = previousAmplifierPlacements;
2345
2413
  if (preGestureOptions !== void 0) replaceWorkingOptions(previousOptions);
2346
2414
  dragInFlight = false;
2347
2415
  drainPendingModes();
@@ -2352,7 +2420,7 @@ function runEdit(opts) {
2352
2420
  recomputeDirty();
2353
2421
  dragInFlight = false;
2354
2422
  drainPendingModes();
2355
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers);
2423
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements);
2356
2424
  }
2357
2425
  function setOptions(partial) {
2358
2426
  if (ctx.settled) return;
@@ -2361,6 +2429,7 @@ function runEdit(opts) {
2361
2429
  const previousStyle = currentStyle;
2362
2430
  const previousNonGeometryDirty = nonGeometryDirty;
2363
2431
  const previousTextAmplifiers = workingTextAmplifiers;
2432
+ const previousAmplifierPlacements = snapshotAmplifierPlacements();
2364
2433
  const previousRendered = rebuildRendered();
2365
2434
  replaceWorkingOptions({
2366
2435
  ...workingOptions ?? {},
@@ -2376,7 +2445,7 @@ function runEdit(opts) {
2376
2445
  renderPreview();
2377
2446
  nonGeometryDirty = true;
2378
2447
  dirty = true;
2379
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers);
2448
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements);
2380
2449
  }
2381
2450
  function setStyle(partial) {
2382
2451
  if (ctx.settled) return;
@@ -2385,6 +2454,7 @@ function runEdit(opts) {
2385
2454
  const previousStyle = currentStyle;
2386
2455
  const previousNonGeometryDirty = nonGeometryDirty;
2387
2456
  const previousTextAmplifiers = workingTextAmplifiers;
2457
+ const previousAmplifierPlacements = snapshotAmplifierPlacements();
2388
2458
  const previousRendered = rebuildRendered();
2389
2459
  currentStyle = {
2390
2460
  ...currentStyle ?? {},
@@ -2400,7 +2470,7 @@ function runEdit(opts) {
2400
2470
  renderPreview();
2401
2471
  nonGeometryDirty = true;
2402
2472
  dirty = true;
2403
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers);
2473
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements);
2404
2474
  }
2405
2475
  /**
2406
2476
  * Shallow-merge into the live working text amplifiers. Mirrors
@@ -2424,6 +2494,7 @@ function runEdit(opts) {
2424
2494
  const previousStyle = currentStyle;
2425
2495
  const previousNonGeometryDirty = nonGeometryDirty;
2426
2496
  const previousTextAmplifiers = workingTextAmplifiers;
2497
+ const previousAmplifierPlacements = snapshotAmplifierPlacements();
2427
2498
  const previousRendered = rebuildRendered();
2428
2499
  const merged = { ...workingTextAmplifiers ?? {} };
2429
2500
  for (const [key, value] of Object.entries(partial)) {
@@ -2444,7 +2515,26 @@ function runEdit(opts) {
2444
2515
  renderPreview();
2445
2516
  nonGeometryDirty = true;
2446
2517
  dirty = true;
2447
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers);
2518
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements);
2519
+ }
2520
+ function setAmplifierPlacements(partial) {
2521
+ if (ctx.settled) return;
2522
+ const previousPoints = workingPoints.map((p) => [...p]);
2523
+ const previousOptions = { ...workingOptions ?? {} };
2524
+ const previousStyle = currentStyle;
2525
+ const previousNonGeometryDirty = nonGeometryDirty;
2526
+ const previousTextAmplifiers = workingTextAmplifiers;
2527
+ const previousAmplifierPlacements = snapshotAmplifierPlacements();
2528
+ const previousRendered = rebuildRendered();
2529
+ const next = { ...workingAmplifierPlacements ?? {} };
2530
+ for (const [field, position] of Object.entries(partial)) if (position === void 0) delete next[field];
2531
+ else next[field] = [...position];
2532
+ workingAmplifierPlacements = Object.keys(next).length > 0 ? next : void 0;
2533
+ const currentRendered = rebuildRendered();
2534
+ renderPreview();
2535
+ nonGeometryDirty = true;
2536
+ dirty = true;
2537
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements);
2448
2538
  }
2449
2539
  /**
2450
2540
  * Flip the size anchor for the measure under edit (ADR-0020). Re-renders the
@@ -2461,12 +2551,13 @@ function runEdit(opts) {
2461
2551
  const previousNonGeometryDirty = nonGeometryDirty;
2462
2552
  const previousSizeAnchor = sizeAnchor;
2463
2553
  const previousTextAmplifiers = workingTextAmplifiers;
2554
+ const previousAmplifierPlacements = snapshotAmplifierPlacements();
2464
2555
  const previousRendered = rebuildRendered();
2465
2556
  sizeAnchor = next;
2466
2557
  const currentRendered = rebuildRendered();
2467
2558
  nonGeometryDirty = true;
2468
2559
  dirty = true;
2469
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, previousSizeAnchor, currentRendered, previousTextAmplifiers);
2560
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, previousSizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements);
2470
2561
  }
2471
2562
  /** Apply a translate gesture by shifting every working point by `delta`. */
2472
2563
  function commitTranslate(delta) {
@@ -2527,6 +2618,9 @@ function runEdit(opts) {
2527
2618
  get textAmplifiers() {
2528
2619
  return workingTextAmplifiers ? { ...workingTextAmplifiers } : void 0;
2529
2620
  },
2621
+ get amplifierPlacements() {
2622
+ return snapshotAmplifierPlacements();
2623
+ },
2530
2624
  get dirty() {
2531
2625
  return dirty;
2532
2626
  },
@@ -2561,6 +2655,9 @@ function runEdit(opts) {
2561
2655
  setTextAmplifiers(partial) {
2562
2656
  setTextAmplifiers(partial);
2563
2657
  },
2658
+ setAmplifierPlacements(partial) {
2659
+ setAmplifierPlacements(partial);
2660
+ },
2564
2661
  setSizeAnchor(next) {
2565
2662
  setSizeAnchor(next);
2566
2663
  },
@@ -2598,6 +2695,31 @@ function runEdit(opts) {
2598
2695
  }
2599
2696
  };
2600
2697
  let activeDrag = null;
2698
+ let activeAmplifierDrag = null;
2699
+ function pickLabel(pixel, tolerance) {
2700
+ for (const feature of lastPreviewFeatures) {
2701
+ const key = feature.properties?.labelPlacementKey;
2702
+ if (typeof key !== "string" || feature.geometry?.type !== "Point") continue;
2703
+ const projected = driver.toPixel(feature.geometry.coordinates);
2704
+ if (!projected) continue;
2705
+ for (const handle of [false, true]) {
2706
+ const targetY = projected[1] - (handle ? LABEL_HANDLE_OFFSET_PX : 0);
2707
+ if (Math.hypot(projected[0] - pixel[0], targetY - pixel[1]) <= tolerance) return {
2708
+ key,
2709
+ handle
2710
+ };
2711
+ }
2712
+ }
2713
+ return null;
2714
+ }
2715
+ /**
2716
+ * The label anchor a pointer pixel maps to: 1:1 for a grab on the label
2717
+ * itself, offset back down by the grip clearance when the drag rides the
2718
+ * visible handle, so the label lands where the anchor (not the grip) points.
2719
+ */
2720
+ function labelAnchorFromPointer(pixel, handle) {
2721
+ return driver.fromPixel([pixel[0], pixel[1] + (handle ? LABEL_HANDLE_OFFSET_PX : 0)]);
2722
+ }
2601
2723
  /**
2602
2724
  * A live transform-box gesture (corner scale, box rotate, or body
2603
2725
  * translate — ADR-0017). `lastSnapshot` starts as the pre-drag points/box
@@ -2646,6 +2768,18 @@ function runEdit(opts) {
2646
2768
  const onDriverDown = (pixel, info) => {
2647
2769
  if (ctx.settled || dragInFlight) return;
2648
2770
  const tol = info?.hitTolerance ?? HANDLE_HIT_TOLERANCE_PX$1;
2771
+ const labelHit = modes.includes("labeldrag") ? pickLabel(pixel, tol) : null;
2772
+ if (labelHit) {
2773
+ activeAmplifierDrag = {
2774
+ field: labelHit.key,
2775
+ handle: labelHit.handle,
2776
+ previous: snapshotAmplifierPlacements(),
2777
+ downPixel: pixel
2778
+ };
2779
+ dragInFlight = true;
2780
+ driver.setDragPanEnabled(false);
2781
+ return;
2782
+ }
2649
2783
  const hit = pickHandle(buildHandleSet(workingPoints, modes, {
2650
2784
  includeMidpoints: midpointHandlesEnabled,
2651
2785
  trailingFixedSlots,
@@ -2705,6 +2839,7 @@ function runEdit(opts) {
2705
2839
  cornerIndex: boxHit.kind === "scale" ? boxHit.index : void 0
2706
2840
  }),
2707
2841
  preDragPoints,
2842
+ preDragAmplifierPlacements: snapshotAmplifierPlacements(),
2708
2843
  preDragBox: box,
2709
2844
  downPixel: pixel,
2710
2845
  lastSnapshot: {
@@ -2731,13 +2866,23 @@ function runEdit(opts) {
2731
2866
  const snapshot = activeBoxDrag.fsm.move(pixel);
2732
2867
  activeBoxDrag.lastSnapshot = snapshot;
2733
2868
  workingPoints.splice(0, workingPoints.length, ...snapshot.points);
2869
+ workingAmplifierPlacements = transformAmplifierPlacements(activeBoxDrag.preDragAmplifierPlacements, activeBoxDrag.preDragBox, snapshot.box, driver);
2734
2870
  renderPreview();
2735
2871
  renderHandlesLayer();
2736
2872
  recomputeDirty();
2737
2873
  }
2738
2874
  const onDriverMove = (pixel) => {
2739
2875
  if (ctx.settled) return;
2740
- if (activeDrag) paintDragSnapshot(pixel);
2876
+ if (activeAmplifierDrag) {
2877
+ const position = labelAnchorFromPointer(pixel, activeAmplifierDrag.handle);
2878
+ if (!position) return;
2879
+ workingAmplifierPlacements = {
2880
+ ...workingAmplifierPlacements ?? {},
2881
+ [activeAmplifierDrag.field]: position
2882
+ };
2883
+ renderPreview();
2884
+ renderHandlesLayer();
2885
+ } else if (activeDrag) paintDragSnapshot(pixel);
2741
2886
  else if (activeBoxDrag) paintBoxDragSnapshot(pixel);
2742
2887
  };
2743
2888
  /** Reset drag-in-flight bookkeeping shared by every "gesture ended" path. */
@@ -2747,6 +2892,19 @@ function runEdit(opts) {
2747
2892
  drainPendingModes();
2748
2893
  }
2749
2894
  const onDriverUp = (pixel) => {
2895
+ if (activeAmplifierDrag) {
2896
+ const { field, handle, previous, downPixel } = activeAmplifierDrag;
2897
+ activeAmplifierDrag = null;
2898
+ const position = labelAnchorFromPointer(pixel, handle);
2899
+ workingAmplifierPlacements = previous;
2900
+ endDragBookkeeping();
2901
+ if (position && Math.hypot(pixel[0] - downPixel[0], pixel[1] - downPixel[1]) > CLICK_SLOP_PX$1) setAmplifierPlacements({ [field]: position });
2902
+ else {
2903
+ renderPreview();
2904
+ renderHandlesLayer();
2905
+ }
2906
+ return;
2907
+ }
2750
2908
  if (activeDrag) {
2751
2909
  if (ctx.settled) {
2752
2910
  activeDrag = null;
@@ -2769,12 +2927,13 @@ function runEdit(opts) {
2769
2927
  driver.setDragPanEnabled(true);
2770
2928
  return;
2771
2929
  }
2772
- const { fsm, preDragPoints, preDragBox, downPixel, preDragOptions } = activeBoxDrag;
2930
+ const { fsm, preDragPoints, preDragAmplifierPlacements, preDragBox, downPixel, preDragOptions } = activeBoxDrag;
2773
2931
  activeBoxDrag = null;
2774
2932
  driver.setDragPanEnabled(true);
2775
2933
  const distance = Math.hypot(pixel[0] - downPixel[0], pixel[1] - downPixel[1]);
2776
2934
  if (fsm.kind === "body" && distance <= CLICK_SLOP_PX$1) {
2777
2935
  replaceWorkingPoints(preDragPoints);
2936
+ workingAmplifierPlacements = preDragAmplifierPlacements;
2778
2937
  renderPreview();
2779
2938
  renderHandlesLayer();
2780
2939
  recomputeDirty();
@@ -2784,8 +2943,9 @@ function runEdit(opts) {
2784
2943
  }
2785
2944
  const finalSnapshot = fsm.move(pixel);
2786
2945
  replaceWorkingPoints(preDragPoints);
2946
+ workingAmplifierPlacements = preDragAmplifierPlacements;
2787
2947
  if (fsm.kind === "rotate") boxAngle += finalSnapshot.box.angle - preDragBox.angle;
2788
- commitGesture(finalSnapshot.points, preDragOptions);
2948
+ commitGesture(finalSnapshot.points, preDragOptions, transformAmplifierPlacements(preDragAmplifierPlacements, preDragBox, finalSnapshot.box, driver));
2789
2949
  return;
2790
2950
  }
2791
2951
  };
@@ -2858,10 +3018,11 @@ function runEdit(opts) {
2858
3018
  unsubDown();
2859
3019
  unsubMove();
2860
3020
  unsubUp();
2861
- if (activeDrag !== null || activeBoxDrag !== null) {
3021
+ if (activeDrag !== null || activeBoxDrag !== null || activeAmplifierDrag !== null) {
2862
3022
  driver.setDragPanEnabled(true);
2863
3023
  activeDrag = null;
2864
3024
  activeBoxDrag = null;
3025
+ activeAmplifierDrag = null;
2865
3026
  dragInFlight = false;
2866
3027
  }
2867
3028
  driver.dispose();
@@ -2972,6 +3133,7 @@ function runGroupTransform(opts) {
2972
3133
  measure,
2973
3134
  editStartPoints,
2974
3135
  workingPoints: editStartPoints.map((p) => [...p]),
3136
+ workingAmplifierPlacements: measure.amplifierPlacements ? structuredClone(measure.amplifierPlacements) : void 0,
2975
3137
  optionsOverride: null,
2976
3138
  box: null,
2977
3139
  lastPreviewFeatures: []
@@ -3003,7 +3165,8 @@ function runGroupTransform(opts) {
3003
3165
  const working = {
3004
3166
  ...member.measure,
3005
3167
  options: mergeOptions(member.measure.options, override),
3006
- controlPoints: member.workingPoints.map((p) => [...p])
3168
+ controlPoints: member.workingPoints.map((p) => [...p]),
3169
+ ...member.workingAmplifierPlacements ? { amplifierPlacements: member.workingAmplifierPlacements } : {}
3007
3170
  };
3008
3171
  const committed = sizeAnchor === "screen" ? working : bakePixelSizeToGround(working, adapter);
3009
3172
  return {
@@ -3017,7 +3180,8 @@ function runGroupTransform(opts) {
3017
3180
  return {
3018
3181
  ...member.measure,
3019
3182
  options: effectiveOptions(i),
3020
- controlPoints: member.workingPoints.map((p) => [...p])
3183
+ controlPoints: member.workingPoints.map((p) => [...p]),
3184
+ ...member.workingAmplifierPlacements ? { amplifierPlacements: member.workingAmplifierPlacements } : {}
3021
3185
  };
3022
3186
  }
3023
3187
  let dirty = false;
@@ -3184,6 +3348,21 @@ function runGroupTransform(opts) {
3184
3348
  member.workingPoints.splice(0, member.workingPoints.length, ...snapshot[i]);
3185
3349
  });
3186
3350
  }
3351
+ function cloneAmplifierPlacementsByMember() {
3352
+ return members.map((member) => member.workingAmplifierPlacements ? structuredClone(member.workingAmplifierPlacements) : void 0);
3353
+ }
3354
+ /**
3355
+ * Adopts `placements[i]` by reference — callers hand over ownership (the
3356
+ * sources are freshly built transforms or snapshot arrays that are dead
3357
+ * after the restore), and working placements are only ever replaced
3358
+ * wholesale, never mutated in place. This runs on every pointermove of a
3359
+ * box drag, so it must not deep-clone.
3360
+ */
3361
+ function applyAmplifierPlacementsByMember(placements) {
3362
+ members.forEach((member, i) => {
3363
+ member.workingAmplifierPlacements = placements[i];
3364
+ });
3365
+ }
3187
3366
  function cloneOptionsOverrideByMember() {
3188
3367
  return members.map((m) => m.optionsOverride ? { ...m.optionsOverride } : null);
3189
3368
  }
@@ -3215,7 +3394,7 @@ function runGroupTransform(opts) {
3215
3394
  * instance to all listeners in order, deferred `reject`/`close`/`abort`
3216
3395
  * (close/abort override reject), exception collection with `AggregateError`.
3217
3396
  */
3218
- function emitChange(previousRendered, previousPointsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered) {
3397
+ function emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered) {
3219
3398
  if (ctx.settled) return;
3220
3399
  let rejectRequested = false;
3221
3400
  let closeRequested = false;
@@ -3245,6 +3424,7 @@ function runGroupTransform(opts) {
3245
3424
  else if (closeRequested) commitClose();
3246
3425
  else if (rejectRequested && !ctx.settled && members === membersAtEmission) {
3247
3426
  restorePointsByMember(previousPointsByMember);
3427
+ applyAmplifierPlacementsByMember(previousAmplifierPlacementsByMember);
3248
3428
  sizeAnchor = previousSizeAnchor;
3249
3429
  nonGeometryDirty = previousNonGeometryDirty;
3250
3430
  applyOptionsOverrides(previousOptionsOverrideByMember);
@@ -3277,17 +3457,20 @@ function runGroupTransform(opts) {
3277
3457
  }
3278
3458
  return snapshots;
3279
3459
  }
3280
- function commitGesture(nextConcatPoints, preGestureOptionsOverrideByMember) {
3460
+ function commitGesture(nextConcatPoints, preGestureOptionsOverrideByMember, nextAmplifierPlacementsByMember) {
3281
3461
  if (ctx.settled) return;
3282
3462
  const previousPointsByMember = clonePointsByMember();
3463
+ const previousAmplifierPlacementsByMember = cloneAmplifierPlacementsByMember();
3283
3464
  const previousOptionsOverrideByMember = preGestureOptionsOverrideByMember ?? cloneOptionsOverrideByMember();
3284
3465
  const previousRendered = rebuildRenderedAllWith(previousOptionsOverrideByMember);
3285
3466
  applyConcatenatedPoints(nextConcatPoints);
3467
+ if (nextAmplifierPlacementsByMember) applyAmplifierPlacementsByMember(nextAmplifierPlacementsByMember);
3286
3468
  let currentRendered;
3287
3469
  try {
3288
3470
  currentRendered = rebuildRenderedAll();
3289
3471
  } catch (err) {
3290
3472
  restorePointsByMember(previousPointsByMember);
3473
+ applyAmplifierPlacementsByMember(previousAmplifierPlacementsByMember);
3291
3474
  dragInFlight = false;
3292
3475
  throw err;
3293
3476
  }
@@ -3295,7 +3478,7 @@ function runGroupTransform(opts) {
3295
3478
  renderHandlesLayer();
3296
3479
  recomputeDirty();
3297
3480
  dragInFlight = false;
3298
- emitChange(previousRendered, previousPointsByMember, sizeAnchor, nonGeometryDirty, previousOptionsOverrideByMember, currentRendered);
3481
+ emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, sizeAnchor, nonGeometryDirty, previousOptionsOverrideByMember, currentRendered);
3299
3482
  }
3300
3483
  function setSizeAnchor(next) {
3301
3484
  if (ctx.settled || sizeAnchor === next) return;
@@ -3303,12 +3486,13 @@ function runGroupTransform(opts) {
3303
3486
  const previousSizeAnchor = sizeAnchor;
3304
3487
  const previousNonGeometryDirty = nonGeometryDirty;
3305
3488
  const previousOptionsOverrideByMember = cloneOptionsOverrideByMember();
3489
+ const previousAmplifierPlacementsByMember = cloneAmplifierPlacementsByMember();
3306
3490
  const previousRendered = rebuildRenderedAll();
3307
3491
  sizeAnchor = next;
3308
3492
  const currentRendered = rebuildRenderedAll();
3309
3493
  nonGeometryDirty = true;
3310
3494
  dirty = true;
3311
- emitChange(previousRendered, previousPointsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered);
3495
+ emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered);
3312
3496
  }
3313
3497
  /**
3314
3498
  * Cancel an in-flight box drag without committing it: restore the pre-drag
@@ -3320,10 +3504,11 @@ function runGroupTransform(opts) {
3320
3504
  */
3321
3505
  function cancelActiveBoxDrag() {
3322
3506
  if (!activeBoxDrag) return;
3323
- const { preDragPoints, preDragOptionsOverrideByMember } = activeBoxDrag;
3507
+ const { preDragPoints, preDragAmplifierPlacementsByMember, preDragOptionsOverrideByMember } = activeBoxDrag;
3324
3508
  activeBoxDrag = null;
3325
3509
  driver.setDragPanEnabled(true);
3326
3510
  applyConcatenatedPoints(preDragPoints);
3511
+ applyAmplifierPlacementsByMember(preDragAmplifierPlacementsByMember);
3327
3512
  applyOptionsOverrides(preDragOptionsOverrideByMember);
3328
3513
  dragInFlight = false;
3329
3514
  }
@@ -3413,6 +3598,7 @@ function runGroupTransform(opts) {
3413
3598
  renderHandlesLayer();
3414
3599
  }
3415
3600
  const preDragPoints = concatWorkingPoints();
3601
+ const preDragAmplifierPlacementsByMember = cloneAmplifierPlacementsByMember();
3416
3602
  activeBoxDrag = {
3417
3603
  fsm: beginBoxDrag({
3418
3604
  kind: boxHit.kind,
@@ -3429,7 +3615,8 @@ function runGroupTransform(opts) {
3429
3615
  points: preDragPoints,
3430
3616
  box: groupBox
3431
3617
  },
3432
- preDragOptionsOverrideByMember
3618
+ preDragOptionsOverrideByMember,
3619
+ preDragAmplifierPlacementsByMember
3433
3620
  };
3434
3621
  dragInFlight = true;
3435
3622
  driver.setDragPanEnabled(false);
@@ -3439,6 +3626,7 @@ function runGroupTransform(opts) {
3439
3626
  const snapshot = activeBoxDrag.fsm.move(pixel);
3440
3627
  activeBoxDrag.lastSnapshot = snapshot;
3441
3628
  applyConcatenatedPoints(snapshot.points);
3629
+ applyAmplifierPlacementsByMember(activeBoxDrag.preDragAmplifierPlacementsByMember.map((placements) => transformAmplifierPlacements(placements, activeBoxDrag.preDragBox, snapshot.box, driver)));
3442
3630
  renderAllPreviews();
3443
3631
  renderHandlesLayer();
3444
3632
  recomputeDirty();
@@ -3455,12 +3643,13 @@ function runGroupTransform(opts) {
3455
3643
  driver.setDragPanEnabled(true);
3456
3644
  return;
3457
3645
  }
3458
- const { fsm, preDragPoints, preDragBox, downPixel, preDragOptionsOverrideByMember } = activeBoxDrag;
3646
+ const { fsm, preDragPoints, preDragBox, downPixel, preDragOptionsOverrideByMember, preDragAmplifierPlacementsByMember } = activeBoxDrag;
3459
3647
  activeBoxDrag = null;
3460
3648
  driver.setDragPanEnabled(true);
3461
3649
  const distance = Math.hypot(pixel[0] - downPixel[0], pixel[1] - downPixel[1]);
3462
3650
  if (fsm.kind === "body" && distance <= CLICK_SLOP_PX) {
3463
3651
  applyConcatenatedPoints(preDragPoints);
3652
+ applyAmplifierPlacementsByMember(preDragAmplifierPlacementsByMember);
3464
3653
  renderAllPreviews();
3465
3654
  renderHandlesLayer();
3466
3655
  recomputeDirty();
@@ -3470,8 +3659,9 @@ function runGroupTransform(opts) {
3470
3659
  }
3471
3660
  const finalSnapshot = fsm.move(pixel);
3472
3661
  applyConcatenatedPoints(preDragPoints);
3662
+ applyAmplifierPlacementsByMember(preDragAmplifierPlacementsByMember);
3473
3663
  if (fsm.kind === "rotate") boxAngle += finalSnapshot.box.angle - preDragBox.angle;
3474
- commitGesture(finalSnapshot.points, preDragOptionsOverrideByMember);
3664
+ commitGesture(finalSnapshot.points, preDragOptionsOverrideByMember, preDragAmplifierPlacementsByMember.map((placements) => transformAmplifierPlacements(placements, preDragBox, finalSnapshot.box, driver)));
3475
3665
  };
3476
3666
  let pickPreview;
3477
3667
  let pickHandles;
@@ -4324,6 +4514,12 @@ const BUILT_IN_INTERACTION_STYLE = {
4324
4514
  strokeColor: "#6b7280",
4325
4515
  strokeWidth: 1.5
4326
4516
  },
4517
+ labelHandle: {
4518
+ pointRadius: 6,
4519
+ fillColor: "#7c3aed",
4520
+ strokeColor: "#ffffff",
4521
+ strokeWidth: 2
4522
+ },
4327
4523
  scaleHandle: {
4328
4524
  pointRadius: 5,
4329
4525
  fillColor: "#ffffff",
@@ -4340,6 +4536,7 @@ const BUILT_IN_INTERACTION_STYLE = {
4340
4536
  const HANDLE_SLOTS = [
4341
4537
  "vertexHandle",
4342
4538
  "midpointHandle",
4539
+ "labelHandle",
4343
4540
  "scaleHandle",
4344
4541
  "rotateHandle"
4345
4542
  ];
@@ -4403,4 +4600,4 @@ function nearestWorldCopyPosition(position, refLng) {
4403
4600
  return [nearestWorldCopyLng(position[0], refLng), ...position.slice(1)];
4404
4601
  }
4405
4602
  //#endregion
4406
- export { BaseMapAdapter, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, PointerCallbackHub, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, TacticalDrawDestroyedError, bindAbortable, coerceHandleStyle, combineWithHostSignal, getSizeAnchor, hitTestFeature, ignoreAbort, isTacticalDrawAbortError, nearestWorldCopyLng, nearestWorldCopyPosition, pickHitTolerance, resolveGroundTextSizePixels, tryControlMeasureIdFromFeature };
4603
+ export { BaseMapAdapter, DEFAULT_LABEL_SIZE_PIXELS, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, PointerCallbackHub, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, TacticalDrawDestroyedError, bindAbortable, coerceHandleStyle, combineWithHostSignal, getSizeAnchor, hitTestFeature, ignoreAbort, isTacticalDrawAbortError, nearestWorldCopyLng, nearestWorldCopyPosition, pickHitTolerance, resolveGroundTextSizePixels, tryControlMeasureIdFromFeature };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orbat-mapper/tactical-draw",
3
- "version": "0.2.0-alpha.15",
3
+ "version": "0.2.0-alpha.17",
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",
@@ -38,7 +38,7 @@
38
38
  "access": "public"
39
39
  },
40
40
  "dependencies": {
41
- "@orbat-mapper/control-measures": "^0.2.0-alpha.11"
41
+ "@orbat-mapper/control-measures": "^0.2.0-alpha.13"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@arethetypeswrong/core": "^0.18.3",